Files
steipete--codexbar/Sources/CodexBar/MenuBarVisibilityWatcher.swift
T
2026-07-13 12:22:33 +08:00

465 lines
19 KiB
Swift

import AppKit
import Foundation
struct StatusItemVisibilitySnapshot: Equatable {
let isVisible: Bool
let hasButton: Bool
let hasWindow: Bool
let hasScreen: Bool
let isOnCurrentScreen: Bool
let buttonWidth: CGFloat
init(
isVisible: Bool,
hasButton: Bool,
hasWindow: Bool,
hasScreen: Bool,
isOnCurrentScreen: Bool = true,
buttonWidth: CGFloat)
{
self.isVisible = isVisible
self.hasButton = hasButton
self.hasWindow = hasWindow
self.hasScreen = hasScreen
self.isOnCurrentScreen = isOnCurrentScreen
self.buttonWidth = buttonWidth
}
}
extension StatusItemVisibilitySnapshot: CustomStringConvertible {
var description: String {
"visible=\(self.isVisible),button=\(self.hasButton),window=\(self.hasWindow),"
+ "screen=\(self.hasScreen),currentScreen=\(self.isOnCurrentScreen),"
+ "width=\(String(format: "%.1f", Double(self.buttonWidth)))"
}
}
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))
}
enum MenuBarVisibilityWatcher {
static let guidanceShownKey = "hasShownTahoeAllowListGuidance"
static let guidanceLastShownAtKey = "tahoeAllowListGuidanceLastShownAt"
static let guidanceRepeatInterval: TimeInterval = 24 * 60 * 60
static let startupFreshnessInterval: TimeInterval = 10
static let startupCheckDelay: TimeInterval = 2
static let screenChangeCheckDelay: Duration = .milliseconds(750)
static let screenChangeFollowUpDelay: Duration = .seconds(2)
static let settingsURL = URL(string: "x-apple.systempreferences:com.apple.MenuBarSettings")!
@MainActor
static func visibilitySnapshot(_ item: NSStatusItem) -> StatusItemVisibilitySnapshot {
let screen = item.button?.window?.screen
return StatusItemVisibilitySnapshot(
isVisible: item.isVisible,
hasButton: item.button != nil,
hasWindow: item.button?.window != nil,
hasScreen: screen != nil,
isOnCurrentScreen: screen.map(self.isCurrentScreen) ?? false,
buttonWidth: item.button?.frame.size.width ?? 0)
}
@MainActor
private static func isCurrentScreen(_ screen: NSScreen) -> Bool {
let screenNumber = self.screenNumber(screen)
return NSScreen.screens.contains { candidate in
if let screenNumber, let candidateNumber = self.screenNumber(candidate) {
return candidateNumber == screenNumber
}
return candidate === screen
}
}
private static func screenNumber(_ screen: NSScreen) -> NSNumber? {
screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
}
static func isBlockedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool {
guard snapshot.isVisible else { return false }
guard snapshot.hasButton else { return true }
// Menu bar managers can park status-item windows off the current screen while preserving the
// underlying NSStatusItem. Recreating in that state makes those managers see a new item.
return !snapshot.hasWindow || snapshot.buttonWidth <= 0
}
static func isDisplacedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool {
guard snapshot.isVisible, snapshot.hasButton, snapshot.hasWindow, snapshot.buttonWidth > 0 else {
return false
}
return !snapshot.hasScreen || !snapshot.isOnCurrentScreen
}
static func hasBlockedVisibleSnapshots(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool {
let visibleItems = snapshots.filter(\.isVisible)
guard !visibleItems.isEmpty else { return false }
return visibleItems.allSatisfy { snapshot in
self.isBlockedSnapshot(snapshot: snapshot)
}
}
static func hasAnyBlockedVisibleSnapshot(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool {
snapshots.contains { snapshot in
snapshot.isVisible && self.isBlockedSnapshot(snapshot: snapshot)
}
}
static func hasAnyDisplacedVisibleSnapshot(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool {
snapshots.contains { snapshot in
self.isDisplacedSnapshot(snapshot: snapshot)
}
}
static func hasAnyStartupRecoveryCandidate(
snapshots: [StatusItemVisibilitySnapshot],
evidence: [StatusItemStartupVisibilityEvidence] = [],
windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [],
detectTahoeBlockedStatusItem: Bool = false)
-> Bool
{
if self.hasAnyBlockedVisibleSnapshot(snapshots) {
return true
}
if detectTahoeBlockedStatusItem,
self.hasAnyTahoeHiddenNoProxyCandidate(evidence: evidence, windowSnapshots: windowSnapshots)
{
return true
}
guard detectTahoeBlockedStatusItem,
self.hasAnyDisplacedVisibleSnapshot(snapshots),
windowSnapshots.contains(where: \.isTahoeBlockedProxy)
else {
return false
}
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
self.visibilitySnapshot(item)
}
}
@MainActor
static func hasBlockedVisibleStatusItems(_ items: [NSStatusItem]) -> Bool {
self.hasBlockedVisibleSnapshots(self.visibilitySnapshots(items))
}
static func shouldAttemptStartupRecovery(
appLaunchedAt: Date,
now: Date = Date(),
snapshots: [StatusItemVisibilitySnapshot],
evidence: [StatusItemStartupVisibilityEvidence] = [],
windowSnapshots: [MenuBarStatusItemWindowSnapshot] = [],
detectTahoeBlockedStatusItem: Bool = false)
-> Bool
{
guard now.timeIntervalSince(appLaunchedAt) <= self.startupFreshnessInterval else { return false }
return self.hasAnyStartupRecoveryCandidate(
snapshots: snapshots,
evidence: evidence,
windowSnapshots: windowSnapshots,
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)
guard lastShownAt > 0 else { return false }
return now.timeIntervalSince1970 - lastShownAt >= self.guidanceRepeatInterval
}
static func markGuidanceShown(defaults: UserDefaults, now: Date = Date()) {
defaults.set(true, forKey: self.guidanceShownKey)
defaults.set(now.timeIntervalSince1970, forKey: self.guidanceLastShownAtKey)
}
@MainActor
static func presentGuidance(
defaults: UserDefaults,
now: Date = Date(),
openURL: (URL) -> Void = { NSWorkspace.shared.open($0) })
{
self.markGuidanceShown(defaults: defaults, now: now)
let alert = NSAlert()
alert.messageText = L("CodexBar can't show its menu bar icon")
alert.informativeText = L(
"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. "
+ "CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on.")
alert.alertStyle = .warning
alert.addButton(withTitle: L("Open Menu Bar Settings"))
alert.addButton(withTitle: L("Dismiss"))
if alert.runModal() == .alertFirstButtonReturn {
openURL(self.settingsURL)
}
}
}
extension StatusItemController {
func scheduleStartupStatusItemVisibilityCheck(appLaunchedAt: Date = Date()) {
guard !SettingsStore.isRunningTests else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + MenuBarVisibilityWatcher.startupCheckDelay) { [weak self] in
Task { @MainActor [weak self] in
self?.checkStartupStatusItemVisibility(appLaunchedAt: appLaunchedAt)
}
}
}
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.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),
])
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 {
self.menuLogger.info(
"Status item materialized after recreation",
metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")])
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),
])
guard #available(macOS 26.0, *),
MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now)
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 scheduleScreenChangeStatusItemVisibilityCheck(
previousScreenCount: Int,
currentScreenCount: Int)
{
guard !SettingsStore.isRunningTests else { return }
self.screenChangeVisibilityTask?.cancel()
self.screenChangeVisibilityTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: MenuBarVisibilityWatcher.screenChangeCheckDelay)
} catch {
return
}
self?.checkScreenChangeStatusItemVisibility(
previousScreenCount: previousScreenCount,
currentScreenCount: currentScreenCount)
}
}
private func checkScreenChangeStatusItemVisibility(previousScreenCount: Int, currentScreenCount: Int) {
self.pendingScreenChangePreviousCount = nil
let settledCurrentScreenCount = NSScreen.screens.count
self.lastKnownScreenCount = settledCurrentScreenCount
let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems)
if MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: 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(),
])
self.recreateStatusItemsForVisibilityRecovery()
self.schedulePostScreenChangeRecoveryVerification(attempt: 1)
return
}
guard MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement(
previousScreenCount: previousScreenCount,
currentScreenCount: settledCurrentScreenCount,
snapshots: snapshots)
else {
return
}
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 schedulePostScreenChangeRecoveryVerification(attempt: Int) {
self.screenChangeVisibilityTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: MenuBarVisibilityWatcher.screenChangeFollowUpDelay)
} catch {
return
}
self?.verifyScreenChangeRecoveryIfNeeded(attempt: attempt)
}
}
private func verifyScreenChangeRecoveryIfNeeded(attempt: Int) {
let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems)
guard MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(snapshots) else {
self.menuLogger.info(
"Status item recovered after display-change recovery",
metadata: ["attempt": "\(attempt)", "snapshots": snapshots.map(\.description).joined(separator: " | ")])
return
}
self.menuLogger.error(
"Status item still blocked after display-change recovery; recreating status items again",
metadata: [
"attempt": "\(attempt)",
"snapshots": snapshots.map(\.description).joined(separator: " | "),
"windows": self.statusItemWindowDiagnosticsDescription(),
])
self.recreateStatusItemsForVisibilityRecovery()
// No further async retries: a menu bar manager may park the newly recreated item in a state
// that still looks blocked, causing repeated NSStatusItem destruction that corrupts Control Center.
// Instead, do one synchronous re-check to surface guidance if macOS itself is blocking the item.
let finalSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems)
guard MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(finalSnapshots) else { return }
self.menuLogger.error(
"Status item still blocked after display-change recovery recreation",
metadata: [
"snapshots": finalSnapshots.map(\.description).joined(separator: " | "),
"windows": self.statusItemWindowDiagnosticsDescription(),
])
guard #available(macOS 26.0, *),
MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults)
else { return }
MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults)
}
private var startupVisibilityStatusItems: [NSStatusItem] {
[self.statusItem] + Array(self.statusItems.values)
}
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
}
return false
}
private func statusItemWindowSnapshots() -> [MenuBarStatusItemWindowSnapshot] {
let names = Set(self.startupVisibilityStatusItems.compactMap { item in
item.autosaveName.isEmpty ? nil : item.autosaveName
})
return MenuBarStatusItemWindowProbe.snapshots(matching: names)
}
private func statusItemWindowDiagnosticsDescription(
_ snapshots: [MenuBarStatusItemWindowSnapshot]? = nil)
-> String
{
let snapshots = snapshots ?? self.statusItemWindowSnapshots()
guard !snapshots.isEmpty else { return "none" }
return snapshots.map(\.description).joined(separator: " | ")
}
}