From 5785fc58984531870d45cd2f20e3b8e1c3cae50e Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:59:37 +0530 Subject: [PATCH 1/3] fix(menubar): recover poisoned status item placement (#1148) --- mac/README.md | 24 + mac/Scripts/package-app.sh | 2 + mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 227 +++++- .../LoginItemRegistrationPolicy.swift | 56 ++ .../StatusItemPlacementPolicy.swift | 130 ++++ .../LoginItemRegistrationPolicyTests.swift | 52 ++ .../StatusItemPlacementPolicyTests.swift | 136 ++++ src/main.ts | 17 +- src/menubar-installer.ts | 718 +++++++++++++++++- tests/menubar-installer-windows.test.ts | 17 + tests/menubar-installer.test.ts | 441 ++++++++++- 11 files changed, 1799 insertions(+), 21 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/LoginItemRegistrationPolicy.swift create mode 100644 mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/LoginItemRegistrationPolicyTests.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift diff --git a/mac/README.md b/mac/README.md index 07e6b1d31..8f5a057bb 100644 --- a/mac/README.md +++ b/mac/README.md @@ -18,6 +18,30 @@ codeburn menubar That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise. +If the process runs but its status item never appears, including after a +reinstall and reboot, macOS may have retained bad per-bundle-id placement state. +First refresh the installed app so it can safely transfer its own Login Item +state, then repair it without keeping a duplicate app: + +```bash +codeburn menubar --force +codeburn menubar --repair-placement +``` + +The installer verifies the official release first, replaces the existing app +at the same path with a locally re-signed copy using a fresh CodeBurn recovery +bundle id, and preserves that id across future `--force` updates. macOS may ask +for CodeBurn permissions again because the repaired app has a new local code +identity. A Developer-ID signed or notarized app cannot be re-identified +locally without invalidating its signature, so the command fails safely for +those artifacts instead of silently downgrading them. + +To return to the official bundle identity later, reinstall it explicitly: + +```bash +codeburn menubar --reset-placement +``` + ### Build from source For contributors running a local build instead of the packaged release: diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 5b47c81b3..361541ec2 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -86,6 +86,8 @@ cat > "${BUNDLE}/Contents/Info.plist" <AppIcon CFBundleIdentifier ${BUNDLE_ID} + CodeBurnLoginItemMaintenanceVersion + 1 CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index f183ff196..a5a8cc24d 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -3,6 +3,7 @@ import SwiftUI import AppKit import Observation import ServiceManagement +import Darwin private let refreshIntervalSeconds: UInt64 = 30 private let forceRefreshWatchdogSeconds: TimeInterval = 90 @@ -53,6 +54,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 +92,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 @@ -122,6 +125,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } func applicationDidFinishLaunching(_ notification: Notification) { + runMaintenanceCommandIfRequested() ProcessInfo.processInfo.automaticTerminationSupportEnabled = false ProcessInfo.processInfo.disableSuddenTermination() // Deliberately NO app-lifetime beginActivity here. A permanent @@ -155,6 +159,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM Task { await updateChecker.checkIfNeeded() } } + /// Runs only from the installed source bundle during identity repair. The + /// exact installed code identity is required for SMAppService to address + /// the source registration. Packaged builds advertise this protocol in + /// Info.plist so older binaries are never launched with an unknown flag. + private func runMaintenanceCommandIfRequested() { + let arguments = ProcessInfo.processInfo.arguments + let statusRequested = arguments.contains("--codeburn-login-item-status") + let unregisterRequested = arguments.contains("--codeburn-unregister-login-item") + let registerRequested = arguments.contains("--codeburn-register-login-item") + guard statusRequested || unregisterRequested || registerRequested else { + return + } + + let key = "codeburn.loginItemRegistered" + let service = SMAppService.mainApp + do { + let state = LoginItemRegistrationPolicy.migrationState( + status: service.status, + wasPreviouslyRegistered: UserDefaults.standard.bool(forKey: key) + ) + if unregisterRequested { + switch service.status { + case .enabled, .requiresApproval: + try service.unregister() + case .notRegistered, .notFound: + break + @unknown default: + break + } + } else if registerRequested, service.status != .enabled { + try service.register() + } + let resultState: LoginItemMigrationState + if registerRequested { + switch service.status { + case .enabled: + resultState = .registered + case .requiresApproval: + resultState = .disabled + case .notRegistered, .notFound: + resultState = .notRegistered + @unknown default: + resultState = .unknown + } + } else { + // Unregister reports the state that was retired so the caller + // can verify it addressed the intended identity. + resultState = state + } + print(resultState.rawValue) + fflush(stdout) + Darwin.exit(EXIT_SUCCESS) + } catch { + let message = "CodeBurn Login Item maintenance failed: \(error.localizedDescription)\n" + FileHandle.standardError.write(Data(message.utf8)) + Darwin.exit(EXIT_FAILURE) + } + } + private func setupWakeObservers() { // Pause the refresh loop while the machine is asleep. Without this, // Task.sleep keeps a wakeup pending across the suspension and the @@ -166,6 +229,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM queue: .main ) { [weak self] _ in Task { @MainActor in + self?.stopStatusItemPlacementRecovery() self?.prepareRefreshPipelineForSleep() } } @@ -182,6 +246,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM Task { @MainActor in self?.displayAsleep = false self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake") + self?.startStatusItemPlacementRecovery() } } @@ -193,6 +258,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 +272,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM ) { [weak self] _ in Task { @MainActor in self?.displayAsleep = true + self?.stopStatusItemPlacementRecovery() } } } @@ -285,15 +352,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM private func registerLoginItemIfNeeded() { let key = "codeburn.loginItemRegistered" - guard !UserDefaults.standard.bool(forKey: key) else { return } + let service = SMAppService.mainApp + let wasPreviouslyRegistered = UserDefaults.standard.bool(forKey: key) + guard LoginItemRegistrationPolicy.shouldRegister( + status: service.status, + wasPreviouslyRegistered: wasPreviouslyRegistered + ) else { + if LoginItemRegistrationPolicy.shouldRecordRegistration( + status: service.status, + wasPreviouslyRegistered: wasPreviouslyRegistered + ) { + UserDefaults.standard.set(true, forKey: key) + } + return + } // Registers in-process. The old path told System Events to make the login // item, which made macOS ask for Automation access on first launch (#1026). // No AppleScript fallback: a failure here must not bring that prompt back. do { - if SMAppService.mainApp.status != .enabled { - try SMAppService.mainApp.register() - } + try service.register() UserDefaults.standard.set(true, forKey: key) } catch { NSLog("CodeBurn: login item registration failed: \(error.localizedDescription)") @@ -959,8 +1037,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 +1089,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; run `codeburn menubar --repair-placement`") + 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; run `codeburn menubar --repair-placement`") + 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; run `codeburn menubar --repair-placement`") + case .parked: + NSLog("CodeBurn: status item stayed parked without a menu-bar reveal; run `codeburn menubar --repair-placement`") + } + } + } + + 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/LoginItemRegistrationPolicy.swift b/mac/Sources/CodeBurnMenubar/LoginItemRegistrationPolicy.swift new file mode 100644 index 000000000..5cb7144e6 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/LoginItemRegistrationPolicy.swift @@ -0,0 +1,56 @@ +import ServiceManagement + +enum LoginItemMigrationState: String, Equatable { + case registered + case disabled + case notRegistered = "not-registered" + case unknown +} + +enum LoginItemRegistrationPolicy { + static func shouldRegister( + status: SMAppService.Status, + wasPreviouslyRegistered: Bool + ) -> Bool { + switch status { + case .enabled, .requiresApproval: + return false + case .notRegistered, .notFound: + // Once registration has succeeded, .notRegistered represents the + // user's later choice in System Settings. Never fight that choice. + return !wasPreviouslyRegistered + @unknown default: + return false + } + } + + static func migrationState( + status: SMAppService.Status, + wasPreviouslyRegistered: Bool + ) -> LoginItemMigrationState { + switch status { + case .enabled: + return .registered + case .requiresApproval: + // Apple uses requiresApproval both while first approval is pending + // and after previously-granted consent is revoked. Released builds + // wrote the registration marker before approval, so the legacy + // marker plus no enabled observation is irreducibly ambiguous. + // Fail closed: never re-register a replacement identity if that + // could override the user's explicit System Settings choice. + return .disabled + case .notRegistered, .notFound: + return wasPreviouslyRegistered ? .disabled : .notRegistered + @unknown default: + return .unknown + } + } + + static func shouldRecordRegistration( + status: SMAppService.Status, + wasPreviouslyRegistered: Bool + ) -> Bool { + status == .enabled || wasPreviouslyRegistered + } + +} diff --git a/mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift b/mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift new file mode 100644 index 000000000..a78786a6f --- /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/LoginItemRegistrationPolicyTests.swift b/mac/Tests/CodeBurnMenubarTests/LoginItemRegistrationPolicyTests.swift new file mode 100644 index 000000000..311a0e590 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/LoginItemRegistrationPolicyTests.swift @@ -0,0 +1,52 @@ +import ServiceManagement +import Testing +@testable import CodeBurnMenubar + +@Suite("Login item registration policy") +struct LoginItemRegistrationPolicyTests { + @Test("registers once without overriding a later user disable") + func respectsStatusAndRegistrationHistory() { + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .enabled, wasPreviouslyRegistered: false)) + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .requiresApproval, wasPreviouslyRegistered: false)) + #expect(LoginItemRegistrationPolicy.shouldRegister(status: .notRegistered, wasPreviouslyRegistered: false)) + #expect(LoginItemRegistrationPolicy.shouldRegister(status: .notFound, wasPreviouslyRegistered: false)) + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .notRegistered, wasPreviouslyRegistered: true)) + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .notFound, wasPreviouslyRegistered: true)) + } + + @Test("classifies revoked approval as disabled during identity migration") + func migrationStatePreservesConsent() { + #expect(LoginItemRegistrationPolicy.migrationState( + status: .enabled, + wasPreviouslyRegistered: true + ) == .registered) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .requiresApproval, + wasPreviouslyRegistered: true + ) == .disabled) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .requiresApproval, + wasPreviouslyRegistered: false + ) == .disabled) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .notRegistered, + wasPreviouslyRegistered: true + ) == .disabled) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .notRegistered, + wasPreviouslyRegistered: false + ) == .notRegistered) + #expect(LoginItemRegistrationPolicy.shouldRecordRegistration( + status: .enabled, + wasPreviouslyRegistered: false + )) + #expect(!LoginItemRegistrationPolicy.shouldRecordRegistration( + status: .requiresApproval, + wasPreviouslyRegistered: false + )) + #expect(LoginItemRegistrationPolicy.shouldRecordRegistration( + status: .requiresApproval, + wasPreviouslyRegistered: true + )) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift b/mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift new file mode 100644 index 000000000..159bb5e7d --- /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") + } +} diff --git a/src/main.ts b/src/main.ts index 51bc1a830..510052b1a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1392,11 +1392,22 @@ program .command('menubar') .description('Install and launch the menubar app on macOS and Windows (one command, no clone)') .option('--force', 'Reinstall even if a copy is already installed') - .action(async (opts: { force?: boolean }) => { + .option('--repair-placement', 'Repair a missing macOS menu bar item with a fresh local bundle identity') + .option('--reset-placement', 'Return the macOS menu bar app to its official bundle identity') + .action(async (opts: { force?: boolean; repairPlacement?: boolean; resetPlacement?: boolean }) => { try { - const result = await installMenubarApp({ force: opts.force, cliVersion: version }) + const result = await installMenubarApp({ + force: opts.force, + repairPlacement: opts.repairPlacement, + resetPlacement: opts.resetPlacement, + cliVersion: version, + }) // A cancelled Windows installer leaves nothing to point at. - if (result.installedPath) console.log(`\n Ready. ${result.installedPath}\n`) + if (result.installedPath) { + console.log(result.launched + ? `\n Ready. ${result.installedPath}\n` + : `\n Installed. Open ${result.installedPath} manually.\n`) + } } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\n Menubar install failed: ${message}\n`) diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 62494349d..13fa96bb0 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' +import { createHash, randomBytes } from 'node:crypto' import { createWriteStream } from 'node:fs' import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { homedir, platform, tmpdir } from 'node:os' @@ -20,6 +20,14 @@ const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases const RELEASE_DOWNLOAD_BASE = 'https://github.com/getagentseal/codeburn/releases/download' const APP_BUNDLE_NAME = 'CodeBurnMenubar.app' const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' +const RECOVERY_BUNDLE_ID_PREFIX = `${EXPECTED_BUNDLE_ID}.recovery.` +const RECOVERY_BUNDLE_ID_PATTERN = /^org\.agentseal\.codeburn-menubar\.recovery\.[0-9a-f]{16}$/ +const STATUS_ITEM_AUTOSAVE_NAME = 'CodeBurnMenubar.MainStatusItem' +const LOGIN_ITEM_MAINTENANCE_VERSION_KEY = 'CodeBurnLoginItemMaintenanceVersion' +const LOGIN_ITEM_STATUS_ARGUMENT = '--codeburn-login-item-status' +const LOGIN_ITEM_UNREGISTER_ARGUMENT = '--codeburn-unregister-login-item' +const LOGIN_ITEM_REGISTER_ARGUMENT = '--codeburn-register-login-item' +const LOGIN_ITEM_MAINTENANCE_TIMEOUT_MS = 5_000 const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' @@ -30,17 +38,86 @@ const WINDOWS_PRODUCT_NAME = 'CodeBurn Menubar' const WINDOWS_ASSET_PATTERN = /^CodeBurn\.Menubar_.+_x64_en-US\.msi$/ const MIN_MACOS_MAJOR = 14 const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') +const PERSISTED_MENUBAR_BUNDLE_ID = join( + homedir(), + 'Library', + 'Application Support', + 'CodeBurn', + 'menubar-bundle-id.v1', +) const PERSISTENT_CLI_REQUIRED_MESSAGE = 'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn' export type InstallResult = { installedPath: string; launched: boolean } +function isMenubarPlacementRecoveryBundleId(bundleID: string): boolean { + return RECOVERY_BUNDLE_ID_PATTERN.test(bundleID) +} + +export function isSupportedMenubarBundleId(bundleID: string): boolean { + return bundleID === EXPECTED_BUNDLE_ID || isMenubarPlacementRecoveryBundleId(bundleID) +} + +export function isAdHocMenubarSignatureDetails(details: string): boolean { + return /^Signature=adhoc$/m.test(details) && /^TeamIdentifier=not set$/m.test(details) +} + +export function isMissingDefaultsDomainError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /Domain \(?[^\n]+\)? (?:does not exist|not found)/i.test(message) || + /domain\/default pair of \([^\n]+\) does not exist/i.test(message) +} + +export function createMenubarPlacementRecoveryBundleId(suffix = randomBytes(8).toString('hex')): string { + if (!/^[0-9a-f]{16}$/.test(suffix)) { + throw new Error('Menubar placement recovery id suffix must be 16 lowercase hex characters.') + } + return `${RECOVERY_BUNDLE_ID_PREFIX}${suffix}` +} + +export function selectMenubarBundleId(options: { + repairPlacement?: boolean + resetPlacement?: boolean + persistedBundleId?: string + recoverySuffix?: string +} = {}): string { + if (options.repairPlacement && options.resetPlacement) { + throw new Error('--repair-placement and --reset-placement cannot be used together.') + } + if (options.repairPlacement) { + return createMenubarPlacementRecoveryBundleId(options.recoverySuffix) + } + if (options.resetPlacement) return EXPECTED_BUNDLE_ID + if (options.persistedBundleId && isMenubarPlacementRecoveryBundleId(options.persistedBundleId)) { + return options.persistedBundleId + } + return EXPECTED_BUNDLE_ID +} + +export function resolveActiveMenubarBundleId(options: { + installedBundleId?: string + persistedBundleId?: string +}): string { + if (options.installedBundleId !== undefined) { + if (!isSupportedMenubarBundleId(options.installedBundleId)) { + throw new Error(`Refusing unsupported installed menubar bundle id ${options.installedBundleId}.`) + } + return options.installedBundleId + } + if (options.persistedBundleId && isMenubarPlacementRecoveryBundleId(options.persistedBundleId)) { + return options.persistedBundleId + } + return EXPECTED_BUNDLE_ID +} + export type ReleaseAsset = { name: string; browser_download_url: string } export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } /// `zip` is the platform's primary asset: the mac bundle zip, or the Windows .msi. export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } export type InstallOptions = { force?: boolean + repairPlacement?: boolean + resetPlacement?: boolean cliVersion?: string platform?: string windows?: WindowsInstallHooks @@ -460,6 +537,54 @@ async function captureCommand(command: string, args: string[]): Promise }) } +async function captureCommandWithTimeout( + command: string, + args: string[], + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let out = '' + let err = '' + let settled = false + const finish = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + callback() + } + const timer = setTimeout(() => { + proc.kill('SIGTERM') + const forceKill = setTimeout(() => proc.kill('SIGKILL'), 250) + forceKill.unref() + finish(() => reject(new Error(`${command} timed out after ${timeoutMs}ms`))) + }, timeoutMs) + timer.unref() + proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() }) + proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() }) + proc.on('error', error => finish(() => reject(error))) + proc.on('close', code => finish(() => { + if (code === 0) resolve(out.trim()) + else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`)) + })) + }) +} + +async function captureCommandStreams(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) resolve({ stdout: stdout.trim(), stderr: stderr.trim() }) + else reject(new Error(`${command} exited with status ${code}${stderr ? `: ${stderr.trim()}` : ''}`)) + }) + }) +} + async function verifyBundleIdentity(appPath: string): Promise { const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [ '-c', @@ -472,6 +597,286 @@ async function verifyBundleIdentity(appPath: string): Promise { await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]) } +export async function reidentifyMenubarBundleForPlacementRecovery( + appPath: string, + bundleID: string, +): Promise { + if (!isMenubarPlacementRecoveryBundleId(bundleID)) { + throw new Error(`Refusing unsupported recovery bundle id ${bundleID}.`) + } + + const signature = await captureCommandStreams('/usr/bin/codesign', [ + '-dvvv', + '--verbose=4', + appPath, + ]) + const signatureDetails = `${signature.stdout}\n${signature.stderr}` + if (!isAdHocMenubarSignatureDetails(signatureDetails)) { + throw new Error( + 'This CodeBurn Menubar build is Developer-ID signed or notarized and cannot be safely ' + + 're-identified locally without invalidating its signature. Placement repair is unavailable ' + + 'for this artifact; use `--reset-placement` to keep the official identity.' + ) + } + + const infoPlist = join(appPath, 'Contents', 'Info.plist') + await runCommand('/usr/libexec/PlistBuddy', [ + '-c', + `Set :CFBundleIdentifier ${bundleID}`, + infoPlist, + ]) + // The official bundle has already passed checksum, identity, and signature + // verification in stageMenubarApp. Changing Info.plist invalidates that + // signature, so apply a local ad-hoc signature and verify the resulting + // bundle before it can replace the installed copy. + await runCommand('/usr/bin/codesign', [ + '--force', + '--sign', + '-', + '--preserve-metadata=entitlements,flags,runtime', + '--timestamp=none', + '--deep', + appPath, + ]) + const writtenBundleID = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + infoPlist, + ]) + if (writtenBundleID !== bundleID) { + throw new Error(`Menubar placement recovery wrote ${writtenBundleID}; expected ${bundleID}.`) + } + await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]) + await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', appPath]).catch(() => {}) +} + +export async function migrateMenubarPreferencesForPlacementRecovery( + sourceBundleID: string, + targetBundleID: string, + stagingDir: string, +): Promise { + const migration = await prepareMenubarPreferenceMigration( + sourceBundleID, + targetBundleID, + stagingDir, + ) + await migration.apply() + await migration.commit() +} + +export type MenubarPreferenceMigration = { + apply: () => Promise + rollback: () => Promise + commit: () => Promise +} + +async function readPreferenceKeysFromPlist(plistPath: string): Promise { + const json = await captureCommand('/usr/bin/plutil', [ + '-convert', + 'json', + '-o', + '-', + plistPath, + ]) + const value: unknown = JSON.parse(json) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Preference export ${plistPath} was not a dictionary.`) + } + return Object.keys(value) +} + +export async function prepareMenubarPreferenceMigration( + sourceBundleID: string, + targetBundleID: string, + stagingDir: string, + options: { preserveLoginDisable?: boolean } = {}, +): Promise { + if (!isSupportedMenubarBundleId(sourceBundleID) || !isSupportedMenubarBundleId(targetBundleID)) { + throw new Error('Refusing to migrate preferences for an unsupported menubar bundle id.') + } + if (sourceBundleID === targetBundleID) { + return { + apply: async () => {}, + rollback: async () => {}, + commit: async () => {}, + } + } + + const sourcePreferencesPath = join(stagingDir, 'menubar-preferences-source.plist') + const targetPreferencesPath = join(stagingDir, 'menubar-preferences-target.plist') + let hasSourcePreferences = false + let hadTargetPreferences = false + let sourcePreferenceKeys: string[] = [] + let targetPreferenceKeys: string[] = [] + let applied = false + + try { + const exported = await captureCommand('/usr/bin/defaults', ['export', sourceBundleID, '-']) + await writeFile(sourcePreferencesPath, `${exported}\n`, { mode: 0o600 }) + await chmod(sourcePreferencesPath, 0o600) + sourcePreferenceKeys = await readPreferenceKeysFromPlist(sourcePreferencesPath) + // `defaults export` can emit an empty dictionary with status 0 for a + // nonexistent domain. Treat that as no source rather than importing an + // empty plist over an existing target domain. + hasSourcePreferences = sourcePreferenceKeys.length > 0 + } catch (error) { + // A first install can have no source preference domain yet. + if (!isMissingDefaultsDomainError(error)) throw error + } + + try { + const exported = await captureCommand('/usr/bin/defaults', ['export', targetBundleID, '-']) + await writeFile(targetPreferencesPath, `${exported}\n`, { mode: 0o600 }) + await chmod(targetPreferencesPath, 0o600) + targetPreferenceKeys = await readPreferenceKeysFromPlist(targetPreferencesPath) + hadTargetPreferences = targetPreferenceKeys.length > 0 + } catch (error) { + // A fresh recovery identity intentionally has no target domain. + if (!isMissingDefaultsDomainError(error)) throw error + } + + return { + apply: async () => { + if (applied) return + if (hasSourcePreferences) { + await runCommand('/usr/bin/defaults', ['import', targetBundleID, sourcePreferencesPath]) + } + applied = true + + // Preserve product settings, never any AppKit status-item placement + // state. Older builds had no autosaveName, so strip every legacy + // NSStatusItem key as well as the documented stable-name keys. + const importedKeys = hasSourcePreferences ? sourcePreferenceKeys : targetPreferenceKeys + const keysToDelete = new Set([ + `NSStatusItem Preferred Position ${STATUS_ITEM_AUTOSAVE_NAME}`, + `NSStatusItem Visible ${STATUS_ITEM_AUTOSAVE_NAME}`, + // Registration history belongs to the bundle identity. Excluding it + // lets the new identity register exactly once while the source + // identity's marker still preserves a user's later disable choice. + 'codeburn.loginItemRegistered', + ...importedKeys.filter(key => key.startsWith('NSStatusItem ')), + ]) + for (const key of keysToDelete) { + try { + await captureCommand('/usr/bin/defaults', ['delete', targetBundleID, key]) + } catch (error) { + if (!isMissingDefaultsDomainError(error)) throw error + } + } + if (options.preserveLoginDisable) { + await runCommand('/usr/bin/defaults', [ + 'write', targetBundleID, 'codeburn.loginItemRegistered', '-bool', 'true', + ]) + } + }, + rollback: async () => { + if (!applied) return + if (hadTargetPreferences) { + await runCommand('/usr/bin/defaults', ['import', targetBundleID, targetPreferencesPath]) + } else { + try { + await captureCommand('/usr/bin/defaults', ['delete', targetBundleID]) + } catch (error) { + if (!isMissingDefaultsDomainError(error)) throw error + } + } + applied = false + }, + commit: async () => { + // Retain the canonical preference domain as the user's reversible + // fallback, but bound recovery residue to the currently selected ID. + if (isMenubarPlacementRecoveryBundleId(sourceBundleID)) { + await runCommand('/usr/bin/defaults', ['delete', sourceBundleID]).catch(() => {}) + } + }, + } +} + +export type MenubarLoginItemState = + | 'registered' + | 'disabled' + | 'not-registered' + | 'unknown' + | 'unsupported' + +type MenubarLoginItemMaintenanceAction = 'status' | 'unregister' | 'register' + +export function planMenubarLoginItemMigration(state: MenubarLoginItemState): { + preserveDisable: boolean + retirePrevious: boolean + restoreOnFailure: boolean +} { + return { + preserveDisable: state === 'disabled', + retirePrevious: state === 'registered' || state === 'disabled', + restoreOnFailure: state === 'registered', + } +} + +export function isRestoredMenubarLoginItemState( + state: MenubarLoginItemState, + originalState: MenubarLoginItemState, +): boolean { + return originalState === 'registered' && state === 'registered' +} + +export async function installedMenubarSupportsLoginItemMaintenance( + installedAppPath: string, +): Promise { + try { + const version = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + `Print :${LOGIN_ITEM_MAINTENANCE_VERSION_KEY}`, + join(installedAppPath, 'Contents', 'Info.plist'), + ]) + return Number.parseInt(version, 10) >= 1 + } catch { + return false + } +} + +export async function runInstalledMenubarLoginItemMaintenance( + installedAppPath: string, + expectedBundleID: string, + action: MenubarLoginItemMaintenanceAction, + options: { timeoutMs?: number } = {}, +): Promise { + if (!isSupportedMenubarBundleId(expectedBundleID)) { + throw new Error(`Refusing Login Item maintenance for unsupported bundle id ${expectedBundleID}.`) + } + if (!(await installedMenubarSupportsLoginItemMaintenance(installedAppPath))) { + return 'unsupported' + } + + const actualBundleID = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + join(installedAppPath, 'Contents', 'Info.plist'), + ]) + if (actualBundleID !== expectedBundleID) { + throw new Error( + `Installed CodeBurn Menubar identity is ${actualBundleID}; expected ${expectedBundleID}.`, + ) + } + + const argument = action === 'status' + ? LOGIN_ITEM_STATUS_ARGUMENT + : action === 'unregister' + ? LOGIN_ITEM_UNREGISTER_ARGUMENT + : LOGIN_ITEM_REGISTER_ARGUMENT + const executablePath = join(installedAppPath, 'Contents', 'MacOS', APP_PROCESS_NAME) + const result = await captureCommandWithTimeout( + executablePath, + [argument], + options.timeoutMs ?? LOGIN_ITEM_MAINTENANCE_TIMEOUT_MS, + ) + if (result === 'registered' || result === 'disabled' || + result === 'not-registered' || result === 'unknown') { + return result + } + throw new Error(`CodeBurn Menubar returned an unexpected Login Item state: ${result || '(empty)'}`) +} + async function resolvePersistentCodeburnPath(): Promise { let output = '' try { @@ -495,6 +900,120 @@ async function persistCodeburnPath(): Promise { await chmod(PERSISTED_CLI_PATH, 0o600) } +async function readPersistedMenubarBundleId(): Promise { + try { + const bundleID = (await readFile(PERSISTED_MENUBAR_BUNDLE_ID, 'utf8')).trim() + return isMenubarPlacementRecoveryBundleId(bundleID) ? bundleID : undefined + } catch { + return undefined + } +} + +async function readInstalledMenubarBundleId(appPath: string): Promise { + return captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + join(appPath, 'Contents', 'Info.plist'), + ]) +} + +async function persistMenubarBundleId(bundleID: string): Promise { + if (bundleID === EXPECTED_BUNDLE_ID) { + await rm(PERSISTED_MENUBAR_BUNDLE_ID, { force: true }) + return + } + if (!isMenubarPlacementRecoveryBundleId(bundleID)) { + throw new Error(`Refusing to persist unsupported menubar bundle id ${bundleID}.`) + } + const supportDir = join(homedir(), 'Library', 'Application Support', 'CodeBurn') + await mkdir(supportDir, { recursive: true, mode: 0o700 }) + await chmod(supportDir, 0o700) + const temporaryDir = await mkdtemp(join(supportDir, '.menubar-bundle-id-')) + const temporaryPath = join(temporaryDir, 'value') + try { + await writeFile(temporaryPath, `${bundleID}\n`, { mode: 0o600 }) + await chmod(temporaryPath, 0o600) + await rename(temporaryPath, PERSISTED_MENUBAR_BUNDLE_ID) + } finally { + await rm(temporaryDir, { recursive: true, force: true }).catch(() => {}) + } +} + +export async function replaceMenubarBundleWithRollback(options: { + stagedPath: string + targetPath: string + commitState: () => Promise + restoreState: () => Promise + launch: () => Promise +}): Promise { + const backupPath = `${options.targetPath}.codeburn-backup-${process.pid}` + const failedPath = `${options.targetPath}.codeburn-failed-${process.pid}` + const hadPreviousBundle = await exists(options.targetPath) + let installedNewBundle = false + + if (hadPreviousBundle) { + await rm(backupPath, { recursive: true, force: true }) + await rename(options.targetPath, backupPath) + } + + try { + await rename(options.stagedPath, options.targetPath) + installedNewBundle = true + await options.commitState() + } catch (installError) { + const rollbackErrors: unknown[] = [] + if (installedNewBundle) { + try { + // Preserve the failed candidate until the previous app is back in its + // canonical path. A rollback must never delete both runnable copies. + await rm(failedPath, { recursive: true, force: true }) + await rename(options.targetPath, failedPath) + } catch (error) { + rollbackErrors.push(error) + } + } + if (hadPreviousBundle) { + try { + await rename(backupPath, options.targetPath) + } catch (error) { + rollbackErrors.push(error) + } + } + if (await exists(options.targetPath)) { + await rm(failedPath, { recursive: true, force: true }).catch(() => {}) + } + try { + await options.restoreState() + } catch (restoreError) { + rollbackErrors.push(restoreError) + } + if (rollbackErrors.length > 0) { + throw new AggregateError( + [installError, ...rollbackErrors], + 'Menubar replacement failed and its previous bundle or identity state could not be fully restored.', + ) + } + throw installError + } + + if (hadPreviousBundle) { + try { + await rm(backupPath, { recursive: true, force: true }) + } catch (error) { + // The new app is already installed and running. This private path does + // not end in .app, so Finder/LaunchServices will not treat it as a + // second installation; report cleanup without turning success into a + // misleading failed-install result. + console.warn(`CodeBurn Menubar installed, but its private rollback backup could not be removed: ${String(error)}`) + } + } + + // LaunchServices failure does not undo an otherwise committed install. The + // caller can retry launch or tell the user exactly which installed app to + // open without restoring the poisoned identity that prompted the repair. + await options.launch() +} + async function isAppRunning(): Promise { return new Promise((resolve) => { const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME]) @@ -673,15 +1192,43 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise { - if ((options.platform ?? platform()) === 'win32') return installWindowsMenubarApp(options) + if ((options.platform ?? platform()) === 'win32') { + if (options.repairPlacement || options.resetPlacement) { + throw new Error('--repair-placement and --reset-placement are only available for the macOS menu bar app.') + } + return installWindowsMenubarApp(options) + } await ensureSupportedPlatform() await persistCodeburnPath() const appsDir = userApplicationsDir() const targetPath = join(appsDir, APP_BUNDLE_NAME) const alreadyInstalled = await exists(targetPath) + const persistedBundleId = await readPersistedMenubarBundleId() + const installedBundleId = alreadyInstalled + ? await readInstalledMenubarBundleId(targetPath) + : undefined + const previousBundleId = resolveActiveMenubarBundleId({ + installedBundleId, + persistedBundleId, + }) + const selectedBundleId = selectMenubarBundleId({ + repairPlacement: options.repairPlacement, + resetPlacement: options.resetPlacement, + persistedBundleId: isMenubarPlacementRecoveryBundleId(previousBundleId) + ? previousBundleId + : undefined, + }) - if (alreadyInstalled && !options.force) { + if (alreadyInstalled && previousBundleId !== selectedBundleId && + !(await installedMenubarSupportsLoginItemMaintenance(targetPath))) { + throw new Error( + 'This installed CodeBurn Menubar predates safe Login Item identity transfer. ' + + 'Run `codeburn menubar --force` once, then rerun the placement repair command.', + ) + } + + if (alreadyInstalled && !options.force && !options.repairPlacement && !options.resetPlacement) { if (!(await isAppRunning())) { await runCommand('/usr/bin/open', [targetPath]) } @@ -710,18 +1257,167 @@ export async function installMenubarApp(options: InstallOptions = {}): Promise {}) + throw new AggregateError( + [error, restoreError], + 'Placement repair stopped before installation and could not restore the previous Login Item.', + ) + } + } + if (wasRunning) await runCommand('/usr/bin/open', [targetPath]).catch(() => {}) + throw error + } + let launched = false + try { + await replaceMenubarBundleWithRollback({ + stagedPath: unpackedApp, + targetPath, + commitState: async () => { + await preferenceMigration.apply() + await persistMenubarBundleId(selectedBundleId) + }, + restoreState: async () => { + await preferenceMigration.rollback() + await persistMenubarBundleId(previousBundleId) + if (loginItemWasUnregistered) { + const restoredState = await runInstalledMenubarLoginItemMaintenance( + targetPath, + previousBundleId, + 'register', + ) + if (!isRestoredMenubarLoginItemState(restoredState, loginItemState)) { + throw new Error('CodeBurn could not restore the previous Login Item identity.') + } + loginItemWasUnregistered = false + } + if (wasRunning) await runCommand('/usr/bin/open', [targetPath]) + }, + launch: async () => { + console.log('Launching CodeBurn Menubar...') + if (options.repairPlacement) { + console.log('macOS may ask for CodeBurn permissions again because placement repair uses a new local identity.') + } else if (options.resetPlacement) { + console.log('Restored the official CodeBurn Menubar bundle identity.') + } + for (let attempt = 1; attempt <= 2; attempt++) { + try { + await runCommand('/usr/bin/open', [targetPath]) + launched = true + return + } catch (error) { + if (attempt === 1) { + await new Promise(resolve => setTimeout(resolve, 250)) + continue + } + console.warn( + `CodeBurn Menubar was installed at ${targetPath}, but macOS did not launch it. ` + + `Open that app manually. ${String(error)}`, + ) + } + } + }, + }) + } catch (error) { + if (loginItemWasUnregistered) { + try { + const restoredState = await runInstalledMenubarLoginItemMaintenance( + targetPath, + previousBundleId, + 'register', + ) + if (!isRestoredMenubarLoginItemState(restoredState, loginItemState)) { + throw new Error(`Unexpected state ${restoredState}`) + } + loginItemWasUnregistered = false + } catch (restoreError) { + throw new AggregateError( + [error, restoreError], + 'Menubar replacement failed and its previous Login Item could not be restored.', + ) + } + } + throw error + } + await preferenceMigration.commit() + return { installedPath: targetPath, launched } } finally { await rm(stagingDir, { recursive: true, force: true }) } diff --git a/tests/menubar-installer-windows.test.ts b/tests/menubar-installer-windows.test.ts index 0ac4c55ea..641f7728f 100644 --- a/tests/menubar-installer-windows.test.ts +++ b/tests/menubar-installer-windows.test.ts @@ -203,6 +203,23 @@ describe('installMenubarApp on windows', () => { expect(installerCalls).toHaveLength(1) }) + it('rejects macOS placement repair flags before touching the Windows installer', async () => { + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + repairPlacement: true, + windows: hooks(), + })).rejects.toThrow(/only available for the macOS menu bar app/) + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + resetPlacement: true, + windows: hooks(), + })).rejects.toThrow(/only available for the macOS menu bar app/) + expect(installerCalls).toEqual([]) + expect(launched).toEqual([]) + }) + it('aborts on a checksum mismatch without running the installer', async () => { await expect(installMenubarApp({ platform: 'win32', diff --git a/tests/menubar-installer.test.ts b/tests/menubar-installer.test.ts index 6e9b9f41c..eebf58c50 100644 --- a/tests/menubar-installer.test.ts +++ b/tests/menubar-installer.test.ts @@ -1,28 +1,132 @@ import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { buildPersistentCodeburnLookupPath, downloadToFile, formatGitHubReleaseLookupError, + createMenubarPlacementRecoveryBundleId, + isSupportedMenubarBundleId, + isAdHocMenubarSignatureDetails, + isMissingDefaultsDomainError, + installedMenubarSupportsLoginItemMaintenance, + isRestoredMenubarLoginItemState, + planMenubarLoginItemMigration, + selectMenubarBundleId, isMissingDirectAssetError, resolveLatestMenubarReleaseAssets, resolveMenubarReleaseAssets, resolvePersistentCodeburnPathFromWhichOutput, resolveProxyUrlForUrl, + resolveActiveMenubarBundleId, + reidentifyMenubarBundleForPlacementRecovery, + migrateMenubarPreferencesForPlacementRecovery, + prepareMenubarPreferenceMigration, + runInstalledMenubarLoginItemMaintenance, + replaceMenubarBundleWithRollback, resolveVersionedMenubarReleaseAssets, shouldFallbackToReleaseApi, verifyChecksum, type ReleaseResponse, } from '../src/menubar-installer.js' +const execFileAsync = promisify(execFile) + function asset(name: string) { return { name, browser_download_url: `https://example.test/${name}` } } describe('resolveMenubarReleaseAssets', () => { + it('fails closed for an ambiguous legacy approval state', () => { + expect(planMenubarLoginItemMigration('disabled')).toEqual({ + preserveDisable: true, + retirePrevious: true, + restoreOnFailure: false, + }) + }) + + it('requires rollback to restore the source consent level exactly', () => { + expect(isRestoredMenubarLoginItemState('registered', 'registered')).toBe(true) + expect(isRestoredMenubarLoginItemState('disabled', 'registered')).toBe(false) + }) + + it('distinguishes an absent defaults domain from an operational export failure', () => { + expect(isMissingDefaultsDomainError(new Error('Domain org.example does not exist.'))).toBe(true) + expect(isMissingDefaultsDomainError(new Error('Domain (org.example) not found.'))).toBe(true) + expect(isMissingDefaultsDomainError(new Error( + 'The domain/default pair of (org.example, MissingKey) does not exist', + ))).toBe(true) + expect(isMissingDefaultsDomainError(new Error('defaults exited with status 1: permission denied'))).toBe(false) + }) + + it('accepts only the canonical or namespaced placement-recovery bundle ids', () => { + expect(isSupportedMenubarBundleId('org.agentseal.codeburn-menubar')).toBe(true) + expect(isSupportedMenubarBundleId( + 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + )).toBe(true) + expect(isSupportedMenubarBundleId( + 'org.agentseal.codeburn-menubar.recovery.not-random' + )).toBe(false) + expect(isSupportedMenubarBundleId('org.attacker.codeburn-menubar')).toBe(false) + }) + + it('creates a stable recovery namespace from validated entropy', () => { + expect(createMenubarPlacementRecoveryBundleId('0123456789abcdef')).toBe( + 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + ) + expect(() => createMenubarPlacementRecoveryBundleId('../escape')).toThrow(/16 lowercase hex/) + }) + + it('persists repaired identity across updates and rotates only on explicit repair', () => { + const repaired = 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + expect(selectMenubarBundleId({ persistedBundleId: repaired })).toBe(repaired) + expect(selectMenubarBundleId({ + repairPlacement: true, + persistedBundleId: repaired, + recoverySuffix: 'fedcba9876543210', + })).toBe('org.agentseal.codeburn-menubar.recovery.fedcba9876543210') + expect(selectMenubarBundleId({ persistedBundleId: 'org.attacker.injected' })).toBe( + 'org.agentseal.codeburn-menubar' + ) + expect(selectMenubarBundleId({ + resetPlacement: true, + persistedBundleId: repaired, + })).toBe('org.agentseal.codeburn-menubar') + expect(() => selectMenubarBundleId({ + repairPlacement: true, + resetPlacement: true, + })).toThrow(/cannot be used together/) + }) + + it('uses the installed bundle identity when the persistence sidecar is missing or stale', () => { + const repaired = createMenubarPlacementRecoveryBundleId('abcdefabcdefabcd') + expect(resolveActiveMenubarBundleId({ installedBundleId: repaired })).toBe(repaired) + expect(resolveActiveMenubarBundleId({ + installedBundleId: 'org.agentseal.codeburn-menubar', + persistedBundleId: repaired, + })).toBe('org.agentseal.codeburn-menubar') + expect(() => resolveActiveMenubarBundleId({ + installedBundleId: 'org.attacker.injected', + })).toThrow(/unsupported installed menubar bundle id/) + }) + + it('allows re-identification only for the ad-hoc release signature', () => { + expect(isAdHocMenubarSignatureDetails(` +CodeDirectory v=20400 flags=0x2(adhoc) +Signature=adhoc +TeamIdentifier=not set +`)).toBe(true) + expect(isAdHocMenubarSignatureDetails(` +Authority=Developer ID Application: AgentSeal +TeamIdentifier=ABCDE12345 +Runtime Version=26.0.0 +`)).toBe(false) + }) + it('ignores dev zips and pairs the checksum with the versioned zip', () => { const release: ReleaseResponse = { tag_name: 'mac-v0.9.8', @@ -437,3 +541,338 @@ describe('release asset download retry', () => { expect((captured as Error).cause).toBe(original) }) }) + +describe.runIf(process.platform === 'darwin')('placement repair bundle re-identification', () => { + let sandbox: string + let appPath: string + + beforeEach(async () => { + sandbox = await mkdtemp(join(tmpdir(), 'menubar-reidentify-')) + appPath = join(sandbox, 'CodeBurnMenubar.app') + const contents = join(appPath, 'Contents') + const executable = join(contents, 'MacOS', 'CodeBurnMenubar') + await mkdir(join(contents, 'MacOS'), { recursive: true }) + await writeFile(executable, '#!/bin/sh\nexit 0\n') + await chmod(executable, 0o755) + await writeFile(join(contents, 'Info.plist'), ` + + +CFBundleExecutableCodeBurnMenubar +CFBundleIdentifierorg.agentseal.codeburn-menubar +CFBundlePackageTypeAPPL +\n`) + await execFileAsync('/usr/bin/codesign', ['--force', '--sign', '-', '--timestamp=none', appPath]) + }) + + afterEach(async () => { + await rm(sandbox, { recursive: true, force: true }) + }) + + it('changes only to a supported recovery id and leaves a valid signed bundle', async () => { + const recoveryID = 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + await reidentifyMenubarBundleForPlacementRecovery(appPath, recoveryID) + + const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents', 'Info.plist'), + ]) + expect(stdout.trim()).toBe(recoveryID) + await expect(execFileAsync('/usr/bin/codesign', [ + '--verify', '--deep', '--strict', appPath, + ])).resolves.toBeDefined() + await expect(reidentifyMenubarBundleForPlacementRecovery( + appPath, + 'org.attacker.injected', + )).rejects.toThrow(/unsupported recovery bundle id/) + }) + + it('runs bounded Login Item maintenance only for a capable exact installed identity', async () => { + const executableDir = join(appPath, 'Contents', 'MacOS') + const executablePath = join(executableDir, 'CodeBurnMenubar') + await mkdir(executableDir, { recursive: true }) + await writeFile(executablePath, `#!/bin/sh +case "$1" in + --codeburn-login-item-status) echo disabled ;; + --codeburn-unregister-login-item) echo disabled ;; + --codeburn-register-login-item) echo registered ;; + *) exit 2 ;; +esac +`) + await chmod(executablePath, 0o755) + + expect(await installedMenubarSupportsLoginItemMaintenance(appPath)).toBe(false) + await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', 'Add :CodeBurnLoginItemMaintenanceVersion integer 1', + join(appPath, 'Contents', 'Info.plist'), + ]) + expect(await installedMenubarSupportsLoginItemMaintenance(appPath)).toBe(true) + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'status', + )).resolves.toBe('disabled') + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'unregister', + )).resolves.toBe('disabled') + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'register', + )).resolves.toBe('registered') + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + createMenubarPlacementRecoveryBundleId('9999999999999999'), + 'status', + )).rejects.toThrow(/identity is org\.agentseal\.codeburn-menubar/) + }) + + it('terminates a non-responsive Login Item maintenance command', async () => { + const executableDir = join(appPath, 'Contents', 'MacOS') + const executablePath = join(executableDir, 'CodeBurnMenubar') + await mkdir(executableDir, { recursive: true }) + await writeFile(executablePath, '#!/bin/sh\nsleep 2\n') + await chmod(executablePath, 0o755) + await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', 'Add :CodeBurnLoginItemMaintenanceVersion integer 1', + join(appPath, 'Contents', 'Info.plist'), + ]) + + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'status', + { timeoutMs: 25 }, + )).rejects.toThrow(/timed out after 25ms/) + }) + + it('migrates preferences between recovery identities without touching other domains', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('1111111111111111') + const targetID = createMenubarPlacementRecoveryBundleId('2222222222222222') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'CodeBurnDisplayMetric', '-string', 'tokens', + ]) + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'codeburn.loginItemRegistered', '-bool', 'true', + ]) + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'NSStatusItem Legacy Ghost Position', '-string', 'poisoned', + ]) + await migrateMenubarPreferencesForPlacementRecovery(sourceID, targetID, sandbox) + + const { stdout } = await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ]) + expect(stdout.trim()).toBe('tokens') + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'codeburn.loginItemRegistered', + ])).rejects.toBeDefined() + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'NSStatusItem Legacy Ghost Position', + ])).rejects.toBeDefined() + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('restores the target preference domain when a prepared migration rolls back', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('3333333333333333') + const targetID = createMenubarPlacementRecoveryBundleId('4444444444444444') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'CodeBurnDisplayMetric', '-string', 'tokens', + ]) + await execFileAsync('/usr/bin/defaults', [ + 'write', targetID, 'CodeBurnDisplayMetric', '-string', 'cost', + ]) + + const migration = await prepareMenubarPreferenceMigration(sourceID, targetID, sandbox) + await migration.apply() + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ])).stdout.trim()).toBe('tokens') + + await migration.rollback() + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ])).stdout.trim()).toBe('cost') + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('carries an explicit Login Items disable choice to a replacement identity', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('7777777777777777') + const targetID = createMenubarPlacementRecoveryBundleId('8888888888888888') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'codeburn.loginItemRegistered', '-bool', 'true', + ]) + const migration = await prepareMenubarPreferenceMigration( + sourceID, + targetID, + sandbox, + { preserveLoginDisable: true }, + ) + await migration.apply() + + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'codeburn.loginItemRegistered', + ])).stdout.trim()).toBe('1') + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('sanitizes target placement and preserves disable without a source domain', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('aaaaaaaaaaaaaaaa') + const targetID = createMenubarPlacementRecoveryBundleId('bbbbbbbbbbbbbbbb') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', targetID, 'NSStatusItem Legacy Ghost Position', '-string', 'poisoned', + ]) + const migration = await prepareMenubarPreferenceMigration( + sourceID, + targetID, + sandbox, + { preserveLoginDisable: true }, + ) + await migration.apply() + + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'codeburn.loginItemRegistered', + ])).stdout.trim()).toBe('1') + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'NSStatusItem Legacy Ghost Position', + ])).rejects.toBeDefined() + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('removes a superseded recovery preference domain only after commit', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('5555555555555555') + const targetID = createMenubarPlacementRecoveryBundleId('6666666666666666') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'CodeBurnDisplayMetric', '-string', 'tokens', + ]) + + const migration = await prepareMenubarPreferenceMigration(sourceID, targetID, sandbox) + await migration.apply() + await migration.commit() + + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', sourceID, 'CodeBurnDisplayMetric', + ])).rejects.toBeDefined() + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ])).stdout.trim()).toBe('tokens') + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('restores the previous app and state when replacement cannot commit', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + await mkdir(targetPath) + await mkdir(stagedPath) + await writeFile(join(targetPath, 'marker'), 'old') + await writeFile(join(stagedPath, 'marker'), 'new') + let restoredState = false + + await expect(replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => { throw new Error('disk full') }, + restoreState: async () => { restoredState = true }, + launch: async () => { throw new Error('must not launch') }, + })).rejects.toThrow(/disk full/) + + expect(await readFile(join(targetPath, 'marker'), 'utf8')).toBe('old') + expect(restoredState).toBe(true) + expect((await readdir(sandbox)).filter(name => name.includes('backup'))).toEqual([]) + }) + + it('commits and launches the new app without leaving a backup bundle', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + await mkdir(targetPath) + await mkdir(stagedPath) + await writeFile(join(targetPath, 'marker'), 'old') + await writeFile(join(stagedPath, 'marker'), 'new') + let launched = false + + await replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => {}, + restoreState: async () => {}, + launch: async () => { launched = true }, + }) + + expect(await readFile(join(targetPath, 'marker'), 'utf8')).toBe('new') + expect(launched).toBe(true) + expect((await readdir(sandbox)).filter(name => name.includes('backup'))).toEqual([]) + }) + + it('keeps the committed new app when only the launch request fails', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + await mkdir(targetPath) + await mkdir(stagedPath) + await writeFile(join(targetPath, 'marker'), 'old') + await writeFile(join(stagedPath, 'marker'), 'new') + let restoredState = false + + await expect(replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => {}, + restoreState: async () => { restoredState = true }, + launch: async () => { throw new Error('launch request failed') }, + })).rejects.toThrow(/launch request failed/) + + expect(await readFile(join(targetPath, 'marker'), 'utf8')).toBe('new') + expect(restoredState).toBe(false) + }) + + it('restores identity state even when the previous bundle cannot be restored', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + const backupPath = `${targetPath}.codeburn-backup-${process.pid}` + await mkdir(targetPath) + await mkdir(stagedPath) + let restoredState = false + + await expect(replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => { + await rm(backupPath, { recursive: true, force: true }) + throw new Error('identity commit failed') + }, + restoreState: async () => { restoredState = true }, + launch: async () => { throw new Error('must not launch') }, + })).rejects.toThrow(/could not be fully restored/) + + expect(restoredState).toBe(true) + }) +}) From d89ce21878978d7a5066d83359713f2694f03b1d Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:09:45 +0530 Subject: [PATCH 2/3] fix(menubar): recover parked status item --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 147 +++++++++++++++++- .../StatusItemPlacementPolicy.swift | 130 ++++++++++++++++ .../StatusItemPlacementPolicyTests.swift | 136 ++++++++++++++++ 3 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index f183ff196..fac3f210d 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 000000000..a78786a6f --- /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 000000000..159bb5e7d --- /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") + } +} From c112ad07ec47d472c525e5e9690924cd062ebd42 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:17:15 +0530 Subject: [PATCH 3/3] refactor(menubar): split installer repair from status recovery --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 147 +----------------- .../StatusItemPlacementPolicy.swift | 130 ---------------- .../StatusItemPlacementPolicyTests.swift | 136 ---------------- 3 files changed, 2 insertions(+), 411 deletions(-) delete mode 100644 mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift delete mode 100644 mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index a5a8cc24d..12f0dbaf2 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -54,7 +54,6 @@ 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 @@ -92,7 +91,6 @@ 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 @@ -229,7 +227,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM queue: .main ) { [weak self] _ in Task { @MainActor in - self?.stopStatusItemPlacementRecovery() self?.prepareRefreshPipelineForSleep() } } @@ -246,7 +243,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM Task { @MainActor in self?.displayAsleep = false self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake") - self?.startStatusItemPlacementRecovery() } } @@ -258,7 +254,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM Task { @MainActor in self?.displayAsleep = false self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake") - self?.startStatusItemPlacementRecovery() } } @@ -272,7 +267,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM ) { [weak self] _ in Task { @MainActor in self?.displayAsleep = true - self?.stopStatusItemPlacementRecovery() } } } @@ -1037,17 +1031,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } private func setupStatusItem() { - 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 - } + statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth) + guard let button = statusItem.button else { 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 @@ -1089,137 +1074,9 @@ 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; run `codeburn menubar --repair-placement`") - 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; run `codeburn menubar --repair-placement`") - 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; run `codeburn menubar --repair-placement`") - case .parked: - NSLog("CodeBurn: status item stayed parked without a menu-bar reveal; run `codeburn menubar --repair-placement`") - } } } - 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 deleted file mode 100644 index a78786a6f..000000000 --- a/mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 159bb5e7d..000000000 --- a/mac/Tests/CodeBurnMenubarTests/StatusItemPlacementPolicyTests.swift +++ /dev/null @@ -1,136 +0,0 @@ -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") - } -}