Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/CodexbarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
62 changes: 56 additions & 6 deletions Sources/CodexBar/MenuBarStatusItemWindowProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve records for the lifecycle checkpoints

With merged icons disabled and 41 or more enabled first-party providers, the two launch traces plus three creation traces per status item exhaust this 128-record cap before scheduleStartupStatusItemVisibilityCheck emits rendered, startup-check, or settled. That leaves precisely the large multi-icon configurations most likely to expose hosting problems without the before/after lifecycle snapshots the diagnostic document asks reporters to compare; reserve capacity for those stages or stop emitting per-item creation records once the reserve is reached.

Useful? React with 👍 / 👎.

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<String>) -> [MenuBarStatusItemWindowSnapshot] {
self.snapshots(
matching: names,
windowInfo: self.windowInfo(),
windowInfo: self.windowInfo() ?? [],
screenFrames: NSScreen.screens.map(\.frame))
}

Expand All @@ -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(
Expand Down
157 changes: 53 additions & 104 deletions Sources/CodexBar/MenuBarVisibilityWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion Sources/CodexBar/StatusItemController+Shutdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/StatusItemController+StatusItemVending.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 0 additions & 4 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
let loginLogger = CodexBarLog.logger(LogCategories.login)
let menuLogger = CodexBarLog.logger(LogCategories.app)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading
Loading