diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4ecefc74..f993b6d495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.67.1 — Unreleased +### Added + +- Menu bar: add opt-in, bounded startup diagnostics for status-item creation and Control Center hosting investigations (#3377). + ### Changed - Settings: simplify menu bar layout controls while keeping token-removal instructions in the section footer (#3999). Thanks @elijahfriedman! diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index dd20a0b07d..11426993b2 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -434,6 +434,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillFinishLaunching(_ notification: Notification) { + MenuBarStatusItemWindowProbe.trace("will-finish-launching") self.configureAppIconForMacOSVersion() // The SwiftUI `Settings` scene is an empty placeholder; macOS otherwise presents it at launch. self.placeholderSettingsWindowGuard.start() @@ -445,6 +446,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { + MenuBarStatusItemWindowProbe.trace("did-finish-launching") self.dockIconController.start() self.memoryPressureMonitor.start() #if DEBUG diff --git a/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift index 174f718de7..02256a3a8f 100644 --- a/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift +++ b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift @@ -37,10 +37,63 @@ struct MenuBarStatusItemWindowSnapshot: Equatable, CustomStringConvertible { } enum MenuBarStatusItemWindowProbe { + @MainActor static let diagnosticsEnabled = ProcessInfo.processInfo + .environment["CODEXBAR_STATUS_ITEM_DIAGNOSTICS"] == "1" + @MainActor private static var diagnosticRecords = 0 + + /// Opt-in, bounded stdout trace; never includes window titles, accounts, or provider content. + @MainActor static func trace(_ stage: String, item: NSStatusItem? = nil, evidence: String = "") { + guard self.diagnosticsEnabled, self.diagnosticRecords < 128 else { return } + self.diagnosticRecords += 1 + let name = item?.autosaveName ?? "" + let window = item?.button?.window + let records = self.windowInfo() + let receipt: [String: Any] = [ + "stage": stage, "sequence": self.diagnosticRecords, + "uptime": ProcessInfo.processInfo.systemUptime, + "bundle": Bundle.main.bundleIdentifier ?? "unknown", + "git": Bundle.main.object(forInfoDictionaryKey: "CodexGitCommit") as? String ?? "unknown", + "mainThread": Thread.isMainThread, "running": NSApp?.isRunning ?? false, + "activationPolicy": NSApp?.activationPolicy().rawValue ?? -1, + "identity": name, "visible": item?.isVisible ?? false, "length": item?.length ?? 0, "evidence": evidence, + "buttonWindow": window?.windowNumber ?? -1, + "buttonFrame": NSStringFromRect(item?.button?.frame ?? .zero), + "windowFrame": NSStringFromRect(window?.frame ?? .zero), + "screens": NSScreen.screens.map { NSStringFromRect($0.frame) }, + "placeholderWindows": NSApp?.windows.filter { + $0.identifier?.rawValue.contains(PlaceholderSettingsWindowDecision.swiftUISettingsNameFragment) == true + }.map { ["number": $0.windowNumber, "frame": NSStringFromRect($0.frame), "visible": $0.isVisible] } ?? [], + "windowQuerySucceeded": records != nil, + "controlCenter": self.hostingDiagnostics(name: name, windowInfo: records ?? []), + ] + guard let data = try? JSONSerialization.data(withJSONObject: receipt, options: [.sortedKeys]) else { return } + FileHandle.standardOutput.write(data + Data([0x0A])) + } + + static func hostingDiagnostics(name: String, windowInfo: [[String: Any]]) -> [String: Any] { + let windows = windowInfo.filter { + ($0[kCGWindowLayer as String] as? Int) == 25 + && ["Control Center", "Control Centre"].contains($0[kCGWindowOwnerName as String] as? String ?? "") + } + let matches = windows.filter { !name.isEmpty && ($0[kCGWindowName as String] as? String) == name } + return [ + "layer25Count": windows.count, + "layer25Numbers": windows.compactMap { $0[kCGWindowNumber as String] as? Int }.sorted(), + "unnamedCount": windows.filter { ($0[kCGWindowName as String] as? String ?? "").isEmpty }.count, + "namedMatches": matches.map { record in + [ + "number": record[kCGWindowNumber as String] as? Int ?? -1, + "bounds": NSStringFromRect(self.bounds(record[kCGWindowBounds as String]) ?? .zero), + "onscreen": (record[kCGWindowIsOnscreen as String] as? Bool) ?? false, + ] as [String: Any] + }, + ] + } + static func snapshots(matching names: Set) -> [MenuBarStatusItemWindowSnapshot] { self.snapshots( matching: names, - windowInfo: self.windowInfo(), + windowInfo: self.windowInfo() ?? [], screenFrames: NSScreen.screens.map(\.frame)) } @@ -61,11 +114,8 @@ enum MenuBarStatusItemWindowProbe { } } - private static func windowInfo() -> [[String: Any]] { - guard let windows = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] else { - return [] - } - return windows + private static func windowInfo() -> [[String: Any]]? { + CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] } private static func snapshot( diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index 4fa01a7701..09ab71b8bf 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -176,19 +176,6 @@ enum MenuBarVisibilityWatcher { detectTahoeBlockedStatusItem: detectTahoeBlockedStatusItem) } - static func shouldRefreshScreenChangePlacement( - previousScreenCount _: Int, - currentScreenCount _: Int, - snapshots: [StatusItemVisibilitySnapshot]) - -> Bool - { - self.hasAnyDisplacedVisibleSnapshot(snapshots) - } - - static func shouldAttemptScreenChangeRecovery(snapshots: [StatusItemVisibilitySnapshot]) -> Bool { - self.hasAnyBlockedVisibleSnapshot(snapshots) - } - static func shouldShowGuidance(defaults: UserDefaults, now: Date = Date()) -> Bool { guard defaults.bool(forKey: self.guidanceShownKey) else { return true } let lastShownAt = defaults.double(forKey: self.guidanceLastShownAtKey) @@ -227,6 +214,12 @@ enum MenuBarVisibilityWatcher { extension StatusItemController { func scheduleStartupStatusItemVisibilityCheck(appLaunchedAt: Date = Date()) { guard !SettingsStore.isRunningTests else { return } + self.traceStatusItems("rendered") + if MenuBarStatusItemWindowProbe.diagnosticsEnabled { + DispatchQueue.main.asyncAfter(deadline: .now() + 15) { [weak self] in + self?.traceStatusItems("settled") + } + } DispatchQueue.main.asyncAfter(deadline: .now() + MenuBarVisibilityWatcher.startupCheckDelay) { [weak self] in Task { @MainActor [weak self] in self?.checkStartupStatusItemVisibility(appLaunchedAt: appLaunchedAt) @@ -235,77 +228,43 @@ extension StatusItemController { } private func checkStartupStatusItemVisibility(appLaunchedAt: Date, now: Date = Date()) { - 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, - detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) - else { - return - } - + self.traceStatusItems("startup-check") + guard let metadata = self.startupRecoveryMetadata(appLaunchedAt: appLaunchedAt, now: now) else { return } self.menuLogger.error( - "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), - ]) + "Status item failed to materialize or remained detached; recreating status items", metadata: metadata) self.recreateStatusItemsForVisibilityRecovery() - - 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, - detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) - else { + guard let recovered = self.startupRecoveryMetadata(appLaunchedAt: appLaunchedAt, now: now) else { self.menuLogger.info( "Status item materialized after recreation", - metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")]) + metadata: self.statusItemVisibilityMetadata()) return } - - self.menuLogger.error( - "Status item still unavailable after recreation", - metadata: [ - "snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "), - "evidence": recoveredEvidence.map(\.description).joined(separator: " | "), - "windows": self.statusItemWindowDiagnosticsDescription(recoveredWindowSnapshots), - ]) + self.menuLogger.error("Status item still unavailable after recreation", metadata: recovered) guard #available(macOS 26.0, *), MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now) - else { - return - } + else { return } MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults, now: now) } - @objc func handleScreenParametersDidChange(_: Notification) { - let previousScreenCount = max( - self.pendingScreenChangePreviousCount ?? self.lastKnownScreenCount, - self.lastKnownScreenCount) - let currentScreenCount = NSScreen.screens.count - self.pendingScreenChangePreviousCount = previousScreenCount - self.lastKnownScreenCount = currentScreenCount - self.scheduleScreenChangeStatusItemVisibilityCheck( - previousScreenCount: previousScreenCount, - currentScreenCount: currentScreenCount) + private func startupRecoveryMetadata(appLaunchedAt: Date, now: Date) -> [String: String]? { + let evidence = self.startupStatusItemVisibilityEvidence() + let windowSnapshots = self.statusItemWindowSnapshots() + guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: appLaunchedAt, + now: now, + snapshots: evidence.map(\.snapshot), + evidence: evidence, + windowSnapshots: windowSnapshots, + detectTahoeBlockedStatusItem: self.canDetectTahoeBlockedStatusItem) + else { return nil } + return [ + "snapshots": evidence.map(\.snapshot.description).joined(separator: " | "), + "evidence": evidence.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(windowSnapshots), + ] } - private func scheduleScreenChangeStatusItemVisibilityCheck( - previousScreenCount: Int, - currentScreenCount: Int) - { + @objc func handleScreenParametersDidChange(_: Notification) { guard !SettingsStore.isRunningTests else { return } self.screenChangeVisibilityTask?.cancel() self.screenChangeVisibilityTask = Task { @MainActor [weak self] in @@ -314,49 +273,39 @@ extension StatusItemController { } catch { return } - self?.checkScreenChangeStatusItemVisibility( - previousScreenCount: previousScreenCount, - currentScreenCount: currentScreenCount) + self?.checkScreenChangeStatusItemVisibility() } } - private func checkScreenChangeStatusItemVisibility(previousScreenCount: Int, currentScreenCount: Int) { - self.pendingScreenChangePreviousCount = nil - let settledCurrentScreenCount = NSScreen.screens.count - self.lastKnownScreenCount = settledCurrentScreenCount + private func checkScreenChangeStatusItemVisibility() { let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) - if MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: snapshots) { + if MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(snapshots) { self.menuLogger.error( - "Display configuration changed; recreating status items", - metadata: [ - "previousScreenCount": "\(previousScreenCount)", - "currentScreenCount": "\(settledCurrentScreenCount)", - "capturedScreenCount": "\(currentScreenCount)", - "snapshots": snapshots.map(\.description).joined(separator: " | "), - "windows": self.statusItemWindowDiagnosticsDescription(), - ]) + "Display configuration changed; recreating status items", metadata: self.statusItemVisibilityMetadata()) self.recreateStatusItemsForVisibilityRecovery() self.schedulePostScreenChangeRecoveryVerification(attempt: 1) - return + } else if MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot(snapshots) { + self.menuLogger.info( + "Display configuration changed; refreshing existing status items", + metadata: self.statusItemVisibilityMetadata()) + self.refreshExistingStatusItemsForVisibilityRecovery() } + } - guard MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( - previousScreenCount: previousScreenCount, - currentScreenCount: settledCurrentScreenCount, - snapshots: snapshots) - else { - return - } + private func statusItemVisibilityMetadata() -> [String: String] { + [ + "snapshots": MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + .map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + "screenCount": "\(NSScreen.screens.count)", + ] + } - self.menuLogger.info( - "Display configuration changed; refreshing existing status items", - metadata: [ - "previousScreenCount": "\(previousScreenCount)", - "currentScreenCount": "\(settledCurrentScreenCount)", - "capturedScreenCount": "\(currentScreenCount)", - "snapshots": snapshots.map(\.description).joined(separator: " | "), - ]) - self.refreshExistingStatusItemsForVisibilityRecovery() + private func traceStatusItems(_ stage: String) { + guard MenuBarStatusItemWindowProbe.diagnosticsEnabled else { return } + for (item, evidence) in zip(self.startupVisibilityStatusItems, self.startupStatusItemVisibilityEvidence()) { + MenuBarStatusItemWindowProbe.trace(stage, item: item, evidence: evidence.description) + } } private func schedulePostScreenChangeRecoveryVerification(attempt: Int) { diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index 9919ce0135..9873fddfcd 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -43,7 +43,6 @@ extension StatusItemController { self.menuCardRefreshMonitor.resetManualRefresh() self.screenChangeVisibilityTask?.cancel() self.screenChangeVisibilityTask = nil - self.pendingScreenChangePreviousCount = nil self.animationDriver?.stop() self.animationDriver = nil self.animationPhase = 0 diff --git a/Sources/CodexBar/StatusItemController+StatusItemVending.swift b/Sources/CodexBar/StatusItemController+StatusItemVending.swift index 0d483a4a04..b0c9672643 100644 --- a/Sources/CodexBar/StatusItemController+StatusItemVending.swift +++ b/Sources/CodexBar/StatusItemController+StatusItemVending.swift @@ -25,13 +25,16 @@ extension StatusItemController { legacyDefaultItemIndex: legacyDefaultItemIndex) // AppKit has no named factory: keep the item zero-width until its stable identity is attached. let item = create(0) + MenuBarStatusItemWindowProbe.trace("created", item: item as? NSStatusItem) // Registration must see the stable identity before its callback can re-enter setup. item.autosaveName = identity.autosaveName + MenuBarStatusItemWindowProbe.trace("named", item: item as? NSStatusItem) onCreated?(item) // Reentrant registration may have already rendered a custom width. if item.length == 0 { item.length = NSStatusItem.variableLength } + MenuBarStatusItemWindowProbe.trace("sized", item: item as? NSStatusItem) if let button = item.button { let title = self.statusItemAccessibilityTitle( isDebugApp: self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier)) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 56f150df81..8259ddf926 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -317,8 +317,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastObservedStoreIconWorkSignature: String? var iconPerfRefreshCycleMetrics: IconPerfRefreshCycleMetrics? var iconPerfUpdatePassActive = false - var lastKnownScreenCount: Int - var pendingScreenChangePreviousCount: Int? var screenChangeVisibilityTask: Task? let loginLogger = CodexBarLog.logger(LogCategories.login) let menuLogger = CodexBarLog.logger(LogCategories.app) @@ -417,7 +415,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin identity: .merged, defaults: settings.userDefaults, legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex) - self.lastKnownScreenCount = NSScreen.screens.count // Status items for individual providers are now created lazily in updateVisibility() super.init() if !repairedStatusItemVisibilityKeys.isEmpty { @@ -920,7 +917,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.loginTask?.cancel() self.overviewSharePresentation.task?.cancel() self.screenChangeVisibilityTask?.cancel() - self.pendingScreenChangePreviousCount = nil NotificationCenter.default.removeObserver(self) } } diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index a810a87cab..bd6c2a685a 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -555,10 +555,7 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( - previousScreenCount: 2, - currentScreenCount: 1, - snapshots: [healthy])) + #expect(!MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([healthy])) } @Test @@ -570,10 +567,7 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( - previousScreenCount: 2, - currentScreenCount: 1, - snapshots: [hidden])) + #expect(!MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([hidden])) } @Test @@ -585,7 +579,7 @@ struct MenuBarVisibilityWatcherTests { hasScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: [blocked])) + #expect(MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot([blocked])) } @Test @@ -598,10 +592,7 @@ struct MenuBarVisibilityWatcherTests { isOnCurrentScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( - previousScreenCount: 2, - currentScreenCount: 1, - snapshots: [displaced])) + #expect(MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([displaced])) } @Test @@ -614,10 +605,7 @@ struct MenuBarVisibilityWatcherTests { isOnCurrentScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( - previousScreenCount: 2, - currentScreenCount: 1, - snapshots: [displaced])) + #expect(MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([displaced])) } @Test @@ -629,10 +617,7 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( - previousScreenCount: 1, - currentScreenCount: 2, - snapshots: [healthy])) + #expect(!MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([healthy])) } @Test @@ -645,10 +630,7 @@ struct MenuBarVisibilityWatcherTests { isOnCurrentScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( - previousScreenCount: 2, - currentScreenCount: 2, - snapshots: [displaced])) + #expect(MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([displaced])) } @Test diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index bbed90ced0..85125254c7 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2707,7 +2707,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController.swift", - line: 349, + line: 347, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/StatusItemHostingDiagnosticsTests.swift b/Tests/CodexBarTests/StatusItemHostingDiagnosticsTests.swift new file mode 100644 index 0000000000..c827aa36d6 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemHostingDiagnosticsTests.swift @@ -0,0 +1,64 @@ +import AppKit +import Testing +@testable import CodexBar + +struct StatusItemHostingDiagnosticsTests { + @Test + func `hosting trace excludes other apps and layers while retaining duplicate candidates`() throws { + let records = [ + self.window(number: 1), + self.window(number: 2, owner: "Control Centre"), + self.window(number: 3, owner: "Other App"), + self.window(number: 4, layer: 0), + self.window(number: 5, name: "unrelated private title"), + ] + let diagnostic = MenuBarStatusItemWindowProbe.hostingDiagnostics(name: "codexbar-merged", windowInfo: records) + #expect(diagnostic["layer25Count"] as? Int == 3) + #expect(diagnostic["layer25Numbers"] as? [Int] == [1, 2, 5]) + let matches = try #require(diagnostic["namedMatches"] as? [[String: Any]]) + #expect(matches.compactMap { $0["number"] as? Int } == [1, 2]) + #expect(matches.first?["bounds"] as? String == "{{20, 0}, {32, 24}}") + #expect(matches.first?["onscreen"] as? Bool == true) + let data = try JSONSerialization.data(withJSONObject: diagnostic) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(!json.contains("unrelated private title")) + } + + @Test + func `redacted names remain counted without inventing a named match`() { + var redacted = self.window(number: 1) + redacted.removeValue(forKey: kCGWindowName as String) + for name in ["", "codexbar-merged"] { + let diagnostic = MenuBarStatusItemWindowProbe.hostingDiagnostics(name: name, windowInfo: [redacted]) + #expect(diagnostic["layer25Count"] as? Int == 1) + #expect(diagnostic["unnamedCount"] as? Int == 1) + #expect((diagnostic["namedMatches"] as? [[String: Any]])?.isEmpty == true) + } + } + + @Test + func `missing server records alone do not change startup recovery`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, hasButton: true, hasWindow: true, hasScreen: true, buttonWidth: 32) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", expectsVisibility: true, visibilityDefault: true, snapshot: snapshot) + #expect(!MenuBarVisibilityWatcher.hasAnyStartupRecoveryCandidate( + snapshots: [snapshot], evidence: [evidence], windowSnapshots: [], detectTahoeBlockedStatusItem: true)) + } + + private func window( + number: Int, + owner: String = "Control Center", + layer: Int = 25, + name: String = "codexbar-merged") -> [String: Any] + { + [ + kCGWindowNumber as String: number, + kCGWindowOwnerName as String: owner, + kCGWindowLayer as String: layer, + kCGWindowName as String: name, + kCGWindowBounds as String: ["X": 20, "Y": 0, "Width": 32, "Height": 24], + kCGWindowIsOnscreen as String: true, + ] + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 5d81d5df56..e2d1c88075 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,6 +18,8 @@ read_when: ## Entry points - `CodexBarApp`: SwiftUI keepalive + Settings scene. - `AppDelegate`: wires status controller, Sparkle updater, notifications. +- Status items are created on the main actor from `applicationDidFinishLaunching`; see + [status-item startup diagnostics](status-item-diagnostics.md) for opt-in lifecycle and Control Center traces. ## Data flow - Background refresh → `UsageFetcher`/provider probes → `UsageStore` → menu/icon/widgets. diff --git a/docs/status-item-diagnostics.md b/docs/status-item-diagnostics.md new file mode 100644 index 0000000000..c06d0e0e59 --- /dev/null +++ b/docs/status-item-diagnostics.md @@ -0,0 +1,92 @@ +--- +summary: "Opt-in startup traces for missing Tahoe menu bar items." +read_when: + - Investigating missing status items or Control Center hosting + - Preparing a diagnostic build for issue 3377 +--- + +# Status-item startup diagnostics + +Set `CODEXBAR_STATUS_ITEM_DIAGNOSTICS=1` when launching a build containing this diagnostic. +It writes newline-delimited JSON to stdout, capped at 128 records per process. No logging is enabled by default. +There is no new UI, permission request, or recovery behavior. The trace does not read credentials or capture pixels. +Normal application startup still runs normally, including configured provider refreshes. + +The stages are `will-finish-launching`, `did-finish-launching`, `created` (zero width), `named`, `sized`, +`rendered`, `startup-check` (about two seconds), and `settled` (about 15 seconds). Later creation/recovery can +produce additional creation records within the cap. Timestamps use system uptime. + +Each record includes the autosave identity, visibility, length, main-thread and application-running state, +activation policy (`0` regular, `1` accessory, `2` prohibited), AppKit button/window number and frames, +and screen frames. Rendered/check/settled records also include expected visibility and the `VisibleCC` default. +Recognized empty SwiftUI Settings windows are listed by number, geometry, and visibility, without titles. +This lets us identify a reported 900×450 window instead of inferring its purpose from its size. + +`controlCenter` contains the total layer-25 window count, sorted window numbers, unnamed-window count, +and all candidates matching the item's autosave name (number, Quartz bounds, onscreen state). +Other applications' window titles and provider/account content are never included. `windowQuerySucceeded=false` +means the window-server query failed, not that Control Center has zero windows. + +A named match is a **candidate**, not proof of hosting or rendered pixels: names can collide, window names can +be redacted, and AppKit and Quartz use different coordinate systems. An empty match must not trigger recovery +by itself. Compare the whole Control Center window-number set before creation and after settling, and compare +AppKit geometry to the candidate Quartz geometry. The trace itself queries AppKit and WindowServer, so it can +perturb a timing-sensitive failure; report if enabling it changes the symptom. + +## Reporter capture + +Use a maintainer-signed diagnostic app containing this change. For #3377, it must keep the production +`com.steipete.codexbar` identity and Developer ID; the ordinary debug package has a different bundle identity +and is only a separate control experiment. Do not reset preferences or move group containers for this capture. + +Quit the existing CodexBar instance once before starting the diagnostic copy, so two instances do not compete +for the same autosave name. Then run the supplied app executable directly (adjust the app path): + +```sh +umask 077 +CODEXBAR_STATUS_ITEM_DIAGNOSTICS=1 \ + /path/to/CodexBar.app/Contents/MacOS/CodexBar > "$HOME/Desktop/codexbar-status-items.jsonl" +``` + +Wait at least 20 seconds. Record whether the icon appeared, whether Bartender was running, and whether the +allow-list toggle remained on. Quit the diagnostic app normally or end this foreground run with Control-C, +then return to the installed app. No persistent environment or settings change is needed. +Inspect the JSON file before sharing it; send it with the build commit and the observed symptom. Do not attach +unrelated application logs. Repeat once with Bartender already quit only if the first capture is inconclusive. + +## Maintainer build recipe + +Build **debug only** from the diagnostic commit. A local control build can use +`CODEXBAR_SIGNING=identity ./Scripts/package_app.sh debug`; it does not relaunch the app, but its +`com.steipete.codexbar.debug` identity cannot establish a fix for the production identity. + +For the reporter's production-identity comparison, stage a copy of a current official signed bundle and replace +only its main executable and SwiftPM resources. The template must use this checkout's dependency versions. +Run these commands in the diagnostic checkout; no command launches or overwrites the installed app: + +```sh +swift build --configuration debug --jobs 2 --product CodexBar +diagnostic_bin_dir="$(swift build --configuration debug --show-bin-path)" +mkdir -p .build/control-center-diagnostic +codesign -d --entitlements :- /Applications/CodexBar.app \ + > .build/control-center-diagnostic/entitlements.plist 2>/dev/null +ditto /Applications/CodexBar.app .build/control-center-diagnostic/CodexBar.app +cp "$diagnostic_bin_dir/CodexBar" .build/control-center-diagnostic/CodexBar.app/Contents/MacOS/CodexBar +install_name_tool -add_rpath '@executable_path/../Frameworks' \ + .build/control-center-diagnostic/CodexBar.app/Contents/MacOS/CodexBar +for bundle in "$diagnostic_bin_dir"/*.bundle; do + ditto "$bundle" ".build/control-center-diagnostic/CodexBar.app/Contents/Resources/$(basename "$bundle")" +done +/usr/libexec/PlistBuddy -c "Set :CodexGitCommit $(git rev-parse HEAD)" \ + .build/control-center-diagnostic/CodexBar.app/Contents/Info.plist +codesign --force --timestamp --options runtime \ + --entitlements .build/control-center-diagnostic/entitlements.plist \ + --sign 'Developer ID Application: Peter Steinberger (Y5PE65HELJ)' \ + .build/control-center-diagnostic/CodexBar.app +codesign --verify --deep --strict .build/control-center-diagnostic/CodexBar.app +``` + +Use the normal [notarization instructions](RELEASING.md) for any externally delivered artifact, without +publishing a release or updating the appcast. Do not give a reporter an unsigned/ad-hoc replacement or tell them +to bypass Gatekeeper. The recipe requires the maintainer's signing identity; a reporter should receive the +finished signed/notarized artifact. Building and tracing are diagnostics, not a demonstrated fix for #3377.