From 6b620a265ce9a087e04d9551558c003a836c247b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 20:09:40 +0100 Subject: [PATCH] fix: detect Tahoe no-window menu block --- CHANGELOG.md | 1 + .../MenuBarStatusItemDefaultsRepair.swift | 15 +- .../CodexBar/MenuBarVisibilityWatcher.swift | 75 +++++++-- Sources/CodexBar/StatusItemController.swift | 11 +- .../MenuBarVisibilityWatcherTests.swift | 144 +++++++++++++++++- ...tusItemControllerSplitLifecycleTests.swift | 22 +++ 6 files changed, 253 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c089411c3..2de8db1bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Kimi K2: reject invalid and out-of-range numeric timestamps while preserving valid second and millisecond values. Thanks @joeVenner! - Kimi K2: reject non-finite credit and token values before they reach menus, CLI output, or widgets. Thanks @joeVenner! - Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! +- Menu bar: detect Tahoe's blocked no-window state at startup when macOS still records the icon as enabled, so affected users receive recovery guidance instead of a silently missing icon (#1945). Thanks @mmyyfirstb! ## 0.40.0 — 2026-07-05 diff --git a/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift index 6e7c6cafd6..6109e558b7 100644 --- a/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift +++ b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift @@ -27,19 +27,28 @@ enum MenuBarStatusItemDefaultsRepair { return itemName.hasPrefix(self.legacyAutosavePrefix) || self.isDefaultStatusItemName(itemName) } + static func visibilityDefault(defaults: UserDefaults, autosaveName: String) -> Bool? { + guard !autosaveName.isEmpty else { return nil } + return self.boolValue(defaults.object(forKey: self.visibilityPrefix + autosaveName)) + } + private static func isDefaultStatusItemName(_ itemName: String) -> Bool { guard itemName.hasPrefix("Item-") else { return false } return itemName.dropFirst("Item-".count).allSatisfy(\.isNumber) } private static func isFalse(_ value: Any?) -> Bool { + self.boolValue(value) == false + } + + private static func boolValue(_ value: Any?) -> Bool? { switch value { case let number as NSNumber: - !number.boolValue + number.boolValue case let bool as Bool: - !bool + bool default: - false + nil } } } diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index 2d637008b3..767f3e092e 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -34,6 +34,18 @@ extension StatusItemVisibilitySnapshot: CustomStringConvertible { } } +struct StatusItemStartupVisibilityEvidence: Equatable, CustomStringConvertible { + let autosaveName: String + let expectsVisibility: Bool + let visibilityDefault: Bool? + let snapshot: StatusItemVisibilitySnapshot + + var description: String { + "name=\(self.autosaveName),expected=\(self.expectsVisibility)," + + "default=\(self.visibilityDefault.map(String.init) ?? "unset"),\(self.snapshot)" + } +} + @MainActor func isStatusItemBlocked(_ item: NSStatusItem) -> Bool { MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item)) @@ -113,14 +125,20 @@ enum MenuBarVisibilityWatcher { static func hasAnyStartupRecoveryCandidate( snapshots: [StatusItemVisibilitySnapshot], + evidence: [StatusItemStartupVisibilityEvidence] = [], windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [], - detectTahoeBlockedProxy: Bool = false) + detectTahoeBlockedStatusItem: Bool = false) -> Bool { if self.hasAnyBlockedVisibleSnapshot(snapshots) { return true } - guard detectTahoeBlockedProxy, + if detectTahoeBlockedStatusItem, + self.hasAnyTahoeHiddenNoProxyCandidate(evidence: evidence, windowSnapshots: windowSnapshots) + { + return true + } + guard detectTahoeBlockedStatusItem, self.hasAnyDisplacedVisibleSnapshot(snapshots), windowSnapshots.contains(where: \.isTahoeBlockedProxy) else { @@ -129,6 +147,24 @@ enum MenuBarVisibilityWatcher { return true } + static func hasAnyTahoeHiddenNoProxyCandidate( + evidence: [StatusItemStartupVisibilityEvidence], + windowSnapshots: [MenuBarStatusItemWindowSnapshot]) + -> Bool + { + evidence.contains { item in + // Tahoe can destroy the Control Center scene while leaving its enabled default behind. + // Requiring both app intent and that default avoids treating ordinary hidden items as blocked. + item.expectsVisibility + && item.visibilityDefault == true + && !item.snapshot.isVisible + && !item.snapshot.hasWindow + && !windowSnapshots.contains { + $0.name == item.autosaveName && $0.isOnscreen && $0.isWithinDisplayBounds + } + } + } + @MainActor static func visibilitySnapshots(_ items: [NSStatusItem]) -> [StatusItemVisibilitySnapshot] { items.map { item in @@ -145,15 +181,17 @@ enum MenuBarVisibilityWatcher { appLaunchedAt: Date, now: Date = Date(), snapshots: [StatusItemVisibilitySnapshot], + evidence: [StatusItemStartupVisibilityEvidence] = [], windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [], - detectTahoeBlockedProxy: Bool = false) + detectTahoeBlockedStatusItem: Bool = false) -> Bool { guard now.timeIntervalSince(appLaunchedAt) <= self.startupFreshnessInterval else { return false } return self.hasAnyStartupRecoveryCandidate( snapshots: snapshots, + evidence: evidence, windowSnapshots: windowSnapshots, - detectTahoeBlockedProxy: detectTahoeBlockedProxy) + detectTahoeBlockedStatusItem: detectTahoeBlockedStatusItem) } static func shouldRefreshScreenChangePlacement( @@ -215,14 +253,16 @@ extension StatusItemController { } private func checkStartupStatusItemVisibility(appLaunchedAt: Date, now: Date = Date()) { - let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + let evidence = self.startupStatusItemVisibilityEvidence() + let snapshots = evidence.map(\.snapshot) let windowSnapshots = self.statusItemWindowSnapshots() guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( appLaunchedAt: appLaunchedAt, now: now, snapshots: snapshots, + evidence: evidence, windowSnapshots: windowSnapshots, - detectTahoeBlockedProxy: self.canDetectTahoeBlockedProxy) + detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) else { return } @@ -231,18 +271,21 @@ extension StatusItemController { "Status item failed to materialize or remained detached; recreating status items", metadata: [ "snapshots": snapshots.map(\.description).joined(separator: " | "), + "evidence": evidence.map(\.description).joined(separator: " | "), "windows": self.statusItemWindowDiagnosticsDescription(windowSnapshots), ]) self.recreateStatusItemsForVisibilityRecovery() - let recoveredSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + let recoveredEvidence = self.startupStatusItemVisibilityEvidence() + let recoveredSnapshots = recoveredEvidence.map(\.snapshot) let recoveredWindowSnapshots = self.statusItemWindowSnapshots() guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( appLaunchedAt: appLaunchedAt, now: now, snapshots: recoveredSnapshots, + evidence: recoveredEvidence, windowSnapshots: recoveredWindowSnapshots, - detectTahoeBlockedProxy: self.canDetectTahoeBlockedProxy) + detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) else { self.menuLogger.info( "Status item materialized after recreation", @@ -254,6 +297,7 @@ extension StatusItemController { "Status item still unavailable after recreation", metadata: [ "snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "), + "evidence": recoveredEvidence.map(\.description).joined(separator: " | "), "windows": self.statusItemWindowDiagnosticsDescription(recoveredWindowSnapshots), ]) guard #available(macOS 26.0, *), @@ -382,7 +426,20 @@ extension StatusItemController { [self.statusItem] + Array(self.statusItems.values) } - private var canDetectTahoeBlockedProxy: Bool { + private func startupStatusItemVisibilityEvidence() -> [StatusItemStartupVisibilityEvidence] { + self.startupVisibilityStatusItems.map { item in + let autosaveName = item.autosaveName ?? "" + return StatusItemStartupVisibilityEvidence( + autosaveName: autosaveName, + expectsVisibility: self.expectedVisibleStatusItemAutosaveNames.contains(autosaveName), + visibilityDefault: MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: self.settings.userDefaults, + autosaveName: autosaveName), + snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item)) + } + } + + private var canDetectTahoeBlockedStatusItem: Bool { if #available(macOS 26.0, *) { return true } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index d923515ec0..500ca28d79 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -130,6 +130,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let menuRefreshEnabledForController: Bool var statusItem: NSStatusItem var statusItems: [UsageProvider: NSStatusItem] = [:] + /// App intent survives Tahoe changing `NSStatusItem.isVisible` after Control Center rejects its scene. + var expectedVisibleStatusItemAutosaveNames: Set = [] var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuSession = MenuSessionCoordinator() @@ -743,8 +745,13 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let anyEnabled = !self.store.enabledProvidersForDisplay().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons + var expectedVisibleAutosaveNames: Set = [] if mergeIcons { - self.statusItem.isVisible = anyEnabled || force + let shouldBeVisible = anyEnabled || force + self.statusItem.isVisible = shouldBeVisible + if shouldBeVisible { + expectedVisibleAutosaveNames.insert(self.statusItem.autosaveName) + } for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } @@ -758,12 +765,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if shouldBeVisible { let item = self.lazyStatusItem(for: provider) item.isVisible = true + expectedVisibleAutosaveNames.insert(item.autosaveName) } else { self.removeProviderStatusItem(for: provider) } } self.attachMenus(fallback: fallback) } + self.expectedVisibleStatusItemAutosaveNames = expectedVisibleAutosaveNames self.updateAnimationState() self.updateBlinkingState() } diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index d61069995a..cd100a2b49 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -244,7 +244,147 @@ struct MenuBarVisibilityWatcherTests { now: launchedAt.addingTimeInterval(2), snapshots: [detachedProxy], windowSnapshots: [blockedWindow], - detectTahoeBlockedProxy: true)) + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery retries expected hidden Tahoe item with enabled default and no window`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery ignores hidden Tahoe item without app and defaults visibility agreement`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let intentionallyHidden = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: false, + visibilityDefault: true, + snapshot: hidden) + let disabledByUser = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: false, + snapshot: hidden) + let unknownDefault = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: nil, + snapshot: hidden) + + for evidence in [intentionallyHidden, disabledByUser, unknownDefault] { + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + detectTahoeBlockedStatusItem: true)) + } + } + + @Test + func `startup recovery ignores hidden item when matching window still exists`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + let existingWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 1500, y: 0, width: 76, height: 24), + isOnscreen: true, + displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329)) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + windowSnapshots: [existingWindow], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery ignores stale hidden matching window record`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + let staleWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 1500, y: 0, width: 76, height: 24), + isOnscreen: false, + displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329)) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + windowSnapshots: [staleWindow], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery keeps hidden no-window detection Tahoe only`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence])) } @Test @@ -262,7 +402,7 @@ struct MenuBarVisibilityWatcherTests { appLaunchedAt: launchedAt, now: launchedAt.addingTimeInterval(2), snapshots: [managed], - detectTahoeBlockedProxy: true)) + detectTahoeBlockedStatusItem: true)) } @Test diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index 6d4f605390..6b6cca6ff6 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -72,12 +72,14 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.statusItems[.codex] != nil) #expect(controller.statusItems[.claude] != nil) + #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-codex", "codexbar-claude"]) settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") #expect(controller.statusItem.isVisible == true) #expect(controller.statusItems.isEmpty) + #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-merged"]) } @Test @@ -413,6 +415,26 @@ struct StatusItemControllerSplitLifecycleTests { #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil) } + @Test + func `status item visibility default distinguishes enabled disabled and unset`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-visibility-default-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "NSStatusItem VisibleCC codexbar-merged") + defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-claude") + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-merged") == true) + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-claude") == false) + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-codex") == nil) + } + @Test func `non destructive visibility refresh preserves split provider status items`() throws { let (_, controller) = try self.makeSplitController()