diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index f183ff19..fac3f210 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -53,6 +53,7 @@ struct CodeBurnApp: App { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate { private var statusItem: NSStatusItem! + private var statusItemPlacementRecoveryTask: Task? private var popover: NSPopover! private var rightClickMonitor: Any? private var lastContextMenuPresentedAt: Date = .distantPast @@ -90,6 +91,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM // is the orphan in #1117. shutdown() still runs for the tidy case. ServeChildRegistry.shared.reapAll() Task { await ServeConnection.shared.shutdown() } + stopStatusItemPlacementRecovery() if let monitor = rightClickMonitor { NSEvent.removeMonitor(monitor) rightClickMonitor = nil @@ -166,6 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM queue: .main ) { [weak self] _ in Task { @MainActor in + self?.stopStatusItemPlacementRecovery() self?.prepareRefreshPipelineForSleep() } } @@ -182,6 +185,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM Task { @MainActor in self?.displayAsleep = false self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake") + self?.startStatusItemPlacementRecovery() } } @@ -193,6 +197,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM Task { @MainActor in self?.displayAsleep = false self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake") + self?.startStatusItemPlacementRecovery() } } @@ -206,6 +211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM ) { [weak self] _ in Task { @MainActor in self?.displayAsleep = true + self?.stopStatusItemPlacementRecovery() } } } @@ -959,8 +965,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } private func setupStatusItem() { - statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth) - guard let button = statusItem.button else { return } + let item = NSStatusBar.system.statusItem(withLength: statusItemWidth) + item.autosaveName = StatusItemPlacementPolicy.autosaveName + // `autosaveName` makes AppKit restore status-item state across launches. + // CodeBurn has no user-facing hide toggle, so explicitly restore the + // supported visible state in case Tahoe persisted a hidden/parked item. + item.isVisible = true + statusItem = item + guard let button = statusItem.button else { + startStatusItemPlacementRecovery() + return + } // Set the bundled flame image immediately to ensure the status item renders. // On macOS Tahoe, status items may fail to appear if only an attributed title @@ -1002,9 +1017,137 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM // Defer the full attributed title setup to ensure initial render completes DispatchQueue.main.async { [weak self] in self?.refreshStatusButton() + self?.startStatusItemPlacementRecovery() } } + /// Tahoe can park an accessory app's status item at the screen's top-right + /// corner when the auto-hidden menu bar is hidden during launch (#1148). + /// Wait for the user's pointer to reveal the bar, then perform up to three + /// supported visibility pulses. Never remove/recreate the item: repeated + /// creation churn is implicated in poisoning the bundle-id state this + /// recovery protects. + private func startStatusItemPlacementRecovery() { + stopStatusItemPlacementRecovery() + + statusItemPlacementRecoveryTask = Task { @MainActor [weak self] in + guard let self else { return } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(120)) + var recovery = StatusItemPlacementRecoveryCoordinator() + + while !Task.isCancelled && clock.now < deadline { + let placement = self.statusItemPlacementState + let revealed = placement.screen.map { screen in + StatusItemPlacementPolicy.isMenuBarRevealed( + pointer: NSEvent.mouseLocation, + screenFrame: screen.frame, + screenVisibleFrame: screen.visibleFrame + ) + } ?? false + switch recovery.action( + for: placement.geometry, + isMenuBarRevealed: revealed, + revealHasSettled: false + ) { + case .stopHealthy: + return + case .poll, .waitForReveal: + try? await Task.sleep(for: .milliseconds(250)) + continue + case .stopExhausted: + NSLog("CodeBurn: status item remains parked after bounded retries") + return + case .settleBeforePulse: + // Let the auto-hide animation finish before asking AppKit + // to place the existing item again. + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + let settledPlacement = self.statusItemPlacementState + let settledReveal = settledPlacement.screen.map { screen in + StatusItemPlacementPolicy.isMenuBarRevealed( + pointer: NSEvent.mouseLocation, + screenFrame: screen.frame, + screenVisibleFrame: screen.visibleFrame + ) + } ?? false + switch recovery.action( + for: settledPlacement.geometry, + isMenuBarRevealed: settledReveal, + revealHasSettled: true + ) { + case .stopHealthy: + return + case .poll, .waitForReveal, .settleBeforePulse: + continue + case .stopExhausted: + NSLog("CodeBurn: status item remains parked after bounded retries") + return + case .pulse(let attempt): + NSLog("CodeBurn: retrying parked status-item placement after menu bar reveal (\(attempt)/\(recovery.maximumPulseCount))") + await StatusItemVisibilityPulse.run { self.statusItem.isVisible = $0 } + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .milliseconds(250)) + continue + } + case .pulse: + // A pulse is only emitted after the settle phase above. + assertionFailure("status item pulse emitted before reveal settled") + return + } + } + + guard !Task.isCancelled else { return } + switch self.statusItemPlacementState { + case .healthy: + return + case .unrealized: + NSLog("CodeBurn: status item did not realize before placement recovery timed out") + case .parked: + NSLog("CodeBurn: status item stayed parked without a menu-bar reveal") + } + } + } + + private func stopStatusItemPlacementRecovery() { + statusItemPlacementRecoveryTask?.cancel() + statusItemPlacementRecoveryTask = nil + } + + private enum StatusItemPlacementState { + case unrealized + case healthy + case parked(NSScreen) + + var geometry: StatusItemPlacementRecoveryGeometry { + switch self { + case .unrealized: return .unrealized + case .healthy: return .healthy + case .parked: return .parked + } + } + + var screen: NSScreen? { + if case .parked(let screen) = self { return screen } + return nil + } + } + + private var statusItemPlacementState: StatusItemPlacementState { + guard let window = statusItem?.button?.window else { return .unrealized } + let frame = window.frame + guard !frame.isEmpty else { return .unrealized } + guard let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(frame) }) + ?? NSScreen.main else { return .unrealized } + let parked = StatusItemPlacementPolicy.isParked( + itemFrame: frame, + screenFrame: screen.frame, + statusBarThickness: NSStatusBar.system.thickness + ) + return parked ? .parked(screen) : .healthy + } + /// Composes the menubar title as a single attributed string with the flame as an inline /// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a /// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge diff --git a/mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift b/mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift new file mode 100644 index 00000000..a78786a6 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift @@ -0,0 +1,130 @@ +import AppKit + +/// Conservative policy for the Tahoe status-item parking failure in #1148. +/// +/// A narrow geometry match matters: menu bar items can legitimately be short +/// or near a display edge. The poisoned state reported in #1148 combines all +/// three signals — legacy 22pt height, flush with the display's right edge, +/// and parked in the top menu-bar band. +enum StatusItemPlacementPolicy { + static let autosaveName: NSStatusItem.AutosaveName = "CodeBurnMenubar.MainStatusItem" + + static func isParked( + itemFrame: CGRect, + screenFrame: CGRect, + statusBarThickness: CGFloat + ) -> Bool { + guard !itemFrame.isEmpty, + !screenFrame.isEmpty, + statusBarThickness > 0 else { return false } + + let geometryTolerance: CGFloat = 1 + let legacyHeight = itemFrame.height + geometryTolerance < statusBarThickness + let flushWithRightEdge = abs(itemFrame.maxX - screenFrame.maxX) <= geometryTolerance + let inTopBand = itemFrame.maxY >= screenFrame.maxY - max(statusBarThickness, itemFrame.height) + return legacyHeight && flushWithRightEdge && inTopBand + } + + static func isMenuBarRevealLocation( + _ location: CGPoint, + screenFrame: CGRect, + activationBand: CGFloat = 4, + edgeOvershoot: CGFloat = 2 + ) -> Bool { + guard activationBand > 0, + edgeOvershoot >= 0, + location.x >= screenFrame.minX, + location.x <= screenFrame.maxX else { return false } + return location.y >= screenFrame.maxY - activationBand + && location.y <= screenFrame.maxY + edgeOvershoot + } + + static func isMenuBarRevealed( + pointer: CGPoint, + screenFrame: CGRect, + screenVisibleFrame: CGRect + ) -> Bool { + let geometryTolerance: CGFloat = 1 + let menuBarOccupiesVisibleFrame = screenVisibleFrame.maxY < screenFrame.maxY - geometryTolerance + return menuBarOccupiesVisibleFrame + || isMenuBarRevealLocation(pointer, screenFrame: screenFrame) + } +} + +enum StatusItemPlacementRecoveryGeometry: Equatable { + case unrealized + case healthy + case parked +} + +enum StatusItemPlacementRecoveryAction: Equatable { + case stopHealthy + case poll + case waitForReveal + case settleBeforePulse + case pulse(Int) + case stopExhausted +} + +/// Pure state machine for the AppKit recovery loop. A reveal is consumed only +/// when a pulse is actually issued; realization lag must not waste the user's +/// one reveal gesture. After a failed pulse, a hide followed by a distinct +/// reveal is required before another attempt. +struct StatusItemPlacementRecoveryCoordinator { + private(set) var pulseCount = 0 + private var requiresHideBeforeNextPulse = false + let maximumPulseCount: Int + + init(maximumPulseCount: Int = 3) { + self.maximumPulseCount = maximumPulseCount + } + + mutating func action( + for geometry: StatusItemPlacementRecoveryGeometry, + isMenuBarRevealed: Bool, + revealHasSettled: Bool + ) -> StatusItemPlacementRecoveryAction { + if geometry == .healthy { + return .stopHealthy + } + guard geometry != .unrealized else { + return .poll + } + guard pulseCount < maximumPulseCount else { + return .stopExhausted + } + + if requiresHideBeforeNextPulse { + if !isMenuBarRevealed { + requiresHideBeforeNextPulse = false + } + return .waitForReveal + } + guard isMenuBarRevealed else { + return .waitForReveal + } + guard revealHasSettled else { + return .settleBeforePulse + } + + pulseCount += 1 + requiresHideBeforeNextPulse = true + return .pulse(pulseCount) + } +} + +@MainActor +enum StatusItemVisibilityPulse { + static func run( + setVisible: (Bool) -> Void, + sleep: (Duration) async throws -> Void = { duration in + try await Task.sleep(for: duration) + } + ) async { + setVisible(false) + // A cancelled sleep throws immediately. Visibility is restored before + // the caller observes cancellation or returns. + try? await sleep(.milliseconds(50)) + setVisible(true) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift b/mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift new file mode 100644 index 00000000..159bb5e7 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("Status item placement policy") +struct StatusItemPlacementPolicyTests { + private let screen = CGRect(x: 0, y: 0, width: 1_440, height: 900) + + @Test("recognizes the Tahoe parked frame reported in issue 1148") + func recognizesParkedFrame() { + let parked = CGRect(x: 1_418, y: 878, width: 22, height: 22) + + #expect(StatusItemPlacementPolicy.isParked( + itemFrame: parked, + screenFrame: screen, + statusBarThickness: 30 + )) + } + + @Test("does not disturb a healthy rightmost item") + func preservesHealthyRightmostItem() { + let healthy = CGRect(x: 1_410, y: 870, width: 30, height: 30) + + #expect(!StatusItemPlacementPolicy.isParked( + itemFrame: healthy, + screenFrame: screen, + statusBarThickness: 30 + )) + } + + @Test("does not mistake a short item away from the corner for parked") + func preservesShortPlacedItem() { + let placed = CGRect(x: 900, y: 878, width: 22, height: 22) + + #expect(!StatusItemPlacementPolicy.isParked( + itemFrame: placed, + screenFrame: screen, + statusBarThickness: 30 + )) + } + + @Test("waits for the pointer to reveal an auto-hidden menu bar") + func recognizesRevealGesture() { + #expect(StatusItemPlacementPolicy.isMenuBarRevealLocation( + CGPoint(x: 720, y: 899), + screenFrame: screen + )) + #expect(StatusItemPlacementPolicy.isMenuBarRevealLocation( + CGPoint(x: 720, y: 900), + screenFrame: screen + )) + #expect(StatusItemPlacementPolicy.isMenuBarRevealLocation( + CGPoint(x: 720, y: 902), + screenFrame: screen + )) + #expect(!StatusItemPlacementPolicy.isMenuBarRevealLocation( + CGPoint(x: 720, y: 880), + screenFrame: screen + )) + #expect(!StatusItemPlacementPolicy.isMenuBarRevealLocation( + CGPoint(x: 1_500, y: 900), + screenFrame: screen + )) + } + + @Test("uses pointer location for an auto-hidden menu bar") + func autoHiddenRevealSignal() { + #expect(!StatusItemPlacementPolicy.isMenuBarRevealed( + pointer: CGPoint(x: 720, y: 500), + screenFrame: screen, + screenVisibleFrame: screen + )) + #expect(StatusItemPlacementPolicy.isMenuBarRevealed( + pointer: CGPoint(x: 720, y: 900), + screenFrame: screen, + screenVisibleFrame: screen + )) + } + + @Test("recognizes a menu bar that occupies the visible frame") + func alwaysVisibleMenuBarSignal() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1_440, height: 870) + #expect(StatusItemPlacementPolicy.isMenuBarRevealed( + pointer: CGPoint(x: 720, y: 500), + screenFrame: screen, + screenVisibleFrame: visibleFrame + )) + } + + @Test("realization lag does not consume the reveal") + func realizationLagPreservesReveal() { + var recovery = StatusItemPlacementRecoveryCoordinator() + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .settleBeforePulse) + #expect(recovery.action(for: .unrealized, isMenuBarRevealed: false, revealHasSettled: true) == .poll) + #expect(recovery.pulseCount == 0) + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .settleBeforePulse) + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(1)) + } + + @Test("requires a hide and distinct reveal after an actual pulse") + func retryRequiresDistinctReveal() { + var recovery = StatusItemPlacementRecoveryCoordinator() + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(1)) + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .waitForReveal) + #expect(recovery.action(for: .parked, isMenuBarRevealed: false, revealHasSettled: false) == .waitForReveal) + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .settleBeforePulse) + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(2)) + } + + @Test("never emits more than three pulses") + func boundsPulseCount() { + var recovery = StatusItemPlacementRecoveryCoordinator(maximumPulseCount: 3) + for attempt in 1...3 { + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(attempt)) + _ = recovery.action(for: .parked, isMenuBarRevealed: false, revealHasSettled: false) + } + #expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .stopExhausted) + #expect(recovery.pulseCount == 3) + } + + @Test("restores visibility when the pulse sleep is cancelled") + @MainActor + func cancellationRestoresVisibility() async { + var visibleStates: [Bool] = [] + await StatusItemVisibilityPulse.run( + setVisible: { visibleStates.append($0) }, + sleep: { _ in throw CancellationError() } + ) + #expect(visibleStates == [false, true]) + } + + @Test("keeps a stable autosave identity across launches") + func stableAutosaveIdentity() { + #expect(StatusItemPlacementPolicy.autosaveName == "CodeBurnMenubar.MainStatusItem") + } +}