diff --git a/Sources/OmniWM/UI/HiddenBar/HiddenBarMenuGuard.swift b/Sources/OmniWM/UI/HiddenBar/HiddenBarMenuGuard.swift index 5e5a76a26..93db2b408 100644 --- a/Sources/OmniWM/UI/HiddenBar/HiddenBarMenuGuard.swift +++ b/Sources/OmniWM/UI/HiddenBar/HiddenBarMenuGuard.swift @@ -2,29 +2,34 @@ // Copyright (C) 2026 OmniNull — https://github.com/OmniNull/OmniWM import CoreGraphics +import Foundation enum HiddenBarMenuGuard { struct WindowInfo { let layer: Int let ownerPID: pid_t let title: String? + var bounds: CGRect = .zero } static func isMenuOpen( windows: [WindowInfo], - menuOwnerPIDs: Set + menuOwnerPIDs: Set, + menuBarFrames: [CGRect] = [] ) -> Bool { guard !menuOwnerPIDs.isEmpty else { return false } let popUpLevel = Int(CGWindowLevelForKey(.popUpMenuWindow)) let menuLevels: Set = [popUpLevel, popUpLevel - 1] + let statusBarLevel = Int(CGWindowLevelForKey(.statusWindow)) return windows.contains { window in - menuLevels.contains(window.layer) - && (window.title?.isEmpty ?? true) - && menuOwnerPIDs.contains(window.ownerPID) + guard menuOwnerPIDs.contains(window.ownerPID) else { return false } + if menuLevels.contains(window.layer), window.title?.isEmpty ?? true { return true } + return window.layer == statusBarLevel && !window.bounds.isEmpty && !menuBarFrames.isEmpty + && !menuBarFrames.contains { $0.contains(window.bounds) } } } - static func isAnyMenuOpen(menuOwnerPIDs: Set) -> Bool? { + static func isAnyMenuOpen(menuOwnerPIDs: Set, menuBarFrames: [CGRect]) -> Bool? { guard !menuOwnerPIDs.isEmpty else { return false } guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] else { return nil } @@ -32,8 +37,12 @@ enum HiddenBarMenuGuard { guard let layer = info[kCGWindowLayer as String] as? Int, let pid = info[kCGWindowOwnerPID as String] as? pid_t else { return nil } - return WindowInfo(layer: layer, ownerPID: pid, title: info[kCGWindowName as String] as? String) + let bounds = (info[kCGWindowBounds as String] as? NSDictionary) + .flatMap { CGRect(dictionaryRepresentation: $0) } ?? .zero + return WindowInfo( + layer: layer, ownerPID: pid, title: info[kCGWindowName as String] as? String, bounds: bounds + ) } - return isMenuOpen(windows: windows, menuOwnerPIDs: menuOwnerPIDs) + return isMenuOpen(windows: windows, menuOwnerPIDs: menuOwnerPIDs, menuBarFrames: menuBarFrames) } } diff --git a/Sources/OmniWM/UI/HiddenBar/MenuBarItemService.swift b/Sources/OmniWM/UI/HiddenBar/MenuBarItemService.swift index 721435bb6..a12dc5c33 100644 --- a/Sources/OmniWM/UI/HiddenBar/MenuBarItemService.swift +++ b/Sources/OmniWM/UI/HiddenBar/MenuBarItemService.swift @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-only // Copyright (C) 2026 OmniNull — https://github.com/OmniNull/OmniWM -import Foundation +import AppKit private final class LockedRunLoopJobSet: @unchecked Sendable { private let lock = NSLock() @@ -91,8 +91,17 @@ final class MenuBarItemService { func isMenuOpen(ownerPIDs: Set) async -> Bool? { guard !stopped else { return nil } let epoch = self.epoch + let menuBarFrames = NSScreen.screens.map { screen in + let height = max( + NSStatusBar.system.thickness, + max(screen.safeAreaInsets.top, screen.frame.maxY - screen.visibleFrame.maxY) + ) + return ScreenCoordinateSpace.toWindowServer(rect: CGRect( + x: screen.frame.minX, y: screen.frame.maxY - height, width: screen.frame.width, height: height + )) + } let menuOpen = await Task.detached(priority: .userInitiated) { - HiddenBarMenuGuard.isAnyMenuOpen(menuOwnerPIDs: ownerPIDs) + HiddenBarMenuGuard.isAnyMenuOpen(menuOwnerPIDs: ownerPIDs, menuBarFrames: menuBarFrames) }.value guard !Task.isCancelled, isCurrent(epoch) else { return nil } return menuOpen diff --git a/Tests/OmniWMTests/HiddenBarLifecyclePolicyTests.swift b/Tests/OmniWMTests/HiddenBarLifecyclePolicyTests.swift index f45ca811d..cf797367b 100644 --- a/Tests/OmniWMTests/HiddenBarLifecyclePolicyTests.swift +++ b/Tests/OmniWMTests/HiddenBarLifecyclePolicyTests.swift @@ -334,6 +334,44 @@ final class HiddenBarLifecyclePolicyTests: XCTestCase { hiddenBar.cleanup() } + @MainActor + func testReconcealmentWaitsForNativePanelToClose() async { + let controller = WindowAdmissionTestSupport.controller(prefix: "HiddenBarNativePanelGuard") + let hiddenBar = controller.hiddenBarController + controller.settings.hiddenBar.rehideIntervalSeconds = 5 + let startedAt = ContinuousClock().now + var now = startedAt + var panelOpen = true + let menuBar = CGRect(x: 0, y: 0, width: 2560, height: 24) + let panel = HiddenBarMenuGuard.WindowInfo( + layer: Int(CGWindowLevelForKey(.statusWindow)), ownerPID: 500, title: nil, + bounds: CGRect(x: 2150, y: 24, width: 280, height: 180) + ) + hiddenBar.reconcealment.menuGuardNow = { now } + hiddenBar.reconcealment.menuGuardSleeper = { delay in + now = now.advanced(by: delay) + if startedAt.duration(to: now) >= .seconds(8), panelOpen { + XCTAssertFalse(hiddenBar.temporarilyRevealedBundleIDsForTests.isEmpty) + panelOpen = false + } + await Task.yield() + } + hiddenBar.reconcealment.menuOpenProviderForTests = { _ in + HiddenBarMenuGuard.isMenuOpen( + windows: panelOpen ? [panel] : [], menuOwnerPIDs: [500], menuBarFrames: [menuBar] + ) + } + hiddenBar.performance.begin() + hiddenBar.startReconcealForTests(revealedBundleIDs: ["com.omniwm.native-panel"]) + await driveReconcealTask(hiddenBar) + + XCTAssertFalse(panelOpen) + XCTAssertGreaterThanOrEqual(startedAt.duration(to: now), .seconds(13)) + XCTAssertEqual(hiddenBar.performance.end()?.terminalReason, .concealed) + XCTAssertTrue(hiddenBar.temporarilyRevealedBundleIDsForTests.isEmpty) + hiddenBar.cleanup() + } + @MainActor func testDefinitelyOpenMenuUsesWatchdogInsteadOfUnknownBound() async { let controller = WindowAdmissionTestSupport.controller(prefix: "HiddenBarWatchdog") diff --git a/Tests/OmniWMTests/HiddenBarMenuGuardTests.swift b/Tests/OmniWMTests/HiddenBarMenuGuardTests.swift index ec2aa73d9..55d171e91 100644 --- a/Tests/OmniWMTests/HiddenBarMenuGuardTests.swift +++ b/Tests/OmniWMTests/HiddenBarMenuGuardTests.swift @@ -67,6 +67,64 @@ final class HiddenBarMenuGuardTests: XCTestCase { ) } + func testNativePanelDefersRehideUntilOnlyItsStatusItemRemains() { + let menuBar = CGRect(x: 0, y: 0, width: 2560, height: 24) + let item = HiddenBarMenuGuard.WindowInfo( + layer: Int(CGWindowLevelForKey(.statusWindow)), ownerPID: 500, title: nil, + bounds: CGRect(x: 2400, y: 0, width: 28, height: 24) + ) + for title in [nil, "Native panel"] { + let panel = HiddenBarMenuGuard.WindowInfo( + layer: item.layer, ownerPID: item.ownerPID, title: title, + bounds: CGRect(x: 2150, y: 24, width: 280, height: 180) + ) + XCTAssertTrue(HiddenBarMenuGuard.isMenuOpen( + windows: [item, panel], menuOwnerPIDs: [500], menuBarFrames: [menuBar] + )) + } + XCTAssertFalse(HiddenBarMenuGuard.isMenuOpen( + windows: [item], menuOwnerPIDs: [500], menuBarFrames: [menuBar] + )) + } + + func testNativePanelGuardKeepsOwnerAndLayerChecks() { + let bounds = CGRect(x: 2150, y: 24, width: 280, height: 180) + let menuBar = CGRect(x: 0, y: 0, width: 2560, height: 24) + for window in [ + HiddenBarMenuGuard.WindowInfo( + layer: Int(CGWindowLevelForKey(.statusWindow)), ownerPID: 42, title: nil, bounds: bounds + ), + HiddenBarMenuGuard.WindowInfo(layer: 0, ownerPID: 500, title: nil, bounds: bounds) + ] { + XCTAssertFalse(HiddenBarMenuGuard.isMenuOpen( + windows: [window], menuOwnerPIDs: [500], menuBarFrames: [menuBar] + )) + } + } + + func testStatusItemsOnNotchedAndOffsetDisplaysDoNotDeferRehide() { + let menuBars = [ + CGRect(x: 0, y: 0, width: 2560, height: 24), + CGRect(x: 2560, y: -1050, width: 1680, height: 38) + ] + let items = menuBars.map { bar in + HiddenBarMenuGuard.WindowInfo( + layer: Int(CGWindowLevelForKey(.statusWindow)), ownerPID: 500, title: nil, + bounds: CGRect(x: bar.maxX - 50, y: bar.minY, width: 28, height: bar.height) + ) + } + XCTAssertFalse(HiddenBarMenuGuard.isMenuOpen( + windows: items, menuOwnerPIDs: [500], menuBarFrames: menuBars + )) + let panel = HiddenBarMenuGuard.WindowInfo( + layer: items[1].layer, ownerPID: 500, title: nil, + bounds: CGRect(x: 3900, y: -1012, width: 280, height: 180) + ) + XCTAssertTrue(HiddenBarMenuGuard.isMenuOpen( + windows: items + [panel], menuOwnerPIDs: [500], menuBarFrames: menuBars + )) + } + func testEmptyOwnerSetShortCircuits() { XCTAssertFalse( HiddenBarMenuGuard.isMenuOpen(