465 lines
19 KiB
Swift
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: " | ")
|
|
}
|
|
}
|