diff --git a/AGENTS.md b/AGENTS.md index 9619a6f..383e5b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t ## Project Overview -AwakeCup is a macOS menu bar application (like Caffeine) that prevents the system and/or display from going to sleep. It uses IOKit power management assertions to control sleep behavior, ServiceManagement for login item registration, and Accessibility APIs for best-effort third-party menu bar item management. +AwakeCup is a macOS menu bar application (like Caffeine) that prevents the system and/or display from going to sleep. It uses IOKit power management assertions to control sleep behavior and ServiceManagement for login item registration. ## Build Commands @@ -21,27 +21,13 @@ The core app shell and sleep-prevention behavior live in `Sources/AwakeCup/Awake - **LaunchAtLogin** (`private enum`): Handles login item registration using `SMAppService` (macOS 13+) with LaunchAgent fallback for older systems. -- **TopMenuBarAutoHide**: Applies AppKit presentation options for the "auto-hide top menu bar" preference. - - **AppDelegate**: Standard AppKit delegate that sets `NSApp.setActivationPolicy(.accessory)` for menu bar–only operation. - **AwakeCupApp** (`@main`): SwiftUI app using `MenuBarExtra` for the menu bar interface. -Menu bar item management is split across `Sources/AwakeCup/MenuBarManager/`: - -- **AccessibilityPermissionController**: Checks and requests Accessibility trust. -- **MenuBarAXClient**: Reads third-party menu bar extras through `AXExtrasMenuBar`, presses AX menu bar items, and registers AX observers. -- **MenuBarInventoryService**: Refreshes, sorts, and observes the current menu bar item inventory. -- **MenuBarLayoutStore**: Persists hidden/always-visible assignments and ordering. -- **MenuBarPresentationController**: Tracks whether hidden items are shown as a panel or expanded strip. -- **MenuBarOverlayController**: Draws masks over hidden item positions and presents the expanded strip. -- **MenuBarManagerViewModel**: Coordinates permissions, inventory, layout, presentation, and overlay behavior. -- **MenuBarExtraContentView** and **MenuBarManagerSettingsView**: SwiftUI surfaces for quick controls and the settings window. - ## Key Implementation Details - Uses `@AppStorage("launchAtLogin")` for persisting the login item preference -- Uses `@AppStorage("autoHideTopMenuBar")` for persisting the top menu bar auto-hide preference - `LSUIElement = true` in Info.plist hides the app from Dock (menu bar only) - The menu bar icon is rendered as an `NSImage` with mode badges and optional countdown arc - Supports three modes: system + display, system only, display only diff --git a/CLAUDE.md b/CLAUDE.md index 9bbc7b3..5266982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -AwakeCup is a macOS menu bar application (like Caffeine) that prevents the system and/or display from going to sleep. It uses IOKit power management assertions to control sleep behavior, ServiceManagement for login item registration, and Accessibility APIs for best-effort third-party menu bar item management. +AwakeCup is a macOS menu bar application (like Caffeine) that prevents the system and/or display from going to sleep. It uses IOKit power management assertions to control sleep behavior and ServiceManagement for login item registration. ## Build Commands @@ -21,27 +21,13 @@ The core app shell and sleep-prevention behavior live in `Sources/AwakeCup/Awake - **LaunchAtLogin** (`private enum`): Handles login item registration using `SMAppService` (macOS 13+) with LaunchAgent fallback for older systems. -- **TopMenuBarAutoHide**: Applies AppKit presentation options for the "auto-hide top menu bar" preference. - - **AppDelegate**: Standard AppKit delegate that sets `NSApp.setActivationPolicy(.accessory)` for menu bar–only operation. - **AwakeCupApp** (`@main`): SwiftUI app using `MenuBarExtra` for the menu bar interface. -Menu bar item management is split across `Sources/AwakeCup/MenuBarManager/`: - -- **AccessibilityPermissionController**: Checks and requests Accessibility trust. -- **MenuBarAXClient**: Reads third-party menu bar extras through `AXExtrasMenuBar`, presses AX menu bar items, and registers AX observers. -- **MenuBarInventoryService**: Refreshes, sorts, and observes the current menu bar item inventory. -- **MenuBarLayoutStore**: Persists hidden/always-visible assignments and ordering. -- **MenuBarPresentationController**: Tracks whether hidden items are shown as a panel or expanded strip. -- **MenuBarOverlayController**: Draws masks over hidden item positions and presents the expanded strip. -- **MenuBarManagerViewModel**: Coordinates permissions, inventory, layout, presentation, and overlay behavior. -- **MenuBarExtraContentView** and **MenuBarManagerSettingsView**: SwiftUI surfaces for quick controls and the settings window. - ## Key Implementation Details - Uses `@AppStorage("launchAtLogin")` for persisting the login item preference -- Uses `@AppStorage("autoHideTopMenuBar")` for persisting the top menu bar auto-hide preference - `LSUIElement = true` in Info.plist hides the app from Dock (menu bar only) - The menu bar icon is rendered as an `NSImage` with mode badges and optional countdown arc - Supports three modes: system + display, system only, display only diff --git a/Package.swift b/Package.swift index 57a32ad..4e47305 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,6 @@ let package = Package( linkerSettings: [ .linkedFramework("IOKit"), .linkedFramework("ServiceManagement"), - .linkedFramework("ApplicationServices"), ] ), .testTarget( diff --git a/README.md b/README.md index 3c1264b..8288d33 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ macOS 菜单栏应用,防止系统或屏幕进入休眠状态。 - 三种唤醒模式:系统 + 屏幕、仅系统、仅屏幕(防锁屏) - 定时选项:自定义分钟/小时、1 小时、2 小时或一直保持 - 支持开机自启动(macOS 13+ 使用系统登录项,早期版本使用 LaunchAgent) -- 支持自动隐藏顶部菜单栏,鼠标移到屏幕顶端时临时显示 - 菜单栏图标支持合并状态徽标: - 仅系统:单竖条徽标 - 仅屏幕:小圆点徽标 diff --git a/Sources/AwakeCup/AwakeCup.swift b/Sources/AwakeCup/AwakeCup.swift index 4f46f6b..34ab1fd 100644 --- a/Sources/AwakeCup/AwakeCup.swift +++ b/Sources/AwakeCup/AwakeCup.swift @@ -416,44 +416,14 @@ final class CaffeineManager: ObservableObject { } } -enum TopMenuBarAutoHide { - static let storageKey = "autoHideTopMenuBar" - - static func options( - from currentOptions: NSApplication.PresentationOptions, - enabled: Bool - ) -> NSApplication.PresentationOptions { - var options = currentOptions - options.remove(.hideMenuBar) - - if enabled { - options.insert(.autoHideMenuBar) - } else { - options.remove(.autoHideMenuBar) - } - - return options - } - - @MainActor - static func apply(_ enabled: Bool, application: NSApplication = .shared) { - application.presentationOptions = options( - from: application.presentationOptions, - enabled: enabled - ) - } -} - final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // 作为菜单栏应用运行:不显示 Dock 图标,不显示主窗口。 NSApp.setActivationPolicy(.accessory) - TopMenuBarAutoHide.apply(UserDefaults.standard.bool(forKey: TopMenuBarAutoHide.storageKey)) } func applicationWillTerminate(_ notification: Notification) { CaffeineManager.shared.deactivate() - TopMenuBarAutoHide.apply(false) } } @@ -759,34 +729,14 @@ private func menuBarHelpText(system: Bool, display: Bool, activeUntil: Date?) -> struct AwakeCupApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var caffeine = CaffeineManager.shared - @StateObject private var menuBarManager: MenuBarManagerViewModel @State private var selectedMode: CaffeineManager.Mode = .systemAndDisplay @AppStorage("launchAtLogin") private var launchAtLogin: Bool = false - @AppStorage(TopMenuBarAutoHide.storageKey) private var autoHideTopMenuBar: Bool = false @State private var launchAtLoginCoordinator = LaunchAtLoginToggleCoordinator() @State private var customDurationValue: String = "" @State private var customDurationUnit: CustomDurationEntry.Unit = .minutes @AppStorage("customDurationHistory") private var historyData: Data = Data() - init() { - let permission = AccessibilityPermissionController() - let inventory = MenuBarInventoryService() - let layout = MenuBarLayoutStore() - let presentation = MenuBarPresentationController() - let overlay = MenuBarOverlayController() - - _menuBarManager = StateObject( - wrappedValue: MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - ) - } - private var history: [CustomDurationEntry] { (try? JSONDecoder().decode([CustomDurationEntry].self, from: historyData)) ?? [] } @@ -906,17 +856,6 @@ struct AwakeCupApp: App { } } - Toggle("自动隐藏顶部菜单栏", isOn: $autoHideTopMenuBar) - .onAppear { - TopMenuBarAutoHide.apply(autoHideTopMenuBar) - } - .onChange(of: autoHideTopMenuBar) { newValue in - TopMenuBarAutoHide.apply(newValue) - } - - Text("鼠标移到屏幕顶端时,顶部菜单栏会临时显示。") - .font(.caption) - .foregroundStyle(.secondary) } } @@ -999,28 +938,29 @@ struct AwakeCupApp: App { } var body: some Scene { + // 使用默认 .menu 样式以兼容 macOS 13。 + // .window 样式(macOS 14+)允许 TextField 等复杂控件,但 SceneBuilder 不支持 + // 运行时条件分支;同时 MenuBarExtraStyle 是协议类型,无法通过计算属性桥接。 + // 未来可通过 Xcode Previews / #if 在编译期切换。 MenuBarExtra { - MenuBarExtraContentView(manager: menuBarManager) { - currentControls - } + currentControls } label: { - Image(nsImage: MenuBarIcon.makeImage( - isSystemActive: caffeine.isSystemActive, - isDisplayActive: caffeine.isDisplayActive, - activeMode: caffeine.activeMode, - activationStartTime: caffeine.activationStartTime, - activeUntil: caffeine.activeUntil - )) - .help(menuBarHelpText( - system: caffeine.isSystemActive, - display: caffeine.isDisplayActive, - activeUntil: caffeine.activeUntil - )) - } - .menuBarExtraStyle(.window) - - Window("菜单栏管理", id: menuBarManagerSettingsWindowID) { - MenuBarManagerSettingsView(viewModel: menuBarManager) - } + menuBarIcon() + } + } + + private func menuBarIcon() -> some View { + Image(nsImage: MenuBarIcon.makeImage( + isSystemActive: caffeine.isSystemActive, + isDisplayActive: caffeine.isDisplayActive, + activeMode: caffeine.activeMode, + activationStartTime: caffeine.activationStartTime, + activeUntil: caffeine.activeUntil + )) + .help(menuBarHelpText( + system: caffeine.isSystemActive, + display: caffeine.isDisplayActive, + activeUntil: caffeine.activeUntil + )) } } diff --git a/Sources/AwakeCup/MenuBarManager/AccessibilityPermissionController.swift b/Sources/AwakeCup/MenuBarManager/AccessibilityPermissionController.swift deleted file mode 100644 index 1b86a2c..0000000 --- a/Sources/AwakeCup/MenuBarManager/AccessibilityPermissionController.swift +++ /dev/null @@ -1,44 +0,0 @@ -@preconcurrency import ApplicationServices -import Foundation -import Combine - -protocol AccessibilityTrustChecking { - func isTrusted(prompt: Bool) -> Bool -} - -struct LiveAccessibilityTrustChecker: AccessibilityTrustChecking { - func isTrusted(prompt: Bool) -> Bool { - let options: CFDictionary? = prompt - ? [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary - : nil - return AXIsProcessTrustedWithOptions(options) - } -} - -@MainActor -final class AccessibilityPermissionController: ObservableObject { - enum State: Equatable { - case unknown - case granted - case denied - } - - @Published private(set) var state: State = .unknown - - var canManageItems: Bool { state == .granted } - - private let checker: AccessibilityTrustChecking - - init(checker: AccessibilityTrustChecking = LiveAccessibilityTrustChecker()) { - self.checker = checker - state = checker.isTrusted(prompt: false) ? .granted : .denied - } - - func refresh() { - state = checker.isTrusted(prompt: false) ? .granted : .denied - } - - func requestAccess() { - state = checker.isTrusted(prompt: true) ? .granted : .denied - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarAXClient.swift b/Sources/AwakeCup/MenuBarManager/MenuBarAXClient.swift deleted file mode 100644 index 2ec1dc4..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarAXClient.swift +++ /dev/null @@ -1,270 +0,0 @@ -@preconcurrency import ApplicationServices -import AppKit -import Foundation - -enum MenuBarAXClientError: LocalizedError, Equatable { - case permissionDenied - case itemNotFound(String) - case pressFailed(String) - - var errorDescription: String? { - switch self { - case .permissionDenied: - return "Accessibility permission is required." - case .itemNotFound(let runtimeID): - return "Menu bar item \(runtimeID) is no longer available." - case .pressFailed(let runtimeID): - return "Unable to press menu bar item \(runtimeID)." - } - } -} - -enum MenuBarAXObservationRegistrationPolicy { - static func shouldTrackObserver(notificationResults: [AXError]) -> Bool { - notificationResults.contains(.success) - } -} - -@MainActor -protocol MenuBarAXReading: AnyObject { - func fetchSnapshots() throws -> [MenuBarItemSnapshot] - func press(_ runtimeID: String) throws - func startObserving(processIDs: Set, onChange: @escaping () -> Void) -> Set - func stopObserving() -} - -@MainActor -final class MenuBarAXClient: MenuBarAXReading { - private struct Observation { - let observer: AXObserver - let source: CFRunLoopSource - let callbackToken: Unmanaged - } - - private static let extrasMenuBarAttribute = "AXExtrasMenuBar" as CFString - private static let observerCallback: AXObserverCallback = { _, _, _, refcon in - guard let refcon else { return } - let box = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - box.invoke() - } - - private var observations: [Int32: Observation] = [:] - private var elementsByRuntimeID: [String: AXUIElement] = [:] - - nonisolated static func identityHint(identifier: String?, help: String?, siblingIndex: Int?) -> String? { - if let identifier = normalizedIdentityAttribute(identifier) { - return "identifier:\(identifier)" - } - if let help = normalizedIdentityAttribute(help) { - return "help:\(help)" - } - if let siblingIndex { - return "index:\(siblingIndex)" - } - return nil - } - - func fetchSnapshots() throws -> [MenuBarItemSnapshot] { - guard AXIsProcessTrusted() else { - throw MenuBarAXClientError.permissionDenied - } - - elementsByRuntimeID.removeAll(keepingCapacity: true) - - var snapshots: [MenuBarItemSnapshot] = [] - for application in NSWorkspace.shared.runningApplications where application.processIdentifier > 0 { - let appElement = AXUIElementCreateApplication(application.processIdentifier) - - var extrasMenuBarValue: CFTypeRef? - let extrasResult = AXUIElementCopyAttributeValue( - appElement, - Self.extrasMenuBarAttribute, - &extrasMenuBarValue - ) - guard extrasResult == .success, let extrasMenuBarValue else { - continue - } - guard CFGetTypeID(extrasMenuBarValue) == AXUIElementGetTypeID() else { - continue - } - let extrasMenuBar = unsafeDowncast(extrasMenuBarValue, to: AXUIElement.self) - - var childrenValue: CFTypeRef? - let childrenResult = AXUIElementCopyAttributeValue( - extrasMenuBar, - kAXChildrenAttribute as CFString, - &childrenValue - ) - guard childrenResult == .success, let children = childrenValue as? [AXUIElement] else { - continue - } - - let shouldIncludeSiblingIndex = children.count > 1 - for (index, child) in children.enumerated() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: application.bundleIdentifier ?? "unknown.bundle", - processID: application.processIdentifier, - title: copyStringAttribute(kAXTitleAttribute as CFString, from: child), - description: copyStringAttribute(kAXDescriptionAttribute as CFString, from: child), - role: copyStringAttribute(kAXRoleAttribute as CFString, from: child), - subrole: copyStringAttribute(kAXSubroleAttribute as CFString, from: child), - frame: copyFrame(from: child), - actionNames: copyActionNames(from: child), - identityHint: Self.identityHint( - identifier: copyStringAttribute(kAXIdentifierAttribute as CFString, from: child), - help: copyStringAttribute(kAXHelpAttribute as CFString, from: child), - siblingIndex: shouldIncludeSiblingIndex ? index + 1 : nil - ) - ) - let record = MenuBarItemRecord(snapshot: snapshot) - elementsByRuntimeID[record.id] = child - snapshots.append(snapshot) - } - } - - return snapshots - } - - func press(_ runtimeID: String) throws { - guard let element = elementsByRuntimeID[runtimeID] else { - throw MenuBarAXClientError.itemNotFound(runtimeID) - } - - let result = AXUIElementPerformAction(element, kAXPressAction as CFString) - guard result == .success else { - throw MenuBarAXClientError.pressFailed(runtimeID) - } - } - - func startObserving(processIDs: Set, onChange: @escaping () -> Void) -> Set { - stopObserving() - - guard !processIDs.isEmpty else { - return [] - } - - var observedProcessIDs: Set = [] - for processID in processIDs { - var observer: AXObserver? - let callbackToken = Unmanaged.passRetained(ObservationCallbackBox(onChange)) - let createResult = AXObserverCreate(processID, Self.observerCallback, &observer) - guard createResult == .success, let observer else { - callbackToken.release() - continue - } - - let runLoopSource = AXObserverGetRunLoopSource(observer) - let applicationElement = AXUIElementCreateApplication(processID) - let notificationResults = [ - AXObserverAddNotification( - observer, - applicationElement, - kAXCreatedNotification as CFString, - callbackToken.toOpaque() - ), - AXObserverAddNotification( - observer, - applicationElement, - kAXMovedNotification as CFString, - callbackToken.toOpaque() - ), - AXObserverAddNotification( - observer, - applicationElement, - kAXUIElementDestroyedNotification as CFString, - callbackToken.toOpaque() - ), - ] - guard MenuBarAXObservationRegistrationPolicy.shouldTrackObserver( - notificationResults: notificationResults - ) else { - callbackToken.release() - continue - } - CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, CFRunLoopMode.defaultMode) - observations[processID] = Observation( - observer: observer, - source: runLoopSource, - callbackToken: callbackToken - ) - observedProcessIDs.insert(processID) - } - - return observedProcessIDs - } - - func stopObserving() { - guard !observations.isEmpty else { - return - } - - for observation in observations.values { - CFRunLoopRemoveSource(CFRunLoopGetMain(), observation.source, CFRunLoopMode.defaultMode) - observation.callbackToken.release() - } - observations.removeAll(keepingCapacity: true) - } - - private func copyStringAttribute(_ attribute: CFString, from element: AXUIElement) -> String? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { - return nil - } - return value as? String - } - - private func copyActionNames(from element: AXUIElement) -> [String] { - var value: CFArray? - guard AXUIElementCopyActionNames(element, &value) == .success, let actions = value as? [String] else { - return [] - } - return actions - } - - private func copyFrame(from element: AXUIElement) -> CGRect? { - var positionValue: CFTypeRef? - var sizeValue: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionValue) == .success, - AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeValue) == .success, - let positionValue, - let sizeValue, - CFGetTypeID(positionValue) == AXValueGetTypeID(), - CFGetTypeID(sizeValue) == AXValueGetTypeID() else { - return nil - } - let positionAXValue = unsafeDowncast(positionValue, to: AXValue.self) - let sizeAXValue = unsafeDowncast(sizeValue, to: AXValue.self) - guard AXValueGetType(positionAXValue) == .cgPoint, - AXValueGetType(sizeAXValue) == .cgSize else { - return nil - } - - var point = CGPoint.zero - var size = CGSize.zero - guard AXValueGetValue(positionAXValue, .cgPoint, &point), - AXValueGetValue(sizeAXValue, .cgSize, &size) else { - return nil - } - return CGRect(origin: point, size: size) - } - - private nonisolated static func normalizedIdentityAttribute(_ value: String?) -> String? { - guard let value else { - return nil - } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} - -private final class ObservationCallbackBox { - private let callback: () -> Void - - init(_ callback: @escaping () -> Void) { - self.callback = callback - } - - func invoke() { - callback() - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarExtraContentView.swift b/Sources/AwakeCup/MenuBarManager/MenuBarExtraContentView.swift deleted file mode 100644 index d17cbb6..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarExtraContentView.swift +++ /dev/null @@ -1,122 +0,0 @@ -import AppKit -import SwiftUI - -let menuBarManagerSettingsWindowID = "menu-bar-manager-settings" - -struct MenuBarExtraContentView: View { - @ObservedObject var manager: MenuBarManagerViewModel - @Environment(\.openWindow) private var openWindow - - private let controls: Controls - - init( - manager: MenuBarManagerViewModel, - @ViewBuilder controls: () -> Controls - ) { - _manager = ObservedObject(wrappedValue: manager) - self.controls = controls() - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - controls - Divider() - managerSection - } - .padding(12) - .frame(minWidth: 320, alignment: .leading) - } - .onAppear { - manager.refresh() - } - } - - @ViewBuilder - private var managerSection: some View { - if manager.canManageItems { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("隐藏项") - .font(.headline) - Spacer() - Text("\(manager.hiddenItems.count)") - .font(.caption) - .foregroundStyle(.secondary) - } - - if manager.hiddenItems.isEmpty { - Text("当前没有隐藏项。") - .font(.callout) - .foregroundStyle(.secondary) - } else { - ForEach(manager.hiddenItems) { item in - HiddenItemQuickAction(item: item) { - manager.press(item) - } - } - } - - if manager.shouldShowRevealHiddenItemsButton { - Button("展开隐藏项") { - manager.showHiddenItems() - } - .disabled(!manager.canRevealHiddenItemsInPreferredMode) - } - - Button("隐藏其他顶部图标") { - manager.hideOtherVisibleItems() - } - .disabled(!manager.canHideOtherVisibleItems) - - Button("菜单栏管理") { - NSApp.activate(ignoringOtherApps: true) - openWindow(id: menuBarManagerSettingsWindowID) - } - - if let inventoryErrorMessage = manager.inventoryErrorMessage { - Text(inventoryErrorMessage) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } else { - VStack(alignment: .leading, spacing: 8) { - Text("菜单栏管理需要辅助功能权限。") - .font(.callout) - Button("请求权限") { - manager.requestAccess() - } - Button("菜单栏管理") { - NSApp.activate(ignoringOtherApps: true) - openWindow(id: menuBarManagerSettingsWindowID) - } - } - } - } -} - -private struct HiddenItemQuickAction: View { - let item: MenuBarItemRecord - let press: () -> Void - - var body: some View { - if item.isManageable { - Button(action: press) { - Text(item.displayName) - .lineLimit(1) - } - } else { - HStack(spacing: 8) { - Text(item.displayName) - .lineLimit(1) - Spacer() - Text("不可点击") - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .help(item.unmanagedReason ?? "该菜单栏项目缺少可执行的辅助功能操作。") - } - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarInventoryService.swift b/Sources/AwakeCup/MenuBarManager/MenuBarInventoryService.swift deleted file mode 100644 index 2755802..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarInventoryService.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Combine -import Foundation - -@MainActor -final class MenuBarInventoryService: ObservableObject { - @Published private(set) var records: [MenuBarItemRecord] = [] - @Published private(set) var lastRefreshError: String? - - private let reader: MenuBarAXReading - private var observedProcessIDs: Set = [] - - init(reader: MenuBarAXReading = MenuBarAXClient()) { - self.reader = reader - } - - func refresh() { - do { - let snapshots = try reader.fetchSnapshots() - let refreshedRecords = snapshots - .map(MenuBarItemRecord.init(snapshot:)) - .sorted(by: MenuBarItemRecord.sortsBeforeInDisplayOrder) - - records = refreshedRecords - lastRefreshError = nil - - let processIDs = Set(refreshedRecords.map(\.processID)) - if processIDs.isEmpty { - stopObservingIfNeeded() - } else if observedProcessIDs != processIDs { - stopObservingIfNeeded() - observedProcessIDs = reader.startObserving(processIDs: processIDs) { [weak self] in - Task { @MainActor in - self?.refresh() - } - } - } - } catch { - records = [] - stopObservingIfNeeded() - lastRefreshError = error.localizedDescription - } - } - - func reset() { - records = [] - lastRefreshError = nil - stopObservingIfNeeded() - } - - func press(_ record: MenuBarItemRecord) throws { - try reader.press(record.id) - } - - private func stopObservingIfNeeded() { - guard !observedProcessIDs.isEmpty else { - return - } - reader.stopObserving() - observedProcessIDs = [] - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarItemRecord.swift b/Sources/AwakeCup/MenuBarManager/MenuBarItemRecord.swift deleted file mode 100644 index 5a23572..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarItemRecord.swift +++ /dev/null @@ -1,238 +0,0 @@ -import CoreGraphics -import Foundation - -enum MenuBarItemSection: String, Codable, CaseIterable, Equatable { - case alwaysVisible - case hidden -} - -enum MenuBarItemManageability: Codable, Equatable { - case manageable - case unmanaged(reason: String) -} - -struct MenuBarItemSnapshot: Equatable { - let bundleIdentifier: String - let processID: Int32 - let title: String? - let description: String? - let role: String? - let subrole: String? - let frame: CGRect? - let actionNames: [String] - let identityHint: String? - - init( - bundleIdentifier: String, - processID: Int32, - title: String?, - description: String?, - role: String?, - subrole: String?, - frame: CGRect?, - actionNames: [String], - identityHint: String? = nil - ) { - self.bundleIdentifier = bundleIdentifier - self.processID = processID - self.title = title - self.description = description - self.role = role - self.subrole = subrole - self.frame = frame - self.actionNames = actionNames - self.identityHint = identityHint - } -} - -struct MenuBarItemRecord: Identifiable, Codable, Equatable { - let persistentID: String - let runtimeID: String - let bundleIdentifier: String - let processID: Int32 - let displayName: String - let axRole: String - let axSubrole: String? - let hasKnownFrame: Bool - let frame: CGRect - let actionNames: [String] - let manageability: MenuBarItemManageability - - var id: String { runtimeID } - - init(snapshot: MenuBarItemSnapshot) { - let role = snapshot.role ?? "AXUnknown" - let hasKnownFrame = snapshot.frame != nil - let frame = snapshot.frame ?? .zero - let sortedActionNames = snapshot.actionNames.sorted() - let displayName = snapshot.title?.nonEmpty - ?? snapshot.description?.nonEmpty - ?? snapshot.bundleIdentifier - let normalizedSubrole = OptionalIdentityString(snapshot.subrole?.nonEmpty) - let normalizedIdentityHint = OptionalIdentityString(snapshot.identityHint?.nonEmpty) - - let manageability: MenuBarItemManageability = snapshot.actionNames.contains("AXPress") - ? .manageable - : .unmanaged(reason: "Missing AXPress action") - - let coarseX = Int(frame.minX.rounded()) - let coarseY = Int(frame.minY.rounded()) - let coarseWidth = Int(frame.width.rounded()) - let coarseHeight = Int(frame.height.rounded()) - - self.persistentID = Self.encodeIdentityComponents( - PersistentIdentityPayload( - version: 1, - bundleIdentifier: snapshot.bundleIdentifier, - role: role, - subrole: normalizedSubrole, - identityHint: normalizedIdentityHint - ) - ) - - self.runtimeID = Self.encodeIdentityComponents( - RuntimeIdentityPayload( - version: 1, - persistentID: persistentID, - displayName: displayName, - actionNames: sortedActionNames, - geometry: GeometryIdentity( - x: coarseX, - y: coarseY, - width: coarseWidth, - height: coarseHeight - ) - ) - ) - - self.bundleIdentifier = snapshot.bundleIdentifier - self.processID = snapshot.processID - self.displayName = displayName - self.axRole = role - self.axSubrole = snapshot.subrole - self.hasKnownFrame = hasKnownFrame - self.frame = frame - self.actionNames = sortedActionNames - self.manageability = manageability - } - - private static func encodeIdentityComponents(_ value: T) -> String { - guard let data = try? JSONEncoder().encode(value) else { - return String(describing: value) - } - return String(decoding: data, as: UTF8.self) - } -} - -extension MenuBarItemRecord { - var isManageable: Bool { - if case .manageable = manageability { - return true - } - return false - } - - var isUnmanaged: Bool { - !isManageable - } - - var unmanagedReason: String? { - if case let .unmanaged(reason) = manageability { - return reason - } - return nil - } - - static func sortsBeforeInDisplayOrder(_ left: MenuBarItemRecord, _ right: MenuBarItemRecord) -> Bool { - switch (left.hasKnownFrame, right.hasKnownFrame) { - case (true, false): - return true - case (false, true): - return false - case (false, false): - return left.persistentID < right.persistentID - case (true, true): - if left.frame.minX != right.frame.minX { - return left.frame.minX < right.frame.minX - } - if left.frame.minY != right.frame.minY { - return left.frame.minY < right.frame.minY - } - return left.persistentID < right.persistentID - } - } -} - -private extension String { - var nonEmpty: String? { - let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} - -private struct OptionalIdentityString: Encodable, Equatable { - let value: String? - - init(_ value: String?) { - self.value = value - } - - func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - if let value { - try container.encode("some") - try container.encode(value) - } else { - try container.encode("none") - } - } -} - -private struct PersistentIdentityPayload: Encodable, Equatable { - let version: Int - let bundleIdentifier: String - let role: String - let subrole: OptionalIdentityString - let identityHint: OptionalIdentityString - - func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - try container.encode(version) - try container.encode(bundleIdentifier) - try container.encode(role) - try container.encode(subrole) - try container.encode(identityHint) - } -} - -private struct GeometryIdentity: Encodable, Equatable { - let x: Int - let y: Int - let width: Int - let height: Int - - func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - try container.encode(x) - try container.encode(y) - try container.encode(width) - try container.encode(height) - } -} - -private struct RuntimeIdentityPayload: Encodable, Equatable { - let version: Int - let persistentID: String - let displayName: String - let actionNames: [String] - let geometry: GeometryIdentity - - func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - try container.encode(version) - try container.encode(persistentID) - try container.encode(displayName) - try container.encode(actionNames) - try container.encode(geometry) - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarLayoutStore.swift b/Sources/AwakeCup/MenuBarManager/MenuBarLayoutStore.swift deleted file mode 100644 index 10f699b..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarLayoutStore.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import Combine - -struct MenuBarLayoutConfiguration: Codable, Equatable { - var assignments: [String: MenuBarItemSection] = [:] - var orderBySection: [MenuBarItemSection: [String]] = [:] -} - -protocol MenuBarLayoutPersisting { - func loadData(forKey key: String) -> Data? - func saveData(_ data: Data, forKey key: String) -} - -struct UserDefaultsLayoutPersistence: MenuBarLayoutPersisting { - let defaults: UserDefaults = .standard - - func loadData(forKey key: String) -> Data? { - defaults.data(forKey: key) - } - - func saveData(_ data: Data, forKey key: String) { - defaults.set(data, forKey: key) - } -} - -@MainActor -final class MenuBarLayoutStore: ObservableObject { - @Published private(set) var configuration: MenuBarLayoutConfiguration - - private let persistence: MenuBarLayoutPersisting - private let storageKey = "menuBarManagerLayout" - - init(persistence: MenuBarLayoutPersisting = UserDefaultsLayoutPersistence()) { - self.persistence = persistence - if let data = persistence.loadData(forKey: storageKey), - let decoded = try? JSONDecoder().decode(MenuBarLayoutConfiguration.self, from: data) { - self.configuration = decoded - } else { - self.configuration = MenuBarLayoutConfiguration() - } - } - - func assign(_ section: MenuBarItemSection, toPersistentID persistentID: String) { - configuration.assignments[persistentID] = section - persist() - } - - func assign(_ section: MenuBarItemSection, toPersistentIDs persistentIDs: [String]) { - guard !persistentIDs.isEmpty else { - return - } - - for persistentID in persistentIDs { - configuration.assignments[persistentID] = section - } - persist() - } - - func updateOrder(for section: MenuBarItemSection, persistentIDs: [String]) { - configuration.orderBySection[section] = persistentIDs - persist() - } - - func section(for item: MenuBarItemRecord) -> MenuBarItemSection { - configuration.assignments[item.persistentID] ?? .alwaysVisible - } - - func orderedItems(inventory: [MenuBarItemRecord], section: MenuBarItemSection) -> [MenuBarItemRecord] { - let records = inventory.filter { self.section(for: $0) == section } - let order = configuration.orderBySection[section] ?? [] - - return records.sorted { left, right in - let leftIndex = order.firstIndex(of: left.persistentID) ?? .max - let rightIndex = order.firstIndex(of: right.persistentID) ?? .max - if leftIndex != rightIndex { return leftIndex < rightIndex } - return MenuBarItemRecord.sortsBeforeInDisplayOrder(left, right) - } - } - - private func persist() { - guard let data = try? JSONEncoder().encode(configuration) else { - return - } - persistence.saveData(data, forKey: storageKey) - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarManagerSettingsView.swift b/Sources/AwakeCup/MenuBarManager/MenuBarManagerSettingsView.swift deleted file mode 100644 index a0e5c89..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarManagerSettingsView.swift +++ /dev/null @@ -1,172 +0,0 @@ -import SwiftUI - -struct MenuBarManagerSettingsView: View { - @ObservedObject var viewModel: MenuBarManagerViewModel - - var body: some View { - List { - revealModeSection - contentSection - } - .frame(minWidth: 460, minHeight: 420) - .navigationTitle("菜单栏管理") - .onAppear { - viewModel.refresh() - } - } - - private var revealModeSection: some View { - Section("显示方式") { - Picker("隐藏项显示方式", selection: revealModeBinding) { - ForEach(HiddenItemRevealMode.allCases, id: \.self) { mode in - Text(mode.displayTitle).tag(mode) - } - } - .pickerStyle(.segmented) - - Text("面板更稳定,展开条更贴近原始菜单栏位置。") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var contentSection: some View { - if viewModel.canManageItems { - Section("快捷操作") { - Button("隐藏其他顶部图标") { - viewModel.hideOtherVisibleItems() - } - .disabled(!viewModel.canHideOtherVisibleItems) - - Text("将除 AwakeCup 外当前顶部图标批量移到隐藏项。") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("始终显示") { - if viewModel.alwaysVisibleItems.isEmpty { - emptyState("当前没有固定显示的项目。") - } else { - ForEach(viewModel.alwaysVisibleItems) { item in - ManagedItemRow(item: item, buttonTitle: "隐藏") { - viewModel.move(item, to: .hidden) - } - } - } - } - - Section("隐藏项") { - if viewModel.hiddenItems.isEmpty { - emptyState("当前没有隐藏项。") - } else { - ForEach(viewModel.hiddenItems) { item in - ManagedItemRow(item: item, buttonTitle: "显示") { - viewModel.move(item, to: .alwaysVisible) - } - } - } - } - - if !viewModel.unmanagedItems.isEmpty { - Section("无法控制") { - ForEach(viewModel.unmanagedItems) { item in - UnmanagedItemRow(item: item) - } - } - } - - if let inventoryErrorMessage = viewModel.inventoryErrorMessage { - Section("状态") { - Text(inventoryErrorMessage) - .font(.callout) - .foregroundStyle(.secondary) - } - } - } else { - Section("权限") { - Text("需要辅助功能权限才能整理其他应用的菜单栏图标。") - .font(.callout) - Button("请求权限") { - viewModel.requestAccess() - } - } - } - } - - private var revealModeBinding: Binding { - Binding( - get: { viewModel.preferredRevealMode }, - set: { viewModel.setPreferredRevealMode($0) } - ) - } - - private func emptyState(_ text: String) -> some View { - Text(text) - .font(.callout) - .foregroundStyle(.secondary) - } -} - -private struct ManagedItemRow: View { - let item: MenuBarItemRecord - let buttonTitle: String - let action: () -> Void - - var body: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(item.displayName) - if item.isUnmanaged { - Label("不可点击", systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Text(item.bundleIdentifier) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Button(buttonTitle, action: action) - .buttonStyle(.bordered) - .controlSize(.small) - } - .padding(.vertical, 2) - } -} - -private struct UnmanagedItemRow: View { - let item: MenuBarItemRecord - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(item.displayName) - Text(item.bundleIdentifier) - .font(.caption) - .foregroundStyle(.secondary) - if case let .unmanaged(reason) = item.manageability { - Text(reason) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 2) - } -} - -private extension HiddenItemRevealMode { - static let allCases: [HiddenItemRevealMode] = [.panel, .expandedStrip] - - var displayTitle: String { - switch self { - case .panel: - return "面板" - case .expandedStrip: - return "展开条" - } - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarManagerViewModel.swift b/Sources/AwakeCup/MenuBarManager/MenuBarManagerViewModel.swift deleted file mode 100644 index cf261fb..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarManagerViewModel.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Combine -import Foundation - -@MainActor -final class MenuBarManagerViewModel: ObservableObject { - @Published private(set) var permissionState: AccessibilityPermissionController.State - @Published private(set) var canManageItems: Bool - @Published private(set) var preferredRevealMode: HiddenItemRevealMode - @Published private(set) var inventoryErrorMessage: String? - @Published private(set) var alwaysVisibleItems: [MenuBarItemRecord] = [] - @Published private(set) var hiddenItems: [MenuBarItemRecord] = [] - @Published private(set) var unmanagedItems: [MenuBarItemRecord] = [] - - private let permission: AccessibilityPermissionController - private let inventory: MenuBarInventoryService - private let layout: MenuBarLayoutStore - private let presentation: MenuBarPresentationController - private let overlay: MenuBarOverlayControlling - private let currentAppBundleIdentifier: String? - private var cancellables: Set = [] - - init( - permission: AccessibilityPermissionController, - inventory: MenuBarInventoryService, - layout: MenuBarLayoutStore, - presentation: MenuBarPresentationController, - overlay: MenuBarOverlayControlling, - currentAppBundleIdentifier: String? = Bundle.main.bundleIdentifier - ) { - self.permission = permission - self.inventory = inventory - self.layout = layout - self.presentation = presentation - self.overlay = overlay - self.currentAppBundleIdentifier = currentAppBundleIdentifier - permissionState = permission.state - canManageItems = permission.canManageItems - preferredRevealMode = presentation.configuration.preferredRevealMode - inventoryErrorMessage = inventory.lastRefreshError - - presentation.$state - .dropFirst() - .sink { [weak self] state in - self?.syncExpandedStripVisibility(using: state) - } - .store(in: &cancellables) - } - - func refresh() { - permission.refresh() - permissionState = permission.state - canManageItems = permission.canManageItems - preferredRevealMode = presentation.configuration.preferredRevealMode - - guard canManageItems else { - inventory.reset() - inventoryErrorMessage = inventory.lastRefreshError - alwaysVisibleItems = [] - hiddenItems = [] - unmanagedItems = [] - presentation.dismiss() - overlay.hideHiddenMask() - return - } - - inventory.refresh() - inventoryErrorMessage = inventory.lastRefreshError - - let inventoryItems = inventory.records - alwaysVisibleItems = layout.orderedItems(inventory: inventoryItems, section: .alwaysVisible) - hiddenItems = layout.orderedItems(inventory: inventoryItems, section: .hidden) - unmanagedItems = alwaysVisibleItems.filter(\.isUnmanaged) - - if hiddenItems.allSatisfy(\.hasKnownFrame) { - overlay.updateHiddenMask(for: hiddenItems) - } else { - overlay.hideHiddenMask() - } - syncExpandedStripVisibility() - } - - func requestAccess() { - permission.requestAccess() - refresh() - } - - func move(_ item: MenuBarItemRecord, to section: MenuBarItemSection) { - layout.assign(section, toPersistentID: item.persistentID) - refresh() - } - - func hideOtherVisibleItems() { - let itemsToHide = alwaysVisibleItems.filter { item in - item.bundleIdentifier != currentAppBundleIdentifier - } - guard !itemsToHide.isEmpty else { - return - } - - layout.assign(.hidden, toPersistentIDs: itemsToHide.map(\.persistentID)) - refresh() - } - - func setPreferredRevealMode(_ mode: HiddenItemRevealMode) { - guard presentation.configuration.preferredRevealMode != mode else { - preferredRevealMode = mode - return - } - presentation.configuration.preferredRevealMode = mode - preferredRevealMode = mode - if mode == .panel, presentation.state.activeSurface == .expandedStrip { - presentation.dismiss() - return - } - syncExpandedStripVisibility() - } - - func showHiddenItems() { - presentation.showPreferred(canPresentExpandedStrip: canPresentExpandedStrip) - syncExpandedStripVisibility() - } - - func dismissHiddenItems() { - presentation.dismiss() - } - - func press(_ item: MenuBarItemRecord) { - do { - try inventory.press(item) - inventoryErrorMessage = nil - } catch { - inventoryErrorMessage = error.localizedDescription - } - } - - var canHideOtherVisibleItems: Bool { - alwaysVisibleItems.contains { item in - item.bundleIdentifier != currentAppBundleIdentifier - } - } - - var shouldShowRevealHiddenItemsButton: Bool { - preferredRevealMode == .expandedStrip - } - - var canRevealHiddenItemsInPreferredMode: Bool { - shouldShowRevealHiddenItemsButton && canPresentExpandedStrip - } - - private var canPresentExpandedStrip: Bool { - !hiddenItems.isEmpty && hiddenItems.allSatisfy(\.hasKnownFrame) && hiddenItems.allSatisfy(\.isManageable) - } - - private func syncExpandedStripVisibility(using state: MenuBarPresentationState? = nil) { - let presentationState = state ?? presentation.state - - guard presentationState.activeSurface == .expandedStrip, !hiddenItems.isEmpty else { - if presentationState.activeSurface == .expandedStrip, hiddenItems.isEmpty { - presentation.dismiss() - } - overlay.dismissExpandedStrip() - return - } - - guard canPresentExpandedStrip else { - presentation.dismiss() - overlay.dismissExpandedStrip() - return - } - - overlay.presentExpandedStrip(for: hiddenItems) { [weak self] item in - self?.pressFromExpandedStrip(item) - } - } - - private func pressFromExpandedStrip(_ item: MenuBarItemRecord) { - press(item) - if inventoryErrorMessage != nil { - presentation.dismiss() - } - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarOverlayController.swift b/Sources/AwakeCup/MenuBarManager/MenuBarOverlayController.swift deleted file mode 100644 index 51b17cd..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarOverlayController.swift +++ /dev/null @@ -1,204 +0,0 @@ -import AppKit -import Foundation -import SwiftUI - -@MainActor -protocol MenuBarOverlayControlling: AnyObject { - func updateHiddenMask(for hiddenItems: [MenuBarItemRecord]) - func hideHiddenMask() - func presentExpandedStrip( - for hiddenItems: [MenuBarItemRecord], - onPress: @escaping (MenuBarItemRecord) -> Void - ) - func dismissExpandedStrip() -} - -@MainActor -final class MenuBarOverlayController: MenuBarOverlayControlling { - private let overlayWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 1) - private var hiddenMaskWindows: [NSWindow] = [] - private var expandedStripPanel: NSPanel? - - deinit { - let hiddenMaskWindows = hiddenMaskWindows - let expandedStripPanel = expandedStripPanel - Task { @MainActor in - hiddenMaskWindows.forEach { $0.orderOut(nil) } - expandedStripPanel?.orderOut(nil) - } - } - - func updateHiddenMask(for hiddenItems: [MenuBarItemRecord]) { - let frames = hiddenItems.hiddenMaskFrames - guard !frames.isEmpty else { - hideHiddenMask() - return - } - - syncHiddenMaskWindows(to: frames) - } - - func hideHiddenMask() { - hiddenMaskWindows.forEach { $0.orderOut(nil) } - hiddenMaskWindows.removeAll(keepingCapacity: true) - } - - func presentExpandedStrip( - for hiddenItems: [MenuBarItemRecord], - onPress: @escaping (MenuBarItemRecord) -> Void - ) { - guard let frame = hiddenItems.boundingFrame else { - dismissExpandedStrip() - return - } - - let panel = expandedStripPanel ?? makeExpandedStripPanel(frame: frame) - panel.setFrame(frame, display: true) - panel.contentView = makeExpandedStripContentView( - hiddenItems: hiddenItems, - bounds: contentBounds(for: frame), - onPress: onPress - ) - panel.orderFrontRegardless() - expandedStripPanel = panel - } - - func dismissExpandedStrip() { - expandedStripPanel?.orderOut(nil) - } - - private func makeHiddenMaskWindow(frame: CGRect) -> NSWindow { - let window = NSWindow( - contentRect: frame, - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.level = overlayWindowLevel - window.isOpaque = true - window.backgroundColor = .windowBackgroundColor - window.hasShadow = false - window.ignoresMouseEvents = false - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.contentView = makeHiddenMaskContentView(bounds: contentBounds(for: frame)) - return window - } - - private func makeExpandedStripPanel(frame: CGRect) -> NSPanel { - let panel = NSPanel( - contentRect: frame, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - panel.level = overlayWindowLevel - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.contentView = makeExpandedStripContentView( - hiddenItems: [], - bounds: contentBounds(for: frame), - onPress: { _ in } - ) - return panel - } - - private func makeHiddenMaskContentView(bounds: CGRect) -> NSView { - let view = NSView(frame: bounds) - view.autoresizingMask = [.width, .height] - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - return view - } - - private func syncHiddenMaskWindows(to frames: [CGRect]) { - while hiddenMaskWindows.count < frames.count { - hiddenMaskWindows.append(makeHiddenMaskWindow(frame: frames[hiddenMaskWindows.count])) - } - - while hiddenMaskWindows.count > frames.count { - hiddenMaskWindows.removeLast().orderOut(nil) - } - - for (window, frame) in zip(hiddenMaskWindows, frames) { - window.setFrame(frame, display: true) - window.contentView = makeHiddenMaskContentView(bounds: contentBounds(for: frame)) - window.orderFrontRegardless() - } - } - - private func makeExpandedStripContentView( - hiddenItems: [MenuBarItemRecord], - bounds: CGRect, - onPress: @escaping (MenuBarItemRecord) -> Void - ) -> NSView { - let hostingView = NSHostingView( - rootView: MenuBarExpandedStripView( - hiddenItems: hiddenItems, - onPress: onPress - ) - ) - hostingView.frame = bounds - hostingView.autoresizingMask = [.width, .height] - return hostingView - } - - private func contentBounds(for frame: CGRect) -> CGRect { - CGRect(origin: .zero, size: frame.size) - } -} - -private extension Array where Element == MenuBarItemRecord { - var boundingFrame: CGRect? { - guard var frame = first?.frame else { - return nil - } - - for item in dropFirst() { - frame = frame.union(item.frame) - } - return frame - } - - var hiddenMaskFrames: [CGRect] { - compactMap { item in - guard item.hasKnownFrame else { - return nil - } - return item.frame.integral - } - } -} - -private struct MenuBarExpandedStripView: View { - let hiddenItems: [MenuBarItemRecord] - let onPress: (MenuBarItemRecord) -> Void - - var body: some View { - HStack(spacing: 6) { - ForEach(hiddenItems, id: \.id) { item in - Button { - onPress(item) - } label: { - Text(item.displayName) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(.regularMaterial, in: Capsule()) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.thinMaterial) - ) - } -} diff --git a/Sources/AwakeCup/MenuBarManager/MenuBarPresentationController.swift b/Sources/AwakeCup/MenuBarManager/MenuBarPresentationController.swift deleted file mode 100644 index e0d878f..0000000 --- a/Sources/AwakeCup/MenuBarManager/MenuBarPresentationController.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Combine -import Foundation - -enum HiddenItemRevealMode: String, Codable, Equatable { - case panel - case expandedStrip -} - -enum MenuBarPresentationSurface: Equatable { - case panel - case expandedStrip -} - -struct MenuBarPresentationState: Equatable { - var activeSurface: MenuBarPresentationSurface? = nil - var autoCollapseDeadline: Date? = nil -} - -struct MenuBarPresentationConfiguration: Codable, Equatable { - var preferredRevealMode: HiddenItemRevealMode = .panel - var autoCollapseAfter: TimeInterval = 5 -} - -protocol MenuBarPresentationPersisting { - func loadData(forKey key: String) -> Data? - func saveData(_ data: Data, forKey key: String) -} - -struct UserDefaultsMenuBarPresentationPersistence: MenuBarPresentationPersisting { - let defaults: UserDefaults = .standard - - func loadData(forKey key: String) -> Data? { - defaults.data(forKey: key) - } - - func saveData(_ data: Data, forKey key: String) { - defaults.set(data, forKey: key) - } -} - -@MainActor -final class MenuBarPresentationController: ObservableObject { - @Published private(set) var state = MenuBarPresentationState() - @Published var configuration: MenuBarPresentationConfiguration { - didSet { - persist() - } - } - - private let persistence: MenuBarPresentationPersisting - private let storageKey = "menuBarManagerPresentation" - private var autoCollapseTimer: Timer? - - init(persistence: MenuBarPresentationPersisting = UserDefaultsMenuBarPresentationPersistence()) { - self.persistence = persistence - if let data = persistence.loadData(forKey: storageKey), - let decoded = try? JSONDecoder().decode(MenuBarPresentationConfiguration.self, from: data) { - configuration = decoded - } else { - configuration = MenuBarPresentationConfiguration() - } - } - - func showPreferred(canPresentExpandedStrip: Bool, now: Date = Date()) { - switch configuration.preferredRevealMode { - case .expandedStrip where canPresentExpandedStrip: - activate(.expandedStrip, autoCollapseDeadline: now.addingTimeInterval(configuration.autoCollapseAfter)) - scheduleAutoCollapse(after: configuration.autoCollapseAfter) - default: - activate(.panel, autoCollapseDeadline: nil) - cancelAutoCollapse() - } - } - - func dismiss() { - cancelAutoCollapse() - state = MenuBarPresentationState() - } - - func tick(now: Date = Date()) { - guard let deadline = state.autoCollapseDeadline, now >= deadline else { - return - } - dismiss() - } - - private func activate(_ surface: MenuBarPresentationSurface, autoCollapseDeadline: Date?) { - state = MenuBarPresentationState( - activeSurface: surface, - autoCollapseDeadline: autoCollapseDeadline - ) - } - - private func scheduleAutoCollapse(after interval: TimeInterval) { - cancelAutoCollapse() - autoCollapseTimer = Timer.scheduledTimer(withTimeInterval: max(0, interval), repeats: false) { [weak self] _ in - Task { @MainActor in - self?.tick() - } - } - } - - private func cancelAutoCollapse() { - autoCollapseTimer?.invalidate() - autoCollapseTimer = nil - } - - private func persist() { - guard let data = try? JSONEncoder().encode(configuration) else { - return - } - persistence.saveData(data, forKey: storageKey) - } -} diff --git a/Tests/AwakeCupTests/MenuBarManager/AccessibilityPermissionControllerTests.swift b/Tests/AwakeCupTests/MenuBarManager/AccessibilityPermissionControllerTests.swift deleted file mode 100644 index 52a20c9..0000000 --- a/Tests/AwakeCupTests/MenuBarManager/AccessibilityPermissionControllerTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import XCTest -@testable import AwakeCup - -@MainActor -final class AccessibilityPermissionControllerTests: XCTestCase { - func testInitialStateIsDeniedAndNotManageableWhenCheckerReturnsFalse() { - let checker = StubAccessibilityTrustChecker(responses: [false]) - - let controller = AccessibilityPermissionController(checker: checker) - - XCTAssertEqual(controller.state, .denied) - XCTAssertFalse(controller.canManageItems) - XCTAssertEqual(checker.promptCalls, 0) - } - - func testInitialStateIsGrantedAndManageableWhenCheckerReturnsTrue() { - let checker = StubAccessibilityTrustChecker(responses: [true]) - - let controller = AccessibilityPermissionController(checker: checker) - - XCTAssertEqual(controller.state, .granted) - XCTAssertTrue(controller.canManageItems) - XCTAssertEqual(checker.promptCalls, 0) - } - - func testRefreshUpdatesStateAfterInitialization() { - let checker = StubAccessibilityTrustChecker(responses: [false, true]) - let controller = AccessibilityPermissionController(checker: checker) - - XCTAssertEqual(controller.state, .denied) - XCTAssertFalse(controller.canManageItems) - - controller.refresh() - - XCTAssertEqual(controller.state, .granted) - XCTAssertTrue(controller.canManageItems) - } - - func testRequestAccessPromptsAndLeavesDeniedWhenStillUntrusted() { - let checker = StubAccessibilityTrustChecker(responses: [false, false]) - let controller = AccessibilityPermissionController(checker: checker) - - XCTAssertEqual(controller.state, .denied) - XCTAssertFalse(controller.canManageItems) - XCTAssertEqual(checker.promptCalls, 0) - - controller.requestAccess() - - XCTAssertEqual(controller.state, .denied) - XCTAssertFalse(controller.canManageItems) - XCTAssertEqual(checker.promptCalls, 1) - } -} - -private final class StubAccessibilityTrustChecker: AccessibilityTrustChecking { - private var responses: [Bool] - private(set) var promptCalls: Int = 0 - - init(responses: [Bool]) { - self.responses = responses - } - - func isTrusted(prompt: Bool) -> Bool { - if prompt { promptCalls += 1 } - if responses.isEmpty { return false } - return responses.removeFirst() - } -} diff --git a/Tests/AwakeCupTests/MenuBarManager/MenuBarAXClientTests.swift b/Tests/AwakeCupTests/MenuBarManager/MenuBarAXClientTests.swift deleted file mode 100644 index 26d8053..0000000 --- a/Tests/AwakeCupTests/MenuBarManager/MenuBarAXClientTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import XCTest -@testable import AwakeCup - -final class MenuBarAXClientTests: XCTestCase { - func testObservationRegistrationRequiresAtLeastOneSuccessfulNotification() { - XCTAssertFalse( - MenuBarAXObservationRegistrationPolicy.shouldTrackObserver( - notificationResults: [.notificationUnsupported, .notificationUnsupported, .failure] - ) - ) - } - - func testObservationRegistrationTracksObserverWhenAnyNotificationSucceeds() { - XCTAssertTrue( - MenuBarAXObservationRegistrationPolicy.shouldTrackObserver( - notificationResults: [.notificationUnsupported, .success, .failure] - ) - ) - } - - func testIdentityHintPrefersAXIdentifier() { - let hint = MenuBarAXClient.identityHint( - identifier: "status-wifi", - help: "Wi-Fi", - siblingIndex: 2 - ) - - XCTAssertEqual(hint, "identifier:status-wifi") - } - - func testIdentityHintFallsBackToAXHelpWhenIdentifierMissing() { - let hint = MenuBarAXClient.identityHint( - identifier: " ", - help: "Battery", - siblingIndex: 1 - ) - - XCTAssertEqual(hint, "help:Battery") - } - - func testIdentityHintFallsBackToSiblingIndexWhenNoStableAttributesExist() { - let hint = MenuBarAXClient.identityHint( - identifier: nil, - help: nil, - siblingIndex: 3 - ) - - XCTAssertEqual(hint, "index:3") - } -} diff --git a/Tests/AwakeCupTests/MenuBarManager/MenuBarInventoryServiceTests.swift b/Tests/AwakeCupTests/MenuBarManager/MenuBarInventoryServiceTests.swift deleted file mode 100644 index ef1fdf9..0000000 --- a/Tests/AwakeCupTests/MenuBarManager/MenuBarInventoryServiceTests.swift +++ /dev/null @@ -1,195 +0,0 @@ -import CoreGraphics -import XCTest -@testable import AwakeCup - -@MainActor -final class MenuBarInventoryServiceTests: XCTestCase { - func testRefreshSortsRecordsByMenuBarPositionAndClearsPreviousError() { - let reader = StubMenuBarAXReader() - reader.fetchResult = .failure(MenuBarAXClientError.permissionDenied) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - - XCTAssertEqual(service.lastRefreshError, "Accessibility permission is required.") - - reader.fetchResult = .success([ - .init(bundleIdentifier: "b", processID: 2, title: "Two", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 80, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 20, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - ]) - - service.refresh() - - XCTAssertEqual(service.records.map(\.displayName), ["One", "Two"]) - XCTAssertEqual(service.records.map(\.frame.minX), [20, 80]) - XCTAssertNil(service.lastRefreshError) - } - - func testRefreshStoresReadableErrorWhenReaderFails() { - let reader = StubMenuBarAXReader() - reader.fetchResult = .failure(MenuBarAXClientError.permissionDenied) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - - XCTAssertEqual(service.lastRefreshError, "Accessibility permission is required.") - XCTAssertTrue(service.records.isEmpty) - } - - func testRefreshOrdersItemsWithUnknownFramesAfterKnownPositions() { - let reader = StubMenuBarAXReader() - reader.fetchResult = .success([ - .init( - bundleIdentifier: "unknown", - processID: 2, - title: "Unknown", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: nil, - actionNames: ["AXPress"] - ), - .init( - bundleIdentifier: "known", - processID: 1, - title: "Known", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 20, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - ), - ]) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - - XCTAssertEqual(service.records.map(\.displayName), ["Known", "Unknown"]) - XCTAssertEqual(service.records.map(\.hasKnownFrame), [true, false]) - } - - func testRefreshClearsStaleRecordsAndStopsObservingWhenAPreviouslySuccessfulRefreshFails() { - let reader = StubMenuBarAXReader() - reader.fetchResult = .success([ - .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 20, y: 0, width: 20, height: 24), actionNames: ["AXPress"]) - ]) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - - XCTAssertEqual(service.records.map(\.displayName), ["One"]) - XCTAssertEqual(reader.startObservationCalls.count, 1) - XCTAssertEqual(reader.stopObservationCalls, 0) - - reader.fetchResult = .failure(MenuBarAXClientError.permissionDenied) - - service.refresh() - - XCTAssertEqual(service.records, []) - XCTAssertEqual(service.lastRefreshError, "Accessibility permission is required.") - XCTAssertEqual(reader.startObservationCalls.count, 1) - XCTAssertEqual(reader.stopObservationCalls, 1) - } - - func testRefreshDoesNotDuplicateObservationWhenProcessIDsAreUnchanged() { - let reader = StubMenuBarAXReader() - reader.fetchResult = .success([ - .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 20, y: 0, width: 20, height: 24), actionNames: ["AXPress"]) - ]) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - service.refresh() - - XCTAssertEqual(reader.startObservationCalls.count, 1) - XCTAssertEqual(reader.stopObservationCalls, 0) - XCTAssertEqual(reader.startObservationCalls.first?.processIDs, Set([1])) - } - - func testRefreshReplacesObservationWhenProcessIDsChange() { - let reader = StubMenuBarAXReader() - reader.fetchResult = .success([ - .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 20, y: 0, width: 20, height: 24), actionNames: ["AXPress"]) - ]) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - - reader.fetchResult = .success([ - .init(bundleIdentifier: "b", processID: 2, title: "Two", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"]) - ]) - - service.refresh() - - XCTAssertEqual(reader.startObservationCalls.count, 2) - XCTAssertEqual(reader.stopObservationCalls, 1) - XCTAssertEqual(reader.startObservationCalls.first?.processIDs, Set([1])) - XCTAssertEqual(reader.startObservationCalls.last?.processIDs, Set([2])) - } - - func testRefreshRetriesObservationWhenReaderOnlyObservesSubsetOfRequestedProcesses() { - let reader = StubMenuBarAXReader() - reader.fetchResult = .success([ - .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 20, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - .init(bundleIdentifier: "b", processID: 2, title: "Two", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"]) - ]) - reader.observedProcessIDsResponses = [Set([1]), Set([1, 2])] - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - service.refresh() - - XCTAssertEqual(reader.startObservationCalls.count, 2) - XCTAssertEqual(reader.startObservationCalls.first?.processIDs, Set([1, 2])) - XCTAssertEqual(reader.startObservationCalls.last?.processIDs, Set([1, 2])) - XCTAssertEqual(reader.stopObservationCalls, 1) - } - - func testPressDelegatesToReaderUsingRuntimeIdentity() throws { - let record = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "a", - processID: 1, - title: "One", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 20, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - )) - let reader = StubMenuBarAXReader() - let service = MenuBarInventoryService(reader: reader) - - try service.press(record) - - XCTAssertEqual(reader.pressedIDs, [record.id]) - } -} - -@MainActor -private final class StubMenuBarAXReader: MenuBarAXReading { - var fetchResult: Result<[MenuBarItemSnapshot], Error> = .success([]) - private(set) var pressedIDs: [String] = [] - private(set) var startObservationCalls: [(processIDs: Set, onChange: () -> Void)] = [] - private(set) var stopObservationCalls = 0 - var observedProcessIDsResponses: [Set] = [] - - func fetchSnapshots() throws -> [MenuBarItemSnapshot] { - try fetchResult.get() - } - - func press(_ runtimeID: String) throws { - pressedIDs.append(runtimeID) - } - - func startObserving(processIDs: Set, onChange: @escaping () -> Void) -> Set { - startObservationCalls.append((processIDs: processIDs, onChange: onChange)) - if !observedProcessIDsResponses.isEmpty { - return observedProcessIDsResponses.removeFirst() - } - return processIDs - } - - func stopObserving() { - stopObservationCalls += 1 - } -} diff --git a/Tests/AwakeCupTests/MenuBarManager/MenuBarItemRecordTests.swift b/Tests/AwakeCupTests/MenuBarManager/MenuBarItemRecordTests.swift deleted file mode 100644 index c464031..0000000 --- a/Tests/AwakeCupTests/MenuBarManager/MenuBarItemRecordTests.swift +++ /dev/null @@ -1,520 +0,0 @@ -import CoreGraphics -import XCTest -@testable import AwakeCup - -final class MenuBarItemRecordTests: XCTestCase { - func testRecordUsesTitleWhenAvailable() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.clock", - processID: 42, - title: "Clock", - description: "Menu Bar Clock", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.displayName, "Clock") - XCTAssertEqual(record.manageability, .manageable) - } - - func testRecordFallsBackToDescriptionWhenTitleMissing() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.vpn", - processID: 77, - title: nil, - description: "VPN", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 150, y: 0, width: 18, height: 24), - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.displayName, "VPN") - } - - func testRecordFallsBackToDescriptionWhenTitleIsEmpty() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.vpn", - processID: 77, - title: "", - description: "VPN", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 150, y: 0, width: 18, height: 24), - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.displayName, "VPN") - } - - func testRecordFallsBackToDescriptionWhenTitleIsWhitespaceOnly() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.vpn", - processID: 77, - title: " ", - description: "VPN", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 150, y: 0, width: 18, height: 24), - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.displayName, "VPN") - } - - func testRecordFallsBackToBundleIdentifierWhenTitleAndDescriptionAreEmpty() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.fallback", - processID: 88, - title: "", - description: "", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 160, y: 0, width: 18, height: 24), - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.displayName, "com.example.fallback") - } - - func testRecordMarksMissingPressActionAsUnmanaged() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.readonly", - processID: 11, - title: "ReadOnly", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 180, y: 0, width: 20, height: 24), - actionNames: [] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.manageability, .unmanaged(reason: "Missing AXPress action")) - } - - func testRecordIsStillManageableWhenFrameIsMissingButPressActionExists() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.framefree", - processID: 12, - title: "FrameFree", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: nil, - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.manageability, .manageable) - } - - func testPersistentIDChangesWhenBundleChanges() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.first", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.second", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - XCTAssertNotEqual(left.persistentID, right.persistentID) - } - - func testPersistentIDIgnoresTitleAndDescriptionChanges() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.stable", - processID: 1, - title: "Clock", - description: "Menu Bar Clock", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.stable", - processID: 1, - title: "Timer", - description: "Timer Menu Bar Item", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - XCTAssertEqual(left.persistentID, right.persistentID) - } - - func testPersistentIDIgnoresActionNameOrdering() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.ordering", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXShowMenu", "AXPress"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.ordering", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress", "AXShowMenu"] - )) - - XCTAssertEqual(left.persistentID, right.persistentID) - } - - func testPersistentIDIgnoresActionSetChanges() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.actions", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress", "AXShowMenu"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.actions", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - XCTAssertEqual(left.persistentID, right.persistentID) - } - - func testRuntimeIDChangesWhenActionSetChanges() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.actions.runtime", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress", "AXShowMenu"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.actions.runtime", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - XCTAssertEqual(left.persistentID, right.persistentID) - XCTAssertNotEqual(left.runtimeID, right.runtimeID) - } - - func testPersistentIDIgnoresFrameChanges() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.geometry", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.geometry", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 220, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - XCTAssertEqual(left.persistentID, right.persistentID) - } - - func testRuntimeIDChangesWhenFrameChanges() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.runtime", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.runtime", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 220, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - XCTAssertNotEqual(left.runtimeID, right.runtimeID) - } - - func testDelimitedFieldsDoNotAliasIdentity() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example|one", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: "hint" - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem|one", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: "hint" - )) - - XCTAssertNotEqual(left.persistentID, right.persistentID) - XCTAssertNotEqual(left.runtimeID, right.runtimeID) - } - - func testWhitespaceOnlyIdentityHintBehavesLikeNoHint() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.hint", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: nil - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.hint", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: " " - )) - - XCTAssertEqual(left.persistentID, right.persistentID) - } - - func testRecordCodableRoundTripPreservesIdentities() throws { - let original = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.codable", - processID: 7, - title: "Clock", - description: "Menu Bar Clock", - role: "AXMenuBarItem", - subrole: "AXStatusItem", - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress", "AXShowMenu"], - identityHint: "primary" - )) - - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(MenuBarItemRecord.self, from: data) - - XCTAssertEqual(decoded, original) - XCTAssertEqual(decoded.persistentID, original.persistentID) - XCTAssertEqual(decoded.runtimeID, original.runtimeID) - } - - func testEncodedIdentityContractIsVersionedAndStable() { - let record = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.contract", - processID: 7, - title: "Clock", - description: "Menu Bar Clock", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress", "AXShowMenu"], - identityHint: "primary" - )) - - XCTAssertEqual( - record.persistentID, - #"[1,"com.example.contract","AXMenuBarItem",["none"],["some","primary"]]"# - ) - XCTAssertEqual( - record.runtimeID, - #"[1,"[1,\"com.example.contract\",\"AXMenuBarItem\",[\"none\"],[\"some\",\"primary\"]]","Clock",["AXPress","AXShowMenu"],[100,0,24,24]]"# - ) - } - - func testRuntimeIDDiffersForMissingFrameLabels() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.no-frame", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: nil, - actionNames: ["AXPress"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.no-frame", - processID: 1, - title: "Timer", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: nil, - actionNames: ["AXPress"] - )) - - XCTAssertNotEqual(left.runtimeID, right.runtimeID) - } - - func testPersistentIDUsesIdentityHintToDifferentiateSiblingItems() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.siblings", - processID: 1, - title: "First", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: "first-sibling" - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.siblings", - processID: 1, - title: "Second", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 120, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: "second-sibling" - )) - - XCTAssertNotEqual(left.persistentID, right.persistentID) - XCTAssertNotEqual(left.runtimeID, right.runtimeID) - } - - func testNilAndLiteralDashOptionalFieldsDoNotAliasPersistentID() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.optional", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: nil - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.optional", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: "-", - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"], - identityHint: "-" - )) - - XCTAssertNotEqual(left.persistentID, right.persistentID) - XCTAssertNotEqual(left.runtimeID, right.runtimeID) - } - - func testRuntimeIDHandlesCommaContainingActionNamesWithoutAliasing() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.comma-actions", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress,AXShowMenu"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.comma-actions", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress", "AXShowMenu"] - )) - - XCTAssertEqual(left.persistentID, right.persistentID) - XCTAssertNotEqual(left.runtimeID, right.runtimeID) - } -} diff --git a/Tests/AwakeCupTests/MenuBarManager/MenuBarLayoutStoreTests.swift b/Tests/AwakeCupTests/MenuBarManager/MenuBarLayoutStoreTests.swift deleted file mode 100644 index 4c5ddc6..0000000 --- a/Tests/AwakeCupTests/MenuBarManager/MenuBarLayoutStoreTests.swift +++ /dev/null @@ -1,200 +0,0 @@ -import Foundation -import CoreGraphics -import XCTest -@testable import AwakeCup - -final class InMemoryLayoutPersistence: MenuBarLayoutPersisting { - private var storage: [String: Data] = [:] - - func loadData(forKey key: String) -> Data? { - storage[key] - } - - func saveData(_ data: Data, forKey key: String) { - storage[key] = data - } -} - -final class CorruptLayoutPersistence: MenuBarLayoutPersisting { - func loadData(forKey key: String) -> Data? { - Data("not-json".utf8) - } - - func saveData(_ data: Data, forKey key: String) {} -} - -@MainActor -final class MenuBarLayoutStoreTests: XCTestCase { - func testAssignUsesPersistentIDAndRevealsItOnReload() { - let persistence = InMemoryLayoutPersistence() - let record = MenuBarItemRecord(snapshot: .init(bundleIdentifier: "com.example.clock", processID: 1, title: "Clock", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 10, y: 0, width: 20, height: 24), actionNames: ["AXPress"])) - let firstStore = MenuBarLayoutStore(persistence: persistence) - firstStore.assign(.hidden, toPersistentID: record.persistentID) - - let secondStore = MenuBarLayoutStore(persistence: persistence) - - XCTAssertEqual(secondStore.configuration.assignments[record.persistentID], .hidden) - } - - func testPersistedLayoutUsesPersistentIDAcrossRuntimeChanges() { - let persistence = InMemoryLayoutPersistence() - let store = MenuBarLayoutStore(persistence: persistence) - - let recordA = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.alpha", - processID: 1, - title: "Alpha", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 220, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - )) - let recordB = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.beta", - processID: 2, - title: "Beta", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 40, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - )) - - let recordAPrime = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.alpha", - processID: 11, - title: "Alpha Prime", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 40, y: 0, width: 20, height: 24), - actionNames: ["AXPress", "AXShowMenu"] - )) - let recordBPrime = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.beta", - processID: 22, - title: "Beta Prime", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 220, y: 0, width: 20, height: 24), - actionNames: ["AXPress", "AXShowMenu"] - )) - - XCTAssertEqual(recordA.persistentID, recordAPrime.persistentID) - XCTAssertEqual(recordB.persistentID, recordBPrime.persistentID) - XCTAssertNotEqual(recordA.runtimeID, recordAPrime.runtimeID) - XCTAssertNotEqual(recordB.runtimeID, recordBPrime.runtimeID) - - store.assign(.hidden, toPersistentID: recordA.persistentID) - store.assign(.hidden, toPersistentID: recordB.persistentID) - store.updateOrder(for: .hidden, persistentIDs: [recordA.persistentID, recordB.persistentID]) - - let reloadedStore = MenuBarLayoutStore(persistence: persistence) - let inventory = [recordAPrime, recordBPrime] - - XCTAssertEqual(reloadedStore.section(for: recordAPrime), .hidden) - XCTAssertEqual(reloadedStore.section(for: recordBPrime), .hidden) - XCTAssertEqual(reloadedStore.configuration.orderBySection[.hidden], [recordA.persistentID, recordB.persistentID]) - XCTAssertEqual(reloadedStore.orderedItems(inventory: inventory, section: .hidden).map(\.displayName), ["Alpha Prime", "Beta Prime"]) - } - - func testOrderedItemsRespectsSavedOrderWithinSection() { - let persistence = InMemoryLayoutPersistence() - let store = MenuBarLayoutStore(persistence: persistence) - let one = MenuBarItemRecord(snapshot: .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 10, y: 0, width: 20, height: 24), actionNames: ["AXPress"])) - let two = MenuBarItemRecord(snapshot: .init(bundleIdentifier: "b", processID: 2, title: "Two", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"])) - - store.assign(.hidden, toPersistentID: two.persistentID) - store.assign(.hidden, toPersistentID: one.persistentID) - store.updateOrder(for: .hidden, persistentIDs: [two.persistentID, one.persistentID]) - - let inventory = [one, two] - let reloadedStore = MenuBarLayoutStore(persistence: persistence) - - XCTAssertEqual(reloadedStore.configuration.orderBySection[.hidden], [two.persistentID, one.persistentID]) - - let hidden = reloadedStore.orderedItems(inventory: inventory, section: .hidden) - - XCTAssertEqual(hidden.map(\.displayName), ["Two", "One"]) - } - - func testUnassignedItemsDefaultToAlwaysVisible() { - let store = MenuBarLayoutStore(persistence: InMemoryLayoutPersistence()) - let inventory = [ - MenuBarItemRecord(snapshot: .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 10, y: 0, width: 20, height: 24), actionNames: ["AXPress"])), - ] - - let visible = store.orderedItems(inventory: inventory, section: .alwaysVisible) - - XCTAssertEqual(visible.map(\.displayName), ["One"]) - } - - func testOrderedItemsPlacesUnknownFramesAfterKnownPositionsWhenNoSavedOrderExists() { - let store = MenuBarLayoutStore(persistence: InMemoryLayoutPersistence()) - let known = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "known", - processID: 1, - title: "Known", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 10, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - )) - let unknown = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "unknown", - processID: 2, - title: "Unknown", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: nil, - actionNames: ["AXPress"] - )) - - store.assign(.hidden, toPersistentID: known.persistentID) - store.assign(.hidden, toPersistentID: unknown.persistentID) - - let ordered = store.orderedItems(inventory: [unknown, known], section: .hidden) - - XCTAssertEqual(ordered.map(\.displayName), ["Known", "Unknown"]) - XCTAssertEqual(ordered.map(\.hasKnownFrame), [true, false]) - } - - func testAssignBatchUpdatesAllProvidedPersistentIDs() { - let store = MenuBarLayoutStore(persistence: InMemoryLayoutPersistence()) - let first = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "first", - processID: 1, - title: "First", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 10, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - )) - let second = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "second", - processID: 2, - title: "Second", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 40, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - )) - - store.assign(.hidden, toPersistentIDs: [first.persistentID, second.persistentID]) - - XCTAssertEqual(store.section(for: first), .hidden) - XCTAssertEqual(store.section(for: second), .hidden) - } - - func testCorruptPersistedJSONFallsBackToDefaultConfiguration() { - let store = MenuBarLayoutStore(persistence: CorruptLayoutPersistence()) - - XCTAssertEqual(store.configuration, MenuBarLayoutConfiguration()) - } -} diff --git a/Tests/AwakeCupTests/MenuBarManager/MenuBarManagerViewModelTests.swift b/Tests/AwakeCupTests/MenuBarManager/MenuBarManagerViewModelTests.swift deleted file mode 100644 index 8ff6a6d..0000000 --- a/Tests/AwakeCupTests/MenuBarManager/MenuBarManagerViewModelTests.swift +++ /dev/null @@ -1,645 +0,0 @@ -import CoreGraphics -import XCTest -@testable import AwakeCup - -@MainActor -final class MenuBarManagerViewModelTests: XCTestCase { - func testAutoCollapsedExpandedStripDismissesOverlay() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let reader = StubMenuBarAXReader(records: [hidden]) - let inventory = MenuBarInventoryService(reader: reader) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration = MenuBarPresentationConfiguration( - preferredRevealMode: .expandedStrip, - autoCollapseAfter: 0.01 - ) - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - viewModel.showHiddenItems() - - XCTAssertEqual(overlay.lastPresentedStripIDs, [hidden.id]) - - RunLoop.main.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertNil(presentation.state.activeSurface) - XCTAssertEqual(overlay.lastPresentedStripIDs, []) - XCTAssertGreaterThanOrEqual(overlay.dismissExpandedStripCalls, 1) - } - - func testRefreshBuildsVisibleHiddenAndUnmanagedCollections() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let visible = makeRecord( - bundleIdentifier: "com.example.visible", - processID: 1, - title: "Visible", - originX: 10, - actionNames: ["AXPress"] - ) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let unmanaged = makeRecord( - bundleIdentifier: "com.example.unmanaged", - processID: 3, - title: "Unmanaged", - originX: 70, - actionNames: [] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden, unmanaged, visible])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - - XCTAssertTrue(viewModel.canManageItems) - XCTAssertEqual(viewModel.alwaysVisibleItems.map(\.displayName), ["Visible", "Unmanaged"]) - XCTAssertEqual(viewModel.hiddenItems.map(\.displayName), ["Hidden"]) - XCTAssertEqual(viewModel.unmanagedItems.map(\.displayName), ["Unmanaged"]) - XCTAssertEqual(overlay.lastHiddenMaskIDs, [hidden.id]) - } - - func testRefreshHidesHiddenMaskWhenHiddenItemFrameIsUnavailable() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: nil, - actionNames: ["AXPress"] - )) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - - XCTAssertEqual(overlay.hideHiddenMaskCalls, 1) - XCTAssertEqual(overlay.lastHiddenMaskIDs, []) - } - - func testRefreshClearsCollectionsAndOverlaysWhenPermissionIsDenied() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [false, false])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - - XCTAssertFalse(viewModel.canManageItems) - XCTAssertTrue(viewModel.alwaysVisibleItems.isEmpty) - XCTAssertTrue(viewModel.hiddenItems.isEmpty) - XCTAssertTrue(viewModel.unmanagedItems.isEmpty) - XCTAssertEqual(overlay.hideHiddenMaskCalls, 1) - XCTAssertEqual(overlay.dismissExpandedStripCalls, 1) - } - - func testShowHiddenItemsPresentsExpandedStripWhenPreferredSurfaceIsActive() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .expandedStrip - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - viewModel.showHiddenItems() - - XCTAssertEqual(presentation.state.activeSurface, .expandedStrip) - XCTAssertEqual(overlay.lastPresentedStripIDs, [hidden.id]) - } - - func testRevealHiddenItemsButtonIsHiddenForPanelMode() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .panel - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - - XCTAssertFalse(viewModel.shouldShowRevealHiddenItemsButton) - XCTAssertFalse(viewModel.canRevealHiddenItemsInPreferredMode) - } - - func testRevealHiddenItemsButtonIsDisabledWhenExpandedStripCannotBePresented() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: [] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .expandedStrip - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - - XCTAssertTrue(viewModel.shouldShowRevealHiddenItemsButton) - XCTAssertFalse(viewModel.canRevealHiddenItemsInPreferredMode) - } - - func testExpandedStripForwardsPressedItemThroughViewModel() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let reader = StubMenuBarAXReader(records: [hidden]) - let inventory = MenuBarInventoryService(reader: reader) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .expandedStrip - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - viewModel.showHiddenItems() - overlay.simulatePresentedStripPress(for: hidden) - - XCTAssertEqual(reader.pressedIDs, [hidden.id]) - XCTAssertNil(viewModel.inventoryErrorMessage) - } - - func testExpandedStripDismissesWhenForwardedPressFails() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let reader = StubMenuBarAXReader(records: [hidden]) - reader.pressError = MenuBarAXClientError.pressFailed(hidden.id) - let inventory = MenuBarInventoryService(reader: reader) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .expandedStrip - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - viewModel.showHiddenItems() - overlay.simulatePresentedStripPress(for: hidden) - - XCTAssertNil(presentation.state.activeSurface) - XCTAssertEqual(overlay.lastPresentedStripIDs, []) - XCTAssertGreaterThanOrEqual(overlay.dismissExpandedStripCalls, 1) - XCTAssertEqual( - viewModel.inventoryErrorMessage, - "Unable to press menu bar item \(hidden.id)." - ) - } - - func testShowHiddenItemsFallsBackToPanelWhenHiddenItemFrameIsUnavailable() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: nil, - actionNames: ["AXPress"] - )) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .expandedStrip - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - viewModel.showHiddenItems() - - XCTAssertEqual(presentation.state.activeSurface, .panel) - XCTAssertEqual(overlay.lastPresentedStripIDs, []) - } - - func testShowHiddenItemsFallsBackToPanelWhenHiddenItemCannotBePressed() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: [] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .expandedStrip - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - viewModel.showHiddenItems() - - XCTAssertEqual(presentation.state.activeSurface, .panel) - XCTAssertEqual(overlay.lastPresentedStripIDs, []) - } - - func testSetPreferredRevealModeDismissesExpandedStripWhenSwitchingBackToPanel() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [hidden])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - presentation.configuration.preferredRevealMode = .expandedStrip - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - viewModel.showHiddenItems() - let dismissCallsBeforeSwitch = overlay.dismissExpandedStripCalls - viewModel.setPreferredRevealMode(.panel) - - XCTAssertNil(presentation.state.activeSurface) - XCTAssertEqual(overlay.lastPresentedStripIDs, []) - XCTAssertEqual(overlay.dismissExpandedStripCalls, dismissCallsBeforeSwitch + 1) - XCTAssertEqual(viewModel.preferredRevealMode, .panel) - } - - func testRefreshStopsInventoryObservationWhenPermissionIsRevoked() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true, false])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let reader = StubMenuBarAXReader(records: [hidden]) - let inventory = MenuBarInventoryService(reader: reader) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - - XCTAssertEqual(reader.startObservationProcessIDs, [Set([hidden.processID])]) - XCTAssertEqual(reader.stopObservationCalls, 0) - XCTAssertEqual(inventory.records.map(\.displayName), ["Hidden"]) - - viewModel.refresh() - - XCTAssertFalse(viewModel.canManageItems) - XCTAssertEqual(reader.stopObservationCalls, 1) - XCTAssertTrue(inventory.records.isEmpty) - } - - func testPressStoresReadableFailureMessage() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true])) - let hidden = makeRecord( - bundleIdentifier: "com.example.hidden", - processID: 2, - title: "Hidden", - originX: 40, - actionNames: ["AXPress"] - ) - let reader = StubMenuBarAXReader(records: [hidden]) - reader.pressError = MenuBarAXClientError.pressFailed(hidden.id) - let inventory = MenuBarInventoryService(reader: reader) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - layout.assign(.hidden, toPersistentID: hidden.persistentID) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - - viewModel.refresh() - XCTAssertNil(viewModel.inventoryErrorMessage) - - viewModel.press(hidden) - - XCTAssertEqual( - viewModel.inventoryErrorMessage, - "Unable to press menu bar item \(hidden.id)." - ) - XCTAssertEqual(viewModel.hiddenItems.map(\.displayName), ["Hidden"]) - } - - func testHideOtherVisibleItemsMovesOnlyNonAppItemsToHidden() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true, true, true])) - let appItem = makeRecord( - bundleIdentifier: "com.awakecup.app", - processID: 1, - title: "AwakeCup", - originX: 10, - actionNames: ["AXPress"] - ) - let externalItem = makeRecord( - bundleIdentifier: "com.example.clock", - processID: 2, - title: "Clock", - originX: 40, - actionNames: [] - ) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(records: [appItem, externalItem])) - let layout = MenuBarLayoutStore(persistence: InMemoryMenuBarLayoutPersistence()) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay, - currentAppBundleIdentifier: "com.awakecup.app" - ) - - viewModel.refresh() - viewModel.hideOtherVisibleItems() - - XCTAssertEqual(viewModel.alwaysVisibleItems.map(\.displayName), ["AwakeCup"]) - XCTAssertEqual(viewModel.hiddenItems.map(\.displayName), ["Clock"]) - XCTAssertEqual(layout.section(for: appItem), .alwaysVisible) - XCTAssertEqual(layout.section(for: externalItem), .hidden) - XCTAssertEqual(overlay.lastHiddenMaskIDs, [externalItem.id]) - } - - private func makeRecord( - bundleIdentifier: String, - processID: Int32, - title: String, - originX: CGFloat, - actionNames: [String] - ) -> MenuBarItemRecord { - MenuBarItemRecord(snapshot: .init( - bundleIdentifier: bundleIdentifier, - processID: processID, - title: title, - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: originX, y: 0, width: 20, height: 24), - actionNames: actionNames - )) - } -} - -private final class InMemoryMenuBarLayoutPersistence: MenuBarLayoutPersisting { - private var storage: [String: Data] = [:] - - func loadData(forKey key: String) -> Data? { - storage[key] - } - - func saveData(_ data: Data, forKey key: String) { - storage[key] = data - } -} - -@MainActor -private final class StubMenuBarOverlayController: MenuBarOverlayControlling { - private(set) var lastHiddenMaskIDs: [String] = [] - private(set) var lastPresentedStripIDs: [String] = [] - private(set) var hideHiddenMaskCalls = 0 - private(set) var dismissExpandedStripCalls = 0 - private var presentedStripPressHandler: ((MenuBarItemRecord) -> Void)? - - func updateHiddenMask(for hiddenItems: [MenuBarItemRecord]) { - lastHiddenMaskIDs = hiddenItems.map(\.id) - } - - func hideHiddenMask() { - hideHiddenMaskCalls += 1 - lastHiddenMaskIDs = [] - } - - func presentExpandedStrip( - for hiddenItems: [MenuBarItemRecord], - onPress: @escaping (MenuBarItemRecord) -> Void - ) { - lastPresentedStripIDs = hiddenItems.map(\.id) - presentedStripPressHandler = onPress - } - - func dismissExpandedStrip() { - dismissExpandedStripCalls += 1 - lastPresentedStripIDs = [] - presentedStripPressHandler = nil - } - - func simulatePresentedStripPress(for item: MenuBarItemRecord) { - presentedStripPressHandler?(item) - } -} - -private final class StubAccessibilityTrustChecker: AccessibilityTrustChecking { - private var responses: [Bool] - - init(responses: [Bool]) { - self.responses = responses - } - - func isTrusted(prompt: Bool) -> Bool { - guard !responses.isEmpty else { - return false - } - return responses.removeFirst() - } -} - -@MainActor -private final class StubMenuBarAXReader: MenuBarAXReading { - private let records: [MenuBarItemRecord] - private(set) var pressedIDs: [String] = [] - private(set) var startObservationProcessIDs: [Set] = [] - private(set) var stopObservationCalls = 0 - var pressError: Error? - - init(records: [MenuBarItemRecord]) { - self.records = records - } - - func fetchSnapshots() throws -> [MenuBarItemSnapshot] { - records.map { - MenuBarItemSnapshot( - bundleIdentifier: $0.bundleIdentifier, - processID: $0.processID, - title: $0.displayName, - description: nil, - role: $0.axRole, - subrole: $0.axSubrole, - frame: $0.hasKnownFrame ? $0.frame : nil, - actionNames: $0.actionNames - ) - } - } - - func press(_ runtimeID: String) throws { - if let pressError { - throw pressError - } - pressedIDs.append(runtimeID) - } - - func startObserving(processIDs: Set, onChange: @escaping () -> Void) -> Set { - startObservationProcessIDs.append(processIDs) - return processIDs - } - - func stopObserving() { - stopObservationCalls += 1 - } -} diff --git a/Tests/AwakeCupTests/MenuBarManager/MenuBarPresentationControllerTests.swift b/Tests/AwakeCupTests/MenuBarManager/MenuBarPresentationControllerTests.swift deleted file mode 100644 index ed36ac0..0000000 --- a/Tests/AwakeCupTests/MenuBarManager/MenuBarPresentationControllerTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation -import XCTest -@testable import AwakeCup - -@MainActor -final class MenuBarPresentationControllerTests: XCTestCase { - func testShowPreferredAutoCollapsesExpandedStripAfterConfiguredDelay() { - let controller = MenuBarPresentationController() - controller.configuration = MenuBarPresentationConfiguration( - preferredRevealMode: .expandedStrip, - autoCollapseAfter: 0.01 - ) - - controller.showPreferred(canPresentExpandedStrip: true) - - XCTAssertEqual(controller.state.activeSurface, .expandedStrip) - - RunLoop.main.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertNil(controller.state.activeSurface) - XCTAssertNil(controller.state.autoCollapseDeadline) - } - - func testShowPreferredUsesExpandedStripWhenAvailable() { - let controller = MenuBarPresentationController() - controller.configuration = MenuBarPresentationConfiguration( - preferredRevealMode: .expandedStrip, - autoCollapseAfter: 5 - ) - let now = Date(timeIntervalSince1970: 10) - - controller.showPreferred(canPresentExpandedStrip: true, now: now) - - XCTAssertEqual(controller.state.activeSurface, .expandedStrip) - XCTAssertEqual(controller.state.autoCollapseDeadline, now.addingTimeInterval(5)) - } - - func testShowPreferredFallsBackToPanelWhenExpandedStripUnavailable() { - let controller = MenuBarPresentationController() - controller.configuration = MenuBarPresentationConfiguration( - preferredRevealMode: .expandedStrip, - autoCollapseAfter: 5 - ) - let now = Date(timeIntervalSince1970: 10) - - controller.showPreferred(canPresentExpandedStrip: false, now: now) - - XCTAssertEqual(controller.state.activeSurface, .panel) - XCTAssertNil(controller.state.autoCollapseDeadline) - } - - func testShowPreferredUsesPanelWhenPanelIsPreferred() { - let controller = MenuBarPresentationController() - controller.configuration = MenuBarPresentationConfiguration( - preferredRevealMode: .panel, - autoCollapseAfter: 5 - ) - let now = Date(timeIntervalSince1970: 10) - - controller.showPreferred(canPresentExpandedStrip: true, now: now) - - XCTAssertEqual(controller.state.activeSurface, .panel) - XCTAssertNil(controller.state.autoCollapseDeadline) - } - - func testDismissClearsActiveSurfaceAndDeadline() { - let controller = MenuBarPresentationController() - controller.configuration = MenuBarPresentationConfiguration( - preferredRevealMode: .expandedStrip, - autoCollapseAfter: 5 - ) - let now = Date(timeIntervalSince1970: 10) - - controller.showPreferred(canPresentExpandedStrip: true, now: now) - controller.dismiss() - - XCTAssertNil(controller.state.activeSurface) - XCTAssertNil(controller.state.autoCollapseDeadline) - } - - func testTickDismissesExpandedStripAfterDeadline() { - let controller = MenuBarPresentationController() - controller.configuration = MenuBarPresentationConfiguration( - preferredRevealMode: .expandedStrip, - autoCollapseAfter: 5 - ) - let now = Date(timeIntervalSince1970: 10) - - controller.showPreferred(canPresentExpandedStrip: true, now: now) - controller.tick(now: now.addingTimeInterval(4)) - - XCTAssertEqual(controller.state.activeSurface, .expandedStrip) - XCTAssertEqual(controller.state.autoCollapseDeadline, now.addingTimeInterval(5)) - - controller.tick(now: now.addingTimeInterval(5)) - - XCTAssertNil(controller.state.activeSurface) - XCTAssertNil(controller.state.autoCollapseDeadline) - } -} diff --git a/Tests/AwakeCupTests/TopMenuBarAutoHideTests.swift b/Tests/AwakeCupTests/TopMenuBarAutoHideTests.swift deleted file mode 100644 index 09ae2c0..0000000 --- a/Tests/AwakeCupTests/TopMenuBarAutoHideTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import AppKit -import XCTest -@testable import AwakeCup - -final class TopMenuBarAutoHideTests: XCTestCase { - func testEnablingAutoHideMenuBarPreservesUnrelatedOptions() { - let updatedOptions = TopMenuBarAutoHide.options( - from: [.autoHideDock], - enabled: true - ) - - XCTAssertTrue(updatedOptions.contains(.autoHideMenuBar)) - XCTAssertTrue(updatedOptions.contains(.autoHideDock)) - } - - func testEnablingAutoHideMenuBarRemovesHardHideMenuBar() { - let updatedOptions = TopMenuBarAutoHide.options( - from: [.hideMenuBar], - enabled: true - ) - - XCTAssertTrue(updatedOptions.contains(.autoHideMenuBar)) - XCTAssertFalse(updatedOptions.contains(.hideMenuBar)) - } - - func testDisablingAutoHideMenuBarLeavesUnrelatedOptions() { - let updatedOptions = TopMenuBarAutoHide.options( - from: [.autoHideMenuBar, .autoHideDock], - enabled: false - ) - - XCTAssertFalse(updatedOptions.contains(.autoHideMenuBar)) - XCTAssertTrue(updatedOptions.contains(.autoHideDock)) - } -} diff --git a/docs/superpowers/plans/2026-04-17-menu-bar-manager-plan.md b/docs/superpowers/plans/2026-04-17-menu-bar-manager-plan.md deleted file mode 100644 index a85e1ec..0000000 --- a/docs/superpowers/plans/2026-04-17-menu-bar-manager-plan.md +++ /dev/null @@ -1,1562 +0,0 @@ -# Menu Bar Manager Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a menu bar manager that can discover third-party menu bar extras, let the user choose which ones stay visible, and expose hidden items through both a panel and a temporary expanded strip. - -**Architecture:** Keep the existing AwakeCup sleep-prevention flow intact and add the menu bar manager as a new set of focused files under `Sources/AwakeCup/MenuBarManager/`. Build the feature in three layers: pure models and persistence first, Accessibility permission and live inventory second, then SwiftUI/AppKit integration for the menu bar panel, settings window, and expanded-strip overlay. - -**Tech Stack:** SwiftUI, AppKit, ApplicationServices Accessibility APIs (`AXUIElement`, `AXObserver`), XCTest, JSON persistence, `@AppStorage` - ---- - -## Planned File Structure - -- Modify: `Package.swift` - Adds `ApplicationServices` so the target can compile Accessibility-driven code. -- Modify: `Sources/AwakeCup/AwakeCup.swift` - Keeps the existing caffeine UI and icon logic, but wires in the new menu bar manager state, settings window, and window-style `MenuBarExtra`. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarItemRecord.swift` - Pure models for discovered items, fingerprinting, sectioning, and unsupported-item classification. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarLayoutStore.swift` - JSON-backed assignment/order/reveal-mode persistence plus reconciliation helpers. -- Create: `Sources/AwakeCup/MenuBarManager/AccessibilityPermissionController.swift` - Trusted-client state and prompt flow around `AXIsProcessTrustedWithOptions`. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarAXClient.swift` - Live Accessibility traversal and observer registration against app extras menu bars. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarInventoryService.swift` - Observable inventory service that refreshes records and handles AX read failures. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarPresentationController.swift` - Pure reveal-state controller for panel vs expanded-strip behavior and auto-collapse. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarOverlayController.swift` - AppKit windows that mask hidden items and render the temporary expanded strip. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarManagerViewModel.swift` - Bridges permission, inventory, layout, and presentation state into SwiftUI. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarManagerSettingsView.swift` - Primary management UI for `Always Visible` and `Hidden`. -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarExtraContentView.swift` - Window-style menu bar panel that combines current AwakeCup controls with hidden-item actions. -- Create: `Tests/AwakeCupTests/MenuBarManager/MenuBarItemRecordTests.swift` -- Create: `Tests/AwakeCupTests/MenuBarManager/MenuBarLayoutStoreTests.swift` -- Create: `Tests/AwakeCupTests/MenuBarManager/AccessibilityPermissionControllerTests.swift` -- Create: `Tests/AwakeCupTests/MenuBarManager/MenuBarInventoryServiceTests.swift` -- Create: `Tests/AwakeCupTests/MenuBarManager/MenuBarPresentationControllerTests.swift` -- Create: `Tests/AwakeCupTests/MenuBarManager/MenuBarManagerViewModelTests.swift` - -## Task 1: Add the item model and unsupported-item classification - -**Files:** -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarItemRecord.swift` -- Test: `Tests/AwakeCupTests/MenuBarManager/MenuBarItemRecordTests.swift` - -- [ ] **Step 1: Write the failing tests for display-name fallback, stable IDs, and unsupported items** - -```swift -import CoreGraphics -import XCTest -@testable import AwakeCup - -final class MenuBarItemRecordTests: XCTestCase { - func testRecordUsesTitleWhenAvailable() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.clock", - processID: 42, - title: "Clock", - description: "Menu Bar Clock", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.displayName, "Clock") - XCTAssertEqual(record.manageability, .manageable) - } - - func testRecordFallsBackToDescriptionWhenTitleMissing() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.vpn", - processID: 77, - title: nil, - description: "VPN", - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 150, y: 0, width: 18, height: 24), - actionNames: ["AXPress"] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.displayName, "VPN") - } - - func testRecordMarksMissingPressActionAsUnmanaged() { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: "com.example.readonly", - processID: 11, - title: "ReadOnly", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 180, y: 0, width: 20, height: 24), - actionNames: [] - ) - - let record = MenuBarItemRecord(snapshot: snapshot) - - XCTAssertEqual(record.manageability, .unmanaged(reason: "Missing AXPress action")) - } - - func testStableIDChangesWhenBundleChanges() { - let left = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.first", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - let right = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "com.example.second", - processID: 1, - title: "Clock", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 100, y: 0, width: 24, height: 24), - actionNames: ["AXPress"] - )) - - XCTAssertNotEqual(left.persistentID, right.persistentID) - } -} -``` - -- [ ] **Step 2: Run the focused test target and verify it fails because the new model does not exist yet** - -Run: `swift test --filter MenuBarItemRecordTests` - -Expected: build failure mentioning missing types like `MenuBarItemSnapshot` and `MenuBarItemRecord`. - -- [ ] **Step 3: Implement the pure item model** - -```swift -import CoreGraphics -import Foundation - -enum MenuBarItemSection: String, Codable, CaseIterable, Equatable { - case alwaysVisible - case hidden -} - -enum MenuBarItemManageability: Codable, Equatable { - case manageable - case unmanaged(reason: String) -} - -struct MenuBarItemSnapshot: Equatable { - let bundleIdentifier: String - let processID: Int32 - let title: String? - let description: String? - let role: String? - let subrole: String? - let frame: CGRect? - let actionNames: [String] -} - -struct MenuBarItemRecord: Identifiable, Codable, Equatable { - let persistentID: String - let runtimeID: String - let bundleIdentifier: String - let processID: Int32 - let displayName: String - let axRole: String - let axSubrole: String? - let frame: CGRect - let actionNames: [String] - let manageability: MenuBarItemManageability - - var id: String { runtimeID } - - init(snapshot: MenuBarItemSnapshot) { - let role = snapshot.role ?? "AXUnknown" - let frame = snapshot.frame ?? .zero - let sortedActionNames = snapshot.actionNames.sorted() - let displayName = snapshot.title?.nonEmpty - ?? snapshot.description?.nonEmpty - ?? snapshot.bundleIdentifier - - let manageability: MenuBarItemManageability = snapshot.actionNames.contains("AXPress") - ? .manageable - : .unmanaged(reason: "Missing AXPress action") - - let coarseX = Int(frame.minX.rounded()) - let coarseY = Int(frame.minY.rounded()) - let coarseWidth = Int(frame.width.rounded()) - let coarseHeight = Int(frame.height.rounded()) - - self.persistentID = [ - snapshot.bundleIdentifier, - role, - snapshot.subrole ?? "-", - ].joined(separator: "|") - self.runtimeID = [ - persistentID, - displayName, - sortedActionNames.joined(separator: ","), - "\(coarseX)", - "\(coarseY)", - "\(coarseWidth)", - "\(coarseHeight)", - ].joined(separator: "|") - self.bundleIdentifier = snapshot.bundleIdentifier - self.processID = snapshot.processID - self.displayName = displayName - self.axRole = role - self.axSubrole = snapshot.subrole - self.frame = frame - self.actionNames = sortedActionNames - self.manageability = manageability - } -} - -private extension String { - var nonEmpty: String? { - isEmpty ? nil : self - } -} -``` - -- [ ] **Step 4: Re-run the tests and verify they pass** - -Run: `swift test --filter MenuBarItemRecordTests` - -Expected: `Executed 4 tests, with 0 failures`. - -- [ ] **Step 5: Commit the model** - -```bash -git add Sources/AwakeCup/MenuBarManager/MenuBarItemRecord.swift Tests/AwakeCupTests/MenuBarManager/MenuBarItemRecordTests.swift -git commit -m "feat: add menu bar item record model" -``` - ---- - -## Task 2: Add persisted layout state and reconciliation - -**Files:** -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarLayoutStore.swift` -- Test: `Tests/AwakeCupTests/MenuBarManager/MenuBarLayoutStoreTests.swift` - -- [ ] **Step 1: Write the failing tests for assignment persistence and section ordering** - -```swift -import CoreGraphics -import XCTest -@testable import AwakeCup - -final class MenuBarLayoutStoreTests: XCTestCase { - func testAssignStoresSectionAndRevealsItOnReload() { - let persistence = InMemoryLayoutPersistence() - let firstStore = MenuBarLayoutStore(persistence: persistence) - firstStore.assign(.hidden, to: "clock") - - let secondStore = MenuBarLayoutStore(persistence: persistence) - - XCTAssertEqual(secondStore.configuration.assignments["clock"], .hidden) - } - - func testOrderedItemsRespectsSavedOrderWithinSection() { - let persistence = InMemoryLayoutPersistence() - let store = MenuBarLayoutStore(persistence: persistence) - store.assign(.hidden, to: "two") - store.assign(.hidden, to: "one") - store.updateOrder(for: .hidden, ids: ["two", "one"]) - - let inventory = [ - MenuBarItemRecord(snapshot: .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 10, y: 0, width: 20, height: 24), actionNames: ["AXPress"])), - MenuBarItemRecord(snapshot: .init(bundleIdentifier: "b", processID: 2, title: "Two", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"])), - ] - - let hidden = store.orderedItems(inventory: inventory, section: .hidden) - - XCTAssertEqual(hidden.map(\.displayName), ["Two", "One"]) - } - - func testUnassignedItemsDefaultToAlwaysVisible() { - let store = MenuBarLayoutStore(persistence: InMemoryLayoutPersistence()) - let inventory = [ - MenuBarItemRecord(snapshot: .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 10, y: 0, width: 20, height: 24), actionNames: ["AXPress"])), - ] - - let visible = store.orderedItems(inventory: inventory, section: .alwaysVisible) - - XCTAssertEqual(visible.map(\.displayName), ["One"]) - } -} -``` - -- [ ] **Step 2: Run the focused tests and verify they fail because the store does not exist yet** - -Run: `swift test --filter MenuBarLayoutStoreTests` - -Expected: build failure mentioning missing `MenuBarLayoutStore` and `InMemoryLayoutPersistence`. - -- [ ] **Step 3: Implement the layout store and JSON persistence** - -```swift -import Foundation - -enum HiddenItemRevealMode: String, Codable, CaseIterable { - case panel - case expandedStrip -} - -struct MenuBarLayoutConfiguration: Codable, Equatable { - var assignments: [String: MenuBarItemSection] = [:] - var orderBySection: [MenuBarItemSection: [String]] = [:] - var defaultRevealMode: HiddenItemRevealMode = .panel - var autoCollapseSeconds: TimeInterval = 8 - var isEnabled: Bool = true -} - -protocol MenuBarLayoutPersisting { - func loadData(forKey key: String) -> Data? - func saveData(_ data: Data, forKey key: String) -} - -struct UserDefaultsLayoutPersistence: MenuBarLayoutPersisting { - let defaults: UserDefaults = .standard - - func loadData(forKey key: String) -> Data? { - defaults.data(forKey: key) - } - - func saveData(_ data: Data, forKey key: String) { - defaults.set(data, forKey: key) - } -} - -final class InMemoryLayoutPersistence: MenuBarLayoutPersisting { - private var storage: [String: Data] = [:] - - func loadData(forKey key: String) -> Data? { - storage[key] - } - - func saveData(_ data: Data, forKey key: String) { - storage[key] = data - } -} - -@MainActor -final class MenuBarLayoutStore: ObservableObject { - @Published private(set) var configuration: MenuBarLayoutConfiguration - - private let persistence: MenuBarLayoutPersisting - private let storageKey = "menuBarManagerLayout" - - init(persistence: MenuBarLayoutPersisting = UserDefaultsLayoutPersistence()) { - self.persistence = persistence - if let data = persistence.loadData(forKey: storageKey), - let decoded = try? JSONDecoder().decode(MenuBarLayoutConfiguration.self, from: data) { - self.configuration = decoded - } else { - self.configuration = MenuBarLayoutConfiguration() - } - } - - func assign(_ section: MenuBarItemSection, to itemID: String) { - configuration.assignments[itemID] = section - persist() - } - - func updateOrder(for section: MenuBarItemSection, ids: [String]) { - configuration.orderBySection[section] = ids - persist() - } - - func setDefaultRevealMode(_ mode: HiddenItemRevealMode) { - configuration.defaultRevealMode = mode - persist() - } - - func section(for item: MenuBarItemRecord) -> MenuBarItemSection { - configuration.assignments[item.persistentID] ?? .alwaysVisible - } - - func orderedItems(inventory: [MenuBarItemRecord], section: MenuBarItemSection) -> [MenuBarItemRecord] { - let records = inventory.filter { self.section(for: $0) == section } - let order = configuration.orderBySection[section] ?? [] - - return records.sorted { left, right in - let leftIndex = order.firstIndex(of: left.persistentID) ?? .max - let rightIndex = order.firstIndex(of: right.persistentID) ?? .max - if leftIndex != rightIndex { return leftIndex < rightIndex } - return left.frame.minX < right.frame.minX - } - } - - private func persist() { - let data = try! JSONEncoder().encode(configuration) - persistence.saveData(data, forKey: storageKey) - } -} -``` - -- [ ] **Step 4: Re-run the layout tests and verify they pass** - -Run: `swift test --filter MenuBarLayoutStoreTests` - -Expected: `Executed 3 tests, with 0 failures`. - -- [ ] **Step 5: Commit the store** - -```bash -git add Sources/AwakeCup/MenuBarManager/MenuBarLayoutStore.swift Tests/AwakeCupTests/MenuBarManager/MenuBarLayoutStoreTests.swift -git commit -m "feat: add menu bar layout store" -``` - ---- - -## Task 3: Add Accessibility permission state and link `ApplicationServices` - -**Files:** -- Modify: `Package.swift` -- Create: `Sources/AwakeCup/MenuBarManager/AccessibilityPermissionController.swift` -- Test: `Tests/AwakeCupTests/MenuBarManager/AccessibilityPermissionControllerTests.swift` - -- [ ] **Step 1: Write the failing tests for permission refresh and prompt flow** - -```swift -import XCTest -@testable import AwakeCup - -@MainActor -final class AccessibilityPermissionControllerTests: XCTestCase { - func testRefreshSetsGrantedWhenCheckerReturnsTrue() { - let checker = StubAccessibilityTrustChecker(responses: [false, true]) - let controller = AccessibilityPermissionController(checker: checker) - - controller.refresh() - XCTAssertEqual(controller.state, .denied) - - controller.refresh() - XCTAssertEqual(controller.state, .granted) - } - - func testRequestAccessPromptsAndLeavesDeniedWhenStillUntrusted() { - let checker = StubAccessibilityTrustChecker(responses: [false]) - let controller = AccessibilityPermissionController(checker: checker) - - controller.requestAccess() - - XCTAssertEqual(controller.state, .denied) - XCTAssertEqual(checker.promptCalls, 1) - } -} -``` - -- [ ] **Step 2: Run the focused test target and verify it fails** - -Run: `swift test --filter AccessibilityPermissionControllerTests` - -Expected: build failure for missing `AccessibilityPermissionController` and `StubAccessibilityTrustChecker`. - -- [ ] **Step 3: Implement the controller and add the missing framework link** - -```swift -// Package.swift linker settings excerpt -.linkedFramework("ApplicationServices"), -``` - -```swift -import ApplicationServices -import Foundation - -protocol AccessibilityTrustChecking { - func isTrusted(prompt: Bool) -> Bool -} - -struct LiveAccessibilityTrustChecker: AccessibilityTrustChecking { - func isTrusted(prompt: Bool) -> Bool { - let options: CFDictionary? = prompt - ? [kAXTrustedCheckOptionPrompt as String: true] as CFDictionary - : nil - return AXIsProcessTrustedWithOptions(options) - } -} - -final class StubAccessibilityTrustChecker: AccessibilityTrustChecking { - private var responses: [Bool] - private(set) var promptCalls: Int = 0 - - init(responses: [Bool]) { - self.responses = responses - } - - func isTrusted(prompt: Bool) -> Bool { - if prompt { promptCalls += 1 } - if responses.isEmpty { return false } - return responses.removeFirst() - } -} - -@MainActor -final class AccessibilityPermissionController: ObservableObject { - enum State: Equatable { - case unknown - case granted - case denied - } - - @Published private(set) var state: State = .unknown - - var canManageItems: Bool { state == .granted } - - private let checker: AccessibilityTrustChecking - - init(checker: AccessibilityTrustChecking = LiveAccessibilityTrustChecker()) { - self.checker = checker - } - - func refresh() { - state = checker.isTrusted(prompt: false) ? .granted : .denied - } - - func requestAccess() { - state = checker.isTrusted(prompt: true) ? .granted : .denied - } -} -``` - -- [ ] **Step 4: Re-run the permission tests and verify they pass** - -Run: `swift test --filter AccessibilityPermissionControllerTests` - -Expected: `Executed 2 tests, with 0 failures`. - -- [ ] **Step 5: Commit the permission layer** - -```bash -git add Package.swift Sources/AwakeCup/MenuBarManager/AccessibilityPermissionController.swift Tests/AwakeCupTests/MenuBarManager/AccessibilityPermissionControllerTests.swift -git commit -m "feat: add accessibility permission controller" -``` - ---- - -## Task 4: Add the live AX reader and observable inventory service - -**Files:** -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarAXClient.swift` -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarInventoryService.swift` -- Test: `Tests/AwakeCupTests/MenuBarManager/MenuBarInventoryServiceTests.swift` - -- [ ] **Step 1: Write the failing tests for sorting and error propagation** - -```swift -import CoreGraphics -import XCTest -@testable import AwakeCup - -final class MenuBarInventoryServiceTests: XCTestCase { - @MainActor - func testRefreshSortsRecordsByMenuBarPosition() { - let reader = StubMenuBarAXReader(result: .success([ - .init(bundleIdentifier: "b", processID: 2, title: "Two", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 80, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - .init(bundleIdentifier: "a", processID: 1, title: "One", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 20, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - ])) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - - XCTAssertEqual(service.records.map(\.displayName), ["One", "Two"]) - XCTAssertNil(service.lastRefreshError) - } - - @MainActor - func testRefreshStoresReadableErrorWhenReaderFails() { - let reader = StubMenuBarAXReader(result: .failure(MenuBarAXClientError.permissionDenied)) - let service = MenuBarInventoryService(reader: reader) - - service.refresh() - - XCTAssertEqual(service.lastRefreshError, "Accessibility permission is required.") - } - - @MainActor - func testPressDelegatesToReader() throws { - let record = MenuBarItemRecord(snapshot: .init( - bundleIdentifier: "a", - processID: 1, - title: "One", - description: nil, - role: "AXMenuBarItem", - subrole: nil, - frame: CGRect(x: 20, y: 0, width: 20, height: 24), - actionNames: ["AXPress"] - )) - let reader = StubMenuBarAXReader(result: .success([])) - let service = MenuBarInventoryService(reader: reader) - - try service.press(record) - - XCTAssertEqual(reader.pressedIDs, [record.id]) - } -} -``` - -- [ ] **Step 2: Run the inventory tests and verify they fail** - -Run: `swift test --filter MenuBarInventoryServiceTests` - -Expected: build failure mentioning missing `MenuBarInventoryService`, `StubMenuBarAXReader`, and `MenuBarAXClientError`. - -- [ ] **Step 3: Implement the AX reader and inventory service** - -```swift -import AppKit -import ApplicationServices -import Foundation - -enum MenuBarAXClientError: LocalizedError { - case permissionDenied - - var errorDescription: String? { - switch self { - case .permissionDenied: - return "Accessibility permission is required." - } - } -} - -protocol MenuBarAXReading { - func fetchSnapshots() throws -> [MenuBarItemSnapshot] - func pressItem(withID id: String) throws - func startObserving(processes: Set, onChange: @escaping () -> Void) - func stopObserving() -} - -final class StubMenuBarAXReader: MenuBarAXReading { - var result: Result<[MenuBarItemSnapshot], Error> - private(set) var pressedIDs: [String] = [] - - init(result: Result<[MenuBarItemSnapshot], Error>) { - self.result = result - } - - func fetchSnapshots() throws -> [MenuBarItemSnapshot] { - try result.get() - } - - func pressItem(withID id: String) throws { - pressedIDs.append(id) - } - - func startObserving(processes: Set, onChange: @escaping () -> Void) {} - func stopObserving() {} -} - -final class LiveMenuBarAXClient: MenuBarAXReading { - private var observers: [Int32: AXObserver] = [:] - private var elementsByID: [String: AXUIElement] = [:] - - func fetchSnapshots() throws -> [MenuBarItemSnapshot] { - guard AXIsProcessTrusted() else { throw MenuBarAXClientError.permissionDenied } - - var snapshots: [MenuBarItemSnapshot] = [] - elementsByID = [:] - - for app in NSWorkspace.shared.runningApplications where app.processIdentifier > 0 { - let application = AXUIElementCreateApplication(app.processIdentifier) - var extrasMenuBarValue: CFTypeRef? - let extrasAttribute = NSAccessibility.Attribute.extrasMenuBar.rawValue as CFString - let extrasResult = AXUIElementCopyAttributeValue(application, extrasAttribute, &extrasMenuBarValue) - guard extrasResult == .success, let extrasMenuBar = extrasMenuBarValue else { continue } - - var childrenValue: CFTypeRef? - let childrenResult = AXUIElementCopyAttributeValue(extrasMenuBar as! AXUIElement, kAXChildrenAttribute as CFString, &childrenValue) - guard childrenResult == .success, let children = childrenValue as? [AXUIElement] else { continue } - - for child in children { - let snapshot = MenuBarItemSnapshot( - bundleIdentifier: app.bundleIdentifier ?? "unknown.bundle", - processID: app.processIdentifier, - title: copyStringAttribute(kAXTitleAttribute, from: child), - description: copyStringAttribute(kAXDescriptionAttribute, from: child), - role: copyStringAttribute(kAXRoleAttribute, from: child), - subrole: copyStringAttribute(kAXSubroleAttribute, from: child), - frame: copyFrame(from: child), - actionNames: copyActionNames(from: child) - ) - let record = MenuBarItemRecord(snapshot: snapshot) - elementsByID[record.id] = child - snapshots.append(snapshot) - } - } - - return snapshots - } - - func pressItem(withID id: String) throws { - guard let element = elementsByID[id] else { return } - _ = AXUIElementPerformAction(element, kAXPressAction as CFString) - } - - func startObserving(processes: Set, onChange: @escaping () -> Void) { - stopObserving() - - for pid in processes { - var observer: AXObserver? - guard AXObserverCreate(pid, { _, _, _, refcon in - let callback = Unmanaged Void>>.fromOpaque(refcon!).takeUnretainedValue() - callback.value() - }, &observer) == .success, let observer else { continue } - - let callbackBox = Unmanaged.passRetained(Box(onChange)) - let appElement = AXUIElementCreateApplication(pid) - _ = AXObserverAddNotification(observer, appElement, kAXCreatedNotification as CFString, callbackBox.toOpaque()) - _ = AXObserverAddNotification(observer, appElement, kAXMovedNotification as CFString, callbackBox.toOpaque()) - CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) - observers[pid] = observer - } - } - - func stopObserving() { - observers.removeAll() - } - - private func copyStringAttribute(_ attribute: String, from element: AXUIElement) -> String? { - var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil } - return value as? String - } - - private func copyActionNames(from element: AXUIElement) -> [String] { - var value: CFArray? - guard AXUIElementCopyActionNames(element, &value) == .success, let array = value as? [String] else { return [] } - return array - } - - private func copyFrame(from element: AXUIElement) -> CGRect? { - var positionValue: CFTypeRef? - var sizeValue: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionValue) == .success, - AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeValue) == .success, - let positionAX = positionValue as? AXValue, - let sizeAX = sizeValue as? AXValue else { return nil } - - var point = CGPoint.zero - var size = CGSize.zero - AXValueGetValue(positionAX, .cgPoint, &point) - AXValueGetValue(sizeAX, .cgSize, &size) - return CGRect(origin: point, size: size) - } -} - -private final class Box { - let value: T - init(_ value: T) { self.value = value } -} -``` - -```swift -import Foundation - -@MainActor -final class MenuBarInventoryService: ObservableObject { - @Published private(set) var records: [MenuBarItemRecord] = [] - @Published private(set) var lastRefreshError: String? - - private let reader: MenuBarAXReading - - init(reader: MenuBarAXReading = LiveMenuBarAXClient()) { - self.reader = reader - } - - func refresh() { - do { - let snapshots = try reader.fetchSnapshots() - let records = snapshots - .map(MenuBarItemRecord.init(snapshot:)) - .sorted { $0.frame.minX < $1.frame.minX } - - self.records = records - self.lastRefreshError = nil - reader.startObserving(processes: Set(records.map(\.processID))) { [weak self] in - Task { @MainActor in - self?.refresh() - } - } - } catch { - self.lastRefreshError = error.localizedDescription - } - } - - func press(_ record: MenuBarItemRecord) throws { - try reader.pressItem(withID: record.id) - } -} -``` - -- [ ] **Step 4: Re-run the inventory tests and verify they pass** - -Run: `swift test --filter MenuBarInventoryServiceTests` - -Expected: `Executed 3 tests, with 0 failures`. - -- [ ] **Step 5: Commit the inventory layer** - -```bash -git add Sources/AwakeCup/MenuBarManager/MenuBarAXClient.swift Sources/AwakeCup/MenuBarManager/MenuBarInventoryService.swift Tests/AwakeCupTests/MenuBarManager/MenuBarInventoryServiceTests.swift -git commit -m "feat: add menu bar inventory service" -``` - ---- - -## Task 5: Add reveal-state logic and the overlay windows - -**Files:** -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarPresentationController.swift` -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarOverlayController.swift` -- Test: `Tests/AwakeCupTests/MenuBarManager/MenuBarPresentationControllerTests.swift` - -- [ ] **Step 1: Write the failing tests for preferred reveal mode, fallback, and auto-collapse** - -```swift -import XCTest -@testable import AwakeCup - -@MainActor -final class MenuBarPresentationControllerTests: XCTestCase { - func testShowPreferredUsesExpandedStripWhenAvailable() { - let controller = MenuBarPresentationController() - controller.configuration.defaultRevealMode = .expandedStrip - - controller.showPreferred(canPresentExpandedStrip: true, now: .init(timeIntervalSince1970: 10)) - - XCTAssertEqual(controller.state.activeSurface, .expandedStrip) - } - - func testShowPreferredFallsBackToPanelWhenStripUnavailable() { - let controller = MenuBarPresentationController() - controller.configuration.defaultRevealMode = .expandedStrip - - controller.showPreferred(canPresentExpandedStrip: false, now: .init(timeIntervalSince1970: 10)) - - XCTAssertEqual(controller.state.activeSurface, .panel) - } - - func testTickAutoDismissesExpandedStripAfterDeadline() { - let controller = MenuBarPresentationController() - controller.configuration.defaultRevealMode = .expandedStrip - controller.configuration.autoCollapseSeconds = 5 - controller.showPreferred(canPresentExpandedStrip: true, now: .init(timeIntervalSince1970: 10)) - - controller.tick(now: .init(timeIntervalSince1970: 16)) - - XCTAssertNil(controller.state.activeSurface) - } -} -``` - -- [ ] **Step 2: Run the focused tests and verify they fail** - -Run: `swift test --filter MenuBarPresentationControllerTests` - -Expected: build failure mentioning missing `MenuBarPresentationController`. - -- [ ] **Step 3: Implement the state controller and overlay windows** - -```swift -import Foundation - -enum HiddenItemsSurface: Equatable { - case panel - case expandedStrip -} - -struct HiddenItemsPresentationState: Equatable { - var activeSurface: HiddenItemsSurface? - var autoCollapseDeadline: Date? -} - -@MainActor -final class MenuBarPresentationController: ObservableObject { - @Published private(set) var state = HiddenItemsPresentationState() - var configuration = MenuBarLayoutConfiguration() - - func showPreferred(canPresentExpandedStrip: Bool, now: Date = Date()) { - if configuration.defaultRevealMode == .expandedStrip, canPresentExpandedStrip { - state.activeSurface = .expandedStrip - state.autoCollapseDeadline = now.addingTimeInterval(configuration.autoCollapseSeconds) - } else { - state.activeSurface = .panel - state.autoCollapseDeadline = nil - } - } - - func dismiss() { - state.activeSurface = nil - state.autoCollapseDeadline = nil - } - - func tick(now: Date = Date()) { - guard let deadline = state.autoCollapseDeadline, now >= deadline else { return } - dismiss() - } -} -``` - -```swift -import AppKit -import SwiftUI - -@MainActor -protocol MenuBarOverlayControlling { - func updateHiddenMask(for hiddenItems: [MenuBarItemRecord]) - func presentExpandedStrip(for hiddenItems: [MenuBarItemRecord]) - func dismissExpandedStrip() - func hideHiddenMask() -} - -@MainActor -final class MenuBarOverlayController: MenuBarOverlayControlling { - private var maskWindow: NSWindow? - private var stripWindow: NSPanel? - - func updateHiddenMask(for hiddenItems: [MenuBarItemRecord]) { - guard let bounds = hiddenItems.boundingRect else { - hideHiddenMask() - return - } - - let window = makeBorderlessWindow(frame: bounds, background: NSColor.windowBackgroundColor) - window.ignoresMouseEvents = true - window.orderFrontRegardless() - maskWindow = window - } - - func presentExpandedStrip(for hiddenItems: [MenuBarItemRecord]) { - guard let frame = hiddenItems.boundingRect else { return } - let panel = NSPanel( - contentRect: frame, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - panel.level = .statusBar - panel.isOpaque = false - panel.backgroundColor = .clear - panel.contentView = NSHostingView(rootView: - HStack(spacing: 8) { - ForEach(hiddenItems) { item in - Text(item.displayName) - .lineLimit(1) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.regularMaterial, in: Capsule()) - } - } - .padding(8) - ) - panel.orderFrontRegardless() - stripWindow = panel - } - - func dismissExpandedStrip() { - stripWindow?.orderOut(nil) - stripWindow = nil - } - - func hideHiddenMask() { - maskWindow?.orderOut(nil) - maskWindow = nil - } - - private func makeBorderlessWindow(frame: CGRect, background: NSColor) -> NSWindow { - let window = NSWindow( - contentRect: frame, - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.level = .statusBar - window.isOpaque = false - window.backgroundColor = background - return window - } -} - -@MainActor -final class StubMenuBarOverlayController: MenuBarOverlayControlling { - private(set) var lastHiddenMaskIDs: [String] = [] - private(set) var lastPresentedStripIDs: [String] = [] - private(set) var dismissExpandedStripCalls: Int = 0 - private(set) var hideHiddenMaskCalls: Int = 0 - - func updateHiddenMask(for hiddenItems: [MenuBarItemRecord]) { - lastHiddenMaskIDs = hiddenItems.map(\.id) - } - - func presentExpandedStrip(for hiddenItems: [MenuBarItemRecord]) { - lastPresentedStripIDs = hiddenItems.map(\.id) - } - - func dismissExpandedStrip() { - dismissExpandedStripCalls += 1 - } - - func hideHiddenMask() { - hideHiddenMaskCalls += 1 - } -} - -private extension Array where Element == MenuBarItemRecord { - var boundingRect: CGRect? { - guard let first = first else { return nil } - return dropFirst().reduce(first.frame) { partial, next in - partial.union(next.frame) - } - } -} -``` - -- [ ] **Step 4: Re-run the presentation tests and verify they pass** - -Run: `swift test --filter MenuBarPresentationControllerTests` - -Expected: `Executed 3 tests, with 0 failures`. - -- [ ] **Step 5: Commit the reveal layer** - -```bash -git add Sources/AwakeCup/MenuBarManager/MenuBarPresentationController.swift Sources/AwakeCup/MenuBarManager/MenuBarOverlayController.swift Tests/AwakeCupTests/MenuBarManager/MenuBarPresentationControllerTests.swift -git commit -m "feat: add hidden item presentation controllers" -``` - ---- - -## Task 6: Wire the manager into the menu bar panel and settings window - -**Files:** -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarManagerViewModel.swift` -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarManagerSettingsView.swift` -- Create: `Sources/AwakeCup/MenuBarManager/MenuBarExtraContentView.swift` -- Test: `Tests/AwakeCupTests/MenuBarManager/MenuBarManagerViewModelTests.swift` -- Modify: `Sources/AwakeCup/AwakeCup.swift` - -- [ ] **Step 1: Write the failing view-model tests for grouping items and permission fallback** - -```swift -import CoreGraphics -import XCTest -@testable import AwakeCup - -@MainActor -final class MenuBarManagerViewModelTests: XCTestCase { - func testRefreshBuildsVisibleAndHiddenCollections() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true])) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(result: .success([ - .init(bundleIdentifier: "a", processID: 1, title: "Visible", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 10, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - .init(bundleIdentifier: "b", processID: 2, title: "Hidden", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - ])) - let layout = MenuBarLayoutStore(persistence: InMemoryLayoutPersistence()) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel(permission: permission, inventory: inventory, layout: layout, presentation: presentation, overlay: overlay) - - layout.assign(.alwaysVisible, to: MenuBarItemRecord(snapshot: .init(bundleIdentifier: "a", processID: 1, title: "Visible", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 10, y: 0, width: 20, height: 24), actionNames: ["AXPress"])).persistentID) - layout.assign(.hidden, to: MenuBarItemRecord(snapshot: .init(bundleIdentifier: "b", processID: 2, title: "Hidden", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"])).persistentID) - - viewModel.refresh() - - XCTAssertEqual(viewModel.alwaysVisibleItems.map(\.displayName), ["Visible"]) - XCTAssertEqual(viewModel.hiddenItems.map(\.displayName), ["Hidden"]) - } - - func testRefreshStopsWhenPermissionIsDenied() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [false])) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(result: .success([]))) - let layout = MenuBarLayoutStore(persistence: InMemoryLayoutPersistence()) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel(permission: permission, inventory: inventory, layout: layout, presentation: presentation, overlay: overlay) - - viewModel.refresh() - - XCTAssertFalse(viewModel.canManageItems) - XCTAssertTrue(viewModel.hiddenItems.isEmpty) - } - - func testShowHiddenItemsPresentsExpandedStripWhenPreferred() { - let permission = AccessibilityPermissionController(checker: StubAccessibilityTrustChecker(responses: [true])) - let inventory = MenuBarInventoryService(reader: StubMenuBarAXReader(result: .success([ - .init(bundleIdentifier: "b", processID: 2, title: "Hidden", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"]), - ])) - let layout = MenuBarLayoutStore(persistence: InMemoryLayoutPersistence()) - layout.setDefaultRevealMode(.expandedStrip) - let presentation = MenuBarPresentationController() - let overlay = StubMenuBarOverlayController() - let viewModel = MenuBarManagerViewModel(permission: permission, inventory: inventory, layout: layout, presentation: presentation, overlay: overlay) - let hiddenRecord = MenuBarItemRecord(snapshot: .init(bundleIdentifier: "b", processID: 2, title: "Hidden", description: nil, role: "AXMenuBarItem", subrole: nil, frame: CGRect(x: 40, y: 0, width: 20, height: 24), actionNames: ["AXPress"])) - layout.assign(.hidden, to: hiddenRecord.persistentID) - - viewModel.refresh() - viewModel.showHiddenItems() - - XCTAssertEqual(overlay.lastPresentedStripIDs, [hiddenRecord.id]) - } -} -``` - -- [ ] **Step 2: Run the focused tests and verify they fail** - -Run: `swift test --filter MenuBarManagerViewModelTests` - -Expected: build failure mentioning missing `MenuBarManagerViewModel`. - -- [ ] **Step 3: Implement the view model** - -```swift -import Foundation - -@MainActor -final class MenuBarManagerViewModel: ObservableObject { - @Published private(set) var canManageItems: Bool = false - @Published private(set) var alwaysVisibleItems: [MenuBarItemRecord] = [] - @Published private(set) var hiddenItems: [MenuBarItemRecord] = [] - @Published private(set) var unmanagedItems: [MenuBarItemRecord] = [] - - private let permission: AccessibilityPermissionController - private let inventory: MenuBarInventoryService - private let layout: MenuBarLayoutStore - private let presentation: MenuBarPresentationController - private let overlay: MenuBarOverlayControlling - - init( - permission: AccessibilityPermissionController, - inventory: MenuBarInventoryService, - layout: MenuBarLayoutStore, - presentation: MenuBarPresentationController, - overlay: MenuBarOverlayControlling - ) { - self.permission = permission - self.inventory = inventory - self.layout = layout - self.presentation = presentation - self.overlay = overlay - } - - func requestAccess() { - permission.requestAccess() - refresh() - } - - func refresh() { - permission.refresh() - canManageItems = permission.canManageItems - guard canManageItems else { - alwaysVisibleItems = [] - hiddenItems = [] - unmanagedItems = [] - overlay.hideHiddenMask() - overlay.dismissExpandedStrip() - return - } - - inventory.refresh() - let manageableItems = inventory.records.filter { - if case .manageable = $0.manageability { return true } - return false - } - alwaysVisibleItems = layout.orderedItems(inventory: manageableItems, section: .alwaysVisible) - hiddenItems = layout.orderedItems(inventory: manageableItems, section: .hidden) - unmanagedItems = inventory.records.filter { - if case .unmanaged = $0.manageability { return true } - return false - } - overlay.updateHiddenMask(for: hiddenItems) - } - - func move(_ item: MenuBarItemRecord, to section: MenuBarItemSection) { - layout.assign(section, to: item.persistentID) - refresh() - } - - func showHiddenItems() { - presentation.configuration = layout.configuration - presentation.showPreferred(canPresentExpandedStrip: !hiddenItems.isEmpty) - if presentation.state.activeSurface == .expandedStrip { - overlay.presentExpandedStrip(for: hiddenItems) - } else { - overlay.dismissExpandedStrip() - } - } - - func press(_ item: MenuBarItemRecord) { - try? inventory.press(item) - } - - func dismissHiddenItems() { - presentation.dismiss() - overlay.dismissExpandedStrip() - } -} -``` - -- [ ] **Step 4: Implement the settings view and menu bar panel content** - -```swift -import SwiftUI - -struct MenuBarManagerSettingsView: View { - @ObservedObject var viewModel: MenuBarManagerViewModel - - var body: some View { - List { - Section("Always Visible") { - ForEach(viewModel.alwaysVisibleItems) { item in - row(for: item, target: .hidden, buttonTitle: "隐藏") - } - } - - Section("Hidden") { - ForEach(viewModel.hiddenItems) { item in - row(for: item, target: .alwaysVisible, buttonTitle: "显示") - } - } - - if !viewModel.unmanagedItems.isEmpty { - Section("Unsupported") { - ForEach(viewModel.unmanagedItems) { item in - VStack(alignment: .leading) { - Text(item.displayName) - if case let .unmanaged(reason) = item.manageability { - Text(reason) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - } - .frame(minWidth: 420, minHeight: 360) - } - - private func row(for item: MenuBarItemRecord, target: MenuBarItemSection, buttonTitle: String) -> some View { - HStack { - VStack(alignment: .leading) { - Text(item.displayName) - Text(item.bundleIdentifier) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button(buttonTitle) { - viewModel.move(item, to: target) - } - } - } -} -``` - -```swift -import SwiftUI - -struct MenuBarExtraContentView: View { - let controls: Controls - @ObservedObject var manager: MenuBarManagerViewModel - @Environment(\.openWindow) private var openWindow - - init( - manager: MenuBarManagerViewModel, - @ViewBuilder controls: () -> Controls - ) { - self.controls = controls() - self._manager = ObservedObject(wrappedValue: manager) - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - controls - - Divider() - - if manager.canManageItems { - Text("隐藏项:\(manager.hiddenItems.count)") - .font(.headline) - ForEach(manager.hiddenItems) { item in - Button(item.displayName) { - manager.press(item) - } - } - Button("显示隐藏项") { - manager.showHiddenItems() - } - Button("打开菜单栏管理") { - openWindow(id: "menu-bar-manager-settings") - } - } else { - Text("需要辅助功能权限才能管理其他应用图标。") - .font(.callout) - Button("请求权限") { - manager.requestAccess() - } - } - } - .padding(12) - .frame(minWidth: 320) - } - .onAppear { - manager.refresh() - } - } -} -``` - -- [ ] **Step 5: Update the app scene wiring in `Sources/AwakeCup/AwakeCup.swift`** - -First extract the current `MenuBarExtra` content block into `private var currentControls: some View` in the same file without changing any caffeine behavior. Then wire the app scenes like this: - -```swift -@StateObject private var permissionController = AccessibilityPermissionController() -@StateObject private var inventoryService = MenuBarInventoryService() -@StateObject private var layoutStore = MenuBarLayoutStore() -@StateObject private var presentationController = MenuBarPresentationController() -private let overlayController: MenuBarOverlayControlling -@StateObject private var menuBarManager: MenuBarManagerViewModel - -init() { - let permission = AccessibilityPermissionController() - let inventory = MenuBarInventoryService() - let layout = MenuBarLayoutStore() - let presentation = MenuBarPresentationController() - let overlay = MenuBarOverlayController() - _permissionController = StateObject(wrappedValue: permission) - _inventoryService = StateObject(wrappedValue: inventory) - _layoutStore = StateObject(wrappedValue: layout) - _presentationController = StateObject(wrappedValue: presentation) - self.overlayController = overlay - _menuBarManager = StateObject( - wrappedValue: MenuBarManagerViewModel( - permission: permission, - inventory: inventory, - layout: layout, - presentation: presentation, - overlay: overlay - ) - ) -} - -private var currentControls: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text(modeStatusText(system: caffeine.isSystemActive, display: caffeine.isDisplayActive)) - Spacer() - if caffeine.isSystemActive || caffeine.isDisplayActive { - Button("停止") { caffeine.deactivate() } - .keyboardShortcut("s") - } - } - - if (caffeine.isSystemActive || caffeine.isDisplayActive), let until = caffeine.activeUntil { - Text("将在 \(until.formatted(date: .omitted, time: .shortened)) 停止") - .font(.caption) - .foregroundStyle(.secondary) - } - - Divider() - - Toggle("开机自启动", isOn: $launchAtLogin) - .disabled(isApplyingLaunchAtLogin) - .onAppear { - launchAtLogin = LaunchAtLogin.currentEnabledState() - } - .onChange(of: launchAtLogin) { newValue in - guard !isApplyingLaunchAtLogin else { return } - isApplyingLaunchAtLogin = true - Task { @MainActor in - defer { isApplyingLaunchAtLogin = false } - do { - try LaunchAtLogin.setEnabled(newValue) - } catch { - launchAtLogin.toggle() - } - } - } - - Text("保持目标") - .font(.caption) - .foregroundStyle(.secondary) - - Picker("保持目标", selection: $selectedMode) { - ForEach(CaffeineManager.Mode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - .labelsHidden() - .pickerStyle(.menu) - - Text("开始保持唤醒") - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 4) { - TextField("30", text: $customDurationValue) - .textFieldStyle(.roundedBorder) - .frame(width: 50) - .onChange(of: customDurationValue) { newValue in - let filtered = newValue.filter { $0.isNumber } - if filtered != newValue { - customDurationValue = filtered - } - } - - Picker("", selection: $customDurationUnit) { - ForEach(CustomDurationEntry.Unit.allCases) { unit in - Text(unit.displayName).tag(unit) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: 60) - - Button("开始") { - activateCustom() - } - .disabled(!canActivateCustom) - .buttonStyle(.bordered) - } - - if !history.isEmpty { - HStack(spacing: 2) { - Text("最近:") - .font(.caption) - .foregroundStyle(.secondary) - ForEach(history) { entry in - Button(entry.displayTitle) { - activateFromHistory(entry) - } - .font(.caption) - .buttonStyle(.link) - if entry.id != history.last?.id { - Text("·") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - Divider() - - ForEach(DurationOption.allCases) { option in - Button(option.title) { - if let seconds = option.seconds { - caffeine.activate(mode: selectedMode, for: seconds) - } else { - caffeine.activateIndefinitely(mode: selectedMode) - } - } - .disabled((caffeine.isSystemActive || caffeine.isDisplayActive) && option == .indefinite && caffeine.activeUntil == nil && caffeine.activeMode == selectedMode) - } - - Divider() - - Button("退出") { NSApp.terminate(nil) } - .keyboardShortcut("q") - } -} - -var body: some Scene { - MenuBarExtra { - MenuBarExtraContentView(manager: menuBarManager) { - currentControls - } - } label: { - Image(nsImage: MenuBarIcon.makeImage( - isSystemActive: caffeine.isSystemActive, - isDisplayActive: caffeine.isDisplayActive, - activeMode: caffeine.activeMode, - activationStartTime: caffeine.activationStartTime, - activeUntil: caffeine.activeUntil - )) - .help(menuBarHelpText( - system: caffeine.isSystemActive, - display: caffeine.isDisplayActive, - activeUntil: caffeine.activeUntil - )) - } - .menuBarExtraStyle(.window) - - Window("菜单栏管理", id: "menu-bar-manager-settings") { - MenuBarManagerSettingsView(viewModel: menuBarManager) - } -} -``` - -- [ ] **Step 6: Re-run the focused view-model tests and the full suite** - -Run: `swift test --filter MenuBarManagerViewModelTests` - -Expected: `Executed 3 tests, with 0 failures`. - -Run: `swift test` - -Expected: all existing and new tests pass with `0 failures`. - -- [ ] **Step 7: Commit the integrated UI** - -```bash -git add Sources/AwakeCup/AwakeCup.swift Sources/AwakeCup/MenuBarManager/MenuBarManagerViewModel.swift Sources/AwakeCup/MenuBarManager/MenuBarManagerSettingsView.swift Sources/AwakeCup/MenuBarManager/MenuBarExtraContentView.swift Tests/AwakeCupTests/MenuBarManager/MenuBarManagerViewModelTests.swift -git commit -m "feat: wire menu bar manager UI" -``` - ---- - -## Task 7: Run full verification and do the manual menu-bar-manager pass - -**Files:** -- Modify only if verification reveals issues. - -- [ ] **Step 1: Run the full automated test suite** - -Run: `swift test` - -Expected: all tests pass with `0 failures`. - -- [ ] **Step 2: Build the app in debug mode** - -Run: `swift build` - -Expected: `Build complete!` - -- [ ] **Step 3: Launch the app locally** - -Run: `swift run` - -Expected: the AwakeCup menu bar icon appears; on first use of the manager the app prompts for Accessibility permission. - -- [ ] **Step 4: Manually verify the five required user flows** - -1. Start with Accessibility denied and confirm the menu bar panel shows a permission CTA instead of partial inventory data. -2. Grant Accessibility and reopen the panel; confirm the settings window lists discovered items under `Always Visible` and `Hidden`. -3. Move one manageable item to `Hidden` and confirm it disappears from the normal visible region under the mask overlay. -4. Trigger `显示隐藏项` and confirm the item becomes reachable through the temporary expanded strip and then auto-collapses. -5. Confirm the existing AwakeCup caffeine controls still activate and deactivate sleep prevention exactly as before. - -- [ ] **Step 5: If verification required changes, commit the fixes** - -```bash -git add -A -git commit -m "fix: address menu bar manager verification issues" -``` diff --git a/docs/superpowers/specs/2026-04-17-menu-bar-manager-design.md b/docs/superpowers/specs/2026-04-17-menu-bar-manager-design.md deleted file mode 100644 index e7b360a..0000000 --- a/docs/superpowers/specs/2026-04-17-menu-bar-manager-design.md +++ /dev/null @@ -1,307 +0,0 @@ -# Menu Bar Manager Feature — Design Specification - -## Overview - -Extend AwakeCup from a single-purpose menu bar utility into a menu bar manager that can collect third-party menu bar icons, let the user decide which items remain visible, and provide two ways to access hidden items: - -- a hidden-items panel opened from AwakeCup's own menu bar icon -- a temporary expanded strip that visually re-exposes hidden items in the menu bar area - -The feature targets macOS menu bar extras owned by other applications. Because AppKit only exposes public APIs for an app's own `NSStatusItem` lifecycle, third-party item management must be built around Accessibility (`AXUIElement`) observation and interaction instead of direct status-item ownership. - -## Goals - -- Discover menu bar items from other running apps that are accessible through the macOS Accessibility API. -- Let the user classify items into two groups: `Always Visible` and `Hidden`. -- Keep AwakeCup as the control point for hidden items. -- Support both access flows: - - open hidden items inside a dedicated panel - - temporarily expose hidden items in a strip aligned with the menu bar -- Persist item grouping, order, and default reveal behavior across launches. -- Degrade safely when permissions are missing or specific items cannot be managed reliably. - -## Non-Goals - -- Re-register or truly re-parent another app's `NSStatusItem` into AwakeCup. -- Use private APIs, code injection, SIP workarounds, or unsupported menu bar hooks. -- Implement advanced triggers in v1 such as hover reveal, scroll reveal, hotkeys, profiles, or search. -- Build a drag-and-drop layout editor in the first iteration. -- Guarantee management of every menu bar extra on the system. Some items may lack sufficient AX metadata and must remain unmanaged. - -## Constraints - -### Public API Boundary - -Apple's public `NSStatusBar` / `NSStatusItem` APIs allow an app to create and remove its own status items only. There is no public AppKit API for hiding or moving another app's status item. The design therefore treats third-party item management as an Accessibility-driven observation and interaction problem. - -### Accessibility Requirement - -The feature requires the app to be a trusted Accessibility client via `AXIsProcessTrustedWithOptions`. Without that permission, AwakeCup must not enter a partial management state. Instead, it should show a permission onboarding flow and keep third-party item management disabled. - -### Reliability Boundary - -Third-party menu bar items vary in AX quality. Some expose stable labels and actions; some do not. The system must classify unsupported or unstable items as `unmanaged` rather than forcing them into hidden behavior. - -## Runtime Model - -AwakeCup remains a menu bar-first app and continues to launch as an accessory application. The app gains a separate settings window, but does not become a Dock-first product. - -Primary entry points: - -- `MenuBarExtra`: quick actions, reveal actions, permission state, and shortcut to settings -- `Settings window`: primary management surface for item visibility and ordering - -The app only activates to the foreground when opening settings or permission guidance. - -## Architecture - -The current single-file structure in `Sources/AwakeCup/AwakeCup.swift` should be split so the new feature does not compound all behavior into one root file. - -### Module Boundaries - -#### `AccessibilityPermissionController` - -Responsibilities: - -- Check whether AwakeCup is trusted for Accessibility access. -- Request the system prompt through `AXIsProcessTrustedWithOptions`. -- Publish permission state to SwiftUI. -- Expose guidance UI state for onboarding and recovery. - -#### `MenuBarInventoryService` - -Responsibilities: - -- Enumerate accessible menu bar items from the system menu bar. -- Normalize raw AX elements into app-level records. -- Track ownership metadata such as bundle ID, process ID, name, available actions, and geometry. -- Refresh inventory on demand and in response to AX notifications where available. - -#### `MenuBarLayoutStore` - -Responsibilities: - -- Persist item grouping (`alwaysVisible`, `hidden`, `unmanaged`). -- Persist user ordering. -- Persist menu-bar-manager preferences such as default reveal mode and auto-collapse timing. -- Reconcile saved layout against newly scanned inventory. - -#### `MenuBarOverlayController` - -Responsibilities: - -- Control the visual hidden area / temporary expanded strip in the menu bar region. -- Show and hide the expanded strip on demand. -- Auto-collapse the strip after a timeout or loss of interaction. -- Keep geometry aligned with the current screen and menu bar metrics. - -#### `HiddenItemsPanelController` - -Responsibilities: - -- Present the hidden-items panel from AwakeCup's menu bar icon. -- Render hidden items in a compact, clickable list/grid. -- Forward user interaction from panel items to the original AX item. - -## Core Interaction Model - -### Menu Bar Layout - -The conceptual layout for v1 is: - -`Always Visible Items | AwakeCup Control Item | Hidden Section / Temporary Expansion Area` - -The important behavior is not literal ownership of every icon slot, but controlled visibility and redisclosure from AwakeCup. - -### Hidden Items Panel - -When the user clicks AwakeCup's control item, AwakeCup can open a panel containing hidden items. Each entry should expose: - -- icon or best-effort visual marker -- readable item/app name -- click action forwarded to the original item - -This panel is the stable fallback interaction because it does not depend on menu bar re-layout timing. - -### Temporary Expanded Strip - -When the user chooses `Show Hidden Items`, AwakeCup displays a temporary strip aligned with the menu bar area. Hidden items appear in their stored order and behave like temporarily exposed menu bar content. The strip auto-collapses after a timeout or when the user dismisses it. - -This mode should feel like re-expanding hidden menu bar items, but the implementation remains an AwakeCup-managed reveal layer rather than a true transfer of third-party status-item ownership. - -### Settings Window - -The settings window is the primary management interface. v1 should include a `Menu Bar Layout` section with two visible groups: - -- `Always Visible` -- `Hidden` - -Each row should show: - -- item icon or fallback glyph -- item name -- owning application -- current manageability state - -The menu bar menu should not mirror this full management UI. It should only expose quick actions: - -- show hidden items -- open hidden-items panel -- open settings -- temporarily pause management - -## Data Model - -### `MenuBarItemRecord` - -The core normalized model should include at least: - -- `id`: app-level stable identifier -- `bundleIdentifier` -- `processID` -- `displayName` -- `axRole` -- `axSubrole` -- `frame` -- `actions` -- `manageability` -- `preferredSection` -- `sortIndex` - -### Stable Identifier Strategy - -Item identity cannot rely on title alone because many menu bar extras expose empty or changing labels. v1 should derive a best-effort stable identifier from a combination of: - -- owning app bundle identifier -- AX role / subrole -- action signature -- geometric hints -- additional AX attributes when available - -If an item cannot be matched reliably across rescans, it should be treated as a new candidate and not silently overwrite a stored mapping. - -### Persistence - -The layout store should persist: - -- per-item section assignment -- per-item order -- preferred default reveal mode -- auto-collapse duration -- whether the manager is currently enabled - -JSON-backed storage is sufficient for v1. The schema should tolerate unknown or missing items during startup reconciliation. - -## Item State Model - -Each discovered item should end up in one of these states: - -- `alwaysVisible`: user wants the item available in the normal visible area -- `hidden`: item is managed by AwakeCup and available through panel / expanded strip -- `unmanaged`: item cannot be safely managed due to missing AX support or unstable identification - -`unmanaged` items must remain visible in the system menu bar and be labeled clearly in settings with the reason they are excluded from management. - -## Permission and Onboarding Flow - -On first launch of the feature: - -1. AwakeCup checks AX trust state. -2. If untrusted, AwakeCup requests the system prompt. -3. Until trust is granted, the menu bar manager surface stays disabled. -4. The menu bar menu and settings window show a focused permission explanation with a direct path to System Settings. - -If permission is later revoked, the app must: - -- stop AX-driven inventory updates -- stop reveal interactions -- preserve stored layout data -- switch the UI back to permission guidance - -## Error Handling and Degradation - -### Unsupported Items - -If an item lacks usable AX attributes or actions, AwakeCup should mark it as `unmanaged` and leave it alone. - -### Inventory Drift - -If app relaunches, system updates, or menu bar changes cause a saved item to no longer match confidently, AwakeCup should: - -- keep the saved record for later reconciliation if appropriate -- avoid forcing the new unmatched item into `hidden` -- surface the mismatch in settings - -### Geometry Drift - -Full-screen mode, notched displays, auto-hidden menu bars, and multi-display setups may affect overlay placement. v1 should prefer conservative behavior: - -- if the expanded strip cannot be positioned confidently, fall back to the hidden-items panel -- do not show a broken or partially off-screen expanded strip - -### Interaction Failures - -If action forwarding to an AX item fails, AwakeCup should: - -- dismiss transient UI if needed -- show a lightweight failure state -- keep the underlying item classification unchanged - -## Testing Strategy - -### Unit Tests - -Add tests for: - -- item record normalization from raw scan inputs -- stable identifier generation rules -- layout reconciliation between saved state and refreshed inventory -- unsupported-item classification -- auto-collapse timing state transitions - -### Integration / Manual Validation - -Validate on real systems for: - -- permission onboarding and revocation recovery -- panel-based access to hidden items -- temporary expanded strip show/hide behavior -- persistence after app restart -- mixed environments where some items are manageable and others are not - -### Regression Protection - -Existing AwakeCup behavior must continue to work: - -- caffeine activation / deactivation -- launch at login -- existing menu bar icon state - -The menu bar manager must not regress the current sleep-prevention flow. - -## V1 Scope - -Included in v1: - -- Accessibility permission onboarding -- menu bar inventory scan -- `Always Visible` and `Hidden` item grouping -- hidden-items panel -- temporary expanded strip -- item ordering persistence -- clear unsupported-item fallback - -Deferred beyond v1: - -- drag-and-drop reordering -- hover / scroll / hotkey triggers -- profiles -- search -- advanced menu bar appearance customization -- fully polished multi-display and notch-specific layout behavior - -## Implementation Notes For This Repository - -- `Sources/AwakeCup/AwakeCup.swift` currently contains the full app. This feature should begin the move toward focused files instead of extending the monolith indefinitely. -- The app already uses `MenuBarExtra` and an `AppDelegate`; the new design should preserve those foundations while adding a settings scene/window and management services. -- The menu bar manager should remain orthogonal to `CaffeineManager`; sleep-prevention behavior is a separate responsibility and should not be coupled to AX scanning logic.