From 77fbdb18aadc06b3769b646cda655d825a122f48 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 18:45:35 +0800 Subject: [PATCH 001/106] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(service):?= =?UTF-8?q?=20make=20LockEngine.lock=20idempotent,=20adopt=20Logger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock is a single global state, so locking while already locked is now a no-op success that re-applies the latest settings (re-arming the auto-unlock timer), rather than throwing. Drop the `.alreadyLocked` error and the "owned by another session" semantics it implied. Add `updateSettings(_:)` so a running lock picks up settings changes live. Replace `print` with `os.Logger`. --- Core/Sources/Service/LockEngine.swift | 58 +++++++++++++++++---------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 93460ca..1487c99 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -4,7 +4,7 @@ import CoreGraphics import Foundation import os -// Use refcon to bridge C callback to Swift instance since CGEventTap requires C function pointer +/// Use refcon to bridge C callback to Swift instance since CGEventTap requires C function pointer private func eventTapCallback( proxy: CGEventTapProxy, type: CGEventType, @@ -18,7 +18,7 @@ private func eventTapCallback( let engine = Unmanaged.fromOpaque(refcon).takeUnretainedValue() if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - print("Event tap disabled by system, attemping to re-enable...") + LockEngine.logger.warning("Event tap disabled by system, attempting to re-enable") if let eventTap = engine.eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) @@ -33,11 +33,12 @@ private func eventTapCallback( public class LockEngine { public static let shared = LockEngine() + static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "LockEngine") + public enum LockEngineError: Error, LocalizedError { case accessibilityPermissionDenied case eventTapCreationFailed case runLoopSourceCreationFailed - case alreadyLocked public var errorDescription: String? { switch self { @@ -47,8 +48,6 @@ public class LockEngine { "Failed to create event tap. This may indicate a permissions issue or system restriction." case .runLoopSourceCreationFailed: "Failed to create run loop source for event tap." - case .alreadyLocked: - "The keyboard is already locked by another session." } } @@ -60,8 +59,6 @@ public class LockEngine { "Try restarting the application. If the problem persists, check Accessibility permissions in System Settings." case .runLoopSourceCreationFailed: "This is a system-level error. Please contact support if it persists." - case .alreadyLocked: - "Wait for the current lock to be released or expire." } } } @@ -76,7 +73,7 @@ public class LockEngine { private static let runLoopSourceOrder: CFIndex = 0 private static let autoRepeatFlagValue: Int64 = 1 - // Unfair lock for thread-safe state access (higher performance than DispatchQueue) + /// Unfair lock for thread-safe state access (higher performance than DispatchQueue) private let stateLock = OSAllocatedUnfairLock() // fileprivate access required for C callback to re-enable tap on system timeout @@ -90,7 +87,7 @@ public class LockEngine { private var _autoUnlockTargetDate: Date? private var _activeSettings: KeyboardLockerSettings = .default - // Thread-safe public accessors + /// Thread-safe public accessors public var isLocked: Bool { withLock { _isLocked } } @@ -105,7 +102,7 @@ public class LockEngine { private init() {} - // Helper method to safely execute critical sections with automatic lock management + /// Helper method to safely execute critical sections with automatic lock management private func withLock(_ block: () throws -> T) rethrows -> T { stateLock.lock() defer { stateLock.unlock() } @@ -113,18 +110,22 @@ public class LockEngine { } public func lock(settings: KeyboardLockerSettings = .default) throws { - // Check lock status atomically - try withLock { - guard !_isLocked else { - throw LockEngineError.alreadyLocked - } + // The lock is a single global state; locking while already locked is a no-op + // success (idempotent), but we still adopt the latest settings. + let alreadyLocked: Bool = withLock { + _activeSettings = settings + return _isLocked + } - // Verify Accessibility permission before attempting to create event tap - guard AccessibilityManager.hasPermission() else { - throw LockEngineError.accessibilityPermissionDenied - } + if alreadyLocked { + // Re-arm the auto-unlock timer / hotkey against the new settings. + configureAutoUnlockTimerIfNeeded() + return + } - _activeSettings = settings + // Verify Accessibility permission before attempting to create event tap. + guard AccessibilityManager.hasPermission() else { + throw LockEngineError.accessibilityPermissionDenied } // Event tap creation must happen on main thread @@ -132,6 +133,19 @@ public class LockEngine { markLocked() } + /// Updates the active settings. If a lock is running, changes take effect immediately + /// (e.g. a new auto-unlock timeout re-arms the timer); otherwise they seed the next lock. + public func updateSettings(_ settings: KeyboardLockerSettings) { + let isLocked: Bool = withLock { + _activeSettings = settings + return _isLocked + } + + if isLocked { + configureAutoUnlockTimerIfNeeded() + } + } + private func startEventTap() throws { guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, @@ -162,7 +176,7 @@ public class LockEngine { configureAutoUnlockTimerIfNeeded() LockStateBroadcaster.broadcast(isLocked: true) - print("LockEngine: Locked") + Self.logger.info("Locked") } private func configureAutoUnlockTimerIfNeeded() { @@ -231,7 +245,7 @@ public class LockEngine { } LockStateBroadcaster.broadcast(isLocked: false) - print("LockEngine: Unlocked") + Self.logger.info("Unlocked") } fileprivate func handleEvent( From 8fece9ae47cfa116061027e65e51d6124b2e6ac4 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 18:46:06 +0800 Subject: [PATCH 002/106] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(core):=20?= =?UTF-8?q?relocate=20settings=20store=20to=20Service,=20SystemSettings=20?= =?UTF-8?q?to=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `KeyboardLockerSettingsStore` from Common into Service so no wrapper can `import Common` and spin up its own UserDefaults-backed store — the Agent is the settings source of truth (architecture contract). Move `SystemSettings` (opens the Accessibility pane, pure UI) out of the shared core into the App target and drop its `public`. --- .../{Common => Service}/KeyboardLockerSettingsStore.swift | 0 {Core/Sources/Common => KeyboardLocker}/SystemSettings.swift | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename Core/Sources/{Common => Service}/KeyboardLockerSettingsStore.swift (100%) rename {Core/Sources/Common => KeyboardLocker}/SystemSettings.swift (82%) diff --git a/Core/Sources/Common/KeyboardLockerSettingsStore.swift b/Core/Sources/Service/KeyboardLockerSettingsStore.swift similarity index 100% rename from Core/Sources/Common/KeyboardLockerSettingsStore.swift rename to Core/Sources/Service/KeyboardLockerSettingsStore.swift diff --git a/Core/Sources/Common/SystemSettings.swift b/KeyboardLocker/SystemSettings.swift similarity index 82% rename from Core/Sources/Common/SystemSettings.swift rename to KeyboardLocker/SystemSettings.swift index e339b7e..7be6c6f 100644 --- a/Core/Sources/Common/SystemSettings.swift +++ b/KeyboardLocker/SystemSettings.swift @@ -1,10 +1,10 @@ import AppKit /// Helper for opening System Settings to specific panes. -public enum SystemSettings { +enum SystemSettings { /// Opens System Settings to Accessibility privacy pane. /// Used when user needs manual guidance to grant permission. - public static func openAccessibilitySettings() { + static func openAccessibilitySettings() { let urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" if let url = URL(string: urlString) { NSWorkspace.shared.open(url) From e23af8ee0ba502d34a13dd271471c57edbcbffaf Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 18:46:25 +0800 Subject: [PATCH 003/106] =?UTF-8?q?=E2=9C=A8=20feat(xpc):=20flow=20setting?= =?UTF-8?q?s=20through=20the=20Agent,=20async=20stateless=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the single-core contract real: settings now cross XPC and the Agent owns them, and every operation is a stateless one-off call. - Protocol: add `applySettings(Data)` / `currentSettings`; settings travel as JSON `Data` across the `@objc` boundary via new `KeyboardLockerSettings.encodedForXPC()` / `decodedFromXPC(_:)` (corrupt or nil data falls back to `.default`). - AgentService: becomes the settings source of truth — loads persisted settings on init, seeds the engine, and `lockKeyboard` uses stored settings instead of `.default`. - XPCClient: rewrite as async/throwing with one auto-reconnecting connection; a dead Agent throws instead of hanging. Delete `LockSessionController` / `startLockSession` — the forbidden client-owned "session" abstraction. - LockStateSubscriber: add `stateChanges` AsyncStream alongside the token API. - klock: rewrite onto async one-off calls (no more semaphore hang path), print the Agent's real unlock hotkey rather than a local default. --- Core/Sources/Client/LockStateSubscriber.swift | 12 + Core/Sources/Client/XPCClient.swift | 257 ++++++++++-------- .../Common/KeyboardLockerSettings.swift | 26 +- Core/Sources/Common/Shared.swift | 10 + KeyboardLockerAgent/AgentService.swift | 59 +++- klock/main.swift | 106 +++----- 6 files changed, 290 insertions(+), 180 deletions(-) diff --git a/Core/Sources/Client/LockStateSubscriber.swift b/Core/Sources/Client/LockStateSubscriber.swift index 7684c2d..eeb51d2 100644 --- a/Core/Sources/Client/LockStateSubscriber.swift +++ b/Core/Sources/Client/LockStateSubscriber.swift @@ -27,6 +27,18 @@ public enum LockStateSubscriber { DistributedNotificationCenter.default().removeObserver(observer) } } + + /// Lock state changes as an `AsyncStream`. The subscription lives for the stream's lifetime + /// and is torn down automatically when the consuming task is cancelled. + public static var stateChanges: AsyncStream { + AsyncStream { continuation in + let token = subscribe { continuation.yield($0) } + continuation.onTermination = { _ in + // Retain the token until termination, then release to unsubscribe. + _ = token + } + } + } } // MARK: - Observer Token diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index ce81e9d..d97c639 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -1,151 +1,186 @@ import Common import Foundation +import os -public enum XPCClientError: Error { +public enum XPCClientError: Error, LocalizedError { case serviceUnavailable -} -/// XPC client for communicating with the KeyboardLocker agent service. -public enum XPCClient { - // MARK: - One-off Operations - - /// Queries the current lock state from the agent. - public static func status(reply: @escaping (Bool, Error?) -> Void) { - executeOneOff( - onError: { reply(false, $0) }, - operation: { service, invalidate in - service.status { isLocked, error in - reply(isLocked, error) - invalidate() - } - } - ) + public var errorDescription: String? { + switch self { + case .serviceUnavailable: + "The KeyboardLocker agent is not reachable." + } } +} - /// Requests Accessibility permission. - /// - Parameters: - /// - showPrompt: Whether to trigger macOS system prompt - /// - reply: Callback with current permission status - public static func requestAccessibilityPermission(showPrompt: Bool = true, reply: @escaping (Bool) -> Void) { - executeOneOff( - onError: { _ in reply(false) }, - operation: { service, invalidate in - service.requestAccessibilityPermission(showPrompt: showPrompt) { granted in - reply(granted) - invalidate() - } - } - ) - } +/// Async client for the KeyboardLocker Agent. +/// +/// Every operation is a **stateless one-off call** to the single global lock owned by the Agent — +/// there is no client-owned "session". State is observed via `LockStateSubscriber`, never inferred +/// from whether a call succeeded. +/// +/// A single connection is reused and lazily recreated after invalidation, so callers survive the +/// Agent being relaunched on demand by `launchd`. +public final class XPCClient: @unchecked Sendable { + public static let shared = XPCClient() - /// Queries whether the agent has Accessibility permission. - public static func accessibilityStatus(reply: @escaping (Bool) -> Void) { - executeOneOff( - onError: { _ in reply(false) }, - operation: { service, invalidate in - service.accessibilityStatus { granted in - reply(granted) - invalidate() - } - } - ) + private let lock = OSAllocatedUnfairLock() + private var connection: NSXPCConnection? + + public init() {} + + // MARK: - Operations + + public func lock() async throws { + try await withProxy { service, resume in + service.lockKeyboard { resume($0) } + } } - /// Force unlock (one-off operation). - public static func unlock(reply: @escaping (Error?) -> Void) { - executeOneOff( - onError: { reply($0) }, - operation: { service, invalidate in - service.unlockKeyboard { error in - reply(error) - invalidate() - } - } - ) + public func unlock() async throws { + try await withProxy { service, resume in + service.unlockKeyboard { resume($0) } + } } - // MARK: - Session Management + /// Current global lock state. + public func status() async throws -> Bool { + try await withProxyReturning { service, resume in + service.status { isLocked, error in resume(isLocked, error) } + } + } - /// Creates a persistent lock session. - public static func startLockSession() -> LockSessionController { - LockSessionController(onStateChange: nil) + /// Persists settings in the Agent (source of truth) and applies them to any running lock. + public func applySettings(_ settings: KeyboardLockerSettings) async throws { + let data = try settings.encodedForXPC() + try await withProxy { service, resume in + service.applySettings(data) { resume($0) } + } } - /// Creates a persistent lock session with state change notifications. - public static func startLockSession( - onStateChange: @escaping (Bool) -> Void - ) -> LockSessionController { - LockSessionController(onStateChange: onStateChange) + /// The Agent's current settings, falling back to `.default` if the Agent can't provide them. + public func currentSettings() async throws -> KeyboardLockerSettings { + let data: Data? = try await withProxyReturning { service, resume in + service.currentSettings { resume($0, nil) } + } + return KeyboardLockerSettings.decodedFromXPC(data) } - // MARK: - Private - - private static func executeOneOff( - onError: @escaping (Error) -> Void, - operation: @escaping (KeyboardLockerServiceProtocol, _ invalidate: @escaping () -> Void) -> Void - ) { - let connection = makeClientConnection() - let service = connection.remoteObjectProxyWithErrorHandler { error in - onError(error) - connection.invalidate() - } as? KeyboardLockerServiceProtocol - - guard let service else { - onError(XPCClientError.serviceUnavailable) - connection.invalidate() - return + @discardableResult + public func requestAccessibilityPermission(showPrompt: Bool = true) async throws -> Bool { + try await withProxyReturning { service, resume in + service.requestAccessibilityPermission(showPrompt: showPrompt) { resume($0, nil) } } + } - operation(service) { connection.invalidate() } + public func accessibilityStatus() async throws -> Bool { + try await withProxyReturning { service, resume in + service.accessibilityStatus { resume($0, nil) } + } } - private static func makeClientConnection() -> NSXPCConnection { + // MARK: - Connection Management + + private func currentConnection() -> NSXPCConnection { + lock.lock() + defer { lock.unlock() } + + if let connection { + return connection + } + let connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) + + // Drop the cached connection on teardown so the next call transparently reconnects. + let clear: @Sendable () -> Void = { [weak self] in self?.clearConnection() } + connection.invalidationHandler = clear + connection.interruptionHandler = clear + connection.resume() + self.connection = connection return connection } -} -// MARK: - Lock Session Controller + private func clearConnection() { + lock.lock() + connection = nil + lock.unlock() + } -/// Manages a persistent XPC connection for lock/unlock operations. -public final class LockSessionController { - private let connection: NSXPCConnection - private let service: KeyboardLockerServiceProtocol? - private var observerToken: ObserverToken? + /// Bridges a reply-based XPC call returning only an optional `Error` into async/throwing form. + /// The continuation is resumed exactly once — whether by the reply, the proxy error handler, + /// or a missing proxy — so a dead Agent throws rather than hanging forever. + private func withProxy( + _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (Error?) -> Void) -> Void + ) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let once = ResumeOnce() + let connection = currentConnection() + + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + once.run { continuation.resume(throwing: error) } + } - fileprivate init(onStateChange: ((Bool) -> Void)?) { - connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) - connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) - connection.resume() - service = connection.remoteObjectProxyWithErrorHandler { _ in } as? KeyboardLockerServiceProtocol + guard let service = proxy as? KeyboardLockerServiceProtocol else { + once.run { continuation.resume(throwing: XPCClientError.serviceUnavailable) } + return + } - if let onStateChange { - observerToken = LockStateSubscriber.subscribe(onStateChange) + body(service) { error in + once.run { + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } } } - deinit { - connection.invalidate() - } + /// Same as `withProxy`, for calls that return a value plus an optional `Error`. + private func withProxyReturning( + _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (T, Error?) -> Void) -> Void + ) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + let once = ResumeOnce() + let connection = currentConnection() - public func lock(reply: @escaping (Error?) -> Void) { - withService(reply: reply) { $0.lockKeyboard(reply: reply) } - } + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + once.run { continuation.resume(throwing: error) } + } - public func unlock(reply: @escaping (Error?) -> Void) { - withService(reply: reply) { $0.unlockKeyboard(reply: reply) } + guard let service = proxy as? KeyboardLockerServiceProtocol else { + once.run { continuation.resume(throwing: XPCClientError.serviceUnavailable) } + return + } + + body(service) { value, error in + once.run { + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: value) + } + } + } + } } +} - private func withService( - reply: @escaping (Error?) -> Void, - operation: (KeyboardLockerServiceProtocol) -> Void - ) { - guard let service else { - reply(XPCClientError.serviceUnavailable) - return +/// Guarantees a continuation is resumed at most once across the reply and error-handler races. +private final class ResumeOnce: @unchecked Sendable { + private let lock = OSAllocatedUnfairLock() + private var done = false + + func run(_ block: () -> Void) { + lock.lock() + let shouldRun = !done + done = true + lock.unlock() + if shouldRun { + block() } - operation(service) } } diff --git a/Core/Sources/Common/KeyboardLockerSettings.swift b/Core/Sources/Common/KeyboardLockerSettings.swift index b8aee7f..9f013dc 100644 --- a/Core/Sources/Common/KeyboardLockerSettings.swift +++ b/Core/Sources/Common/KeyboardLockerSettings.swift @@ -13,7 +13,9 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { case timed(seconds: TimeInterval) /// Identifiable conformance using self as ID - public var id: Self { self } + public var id: Self { + self + } /// Converts policy to timeout in seconds, nil when disabled public var timeout: TimeInterval? { @@ -126,9 +128,27 @@ extension KeyboardLockerSettings.Hotkey: Codable { // MARK: - Hotkey Display -extension KeyboardLockerSettings.Hotkey { +public extension KeyboardLockerSettings.Hotkey { /// Human-readable representation of the hotkey (e.g., "⌃⌘L") - public var displayString: String { + var displayString: String { KeyCodeConverter.stringFromKeyCode(keyCode, modifiers: modifierFlags) ?? "?" } } + +// MARK: - XPC Serialization + +public extension KeyboardLockerSettings { + /// Encodes settings for transport across the `@objc` XPC boundary as JSON. + func encodedForXPC() throws -> Data { + try JSONEncoder().encode(self) + } + + /// Decodes settings received over XPC, falling back to `.default` on nil/corrupt data + /// so a wrapper never operates on an ambiguous state. + static func decodedFromXPC(_ data: Data?) -> KeyboardLockerSettings { + guard let data, let settings = try? JSONDecoder().decode(KeyboardLockerSettings.self, from: data) else { + return .default + } + return settings + } +} diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index c6cd92b..249b370 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -46,6 +46,16 @@ public protocol KeyboardLockerServiceProtocol { func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) + // MARK: Settings Methods + + /// Persists new settings in the Agent (the source of truth) and applies them to a running lock. + /// Settings cross the `@objc` XPC boundary as JSON-encoded `KeyboardLockerSettings`. + func applySettings(_ data: Data, reply: @escaping (Error?) -> Void) + + /// Returns the Agent's current settings as JSON-encoded `KeyboardLockerSettings`. + /// `data` is nil only if the Agent failed to encode; wrappers fall back to `.default`. + func currentSettings(reply: @escaping (Data?) -> Void) + // MARK: Accessibility Permission Methods func requestAccessibilityPermission(showPrompt: Bool, reply: @escaping (Bool) -> Void) diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index d2fb866..7468ee9 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -1,12 +1,31 @@ import Foundation +import os import Service -/// XPC service implementation for keyboard lock/unlock operations. +/// XPC service implementation. Owns the settings source of truth and drives the single +/// global `LockEngine`. All wrappers reach the lock exclusively through this object. final class AgentService: NSObject, KeyboardLockerServiceProtocol { + private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "AgentService") + + private let store = KeyboardLockerSettingsStore() + private let settingsLock = OSAllocatedUnfairLock() + private var settings: KeyboardLockerSettings + + override init() { + let loaded = store.load() + settings = loaded + super.init() + // Seed the engine so a lock triggered before any settings write uses persisted values. + LockEngine.shared.updateSettings(loaded) + } + + // MARK: - Locking + func lockKeyboard(reply: @escaping (Error?) -> Void) { + let current = currentSettingsValue() executeOnMainThread { do { - try LockEngine.shared.lock() + try LockEngine.shared.lock(settings: current) reply(nil) } catch { reply(error) @@ -27,6 +46,34 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + // MARK: - Settings + + func applySettings(_ data: Data, reply: @escaping (Error?) -> Void) { + let newSettings = KeyboardLockerSettings.decodedFromXPC(data) + + settingsLock.lock() + settings = newSettings + settingsLock.unlock() + + do { + try store.save(newSettings) + } catch { + // Persistence failure is non-fatal for the running lock; surface it but still apply. + Self.logger.error("Failed to persist settings: \(error.localizedDescription, privacy: .public)") + } + + executeOnMainThread { + LockEngine.shared.updateSettings(newSettings) + reply(nil) + } + } + + func currentSettings(reply: @escaping (Data?) -> Void) { + reply(try? currentSettingsValue().encodedForXPC()) + } + + // MARK: - Accessibility + func requestAccessibilityPermission(showPrompt: Bool, reply: @escaping (Bool) -> Void) { executeOnMainThread { reply(AccessibilityManager.requestPermission(showPrompt: showPrompt)) @@ -39,6 +86,14 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + // MARK: - Helpers + + private func currentSettingsValue() -> KeyboardLockerSettings { + settingsLock.lock() + defer { settingsLock.unlock() } + return settings + } + private func executeOnMainThread(_ operation: @escaping () -> Void) { if Thread.isMainThread { operation() diff --git a/klock/main.swift b/klock/main.swift index 0bc8a52..97f8649 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -8,17 +8,12 @@ import Client import Foundation -// MARK: - Entry Point - -KlockCLI.run() +await KlockCLI.run() // MARK: - CLI enum KlockCLI { - private static var session: LockSessionController? - private static var stateToken: ObserverToken? - - static func run() { + static func run() async { guard let command = CommandLine.arguments.dropFirst().first else { printUsage() exit(ExitCode.error) @@ -26,11 +21,11 @@ enum KlockCLI { switch command { case "lock": - executeLock() + await executeLock() case "unlock": - executeUnlock() + await executeUnlock() case "status": - executeStatus() + await executeStatus() default: printError("Unknown command: \(command)") printUsage() @@ -40,73 +35,56 @@ enum KlockCLI { // MARK: - Commands - private static func executeLock() { - print("Locking...") - - session = XPCClient.startLockSession() - session?.lock { error in - if let error { - printError(error.localizedDescription) - if let suggestion = (error as? LocalizedError)?.recoverySuggestion { - print(" \(suggestion)") - } - exit(ExitCode.error) - } - let hotkey = KeyboardLockerSettings.default.unlockHotkey.displayString - print("Locked. Press \(hotkey) to unlock.") + private static func executeLock() async { + do { + try await XPCClient.shared.lock() + } catch { + reportFailure(error) + exit(ExitCode.error) } - // Listen for external unlock (hotkey pressed) - stateToken = LockStateSubscriber.subscribe { isLocked in - if !isLocked { - print("Unlocked.") - stateToken = nil - session = nil - exit(ExitCode.success) - } - } + let hotkey = await (try? XPCClient.shared.currentSettings())?.unlockHotkey.displayString + ?? KeyboardLockerSettings.default.unlockHotkey.displayString + print("Locked. Press \(hotkey) to unlock.") - RunLoop.main.run() + // Stay alive to report when the lock is released (hotkey, timeout, or another surface). + for await isLocked in LockStateSubscriber.stateChanges where !isLocked { + print("Unlocked.") + exit(ExitCode.success) + } } - private static func executeUnlock() { - var exitCode = ExitCode.success - let sem = DispatchSemaphore(value: 0) - - XPCClient.unlock { error in - if let error { - printError(error.localizedDescription) - exitCode = ExitCode.error - } else { - print("Unlocked.") - } - sem.signal() + private static func executeUnlock() async { + do { + try await XPCClient.shared.unlock() + print("Unlocked.") + exit(ExitCode.success) + } catch { + reportFailure(error) + exit(ExitCode.error) } - - sem.wait() - exit(exitCode) } - private static func executeStatus() { - var exitCode = ExitCode.success - let sem = DispatchSemaphore(value: 0) - - XPCClient.status { isLocked, error in - if let error { - printError(error.localizedDescription) - exitCode = ExitCode.error - } else { - print(isLocked ? "Locked" : "Unlocked") - } - sem.signal() + private static func executeStatus() async { + do { + let isLocked = try await XPCClient.shared.status() + print(isLocked ? "Locked" : "Unlocked") + exit(ExitCode.success) + } catch { + reportFailure(error) + exit(ExitCode.error) } - - sem.wait() - exit(exitCode) } // MARK: - Helpers + private static func reportFailure(_ error: Error) { + printError(error.localizedDescription) + if let suggestion = (error as? LocalizedError)?.recoverySuggestion { + print(" \(suggestion)") + } + } + private static func printUsage() { print("Usage: klock ") } From 9d2a629681235a049453cfed893808941ed117ba Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 18:46:34 +0800 Subject: [PATCH 004/106] =?UTF-8?q?=E2=9C=A8=20feat(app):=20register=20Age?= =?UTF-8?q?nt=20via=20SMAppService,=20add=20lock=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentRegistrar: register the Agent as a launchd-managed login item at launch (idempotent) so it runs on demand for every surface even when the App is closed — the contract's Agent lifecycle requirement. - LockController: thin @MainActor ObservableObject that issues async XPCClient calls and reflects the Agent's broadcast state, never inferring state from whether a call succeeded. Holds no lock/settings logic. - MainView: minimal lock/unlock toggle with live status and error surface, wired through the environment. Settings/About stay stubs. --- KeyboardLocker/AgentRegistrar.swift | 34 +++++++++++++++++++ KeyboardLocker/ContentView.swift | 1 + KeyboardLocker/KeyboardLockerApp.swift | 10 +++++- KeyboardLocker/LockController.swift | 46 ++++++++++++++++++++++++++ KeyboardLocker/Views/MainView.swift | 29 ++++++++++++++-- 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 KeyboardLocker/AgentRegistrar.swift create mode 100644 KeyboardLocker/LockController.swift diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift new file mode 100644 index 0000000..6a24ab2 --- /dev/null +++ b/KeyboardLocker/AgentRegistrar.swift @@ -0,0 +1,34 @@ +import Client +import Foundation +import os +import ServiceManagement + +/// Registers the Agent as a launchd-managed login item so `launchd` can start it on demand +/// for any XPC client — even when this App is not running. This is the contract's +/// "Agent Lifecycle Requirement": wrappers must not assume the Agent is already up. +enum AgentRegistrar { + private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "AgentRegistrar") + + /// Filename of the launchd plist bundled at `Contents/Library/LaunchAgents/`. + private static let plistName = "io.lzhlovesjyq.keyboardlocker.agent.plist" + + private static var agent: SMAppService { + SMAppService.agent(plistName: plistName) + } + + /// Ensures the Agent is registered. Idempotent; safe to call on every launch. + static func registerIfNeeded() { + let service = agent + switch service.status { + case .enabled: + return + default: + do { + try service.register() + logger.info("Registered agent (status: \(String(describing: service.status), privacy: .public))") + } catch { + logger.error("Failed to register agent: \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift index 14f8528..1746116 100644 --- a/KeyboardLocker/ContentView.swift +++ b/KeyboardLocker/ContentView.swift @@ -26,4 +26,5 @@ struct ContentView: View { #Preview { ContentView() + .environmentObject(LockController()) } diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift index 6d71c2b..58de179 100644 --- a/KeyboardLocker/KeyboardLockerApp.swift +++ b/KeyboardLocker/KeyboardLockerApp.swift @@ -9,9 +9,17 @@ import SwiftUI @main struct KeyboardLockerApp: App { + @StateObject private var lockController = LockController() + + init() { + // Ensure the Agent is launchd-registered so it runs on demand for every surface. + AgentRegistrar.registerIfNeeded() + } + var body: some Scene { - MenuBarExtra("KeyboardLocker", systemImage: "lock.open.fill") { + MenuBarExtra("KeyboardLocker", systemImage: lockController.isLocked ? "lock.fill" : "lock.open.fill") { ContentView() + .environmentObject(lockController) } .menuBarExtraStyle(.window) } diff --git a/KeyboardLocker/LockController.swift b/KeyboardLocker/LockController.swift new file mode 100644 index 0000000..60bdc57 --- /dev/null +++ b/KeyboardLocker/LockController.swift @@ -0,0 +1,46 @@ +import Client +import Combine +import Foundation + +/// Observable view state for the lock. A thin wrapper: it holds no lock logic and no settings — +/// it issues one-off XPC calls and reflects the Agent's broadcast state, never inferring state +/// from whether a call succeeded. +@MainActor +final class LockController: ObservableObject { + @Published private(set) var isLocked = false + @Published private(set) var lastError: String? + + private var stateToken: ObserverToken? + + init() { + stateToken = LockStateSubscriber.subscribe { [weak self] locked in + // Subscriber delivers on the main queue. + self?.isLocked = locked + } + Task { await refresh() } + } + + func refresh() async { + do { + isLocked = try await XPCClient.shared.status() + } catch { + lastError = error.localizedDescription + } + } + + func toggle() { + Task { + do { + if isLocked { + try await XPCClient.shared.unlock() + } else { + try await XPCClient.shared.lock() + } + lastError = nil + } catch { + lastError = error.localizedDescription + } + // State itself arrives via the broadcast subscription, not from this call's success. + } + } +} diff --git a/KeyboardLocker/Views/MainView.swift b/KeyboardLocker/Views/MainView.swift index 50b4b0b..7bfd8eb 100644 --- a/KeyboardLocker/Views/MainView.swift +++ b/KeyboardLocker/Views/MainView.swift @@ -8,10 +8,13 @@ import SwiftUI struct MainView: View { + @EnvironmentObject private var lock: LockController + var body: some View { VStack(spacing: 16) { header Divider() + lockControl Spacer() footer } @@ -20,15 +23,36 @@ struct MainView: View { private var header: some View { HStack { - Image(systemName: "lock.open.fill") + Image(systemName: lock.isLocked ? "lock.fill" : "lock.open.fill") .font(.title) - .foregroundStyle(.secondary) + .foregroundStyle(lock.isLocked ? Color.accentColor : .secondary) Text("KeyboardLocker") .font(.headline) Spacer() } } + private var lockControl: some View { + VStack(spacing: 8) { + Button(lock.isLocked ? "Unlock" : "Lock Keyboard") { + lock.toggle() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Text(lock.isLocked ? "Keyboard is locked" : "Keyboard is unlocked") + .font(.subheadline) + .foregroundStyle(.secondary) + + if let error = lock.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + } + } + private var footer: some View { HStack { NavigationLink(value: AppRoute.settings) { @@ -53,6 +77,7 @@ struct MainView: View { #Preview { NavigationStack { MainView() + .environmentObject(LockController()) } .frame(width: 280, height: 320) } From ce4ab6ea3f325c1077e754309bac0a52e884a963 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 18:46:45 +0800 Subject: [PATCH 005/106] =?UTF-8?q?=F0=9F=93=9D=20docs(development):=20doc?= =?UTF-8?q?ument=20settings-over-XPC,=20async=20client,=20SMAppService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the XPC flow, component map, and common-task recipes to match the single-core rewrite: async stateless XPCClient (no session), Agent-owned settings via applySettings/currentSettings, relocated store/SystemSettings, SMAppService registration, and idempotent lock. Correct the stale note claiming lock throws `.eventTapCreationFailed` without permission (it throws `.accessibilityPermissionDenied`). --- docs/development.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/development.md b/docs/development.md index f203906..399165d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,44 +6,49 @@ Task-specific workflows and component reference. For the non-negotiable design r All surfaces talk to the Agent over `KeyboardLockerServiceProtocol` (Mach service `io.lzhlovesjyq.keyboardlocker.agent`). -1. A wrapper (App/CLI/…) sends a **stateless one-off call**: `lockKeyboard` / `unlockKeyboard` / `status` / `accessibilityStatus` / `requestAccessibilityPermission`. +1. A wrapper (App/CLI/…) sends a **stateless one-off call** via the async `XPCClient`: `lock` / `unlock` / `status` / `applySettings` / `currentSettings` / `accessibilityStatus` / `requestAccessibilityPermission`. Failures throw (a dead Agent surfaces as a thrown error, never a hang). 2. The Agent's `ServiceDelegate` accepts the connection (after `XPCAccessControl` validates code signature + bundle ID) and routes to `AgentService`. -3. `AgentService` calls `LockEngine.shared.lock(settings:)` / `unlock()`. +3. `AgentService` owns the settings source of truth (`KeyboardLockerSettingsStore`, in `Service`) and drives `LockEngine.shared.lock(settings:)` / `unlock()`. 4. `LockEngine` creates the CGEventTap and, on any state change, calls `LockStateBroadcaster.broadcast(isLocked:)`. -5. Wrappers observe state via `LockStateSubscriber.subscribe(_:)` (returns an `ObserverToken` that unsubscribes on deallocation) — never by inferring from "did my call succeed". +5. Wrappers observe state via `LockStateSubscriber.subscribe(_:)` (returns an `ObserverToken`) or `LockStateSubscriber.stateChanges` (`AsyncStream`) — never by inferring from "did my call succeed". -> The lock is a single global boolean owned by the Agent. Prefer the stateless `XPCClient.status/unlock` calls and the broadcast subscription. `LockSessionController` still exists in `XPCClient.swift` but implies client-owned "session" semantics that conflict with the architecture contract; treat it as legacy pending cleanup, don't build new features on it. +> The lock is a single global boolean owned by the Agent, and `lock()` is **idempotent** (locking while locked re-applies settings and returns success). There is no client-owned "session"; every call is a one-off. The Agent must be `SMAppService`-registered so `launchd` starts it on demand — the App does this at launch via `AgentRegistrar` (see below). ## Component Map Only the non-obvious responsibilities are listed; read the source for signatures. **Common** (`Core/Sources/Common/`) — shared by all targets -- `Shared.swift`: `KeyboardLockerServiceProtocol`, `SharedConstants` (Mach name, bundle-ID allowlist, CLI constants), `NotificationNames.stateChanged`. -- `KeyboardLockerSettings.swift`: `KeyboardLockerSettings` (`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`, `unlockHotkey`, `showsUnlockNotification`) + `.default`. +- `Shared.swift`: `KeyboardLockerServiceProtocol` (locking + `applySettings(Data)`/`currentSettings` + accessibility), `SharedConstants` (Mach name, bundle-ID allowlist, CLI constants), `NotificationNames.stateChanged`. +- `KeyboardLockerSettings.swift`: `KeyboardLockerSettings` (`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`, `unlockHotkey`, `showsUnlockNotification`) + `.default` + `encodedForXPC()`/`decodedFromXPC(_:)` (JSON transport across the `@objc` boundary). - `KeyCodeConverter.swift`: layout-aware `CGKeyCode` → shortcut string (⌃⌥⇧⌘ order) via `UCKeyTranslate`. -- `SystemSettings.swift`: opens the Accessibility pane. -**Client** (`Core/Sources/Client/`) — App/CLI, imports never `Service` -- `XPCClient.swift`: static `status` / `unlock` / `accessibilityStatus` / `requestAccessibilityPermission`. -- `LockStateSubscriber.swift`: distributed-notification subscription → `ObserverToken`. +**Client** (`Core/Sources/Client/`) — App/CLI, never imports `Service` +- `XPCClient.swift`: async/throwing `XPCClient.shared` with one auto-reconnecting connection; `lock`/`unlock`/`status`/`applySettings`/`currentSettings`/`accessibilityStatus`/`requestAccessibilityPermission`. No "session" type. +- `LockStateSubscriber.swift`: distributed-notification subscription → `ObserverToken`, plus `stateChanges` (`AsyncStream`). **Service** (`Core/Sources/Service/`) — Agent only -- `LockEngine.swift`: CGEventTap singleton, auto-unlock timer, hotkey detection, `OSAllocatedUnfairLock` state. +- `LockEngine.swift`: CGEventTap singleton, idempotent `lock(settings:)`, `updateSettings(_:)`, auto-unlock timer, hotkey detection, `OSAllocatedUnfairLock` state, `os.Logger`. +- `KeyboardLockerSettingsStore.swift`: `UserDefaults`-backed settings persistence — lives in `Service` so no wrapper can own its own store (contract source-of-truth rule). - `LockStateBroadcaster.swift`: posts Darwin (payload-free) + Distributed (with payload) notifications. - `AccessibilityManager.swift`, `XPCAccessControl.swift` (release = signature + Team ID + allowlist; debug = allowlist only), `XPCServerConnection.swift`. +**App** (`KeyboardLocker/`) — thin SwiftUI wrapper +- `AgentRegistrar.swift`: `SMAppService.agent(plistName:)` registration at launch (idempotent). +- `LockController.swift`: `@MainActor ObservableObject` view state — issues async `XPCClient` calls, reflects broadcast state, holds no lock/settings logic. +- `SystemSettings.swift`: opens the Accessibility pane (UI concern, App-local, not in `Common`). + ## Common Tasks ### Add a setting 1. Add a `Codable`/`Sendable` property to `KeyboardLockerSettings` and update `.default`. -2. If the engine consumes it, read it in `LockEngine.lock(settings:)`. -3. SwiftUI binds to settings, but wrappers must **not** own a `KeyboardLockerSettingsStore` — the Agent is the source of truth (see architecture contract). Read/write through XPC. +2. If the engine consumes it, read it in `LockEngine.lock(settings:)` / `updateSettings(_:)`. +3. Wrappers read/write settings only through `XPCClient.currentSettings()` / `applySettings(_:)`; the Agent persists via its `KeyboardLockerSettingsStore`. Wrappers must **not** own a store (see architecture contract). ### Add an XPC method -1. Add the signature to `KeyboardLockerServiceProtocol` in `Common/Shared.swift`. +1. Add the signature to `KeyboardLockerServiceProtocol` in `Common/Shared.swift`. Values that aren't `@objc`-representable (like `KeyboardLockerSettings`) cross as JSON `Data` — reuse `encodedForXPC()`/`decodedFromXPC(_:)`. 2. Implement it in `KeyboardLockerAgent/AgentService.swift`. -3. Add a thin `XPCClient` wrapper in `Client/` if a surface needs it. +3. Add a thin async wrapper on `XPCClient` in `Client/` if a surface needs it. ### Modify event filtering In `LockEngine.handleEvent(proxy:type:event:)`: return `nil` to block, `Unmanaged.passUnretained(event)` to allow, and call `unlock()` when unlock conditions (hotkey or timeout) are met. `Hotkey.matches(keyCode:flags:)` filters CapsLock/NumLock via `relevantModifierMask`. @@ -53,7 +58,7 @@ In `LockEngine.handleEvent(proxy:type:event:)`: return `nil` to block, `Unmanage ## Testing Notes -- `LockEngine` throws `.eventTapCreationFailed` without Accessibility permission. +- `LockEngine.lock` throws `.accessibilityPermissionDenied` when Accessibility permission is missing (checked before creating the event tap). - The Agent must be running/registered (`SMAppService`) for XPC calls to succeed. - Engine operations dispatch to the main thread for CFRunLoop access. - The system may disable the event tap (timeout/user input); `LockEngine` attempts re-enable. From dd3849824d18680addd7dc94283623610c9a6f63 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 18:46:45 +0800 Subject: [PATCH 006/106] =?UTF-8?q?=F0=9F=8E=A8=20style:=20apply=20swiftfo?= =?UTF-8?q?rmat=20to=20untouched=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incidental normalization swept up by `swiftformat .` in files this refactor did not otherwise modify (spacing around `..<`, expanded single-line computed properties). No behavior change. --- Core/Sources/Service/XPCAccessControl.swift | 2 +- KeyboardLocker/CLIInstaller.swift | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Core/Sources/Service/XPCAccessControl.swift b/Core/Sources/Service/XPCAccessControl.swift index eb9daca..7fd4717 100644 --- a/Core/Sources/Service/XPCAccessControl.swift +++ b/Core/Sources/Service/XPCAccessControl.swift @@ -89,7 +89,7 @@ public enum XPCAccessControl { { let start = name.index(match.lowerBound, offsetBy: 1) let end = name.index(match.upperBound, offsetBy: -1) - return String(name[start.. Date: Wed, 15 Jul 2026 20:18:51 +0800 Subject: [PATCH 007/106] =?UTF-8?q?=E2=9C=A8=20feat(app):=20reconcile=20lo?= =?UTF-8?q?ck=20state=20when=20the=20menu=20window=20appears?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A distributed broadcast can be missed while a menu-bar app is suspended, leaving the UI showing a stale state until the next change. Fetch the Agent's authoritative state via `LockController.reconcile()` on `ContentView.onAppear`, so opening the menu always shows the truth regardless of what happened (CLI lock, timeout, hotkey) while idle. --- KeyboardLocker/ContentView.swift | 6 ++++++ KeyboardLocker/LockController.swift | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift index 1746116..9262796 100644 --- a/KeyboardLocker/ContentView.swift +++ b/KeyboardLocker/ContentView.swift @@ -8,6 +8,8 @@ import SwiftUI struct ContentView: View { + @EnvironmentObject private var lock: LockController + var body: some View { NavigationStack { MainView() @@ -21,6 +23,10 @@ struct ContentView: View { } } .frame(width: 280, height: 320) + .onAppear { + // The menu window just opened; reconcile in case a broadcast was missed while suspended. + lock.reconcile() + } } } diff --git a/KeyboardLocker/LockController.swift b/KeyboardLocker/LockController.swift index 60bdc57..3db233e 100644 --- a/KeyboardLocker/LockController.swift +++ b/KeyboardLocker/LockController.swift @@ -28,6 +28,13 @@ final class LockController: ObservableObject { } } + /// Reconcile against the Agent's authoritative state. Call when the UI becomes visible: + /// a broadcast may have been missed while the app was suspended (distributed notifications + /// are best-effort and undelivered to a non-running process). + func reconcile() { + Task { await refresh() } + } + func toggle() { Task { do { From 67c2cb34493b6214f350b016892b809e9993537a Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 20:19:01 +0800 Subject: [PATCH 008/106] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(client):?= =?UTF-8?q?=20treat=20broadcasts=20as=20hints,=20fetch=20authoritative=20s?= =?UTF-8?q?tate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework LockStateSubscriber so notifications are signals, not truth: on any signal it fetches the Agent's state via `status()` (retried across a transient Agent relaunch, de-duplicated) rather than trusting a payload that can be dropped, reordered, or stale. - Add a Darwin channel observer alongside the existing Distributed one. Darwin can wake an App-Napped / suspended menu-bar app so it learns of a lock started by another surface while it was idle; Distributed remains the reliable path for a running CFRunLoop (and the async CLI). - Document the two wrapper shapes in the architecture contract: one-shot surfaces (CLI/AppleScript/Shortcuts) are synchronized by construction via a direct `status()` call and must NOT add notification handling; only long-lived state-reflecting UI (App/Widgets) subscribes and reconciles. --- Core/Sources/Client/LockStateSubscriber.swift | 130 ++++++++++++++++-- .../Service/LockStateBroadcaster.swift | 13 +- docs/architecture.md | 9 ++ docs/development.md | 2 +- 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/Core/Sources/Client/LockStateSubscriber.swift b/Core/Sources/Client/LockStateSubscriber.swift index eeb51d2..d30f89e 100644 --- a/Core/Sources/Client/LockStateSubscriber.swift +++ b/Core/Sources/Client/LockStateSubscriber.swift @@ -1,30 +1,50 @@ import Common import Foundation +import os // MARK: - Lock State Subscriber -/// Subscribes to lock state changes via DistributedNotification. +/// Subscribes to lock state changes broadcast by the Agent (`LockStateBroadcaster`). /// -/// Use this in App/CLI processes to receive state changes from the Agent. -/// The Agent broadcasts changes using `LockStateBroadcaster`. +/// Two channels are observed for robustness, and **both converge on the same behavior**: +/// on any signal, fetch the authoritative state from the Agent via `XPCClient.status()` +/// rather than trusting a notification payload (which can be dropped, delivered out of +/// order, or arrive stale). +/// +/// - **Darwin** (`CFNotificationCenterGetDarwinNotifyCenter`): can wake an App-Napped / +/// suspended process, so a menu-bar app still learns of a lock started by the CLI while +/// it was idle. Payload-free by design. +/// - **Distributed**: retained because a pure async command-line tool (klock) does not +/// reliably run a CFRunLoop for Darwin callbacks; the distributed center delivers on the +/// main queue, which the CLI's async main drains. +/// +/// Delivered values are de-duplicated, so redundant signals across the two channels surface +/// as at most one handler call per actual state change. public enum LockStateSubscriber { public typealias StateChangeHandler = (Bool) -> Void + private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "LockStateSubscriber") + /// Subscribes to lock state changes. - /// Returns a token that must be retained; subscription is cancelled when token is deallocated. + /// Returns a token that must be retained; subscription is cancelled when the token deallocates. public static func subscribe(_ handler: @escaping StateChangeHandler) -> ObserverToken { - let observer = DistributedNotificationCenter.default().addObserver( + let reconciler = StateReconciler(handler: handler) + + let darwin = DarwinObserver(name: NotificationNames.stateChanged) { + reconciler.signal() + } + + let distributed = DistributedNotificationCenter.default().addObserver( forName: Notification.Name(NotificationNames.stateChanged), object: nil, queue: .main - ) { notification in - if let isLocked = notification.userInfo?["isLocked"] as? Bool { - handler(isLocked) - } + ) { _ in + reconciler.signal() } return ObserverToken { - DistributedNotificationCenter.default().removeObserver(observer) + darwin.cancel() + DistributedNotificationCenter.default().removeObserver(distributed) } } @@ -41,6 +61,96 @@ public enum LockStateSubscriber { } } +// MARK: - State Reconciler + +/// On each signal, fetches the Agent's authoritative lock state and forwards it to the handler +/// only when it differs from the last value delivered — collapsing duplicate cross-channel +/// signals into one handler call per real change. +private final class StateReconciler: @unchecked Sendable { + private let handler: LockStateSubscriber.StateChangeHandler + private let lock = OSAllocatedUnfairLock() + private var lastDelivered: Bool? + + /// Transient XPC failures (e.g. the Agent being relaunched on demand) are retried so a real + /// state change is never dropped just because one fetch raced a reconnect. + private static let fetchAttempts = 3 + private static let retryDelay: Duration = .milliseconds(200) + + init(handler: @escaping LockStateSubscriber.StateChangeHandler) { + self.handler = handler + } + + func signal() { + Task { [weak self] in + guard let self, let isLocked = await fetchState() else { + return + } + + let shouldDeliver: Bool = lock.withLock { + guard lastDelivered != isLocked else { + return false + } + lastDelivered = isLocked + return true + } + + guard shouldDeliver else { + return + } + + await MainActor.run { self.handler(isLocked) } + } + } + + private func fetchState() async -> Bool? { + for attempt in 1 ... Self.fetchAttempts { + if let isLocked = try? await XPCClient.shared.status() { + return isLocked + } + if attempt < Self.fetchAttempts { + try? await Task.sleep(for: Self.retryDelay) + } + } + return nil + } +} + +// MARK: - Darwin Observer + +/// RAII wrapper around a Darwin notification observer. The C callback cannot capture context, +/// so the observing instance is passed through the opaque observer pointer. +private final class DarwinObserver { + private let name: CFNotificationName + private let onNotify: () -> Void + + init(name: String, onNotify: @escaping () -> Void) { + self.name = CFNotificationName(name as CFString) + self.onNotify = onNotify + + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + observer, + { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged.fromOpaque(observer).takeUnretainedValue().onNotify() + }, + self.name.rawValue, + nil, + .deliverImmediately + ) + } + + func cancel() { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + name, + nil + ) + } +} + // MARK: - Observer Token /// Token that controls subscription lifecycle. diff --git a/Core/Sources/Service/LockStateBroadcaster.swift b/Core/Sources/Service/LockStateBroadcaster.swift index 5cd4034..e4c3770 100644 --- a/Core/Sources/Service/LockStateBroadcaster.swift +++ b/Core/Sources/Service/LockStateBroadcaster.swift @@ -5,12 +5,15 @@ import Foundation /// Broadcasts lock state changes via system notifications. /// -/// Call `broadcast(isLocked:)` from the Agent process after lock state changes. -/// Clients subscribe using `LockStateSubscriber` in the Client module. +/// Call `broadcast(isLocked:)` from the Agent process after lock state changes. Only +/// **long-lived, state-reflecting** surfaces (App menu bar, Widgets) need this — they subscribe +/// via `LockStateSubscriber`. One-shot surfaces (CLI queries, AppleScript, Shortcuts) read the +/// authoritative state directly with a `status` XPC call and are synchronized by construction. /// -/// Notification channels: -/// - **Darwin**: Lightweight system-wide (no payload). For CLI, scripts, Shortcuts. -/// - **Distributed**: With state payload. For widgets, extensions, other apps. +/// Both channels carry no meaningful payload for consumers — subscribers fetch the authoritative +/// state from the Agent on any signal. Two channels are posted for delivery robustness: +/// - **Darwin**: can wake an App-Napped / suspended process so it learns of changes made while idle. +/// - **Distributed**: delivered on the main queue; the reliable path for a running CFRunLoop. public enum LockStateBroadcaster { /// Broadcasts lock state change to all system notification channels. public static func broadcast(isLocked: Bool) { diff --git a/docs/architecture.md b/docs/architecture.md index 94c75af..b7e47b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,15 @@ There is one physical keyboard, so lock state is a **single global boolean owned - Lock operations are **stateless one-off XPC calls** (`lock` / `unlock` / `status`), symmetric with each other. Do not model the lock as a "session" a client "owns" — a wrapper's connection lifetime is unrelated to the lock's lifetime. - To react to state changes, subscribe to the global broadcast (`LockStateSubscriber`). Never infer state from "did my call succeed". +## State Synchronization + +Because state lives in exactly one place (the Agent), wrappers stay in sync by always deferring to it. There are two wrapper shapes, and they read state differently: + +- **One-shot surfaces** — CLI `status`/`unlock`, AppleScript, Shortcuts (App Intents), any script. They ask the Agent at the moment they run (`XPCClient.status()`) and act on the answer. They hold no cached state, so they are **synchronized by construction** and need no subscription. Do not add notification handling to a one-shot surface. +- **Long-lived, state-reflecting surfaces** — the App menu bar, future Widgets. They display state continuously, so they must both **subscribe** (`LockStateSubscriber`) and **reconcile on becoming visible / on launch** (fetch `status()`), because a broadcast can be missed while the process is suspended. + +`LockStateSubscriber` treats notifications as *hints*, not truth: on any signal (Darwin or Distributed) it fetches the authoritative state from the Agent and de-duplicates. The Agent broadcasts on both channels only so a suspended App can be woken (Darwin) and a running one updated promptly (Distributed). A notification payload is never the source of truth. + ## Rules for Adding a New Wrapper (Widget, Shortcut, AppleScript, …) Before writing a wrapper, confirm all of the following: diff --git a/docs/development.md b/docs/development.md index 399165d..211af0b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,7 +25,7 @@ Only the non-obvious responsibilities are listed; read the source for signatures **Client** (`Core/Sources/Client/`) — App/CLI, never imports `Service` - `XPCClient.swift`: async/throwing `XPCClient.shared` with one auto-reconnecting connection; `lock`/`unlock`/`status`/`applySettings`/`currentSettings`/`accessibilityStatus`/`requestAccessibilityPermission`. No "session" type. -- `LockStateSubscriber.swift`: distributed-notification subscription → `ObserverToken`, plus `stateChanges` (`AsyncStream`). +- `LockStateSubscriber.swift`: subscribes to Darwin + Distributed broadcasts, treats each as a hint and fetches authoritative state via `XPCClient.status()` (retried, de-duplicated) → `ObserverToken`, plus `stateChanges` (`AsyncStream`). Only long-lived UI needs this; one-shot surfaces read `status()` directly (see architecture "State Synchronization"). **Service** (`Core/Sources/Service/`) — Agent only - `LockEngine.swift`: CGEventTap singleton, idempotent `lock(settings:)`, `updateSettings(_:)`, auto-unlock timer, hotkey detection, `OSAllocatedUnfairLock` state, `os.Logger`. From 6a29f76ef4bedb6dc043f5425eb82be350cf63c8 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 20:22:14 +0800 Subject: [PATCH 009/106] =?UTF-8?q?=F0=9F=93=9D=20docs(architecture):=20wi?= =?UTF-8?q?re=20state-sync=20rules=20into=20the=20wrapper=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The State Synchronization section existed but nothing in the "Adding a New Wrapper" checklist or the violations list pointed to it, so someone adding Shortcuts/AppleScript could follow the checklist and never learn a one-shot surface must not add notification handling. Add checklist item 6 (read state per your shape) and two violation entries (one-shot surface subscribing/caching; trusting a payload over status()). --- docs/architecture.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index b7e47b8..47aa1bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,7 @@ Before writing a wrapper, confirm all of the following: 3. **Holds no independent state.** No private `UserDefaults`, no local lock flag. Read settings and lock state from the core. 4. **Emits nothing to other processes.** Only the Agent broadcasts state. 5. **Degrades honestly when the Agent is unavailable.** Every wrapper must handle "core not reachable" (see the Agent lifecycle requirement below) rather than assuming success. +6. **Reads state the way its shape dictates.** Decide which of the two shapes in "State Synchronization" the wrapper is: a one-shot surface calls `status()` directly and adds **no** notification handling; a long-lived UI subscribes via `LockStateSubscriber` *and* reconciles on becoming visible. ## Agent Lifecycle Requirement @@ -69,3 +70,5 @@ These are the concrete failure modes this contract exists to prevent: - A "session" abstraction implying a client owns the lock → confusing behavior where one surface can't unlock what another locked. - Duplicated lock/settings logic across App and CLI → the exact maintenance explosion the DRY rule forbids. - A wrapper importing `Service` to "just call the engine directly" → bypasses the single core. +- A one-shot surface (CLI/AppleScript/Shortcuts) subscribing to broadcasts, or caching lock state between invocations → needless complexity and a new drift source; it should just call `status()` when it runs. +- Trusting a notification payload as the state instead of fetching `status()` → stale/reordered/dropped notifications silently desync the UI. From 7298d3fde62168b104138ab7751c2c92b7237536 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 20:27:57 +0800 Subject: [PATCH 010/106] =?UTF-8?q?=F0=9F=93=9D=20docs:=20translate=20arch?= =?UTF-8?q?itecture=20and=20development=20guides=20to=20Chinese?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate the prose in docs/architecture.md and docs/development.md to Chinese while keeping all code identifiers, file paths, protocol/method names, commands, and the ASCII diagram in English (per the repo's Chinese-context-with-English-code-blocks convention). No content changes. --- docs/architecture.md | 84 ++++++++++++++++++++-------------------- docs/development.md | 92 ++++++++++++++++++++++---------------------- 2 files changed, 88 insertions(+), 88 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 47aa1bb..aa573ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,15 +1,15 @@ -# Core Architecture Contract +# 核心架构契约 -**This is the foundational rule of the project. Every feature — the App, `klock`, Shortcuts, AppleScript, Widgets, and anything added later — MUST follow it. When a change conflicts with this contract, the contract wins; revise the change, not the contract.** +**这是项目的根本规则。每一个功能面 —— App、`klock`、Shortcuts、AppleScript、Widgets 以及后续新增的任何形态 —— 都必须遵守它。当某个改动与本契约冲突时,契约优先;修改的是那个改动,而不是契约。** -## Single Core Principle +## 单核原则 -The **Agent is the one and only place that executes real work.** All other surfaces (App, CLI, Shortcuts, AppleScript, Widgets, extensions) are **thin wrappers** whose sole job is: +**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、AppleScript、Widgets、扩展)都是**薄 wrapper**,职责仅限两件事: -1. Translate a user intent into an XPC call to the Agent, and -2. Observe global state via notifications. +1. 把用户意图翻译成对 Agent 的一次 XPC 调用; +2. 通过通知观察全局状态。 -A wrapper never owns behavior or state. If two wrappers appear to need the same logic, that logic belongs in the core, not copied into each wrapper. +wrapper 永远不拥有行为或状态。如果两个 wrapper 看起来需要同一段逻辑,那段逻辑属于核心,而不该复制进每个 wrapper。 ``` App ─────┐ @@ -20,55 +20,55 @@ Widget ──┘ ├─ Settings ownership (source of truth) └─ Accessibility (permission gate) ``` -## What Belongs Where +## 各职责的归属 -| Concern | Home | Rule | +| 关注点 | 归属 | 规则 | |---------|------|------| -| Lock/unlock execution (CGEventTap) | **Agent** (`Service/LockEngine`) | Never runs anywhere else. No wrapper touches CGEventTap. | -| Settings (source of truth) | **Agent** | The Agent loads/owns settings and applies them. Wrappers read/write settings only through XPC, never by holding their own `UserDefaults`. | -| Accessibility permission | **Agent** | The Agent holds the permission and answers status/requests over XPC. | -| State broadcast | **Agent** (`LockStateBroadcaster`) | Only the core emits state. Wrappers subscribe, never emit. | -| UI / intent translation | **Wrappers** | Wrappers may hold view state, but no domain logic. | +| 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | +| 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 只通过 XPC 读写设置,绝不自己持有 `UserDefaults`。 | +| Accessibility 权限 | **Agent** | Agent 持有权限,并通过 XPC 回答状态查询 / 权限请求。 | +| 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | +| UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,但不含任何领域逻辑。 | -## Global Lock Semantics +## 全局锁语义 -There is one physical keyboard, so lock state is a **single global boolean owned by the Agent**. There is no per-client or per-session ownership of the lock. +物理键盘只有一个,所以锁状态是**一个由 Agent 拥有的全局布尔值**。不存在按客户端或按会话划分的锁归属。 -- Any wrapper may lock; any wrapper may unlock. A lock started by the CLI is unlockable by the App, and vice versa. -- Lock operations are **stateless one-off XPC calls** (`lock` / `unlock` / `status`), symmetric with each other. Do not model the lock as a "session" a client "owns" — a wrapper's connection lifetime is unrelated to the lock's lifetime. -- To react to state changes, subscribe to the global broadcast (`LockStateSubscriber`). Never infer state from "did my call succeed". +- 任何 wrapper 都可以锁;任何 wrapper 都可以解锁。CLI 发起的锁,App 可以解开,反之亦然。 +- 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 +- 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 -## State Synchronization +## 状态同步 -Because state lives in exactly one place (the Agent), wrappers stay in sync by always deferring to it. There are two wrapper shapes, and they read state differently: +因为状态只存在于唯一一处(Agent),wrapper 保持同步的方式就是始终以它为准。wrapper 有两种形态,读取状态的方式不同: -- **One-shot surfaces** — CLI `status`/`unlock`, AppleScript, Shortcuts (App Intents), any script. They ask the Agent at the moment they run (`XPCClient.status()`) and act on the answer. They hold no cached state, so they are **synchronized by construction** and need no subscription. Do not add notification handling to a one-shot surface. -- **Long-lived, state-reflecting surfaces** — the App menu bar, future Widgets. They display state continuously, so they must both **subscribe** (`LockStateSubscriber`) and **reconcile on becoming visible / on launch** (fetch `status()`), because a broadcast can be missed while the process is suspended. +- **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 +- **长命的状态反映面** —— App 菜单栏、未来的 Widgets。它们持续显示状态,因此必须既**订阅**(`LockStateSubscriber`),又在**变为可见 / 启动时校准**(拉取 `status()`)—— 因为进程被挂起期间可能错过某次广播。 -`LockStateSubscriber` treats notifications as *hints*, not truth: on any signal (Darwin or Distributed) it fetches the authoritative state from the Agent and de-duplicates. The Agent broadcasts on both channels only so a suspended App can be woken (Darwin) and a running one updated promptly (Distributed). A notification payload is never the source of truth. +`LockStateSubscriber` 把通知当作*提示*而非真相:收到任一信号(Darwin 或 Distributed)后,它都会向 Agent 拉取权威状态并去重。Agent 之所以在两个通道都广播,只是为了让被挂起的 App 能被唤醒(Darwin)、让正在运行的 App 能及时更新(Distributed)。通知的载荷永远不是真相源。 -## Rules for Adding a New Wrapper (Widget, Shortcut, AppleScript, …) +## 新增 wrapper 的规则(Widget、Shortcut、AppleScript……) -Before writing a wrapper, confirm all of the following: +编写 wrapper 之前,逐条确认以下几点: -1. **Imports `Client`, never `Service`.** Wrappers talk to the core through the XPC client only. Importing `Service` (LockEngine, event tap) into a wrapper is a contract violation. -2. **Adds no new domain logic.** If the wrapper needs behavior the core doesn't expose, add it to the Agent + `KeyboardLockerServiceProtocol` first, then call it. Do not implement it wrapper-side. -3. **Holds no independent state.** No private `UserDefaults`, no local lock flag. Read settings and lock state from the core. -4. **Emits nothing to other processes.** Only the Agent broadcasts state. -5. **Degrades honestly when the Agent is unavailable.** Every wrapper must handle "core not reachable" (see the Agent lifecycle requirement below) rather than assuming success. -6. **Reads state the way its shape dictates.** Decide which of the two shapes in "State Synchronization" the wrapper is: a one-shot surface calls `status()` directly and adds **no** notification handling; a long-lived UI subscribes via `LockStateSubscriber` *and* reconciles on becoming visible. +1. **只 import `Client`,绝不 import `Service`。** wrapper 只通过 XPC 客户端与核心通信。把 `Service`(LockEngine、event tap)引入 wrapper 是违约。 +2. **不新增领域逻辑。** 如果 wrapper 需要核心尚未暴露的行为,先把它加到 Agent + `KeyboardLockerServiceProtocol`,再调用;不要在 wrapper 侧实现。 +3. **不持有独立状态。** 没有私有 `UserDefaults`,没有本地锁标志。设置和锁状态都从核心读取。 +4. **不向其他进程发出任何东西。** 只有 Agent 广播状态。 +5. **Agent 不可达时诚实降级。** 每个 wrapper 都必须处理"核心不可达"(见下方 Agent 生命周期要求),而不是假定调用成功。 +6. **按自身形态读取状态。** 判断这个 wrapper 属于"状态同步"里的哪一种形态:一次性面直接调 `status()`,**不加**任何通知处理;长命 UI 通过 `LockStateSubscriber` 订阅,*并且*在变为可见时校准。 -## Agent Lifecycle Requirement +## Agent 生命周期要求 -Wrappers depend on the Agent being reachable even when the App is not running. The Agent MUST be registered with the system (`SMAppService`) so `launchd` can launch it on demand for any XPC client. A wrapper that assumes the Agent is already running (e.g. because the App happens to be open) is incorrect. +即使 App 没有运行,wrapper 也依赖 Agent 可达。Agent 必须向系统注册(`SMAppService`),以便 `launchd` 能按需为任何 XPC 客户端拉起它。假定 Agent 已经在运行(例如"因为 App 恰好开着")的 wrapper 是错误的。 -## Contract Violations to Watch For +## 需要警惕的违约 -These are the concrete failure modes this contract exists to prevent: +以下是本契约要防范的具体失败模式: -- A wrapper holding its own `KeyboardLockerSettingsStore` / `UserDefaults` → settings drift from the core (the Agent would act on stale or default settings). -- A "session" abstraction implying a client owns the lock → confusing behavior where one surface can't unlock what another locked. -- Duplicated lock/settings logic across App and CLI → the exact maintenance explosion the DRY rule forbids. -- A wrapper importing `Service` to "just call the engine directly" → bypasses the single core. -- A one-shot surface (CLI/AppleScript/Shortcuts) subscribing to broadcasts, or caching lock state between invocations → needless complexity and a new drift source; it should just call `status()` when it runs. -- Trusting a notification payload as the state instead of fetching `status()` → stale/reordered/dropped notifications silently desync the UI. +- wrapper 自己持有 `KeyboardLockerSettingsStore` / `UserDefaults` → 设置与核心漂移(Agent 会基于过期或默认设置行动)。 +- 引入"会话"抽象、暗示客户端拥有锁 → 造成"某个面无法解开另一个面锁上的锁"这种迷惑行为。 +- 锁/设置逻辑在 App 与 CLI 之间重复 → 正是 DRY 规则要禁止的维护爆炸。 +- wrapper 为了"直接调引擎"而 import `Service` → 绕过了单核。 +- 一次性面(CLI/AppleScript/Shortcuts)订阅广播,或在多次调用之间缓存锁状态 → 徒增复杂度并制造新的漂移源;它运行时直接调 `status()` 即可。 +- 把通知载荷当作状态、而不去拉取 `status()` → 过期/乱序/丢失的通知会悄悄让 UI 失去同步。 diff --git a/docs/development.md b/docs/development.md index 211af0b..8bfdd2a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,64 +1,64 @@ -# Development Guide +# 开发指南 -Task-specific workflows and component reference. For the non-negotiable design rules, read [architecture.md](architecture.md) first — it always wins over anything here. +任务相关的工作流与组件参考。关于不可协商的设计规则,先读 [architecture.md](architecture.md) —— 它始终优先于本文档的任何内容。 -## XPC Communication Flow +## XPC 通信流程 -All surfaces talk to the Agent over `KeyboardLockerServiceProtocol` (Mach service `io.lzhlovesjyq.keyboardlocker.agent`). +所有面都通过 `KeyboardLockerServiceProtocol` 与 Agent 通信(Mach 服务 `io.lzhlovesjyq.keyboardlocker.agent`)。 -1. A wrapper (App/CLI/…) sends a **stateless one-off call** via the async `XPCClient`: `lock` / `unlock` / `status` / `applySettings` / `currentSettings` / `accessibilityStatus` / `requestAccessibilityPermission`. Failures throw (a dead Agent surfaces as a thrown error, never a hang). -2. The Agent's `ServiceDelegate` accepts the connection (after `XPCAccessControl` validates code signature + bundle ID) and routes to `AgentService`. -3. `AgentService` owns the settings source of truth (`KeyboardLockerSettingsStore`, in `Service`) and drives `LockEngine.shared.lock(settings:)` / `unlock()`. -4. `LockEngine` creates the CGEventTap and, on any state change, calls `LockStateBroadcaster.broadcast(isLocked:)`. -5. Wrappers observe state via `LockStateSubscriber.subscribe(_:)` (returns an `ObserverToken`) or `LockStateSubscriber.stateChanges` (`AsyncStream`) — never by inferring from "did my call succeed". +1. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `applySettings` / `currentSettings` / `accessibilityStatus` / `requestAccessibilityPermission`。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 +2. Agent 的 `ServiceDelegate` 接受连接(在 `XPCAccessControl` 校验代码签名 + bundle ID 之后),并路由到 `AgentService`。 +3. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:)` / `unlock()`。 +4. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast(isLocked:)`。 +5. wrapper 通过 `LockStateSubscriber.subscribe(_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态 —— 绝不从"我这次调用是否成功"推断。 -> The lock is a single global boolean owned by the Agent, and `lock()` is **idempotent** (locking while locked re-applies settings and returns success). There is no client-owned "session"; every call is a one-off. The Agent must be `SMAppService`-registered so `launchd` starts it on demand — the App does this at launch via `AgentRegistrar` (see below). +> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**幂等**的(已锁时再锁会重新应用设置并返回成功)。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 -## Component Map +## 组件地图 -Only the non-obvious responsibilities are listed; read the source for signatures. +只列出不那么显而易见的职责;签名请读源码。 -**Common** (`Core/Sources/Common/`) — shared by all targets -- `Shared.swift`: `KeyboardLockerServiceProtocol` (locking + `applySettings(Data)`/`currentSettings` + accessibility), `SharedConstants` (Mach name, bundle-ID allowlist, CLI constants), `NotificationNames.stateChanged`. -- `KeyboardLockerSettings.swift`: `KeyboardLockerSettings` (`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`, `unlockHotkey`, `showsUnlockNotification`) + `.default` + `encodedForXPC()`/`decodedFromXPC(_:)` (JSON transport across the `@objc` boundary). -- `KeyCodeConverter.swift`: layout-aware `CGKeyCode` → shortcut string (⌃⌥⇧⌘ order) via `UCKeyTranslate`. +**Common**(`Core/Sources/Common/`)—— 所有 target 共享 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(锁操作 + `applySettings(Data)`/`currentSettings` + accessibility)、`SharedConstants`(Mach 名、bundle-ID 白名单、CLI 常量)、`NotificationNames.stateChanged`。 +- `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`、`showsUnlockNotification`)+ `.default` + `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界的 JSON 传输)。 +- `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 -**Client** (`Core/Sources/Client/`) — App/CLI, never imports `Service` -- `XPCClient.swift`: async/throwing `XPCClient.shared` with one auto-reconnecting connection; `lock`/`unlock`/`status`/`applySettings`/`currentSettings`/`accessibilityStatus`/`requestAccessibilityPermission`. No "session" type. -- `LockStateSubscriber.swift`: subscribes to Darwin + Distributed broadcasts, treats each as a hint and fetches authoritative state via `XPCClient.status()` (retried, de-duplicated) → `ObserverToken`, plus `stateChanges` (`AsyncStream`). Only long-lived UI needs this; one-shot surfaces read `status()` directly (see architecture "State Synchronization"). +**Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条自动重连的连接;`lock`/`unlock`/`status`/`applySettings`/`currentSettings`/`accessibilityStatus`/`requestAccessibilityPermission`。没有 "session" 类型。 +- `LockStateSubscriber.swift`:订阅 Darwin + Distributed 广播,把每个信号当作提示,并通过 `XPCClient.status()` 拉取权威状态(带重试、去重)→ `ObserverToken`,另有 `stateChanges`(`AsyncStream`)。只有长命 UI 需要它;一次性面直接读 `status()`(见 architecture 的"状态同步")。 -**Service** (`Core/Sources/Service/`) — Agent only -- `LockEngine.swift`: CGEventTap singleton, idempotent `lock(settings:)`, `updateSettings(_:)`, auto-unlock timer, hotkey detection, `OSAllocatedUnfairLock` state, `os.Logger`. -- `KeyboardLockerSettingsStore.swift`: `UserDefaults`-backed settings persistence — lives in `Service` so no wrapper can own its own store (contract source-of-truth rule). -- `LockStateBroadcaster.swift`: posts Darwin (payload-free) + Distributed (with payload) notifications. -- `AccessibilityManager.swift`, `XPCAccessControl.swift` (release = signature + Team ID + allowlist; debug = allowlist only), `XPCServerConnection.swift`. +**Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 +- `LockEngine.swift`:CGEventTap 单例、幂等的 `lock(settings:)`、`updateSettings(_:)`、自动解锁定时器、热键检测、`OSAllocatedUnfairLock` 状态、`os.Logger`。 +- `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 +- `LockStateBroadcaster.swift`:发出 Darwin(无载荷)+ Distributed(带载荷)通知。 +- `AccessibilityManager.swift`、`XPCAccessControl.swift`(release = 签名 + Team ID + 白名单;debug = 仅白名单)、`XPCServerConnection.swift`。 -**App** (`KeyboardLocker/`) — thin SwiftUI wrapper -- `AgentRegistrar.swift`: `SMAppService.agent(plistName:)` registration at launch (idempotent). -- `LockController.swift`: `@MainActor ObservableObject` view state — issues async `XPCClient` calls, reflects broadcast state, holds no lock/settings logic. -- `SystemSettings.swift`: opens the Accessibility pane (UI concern, App-local, not in `Common`). +**App**(`KeyboardLocker/`)—— 薄 SwiftUI wrapper +- `AgentRegistrar.swift`:启动时做 `SMAppService.agent(plistName:)` 注册(幂等)。 +- `LockController.swift`:`@MainActor ObservableObject` 视图状态 —— 发出异步 `XPCClient` 调用、反映广播状态,不含任何锁/设置逻辑。 +- `SystemSettings.swift`:打开 Accessibility 面板(UI 关注点,App 本地持有,不放进 `Common`)。 -## Common Tasks +## 常见任务 -### Add a setting -1. Add a `Codable`/`Sendable` property to `KeyboardLockerSettings` and update `.default`. -2. If the engine consumes it, read it in `LockEngine.lock(settings:)` / `updateSettings(_:)`. -3. Wrappers read/write settings only through `XPCClient.currentSettings()` / `applySettings(_:)`; the Agent persists via its `KeyboardLockerSettingsStore`. Wrappers must **not** own a store (see architecture contract). +### 新增一个设置项 +1. 给 `KeyboardLockerSettings` 加一个 `Codable`/`Sendable` 属性,并更新 `.default`。 +2. 如果引擎会消费它,在 `LockEngine.lock(settings:)` / `updateSettings(_:)` 中读取。 +3. wrapper 只通过 `XPCClient.currentSettings()` / `applySettings(_:)` 读写设置;由 Agent 经其 `KeyboardLockerSettingsStore` 持久化。wrapper **不得**拥有 store(见架构契约)。 -### Add an XPC method -1. Add the signature to `KeyboardLockerServiceProtocol` in `Common/Shared.swift`. Values that aren't `@objc`-representable (like `KeyboardLockerSettings`) cross as JSON `Data` — reuse `encodedForXPC()`/`decodedFromXPC(_:)`. -2. Implement it in `KeyboardLockerAgent/AgentService.swift`. -3. Add a thin async wrapper on `XPCClient` in `Client/` if a surface needs it. +### 新增一个 XPC 方法 +1. 在 `Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 里加签名。无法用 `@objc` 表达的值(如 `KeyboardLockerSettings`)以 JSON `Data` 跨界 —— 复用 `encodedForXPC()`/`decodedFromXPC(_:)`。 +2. 在 `KeyboardLockerAgent/AgentService.swift` 中实现它。 +3. 如果某个面需要,在 `Client/` 的 `XPCClient` 上加一个薄的异步封装。 -### Modify event filtering -In `LockEngine.handleEvent(proxy:type:event:)`: return `nil` to block, `Unmanaged.passUnretained(event)` to allow, and call `unlock()` when unlock conditions (hotkey or timeout) are met. `Hotkey.matches(keyCode:flags:)` filters CapsLock/NumLock via `relevantModifierMask`. +### 修改事件过滤 +在 `LockEngine.handleEvent(proxy:type:event:)` 中:返回 `nil` 拦截、返回 `Unmanaged.passUnretained(event)` 放行,并在满足解锁条件(热键或超时)时调用 `unlock()`。`Hotkey.matches(keyCode:flags:)` 会通过 `relevantModifierMask` 过滤掉 CapsLock/NumLock。 -### CLI installation -`CLIInstaller` symlinks `/usr/local/bin/klock` → the CLI binary inside the app bundle (version consistency), prompting for admin via AppleScript. `install()`/`uninstall()` return `InstallResult` (`.success` / `.alreadyInstalled` / `.cancelled` / `.failed(Error)`); check `isInstalled` / `isCurrentVersionInstalled`. +### CLI 安装 +`CLIInstaller` 把 `/usr/local/bin/klock` 软链到 app bundle 内的 CLI 二进制(保证版本一致),并通过 AppleScript 请求管理员权限。`install()`/`uninstall()` 返回 `InstallResult`(`.success` / `.alreadyInstalled` / `.cancelled` / `.failed(Error)`);用 `isInstalled` / `isCurrentVersionInstalled` 判断状态。 -## Testing Notes +## 测试须知 -- `LockEngine.lock` throws `.accessibilityPermissionDenied` when Accessibility permission is missing (checked before creating the event tap). -- The Agent must be running/registered (`SMAppService`) for XPC calls to succeed. -- Engine operations dispatch to the main thread for CFRunLoop access. -- The system may disable the event tap (timeout/user input); `LockEngine` attempts re-enable. +- 缺少 Accessibility 权限时,`LockEngine.lock` 抛出 `.accessibilityPermissionDenied`(在创建 event tap 之前检查)。 +- XPC 调用要成功,Agent 必须正在运行 / 已注册(`SMAppService`)。 +- 引擎操作会派发到主线程,以便访问 CFRunLoop。 +- 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。 From 7b55a5409e3c11c988608a5a1ea3348020c1e8c2 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 20:48:24 +0800 Subject: [PATCH 011/106] =?UTF-8?q?=F0=9F=93=9D=20docs(architecture):=20ad?= =?UTF-8?q?d=20"why=20single-core"=20lessons=20from=20the=20old=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepend a section grounding the contract in the concrete failure modes of a prior implementation (the `main`-branch design): per-process `.shared` singletons that never shared state across App/CLI/AppleScript, callback-based state push that only reached the local process, layering split by concern instead of by process boundary, and per-process UserDefaults drift. Each maps to a rule the contract already states, so newcomers see the "why" before the "what". --- docs/architecture.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index aa573ca..76ad814 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,6 +2,21 @@ **这是项目的根本规则。每一个功能面 —— App、`klock`、Shortcuts、AppleScript、Widgets 以及后续新增的任何形态 —— 都必须遵守它。当某个改动与本契约冲突时,契约优先;修改的是那个改动,而不是契约。** +## 为什么是单核(从旧方案学到的) + +本契约不是凭空的偏好,而是对一版**跑偏的旧实现**的直接回应。旧方案工程质量不差(依赖注入、职责分层、注释齐全),却选错了架构轴,导致多个功能面之间的锁状态根本无法对齐。把它的失败模式记在这里,是为了让契约的每条规则都有据可依,后来者(包括未来的自己)不再重蹈。 + +- **进程内单例不是跨进程共享。** 旧方案让 App、CLI、AppleScript 各自 `import` 核心并持有 `KeyboardLockCore.shared`。`static let shared` 是**进程内**单例——三个进程三个实例,不是同一个。于是 CLI `lock` 锁的是 CLI 进程自己的 event tap,CLI 一退出锁就被 `deinit` 拆掉,锁**活不过命令**;App 与 CLI 的 `isLocked` 是两个变量,永远对不上。 + → **教训**:「一个物理键盘 = 一个全局状态」这个领域事实,必须映射为「一个**进程**持有它」(Agent + XPC),而不是「一个**类型**持有它」。语言层单例解决不了进程边界。 +- **回调式状态推送只能通知本进程。** 旧核心用 `var onLockStateChanged: ((Bool, Date?) -> Void)?` 广播变化,闭包只能绑本进程对象;CLI 改了状态,App 的回调永远收不到。旧架构连「跨进程同步」的入口都没有。 + → **教训**:多面场景下必须用系统级广播(见下文"状态同步"),而非进程内回调。 +- **分层分对了,轴选错了。** 旧方案按"关注点"垂直分层非常漂亮,却没有先按"进程边界"水平切分,于是每一层都被复制进三个进程。 + → **教训**:干净的类型分层 ≠ 正确的系统架构。有多个入口进程时,**第一刀切在进程边界(谁执行 / 谁只是 wrapper),再谈类型优雅**。 +- **各进程独立读 `UserDefaults` → 设置漂移。** 旧方案每个进程各持一份配置,CLI 改了热键 App 不知道。 + → **教训**:设置也只能有一个真相源(Agent),wrapper 只经 XPC 读写(见下文"各职责的归属")。 + +一句话:**有进程边界时,先按进程切,再谈类型优雅;单例和回调是进程内工具,不要拿来跨进程。** 下面的规则都是这句话的展开。 + ## 单核原则 **Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、AppleScript、Widgets、扩展)都是**薄 wrapper**,职责仅限两件事: From 30029856d63f8b46bc95f3d54f6edd3f4e2e5143 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 21:36:58 +0800 Subject: [PATCH 012/106] =?UTF-8?q?=F0=9F=94=92=20fix(client):=20make=20XP?= =?UTF-8?q?CClient=20a=20true=20singleton=20with=20private=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public initializer let any wrapper construct its own XPCClient with a separate cached connection, defeating the single-connection reuse the type guarantees and inviting cross-instance state drift — the in-process cousin of the very divergence the single-core contract forbids. --- Core/Sources/Client/XPCClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index d97c639..f6558de 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -27,7 +27,7 @@ public final class XPCClient: @unchecked Sendable { private let lock = OSAllocatedUnfairLock() private var connection: NSXPCConnection? - public init() {} + private init() {} // MARK: - Operations From 8fe0d5713db17d95684045a8a394038fdf372684 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 21:37:49 +0800 Subject: [PATCH 013/106] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(broadcast?= =?UTF-8?q?er):=20drop=20the=20unread=20isLocked=20notification=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribers already treat every signal as a content-free hint and fetch authoritative state via status(), so the distributed userInfo was dead data that contradicted the 'payload is never the source of truth' rule. broadcast(isLocked:) becomes a pure broadcast(); docs realigned. --- Core/Sources/Service/LockEngine.swift | 4 ++-- .../Sources/Service/LockStateBroadcaster.swift | 18 +++++++++--------- docs/development.md | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 1487c99..be59883 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -175,7 +175,7 @@ public class LockEngine { } configureAutoUnlockTimerIfNeeded() - LockStateBroadcaster.broadcast(isLocked: true) + LockStateBroadcaster.broadcast() Self.logger.info("Locked") } @@ -244,7 +244,7 @@ public class LockEngine { _autoUnlockTargetDate = nil } - LockStateBroadcaster.broadcast(isLocked: false) + LockStateBroadcaster.broadcast() Self.logger.info("Unlocked") } diff --git a/Core/Sources/Service/LockStateBroadcaster.swift b/Core/Sources/Service/LockStateBroadcaster.swift index e4c3770..b4ca91a 100644 --- a/Core/Sources/Service/LockStateBroadcaster.swift +++ b/Core/Sources/Service/LockStateBroadcaster.swift @@ -5,20 +5,21 @@ import Foundation /// Broadcasts lock state changes via system notifications. /// -/// Call `broadcast(isLocked:)` from the Agent process after lock state changes. Only +/// Call `broadcast()` from the Agent process after lock state changes. Only /// **long-lived, state-reflecting** surfaces (App menu bar, Widgets) need this — they subscribe /// via `LockStateSubscriber`. One-shot surfaces (CLI queries, AppleScript, Shortcuts) read the /// authoritative state directly with a `status` XPC call and are synchronized by construction. /// -/// Both channels carry no meaningful payload for consumers — subscribers fetch the authoritative -/// state from the Agent on any signal. Two channels are posted for delivery robustness: +/// Both channels are payload-free "something changed" signals — subscribers fetch the +/// authoritative state from the Agent on any signal, never trusting a notification's contents. +/// Two channels are posted for delivery robustness: /// - **Darwin**: can wake an App-Napped / suspended process so it learns of changes made while idle. /// - **Distributed**: delivered on the main queue; the reliable path for a running CFRunLoop. public enum LockStateBroadcaster { - /// Broadcasts lock state change to all system notification channels. - public static func broadcast(isLocked: Bool) { + /// Broadcasts a lock state change to all system notification channels. + public static func broadcast() { postDarwin() - postDistributed(isLocked: isLocked) + postDistributed() } private static func postDarwin() { @@ -31,11 +32,10 @@ public enum LockStateBroadcaster { ) } - private static func postDistributed(isLocked: Bool) { + private static func postDistributed() { DistributedNotificationCenter.default().post( name: Notification.Name(NotificationNames.stateChanged), - object: nil, - userInfo: ["isLocked": isLocked] + object: nil ) } } diff --git a/docs/development.md b/docs/development.md index 8bfdd2a..2e8b26e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ 1. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `applySettings` / `currentSettings` / `accessibilityStatus` / `requestAccessibilityPermission`。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 2. Agent 的 `ServiceDelegate` 接受连接(在 `XPCAccessControl` 校验代码签名 + bundle ID 之后),并路由到 `AgentService`。 3. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:)` / `unlock()`。 -4. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast(isLocked:)`。 +4. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 5. wrapper 通过 `LockStateSubscriber.subscribe(_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态 —— 绝不从"我这次调用是否成功"推断。 > 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**幂等**的(已锁时再锁会重新应用设置并返回成功)。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 @@ -30,7 +30,7 @@ **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 - `LockEngine.swift`:CGEventTap 单例、幂等的 `lock(settings:)`、`updateSettings(_:)`、自动解锁定时器、热键检测、`OSAllocatedUnfairLock` 状态、`os.Logger`。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 -- `LockStateBroadcaster.swift`:发出 Darwin(无载荷)+ Distributed(带载荷)通知。 +- `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `AccessibilityManager.swift`、`XPCAccessControl.swift`(release = 签名 + Team ID + 白名单;debug = 仅白名单)、`XPCServerConnection.swift`。 **App**(`KeyboardLocker/`)—— 薄 SwiftUI wrapper From 54d37d5cdfc4bf770b31f8692c206ac39ed39d02 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 21:38:03 +0800 Subject: [PATCH 014/106] =?UTF-8?q?=F0=9F=90=9B=20fix(engine):=20stop=20tr?= =?UTF-8?q?eating=20flagsChanged=20as=20an=20unlock=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flagsChanged branch ignored the hotkey's modifier flags and fired on any modifier change while the hotkey's key was physically held — a false unlock risk for the default ⌃⌘L (unlock already flows through keyDown). Its only intended use, modifier-only hotkeys, is unreachable with no settings UI. Dropped the branch; flagsChanged stays in the event mask so modifier keys are still blocked while locked. --- Core/Sources/Service/LockEngine.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index be59883..a93d31d 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -279,10 +279,6 @@ public class LockEngine { let isAutoRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) == Self.autoRepeatFlagValue return !isAutoRepeat - case .flagsChanged: - let keyCode = hotkey.keyCode - return CGEventSource.keyState(.hidSystemState, key: keyCode) - default: return false } From da660eeb7750691f69bcaa5b73716b5a82b6a60d Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 21:38:23 +0800 Subject: [PATCH 015/106] =?UTF-8?q?=F0=9F=94=A5=20refactor(engine):=20narr?= =?UTF-8?q?ow=20unused=20lock-timing=20accessors=20to=20internal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lockStartedAt, autoUnlockTargetDate, lockDuration(at:), and remainingAutoUnlockTime(at:) have no cross-module consumers. Dropping public honors the 'minimal public surface' rule — every public in Core is a maintenance commitment — while keeping them available in-module for a future state-display UI. --- Core/Sources/Service/LockEngine.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index a93d31d..73abde6 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -92,11 +92,11 @@ public class LockEngine { withLock { _isLocked } } - public var lockStartedAt: Date? { + var lockStartedAt: Date? { withLock { _lockStartedAt } } - public var autoUnlockTargetDate: Date? { + var autoUnlockTargetDate: Date? { withLock { _autoUnlockTargetDate } } @@ -284,7 +284,7 @@ public class LockEngine { } } - public func lockDuration(at date: Date = Date()) -> TimeInterval? { + func lockDuration(at date: Date = Date()) -> TimeInterval? { let start = withLock { _lockStartedAt } guard let start else { return nil @@ -292,7 +292,7 @@ public class LockEngine { return max(0, date.timeIntervalSince(start)) } - public func remainingAutoUnlockTime(at date: Date = Date()) -> TimeInterval? { + func remainingAutoUnlockTime(at date: Date = Date()) -> TimeInterval? { let deadline = withLock { _autoUnlockTargetDate } guard let deadline else { return nil From 851522185715d199862035ad82c0988477463cc0 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 21:40:47 +0800 Subject: [PATCH 016/106] =?UTF-8?q?=F0=9F=94=A5=20refactor:=20remove=20unw?= =?UTF-8?q?ired=20settings-write=20and=20accessibility=20XPC=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applySettings, requestAccessibilityPermission, and accessibilityStatus were fully plumbed (protocol + client + agent) but had no caller — no settings UI ever wrote, and no wrapper drove the permission prompt. Per 'minimal public surface' and 'slow is fast', drop the speculative machinery rather than ship dead infrastructure: - protocol keeps only lock/unlock/status/currentSettings (read) - AgentService settings become an immutable init-time value; the mutable store/lock and currentSettingsValue() collapse away - AccessibilityManager keeps hasPermission() (used by LockEngine.lock); requestPermission() and the SettingsStore.save() write path go - delete SystemSettings.swift (dead Accessibility-pane opener) Docs realigned: the architecture contract now states settings are read-only and permission is checked in-engine, with write/request paths marked as unwired — must still route through the Agent when added. --- Core/Sources/Client/XPCClient.swift | 21 -------- Core/Sources/Common/Shared.swift | 9 ---- .../Service/AccessibilityManager.swift | 19 ------- .../Service/KeyboardLockerSettingsStore.swift | 6 --- KeyboardLocker/SystemSettings.swift | 13 ----- KeyboardLockerAgent/AgentService.swift | 54 ++----------------- docs/architecture.md | 4 +- docs/development.md | 9 ++-- 8 files changed, 11 insertions(+), 124 deletions(-) delete mode 100644 KeyboardLocker/SystemSettings.swift diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index f6558de..f3bb000 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -50,14 +50,6 @@ public final class XPCClient: @unchecked Sendable { } } - /// Persists settings in the Agent (source of truth) and applies them to any running lock. - public func applySettings(_ settings: KeyboardLockerSettings) async throws { - let data = try settings.encodedForXPC() - try await withProxy { service, resume in - service.applySettings(data) { resume($0) } - } - } - /// The Agent's current settings, falling back to `.default` if the Agent can't provide them. public func currentSettings() async throws -> KeyboardLockerSettings { let data: Data? = try await withProxyReturning { service, resume in @@ -66,19 +58,6 @@ public final class XPCClient: @unchecked Sendable { return KeyboardLockerSettings.decodedFromXPC(data) } - @discardableResult - public func requestAccessibilityPermission(showPrompt: Bool = true) async throws -> Bool { - try await withProxyReturning { service, resume in - service.requestAccessibilityPermission(showPrompt: showPrompt) { resume($0, nil) } - } - } - - public func accessibilityStatus() async throws -> Bool { - try await withProxyReturning { service, resume in - service.accessibilityStatus { resume($0, nil) } - } - } - // MARK: - Connection Management private func currentConnection() -> NSXPCConnection { diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 249b370..f0045ae 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -48,16 +48,7 @@ public protocol KeyboardLockerServiceProtocol { // MARK: Settings Methods - /// Persists new settings in the Agent (the source of truth) and applies them to a running lock. - /// Settings cross the `@objc` XPC boundary as JSON-encoded `KeyboardLockerSettings`. - func applySettings(_ data: Data, reply: @escaping (Error?) -> Void) - /// Returns the Agent's current settings as JSON-encoded `KeyboardLockerSettings`. /// `data` is nil only if the Agent failed to encode; wrappers fall back to `.default`. func currentSettings(reply: @escaping (Data?) -> Void) - - // MARK: Accessibility Permission Methods - - func requestAccessibilityPermission(showPrompt: Bool, reply: @escaping (Bool) -> Void) - func accessibilityStatus(reply: @escaping (Bool) -> Void) } diff --git a/Core/Sources/Service/AccessibilityManager.swift b/Core/Sources/Service/AccessibilityManager.swift index c43284f..a88a57c 100644 --- a/Core/Sources/Service/AccessibilityManager.swift +++ b/Core/Sources/Service/AccessibilityManager.swift @@ -10,23 +10,4 @@ public final class AccessibilityManager { public static func hasPermission() -> Bool { AXIsProcessTrusted() } - - /// Requests Accessibility permission from the user - /// - Parameter showPrompt: Whether to trigger macOS system prompt - /// - Returns: Current permission status after request - @discardableResult - public static func requestPermission(showPrompt: Bool = true) -> Bool { - // Early return if already granted to avoid unnecessary system calls - if hasPermission() { - return true - } - - guard showPrompt else { - return false - } - - // Trigger macOS system prompt that opens Privacy & Security settings - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary - return AXIsProcessTrustedWithOptions(options) - } } diff --git a/Core/Sources/Service/KeyboardLockerSettingsStore.swift b/Core/Sources/Service/KeyboardLockerSettingsStore.swift index e43996e..e74cfca 100644 --- a/Core/Sources/Service/KeyboardLockerSettingsStore.swift +++ b/Core/Sources/Service/KeyboardLockerSettingsStore.swift @@ -26,12 +26,6 @@ public final class KeyboardLockerSettingsStore { return settings } - /// Saves settings to storage, throws error if encoding fails - public func save(_ settings: KeyboardLockerSettings) throws { - let data = try encoder.encode(settings) - userDefaults.set(data, forKey: storageKey) - } - private func registerDefaultsIfNeeded() { guard userDefaults.object(forKey: storageKey) == nil else { return diff --git a/KeyboardLocker/SystemSettings.swift b/KeyboardLocker/SystemSettings.swift deleted file mode 100644 index 7be6c6f..0000000 --- a/KeyboardLocker/SystemSettings.swift +++ /dev/null @@ -1,13 +0,0 @@ -import AppKit - -/// Helper for opening System Settings to specific panes. -enum SystemSettings { - /// Opens System Settings to Accessibility privacy pane. - /// Used when user needs manual guidance to grant permission. - static func openAccessibilitySettings() { - let urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - if let url = URL(string: urlString) { - NSWorkspace.shared.open(url) - } - } -} diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index 7468ee9..68f4758 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -5,24 +5,20 @@ import Service /// XPC service implementation. Owns the settings source of truth and drives the single /// global `LockEngine`. All wrappers reach the lock exclusively through this object. final class AgentService: NSObject, KeyboardLockerServiceProtocol { - private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "AgentService") - - private let store = KeyboardLockerSettingsStore() - private let settingsLock = OSAllocatedUnfairLock() - private var settings: KeyboardLockerSettings + private let settings: KeyboardLockerSettings override init() { - let loaded = store.load() + let loaded = KeyboardLockerSettingsStore().load() settings = loaded super.init() - // Seed the engine so a lock triggered before any settings write uses persisted values. + // Seed the engine so a lock uses persisted values. LockEngine.shared.updateSettings(loaded) } // MARK: - Locking func lockKeyboard(reply: @escaping (Error?) -> Void) { - let current = currentSettingsValue() + let current = settings executeOnMainThread { do { try LockEngine.shared.lock(settings: current) @@ -48,52 +44,12 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Settings - func applySettings(_ data: Data, reply: @escaping (Error?) -> Void) { - let newSettings = KeyboardLockerSettings.decodedFromXPC(data) - - settingsLock.lock() - settings = newSettings - settingsLock.unlock() - - do { - try store.save(newSettings) - } catch { - // Persistence failure is non-fatal for the running lock; surface it but still apply. - Self.logger.error("Failed to persist settings: \(error.localizedDescription, privacy: .public)") - } - - executeOnMainThread { - LockEngine.shared.updateSettings(newSettings) - reply(nil) - } - } - func currentSettings(reply: @escaping (Data?) -> Void) { - reply(try? currentSettingsValue().encodedForXPC()) - } - - // MARK: - Accessibility - - func requestAccessibilityPermission(showPrompt: Bool, reply: @escaping (Bool) -> Void) { - executeOnMainThread { - reply(AccessibilityManager.requestPermission(showPrompt: showPrompt)) - } - } - - func accessibilityStatus(reply: @escaping (Bool) -> Void) { - executeOnMainThread { - reply(AccessibilityManager.hasPermission()) - } + reply(try? settings.encodedForXPC()) } // MARK: - Helpers - private func currentSettingsValue() -> KeyboardLockerSettings { - settingsLock.lock() - defer { settingsLock.unlock() } - return settings - } - private func executeOnMainThread(_ operation: @escaping () -> Void) { if Thread.isMainThread { operation() diff --git a/docs/architecture.md b/docs/architecture.md index 76ad814..9442966 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,8 +40,8 @@ Widget ──┘ ├─ Settings ownership (source of truth) | 关注点 | 归属 | 规则 | |---------|------|------| | 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | -| 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 只通过 XPC 读写设置,绝不自己持有 `UserDefaults`。 | -| Accessibility 权限 | **Agent** | Agent 持有权限,并通过 XPC 回答状态查询 / 权限请求。 | +| 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 绝不自己持有 `UserDefaults`,只经 XPC 访问。当前仅暴露读取(`currentSettings`);写入(`applySettings`)尚未接线,加入时同样必须经 Agent,绝不在 wrapper 侧落地。 | +| Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。向 wrapper 暴露状态查询 / 权限请求的 XPC 接口尚未接线;加入时只能由 Agent 经 XPC 提供,wrapper 不得自行请求权限。 | | 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | | UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,但不含任何领域逻辑。 | diff --git a/docs/development.md b/docs/development.md index 2e8b26e..8472a75 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,7 +6,7 @@ 所有面都通过 `KeyboardLockerServiceProtocol` 与 Agent 通信(Mach 服务 `io.lzhlovesjyq.keyboardlocker.agent`)。 -1. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `applySettings` / `currentSettings` / `accessibilityStatus` / `requestAccessibilityPermission`。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 +1. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `currentSettings`。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 2. Agent 的 `ServiceDelegate` 接受连接(在 `XPCAccessControl` 校验代码签名 + bundle ID 之后),并路由到 `AgentService`。 3. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:)` / `unlock()`。 4. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 @@ -19,12 +19,12 @@ 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(锁操作 + `applySettings(Data)`/`currentSettings` + accessibility)、`SharedConstants`(Mach 名、bundle-ID 白名单、CLI 常量)、`NotificationNames.stateChanged`。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(锁操作 + `currentSettings`)、`SharedConstants`(Mach 名、bundle-ID 白名单、CLI 常量)、`NotificationNames.stateChanged`。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`、`showsUnlockNotification`)+ `.default` + `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界的 JSON 传输)。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` -- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条自动重连的连接;`lock`/`unlock`/`status`/`applySettings`/`currentSettings`/`accessibilityStatus`/`requestAccessibilityPermission`。没有 "session" 类型。 +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条自动重连的连接;`lock`/`unlock`/`status`/`currentSettings`。没有 "session" 类型。 - `LockStateSubscriber.swift`:订阅 Darwin + Distributed 广播,把每个信号当作提示,并通过 `XPCClient.status()` 拉取权威状态(带重试、去重)→ `ObserverToken`,另有 `stateChanges`(`AsyncStream`)。只有长命 UI 需要它;一次性面直接读 `status()`(见 architecture 的"状态同步")。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 @@ -36,14 +36,13 @@ **App**(`KeyboardLocker/`)—— 薄 SwiftUI wrapper - `AgentRegistrar.swift`:启动时做 `SMAppService.agent(plistName:)` 注册(幂等)。 - `LockController.swift`:`@MainActor ObservableObject` 视图状态 —— 发出异步 `XPCClient` 调用、反映广播状态,不含任何锁/设置逻辑。 -- `SystemSettings.swift`:打开 Accessibility 面板(UI 关注点,App 本地持有,不放进 `Common`)。 ## 常见任务 ### 新增一个设置项 1. 给 `KeyboardLockerSettings` 加一个 `Codable`/`Sendable` 属性,并更新 `.default`。 2. 如果引擎会消费它,在 `LockEngine.lock(settings:)` / `updateSettings(_:)` 中读取。 -3. wrapper 只通过 `XPCClient.currentSettings()` / `applySettings(_:)` 读写设置;由 Agent 经其 `KeyboardLockerSettingsStore` 持久化。wrapper **不得**拥有 store(见架构契约)。 +3. 目前 wrapper 只能通过 `XPCClient.currentSettings()` **读取**设置(由 Agent 经 `KeyboardLockerSettingsStore` 加载)。写入路径(`applySettings` + UI)尚未接线;要让用户改设置,先在 Agent + `KeyboardLockerServiceProtocol` 上补写入方法(见"新增一个 XPC 方法"),再由 Agent 持久化 —— wrapper **不得**拥有 store(见架构契约)。 ### 新增一个 XPC 方法 1. 在 `Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 里加签名。无法用 `@objc` 表达的值(如 `KeyboardLockerSettings`)以 JSON `Data` 跨界 —— 复用 `encodedForXPC()`/`decodedFromXPC(_:)`。 From 58bb09cde31e0d5396f5a9692cee8badabe12a46 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 21:41:39 +0800 Subject: [PATCH 017/106] =?UTF-8?q?=F0=9F=94=A5=20refactor(settings):=20dr?= =?UTF-8?q?op=20model=20fields=20with=20no=20behavior=20behind=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - showsUnlockNotification: no notification code exists anywhere, so the flag toggled nothing — removed until a notification feature lands. - AutoUnlockPolicy.presets / PresetTimeouts: UI-binding helpers with no UI consuming them. - Hotkey.isValid: validation helper with no caller. Shrinks the model to what the engine actually reads (autoUnlockPolicy, unlockHotkey). --- .../Common/KeyboardLockerSettings.swift | 23 ------------------- docs/development.md | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/Core/Sources/Common/KeyboardLockerSettings.swift b/Core/Sources/Common/KeyboardLockerSettings.swift index 9f013dc..e41f43d 100644 --- a/Core/Sources/Common/KeyboardLockerSettings.swift +++ b/Core/Sources/Common/KeyboardLockerSettings.swift @@ -26,20 +26,6 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { seconds } } - - private enum PresetTimeouts { - static let short: TimeInterval = 30 - static let medium: TimeInterval = 60 - static let long: TimeInterval = 120 - } - - /// Common presets for UI binding - public static let presets: [AutoUnlockPolicy] = [ - .disabled, - .timed(seconds: PresetTimeouts.short), - .timed(seconds: PresetTimeouts.medium), - .timed(seconds: PresetTimeouts.long), - ] } /// Represents unlock hotkey combination @@ -60,11 +46,6 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { .maskShift, ] - /// Validates hotkey has at least one modifier key - public var isValid: Bool { - modifierFlags.intersection(Self.relevantModifierMask).isEmpty == false - } - /// Checks if event's keyCode and modifiers match this hotkey public func matches(keyCode: CGKeyCode, flags: CGEventFlags) -> Bool { guard keyCode == self.keyCode else { @@ -82,22 +63,18 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { public var autoUnlockPolicy: AutoUnlockPolicy public var unlockHotkey: Hotkey - public var showsUnlockNotification: Bool public init( autoUnlockPolicy: AutoUnlockPolicy, - showsUnlockNotification: Bool, unlockHotkey: Hotkey ) { self.autoUnlockPolicy = autoUnlockPolicy - self.showsUnlockNotification = showsUnlockNotification self.unlockHotkey = unlockHotkey } /// Default settings for initial launch or reset public static let `default` = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), - showsUnlockNotification: true, unlockHotkey: Hotkey( keyCode: SharedConstants.defaultUnlockKeyCode, modifierFlags: [.maskControl, .maskCommand] diff --git a/docs/development.md b/docs/development.md index 8472a75..3e26cb0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -20,7 +20,7 @@ **Common**(`Core/Sources/Common/`)—— 所有 target 共享 - `Shared.swift`:`KeyboardLockerServiceProtocol`(锁操作 + `currentSettings`)、`SharedConstants`(Mach 名、bundle-ID 白名单、CLI 常量)、`NotificationNames.stateChanged`。 -- `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`、`showsUnlockNotification`)+ `.default` + `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界的 JSON 传输)。 +- `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界的 JSON 传输)。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` From 90fedf2d2d654f797a487523c6745d30080f2f96 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 15 Jul 2026 21:42:46 +0800 Subject: [PATCH 018/106] =?UTF-8?q?=F0=9F=94=A5=20refactor:=20remove=20unw?= =?UTF-8?q?ired=20CLI-install=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLIInstaller (symlink klock into /usr/local/bin via AppleScript admin prompt) had no call site — the App exposed no install entry point — and its supporting SharedConstants.cliName / cliInstallPath were consumed by nothing else. Delete the file, the constants, and the doc section. The klock binary still builds with the bundle; add an install path back with a real UI when the feature is actually wanted. --- Core/Sources/Common/Shared.swift | 8 -- KeyboardLocker/CLIInstaller.swift | 139 ------------------------------ docs/development.md | 5 +- 3 files changed, 1 insertion(+), 151 deletions(-) delete mode 100644 KeyboardLocker/CLIInstaller.swift diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index f0045ae..363df30 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -15,14 +15,6 @@ public enum SharedConstants { "io.lzhlovesjyq.keyboardlocker.klock", "klock", // CLI tool codesign identifier (no Info.plist = short name only) ] - - // MARK: - CLI Constants - - /// CLI executable name - public static let cliName = "klock" - - /// CLI installation path - public static let cliInstallPath = "/usr/local/bin/klock" } // MARK: - Notification Names diff --git a/KeyboardLocker/CLIInstaller.swift b/KeyboardLocker/CLIInstaller.swift deleted file mode 100644 index c4ef82c..0000000 --- a/KeyboardLocker/CLIInstaller.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// CLIInstaller.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import AppKit -import Common -import Foundation - -enum CLIInstaller { - static var cliName: String { - SharedConstants.cliName - } - - static var installPath: String { - SharedConstants.cliInstallPath - } - - enum InstallResult { - case success - case alreadyInstalled - case cancelled - case failed(Error) - } - - enum InstallError: LocalizedError { - case cliNotFoundInBundle - case scriptExecutionFailed(String) - - var errorDescription: String? { - switch self { - case .cliNotFoundInBundle: - "CLI tool not found in application bundle." - case let .scriptExecutionFailed(message): - "Installation failed: \(message)" - } - } - } - - /// Path to the CLI binary inside the app bundle - static var cliBundlePath: String? { - Bundle.main.path(forAuxiliaryExecutable: cliName) - } - - /// Check if CLI is already installed at the target path - static var isInstalled: Bool { - FileManager.default.fileExists(atPath: installPath) - } - - /// Check if the installed CLI points to the current app bundle - static var isCurrentVersionInstalled: Bool { - guard isInstalled, - let bundlePath = cliBundlePath, - let linkDest = try? FileManager.default.destinationOfSymbolicLink(atPath: installPath) - else { - return false - } - return linkDest == bundlePath - } - - /// Install the CLI tool to /usr/local/bin with admin privileges - static func install() -> InstallResult { - guard let bundlePath = cliBundlePath else { - return .failed(InstallError.cliNotFoundInBundle) - } - - // If already installed and pointing to current bundle, skip - if isCurrentVersionInstalled { - return .alreadyInstalled - } - - // Build the shell command - // Remove existing symlink if present, then create new one - let commands = [ - "mkdir -p /usr/local/bin", - "rm -f '\(installPath)'", - "ln -s '\(bundlePath)' '\(installPath)'", - ] - let script = commands.joined(separator: " && ") - - // Execute with admin privileges using AppleScript - let appleScript = """ - do shell script "\(script)" with administrator privileges - """ - - var error: NSDictionary? - if let scriptObject = NSAppleScript(source: appleScript) { - scriptObject.executeAndReturnError(&error) - - if let error { - let message = error[NSAppleScript.errorMessage] as? String ?? "Unknown error" - // Check if user cancelled - if let errorNumber = error[NSAppleScript.errorNumber] as? Int, - errorNumber == -128 - { - return .cancelled - } - return .failed(InstallError.scriptExecutionFailed(message)) - } - - return .success - } - - return .failed(InstallError.scriptExecutionFailed("Failed to create AppleScript")) - } - - /// Uninstall the CLI tool - static func uninstall() -> InstallResult { - guard isInstalled else { - return .success - } - - let script = "rm -f '\(installPath)'" - let appleScript = """ - do shell script "\(script)" with administrator privileges - """ - - var error: NSDictionary? - if let scriptObject = NSAppleScript(source: appleScript) { - scriptObject.executeAndReturnError(&error) - - if let error { - let message = error[NSAppleScript.errorMessage] as? String ?? "Unknown error" - if let errorNumber = error[NSAppleScript.errorNumber] as? Int, - errorNumber == -128 - { - return .cancelled - } - return .failed(InstallError.scriptExecutionFailed(message)) - } - - return .success - } - - return .failed(InstallError.scriptExecutionFailed("Failed to create AppleScript")) - } -} diff --git a/docs/development.md b/docs/development.md index 3e26cb0..5cc8249 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(锁操作 + `currentSettings`)、`SharedConstants`(Mach 名、bundle-ID 白名单、CLI 常量)、`NotificationNames.stateChanged`。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(锁操作 + `currentSettings`)、`SharedConstants`(Mach 名、bundle-ID 白名单)、`NotificationNames.stateChanged`。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界的 JSON 传输)。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 @@ -52,9 +52,6 @@ ### 修改事件过滤 在 `LockEngine.handleEvent(proxy:type:event:)` 中:返回 `nil` 拦截、返回 `Unmanaged.passUnretained(event)` 放行,并在满足解锁条件(热键或超时)时调用 `unlock()`。`Hotkey.matches(keyCode:flags:)` 会通过 `relevantModifierMask` 过滤掉 CapsLock/NumLock。 -### CLI 安装 -`CLIInstaller` 把 `/usr/local/bin/klock` 软链到 app bundle 内的 CLI 二进制(保证版本一致),并通过 AppleScript 请求管理员权限。`install()`/`uninstall()` 返回 `InstallResult`(`.success` / `.alreadyInstalled` / `.cancelled` / `.failed(Error)`);用 `isInstalled` / `isCurrentVersionInstalled` 判断状态。 - ## 测试须知 - 缺少 Accessibility 权限时,`LockEngine.lock` 抛出 `.accessibilityPermissionDenied`(在创建 event tap 之前检查)。 From 0261ba81b34440de579f00d2318a87ffebae0402 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 11:01:38 +0800 Subject: [PATCH 019/106] fix(xpc): derive trusted team from agent Use the running agent signing identity as the Release authorization source of truth. Remove the stale hard-coded Team ID and certificate common-name fallback that rejected valid signed clients. --- Core/Sources/Service/XPCAccessControl.swift | 63 +++++++++++---------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/Core/Sources/Service/XPCAccessControl.swift b/Core/Sources/Service/XPCAccessControl.swift index 7fd4717..15e24af 100644 --- a/Core/Sources/Service/XPCAccessControl.swift +++ b/Core/Sources/Service/XPCAccessControl.swift @@ -4,11 +4,22 @@ import Security /// Validates XPC connections by verifying code signature and bundle identifier. /// -/// - **Release**: Full verification (signature + Team ID + bundle ID allowlist) +/// - **Release**: Full verification (signature + Agent Team ID + bundle ID allowlist) /// - **Debug**: Relaxed verification (bundle ID allowlist only) public enum XPCAccessControl { - /// Apple Developer Team ID for certificate verification (Release only) - private static let expectedTeamID = "SBLX9H66X2" + #if !DEBUG + /// Reads the Agent's signing Team ID from the running process so code-signing configuration + /// remains the single source of truth. + private static let agentTeamID: String? = { + guard let staticCode = staticCodeForCurrentProcess(), + SecStaticCodeCheckValidity(staticCode, SecCSFlags(), nil) == errSecSuccess, + let info = signingInfo(for: staticCode) + else { + return nil + } + return teamIdentifier(from: info) + }() + #endif /// Returns true if the connection passes security checks. public static func isConnectionAuthorized(_ connection: NSXPCConnection) -> Bool { @@ -31,8 +42,10 @@ public enum XPCAccessControl { } #if !DEBUG - // Release: Verify Team ID - guard extractTeamID(from: info) == expectedTeamID else { + // Release: Require the client to be signed by the same team as the Agent. + guard let agentTeamID = Self.agentTeamID, + teamIdentifier(from: info) == agentTeamID + else { return false } #endif @@ -52,6 +65,19 @@ public enum XPCAccessControl { return nil } + return staticCode(for: code) + } + + private static func staticCodeForCurrentProcess() -> SecStaticCode? { + var code: SecCode? + guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, let code else { + return nil + } + + return staticCode(for: code) + } + + private static func staticCode(for code: SecCode) -> SecStaticCode? { var staticCode: SecStaticCode? guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess else { return nil @@ -68,30 +94,7 @@ public enum XPCAccessControl { return info as? [CFString: Any] } - private static func extractTeamID(from info: [CFString: Any]) -> String? { - if let teamID = info[kSecCodeInfoTeamIdentifier] as? String { - return teamID - } - - // Fallback: Parse from certificate Common Name - guard let certs = info[kSecCodeInfoCertificates] as? [SecCertificate], - let leafCert = certs.first - else { - return nil - } - - var commonName: CFString? - SecCertificateCopyCommonName(leafCert, &commonName) - - // Extract Team ID from "Developer Name (TEAMID)" format - if let name = commonName as String?, - let match = name.range(of: #"\(([A-Z0-9]{10})\)$"#, options: .regularExpression) - { - let start = name.index(match.lowerBound, offsetBy: 1) - let end = name.index(match.upperBound, offsetBy: -1) - return String(name[start ..< end]) - } - - return nil + private static func teamIdentifier(from info: [CFString: Any]) -> String? { + info[kSecCodeInfoTeamIdentifier] as? String } } From ec0d88d9e7b3f3f1ef82691e21fb95261b6705a2 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 11:11:51 +0800 Subject: [PATCH 020/106] fix(build): produce distributable app archive Mark the embedded Agent and CLI targets as skip-install dependencies so the archive contains only the main app while preserving both embedded products. --- KeyboardLocker.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index adbc606..573a32b 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -570,6 +570,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.agent; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -607,6 +608,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.agent; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -626,6 +628,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -642,6 +645,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; From 929ebc7b60fffa31d6784764ee2b0073941b3ace Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 11:20:26 +0800 Subject: [PATCH 021/106] fix(input): restrict lock to keyboard events Keep all mouse input and system-defined media keys available while locked. Align permission messaging and project guidance with the keyboard-only product contract. --- CLAUDE.md | 2 +- Core/Sources/Service/AccessibilityManager.swift | 2 +- Core/Sources/Service/LockEngine.swift | 7 +++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3720f15..ff762e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for Claude Code when working in this repository. ## Project Overview -KeyboardLocker locks keyboard/mouse input via CGEventTap on macOS. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): +KeyboardLocker suppresses standard keyboard events via CGEventTap on macOS. Mouse input and system-defined media keys remain available. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): - **KeyboardLocker** (App, SwiftUI) — imports `Client` - **KeyboardLockerAgent** (XPC service, runs the lock engine, holds Accessibility permission) — imports `Service` diff --git a/Core/Sources/Service/AccessibilityManager.swift b/Core/Sources/Service/AccessibilityManager.swift index a88a57c..9f67346 100644 --- a/Core/Sources/Service/AccessibilityManager.swift +++ b/Core/Sources/Service/AccessibilityManager.swift @@ -1,7 +1,7 @@ import AppKit import ApplicationServices -/// Manages Accessibility permission for CGEventTap-based keyboard/mouse monitoring +/// Manages Accessibility permission for CGEventTap-based keyboard filtering public final class AccessibilityManager { private init() {} diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 73abde6..5b13069 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -43,7 +43,7 @@ public class LockEngine { public var errorDescription: String? { switch self { case .accessibilityPermissionDenied: - "Accessibility permission is required to lock keyboard and mouse input." + "Accessibility permission is required to lock keyboard input." case .eventTapCreationFailed: "Failed to create event tap. This may indicate a permissions issue or system restriction." case .runLoopSourceCreationFailed: @@ -63,12 +63,11 @@ public class LockEngine { } } + /// Mouse and system-defined events remain available under the keyboard-only product contract. static let eventMasks: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | - (1 << CGEventType.flagsChanged.rawValue) | - (1 << CGEventType.otherMouseDown.rawValue) | - (1 << CGEventType.otherMouseUp.rawValue) + (1 << CGEventType.flagsChanged.rawValue) private static let runLoopSourceOrder: CFIndex = 0 private static let autoRepeatFlagValue: Int64 = 1 From f33d481ae0760c8497815ede1b6dd4c4bf674a78 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 17:07:10 +0800 Subject: [PATCH 022/106] =?UTF-8?q?=F0=9F=93=9D=20docs(xpc):=20add=20XPC?= =?UTF-8?q?=20implementation=20guide=20and=20link=20it=20from=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- docs/xpc.md | 574 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 docs/xpc.md diff --git a/CLAUDE.md b/CLAUDE.md index ff762e7..f0fd672 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `… **Before adding or changing any feature (App, CLI, Shortcuts, AppleScript, Widgets, …), read [docs/architecture.md](docs/architecture.md).** It defines the non-negotiable contract: the Agent is the single executor, all other surfaces are thin wrappers, the lock is one global state. When a change conflicts with it, the contract wins. -For task workflows and component details, see [docs/development.md](docs/development.md). +For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). ## Project-Specific Rules diff --git a/docs/xpc.md b/docs/xpc.md new file mode 100644 index 0000000..e73532d --- /dev/null +++ b/docs/xpc.md @@ -0,0 +1,574 @@ +# XPC 实现与使用指南 + +本文解释 KeyboardLocker 当前的 XPC 实现:`Core` 为什么同时出现在 wrapper 和 Agent 的依赖图里、一次调用如何跨进程执行、锁状态如何同步,以及连接或 Agent 异常时应该如何理解系统状态。 + +三份文档各自只负责一个层次,避免形成重复的事实源: + +- [architecture.md](architecture.md):解释为什么必须是单 Agent、哪些架构边界不可违反。 +- 本文:解释 XPC 在当前实现里如何工作。 +- [development.md](development.md):提供组件索引和常见修改步骤。 + +## 先记住四个结论 + +1. **`Core` 不是进程,也不是后台服务。** 它只是一个本地 Swift Package,构建时向不同 executable 提供代码模块。 +2. **两侧共享的是协议和数据定义,不是内存或单例。** `Common` 会编译进 wrapper 和 Agent,但唯一的锁状态只存在于 Agent 进程的 `LockEngine.shared` 中。 +3. **wrapper 与 Agent 没有互相直接调用 Swift 对象。** wrapper 取得的是 `KeyboardLockerServiceProtocol` 的远程 proxy;macOS 把方法参数和 reply 序列化后跨进程传输。 +4. **XPC connection 不拥有锁。** connection、App 或 CLI 退出后,锁仍由 Agent 持有;只有显式/自动 unlock、解锁热键或 Agent 退出才会释放 event tap。 + +源码中的几个名字处在不同抽象层,阅读时可以这样理解: + +| 名字 | 实际含义 | +|---|---| +| `Core` | 容纳三种 library product 的 Swift Package;不是“正在运行的核心” | +| `Common` | Client/Agent 两侧共同理解的 wire contract 与 value definitions | +| `Client` | wrapper 侧的 outgoing XPC adapter | +| `Service` | Agent 侧的 domain/runtime library;它本身不是进程 | +| `AgentService` | 把 wire method 转成 domain call 的 exported object | +| `KeyboardLockerAgent` | 真正运行 listener、持有 event tap 和全局状态的 executable/process | + +## 构建依赖和运行进程是两张不同的图 + +### 构建时:`Core` 提供三个 library product + +`Core/Package.swift` 定义三个独立 product: + +```text +Core +├── Common +├── Client -> Common +└── Service -> Common + +KeyboardLocker -> Client -> Common +klock -> Client -> Common +KeyboardLockerAgent -> Service -> Common +``` + +Xcode 会按 package 名把这些 dependency 都显示在 `Core` 下面,因此看起来像 App 和 Agent “都引用了 Core”。但 target 实际链接的是不同 product:主 App 和 `klock` 只链接 `Client`,Agent 只链接 `Service`;没有 target 把整个 package 的全部代码一起引入。 + +它们的职责是: + +| Product | 被谁链接 | 内容 | 不包含什么 | +|---|---|---|---| +| `Common` | `Client`、`Service` | XPC 协议、Mach service 名、通知名、跨进程值类型 | connection、listener、锁状态、event tap | +| `Client` | App、CLI | `NSXPCConnection`、async 封装、状态订阅 | `LockEngine`、设置存储、Accessibility 执行 | +| `Service` | Agent | listener connection 配置、访问控制、锁引擎、设置真相源、状态广播 | App UI、CLI 命令、客户端 connection | + +`Client/Exports.swift` 和 `Service/Exports.swift` 使用 `@_exported import Common`,所以 App 只写 `import Client`、Agent 只写 `import Service`,也能看到 `Common` 中的类型。这只是 import 便利,不会让 App 获得 `Service`,也不会让两边共享内存。 + +### 运行时:真正存在的是三个独立进程 + +```mermaid +flowchart LR + subgraph AppProcess["KeyboardLocker process"] + AppUI["SwiftUI + LockController"] --> AppClient["XPCClient"] + end + + subgraph CLIProcess["klock process"] + CLI["Command handler"] --> CLIClient["XPCClient"] + end + + AppClient -->|"NSXPCConnection"| Mach["Mach service"] + CLIClient -->|"NSXPCConnection"| Mach + subgraph AgentProcess["KeyboardLockerAgent process"] + Listener["NSXPCListener"] --> AgentService["AgentService"] + AgentService --> Engine["LockEngine.shared"] + AgentService --> Settings["Settings store"] + AgentService --> Accessibility["Accessibility APIs"] + end + + Launchd["launchd"] -->|"starts on demand"| Listener + Mach --> Listener +``` + +`Core` 不会作为第四个进程出现。它的代码分别被链接进上图中的 executable: + +- App 和 CLI 各自包含一份 `Client + Common` 代码。 +- Agent 包含一份 `Service + Common` 代码。 +- 只有 Agent 的 executable 包含 `LockEngine`,因此 App/CLI 不可能绕过 XPC 直接操作 event tap。 + +## `Common` 为什么必须同时存在于两侧 + +XPC 两侧必须对“线上消息长什么样”达成一致。当前契约定义在 `Common/Shared.swift`: + +```swift +@objc(KeyboardLockerServiceProtocol) +public protocol KeyboardLockerServiceProtocol { + func lockKeyboard(reply: @escaping (Error?) -> Void) + func unlockKeyboard(reply: @escaping (Error?) -> Void) + func status(reply: @escaping (Bool, Error?) -> Void) + func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) + func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) + func currentSettings(reply: @escaping (Data?) -> Void) +} +``` + +同一个 protocol 在两侧承担不同角色: + +- Client 侧把它交给 `NSXPCInterface`,说明远端 proxy 允许调用哪些 selector。 +- Agent 侧由 `AgentService` conform,提供 selector 的真实实现。 + +这和 HTTP 的 client/server 共同依赖同一份 OpenAPI schema 类似:共享的是消息契约,不是业务实例。 + +协议使用 `@objc`,因为 `NSXPCInterface` 基于 Objective-C runtime 描述方法。跨边界参数需要是 XPC 可序列化类型;当前设置模型是 Swift `Codable` struct,所以先编码成 JSON `Data`,到另一侧再解码,而不是把 Swift struct 直接放进 protocol。 + +## Agent 如何被系统找到 + +Release App 的关键 bundle layout 是: + +```text +KeyboardLocker.app +└── Contents + ├── Library + │ ├── LaunchAgents + │ │ └── io.lzhlovesjyq.keyboardlocker.agent.plist + │ └── LoginItems + │ └── KeyboardLockerAgent.app + └── MacOS + ├── KeyboardLocker + └── klock +``` + +启动链路如下: + +1. App 的 `AgentRegistrar` 使用 `SMAppService.agent(plistName:)` 注册 bundled Agent。 +2. plist 的 `MachServices` 声明 `io.lzhlovesjyq.keyboardlocker.agent`。 +3. 注册成功后,`launchd` 管理 Agent 生命周期。 +4. App 或 CLI 向这个 Mach service 发送第一条 XPC message 时,如果 Agent 尚未运行,`launchd` 可以按需启动它。仅创建或 activate connection 不等于 Agent 已经启动。 +5. Agent 在 `main.swift` 中创建同名 `NSXPCListener` 并开始接收连接。 + +这里有两个不同的 readiness 条件: + +- `SMAppService.Status.enabled`:系统允许这个 Agent 运行。 +- XPC 调用成功:当前 Agent executable 确实启动、接受连接并理解当前 protocol。 + +前者不是后者的充分条件。例如 App 更新后,系统中可能仍运行旧 Agent;它虽然是 enabled,却不认识新加入的 selector。App 因此会把“Agent enabled 但 XPC 不可达”显示为独立错误,并只在用户明确操作时重启 Agent。 + +`klock` 自己不注册 Agent。它依赖完整 App 已经完成注册;一旦注册,App 是否仍在运行不影响 CLI 连接 Agent。 + +## Listener 和 Connection 到底是什么关系 + +“Agent 是 Listener、wrapper 是 Connection”作为第一层理解是对的,但完整模型是: + +- 每个 wrapper 进程主动创建一条 **client-side `NSXPCConnection`**。 +- Agent 创建一个 **`NSXPCListener`**,只负责等待、审查并接受新连接。 +- Listener 每接受一个 wrapper,Foundation 都会在 Agent 侧创建一条对应的 **server-side `NSXPCConnection`**。 +- 业务 method 不经过 Listener delegate;它们在这对 client/server connection 之间传输。 + +```mermaid +flowchart LR + AppConnection["App-side NSXPCConnection"] <-->|"peer channel A"| AgentConnectionA["Agent-side NSXPCConnection A"] + CLIConnection["CLI-side NSXPCConnection"] <-->|"peer channel B"| AgentConnectionB["Agent-side NSXPCConnection B"] + + Listener["One NSXPCListener"] -->|"accepts"| AgentConnectionA + Listener -->|"accepts"| AgentConnectionB + + AgentConnectionA --> SharedService["Shared AgentService"] + AgentConnectionB --> SharedService + SharedService --> Engine["LockEngine.shared"] +``` + +| 对象 | 所在进程 | 当前数量 | 作用 | +|---|---|---|---| +| wrapper-side `NSXPCConnection` | App 或 CLI | `XPCClient.shared` 每个进程通常缓存一条 | 主动找到 Mach service、创建 remote proxy、发送调用、接收 reply/error | +| `NSXPCListener` | Agent | 一个 Mach service 对应一个 | 接收新 connection request,并把每个新 peer 交给 delegate 审查 | +| Agent-side `NSXPCConnection` | Agent | 每个已接受 wrapper 一条 | 暴露 `AgentService`、接收该 wrapper 的 method call、把 reply 发回去 | +| `AgentService` | Agent | 当前整个进程共享一个 | 所有 connection 共用的 wire adapter,保证最终指向同一个全局引擎 | + +在当前代码中: + +1. `KeyboardLockerAgent/main.swift` 创建一个 `NSXPCListener(machServiceName:)`。 +2. 新客户端到来时,Listener 调用 `listener(_:shouldAcceptNewConnection:)`。 +3. delegate 用 `XPCAccessControl` 审查这条新 connection 的对端身份。 +4. 接受后,`XPCServerConnection.configure` 为 **这条 Agent-side connection** 设置 `exportedInterface` 和 `exportedObject`,然后 activate/resume。 +5. 下一位客户端会得到另一条 Agent-side connection,但仍指向同一个 `sharedService`。 + +因此 Listener 更像 socket server 的 acceptor;接受之后的 peer connection 才像一条已经建立的 socket。不过 XPC 是消息/RPC 系统,不应把它理解成可直接读取任意 bytes 的 TCP stream。 + +### Client connection 和 Agent connection 不是同一个对象 + +两侧的 `NSXPCConnection` 是两个不同的 Foundation object: + +```text +Wrapper process Agent process + +clientConnection <--- XPC/Mach ---> serverConnection +NSXPCConnection NSXPCConnection +different object different object +different address space different address space +``` + +Client 侧的对象由 wrapper 主动创建: + +```swift +let clientConnection = NSXPCConnection( + machServiceName: SharedConstants.machServiceName +) +``` + +Agent 侧的对象由 Listener 创建,并作为参数交给 delegate: + +```swift +func listener( + _: NSXPCListener, + shouldAcceptNewConnection serverConnection: NSXPCConnection +) -> Bool +``` + +它们不能共享 object identity,也不能直接访问彼此的内存。准确说法是: + +- `clientConnection` 和 `serverConnection` 是两个不同的 object。 +- 它们分别包装当前进程这一侧的 endpoint 与 peer 状态。 +- XPC runtime 把两个 endpoint 关联成同一条双向逻辑 channel。 +- 任意一侧退出或 invalidate 会影响另一侧观察到的 connection lifecycle,但这不表示它们是同一个对象。 + +业务调用的对象映射是: + +```text +clientConnection.remoteObjectProxy + │ + │ encoded XPC message + ▼ +serverConnection.exportedObject + │ + └─ shared AgentService + │ + └─ LockEngine.shared +``` + +Client 的 `remoteObjectProxy` 也不是 Agent 的 `AgentService`。前者是 wrapper 进程中的本地动态代理;对它调用 method 时,Foundation 才把 invocation 编码并传到 Agent,由后者真正执行。 + +如果 App 和 CLI 同时连接,会形成两对不同的 connection object: + +```text +App clientConnection <--> Agent serverConnection A ─┐ + ├─> shared AgentService +CLI clientConnection <--> Agent serverConnection B ─┘ +``` + +所以文档中出现“同一条 connection”时,指的是同一条**逻辑 peer channel**,不是两个进程共享同一个 `NSXPCConnection` 实例。 + +### `remote` 和 `exported` 是相对于当前进程命名的 + +`NSXPCConnection` 是双向 channel,两侧拥有同一组配置概念: + +| 属性/对象 | 含义 | +|---|---| +| `remoteObjectInterface` | 当前进程准备调用的“对端对象”允许有哪些 method | +| `remoteObjectProxy` | 当前进程中的本地替身;对它调用 method 会产生跨进程 message | +| `exportedInterface` | 当前进程允许对端调用自己的哪些 method | +| `exportedObject` | 当前进程实际接收并执行这些 method 的本地对象 | + +当前项目只使用单向业务接口: + +```text +Wrapper side + remoteObjectInterface = KeyboardLockerServiceProtocol + remoteObjectProxy = proxy for AgentService + exportedInterface = nil + exportedObject = nil + +Agent side + remoteObjectInterface = nil + remoteObjectProxy = unused + exportedInterface = KeyboardLockerServiceProtocol + exportedObject = shared AgentService +``` + +所以 wrapper 侧这句: + +```swift +connection.remoteObjectInterface = NSXPCInterface( + with: KeyboardLockerServiceProtocol.self +) +``` + +和 Agent 侧这两句: + +```swift +connection.exportedInterface = NSXPCInterface( + with: KeyboardLockerServiceProtocol.self +) +connection.exportedObject = exportedService +``` + +是在描述同一条 channel 的两端。两端不是共享一个 Swift object;只是对“远端能调用什么”和“本地由谁执行”达成一致。 + +XPC 本身允许 Agent 反过来调用 wrapper:wrapper 可以设置自己的 `exportedInterface/object`,Agent 再取得 remote proxy。但 KeyboardLocker 没有这样做。全局状态变化使用 payload-free notification 作为刷新信号,再由 wrapper 调 `status()`,避免把状态绑定到某条 client connection。 + +## 从 Foundation RPC 到 Mach IPC 的分层 + +当前项目使用的是 Foundation 的 `NSXPCConnection` API。它建立在较低层的 libxpc、Mach IPC 与 `launchd` 服务发现之上: + +```text +Project API + XPCClient.lock() + ↓ +Foundation RPC layer + NSXPCConnection + NSXPCInterface + remote proxy + NSXPCCoder + ↓ +libxpc layer + peer connection + opaque XPC message + reply correlation + ↓ +Mach IPC layer + Mach ports and Mach messages + ↓ +launchd / bootstrap namespace + service lookup, on-demand process launch, endpoint handoff + ↓ +Agent peer connection + decode invocation -> AgentService -> LockEngine +``` + +其中可以依赖的公开语义是: + +- XPC connection 是双向 peer channel。 +- `NSXPCInterface` 定义允许的 selector、参数/reply 类型和额外 proxy。 +- Foundation 自动编码调用参数并在对端解码,再把调用派发给 `exportedObject`。 +- 低层 libxpc 发送的是 XPC message,并使用 reply handler 关联响应。 +- Mach service name 必须存在于当前进程可访问的 bootstrap namespace,并由 `launchd.plist` 声明。 +- `launchd` 可以在第一条消息到达时按需启动 Agent,再把请求交给 Agent listener。 + +不应依赖的部分是 Foundation/libxpc 的具体 wire bytes、内部 dictionary key、Mach message layout 或私有握手过程。Apple 明确把底层 encoding 和 communication channel 视为 opaque implementation detail;它们不是稳定 ABI,也不应该被持久化或自行解析。 + +### 一次 proxy method call 在系统里发生什么 + +以 `XPCClient.status()` 为例: + +1. `NSXPCConnection(machServiceName:)` 只创建本地 connection object;此时不验证服务名,也不保证 Agent 已运行。 +2. Client 设置 `remoteObjectInterface`、interruption/invalidation handler,然后调用 `resume()`。 +3. 当前 SDK 推荐新代码使用 `activate()`;项目的单次初始 `resume()` 仍有兼容语义:对 inactive 且未 suspend 的 connection 等价于 activate。 +4. `remoteObjectProxyWithErrorHandler` 创建一个本地动态 proxy。它不是 `AgentService` 的引用,也不能直接访问 Agent 地址空间。 +5. 对 proxy 调用 `status(reply:)` 时,Foundation 根据 `NSXPCInterface` 检查 selector 与参数形状,并把 invocation 编码成 opaque XPC message。 +6. 第一条实际 message 触发 service lookup;如果需要,`launchd` 启动 Agent。 +7. Agent Listener 收到 connection request,创建 Agent-side `NSXPCConnection` 并交给 delegate。 +8. delegate 校验身份、设置 `exportedInterface/object`、resume connection 并返回 `true`;返回 `false` 会拒绝并 invalidate 它。 +9. Foundation 在 Agent-side connection 上解码 invocation,并调用 `AgentService.status(reply:)`。 +10. `AgentService` 读取 `LockEngine.shared.isLocked`,再调用 `reply(isLocked, nil)`。 +11. 这里的 reply closure 不是把 Client 的 Swift closure 复制到 Agent 执行;它是 Foundation 提供的跨进程 reply capability。调用它会生成响应 message。 +12. Client connection 收到响应后,在自己的 connection queue 上执行 reply handler;`XPCClient` 再恢复 Swift continuation。 + +当前所有 wire method 都是 `Void + reply block` 形式,因此 proxy method 本身是异步发送;真正的业务完成点是 reply 到达,而不是 proxy call 返回。项目没有使用 synchronous proxy,因为同步 IPC 可能无限阻塞调用线程,尤其不适合 UI/main thread。 + +### Queue 与并发边界 + +Foundation 当前公开的调度保证包括: + +- 每个 `NSXPCConnection` 有自己的 private serial queue,用于 reply、interruption 和 invalidation handler。 +- 每个 `NSXPCListener` 有自己的 private serial queue,用于 delegate callback。 +- 发给 `exportedObject` 的调用会被串行投递到 non-main queue;业务代码不能假设自己运行在 main thread。 +- 不同 wrapper 对应不同 Agent-side connection,因此不要把“单 connection 串行”误解成“整个 Agent 全局串行”。 + +这解释了 `AgentService.executeOnMainThread` 的必要性:多个 client connection 最终都把 `LockEngine` 操作派发到 Agent main thread,event tap、CFRunLoop 和 timer 的生命周期因此收敛到同一执行上下文。`LockEngine` 内部的 `OSAllocatedUnfairLock` 继续保护可能跨线程读取的状态。 + +### Interruption、invalidation 与项目自己的 timeout + +三者属于不同层次: + +| 事件 | Foundation/XPC 含义 | 当前项目处理 | +|---|---|---| +| interruption | 远端进程退出或 crash;named service 之后可能重新建立 | 清除缓存 connection,让下次调用重新创建 | +| invalidation | connection 无法建立或已永久结束;不能再收发消息 | 清除缓存 connection,后续重建 | +| proxy error | 当前 method 无法取得 reply | 恢复对应 continuation 并抛错 | +| 5 秒 timeout | 项目自己加的上层 deadline,不是 Foundation 自动提供 | 让本 connection 失效;mutation 再查询权威状态 | + +Foundation 保证带 reply 的 proxy call 最终调用 reply handler 或 error handler 之一。项目的 `ResumeOnce` 还要处理自定义 timeout 与这两个 callback 的竞争,确保 Swift continuation 仍然只恢复一次。 + +## 一次 `lock` 调用如何跨进程 + +```mermaid +sequenceDiagram + participant UI as App or CLI + participant Client as XPCClient + participant ClientConnection as Client Connection + participant Launchd as launchd + participant Listener as Agent Listener + participant Peer as Agent Connection + participant Adapter as AgentService + participant Engine as LockEngine + + UI->>Client: lock() + Client->>ClientConnection: call remote proxy + ClientConnection->>Launchd: send first message to Mach service + Launchd->>Listener: start Agent / deliver connection request + Listener->>Peer: validate, configure, and accept + ClientConnection->>Peer: lockKeyboard(reply:) + Peer->>Adapter: dispatch to exported object + Adapter->>Engine: lock(settings:) on main thread + Engine-->>Adapter: success or domain error + Adapter-->>Peer: reply(error) + Peer-->>ClientConnection: reply message + ClientConnection-->>Client: resume continuation + Client-->>UI: return or throw +``` + +对应源码中的实际步骤是: + +1. App 的 `LockController` 或 CLI 调用 `XPCClient.shared.lock()`。 +2. `XPCClient.currentConnection()` 创建或复用 `NSXPCConnection(machServiceName:)`。 +3. Client 把 `KeyboardLockerServiceProtocol` 设置成 `remoteObjectInterface`,再取得 remote proxy。 +4. 第一条实际 message 让 `launchd` 按需启动 Agent;Agent 的 `NSXPCListenerDelegate` 收到新 connection。 +5. `XPCAccessControl` 在暴露任何方法前校验调用方身份。 +6. `XPCServerConnection` 为 Agent-side connection 设置 protocol,并把同一个 `AgentService` 实例设置为 `exportedObject`。 +7. 对 remote proxy 的 `lockKeyboard(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 +8. `AgentService` 把引擎操作切到主线程,因为 event tap 和 CFRunLoop 生命周期在这里维护。 +9. `LockEngine.shared.lock(settings:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap。 +10. reply 通过 XPC 返回,`XPCClient` 把 callback bridge 成 Swift async/throwing API。 + +`AgentService` 是 executable target 中的 adapter:它把 wire protocol 翻译成 `Service` 模块里的领域调用。`Service` product 本身不会启动进程;真正创建 listener、保持 RunLoop 的是 `KeyboardLockerAgent/main.swift`。 + +## 当前 XPC 方法分别表达什么 + +| Wire method | Client API | 权威执行方 | 语义 | +|---|---|---|---| +| `lockKeyboard` | `lock()` | `LockEngine` | 幂等地进入全局 locked 状态;已锁时重新应用设置 | +| `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | +| `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | +| `hasAccessibilityPermission` | `hasAccessibilityPermission()` | `AccessibilityManager` | 查询 **Agent 进程** 当前是否受信任 | +| `requestAccessibilityPermission` | `requestAccessibilityPermission()` | `AccessibilityManager` | 请求系统异步显示 Agent 的授权 prompt;reply 不代表用户已授权 | +| `currentSettings` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 持有的设置快照 | + +Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使用 Accessibility API 的进程身份。App 获得 Accessibility 权限并不能让 Agent 创建 event tap。 + +## Connection 生命周期不等于锁生命周期 + +`XPCClient` 会缓存一条 connection,这是减少重复建连的实现优化,不是业务 session: + +- App 和 CLI 各有自己的 connection。 +- 某条 connection interruption/invalidation 后,只清除 Client 侧缓存;下一次调用会重新连接。 +- App 退出、CLI 退出或 connection 断开,不会触发 unlock。 +- `klock lock` 当前会继续运行以打印后续的 `Unlocked.`,但它只是观察者;强制结束 CLI 不会解除锁。 +- Agent 退出则不同:event tap 属于 Agent 进程,进程退出会由系统释放它,内存中的 locked 状态也会消失。 + +因此项目刻意没有 `LockSession`、client ownership 或“连接释放时自动 unlock”之类的抽象。领域事实是一个物理键盘对应一个由 Agent 持有的全局状态。 + +## Reply、超时与“结果未知” + +`XPCClient` 用 `withCheckedThrowingContinuation` 把 reply callback 转为 async,并通过 `ResumeOnce` 保证 reply、proxy error、missing proxy 和 timeout 竞争时只恢复 continuation 一次。 + +所有调用都有 5 秒响应上限: + +| 情况 | Client 看到的结果 | +|---|---| +| Agent reply 成功 | async 方法正常返回 | +| Agent reply 领域错误 | 抛出 Agent 返回的错误,例如缺少 Accessibility 权限 | +| proxy 创建或传输失败 | 抛出 transport error / `serviceUnavailable` | +| 5 秒内没有任何结果 | connection 失效并抛出 `timedOut`,或由 mutation API 转换为 outcome unknown | + +对 mutation 而言,timeout 不等于“Agent 没执行”。请求可能已经到达 Agent,只是 reply 没有及时返回。因此: + +- `lock()` timeout 后通过一条新 connection 查询 `status()`;如果已经 locked,就按成功处理。 +- `unlock()` 对称地确认是否已经 unlocked。 +- 无法确认最终状态时抛出 `operationOutcomeUnknown`,而不是谎称操作一定失败。 +- Accessibility prompt 请求 timeout 同样只表示最终结果未知;不能据此断言 prompt 没有发出。 + +App 在动作结束后仍会做完整 reconciliation,因为跨进程系统中应以 Agent 的当前查询结果为准,而不是长期相信某次历史 reply。 + +## 为什么还需要通知 + +XPC request/reply 适合“现在执行”或“现在查询”,不会自动告诉其他 wrapper 状态后来发生了变化。例如 CLI 锁定后,App 需要更新菜单栏图标;自动解锁发生时,所有长命 UI 也需要刷新。 + +当前状态同步链路是: + +```mermaid +sequenceDiagram + participant Agent as AgentService + LockEngine + participant Broadcast as State Broadcaster + participant Subscriber as State Subscriber + participant Client as XPCClient + participant UI as Long-lived UI + + Agent->>Broadcast: state changed + Broadcast-->>Subscriber: Darwin + Distributed signals + Subscriber->>Client: status() + Client->>Agent: authoritative XPC query + Agent-->>Client: current state + Client-->>Subscriber: current state + Subscriber-->>UI: de-duplicated update +``` + +关键点: + +- Darwin 和 Distributed notification 都不携带锁状态,只表达“可能有变化”。 +- 通知可能重复、丢失或乱序,所以 subscriber 收到信号后必须再走 XPC `status()`。 +- 两个通知通道是为了提高不同进程状态下的交付可靠性,不是两套状态源。 +- App 打开菜单或重新变为 active 时还会主动 reconcile,以弥补挂起期间错过的通知。 +- 一次性 `status` / `unlock` 命令直接查询 Agent,不需要为了读取当前状态先等待通知。 + +## Connection 访问控制 + +Agent 在 `NSXPCListenerDelegate` 中先调用 `XPCAccessControl.isConnectionAuthorized`,只有通过后才设置 `exportedObject`: + +- Release:校验签名完整性、调用方 Team ID 与正在运行的 Agent 相同,并检查 bundle/code-sign identifier allowlist。 +- Debug:保留 identifier allowlist,放宽 Team ID 与完整签名要求,便于本地开发。 + +当前允许的调用方是主 App 和 bundled CLI 对应的 identifier。仅仅知道 Mach service 名称,不代表任意进程都能调用 Agent。 + +## `SMAppService` 状态和 XPC 状态不要混为一谈 + +| 观察结果 | 含义 | App 行为 | +|---|---|---| +| `.notRegistered` | Agent 尚未注册 | 尝试 `register()`,然后重新读取状态 | +| `.enabled` | 系统允许 Agent 运行 | 继续通过 XPC 查询真实 readiness | +| `.requiresApproval` | 用户需要在 Login Items 批准 | 显示说明和 System Settings 入口 | +| `.notFound` | bundle 内找不到声明的 Agent/plist | 报告安装或 bundle layout 错误 | +| enabled + XPC error | Agent 未启动、崩溃、版本不兼容或连接被拒绝 | 诚实显示不可达;可提供显式 retry/restart | + +显式 restart 会先尽可能 unlock,再等待 `SMAppService.unregister()` 完成并重新注册 bundled Agent。不能自动 restart,因为 Agent 退出本身会释放正在工作的 event tap。 + +## 新增 XPC 能力时的修改顺序 + +1. 在 `Core/Sources/Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 增加最小 wire method。 +2. 只使用 Objective-C/XPC 能表达的参数和 reply 类型;复杂 Swift value 优先编码成 `Data`。 +3. 在 `KeyboardLockerAgent/AgentService.swift` 实现 protocol adapter。 +4. 把真实领域行为放在 `Service`,不要堆进 App、CLI 或 wire adapter。 +5. 在 `Core/Sources/Client/XPCClient.swift` 增加薄的 async/throwing 封装。 +6. wrapper 只调用 `XPCClient`;App/CLI 不得 import `Service`。 +7. 明确方法是 query 还是 mutation,并定义 timeout 后能否确认最终结果。 +8. 如果状态可能在调用之外变化,继续用“notification signal + XPC query”,不要把通知 payload 变成第二个状态源。 + +## 常见误解 + +### “Core 被两侧引用,所以是不是有两份锁状态?” + +不是。两侧都能看到的是 `Common` 契约;`LockEngine` 位于只有 Agent 链接的 `Service` product。App/CLI 进程里根本没有 `LockEngine` 实例。 + +### “`Service` product 就是 XPC 后台进程吗?” + +不是。`Service` 是 library product。`KeyboardLockerAgent` 才是 executable;它创建 listener,并把 `Service` 中的能力暴露给 XPC。 + +### “XPC connection 断开时会自动解锁吗?” + +不会。锁属于 Agent,不属于 connection。只有 unlock、自动解锁、解锁热键或 Agent 进程退出会释放锁。 + +### “通知已经告诉我状态,为什么还要调用 `status()`?” + +因为跨进程通知不是可靠、有序的状态存储。这里的通知只是刷新提示,Agent 的 `status()` 才是权威事实。 + +### “主 App 请求 Accessibility 权限不就够了吗?” + +不够。创建 event tap 的是 Agent,必须由 Agent 自己查询和请求对应权限。 + +## 源码导航 + +| 想理解什么 | 入口 | +|---|---| +| Wire protocol、Mach name、allowlist | [`Core/Sources/Common/Shared.swift`](../Core/Sources/Common/Shared.swift) | +| Client connection、async bridge、timeout | [`Core/Sources/Client/XPCClient.swift`](../Core/Sources/Client/XPCClient.swift) | +| 长命 UI 的状态订阅 | [`Core/Sources/Client/LockStateSubscriber.swift`](../Core/Sources/Client/LockStateSubscriber.swift) | +| Agent listener 与进程入口 | [`KeyboardLockerAgent/main.swift`](../KeyboardLockerAgent/main.swift) | +| Wire method 到领域方法的适配 | [`KeyboardLockerAgent/AgentService.swift`](../KeyboardLockerAgent/AgentService.swift) | +| 新 connection 的 server 配置 | [`Core/Sources/Service/XPCServerConnection.swift`](../Core/Sources/Service/XPCServerConnection.swift) | +| 调用方身份校验 | [`Core/Sources/Service/XPCAccessControl.swift`](../Core/Sources/Service/XPCAccessControl.swift) | +| 全局锁与 event tap | [`Core/Sources/Service/LockEngine.swift`](../Core/Sources/Service/LockEngine.swift) | +| 状态变化信号 | [`Core/Sources/Service/LockStateBroadcaster.swift`](../Core/Sources/Service/LockStateBroadcaster.swift) | +| Agent 注册与显式恢复 | [`KeyboardLocker/AgentRegistrar.swift`](../KeyboardLocker/AgentRegistrar.swift) | +| App readiness 与操作协调 | [`KeyboardLocker/LockController.swift`](../KeyboardLocker/LockController.swift) | + +## Apple 参考资料 + +- [`NSXPCConnection`](https://developer.apple.com/documentation/foundation/nsxpcconnection) +- [`NSXPCListener`](https://developer.apple.com/documentation/foundation/nsxpclistener) +- [`NSXPCListenerDelegate.listener(_:shouldAcceptNewConnection:)`](https://developer.apple.com/documentation/foundation/nsxpclistenerdelegate/listener%28_%3Ashouldacceptnewconnection%3A%29) +- [`NSXPCInterface`](https://developer.apple.com/documentation/foundation/nsxpcinterface) +- [Creating XPC Services](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html) +- [Creating Launch Daemons and Agents](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html) From 49e1de7498f7a7e30585b86a06427a2d01458c73 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 20:15:55 +0800 Subject: [PATCH 023/106] feat(xpc): add negotiated agent replacement Advertise a versioned service descriptor and gate optional calls on the negotiated connection generation. Protect replacement with an exact-instance prepare/commit drain and recover unknown commit replies through ticket status. --- Core/Package.swift | 2 + .../Sources/Client/ServiceCompatibility.swift | 171 ++++++++++ Core/Sources/Client/XPCClient.swift | 296 ++++++++++++++-- Core/Sources/Common/ServiceDescriptor.swift | 315 ++++++++++++++++++ Core/Sources/Common/Shared.swift | 44 +++ .../Service/AccessibilityManager.swift | 9 + .../Service/ReplacementTransaction.swift | 138 ++++++++ .../ServiceCompatibilityTests.swift | 255 ++++++++++++++ .../ReplacementTransactionTests.swift | 86 +++++ KeyboardLockerAgent/AgentService.swift | 267 +++++++++++++++ 10 files changed, 1550 insertions(+), 33 deletions(-) create mode 100644 Core/Sources/Client/ServiceCompatibility.swift create mode 100644 Core/Sources/Common/ServiceDescriptor.swift create mode 100644 Core/Sources/Service/ReplacementTransaction.swift create mode 100644 Core/Tests/ClientTests/ServiceCompatibilityTests.swift create mode 100644 Core/Tests/ServiceTests/ReplacementTransactionTests.swift diff --git a/Core/Package.swift b/Core/Package.swift index 6af40a1..5a8f44d 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -16,5 +16,7 @@ let package = Package( .target(name: "Common"), .target(name: "Client", dependencies: ["Common"]), .target(name: "Service", dependencies: ["Common"]), + .testTarget(name: "ClientTests", dependencies: ["Client"]), + .testTarget(name: "ServiceTests", dependencies: ["Service"]), ] ) diff --git a/Core/Sources/Client/ServiceCompatibility.swift b/Core/Sources/Client/ServiceCompatibility.swift new file mode 100644 index 0000000..c9d19d6 --- /dev/null +++ b/Core/Sources/Client/ServiceCompatibility.swift @@ -0,0 +1,171 @@ +import Common +import Foundation + +/// Monotonic `CFBundleVersion` value with arbitrary-precision dotted numeric comparison. +public struct ServiceBuildVersion: Comparable, Sendable { + public let rawValue: String + + private let components: [String] + + public init?(_ rawValue: String) { + let sourceComponents = rawValue.split( + separator: ".", + omittingEmptySubsequences: false + ) + guard !sourceComponents.isEmpty else { + return nil + } + + var normalized: [String] = [] + for component in sourceComponents { + guard !component.isEmpty, + component.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) + else { + return nil + } + + let significantDigits = component.drop(while: { $0 == "0" }) + normalized.append(significantDigits.isEmpty ? "0" : String(significantDigits)) + } + + self.rawValue = rawValue + components = normalized + } + + public static func == (lhs: ServiceBuildVersion, rhs: ServiceBuildVersion) -> Bool { + compare(lhs, rhs) == 0 + } + + public static func < (lhs: ServiceBuildVersion, rhs: ServiceBuildVersion) -> Bool { + compare(lhs, rhs) < 0 + } + + private static func compare( + _ lhs: ServiceBuildVersion, + _ rhs: ServiceBuildVersion + ) -> Int { + for index in 0 ..< max(lhs.components.count, rhs.components.count) { + let left = index < lhs.components.count ? lhs.components[index] : "0" + let right = index < rhs.components.count ? rhs.components[index] : "0" + + if left.count != right.count { + return left.count < right.count ? -1 : 1 + } + if left != right { + return left < right ? -1 : 1 + } + } + return 0 + } +} + +/// Contract and bundled identity the App requires from a running Agent. +public struct ServiceCompatibilityRequirements: Equatable, Sendable { + public let minimumProtocolVersion: ServiceProtocolVersion + public let requiredCapabilities: Set + public let agentBundleIdentifier: String + public let agentVersion: String + public let agentBuild: String + + public init( + minimumProtocolVersion: ServiceProtocolVersion = ServiceContract.protocolVersion, + requiredCapabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String, + agentVersion: String, + agentBuild: String + ) { + self.minimumProtocolVersion = minimumProtocolVersion + self.requiredCapabilities = requiredCapabilities + self.agentBundleIdentifier = agentBundleIdentifier + self.agentVersion = agentVersion + self.agentBuild = agentBuild + } +} + +public enum ServiceCompatibilityIssue: Error, Equatable, LocalizedError { + case agentBuildMismatch(expected: String, actual: String) + case agentBundleIdentifierMismatch(expected: String, actual: String) + case agentVersionMismatch(expected: String, actual: String) + case missingCapabilities([ServiceCapability]) + case protocolMajorMismatch(expected: Int, actual: Int) + case protocolMinorTooOld(required: Int, actual: Int) + + public var errorDescription: String? { + switch self { + case let .agentBuildMismatch(expected, actual): + return "The running agent build is \(actual); the bundled build is \(expected)." + + case let .agentBundleIdentifierMismatch(expected, actual): + return "The running agent identifier is \(actual); the bundled identifier is \(expected)." + + case let .agentVersionMismatch(expected, actual): + return "The running agent version is \(actual); the bundled version is \(expected)." + + case let .missingCapabilities(capabilities): + let names = capabilities.map(\.rawValue).sorted().joined(separator: ", ") + return "The running agent is missing required capabilities: \(names)." + + case let .protocolMajorMismatch(expected, actual): + return "The running agent uses XPC protocol major version \(actual); this app requires \(expected)." + + case let .protocolMinorTooOld(required, actual): + return "The running agent uses XPC protocol minor version \(actual); this app requires at least \(required)." + } + } +} + +public extension ServiceDescriptor { + func compatibilityIssues( + against requirements: ServiceCompatibilityRequirements + ) -> [ServiceCompatibilityIssue] { + var issues: [ServiceCompatibilityIssue] = [] + + if protocolVersion.major != requirements.minimumProtocolVersion.major { + issues.append(.protocolMajorMismatch( + expected: requirements.minimumProtocolVersion.major, + actual: protocolVersion.major + )) + } else if protocolVersion.minor < requirements.minimumProtocolVersion.minor { + issues.append(.protocolMinorTooOld( + required: requirements.minimumProtocolVersion.minor, + actual: protocolVersion.minor + )) + } + + let missingCapabilities = requirements.requiredCapabilities.subtracting(capabilities) + if !missingCapabilities.isEmpty { + issues.append(.missingCapabilities(missingCapabilities.sorted { + $0.rawValue < $1.rawValue + })) + } + + if agentBundleIdentifier != requirements.agentBundleIdentifier { + issues.append(.agentBundleIdentifierMismatch( + expected: requirements.agentBundleIdentifier, + actual: agentBundleIdentifier + )) + } + if agentVersion != requirements.agentVersion { + issues.append(.agentVersionMismatch( + expected: requirements.agentVersion, + actual: agentVersion + )) + } + if agentBuild != requirements.agentBuild { + issues.append(.agentBuildMismatch( + expected: requirements.agentBuild, + actual: agentBuild + )) + } + + return issues + } + + func validateCompatibility( + against requirements: ServiceCompatibilityRequirements + ) throws { + if let issue = compatibilityIssues(against: requirements).first { + throw issue + } + } +} diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index f3bb000..ef483f3 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -3,12 +3,31 @@ import Foundation import os public enum XPCClientError: Error, LocalizedError { + case agentChanged(expected: UUID, actual: UUID) + case missingCapability(ServiceCapability) + case operationOutcomeUnknown case serviceUnavailable + case timedOut public var errorDescription: String? { switch self { + case let .agentChanged(expected, actual): + """ + The KeyboardLocker agent changed after negotiation \ + (expected \(expected.uuidString), found \(actual.uuidString)). + """ + + case let .missingCapability(capability): + "The KeyboardLocker agent does not support \(capability.rawValue)." + + case .operationOutcomeUnknown: + "The KeyboardLocker agent did not confirm the operation. Its final outcome is unknown." + case .serviceUnavailable: "The KeyboardLocker agent is not reachable." + + case .timedOut: + "The KeyboardLocker agent did not respond in time." } } } @@ -24,6 +43,8 @@ public enum XPCClientError: Error, LocalizedError { public final class XPCClient: @unchecked Sendable { public static let shared = XPCClient() + private static let responseTimeout: Duration = .seconds(5) + private let lock = OSAllocatedUnfairLock() private var connection: NSXPCConnection? @@ -31,15 +52,39 @@ public final class XPCClient: @unchecked Sendable { // MARK: - Operations + /// Version and capabilities of the Agent process behind the current connection. + /// + /// Capability-gated methods repeat this negotiation on the exact connection they invoke. + public func serviceDescriptor() async throws -> ServiceDescriptor { + try await serviceDescriptor(using: currentConnection()) + } + + private func serviceDescriptor( + using connection: NSXPCConnection + ) async throws -> ServiceDescriptor { + let data: Data? = try await withProxyReturning(using: connection) { service, resume in + service.serviceDescriptor { resume($0, $1) } + } + return try ServiceDescriptor.decodedFromXPC(data) + } + public func lock() async throws { - try await withProxy { service, resume in - service.lockKeyboard { resume($0) } + do { + try await withProxy { service, resume in + service.lockKeyboard { resume($0) } + } + } catch XPCClientError.timedOut { + try await confirmTimedOutMutation(expectedIsLocked: true) } } public func unlock() async throws { - try await withProxy { service, resume in - service.unlockKeyboard { resume($0) } + do { + try await withProxy { service, resume in + service.unlockKeyboard { resume($0) } + } + } catch XPCClientError.timedOut { + try await confirmTimedOutMutation(expectedIsLocked: false) } } @@ -50,9 +95,131 @@ public final class XPCClient: @unchecked Sendable { } } + /// Enters the Agent's fail-safe replacement drain and returns its ownership ticket. + public func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + let connection = try await negotiatedConnection( + requiring: [.committedReplacementDrain, .prepareForReplacement], + expectedAgentInstanceID: expectedAgentInstanceID + ) + let data: Data? = try await withProxyReturning(using: connection) { service, resume in + service.prepareForReplacement( + unlockIfNeeded: unlockIfNeeded, + expectedAgentInstanceID: expectedAgentInstanceID + ) { + resume($0, $1) + } + } + let ticket = try ServiceReplacementTicket.decodedFromXPC(data) + guard ticket.agentInstanceID == expectedAgentInstanceID else { + try? await cancelReplacementPreparation(ticket: ticket) + throw XPCClientError.agentChanged( + expected: expectedAgentInstanceID, + actual: ticket.agentInstanceID + ) + } + return ticket + } + + /// Releases a replacement preparation before it has been committed. + public func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws { + let connection = try await negotiatedConnection( + requiring: [.prepareForReplacement], + expectedAgentInstanceID: ticket.agentInstanceID + ) + let data = try ticket.encodedForXPC() + try await withProxy(using: connection) { service, resume in + service.cancelReplacementPreparation(ticket: data, reply: resume) + } + } + + /// Makes a prepared drain non-expiring immediately before unregister is submitted. + public func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws { + do { + try await commitReplacementOnce(ticket: ticket) + } catch { + // Commit is idempotent. Re-negotiate once so a lost reply cannot strand a preparation + // merely because the original connection was interrupted. + resetConnection() + do { + try await commitReplacementOnce(ticket: ticket) + } catch let retryError { + if let status = try? await replacementStatus(ticket: ticket), + status.phase == .committed + { + return + } + throw retryError + } + } + } + + private func commitReplacementOnce( + ticket: ServiceReplacementTicket + ) async throws { + let connection = try await negotiatedConnection( + requiring: [.committedReplacementDrain], + expectedAgentInstanceID: ticket.agentInstanceID + ) + let data = try ticket.encodedForXPC() + try await withProxy(using: connection) { service, resume in + service.commitReplacement(ticket: data, reply: resume) + } + } + + public func replacementStatus( + ticket: ServiceReplacementTicket + ) async throws -> ServiceReplacementStatus { + let connection = try await negotiatedConnection( + requiring: [.committedReplacementDrain], + expectedAgentInstanceID: ticket.agentInstanceID + ) + let ticketData = try ticket.encodedForXPC() + let statusData: Data? = try await withProxyReturning(using: connection) { service, resume in + service.replacementStatus(ticket: ticketData) { + resume($0, $1) + } + } + return try ServiceReplacementStatus.decodedFromXPC(statusData) + } + + /// Whether the Agent process currently has Accessibility permission. + public func hasAccessibilityPermission() async throws -> Bool { + let connection = try await negotiatedConnection( + requiring: [.accessibilityStatus] + ) + return try await withProxyReturning(using: connection) { service, resume in + service.hasAccessibilityPermission { resume($0, nil) } + } + } + + /// Requests the system Accessibility prompt from the Agent process. + /// Completion means the request was sent; permission must be queried again later. + public func requestAccessibilityPermission() async throws { + let connection = try await negotiatedConnection( + requiring: [.accessibilityPrompt] + ) + do { + try await withProxy(using: connection) { service, resume in + service.requestAccessibilityPermission(reply: resume) + } + } catch XPCClientError.timedOut { + throw XPCClientError.operationOutcomeUnknown + } + } + /// The Agent's current settings, falling back to `.default` if the Agent can't provide them. public func currentSettings() async throws -> KeyboardLockerSettings { - let data: Data? = try await withProxyReturning { service, resume in + let connection = try await negotiatedConnection( + requiring: [.currentSettings] + ) + let data: Data? = try await withProxyReturning(using: connection) { service, resume in service.currentSettings { resume($0, nil) } } return KeyboardLockerSettings.decodedFromXPC(data) @@ -60,6 +227,52 @@ public final class XPCClient: @unchecked Sendable { // MARK: - Connection Management + /// Invalidates the cached connection so a subsequent call resolves the currently registered + /// Agent instead of reusing a connection to a process being replaced. + public func resetConnection() { + lock.lock() + let candidate = connection + connection = nil + lock.unlock() + candidate?.invalidate() + } + + /// Lock mutations are idempotent, so a timed-out reply can be recovered by checking whether + /// the Agent reached the requested global state through a fresh connection. + private func confirmTimedOutMutation(expectedIsLocked: Bool) async throws { + if let isLocked = try? await status(), isLocked == expectedIsLocked { + return + } + throw XPCClientError.operationOutcomeUnknown + } + + /// Returns one negotiated connection generation. The subsequent feature call must use this + /// exact object; transparent reconnection would invalidate the descriptor and capability grant. + private func negotiatedConnection( + requiring capabilities: Set, + expectedAgentInstanceID: UUID? = nil + ) async throws -> NSXPCConnection { + let connection = currentConnection() + let descriptor = try await serviceDescriptor(using: connection) + + if let expectedAgentInstanceID, + descriptor.agentInstanceID != expectedAgentInstanceID + { + throw XPCClientError.agentChanged( + expected: expectedAgentInstanceID, + actual: descriptor.agentInstanceID + ) + } + if let missingCapability = capabilities + .subtracting(descriptor.capabilities) + .sorted(by: { $0.rawValue < $1.rawValue }) + .first + { + throw XPCClientError.missingCapability(missingCapability) + } + return connection + } + private func currentConnection() -> NSXPCConnection { lock.lock() defer { lock.unlock() } @@ -72,7 +285,10 @@ public final class XPCClient: @unchecked Sendable { connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) // Drop the cached connection on teardown so the next call transparently reconnects. - let clear: @Sendable () -> Void = { [weak self] in self?.clearConnection() } + let connectionID = ObjectIdentifier(connection) + let clear: @Sendable () -> Void = { [weak self] in + self?.clearConnection(ifMatching: connectionID) + } connection.invalidationHandler = clear connection.interruptionHandler = clear @@ -81,62 +297,76 @@ public final class XPCClient: @unchecked Sendable { return connection } - private func clearConnection() { + private func clearConnection(ifMatching candidateID: ObjectIdentifier) { lock.lock() - connection = nil + if let activeConnection = connection, ObjectIdentifier(activeConnection) == candidateID { + connection = nil + } lock.unlock() } + private func invalidateConnection(_ candidate: NSXPCConnection) { + clearConnection(ifMatching: ObjectIdentifier(candidate)) + candidate.invalidate() + } + /// Bridges a reply-based XPC call returning only an optional `Error` into async/throwing form. /// The continuation is resumed exactly once — whether by the reply, the proxy error handler, /// or a missing proxy — so a dead Agent throws rather than hanging forever. private func withProxy( + using connection: NSXPCConnection? = nil, _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (Error?) -> Void) -> Void ) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let once = ResumeOnce() - let connection = currentConnection() - - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - once.run { continuation.resume(throwing: error) } - } - - guard let service = proxy as? KeyboardLockerServiceProtocol else { - once.run { continuation.resume(throwing: XPCClientError.serviceUnavailable) } - return - } - + let _: Void = try await withProxyReturning(using: connection) { service, resume in body(service) { error in - once.run { - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + resume((), error) } } } - /// Same as `withProxy`, for calls that return a value plus an optional `Error`. + /// Central reply bridge. Every operation has a bounded response time and resumes exactly once + /// across reply, proxy-error, missing-proxy, and timeout races. private func withProxyReturning( + using providedConnection: NSXPCConnection? = nil, _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (T, Error?) -> Void) -> Void ) async throws -> T { - try await withCheckedThrowingContinuation { continuation in + let connection = if let providedConnection { + providedConnection + } else { + currentConnection() + } + + return try await withCheckedThrowingContinuation { continuation in let once = ResumeOnce() - let connection = currentConnection() + let timeoutTask = Task { [weak self] in + try? await Task.sleep(for: Self.responseTimeout) + guard !Task.isCancelled else { + return + } + once.run { + self?.invalidateConnection(connection) + continuation.resume(throwing: XPCClientError.timedOut) + } + } let proxy = connection.remoteObjectProxyWithErrorHandler { error in - once.run { continuation.resume(throwing: error) } + once.run { + timeoutTask.cancel() + continuation.resume(throwing: error) + } } guard let service = proxy as? KeyboardLockerServiceProtocol else { - once.run { continuation.resume(throwing: XPCClientError.serviceUnavailable) } + once.run { + timeoutTask.cancel() + continuation.resume(throwing: XPCClientError.serviceUnavailable) + } return } body(service) { value, error in once.run { + timeoutTask.cancel() if let error { continuation.resume(throwing: error) } else { diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift new file mode 100644 index 0000000..c8027fd --- /dev/null +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -0,0 +1,315 @@ +import Foundation + +/// Version of the XPC method contract implemented by an Agent. +/// +/// A major version change is breaking. Minor versions may only add selectors or behavior, and +/// clients must still check capabilities before invoking an optional selector. +public struct ServiceProtocolVersion: Codable, Equatable, Hashable, Sendable { + public let major: Int + public let minor: Int + + public init(major: Int, minor: Int) { + self.major = major + self.minor = minor + } +} + +/// Stable string capability carried by `ServiceDescriptor`. +/// +/// This is intentionally not an enum: older clients must preserve and ignore capability names +/// introduced by newer Agents instead of failing to decode the descriptor. +public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static let accessibilityPrompt = Self(rawValue: "accessibility-prompt") + public static let accessibilityStatus = Self(rawValue: "accessibility-status") + public static let currentSettings = Self(rawValue: "current-settings") + public static let committedReplacementDrain = Self( + rawValue: "committed-replacement-drain" + ) + public static let lockControl = Self(rawValue: "lock-control") + public static let prepareForReplacement = Self(rawValue: "prepare-for-replacement") +} + +/// Additive replacement phase exposed for recovery without revealing the ownership ticket. +public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static let committed = Self(rawValue: "committed") + public static let inactive = Self(rawValue: "inactive") + public static let prepared = Self(rawValue: "prepared") + public static let unknown = Self(rawValue: "unknown") +} + +/// Current bootstrap contract advertised by the bundled Agent. +public enum ServiceContract { + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 1) + + public static let requiredCapabilities: Set = [ + .accessibilityPrompt, + .accessibilityStatus, + .committedReplacementDrain, + .currentSettings, + .lockControl, + .prepareForReplacement, + ] +} + +/// Process-scoped description of the Agent behind an XPC connection. +/// +/// Version and build metadata are compatibility diagnostics, not peer authentication. The XPC +/// connection's code-signing requirement remains the security boundary. +public struct ServiceDescriptor: Codable, Equatable, Sendable { + public let protocolVersion: ServiceProtocolVersion + public let capabilities: Set + public let agentBundleIdentifier: String + public let agentVersion: String + public let agentBuild: String + public let agentInstanceID: UUID + public let replacementPending: Bool + public let replacementPhase: ServiceReplacementPhase + + public init( + protocolVersion: ServiceProtocolVersion, + capabilities: Set, + agentBundleIdentifier: String, + agentVersion: String, + agentBuild: String, + agentInstanceID: UUID, + replacementPending: Bool = false, + replacementPhase: ServiceReplacementPhase = .inactive + ) { + self.protocolVersion = protocolVersion + self.capabilities = capabilities + self.agentBundleIdentifier = agentBundleIdentifier + self.agentVersion = agentVersion + self.agentBuild = agentBuild + self.agentInstanceID = agentInstanceID + self.replacementPending = replacementPending || replacementPhase != .inactive + self.replacementPhase = replacementPhase + } + + private enum CodingKeys: String, CodingKey { + case agentBuild + case agentBundleIdentifier + case agentInstanceID + case agentVersion + case capabilities + case protocolVersion + case replacementPending + case replacementPhase + } + + /// Bootstrap fields are decoded explicitly so additive fields can default when an older Agent + /// omits them. Existing required keys and their types must remain stable for this Mach service. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = try container.decode(ServiceProtocolVersion.self, forKey: .protocolVersion) + capabilities = try container.decode(Set.self, forKey: .capabilities) + agentBundleIdentifier = try container.decode(String.self, forKey: .agentBundleIdentifier) + agentVersion = try container.decode(String.self, forKey: .agentVersion) + agentBuild = try container.decode(String.self, forKey: .agentBuild) + agentInstanceID = try container.decode(UUID.self, forKey: .agentInstanceID) + let decodedReplacementPending = try container.decodeIfPresent( + Bool.self, + forKey: .replacementPending + ) ?? false + let decodedReplacementPhase = try container.decodeIfPresent( + ServiceReplacementPhase.self, + forKey: .replacementPhase + ) ?? (decodedReplacementPending ? .unknown : .inactive) + replacementPending = decodedReplacementPending || decodedReplacementPhase != .inactive + replacementPhase = decodedReplacementPhase + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(protocolVersion, forKey: .protocolVersion) + try container.encode(capabilities, forKey: .capabilities) + try container.encode(agentBundleIdentifier, forKey: .agentBundleIdentifier) + try container.encode(agentVersion, forKey: .agentVersion) + try container.encode(agentBuild, forKey: .agentBuild) + try container.encode(agentInstanceID, forKey: .agentInstanceID) + try container.encode(replacementPending, forKey: .replacementPending) + try container.encode(replacementPhase, forKey: .replacementPhase) + } +} + +/// Opaque ownership token for a fail-safe Agent replacement barrier. +public struct ServiceReplacementTicket: Codable, Equatable, Sendable { + public let id: UUID + public let agentInstanceID: UUID + + public init(id: UUID, agentInstanceID: UUID) { + self.id = id + self.agentInstanceID = agentInstanceID + } +} + +/// Ticket-specific replacement state used to recover an unknown commit reply. +public struct ServiceReplacementStatus: Codable, Equatable, Sendable { + public let phase: ServiceReplacementPhase + + public init(phase: ServiceReplacementPhase) { + self.phase = phase + } +} + +public enum ServiceDescriptorCodingError: Error, LocalizedError { + case missingPayload + case payloadTooLarge + case invalidPayload + + public var errorDescription: String? { + switch self { + case .missingPayload: + "The KeyboardLocker agent returned no service descriptor." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized service descriptor." + case .invalidPayload: + "The KeyboardLocker agent returned an invalid service descriptor." + } + } +} + +public enum ServiceReplacementTicketCodingError: Error, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned an invalid replacement ticket." + case .missingPayload: + "The KeyboardLocker agent returned no replacement ticket." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized replacement ticket." + } + } +} + +public enum ServiceReplacementStatusCodingError: Error, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned an invalid replacement status." + case .missingPayload: + "The KeyboardLocker agent returned no replacement status." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized replacement status." + } + } +} + +// MARK: - XPC Serialization + +public extension ServiceDescriptor { + static let maximumEncodedSize = 16 * 1024 + + func encodedForXPC() throws -> Data { + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw ServiceDescriptorCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> ServiceDescriptor { + guard let data else { + throw ServiceDescriptorCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw ServiceDescriptorCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(ServiceDescriptor.self, from: data) + } catch { + throw ServiceDescriptorCodingError.invalidPayload + } + } +} + +public extension ServiceReplacementTicket { + static let maximumEncodedSize = 4 * 1024 + + func encodedForXPC() throws -> Data { + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw ServiceReplacementTicketCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> ServiceReplacementTicket { + guard let data else { + throw ServiceReplacementTicketCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw ServiceReplacementTicketCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(ServiceReplacementTicket.self, from: data) + } catch { + throw ServiceReplacementTicketCodingError.invalidPayload + } + } +} + +public extension ServiceReplacementStatus { + static let maximumEncodedSize = 4 * 1024 + + func encodedForXPC() throws -> Data { + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw ServiceReplacementStatusCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> ServiceReplacementStatus { + guard let data else { + throw ServiceReplacementStatusCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw ServiceReplacementStatusCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(ServiceReplacementStatus.self, from: data) + } catch { + throw ServiceReplacementStatusCodingError.invalidPayload + } + } +} diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 363df30..b69b318 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -6,6 +6,9 @@ public enum SharedConstants { /// Mach service name for XPC communication between App/Agent/CLI public static let machServiceName = "io.lzhlovesjyq.keyboardlocker.agent" + /// Bundle identifier of the launchd-managed Agent bundled with the App. + public static let agentBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.agent" + /// Default unlock key code for 'L' key (⌃⌘L) public static let defaultUnlockKeyCode: UInt16 = 37 @@ -32,12 +35,53 @@ public enum NotificationNames { /// Implemented by Agent, consumed by App/CLI clients. @objc(KeyboardLockerServiceProtocol) public protocol KeyboardLockerServiceProtocol { + // MARK: Bootstrap Methods + + /// Returns a versioned process descriptor before clients invoke capability-gated methods. + func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) + // MARK: Keyboard Locking Methods func lockKeyboard(reply: @escaping (Error?) -> Void) func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) + /// Atomically enters a short-lived fail-safe drain and optionally unlocks before returning its + /// exclusive ownership ticket. + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID, + reply: @escaping (Data?, Error?) -> Void + ) + + /// Commits a prepared drain immediately before Service Management unregister is submitted. + /// A committed drain cannot expire or be cancelled; only old-Agent process exit clears it. + func commitReplacement( + ticket: Data, + reply: @escaping (Error?) -> Void + ) + + /// Returns the phase of the exact replacement ticket without mutating the transaction. + func replacementStatus( + ticket: Data, + reply: @escaping (Data?, Error?) -> Void + ) + + /// Cancels a still-prepared drain before Service Management unregister is submitted. + func cancelReplacementPreparation( + ticket: Data, + reply: @escaping (Error?) -> Void + ) + + // MARK: Accessibility Methods + + /// Returns whether the Agent process currently has Accessibility permission. + func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) + + /// Asks the Agent process to trigger the system Accessibility permission prompt. + /// A successful reply means the prompt request was sent, not that access was granted. + func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) + // MARK: Settings Methods /// Returns the Agent's current settings as JSON-encoded `KeyboardLockerSettings`. diff --git a/Core/Sources/Service/AccessibilityManager.swift b/Core/Sources/Service/AccessibilityManager.swift index 9f67346..581e18f 100644 --- a/Core/Sources/Service/AccessibilityManager.swift +++ b/Core/Sources/Service/AccessibilityManager.swift @@ -10,4 +10,13 @@ public final class AccessibilityManager { public static func hasPermission() -> Bool { AXIsProcessTrusted() } + + /// Asks macOS to present the Accessibility permission prompt for the Agent process. + /// The prompt is asynchronous, so callers must query `hasPermission()` again later. + public static func requestPermission() { + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true, + ] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } } diff --git a/Core/Sources/Service/ReplacementTransaction.swift b/Core/Sources/Service/ReplacementTransaction.swift new file mode 100644 index 0000000..04fc1e5 --- /dev/null +++ b/Core/Sources/Service/ReplacementTransaction.swift @@ -0,0 +1,138 @@ +import Common +import Foundation + +/// Server-side state machine for replacing the launchd-managed Agent. +/// +/// The caller must serialize access. `AgentService` does this on the main queue. +public struct ReplacementTransaction { + public struct Preparation: Equatable, Sendable { + public let ticket: ServiceReplacementTicket + + fileprivate let generation: UInt64 + } + + private enum Phase { + case committed(ServiceReplacementTicket) + case idle + case prepared(Preparation) + } + + private var generation: UInt64 = 0 + private var phase: Phase = .idle + + public init() {} + + public var isPending: Bool { + switch phase { + case .committed, .prepared: + true + case .idle: + false + } + } + + public var isCommitted: Bool { + if case .committed = phase { + return true + } + return false + } + + public var servicePhase: ServiceReplacementPhase { + switch phase { + case .committed: + .committed + case .idle: + .inactive + case .prepared: + .prepared + } + } + + public mutating func prepare( + ticket: ServiceReplacementTicket + ) throws -> Preparation { + guard case .idle = phase else { + throw ReplacementTransactionError.alreadyInProgress + } + + generation &+= 1 + let preparation = Preparation(ticket: ticket, generation: generation) + phase = .prepared(preparation) + return preparation + } + + /// Moves the drain into its fail-closed phase. Repeating the same commit is idempotent. + public mutating func commit(ticket: ServiceReplacementTicket) throws { + switch phase { + case let .prepared(preparation) where preparation.ticket == ticket: + phase = .committed(ticket) + + case let .committed(activeTicket) where activeTicket == ticket: + return + + case .committed, .idle, .prepared: + throw ReplacementTransactionError.ticketInactive + } + } + + public func status(for ticket: ServiceReplacementTicket) -> ServiceReplacementStatus { + let statusPhase: ServiceReplacementPhase = switch phase { + case let .committed(activeTicket) where activeTicket == ticket: + .committed + case let .prepared(preparation) where preparation.ticket == ticket: + .prepared + case .committed, .idle, .prepared: + .inactive + } + return ServiceReplacementStatus(phase: statusPhase) + } + + /// Cancels only work that has not yet been committed to Service Management. + public mutating func cancel(ticket: ServiceReplacementTicket) throws { + switch phase { + case let .prepared(preparation) where preparation.ticket == ticket: + phase = .idle + + case .committed: + throw ReplacementTransactionError.committedCannotCancel + + case .idle, .prepared: + throw ReplacementTransactionError.ticketInactive + } + } + + /// Expires exactly the scheduled preparation. A stale timer cannot clear newer state. + @discardableResult + public mutating func expire( + preparation: Preparation + ) -> Bool { + guard case let .prepared(activePreparation) = phase, + activePreparation == preparation + else { + return false + } + + phase = .idle + return true + } +} + +public enum ReplacementTransactionError: Error, Equatable, LocalizedError { + case alreadyInProgress + case committedCannotCancel + case ticketInactive + + public var errorDescription: String? { + switch self { + case .alreadyInProgress: + "Another agent replacement is already in progress." + + case .committedCannotCancel: + "A committed agent replacement cannot be cancelled." + + case .ticketInactive: + "The replacement ticket is no longer active." + } + } +} diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift new file mode 100644 index 0000000..dd9d5f7 --- /dev/null +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -0,0 +1,255 @@ +import Client +import Foundation +import XCTest + +final class ServiceCompatibilityTests: XCTestCase { + func testBuildVersionsUseNumericComponentOrdering() throws { + let older = try XCTUnwrap(ServiceBuildVersion("1.9")) + let newer = try XCTUnwrap(ServiceBuildVersion("1.10")) + let equivalent = try XCTUnwrap(ServiceBuildVersion("01.010.0")) + + XCTAssertLessThan(older, newer) + XCTAssertEqual(newer, equivalent) + } + + func testBuildVersionRejectsNonNumericComponents() { + XCTAssertNil(ServiceBuildVersion("")) + XCTAssertNil(ServiceBuildVersion("1.beta")) + XCTAssertNil(ServiceBuildVersion("1..2")) + } + + func testMatchingDescriptorIsCompatible() throws { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.union([ + ServiceCapability(rawValue: "future-capability"), + ]) + ) + + XCTAssertTrue(descriptor.compatibilityIssues(against: requirements).isEmpty) + XCTAssertNoThrow(try descriptor.validateCompatibility(against: requirements)) + } + + func testNewerProtocolMinorIsCompatible() { + let requirements = makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion(major: 1, minor: 2) + ) + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 3) + ) + + XCTAssertTrue(descriptor.compatibilityIssues(against: requirements).isEmpty) + } + + func testDifferentProtocolMajorIsIncompatible() { + let requirements = makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion(major: 2, minor: 0) + ) + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 99) + ) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.protocolMajorMismatch(expected: 2, actual: 1)] + ) + } + + func testOlderProtocolMinorIsIncompatible() { + let requirements = makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion(major: 1, minor: 2) + ) + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 1) + ) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.protocolMinorTooOld(required: 2, actual: 1)] + ) + } + + func testMissingCapabilitiesAreReportedInStableOrder() { + let requirements = makeRequirements(requiredCapabilities: [ + .currentSettings, + .lockControl, + .prepareForReplacement, + ]) + let descriptor = makeDescriptor(capabilities: [.lockControl]) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.missingCapabilities([.currentSettings, .prepareForReplacement])] + ) + } + + func testCurrentContractRequiresCommittedReplacementDrain() { + XCTAssertTrue( + ServiceContract.requiredCapabilities.contains(.committedReplacementDrain) + ) + XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 1) + } + + func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .committedReplacementDrain, + ]) + ) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.missingCapabilities([.committedReplacementDrain])] + ) + } + + func testBuildMismatchRequiresReplacement() { + let requirements = makeRequirements(agentBuild: "42") + let descriptor = makeDescriptor(agentBuild: "41") + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.agentBuildMismatch(expected: "42", actual: "41")] + ) + } + + func testDescriptorRoundTripPreservesUnknownCapabilities() throws { + let descriptor = makeDescriptor(capabilities: [ + .lockControl, + ServiceCapability(rawValue: "capability-added-by-a-newer-agent"), + ], replacementPhase: .prepared) + + let decoded = try ServiceDescriptor.decodedFromXPC(descriptor.encodedForXPC()) + + XCTAssertEqual(decoded, descriptor) + } + + func testDescriptorDecodesLegacyPayloadWithoutAdditiveField() throws { + let instanceID = UUID() + let payload = """ + { + "protocolVersion": {"major": 1, "minor": 0}, + "capabilities": ["lock-control"], + "agentBundleIdentifier": "\(SharedConstants.agentBundleIdentifier)", + "agentVersion": "1.0", + "agentBuild": "1", + "agentInstanceID": "\(instanceID.uuidString)" + } + """.data(using: .utf8) + + let descriptor = try ServiceDescriptor.decodedFromXPC(payload) + + XCTAssertEqual(descriptor.agentInstanceID, instanceID) + XCTAssertFalse(descriptor.replacementPending) + XCTAssertEqual(descriptor.replacementPhase, .inactive) + } + + func testDescriptorIgnoresFutureFields() throws { + let descriptor = makeDescriptor() + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] + ) + object["fieldAddedByFutureMinorVersion"] = ["value": 42] + let futurePayload = try JSONSerialization.data(withJSONObject: object) + + XCTAssertEqual( + try ServiceDescriptor.decodedFromXPC(futurePayload), + descriptor + ) + } + + func testDescriptorMapsPendingLegacyPayloadToUnknownPhase() throws { + let descriptor = makeDescriptor() + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] + ) + object["replacementPending"] = true + object.removeValue(forKey: "replacementPhase") + let legacyPayload = try JSONSerialization.data(withJSONObject: object) + + let decoded = try ServiceDescriptor.decodedFromXPC(legacyPayload) + + XCTAssertTrue(decoded.replacementPending) + XCTAssertEqual(decoded.replacementPhase, .unknown) + } + + func testDescriptorPreservesFutureReplacementPhase() throws { + let futurePhase = ServiceReplacementPhase(rawValue: "future-phase") + let descriptor = makeDescriptor(replacementPhase: futurePhase) + + let decoded = try ServiceDescriptor.decodedFromXPC(descriptor.encodedForXPC()) + + XCTAssertEqual(decoded.replacementPhase, futurePhase) + XCTAssertTrue(decoded.replacementPending) + } + + func testDescriptorRejectsMissingRequiredBootstrapField() throws { + let descriptor = makeDescriptor() + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] + ) + object.removeValue(forKey: "protocolVersion") + let invalidPayload = try JSONSerialization.data(withJSONObject: object) + + XCTAssertThrowsError(try ServiceDescriptor.decodedFromXPC(invalidPayload)) + } + + func testReplacementTicketRoundTrip() throws { + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: UUID() + ) + + XCTAssertEqual( + try ServiceReplacementTicket.decodedFromXPC(ticket.encodedForXPC()), + ticket + ) + } + + func testReplacementStatusRoundTrip() throws { + let status = ServiceReplacementStatus(phase: .committed) + + XCTAssertEqual( + try ServiceReplacementStatus.decodedFromXPC(status.encodedForXPC()), + status + ) + } + + private func makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion = ServiceContract.protocolVersion, + requiredCapabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String = SharedConstants.agentBundleIdentifier, + agentVersion: String = "1.0", + agentBuild: String = "1" + ) -> ServiceCompatibilityRequirements { + ServiceCompatibilityRequirements( + minimumProtocolVersion: minimumProtocolVersion, + requiredCapabilities: requiredCapabilities, + agentBundleIdentifier: agentBundleIdentifier, + agentVersion: agentVersion, + agentBuild: agentBuild + ) + } + + private func makeDescriptor( + protocolVersion: ServiceProtocolVersion = ServiceContract.protocolVersion, + capabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String = SharedConstants.agentBundleIdentifier, + agentVersion: String = "1.0", + agentBuild: String = "1", + agentInstanceID: UUID = UUID(), + replacementPhase: ServiceReplacementPhase = .inactive + ) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: protocolVersion, + capabilities: capabilities, + agentBundleIdentifier: agentBundleIdentifier, + agentVersion: agentVersion, + agentBuild: agentBuild, + agentInstanceID: agentInstanceID, + replacementPending: replacementPhase != .inactive, + replacementPhase: replacementPhase + ) + } +} diff --git a/Core/Tests/ServiceTests/ReplacementTransactionTests.swift b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift new file mode 100644 index 0000000..ec7ee80 --- /dev/null +++ b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift @@ -0,0 +1,86 @@ +import Common +import Service +import XCTest + +final class ReplacementTransactionTests: XCTestCase { + func testPrepareIsExclusive() throws { + var transaction = ReplacementTransaction() + let first = makeTicket() + + _ = try transaction.prepare(ticket: first) + + XCTAssertTrue(transaction.isPending) + XCTAssertThrowsError(try transaction.prepare(ticket: makeTicket())) { error in + XCTAssertEqual(error as? ReplacementTransactionError, .alreadyInProgress) + } + } + + func testPreparedTransactionCanBeCancelledOnlyByItsOwner() throws { + var transaction = ReplacementTransaction() + let owner = makeTicket() + _ = try transaction.prepare(ticket: owner) + + XCTAssertThrowsError(try transaction.cancel(ticket: makeTicket())) { error in + XCTAssertEqual(error as? ReplacementTransactionError, .ticketInactive) + } + + try transaction.cancel(ticket: owner) + XCTAssertFalse(transaction.isPending) + } + + func testPreparationExpires() throws { + var transaction = ReplacementTransaction() + let preparation = try transaction.prepare(ticket: makeTicket()) + + XCTAssertEqual(transaction.status(for: preparation.ticket).phase, .prepared) + XCTAssertEqual(transaction.status(for: makeTicket()).phase, .inactive) + XCTAssertTrue(transaction.expire(preparation: preparation)) + XCTAssertEqual(transaction.status(for: preparation.ticket).phase, .inactive) + XCTAssertFalse(transaction.isPending) + } + + func testStaleExpirationCannotClearNewPreparation() throws { + var transaction = ReplacementTransaction() + let first = try transaction.prepare(ticket: makeTicket()) + try transaction.cancel(ticket: first.ticket) + let second = try transaction.prepare(ticket: makeTicket()) + + XCTAssertFalse(transaction.expire(preparation: first)) + XCTAssertTrue(transaction.isPending) + XCTAssertTrue(transaction.expire(preparation: second)) + } + + func testCommitIsIdempotentAndNonExpiring() throws { + var transaction = ReplacementTransaction() + let preparation = try transaction.prepare(ticket: makeTicket()) + + try transaction.commit(ticket: preparation.ticket) + try transaction.commit(ticket: preparation.ticket) + + XCTAssertTrue(transaction.isPending) + XCTAssertTrue(transaction.isCommitted) + XCTAssertEqual( + transaction.status(for: preparation.ticket).phase, + .committed + ) + XCTAssertFalse(transaction.expire(preparation: preparation)) + XCTAssertThrowsError(try transaction.cancel(ticket: preparation.ticket)) { error in + XCTAssertEqual(error as? ReplacementTransactionError, .committedCannotCancel) + } + XCTAssertTrue(transaction.isPending) + } + + func testWrongTicketCannotCommit() throws { + var transaction = ReplacementTransaction() + _ = try transaction.prepare(ticket: makeTicket()) + + XCTAssertThrowsError(try transaction.commit(ticket: makeTicket())) { error in + XCTAssertEqual(error as? ReplacementTransactionError, .ticketInactive) + } + XCTAssertFalse(transaction.isCommitted) + } + + private func makeTicket() -> ServiceReplacementTicket { + ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + } +} diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index 68f4758..025f10c 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -5,21 +5,66 @@ import Service /// XPC service implementation. Owns the settings source of truth and drives the single /// global `LockEngine`. All wrappers reach the lock exclusively through this object. final class AgentService: NSObject, KeyboardLockerServiceProtocol { + private static let replacementPreparationDuration: TimeInterval = 30 + + private let descriptorResult: Result private let settings: KeyboardLockerSettings + private var replacement = ReplacementTransaction() + private var replacementPreparationExpiration: DispatchWorkItem? override init() { let loaded = KeyboardLockerSettingsStore().load() + descriptorResult = Result { + try Self.makeServiceDescriptor() + } settings = loaded super.init() // Seed the engine so a lock uses persisted values. LockEngine.shared.updateSettings(loaded) } + // MARK: - Bootstrap + + func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) { + executeOnMainThread { + switch self.descriptorResult { + case let .success(base): + let descriptor = ServiceDescriptor( + protocolVersion: base.protocolVersion, + capabilities: base.capabilities, + agentBundleIdentifier: base.agentBundleIdentifier, + agentVersion: base.agentVersion, + agentBuild: base.agentBuild, + agentInstanceID: base.agentInstanceID, + replacementPending: self.replacement.isPending, + replacementPhase: self.replacement.servicePhase + ) + do { + let data = try descriptor.encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + + case let .failure(error): + reply(nil, error) + } + } + } + // MARK: - Locking func lockKeyboard(reply: @escaping (Error?) -> Void) { let current = settings executeOnMainThread { + guard !self.replacement.isPending else { + reply(Self.replacementError( + code: 1, + description: "The agent is preparing to be replaced and is not accepting new lock requests." + )) + return + } + do { try LockEngine.shared.lock(settings: current) reply(nil) @@ -42,6 +87,150 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID, + reply: @escaping (Data?, Error?) -> Void + ) { + executeOnMainThread { + guard case let .success(descriptor) = self.descriptorResult else { + reply(nil, Self.replacementError( + code: 2, + description: "The agent could not create a replacement ticket." + )) + return + } + + guard descriptor.agentInstanceID == expectedAgentInstanceID else { + reply(nil, Self.replacementError( + code: 8, + description: "The running agent changed before replacement could be prepared." + )) + return + } + + guard unlockIfNeeded || !LockEngine.shared.isLocked else { + reply(nil, Self.replacementError( + code: 3, + description: "The keyboard must be unlocked before the agent can be replaced." + )) + return + } + + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let data: Data + do { + data = try ticket.encodedForXPC() + } catch { + reply(nil, error) + return + } + + let preparation: ReplacementTransaction.Preparation + do { + preparation = try self.replacement.prepare(ticket: ticket) + } catch { + reply(nil, Self.replacementError(error)) + return + } + + // Install the barrier before unlocking so no queued client can re-lock in between. + self.scheduleExpiration(for: preparation) + if unlockIfNeeded { + LockEngine.shared.unlock() + } + reply(data, nil) + } + } + + func commitReplacement( + ticket data: Data, + reply: @escaping (Error?) -> Void + ) { + let candidate: ServiceReplacementTicket + do { + candidate = try ServiceReplacementTicket.decodedFromXPC(data) + } catch { + reply(error) + return + } + + executeOnMainThread { + do { + try self.replacement.commit(ticket: candidate) + self.replacementPreparationExpiration?.cancel() + self.replacementPreparationExpiration = nil + reply(nil) + } catch { + reply(Self.replacementError(error)) + } + } + } + + func replacementStatus( + ticket data: Data, + reply: @escaping (Data?, Error?) -> Void + ) { + let candidate: ServiceReplacementTicket + do { + candidate = try ServiceReplacementTicket.decodedFromXPC(data) + } catch { + reply(nil, error) + return + } + + executeOnMainThread { + do { + let data = try self.replacement.status(for: candidate).encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + } + } + + func cancelReplacementPreparation( + ticket data: Data, + reply: @escaping (Error?) -> Void + ) { + let candidate: ServiceReplacementTicket + do { + candidate = try ServiceReplacementTicket.decodedFromXPC(data) + } catch { + reply(error) + return + } + + executeOnMainThread { + do { + try self.replacement.cancel(ticket: candidate) + self.replacementPreparationExpiration?.cancel() + self.replacementPreparationExpiration = nil + reply(nil) + } catch { + reply(Self.replacementError(error)) + } + } + } + + // MARK: - Accessibility + + func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) { + executeOnMainThread { + reply(AccessibilityManager.hasPermission()) + } + } + + func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) { + executeOnMainThread { + AccessibilityManager.requestPermission() + reply(nil) + } + } + // MARK: - Settings func currentSettings(reply: @escaping (Data?) -> Void) { @@ -50,6 +239,73 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Helpers + private static func makeServiceDescriptor(bundle: Bundle = .main) throws -> ServiceDescriptor { + guard let bundleIdentifier = bundle.bundleIdentifier, !bundleIdentifier.isEmpty else { + throw AgentDescriptorError.missingMetadata("CFBundleIdentifier") + } + guard let version = bundle.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String, !version.isEmpty else { + throw AgentDescriptorError.missingMetadata("CFBundleShortVersionString") + } + guard let build = bundle.object( + forInfoDictionaryKey: "CFBundleVersion" + ) as? String, !build.isEmpty else { + throw AgentDescriptorError.missingMetadata("CFBundleVersion") + } + + return ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: bundleIdentifier, + agentVersion: version, + agentBuild: build, + agentInstanceID: UUID() + ) + } + + private static func replacementError(code: Int, description: String) -> NSError { + NSError( + domain: "\(SharedConstants.agentBundleIdentifier).replacement", + code: code, + userInfo: [NSLocalizedDescriptionKey: description] + ) + } + + private static func replacementError(_ error: Error) -> NSError { + let code = switch error as? ReplacementTransactionError { + case .alreadyInProgress: + 5 + case .committedCannotCancel: + 6 + case .ticketInactive: + 4 + case nil: + 7 + } + return replacementError(code: code, description: error.localizedDescription) + } + + private func scheduleExpiration( + for preparation: ReplacementTransaction.Preparation + ) { + replacementPreparationExpiration?.cancel() + + let expiration = DispatchWorkItem { [weak self] in + guard let self else { + return + } + if replacement.expire(preparation: preparation) { + replacementPreparationExpiration = nil + } + } + replacementPreparationExpiration = expiration + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.replacementPreparationDuration, + execute: expiration + ) + } + private func executeOnMainThread(_ operation: @escaping () -> Void) { if Thread.isMainThread { operation() @@ -58,3 +314,14 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } } + +private enum AgentDescriptorError: Error, LocalizedError { + case missingMetadata(String) + + var errorDescription: String? { + switch self { + case let .missingMetadata(key): + "The Agent bundle is missing required metadata: \(key)." + } + } +} From 5f90457c31be98e268653517f0c2a412ff60a5c6 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 20:17:17 +0800 Subject: [PATCH 024/106] fix(xpc): enforce mutual peer authentication Install same-team, exact-identifier requirements on both XPC endpoints. Embed the namespaced signing identifier in klock and cover requirement construction with unit tests. --- Core/Package.swift | 1 + Core/Sources/Client/XPCClient.swift | 26 +++- Core/Sources/Common/Shared.swift | 3 +- .../Common/XPCCodeSigningRequirement.swift | 141 ++++++++++++++++++ Core/Sources/Service/XPCAccessControl.swift | 99 +----------- .../Sources/Service/XPCServerConnection.swift | 2 +- .../XPCCodeSigningRequirementTests.swift | 78 ++++++++++ KeyboardLocker.xcodeproj/project.pbxproj | 4 + KeyboardLockerAgent/main.swift | 16 +- 9 files changed, 263 insertions(+), 107 deletions(-) create mode 100644 Core/Sources/Common/XPCCodeSigningRequirement.swift create mode 100644 Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift diff --git a/Core/Package.swift b/Core/Package.swift index 5a8f44d..4592a81 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -16,6 +16,7 @@ let package = Package( .target(name: "Common"), .target(name: "Client", dependencies: ["Common"]), .target(name: "Service", dependencies: ["Common"]), + .testTarget(name: "CommonTests", dependencies: ["Common"]), .testTarget(name: "ClientTests", dependencies: ["Client"]), .testTarget(name: "ServiceTests", dependencies: ["Service"]), ] diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index ef483f3..d126c3d 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -6,6 +6,7 @@ public enum XPCClientError: Error, LocalizedError { case agentChanged(expected: UUID, actual: UUID) case missingCapability(ServiceCapability) case operationOutcomeUnknown + case peerAuthenticationUnavailable(String) case serviceUnavailable case timedOut @@ -23,6 +24,9 @@ public enum XPCClientError: Error, LocalizedError { case .operationOutcomeUnknown: "The KeyboardLocker agent did not confirm the operation. Its final outcome is unknown." + case let .peerAuthenticationUnavailable(message): + "XPC peer authentication could not be configured. \(message)" + case .serviceUnavailable: "The KeyboardLocker agent is not reachable." @@ -43,6 +47,12 @@ public enum XPCClientError: Error, LocalizedError { public final class XPCClient: @unchecked Sendable { public static let shared = XPCClient() + private static let agentCodeSigningRequirement = Result { + try XPCCodeSigningRequirement.sameTeam( + identifiers: [SharedConstants.agentBundleIdentifier] + ) + } + private static let responseTimeout: Duration = .seconds(5) private let lock = OSAllocatedUnfairLock() @@ -252,7 +262,7 @@ public final class XPCClient: @unchecked Sendable { requiring capabilities: Set, expectedAgentInstanceID: UUID? = nil ) async throws -> NSXPCConnection { - let connection = currentConnection() + let connection = try currentConnection() let descriptor = try await serviceDescriptor(using: connection) if let expectedAgentInstanceID, @@ -273,7 +283,14 @@ public final class XPCClient: @unchecked Sendable { return connection } - private func currentConnection() -> NSXPCConnection { + private func currentConnection() throws -> NSXPCConnection { + let codeSigningRequirement: String + do { + codeSigningRequirement = try Self.agentCodeSigningRequirement.get() + } catch { + throw XPCClientError.peerAuthenticationUnavailable(error.localizedDescription) + } + lock.lock() defer { lock.unlock() } @@ -283,6 +300,7 @@ public final class XPCClient: @unchecked Sendable { let connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) + connection.setCodeSigningRequirement(codeSigningRequirement) // Drop the cached connection on teardown so the next call transparently reconnects. let connectionID = ObjectIdentifier(connection) @@ -292,7 +310,7 @@ public final class XPCClient: @unchecked Sendable { connection.invalidationHandler = clear connection.interruptionHandler = clear - connection.resume() + connection.activate() self.connection = connection return connection } @@ -333,7 +351,7 @@ public final class XPCClient: @unchecked Sendable { let connection = if let providedConnection { providedConnection } else { - currentConnection() + try currentConnection() } return try await withCheckedThrowingContinuation { continuation in diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index b69b318..dbdfe53 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -12,11 +12,10 @@ public enum SharedConstants { /// Default unlock key code for 'L' key (⌃⌘L) public static let defaultUnlockKeyCode: UInt16 = 37 - /// Bundle identifiers allowed to talk to the agent's Mach service + /// Exact code-signing identifiers allowed to talk to the Agent's Mach service. public static let authorizedClientBundleIdentifiers: Set = [ "io.lzhlovesjyq.keyboardlocker", "io.lzhlovesjyq.keyboardlocker.klock", - "klock", // CLI tool codesign identifier (no Info.plist = short name only) ] } diff --git a/Core/Sources/Common/XPCCodeSigningRequirement.swift b/Core/Sources/Common/XPCCodeSigningRequirement.swift new file mode 100644 index 0000000..b8845e6 --- /dev/null +++ b/Core/Sources/Common/XPCCodeSigningRequirement.swift @@ -0,0 +1,141 @@ +import Foundation +import Security + +/// Builds OS-enforced XPC peer requirements from the current process's signing identity. +/// +/// Every KeyboardLocker executable derives the Team ID from its own validated signature. This +/// keeps signing configuration as the source of truth while still requiring an exact peer +/// identifier. Unsigned and ad-hoc processes fail closed because they have no Apple Team ID. +public enum XPCCodeSigningRequirement { + public static func sameTeam( + identifiers: Set + ) throws -> String { + try make( + teamIdentifier: currentProcessTeamIdentifier(), + identifiers: identifiers + ) + } + + static func make( + teamIdentifier: String, + identifiers: Set + ) throws -> String { + guard teamIdentifier.utf8.count == 10, + teamIdentifier.utf8.allSatisfy(isUppercaseLetterOrDigit) + else { + throw XPCCodeSigningRequirementError.invalidTeamIdentifier + } + guard !identifiers.isEmpty else { + throw XPCCodeSigningRequirementError.missingSigningIdentifier + } + + let identifierClauses = try identifiers.sorted().map { identifier in + guard !identifier.isEmpty, + identifier.utf8.allSatisfy(Self.isIdentifierCharacter) + else { + throw XPCCodeSigningRequirementError.invalidSigningIdentifier(identifier) + } + return #"identifier "\#(identifier)""# + } + + let requirement = """ + anchor apple generic and certificate leaf[subject.OU] = "\(teamIdentifier)" and \ + (\(identifierClauses.joined(separator: " or "))) + """ + + var compiledRequirement: SecRequirement? + let status = SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ) + guard status == errSecSuccess, compiledRequirement != nil else { + throw XPCCodeSigningRequirementError.invalidRequirement(status) + } + + return requirement + } + + private static func currentProcessTeamIdentifier() throws -> String { + var code: SecCode? + let copyStatus = SecCodeCopySelf(SecCSFlags(), &code) + guard copyStatus == errSecSuccess, let code else { + throw XPCCodeSigningRequirementError.currentProcessSignatureUnavailable(copyStatus) + } + + let validityStatus = SecCodeCheckValidity(code, SecCSFlags(), nil) + guard validityStatus == errSecSuccess else { + throw XPCCodeSigningRequirementError.currentProcessSignatureInvalid(validityStatus) + } + + var staticCode: SecStaticCode? + let staticCodeStatus = SecCodeCopyStaticCode( + code, + SecCSFlags(), + &staticCode + ) + guard staticCodeStatus == errSecSuccess, let staticCode else { + throw XPCCodeSigningRequirementError.currentProcessSignatureUnavailable(staticCodeStatus) + } + + var information: CFDictionary? + let informationStatus = SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) + guard informationStatus == errSecSuccess, + let dictionary = information as? [CFString: Any], + let teamIdentifier = dictionary[kSecCodeInfoTeamIdentifier] as? String + else { + throw XPCCodeSigningRequirementError.missingTeamIdentifier + } + return teamIdentifier + } + + private static func isUppercaseLetterOrDigit(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) + } + + private static func isIdentifierCharacter(_ byte: UInt8) -> Bool { + isUppercaseLetterOrDigit(byte) + || (byte >= 97 && byte <= 122) + || byte == 45 + || byte == 46 + } +} + +enum XPCCodeSigningRequirementError: Error, LocalizedError { + case currentProcessSignatureInvalid(OSStatus) + case currentProcessSignatureUnavailable(OSStatus) + case invalidRequirement(OSStatus) + case invalidSigningIdentifier(String) + case invalidTeamIdentifier + case missingSigningIdentifier + case missingTeamIdentifier + + var errorDescription: String? { + switch self { + case let .currentProcessSignatureInvalid(status): + "The current process has an invalid code signature (OSStatus \(status))." + + case let .currentProcessSignatureUnavailable(status): + "The current process code signature is unavailable (OSStatus \(status))." + + case let .invalidRequirement(status): + "The XPC code-signing requirement is invalid (OSStatus \(status))." + + case let .invalidSigningIdentifier(identifier): + "The XPC signing identifier is invalid: \(identifier)." + + case .invalidTeamIdentifier: + "The current process has an invalid Apple Team ID." + + case .missingSigningIdentifier: + "The XPC code-signing requirement has no allowed identifier." + + case .missingTeamIdentifier: + "The current process is not signed with an Apple Team ID." + } + } +} diff --git a/Core/Sources/Service/XPCAccessControl.swift b/Core/Sources/Service/XPCAccessControl.swift index 15e24af..bc2494f 100644 --- a/Core/Sources/Service/XPCAccessControl.swift +++ b/Core/Sources/Service/XPCAccessControl.swift @@ -1,100 +1,11 @@ import Common import Foundation -import Security -/// Validates XPC connections by verifying code signature and bundle identifier. -/// -/// - **Release**: Full verification (signature + Agent Team ID + bundle ID allowlist) -/// - **Debug**: Relaxed verification (bundle ID allowlist only) +/// Creates the OS-enforced requirement for clients of the Agent's Mach service. public enum XPCAccessControl { - #if !DEBUG - /// Reads the Agent's signing Team ID from the running process so code-signing configuration - /// remains the single source of truth. - private static let agentTeamID: String? = { - guard let staticCode = staticCodeForCurrentProcess(), - SecStaticCodeCheckValidity(staticCode, SecCSFlags(), nil) == errSecSuccess, - let info = signingInfo(for: staticCode) - else { - return nil - } - return teamIdentifier(from: info) - }() - #endif - - /// Returns true if the connection passes security checks. - public static func isConnectionAuthorized(_ connection: NSXPCConnection) -> Bool { - guard let staticCode = staticCode(for: connection) else { - return false - } - - #if !DEBUG - // Release: Validate signature integrity - guard SecStaticCodeCheckValidity(staticCode, SecCSFlags(), nil) == errSecSuccess else { - return false - } - #endif - - guard let info = signingInfo(for: staticCode), - let bundleID = info[kSecCodeInfoIdentifier] as? String, - SharedConstants.authorizedClientBundleIdentifiers.contains(bundleID) - else { - return false - } - - #if !DEBUG - // Release: Require the client to be signed by the same team as the Agent. - guard let agentTeamID = Self.agentTeamID, - teamIdentifier(from: info) == agentTeamID - else { - return false - } - #endif - - return true - } - - // MARK: - Private Helpers - - private static func staticCode(for connection: NSXPCConnection) -> SecStaticCode? { - let attributes: [CFString: Any] = [kSecGuestAttributePid: connection.processIdentifier] - - var code: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, attributes as CFDictionary, SecCSFlags(), &code) == errSecSuccess, - let code - else { - return nil - } - - return staticCode(for: code) - } - - private static func staticCodeForCurrentProcess() -> SecStaticCode? { - var code: SecCode? - guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, let code else { - return nil - } - - return staticCode(for: code) - } - - private static func staticCode(for code: SecCode) -> SecStaticCode? { - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess else { - return nil - } - return staticCode - } - - private static func signingInfo(for staticCode: SecStaticCode) -> [CFString: Any]? { - var info: CFDictionary? - let flags = SecCSFlags(rawValue: kSecCSSigningInformation) - guard SecCodeCopySigningInformation(staticCode, flags, &info) == errSecSuccess else { - return nil - } - return info as? [CFString: Any] - } - - private static func teamIdentifier(from info: [CFString: Any]) -> String? { - info[kSecCodeInfoTeamIdentifier] as? String + public static func authorizedClientRequirement() throws -> String { + try XPCCodeSigningRequirement.sameTeam( + identifiers: SharedConstants.authorizedClientBundleIdentifiers + ) } } diff --git a/Core/Sources/Service/XPCServerConnection.swift b/Core/Sources/Service/XPCServerConnection.swift index 1f30021..7fc9e79 100644 --- a/Core/Sources/Service/XPCServerConnection.swift +++ b/Core/Sources/Service/XPCServerConnection.swift @@ -11,6 +11,6 @@ public enum XPCServerConnection { ) { connection.exportedInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) connection.exportedObject = exportedService - connection.resume() + connection.activate() } } diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift new file mode 100644 index 0000000..f5406c2 --- /dev/null +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift @@ -0,0 +1,78 @@ +@testable import Common +import Security +import XCTest + +final class XPCCodeSigningRequirementTests: XCTestCase { + func testAuthorizedClientIdentifiersAreExactAndNamespaced() { + XCTAssertEqual( + SharedConstants.authorizedClientBundleIdentifiers, + [ + "io.lzhlovesjyq.keyboardlocker", + "io.lzhlovesjyq.keyboardlocker.klock", + ] + ) + XCTAssertEqual( + SharedConstants.agentBundleIdentifier, + "io.lzhlovesjyq.keyboardlocker.agent" + ) + } + + func testRequirementUsesExactTeamAndIdentifiersInStableOrder() throws { + let requirement = try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: [ + "io.example.tool", + "io.example.app", + ] + ) + + XCTAssertEqual( + requirement, + """ + anchor apple generic and certificate leaf[subject.OU] = "A1B2C3D4E5" and \ + (identifier "io.example.app" or identifier "io.example.tool") + """ + ) + + var compiledRequirement: SecRequirement? + XCTAssertEqual( + SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ), + errSecSuccess + ) + XCTAssertNotNil(compiledRequirement) + } + + func testRequirementRejectsInvalidTeamIdentifiers() { + XCTAssertThrowsError( + try XPCCodeSigningRequirement.make( + teamIdentifier: "too-short", + identifiers: ["io.example.app"] + ) + ) + XCTAssertThrowsError( + try XPCCodeSigningRequirement.make( + teamIdentifier: "a1b2c3d4e5", + identifiers: ["io.example.app"] + ) + ) + } + + func testRequirementRejectsMissingOrUnsafeSigningIdentifiers() { + XCTAssertThrowsError( + try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: [] + ) + ) + XCTAssertThrowsError( + try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: [#"io.example."injected"#] + ) + ) + } +} diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index 573a32b..e1296cb 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -622,9 +622,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -639,9 +641,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/KeyboardLockerAgent/main.swift b/KeyboardLockerAgent/main.swift index 00983ea..cb2e899 100644 --- a/KeyboardLockerAgent/main.swift +++ b/KeyboardLockerAgent/main.swift @@ -11,11 +11,7 @@ private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { _: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection ) -> Bool { - guard XPCAccessControl.isConnectionAuthorized(newConnection) else { - print("KeyboardLockerAgent: Rejected unauthorized connection") - return false - } - + // The listener's code-signing requirement is evaluated before this delegate is called. XPCServerConnection.configure(newConnection, exportedService: sharedService) return true } @@ -24,9 +20,17 @@ private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { @MainActor private func startAgent() { let listener = NSXPCListener(machServiceName: SharedConstants.machServiceName) + do { + try listener.setConnectionCodeSigningRequirement( + XPCAccessControl.authorizedClientRequirement() + ) + } catch { + fatalError("KeyboardLockerAgent could not configure XPC peer authentication: \(error)") + } + let delegate = ServiceDelegate() listener.delegate = delegate - listener.resume() + listener.activate() print("KeyboardLockerAgent started, listening on \(SharedConstants.machServiceName)") RunLoop.main.run() From b3fd4d233085e182f202c4125183c9517aa8c070 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 20:17:37 +0800 Subject: [PATCH 025/106] feat(app): coordinate agent readiness and updates Model registration, compatibility, Accessibility, and replacement states explicitly in the wrapper UI. Extract readiness and replacement orchestration behind injectable adapters while keeping LockController as the presentation facade. --- .../AgentCoordinationServices.swift | 142 ++++ .../AgentReadinessCoordinator.swift | 216 ++++++ KeyboardLocker/AgentRegistrar.swift | 223 +++++- .../AgentReplacementCoordinator.swift | 161 +++++ KeyboardLocker/ContentView.swift | 8 +- KeyboardLocker/KeyboardLockerApp.swift | 7 +- KeyboardLocker/LockController.swift | 643 +++++++++++++++++- KeyboardLocker/Views/MainView.swift | 333 ++++++++- 8 files changed, 1687 insertions(+), 46 deletions(-) create mode 100644 KeyboardLocker/AgentCoordinationServices.swift create mode 100644 KeyboardLocker/AgentReadinessCoordinator.swift create mode 100644 KeyboardLocker/AgentReplacementCoordinator.swift diff --git a/KeyboardLocker/AgentCoordinationServices.swift b/KeyboardLocker/AgentCoordinationServices.swift new file mode 100644 index 0000000..289d385 --- /dev/null +++ b/KeyboardLocker/AgentCoordinationServices.swift @@ -0,0 +1,142 @@ +import Client +import Foundation + +@MainActor +protocol AgentControlServing { + func lock() async throws + func unlock() async throws + func requestAccessibilityPermission() async throws + func resetConnection() +} + +@MainActor +protocol AgentReadinessServing { + func serviceDescriptor() async throws -> ServiceDescriptor + func status() async throws -> Bool + func hasAccessibilityPermission() async throws -> Bool + func resetConnection() +} + +@MainActor +protocol AgentReplacementServing { + func serviceDescriptor() async throws -> ServiceDescriptor + func unlock() async throws + func status() async throws -> Bool + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket + func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws + func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws + func resetConnection() +} + +/// Complete live Client surface retained by `LockController`. +/// +/// Coordinators depend on the narrower protocols above, so future fakes only implement the +/// external capability under test instead of the entire App Client. +@MainActor +protocol AgentClientServing: + AgentControlServing, + AgentReadinessServing, + AgentReplacementServing {} + +@MainActor +struct LiveAgentClient: AgentClientServing { + func serviceDescriptor() async throws -> ServiceDescriptor { + try await XPCClient.shared.serviceDescriptor() + } + + func lock() async throws { + try await XPCClient.shared.lock() + } + + func unlock() async throws { + try await XPCClient.shared.unlock() + } + + func status() async throws -> Bool { + try await XPCClient.shared.status() + } + + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + try await XPCClient.shared.prepareForReplacement( + unlockIfNeeded: unlockIfNeeded, + expectedAgentInstanceID: expectedAgentInstanceID + ) + } + + func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws { + try await XPCClient.shared.cancelReplacementPreparation(ticket: ticket) + } + + func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws { + try await XPCClient.shared.commitReplacement(ticket: ticket) + } + + func hasAccessibilityPermission() async throws -> Bool { + try await XPCClient.shared.hasAccessibilityPermission() + } + + func requestAccessibilityPermission() async throws { + try await XPCClient.shared.requestAccessibilityPermission() + } + + func resetConnection() { + XPCClient.shared.resetConnection() + } +} + +@MainActor +protocol AgentReadinessLifecycleServing { + func ensureEnabled() -> AgentRegistrar.State + func compatibility( + of descriptor: ServiceDescriptor + ) -> AgentRegistrar.Compatibility +} + +@MainActor +protocol AgentReplacementLifecycleServing { + func restart() async -> AgentRegistrar.State +} + +/// Complete lifecycle surface retained by `LockController`. +@MainActor +protocol AgentLifecycleServing: + AgentReadinessLifecycleServing, + AgentReplacementLifecycleServing +{ + func openApprovalSettings() +} + +@MainActor +struct LiveAgentLifecycle: AgentLifecycleServing { + func ensureEnabled() -> AgentRegistrar.State { + AgentRegistrar.ensureEnabled() + } + + func compatibility( + of descriptor: ServiceDescriptor + ) -> AgentRegistrar.Compatibility { + AgentRegistrar.compatibility(of: descriptor) + } + + func restart() async -> AgentRegistrar.State { + await AgentRegistrar.restart() + } + + func openApprovalSettings() { + AgentRegistrar.openApprovalSettings() + } +} diff --git a/KeyboardLocker/AgentReadinessCoordinator.swift b/KeyboardLocker/AgentReadinessCoordinator.swift new file mode 100644 index 0000000..112bbd3 --- /dev/null +++ b/KeyboardLocker/AgentReadinessCoordinator.swift @@ -0,0 +1,216 @@ +import Client +import Foundation + +/// Resolves Service Management, XPC contract, replacement, lock, and Accessibility facts. +/// +/// This type deliberately returns domain outcomes instead of mutating SwiftUI state. The +/// `LockController` remains the presentation façade and decides how each outcome is shown. +@MainActor +struct AgentReadinessCoordinator { + enum Outcome { + case agentDidNotRestart + case cancelled + case failure(Error, context: String?) + case invalidBundle(AgentRegistrar.Failure) + case ready(isLocked: Bool, hasAccessibilityPermission: Bool) + case registration(AgentRegistrar.State) + case replacementInProgress(ServiceDescriptor) + case updateAvailable( + descriptor: ServiceDescriptor, + message: String, + bundledBuild: String, + isLocked: Bool + ) + case updateRequired(AgentUpdatePlan) + } + + private let client: any AgentReadinessServing + private let lifecycle: any AgentReadinessLifecycleServing + + init( + client: any AgentReadinessServing, + lifecycle: any AgentReadinessLifecycleServing + ) { + self.client = client + self.lifecycle = lifecycle + } + + func inspect( + expectedPreviousAgentInstanceID: UUID? = nil + ) async -> Outcome { + let registrationState = lifecycle.ensureEnabled() + guard !Task.isCancelled else { + return .cancelled + } + guard case .enabled = registrationState else { + return .registration(registrationState) + } + + let descriptor: ServiceDescriptor + do { + descriptor = try await client.serviceDescriptor() + } catch let initialHandshakeError { + guard !Task.isCancelled else { + return .cancelled + } + + client.resetConnection() + do { + descriptor = try await client.serviceDescriptor() + } catch let retryHandshakeError { + return await inspectUnverifiedAgent( + initialError: initialHandshakeError, + retryError: retryHandshakeError + ) + } + } + + guard !Task.isCancelled else { + return .cancelled + } + if let expectedPreviousAgentInstanceID, + descriptor.agentInstanceID == expectedPreviousAgentInstanceID + { + return .agentDidNotRestart + } + + let compatibility = lifecycle.compatibility(of: descriptor) + if isTrustedReplacementInProgress( + descriptor: descriptor, + compatibility: compatibility + ) { + return .replacementInProgress(descriptor) + } + + switch compatibility { + case let .bundledAgentUpgradeAvailable(message, bundledBuild): + do { + let isLocked = try await client.status() + guard !Task.isCancelled else { + return .cancelled + } + return .updateAvailable( + descriptor: descriptor, + message: message, + bundledBuild: bundledBuild, + isLocked: isLocked + ) + } catch { + return .failure(error, context: nil) + } + + case .compatible: + do { + async let lockState = client.status() + async let permissionState = client.hasAccessibilityPermission() + let (isLocked, hasAccessibilityPermission) = try await ( + lockState, + permissionState + ) + guard !Task.isCancelled else { + return .cancelled + } + return .ready( + isLocked: isLocked, + hasAccessibilityPermission: hasAccessibilityPermission + ) + } catch { + return .failure(error, context: nil) + } + + case let .invalidBundle(failure): + return .invalidBundle(failure) + + case let .updateRequired(message, canReadLockState, supportsSafeReplacement): + guard canReadLockState else { + guard !supportsSafeReplacement else { + return .failure( + AgentReadinessError.safeReplacementRequiresReadableLockState, + context: nil + ) + } + return .updateRequired(AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: nil), + message: message + )) + } + + do { + let isLocked = try await client.status() + guard !Task.isCancelled else { + return .cancelled + } + let mode: AgentUpdatePlan.Mode = supportsSafeReplacement + ? .safe(descriptor: descriptor, isLocked: isLocked) + : .forced(descriptor: descriptor, isLocked: isLocked) + return .updateRequired(AgentUpdatePlan( + mode: mode, + message: message + )) + } catch { + return .failure(error, context: nil) + } + } + } + + private func inspectUnverifiedAgent( + initialError: Error, + retryError: Error + ) async -> Outcome { + client.resetConnection() + + do { + let isLocked = try await client.status() + guard !Task.isCancelled else { + return .cancelled + } + return .updateRequired(AgentUpdatePlan( + mode: .forced(descriptor: nil, isLocked: isLocked), + message: """ + The running background agent's XPC contract could not be verified after reconnecting. \ + It may use an earlier contract, or its descriptor handshake may be failing. Replacing \ + this unverified Agent cannot block another client from locking during the transition, \ + so a new lock created in that brief window may be released. + """ + )) + } catch { + return .failure( + error, + context: """ + Descriptor handshake failed before and after reconnecting: \ + \(initialError.localizedDescription) \(retryError.localizedDescription) + """ + ) + } + } + + private func isTrustedReplacementInProgress( + descriptor: ServiceDescriptor, + compatibility: AgentRegistrar.Compatibility + ) -> Bool { + guard descriptor.replacementPending, + descriptor.agentBundleIdentifier == SharedConstants.agentBundleIdentifier, + descriptor.protocolVersion.major == ServiceContract.protocolVersion.major, + descriptor.capabilities.contains(.prepareForReplacement), + descriptor.capabilities.contains(.committedReplacementDrain) + else { + return false + } + + if case .invalidBundle = compatibility { + return false + } + return true + } +} + +private enum AgentReadinessError: Error, LocalizedError { + case safeReplacementRequiresReadableLockState + + var errorDescription: String? { + switch self { + case .safeReplacementRequiresReadableLockState: + "Safe Agent replacement requires a readable authoritative lock state." + } + } +} diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index 6a24ab2..3541e8b 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -7,6 +7,43 @@ import ServiceManagement /// for any XPC client — even when this App is not running. This is the contract's /// "Agent Lifecycle Requirement": wrappers must not assume the Agent is already up. enum AgentRegistrar { + enum State: Equatable { + case enabled + case approvalRequired + case unavailable(Failure) + } + + enum Failure: Error, Equatable { + case invalidBundle(String) + case notFound + case registrationFailed(String) + case restartFailed(String) + + var message: String { + switch self { + case let .invalidBundle(message): + "The bundled KeyboardLocker agent is invalid. \(message)" + case .notFound: + "The bundled KeyboardLocker agent could not be found. Reinstall the complete application bundle." + case let .registrationFailed(message): + "The KeyboardLocker agent could not be registered. \(message)" + case let .restartFailed(message): + "The KeyboardLocker agent could not be restarted. \(message)" + } + } + } + + enum Compatibility: Equatable { + case bundledAgentUpgradeAvailable(message: String, bundledBuild: String) + case compatible + case invalidBundle(Failure) + case updateRequired( + message: String, + canReadLockState: Bool, + supportsSafeReplacement: Bool + ) + } + private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "AgentRegistrar") /// Filename of the launchd plist bundled at `Contents/Library/LaunchAgents/`. @@ -16,19 +53,197 @@ enum AgentRegistrar { SMAppService.agent(plistName: plistName) } - /// Ensures the Agent is registered. Idempotent; safe to call on every launch. - static func registerIfNeeded() { + /// Ensures the Agent is registered and returns the user-visible lifecycle state. + static func ensureEnabled() -> State { let service = agent switch service.status { case .enabled: - return - default: + return .enabled + + case .requiresApproval: + return .approvalRequired + + case .notFound: + return .unavailable(.notFound) + + case .notRegistered: do { try service.register() logger.info("Registered agent (status: \(String(describing: service.status), privacy: .public))") } catch { logger.error("Failed to register agent: \(error.localizedDescription, privacy: .public)") + return stateAfterRegistration(service.status, error: error) } + return stateAfterRegistration(service.status) + + @unknown default: + return .unavailable(.registrationFailed("The system returned an unsupported registration status.")) + } + } + + static func openApprovalSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + /// Compares the running Agent's self-reported contract with the Agent bundled in this App. + /// This is lifecycle compatibility only and must not be treated as peer authentication. + static func compatibility(of descriptor: ServiceDescriptor) -> Compatibility { + let requirements: ServiceCompatibilityRequirements + do { + requirements = try bundledCompatibilityRequirements() + } catch let failure as Failure { + return .invalidBundle(failure) + } catch { + return .invalidBundle(.invalidBundle(error.localizedDescription)) + } + + let issues = descriptor.compatibilityIssues(against: requirements) + guard !issues.isEmpty else { + return .compatible + } + + let details = issues.compactMap(\.errorDescription).joined(separator: " ") + let identifierMatches = descriptor.agentBundleIdentifier == requirements.agentBundleIdentifier + let sameProtocolMajor = descriptor.protocolVersion.major == requirements.minimumProtocolVersion.major + let canReadLockState = identifierMatches + && sameProtocolMajor + && descriptor.capabilities.contains(.lockControl) + let supportsSafeReplacement = canReadLockState + && descriptor.capabilities.contains(.prepareForReplacement) + && descriptor.capabilities.contains(.committedReplacementDrain) + + switch compareBuilds(running: descriptor.agentBuild, bundled: requirements.agentBuild) { + case .orderedAscending where supportsSafeReplacement: + return .bundledAgentUpgradeAvailable( + message: """ + The running background agent is older than the version bundled with this app. \(details) + """, + bundledBuild: requirements.agentBuild + ) + + case .orderedDescending: + return .updateRequired( + message: """ + A newer background agent is running than the version bundled with this app. \ + Replacing it will use the older bundled version. \(details) + """, + canReadLockState: canReadLockState, + supportsSafeReplacement: supportsSafeReplacement + ) + + case .orderedAscending, .orderedSame, nil: + return .updateRequired( + message: """ + The running background agent does not match the version bundled with this app. \(details) + """, + canReadLockState: canReadLockState, + supportsSafeReplacement: supportsSafeReplacement + ) + } + } + + private static func compareBuilds( + running: String, + bundled: String + ) -> ComparisonResult? { + guard let runningBuild = ServiceBuildVersion(running), + let bundledBuild = ServiceBuildVersion(bundled) + else { + return nil } + if runningBuild < bundledBuild { + return .orderedAscending + } + if runningBuild > bundledBuild { + return .orderedDescending + } + return .orderedSame + } + + /// Stops the currently registered Agent and waits for it to exit before registering the + /// bundled version. Callers must first enforce the lock-safety policy in + /// `AgentReplacementCoordinator`. + static func restart() async -> State { + let service = agent + + switch service.status { + case .notRegistered: + return ensureEnabled() + + case .notFound: + return .unavailable(.notFound) + + case .enabled, .requiresApproval: + do { + try await service.unregister() + } catch { + guard service.status == .notRegistered else { + logger.error("Failed to restart agent: \(error.localizedDescription, privacy: .public)") + return .unavailable(.restartFailed(error.localizedDescription)) + } + } + return ensureEnabled() + + @unknown default: + return .unavailable(.restartFailed("The system returned an unsupported registration status.")) + } + } + + private static func stateAfterRegistration( + _ status: SMAppService.Status, + error: Error? = nil + ) -> State { + switch status { + case .enabled: + .enabled + case .requiresApproval: + .approvalRequired + case .notFound: + .unavailable(.notFound) + case .notRegistered: + .unavailable(.registrationFailed(error?.localizedDescription ?? "Registration did not complete.")) + @unknown default: + .unavailable(.registrationFailed( + error?.localizedDescription ?? "The system returned an unsupported registration status." + )) + } + } + + private static func bundledCompatibilityRequirements() throws -> ServiceCompatibilityRequirements { + let agentURL = Bundle.main.bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("LoginItems", isDirectory: true) + .appendingPathComponent("KeyboardLockerAgent.app", isDirectory: true) + + guard FileManager.default.fileExists(atPath: agentURL.path), + let bundle = Bundle(url: agentURL) + else { + throw Failure.notFound + } + guard let bundleIdentifier = bundle.bundleIdentifier, !bundleIdentifier.isEmpty else { + throw Failure.invalidBundle("CFBundleIdentifier is missing.") + } + guard bundleIdentifier == SharedConstants.agentBundleIdentifier else { + throw Failure.invalidBundle( + "Expected identifier \(SharedConstants.agentBundleIdentifier), found \(bundleIdentifier)." + ) + } + guard let version = bundle.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String, !version.isEmpty else { + throw Failure.invalidBundle("CFBundleShortVersionString is missing.") + } + guard let build = bundle.object( + forInfoDictionaryKey: "CFBundleVersion" + ) as? String, !build.isEmpty else { + throw Failure.invalidBundle("CFBundleVersion is missing.") + } + + return ServiceCompatibilityRequirements( + agentBundleIdentifier: bundleIdentifier, + agentVersion: version, + agentBuild: build + ) } } diff --git a/KeyboardLocker/AgentReplacementCoordinator.swift b/KeyboardLocker/AgentReplacementCoordinator.swift new file mode 100644 index 0000000..bb363fc --- /dev/null +++ b/KeyboardLocker/AgentReplacementCoordinator.swift @@ -0,0 +1,161 @@ +import Client +import Foundation + +/// One generation-consistent replacement decision produced by readiness evaluation. +struct AgentUpdatePlan: Equatable { + enum Mode: Equatable { + case forced(descriptor: ServiceDescriptor?, isLocked: Bool?) + case safe(descriptor: ServiceDescriptor, isLocked: Bool) + } + + let mode: Mode + let message: String + + var descriptor: ServiceDescriptor? { + switch mode { + case let .forced(descriptor, _): + descriptor + case let .safe(descriptor, _): + descriptor + } + } + + var isLocked: Bool? { + switch mode { + case let .forced(_, isLocked): + isLocked + case let .safe(_, isLocked): + isLocked + } + } + + var supportsSafeReplacement: Bool { + if case .safe = mode { + return true + } + return false + } + + var canReadLockState: Bool { + isLocked != nil + } + + func updatingLockState(_ isLocked: Bool) -> Self { + switch mode { + case let .forced(descriptor, currentLockState): + guard currentLockState != nil else { + return self + } + return Self( + mode: .forced(descriptor: descriptor, isLocked: isLocked), + message: message + ) + + case let .safe(descriptor, _): + return Self( + mode: .safe(descriptor: descriptor, isLocked: isLocked), + message: message + ) + } + } +} + +/// Owns the App-side ordering for replacing a launchd-managed Agent. +/// +/// The coordinator contains no presentation state. It guarantees that Service Management restart +/// is attempted only after a safe replacement transaction has committed. Passing a forced plan is +/// a caller precondition that represents the user's explicit authorization of the legacy fallback. +@MainActor +struct AgentReplacementCoordinator { + enum Outcome { + case failed(error: Error, currentLockState: Bool?) + case registration(AgentRegistrar.State) + case replacementInProgress(ServiceDescriptor) + case restarted(previousAgentInstanceID: UUID?) + } + + private let client: any AgentReplacementServing + private let lifecycle: any AgentReplacementLifecycleServing + + init( + client: any AgentReplacementServing, + lifecycle: any AgentReplacementLifecycleServing + ) { + self.client = client + self.lifecycle = lifecycle + } + + func replace(_ plan: AgentUpdatePlan) async -> Outcome { + var replacementTicket: ServiceReplacementTicket? + var didCommitReplacement = false + + do { + switch plan.mode { + case let .safe(descriptor, isLocked): + replacementTicket = try await client.prepareForReplacement( + unlockIfNeeded: isLocked, + expectedAgentInstanceID: descriptor.agentInstanceID + ) + if let replacementTicket { + try await client.commitReplacement(ticket: replacementTicket) + didCommitReplacement = true + } + + case let .forced(_, isLocked): + if isLocked != nil { + // The legacy path has no cross-client drain. The explicit user action authorizes + // releasing the known lock immediately before the shortest possible restart window. + try await client.unlock() + } + } + + let registrationState = await lifecycle.restart() + guard case .enabled = registrationState else { + if let replacementTicket, !didCommitReplacement { + try? await client.cancelReplacementPreparation( + ticket: replacementTicket + ) + } + client.resetConnection() + return .registration(registrationState) + } + + client.resetConnection() + return .restarted( + previousAgentInstanceID: plan.descriptor?.agentInstanceID + ) + } catch { + if let replacementTicket, !didCommitReplacement { + try? await client.cancelReplacementPreparation( + ticket: replacementTicket + ) + } + + if plan.supportsSafeReplacement, + let descriptor = await reportedReplacementInProgress() + { + return .replacementInProgress(descriptor) + } + + let currentLockState: Bool? = if plan.canReadLockState { + try? await client.status() + } else { + nil + } + return .failed(error: error, currentLockState: currentLockState) + } + } + + private func reportedReplacementInProgress() async -> ServiceDescriptor? { + guard let descriptor = try? await client.serviceDescriptor(), + descriptor.replacementPending, + descriptor.agentBundleIdentifier == SharedConstants.agentBundleIdentifier, + descriptor.protocolVersion.major == ServiceContract.protocolVersion.major, + descriptor.capabilities.contains(.prepareForReplacement), + descriptor.capabilities.contains(.committedReplacementDrain) + else { + return nil + } + return descriptor + } +} diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift index 9262796..577de83 100644 --- a/KeyboardLocker/ContentView.swift +++ b/KeyboardLocker/ContentView.swift @@ -5,6 +5,8 @@ // Created by Eden on 2025/11/19. // +import AppKit +import Combine import SwiftUI struct ContentView: View { @@ -27,10 +29,14 @@ struct ContentView: View { // The menu window just opened; reconcile in case a broadcast was missed while suspended. lock.reconcile() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + // Permission and Login Item changes happen in System Settings while this App is inactive. + lock.reconcile() + } } } #Preview { ContentView() - .environmentObject(LockController()) + .environmentObject(LockController(previewState: .ready(isLocked: false))) } diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift index 58de179..d57817e 100644 --- a/KeyboardLocker/KeyboardLockerApp.swift +++ b/KeyboardLocker/KeyboardLockerApp.swift @@ -11,13 +11,8 @@ import SwiftUI struct KeyboardLockerApp: App { @StateObject private var lockController = LockController() - init() { - // Ensure the Agent is launchd-registered so it runs on demand for every surface. - AgentRegistrar.registerIfNeeded() - } - var body: some Scene { - MenuBarExtra("KeyboardLocker", systemImage: lockController.isLocked ? "lock.fill" : "lock.open.fill") { + MenuBarExtra("KeyboardLocker", systemImage: lockController.systemImageName) { ContentView() .environmentObject(lockController) } diff --git a/KeyboardLocker/LockController.swift b/KeyboardLocker/LockController.swift index 3db233e..5f8d4c6 100644 --- a/KeyboardLocker/LockController.swift +++ b/KeyboardLocker/LockController.swift @@ -1,53 +1,646 @@ +import AppKit import Client import Combine import Foundation -/// Observable view state for the lock. A thin wrapper: it holds no lock logic and no settings — -/// it issues one-off XPC calls and reflects the Agent's broadcast state, never inferring state -/// from whether a call succeeded. +/// Observable view state for the App wrapper. Registration and Accessibility remain owned by +/// their system boundaries; this controller only coordinates those facts for presentation. @MainActor final class LockController: ObservableObject { - @Published private(set) var isLocked = false + enum ViewState: Equatable { + case checking(lastKnownLock: Bool?) + case agentApprovalRequired + case agentReplacementInProgress(message: String) + case agentUpdateRequired(isLocked: Bool?, message: String) + case accessibilityRequired(isLocked: Bool) + case ready(isLocked: Bool) + case unavailable(message: String, canRestartAgent: Bool) + } + + enum Activity: Equatable { + case locking + case requestingAccessibility + case restartingAgent + case unlocking + case updatingAgent + } + + @Published private(set) var state: ViewState = .checking(lastKnownLock: nil) + @Published private(set) var activity: Activity? @Published private(set) var lastError: String? - private var stateToken: ObserverToken? + var agentUpdateUsesSafeReplacement: Bool { + pendingUpdatePlan?.supportsSafeReplacement ?? false + } + + var systemImageName: String { + switch state { + case .checking(lastKnownLock: true): + "lock.fill" + + case .checking: + "ellipsis.circle" + + case .ready(isLocked: true), + .accessibilityRequired(isLocked: true), + .agentUpdateRequired(isLocked: true, message: _): + "lock.fill" + + case .ready(isLocked: false): + "lock.open.fill" - init() { - stateToken = LockStateSubscriber.subscribe { [weak self] locked in - // Subscriber delivers on the main queue. - self?.isLocked = locked + case .agentApprovalRequired, + .agentReplacementInProgress, + .agentUpdateRequired(isLocked: false, message: _), + .accessibilityRequired(isLocked: false), + .unavailable: + "exclamationmark.triangle.fill" + + case .agentUpdateRequired(isLocked: nil, message: _): + "questionmark.diamond.fill" } - Task { await refresh() } } - func refresh() async { - do { - isLocked = try await XPCClient.shared.status() - } catch { - lastError = error.localizedDescription - } + private var reconciliationTask: Task? + private var needsFollowUpReconciliation = false + private var pendingUpdatePlan: AgentUpdatePlan? + private var attemptedAutomaticUpdateBuilds: Set = [] + private var stateToken: ObserverToken? + private let client: any AgentClientServing + private let lifecycle: any AgentLifecycleServing + private let readinessCoordinator: AgentReadinessCoordinator + private let replacementCoordinator: AgentReplacementCoordinator + private let usesLiveServices: Bool + + private static let replacementProgressPollInterval: Duration = .seconds(3) + + convenience init() { + self.init( + client: LiveAgentClient(), + lifecycle: LiveAgentLifecycle(), + usesLiveServices: true, + initialState: .checking(lastKnownLock: nil) + ) + reconcile() } - /// Reconcile against the Agent's authoritative state. Call when the UI becomes visible: - /// a broadcast may have been missed while the app was suspended (distributed notifications - /// are best-effort and undelivered to a non-running process). + /// Creates a static fixture without touching Service Management, XPC, or system settings. + convenience init(previewState: ViewState) { + self.init( + client: LiveAgentClient(), + lifecycle: LiveAgentLifecycle(), + usesLiveServices: false, + initialState: previewState + ) + } + + init( + client: any AgentClientServing, + lifecycle: any AgentLifecycleServing, + usesLiveServices: Bool, + initialState: ViewState + ) { + self.client = client + self.lifecycle = lifecycle + readinessCoordinator = AgentReadinessCoordinator( + client: client, + lifecycle: lifecycle + ) + replacementCoordinator = AgentReplacementCoordinator( + client: client, + lifecycle: lifecycle + ) + self.usesLiveServices = usesLiveServices + state = initialState + } + + /// Rebuilds the complete readiness snapshot from Service Management and the Agent. func reconcile() { - Task { await refresh() } + guard usesLiveServices, activity == nil else { + return + } + startReconciliation() } func toggle() { - Task { + guard usesLiveServices, activity == nil else { + return + } + + let isLocked: Bool + let allowsAutomaticAgentUpdateAfterAction: Bool + switch state { + case let .ready(currentLockState): + isLocked = currentLockState + allowsAutomaticAgentUpdateAfterAction = true + + case .checking(lastKnownLock: true): + isLocked = true + allowsAutomaticAgentUpdateAfterAction = true + + case .accessibilityRequired(isLocked: true): + // Unlock must remain available if permission is revoked while the Agent still reports locked. + isLocked = true + allowsAutomaticAgentUpdateAfterAction = true + + case .agentUpdateRequired(isLocked: true, message: _): + // Unlock must remain independently available without forcing an Agent replacement. + isLocked = true + allowsAutomaticAgentUpdateAfterAction = false + + default: + return + } + + reconciliationTask?.cancel() + activity = isLocked ? .unlocking : .locking + lastError = nil + + Task { [weak self] in + guard let self else { + return + } + + var actionError: String? do { if isLocked { - try await XPCClient.shared.unlock() + try await client.unlock() } else { - try await XPCClient.shared.lock() + try await client.lock() } - lastError = nil } catch { + actionError = error.localizedDescription + } + + activity = nil + startReconciliation( + preserving: actionError, + allowsAutomaticAgentUpdate: allowsAutomaticAgentUpdateAfterAction + ) + } + } + + func requestAccessibilityPermission() { + guard usesLiveServices, case .accessibilityRequired = state, activity == nil else { + return + } + + reconciliationTask?.cancel() + activity = .requestingAccessibility + lastError = nil + + Task { [weak self] in + guard let self else { + return + } + + var actionError: String? + do { + try await client.requestAccessibilityPermission() + } catch { + actionError = error.localizedDescription + } + + activity = nil + // The system prompt is asynchronous; only a fresh Agent query can confirm permission. + startReconciliation(preserving: actionError) + } + } + + func openAgentApprovalSettings() { + guard usesLiveServices else { + return + } + lifecycle.openApprovalSettings() + } + + func openAccessibilitySettings() { + guard usesLiveServices else { + return + } + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ), NSWorkspace.shared.open(url) else { + lastError = "Unable to open Accessibility settings." + return + } + lastError = nil + } + + func restartAgent() { + guard usesLiveServices, + case .unavailable(message: _, canRestartAgent: true) = state, + activity == nil + else { + return + } + + activity = .restartingAgent + lastError = nil + stopStateObservation() + + Task { [weak self] in + guard let self else { + return + } + defer { + activity = nil + } + + // Gracefully clear the logical lock when possible; unregistering the Agent still releases + // its event tap if the old process is incompatible or unresponsive. + try? await client.unlock() + client.resetConnection() + let registrationState = await lifecycle.restart() + client.resetConnection() + + guard case .enabled = registrationState else { + _ = applyRegistrationState(registrationState) + return + } + await refresh( + preserving: nil, + allowsAutomaticAgentUpdate: false + ) + } + } + + func updateAgent() { + guard usesLiveServices, + case .agentUpdateRequired = state, + let updatePlan = pendingUpdatePlan, + activity == nil + else { + return + } + + reconciliationTask?.cancel() + activity = .updatingAgent + lastError = nil + stopStateObservation() + + Task { [weak self] in + guard let self else { + return + } + + defer { + activity = nil + } + + let outcome = await replacementCoordinator.replace(updatePlan) + await handleAgentReplacementOutcome( + outcome, + updatePlan: updatePlan, + preserving: nil + ) + } + } + + private func startReconciliation( + preserving actionError: String? = nil, + allowsAutomaticAgentUpdate: Bool = true + ) { + reconciliationTask?.cancel() + stopStateObservation() + + let lastKnownLock: Bool? = switch state { + case let .checking(lastKnownLock): + lastKnownLock + case let .agentUpdateRequired(isLocked, _): + isLocked + case let .accessibilityRequired(isLocked), + let .ready(isLocked): + isLocked + case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + nil + } + + needsFollowUpReconciliation = false + state = .checking(lastKnownLock: lastKnownLock) + lastError = nil + + reconciliationTask = Task { [weak self] in + await self?.refresh( + preserving: actionError, + allowsAutomaticAgentUpdate: allowsAutomaticAgentUpdate, + expectedPreviousAgentInstanceID: nil + ) + } + } + + private func refresh( + preserving actionError: String?, + allowsAutomaticAgentUpdate: Bool, + expectedPreviousAgentInstanceID: UUID? = nil + ) async { + let outcome = await readinessCoordinator.inspect( + expectedPreviousAgentInstanceID: expectedPreviousAgentInstanceID + ) + guard !Task.isCancelled else { + return + } + + switch outcome { + case .agentDidNotRestart: + await showServiceFailure(AgentUpdateError.agentDidNotRestart) + + case .cancelled: + return + + case let .failure(error, context): + await showServiceFailure(error, context: context) + + case let .invalidBundle(failure): + pendingUpdatePlan = nil + state = .unavailable(message: failure.message, canRestartAgent: false) + lastError = nil + + case let .ready(isLocked, hasAccessibilityPermission): + pendingUpdatePlan = nil + state = hasAccessibilityPermission + ? .ready(isLocked: isLocked) + : .accessibilityRequired(isLocked: isLocked) + lastError = actionError + startStateObservation() + + if needsFollowUpReconciliation { + startReconciliation( + preserving: actionError, + allowsAutomaticAgentUpdate: allowsAutomaticAgentUpdate + ) + } + + case let .registration(registrationState): + _ = applyRegistrationState(registrationState) + + case let .replacementInProgress(descriptor): + showAgentReplacementInProgress(descriptor: descriptor) + + case let .updateAvailable(descriptor, message, bundledBuild, isLocked): + if !isLocked, + allowsAutomaticAgentUpdate, + !attemptedAutomaticUpdateBuilds.contains(bundledBuild) + { + attemptedAutomaticUpdateBuilds.insert(bundledBuild) + await replaceAgentAutomatically( + descriptor, + updateMessage: message, + preserving: actionError + ) + } else { + showAgentUpdateRequired(plan: AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: isLocked), + message: message + )) + } + + case let .updateRequired(updatePlan): + showAgentUpdateRequired(plan: updatePlan) + } + } + + private func replaceAgentAutomatically( + _ previousDescriptor: ServiceDescriptor, + updateMessage: String, + preserving actionError: String? + ) async { + guard !Task.isCancelled else { + return + } + + activity = .updatingAgent + defer { + activity = nil + } + + let updatePlan = AgentUpdatePlan( + mode: .safe(descriptor: previousDescriptor, isLocked: false), + message: updateMessage + ) + let outcome = await replacementCoordinator.replace(updatePlan) + await handleAgentReplacementOutcome( + outcome, + updatePlan: updatePlan, + preserving: actionError + ) + } + + private func handleAgentReplacementOutcome( + _ outcome: AgentReplacementCoordinator.Outcome, + updatePlan: AgentUpdatePlan, + preserving actionError: String? + ) async { + switch outcome { + case let .failed(error, currentLockState): + if let currentLockState { + showAgentUpdateRequired( + plan: updatePlan.updatingLockState(currentLockState) + ) lastError = error.localizedDescription + } else { + await showServiceFailure(error) + } + + case let .registration(registrationState): + _ = applyRegistrationState(registrationState) + + case let .replacementInProgress(descriptor): + showAgentReplacementInProgress(descriptor: descriptor) + + case let .restarted(previousAgentInstanceID): + await refresh( + preserving: actionError, + allowsAutomaticAgentUpdate: false, + expectedPreviousAgentInstanceID: previousAgentInstanceID + ) + } + } + + private func showAgentReplacementInProgress(descriptor: ServiceDescriptor) { + let message = switch descriptor.replacementPhase { + case .committed: + """ + An Agent replacement has been committed. New lock requests remain blocked while its \ + coordinator finishes and the old Agent exits. If this state persists, restart macOS; \ + another app instance cannot safely take over an unregister that may still be in flight. + """ + + case .prepared: + """ + An Agent replacement is being prepared. New lock requests remain blocked until its \ + coordinator commits or the short preparation expires. + """ + + default: + """ + The Agent reports a replacement state this app does not recognize. New lock requests \ + remain blocked to protect the current lock state. Update the app, or restart macOS if \ + this state persists. + """ + } + + pendingUpdatePlan = nil + stopStateObservation() + state = .agentReplacementInProgress(message: message) + lastError = nil + reconciliationTask?.cancel() + reconciliationTask = Task { [weak self] in + do { + try await Task.sleep(for: Self.replacementProgressPollInterval) + } catch { + return + } + guard !Task.isCancelled else { + return } - // State itself arrives via the broadcast subscription, not from this call's success. + self?.startReconciliation(allowsAutomaticAgentUpdate: false) + } + } + + private func showAgentUpdateRequired(plan: AgentUpdatePlan) { + pendingUpdatePlan = plan + state = .agentUpdateRequired( + isLocked: plan.isLocked, + message: plan.message + ) + lastError = nil + if plan.canReadLockState { + startStateObservation() + } else { + stopStateObservation() + } + } + + private func showServiceFailure(_ error: Error, context: String? = nil) async { + guard !Task.isCancelled else { + return + } + stopStateObservation() + + if let clientError = error as? XPCClientError, + case .peerAuthenticationUnavailable = clientError + { + pendingUpdatePlan = nil + state = .unavailable( + message: """ + This copy of KeyboardLocker cannot establish its signed XPC identity. \ + \(clientError.localizedDescription) Install and run the complete app bundle signed by \ + the configured Apple development team. + """, + canRestartAgent: false + ) + lastError = nil + return + } + + // An XPC failure can mean the user disabled the Agent while the App was running. + let currentRegistrationState = lifecycle.ensureEnabled() + guard !Task.isCancelled else { + return + } + + if applyRegistrationState(currentRegistrationState) { + pendingUpdatePlan = nil + let contextMessage = context.map { " \($0)" } ?? "" + state = .unavailable( + message: """ + The background agent is enabled but could not be reached. \ + \(error.localizedDescription)\(contextMessage) + """, + canRestartAgent: true + ) + lastError = nil + } + } + + /// Returns `true` only when XPC readiness checks should continue. + private func applyRegistrationState(_ registrationState: AgentRegistrar.State) -> Bool { + switch registrationState { + case .enabled: + return true + + case .approvalRequired: + stopStateObservation() + pendingUpdatePlan = nil + state = .agentApprovalRequired + lastError = nil + return false + + case let .unavailable(failure): + stopStateObservation() + pendingUpdatePlan = nil + let canRestartAgent = if case .restartFailed = failure { + true + } else { + false + } + state = .unavailable( + message: failure.message, + canRestartAgent: canRestartAgent + ) + lastError = nil + return false + } + } + + private func receiveLockState(_ isLocked: Bool) { + if activity != nil { + return + } + + switch state { + case .ready: + state = .ready(isLocked: isLocked) + lastError = nil + + case .accessibilityRequired: + state = .accessibilityRequired(isLocked: isLocked) + lastError = nil + + case .agentUpdateRequired: + if let pendingUpdatePlan, + pendingUpdatePlan.canReadLockState + { + let updatedPlan = pendingUpdatePlan.updatingLockState(isLocked) + self.pendingUpdatePlan = updatedPlan + state = .agentUpdateRequired( + isLocked: isLocked, + message: updatedPlan.message + ) + lastError = nil + } + + case .checking: + needsFollowUpReconciliation = true + state = .checking(lastKnownLock: isLocked) + + case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + reconcile() + } + } + + private func startStateObservation() { + guard stateToken == nil else { + return + } + stateToken = LockStateSubscriber.subscribe { [weak self] isLocked in + self?.receiveLockState(isLocked) + } + } + + private func stopStateObservation() { + stateToken = nil + } +} + +private enum AgentUpdateError: Error, LocalizedError { + case agentDidNotRestart + + var errorDescription: String? { + switch self { + case .agentDidNotRestart: + "The background agent did not restart into a new process." } } } diff --git a/KeyboardLocker/Views/MainView.swift b/KeyboardLocker/Views/MainView.swift index 7bfd8eb..1a1e5d2 100644 --- a/KeyboardLocker/Views/MainView.swift +++ b/KeyboardLocker/Views/MainView.swift @@ -14,7 +14,7 @@ struct MainView: View { VStack(spacing: 16) { header Divider() - lockControl + statusContent Spacer() footer } @@ -23,36 +23,349 @@ struct MainView: View { private var header: some View { HStack { - Image(systemName: lock.isLocked ? "lock.fill" : "lock.open.fill") + Image(systemName: lock.systemImageName) .font(.title) - .foregroundStyle(lock.isLocked ? Color.accentColor : .secondary) + .foregroundStyle(statusColor) Text("KeyboardLocker") .font(.headline) Spacer() } } - private var lockControl: some View { + private var statusColor: Color { + switch lock.state { + case .ready(isLocked: true), + .accessibilityRequired(isLocked: true), + .agentUpdateRequired(isLocked: true, message: _): + .accentColor + case .agentApprovalRequired, + .agentReplacementInProgress, + .agentUpdateRequired(isLocked: false, message: _), + .agentUpdateRequired(isLocked: nil, message: _), + .accessibilityRequired(isLocked: false), + .unavailable: + .orange + case .checking(lastKnownLock: true): + .accentColor + case .checking, .ready(isLocked: false): + .secondary + } + } + + @ViewBuilder + private var statusContent: some View { + switch lock.state { + case let .checking(lastKnownLock): + checkingView(lastKnownLock: lastKnownLock) + case .agentApprovalRequired: + agentApprovalView + case let .agentReplacementInProgress(message): + agentReplacementInProgressView(message: message) + case let .agentUpdateRequired(isLocked, message): + agentUpdateView(isLocked: isLocked, message: message) + case let .accessibilityRequired(isLocked): + accessibilityView(isLocked: isLocked) + case let .ready(isLocked): + readyView(isLocked: isLocked) + case let .unavailable(message, canRestartAgent): + unavailableView(message: message, canRestartAgent: canRestartAgent) + } + } + + private func checkingView(lastKnownLock: Bool?) -> some View { + VStack(spacing: 8) { + if lastKnownLock == true { + Button { + lock.toggle() + } label: { + HStack(spacing: 6) { + if lock.activity == .unlocking { + ProgressView() + .controlSize(.small) + } + Text(lock.activity == .unlocking ? "Unlocking…" : "Unlock") + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(lock.activity != nil) + } + + if lock.activity != .unlocking { + ProgressView() + .controlSize(.small) + Text( + lock.activity == .updatingAgent + ? "Updating background agent…" + : "Verifying KeyboardLocker status…" + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + private func agentReplacementInProgressView(message: String) -> some View { + VStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Agent Replacement in Progress") + .font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Check Again") { + lock.reconcile() + } + .buttonStyle(.borderedProminent) + .disabled(lock.activity != nil) + } + } + + private func agentUpdateView(isLocked: Bool?, message: String) -> some View { + VStack(spacing: 8) { + Text("Agent Update Required") + .font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Text(agentUpdateWarning(isLocked: isLocked)) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if isLocked == true { + Button { + lock.toggle() + } label: { + HStack(spacing: 6) { + if lock.activity == .unlocking { + ProgressView() + .controlSize(.small) + } + Text(lock.activity == .unlocking ? "Unlocking…" : "Unlock") + } + } + .buttonStyle(.bordered) + .disabled(lock.activity != nil) + } + + Button { + lock.updateAgent() + } label: { + HStack(spacing: 6) { + if lock.activity == .updatingAgent { + ProgressView() + .controlSize(.small) + } + Text(agentUpdateTitle(isLocked: isLocked)) + } + } + .buttonStyle(.borderedProminent) + .disabled(lock.activity != nil) + + Button("Check Again") { + lock.reconcile() + } + .buttonStyle(.borderless) + .disabled(lock.activity != nil) + + actionError + } + } + + private var agentApprovalView: some View { + VStack(spacing: 8) { + Text("Background Agent Disabled") + .font(.headline) + Text("Enable KeyboardLocker in System Settings → General → Login Items.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Open Login Items") { + lock.openAgentApprovalSettings() + } + .buttonStyle(.borderedProminent) + + Button("Check Again") { + lock.reconcile() + } + .buttonStyle(.borderless) + } + } + + private func accessibilityView(isLocked: Bool) -> some View { VStack(spacing: 8) { - Button(lock.isLocked ? "Unlock" : "Lock Keyboard") { + if isLocked { + Button { + lock.toggle() + } label: { + HStack(spacing: 6) { + if lock.activity == .unlocking { + ProgressView() + .controlSize(.small) + } + Text(lock.activity == .unlocking ? "Unlocking…" : "Unlock") + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(lock.activity != nil) + + Text("Keyboard is locked") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text("Accessibility Access Required") + .font(.headline) + Text("Allow KeyboardLockerAgent to filter keyboard events. After granting access, check again.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button { + lock.requestAccessibilityPermission() + } label: { + HStack(spacing: 6) { + if lock.activity == .requestingAccessibility { + ProgressView() + .controlSize(.small) + } + Text("Request Access") + } + } + .buttonStyle(.bordered) + .disabled(lock.activity != nil) + + HStack(spacing: 12) { + Button("Open Settings") { + lock.openAccessibilitySettings() + } + .buttonStyle(.borderless) + + Button("Check Again") { + lock.reconcile() + } + .buttonStyle(.borderless) + .disabled(lock.activity != nil) + } + + actionError + } + } + + private func readyView(isLocked: Bool) -> some View { + VStack(spacing: 8) { + Button { lock.toggle() + } label: { + HStack(spacing: 6) { + if lock.activity == .locking || lock.activity == .unlocking { + ProgressView() + .controlSize(.small) + } + Text(toggleTitle(isLocked: isLocked)) + } } .buttonStyle(.borderedProminent) .controlSize(.large) + .disabled(lock.activity != nil) - Text(lock.isLocked ? "Keyboard is locked" : "Keyboard is unlocked") + Text(isLocked ? "Keyboard is locked" : "Keyboard is unlocked") .font(.subheadline) .foregroundStyle(.secondary) - if let error = lock.lastError { - Text(error) + actionError + } + } + + private func toggleTitle(isLocked: Bool) -> String { + switch lock.activity { + case .locking: + "Locking…" + case .unlocking: + "Unlocking…" + case .requestingAccessibility, .restartingAgent, .updatingAgent, nil: + isLocked ? "Unlock" : "Lock Keyboard" + } + } + + private func agentUpdateTitle(isLocked: Bool?) -> String { + if lock.activity == .updatingAgent { + return "Updating Agent…" + } + return isLocked == true ? "Unlock and Replace Agent" : "Replace Agent" + } + + private func agentUpdateWarning(isLocked: Bool?) -> String { + if lock.agentUpdateUsesSafeReplacement { + return isLocked == true + ? "The Agent will block new lock requests, unlock, and remain drained until replacement completes." + : "The Agent will block new lock requests until replacement completes." + } + if isLocked == nil { + return "The running Agent's lock state cannot be verified. Replacing it may release an active keyboard lock." + } + return """ + This Agent cannot block other clients during replacement. A lock created in that brief \ + window may be released. + """ + } + + private func unavailableView(message: String, canRestartAgent: Bool) -> some View { + VStack(spacing: 8) { + Text("KeyboardLocker Is Unavailable") + .font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Retry") { + lock.reconcile() + } + .buttonStyle(.borderedProminent) + .disabled(lock.activity != nil) + + if canRestartAgent { + Text("Restarting the agent releases any active keyboard lock.") .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) + + Button { + lock.restartAgent() + } label: { + HStack(spacing: 6) { + if lock.activity == .restartingAgent { + ProgressView() + .controlSize(.small) + } + Text(lock.activity == .restartingAgent ? "Restarting Agent…" : "Restart Agent") + } + } + .buttonStyle(.bordered) + .disabled(lock.activity != nil) } } } + @ViewBuilder + private var actionError: some View { + if let error = lock.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + } + private var footer: some View { HStack { NavigationLink(value: AppRoute.settings) { @@ -77,7 +390,7 @@ struct MainView: View { #Preview { NavigationStack { MainView() - .environmentObject(LockController()) + .environmentObject(LockController(previewState: .accessibilityRequired(isLocked: false))) } .frame(width: 280, height: 320) } From 57d0c44ce4ad8f8b3c6fe8970bc3ae085e772f28 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 20:17:44 +0800 Subject: [PATCH 026/106] docs(xpc): document agent lifecycle boundaries Describe connection identity, mutual peer authentication, compatibility negotiation, and fail-safe replacement. Record App-side coordinator ownership, recovery behavior, and the safe test-carrier boundary. --- docs/architecture.md | 25 ++++++- docs/development.md | 43 +++++++---- docs/xpc.md | 165 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 189 insertions(+), 44 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9442966..922848d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,15 +35,19 @@ Widget ──┘ ├─ Settings ownership (source of truth) └─ Accessibility (permission gate) ``` +这里的 `Core`、SwiftPM product、运行进程和 XPC connection 是不同层次。完整的构建依赖图、进程图和调用时序见 [XPC 实现与使用指南](xpc.md)。 + ## 各职责的归属 | 关注点 | 归属 | 规则 | |---------|------|------| | 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | | 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 绝不自己持有 `UserDefaults`,只经 XPC 访问。当前仅暴露读取(`currentSettings`);写入(`applySettings`)尚未接线,加入时同样必须经 Agent,绝不在 wrapper 侧落地。 | -| Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。向 wrapper 暴露状态查询 / 权限请求的 XPC 接口尚未接线;加入时只能由 Agent 经 XPC 提供,wrapper 不得自行请求权限。 | +| Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。wrapper 只能经 XPC 查询状态或请求 Agent 触发系统 prompt,不得自行调用 Accessibility API。权限 prompt 是异步的;请求完成不代表已授权,wrapper 必须重新查询。 | | 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | -| UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,但不含任何领域逻辑。 | +| UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,并协调只存在于自身进程的系统边界(例如 App 的 `SMAppService` 生命周期);不得复制 Agent 的锁、设置或 Accessibility 领域逻辑。 | + +App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management、XPC 两个系统边界之间的应用层编排,不是第二份锁核心。它必须保持为无锁状态真相源的薄协调器:输入来自 Agent/系统查询,输出由 `LockController` 映射到 UI;任何 CGEventTap、设置持久化和 Accessibility 判定仍只在 Agent 内执行。 ## 全局锁语义 @@ -77,6 +81,23 @@ Widget ──┘ ├─ Settings ownership (source of truth) 即使 App 没有运行,wrapper 也依赖 Agent 可达。Agent 必须向系统注册(`SMAppService`),以便 `launchd` 能按需为任何 XPC 客户端拉起它。假定 Agent 已经在运行(例如"因为 App 恰好开着")的 wrapper 是错误的。 +App 必须区分 `SMAppService` 的 enabled、requires-approval、not-found 和 registration-failure 状态。只有 enabled 才继续查询 Agent;requires-approval 必须提供 Login Items 恢复入口。Agent 已 enabled 但 XPC 失败属于不可达,不能伪装成未锁定或缺少 Accessibility 权限。`enabled` 只表示系统允许该 service 运行,不证明当前进程就是 App 内 bundled 版本。 + +App 在调用新能力前必须先读取 `ServiceDescriptor`,分别验证 XPC protocol major/minor、required capabilities 和 bundled Agent 的 identifier/version/build。descriptor 中的身份字段只用于兼容性与更新判断,不能代替 XPC connection 的代码签名认证。descriptor 必须在 fresh connection 上重试后才能降级;若两次 descriptor 都失败而旧 `status` 成功,只能断言 base contract 可达,不能断言远端一定是 legacy Agent。此时只允许使用旧 `status` / `unlock` 做显式安全迁移,不得继续调用新 selector。 + +XPC peer authentication 必须由系统双向执行。Agent Listener 在 activate 前安装同 Team + 主 App/CLI 精确 signing identifier 的 connection requirement;App/CLI connection 在 activate 前安装同 Team + 精确 Agent signing identifier 的 requirement。Debug 不得降级成 identifier-only,不能通过 PID 后查静态签名来代替 XPC runtime 的 requirement。Team ID 从各进程自身已验证的签名读取;unsigned/ad-hoc 进程 fail closed。 + +capability grant 必须绑定到读取 descriptor 的同一条 client-side connection generation。所有 optional method 都要在一条具体 connection 上完成 handshake 与 capability check,再经同一个 `NSXPCConnection` 发出;connection 失效就同时失去 grant。named connection object 仍可能在 interruption 后面对新进程,因此 replacement wire request 必须携带 expected `agentInstanceID`,由 Agent 在副作用前原子验证,Client 也必须验证返回 ticket。 + +Agent 更新必须遵守以下边界: + +- 当前锁为 locked 时绝不自动退出 Agent,因为进程退出会释放其 event tap;UI 必须明确说明并由用户选择 `Unlock and Replace Agent`。 +- unlocked 也不能仅凭一次 `status()` 就自动替换,因为另一个 wrapper 可能随后重新 lock。只有 identifier 与 protocol major 相同、bundled `CFBundleVersion` 可比较且严格更高、并且 Agent 同时声明 prepared 与 committed-drain capability 时,App 才可自动更新。运行中 Agent build 更高时绝不自动降级。 +- `prepareForReplacement` 必须在 Agent 的串行执行边界安装短期 fail-safe barrier,再按策略解锁并返回独占 ownership ticket。Agent 必须拒绝第二个 prepare,不能覆盖 ticket 或转移 cancellation 权限。 +- legacy Agent、major 不兼容、身份不匹配或缺少 replacement capability 的 Agent 只能通过明确用户动作更新。无法建立 drain 时 UI 必须说明 replacement 窗口中的新 lock 可能被进程退出释放。 +- 替换顺序固定为 prepare/unlock → 幂等 commit 为不可取消、不可过期的 drain → 仅在 commit reply 成功后提交并等待 `SMAppService.unregister()` → 注册 bundled Agent → 失效旧 Client connection → 建立新 connection → 重新读取 descriptor。新 descriptor 必须兼容且 `agentInstanceID` 必须变化;任何失败都停止。每个 App 进程对同一 bundled build 最多自动尝试一次,不得形成 restart loop。 +- prepared drain 可以在 commit 前由 owner cancel 或短期 expire;committed drain 只能随旧 Agent 进程退出清除,绝不能用 heartbeat loss 或固定 timeout 推断在途 unregister 已取消。commit reply 丢失时由 exact ticket 的 `replacementStatus` 恢复真实结果。其他 App 通过 additive `replacementPhase` 解释 UI,只能周期性重新握手,不得接管、cancel 或再次 unregister。commit 后 coordinator crash 的极端窗口选择 fail closed;若长期未恢复,需要重启 macOS,而不能冒险让第二次 unregister 释放后来建立的活动锁。 + ## 需要警惕的违约 以下是本契约要防范的具体失败模式: diff --git a/docs/development.md b/docs/development.md index 5cc8249..8520b72 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,13 +4,16 @@ ## XPC 通信流程 +本节是开发时的速查。若需要理解 `Core` 为什么被两侧引用、library 与进程的区别、Agent 注册、完整调用时序、connection 生命周期和失败语义,先读 [XPC 实现与使用指南](xpc.md)。 + 所有面都通过 `KeyboardLockerServiceProtocol` 与 Agent 通信(Mach 服务 `io.lzhlovesjyq.keyboardlocker.agent`)。 -1. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `currentSettings`。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 -2. Agent 的 `ServiceDelegate` 接受连接(在 `XPCAccessControl` 校验代码签名 + bundle ID 之后),并路由到 `AgentService`。 -3. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:)` / `unlock()`。 -4. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 -5. wrapper 通过 `LockStateSubscriber.subscribe(_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态 —— 绝不从"我这次调用是否成功"推断。 +1. App 建立 connection 后先调用 `XPCClient.serviceDescriptor()`,验证 protocol version、required capabilities 和 bundled Agent build;只有兼容后才调用新增 selector。descriptor 在 fresh connection 上重试后仍失败、但旧 `status` 成功时,只能按 unverified base contract 处理,不能断言它一定是 legacy Agent。capability-gated Client API 会在同一条具体 connection generation 上重新握手并发送 feature selector,connection 变化后不能复用旧 grant。 +2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `currentSettings`。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 +3. Client 与 Listener 都在 activate 前安装同 Team + 精确 signing identifier 的 XPC requirement;系统完成双向认证后,Agent 的 `ServiceDelegate` 才接受连接并路由到 `AgentService`。 +4. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:)` / `unlock()`。 +5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 +6. wrapper 通过 `LockStateSubscriber.subscribe(_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态 —— 绝不从"我这次调用是否成功"推断。 > 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**幂等**的(已锁时再锁会重新应用设置并返回成功)。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 @@ -19,23 +22,30 @@ 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(锁操作 + `currentSettings`)、`SharedConstants`(Mach 名、bundle-ID 白名单)、`NotificationNames.stateChanged`。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、锁操作、replacement drain、Accessibility 状态 / 请求、`currentSettings`)、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。 +- `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 +- `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成并预编译同 Team + 精确 identifier 的双向 XPC requirement;unsigned/ad-hoc 进程 fail closed。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界的 JSON 传输)。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` -- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条自动重连的连接;`lock`/`unlock`/`status`/`currentSettings`。没有 "session" 类型。 +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条自动重连的连接;所有调用共享有界响应超时,超时只失效对应连接。`lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;optional method 在同一 connection 上完成 descriptor/capability gate;replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 +- `ServiceCompatibility.swift`:纯值兼容性规则与任意精度 dotted-numeric `ServiceBuildVersion` —— major 必须相等、running minor 不得低于最低版本、required capabilities 必须齐全,且运行中 Agent 的 identifier/version/build 必须与 bundled Agent 一致。 - `LockStateSubscriber.swift`:订阅 Darwin + Distributed 广播,把每个信号当作提示,并通过 `XPCClient.status()` 拉取权威状态(带重试、去重)→ `ObserverToken`,另有 `stateChanges`(`AsyncStream`)。只有长命 UI 需要它;一次性面直接读 `status()`(见 architecture 的"状态同步")。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 - `LockEngine.swift`:CGEventTap 单例、幂等的 `lock(settings:)`、`updateSettings(_:)`、自动解锁定时器、热键检测、`OSAllocatedUnfairLock` 状态、`os.Logger`。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 -- `AccessibilityManager.swift`、`XPCAccessControl.swift`(release = 签名 + Team ID + 白名单;debug = 仅白名单)、`XPCServerConnection.swift`。 +- `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 +- `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 **App**(`KeyboardLocker/`)—— 薄 SwiftUI wrapper -- `AgentRegistrar.swift`:启动时做 `SMAppService.agent(plistName:)` 注册(幂等)。 -- `LockController.swift`:`@MainActor ObservableObject` 视图状态 —— 发出异步 `XPCClient` 调用、反映广播状态,不含任何锁/设置逻辑。 +- `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 +- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol,协调器无需依赖完整 Client/lifecycle surface。 +- `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 +- `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 +- `LockController.swift`:`@MainActor ObservableObject` 视图 façade —— 持有 SwiftUI state、异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把协调器 outcome 映射成 UI;不直接实现 handshake、replacement transaction、锁或设置逻辑。 ## 常见任务 @@ -45,9 +55,11 @@ 3. 目前 wrapper 只能通过 `XPCClient.currentSettings()` **读取**设置(由 Agent 经 `KeyboardLockerSettingsStore` 加载)。写入路径(`applySettings` + UI)尚未接线;要让用户改设置,先在 Agent + `KeyboardLockerServiceProtocol` 上补写入方法(见"新增一个 XPC 方法"),再由 Agent 持久化 —— wrapper **不得**拥有 store(见架构契约)。 ### 新增一个 XPC 方法 -1. 在 `Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 里加签名。无法用 `@objc` 表达的值(如 `KeyboardLockerSettings`)以 JSON `Data` 跨界 —— 复用 `encodedForXPC()`/`decodedFromXPC(_:)`。 -2. 在 `KeyboardLockerAgent/AgentService.swift` 中实现它。 -3. 如果某个面需要,在 `Client/` 的 `XPCClient` 上加一个薄的异步封装。 +1. 先判断它是否 optional/additive。不得在同一 protocol major 内修改或移除既有 selector、参数顺序或 reply 形状;破坏性变化需要新 major,无法保留 selector union 时需要新 Mach service。 +2. 在 `Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 里加签名,同时在 `ServiceCapability` 增加一个从不复用的稳定名字,并按兼容需求更新 protocol minor / required capability。无法用 `@objc` 表达的值(如 `KeyboardLockerSettings`)以有大小上限的 JSON `Data` 跨界。 +3. 在 `KeyboardLockerAgent/AgentService.swift` 中实现它并在 descriptor 中声明 capability。 +4. 在 `Client/` 的 `XPCClient` 上加一个薄的异步封装;调用前必须完成 descriptor handshake 并检查 capability。 +5. 在 `Core/Tests/ClientTests/` 补充 old/future descriptor fixture、round-trip、build ordering 与兼容性测试;Server 状态机在 `Core/Tests/ServiceTests/` 覆盖 exclusivity、stale ticket/timer、cancel、expiry 与 committed fail-closed 语义。 ### 修改事件过滤 在 `LockEngine.handleEvent(proxy:type:event:)` 中:返回 `nil` 拦截、返回 `Unmanaged.passUnretained(event)` 放行,并在满足解锁条件(热键或超时)时调用 `unlock()`。`Hotkey.matches(keyCode:flags:)` 会通过 `relevantModifierMask` 过滤掉 CapsLock/NumLock。 @@ -55,6 +67,9 @@ ## 测试须知 - 缺少 Accessibility 权限时,`LockEngine.lock` 抛出 `.accessibilityPermissionDenied`(在创建 event tap 之前检查)。 -- XPC 调用要成功,Agent 必须正在运行 / 已注册(`SMAppService`)。 +- `requestAccessibilityPermission()` 只表示 Agent 已请求系统显示异步 prompt;用户操作完成后必须重新调用 `hasAccessibilityPermission()`。 +- XPC 调用要成功,Agent 必须正在运行 / 已注册(`SMAppService`),且 App、CLI 与 Agent 都必须使用同一 Apple Team 的项目签名。Debug 与 Release 都执行双向 XPC code-signing requirement;unsigned/ad-hoc 可执行文件即使复制 signing identifier 也会被拒绝。 +- `klock` target 必须保留 generated embedded Info.plist section;这是让实际 code-signing identifier 等于 `io.lzhlovesjyq.keyboardlocker.klock` 的载体。只设置 `PRODUCT_BUNDLE_IDENTIFIER` 而不嵌入 Info.plist 时,`codesign` 会退回裸名称 `klock`,Agent 会拒绝它。 - 引擎操作会派发到主线程,以便访问 CFRunLoop。 - 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。 +- App 协调器通过 protocol injection 与 presentation state 解耦。当前 Xcode 工程没有安全的 non-hosted App-model test target;不要用 hosted App tests 覆盖它们,因为测试宿主会初始化 live `LockController` 并触碰 `SMAppService`/XPC。新增确定性协调器测试时应先建立独立的 non-hosted App-model target,再注入 fake Client/lifecycle。 diff --git a/docs/xpc.md b/docs/xpc.md index e73532d..d19c95e 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -93,9 +93,27 @@ XPC 两侧必须对“线上消息长什么样”达成一致。当前契约定 ```swift @objc(KeyboardLockerServiceProtocol) public protocol KeyboardLockerServiceProtocol { + func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) func lockKeyboard(reply: @escaping (Error?) -> Void) func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID, + reply: @escaping (Data?, Error?) -> Void + ) + func commitReplacement( + ticket: Data, + reply: @escaping (Error?) -> Void + ) + func replacementStatus( + ticket: Data, + reply: @escaping (Data?, Error?) -> Void + ) + func cancelReplacementPreparation( + ticket: Data, + reply: @escaping (Error?) -> Void + ) func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) func currentSettings(reply: @escaping (Data?) -> Void) @@ -109,7 +127,7 @@ public protocol KeyboardLockerServiceProtocol { 这和 HTTP 的 client/server 共同依赖同一份 OpenAPI schema 类似:共享的是消息契约,不是业务实例。 -协议使用 `@objc`,因为 `NSXPCInterface` 基于 Objective-C runtime 描述方法。跨边界参数需要是 XPC 可序列化类型;当前设置模型是 Swift `Codable` struct,所以先编码成 JSON `Data`,到另一侧再解码,而不是把 Swift struct 直接放进 protocol。 +协议使用 `@objc`,因为 `NSXPCInterface` 基于 Objective-C runtime 描述方法。跨边界参数需要是 XPC 可序列化类型;`ServiceDescriptor` 和设置模型都是 Swift `Codable` struct,所以先编码成有大小上限的 JSON `Data`,到另一侧再解码,而不是把 Swift struct 直接放进 protocol。 ## Agent 如何被系统找到 @@ -141,10 +159,79 @@ KeyboardLocker.app - `SMAppService.Status.enabled`:系统允许这个 Agent 运行。 - XPC 调用成功:当前 Agent executable 确实启动、接受连接并理解当前 protocol。 -前者不是后者的充分条件。例如 App 更新后,系统中可能仍运行旧 Agent;它虽然是 enabled,却不认识新加入的 selector。App 因此会把“Agent enabled 但 XPC 不可达”显示为独立错误,并只在用户明确操作时重启 Agent。 +前者不是后者的充分条件。例如 App 更新后,系统中可能仍运行旧 Agent;它虽然是 enabled,却不认识新加入的 selector。App 必须通过下方的 descriptor handshake 判断运行中进程,不能从 `SMAppService.Status` 推断版本。 `klock` 自己不注册 Agent。它依赖完整 App 已经完成注册;一旦注册,App 是否仍在运行不影响 CLI 连接 Agent。 +## Bootstrap handshake、兼容性和 Agent 更新 + +`serviceDescriptor` 是 feature method 之前的稳定 bootstrap selector。Agent 每次进程启动生成一次 descriptor: + +| 字段 | 用途 | +|---|---| +| `protocolVersion.major/minor` | major 必须相等;running minor 必须不低于 Client 要求 | +| `capabilities` | Client 只调用 Agent 明确声明支持的 optional selector;未知字符串由旧 Client 保留并忽略 | +| `agentBundleIdentifier` | 与 bundled Agent metadata 比较,发现错误/旧 executable | +| `agentVersion` / `agentBuild` | 判断运行中 Agent 是否就是 App 当前携带的构建 | +| `agentInstanceID` | 每个 Agent process 唯一;重启后必须变化,用于区分进程代际 | +| `replacementPending` | 当前是否存在独占的 prepared 或 committed replacement drain | +| `replacementPhase` | additive 的 `inactive` / `prepared` / `committed`;未知 future string 由旧 Client 保留并忽略 | + +`agentBundleIdentifier`、version 和 build 是远端自报的兼容性信息,不是安全凭证。它们只有在 connection 已完成 peer authentication 后才有意义;不能用 descriptor 替代代码签名 requirement。 + +descriptor/capability grant 还必须绑定到读取它的 **同一条 client-side connection generation**。`XPCClient` 的 capability-gated API 会先在一条具体 connection 上读取 descriptor,再用同一个 `NSXPCConnection` 发送 feature selector;若这条 connection 已失效,调用失败并重新协商,不能把 Agent A 的 capability 缓存用于透明重连后的 Agent B。由于 named connection object 在 interruption 后仍可能面对新的远端进程,replacement request 还把 expected `agentInstanceID` 放进 wire message:Agent 在安装 barrier 前原子比较自己的 instance ID,Client 再校验返回 ticket,形成两侧 generation fence。 + +App 的 readiness 顺序是: + +```text +SMAppService enabled + -> serviceDescriptor() + -> protocol/capability/bundled-build comparison + -> status() + hasAccessibilityPermission() + -> ready UI +``` + +legacy Agent 没有 `serviceDescriptor`,但 descriptor 调用失败也可能只是 transport 或实现故障,不能仅凭一次失败断言远端版本: + +1. Client 失效旧 connection,并在新 connection 上重试一次 `serviceDescriptor`。 +2. 两次 descriptor 都失败后,只调用最早就存在的 `status`。 +3. `status` 成功只能证明 base contract 可达;App 将远端标记为 unverified,显示 `Agent Update Required`,保留旧 `unlock` 作为安全迁移能力,不调用 Accessibility 等新 selector。 +4. `status` 也失败才归类为 unavailable。 + +运行中 Agent 与 bundled Agent 不一致时,兼容性与更新方向必须分开判断: + +- protocol major 不同、identifier 不同或缺少 `lock-control` 时,App 不调用当前 `status`/`prepare` selector,锁状态显示 unknown;只有明确用户动作可以强制 replacement。 +- running build 高于 bundled build 时绝不自动降级;UI 可以让用户明确选择是否使用当前 App 携带的版本。 +- 只有 identifier 和 protocol major 相同、running `CFBundleVersion` 使用 dotted numeric 规则严格低于 bundled build、并支持 `lock-control + prepare-for-replacement + committed-replacement-drain` 时,才进入自动 upgrade 候选。 +- locked 候选绝不自动终止;用户必须明确选择 `Unlock and Replace Agent`。 +- unlocked 自动候选调用 `prepareForReplacement(unlockIfNeeded: false)`。Agent 在自己的串行执行边界再次确认 unlocked、安装 barrier,然后返回 `ServiceReplacementTicket`。 +- 显式 locked replacement 使用 `unlockIfNeeded: true`;Agent 先安装 barrier 再 unlock,因此不存在 `unlock → prepare` 的跨客户端空窗。 +- legacy 或没有 replacement capability 时无法建立 barrier,只能执行带风险说明的用户授权 replacement。 + +受控 replacement 的顺序是: + +```text +prepareForReplacement(unlockIfNeeded:, expectedAgentInstanceID:) -> ownership ticket + -> short-lived prepared drain rejects every new lock + -> commitReplacement(ticket) + -> non-expiring committed drain + -> await SMAppService.unregister() + -> register bundled Agent + -> reset/create Client connection + -> serviceDescriptor() again + -> require compatible build and a new agentInstanceID +``` + +replacement 是两阶段 transaction: + +1. `prepareForReplacement` 创建短期、可 cancel/expire 的独占 preparation。它覆盖“已安装 barrier 但尚未向 Service Management 提交 unregister”的窗口;owner 在这里退出不会永久占用 Agent。 +2. App 紧接着调用幂等的 `commitReplacement(ticket:)`。commit 只接受同一 Agent instance 的活跃 ticket,并把 drain 切换为不可取消、不可过期的 fail-closed 状态。若 commit reply 丢失,Client 用 `replacementStatus(ticket:)` 查询这个 ticket 的真实 phase;确认 committed 后继续,不凭 transport error猜测结果。 +3. 只有收到 commit 成功 reply 后,App 才允许调用 `SMAppService.unregister()`。因此一旦系统中可能存在在途 unregister,Agent 就不会因为 client heartbeat 丢失或任意固定 timeout 而重新接受 lock;barrier 只随旧 Agent 进程退出消失。 + +Agent 拒绝第二个 prepare,绝不允许新 coordinator 覆盖 ticket。另一份 App 从 `replacementPhase` 区分“等待短期 preparation 到期”和“已 committed、等待旧进程退出”,并周期性重新握手,但不能接管、cancel 或再次 unregister。`SMAppService` 没有暴露可用于 fencing 的 operation token;用固定 grace 让第二 coordinator 再次 unregister,会重新制造“迟到的第一次 unregister 杀死新 Agent”的风险。commit 到 unregister 提交之间仍存在极小的 coordinator crash 窗口;此时设计明确选择 fail closed,旧 Agent 持续拒绝新 lock。若 committed 状态长期不消失,用户需要重启 macOS 完成系统级恢复,而不是由 App 猜测在途操作已经取消。 + +同一 App 进程对同一个 bundled build 最多发起一次自动 replacement。自动失败、post-restart descriptor 校验失败或新 Agent 仍不兼容时只允许显式重试,避免 App activation/reconciliation 形成 restart loop。post-restart verification 完成前 `activity` 始终保持 updating,其他 reconciliation 只会等待。 + ## Listener 和 Connection 到底是什么关系 “Agent 是 Listener、wrapper 是 Connection”作为第一层理解是对的,但完整模型是: @@ -177,9 +264,9 @@ flowchart LR 在当前代码中: 1. `KeyboardLockerAgent/main.swift` 创建一个 `NSXPCListener(machServiceName:)`。 -2. 新客户端到来时,Listener 调用 `listener(_:shouldAcceptNewConnection:)`。 -3. delegate 用 `XPCAccessControl` 审查这条新 connection 的对端身份。 -4. 接受后,`XPCServerConnection.configure` 为 **这条 Agent-side connection** 设置 `exportedInterface` 和 `exportedObject`,然后 activate/resume。 +2. Listener 在 activate 前安装 `XPCAccessControl` 生成的 code-signing requirement;不满足 requirement 的 peer 会由 XPC 在调用 delegate 前拒绝。 +3. 新客户端通过身份检查后,Listener 才调用 `listener(_:shouldAcceptNewConnection:)`。 +4. delegate 通过 `XPCServerConnection.configure` 为 **这条 Agent-side connection** 设置 `exportedInterface` 和 `exportedObject`,然后 activate。 5. 下一位客户端会得到另一条 Agent-side connection,但仍指向同一个 `sharedService`。 因此 Listener 更像 socket server 的 acceptor;接受之后的 peer connection 才像一条已经建立的 socket。不过 XPC 是消息/RPC 系统,不应把它理解成可直接读取任意 bytes 的 TCP stream。 @@ -335,13 +422,13 @@ Agent peer connection 以 `XPCClient.status()` 为例: 1. `NSXPCConnection(machServiceName:)` 只创建本地 connection object;此时不验证服务名,也不保证 Agent 已运行。 -2. Client 设置 `remoteObjectInterface`、interruption/invalidation handler,然后调用 `resume()`。 -3. 当前 SDK 推荐新代码使用 `activate()`;项目的单次初始 `resume()` 仍有兼容语义:对 inactive 且未 suspend 的 connection 等价于 activate。 +2. Client 设置 `remoteObjectInterface`、双向 code-signing requirement 和 interruption/invalidation handler,然后调用 `activate()`。 +3. Agent-side connection 同样先设置 exported interface/object,再调用一次 `activate()`。 4. `remoteObjectProxyWithErrorHandler` 创建一个本地动态 proxy。它不是 `AgentService` 的引用,也不能直接访问 Agent 地址空间。 5. 对 proxy 调用 `status(reply:)` 时,Foundation 根据 `NSXPCInterface` 检查 selector 与参数形状,并把 invocation 编码成 opaque XPC message。 6. 第一条实际 message 触发 service lookup;如果需要,`launchd` 启动 Agent。 -7. Agent Listener 收到 connection request,创建 Agent-side `NSXPCConnection` 并交给 delegate。 -8. delegate 校验身份、设置 `exportedInterface/object`、resume connection 并返回 `true`;返回 `false` 会拒绝并 invalidate 它。 +7. Agent Listener 收到 connection request 后,XPC runtime 先执行 Listener 的 code-signing requirement;只有通过的 peer 才会创建 Agent-side `NSXPCConnection` 并进入 delegate。 +8. delegate 设置 `exportedInterface/object`、activate connection 并返回 `true`;返回 `false` 会拒绝并 invalidate 它。 9. Foundation 在 Agent-side connection 上解码 invocation,并调用 `AgentService.status(reply:)`。 10. `AgentService` 读取 `LockEngine.shared.isLocked`,再调用 `reply(isLocked, nil)`。 11. 这里的 reply closure 不是把 Client 的 Swift closure 复制到 Agent 执行;它是 Foundation 提供的跨进程 reply capability。调用它会生成响应 message。 @@ -403,11 +490,11 @@ sequenceDiagram 对应源码中的实际步骤是: -1. App 的 `LockController` 或 CLI 调用 `XPCClient.shared.lock()`。 +1. App 的 `LockController` 已在 readiness reconciliation 中完成 descriptor handshake,随后 App 或 CLI 调用 `XPCClient.shared.lock()`;CLI 使用的 lock/status/unlock 属于 legacy base selector。 2. `XPCClient.currentConnection()` 创建或复用 `NSXPCConnection(machServiceName:)`。 3. Client 把 `KeyboardLockerServiceProtocol` 设置成 `remoteObjectInterface`,再取得 remote proxy。 -4. 第一条实际 message 让 `launchd` 按需启动 Agent;Agent 的 `NSXPCListenerDelegate` 收到新 connection。 -5. `XPCAccessControl` 在暴露任何方法前校验调用方身份。 +4. Client 在 activate 前安装只接受同 Team、精确 Agent signing identifier 的 requirement;第一条实际 message 让 `launchd` 按需启动 Agent。 +5. Agent Listener 的 requirement 在 delegate 暴露任何方法前校验调用方身份;Client requirement 同时拒绝伪造的 Agent。 6. `XPCServerConnection` 为 Agent-side connection 设置 protocol,并把同一个 `AgentService` 实例设置为 `exportedObject`。 7. 对 remote proxy 的 `lockKeyboard(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 8. `AgentService` 把引擎操作切到主线程,因为 event tap 和 CFRunLoop 生命周期在这里维护。 @@ -420,9 +507,14 @@ sequenceDiagram | Wire method | Client API | 权威执行方 | 语义 | |---|---|---|---| +| `serviceDescriptor` | `serviceDescriptor()` | `AgentService` | bootstrap query;返回 protocol、capability、bundled metadata 和 process instance ID | | `lockKeyboard` | `lock()` | `LockEngine` | 幂等地进入全局 locked 状态;已锁时重新应用设置 | | `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | | `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | +| `prepareForReplacement` | `prepareForReplacement(unlockIfNeeded:expectedAgentInstanceID:)` | `AgentService` | Agent 原子校验 expected instance,安装短期 prepared drain,可在同一 execution turn 解锁,返回同 generation ticket | +| `commitReplacement` | `commitReplacement(ticket:)` | `AgentService` | 在提交 unregister 前把 prepared drain 幂等切换为不可过期的 committed drain | +| `replacementStatus` | `replacementStatus(ticket:)` | `AgentService` | 查询 exact ticket 的 inactive/prepared/committed phase,恢复丢失的 commit reply | +| `cancelReplacementPreparation` | `cancelReplacementPreparation(ticket:)` | `AgentService` | 仅在 commit 前由 ticket owner 解除 preparation;committed drain 拒绝 cancel | | `hasAccessibilityPermission` | `hasAccessibilityPermission()` | `AccessibilityManager` | 查询 **Agent 进程** 当前是否受信任 | | `requestAccessibilityPermission` | `requestAccessibilityPermission()` | `AccessibilityManager` | 请求系统异步显示 Agent 的授权 prompt;reply 不代表用户已授权 | | `currentSettings` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 持有的设置快照 | @@ -496,12 +588,16 @@ sequenceDiagram ## Connection 访问控制 -Agent 在 `NSXPCListenerDelegate` 中先调用 `XPCAccessControl.isConnectionAuthorized`,只有通过后才设置 `exportedObject`: +身份认证是双向且由 XPC runtime 强制执行: + +- Agent 的 `NSXPCListener` 在 activate 前调用 `setConnectionCodeSigningRequirement`,只接受与 Agent 同 Team 且 signing identifier 精确匹配主 App 或 bundled CLI 的 Client。系统在调用 delegate 前完成检查,因此未认证 peer 看不到任何 exported selector。 +- App/CLI 的 `NSXPCConnection` 在 activate 前调用 `setCodeSigningRequirement`,只接受与 Client 同 Team 且 signing identifier 精确匹配 bundled Agent 的服务进程。 +- requirement 使用 `anchor apple generic`、Apple 签名证书 `subject.OU` 中的 Team ID 和精确 `identifier`;Team ID 从当前进程已经验证的签名动态读取,不在源码中重复硬编码。 +- Debug 和 Release 使用同一条安全边界。Xcode Debug target 已配置 Apple Development 签名;unsigned、ad-hoc、错误 Team 或仅伪造 identifier 的进程都会 fail closed。 -- Release:校验签名完整性、调用方 Team ID 与正在运行的 Agent 相同,并检查 bundle/code-sign identifier allowlist。 -- Debug:保留 identifier allowlist,放宽 Team ID 与完整签名要求,便于本地开发。 +当前允许的调用方只有主 App 和 bundled CLI 的 namespaced signing identifier;不再接受通用的 `klock` identifier。仅仅知道 Mach service 名称,或在自己的签名中复制 bundle identifier,都不能调用 Agent。descriptor 中的 bundle/version/build 仍只用于兼容性判断,不参与身份认证。 -当前允许的调用方是主 App 和 bundled CLI 对应的 identifier。仅仅知道 Mach service 名称,不代表任意进程都能调用 Agent。 +`klock` 是裸 Mach-O command-line tool,因此 target 必须生成并嵌入 `__TEXT,__info_plist`;否则 `PRODUCT_BUNDLE_IDENTIFIER` 不会成为实际 code-signing identifier,`codesign` 会退回可执行文件名 `klock`,并被 Listener requirement 正确拒绝。`CREATE_INFOPLIST_SECTION_IN_BINARY` 和 `GENERATE_INFOPLIST_FILE` 属于 XPC 身份契约,不能当作无关构建设置移除。 ## `SMAppService` 状态和 XPC 状态不要混为一谈 @@ -511,20 +607,28 @@ Agent 在 `NSXPCListenerDelegate` 中先调用 `XPCAccessControl.isConnectionAut | `.enabled` | 系统允许 Agent 运行 | 继续通过 XPC 查询真实 readiness | | `.requiresApproval` | 用户需要在 Login Items 批准 | 显示说明和 System Settings 入口 | | `.notFound` | bundle 内找不到声明的 Agent/plist | 报告安装或 bundle layout 错误 | -| enabled + XPC error | Agent 未启动、崩溃、版本不兼容或连接被拒绝 | 诚实显示不可达;可提供显式 retry/restart | +| enabled + descriptor compatible | 当前进程理解所需 contract 且匹配 bundled Agent | 继续查询 lock / Accessibility readiness | +| enabled + running build lower + same major + replacement capability | 安全自动 upgrade 候选 | unlocked 时每 bundled build 自动尝试一次;locked 时要求用户确认 | +| enabled + running build higher | 旧 App 面对更新 Agent | 禁止自动降级;只提供明确用户 replacement | +| enabled + incompatible major/identity/capability | 当前 selector 语义不能安全假定 | 不查询不受保证的 lock state,显示 unknown 和 forced replacement 风险 | +| enabled + fresh-connection descriptor retry 仍失败 + base `status` success | base contract 可达,但版本无法确认 | 只保留 `status`/`unlock`,显示显式 unverified update | +| enabled + descriptor retry 和 base `status` 都失败 | Agent 未启动、崩溃或连接被拒绝 | 诚实显示不可达;可提供显式 retry/restart | +| enabled + compatible descriptor + `replacementPending` | 存在 prepared 或 committed replacement | 周期性重新握手并显示 replacement in progress;不得接管、cancel 或再次 unregister | -显式 restart 会先尽可能 unlock,再等待 `SMAppService.unregister()` 完成并重新注册 bundled Agent。不能自动 restart,因为 Agent 退出本身会释放正在工作的 event tap。 +locked Agent 不能自动 restart,因为 Agent 退出本身会释放正在工作的 event tap。只有严格更旧、unlocked、same-major 且支持 ticket barrier 的 Agent 才可受控自动 upgrade;其他情况需要明确用户动作。安全路径必须保持 barrier 直到 `unregister()` 结束,然后清除旧 Client connection 并对新进程重新握手。 ## 新增 XPC 能力时的修改顺序 -1. 在 `Core/Sources/Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 增加最小 wire method。 -2. 只使用 Objective-C/XPC 能表达的参数和 reply 类型;复杂 Swift value 优先编码成 `Data`。 -3. 在 `KeyboardLockerAgent/AgentService.swift` 实现 protocol adapter。 -4. 把真实领域行为放在 `Service`,不要堆进 App、CLI 或 wire adapter。 -5. 在 `Core/Sources/Client/XPCClient.swift` 增加薄的 async/throwing 封装。 -6. wrapper 只调用 `XPCClient`;App/CLI 不得 import `Service`。 -7. 明确方法是 query 还是 mutation,并定义 timeout 后能否确认最终结果。 -8. 如果状态可能在调用之外变化,继续用“notification signal + XPC query”,不要把通知 payload 变成第二个状态源。 +1. 在同一 protocol major 内只允许新增 selector;不得修改/移除既有 selector、参数顺序、类型或 reply 形状。无法长期保留 selector union 的破坏性升级需要新的 Mach service name。 +2. 在 `Core/Sources/Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 增加最小 wire method,在 `ServiceCapability` 增加一个稳定且永不复用的名字,并按需要提高 protocol minor。 +3. 只使用 Objective-C/XPC 能表达的参数和 reply 类型;复杂 Swift value 编码成有明确 schema 和大小上限的 `Data`。 +4. 在 `KeyboardLockerAgent/AgentService.swift` 实现 protocol adapter,并在 descriptor 声明 capability。 +5. 把真实领域行为放在 `Service`,不要堆进 App、CLI 或 wire adapter。 +6. 在 `Core/Sources/Client/XPCClient.swift` 增加薄的 async/throwing 封装;调用方先 handshake 并检查 capability。 +7. wrapper 只调用 `XPCClient`;App/CLI 不得 import `Service`。 +8. 明确方法是 query 还是 mutation,并定义 timeout 后能否确认最终结果。 +9. 如果状态可能在调用之外变化,继续用“notification signal + XPC query”,不要把通知 payload 变成第二个状态源。 +10. 在 `Core/Tests/ClientTests` 覆盖 descriptor round-trip、major/minor、unknown capability、missing capability 与 bundled build mismatch。 ## 常见误解 @@ -553,16 +657,21 @@ Agent 在 `NSXPCListenerDelegate` 中先调用 `XPCAccessControl.isConnectionAut | 想理解什么 | 入口 | |---|---| | Wire protocol、Mach name、allowlist | [`Core/Sources/Common/Shared.swift`](../Core/Sources/Common/Shared.swift) | +| Descriptor、protocol version、capability | [`Core/Sources/Common/ServiceDescriptor.swift`](../Core/Sources/Common/ServiceDescriptor.swift) | +| Client compatibility rules | [`Core/Sources/Client/ServiceCompatibility.swift`](../Core/Sources/Client/ServiceCompatibility.swift) | | Client connection、async bridge、timeout | [`Core/Sources/Client/XPCClient.swift`](../Core/Sources/Client/XPCClient.swift) | | 长命 UI 的状态订阅 | [`Core/Sources/Client/LockStateSubscriber.swift`](../Core/Sources/Client/LockStateSubscriber.swift) | | Agent listener 与进程入口 | [`KeyboardLockerAgent/main.swift`](../KeyboardLockerAgent/main.swift) | | Wire method 到领域方法的适配 | [`KeyboardLockerAgent/AgentService.swift`](../KeyboardLockerAgent/AgentService.swift) | | 新 connection 的 server 配置 | [`Core/Sources/Service/XPCServerConnection.swift`](../Core/Sources/Service/XPCServerConnection.swift) | -| 调用方身份校验 | [`Core/Sources/Service/XPCAccessControl.swift`](../Core/Sources/Service/XPCAccessControl.swift) | +| 双向 code-signing requirement | [`Core/Sources/Common/XPCCodeSigningRequirement.swift`](../Core/Sources/Common/XPCCodeSigningRequirement.swift)、[`Core/Sources/Service/XPCAccessControl.swift`](../Core/Sources/Service/XPCAccessControl.swift) | | 全局锁与 event tap | [`Core/Sources/Service/LockEngine.swift`](../Core/Sources/Service/LockEngine.swift) | | 状态变化信号 | [`Core/Sources/Service/LockStateBroadcaster.swift`](../Core/Sources/Service/LockStateBroadcaster.swift) | | Agent 注册与显式恢复 | [`KeyboardLocker/AgentRegistrar.swift`](../KeyboardLocker/AgentRegistrar.swift) | -| App readiness 与操作协调 | [`KeyboardLocker/LockController.swift`](../KeyboardLocker/LockController.swift) | +| App 的可注入 XPC/lifecycle adapter | [`KeyboardLocker/AgentCoordinationServices.swift`](../KeyboardLocker/AgentCoordinationServices.swift) | +| Registration、handshake 与 readiness 快照 | [`KeyboardLocker/AgentReadinessCoordinator.swift`](../KeyboardLocker/AgentReadinessCoordinator.swift) | +| Safe/forced Agent replacement 顺序 | [`KeyboardLocker/AgentReplacementCoordinator.swift`](../KeyboardLocker/AgentReplacementCoordinator.swift) | +| App 视图状态、任务与订阅 façade | [`KeyboardLocker/LockController.swift`](../KeyboardLocker/LockController.swift) | ## Apple 参考资料 From 967a7d0e2889ae8dc0787f9bd2abfb948274a99e Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 16 Jul 2026 21:18:20 +0800 Subject: [PATCH 027/106] fix(core): stabilize lock lifecycle handling Reconcile lock state only after observers are installed and suppress stale deliveries across cancellation and concurrent signals. Combine notifications with bounded authoritative polling for CLI unlock observation, align auto-unlock deadlines with timer rearming, and remove debug scheme actions that could silently terminate the active agent. Add deterministic regression coverage and align local validation with the active Xcode toolchain. --- .swift-version | 2 +- .swiftformat | 1 + CLAUDE.md | 4 + Core/Sources/Client/LockStateSubscriber.swift | 179 +++++++++-- Core/Sources/Client/UnlockStatusPoller.swift | 157 ++++++++++ Core/Sources/Service/LockEngine.swift | 51 +++- .../LockStateSubscriberTests.swift | 284 ++++++++++++++++++ .../ClientTests/UnlockStatusPollerTests.swift | 202 +++++++++++++ .../Tests/ClientTests/UnlockWaiterTests.swift | 202 +++++++++++++ .../LockEngineAutoUnlockScheduleTests.swift | 80 +++++ .../xcschemes/KeyboardLocker.xcscheme | 18 -- KeyboardLocker/LockController.swift | 15 +- docs/architecture.md | 6 +- docs/development.md | 7 +- docs/xpc.md | 9 +- klock/main.swift | 7 +- 16 files changed, 1166 insertions(+), 58 deletions(-) create mode 100644 Core/Sources/Client/UnlockStatusPoller.swift create mode 100644 Core/Tests/ClientTests/LockStateSubscriberTests.swift create mode 100644 Core/Tests/ClientTests/UnlockStatusPollerTests.swift create mode 100644 Core/Tests/ClientTests/UnlockWaiterTests.swift create mode 100644 Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift diff --git a/.swift-version b/.swift-version index f9ce5a9..7e58507 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.10 +xcode diff --git a/.swiftformat b/.swiftformat index 618f612..863b006 100644 --- a/.swiftformat +++ b/.swiftformat @@ -72,6 +72,7 @@ --storedvarattrs preserve --stripunusedargs always --structthreshold 0 +--swift-version 5.10 --tabwidth unspecified --throwcapturing --timezone system diff --git a/CLAUDE.md b/CLAUDE.md index f0fd672..e8d65e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,5 +32,9 @@ These override the model's defaults — the rest of Swift style is left to stand ```bash xcodebuild -scheme KeyboardLocker -configuration Debug build # or KeyboardLockerAgent / klock / Core +swift test --package-path Core swiftformat . # 2-space indent, alpha-sorted imports; run before committing ``` + +`.swift-version` intentionally selects the active Xcode toolchain. `Core/Package.swift` +and SwiftFormat keep Swift 5.10 source semantics. diff --git a/Core/Sources/Client/LockStateSubscriber.swift b/Core/Sources/Client/LockStateSubscriber.swift index d30f89e..340c138 100644 --- a/Core/Sources/Client/LockStateSubscriber.swift +++ b/Core/Sources/Client/LockStateSubscriber.swift @@ -18,17 +18,41 @@ import os /// reliably run a CFRunLoop for Darwin callbacks; the distributed center delivers on the /// main queue, which the CLI's async main drains. /// -/// Delivered values are de-duplicated, so redundant signals across the two channels surface -/// as at most one handler call per actual state change. +/// Delivered values are de-duplicated, so the initial calibration is emitted at most once and +/// redundant signals across the two channels surface as at most one handler call per actual +/// state change. public enum LockStateSubscriber { public typealias StateChangeHandler = (Bool) -> Void private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "LockStateSubscriber") - /// Subscribes to lock state changes. + /// Subscribes to the authoritative lock state. + /// After observer installation, the handler receives the current state when it differs from + /// `initialState`, then receives de-duplicated changes. Pass a trusted snapshot to avoid + /// re-delivering it; the default `nil` always emits a successful initial calibration. + /// A transiently failed initial calibration is retried on the next signal. /// Returns a token that must be retained; subscription is cancelled when the token deallocates. - public static func subscribe(_ handler: @escaping StateChangeHandler) -> ObserverToken { - let reconciler = StateReconciler(handler: handler) + public static func subscribe( + initialState: Bool? = nil, + _ handler: @escaping StateChangeHandler + ) -> ObserverToken { + subscribe( + initialState: initialState, + fetchState: { try await XPCClient.shared.status() }, + handler + ) + } + + static func subscribe( + initialState: Bool? = nil, + fetchState: @escaping StateReconciler.FetchState, + _ handler: @escaping StateChangeHandler + ) -> ObserverToken { + let reconciler = StateReconciler( + initialState: initialState, + fetchState: fetchState, + handler: handler + ) let darwin = DarwinObserver(name: NotificationNames.stateChanged) { reconciler.signal() @@ -42,17 +66,24 @@ public enum LockStateSubscriber { reconciler.signal() } + // Notifications are hints rather than a durable event log. Calibrate only after both + // observers are installed so a state change racing this first fetch schedules a follow-up + // pass instead of falling into a subscribe/snapshot gap. + reconciler.signal() + return ObserverToken { + reconciler.cancel() darwin.cancel() DistributedNotificationCenter.default().removeObserver(distributed) } } - /// Lock state changes as an `AsyncStream`. The subscription lives for the stream's lifetime - /// and is torn down automatically when the consuming task is cancelled. + /// The authoritative lock state as an `AsyncStream`, beginning with an initial calibration. + /// The subscription lives for the stream's lifetime and is torn down automatically when the + /// consuming task is cancelled. public static var stateChanges: AsyncStream { AsyncStream { continuation in - let token = subscribe { continuation.yield($0) } + let token = subscribe(initialState: nil) { continuation.yield($0) } continuation.onTermination = { _ in // Retain the token until termination, then release to unsubscribe. _ = token @@ -66,53 +97,151 @@ public enum LockStateSubscriber { /// On each signal, fetches the Agent's authoritative lock state and forwards it to the handler /// only when it differs from the last value delivered — collapsing duplicate cross-channel /// signals into one handler call per real change. -private final class StateReconciler: @unchecked Sendable { +final class StateReconciler: @unchecked Sendable { + typealias FetchState = @Sendable () async throws -> Bool + + private let fetchState: FetchState private let handler: LockStateSubscriber.StateChangeHandler private let lock = OSAllocatedUnfairLock() private var lastDelivered: Bool? + private var hasPendingSignal = false + private var isReconciling = false + private var isCancelled = false + private var workerTask: Task? /// Transient XPC failures (e.g. the Agent being relaunched on demand) are retried so a real /// state change is never dropped just because one fetch raced a reconnect. private static let fetchAttempts = 3 private static let retryDelay: Duration = .milliseconds(200) - init(handler: @escaping LockStateSubscriber.StateChangeHandler) { + init( + initialState: Bool? = nil, + fetchState: @escaping FetchState, + handler: @escaping LockStateSubscriber.StateChangeHandler + ) { + lastDelivered = initialState + self.fetchState = fetchState self.handler = handler } func signal() { - Task { [weak self] in - guard let self, let isLocked = await fetchState() else { + lock.withLock { + guard !isCancelled else { return } - let shouldDeliver: Bool = lock.withLock { - guard lastDelivered != isLocked else { - return false + hasPendingSignal = true + guard !isReconciling else { + return + } + + isReconciling = true + // Create and retain the worker while holding the same lock that protects + // `isReconciling`. The task's first state access blocks on this lock, so a completed + // worker can never overwrite the handle of a newer active worker. + workerTask = Task { [weak self] in + guard let self else { + return } - lastDelivered = isLocked - return true + await reconcilePendingSignals() } + } + } - guard shouldDeliver else { - return + func cancel() { + let task: Task? = lock.withLock { + guard !isCancelled else { + return nil } - await MainActor.run { self.handler(isLocked) } + isCancelled = true + hasPendingSignal = false + defer { workerTask = nil } + return workerTask } + task?.cancel() } - private func fetchState() async -> Bool? { + private func reconcilePendingSignals() async { + while takePendingSignal() { + guard let isLocked = await fetchAuthoritativeState() else { + continue + } + + await MainActor.run { + let shouldDeliver: Bool = self.lock.withLock { + guard !self.isCancelled, + self.lastDelivered != isLocked + else { + return false + } + + self.lastDelivered = isLocked + return true + } + guard shouldDeliver else { + return + } + + self.handler(isLocked) + } + } + } + + /// Atomically claims one pending pass. Clearing the worker flag in the same critical section + /// prevents a signal from being stranded between the worker's final check and its exit. + private func takePendingSignal() -> Bool { + lock.withLock { + guard !isCancelled else { + isReconciling = false + return false + } + + guard hasPendingSignal else { + isReconciling = false + return false + } + + hasPendingSignal = false + return true + } + } + + private func fetchAuthoritativeState() async -> Bool? { for attempt in 1 ... Self.fetchAttempts { - if let isLocked = try? await XPCClient.shared.status() { - return isLocked + guard shouldContinue else { + return nil } - if attempt < Self.fetchAttempts { - try? await Task.sleep(for: Self.retryDelay) + + do { + let isLocked = try await fetchState() + return shouldContinue ? isLocked : nil + } catch { + guard attempt < Self.fetchAttempts, + shouldContinue + else { + return nil + } + + do { + try await Task.sleep(for: Self.retryDelay) + } catch { + return nil + } } } return nil } + + private var shouldContinue: Bool { + guard !Task.isCancelled else { + return false + } + + return lock.withLock { + !isCancelled + } + } } // MARK: - Darwin Observer diff --git a/Core/Sources/Client/UnlockStatusPoller.swift b/Core/Sources/Client/UnlockStatusPoller.swift new file mode 100644 index 0000000..bfc4b41 --- /dev/null +++ b/Core/Sources/Client/UnlockStatusPoller.swift @@ -0,0 +1,157 @@ +import Foundation + +struct UnlockWaiter: Sendable { + typealias CancelPolling = @Sendable () -> Void + typealias PollUntilUnlocked = @Sendable () async throws -> Void + + private let cancelPolling: CancelPolling + private let pollUntilUnlocked: PollUntilUnlocked + private let stateChanges: AsyncStream + + init( + stateChanges: AsyncStream, + pollUntilUnlocked: @escaping PollUntilUnlocked, + cancelPolling: @escaping CancelPolling + ) { + self.stateChanges = stateChanges + self.pollUntilUnlocked = pollUntilUnlocked + self.cancelPolling = cancelPolling + } + + func waitUntilUnlocked() async throws { + try await withThrowingTaskGroup(of: ObservationResult.self) { group in + group.addTask { + for await isLocked in stateChanges { + if !isLocked { + return .unlocked + } + } + return .observationEnded + } + + group.addTask { + try await withTaskCancellationHandler { + try await pollUntilUnlocked() + } onCancel: { + // XPC reply continuations do not observe Swift task cancellation by themselves. + // Invalidating this client's cached connection makes an in-flight status request + // complete through its proxy error handler instead of delaying group teardown until + // the response timeout. + cancelPolling() + } + return .unlocked + } + + defer { + group.cancelAll() + } + + while let result = try await group.next() { + switch result { + case .unlocked: + return + case .observationEnded: + continue + } + } + + throw XPCClientError.serviceUnavailable + } + } + + private enum ObservationResult: Sendable { + case observationEnded + case unlocked + } +} + +struct UnlockStatusPoller: Sendable { + typealias FetchStatus = @Sendable () async throws -> Bool + typealias ResetConnection = @Sendable () -> Void + typealias Sleep = @Sendable (Duration) async throws -> Void + + private let fetchStatus: FetchStatus + private let maximumConsecutiveFailures: Int + private let pollInterval: Duration + private let resetConnection: ResetConnection + private let sleep: Sleep + + init( + pollInterval: Duration, + maximumConsecutiveFailures: Int, + fetchStatus: @escaping FetchStatus, + resetConnection: @escaping ResetConnection, + sleep: @escaping Sleep + ) { + precondition(maximumConsecutiveFailures > 0) + self.pollInterval = pollInterval + self.maximumConsecutiveFailures = maximumConsecutiveFailures + self.fetchStatus = fetchStatus + self.resetConnection = resetConnection + self.sleep = sleep + } + + func waitUntilUnlocked() async throws { + var consecutiveFailures = 0 + + while true { + try Task.checkCancellation() + + do { + let isLocked = try await fetchStatus() + if !isLocked { + return + } + consecutiveFailures = 0 + } catch is CancellationError { + throw CancellationError() + } catch { + consecutiveFailures += 1 + resetConnection() + if consecutiveFailures >= maximumConsecutiveFailures { + throw error + } + } + + try await sleep(pollInterval) + } + } +} + +extension XPCClient { + /// Waits until the Agent reports that the global keyboard lock has been released. + /// + /// This observer does not own the lock. Notifications provide prompt updates, while periodic + /// authoritative reads recover when notifications are lost or an Agent restart releases the + /// event tap without broadcasting. Persistent query failures are surfaced instead of being + /// misreported as an unlock. + public func waitUntilUnlocked() async throws { + let waiter = UnlockWaiter( + stateChanges: LockStateSubscriber.stateChanges, + pollUntilUnlocked: { [self] in + try await pollUntilAuthoritativeUnlock() + }, + cancelPolling: { [self] in + resetConnection() + } + ) + try await waiter.waitUntilUnlocked() + } + + private func pollUntilAuthoritativeUnlock() async throws { + let poller = UnlockStatusPoller( + pollInterval: .seconds(1), + maximumConsecutiveFailures: 3, + fetchStatus: { [self] in + try await status() + }, + resetConnection: { [self] in + resetConnection() + }, + sleep: { interval in + try await Task.sleep(for: interval) + } + ) + try await poller.waitUntilUnlocked() + } +} diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 5b13069..ce42337 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -4,6 +4,37 @@ import CoreGraphics import Foundation import os +struct AutoUnlockSchedule: Equatable { + let deadline: Date + let delay: TimeInterval + + static func make( + timeout: TimeInterval?, + referenceDate: Date, + currentDate: Date + ) -> Self? { + guard + let timeout, + timeout.isFinite, + timeout > 0, + referenceDate.timeIntervalSinceReferenceDate.isFinite, + currentDate.timeIntervalSinceReferenceDate.isFinite + else { + return nil + } + + let deadline = referenceDate.addingTimeInterval(timeout) + guard deadline.timeIntervalSinceReferenceDate.isFinite else { + return nil + } + + return Self( + deadline: deadline, + delay: max(0, deadline.timeIntervalSince(currentDate)) + ) + } +} + /// Use refcon to bridge C callback to Swift instance since CGEventTap requires C function pointer private func eventTapCallback( proxy: CGEventTapProxy, @@ -181,20 +212,30 @@ public class LockEngine { private func configureAutoUnlockTimerIfNeeded() { cancelAutoUnlockTimer() - let (timeout, startDate) = withLock { - (_activeSettings.autoUnlockPolicy.timeout, _lockStartedAt) + let (timeout, isLocked) = withLock { + (_activeSettings.autoUnlockPolicy.timeout, _isLocked) } - guard let timeout, timeout > 0, let startDate else { + // Re-arming starts a fresh timeout window without changing the duration + // of the lock session tracked by `_lockStartedAt`. + let referenceDate = Date() + guard + isLocked, + let schedule = AutoUnlockSchedule.make( + timeout: timeout, + referenceDate: referenceDate, + currentDate: Date() + ) + else { return } withLock { - _autoUnlockTargetDate = startDate.addingTimeInterval(timeout) + _autoUnlockTargetDate = schedule.deadline } let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now() + timeout) + timer.schedule(deadline: .now() + schedule.delay) timer.setEventHandler { [weak self] in self?.unlock() } diff --git a/Core/Tests/ClientTests/LockStateSubscriberTests.swift b/Core/Tests/ClientTests/LockStateSubscriberTests.swift new file mode 100644 index 0000000..47fb4e1 --- /dev/null +++ b/Core/Tests/ClientTests/LockStateSubscriberTests.swift @@ -0,0 +1,284 @@ +@testable import Client +import XCTest + +final class LockStateSubscriberTests: XCTestCase { + func testSubscriptionCalibratesImmediatelyAfterInstallingObservers() async { + let fetcher = ControlledStateFetcher() + let receivedInitialState = expectation(description: "Received initial authoritative state") + let recorder = StateRecorder { + receivedInitialState.fulfill() + } + + let token = LockStateSubscriber.subscribe( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + await fetcher.resolveFetch(0, with: true) + await fulfillment(of: [receivedInitialState], timeout: 1) + + XCTAssertEqual(recorder.values, [true]) + withExtendedLifetime(token) {} + } + + func testInitialCalibrationMatchingSeedIsNotRedelivered() async { + let fetcher = ControlledStateFetcher() + let receivedState = expectation(description: "Did not redeliver seeded state") + receivedState.isInverted = true + let recorder = StateRecorder { + receivedState.fulfill() + } + + let token = LockStateSubscriber.subscribe( + initialState: true, + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + await fetcher.resolveFetch(0, with: true) + await fulfillment(of: [receivedState], timeout: 0.1) + + XCTAssertTrue(recorder.values.isEmpty) + withExtendedLifetime(token) {} + } + + func testInitialCalibrationDifferingFromSeedIsDelivered() async { + let fetcher = ControlledStateFetcher() + let receivedState = expectation(description: "Received state differing from seed") + let recorder = StateRecorder { + receivedState.fulfill() + } + + let token = LockStateSubscriber.subscribe( + initialState: false, + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + await fetcher.resolveFetch(0, with: true) + await fulfillment(of: [receivedState], timeout: 1) + + XCTAssertEqual(recorder.values, [true]) + withExtendedLifetime(token) {} + } + + func testReleasingTokenSuppressesPendingCalibrationDelivery() async { + let fetcher = ControlledStateFetcher() + let receivedState = expectation(description: "Did not receive state after cancellation") + receivedState.isInverted = true + let recorder = StateRecorder { + receivedState.fulfill() + } + + var token: ObserverToken? = LockStateSubscriber.subscribe( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + token = nil + await fetcher.resolveFetch(0, with: true) + await fulfillment(of: [receivedState], timeout: 0.1) + + let fetchMetrics = await fetcher.metrics + XCTAssertNil(token) + XCTAssertTrue(recorder.values.isEmpty) + XCTAssertEqual(fetchMetrics.startedCount, 1) + } + + func testSignalsDuringFetchAreSerializedAndCoalescedIntoOneFollowUp() async { + let fetcher = ControlledStateFetcher() + let receivedStates = expectation(description: "Received reconciled states") + receivedStates.expectedFulfillmentCount = 2 + let recorder = StateRecorder { + receivedStates.fulfill() + } + let reconciler = StateReconciler( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + reconciler.signal() + await fetcher.waitUntilStarted(1) + + reconciler.signal() + reconciler.signal() + reconciler.signal() + await Task.yield() + + var fetchMetrics = await fetcher.metrics + XCTAssertEqual(fetchMetrics.startedCount, 1) + XCTAssertEqual(fetchMetrics.maximumConcurrentFetches, 1) + + await fetcher.resolveFetch(0, with: false) + await fetcher.waitUntilStarted(2) + + fetchMetrics = await fetcher.metrics + XCTAssertEqual(fetchMetrics.startedCount, 2) + XCTAssertEqual(fetchMetrics.maximumConcurrentFetches, 1) + + await fetcher.resolveFetch(1, with: true) + await fulfillment(of: [receivedStates], timeout: 1) + await Task.yield() + + fetchMetrics = await fetcher.metrics + XCTAssertEqual(recorder.values, [false, true]) + XCTAssertEqual(fetchMetrics.startedCount, 2) + XCTAssertEqual(fetchMetrics.maximumConcurrentFetches, 1) + } + + func testConsecutiveEqualAuthoritativeStatesAreDeduplicated() async { + let fetcher = ControlledStateFetcher() + let receivedStates = expectation(description: "Received distinct states") + receivedStates.expectedFulfillmentCount = 2 + let recorder = StateRecorder { + receivedStates.fulfill() + } + let reconciler = StateReconciler( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + reconciler.signal() + await fetcher.waitUntilStarted(1) + await fetcher.resolveFetch(0, with: true) + await recorder.waitUntilCount(1) + + reconciler.signal() + await fetcher.waitUntilStarted(2) + await fetcher.resolveFetch(1, with: true) + await fetcher.waitUntilCompleted(2) + + reconciler.signal() + await fetcher.waitUntilStarted(3) + await fetcher.resolveFetch(2, with: false) + await fulfillment(of: [receivedStates], timeout: 1) + + XCTAssertEqual(recorder.values, [true, false]) + } +} + +private actor ControlledStateFetcher { + private var continuations: [CheckedContinuation?] = [] + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var completionWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private(set) var startedCount = 0 + private(set) var completedCount = 0 + private(set) var concurrentFetches = 0 + private(set) var maximumConcurrentFetches = 0 + + var metrics: (startedCount: Int, maximumConcurrentFetches: Int) { + (startedCount, maximumConcurrentFetches) + } + + func fetch() async throws -> Bool { + startedCount += 1 + concurrentFetches += 1 + maximumConcurrentFetches = max(maximumConcurrentFetches, concurrentFetches) + + let readyStartWaiters = startWaiters.filter { $0.count <= startedCount } + startWaiters.removeAll { $0.count <= startedCount } + readyStartWaiters.forEach { $0.continuation.resume() } + + return try await withCheckedThrowingContinuation { continuation in + continuations.append(continuation) + } + } + + func resolveFetch(_ index: Int, with value: Bool) { + guard continuations.indices.contains(index), + let continuation = continuations[index] + else { + XCTFail("No pending fetch at index \(index)") + return + } + + continuations[index] = nil + concurrentFetches -= 1 + completedCount += 1 + continuation.resume(returning: value) + + let readyCompletionWaiters = completionWaiters.filter { $0.count <= completedCount } + completionWaiters.removeAll { $0.count <= completedCount } + readyCompletionWaiters.forEach { $0.continuation.resume() } + } + + func waitUntilStarted(_ count: Int) async { + guard startedCount < count else { + return + } + + await withCheckedContinuation { continuation in + startWaiters.append((count, continuation)) + } + } + + func waitUntilCompleted(_ count: Int) async { + guard completedCount < count else { + return + } + + await withCheckedContinuation { continuation in + completionWaiters.append((count, continuation)) + } + } +} + +private final class StateRecorder: @unchecked Sendable { + private typealias CountWaiter = ( + count: Int, + continuation: CheckedContinuation + ) + + private let lock = NSLock() + private let onRecord: () -> Void + private var recordedValues: [Bool] = [] + private var countWaiters: [CountWaiter] = [] + + init(onRecord: @escaping () -> Void = {}) { + self.onRecord = onRecord + } + + var values: [Bool] { + lock.withLock { + recordedValues + } + } + + func record(_ value: Bool) { + let readyWaiters: [CountWaiter] = lock.withLock { + recordedValues.append(value) + let ready = countWaiters.filter { $0.count <= recordedValues.count } + countWaiters.removeAll { $0.count <= recordedValues.count } + return ready + } + readyWaiters.forEach { $0.continuation.resume() } + onRecord() + } + + func waitUntilCount(_ count: Int) async { + await withCheckedContinuation { continuation in + let isAlreadySatisfied = lock.withLock { + guard recordedValues.count < count else { + return true + } + + countWaiters.append((count, continuation)) + return false + } + + if isAlreadySatisfied { + continuation.resume() + } + } + } +} diff --git a/Core/Tests/ClientTests/UnlockStatusPollerTests.swift b/Core/Tests/ClientTests/UnlockStatusPollerTests.swift new file mode 100644 index 0000000..13607d3 --- /dev/null +++ b/Core/Tests/ClientTests/UnlockStatusPollerTests.swift @@ -0,0 +1,202 @@ +@testable import Client +import Foundation +import XCTest + +final class UnlockStatusPollerTests: XCTestCase { + func testReturnsImmediatelyWhenAlreadyUnlocked() async throws { + let source = ScriptedStatusSource([.value(false)]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + XCTAssertEqual(fetchCount, 1) + XCTAssertEqual(resetRecorder.count, 0) + XCTAssertEqual(sleepCount, 0) + } + + func testKeepsPollingUntilAuthoritativeStateIsUnlocked() async throws { + let source = ScriptedStatusSource([.value(true), .value(false)]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + XCTAssertEqual(fetchCount, 2) + XCTAssertEqual(resetRecorder.count, 0) + XCTAssertEqual(sleepCount, 1) + } + + func testTransientFailureResetsConnectionAndCanRecoverAsUnlocked() async throws { + let source = ScriptedStatusSource([ + .failure(.first), + .value(false), + ]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + XCTAssertEqual(fetchCount, 2) + XCTAssertEqual(resetRecorder.count, 1) + XCTAssertEqual(sleepCount, 1) + } + + func testSuccessfulLockedReadClearsConsecutiveFailureCount() async throws { + let source = ScriptedStatusSource([ + .failure(.first), + .value(true), + .failure(.second), + .value(true), + .failure(.third), + .value(false), + ]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + maximumConsecutiveFailures: 2, + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + XCTAssertEqual(fetchCount, 6) + XCTAssertEqual(resetRecorder.count, 3) + XCTAssertEqual(sleepCount, 5) + } + + func testThrowsLastErrorAfterMaximumConsecutiveFailures() async { + let source = ScriptedStatusSource([ + .failure(.first), + .failure(.second), + .failure(.third), + ]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + do { + try await poller.waitUntilUnlocked() + XCTFail("Expected polling to fail") + } catch { + XCTAssertEqual(error as? TestFailure, .third) + } + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + XCTAssertEqual(fetchCount, 3) + XCTAssertEqual(resetRecorder.count, 3) + XCTAssertEqual(sleepCount, 2) + } + + private func makePoller( + maximumConsecutiveFailures: Int = 3, + source: ScriptedStatusSource, + resetRecorder: ResetRecorder, + sleepRecorder: SleepRecorder + ) -> UnlockStatusPoller { + UnlockStatusPoller( + pollInterval: .seconds(1), + maximumConsecutiveFailures: maximumConsecutiveFailures, + fetchStatus: { + try await source.fetch() + }, + resetConnection: { + resetRecorder.record() + }, + sleep: { _ in + await sleepRecorder.record() + } + ) + } +} + +private actor ScriptedStatusSource { + enum Outcome: Sendable { + case failure(TestFailure) + case value(Bool) + } + + private var outcomes: [Outcome] + private(set) var fetchCount = 0 + + init(_ outcomes: [Outcome]) { + self.outcomes = outcomes + } + + func fetch() throws -> Bool { + fetchCount += 1 + guard !outcomes.isEmpty else { + XCTFail("Status source exhausted") + throw TestFailure.sourceExhausted + } + + switch outcomes.removeFirst() { + case let .failure(error): + throw error + case let .value(isLocked): + return isLocked + } + } +} + +private final class ResetRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedCount = 0 + + var count: Int { + lock.withLock { + recordedCount + } + } + + func record() { + lock.withLock { + recordedCount += 1 + } + } +} + +private actor SleepRecorder { + private(set) var count = 0 + + func record() { + count += 1 + } +} + +private enum TestFailure: Error, Equatable, Sendable { + case first + case second + case sourceExhausted + case third +} diff --git a/Core/Tests/ClientTests/UnlockWaiterTests.swift b/Core/Tests/ClientTests/UnlockWaiterTests.swift new file mode 100644 index 0000000..7d28a6e --- /dev/null +++ b/Core/Tests/ClientTests/UnlockWaiterTests.swift @@ -0,0 +1,202 @@ +@testable import Client +import Foundation +import XCTest + +final class UnlockWaiterTests: XCTestCase { + func testNotificationUnlockCancelsInFlightPolling() async throws { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + + stateContinuation.yield(false) + try await waitTask.value + + XCTAssertEqual(polling.cancellationCount, 1) + stateContinuation.finish() + } + + func testPollingUnlockCancelsObservation() async throws { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + + polling.succeed() + try await waitTask.value + + XCTAssertEqual(polling.cancellationCount, 0) + stateContinuation.finish() + } + + func testEndedObservationKeepsWaitingForPollingFallback() async throws { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + + stateContinuation.finish() + polling.succeed() + + try await waitTask.value + XCTAssertEqual(polling.cancellationCount, 0) + } + + func testPollingFailureIsPropagated() async { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + polling.fail(with: TestFailure.pollFailed) + + do { + try await waitTask.value + XCTFail("Expected polling failure") + } catch { + XCTAssertEqual(error as? TestFailure, .pollFailed) + } + + XCTAssertEqual(polling.cancellationCount, 0) + stateContinuation.finish() + } + + func testCancellingWaitCancelsInFlightPolling() async { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + waitTask.cancel() + + do { + try await waitTask.value + XCTFail("Expected cancellation") + } catch { + XCTAssertTrue(error is CancellationError) + } + + XCTAssertEqual(polling.cancellationCount, 1) + stateContinuation.finish() + } + + private func makeWaiter( + stateChanges: AsyncStream, + polling: ControlledPollingOperation + ) -> UnlockWaiter { + UnlockWaiter( + stateChanges: stateChanges, + pollUntilUnlocked: { + try await polling.run() + }, + cancelPolling: { + polling.cancel() + } + ) + } +} + +private final class ControlledPollingOperation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var didStart = false + private var startWaiters: [CheckedContinuation] = [] + private var recordedCancellationCount = 0 + + var cancellationCount: Int { + lock.withLock { + recordedCancellationCount + } + } + + func run() async throws { + try await withCheckedThrowingContinuation { continuation in + let readyWaiters: [CheckedContinuation] = lock.withLock { + precondition(self.continuation == nil) + self.continuation = continuation + didStart = true + defer { startWaiters.removeAll() } + return startWaiters + } + readyWaiters.forEach { $0.resume() } + } + } + + func waitUntilStarted() async { + await withCheckedContinuation { continuation in + let isAlreadyStarted = lock.withLock { + guard !didStart else { + return true + } + + startWaiters.append(continuation) + return false + } + + if isAlreadyStarted { + continuation.resume() + } + } + } + + func succeed() { + takeContinuation()?.resume() + } + + func fail(with error: Error) { + takeContinuation()?.resume(throwing: error) + } + + func cancel() { + let pendingContinuation: CheckedContinuation? = lock.withLock { + recordedCancellationCount += 1 + defer { continuation = nil } + return continuation + } + pendingContinuation?.resume(throwing: CancellationError()) + } + + private func takeContinuation() -> CheckedContinuation? { + lock.withLock { + defer { continuation = nil } + return continuation + } + } +} + +private enum TestFailure: Error, Equatable { + case pollFailed +} diff --git a/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift new file mode 100644 index 0000000..d3a6c0b --- /dev/null +++ b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift @@ -0,0 +1,80 @@ +import Foundation +@testable import Service +import XCTest + +final class LockEngineAutoUnlockScheduleTests: XCTestCase { + private let initialDate = Date(timeIntervalSinceReferenceDate: 1000) + + func testInitialScheduleUsesTimeoutForDeadlineAndDelay() throws { + let schedule = try XCTUnwrap( + AutoUnlockSchedule.make( + timeout: 60, + referenceDate: initialDate, + currentDate: initialDate + ) + ) + + XCTAssertEqual(schedule.deadline, initialDate.addingTimeInterval(60)) + XCTAssertEqual(schedule.delay, 60) + } + + func testRearmedScheduleStartsANewTimeoutWindow() throws { + let initialSchedule = try XCTUnwrap( + AutoUnlockSchedule.make( + timeout: 60, + referenceDate: initialDate, + currentDate: initialDate + ) + ) + let rearmDate = initialDate.addingTimeInterval(15) + let rearmedSchedule = try XCTUnwrap( + AutoUnlockSchedule.make( + timeout: 60, + referenceDate: rearmDate, + currentDate: rearmDate + ) + ) + + XCTAssertEqual(rearmedSchedule.deadline, rearmDate.addingTimeInterval(60)) + XCTAssertEqual(rearmedSchedule.delay, 60) + XCTAssertEqual( + rearmedSchedule.deadline.timeIntervalSince(initialSchedule.deadline), + 15 + ) + } + + func testPastDeadlineSchedulesImmediateUnlock() throws { + let schedule = try XCTUnwrap( + AutoUnlockSchedule.make( + timeout: 10, + referenceDate: initialDate, + currentDate: initialDate.addingTimeInterval(15) + ) + ) + + XCTAssertEqual(schedule.deadline, initialDate.addingTimeInterval(10)) + XCTAssertEqual(schedule.delay, 0) + } + + func testInvalidTimeoutDoesNotCreateSchedule() { + let invalidTimeouts: [TimeInterval?] = [ + nil, + 0, + -1, + .nan, + .infinity, + -.infinity, + ] + + for timeout in invalidTimeouts { + XCTAssertNil( + AutoUnlockSchedule.make( + timeout: timeout, + referenceDate: initialDate, + currentDate: initialDate + ), + "Expected \(String(describing: timeout)) to be rejected" + ) + } + } +} diff --git a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme index d61dbec..17257f5 100644 --- a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme +++ b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme @@ -40,24 +40,6 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - - - - - - - - - - - - `)观察状态 —— 绝不从"我这次调用是否成功"推断。 +6. wrapper 通过 `LockStateSubscriber.subscribe(initialState:_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态。subscriber 会在 observer 安装完成后立即拉取一次权威初始状态,后续信号串行合并并再次查询 —— 绝不从"我这次调用是否成功"或通知 payload 推断。 -> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**幂等**的(已锁时再锁会重新应用设置并返回成功)。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 +> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**幂等**的(已锁时再锁会重新应用设置并返回成功)。重复 lock 或锁定期间更新设置会从本次调用重新开始完整的 auto-unlock timeout window,但不会改写整次锁定会话的起点。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 ## 组件地图 @@ -31,7 +31,8 @@ **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` - `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条自动重连的连接;所有调用共享有界响应超时,超时只失效对应连接。`lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;optional method 在同一 connection 上完成 descriptor/capability gate;replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 - `ServiceCompatibility.swift`:纯值兼容性规则与任意精度 dotted-numeric `ServiceBuildVersion` —— major 必须相等、running minor 不得低于最低版本、required capabilities 必须齐全,且运行中 Agent 的 identifier/version/build 必须与 bundled Agent 一致。 -- `LockStateSubscriber.swift`:订阅 Darwin + Distributed 广播,把每个信号当作提示,并通过 `XPCClient.status()` 拉取权威状态(带重试、去重)→ `ObserverToken`,另有 `stateChanges`(`AsyncStream`)。只有长命 UI 需要它;一次性面直接读 `status()`(见 architecture 的"状态同步")。 +- `LockStateSubscriber.swift`:先安装 Darwin + Distributed observer,再立即拉取一次权威状态;后续把每个信号当作提示,通过 `XPCClient.status()` 串行校准(带重试、signal coalescing、去重和 cancellation fence)→ `ObserverToken`,另有会先产出当前权威状态的 `stateChanges`(`AsyncStream`)。取消会丢弃尚未进入 handler 的结果,但不会回溯撤销已经开始执行的 handler。长命 UI 使用 snapshot seed 避免重复呈现相同状态;一次性面直接读 `status()`(见 architecture 的"状态同步")。 +- `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 - `LockEngine.swift`:CGEventTap 单例、幂等的 `lock(settings:)`、`updateSettings(_:)`、自动解锁定时器、热键检测、`OSAllocatedUnfairLock` 状态、`os.Logger`。 diff --git a/docs/xpc.md b/docs/xpc.md index d19c95e..5fd4b2a 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -528,7 +528,7 @@ Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使 - App 和 CLI 各有自己的 connection。 - 某条 connection interruption/invalidation 后,只清除 Client 侧缓存;下一次调用会重新连接。 - App 退出、CLI 退出或 connection 断开,不会触发 unlock。 -- `klock lock` 当前会继续运行以打印后续的 `Unlocked.`,但它只是观察者;强制结束 CLI 不会解除锁。 +- `klock lock` 当前会继续运行以打印后续的 `Unlocked.`,但它只是观察者;强制结束 CLI 不会解除锁。等待期间 notification subscriber 提供及时更新,周期性 `status()` 提供丢通知与 Agent 重启后的恢复;连续无法取得权威状态时 CLI 报错退出。 - Agent 退出则不同:event tap 属于 Agent 进程,进程退出会由系统释放它,内存中的 locked 状态也会消失。 因此项目刻意没有 `LockSession`、client ownership 或“连接释放时自动 unlock”之类的抽象。领域事实是一个物理键盘对应一个由 Agent 持有的全局状态。 @@ -569,6 +569,11 @@ sequenceDiagram participant Client as XPCClient participant UI as Long-lived UI + UI->>Subscriber: subscribe(last known state) + Subscriber->>Client: initial status() after observers installed + Client->>Agent: authoritative XPC query + Agent-->>Subscriber: current state + Subscriber-->>UI: de-duplicated initial state Agent->>Broadcast: state changed Broadcast-->>Subscriber: Darwin + Distributed signals Subscriber->>Client: status() @@ -581,7 +586,9 @@ sequenceDiagram 关键点: - Darwin 和 Distributed notification 都不携带锁状态,只表达“可能有变化”。 +- subscriber 在两个 observer 都安装完成后才发起初始 `status()`;setup 期间发生的变化会被初始查询或 follow-up signal 覆盖。 - 通知可能重复、丢失或乱序,所以 subscriber 收到信号后必须再走 XPC `status()`。 +- 同一 subscription 内最多只有一个权威查询 worker;并发信号被合并为后续查询,取消后尚未进入 handler 的在途结果不再交付。 - 两个通知通道是为了提高不同进程状态下的交付可靠性,不是两套状态源。 - App 打开菜单或重新变为 active 时还会主动 reconcile,以弥补挂起期间错过的通知。 - 一次性 `status` / `unlock` 命令直接查询 Agent,不需要为了读取当前状态先等待通知。 diff --git a/klock/main.swift b/klock/main.swift index 97f8649..6a90c36 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -47,10 +47,13 @@ enum KlockCLI { ?? KeyboardLockerSettings.default.unlockHotkey.displayString print("Locked. Press \(hotkey) to unlock.") - // Stay alive to report when the lock is released (hotkey, timeout, or another surface). - for await isLocked in LockStateSubscriber.stateChanges where !isLocked { + do { + try await XPCClient.shared.waitUntilUnlocked() print("Unlocked.") exit(ExitCode.success) + } catch { + reportFailure(error) + exit(ExitCode.error) } } From 17d01334bc106d0af372dd480a86694880213d7a Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 09:42:57 +0800 Subject: [PATCH 028/106] refactor(app): remove presentation layer Keep app-side coordination framework-independent while the UI is redesigned.\n\nReplace the SwiftUI bootstrap with headless Agent registration and update the architecture documentation to match. --- CLAUDE.md | 2 +- .../AgentCoordinationServices.swift | 13 +- .../AgentReadinessCoordinator.swift | 3 +- KeyboardLocker/AgentRegistrar.swift | 4 - ...kController.swift => AppCoordinator.swift} | 95 +---- KeyboardLocker/ContentView.swift | 42 -- KeyboardLocker/KeyboardLockerApp.swift | 21 - .../KeyboardLockerApplication.swift | 7 + KeyboardLocker/Navigation/AppRoute.swift | 13 - KeyboardLocker/Views/AboutView.swift | 33 -- KeyboardLocker/Views/MainView.swift | 396 ------------------ KeyboardLocker/Views/SettingsView.swift | 33 -- docs/architecture.md | 4 +- docs/development.md | 6 +- docs/xpc.md | 12 +- 15 files changed, 35 insertions(+), 649 deletions(-) rename KeyboardLocker/{LockController.swift => AppCoordinator.swift} (85%) delete mode 100644 KeyboardLocker/ContentView.swift delete mode 100644 KeyboardLocker/KeyboardLockerApp.swift create mode 100644 KeyboardLocker/KeyboardLockerApplication.swift delete mode 100644 KeyboardLocker/Navigation/AppRoute.swift delete mode 100644 KeyboardLocker/Views/AboutView.swift delete mode 100644 KeyboardLocker/Views/MainView.swift delete mode 100644 KeyboardLocker/Views/SettingsView.swift diff --git a/CLAUDE.md b/CLAUDE.md index e8d65e8..6c306b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in this repository. KeyboardLocker suppresses standard keyboard events via CGEventTap on macOS. Mouse input and system-defined media keys remain available. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): -- **KeyboardLocker** (App, SwiftUI) — imports `Client` +- **KeyboardLocker** (App, headless Agent-registration bootstrap; presentation layer intentionally absent) — imports `Client` - **KeyboardLockerAgent** (XPC service, runs the lock engine, holds Accessibility permission) — imports `Service` - **klock** (CLI) — imports `Client` - **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, App/CLI), `Service` (lock engine, Agent). `Client`/`Service` both `@_exported import Common`. diff --git a/KeyboardLocker/AgentCoordinationServices.swift b/KeyboardLocker/AgentCoordinationServices.swift index 289d385..3ece629 100644 --- a/KeyboardLocker/AgentCoordinationServices.swift +++ b/KeyboardLocker/AgentCoordinationServices.swift @@ -35,7 +35,7 @@ protocol AgentReplacementServing { func resetConnection() } -/// Complete live Client surface retained by `LockController`. +/// Complete live Client surface retained by `AppCoordinator`. /// /// Coordinators depend on the narrower protocols above, so future fakes only implement the /// external capability under test instead of the entire App Client. @@ -111,14 +111,11 @@ protocol AgentReplacementLifecycleServing { func restart() async -> AgentRegistrar.State } -/// Complete lifecycle surface retained by `LockController`. +/// Complete lifecycle surface retained by `AppCoordinator`. @MainActor protocol AgentLifecycleServing: AgentReadinessLifecycleServing, - AgentReplacementLifecycleServing -{ - func openApprovalSettings() -} + AgentReplacementLifecycleServing {} @MainActor struct LiveAgentLifecycle: AgentLifecycleServing { @@ -135,8 +132,4 @@ struct LiveAgentLifecycle: AgentLifecycleServing { func restart() async -> AgentRegistrar.State { await AgentRegistrar.restart() } - - func openApprovalSettings() { - AgentRegistrar.openApprovalSettings() - } } diff --git a/KeyboardLocker/AgentReadinessCoordinator.swift b/KeyboardLocker/AgentReadinessCoordinator.swift index 112bbd3..a175659 100644 --- a/KeyboardLocker/AgentReadinessCoordinator.swift +++ b/KeyboardLocker/AgentReadinessCoordinator.swift @@ -3,8 +3,7 @@ import Foundation /// Resolves Service Management, XPC contract, replacement, lock, and Accessibility facts. /// -/// This type deliberately returns domain outcomes instead of mutating SwiftUI state. The -/// `LockController` remains the presentation façade and decides how each outcome is shown. +/// This type returns domain outcomes so presentation code can remain outside the business layer. @MainActor struct AgentReadinessCoordinator { enum Outcome { diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index 3541e8b..2c50ee3 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -81,10 +81,6 @@ enum AgentRegistrar { } } - static func openApprovalSettings() { - SMAppService.openSystemSettingsLoginItems() - } - /// Compares the running Agent's self-reported contract with the Agent bundled in this App. /// This is lifecycle compatibility only and must not be treated as peer authentication. static func compatibility(of descriptor: ServiceDescriptor) -> Compatibility { diff --git a/KeyboardLocker/LockController.swift b/KeyboardLocker/AppCoordinator.swift similarity index 85% rename from KeyboardLocker/LockController.swift rename to KeyboardLocker/AppCoordinator.swift index 8a4717f..28aa82b 100644 --- a/KeyboardLocker/LockController.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -1,13 +1,10 @@ -import AppKit import Client -import Combine import Foundation -/// Observable view state for the App wrapper. Registration and Accessibility remain owned by -/// their system boundaries; this controller only coordinates those facts for presentation. +/// Coordinates App-side lock actions, readiness, and Agent replacement without owning domain state. @MainActor -final class LockController: ObservableObject { - enum ViewState: Equatable { +final class AppCoordinator { + enum State: Equatable { case checking(lastKnownLock: Bool?) case agentApprovalRequired case agentReplacementInProgress(message: String) @@ -25,41 +22,9 @@ final class LockController: ObservableObject { case updatingAgent } - @Published private(set) var state: ViewState = .checking(lastKnownLock: nil) - @Published private(set) var activity: Activity? - @Published private(set) var lastError: String? - - var agentUpdateUsesSafeReplacement: Bool { - pendingUpdatePlan?.supportsSafeReplacement ?? false - } - - var systemImageName: String { - switch state { - case .checking(lastKnownLock: true): - "lock.fill" - - case .checking: - "ellipsis.circle" - - case .ready(isLocked: true), - .accessibilityRequired(isLocked: true), - .agentUpdateRequired(isLocked: true, message: _): - "lock.fill" - - case .ready(isLocked: false): - "lock.open.fill" - - case .agentApprovalRequired, - .agentReplacementInProgress, - .agentUpdateRequired(isLocked: false, message: _), - .accessibilityRequired(isLocked: false), - .unavailable: - "exclamationmark.triangle.fill" - - case .agentUpdateRequired(isLocked: nil, message: _): - "questionmark.diamond.fill" - } - } + private(set) var state: State = .checking(lastKnownLock: nil) + private(set) var activity: Activity? + private(set) var lastError: String? private var reconciliationTask: Task? private var needsFollowUpReconciliation = false @@ -70,7 +35,6 @@ final class LockController: ObservableObject { private let lifecycle: any AgentLifecycleServing private let readinessCoordinator: AgentReadinessCoordinator private let replacementCoordinator: AgentReplacementCoordinator - private let usesLiveServices: Bool private static let replacementProgressPollInterval: Duration = .seconds(3) @@ -78,27 +42,15 @@ final class LockController: ObservableObject { self.init( client: LiveAgentClient(), lifecycle: LiveAgentLifecycle(), - usesLiveServices: true, initialState: .checking(lastKnownLock: nil) ) reconcile() } - /// Creates a static fixture without touching Service Management, XPC, or system settings. - convenience init(previewState: ViewState) { - self.init( - client: LiveAgentClient(), - lifecycle: LiveAgentLifecycle(), - usesLiveServices: false, - initialState: previewState - ) - } - init( client: any AgentClientServing, lifecycle: any AgentLifecycleServing, - usesLiveServices: Bool, - initialState: ViewState + initialState: State ) { self.client = client self.lifecycle = lifecycle @@ -110,20 +62,19 @@ final class LockController: ObservableObject { client: client, lifecycle: lifecycle ) - self.usesLiveServices = usesLiveServices state = initialState } /// Rebuilds the complete readiness snapshot from Service Management and the Agent. func reconcile() { - guard usesLiveServices, activity == nil else { + guard activity == nil else { return } startReconciliation() } func toggle() { - guard usesLiveServices, activity == nil else { + guard activity == nil else { return } @@ -181,7 +132,7 @@ final class LockController: ObservableObject { } func requestAccessibilityPermission() { - guard usesLiveServices, case .accessibilityRequired = state, activity == nil else { + guard case .accessibilityRequired = state, activity == nil else { return } @@ -207,29 +158,8 @@ final class LockController: ObservableObject { } } - func openAgentApprovalSettings() { - guard usesLiveServices else { - return - } - lifecycle.openApprovalSettings() - } - - func openAccessibilitySettings() { - guard usesLiveServices else { - return - } - guard let url = URL( - string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - ), NSWorkspace.shared.open(url) else { - lastError = "Unable to open Accessibility settings." - return - } - lastError = nil - } - func restartAgent() { - guard usesLiveServices, - case .unavailable(message: _, canRestartAgent: true) = state, + guard case .unavailable(message: _, canRestartAgent: true) = state, activity == nil else { return @@ -266,8 +196,7 @@ final class LockController: ObservableObject { } func updateAgent() { - guard usesLiveServices, - case .agentUpdateRequired = state, + guard case .agentUpdateRequired = state, let updatePlan = pendingUpdatePlan, activity == nil else { diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift deleted file mode 100644 index 577de83..0000000 --- a/KeyboardLocker/ContentView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ContentView.swift -// KeyboardLocker -// -// Created by Eden on 2025/11/19. -// - -import AppKit -import Combine -import SwiftUI - -struct ContentView: View { - @EnvironmentObject private var lock: LockController - - var body: some View { - NavigationStack { - MainView() - .navigationDestination(for: AppRoute.self) { route in - switch route { - case .settings: - SettingsView() - case .about: - AboutView() - } - } - } - .frame(width: 280, height: 320) - .onAppear { - // The menu window just opened; reconcile in case a broadcast was missed while suspended. - lock.reconcile() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - // Permission and Login Item changes happen in System Settings while this App is inactive. - lock.reconcile() - } - } -} - -#Preview { - ContentView() - .environmentObject(LockController(previewState: .ready(isLocked: false))) -} diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift deleted file mode 100644 index d57817e..0000000 --- a/KeyboardLocker/KeyboardLockerApp.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// KeyboardLockerApp.swift -// KeyboardLocker -// -// Created by Eden on 2025/11/19. -// - -import SwiftUI - -@main -struct KeyboardLockerApp: App { - @StateObject private var lockController = LockController() - - var body: some Scene { - MenuBarExtra("KeyboardLocker", systemImage: lockController.systemImageName) { - ContentView() - .environmentObject(lockController) - } - .menuBarExtraStyle(.window) - } -} diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift new file mode 100644 index 0000000..9bd9ef2 --- /dev/null +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -0,0 +1,7 @@ +@main +enum KeyboardLockerApplication { + @MainActor + static func main() { + _ = AgentRegistrar.ensureEnabled() + } +} diff --git a/KeyboardLocker/Navigation/AppRoute.swift b/KeyboardLocker/Navigation/AppRoute.swift deleted file mode 100644 index dcc4417..0000000 --- a/KeyboardLocker/Navigation/AppRoute.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// AppRoute.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import Foundation - -/// Navigation routes for the app -enum AppRoute: Hashable { - case settings, about -} diff --git a/KeyboardLocker/Views/AboutView.swift b/KeyboardLocker/Views/AboutView.swift deleted file mode 100644 index b55c32b..0000000 --- a/KeyboardLocker/Views/AboutView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AboutView.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import SwiftUI - -struct AboutView: View { - var body: some View { - VStack(spacing: 16) { - Text("About") - .font(.headline) - - Spacer() - - Text("About content will be implemented here.") - .foregroundStyle(.secondary) - - Spacer() - } - .padding() - .navigationTitle("About") - } -} - -#Preview { - NavigationStack { - AboutView() - } - .frame(width: 280, height: 320) -} diff --git a/KeyboardLocker/Views/MainView.swift b/KeyboardLocker/Views/MainView.swift deleted file mode 100644 index 1a1e5d2..0000000 --- a/KeyboardLocker/Views/MainView.swift +++ /dev/null @@ -1,396 +0,0 @@ -// -// MainView.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import SwiftUI - -struct MainView: View { - @EnvironmentObject private var lock: LockController - - var body: some View { - VStack(spacing: 16) { - header - Divider() - statusContent - Spacer() - footer - } - .padding() - } - - private var header: some View { - HStack { - Image(systemName: lock.systemImageName) - .font(.title) - .foregroundStyle(statusColor) - Text("KeyboardLocker") - .font(.headline) - Spacer() - } - } - - private var statusColor: Color { - switch lock.state { - case .ready(isLocked: true), - .accessibilityRequired(isLocked: true), - .agentUpdateRequired(isLocked: true, message: _): - .accentColor - case .agentApprovalRequired, - .agentReplacementInProgress, - .agentUpdateRequired(isLocked: false, message: _), - .agentUpdateRequired(isLocked: nil, message: _), - .accessibilityRequired(isLocked: false), - .unavailable: - .orange - case .checking(lastKnownLock: true): - .accentColor - case .checking, .ready(isLocked: false): - .secondary - } - } - - @ViewBuilder - private var statusContent: some View { - switch lock.state { - case let .checking(lastKnownLock): - checkingView(lastKnownLock: lastKnownLock) - case .agentApprovalRequired: - agentApprovalView - case let .agentReplacementInProgress(message): - agentReplacementInProgressView(message: message) - case let .agentUpdateRequired(isLocked, message): - agentUpdateView(isLocked: isLocked, message: message) - case let .accessibilityRequired(isLocked): - accessibilityView(isLocked: isLocked) - case let .ready(isLocked): - readyView(isLocked: isLocked) - case let .unavailable(message, canRestartAgent): - unavailableView(message: message, canRestartAgent: canRestartAgent) - } - } - - private func checkingView(lastKnownLock: Bool?) -> some View { - VStack(spacing: 8) { - if lastKnownLock == true { - Button { - lock.toggle() - } label: { - HStack(spacing: 6) { - if lock.activity == .unlocking { - ProgressView() - .controlSize(.small) - } - Text(lock.activity == .unlocking ? "Unlocking…" : "Unlock") - } - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(lock.activity != nil) - } - - if lock.activity != .unlocking { - ProgressView() - .controlSize(.small) - Text( - lock.activity == .updatingAgent - ? "Updating background agent…" - : "Verifying KeyboardLocker status…" - ) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - - private func agentReplacementInProgressView(message: String) -> some View { - VStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Agent Replacement in Progress") - .font(.headline) - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button("Check Again") { - lock.reconcile() - } - .buttonStyle(.borderedProminent) - .disabled(lock.activity != nil) - } - } - - private func agentUpdateView(isLocked: Bool?, message: String) -> some View { - VStack(spacing: 8) { - Text("Agent Update Required") - .font(.headline) - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Text(agentUpdateWarning(isLocked: isLocked)) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - if isLocked == true { - Button { - lock.toggle() - } label: { - HStack(spacing: 6) { - if lock.activity == .unlocking { - ProgressView() - .controlSize(.small) - } - Text(lock.activity == .unlocking ? "Unlocking…" : "Unlock") - } - } - .buttonStyle(.bordered) - .disabled(lock.activity != nil) - } - - Button { - lock.updateAgent() - } label: { - HStack(spacing: 6) { - if lock.activity == .updatingAgent { - ProgressView() - .controlSize(.small) - } - Text(agentUpdateTitle(isLocked: isLocked)) - } - } - .buttonStyle(.borderedProminent) - .disabled(lock.activity != nil) - - Button("Check Again") { - lock.reconcile() - } - .buttonStyle(.borderless) - .disabled(lock.activity != nil) - - actionError - } - } - - private var agentApprovalView: some View { - VStack(spacing: 8) { - Text("Background Agent Disabled") - .font(.headline) - Text("Enable KeyboardLocker in System Settings → General → Login Items.") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button("Open Login Items") { - lock.openAgentApprovalSettings() - } - .buttonStyle(.borderedProminent) - - Button("Check Again") { - lock.reconcile() - } - .buttonStyle(.borderless) - } - } - - private func accessibilityView(isLocked: Bool) -> some View { - VStack(spacing: 8) { - if isLocked { - Button { - lock.toggle() - } label: { - HStack(spacing: 6) { - if lock.activity == .unlocking { - ProgressView() - .controlSize(.small) - } - Text(lock.activity == .unlocking ? "Unlocking…" : "Unlock") - } - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(lock.activity != nil) - - Text("Keyboard is locked") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Text("Accessibility Access Required") - .font(.headline) - Text("Allow KeyboardLockerAgent to filter keyboard events. After granting access, check again.") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button { - lock.requestAccessibilityPermission() - } label: { - HStack(spacing: 6) { - if lock.activity == .requestingAccessibility { - ProgressView() - .controlSize(.small) - } - Text("Request Access") - } - } - .buttonStyle(.bordered) - .disabled(lock.activity != nil) - - HStack(spacing: 12) { - Button("Open Settings") { - lock.openAccessibilitySettings() - } - .buttonStyle(.borderless) - - Button("Check Again") { - lock.reconcile() - } - .buttonStyle(.borderless) - .disabled(lock.activity != nil) - } - - actionError - } - } - - private func readyView(isLocked: Bool) -> some View { - VStack(spacing: 8) { - Button { - lock.toggle() - } label: { - HStack(spacing: 6) { - if lock.activity == .locking || lock.activity == .unlocking { - ProgressView() - .controlSize(.small) - } - Text(toggleTitle(isLocked: isLocked)) - } - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(lock.activity != nil) - - Text(isLocked ? "Keyboard is locked" : "Keyboard is unlocked") - .font(.subheadline) - .foregroundStyle(.secondary) - - actionError - } - } - - private func toggleTitle(isLocked: Bool) -> String { - switch lock.activity { - case .locking: - "Locking…" - case .unlocking: - "Unlocking…" - case .requestingAccessibility, .restartingAgent, .updatingAgent, nil: - isLocked ? "Unlock" : "Lock Keyboard" - } - } - - private func agentUpdateTitle(isLocked: Bool?) -> String { - if lock.activity == .updatingAgent { - return "Updating Agent…" - } - return isLocked == true ? "Unlock and Replace Agent" : "Replace Agent" - } - - private func agentUpdateWarning(isLocked: Bool?) -> String { - if lock.agentUpdateUsesSafeReplacement { - return isLocked == true - ? "The Agent will block new lock requests, unlock, and remain drained until replacement completes." - : "The Agent will block new lock requests until replacement completes." - } - if isLocked == nil { - return "The running Agent's lock state cannot be verified. Replacing it may release an active keyboard lock." - } - return """ - This Agent cannot block other clients during replacement. A lock created in that brief \ - window may be released. - """ - } - - private func unavailableView(message: String, canRestartAgent: Bool) -> some View { - VStack(spacing: 8) { - Text("KeyboardLocker Is Unavailable") - .font(.headline) - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button("Retry") { - lock.reconcile() - } - .buttonStyle(.borderedProminent) - .disabled(lock.activity != nil) - - if canRestartAgent { - Text("Restarting the agent releases any active keyboard lock.") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button { - lock.restartAgent() - } label: { - HStack(spacing: 6) { - if lock.activity == .restartingAgent { - ProgressView() - .controlSize(.small) - } - Text(lock.activity == .restartingAgent ? "Restarting Agent…" : "Restart Agent") - } - } - .buttonStyle(.bordered) - .disabled(lock.activity != nil) - } - } - } - - @ViewBuilder - private var actionError: some View { - if let error = lock.lastError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - } - } - - private var footer: some View { - HStack { - NavigationLink(value: AppRoute.settings) { - Label("Settings", systemImage: "gear") - } - .buttonStyle(.borderless) - - NavigationLink(value: AppRoute.about) { - Label("About", systemImage: "info.circle") - } - .buttonStyle(.borderless) - - Spacer() - - Button("Quit") { - NSApplication.shared.terminate(nil) - } - } - } -} - -#Preview { - NavigationStack { - MainView() - .environmentObject(LockController(previewState: .accessibilityRequired(isLocked: false))) - } - .frame(width: 280, height: 320) -} diff --git a/KeyboardLocker/Views/SettingsView.swift b/KeyboardLocker/Views/SettingsView.swift deleted file mode 100644 index 3f9086b..0000000 --- a/KeyboardLocker/Views/SettingsView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// SettingsView.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import SwiftUI - -struct SettingsView: View { - var body: some View { - VStack(spacing: 16) { - Text("Settings") - .font(.headline) - - Spacer() - - Text("Settings content will be implemented here.") - .foregroundStyle(.secondary) - - Spacer() - } - .padding() - .navigationTitle("Settings") - } -} - -#Preview { - NavigationStack { - SettingsView() - } - .frame(width: 280, height: 320) -} diff --git a/docs/architecture.md b/docs/architecture.md index ff4ec8c..d252db6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,7 +47,7 @@ Widget ──┘ ├─ Settings ownership (source of truth) | 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | | UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,并协调只存在于自身进程的系统边界(例如 App 的 `SMAppService` 生命周期);不得复制 Agent 的锁、设置或 Accessibility 领域逻辑。 | -App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management、XPC 两个系统边界之间的应用层编排,不是第二份锁核心。它必须保持为无锁状态真相源的薄协调器:输入来自 Agent/系统查询,输出由 `LockController` 映射到 UI;任何 CGEventTap、设置持久化和 Accessibility 判定仍只在 Agent 内执行。 +App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management、XPC 两个系统边界之间的应用层编排,不是第二份锁核心。它必须保持为无锁状态真相源的薄协调器:`AppCoordinator` 只消费 Agent/系统查询并暴露应用状态与动作,不依赖 presentation framework;后续 UI 只能映射这些状态与动作。任何 CGEventTap、设置持久化和 Accessibility 判定仍只在 Agent 内执行。 ## 全局锁语义 @@ -62,7 +62,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management 因为状态只存在于唯一一处(Agent),wrapper 保持同步的方式就是始终以它为准。wrapper 有两种形态,读取状态的方式不同: - **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 -- **长命的状态反映面** —— App 菜单栏、未来的 Widgets。它们持续显示状态,因此必须既**订阅**(`LockStateSubscriber`),又在**变为可见 / 启动时校准**(拉取 `status()`)—— 因为进程被挂起期间可能错过某次广播。`LockStateSubscriber` 会先安装两个 observer,再立即做一次权威初始查询;只要初始查询成功或后续 signal 到达,snapshot 与 subscription 之间的变化就会被校准。调用方仍需在重新可见时做完整 readiness reconciliation。 +- **长命的状态反映面** —— 后续恢复的 App UI、未来的 Widgets。它们持续显示状态,因此必须既**订阅**(`LockStateSubscriber`),又在**变为可见 / 启动时校准**(拉取 `status()`)—— 因为进程被挂起期间可能错过某次广播。`LockStateSubscriber` 会先安装两个 observer,再立即做一次权威初始查询;只要初始查询成功或后续 signal 到达,snapshot 与 subscription 之间的变化就会被校准。调用方仍需在重新可见时做完整 readiness reconciliation。 `LockStateSubscriber` 把通知当作*提示*而非真相:订阅后的初始校准和收到的任一信号(Darwin 或 Distributed)都会向 Agent 拉取权威状态。多个并发信号被串行合并,相同状态被去重;subscription 取消后,尚未进入 handler 的在途结果会被丢弃。已经开始执行的 handler 不可被回溯撤销。Agent 之所以在两个通道都广播,只是为了让被挂起的 App 能被唤醒(Darwin)、让正在运行的 App 能及时更新(Distributed)。通知的载荷永远不是真相源。 diff --git a/docs/development.md b/docs/development.md index 220434c..f3c6f0b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -41,12 +41,12 @@ - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 -**App**(`KeyboardLocker/`)—— 薄 SwiftUI wrapper +**App**(`KeyboardLocker/`)—— 无 presentation framework 的薄 wrapper;当前入口仅完成 Agent 注册 - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 - `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol,协调器无需依赖完整 Client/lifecycle surface。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 -- `LockController.swift`:`@MainActor ObservableObject` 视图 façade —— 持有 SwiftUI state、异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把协调器 outcome 映射成 UI;不直接实现 handshake、replacement transaction、锁或设置逻辑。 +- `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为应用状态;不直接实现 handshake、replacement transaction、锁或设置逻辑。 ## 常见任务 @@ -73,4 +73,4 @@ - `klock` target 必须保留 generated embedded Info.plist section;这是让实际 code-signing identifier 等于 `io.lzhlovesjyq.keyboardlocker.klock` 的载体。只设置 `PRODUCT_BUNDLE_IDENTIFIER` 而不嵌入 Info.plist 时,`codesign` 会退回裸名称 `klock`,Agent 会拒绝它。 - 引擎操作会派发到主线程,以便访问 CFRunLoop。 - 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。 -- App 协调器通过 protocol injection 与 presentation state 解耦。当前 Xcode 工程没有安全的 non-hosted App-model test target;不要用 hosted App tests 覆盖它们,因为测试宿主会初始化 live `LockController` 并触碰 `SMAppService`/XPC。新增确定性协调器测试时应先建立独立的 non-hosted App-model target,再注入 fake Client/lifecycle。 +- App 协调器通过 protocol injection 与 presentation layer 解耦。当前 Xcode 工程没有独立的 App-model test target;新增确定性协调器测试时应先建立 non-hosted target,再注入 fake Client/lifecycle,避免测试触碰 live `SMAppService`/XPC。 diff --git a/docs/xpc.md b/docs/xpc.md index 5fd4b2a..9398160 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -60,7 +60,7 @@ Xcode 会按 package 名把这些 dependency 都显示在 `Core` 下面,因此 ```mermaid flowchart LR subgraph AppProcess["KeyboardLocker process"] - AppUI["SwiftUI + LockController"] --> AppClient["XPCClient"] + AppCoordinator["AppCoordinator"] --> AppClient["XPCClient"] end subgraph CLIProcess["klock process"] @@ -188,7 +188,7 @@ SMAppService enabled -> serviceDescriptor() -> protocol/capability/bundled-build comparison -> status() + hasAccessibilityPermission() - -> ready UI + -> ready application state ``` legacy Agent 没有 `serviceDescriptor`,但 descriptor 调用失败也可能只是 transport 或实现故障,不能仅凭一次失败断言远端版本: @@ -490,7 +490,7 @@ sequenceDiagram 对应源码中的实际步骤是: -1. App 的 `LockController` 已在 readiness reconciliation 中完成 descriptor handshake,随后 App 或 CLI 调用 `XPCClient.shared.lock()`;CLI 使用的 lock/status/unlock 属于 legacy base selector。 +1. App 的 `AppCoordinator` 已在 readiness reconciliation 中完成 descriptor handshake,随后 App 或 CLI 调用 `XPCClient.shared.lock()`;CLI 使用的 lock/status/unlock 属于 legacy base selector。 2. `XPCClient.currentConnection()` 创建或复用 `NSXPCConnection(machServiceName:)`。 3. Client 把 `KeyboardLockerServiceProtocol` 设置成 `remoteObjectInterface`,再取得 remote proxy。 4. Client 在 activate 前安装只接受同 Team、精确 Agent signing identifier 的 requirement;第一条实际 message 让 `launchd` 按需启动 Agent。 @@ -557,7 +557,7 @@ App 在动作结束后仍会做完整 reconciliation,因为跨进程系统中 ## 为什么还需要通知 -XPC request/reply 适合“现在执行”或“现在查询”,不会自动告诉其他 wrapper 状态后来发生了变化。例如 CLI 锁定后,App 需要更新菜单栏图标;自动解锁发生时,所有长命 UI 也需要刷新。 +XPC request/reply 适合“现在执行”或“现在查询”,不会自动告诉其他 wrapper 状态后来发生了变化。例如 CLI 锁定后,后续恢复的 App UI 需要更新状态;自动解锁发生时,所有长命 UI 也需要刷新。 当前状态同步链路是: @@ -590,7 +590,7 @@ sequenceDiagram - 通知可能重复、丢失或乱序,所以 subscriber 收到信号后必须再走 XPC `status()`。 - 同一 subscription 内最多只有一个权威查询 worker;并发信号被合并为后续查询,取消后尚未进入 handler 的在途结果不再交付。 - 两个通知通道是为了提高不同进程状态下的交付可靠性,不是两套状态源。 -- App 打开菜单或重新变为 active 时还会主动 reconcile,以弥补挂起期间错过的通知。 +- 后续 App UI 变为可见或 active 时还必须主动 reconcile,以弥补挂起期间错过的通知。 - 一次性 `status` / `unlock` 命令直接查询 Agent,不需要为了读取当前状态先等待通知。 ## Connection 访问控制 @@ -678,7 +678,7 @@ locked Agent 不能自动 restart,因为 Agent 退出本身会释放正在工作 | App 的可注入 XPC/lifecycle adapter | [`KeyboardLocker/AgentCoordinationServices.swift`](../KeyboardLocker/AgentCoordinationServices.swift) | | Registration、handshake 与 readiness 快照 | [`KeyboardLocker/AgentReadinessCoordinator.swift`](../KeyboardLocker/AgentReadinessCoordinator.swift) | | Safe/forced Agent replacement 顺序 | [`KeyboardLocker/AgentReplacementCoordinator.swift`](../KeyboardLocker/AgentReplacementCoordinator.swift) | -| App 视图状态、任务与订阅 façade | [`KeyboardLocker/LockController.swift`](../KeyboardLocker/LockController.swift) | +| App 应用状态、任务与订阅协调 | [`KeyboardLocker/AppCoordinator.swift`](../KeyboardLocker/AppCoordinator.swift) | ## Apple 参考资料 From 50719d2a6f63216d1cff4c77e98949a2fdfd7e0e Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:01:05 +0800 Subject: [PATCH 029/106] refactor(service): isolate LockEngine to MainActor with transactional event tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace OSAllocatedUnfairLock manual state management with @MainActor isolation and a LockRuntimeState value type that enforces atomic transitions. Introduce EventTapInstallation as a generic transactional factory — any incomplete tap/source installation is rolled back in reverse order, so callers never retain a half-installed input filter. LockEngine changes: - Extract LockRuntimeState: begin/updateSettings/setAutoUnlockTargetDate/ end enforce the legal state machine inline - Extract EventTapInstallation: createTap → createSource → attachSource → enableTap → isTapEnabled; defer block rolls back on any failure - Annotate LockEngine @MainActor; remove stateLock and withLock(_:) - Add eventTapGeneration to detect stale re-enable attempts in handleDisabledEvent; if re-enable fails, tear down and transition to unlocked (fail-open rather than reporting a phantom lock) - lock() is now strictly idempotent: duplicate calls return without touching settings, lock-started-at, or the auto-unlock deadline - @preconcurrency import CoreGraphics / ApplicationServices for sendable-unsafe CG types used within @MainActor context --- .../Service/AccessibilityManager.swift | 2 +- Core/Sources/Service/LockEngine.swift | 310 ++++++++++++------ .../EventTapInstallationTests.swift | 130 ++++++++ .../LockEngineAutoUnlockScheduleTests.swift | 2 +- .../ServiceTests/LockRuntimeStateTests.swift | 50 +++ 5 files changed, 391 insertions(+), 103 deletions(-) create mode 100644 Core/Tests/ServiceTests/EventTapInstallationTests.swift create mode 100644 Core/Tests/ServiceTests/LockRuntimeStateTests.swift diff --git a/Core/Sources/Service/AccessibilityManager.swift b/Core/Sources/Service/AccessibilityManager.swift index 581e18f..3814c45 100644 --- a/Core/Sources/Service/AccessibilityManager.swift +++ b/Core/Sources/Service/AccessibilityManager.swift @@ -1,5 +1,5 @@ import AppKit -import ApplicationServices +@preconcurrency import ApplicationServices /// Manages Accessibility permission for CGEventTap-based keyboard filtering public final class AccessibilityManager { diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index ce42337..dd24f68 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -1,6 +1,6 @@ import AppKit import Common -import CoreGraphics +@preconcurrency import CoreGraphics import Foundation import os @@ -35,9 +35,97 @@ struct AutoUnlockSchedule: Equatable { } } +struct LockRuntimeState: Equatable { + private(set) var activeSettings: KeyboardLockerSettings = .default + private(set) var autoUnlockTargetDate: Date? + private(set) var isLocked = false + private(set) var startedAt: Date? + + mutating func begin( + settings: KeyboardLockerSettings, + at date: Date + ) -> Bool { + guard !isLocked else { + return false + } + + activeSettings = settings + autoUnlockTargetDate = nil + isLocked = true + startedAt = date + return true + } + + mutating func updateSettings(_ settings: KeyboardLockerSettings) { + activeSettings = settings + } + + mutating func setAutoUnlockTargetDate(_ date: Date?) { + autoUnlockTargetDate = date + } + + mutating func end() { + autoUnlockTargetDate = nil + isLocked = false + startedAt = nil + } +} + +enum EventTapInstallationError: Error, Equatable { + case eventTapCreationFailed + case eventTapEnableFailed + case runLoopSourceCreationFailed +} + +/// Acquires an event tap and its run-loop source as one transaction. Any incomplete installation +/// is rolled back in reverse order, so callers never retain a half-installed input filter. +struct EventTapInstallation { + let tap: Tap + let source: Source + + static func make( + createTap: () -> Tap?, + createSource: (Tap) -> Source?, + attachSource: (Source) -> Void, + enableTap: (Tap) -> Void, + isTapEnabled: (Tap) -> Bool, + detachSource: (Source) -> Void, + invalidateTap: (Tap) -> Void + ) throws -> Self { + guard let tap = createTap() else { + throw EventTapInstallationError.eventTapCreationFailed + } + + var source: Source? + var committed = false + defer { + if !committed { + if let source { + detachSource(source) + } + invalidateTap(tap) + } + } + + guard let createdSource = createSource(tap) else { + throw EventTapInstallationError.runLoopSourceCreationFailed + } + source = createdSource + attachSource(createdSource) + enableTap(tap) + + guard isTapEnabled(tap) else { + throw EventTapInstallationError.eventTapEnableFailed + } + + committed = true + return Self(tap: tap, source: createdSource) + } +} + /// Use refcon to bridge C callback to Swift instance since CGEventTap requires C function pointer private func eventTapCallback( - proxy: CGEventTapProxy, + proxy _: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer? @@ -47,21 +135,19 @@ private func eventTapCallback( } let engine = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - LockEngine.logger.warning("Event tap disabled by system, attempting to re-enable") - - if let eventTap = engine.eventTap { - CGEvent.tapEnable(tap: eventTap, enable: true) + // The callback runs on the main run loop where the tap is installed. Make that runtime + // guarantee explicit so every event-tap resource remains isolated to `MainActor`. + return MainActor.assumeIsolated { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + return engine.handleDisabledEvent(event) } - return Unmanaged.passUnretained(event) + return engine.handleEvent(type: type, event: event) } - - return engine.handleEvent(proxy: proxy, type: type, event: event) } -public class LockEngine { +@MainActor +public final class LockEngine { public static let shared = LockEngine() static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "LockEngine") @@ -69,6 +155,7 @@ public class LockEngine { public enum LockEngineError: Error, LocalizedError { case accessibilityPermissionDenied case eventTapCreationFailed + case eventTapEnableFailed case runLoopSourceCreationFailed public var errorDescription: String? { @@ -77,6 +164,8 @@ public class LockEngine { "Accessibility permission is required to lock keyboard input." case .eventTapCreationFailed: "Failed to create event tap. This may indicate a permissions issue or system restriction." + case .eventTapEnableFailed: + "The keyboard event tap was created but could not be enabled." case .runLoopSourceCreationFailed: "Failed to create run loop source for event tap." } @@ -88,6 +177,8 @@ public class LockEngine { "Open System Settings → Privacy & Security → Accessibility and enable access for this application." case .eventTapCreationFailed: "Try restarting the application. If the problem persists, check Accessibility permissions in System Settings." + case .eventTapEnableFailed: + "Check Accessibility permissions in System Settings, then try locking again." case .runLoopSourceCreationFailed: "This is a system-level error. Please contact support if it persists." } @@ -103,53 +194,30 @@ public class LockEngine { private static let runLoopSourceOrder: CFIndex = 0 private static let autoRepeatFlagValue: Int64 = 1 - /// Unfair lock for thread-safe state access (higher performance than DispatchQueue) - private let stateLock = OSAllocatedUnfairLock() - - // fileprivate access required for C callback to re-enable tap on system timeout - fileprivate var eventTap: CFMachPort? + private var eventTap: CFMachPort? + private var eventTapGeneration: UInt64 = 0 private var runLoopSource: CFRunLoopSource? private var autoUnlockTimer: DispatchSourceTimer? + private var runtimeState = LockRuntimeState() - // Private state storage protected by stateLock - private var _isLocked = false - private var _lockStartedAt: Date? - private var _autoUnlockTargetDate: Date? - private var _activeSettings: KeyboardLockerSettings = .default - - /// Thread-safe public accessors public var isLocked: Bool { - withLock { _isLocked } + runtimeState.isLocked } var lockStartedAt: Date? { - withLock { _lockStartedAt } + runtimeState.startedAt } var autoUnlockTargetDate: Date? { - withLock { _autoUnlockTargetDate } + runtimeState.autoUnlockTargetDate } private init() {} - /// Helper method to safely execute critical sections with automatic lock management - private func withLock(_ block: () throws -> T) rethrows -> T { - stateLock.lock() - defer { stateLock.unlock() } - return try block() - } - public func lock(settings: KeyboardLockerSettings = .default) throws { - // The lock is a single global state; locking while already locked is a no-op - // success (idempotent), but we still adopt the latest settings. - let alreadyLocked: Bool = withLock { - _activeSettings = settings - return _isLocked - } - - if alreadyLocked { - // Re-arm the auto-unlock timer / hotkey against the new settings. - configureAutoUnlockTimerIfNeeded() + // A duplicate request is a strict no-op. In particular, it must not extend the authoritative + // auto-unlock deadline merely because another wrapper repeated the same global intent. + if runtimeState.isLocked { return } @@ -160,50 +228,72 @@ public class LockEngine { // Event tap creation must happen on main thread try startEventTap() + guard runtimeState.begin(settings: settings, at: Date()) else { + teardownEventTap() + return + } markLocked() } /// Updates the active settings. If a lock is running, changes take effect immediately /// (e.g. a new auto-unlock timeout re-arms the timer); otherwise they seed the next lock. public func updateSettings(_ settings: KeyboardLockerSettings) { - let isLocked: Bool = withLock { - _activeSettings = settings - return _isLocked - } + runtimeState.updateSettings(settings) - if isLocked { + if runtimeState.isLocked { configureAutoUnlockTimerIfNeeded() } } private func startEventTap() throws { - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: Self.eventMasks, - callback: eventTapCallback, - userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) else { + let installation: EventTapInstallation + do { + installation = try EventTapInstallation.make( + createTap: { + CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: Self.eventMasks, + callback: eventTapCallback, + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + }, + createSource: { + CFMachPortCreateRunLoopSource( + kCFAllocatorDefault, + $0, + Self.runLoopSourceOrder + ) + }, + attachSource: { + CFRunLoopAddSource(CFRunLoopGetMain(), $0, .commonModes) + }, + enableTap: { + CGEvent.tapEnable(tap: $0, enable: true) + }, + isTapEnabled: { + CGEvent.tapIsEnabled(tap: $0) + }, + detachSource: { + CFRunLoopRemoveSource(CFRunLoopGetMain(), $0, .commonModes) + }, + invalidateTap: CFMachPortInvalidate + ) + } catch EventTapInstallationError.eventTapCreationFailed { throw LockEngineError.eventTapCreationFailed - } - eventTap = tap - - guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, Self.runLoopSourceOrder) else { + } catch EventTapInstallationError.runLoopSourceCreationFailed { throw LockEngineError.runLoopSourceCreationFailed + } catch EventTapInstallationError.eventTapEnableFailed { + throw LockEngineError.eventTapEnableFailed } - runLoopSource = source - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) + eventTapGeneration &+= 1 + eventTap = installation.tap + runLoopSource = installation.source } private func markLocked() { - withLock { - _isLocked = true - _lockStartedAt = Date() - } - configureAutoUnlockTimerIfNeeded() LockStateBroadcaster.broadcast() Self.logger.info("Locked") @@ -212,15 +302,13 @@ public class LockEngine { private func configureAutoUnlockTimerIfNeeded() { cancelAutoUnlockTimer() - let (timeout, isLocked) = withLock { - (_activeSettings.autoUnlockPolicy.timeout, _isLocked) - } + let timeout = runtimeState.activeSettings.autoUnlockPolicy.timeout - // Re-arming starts a fresh timeout window without changing the duration - // of the lock session tracked by `_lockStartedAt`. + // An explicit settings update starts a fresh timeout window without changing when the + // authoritative lock began. A duplicate lock request never reaches this path. let referenceDate = Date() guard - isLocked, + runtimeState.isLocked, let schedule = AutoUnlockSchedule.make( timeout: timeout, referenceDate: referenceDate, @@ -230,9 +318,7 @@ public class LockEngine { return } - withLock { - _autoUnlockTargetDate = schedule.deadline - } + runtimeState.setAutoUnlockTargetDate(schedule.deadline) let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + schedule.delay) @@ -246,16 +332,11 @@ public class LockEngine { private func cancelAutoUnlockTimer() { autoUnlockTimer?.cancel() autoUnlockTimer = nil - - withLock { - _autoUnlockTargetDate = nil - } + runtimeState.setAutoUnlockTargetDate(nil) } public func unlock() { - // Check lock status atomically - let shouldUnlock = withLock { _isLocked } - guard shouldUnlock else { + guard runtimeState.isLocked else { return } @@ -265,35 +346,64 @@ public class LockEngine { } private func teardownEventTap() { - if let tap = eventTap { + let tap = eventTap + if let tap { CGEvent.tapEnable(tap: tap, enable: false) - CFMachPortInvalidate(tap) - eventTap = nil } if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes) + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) runLoopSource = nil } + + if let tap { + CFMachPortInvalidate(tap) + eventTap = nil + } } private func resetLockState() { - withLock { - _isLocked = false - _lockStartedAt = nil - _autoUnlockTargetDate = nil - } + runtimeState.end() LockStateBroadcaster.broadcast() Self.logger.info("Unlocked") } + fileprivate func handleDisabledEvent(_ event: CGEvent) -> Unmanaged? { + guard runtimeState.isLocked, let tap = eventTap else { + return Unmanaged.passUnretained(event) + } + + Self.logger.warning("Event tap disabled by system, attempting to re-enable") + CGEvent.tapEnable(tap: tap, enable: true) + + guard !CGEvent.tapIsEnabled(tap: tap) else { + return Unmanaged.passUnretained(event) + } + + let failedGeneration = eventTapGeneration + Self.logger.error("Event tap could not be re-enabled; transitioning to unlocked") + DispatchQueue.main.async { [weak self] in + self?.handleEventTapFailure(generation: failedGeneration) + } + return Unmanaged.passUnretained(event) + } + + private func handleEventTapFailure(generation: UInt64) { + guard runtimeState.isLocked, eventTapGeneration == generation else { + return + } + + cancelAutoUnlockTimer() + teardownEventTap() + resetLockState() + } + fileprivate func handleEvent( - proxy _: CGEventTapProxy, type: CGEventType, event: CGEvent ) -> Unmanaged? { - guard isLocked else { + guard runtimeState.isLocked else { return Unmanaged.passUnretained(event) } @@ -307,7 +417,7 @@ public class LockEngine { } private func shouldTriggerUnlock(for type: CGEventType, event: CGEvent) -> Bool { - let hotkey = withLock { _activeSettings.unlockHotkey } + let hotkey = runtimeState.activeSettings.unlockHotkey switch type { case .keyDown: @@ -325,16 +435,14 @@ public class LockEngine { } func lockDuration(at date: Date = Date()) -> TimeInterval? { - let start = withLock { _lockStartedAt } - guard let start else { + guard let start = runtimeState.startedAt else { return nil } return max(0, date.timeIntervalSince(start)) } func remainingAutoUnlockTime(at date: Date = Date()) -> TimeInterval? { - let deadline = withLock { _autoUnlockTargetDate } - guard let deadline else { + guard let deadline = runtimeState.autoUnlockTargetDate else { return nil } return max(0, deadline.timeIntervalSince(date)) diff --git a/Core/Tests/ServiceTests/EventTapInstallationTests.swift b/Core/Tests/ServiceTests/EventTapInstallationTests.swift new file mode 100644 index 0000000..81f5594 --- /dev/null +++ b/Core/Tests/ServiceTests/EventTapInstallationTests.swift @@ -0,0 +1,130 @@ +@testable import Service +import XCTest + +final class EventTapInstallationTests: XCTestCase { + func testMissingTapHasNoResourcesToRollBack() { + var actions: [String] = [] + + XCTAssertThrowsError( + try makeInstallation( + actions: &actions, + tap: nil, + source: 2, + isEnabled: true + ) + ) { error in + XCTAssertEqual( + error as? EventTapInstallationError, + .eventTapCreationFailed + ) + } + XCTAssertEqual(actions, ["createTap"]) + } + + func testMissingSourceInvalidatesCreatedTap() { + var actions: [String] = [] + + XCTAssertThrowsError( + try makeInstallation( + actions: &actions, + tap: 1, + source: nil, + isEnabled: true + ) + ) { error in + XCTAssertEqual( + error as? EventTapInstallationError, + .runLoopSourceCreationFailed + ) + } + XCTAssertEqual(actions, ["createTap", "createSource", "invalidateTap"]) + } + + func testDisabledTapDetachesSourceBeforeInvalidatingTap() { + var actions: [String] = [] + + XCTAssertThrowsError( + try makeInstallation( + actions: &actions, + tap: 1, + source: 2, + isEnabled: false + ) + ) { error in + XCTAssertEqual( + error as? EventTapInstallationError, + .eventTapEnableFailed + ) + } + XCTAssertEqual( + actions, + [ + "createTap", + "createSource", + "attachSource", + "enableTap", + "isTapEnabled", + "detachSource", + "invalidateTap", + ] + ) + } + + func testSuccessfulInstallationTransfersResourceOwnership() throws { + var actions: [String] = [] + + let installation = try makeInstallation( + actions: &actions, + tap: 1, + source: 2, + isEnabled: true + ) + + XCTAssertEqual(installation.tap, 1) + XCTAssertEqual(installation.source, 2) + XCTAssertEqual( + actions, + [ + "createTap", + "createSource", + "attachSource", + "enableTap", + "isTapEnabled", + ] + ) + } + + private func makeInstallation( + actions: inout [String], + tap: Int?, + source: Int?, + isEnabled: Bool + ) throws -> EventTapInstallation { + try EventTapInstallation.make( + createTap: { + actions.append("createTap") + return tap + }, + createSource: { _ in + actions.append("createSource") + return source + }, + attachSource: { _ in + actions.append("attachSource") + }, + enableTap: { _ in + actions.append("enableTap") + }, + isTapEnabled: { _ in + actions.append("isTapEnabled") + return isEnabled + }, + detachSource: { _ in + actions.append("detachSource") + }, + invalidateTap: { _ in + actions.append("invalidateTap") + } + ) + } +} diff --git a/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift index d3a6c0b..5a159fa 100644 --- a/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift +++ b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift @@ -18,7 +18,7 @@ final class LockEngineAutoUnlockScheduleTests: XCTestCase { XCTAssertEqual(schedule.delay, 60) } - func testRearmedScheduleStartsANewTimeoutWindow() throws { + func testExplicitRearmStartsANewTimeoutWindow() throws { let initialSchedule = try XCTUnwrap( AutoUnlockSchedule.make( timeout: 60, diff --git a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift new file mode 100644 index 0000000..ef71179 --- /dev/null +++ b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift @@ -0,0 +1,50 @@ +import Common +import Foundation +@testable import Service +import XCTest + +final class LockRuntimeStateTests: XCTestCase { + func testDuplicateBeginPreservesOriginalRuntimeStateAndDeadline() { + let originalSettings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let replacementSettings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 300), + unlockHotkey: .init(keyCode: 13, modifierFlags: [.maskControl]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + + XCTAssertTrue(state.begin(settings: originalSettings, at: startedAt)) + state.setAutoUnlockTargetDate(deadline) + + XCTAssertFalse( + state.begin( + settings: replacementSettings, + at: startedAt.addingTimeInterval(10) + ) + ) + XCTAssertEqual(state.activeSettings, originalSettings) + XCTAssertEqual(state.startedAt, startedAt) + XCTAssertEqual(state.autoUnlockTargetDate, deadline) + XCTAssertTrue(state.isLocked) + } + + func testEndedLockCanBeginAgainWithoutRetainingTimingState() { + let firstStart = Date(timeIntervalSinceReferenceDate: 1000) + let secondStart = firstStart.addingTimeInterval(90) + var state = LockRuntimeState() + + XCTAssertTrue(state.begin(settings: .default, at: firstStart)) + state.setAutoUnlockTargetDate(firstStart.addingTimeInterval(60)) + state.end() + + XCTAssertFalse(state.isLocked) + XCTAssertNil(state.startedAt) + XCTAssertNil(state.autoUnlockTargetDate) + XCTAssertTrue(state.begin(settings: .default, at: secondStart)) + XCTAssertEqual(state.startedAt, secondStart) + } +} From 8a0761dd0810f5add576765132b95ad4669c2b10 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:01:18 +0800 Subject: [PATCH 030/106] feat(common): propagate settings coding errors across XPC boundary Replace silent .default fallback with explicit error propagation so wrappers never fabricate a second "current settings" snapshot. Common: - Add KeyboardLockerSettingsCodingError (invalidPayload, missingPayload, payloadTooLarge) with LocalizedError descriptions - decodedFromXPC(_:) now throws instead of returning .default on failure; validate size before decoding - encodedForXPC() validates size after encoding (max 16 KB) - Add currentSettingsWithError(reply:) to the @objc protocol as an additive selector alongside the legacy currentSettings(reply:) - Bump ServiceContract to protocol v1.2, require currentSettingsWithError capability Client: - XPCClient.currentSettings() calls currentSettingsWithError and throws on failure - During interruption, invalidate the NSXPCConnection object so it can't transparently reattach to a different Agent process generation - Wrap the connection in XPCConnectionReference for safe sharing between the timeout task and the proxy error handler - Annotate ObserverToken @unchecked Sendable (immutable, tear-down is thread-safe) Tests: - KeyboardLockerSettingsCodingTests: round-trip, missing/invalid/oversized - ServiceCompatibilityTests: currentSettingsWithError is required; descriptor missing it is flagged --- Core/Sources/Client/LockStateSubscriber.swift | 5 +- Core/Sources/Client/XPCClient.swift | 37 ++++++++++--- .../Common/KeyboardLockerSettings.swift | 44 ++++++++++++--- Core/Sources/Common/ServiceDescriptor.swift | 6 ++- Core/Sources/Common/Shared.swift | 5 +- .../ServiceCompatibilityTests.swift | 21 ++++++++ .../KeyboardLockerSettingsCodingTests.swift | 54 +++++++++++++++++++ 7 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift diff --git a/Core/Sources/Client/LockStateSubscriber.swift b/Core/Sources/Client/LockStateSubscriber.swift index 340c138..9cbcd39 100644 --- a/Core/Sources/Client/LockStateSubscriber.swift +++ b/Core/Sources/Client/LockStateSubscriber.swift @@ -284,7 +284,10 @@ private final class DarwinObserver { /// Token that controls subscription lifecycle. /// Subscription is automatically cancelled when token is deallocated. -public final class ObserverToken { +/// +/// The token is immutable and its teardown path is thread-safe, so releasing the final reference +/// from an `AsyncStream` termination callback is safe on any executor. +public final class ObserverToken: @unchecked Sendable { private let onDeinit: () -> Void public init(onDeinit: @escaping () -> Void) { diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index d126c3d..ac473d0 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -224,15 +224,15 @@ public final class XPCClient: @unchecked Sendable { } } - /// The Agent's current settings, falling back to `.default` if the Agent can't provide them. + /// The Agent's authoritative current settings. public func currentSettings() async throws -> KeyboardLockerSettings { let connection = try await negotiatedConnection( - requiring: [.currentSettings] + requiring: [.currentSettingsWithError] ) let data: Data? = try await withProxyReturning(using: connection) { service, resume in - service.currentSettings { resume($0, nil) } + service.currentSettingsWithError { resume($0, $1) } } - return KeyboardLockerSettings.decodedFromXPC(data) + return try KeyboardLockerSettings.decodedFromXPC(data) } // MARK: - Connection Management @@ -308,7 +308,16 @@ public final class XPCClient: @unchecked Sendable { self?.clearConnection(ifMatching: connectionID) } connection.invalidationHandler = clear - connection.interruptionHandler = clear + connection.interruptionHandler = { [weak self, weak connection] in + guard let connection else { + return + } + + // An interrupted named connection may transparently attach to a different Agent process. + // Invalidate this object so a descriptor/capability grant can never cross process + // generations; the next operation must create a fresh connection and negotiate again. + self?.invalidateConnection(connection) + } connection.activate() self.connection = connection @@ -344,7 +353,7 @@ public final class XPCClient: @unchecked Sendable { /// Central reply bridge. Every operation has a bounded response time and resumes exactly once /// across reply, proxy-error, missing-proxy, and timeout races. - private func withProxyReturning( + private func withProxyReturning( using providedConnection: NSXPCConnection? = nil, _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (T, Error?) -> Void) -> Void ) async throws -> T { @@ -356,18 +365,19 @@ public final class XPCClient: @unchecked Sendable { return try await withCheckedThrowingContinuation { continuation in let once = ResumeOnce() + let connectionReference = XPCConnectionReference(connection) let timeoutTask = Task { [weak self] in try? await Task.sleep(for: Self.responseTimeout) guard !Task.isCancelled else { return } once.run { - self?.invalidateConnection(connection) + self?.invalidateConnection(connectionReference.value) continuation.resume(throwing: XPCClientError.timedOut) } } - let proxy = connection.remoteObjectProxyWithErrorHandler { error in + let proxy = connectionReference.value.remoteObjectProxyWithErrorHandler { error in once.run { timeoutTask.cancel() continuation.resume(throwing: error) @@ -396,6 +406,17 @@ public final class XPCClient: @unchecked Sendable { } } +/// Foundation documents `NSXPCConnection` as supporting calls from multiple threads. This box +/// makes that external synchronization contract explicit when a connection is shared with the +/// timeout task that can invalidate it. +private final class XPCConnectionReference: @unchecked Sendable { + let value: NSXPCConnection + + init(_ value: NSXPCConnection) { + self.value = value + } +} + /// Guarantees a continuation is resumed at most once across the reply and error-handler races. private final class ResumeOnce: @unchecked Sendable { private let lock = OSAllocatedUnfairLock() diff --git a/Core/Sources/Common/KeyboardLockerSettings.swift b/Core/Sources/Common/KeyboardLockerSettings.swift index e41f43d..f50dc2f 100644 --- a/Core/Sources/Common/KeyboardLockerSettings.swift +++ b/Core/Sources/Common/KeyboardLockerSettings.swift @@ -114,18 +114,48 @@ public extension KeyboardLockerSettings.Hotkey { // MARK: - XPC Serialization +public enum KeyboardLockerSettingsCodingError: Error, Equatable, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned invalid settings." + case .missingPayload: + "The KeyboardLocker agent returned no settings." + case .payloadTooLarge: + "The KeyboardLocker agent returned oversized settings." + } + } +} + public extension KeyboardLockerSettings { + static let maximumEncodedSize = 16 * 1024 + /// Encodes settings for transport across the `@objc` XPC boundary as JSON. func encodedForXPC() throws -> Data { - try JSONEncoder().encode(self) + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw KeyboardLockerSettingsCodingError.payloadTooLarge + } + return data } - /// Decodes settings received over XPC, falling back to `.default` on nil/corrupt data - /// so a wrapper never operates on an ambiguous state. - static func decodedFromXPC(_ data: Data?) -> KeyboardLockerSettings { - guard let data, let settings = try? JSONDecoder().decode(KeyboardLockerSettings.self, from: data) else { - return .default + /// Decodes the Agent's authoritative settings snapshot without inventing a wrapper-side + /// fallback when the transport payload is absent or corrupt. + static func decodedFromXPC(_ data: Data?) throws -> KeyboardLockerSettings { + guard let data else { + throw KeyboardLockerSettingsCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw KeyboardLockerSettingsCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(KeyboardLockerSettings.self, from: data) + } catch { + throw KeyboardLockerSettingsCodingError.invalidPayload } - return settings } } diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift index c8027fd..9696dc3 100644 --- a/Core/Sources/Common/ServiceDescriptor.swift +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -38,6 +38,9 @@ public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, public static let accessibilityPrompt = Self(rawValue: "accessibility-prompt") public static let accessibilityStatus = Self(rawValue: "accessibility-status") public static let currentSettings = Self(rawValue: "current-settings") + public static let currentSettingsWithError = Self( + rawValue: "current-settings-with-error" + ) public static let committedReplacementDrain = Self( rawValue: "committed-replacement-drain" ) @@ -71,13 +74,14 @@ public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sen /// Current bootstrap contract advertised by the bundled Agent. public enum ServiceContract { - public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 1) + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 2) public static let requiredCapabilities: Set = [ .accessibilityPrompt, .accessibilityStatus, .committedReplacementDrain, .currentSettings, + .currentSettingsWithError, .lockControl, .prepareForReplacement, ] diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index dbdfe53..38a0504 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -84,6 +84,9 @@ public protocol KeyboardLockerServiceProtocol { // MARK: Settings Methods /// Returns the Agent's current settings as JSON-encoded `KeyboardLockerSettings`. - /// `data` is nil only if the Agent failed to encode; wrappers fall back to `.default`. + /// Retained for compatibility with protocol 1.1 clients. func currentSettings(reply: @escaping (Data?) -> Void) + + /// Returns the Agent's current settings and reports serialization failures explicitly. + func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) } diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift index dd9d5f7..9980aa8 100644 --- a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -90,6 +90,13 @@ final class ServiceCompatibilityTests: XCTestCase { XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 1) } + func testCurrentContractRequiresErrorReportingSettingsSelector() { + XCTAssertTrue( + ServiceContract.requiredCapabilities.contains(.currentSettingsWithError) + ) + XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 2) + } + func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( @@ -104,6 +111,20 @@ final class ServiceCompatibilityTests: XCTestCase { ) } + func testAgentWithoutErrorReportingSettingsIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .currentSettingsWithError, + ]) + ) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.missingCapabilities([.currentSettingsWithError])] + ) + } + func testBuildMismatchRequiresReplacement() { let requirements = makeRequirements(agentBuild: "42") let descriptor = makeDescriptor(agentBuild: "41") diff --git a/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift b/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift new file mode 100644 index 0000000..e8c8506 --- /dev/null +++ b/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift @@ -0,0 +1,54 @@ +import Common +import Foundation +import XCTest + +final class KeyboardLockerSettingsCodingTests: XCTestCase { + func testRoundTripPreservesSettings() throws { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 42), + unlockHotkey: .init( + keyCode: 12, + modifierFlags: [.maskAlternate, .maskShift] + ) + ) + + XCTAssertEqual( + try KeyboardLockerSettings.decodedFromXPC(settings.encodedForXPC()), + settings + ) + } + + func testMissingPayloadIsRejected() { + XCTAssertThrowsError(try KeyboardLockerSettings.decodedFromXPC(nil)) { error in + XCTAssertEqual( + error as? KeyboardLockerSettingsCodingError, + .missingPayload + ) + } + } + + func testInvalidPayloadIsRejected() { + XCTAssertThrowsError( + try KeyboardLockerSettings.decodedFromXPC(Data("not-json".utf8)) + ) { error in + XCTAssertEqual( + error as? KeyboardLockerSettingsCodingError, + .invalidPayload + ) + } + } + + func testOversizedPayloadIsRejectedBeforeDecoding() { + let payload = Data( + repeating: 0, + count: KeyboardLockerSettings.maximumEncodedSize + 1 + ) + + XCTAssertThrowsError(try KeyboardLockerSettings.decodedFromXPC(payload)) { error in + XCTAssertEqual( + error as? KeyboardLockerSettingsCodingError, + .payloadTooLarge + ) + } + } +} From 8c5f125596112a5a915c44cbf89714eae9098e9a Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:01:38 +0800 Subject: [PATCH 031/106] refactor(agent): adopt MainActor isolation and implement currentSettingsWithError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentService: - Annotate stored properties (descriptorResult, settings, replacement, replacementPreparationExpiration) and init() with @MainActor so all Agent mutable state shares one isolation domain with LockEngine - Rename executeOnMainThread → executeOnMainActor; use MainActor.assumeIsolated instead of bare main-thread dispatch - Introduce MainActorOperation as an @unchecked Sendable transfer object that moves @MainActor closures across the nonisolated XPC adapter boundary - Implement currentSettingsWithError: reply with encoded data or the concrete coding error - Wrap legacy currentSettings in executeOnMainActor for consistency main.swift: - Move AgentService instantiation inside startAgent() so the instance is scoped to the process, not a module-level global - Retain the (listener, delegate) tuple with withExtendedLifetime for the RunLoop duration; NSXPCListener.delegate is weak, so this prevents silent deallocation --- KeyboardLockerAgent/AgentService.swift | 76 ++++++++++++++++++-------- KeyboardLockerAgent/main.swift | 21 +++++-- 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index 025f10c..a5aece4 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -7,12 +7,12 @@ import Service final class AgentService: NSObject, KeyboardLockerServiceProtocol { private static let replacementPreparationDuration: TimeInterval = 30 - private let descriptorResult: Result - private let settings: KeyboardLockerSettings - private var replacement = ReplacementTransaction() - private var replacementPreparationExpiration: DispatchWorkItem? + @MainActor private let descriptorResult: Result + @MainActor private let settings: KeyboardLockerSettings + @MainActor private var replacement = ReplacementTransaction() + @MainActor private var replacementPreparationExpiration: DispatchWorkItem? - override init() { + @MainActor override init() { let loaded = KeyboardLockerSettingsStore().load() descriptorResult = Result { try Self.makeServiceDescriptor() @@ -26,7 +26,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Bootstrap func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) { - executeOnMainThread { + executeOnMainActor { switch self.descriptorResult { case let .success(base): let descriptor = ServiceDescriptor( @@ -55,8 +55,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Locking func lockKeyboard(reply: @escaping (Error?) -> Void) { - let current = settings - executeOnMainThread { + executeOnMainActor { guard !self.replacement.isPending else { reply(Self.replacementError( code: 1, @@ -66,7 +65,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } do { - try LockEngine.shared.lock(settings: current) + try LockEngine.shared.lock(settings: self.settings) reply(nil) } catch { reply(error) @@ -75,14 +74,14 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } func unlockKeyboard(reply: @escaping (Error?) -> Void) { - executeOnMainThread { + executeOnMainActor { LockEngine.shared.unlock() reply(nil) } } func status(reply: @escaping (Bool, Error?) -> Void) { - executeOnMainThread { + executeOnMainActor { reply(LockEngine.shared.isLocked, nil) } } @@ -92,7 +91,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { expectedAgentInstanceID: UUID, reply: @escaping (Data?, Error?) -> Void ) { - executeOnMainThread { + executeOnMainActor { guard case let .success(descriptor) = self.descriptorResult else { reply(nil, Self.replacementError( code: 2, @@ -158,7 +157,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { return } - executeOnMainThread { + executeOnMainActor { do { try self.replacement.commit(ticket: candidate) self.replacementPreparationExpiration?.cancel() @@ -182,7 +181,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { return } - executeOnMainThread { + executeOnMainActor { do { let data = try self.replacement.status(for: candidate).encodedForXPC() reply(data, nil) @@ -204,7 +203,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { return } - executeOnMainThread { + executeOnMainActor { do { try self.replacement.cancel(ticket: candidate) self.replacementPreparationExpiration?.cancel() @@ -219,13 +218,13 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Accessibility func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) { - executeOnMainThread { + executeOnMainActor { reply(AccessibilityManager.hasPermission()) } } func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) { - executeOnMainThread { + executeOnMainActor { AccessibilityManager.requestPermission() reply(nil) } @@ -234,7 +233,20 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Settings func currentSettings(reply: @escaping (Data?) -> Void) { - reply(try? settings.encodedForXPC()) + executeOnMainActor { + reply(try? self.settings.encodedForXPC()) + } + } + + func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) { + executeOnMainActor { + do { + let data = try self.settings.encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + } } // MARK: - Helpers @@ -286,7 +298,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { return replacementError(code: code, description: error.localizedDescription) } - private func scheduleExpiration( + @MainActor private func scheduleExpiration( for preparation: ReplacementTransaction.Preparation ) { replacementPreparationExpiration?.cancel() @@ -306,15 +318,35 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { ) } - private func executeOnMainThread(_ operation: @escaping () -> Void) { + private func executeOnMainActor(_ operation: @escaping @MainActor () -> Void) { + let operation = MainActorOperation(operation) if Thread.isMainThread { - operation() + MainActor.assumeIsolated { + operation() + } } else { - DispatchQueue.main.async(execute: operation) + DispatchQueue.main.async { + MainActor.assumeIsolated { + operation() + } + } } } } +/// Immutable transfer object used to move an Objective-C XPC reply operation onto `MainActor`. +private final class MainActorOperation: @unchecked Sendable { + private let body: @MainActor () -> Void + + init(_ body: @escaping @MainActor () -> Void) { + self.body = body + } + + @MainActor func callAsFunction() { + body() + } +} + private enum AgentDescriptorError: Error, LocalizedError { case missingMetadata(String) diff --git a/KeyboardLockerAgent/main.swift b/KeyboardLockerAgent/main.swift index cb2e899..43a77ca 100644 --- a/KeyboardLockerAgent/main.swift +++ b/KeyboardLockerAgent/main.swift @@ -1,12 +1,14 @@ import Foundation import Service -/// Singleton service instance shared across all XPC connections -/// Ensures consistent state and avoids per-connection instance issues -private let sharedService = AgentService() - /// Accepts incoming XPC connections and configures them using the factory private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { + private let sharedService: AgentService + + init(sharedService: AgentService) { + self.sharedService = sharedService + } + func listener( _: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection @@ -19,6 +21,9 @@ private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { @MainActor private func startAgent() { + // One process-wide service owns the authoritative settings, replacement transaction, and + // LockEngine. Every accepted connection receives only a proxy to this same instance. + let sharedService = AgentService() let listener = NSXPCListener(machServiceName: SharedConstants.machServiceName) do { try listener.setConnectionCodeSigningRequirement( @@ -28,12 +33,16 @@ private func startAgent() { fatalError("KeyboardLockerAgent could not configure XPC peer authentication: \(error)") } - let delegate = ServiceDelegate() + let delegate = ServiceDelegate(sharedService: sharedService) listener.delegate = delegate listener.activate() print("KeyboardLockerAgent started, listening on \(SharedConstants.machServiceName)") - RunLoop.main.run() + // `NSXPCListener.delegate` is weak. Retain both objects explicitly for the lifetime of the + // process instead of relying on optimizer-dependent local-variable lifetime extension. + withExtendedLifetime((listener, delegate)) { + RunLoop.main.run() + } } MainActor.assumeIsolated { From 6816154c3bee1b7b6d1788df0a0b5197b2a4427f Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:01:53 +0800 Subject: [PATCH 032/106] feat(app): add menu-bar presentation with snapshot-driven coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore a presentation layer as a thin status-menu controller that renders AppCoordinator snapshots and forwards user actions. The coordinator remains presentation-framework-agnostic; StatusMenuController is the only AppKit dependency. AppCoordinator: - Publish a value-type Snapshot (state + activity + lastError) via an onSnapshotChange callback; deduplicate publications so repeated identical state does not trigger redundant renders - Accept an AgentLockStateObserving protocol for lock-state subscription, injected as LiveAgentLockStateObserver in production and as a fake in tests - Inject lockStateObserver through the existing internal init; production init() wires the live implementation AgentCoordinationServices: - Add Sendable conformance to all coordinator-facing protocols - Extract AgentLockStateObserving protocol + LiveAgentLockStateObserver adapter wrapping LockStateSubscriber.subscribe - Annotate AgentRegistrar @MainActor KeyboardLockerApplication: - Set up NSApplication with accessory activation policy and a delegate that owns the StatusMenuController → AppCoordinator tree - Use withExtendedLifetime to keep the delegate alive for the run loop StatusMenuController (new): - Observe coordinator snapshots and render a menu-bar icon + context menu - States map to SF Symbols and descriptive titles - Actions: toggle lock, request accessibility, open Login Items settings, update/restart Agent (with confirmation alerts), show details, refresh, quit - Reconcile on menuWillOpen so cross-process hints are recalibrated Tests: - New KeyboardLockerModelTests target (non-hosted, linked against Client) - AppCoordinatorTests: reconcile publishes ready snapshot; toggle locks through client; authoritative state update deduplicates publications - FakeAgentClient, FakeAgentLifecycle, FakeLockStateObserver fakes --- KeyboardLocker.xcodeproj/project.pbxproj | 115 +++++++ .../xcschemes/KeyboardLocker.xcscheme | 13 + .../AgentCoordinationServices.swift | 28 +- KeyboardLocker/AgentRegistrar.swift | 1 + KeyboardLocker/AppCoordinator.swift | 53 +++- .../KeyboardLockerApplication.swift | 22 +- KeyboardLocker/StatusMenuController.swift | 297 ++++++++++++++++++ .../AppCoordinatorTests.swift | 217 +++++++++++++ 8 files changed, 736 insertions(+), 10 deletions(-) create mode 100644 KeyboardLocker/StatusMenuController.swift create mode 100644 KeyboardLockerModelTests/AppCoordinatorTests.swift diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index e1296cb..ba59355 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFCFE2EEFAF0B0065F041 /* Client */; }; F2BFFD012EEFAF1A0065F041 /* Service in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD002EEFAF1A0065F041 /* Service */; }; F2BFFD032EEFAF200065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD022EEFAF200065F041 /* Client */; }; + A1000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000A /* Client */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -80,6 +81,7 @@ F21304362ECDADC900735580 /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLockerAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; F21304B42ECDC26400735580 /* klock */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = klock; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000001 /* KeyboardLockerModelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KeyboardLockerModelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -90,6 +92,16 @@ ); target = F21304272ECDAD3500735580 /* KeyboardLocker */; }; + A1000000000000000000000C /* Exceptions for "KeyboardLocker" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + KeyboardLockerApplication.swift, + StatusMenuController.swift, + io.lzhlovesjyq.keyboardlocker.agent.plist, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -111,6 +123,7 @@ exceptions = ( F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */, F213055A2ECE3CC500735580 /* Exceptions for "KeyboardLocker" folder in "Copy Agent Plist" phase from "KeyboardLocker" target */, + A1000000000000000000000C /* Exceptions for "KeyboardLocker" folder in "KeyboardLockerModelTests" target */, ); path = KeyboardLocker; sourceTree = ""; @@ -125,6 +138,11 @@ path = klock; sourceTree = ""; }; + A10000000000000000000002 /* KeyboardLockerModelTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = KeyboardLockerModelTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -152,6 +170,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000000B /* Client in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -162,6 +188,7 @@ F213042A2ECDAD3500735580 /* KeyboardLocker */, F213049C2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B52ECDC26400735580 /* klock */, + A10000000000000000000002 /* KeyboardLockerModelTests */, F21304292ECDAD3500735580 /* Products */, ); sourceTree = ""; @@ -172,6 +199,7 @@ F21304282ECDAD3500735580 /* KeyboardLocker.app */, F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */, F21304B42ECDC26400735580 /* klock */, + A10000000000000000000001 /* KeyboardLockerModelTests.xctest */, ); name = Products; sourceTree = ""; @@ -253,6 +281,30 @@ productReference = F21304B42ECDC26400735580 /* klock */; productType = "com.apple.product-type.tool"; }; + A10000000000000000000003 /* KeyboardLockerModelTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */; + buildPhases = ( + A10000000000000000000004 /* Sources */, + A10000000000000000000005 /* Frameworks */, + A10000000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F213042A2ECDAD3500735580 /* KeyboardLocker */, + A10000000000000000000002 /* KeyboardLockerModelTests */, + ); + name = KeyboardLockerModelTests; + packageProductDependencies = ( + A1000000000000000000000A /* Client */, + ); + productName = KeyboardLockerModelTests; + productReference = A10000000000000000000001 /* KeyboardLockerModelTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -272,6 +324,9 @@ F21304B32ECDC26400735580 = { CreatedOnToolsVersion = 26.1.1; }; + A10000000000000000000003 = { + CreatedOnToolsVersion = 26.2; + }; }; }; buildConfigurationList = F21304232ECDAD3500735580 /* Build configuration list for PBXProject "KeyboardLocker" */; @@ -291,6 +346,7 @@ F21304272ECDAD3500735580 /* KeyboardLocker */, F213049A2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B32ECDC26400735580 /* klock */, + A10000000000000000000003 /* KeyboardLockerModelTests */, ); }; /* End PBXProject section */ @@ -310,6 +366,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -334,6 +397,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -656,6 +726,38 @@ }; name = Release; }; + A10000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = V65YCRQZ2M; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + }; + name = Debug; + }; + A10000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = V65YCRQZ2M; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -695,6 +797,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000000000000000000007 /* Debug */, + A10000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ @@ -710,6 +821,10 @@ isa = XCSwiftPackageProductDependency; productName = Client; }; + A1000000000000000000000A /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F21304202ECDAD3500735580 /* Project object */; diff --git a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme index 17257f5..b688f6f 100644 --- a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme +++ b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme @@ -29,6 +29,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + ServiceDescriptor func status() async throws -> Bool func hasAccessibilityPermission() async throws -> Bool @@ -18,7 +18,7 @@ protocol AgentReadinessServing { } @MainActor -protocol AgentReplacementServing { +protocol AgentReplacementServing: Sendable { func serviceDescriptor() async throws -> ServiceDescriptor func unlock() async throws func status() async throws -> Bool @@ -35,6 +35,14 @@ protocol AgentReplacementServing { func resetConnection() } +@MainActor +protocol AgentLockStateObserving: Sendable { + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken +} + /// Complete live Client surface retained by `AppCoordinator`. /// /// Coordinators depend on the narrower protocols above, so future fakes only implement the @@ -99,7 +107,17 @@ struct LiveAgentClient: AgentClientServing { } @MainActor -protocol AgentReadinessLifecycleServing { +struct LiveAgentLockStateObserver: AgentLockStateObserving { + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + LockStateSubscriber.subscribe(initialState: initialState, handler) + } +} + +@MainActor +protocol AgentReadinessLifecycleServing: Sendable { func ensureEnabled() -> AgentRegistrar.State func compatibility( of descriptor: ServiceDescriptor @@ -107,7 +125,7 @@ protocol AgentReadinessLifecycleServing { } @MainActor -protocol AgentReplacementLifecycleServing { +protocol AgentReplacementLifecycleServing: Sendable { func restart() async -> AgentRegistrar.State } diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index 2c50ee3..999428d 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -6,6 +6,7 @@ import ServiceManagement /// Registers the Agent as a launchd-managed login item so `launchd` can start it on demand /// for any XPC client — even when this App is not running. This is the contract's /// "Agent Lifecycle Requirement": wrappers must not assume the Agent is already up. +@MainActor enum AgentRegistrar { enum State: Equatable { case enabled diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift index 28aa82b..018d8dd 100644 --- a/KeyboardLocker/AppCoordinator.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -22,9 +22,39 @@ final class AppCoordinator { case updatingAgent } - private(set) var state: State = .checking(lastKnownLock: nil) - private(set) var activity: Activity? - private(set) var lastError: String? + struct Snapshot: Equatable { + let state: State + let activity: Activity? + let lastError: String? + } + + private(set) var state: State = .checking(lastKnownLock: nil) { + didSet { + publishSnapshotIfNeeded() + } + } + + private(set) var activity: Activity? { + didSet { + publishSnapshotIfNeeded() + } + } + + private(set) var lastError: String? { + didSet { + publishSnapshotIfNeeded() + } + } + + var snapshot: Snapshot { + Snapshot(state: state, activity: activity, lastError: lastError) + } + + var onSnapshotChange: ((Snapshot) -> Void)? { + didSet { + publishSnapshotIfNeeded(force: true) + } + } private var reconciliationTask: Task? private var needsFollowUpReconciliation = false @@ -33,8 +63,10 @@ final class AppCoordinator { private var stateToken: ObserverToken? private let client: any AgentClientServing private let lifecycle: any AgentLifecycleServing + private let lockStateObserver: any AgentLockStateObserving private let readinessCoordinator: AgentReadinessCoordinator private let replacementCoordinator: AgentReplacementCoordinator + private var lastPublishedSnapshot: Snapshot? private static let replacementProgressPollInterval: Duration = .seconds(3) @@ -42,6 +74,7 @@ final class AppCoordinator { self.init( client: LiveAgentClient(), lifecycle: LiveAgentLifecycle(), + lockStateObserver: LiveAgentLockStateObserver(), initialState: .checking(lastKnownLock: nil) ) reconcile() @@ -50,10 +83,12 @@ final class AppCoordinator { init( client: any AgentClientServing, lifecycle: any AgentLifecycleServing, + lockStateObserver: any AgentLockStateObserving, initialState: State ) { self.client = client self.lifecycle = lifecycle + self.lockStateObserver = lockStateObserver readinessCoordinator = AgentReadinessCoordinator( client: client, lifecycle: lifecycle @@ -566,7 +601,7 @@ final class AppCoordinator { nil } - stateToken = LockStateSubscriber.subscribe(initialState: initialState) { [weak self] isLocked in + stateToken = lockStateObserver.subscribe(initialState: initialState) { [weak self] isLocked in self?.receiveLockState(isLocked) } } @@ -574,6 +609,16 @@ final class AppCoordinator { private func stopStateObservation() { stateToken = nil } + + private func publishSnapshotIfNeeded(force: Bool = false) { + let currentSnapshot = snapshot + guard force || currentSnapshot != lastPublishedSnapshot else { + return + } + + lastPublishedSnapshot = currentSnapshot + onSnapshotChange?(currentSnapshot) + } } private enum AgentUpdateError: Error, LocalizedError { diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index 9bd9ef2..255196e 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -1,7 +1,27 @@ +import AppKit + @main enum KeyboardLockerApplication { @MainActor static func main() { - _ = AgentRegistrar.ensureEnabled() + let application = NSApplication.shared + let delegate = KeyboardLockerApplicationDelegate() + application.delegate = delegate + application.setActivationPolicy(.accessory) + + // `NSApplication.delegate` is weak. Keep the process-wide owner alive while the event loop + // runs so the status item and coordinator remain reachable. + withExtendedLifetime(delegate) { + application.run() + } + } +} + +@MainActor +private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDelegate { + private var statusMenuController: StatusMenuController? + + func applicationDidFinishLaunching(_: Notification) { + statusMenuController = StatusMenuController(coordinator: AppCoordinator()) } } diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift new file mode 100644 index 0000000..8fe7c7b --- /dev/null +++ b/KeyboardLocker/StatusMenuController.swift @@ -0,0 +1,297 @@ +import AppKit +import ServiceManagement + +/// Thin menu-bar presentation for `AppCoordinator` snapshots and actions. +/// It never reads or mutates lock/settings state outside the coordinator's Client boundary. +@MainActor +final class StatusMenuController: NSObject, NSMenuDelegate { + private let coordinator: AppCoordinator + private let menu = NSMenu() + private let statusItem: NSStatusItem + private var currentSnapshot: AppCoordinator.Snapshot + private var detailMessage: String? + + init(coordinator: AppCoordinator) { + self.coordinator = coordinator + currentSnapshot = coordinator.snapshot + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + super.init() + + menu.delegate = self + statusItem.menu = menu + coordinator.onSnapshotChange = { [weak self] snapshot in + self?.render(snapshot) + } + } + + func menuWillOpen(_: NSMenu) { + // A long-lived wrapper must recalibrate when it becomes visible because cross-process + // notification delivery is only a refresh hint, never the authoritative state. + coordinator.reconcile() + } + + private func render(_ snapshot: AppCoordinator.Snapshot) { + currentSnapshot = snapshot + detailMessage = makeDetailMessage(for: snapshot) + + let appearance = makeAppearance(for: snapshot) + if let button = statusItem.button { + let image = NSImage( + systemSymbolName: appearance.symbolName, + accessibilityDescription: appearance.accessibilityDescription + ) + image?.isTemplate = true + button.image = image + button.toolTip = appearance.title + } + + rebuildMenu(statusTitle: appearance.title) + } + + private func rebuildMenu(statusTitle: String) { + menu.removeAllItems() + + let status = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + menu.addItem(.separator()) + + if currentSnapshot.activity == nil { + addStateActions(for: currentSnapshot.state) + } + + if detailMessage != nil { + addAction(title: "Show Details…", action: #selector(showDetails)) + } + + menu.addItem(.separator()) + addAction( + title: "Refresh Status", + action: #selector(refresh), + isEnabled: currentSnapshot.activity == nil + ) + menu.addItem(.separator()) + addAction(title: "Quit KeyboardLocker", action: #selector(quit)) + } + + private func addStateActions(for state: AppCoordinator.State) { + switch state { + case .checking, .agentReplacementInProgress: + break + + case .agentApprovalRequired: + addAction( + title: "Open Login Items Settings…", + action: #selector(openLoginItemsSettings) + ) + + case let .agentUpdateRequired(isLocked, _): + if isLocked == true { + addAction(title: "Unlock Keyboard", action: #selector(toggleLock)) + } + addAction(title: "Update Background Agent…", action: #selector(updateAgent)) + + case let .accessibilityRequired(isLocked): + if isLocked { + addAction(title: "Unlock Keyboard", action: #selector(toggleLock)) + } + addAction( + title: "Grant Accessibility Access…", + action: #selector(requestAccessibilityPermission) + ) + + case let .ready(isLocked): + addAction( + title: isLocked ? "Unlock Keyboard" : "Lock Keyboard", + action: #selector(toggleLock) + ) + + case let .unavailable(_, canRestartAgent): + if canRestartAgent { + addAction(title: "Restart Background Agent…", action: #selector(restartAgent)) + } + } + } + + private func addAction( + title: String, + action: Selector, + isEnabled: Bool = true + ) { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = self + item.isEnabled = isEnabled + menu.addItem(item) + } + + private func makeAppearance( + for snapshot: AppCoordinator.Snapshot + ) -> (title: String, symbolName: String, accessibilityDescription: String) { + if let activity = snapshot.activity { + let title = switch activity { + case .locking: + "KeyboardLocker — Locking…" + case .requestingAccessibility: + "KeyboardLocker — Requesting Access…" + case .restartingAgent: + "KeyboardLocker — Restarting Agent…" + case .unlocking: + "KeyboardLocker — Unlocking…" + case .updatingAgent: + "KeyboardLocker — Updating Agent…" + } + return (title, "arrow.triangle.2.circlepath", "Keyboard lock operation in progress") + } + + switch snapshot.state { + case let .checking(lastKnownLock): + let title = lastKnownLock == true + ? "KeyboardLocker — Checking (Locked)" + : "KeyboardLocker — Checking…" + return (title, "arrow.triangle.2.circlepath", "Checking keyboard lock status") + + case .agentApprovalRequired: + return ( + "KeyboardLocker — Approval Required", + "exclamationmark.triangle.fill", + "Background agent approval required" + ) + + case .agentReplacementInProgress: + return ( + "KeyboardLocker — Updating Agent", + "arrow.triangle.2.circlepath", + "Background agent update in progress" + ) + + case let .agentUpdateRequired(isLocked, _): + return ( + isLocked == true + ? "KeyboardLocker — Locked (Update Required)" + : "KeyboardLocker — Agent Update Required", + "exclamationmark.triangle.fill", + "Background agent update required" + ) + + case let .accessibilityRequired(isLocked): + return ( + isLocked + ? "KeyboardLocker — Locked (Access Required)" + : "KeyboardLocker — Accessibility Required", + isLocked ? "lock.fill" : "exclamationmark.triangle.fill", + isLocked ? "Keyboard locked" : "Accessibility access required" + ) + + case let .ready(isLocked): + return isLocked + ? ("KeyboardLocker — Locked", "lock.fill", "Keyboard locked") + : ("KeyboardLocker — Unlocked", "lock.open.fill", "Keyboard unlocked") + + case .unavailable: + return ( + "KeyboardLocker — Agent Unavailable", + "exclamationmark.triangle.fill", + "Background agent unavailable" + ) + } + } + + private func makeDetailMessage(for snapshot: AppCoordinator.Snapshot) -> String? { + var messages: [String] = [] + + switch snapshot.state { + case .checking, .ready: + break + + case .agentApprovalRequired: + messages.append( + "Enable KeyboardLocker in System Settings → General → Login Items, then refresh status." + ) + + case let .agentReplacementInProgress(message), + let .agentUpdateRequired(_, message), + let .unavailable(message, _): + messages.append(message) + + case .accessibilityRequired: + messages.append( + "The background Agent needs Accessibility access before it can filter keyboard events." + ) + } + + if let lastError = snapshot.lastError { + messages.append(lastError) + } + + return messages.isEmpty ? nil : messages.joined(separator: "\n\n") + } + + @objc private func toggleLock() { + coordinator.toggle() + } + + @objc private func requestAccessibilityPermission() { + coordinator.requestAccessibilityPermission() + } + + @objc private func openLoginItemsSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + @objc private func updateAgent() { + guard case let .agentUpdateRequired(isLocked, message) = currentSnapshot.state else { + return + } + + let confirmation = NSAlert() + confirmation.alertStyle = .warning + confirmation.messageText = "Update the KeyboardLocker Agent?" + confirmation.informativeText = if isLocked == true { + "The keyboard will be unlocked before the Agent is replaced.\n\n\(message)" + } else { + message + } + confirmation.addButton(withTitle: "Update Agent") + confirmation.addButton(withTitle: "Cancel") + + NSApp.activate(ignoringOtherApps: true) + if confirmation.runModal() == .alertFirstButtonReturn { + coordinator.updateAgent() + } + } + + @objc private func restartAgent() { + let confirmation = NSAlert() + confirmation.alertStyle = .warning + confirmation.messageText = "Restart the KeyboardLocker Agent?" + confirmation.informativeText = "Restarting an unresponsive Agent may release an active keyboard lock." + confirmation.addButton(withTitle: "Restart Agent") + confirmation.addButton(withTitle: "Cancel") + + NSApp.activate(ignoringOtherApps: true) + if confirmation.runModal() == .alertFirstButtonReturn { + coordinator.restartAgent() + } + } + + @objc private func showDetails() { + guard let detailMessage else { + return + } + + let alert = NSAlert() + alert.messageText = "KeyboardLocker" + alert.informativeText = detailMessage + alert.addButton(withTitle: "OK") + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } + + @objc private func refresh() { + coordinator.reconcile() + } + + @objc private func quit() { + NSApp.terminate(nil) + } +} diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift new file mode 100644 index 0000000..3936ba3 --- /dev/null +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -0,0 +1,217 @@ +import Client +import Foundation +import XCTest + +final class AppCoordinatorTests: XCTestCase { + @MainActor + func testReconcilePublishesAuthoritativeReadySnapshot() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let lifecycle = FakeAgentLifecycle() + let observer = FakeLockStateObserver() + let coordinator = makeCoordinator( + client: client, + lifecycle: lifecycle, + observer: observer + ) + var snapshots: [AppCoordinator.Snapshot] = [] + coordinator.onSnapshotChange = { snapshots.append($0) } + + coordinator.reconcile() + try await waitUntil { + coordinator.state == .ready(isLocked: false) + } + + XCTAssertEqual(coordinator.snapshot, AppCoordinator.Snapshot( + state: .ready(isLocked: false), + activity: nil, + lastError: nil + )) + XCTAssertEqual(observer.initialStates, [false]) + XCTAssertEqual(snapshots.last, coordinator.snapshot) + } + + @MainActor + func testToggleLocksThroughClientThenReconcilesAuthoritativeState() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let lifecycle = FakeAgentLifecycle() + let observer = FakeLockStateObserver() + let coordinator = makeCoordinator( + client: client, + lifecycle: lifecycle, + observer: observer, + initialState: .ready(isLocked: false) + ) + var activities: [AppCoordinator.Activity?] = [] + coordinator.onSnapshotChange = { activities.append($0.activity) } + + coordinator.toggle() + try await waitUntil { + coordinator.state == .ready(isLocked: true) && coordinator.activity == nil + } + + XCTAssertEqual(client.lockCallCount, 1) + XCTAssertEqual(client.unlockCallCount, 0) + XCTAssertTrue(activities.contains(.locking)) + XCTAssertEqual(observer.initialStates, [true]) + } + + @MainActor + func testObservedAuthoritativeStateUpdatesReadySnapshotWithoutDuplicatePublication() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let lifecycle = FakeAgentLifecycle() + let observer = FakeLockStateObserver() + let coordinator = makeCoordinator( + client: client, + lifecycle: lifecycle, + observer: observer + ) + var snapshots: [AppCoordinator.Snapshot] = [] + coordinator.onSnapshotChange = { snapshots.append($0) } + + coordinator.reconcile() + try await waitUntil { + coordinator.state == .ready(isLocked: false) + } + let publicationCountBeforeDuplicate = snapshots.count + + observer.send(false) + XCTAssertEqual(snapshots.count, publicationCountBeforeDuplicate) + + observer.send(true) + XCTAssertEqual(coordinator.state, .ready(isLocked: true)) + XCTAssertEqual(snapshots.last?.state, .ready(isLocked: true)) + } + + @MainActor + private func makeCoordinator( + client: FakeAgentClient, + lifecycle: FakeAgentLifecycle, + observer: FakeLockStateObserver, + initialState: AppCoordinator.State = .checking(lastKnownLock: nil) + ) -> AppCoordinator { + AppCoordinator( + client: client, + lifecycle: lifecycle, + lockStateObserver: observer, + initialState: initialState + ) + } + + @MainActor + private func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @escaping @MainActor () -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while !condition() { + guard clock.now < deadline else { + XCTFail("Timed out waiting for AppCoordinator state") + return + } + try await Task.sleep(for: .milliseconds(10)) + } + } +} + +@MainActor +private final class FakeAgentClient: AgentClientServing { + private(set) var lockCallCount = 0 + private(set) var unlockCallCount = 0 + var accessibilityPermissionGranted: Bool + var isLocked: Bool + + private let descriptor = ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: UUID() + ) + + init(isLocked: Bool, hasAccessibilityPermission: Bool) { + self.isLocked = isLocked + accessibilityPermissionGranted = hasAccessibilityPermission + } + + func serviceDescriptor() async throws -> ServiceDescriptor { + descriptor + } + + func lock() async throws { + lockCallCount += 1 + isLocked = true + } + + func unlock() async throws { + unlockCallCount += 1 + isLocked = false + } + + func status() async throws -> Bool { + isLocked + } + + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + if unlockIfNeeded { + isLocked = false + } + return ServiceReplacementTicket( + id: UUID(), + agentInstanceID: expectedAgentInstanceID + ) + } + + func cancelReplacementPreparation(ticket _: ServiceReplacementTicket) async throws {} + + func commitReplacement(ticket _: ServiceReplacementTicket) async throws {} + + func hasAccessibilityPermission() async throws -> Bool { + accessibilityPermissionGranted + } + + func requestAccessibilityPermission() async throws {} + + func resetConnection() {} +} + +@MainActor +private final class FakeAgentLifecycle: AgentLifecycleServing { + func ensureEnabled() -> AgentRegistrar.State { + .enabled + } + + func compatibility( + of _: ServiceDescriptor + ) -> AgentRegistrar.Compatibility { + .compatible + } + + func restart() async -> AgentRegistrar.State { + .enabled + } +} + +@MainActor +private final class FakeLockStateObserver: AgentLockStateObserving { + private(set) var initialStates: [Bool?] = [] + private var handler: ((Bool) -> Void)? + + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + initialStates.append(initialState) + self.handler = handler + return ObserverToken {} + } + + func send(_ isLocked: Bool) { + handler?(isLocked) + } +} From a1ec38341b041dbaa6d59d4b525cd534a4691520 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:02:03 +0800 Subject: [PATCH 033/106] fix(klock): handle settings fetch failure without fabricating a fallback When the Agent's current settings cannot be decoded, report the unlock hotkey as unavailable instead of silently substituting .default. Always print the lock status line; if the hotkey is unreachable, fall back to "Run `klock unlock` to unlock." and emit a warning to stderr with the underlying error. docs: synchronize architecture, development, and XPC documentation - LockEngine: @MainActor isolation, strict lock() idempotency, transactional tap installation, fail-open on irrecoverable tap disable - Settings: throwing decode, no wrapper-side .default fallback - Protocol: v1.2 with currentSettingsWithError, legacy currentSettings preserved - AgentService: MainActor isolation via executeOnMainActor, nonisolated XPC adapter pattern - XPC connection: interruption triggers explicit invalidation to prevent cross-generation object reuse - App: menu-bar presentation layer, snapshot-driven coordinator, KeyboardLockerModelTests target --- docs/architecture.md | 4 +++- docs/development.md | 25 +++++++++++++------------ docs/xpc.md | 18 ++++++++++-------- klock/main.swift | 14 +++++++++++--- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d252db6..7983c3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,7 @@ Widget ──┘ ├─ Settings ownership (source of truth) | 关注点 | 归属 | 规则 | |---------|------|------| | 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | -| 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 绝不自己持有 `UserDefaults`,只经 XPC 访问。当前仅暴露读取(`currentSettings`);写入(`applySettings`)尚未接线,加入时同样必须经 Agent,绝不在 wrapper 侧落地。 | +| 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 绝不自己持有 `UserDefaults`,只经 XPC 访问。当前仅暴露读取(`XPCClient.currentSettings()`);写入(`applySettings`)尚未接线,加入时同样必须经 Agent,绝不在 wrapper 侧落地。读取失败必须显式呈现为 unavailable,不能把 wrapper 的 `.default` 冒充为 Agent 当前值。 | | Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。wrapper 只能经 XPC 查询状态或请求 Agent 触发系统 prompt,不得自行调用 Accessibility API。权限 prompt 是异步的;请求完成不代表已授权,wrapper 必须重新查询。 | | 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | | UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,并协调只存在于自身进程的系统边界(例如 App 的 `SMAppService` 生命周期);不得复制 Agent 的锁、设置或 Accessibility 领域逻辑。 | @@ -55,6 +55,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 任何 wrapper 都可以锁;任何 wrapper 都可以解锁。CLI 发起的锁,App 可以解开,反之亦然。 - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 +- `lock` 是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改当前设置、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。 - 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 ## 状态同步 @@ -105,6 +106,7 @@ Agent 更新必须遵守以下边界: 以下是本契约要防范的具体失败模式: - wrapper 自己持有 `KeyboardLockerSettingsStore` / `UserDefaults` → 设置与核心漂移(Agent 会基于过期或默认设置行动)。 +- wrapper 在设置 payload 缺失或损坏时回退 `.default` → wrapper 凭空制造第二份“当前设置”,展示的解锁方式可能与 Agent 实际执行不一致。 - 引入"会话"抽象、暗示客户端拥有锁 → 造成"某个面无法解开另一个面锁上的锁"这种迷惑行为。 - 锁/设置逻辑在 App 与 CLI 之间重复 → 正是 DRY 规则要禁止的维护爆炸。 - wrapper 为了"直接调引擎"而 import `Service` → 绕过了单核。 diff --git a/docs/development.md b/docs/development.md index f3c6f0b..bfd1360 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,38 +15,39 @@ 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 6. wrapper 通过 `LockStateSubscriber.subscribe(initialState:_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态。subscriber 会在 observer 安装完成后立即拉取一次权威初始状态,后续信号串行合并并再次查询 —— 绝不从"我这次调用是否成功"或通知 payload 推断。 -> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**幂等**的(已锁时再锁会重新应用设置并返回成功)。重复 lock 或锁定期间更新设置会从本次调用重新开始完整的 auto-unlock timeout window,但不会改写整次锁定会话的起点。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 +> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**严格幂等**的:已锁时重复调用会直接成功,不修改当前设置、锁定起点或 auto-unlock deadline。只有显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 ## 组件地图 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、锁操作、replacement drain、Accessibility 状态 / 请求、`currentSettings`)、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、锁操作、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。 - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成并预编译同 Team + 精确 identifier 的双向 XPC requirement;unsigned/ad-hoc 进程 fail closed。 -- `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界的 JSON 传输)。 +- `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` -- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条自动重连的连接;所有调用共享有界响应超时,超时只失效对应连接。`lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;optional method 在同一 connection 上完成 descriptor/capability gate;replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。`lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;optional method 在同一 connection 上完成 descriptor/capability gate;replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 - `ServiceCompatibility.swift`:纯值兼容性规则与任意精度 dotted-numeric `ServiceBuildVersion` —— major 必须相等、running minor 不得低于最低版本、required capabilities 必须齐全,且运行中 Agent 的 identifier/version/build 必须与 bundled Agent 一致。 - `LockStateSubscriber.swift`:先安装 Darwin + Distributed observer,再立即拉取一次权威状态;后续把每个信号当作提示,通过 `XPCClient.status()` 串行校准(带重试、signal coalescing、去重和 cancellation fence)→ `ObserverToken`,另有会先产出当前权威状态的 `stateChanges`(`AsyncStream`)。取消会丢弃尚未进入 handler 的结果,但不会回溯撤销已经开始执行的 handler。长命 UI 使用 snapshot seed 避免重复呈现相同状态;一次性面直接读 `status()`(见 architecture 的"状态同步")。 - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:CGEventTap 单例、幂等的 `lock(settings:)`、`updateSettings(_:)`、自动解锁定时器、热键检测、`OSAllocatedUnfairLock` 状态、`os.Logger`。 +- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、严格幂等的 `lock(settings:)`、显式 `updateSettings(_:)`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。重复 `lock` 不会修改活动设置或延长 deadline;资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 -**App**(`KeyboardLocker/`)—— 无 presentation framework 的薄 wrapper;当前入口仅完成 Agent 注册 +**App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper;presentation 只映射协调器 snapshot 与 action - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 -- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol,协调器无需依赖完整 Client/lifecycle surface。 +- `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 +- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol,协调器无需依赖完整 Client/lifecycle surface。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 -- `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为应用状态;不直接实现 handshake、replacement transaction、锁或设置逻辑。 +- `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。 ## 常见任务 @@ -63,7 +64,7 @@ 5. 在 `Core/Tests/ClientTests/` 补充 old/future descriptor fixture、round-trip、build ordering 与兼容性测试;Server 状态机在 `Core/Tests/ServiceTests/` 覆盖 exclusivity、stale ticket/timer、cancel、expiry 与 committed fail-closed 语义。 ### 修改事件过滤 -在 `LockEngine.handleEvent(proxy:type:event:)` 中:返回 `nil` 拦截、返回 `Unmanaged.passUnretained(event)` 放行,并在满足解锁条件(热键或超时)时调用 `unlock()`。`Hotkey.matches(keyCode:flags:)` 会通过 `relevantModifierMask` 过滤掉 CapsLock/NumLock。 +在 `LockEngine.handleEvent(type:event:)` 中:返回 `nil` 拦截、返回 `Unmanaged.passUnretained(event)` 放行,并在满足解锁条件(热键或超时)时调用 `unlock()`。`Hotkey.matches(keyCode:flags:)` 会通过 `relevantModifierMask` 过滤掉 CapsLock/NumLock。 ## 测试须知 @@ -71,6 +72,6 @@ - `requestAccessibilityPermission()` 只表示 Agent 已请求系统显示异步 prompt;用户操作完成后必须重新调用 `hasAccessibilityPermission()`。 - XPC 调用要成功,Agent 必须正在运行 / 已注册(`SMAppService`),且 App、CLI 与 Agent 都必须使用同一 Apple Team 的项目签名。Debug 与 Release 都执行双向 XPC code-signing requirement;unsigned/ad-hoc 可执行文件即使复制 signing identifier 也会被拒绝。 - `klock` target 必须保留 generated embedded Info.plist section;这是让实际 code-signing identifier 等于 `io.lzhlovesjyq.keyboardlocker.klock` 的载体。只设置 `PRODUCT_BUNDLE_IDENTIFIER` 而不嵌入 Info.plist 时,`codesign` 会退回裸名称 `klock`,Agent 会拒绝它。 -- 引擎操作会派发到主线程,以便访问 CFRunLoop。 -- 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。 -- App 协调器通过 protocol injection 与 presentation layer 解耦。当前 Xcode 工程没有独立的 App-model test target;新增确定性协调器测试时应先建立 non-hosted target,再注入 fake Client/lifecycle,避免测试触碰 live `SMAppService`/XPC。 +- `AgentService` 是 nonisolated XPC adapter;它把所有 Agent 可变状态与引擎操作切到 `MainActor`,以便在同一隔离域维护 CFRunLoop、timer、settings 和 replacement transaction。 +- 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。若重新启用仍失败,会清理 tap/timer、切换为 unlocked 并广播,绝不继续报告虚假的 locked 状态。 +- App 协调器通过 protocol injection 与 presentation layer 解耦。`KeyboardLockerModelTests` 是 non-hosted test target,通过 fake Client/lifecycle/state observer 覆盖确定性协调流程,不触碰 live `SMAppService`/XPC。 diff --git a/docs/xpc.md b/docs/xpc.md index 9398160..2cb0fce 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -117,6 +117,7 @@ public protocol KeyboardLockerServiceProtocol { func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) func currentSettings(reply: @escaping (Data?) -> Void) + func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) } ``` @@ -179,7 +180,7 @@ KeyboardLocker.app `agentBundleIdentifier`、version 和 build 是远端自报的兼容性信息,不是安全凭证。它们只有在 connection 已完成 peer authentication 后才有意义;不能用 descriptor 替代代码签名 requirement。 -descriptor/capability grant 还必须绑定到读取它的 **同一条 client-side connection generation**。`XPCClient` 的 capability-gated API 会先在一条具体 connection 上读取 descriptor,再用同一个 `NSXPCConnection` 发送 feature selector;若这条 connection 已失效,调用失败并重新协商,不能把 Agent A 的 capability 缓存用于透明重连后的 Agent B。由于 named connection object 在 interruption 后仍可能面对新的远端进程,replacement request 还把 expected `agentInstanceID` 放进 wire message:Agent 在安装 barrier 前原子比较自己的 instance ID,Client 再校验返回 ticket,形成两侧 generation fence。 +descriptor/capability grant 还必须绑定到读取它的 **同一条 client-side connection generation**。`XPCClient` 的 capability-gated API 会先在一条具体 connection 上读取 descriptor,再用同一个 `NSXPCConnection` 发送 feature selector;若这条 connection interruption/invalidation,Client 会 invalidate 该 object,后续调用以新 connection 重新协商,不能把 Agent A 的 capability grant 用于 Agent B。replacement request 还把 expected `agentInstanceID` 放进 wire message:Agent 在安装 barrier 前原子比较自己的 instance ID,Client 再校验返回 ticket,形成两侧 generation fence。 App 的 readiness 顺序是: @@ -445,7 +446,7 @@ Foundation 当前公开的调度保证包括: - 发给 `exportedObject` 的调用会被串行投递到 non-main queue;业务代码不能假设自己运行在 main thread。 - 不同 wrapper 对应不同 Agent-side connection,因此不要把“单 connection 串行”误解成“整个 Agent 全局串行”。 -这解释了 `AgentService.executeOnMainThread` 的必要性:多个 client connection 最终都把 `LockEngine` 操作派发到 Agent main thread,event tap、CFRunLoop 和 timer 的生命周期因此收敛到同一执行上下文。`LockEngine` 内部的 `OSAllocatedUnfairLock` 继续保护可能跨线程读取的状态。 +这解释了 `AgentService.executeOnMainActor` 的必要性:nonisolated XPC adapter 把多个 client connection 的调用切到 `MainActor`;Agent 的 settings、replacement transaction 与 `LockEngine` 状态都受编译器检查的同一隔离域约束,event tap、CFRunLoop 和 timer 的生命周期也收敛到这里。 ### Interruption、invalidation 与项目自己的 timeout @@ -453,7 +454,7 @@ Foundation 当前公开的调度保证包括: | 事件 | Foundation/XPC 含义 | 当前项目处理 | |---|---|---| -| interruption | 远端进程退出或 crash;named service 之后可能重新建立 | 清除缓存 connection,让下次调用重新创建 | +| interruption | 远端进程退出或 crash;同一个 named connection object 之后可能透明附着到新进程 | 清除缓存并主动 invalidate 该 object;下次调用创建新 connection 并重新握手 | | invalidation | connection 无法建立或已永久结束;不能再收发消息 | 清除缓存 connection,后续重建 | | proxy error | 当前 method 无法取得 reply | 恢复对应 continuation 并抛错 | | 5 秒 timeout | 项目自己加的上层 deadline,不是 Foundation 自动提供 | 让本 connection 失效;mutation 再查询权威状态 | @@ -480,7 +481,7 @@ sequenceDiagram Listener->>Peer: validate, configure, and accept ClientConnection->>Peer: lockKeyboard(reply:) Peer->>Adapter: dispatch to exported object - Adapter->>Engine: lock(settings:) on main thread + Adapter->>Engine: lock(settings:) on MainActor Engine-->>Adapter: success or domain error Adapter-->>Peer: reply(error) Peer-->>ClientConnection: reply message @@ -497,7 +498,7 @@ sequenceDiagram 5. Agent Listener 的 requirement 在 delegate 暴露任何方法前校验调用方身份;Client requirement 同时拒绝伪造的 Agent。 6. `XPCServerConnection` 为 Agent-side connection 设置 protocol,并把同一个 `AgentService` 实例设置为 `exportedObject`。 7. 对 remote proxy 的 `lockKeyboard(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 -8. `AgentService` 把引擎操作切到主线程,因为 event tap 和 CFRunLoop 生命周期在这里维护。 +8. `AgentService` 把领域操作切到 `MainActor`,Agent 可变状态、event tap 和 CFRunLoop 生命周期都在这里维护。 9. `LockEngine.shared.lock(settings:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap。 10. reply 通过 XPC 返回,`XPCClient` 把 callback bridge 成 Swift async/throwing API。 @@ -508,7 +509,7 @@ sequenceDiagram | Wire method | Client API | 权威执行方 | 语义 | |---|---|---|---| | `serviceDescriptor` | `serviceDescriptor()` | `AgentService` | bootstrap query;返回 protocol、capability、bundled metadata 和 process instance ID | -| `lockKeyboard` | `lock()` | `LockEngine` | 幂等地进入全局 locked 状态;已锁时重新应用设置 | +| `lockKeyboard` | `lock()` | `LockEngine` | 严格幂等地进入全局 locked 状态;已锁时直接成功且不修改设置、锁定起点或 auto-unlock deadline | | `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | | `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | | `prepareForReplacement` | `prepareForReplacement(unlockIfNeeded:expectedAgentInstanceID:)` | `AgentService` | Agent 原子校验 expected instance,安装短期 prepared drain,可在同一 execution turn 解锁,返回同 generation ticket | @@ -517,7 +518,8 @@ sequenceDiagram | `cancelReplacementPreparation` | `cancelReplacementPreparation(ticket:)` | `AgentService` | 仅在 commit 前由 ticket owner 解除 preparation;committed drain 拒绝 cancel | | `hasAccessibilityPermission` | `hasAccessibilityPermission()` | `AccessibilityManager` | 查询 **Agent 进程** 当前是否受信任 | | `requestAccessibilityPermission` | `requestAccessibilityPermission()` | `AccessibilityManager` | 请求系统异步显示 Agent 的授权 prompt;reply 不代表用户已授权 | -| `currentSettings` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 持有的设置快照 | +| `currentSettings` | protocol 1.1 legacy Client only | `AgentService` | 为既有 selector ABI 保留;编码失败只能返回 `nil` | +| `currentSettingsWithError` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 持有的设置快照;缺失、损坏、过大或编码失败均显式抛错,不在 wrapper 侧回退默认值 | Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使用 Accessibility API 的进程身份。App 获得 Accessibility 权限并不能让 Agent 创建 event tap。 @@ -526,7 +528,7 @@ Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使 `XPCClient` 会缓存一条 connection,这是减少重复建连的实现优化,不是业务 session: - App 和 CLI 各有自己的 connection。 -- 某条 connection interruption/invalidation 后,只清除 Client 侧缓存;下一次调用会重新连接。 +- 某条 connection interruption 后,Client 会将其 invalidate 并清除缓存;invalidation 同样清除缓存。下一次调用创建新 connection 并重新握手。 - App 退出、CLI 退出或 connection 断开,不会触发 unlock。 - `klock lock` 当前会继续运行以打印后续的 `Unlocked.`,但它只是观察者;强制结束 CLI 不会解除锁。等待期间 notification subscriber 提供及时更新,周期性 `status()` 提供丢通知与 Agent 重启后的恢复;连续无法取得权威状态时 CLI 报错退出。 - Agent 退出则不同:event tap 属于 Agent 进程,进程退出会由系统释放它,内存中的 locked 状态也会消失。 diff --git a/klock/main.swift b/klock/main.swift index 6a90c36..afd930c 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -43,9 +43,13 @@ enum KlockCLI { exit(ExitCode.error) } - let hotkey = await (try? XPCClient.shared.currentSettings())?.unlockHotkey.displayString - ?? KeyboardLockerSettings.default.unlockHotkey.displayString - print("Locked. Press \(hotkey) to unlock.") + do { + let hotkey = try await XPCClient.shared.currentSettings().unlockHotkey.displayString + print("Locked. Press \(hotkey) to unlock.") + } catch { + print("Locked. Run `klock unlock` to unlock.") + printWarning("Could not read the configured unlock shortcut: \(error.localizedDescription)") + } do { try await XPCClient.shared.waitUntilUnlocked() @@ -96,6 +100,10 @@ enum KlockCLI { FileHandle.standardError.write(Data("Error: \(message)\n".utf8)) } + private static func printWarning(_ message: String) { + FileHandle.standardError.write(Data("Warning: \(message)\n".utf8)) + } + private enum ExitCode { static let success: Int32 = 0 static let error: Int32 = 1 From b394b2b348a24c254af03dfd1780609b893b05e5 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:27:49 +0800 Subject: [PATCH 034/106] fix(agent): recover missing registration Treat SMAppService.notFound as an ambiguous state: validate bundled assets, then attempt registration when the bundle is complete. Preserve registration failures and cover the recovery path with model tests. --- KeyboardLocker/AgentRegistrar.swift | 70 ++++++++++++-- .../AgentRegistrarTests.swift | 93 +++++++++++++++++++ docs/xpc.md | 2 +- 3 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 KeyboardLockerModelTests/AgentRegistrarTests.swift diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index 999428d..cbf5b8b 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -3,6 +3,15 @@ import Foundation import os import ServiceManagement +@MainActor +protocol AgentRegistrationServing: AnyObject { + var status: SMAppService.Status { get } + + func register() throws +} + +extension SMAppService: AgentRegistrationServing {} + /// Registers the Agent as a launchd-managed login item so `launchd` can start it on demand /// for any XPC client — even when this App is not running. This is the contract's /// "Agent Lifecycle Requirement": wrappers must not assume the Agent is already up. @@ -56,7 +65,16 @@ enum AgentRegistrar { /// Ensures the Agent is registered and returns the user-visible lifecycle state. static func ensureEnabled() -> State { - let service = agent + ensureEnabled( + service: agent, + validateBundledRegistration: validateBundledRegistrationAssets + ) + } + + static func ensureEnabled( + service: any AgentRegistrationServing, + validateBundledRegistration: () throws -> Void + ) -> State { switch service.status { case .enabled: return .enabled @@ -65,7 +83,14 @@ enum AgentRegistrar { return .approvalRequired case .notFound: - return .unavailable(.notFound) + do { + try validateBundledRegistration() + } catch let failure as Failure { + return .unavailable(failure) + } catch { + return .unavailable(.invalidBundle(error.localizedDescription)) + } + fallthrough case .notRegistered: do { @@ -164,11 +189,11 @@ enum AgentRegistrar { let service = agent switch service.status { - case .notRegistered: - return ensureEnabled() - - case .notFound: - return .unavailable(.notFound) + case .notFound, .notRegistered: + return ensureEnabled( + service: service, + validateBundledRegistration: validateBundledRegistrationAssets + ) case .enabled, .requiresApproval: do { @@ -179,7 +204,10 @@ enum AgentRegistrar { return .unavailable(.restartFailed(error.localizedDescription)) } } - return ensureEnabled() + return ensureEnabled( + service: service, + validateBundledRegistration: validateBundledRegistrationAssets + ) @unknown default: return .unavailable(.restartFailed("The system returned an unsupported registration status.")) @@ -193,12 +221,19 @@ enum AgentRegistrar { switch status { case .enabled: .enabled + case .requiresApproval: .approvalRequired + case .notFound: - .unavailable(.notFound) + .unavailable(.registrationFailed( + error?.localizedDescription + ?? "Service Management still could not find the agent after registration." + )) + case .notRegistered: .unavailable(.registrationFailed(error?.localizedDescription ?? "Registration did not complete.")) + @unknown default: .unavailable(.registrationFailed( error?.localizedDescription ?? "The system returned an unsupported registration status." @@ -206,6 +241,23 @@ enum AgentRegistrar { } } + private static func validateBundledRegistrationAssets() throws { + let plistURL = Bundle.main.bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("LaunchAgents", isDirectory: true) + .appendingPathComponent(plistName, isDirectory: false) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: plistURL.path, isDirectory: &isDirectory), + !isDirectory.boolValue + else { + throw Failure.notFound + } + + _ = try bundledCompatibilityRequirements() + } + private static func bundledCompatibilityRequirements() throws -> ServiceCompatibilityRequirements { let agentURL = Bundle.main.bundleURL .appendingPathComponent("Contents", isDirectory: true) diff --git a/KeyboardLockerModelTests/AgentRegistrarTests.swift b/KeyboardLockerModelTests/AgentRegistrarTests.swift new file mode 100644 index 0000000..e66bbc4 --- /dev/null +++ b/KeyboardLockerModelTests/AgentRegistrarTests.swift @@ -0,0 +1,93 @@ +import ServiceManagement +import XCTest + +final class AgentRegistrarTests: XCTestCase { + @MainActor + func testNotFoundWithValidBundledAssetsAttemptsRegistration() { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .enabled + ) + + let state = AgentRegistrar.ensureEnabled( + service: service, + validateBundledRegistration: {} + ) + + XCTAssertEqual(state, .enabled) + XCTAssertEqual(service.registrationCallCount, 1) + } + + @MainActor + func testNotFoundWithMissingBundledAssetsDoesNotAttemptRegistration() { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .enabled + ) + + let state = AgentRegistrar.ensureEnabled( + service: service, + validateBundledRegistration: { + throw AgentRegistrar.Failure.notFound + } + ) + + XCTAssertEqual(state, .unavailable(.notFound)) + XCTAssertEqual(service.registrationCallCount, 0) + } + + @MainActor + func testNotFoundRegistrationFailurePreservesUnderlyingError() { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .notFound, + registrationError: RegistrationError.failed + ) + + let state = AgentRegistrar.ensureEnabled( + service: service, + validateBundledRegistration: {} + ) + + XCTAssertEqual( + state, + .unavailable(.registrationFailed("Registration failed for testing.")) + ) + XCTAssertEqual(service.registrationCallCount, 1) + } +} + +@MainActor +private final class FakeAgentRegistrationService: AgentRegistrationServing { + private(set) var registrationCallCount = 0 + private(set) var status: SMAppService.Status + + private let registrationError: Error? + private let statusAfterRegistration: SMAppService.Status + + init( + status: SMAppService.Status, + statusAfterRegistration: SMAppService.Status, + registrationError: Error? = nil + ) { + self.status = status + self.statusAfterRegistration = statusAfterRegistration + self.registrationError = registrationError + } + + func register() throws { + registrationCallCount += 1 + if let registrationError { + throw registrationError + } + status = statusAfterRegistration + } +} + +private enum RegistrationError: LocalizedError { + case failed + + var errorDescription: String? { + "Registration failed for testing." + } +} diff --git a/docs/xpc.md b/docs/xpc.md index 2cb0fce..72c65f4 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -615,7 +615,7 @@ sequenceDiagram | `.notRegistered` | Agent 尚未注册 | 尝试 `register()`,然后重新读取状态 | | `.enabled` | 系统允许 Agent 运行 | 继续通过 XPC 查询真实 readiness | | `.requiresApproval` | 用户需要在 Login Items 批准 | 显示说明和 System Settings 入口 | -| `.notFound` | bundle 内找不到声明的 Agent/plist | 报告安装或 bundle layout 错误 | +| `.notFound` | Service Management 无法解析该 service;既可能是 bundled Agent/plist 缺失,也可能只是当前 App 尚无 Background Task Management 注册记录 | 先独立校验 bundled assets;完整时尝试 `register()` 并以注册后的状态或错误为准,缺失时才报告 bundle layout 错误 | | enabled + descriptor compatible | 当前进程理解所需 contract 且匹配 bundled Agent | 继续查询 lock / Accessibility readiness | | enabled + running build lower + same major + replacement capability | 安全自动 upgrade 候选 | unlocked 时每 bundled build 自动尝试一次;locked 时要求用户确认 | | enabled + running build higher | 旧 App 面对更新 Agent | 禁止自动降级;只提供明确用户 replacement | From 070db7813297aca9b2bb42db61a4631a435a1690 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:36:40 +0800 Subject: [PATCH 035/106] chore(dev): add scoped registration reset Add a reusable Debug workflow that quits the app, asks the agent to unlock, unregisters only KeyboardLocker through SMAppService, and verifies the service is stopped. Keep TCC, user settings, and other apps background items untouched. --- KeyboardLocker/AgentRegistrar.swift | 53 ++++++++- .../KeyboardLockerApplication.swift | 54 +++++++++ .../AgentRegistrarTests.swift | 61 +++++++++- docs/development.md | 12 ++ scripts/reset-keyboardlocker.sh | 109 ++++++++++++++++++ 5 files changed, 283 insertions(+), 6 deletions(-) create mode 100755 scripts/reset-keyboardlocker.sh diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index cbf5b8b..bde425e 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -8,6 +8,7 @@ protocol AgentRegistrationServing: AnyObject { var status: SMAppService.Status { get } func register() throws + func unregister() async throws } extension SMAppService: AgentRegistrationServing {} @@ -28,6 +29,7 @@ enum AgentRegistrar { case notFound case registrationFailed(String) case restartFailed(String) + case unregistrationFailed(String) var message: String { switch self { @@ -39,6 +41,8 @@ enum AgentRegistrar { "The KeyboardLocker agent could not be registered. \(message)" case let .restartFailed(message): "The KeyboardLocker agent could not be restarted. \(message)" + case let .unregistrationFailed(message): + "The KeyboardLocker agent could not be unregistered. \(message)" } } } @@ -63,6 +67,13 @@ enum AgentRegistrar { SMAppService.agent(plistName: plistName) } + static var isAgentEnabled: Bool { + if case .enabled = agent.status { + return true + } + return false + } + /// Ensures the Agent is registered and returns the user-visible lifecycle state. static func ensureEnabled() -> State { ensureEnabled( @@ -197,12 +208,10 @@ enum AgentRegistrar { case .enabled, .requiresApproval: do { - try await service.unregister() + try await unregister(service: service) } catch { - guard service.status == .notRegistered else { - logger.error("Failed to restart agent: \(error.localizedDescription, privacy: .public)") - return .unavailable(.restartFailed(error.localizedDescription)) - } + logger.error("Failed to restart agent: \(error.localizedDescription, privacy: .public)") + return .unavailable(.restartFailed(error.localizedDescription)) } return ensureEnabled( service: service, @@ -214,6 +223,40 @@ enum AgentRegistrar { } } + /// Removes only this App's Service Management registration. The caller is responsible for + /// resolving any active keyboard lock before invoking this lifecycle operation. + static func unregister() async throws { + try await unregister(service: agent) + } + + static func unregister(service: any AgentRegistrationServing) async throws { + switch service.status { + case .notFound, .notRegistered: + return + + case .enabled, .requiresApproval: + do { + try await service.unregister() + logger.info("Unregistered agent") + } catch { + switch service.status { + case .notFound, .notRegistered: + return + case .enabled, .requiresApproval: + logger.error("Failed to unregister agent: \(error.localizedDescription, privacy: .public)") + throw error + @unknown default: + throw error + } + } + + @unknown default: + throw Failure.unregistrationFailed( + "The system returned an unsupported registration status." + ) + } + } + private static func stateAfterRegistration( _ status: SMAppService.Status, error: Error? = nil diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index 255196e..2d44228 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -1,9 +1,17 @@ import AppKit +import Client +import Foundation @main enum KeyboardLockerApplication { @MainActor static func main() { + #if DEBUG + if runDevelopmentCommandIfRequested() { + return + } + #endif + let application = NSApplication.shared let delegate = KeyboardLockerApplicationDelegate() application.delegate = delegate @@ -15,6 +23,52 @@ enum KeyboardLockerApplication { application.run() } } + + #if DEBUG + @MainActor + private static func runDevelopmentCommandIfRequested() -> Bool { + guard CommandLine.arguments.dropFirst().elementsEqual(["--reset-agent-registration"]) else { + return false + } + + Task { @MainActor in + exit(await resetAgentRegistration()) + } + RunLoop.main.run() + return true + } + + @MainActor + private static func resetAgentRegistration() async -> Int32 { + if AgentRegistrar.isAgentEnabled { + do { + try await XPCClient.shared.unlock() + } catch { + writeStandardError( + """ + Warning: Could not unlock the agent before reset: \(error.localizedDescription) \ + Continuing with the explicit reset; stopping the agent releases its event tap. + """ + ) + } + } + + XPCClient.shared.resetConnection() + + do { + try await AgentRegistrar.unregister() + print("KeyboardLocker agent registration reset.") + return EXIT_SUCCESS + } catch { + writeStandardError("Error: Could not reset agent registration: \(error.localizedDescription)") + return EXIT_FAILURE + } + } + + private static func writeStandardError(_ message: String) { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + } + #endif } @MainActor diff --git a/KeyboardLockerModelTests/AgentRegistrarTests.swift b/KeyboardLockerModelTests/AgentRegistrarTests.swift index e66bbc4..e78df22 100644 --- a/KeyboardLockerModelTests/AgentRegistrarTests.swift +++ b/KeyboardLockerModelTests/AgentRegistrarTests.swift @@ -55,24 +55,75 @@ final class AgentRegistrarTests: XCTestCase { ) XCTAssertEqual(service.registrationCallCount, 1) } + + @MainActor + func testUnregisterRemovesEnabledService() async throws { + let service = FakeAgentRegistrationService( + status: .enabled, + statusAfterRegistration: .enabled, + statusAfterUnregistration: .notRegistered + ) + + try await AgentRegistrar.unregister(service: service) + + XCTAssertEqual(service.status, .notRegistered) + XCTAssertEqual(service.unregistrationCallCount, 1) + } + + @MainActor + func testUnregisterIsIdempotentWhenServiceIsNotFound() async throws { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .enabled + ) + + try await AgentRegistrar.unregister(service: service) + + XCTAssertEqual(service.status, .notFound) + XCTAssertEqual(service.unregistrationCallCount, 0) + } + + @MainActor + func testUnregisterPreservesUnderlyingError() async { + let service = FakeAgentRegistrationService( + status: .enabled, + statusAfterRegistration: .enabled, + unregistrationError: RegistrationError.failed + ) + + do { + try await AgentRegistrar.unregister(service: service) + XCTFail("Expected unregister to throw.") + } catch { + XCTAssertEqual(error.localizedDescription, "Registration failed for testing.") + } + XCTAssertEqual(service.unregistrationCallCount, 1) + } } @MainActor private final class FakeAgentRegistrationService: AgentRegistrationServing { private(set) var registrationCallCount = 0 + private(set) var unregistrationCallCount = 0 private(set) var status: SMAppService.Status private let registrationError: Error? private let statusAfterRegistration: SMAppService.Status + private let statusAfterUnregistration: SMAppService.Status + private let unregistrationError: Error? init( status: SMAppService.Status, statusAfterRegistration: SMAppService.Status, - registrationError: Error? = nil + registrationError: Error? = nil, + statusAfterUnregistration: SMAppService.Status = .notRegistered, + unregistrationError: Error? = nil ) { self.status = status self.statusAfterRegistration = statusAfterRegistration self.registrationError = registrationError + self.statusAfterUnregistration = statusAfterUnregistration + self.unregistrationError = unregistrationError } func register() throws { @@ -82,6 +133,14 @@ private final class FakeAgentRegistrationService: AgentRegistrationServing { } status = statusAfterRegistration } + + func unregister() async throws { + unregistrationCallCount += 1 + if let unregistrationError { + throw unregistrationError + } + status = statusAfterUnregistration + } } private enum RegistrationError: LocalizedError { diff --git a/docs/development.md b/docs/development.md index bfd1360..aaf0196 100644 --- a/docs/development.md +++ b/docs/development.md @@ -68,6 +68,18 @@ ## 测试须知 +### 重置本地 Agent 注册 + +需要复现首次启动或清理调试注册状态时,运行: + +```bash +./scripts/reset-keyboardlocker.sh +``` + +脚本会构建 Debug App、退出正在运行的 KeyboardLocker、请求 Agent 解锁,然后由 App 自身通过 `SMAppService.unregister()` 移除自己的 LaunchAgent 注册,并确认对应的 `launchd` service 与 Agent 进程都已消失。下次启动 App 时会按首次启动路径重新注册 Agent。 + +该操作是应用范围的开发重置:不会调用影响其他应用的 `sfltool resetbtm`,也不会重置 Accessibility/TCC 权限或删除 Agent 持有的用户设置。 + - 缺少 Accessibility 权限时,`LockEngine.lock` 抛出 `.accessibilityPermissionDenied`(在创建 event tap 之前检查)。 - `requestAccessibilityPermission()` 只表示 Agent 已请求系统显示异步 prompt;用户操作完成后必须重新调用 `hasAccessibilityPermission()`。 - XPC 调用要成功,Agent 必须正在运行 / 已注册(`SMAppService`),且 App、CLI 与 Agent 都必须使用同一 Apple Team 的项目签名。Debug 与 Release 都执行双向 XPC code-signing requirement;unsigned/ad-hoc 可执行文件即使复制 signing identifier 也会被拒绝。 diff --git a/scripts/reset-keyboardlocker.sh b/scripts/reset-keyboardlocker.sh new file mode 100755 index 0000000..97e99a2 --- /dev/null +++ b/scripts/reset-keyboardlocker.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +readonly REPO_ROOT +readonly PROJECT_PATH="$REPO_ROOT/KeyboardLocker.xcodeproj" +readonly SCHEME="KeyboardLocker" +readonly CONFIGURATION="Debug" + +echo "Building KeyboardLocker ($CONFIGURATION)..." +xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -quiet \ + build + +build_settings="$( + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -showBuildSettings 2>/dev/null +)" +target_build_dir="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*TARGET_BUILD_DIR = / { print $2; exit }' +)" +full_product_name="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*FULL_PRODUCT_NAME = / { print $2; exit }' +)" + +if [[ -z "$target_build_dir" || -z "$full_product_name" ]]; then + echo "Error: Could not resolve the Debug app path from Xcode build settings." >&2 + exit 1 +fi + +readonly APP_PATH="$target_build_dir/$full_product_name" +readonly APP_INFO_PLIST="$APP_PATH/Contents/Info.plist" +readonly AGENT_PLIST="$APP_PATH/Contents/Library/LaunchAgents/io.lzhlovesjyq.keyboardlocker.agent.plist" + +if [[ ! -x "$APP_PATH/Contents/MacOS/KeyboardLocker" || ! -f "$AGENT_PLIST" ]]; then + echo "Error: The built KeyboardLocker app is incomplete: $APP_PATH" >&2 + exit 1 +fi + +APP_BUNDLE_IDENTIFIER="$( + /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_INFO_PLIST" +)" +readonly APP_BUNDLE_IDENTIFIER +APP_EXECUTABLE="$( + /usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP_INFO_PLIST" +)" +readonly APP_EXECUTABLE +AGENT_LABEL="$( + /usr/libexec/PlistBuddy -c 'Print :Label' "$AGENT_PLIST" +)" +readonly AGENT_LABEL +AGENT_PROGRAM="$( + /usr/libexec/PlistBuddy -c 'Print :BundleProgram' "$AGENT_PLIST" +)" +readonly AGENT_PROGRAM +AGENT_EXECUTABLE="$(basename "$AGENT_PROGRAM")" +readonly AGENT_EXECUTABLE + +if pgrep -x "$APP_EXECUTABLE" >/dev/null; then + echo "Quitting KeyboardLocker..." + osascript -e "tell application id \"$APP_BUNDLE_IDENTIFIER\" to quit" >/dev/null + + for ((attempt = 0; attempt < 50; attempt += 1)); do + if ! pgrep -x "$APP_EXECUTABLE" >/dev/null; then + break + fi + sleep 0.1 + done + + if pgrep -x "$APP_EXECUTABLE" >/dev/null; then + echo "Error: KeyboardLocker did not quit; registration was not changed." >&2 + exit 1 + fi +fi + +echo "Resetting KeyboardLocker agent registration..." +PROFILE_OUTPUT="$(mktemp "${TMPDIR:-/tmp}/keyboardlocker-reset.profraw.XXXXXX")" +readonly PROFILE_OUTPUT +trap 'rm -f "$PROFILE_OUTPUT"' EXIT + +LLVM_PROFILE_FILE="$PROFILE_OUTPUT" \ + "$APP_PATH/Contents/MacOS/$APP_EXECUTABLE" --reset-agent-registration +rm -f "$PROFILE_OUTPUT" +trap - EXIT + +SERVICE_TARGET="gui/$(id -u)/$AGENT_LABEL" +readonly SERVICE_TARGET +for ((attempt = 0; attempt < 50; attempt += 1)); do + if ! launchctl print "$SERVICE_TARGET" >/dev/null 2>&1 \ + && ! pgrep -x "$AGENT_EXECUTABLE" >/dev/null; then + echo "KeyboardLocker is reset to an unregistered, stopped state." + exit 0 + fi + sleep 0.1 +done + +echo "Error: The KeyboardLocker agent is still registered or running." >&2 +exit 1 From 54ccf945a9eecf2c294ee14c03c5a8e1e48e7b35 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 11:41:36 +0800 Subject: [PATCH 036/106] chore(project): normalize model test groups Let Xcode serialize the model-test synchronized group first and sort its membership exceptions. The remaining project-file churn only reorders object sections; target membership and build settings stay unchanged. --- KeyboardLocker.xcodeproj/project.pbxproj | 202 +++++++++++------------ 1 file changed, 101 insertions(+), 101 deletions(-) diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index ba59355..2c22a1a 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -7,12 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + A1000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000A /* Client */; }; F21304CF2ECDC2E200735580 /* KeyboardLockerAgent.app in Copy Agent App */ = {isa = PBXBuildFile; fileRef = F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F21304D32ECDC51F00735580 /* klock in Copy CLI */ = {isa = PBXBuildFile; fileRef = F21304B42ECDC26400735580 /* klock */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFCFE2EEFAF0B0065F041 /* Client */; }; F2BFFD012EEFAF1A0065F041 /* Service in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD002EEFAF1A0065F041 /* Service */; }; F2BFFD032EEFAF200065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD022EEFAF200065F041 /* Client */; }; - A1000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000A /* Client */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -77,30 +77,30 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A10000000000000000000001 /* KeyboardLockerModelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KeyboardLockerModelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F21304282ECDAD3500735580 /* KeyboardLocker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLocker.app; sourceTree = BUILT_PRODUCTS_DIR; }; F21304362ECDADC900735580 /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLockerAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; F21304B42ECDC26400735580 /* klock */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = klock; sourceTree = BUILT_PRODUCTS_DIR; }; - A10000000000000000000001 /* KeyboardLockerModelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KeyboardLockerModelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */ = { + A1000000000000000000000C /* Exceptions for "KeyboardLocker" folder in "KeyboardLockerModelTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Assets.xcassets, io.lzhlovesjyq.keyboardlocker.agent.plist, + KeyboardLockerApplication.swift, + StatusMenuController.swift, ); - target = F21304272ECDAD3500735580 /* KeyboardLocker */; + target = A10000000000000000000003 /* KeyboardLockerModelTests */; }; - A1000000000000000000000C /* Exceptions for "KeyboardLocker" folder in "KeyboardLockerModelTests" target */ = { + F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Assets.xcassets, - KeyboardLockerApplication.swift, - StatusMenuController.swift, io.lzhlovesjyq.keyboardlocker.agent.plist, ); - target = A10000000000000000000003 /* KeyboardLockerModelTests */; + target = F21304272ECDAD3500735580 /* KeyboardLocker */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -118,6 +118,11 @@ /* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000002 /* KeyboardLockerModelTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = KeyboardLockerModelTests; + sourceTree = ""; + }; F213042A2ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -138,43 +143,38 @@ path = klock; sourceTree = ""; }; - A10000000000000000000002 /* KeyboardLockerModelTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = KeyboardLockerModelTests; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - F21304252ECDAD3500735580 /* Frameworks */ = { + A10000000000000000000005 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */, + A1000000000000000000000B /* Client in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - F21304982ECDC1AA00735580 /* Frameworks */ = { + F21304252ECDAD3500735580 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F2BFFD012EEFAF1A0065F041 /* Service in Frameworks */, + F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - F21304B12ECDC26400735580 /* Frameworks */ = { + F21304982ECDC1AA00735580 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F2BFFD032EEFAF200065F041 /* Client in Frameworks */, + F2BFFD012EEFAF1A0065F041 /* Service in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - A10000000000000000000005 /* Frameworks */ = { + F21304B12ECDC26400735580 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A1000000000000000000000B /* Client in Frameworks */, + F2BFFD032EEFAF200065F041 /* Client in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -207,6 +207,30 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + A10000000000000000000003 /* KeyboardLockerModelTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */; + buildPhases = ( + A10000000000000000000004 /* Sources */, + A10000000000000000000005 /* Frameworks */, + A10000000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000002 /* KeyboardLockerModelTests */, + F213042A2ECDAD3500735580 /* KeyboardLocker */, + ); + name = KeyboardLockerModelTests; + packageProductDependencies = ( + A1000000000000000000000A /* Client */, + ); + productName = KeyboardLockerModelTests; + productReference = A10000000000000000000001 /* KeyboardLockerModelTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; F21304272ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXNativeTarget; buildConfigurationList = F21304332ECDAD3600735580 /* Build configuration list for PBXNativeTarget "KeyboardLocker" */; @@ -281,30 +305,6 @@ productReference = F21304B42ECDC26400735580 /* klock */; productType = "com.apple.product-type.tool"; }; - A10000000000000000000003 /* KeyboardLockerModelTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */; - buildPhases = ( - A10000000000000000000004 /* Sources */, - A10000000000000000000005 /* Frameworks */, - A10000000000000000000006 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - F213042A2ECDAD3500735580 /* KeyboardLocker */, - A10000000000000000000002 /* KeyboardLockerModelTests */, - ); - name = KeyboardLockerModelTests; - packageProductDependencies = ( - A1000000000000000000000A /* Client */, - ); - productName = KeyboardLockerModelTests; - productReference = A10000000000000000000001 /* KeyboardLockerModelTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -315,6 +315,9 @@ LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 2620; TargetAttributes = { + A10000000000000000000003 = { + CreatedOnToolsVersion = 26.2; + }; F21304272ECDAD3500735580 = { CreatedOnToolsVersion = 26.1.1; }; @@ -324,9 +327,6 @@ F21304B32ECDC26400735580 = { CreatedOnToolsVersion = 26.1.1; }; - A10000000000000000000003 = { - CreatedOnToolsVersion = 26.2; - }; }; }; buildConfigurationList = F21304232ECDAD3500735580 /* Build configuration list for PBXProject "KeyboardLocker" */; @@ -352,21 +352,21 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - F21304262ECDAD3500735580 /* Resources */ = { + A10000000000000000000006 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - F21304992ECDC1AA00735580 /* Resources */ = { + F21304262ECDAD3500735580 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - A10000000000000000000006 /* Resources */ = { + F21304992ECDC1AA00735580 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -376,28 +376,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - F21304242ECDAD3500735580 /* Sources */ = { + A10000000000000000000004 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - F21304972ECDC1AA00735580 /* Sources */ = { + F21304242ECDAD3500735580 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - F21304B02ECDC26400735580 /* Sources */ = { + F21304972ECDC1AA00735580 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - A10000000000000000000004 /* Sources */ = { + F21304B02ECDC26400735580 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -420,6 +420,38 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + A10000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = V65YCRQZ2M; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + }; + name = Debug; + }; + A10000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = V65YCRQZ2M; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + }; + name = Release; + }; F21304312ECDAD3600735580 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -726,41 +758,18 @@ }; name = Release; }; - A10000000000000000000007 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = ""; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = V65YCRQZ2M; - GENERATE_INFOPLIST_FILE = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_VERSION = 5.0; - TEST_HOST = ""; - }; - name = Debug; - }; - A10000000000000000000008 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = ""; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = V65YCRQZ2M; - GENERATE_INFOPLIST_FILE = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_VERSION = 5.0; - TEST_HOST = ""; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000000000000000000007 /* Debug */, + A10000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; F21304232ECDAD3500735580 /* Build configuration list for PBXProject "KeyboardLocker" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -797,18 +806,13 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A10000000000000000000007 /* Debug */, - A10000000000000000000008 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + A1000000000000000000000A /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; F2BFFCFE2EEFAF0B0065F041 /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; @@ -821,10 +825,6 @@ isa = XCSwiftPackageProductDependency; productName = Client; }; - A1000000000000000000000A /* Client */ = { - isa = XCSwiftPackageProductDependency; - productName = Client; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F21304202ECDAD3500735580 /* Project object */; From 94303c61913eed6d4ddb4656df381e67732406ec Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 13:03:23 +0800 Subject: [PATCH 037/106] feat(klock): add CLI installation workflow Make the signed bundled command discoverable without editing shell profiles or requiring elevated installation. - add safe development and App-managed symbolic link flows - expose help, version, and actionable first-use recovery guidance - cover link ownership and document the planned Homebrew Cask path --- Core/Sources/Client/XPCClient.swift | 19 +- .../ClientTests/XPCClientErrorTests.swift | 24 ++ KeyboardLocker.xcodeproj/project.pbxproj | 4 + .../CommandLineToolLinkManager.swift | 253 ++++++++++++++++++ KeyboardLocker/StatusMenuController.swift | 145 +++++++++- .../CommandLineToolLinkManagerTests.swift | 157 +++++++++++ docs/development.md | 22 ++ klock/main.swift | 78 +++++- scripts/install-klock-dev.sh | 212 +++++++++++++++ 9 files changed, 906 insertions(+), 8 deletions(-) create mode 100644 Core/Tests/ClientTests/XPCClientErrorTests.swift create mode 100644 KeyboardLocker/CommandLineToolLinkManager.swift create mode 100644 KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift create mode 100755 scripts/install-klock-dev.sh diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index ac473d0..822fcb8 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -34,6 +34,16 @@ public enum XPCClientError: Error, LocalizedError { "The KeyboardLocker agent did not respond in time." } } + + public var recoverySuggestion: String? { + switch self { + case .serviceUnavailable: + "Open KeyboardLocker once to register its background agent, then retry. If KeyboardLocker is already running, choose Show Details… from its menu." + + default: + nil + } + } } /// Async client for the KeyboardLocker Agent. @@ -380,7 +390,7 @@ public final class XPCClient: @unchecked Sendable { let proxy = connectionReference.value.remoteObjectProxyWithErrorHandler { error in once.run { timeoutTask.cancel() - continuation.resume(throwing: error) + continuation.resume(throwing: Self.normalizedProxyError(error)) } } @@ -404,6 +414,13 @@ public final class XPCClient: @unchecked Sendable { } } } + + /// A proxy error means the request did not reach a callable XPC peer. Keep Foundation's + /// transport details behind the Client boundary so every wrapper can present one actionable + /// service-availability failure. + static func normalizedProxyError(_: Error) -> Error { + return XPCClientError.serviceUnavailable + } } /// Foundation documents `NSXPCConnection` as supporting calls from multiple threads. This box diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift new file mode 100644 index 0000000..0c71a0d --- /dev/null +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -0,0 +1,24 @@ +import Foundation +import XCTest +@testable import Client + +final class XPCClientErrorTests: XCTestCase { + func testProxyErrorsNormalizeToServiceUnavailable() { + let transportError = NSError( + domain: NSCocoaErrorDomain, + code: CocoaError.xpcConnectionInvalid.rawValue + ) + + guard case XPCClientError.serviceUnavailable = XPCClient.normalizedProxyError(transportError) + else { + return XCTFail("Expected the proxy error to normalize to serviceUnavailable.") + } + } + + func testServiceUnavailableExplainsFirstUseRecovery() throws { + let suggestion = try XCTUnwrap(XPCClientError.serviceUnavailable.recoverySuggestion) + + XCTAssertTrue(suggestion.contains("Open KeyboardLocker once")) + XCTAssertTrue(suggestion.contains("Show Details")) + } +} diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index 2c22a1a..c9ae7f5 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -725,11 +725,13 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -744,11 +746,13 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/KeyboardLocker/CommandLineToolLinkManager.swift b/KeyboardLocker/CommandLineToolLinkManager.swift new file mode 100644 index 0000000..cb7af62 --- /dev/null +++ b/KeyboardLocker/CommandLineToolLinkManager.swift @@ -0,0 +1,253 @@ +import Foundation + +/// Manages a discoverability link for the bundled, signed `klock` executable. +/// +/// The executable remains inside the App bundle so updates and XPC signing identity stay aligned. +/// This type only owns a symlink in a user-writable command directory; it never copies the binary, +/// edits shell profiles, or elevates privileges. +@MainActor +final class CommandLineToolLinkManager { + enum State: Equatable { + case conflict(destination: URL) + case installed(destination: URL) + case notInstalled(destination: URL, requiresPathSetup: Bool) + case sourceUnavailable(URL) + } + + enum InstallationError: Error, Equatable, LocalizedError { + case destinationConflict(String) + case destinationNotWritable(String) + case sourceUnavailable(String) + + var errorDescription: String? { + switch self { + case let .destinationConflict(path): + "A different item already exists at \(path). KeyboardLocker did not replace it." + case let .destinationNotWritable(path): + "KeyboardLocker cannot write to the command directory at \(path)." + case let .sourceUnavailable(path): + "The bundled klock executable is missing or not executable at \(path)." + } + } + } + + private static let executableName = "klock" + + private let fileManager: FileManager + private let managedDestinations: [URL] + private let pathDirectoryPaths: Set + private let preferredDestination: URL + private let sourceURL: URL + + convenience init( + bundleURL: URL = Bundle.main.bundleURL, + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { + let homeDirectory = fileManager.homeDirectoryForCurrentUser + let localBin = homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + let homebrewBins = [ + URL(fileURLWithPath: "/opt/homebrew/bin", isDirectory: true), + URL(fileURLWithPath: "/usr/local/bin", isDirectory: true), + ] + let knownDirectories = [localBin] + homebrewBins + let pathDirectories = Self.pathDirectories(from: environment) + + let preferredDirectory = pathDirectories.first { candidate in + knownDirectories.contains(where: { Self.pathsMatch($0, candidate) }) + && Self.canInstall( + in: candidate, + localBin: localBin, + fileManager: fileManager + ) + } ?? homebrewBins.first { candidate in + let brew = candidate.appendingPathComponent("brew", isDirectory: false) + return fileManager.isExecutableFile(atPath: brew.path) + && fileManager.isWritableFile(atPath: candidate.path) + } ?? localBin + + let destinations = ([preferredDirectory] + knownDirectories) + .reduce(into: [URL]()) { result, directory in + let destination = directory.appendingPathComponent(Self.executableName) + guard !result.contains(where: { Self.pathsMatch($0, destination) }) else { + return + } + result.append(destination) + } + + self.init( + sourceURL: bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("MacOS", isDirectory: true) + .appendingPathComponent(Self.executableName, isDirectory: false), + preferredDestination: preferredDirectory + .appendingPathComponent(Self.executableName, isDirectory: false), + managedDestinations: destinations, + pathDirectories: pathDirectories, + fileManager: fileManager + ) + } + + init( + sourceURL: URL, + preferredDestination: URL, + managedDestinations: [URL]? = nil, + pathDirectories: [URL], + fileManager: FileManager = .default + ) { + self.sourceURL = sourceURL.standardizedFileURL + self.preferredDestination = preferredDestination.standardizedFileURL + self.managedDestinations = (managedDestinations ?? [preferredDestination]) + .map(\.standardizedFileURL) + pathDirectoryPaths = Set(pathDirectories.map { $0.standardizedFileURL.path }) + self.fileManager = fileManager + } + + var state: State { + if let installedDestination = managedDestinations.first(where: pointsToBundledExecutable) { + return .installed(destination: installedDestination) + } + + guard fileManager.isExecutableFile(atPath: sourceURL.path) else { + return .sourceUnavailable(sourceURL) + } + + if itemExistsIncludingBrokenSymlink(at: preferredDestination) { + return .conflict(destination: preferredDestination) + } + + let directory = preferredDestination.deletingLastPathComponent().standardizedFileURL + return .notInstalled( + destination: preferredDestination, + requiresPathSetup: !pathDirectoryPaths.contains(directory.path) + ) + } + + @discardableResult + func install() throws -> State { + switch state { + case .installed: + return state + + case let .conflict(destination): + throw InstallationError.destinationConflict(displayPath(destination)) + + case let .sourceUnavailable(source): + throw InstallationError.sourceUnavailable(displayPath(source)) + + case let .notInstalled(destination, _): + let directory = destination.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } catch { + throw InstallationError.destinationNotWritable(displayPath(directory)) + } + guard fileManager.isWritableFile(atPath: directory.path) else { + throw InstallationError.destinationNotWritable(displayPath(directory)) + } + do { + try fileManager.createSymbolicLink(at: destination, withDestinationURL: sourceURL) + } catch { + if itemExistsIncludingBrokenSymlink(at: destination) { + throw InstallationError.destinationConflict(displayPath(destination)) + } + throw error + } + return state + } + } + + @discardableResult + func uninstall() throws -> State { + switch state { + case let .installed(destination): + try fileManager.removeItem(at: destination) + return state + + case .notInstalled: + return state + + case let .conflict(destination): + throw InstallationError.destinationConflict(displayPath(destination)) + + case let .sourceUnavailable(source): + throw InstallationError.sourceUnavailable(displayPath(source)) + } + } + + func displayPath(_ url: URL) -> String { + let path = url.standardizedFileURL.path + let homePath = fileManager.homeDirectoryForCurrentUser.standardizedFileURL.path + guard path == homePath || path.hasPrefix("\(homePath)/") else { + return path + } + return "~\(path.dropFirst(homePath.count))" + } + + func pathSetupCommand(for destination: URL) -> String { + let directory = destination.deletingLastPathComponent() + let displayDirectory = displayPath(directory) + let shellDirectory = displayDirectory.hasPrefix("~/") + ? "$HOME/\(displayDirectory.dropFirst(2))" + : displayDirectory + return "export PATH=\"\(shellDirectory):$PATH\"" + } + + func canRemoveLink(at destination: URL) -> Bool { + fileManager.isWritableFile( + atPath: destination.deletingLastPathComponent().path + ) + } + + private func pointsToBundledExecutable(_ destination: URL) -> Bool { + guard let target = symbolicLinkTarget(at: destination) else { + return false + } + return Self.pathsMatch(target.resolvingSymlinksInPath(), sourceURL.resolvingSymlinksInPath()) + } + + private func symbolicLinkTarget(at destination: URL) -> URL? { + guard let targetPath = try? fileManager.destinationOfSymbolicLink(atPath: destination.path) else { + return nil + } + if targetPath.hasPrefix("/") { + return URL(fileURLWithPath: targetPath).standardizedFileURL + } + return destination.deletingLastPathComponent() + .appendingPathComponent(targetPath) + .standardizedFileURL + } + + private func itemExistsIncludingBrokenSymlink(at url: URL) -> Bool { + symbolicLinkTarget(at: url) != nil || fileManager.fileExists(atPath: url.path) + } + + private static func pathDirectories(from environment: [String: String]) -> [URL] { + (environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + .filter { $0.hasPrefix("/") } + .map { URL(fileURLWithPath: $0, isDirectory: true).standardizedFileURL } + } + + private static func canInstall( + in directory: URL, + localBin: URL, + fileManager: FileManager + ) -> Bool { + if pathsMatch(directory, localBin) { + return true + } + return fileManager.fileExists(atPath: directory.path) + && fileManager.isWritableFile(atPath: directory.path) + } + + private static func pathsMatch(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.standardizedFileURL.path == rhs.standardizedFileURL.path + } +} diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index 8fe7c7b..2beb371 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -5,14 +5,26 @@ import ServiceManagement /// It never reads or mutates lock/settings state outside the coordinator's Client boundary. @MainActor final class StatusMenuController: NSObject, NSMenuDelegate { + private let commandLineToolManager: CommandLineToolLinkManager private let coordinator: AppCoordinator private let menu = NSMenu() private let statusItem: NSStatusItem private var currentSnapshot: AppCoordinator.Snapshot private var detailMessage: String? - init(coordinator: AppCoordinator) { + convenience init(coordinator: AppCoordinator) { + self.init( + coordinator: coordinator, + commandLineToolManager: CommandLineToolLinkManager() + ) + } + + init( + coordinator: AppCoordinator, + commandLineToolManager: CommandLineToolLinkManager + ) { self.coordinator = coordinator + self.commandLineToolManager = commandLineToolManager currentSnapshot = coordinator.snapshot statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) super.init() @@ -71,6 +83,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { isEnabled: currentSnapshot.activity == nil ) menu.addItem(.separator()) + addAction(title: "Command Line Tool…", action: #selector(manageCommandLineTool)) + menu.addItem(.separator()) addAction(title: "Quit KeyboardLocker", action: #selector(quit)) } @@ -291,6 +305,135 @@ final class StatusMenuController: NSObject, NSMenuDelegate { coordinator.reconcile() } + @objc private func manageCommandLineTool() { + NSApp.activate(ignoringOtherApps: true) + + switch commandLineToolManager.state { + case let .installed(destination): + let canRemove = commandLineToolManager.canRemoveLink(at: destination) + let alert = NSAlert() + alert.messageText = "klock Command Is Installed" + alert.informativeText = if canRemove { + "Terminal command: \(commandLineToolManager.displayPath(destination)). If Terminal cannot find it, copy the PATH command." + } else { + "Terminal command: \(commandLineToolManager.displayPath(destination)). KeyboardLocker does not have permission to remove this link." + } + if canRemove { + alert.addButton(withTitle: "Uninstall") + } + alert.addButton(withTitle: "Copy PATH Command") + alert.addButton(withTitle: "Close") + + switch alert.runModal() { + case .alertFirstButtonReturn where canRemove: + do { + _ = try commandLineToolManager.uninstall() + showCommandLineToolResult( + title: "klock Command Removed", + message: "The command link was removed. The bundled executable was not changed." + ) + } catch { + showCommandLineToolError(error) + } + + case .alertSecondButtonReturn where canRemove, + .alertFirstButtonReturn: + copyPathCommand(for: destination) + + default: + return + } + + case let .notInstalled(destination, requiresPathSetup): + let path = commandLineToolManager.displayPath(destination) + let alert = NSAlert() + alert.messageText = "Install the klock Command?" + alert.informativeText = if requiresPathSetup { + """ + KeyboardLocker will create a symbolic link at \(path). It will not modify shell profiles. \ + You may need to add its directory to PATH before Terminal can find klock. + """ + } else { + "KeyboardLocker will create a symbolic link at \(path). The signed executable stays inside the App." + } + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + do { + _ = try commandLineToolManager.install() + showCommandLineToolInstalled( + at: destination, + requiresPathSetup: requiresPathSetup + ) + } catch { + showCommandLineToolError(error) + } + + case let .conflict(destination): + showCommandLineToolResult( + title: "Cannot Install klock", + message: "A different item already exists at \(commandLineToolManager.displayPath(destination)). Nothing was changed." + ) + + case let .sourceUnavailable(source): + showCommandLineToolResult( + title: "klock Is Unavailable", + message: "The App bundle is incomplete. The expected executable was not found at \(commandLineToolManager.displayPath(source))." + ) + } + } + + private func showCommandLineToolInstalled( + at destination: URL, + requiresPathSetup: Bool + ) { + let alert = NSAlert() + alert.messageText = "klock Command Installed" + alert.informativeText = if requiresPathSetup { + """ + Installed at \(commandLineToolManager.displayPath(destination)). Add its directory to PATH, \ + then open a new Terminal and run klock --help. + """ + } else { + "Installed at \(commandLineToolManager.displayPath(destination)). Open a new Terminal and run klock --help." + } + if requiresPathSetup { + alert.addButton(withTitle: "Copy PATH Command") + alert.addButton(withTitle: "Close") + if alert.runModal() == .alertFirstButtonReturn { + copyPathCommand(for: destination) + } + } else { + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + + private func copyPathCommand(for destination: URL) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + commandLineToolManager.pathSetupCommand(for: destination), + forType: .string + ) + } + + private func showCommandLineToolError(_ error: Error) { + showCommandLineToolResult( + title: "Command Line Tool Error", + message: error.localizedDescription + ) + } + + private func showCommandLineToolResult(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } + @objc private func quit() { NSApp.terminate(nil) } diff --git a/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift new file mode 100644 index 0000000..9898cda --- /dev/null +++ b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift @@ -0,0 +1,157 @@ +import Foundation +import XCTest + +final class CommandLineToolLinkManagerTests: XCTestCase { + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + if let temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + temporaryDirectory = nil + } + + @MainActor + func testInstallAndUninstallManageOnlySymbolicLink() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + XCTAssertEqual( + manager.state, + .notInstalled(destination: destination, requiresPathSetup: false) + ) + + XCTAssertEqual(try manager.install(), .installed(destination: destination)) + XCTAssertTrue(manager.canRemoveLink(at: destination)) + XCTAssertEqual( + try FileManager.default.destinationOfSymbolicLink(atPath: destination.path), + source.path + ) + + XCTAssertEqual( + try manager.uninstall(), + .notInstalled(destination: destination, requiresPathSetup: false) + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: source.path)) + } + + @MainActor + func testInstallIsIdempotentForOwnedSymbolicLink() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + _ = try manager.install() + XCTAssertEqual(try manager.install(), .installed(destination: destination)) + } + + @MainActor + func testOwnedSymbolicLinkCanBeRemovedWhenBundledExecutableDisappears() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + _ = try manager.install() + try FileManager.default.removeItem(at: source) + + XCTAssertEqual(manager.state, .installed(destination: destination)) + XCTAssertEqual( + try manager.uninstall(), + .sourceUnavailable(source) + ) + XCTAssertThrowsError( + try FileManager.default.destinationOfSymbolicLink(atPath: destination.path) + ) + } + + @MainActor + func testForeignItemIsReportedAndNeverOverwritten() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) + let destination = binDirectory.appendingPathComponent("klock") + try Data("foreign".utf8).write(to: destination) + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + XCTAssertEqual(manager.state, .conflict(destination: destination)) + XCTAssertThrowsError(try manager.install()) { error in + guard let installationError = error as? CommandLineToolLinkManager.InstallationError, + case .destinationConflict = installationError + else { + return XCTFail("Expected a destination conflict, found \(error).") + } + } + XCTAssertEqual(try Data(contentsOf: destination), Data("foreign".utf8)) + } + + @MainActor + func testMissingPathDirectoryIsReportedWithoutEditingShellConfiguration() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [] + ) + + XCTAssertEqual( + manager.state, + .notInstalled(destination: destination, requiresPathSetup: true) + ) + XCTAssertTrue(manager.pathSetupCommand(for: destination).contains("export PATH=")) + } + + @MainActor + private func makeManager( + source: URL, + destination: URL, + pathDirectories: [URL] + ) -> CommandLineToolLinkManager { + CommandLineToolLinkManager( + sourceURL: source, + preferredDestination: destination, + pathDirectories: pathDirectories + ) + } + + private func makeExecutable(named name: String) throws -> URL { + let executable = temporaryDirectory.appendingPathComponent(name) + try Data("test".utf8).write(to: executable) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executable.path + ) + return executable + } +} diff --git a/docs/development.md b/docs/development.md index aaf0196..135654f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -68,6 +68,28 @@ ## 测试须知 +### 在 Terminal 中使用开发版 `klock` + +开发阶段可用脚本安装、检查或移除指向 Debug App 内已签名 `klock` 的 symbolic link: + +```bash +./scripts/install-klock-dev.sh install +./scripts/install-klock-dev.sh status +./scripts/install-klock-dev.sh uninstall +``` + +默认目标是 `~/.local/bin/klock`,也可通过 `--bin-dir PATH` 或 `KLOCK_BIN_DIR` 指定其他用户可写目录。脚本会先验证 bundled executable 的 code signature、精确 signing identifier,以及 App、Agent、CLI 三者的 Team identifier 一致性;遇到已有文件或其他 link 时会拒绝覆盖。它不会复制 binary、修改 shell profile 或调用 `sudo`。若目标目录不在 `PATH`,脚本只打印需要添加的 `export PATH=...`,由用户决定写入哪个 shell 配置。 + +已构建的 App 也在 status menu 中提供 **Command Line Tool…**。该入口遵循同一所有权边界:只创建或移除指向当前 App bundle 的 link;需要配置 `PATH` 时仅提供可复制命令,不会静默修改 dotfile。 + +`klock` 自身不注册后台 Agent。首次使用前必须至少启动一次 KeyboardLocker App;Agent 未注册时,CLI 会给出对应恢复提示。 + +### Homebrew Cask 发布计划(尚未实现) + +正式分发计划采用 Homebrew Cask,而不是把 `klock` 作为独立 Formula 重新编译。Cask 应安装同一个经过 Developer ID 签名和 notarization 的 `KeyboardLocker.app`,并用 `binary` artifact 暴露 App bundle 内的 `Contents/MacOS/klock`;这样 App、CLI 与 Agent 保持同一版本和签名来源。 + +开始实现前需要先具备 versioned release artifact、稳定下载地址、SHA-256、Developer ID 签名及 notarization 验证。首个交付目标为项目自己的 tap,发布流程稳定且满足上游接收要求后,再评估提交到官方 Homebrew Cask 仓库。当前仓库尚未新增 Cask definition 或 release automation。 + ### 重置本地 Agent 注册 需要复现首次启动或清理调试注册状态时,运行: diff --git a/klock/main.swift b/klock/main.swift index afd930c..599f597 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -14,18 +14,45 @@ await KlockCLI.run() enum KlockCLI { static func run() async { - guard let command = CommandLine.arguments.dropFirst().first else { + let arguments = Array(CommandLine.arguments.dropFirst()) + guard let command = arguments.first else { printUsage() - exit(ExitCode.error) + exit(ExitCode.success) } switch command { + case "help", "--help", "-h": + guard arguments.count == 1 else { + reportUnexpectedArguments(Array(arguments.dropFirst())) + } + printUsage() + exit(ExitCode.success) + + case "version", "--version", "-v": + guard arguments.count == 1 else { + reportUnexpectedArguments(Array(arguments.dropFirst())) + } + printVersion() + exit(ExitCode.success) + case "lock": + guard arguments.count == 1 else { + reportUnexpectedArguments(Array(arguments.dropFirst())) + } await executeLock() + case "unlock": + guard arguments.count == 1 else { + reportUnexpectedArguments(Array(arguments.dropFirst())) + } await executeUnlock() + case "status": + guard arguments.count == 1 else { + reportUnexpectedArguments(Array(arguments.dropFirst())) + } await executeStatus() + default: printError("Unknown command: \(command)") printUsage() @@ -88,20 +115,59 @@ enum KlockCLI { private static func reportFailure(_ error: Error) { printError(error.localizedDescription) if let suggestion = (error as? LocalizedError)?.recoverySuggestion { - print(" \(suggestion)") + printStandardError(" \(suggestion)") } } + private static func reportUnexpectedArguments(_ arguments: [String]) -> Never { + printError("Unexpected argument\(arguments.count == 1 ? "" : "s"): \(arguments.joined(separator: " "))") + printUsage() + exit(ExitCode.error) + } + + private static func printVersion() { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + guard let version, !version.isEmpty, let build, !build.isEmpty else { + printError("Version metadata is unavailable in this build.") + exit(ExitCode.error) + } + + print("klock \(version) (\(build))") + } + private static func printUsage() { - print("Usage: klock ") + print( + """ + OVERVIEW: Control KeyboardLocker from Terminal. + + USAGE: klock + + COMMANDS: + lock Lock the keyboard and wait until it is unlocked. + unlock Unlock the keyboard. + status Print the current lock state. + help Show this help message. + version Show the klock version. + + OPTIONS: + -h, --help Show this help message. + -v, --version Show the klock version. + """ + ) } private static func printError(_ message: String) { - FileHandle.standardError.write(Data("Error: \(message)\n".utf8)) + printStandardError("Error: \(message)") } private static func printWarning(_ message: String) { - FileHandle.standardError.write(Data("Warning: \(message)\n".utf8)) + printStandardError("Warning: \(message)") + } + + private static func printStandardError(_ message: String) { + FileHandle.standardError.write(Data("\(message)\n".utf8)) } private enum ExitCode { diff --git a/scripts/install-klock-dev.sh b/scripts/install-klock-dev.sh new file mode 100755 index 0000000..489b022 --- /dev/null +++ b/scripts/install-klock-dev.sh @@ -0,0 +1,212 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +readonly REPO_ROOT +readonly PROJECT_PATH="$REPO_ROOT/KeyboardLocker.xcodeproj" +readonly SCHEME="KeyboardLocker" +readonly CONFIGURATION="Debug" +readonly EXPECTED_IDENTIFIER="io.lzhlovesjyq.keyboardlocker.klock" + +usage() { + cat <<'EOF' +Usage: ./scripts/install-klock-dev.sh [install|status|uninstall] [--bin-dir PATH] + +Commands: + install Build the Debug App and link its bundled klock command (default). + status Report whether the expected command link is installed. + uninstall Remove only the command link owned by this Debug build. + +Options: + --bin-dir PATH Command directory. Defaults to $KLOCK_BIN_DIR or ~/.local/bin. + -h, --help Show this help message. + +This script never copies klock, edits shell profiles, or uses sudo. +EOF +} + +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +action="install" +bin_dir="${KLOCK_BIN_DIR:-$HOME/.local/bin}" + +if (($# > 0)) && [[ "$1" != -* ]]; then + action="$1" + shift +fi + +while (($# > 0)); do + case "$1" in + --bin-dir) + (($# >= 2)) || fail "--bin-dir requires a path." + bin_dir="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +case "$action" in + install|status|uninstall) + ;; + *) + fail "Unknown command: $action" + ;; +esac + +if [[ "$bin_dir" != /* ]]; then + bin_dir="$PWD/$bin_dir" +fi +readonly BIN_DIR="$bin_dir" +readonly DESTINATION="$BIN_DIR/klock" + +resolve_source() { + local build_settings + local target_build_dir + local full_product_name + + build_settings="$( + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -showBuildSettings 2>/dev/null + )" + target_build_dir="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*TARGET_BUILD_DIR = / { print $2; exit }' + )" + full_product_name="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*FULL_PRODUCT_NAME = / { print $2; exit }' + )" + + [[ -n "$target_build_dir" && -n "$full_product_name" ]] \ + || fail "Could not resolve the Debug App path from Xcode build settings." + + printf '%s/%s/Contents/MacOS/klock\n' "$target_build_dir" "$full_product_name" +} + +SOURCE="$(resolve_source)" +readonly SOURCE +readonly APP_PATH="${SOURCE%/Contents/MacOS/klock}" +readonly AGENT_PATH="$APP_PATH/Contents/Library/LoginItems/KeyboardLockerAgent.app" + +link_points_to_source() { + [[ -L "$DESTINATION" ]] || return 1 + [[ "$(readlink "$DESTINATION")" == "$SOURCE" ]] +} + +print_path_guidance() { + case ":${PATH:-}:" in + *":$BIN_DIR:"*) + printf "Run \`hash -r\` or open a new Terminal, then use \`klock --help\`.\n" + ;; + *) + printf 'The command directory is not currently in PATH. Add it in your shell configuration:\n' + printf " export PATH=\"%s:\$PATH\"\n" "$BIN_DIR" + printf "Then open a new Terminal and run \`klock --help\`.\n" + ;; + esac +} + +show_status() { + if link_points_to_source; then + printf 'Installed: %s -> %s\n' "$DESTINATION" "$SOURCE" + return 0 + fi + + if [[ -L "$DESTINATION" ]]; then + printf 'Conflict: %s points to %s\n' "$DESTINATION" "$(readlink "$DESTINATION")" >&2 + return 2 + fi + + if [[ -e "$DESTINATION" ]]; then + printf 'Conflict: a non-symlink item exists at %s\n' "$DESTINATION" >&2 + return 2 + fi + + printf 'Not installed: %s\n' "$DESTINATION" + return 1 +} + +case "$action" in + status) + show_status + ;; + + uninstall) + if link_points_to_source; then + rm "$DESTINATION" + printf 'Removed: %s\n' "$DESTINATION" + exit 0 + fi + + if [[ -L "$DESTINATION" || -e "$DESTINATION" ]]; then + fail "Refusing to remove an item not owned by this Debug build: $DESTINATION" + fi + + printf 'Already not installed: %s\n' "$DESTINATION" + ;; + + install) + if ! link_points_to_source && [[ -L "$DESTINATION" || -e "$DESTINATION" ]]; then + fail "Refusing to replace an existing item: $DESTINATION" + fi + + printf 'Building KeyboardLocker (%s)...\n' "$CONFIGURATION" + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -quiet \ + build + + [[ -x "$SOURCE" ]] || fail "The bundled klock executable is missing: $SOURCE" + [[ -d "$AGENT_PATH" ]] || fail "The bundled KeyboardLocker Agent is missing: $AGENT_PATH" + codesign --verify --strict "$SOURCE" \ + || fail "The bundled klock executable failed code-signature verification." + + signing_info="$(codesign -dvv "$SOURCE" 2>&1)" + identifier="$(printf '%s\n' "$signing_info" | awk -F= '/^Identifier=/ { print $2; exit }')" + team_identifier="$(printf '%s\n' "$signing_info" | awk -F= '/^TeamIdentifier=/ { print $2; exit }')" + app_team_identifier="$(codesign -dvv "$APP_PATH" 2>&1 | awk -F= '/^TeamIdentifier=/ { print $2; exit }')" + agent_team_identifier="$(codesign -dvv "$AGENT_PATH" 2>&1 | awk -F= '/^TeamIdentifier=/ { print $2; exit }')" + [[ "$identifier" == "$EXPECTED_IDENTIFIER" ]] \ + || fail "Unexpected klock signing identifier: ${identifier:-missing}" + [[ -n "$team_identifier" && "$team_identifier" != "not set" ]] \ + || fail "The bundled klock executable does not have a Team identifier." + [[ "$team_identifier" == "$app_team_identifier" \ + && "$team_identifier" == "$agent_team_identifier" ]] \ + || fail "The App, Agent, and klock executable do not share one Team identifier." + + if link_points_to_source; then + printf 'Already installed: %s -> %s\n' "$DESTINATION" "$SOURCE" + print_path_guidance + exit 0 + fi + + if [[ -L "$DESTINATION" || -e "$DESTINATION" ]]; then + fail "Refusing to replace an existing item: $DESTINATION" + fi + + mkdir -p "$BIN_DIR" + [[ -d "$BIN_DIR" && -w "$BIN_DIR" ]] \ + || fail "The command directory is not writable: $BIN_DIR" + ln -s "$SOURCE" "$DESTINATION" + printf 'Installed: %s -> %s\n' "$DESTINATION" "$SOURCE" + print_path_guidance + ;; +esac From 0a1022b5ea6383b098b8d8dfb4e53555a9cff9c9 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 14:27:09 +0800 Subject: [PATCH 038/106] feat(core): add interactive lock requests Return whether an atomic lock request acquired the global lock while preserving strict duplicate semantics. Allow only newly acquired interactive locks to accept the Agent-side Control-C gesture, and surface capability or unknown-outcome recovery through XPC. --- Core/Sources/Client/XPCClient.swift | 30 ++++++ Core/Sources/Common/ServiceDescriptor.swift | 4 +- Core/Sources/Common/Shared.swift | 18 ++++ Core/Sources/Service/LockEngine.swift | 82 +++++++++++----- .../ServiceCompatibilityTests.swift | 21 ++++ .../ClientTests/XPCClientErrorTests.swift | 17 ++++ .../ServiceTests/LockRuntimeStateTests.swift | 61 +++++++++++- .../UnlockGestureMatcherTests.swift | 97 +++++++++++++++++++ KeyboardLockerAgent/AgentService.swift | 39 ++++++-- 9 files changed, 332 insertions(+), 37 deletions(-) create mode 100644 Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 822fcb8..2747431 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -37,6 +37,12 @@ public enum XPCClientError: Error, LocalizedError { public var recoverySuggestion: String? { switch self { + case .missingCapability: + "Open KeyboardLocker to update its background agent, then retry." + + case .operationOutcomeUnknown: + "Run `klock status` to inspect the authoritative state, then use `klock unlock` if needed." + case .serviceUnavailable: "Open KeyboardLocker once to register its background agent, then retry. If KeyboardLocker is already running, choose Show Details… from its menu." @@ -98,6 +104,30 @@ public final class XPCClient: @unchecked Sendable { } } + /// Atomically creates an interactive global lock or reports that one was already active. + /// + /// A newly acquired lock treats Control-C as an additional Agent-side unlock gesture. This + /// outcome does not grant client ownership; it only describes whether this request performed + /// the unlocked-to-locked transition. + public func lockInteractively() async throws -> LockRequestOutcome { + let connection = try await negotiatedConnection( + requiring: [.interactiveLock] + ) + + do { + let didAcquireLock: Bool = try await withProxyReturning( + using: connection + ) { service, resume in + service.lockKeyboardInteractively { resume($0, $1) } + } + return didAcquireLock ? .acquired : .alreadyLocked + } catch XPCClientError.timedOut { + // Status alone cannot recover whether this request created a lock or observed an existing + // one, so a lost mutation reply must remain explicitly unknown. + throw XPCClientError.operationOutcomeUnknown + } + } + public func unlock() async throws { do { try await withProxy { service, resume in diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift index 9696dc3..b7d10c5 100644 --- a/Core/Sources/Common/ServiceDescriptor.swift +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -44,6 +44,7 @@ public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, public static let committedReplacementDrain = Self( rawValue: "committed-replacement-drain" ) + public static let interactiveLock = Self(rawValue: "interactive-lock") public static let lockControl = Self(rawValue: "lock-control") public static let prepareForReplacement = Self(rawValue: "prepare-for-replacement") } @@ -74,7 +75,7 @@ public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sen /// Current bootstrap contract advertised by the bundled Agent. public enum ServiceContract { - public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 2) + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 3) public static let requiredCapabilities: Set = [ .accessibilityPrompt, @@ -82,6 +83,7 @@ public enum ServiceContract { .committedReplacementDrain, .currentSettings, .currentSettingsWithError, + .interactiveLock, .lockControl, .prepareForReplacement, ] diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 38a0504..257bc64 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -19,6 +19,17 @@ public enum SharedConstants { ] } +// MARK: - Lock Requests + +/// Whether one atomic lock request created the global lock or found it already active. +/// +/// This describes a state transition, not client ownership. The Agent still owns one global lock, +/// and any authorized wrapper may explicitly unlock it. +public enum LockRequestOutcome: Equatable, Sendable { + case acquired + case alreadyLocked +} + // MARK: - Notification Names /// Shared notification identifiers for cross-process communication. @@ -42,6 +53,13 @@ public protocol KeyboardLockerServiceProtocol { // MARK: Keyboard Locking Methods func lockKeyboard(reply: @escaping (Error?) -> Void) + + /// Requests a lock that treats Control-C as an additional unlock gesture only when this call + /// atomically creates the global lock. `didAcquireLock` is false for a strict duplicate no-op. + func lockKeyboardInteractively( + reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void + ) + func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index dd24f68..2e8be02 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon import Common @preconcurrency import CoreGraphics import Foundation @@ -37,23 +38,26 @@ struct AutoUnlockSchedule: Equatable { struct LockRuntimeState: Equatable { private(set) var activeSettings: KeyboardLockerSettings = .default + private(set) var allowsControlCUnlock = false private(set) var autoUnlockTargetDate: Date? private(set) var isLocked = false private(set) var startedAt: Date? mutating func begin( settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool, at date: Date - ) -> Bool { + ) -> LockRequestOutcome { guard !isLocked else { - return false + return .alreadyLocked } activeSettings = settings + self.allowsControlCUnlock = allowsControlCUnlock autoUnlockTargetDate = nil isLocked = true startedAt = date - return true + return .acquired } mutating func updateSettings(_ settings: KeyboardLockerSettings) { @@ -65,12 +69,42 @@ struct LockRuntimeState: Equatable { } mutating func end() { + allowsControlCUnlock = false autoUnlockTargetDate = nil isLocked = false startedAt = nil } } +struct UnlockGestureMatcher { + static let controlCKeyCode = CGKeyCode(kVK_ANSI_C) + + private static let controlCHotkey = KeyboardLockerSettings.Hotkey( + keyCode: controlCKeyCode, + modifierFlags: [.maskControl] + ) + + static func matches( + type: CGEventType, + keyCode: CGKeyCode, + flags: CGEventFlags, + isAutoRepeat: Bool, + configuredHotkey: KeyboardLockerSettings.Hotkey, + allowsControlCUnlock: Bool + ) -> Bool { + guard type == .keyDown, !isAutoRepeat else { + return false + } + + if configuredHotkey.matches(keyCode: keyCode, flags: flags) { + return true + } + + return allowsControlCUnlock + && controlCHotkey.matches(keyCode: keyCode, flags: flags) + } +} + enum EventTapInstallationError: Error, Equatable { case eventTapCreationFailed case eventTapEnableFailed @@ -214,11 +248,15 @@ public final class LockEngine { private init() {} - public func lock(settings: KeyboardLockerSettings = .default) throws { + @discardableResult + public func lock( + settings: KeyboardLockerSettings = .default, + allowsControlCUnlock: Bool = false + ) throws -> LockRequestOutcome { // A duplicate request is a strict no-op. In particular, it must not extend the authoritative // auto-unlock deadline merely because another wrapper repeated the same global intent. if runtimeState.isLocked { - return + return .alreadyLocked } // Verify Accessibility permission before attempting to create event tap. @@ -228,11 +266,17 @@ public final class LockEngine { // Event tap creation must happen on main thread try startEventTap() - guard runtimeState.begin(settings: settings, at: Date()) else { + let outcome = runtimeState.begin( + settings: settings, + allowsControlCUnlock: allowsControlCUnlock, + at: Date() + ) + guard outcome == .acquired else { teardownEventTap() - return + return outcome } markLocked() + return outcome } /// Updates the active settings. If a lock is running, changes take effect immediately @@ -417,21 +461,15 @@ public final class LockEngine { } private func shouldTriggerUnlock(for type: CGEventType, event: CGEvent) -> Bool { - let hotkey = runtimeState.activeSettings.unlockHotkey - - switch type { - case .keyDown: - let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) - guard hotkey.matches(keyCode: keyCode, flags: event.flags) else { - return false - } - - let isAutoRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) == Self.autoRepeatFlagValue - return !isAutoRepeat - - default: - return false - } + UnlockGestureMatcher.matches( + type: type, + keyCode: CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)), + flags: event.flags, + isAutoRepeat: event.getIntegerValueField(.keyboardEventAutorepeat) + == Self.autoRepeatFlagValue, + configuredHotkey: runtimeState.activeSettings.unlockHotkey, + allowsControlCUnlock: runtimeState.allowsControlCUnlock + ) } func lockDuration(at date: Date = Date()) -> TimeInterval? { diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift index 9980aa8..4cab716 100644 --- a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -97,6 +97,13 @@ final class ServiceCompatibilityTests: XCTestCase { XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 2) } + func testCurrentContractRequiresInteractiveLockSelector() { + XCTAssertTrue( + ServiceContract.requiredCapabilities.contains(.interactiveLock) + ) + XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 3) + } + func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( @@ -125,6 +132,20 @@ final class ServiceCompatibilityTests: XCTestCase { ) } + func testAgentWithoutInteractiveLockIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .interactiveLock, + ]) + ) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.missingCapabilities([.interactiveLock])] + ) + } + func testBuildMismatchRequiresReplacement() { let requirements = makeRequirements(agentBuild: "42") let descriptor = makeDescriptor(agentBuild: "41") diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index 0c71a0d..fa5272c 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -21,4 +21,21 @@ final class XPCClientErrorTests: XCTestCase { XCTAssertTrue(suggestion.contains("Open KeyboardLocker once")) XCTAssertTrue(suggestion.contains("Show Details")) } + + func testMissingCapabilityExplainsAgentUpdateRecovery() throws { + let suggestion = try XCTUnwrap( + XPCClientError.missingCapability(.interactiveLock).recoverySuggestion + ) + + XCTAssertTrue(suggestion.contains("update its background agent")) + } + + func testUnknownMutationOutcomeExplainsStateRecovery() throws { + let suggestion = try XCTUnwrap( + XPCClientError.operationOutcomeUnknown.recoverySuggestion + ) + + XCTAssertTrue(suggestion.contains("klock status")) + XCTAssertTrue(suggestion.contains("klock unlock")) + } } diff --git a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift index ef71179..d3f07fa 100644 --- a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift +++ b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift @@ -4,6 +4,31 @@ import Foundation import XCTest final class LockRuntimeStateTests: XCTestCase { + func testInteractiveDuplicateDoesNotEnableControlCForExistingLock() { + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + + XCTAssertEqual( + state.begin( + settings: .default, + allowsControlCUnlock: false, + at: startedAt + ), + .acquired + ) + XCTAssertEqual( + state.begin( + settings: .default, + allowsControlCUnlock: true, + at: startedAt.addingTimeInterval(1) + ), + .alreadyLocked + ) + + XCTAssertFalse(state.allowsControlCUnlock) + XCTAssertEqual(state.startedAt, startedAt) + } + func testDuplicateBeginPreservesOriginalRuntimeStateAndDeadline() { let originalSettings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), @@ -17,16 +42,26 @@ final class LockRuntimeStateTests: XCTestCase { let deadline = startedAt.addingTimeInterval(60) var state = LockRuntimeState() - XCTAssertTrue(state.begin(settings: originalSettings, at: startedAt)) + XCTAssertEqual( + state.begin( + settings: originalSettings, + allowsControlCUnlock: true, + at: startedAt + ), + .acquired + ) state.setAutoUnlockTargetDate(deadline) - XCTAssertFalse( + XCTAssertEqual( state.begin( settings: replacementSettings, + allowsControlCUnlock: false, at: startedAt.addingTimeInterval(10) - ) + ), + .alreadyLocked ) XCTAssertEqual(state.activeSettings, originalSettings) + XCTAssertTrue(state.allowsControlCUnlock) XCTAssertEqual(state.startedAt, startedAt) XCTAssertEqual(state.autoUnlockTargetDate, deadline) XCTAssertTrue(state.isLocked) @@ -37,14 +72,30 @@ final class LockRuntimeStateTests: XCTestCase { let secondStart = firstStart.addingTimeInterval(90) var state = LockRuntimeState() - XCTAssertTrue(state.begin(settings: .default, at: firstStart)) + XCTAssertEqual( + state.begin( + settings: .default, + allowsControlCUnlock: true, + at: firstStart + ), + .acquired + ) state.setAutoUnlockTargetDate(firstStart.addingTimeInterval(60)) state.end() + XCTAssertFalse(state.allowsControlCUnlock) XCTAssertFalse(state.isLocked) XCTAssertNil(state.startedAt) XCTAssertNil(state.autoUnlockTargetDate) - XCTAssertTrue(state.begin(settings: .default, at: secondStart)) + XCTAssertEqual( + state.begin( + settings: .default, + allowsControlCUnlock: false, + at: secondStart + ), + .acquired + ) + XCTAssertFalse(state.allowsControlCUnlock) XCTAssertEqual(state.startedAt, secondStart) } } diff --git a/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift b/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift new file mode 100644 index 0000000..27e5fca --- /dev/null +++ b/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift @@ -0,0 +1,97 @@ +import Common +import CoreGraphics +@testable import Service +import XCTest + +final class UnlockGestureMatcherTests: XCTestCase { + private let configuredHotkey = KeyboardLockerSettings.Hotkey( + keyCode: 37, + modifierFlags: [.maskControl, .maskCommand] + ) + + func testConfiguredHotkeyAlwaysUnlocks() { + XCTAssertTrue( + matches( + keyCode: configuredHotkey.keyCode, + flags: configuredHotkey.modifierFlags, + allowsControlCUnlock: false + ) + ) + } + + func testControlCUnlocksInteractiveLock() { + XCTAssertTrue( + matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: true + ) + ) + } + + func testControlCDoesNotUnlockNonInteractiveLock() { + XCTAssertFalse( + matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: false + ) + ) + } + + func testControlCRejectsAdditionalRelevantModifiers() { + XCTAssertFalse( + matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl, .maskCommand], + allowsControlCUnlock: true + ) + ) + } + + func testControlCIgnoresCapsLock() { + XCTAssertTrue( + matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl, .maskAlphaShift], + allowsControlCUnlock: true + ) + ) + } + + func testAutoRepeatAndKeyUpNeverUnlock() { + XCTAssertFalse( + matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: true, + isAutoRepeat: true + ) + ) + XCTAssertFalse( + matches( + type: .keyUp, + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: true + ) + ) + } + + private func matches( + type: CGEventType = .keyDown, + keyCode: CGKeyCode, + flags: CGEventFlags, + allowsControlCUnlock: Bool, + isAutoRepeat: Bool = false + ) -> Bool { + UnlockGestureMatcher.matches( + type: type, + keyCode: keyCode, + flags: flags, + isAutoRepeat: isAutoRepeat, + configuredHotkey: configuredHotkey, + allowsControlCUnlock: allowsControlCUnlock + ) + } +} diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index a5aece4..54a5b4b 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -56,16 +56,8 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { func lockKeyboard(reply: @escaping (Error?) -> Void) { executeOnMainActor { - guard !self.replacement.isPending else { - reply(Self.replacementError( - code: 1, - description: "The agent is preparing to be replaced and is not accepting new lock requests." - )) - return - } - do { - try LockEngine.shared.lock(settings: self.settings) + _ = try self.performLock(allowsControlCUnlock: false) reply(nil) } catch { reply(error) @@ -73,6 +65,19 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + func lockKeyboardInteractively( + reply: @escaping (Bool, Error?) -> Void + ) { + executeOnMainActor { + do { + let outcome = try self.performLock(allowsControlCUnlock: true) + reply(outcome == .acquired, nil) + } catch { + reply(false, error) + } + } + } + func unlockKeyboard(reply: @escaping (Error?) -> Void) { executeOnMainActor { LockEngine.shared.unlock() @@ -251,6 +256,22 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Helpers + @MainActor private func performLock( + allowsControlCUnlock: Bool + ) throws -> LockRequestOutcome { + guard !replacement.isPending else { + throw Self.replacementError( + code: 1, + description: "The agent is preparing to be replaced and is not accepting new lock requests." + ) + } + + return try LockEngine.shared.lock( + settings: settings, + allowsControlCUnlock: allowsControlCUnlock + ) + } + private static func makeServiceDescriptor(bundle: Bundle = .main) throws -> ServiceDescriptor { guard let bundleIdentifier = bundle.bundleIdentifier, !bundleIdentifier.isEmpty else { throw AgentDescriptorError.missingMetadata("CFBundleIdentifier") From 7e596cdd882f3ed9eda0086aad5919b346c68c2d Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 14:27:17 +0800 Subject: [PATCH 039/106] feat(klock): add Control-C unlock flow Use the atomic interactive-lock outcome so klock waits only for locks it creates and leaves existing App or CLI locks unchanged. Document the cross-wrapper behavior, timeout boundary, and absence of client-owned lock sessions. --- docs/architecture.md | 3 ++- docs/development.md | 12 +++++++----- docs/xpc.md | 16 ++++++++++------ klock/main.swift | 30 +++++++++++++++++++++++------- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7983c3a..85f33b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 任何 wrapper 都可以锁;任何 wrapper 都可以解锁。CLI 发起的锁,App 可以解开,反之亦然。 - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `lock` 是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改当前设置、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。 +- interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。 - 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 ## 状态同步 @@ -67,7 +68,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management `LockStateSubscriber` 把通知当作*提示*而非真相:订阅后的初始校准和收到的任一信号(Darwin 或 Distributed)都会向 Agent 拉取权威状态。多个并发信号被串行合并,相同状态被去重;subscription 取消后,尚未进入 handler 的在途结果会被丢弃。已经开始执行的 handler 不可被回溯撤销。Agent 之所以在两个通道都广播,只是为了让被挂起的 App 能被唤醒(Darwin)、让正在运行的 App 能及时更新(Distributed)。通知的载荷永远不是真相源。 -`klock lock` 是一个会等待并报告后续解锁的长命命令,不是一次性 `status`。它同时使用 `LockStateSubscriber` 获得及时更新,并周期性查询 `status()` 以恢复双通道通知都丢失或 Agent 重启的场景;连续无法取得权威状态时必须报错退出,不能把 transport failure 猜成 unlocked。 +`klock lock` 先发出 atomic interactive lock request。只有 outcome 为 acquired 时,它才等待并报告后续解锁;本轮 event tap 会把 `Ctrl+C` 当作额外解锁手势并在 Agent 内消费该事件。若 outcome 为 already locked,CLI 必须说明本命令没有创建新锁并立即退出,不能把 App 或另一个 CLI 已建立的锁变成自己的可取消 session。acquired 后的等待同时使用 `LockStateSubscriber` 获得及时更新,并周期性查询 `status()` 以恢复双通道通知都丢失或 Agent 重启的场景;连续无法取得权威状态时必须报错退出,不能把 transport failure 猜成 unlocked。 ## 新增 wrapper 的规则(Widget、Shortcut、AppleScript……) diff --git a/docs/development.md b/docs/development.md index 135654f..08f3b91 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,7 +11,7 @@ 1. App 建立 connection 后先调用 `XPCClient.serviceDescriptor()`,验证 protocol version、required capabilities 和 bundled Agent build;只有兼容后才调用新增 selector。descriptor 在 fresh connection 上重试后仍失败、但旧 `status` 成功时,只能按 unverified base contract 处理,不能断言它一定是 legacy Agent。capability-gated Client API 会在同一条具体 connection generation 上重新握手并发送 feature selector,connection 变化后不能复用旧 grant。 2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `currentSettings`。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 3. Client 与 Listener 都在 activate 前安装同 Team + 精确 signing identifier 的 XPC requirement;系统完成双向认证后,Agent 的 `ServiceDelegate` 才接受连接并路由到 `AgentService`。 -4. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:)` / `unlock()`。 +4. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:allowsControlCUnlock:)` / `unlock()`。 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 6. wrapper 通过 `LockStateSubscriber.subscribe(initialState:_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态。subscriber 会在 observer 安装完成后立即拉取一次权威初始状态,后续信号串行合并并再次查询 —— 绝不从"我这次调用是否成功"或通知 payload 推断。 @@ -22,20 +22,20 @@ 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、锁操作、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive 锁操作、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector,旧 `lockKeyboard` ABI 保持不变。 - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成并预编译同 Team + 精确 identifier 的双向 XPC requirement;unsigned/ad-hoc 进程 fail closed。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` -- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。`lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;optional method 在同一 connection 上完成 descriptor/capability gate;replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。legacy `lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;`lockInteractively()` 在同一 connection 上完成 descriptor/capability gate 并原子返回 acquired/already locked,其 reply 丢失时不会用非原子的 `status()` 猜测 outcome。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 - `ServiceCompatibility.swift`:纯值兼容性规则与任意精度 dotted-numeric `ServiceBuildVersion` —— major 必须相等、running minor 不得低于最低版本、required capabilities 必须齐全,且运行中 Agent 的 identifier/version/build 必须与 bundled Agent 一致。 - `LockStateSubscriber.swift`:先安装 Darwin + Distributed observer,再立即拉取一次权威状态;后续把每个信号当作提示,通过 `XPCClient.status()` 串行校准(带重试、signal coalescing、去重和 cancellation fence)→ `ObserverToken`,另有会先产出当前权威状态的 `stateChanges`(`AsyncStream`)。取消会丢弃尚未进入 handler 的结果,但不会回溯撤销已经开始执行的 handler。长命 UI 使用 snapshot seed 避免重复呈现相同状态;一次性面直接读 `status()`(见 architecture 的"状态同步")。 - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、严格幂等的 `lock(settings:)`、显式 `updateSettings(_:)`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。重复 `lock` 不会修改活动设置或延长 deadline;资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 +- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、严格幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、显式 `updateSettings(_:)`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。重复 `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline;资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 @@ -53,7 +53,7 @@ ### 新增一个设置项 1. 给 `KeyboardLockerSettings` 加一个 `Codable`/`Sendable` 属性,并更新 `.default`。 -2. 如果引擎会消费它,在 `LockEngine.lock(settings:)` / `updateSettings(_:)` 中读取。 +2. 如果引擎会消费它,在 `LockEngine.lock(settings:allowsControlCUnlock:)` / `updateSettings(_:)` 中读取。 3. 目前 wrapper 只能通过 `XPCClient.currentSettings()` **读取**设置(由 Agent 经 `KeyboardLockerSettingsStore` 加载)。写入路径(`applySettings` + UI)尚未接线;要让用户改设置,先在 Agent + `KeyboardLockerServiceProtocol` 上补写入方法(见"新增一个 XPC 方法"),再由 Agent 持久化 —— wrapper **不得**拥有 store(见架构契约)。 ### 新增一个 XPC 方法 @@ -84,6 +84,8 @@ `klock` 自身不注册后台 Agent。首次使用前必须至少启动一次 KeyboardLocker App;Agent 未注册时,CLI 会给出对应恢复提示。 +`klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。 + ### Homebrew Cask 发布计划(尚未实现) 正式分发计划采用 Homebrew Cask,而不是把 `klock` 作为独立 Formula 重新编译。Cask 应安装同一个经过 Developer ID 签名和 notarization 的 `KeyboardLocker.app`,并用 `binary` artifact 暴露 App bundle 内的 `Contents/MacOS/klock`;这样 App、CLI 与 Agent 保持同一版本和签名来源。 diff --git a/docs/xpc.md b/docs/xpc.md index 72c65f4..69ccc8a 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -95,6 +95,9 @@ XPC 两侧必须对“线上消息长什么样”达成一致。当前契约定 public protocol KeyboardLockerServiceProtocol { func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) func lockKeyboard(reply: @escaping (Error?) -> Void) + func lockKeyboardInteractively( + reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void + ) func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) func prepareForReplacement( @@ -479,9 +482,9 @@ sequenceDiagram ClientConnection->>Launchd: send first message to Mach service Launchd->>Listener: start Agent / deliver connection request Listener->>Peer: validate, configure, and accept - ClientConnection->>Peer: lockKeyboard(reply:) + ClientConnection->>Peer: lockKeyboard(...) / lockKeyboardInteractively(...) Peer->>Adapter: dispatch to exported object - Adapter->>Engine: lock(settings:) on MainActor + Adapter->>Engine: lock(settings:allowsControlCUnlock:) on MainActor Engine-->>Adapter: success or domain error Adapter-->>Peer: reply(error) Peer-->>ClientConnection: reply message @@ -491,15 +494,15 @@ sequenceDiagram 对应源码中的实际步骤是: -1. App 的 `AppCoordinator` 已在 readiness reconciliation 中完成 descriptor handshake,随后 App 或 CLI 调用 `XPCClient.shared.lock()`;CLI 使用的 lock/status/unlock 属于 legacy base selector。 +1. App 的 `AppCoordinator` 已在 readiness reconciliation 中完成 descriptor handshake,随后 App 调用 legacy `XPCClient.shared.lock()`;CLI 的 `klock lock` 使用 capability-gated `lockInteractively()`,原子取得 acquired/already-locked outcome。 2. `XPCClient.currentConnection()` 创建或复用 `NSXPCConnection(machServiceName:)`。 3. Client 把 `KeyboardLockerServiceProtocol` 设置成 `remoteObjectInterface`,再取得 remote proxy。 4. Client 在 activate 前安装只接受同 Team、精确 Agent signing identifier 的 requirement;第一条实际 message 让 `launchd` 按需启动 Agent。 5. Agent Listener 的 requirement 在 delegate 暴露任何方法前校验调用方身份;Client requirement 同时拒绝伪造的 Agent。 6. `XPCServerConnection` 为 Agent-side connection 设置 protocol,并把同一个 `AgentService` 实例设置为 `exportedObject`。 -7. 对 remote proxy 的 `lockKeyboard(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 +7. 对 remote proxy 的 legacy `lockKeyboard(reply:)` 或 additive `lockKeyboardInteractively(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 8. `AgentService` 把领域操作切到 `MainActor`,Agent 可变状态、event tap 和 CFRunLoop 生命周期都在这里维护。 -9. `LockEngine.shared.lock(settings:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap。 +9. `LockEngine.shared.lock(settings:allowsControlCUnlock:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap;strict duplicate 在创建资源前直接返回 already locked。 10. reply 通过 XPC 返回,`XPCClient` 把 callback bridge 成 Swift async/throwing API。 `AgentService` 是 executable target 中的 adapter:它把 wire protocol 翻译成 `Service` 模块里的领域调用。`Service` product 本身不会启动进程;真正创建 listener、保持 RunLoop 的是 `KeyboardLockerAgent/main.swift`。 @@ -510,6 +513,7 @@ sequenceDiagram |---|---|---|---| | `serviceDescriptor` | `serviceDescriptor()` | `AgentService` | bootstrap query;返回 protocol、capability、bundled metadata 和 process instance ID | | `lockKeyboard` | `lock()` | `LockEngine` | 严格幂等地进入全局 locked 状态;已锁时直接成功且不修改设置、锁定起点或 auto-unlock deadline | +| `lockKeyboardInteractively` | `lockInteractively()` | `LockEngine` | 原子返回本次请求是否创建全局锁;仅 acquired 的新锁临时接受 `Ctrl+C` 作为额外解锁手势 | | `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | | `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | | `prepareForReplacement` | `prepareForReplacement(unlockIfNeeded:expectedAgentInstanceID:)` | `AgentService` | Agent 原子校验 expected instance,安装短期 prepared drain,可在同一 execution turn 解锁,返回同 generation ticket | @@ -530,7 +534,7 @@ Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使 - App 和 CLI 各有自己的 connection。 - 某条 connection interruption 后,Client 会将其 invalidate 并清除缓存;invalidation 同样清除缓存。下一次调用创建新 connection 并重新握手。 - App 退出、CLI 退出或 connection 断开,不会触发 unlock。 -- `klock lock` 当前会继续运行以打印后续的 `Unlocked.`,但它只是观察者;强制结束 CLI 不会解除锁。等待期间 notification subscriber 提供及时更新,周期性 `status()` 提供丢通知与 Agent 重启后的恢复;连续无法取得权威状态时 CLI 报错退出。 +- `klock lock` 只有在 interactive request 返回 acquired 时才继续等待并打印后续的 `Unlocked.`;该轮锁由 Agent 额外识别并消费 `Ctrl+C` 解锁手势。already locked 表示严格 duplicate no-op,CLI 立即退出,不会解除或修改 App/其他 CLI 建立的锁。直接杀死 CLI 进程仍不会自动解除锁;等待期间 notification subscriber 提供及时更新,周期性 `status()` 提供丢通知与 Agent 重启后的恢复;连续无法取得权威状态时 CLI 报错退出。 - Agent 退出则不同:event tap 属于 Agent 进程,进程退出会由系统释放它,内存中的 locked 状态也会消失。 因此项目刻意没有 `LockSession`、client ownership 或“连接释放时自动 unlock”之类的抽象。领域事实是一个物理键盘对应一个由 Agent 持有的全局状态。 diff --git a/klock/main.swift b/klock/main.swift index 599f597..1dbce3b 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -63,18 +63,34 @@ enum KlockCLI { // MARK: - Commands private static func executeLock() async { + let unlockHotkey: Result do { - try await XPCClient.shared.lock() + unlockHotkey = .success( + try await XPCClient.shared.currentSettings().unlockHotkey.displayString + ) } catch { - reportFailure(error) - exit(ExitCode.error) + unlockHotkey = .failure(error) } + let outcome: LockRequestOutcome do { - let hotkey = try await XPCClient.shared.currentSettings().unlockHotkey.displayString - print("Locked. Press \(hotkey) to unlock.") + outcome = try await XPCClient.shared.lockInteractively() } catch { - print("Locked. Run `klock unlock` to unlock.") + reportFailure(error) + exit(ExitCode.error) + } + + guard outcome == .acquired else { + print("Already locked. This command did not create a new lock.") + exit(ExitCode.success) + } + + switch unlockHotkey { + case let .success(hotkey): + print("Locked. Press \(hotkey) or Ctrl+C to unlock.") + + case let .failure(error): + print("Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.") printWarning("Could not read the configured unlock shortcut: \(error.localizedDescription)") } @@ -145,7 +161,7 @@ enum KlockCLI { USAGE: klock COMMANDS: - lock Lock the keyboard and wait until it is unlocked. + lock Lock the keyboard; Ctrl+C unlocks an acquired lock. unlock Unlock the keyboard. status Print the current lock state. help Show this help message. From abc4f35a46361cdbbc39f06e5c0bed9b371dbc25 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 15:14:12 +0800 Subject: [PATCH 040/106] feat(core): add authoritative lock snapshot Expose a versioned, capability-gated runtime snapshot so richer wrappers can read one coherent Agent-owned state without duplicating timing or settings logic. Keep the lightweight status selector for compatibility and recovery. Bump the additive XPC contract to protocol 1.4, and cover payload validation, runtime coherence, and compatibility. --- Core/Sources/Client/XPCClient.swift | 11 ++ Core/Sources/Common/LockStatusSnapshot.swift | 118 ++++++++++++++++++ Core/Sources/Common/ServiceDescriptor.swift | 4 +- Core/Sources/Common/Shared.swift | 4 + Core/Sources/Service/LockEngine.swift | 15 +++ .../ServiceCompatibilityTests.swift | 21 ++++ .../LockStatusSnapshotCodingTests.swift | 106 ++++++++++++++++ .../ServiceTests/LockRuntimeStateTests.swift | 55 ++++++++ KeyboardLockerAgent/AgentService.swift | 11 ++ docs/architecture.md | 9 +- docs/development.md | 9 +- docs/xpc.md | 4 +- 12 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 Core/Sources/Common/LockStatusSnapshot.swift create mode 100644 Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 2747431..4a52cd0 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -145,6 +145,17 @@ public final class XPCClient: @unchecked Sendable { } } + /// One authoritative point-in-time snapshot of the global lock and its active settings. + public func lockStatusSnapshot() async throws -> LockStatusSnapshot { + let connection = try await negotiatedConnection( + requiring: [.lockStatusSnapshot] + ) + let data: Data? = try await withProxyReturning(using: connection) { service, resume in + service.lockStatusSnapshot { resume($0, $1) } + } + return try LockStatusSnapshot.decodedFromXPC(data) + } + /// Enters the Agent's fail-safe replacement drain and returns its ownership ticket. public func prepareForReplacement( unlockIfNeeded: Bool, diff --git a/Core/Sources/Common/LockStatusSnapshot.swift b/Core/Sources/Common/LockStatusSnapshot.swift new file mode 100644 index 0000000..c4f64b7 --- /dev/null +++ b/Core/Sources/Common/LockStatusSnapshot.swift @@ -0,0 +1,118 @@ +import Foundation + +/// One authoritative, point-in-time view of the global keyboard lock. +/// +/// Wrappers may cache this value for presentation, but the Agent remains the source of truth. +/// Format 1 may only gain backward-compatible fields; incompatible semantics require a new XPC +/// capability instead of reinterpreting this payload. +public struct LockStatusSnapshot: Codable, Equatable, Sendable { + public static let currentFormatVersion = 1 + + public let formatVersion: Int + public let capturedAt: Date + public let isLocked: Bool + public let startedAt: Date? + public let autoUnlockTargetDate: Date? + public let settings: KeyboardLockerSettings + + public init( + formatVersion: Int = Self.currentFormatVersion, + capturedAt: Date, + isLocked: Bool, + startedAt: Date?, + autoUnlockTargetDate: Date?, + settings: KeyboardLockerSettings + ) { + self.formatVersion = formatVersion + self.capturedAt = capturedAt + self.isLocked = isLocked + self.startedAt = startedAt + self.autoUnlockTargetDate = autoUnlockTargetDate + self.settings = settings + } + + /// Duration derived from the authoritative start time rather than a stale transported counter. + public func lockDuration(at date: Date = Date()) -> TimeInterval? { + guard isLocked, let startedAt else { + return nil + } + return max(0, date.timeIntervalSince(startedAt)) + } + + /// Remaining time derived from the authoritative deadline rather than a stale transported value. + public func remainingAutoUnlockTime(at date: Date = Date()) -> TimeInterval? { + guard isLocked, let autoUnlockTargetDate else { + return nil + } + return max(0, autoUnlockTargetDate.timeIntervalSince(date)) + } + + fileprivate var hasConsistentLockState: Bool { + if isLocked { + return startedAt != nil + } + return startedAt == nil && autoUnlockTargetDate == nil + } +} + +public enum LockStatusSnapshotCodingError: Error, Equatable, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + case unsupportedFormat(Int) + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned an invalid lock status snapshot." + case .missingPayload: + "The KeyboardLocker agent returned no lock status snapshot." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized lock status snapshot." + case let .unsupportedFormat(version): + "The KeyboardLocker agent returned unsupported lock status format \(version)." + } + } +} + +// MARK: - XPC Serialization + +public extension LockStatusSnapshot { + static let maximumEncodedSize = 32 * 1024 + + func encodedForXPC() throws -> Data { + guard formatVersion == Self.currentFormatVersion, hasConsistentLockState else { + throw LockStatusSnapshotCodingError.invalidPayload + } + + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw LockStatusSnapshotCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> LockStatusSnapshot { + guard let data else { + throw LockStatusSnapshotCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw LockStatusSnapshotCodingError.payloadTooLarge + } + + let snapshot: LockStatusSnapshot + do { + snapshot = try JSONDecoder().decode(LockStatusSnapshot.self, from: data) + } catch { + throw LockStatusSnapshotCodingError.invalidPayload + } + + guard snapshot.formatVersion == currentFormatVersion else { + throw LockStatusSnapshotCodingError.unsupportedFormat(snapshot.formatVersion) + } + guard snapshot.hasConsistentLockState else { + throw LockStatusSnapshotCodingError.invalidPayload + } + return snapshot + } +} diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift index b7d10c5..522a3db 100644 --- a/Core/Sources/Common/ServiceDescriptor.swift +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -46,6 +46,7 @@ public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, ) public static let interactiveLock = Self(rawValue: "interactive-lock") public static let lockControl = Self(rawValue: "lock-control") + public static let lockStatusSnapshot = Self(rawValue: "lock-status-snapshot") public static let prepareForReplacement = Self(rawValue: "prepare-for-replacement") } @@ -75,7 +76,7 @@ public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sen /// Current bootstrap contract advertised by the bundled Agent. public enum ServiceContract { - public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 3) + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 4) public static let requiredCapabilities: Set = [ .accessibilityPrompt, @@ -85,6 +86,7 @@ public enum ServiceContract { .currentSettingsWithError, .interactiveLock, .lockControl, + .lockStatusSnapshot, .prepareForReplacement, ] } diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 257bc64..7dc854c 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -63,6 +63,10 @@ public protocol KeyboardLockerServiceProtocol { func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) + /// Returns one authoritative point-in-time lock snapshot as JSON-encoded + /// `LockStatusSnapshot`. This is additive; legacy clients continue using `status`. + func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) + /// Atomically enters a short-lived fail-safe drain and optionally unlocks before returning its /// exclusive ownership ticket. func prepareForReplacement( diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 2e8be02..8a94b48 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -74,6 +74,16 @@ struct LockRuntimeState: Equatable { isLocked = false startedAt = nil } + + func statusSnapshot(capturedAt: Date) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: isLocked, + startedAt: startedAt, + autoUnlockTargetDate: autoUnlockTargetDate, + settings: activeSettings + ) + } } struct UnlockGestureMatcher { @@ -238,6 +248,11 @@ public final class LockEngine { runtimeState.isLocked } + /// One coherent Agent-owned snapshot for read-only wrappers and presentation surfaces. + public var statusSnapshot: LockStatusSnapshot { + runtimeState.statusSnapshot(capturedAt: Date()) + } + var lockStartedAt: Date? { runtimeState.startedAt } diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift index 4cab716..e7e2621 100644 --- a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -104,6 +104,13 @@ final class ServiceCompatibilityTests: XCTestCase { XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 3) } + func testCurrentContractRequiresLockStatusSnapshotSelector() { + XCTAssertTrue( + ServiceContract.requiredCapabilities.contains(.lockStatusSnapshot) + ) + XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 4) + } + func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( @@ -146,6 +153,20 @@ final class ServiceCompatibilityTests: XCTestCase { ) } + func testAgentWithoutLockStatusSnapshotIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .lockStatusSnapshot, + ]) + ) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.missingCapabilities([.lockStatusSnapshot])] + ) + } + func testBuildMismatchRequiresReplacement() { let requirements = makeRequirements(agentBuild: "42") let descriptor = makeDescriptor(agentBuild: "41") diff --git a/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift new file mode 100644 index 0000000..7fa5aea --- /dev/null +++ b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift @@ -0,0 +1,106 @@ +import Common +import Foundation +import XCTest + +final class LockStatusSnapshotCodingTests: XCTestCase { + private let capturedAt = Date(timeIntervalSinceReferenceDate: 1000) + + func testRoundTripPreservesAuthoritativeRuntimeDatesAndSettings() throws { + let startedAt = capturedAt.addingTimeInterval(-10) + let deadline = capturedAt.addingTimeInterval(50) + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init( + keyCode: 12, + modifierFlags: [.maskAlternate, .maskShift] + ) + ) + let snapshot = LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: true, + startedAt: startedAt, + autoUnlockTargetDate: deadline, + settings: settings + ) + + let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) + + XCTAssertEqual(decoded, snapshot) + XCTAssertEqual(decoded.lockDuration(at: capturedAt), 10) + XCTAssertEqual(decoded.remainingAutoUnlockTime(at: capturedAt), 50) + } + + func testUnlockedSnapshotHasNoDerivedTimingValues() throws { + let snapshot = LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + + let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) + + XCTAssertNil(decoded.lockDuration(at: capturedAt)) + XCTAssertNil(decoded.remainingAutoUnlockTime(at: capturedAt)) + } + + func testMissingPayloadIsRejected() { + XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(nil)) { error in + XCTAssertEqual(error as? LockStatusSnapshotCodingError, .missingPayload) + } + } + + func testInvalidPayloadIsRejected() { + XCTAssertThrowsError( + try LockStatusSnapshot.decodedFromXPC(Data("not-json".utf8)) + ) { error in + XCTAssertEqual(error as? LockStatusSnapshotCodingError, .invalidPayload) + } + } + + func testUnsupportedFormatIsRejected() throws { + let snapshot = LockStatusSnapshot( + formatVersion: LockStatusSnapshot.currentFormatVersion + 1, + capturedAt: capturedAt, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + let payload = try JSONEncoder().encode(snapshot) + + XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(payload)) { error in + XCTAssertEqual( + error as? LockStatusSnapshotCodingError, + .unsupportedFormat(LockStatusSnapshot.currentFormatVersion + 1) + ) + } + } + + func testInconsistentUnlockedRuntimeDatesAreRejected() throws { + let snapshot = LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: false, + startedAt: capturedAt, + autoUnlockTargetDate: nil, + settings: .default + ) + let payload = try JSONEncoder().encode(snapshot) + + XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(payload)) { error in + XCTAssertEqual(error as? LockStatusSnapshotCodingError, .invalidPayload) + } + } + + func testOversizedPayloadIsRejectedBeforeDecoding() { + let payload = Data( + repeating: 0, + count: LockStatusSnapshot.maximumEncodedSize + 1 + ) + + XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(payload)) { error in + XCTAssertEqual(error as? LockStatusSnapshotCodingError, .payloadTooLarge) + } + } +} diff --git a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift index d3f07fa..f8cab49 100644 --- a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift +++ b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift @@ -98,4 +98,59 @@ final class LockRuntimeStateTests: XCTestCase { XCTAssertFalse(state.allowsControlCUnlock) XCTAssertEqual(state.startedAt, secondStart) } + + func testSnapshotCapturesOneCoherentRuntimeState() { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let capturedAt = startedAt.addingTimeInterval(10) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + + XCTAssertEqual( + state.begin( + settings: settings, + allowsControlCUnlock: false, + at: startedAt + ), + .acquired + ) + state.setAutoUnlockTargetDate(deadline) + + XCTAssertEqual( + state.statusSnapshot(capturedAt: capturedAt), + LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: true, + startedAt: startedAt, + autoUnlockTargetDate: deadline, + settings: settings + ) + ) + } + + func testSnapshotClearsRuntimeDatesAfterUnlock() { + let capturedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: capturedAt.addingTimeInterval(-10) + ) + state.setAutoUnlockTargetDate(capturedAt.addingTimeInterval(50)) + state.end() + + XCTAssertEqual( + state.statusSnapshot(capturedAt: capturedAt), + LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + ) + } } diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index 54a5b4b..ed487f1 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -91,6 +91,17 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) { + executeOnMainActor { + do { + let data = try LockEngine.shared.statusSnapshot.encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + } + } + func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID, diff --git a/docs/architecture.md b/docs/architecture.md index 85f33b4..87451c8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,7 @@ Widget ──┘ ├─ Settings ownership (source of truth) |---------|------|------| | 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | | 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 绝不自己持有 `UserDefaults`,只经 XPC 访问。当前仅暴露读取(`XPCClient.currentSettings()`);写入(`applySettings`)尚未接线,加入时同样必须经 Agent,绝不在 wrapper 侧落地。读取失败必须显式呈现为 unavailable,不能把 wrapper 的 `.default` 冒充为 Agent 当前值。 | +| 锁状态快照 | **Agent**(`Service/LockEngine`) | `XPCClient.lockStatusSnapshot()` 一次返回同一 execution turn 中的 `isLocked`、锁定起点、auto-unlock deadline 与 active settings。wrapper 可以缓存它用于呈现,但不能从缓存反推或修改 Agent 状态。 | | Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。wrapper 只能经 XPC 查询状态或请求 Agent 触发系统 prompt,不得自行调用 Accessibility API。权限 prompt 是异步的;请求完成不代表已授权,wrapper 必须重新查询。 | | 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | | UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,并协调只存在于自身进程的系统边界(例如 App 的 `SMAppService` 生命周期);不得复制 Agent 的锁、设置或 Accessibility 领域逻辑。 | @@ -57,14 +58,16 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `lock` 是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改当前设置、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。 - interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。 +- `status()` 保留为兼容旧 wrapper 的最小布尔查询;需要呈现锁定时长、auto-unlock deadline 或 active settings 的 wrapper 使用 capability-gated `lockStatusSnapshot()`。snapshot 传输权威时间点,不传会立即过期的 duration/countdown counter。 - 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 ## 状态同步 因为状态只存在于唯一一处(Agent),wrapper 保持同步的方式就是始终以它为准。wrapper 有两种形态,读取状态的方式不同: -- **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 -- **长命的状态反映面** —— 后续恢复的 App UI、未来的 Widgets。它们持续显示状态,因此必须既**订阅**(`LockStateSubscriber`),又在**变为可见 / 启动时校准**(拉取 `status()`)—— 因为进程被挂起期间可能错过某次广播。`LockStateSubscriber` 会先安装两个 observer,再立即做一次权威初始查询;只要初始查询成功或后续 signal 到达,snapshot 与 subscription 之间的变化就会被校准。调用方仍需在重新可见时做完整 readiness reconciliation。 +- **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()` 或 `lockStatusSnapshot()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 +- **长命的状态反映面** —— menu-bar App 等持续运行的 UI。它们必须既接收状态变化 signal,又在**变为可见 / 启动时校准** —— 因为进程被挂起期间可能错过某次广播。只显示布尔状态的现有 App 可以继续使用 `LockStateSubscriber`;需要起点、deadline 与 active settings 的界面在 signal 或重新可见时重新拉取 `lockStatusSnapshot()`。通知与本地缓存都不是真相源。 +- **系统托管的 snapshot 面** —— Widget、Control 等由系统按需启动的 provider。它们不假定进程常驻,也不安装长命 subscription;每次生成 timeline/value 或处理 action 时读取 `lockStatusSnapshot()`,把本地值只当 presentation cache。 `LockStateSubscriber` 把通知当作*提示*而非真相:订阅后的初始校准和收到的任一信号(Darwin 或 Distributed)都会向 Agent 拉取权威状态。多个并发信号被串行合并,相同状态被去重;subscription 取消后,尚未进入 handler 的在途结果会被丢弃。已经开始执行的 handler 不可被回溯撤销。Agent 之所以在两个通道都广播,只是为了让被挂起的 App 能被唤醒(Darwin)、让正在运行的 App 能及时更新(Distributed)。通知的载荷永远不是真相源。 @@ -79,7 +82,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management 3. **不持有独立状态。** 没有私有 `UserDefaults`,没有本地锁标志。设置和锁状态都从核心读取。 4. **不向其他进程发出任何东西。** 只有 Agent 广播状态。 5. **Agent 不可达时诚实降级。** 每个 wrapper 都必须处理"核心不可达"(见下方 Agent 生命周期要求),而不是假定调用成功。 -6. **按自身形态读取状态。** 判断这个 wrapper 属于"状态同步"里的哪一种形态:一次性面直接调 `status()`,**不加**任何通知处理;长命 UI 通过 `LockStateSubscriber` 订阅,*并且*在变为可见时校准。 +6. **按自身形态读取状态。** 判断这个 wrapper 属于"状态同步"里的哪一种形态:一次性面直接 query,**不加**任何通知处理;长命 UI 订阅 signal 并重新 query;系统托管的 snapshot 面则在每次 provider/action execution 中读取 `lockStatusSnapshot()`。 ## Agent 生命周期要求 diff --git a/docs/development.md b/docs/development.md index 08f3b91..ec4a38a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ 所有面都通过 `KeyboardLockerServiceProtocol` 与 Agent 通信(Mach 服务 `io.lzhlovesjyq.keyboardlocker.agent`)。 1. App 建立 connection 后先调用 `XPCClient.serviceDescriptor()`,验证 protocol version、required capabilities 和 bundled Agent build;只有兼容后才调用新增 selector。descriptor 在 fresh connection 上重试后仍失败、但旧 `status` 成功时,只能按 unverified base contract 处理,不能断言它一定是 legacy Agent。capability-gated Client API 会在同一条具体 connection generation 上重新握手并发送 feature selector,connection 变化后不能复用旧 grant。 -2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `currentSettings`。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 +2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `lockStatusSnapshot` / `currentSettings`。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 3. Client 与 Listener 都在 activate 前安装同 Team + 精确 signing identifier 的 XPC requirement;系统完成双向认证后,Agent 的 `ServiceDelegate` 才接受连接并路由到 `AgentService`。 4. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:allowsControlCUnlock:)` / `unlock()`。 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 @@ -22,20 +22,21 @@ 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive 锁操作、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector,旧 `lockKeyboard` ABI 保持不变。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`,旧 `status` / `lockKeyboard` ABI 保持不变。 +- `LockStatusSnapshot.swift`:`LockStatusSnapshot` format 1 和有大小上限的 JSON XPC 编解码。snapshot 原子携带 capture time、布尔状态、锁定起点、auto-unlock deadline 与 active settings;duration/countdown 由 consumer 根据权威时间点派生,不作为会迅速过期的 transport 字段。 - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成并预编译同 Team + 精确 identifier 的双向 XPC requirement;unsigned/ad-hoc 进程 fail closed。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` -- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。legacy `lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;`lockInteractively()` 在同一 connection 上完成 descriptor/capability gate 并原子返回 acquired/already locked,其 reply 丢失时不会用非原子的 `status()` 猜测 outcome。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。legacy `lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;`lockInteractively()` 与 `lockStatusSnapshot()` 都在同一 connection 上完成 descriptor/capability gate 后调用对应 selector。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 - `ServiceCompatibility.swift`:纯值兼容性规则与任意精度 dotted-numeric `ServiceBuildVersion` —— major 必须相等、running minor 不得低于最低版本、required capabilities 必须齐全,且运行中 Agent 的 identifier/version/build 必须与 bundled Agent 一致。 - `LockStateSubscriber.swift`:先安装 Darwin + Distributed observer,再立即拉取一次权威状态;后续把每个信号当作提示,通过 `XPCClient.status()` 串行校准(带重试、signal coalescing、去重和 cancellation fence)→ `ObserverToken`,另有会先产出当前权威状态的 `stateChanges`(`AsyncStream`)。取消会丢弃尚未进入 handler 的结果,但不会回溯撤销已经开始执行的 handler。长命 UI 使用 snapshot seed 避免重复呈现相同状态;一次性面直接读 `status()`(见 architecture 的"状态同步")。 - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、严格幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、显式 `updateSettings(_:)`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。重复 `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline;资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 +- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、严格幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。重复 `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline;资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 diff --git a/docs/xpc.md b/docs/xpc.md index 69ccc8a..5034b8d 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -100,6 +100,7 @@ public protocol KeyboardLockerServiceProtocol { ) func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) + func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID, @@ -131,7 +132,7 @@ public protocol KeyboardLockerServiceProtocol { 这和 HTTP 的 client/server 共同依赖同一份 OpenAPI schema 类似:共享的是消息契约,不是业务实例。 -协议使用 `@objc`,因为 `NSXPCInterface` 基于 Objective-C runtime 描述方法。跨边界参数需要是 XPC 可序列化类型;`ServiceDescriptor` 和设置模型都是 Swift `Codable` struct,所以先编码成有大小上限的 JSON `Data`,到另一侧再解码,而不是把 Swift struct 直接放进 protocol。 +协议使用 `@objc`,因为 `NSXPCInterface` 基于 Objective-C runtime 描述方法。跨边界参数需要是 XPC 可序列化类型;`ServiceDescriptor`、`LockStatusSnapshot` 和设置模型都是 Swift `Codable` struct,所以先编码成有大小上限的 JSON `Data`,到另一侧再解码,而不是把 Swift struct 直接放进 protocol。 ## Agent 如何被系统找到 @@ -516,6 +517,7 @@ sequenceDiagram | `lockKeyboardInteractively` | `lockInteractively()` | `LockEngine` | 原子返回本次请求是否创建全局锁;仅 acquired 的新锁临时接受 `Ctrl+C` 作为额外解锁手势 | | `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | | `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | +| `lockStatusSnapshot` | `lockStatusSnapshot()` | `LockEngine` / `AgentService` | capability-gated query;在一个 Agent execution turn 中读取布尔状态、capture time、锁定起点、auto-unlock deadline 与 active settings,并以有大小上限的 format-1 JSON payload 返回 | | `prepareForReplacement` | `prepareForReplacement(unlockIfNeeded:expectedAgentInstanceID:)` | `AgentService` | Agent 原子校验 expected instance,安装短期 prepared drain,可在同一 execution turn 解锁,返回同 generation ticket | | `commitReplacement` | `commitReplacement(ticket:)` | `AgentService` | 在提交 unregister 前把 prepared drain 幂等切换为不可过期的 committed drain | | `replacementStatus` | `replacementStatus(ticket:)` | `AgentService` | 查询 exact ticket 的 inactive/prepared/committed phase,恢复丢失的 commit reply | From cddcc0d3ac0af3fa9d3ce0a521520c7085b6d646 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 16:14:23 +0800 Subject: [PATCH 041/106] feat(app-intents): expose shortcut actions Add one-shot lock, unlock, and typed Boolean status actions that call the authoritative Agent through Client. Keep the actions composable on macOS without registering unsupported promoted App Shortcuts. --- .../AgentCoordinationServices.swift | 11 +++ .../AppIntents/KeyboardLockAppIntents.swift | 68 +++++++++++++ .../KeyboardLockAppIntentTests.swift | 98 +++++++++++++++++++ docs/development.md | 5 +- 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift create mode 100644 KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift diff --git a/KeyboardLocker/AgentCoordinationServices.swift b/KeyboardLocker/AgentCoordinationServices.swift index 3c1801b..57efeb3 100644 --- a/KeyboardLocker/AgentCoordinationServices.swift +++ b/KeyboardLocker/AgentCoordinationServices.swift @@ -9,6 +9,14 @@ protocol AgentControlServing: Sendable { func resetConnection() } +/// One-shot lock capabilities used by system automation wrappers. +@MainActor +protocol AgentLockActionServing: Sendable { + func lock() async throws + func unlock() async throws + func status() async throws -> Bool +} + @MainActor protocol AgentReadinessServing: Sendable { func serviceDescriptor() async throws -> ServiceDescriptor @@ -50,11 +58,14 @@ protocol AgentLockStateObserving: Sendable { @MainActor protocol AgentClientServing: AgentControlServing, + AgentLockActionServing, AgentReadinessServing, AgentReplacementServing {} @MainActor struct LiveAgentClient: AgentClientServing { + nonisolated init() {} + func serviceDescriptor() async throws -> ServiceDescriptor { try await XPCClient.shared.serviceDescriptor() } diff --git a/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift new file mode 100644 index 0000000..1430072 --- /dev/null +++ b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift @@ -0,0 +1,68 @@ +import AppIntents +import Foundation + +@MainActor +struct LockKeyboardIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Lock Keyboard" + nonisolated static let description = IntentDescription( + "Locks standard keyboard input while keeping mouse and media keys available." + ) + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult { + try await client.lock() + return .result() + } +} + +@MainActor +struct UnlockKeyboardIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Unlock Keyboard" + nonisolated static let description = IntentDescription("Unlocks standard keyboard input.") + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult { + try await client.unlock() + return .result() + } +} + +@MainActor +struct GetKeyboardLockStatusIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Get Keyboard Lock Status" + nonisolated static let description = IntentDescription( + "Returns whether standard keyboard input is currently locked." + ) + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult & ReturnsValue { + try await .result(value: client.status()) + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift new file mode 100644 index 0000000..1b8ca00 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift @@ -0,0 +1,98 @@ +import AppIntents +import XCTest + +final class KeyboardLockAppIntentTests: XCTestCase { + @MainActor + func testLockIntentInvokesOnlyLockCapability() async throws { + let client = FakeAgentLockActionClient(isLocked: false) + + _ = try await LockKeyboardIntent(client: client).perform() + + XCTAssertEqual(client.lockCallCount, 1) + XCTAssertEqual(client.unlockCallCount, 0) + XCTAssertEqual(client.statusCallCount, 0) + XCTAssertTrue(client.isLocked) + } + + @MainActor + func testUnlockIntentInvokesOnlyUnlockCapability() async throws { + let client = FakeAgentLockActionClient(isLocked: true) + + _ = try await UnlockKeyboardIntent(client: client).perform() + + XCTAssertEqual(client.lockCallCount, 0) + XCTAssertEqual(client.unlockCallCount, 1) + XCTAssertEqual(client.statusCallCount, 0) + XCTAssertFalse(client.isLocked) + } + + @MainActor + func testStatusIntentReturnsAuthoritativeClientValue() async throws { + let client = FakeAgentLockActionClient(isLocked: true) + + let result = try await GetKeyboardLockStatusIntent(client: client).perform() + + XCTAssertEqual(result.value, true) + XCTAssertEqual(client.lockCallCount, 0) + XCTAssertEqual(client.unlockCallCount, 0) + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testStatusIntentPropagatesAgentFailureWithoutFallback() async { + let client = FakeAgentLockActionClient( + isLocked: false, + error: IntentClientError.unavailable + ) + + do { + _ = try await GetKeyboardLockStatusIntent(client: client).perform() + XCTFail("Expected the Agent error to be propagated.") + } catch { + XCTAssertEqual(error as? IntentClientError, .unavailable) + } + } +} + +@MainActor +private final class FakeAgentLockActionClient: AgentLockActionServing { + private(set) var lockCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var unlockCallCount = 0 + private(set) var isLocked: Bool + + private let error: Error? + + init(isLocked: Bool, error: Error? = nil) { + self.isLocked = isLocked + self.error = error + } + + func lock() async throws { + try throwConfiguredError() + lockCallCount += 1 + isLocked = true + } + + func unlock() async throws { + try throwConfiguredError() + unlockCallCount += 1 + isLocked = false + } + + func status() async throws -> Bool { + try throwConfiguredError() + statusCallCount += 1 + return isLocked + } + + private func throwConfiguredError() throws { + if let error { + throw error + } + } +} + +private enum IntentClientError: Error, Equatable { + case unavailable +} diff --git a/docs/development.md b/docs/development.md index ec4a38a..56fc7e3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,10 +42,11 @@ - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 -**App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper;presentation 只映射协调器 snapshot 与 action +**App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;两者都只调用 Client - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 - `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 -- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol,协调器无需依赖完整 Client/lifecycle surface。 +- `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知,也不注册 macOS 不支持的 promoted App Shortcuts。 +- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 - `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。 From 80b809e6aadff7127d79cf722ceaf8720306a235 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 16:28:16 +0800 Subject: [PATCH 042/106] feat(applescript): add scripting commands Expose lock, unlock, and authoritative status through the app scripting dictionary. Suspend each Apple event while the async Agent request completes, then resume with typed results or explicit AppleScript errors. --- KeyboardLocker.xcodeproj/project.pbxproj | 15 +-- .../KeyboardLockerScriptCommands.swift | 89 +++++++++++++ KeyboardLocker/Info.plist | 32 +++++ KeyboardLocker/KeyboardLocker.sdef | 16 +++ .../KeyboardLockerScriptCommandTests.swift | 123 ++++++++++++++++++ docs/development.md | 1 + 6 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift create mode 100644 KeyboardLocker/Info.plist create mode 100644 KeyboardLocker/KeyboardLocker.sdef create mode 100644 KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index c9ae7f5..96fd42b 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -89,7 +89,9 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Assets.xcassets, + Info.plist, io.lzhlovesjyq.keyboardlocker.agent.plist, + KeyboardLocker.sdef, KeyboardLockerApplication.swift, StatusMenuController.swift, ); @@ -98,6 +100,7 @@ F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Info.plist, io.lzhlovesjyq.keyboardlocker.agent.plist, ); target = F21304272ECDAD3500735580 /* KeyboardLocker */; @@ -588,10 +591,8 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLocker/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -623,10 +624,8 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLocker/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift new file mode 100644 index 0000000..dc2ec5e --- /dev/null +++ b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift @@ -0,0 +1,89 @@ +import Client +import Foundation + +nonisolated class AsyncKeyboardLockerScriptCommand: NSScriptCommand { + override final nonisolated func performDefaultImplementation() -> Any? { + suspendExecution() + let commandReference = SuspendedScriptCommandReference(self) + + // Cocoa scripting requires the handler to return before a suspended command is resumed. + DispatchQueue.main.async { + Task { @MainActor in + let command = commandReference.value + do { + let result = try await type(of: command).perform(using: LiveAgentClient()) + command.resumeExecution(withResult: result) + } catch { + command.scriptErrorNumber = AppleScriptErrorPresentation.errorNumber(for: error) + command.scriptErrorString = AppleScriptErrorPresentation.message(for: error) + command.resumeExecution(withResult: nil) + } + } + } + + return nil + } + + @MainActor + class func perform(using _: any AgentLockActionServing) async throws -> Any? { + preconditionFailure("Subclasses must implement their Agent operation.") + } +} + +/// Cocoa permits a suspended command to resume on another thread. This box only carries the +/// retained command into a main-actor task, where all post-suspension access occurs. +private final nonisolated class SuspendedScriptCommandReference: @unchecked Sendable { + let value: AsyncKeyboardLockerScriptCommand + + init(_ value: AsyncKeyboardLockerScriptCommand) { + self.value = value + } +} + +@objc(LockKeyboardScriptCommand) +final nonisolated class LockKeyboardScriptCommand: AsyncKeyboardLockerScriptCommand { + @MainActor + override class func perform(using client: any AgentLockActionServing) async throws -> Any? { + try await client.lock() + return nil + } +} + +@objc(UnlockKeyboardScriptCommand) +final nonisolated class UnlockKeyboardScriptCommand: AsyncKeyboardLockerScriptCommand { + @MainActor + override class func perform(using client: any AgentLockActionServing) async throws -> Any? { + try await client.unlock() + return nil + } +} + +@objc(GetKeyboardLockStatusScriptCommand) +final nonisolated class GetKeyboardLockStatusScriptCommand: AsyncKeyboardLockerScriptCommand { + @MainActor + override class func perform(using client: any AgentLockActionServing) async throws -> Any? { + try await NSNumber(value: client.status()) + } +} + +@MainActor +enum AppleScriptErrorPresentation { + static func errorNumber(for error: Error) -> Int { + if let clientError = error as? XPCClientError, + case .timedOut = clientError + { + return Int(errAETimeout) + } + return Int(errAEEventFailed) + } + + static func message(for error: Error) -> String { + let error = error as NSError + guard let recoverySuggestion = error.localizedRecoverySuggestion, + !recoverySuggestion.isEmpty + else { + return error.localizedDescription + } + return "\(error.localizedDescription) \(recoverySuggestion)" + } +} diff --git a/KeyboardLocker/Info.plist b/KeyboardLocker/Info.plist new file mode 100644 index 0000000..4c9fef6 --- /dev/null +++ b/KeyboardLocker/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSUIElement + + NSAppleScriptEnabled + + NSHumanReadableCopyright + + OSAScriptingDefinition + KeyboardLocker.sdef + + diff --git a/KeyboardLocker/KeyboardLocker.sdef b/KeyboardLocker/KeyboardLocker.sdef new file mode 100644 index 0000000..aa7ffcd --- /dev/null +++ b/KeyboardLocker/KeyboardLocker.sdef @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift new file mode 100644 index 0000000..0a6f87e --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift @@ -0,0 +1,123 @@ +import Client +import Foundation +import XCTest + +final class KeyboardLockerScriptCommandTests: XCTestCase { + @MainActor + func testLockCommandInvokesOnlyLockCapability() async throws { + let client = FakeAppleScriptAgentClient(isLocked: false) + + let result = try await LockKeyboardScriptCommand.perform(using: client) + + XCTAssertNil(result) + XCTAssertEqual(client.lockCallCount, 1) + XCTAssertEqual(client.unlockCallCount, 0) + XCTAssertEqual(client.statusCallCount, 0) + XCTAssertTrue(client.isLocked) + } + + @MainActor + func testUnlockCommandInvokesOnlyUnlockCapability() async throws { + let client = FakeAppleScriptAgentClient(isLocked: true) + + let result = try await UnlockKeyboardScriptCommand.perform(using: client) + + XCTAssertNil(result) + XCTAssertEqual(client.lockCallCount, 0) + XCTAssertEqual(client.unlockCallCount, 1) + XCTAssertEqual(client.statusCallCount, 0) + XCTAssertFalse(client.isLocked) + } + + @MainActor + func testStatusCommandReturnsAuthoritativeBoolean() async throws { + let client = FakeAppleScriptAgentClient(isLocked: true) + + let result = try await GetKeyboardLockStatusScriptCommand.perform(using: client) + + XCTAssertEqual(result as? NSNumber, NSNumber(value: true)) + XCTAssertEqual(client.lockCallCount, 0) + XCTAssertEqual(client.unlockCallCount, 0) + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testCommandPropagatesAgentFailure() async { + let client = FakeAppleScriptAgentClient( + isLocked: false, + error: AppleScriptClientError.unavailable + ) + + do { + _ = try await LockKeyboardScriptCommand.perform(using: client) + XCTFail("Expected the Agent error to be propagated.") + } catch { + XCTAssertEqual(error as? AppleScriptClientError, .unavailable) + } + } + + @MainActor + func testErrorPresentationIncludesClientRecoverySuggestion() { + let message = AppleScriptErrorPresentation.message( + for: XPCClientError.serviceUnavailable + ) + + XCTAssertTrue(message.contains("The KeyboardLocker agent is not reachable.")) + XCTAssertTrue(message.contains("Open KeyboardLocker once")) + } + + @MainActor + func testErrorPresentationUsesAppleEventTimeoutOnlyForClientTimeout() { + XCTAssertEqual( + AppleScriptErrorPresentation.errorNumber(for: XPCClientError.timedOut), + Int(errAETimeout) + ) + XCTAssertEqual( + AppleScriptErrorPresentation.errorNumber(for: XPCClientError.serviceUnavailable), + Int(errAEEventFailed) + ) + } +} + +@MainActor +private final class FakeAppleScriptAgentClient: AgentLockActionServing { + private(set) var lockCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var unlockCallCount = 0 + private(set) var isLocked: Bool + + private let error: Error? + + init(isLocked: Bool, error: Error? = nil) { + self.isLocked = isLocked + self.error = error + } + + func lock() async throws { + try throwConfiguredError() + lockCallCount += 1 + isLocked = true + } + + func unlock() async throws { + try throwConfiguredError() + unlockCallCount += 1 + isLocked = false + } + + func status() async throws -> Bool { + try throwConfiguredError() + statusCallCount += 1 + return isLocked + } + + private func throwConfiguredError() throws { + if let error { + throw error + } + } +} + +private enum AppleScriptClientError: Error, Equatable { + case unavailable +} diff --git a/docs/development.md b/docs/development.md index 56fc7e3..b2355a6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -46,6 +46,7 @@ - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 - `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 - `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知,也不注册 macOS 不支持的 promoted App Shortcuts。 +- `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 - `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 From dbc065838ff522ce687c65681f4cdb6da2c6cbfb Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 16:32:11 +0800 Subject: [PATCH 043/106] feat(klock): add noninteractive lock mode Allow automation to confirm the global locked state and exit immediately without enabling the interactive Control-C gesture. Document the shared semantics across Shortcuts, AppleScript, and CLI wrappers. --- CLAUDE.md | 2 +- docs/architecture.md | 2 ++ docs/automation.md | 69 ++++++++++++++++++++++++++++++++++++++++++++ docs/development.md | 6 ++-- klock/main.swift | 42 +++++++++++++++++++-------- 5 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 docs/automation.md diff --git a/CLAUDE.md b/CLAUDE.md index 6c306b3..812c9dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `… **Before adding or changing any feature (App, CLI, Shortcuts, AppleScript, Widgets, …), read [docs/architecture.md](docs/architecture.md).** It defines the non-negotiable contract: the Agent is the single executor, all other surfaces are thin wrappers, the lock is one global state. When a change conflicts with it, the contract wins. -For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). +For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). For Shortcuts, AppleScript, and CLI usage, see [docs/automation.md](docs/automation.md). ## Project-Specific Rules diff --git a/docs/architecture.md b/docs/architecture.md index 87451c8..427191b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,8 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management `klock lock` 先发出 atomic interactive lock request。只有 outcome 为 acquired 时,它才等待并报告后续解锁;本轮 event tap 会把 `Ctrl+C` 当作额外解锁手势并在 Agent 内消费该事件。若 outcome 为 already locked,CLI 必须说明本命令没有创建新锁并立即退出,不能把 App 或另一个 CLI 已建立的锁变成自己的可取消 session。acquired 后的等待同时使用 `LockStateSubscriber` 获得及时更新,并周期性查询 `status()` 以恢复双通道通知都丢失或 Agent 重启的场景;连续无法取得权威状态时必须报错退出,不能把 transport failure 猜成 unlocked。 +`klock lock --no-wait` 是独立的 one-shot desired-state action:它调用幂等 `lock()`,在 Agent 确认 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待后续 unlock。它同样不拥有 lock;之后执行的 `klock unlock` 会解开全局锁,而不是只撤销某个脚本创建的锁。 + ## 新增 wrapper 的规则(Widget、Shortcut、AppleScript……) 编写 wrapper 之前,逐条确认以下几点: diff --git a/docs/automation.md b/docs/automation.md new file mode 100644 index 0000000..2630161 --- /dev/null +++ b/docs/automation.md @@ -0,0 +1,69 @@ +# 自动化 + +KeyboardLocker 的 Shortcuts、AppleScript 与 CLI 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 + +所有入口共享以下语义: + +- `lock` 与 `unlock` 是幂等的 desired-state action。 +- `status` 每次都从 Agent 查询权威状态;wrapper 不维护本地副本。 +- lock 是全局状态,不属于发起它的 Shortcut、AppleScript 或 shell process。任意入口的 `unlock` 都会解开同一个全局锁。 +- 当前没有暴露 client-side `toggle`。需要确定结果时,显式选择 `lock` 或 `unlock`。 +- Agent 不可达、权限不足或结果无法确认时,wrapper 会明确失败,不会把失败猜成 unlocked。 + +## Shortcuts + +KeyboardLocker 提供三个可组合 action: + +- `Lock Keyboard` +- `Unlock Keyboard` +- `Get Keyboard Lock Status` + +`Get Keyboard Lock Status` 返回 typed Boolean,可直接接入 Shortcuts 的条件分支。当前版本没有声明 `AppShortcutsProvider`;这些 action 从 Shortcuts 的 action library 中选择,不会作为 promoted shortcut 自动出现,也没有预注册的 invocation phrase。 + +## AppleScript + +应用 scripting dictionary 提供三个 command: + +```applescript +tell application "KeyboardLocker" + lock keyboard + set isLocked to get keyboard lock status + unlock keyboard + return isLocked +end tell +``` + +开发构建可能与已安装版本使用相同 bundle identifier。验证特定 build 时,用绝对 app path 避免 Launch Services 命中另一份应用: + +```applescript +tell application "/absolute/path/to/KeyboardLocker.app" + get keyboard lock status +end tell +``` + +命令会等待异步 Agent 调用完成后再回复 Apple event。XPC timeout 返回 Apple event timeout (`-1712`),其余 Agent 调用失败返回 Apple event failed (`-10000`),并保留 Client 提供的恢复建议。 + +## CLI + +交互式锁定会等待后续 unlock。只有本次命令真正创建新锁时,Agent 才为该轮锁临时启用 `Ctrl+C` 解锁: + +```bash +klock lock +``` + +自动化应使用 one-shot 模式。它在 Agent 确认 locked 后立即退出,不会启用 `Ctrl+C`,也不会等待: + +```bash +klock lock --no-wait +``` + +其他命令: + +```bash +klock status +klock unlock +``` + +`status` 当前输出供人阅读的 `Locked` 或 `Unlocked`。在增加明确的 machine-readable output contract 前,脚本不要把这段文本当成稳定 JSON/API。所有命令在调用成功时返回 exit code `0`,参数或 Agent 调用失败时返回 `1`。 + +不要把 `lock --no-wait` / `unlock` 当成 acquire/release pair:若调用 `lock --no-wait` 时全局状态本来已经 locked,后续无条件执行 `unlock` 会解除其他 wrapper 建立的锁。需要这种 ownership 语义时,应先在 Agent contract 中设计明确的 lease/session capability,而不是在 shell 中模拟。 diff --git a/docs/development.md b/docs/development.md index b2355a6..940a610 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,7 @@ **App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;两者都只调用 Client - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 - `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 -- `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知,也不注册 macOS 不支持的 promoted App Shortcuts。 +- `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。当前没有声明 `AppShortcutsProvider`,因此不会生成 promoted App Shortcuts 或 invocation phrases。 - `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 - `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 @@ -87,7 +87,9 @@ `klock` 自身不注册后台 Agent。首次使用前必须至少启动一次 KeyboardLocker App;Agent 未注册时,CLI 会给出对应恢复提示。 -`klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。 +`klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 + +Shortcuts、AppleScript 与 CLI 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 ### Homebrew Cask 发布计划(尚未实现) diff --git a/klock/main.swift b/klock/main.swift index 1dbce3b..9767094 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -36,10 +36,16 @@ enum KlockCLI { exit(ExitCode.success) case "lock": - guard arguments.count == 1 else { - reportUnexpectedArguments(Array(arguments.dropFirst())) + switch Array(arguments.dropFirst()) { + case []: + await executeInteractiveLock() + + case ["--no-wait"]: + await executeNonInteractiveLock() + + case let unexpectedArguments: + reportUnexpectedArguments(unexpectedArguments) } - await executeLock() case "unlock": guard arguments.count == 1 else { @@ -62,7 +68,7 @@ enum KlockCLI { // MARK: - Commands - private static func executeLock() async { + private static func executeInteractiveLock() async { let unlockHotkey: Result do { unlockHotkey = .success( @@ -104,6 +110,17 @@ enum KlockCLI { } } + private static func executeNonInteractiveLock() async { + do { + try await XPCClient.shared.lock() + print("Locked.") + exit(ExitCode.success) + } catch { + reportFailure(error) + exit(ExitCode.error) + } + } + private static func executeUnlock() async { do { try await XPCClient.shared.unlock() @@ -158,18 +175,19 @@ enum KlockCLI { """ OVERVIEW: Control KeyboardLocker from Terminal. - USAGE: klock + USAGE: klock [options] COMMANDS: - lock Lock the keyboard; Ctrl+C unlocks an acquired lock. - unlock Unlock the keyboard. - status Print the current lock state. - help Show this help message. - version Show the klock version. + lock [--no-wait] Lock the keyboard; by default, wait until it is unlocked. + unlock Unlock the keyboard. + status Print the current lock state. + help Show this help message. + version Show the klock version. OPTIONS: - -h, --help Show this help message. - -v, --version Show the klock version. + --no-wait Return after lock is confirmed; do not enable Ctrl+C unlock. + -h, --help Show this help message. + -v, --version Show the klock version. """ ) } From 9682636fdaf9b37b5e57eb07e0a75a99a13ce735 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 16:36:14 +0800 Subject: [PATCH 044/106] feat(xpc): authorize WidgetKit extension Reserve one exact signing identity for Widget and Control providers, and keep the extension sandboxed with lookup access limited to the KeyboardLocker Agent Mach service. Preserve bidirectional same-team peer authentication. --- CLAUDE.md | 7 ++++--- Core/Sources/Common/Shared.swift | 14 +++++++++++--- .../XPCCodeSigningRequirementTests.swift | 1 + .../KeyboardLockerWidgets.entitlements | 12 ++++++++++++ docs/architecture.md | 2 +- docs/xpc.md | 8 +++++--- 6 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements diff --git a/CLAUDE.md b/CLAUDE.md index 812c9dc..12f0f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,12 +6,13 @@ Guidance for Claude Code when working in this repository. KeyboardLocker suppresses standard keyboard events via CGEventTap on macOS. Mouse input and system-defined media keys remain available. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): -- **KeyboardLocker** (App, headless Agent-registration bootstrap; presentation layer intentionally absent) — imports `Client` +- **KeyboardLocker** (menu-bar App, Agent lifecycle coordinator, and one-shot system actions) — imports `Client` - **KeyboardLockerAgent** (XPC service, runs the lock engine, holds Accessibility permission) — imports `Service` - **klock** (CLI) — imports `Client` -- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, App/CLI), `Service` (lock engine, Agent). `Client`/`Service` both `@_exported import Common`. +- **KeyboardLockerWidgets** (WidgetKit extension hosting Widget and macOS Control) — imports `Client` +- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, wrappers), `Service` (lock engine, Agent). `Client`/`Service` both `@_exported import Common`. -Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`. +Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`, WidgetKit extension `…​.widgets`. ## Architecture Contract — read first diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 7dc854c..8cbac6b 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -3,19 +3,27 @@ import Foundation // MARK: - Shared Constants public enum SharedConstants { - /// Mach service name for XPC communication between App/Agent/CLI + /// Mach service name for XPC communication between the Agent and authorized wrappers. public static let machServiceName = "io.lzhlovesjyq.keyboardlocker.agent" + private static let appBundleIdentifier = "io.lzhlovesjyq.keyboardlocker" + /// Bundle identifier of the launchd-managed Agent bundled with the App. public static let agentBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.agent" + private static let commandLineToolBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.klock" + + /// One WidgetKit extension process hosts both the status Widget and macOS Control. + private static let widgetKitExtensionBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.widgets" + /// Default unlock key code for 'L' key (⌃⌘L) public static let defaultUnlockKeyCode: UInt16 = 37 /// Exact code-signing identifiers allowed to talk to the Agent's Mach service. public static let authorizedClientBundleIdentifiers: Set = [ - "io.lzhlovesjyq.keyboardlocker", - "io.lzhlovesjyq.keyboardlocker.klock", + appBundleIdentifier, + commandLineToolBundleIdentifier, + widgetKitExtensionBundleIdentifier, ] } diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift index f5406c2..1588ab9 100644 --- a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift @@ -9,6 +9,7 @@ final class XPCCodeSigningRequirementTests: XCTestCase { [ "io.lzhlovesjyq.keyboardlocker", "io.lzhlovesjyq.keyboardlocker.klock", + "io.lzhlovesjyq.keyboardlocker.widgets", ] ) XCTAssertEqual( diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements b/KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements new file mode 100644 index 0000000..631a0dc --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.temporary-exception.mach-lookup.global-name + + io.lzhlovesjyq.keyboardlocker.agent + + + diff --git a/docs/architecture.md b/docs/architecture.md index 427191b..fc424bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ App 必须区分 `SMAppService` 的 enabled、requires-approval、not-found 和 App 在调用新能力前必须先读取 `ServiceDescriptor`,分别验证 XPC protocol major/minor、required capabilities 和 bundled Agent 的 identifier/version/build。descriptor 中的身份字段只用于兼容性与更新判断,不能代替 XPC connection 的代码签名认证。descriptor 必须在 fresh connection 上重试后才能降级;若两次 descriptor 都失败而旧 `status` 成功,只能断言 base contract 可达,不能断言远端一定是 legacy Agent。此时只允许使用旧 `status` / `unlock` 做显式安全迁移,不得继续调用新 selector。 -XPC peer authentication 必须由系统双向执行。Agent Listener 在 activate 前安装同 Team + 主 App/CLI 精确 signing identifier 的 connection requirement;App/CLI connection 在 activate 前安装同 Team + 精确 Agent signing identifier 的 requirement。Debug 不得降级成 identifier-only,不能通过 PID 后查静态签名来代替 XPC runtime 的 requirement。Team ID 从各进程自身已验证的签名读取;unsigned/ad-hoc 进程 fail closed。 +XPC peer authentication 必须由系统双向执行。Agent Listener 在 activate 前安装同 Team + 每个 wrapper 精确 signing identifier 的 connection requirement;wrapper connection 在 activate 前安装同 Team + 精确 Agent signing identifier 的 requirement。WidgetKit extension 使用单独的 `io.lzhlovesjyq.keyboardlocker.widgets` 身份承载 Widget 与 Control;它保持 App Sandbox,并只通过 Mach lookup temporary exception 访问 KeyboardLocker Agent 的 global service。Debug 不得降级成 identifier-only,不能通过 PID 后查静态签名来代替 XPC runtime 的 requirement。Team ID 从各进程自身已验证的签名读取;unsigned/ad-hoc 进程 fail closed。 capability grant 必须绑定到读取 descriptor 的同一条 client-side connection generation。所有 optional method 都要在一条具体 connection 上完成 handshake 与 capability check,再经同一个 `NSXPCConnection` 发出;connection 失效就同时失去 grant。named connection object 仍可能在 interruption 后面对新进程,因此 replacement wire request 必须携带 expected `agentInstanceID`,由 Agent 在副作用前原子验证,Client 也必须验证返回 ticket。 diff --git a/docs/xpc.md b/docs/xpc.md index 5034b8d..897f0cf 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -605,12 +605,14 @@ sequenceDiagram 身份认证是双向且由 XPC runtime 强制执行: -- Agent 的 `NSXPCListener` 在 activate 前调用 `setConnectionCodeSigningRequirement`,只接受与 Agent 同 Team 且 signing identifier 精确匹配主 App 或 bundled CLI 的 Client。系统在调用 delegate 前完成检查,因此未认证 peer 看不到任何 exported selector。 -- App/CLI 的 `NSXPCConnection` 在 activate 前调用 `setCodeSigningRequirement`,只接受与 Client 同 Team 且 signing identifier 精确匹配 bundled Agent 的服务进程。 +- Agent 的 `NSXPCListener` 在 activate 前调用 `setConnectionCodeSigningRequirement`,只接受与 Agent 同 Team 且 signing identifier 精确匹配主 App、bundled CLI 或 WidgetKit extension 的 Client。系统在调用 delegate 前完成检查,因此未认证 peer 看不到任何 exported selector。 +- App、CLI 与 WidgetKit extension 的 `NSXPCConnection` 在 activate 前调用 `setCodeSigningRequirement`,只接受与 Client 同 Team 且 signing identifier 精确匹配 bundled Agent 的服务进程。 - requirement 使用 `anchor apple generic`、Apple 签名证书 `subject.OU` 中的 Team ID 和精确 `identifier`;Team ID 从当前进程已经验证的签名动态读取,不在源码中重复硬编码。 - Debug 和 Release 使用同一条安全边界。Xcode Debug target 已配置 Apple Development 签名;unsigned、ad-hoc、错误 Team 或仅伪造 identifier 的进程都会 fail closed。 -当前允许的调用方只有主 App 和 bundled CLI 的 namespaced signing identifier;不再接受通用的 `klock` identifier。仅仅知道 Mach service 名称,或在自己的签名中复制 bundle identifier,都不能调用 Agent。descriptor 中的 bundle/version/build 仍只用于兼容性判断,不参与身份认证。 +当前允许的调用方只有主 App、bundled CLI 与 `io.lzhlovesjyq.keyboardlocker.widgets` WidgetKit extension 的 namespaced signing identifier;不再接受通用的 `klock` identifier。仅仅知道 Mach service 名称,或在自己的签名中复制 bundle identifier,都不能调用 Agent。descriptor 中的 bundle/version/build 仍只用于兼容性判断,不参与身份认证。 + +WidgetKit extension 保持 App Sandbox 与 `APPLICATION_EXTENSION_API_ONLY`;它的 entitlements 只为 `io.lzhlovesjyq.keyboardlocker.agent` 声明 global Mach lookup temporary exception。这个 sandbox permission 只允许发起 lookup,不能代替 Listener 的同 Team + 精确 identifier 检查;Client 仍会反向验证 Agent。Widget 与 macOS 26+ Control 共用这一 extension process 和 signing identity,但每次 timeline/value/action execution 都建立在 Agent 权威 snapshot/action 上,不共享另一份业务状态。 `klock` 是裸 Mach-O command-line tool,因此 target 必须生成并嵌入 `__TEXT,__info_plist`;否则 `PRODUCT_BUNDLE_IDENTIFIER` 不会成为实际 code-signing identifier,`codesign` 会退回可执行文件名 `klock`,并被 Listener requirement 正确拒绝。`CREATE_INFOPLIST_SECTION_IN_BINARY` 和 `GENERATE_INFOPLIST_FILE` 属于 XPC 身份契约,不能当作无关构建设置移除。 From 120e656ac34207f8229b9c1760e3ac44baa44679 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 16:48:06 +0800 Subject: [PATCH 045/106] feat(widget): add lock status widget --- CLAUDE.md | 2 +- KeyboardLocker.xcodeproj/project.pbxproj | 181 ++++++++++++++++++ .../KeyboardLockerWidgetTimelineTests.swift | 77 ++++++++ KeyboardLockerWidgets/Info.plist | 29 +++ .../KeyboardLockerStatusWidget.swift | 83 ++++++++ .../KeyboardLockerWidgetTimeline.swift | 127 ++++++++++++ .../KeyboardLockerWidgetsBundle.swift | 9 + docs/automation.md | 10 +- docs/development.md | 7 +- docs/xpc.md | 10 +- 10 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift create mode 100644 KeyboardLockerWidgets/Info.plist create mode 100644 KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift create mode 100644 KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift create mode 100644 KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift diff --git a/CLAUDE.md b/CLAUDE.md index 12f0f0a..36e079f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `… **Before adding or changing any feature (App, CLI, Shortcuts, AppleScript, Widgets, …), read [docs/architecture.md](docs/architecture.md).** It defines the non-negotiable contract: the Agent is the single executor, all other surfaces are thin wrappers, the lock is one global state. When a change conflicts with it, the contract wins. -For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). For Shortcuts, AppleScript, and CLI usage, see [docs/automation.md](docs/automation.md). +For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). For Shortcuts, AppleScript, CLI, and Widget usage, see [docs/automation.md](docs/automation.md). ## Project-Specific Rules diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index 96fd42b..d95bc11 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ A1000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000A /* Client */; }; + B2000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = B2000000000000000000000A /* Client */; }; + B2000000000000000000000C /* KeyboardLockerWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F21304CF2ECDC2E200735580 /* KeyboardLockerAgent.app in Copy Agent App */ = {isa = PBXBuildFile; fileRef = F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F21304D32ECDC51F00735580 /* klock in Copy CLI */ = {isa = PBXBuildFile; fileRef = F21304B42ECDC26400735580 /* klock */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFCFE2EEFAF0B0065F041 /* Client */; }; @@ -30,9 +32,27 @@ remoteGlobalIDString = F21304B32ECDC26400735580; remoteInfo = klock; }; + B2000000000000000000000E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F21304202ECDAD3500735580 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B20000000000000000000003; + remoteInfo = KeyboardLockerWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + B2000000000000000000000D /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + B2000000000000000000000C /* KeyboardLockerWidgets.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; F21304B22ECDC26400735580 /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -78,6 +98,7 @@ /* Begin PBXFileReference section */ A10000000000000000000001 /* KeyboardLockerModelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KeyboardLockerModelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000001 /* KeyboardLockerWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KeyboardLockerWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F21304282ECDAD3500735580 /* KeyboardLocker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLocker.app; sourceTree = BUILT_PRODUCTS_DIR; }; F21304362ECDADC900735580 /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLockerAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -105,6 +126,24 @@ ); target = F21304272ECDAD3500735580 /* KeyboardLocker */; }; + B20000000000000000000010 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + KeyboardLockerWidgets.entitlements, + ); + target = B20000000000000000000003 /* KeyboardLockerWidgets */; + }; + B20000000000000000000011 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + KeyboardLockerStatusWidget.swift, + KeyboardLockerWidgets.entitlements, + KeyboardLockerWidgetsBundle.swift, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -126,6 +165,15 @@ path = KeyboardLockerModelTests; sourceTree = ""; }; + B20000000000000000000002 /* KeyboardLockerWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + B20000000000000000000010 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerWidgets" target */, + B20000000000000000000011 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerModelTests" target */, + ); + path = KeyboardLockerWidgets; + sourceTree = ""; + }; F213042A2ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -157,6 +205,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B2000000000000000000000B /* Client in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304252ECDAD3500735580 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -189,6 +245,7 @@ children = ( F21304362ECDADC900735580 /* Core */, F213042A2ECDAD3500735580 /* KeyboardLocker */, + B20000000000000000000002 /* KeyboardLockerWidgets */, F213049C2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B52ECDC26400735580 /* klock */, A10000000000000000000002 /* KeyboardLockerModelTests */, @@ -200,6 +257,7 @@ isa = PBXGroup; children = ( F21304282ECDAD3500735580 /* KeyboardLocker.app */, + B20000000000000000000001 /* KeyboardLockerWidgets.appex */, F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */, F21304B42ECDC26400735580 /* klock */, A10000000000000000000001 /* KeyboardLockerModelTests.xctest */, @@ -225,6 +283,7 @@ fileSystemSynchronizedGroups = ( A10000000000000000000002 /* KeyboardLockerModelTests */, F213042A2ECDAD3500735580 /* KeyboardLocker */, + B20000000000000000000002 /* KeyboardLockerWidgets */, ); name = KeyboardLockerModelTests; packageProductDependencies = ( @@ -234,6 +293,29 @@ productReference = A10000000000000000000001 /* KeyboardLockerModelTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + B20000000000000000000003 /* KeyboardLockerWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = B20000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerWidgets" */; + buildPhases = ( + B20000000000000000000004 /* Sources */, + B20000000000000000000005 /* Frameworks */, + B20000000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + B20000000000000000000002 /* KeyboardLockerWidgets */, + ); + name = KeyboardLockerWidgets; + packageProductDependencies = ( + B2000000000000000000000A /* Client */, + ); + productName = KeyboardLockerWidgets; + productReference = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; F21304272ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXNativeTarget; buildConfigurationList = F21304332ECDAD3600735580 /* Build configuration list for PBXNativeTarget "KeyboardLocker" */; @@ -244,12 +326,14 @@ F21305582ECE3C9B00735580 /* Copy Agent Plist */, F21304C02ECDC2A400735580 /* Copy Agent App */, F21304D22ECDC50400735580 /* Copy CLI */, + B2000000000000000000000D /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( F21304D52ECDC53D00735580 /* PBXTargetDependency */, F21304D72ECDC53D00735580 /* PBXTargetDependency */, + B2000000000000000000000F /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( F213042A2ECDAD3500735580 /* KeyboardLocker */, @@ -321,6 +405,9 @@ A10000000000000000000003 = { CreatedOnToolsVersion = 26.2; }; + B20000000000000000000003 = { + CreatedOnToolsVersion = 26.3; + }; F21304272ECDAD3500735580 = { CreatedOnToolsVersion = 26.1.1; }; @@ -347,6 +434,7 @@ projectRoot = ""; targets = ( F21304272ECDAD3500735580 /* KeyboardLocker */, + B20000000000000000000003 /* KeyboardLockerWidgets */, F213049A2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B32ECDC26400735580 /* klock */, A10000000000000000000003 /* KeyboardLockerModelTests */, @@ -362,6 +450,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304262ECDAD3500735580 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -386,6 +481,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304242ECDAD3500735580 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -420,6 +522,11 @@ target = F21304B32ECDC26400735580 /* klock */; targetProxy = F21304D62ECDC53D00735580 /* PBXContainerItemProxy */; }; + B2000000000000000000000F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B20000000000000000000003 /* KeyboardLockerWidgets */; + targetProxy = B2000000000000000000000E /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -455,6 +562,67 @@ }; name = Release; }; + B20000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + B20000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; F21304312ECDAD3600735580 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -773,6 +941,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B20000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B20000000000000000000007 /* Debug */, + B20000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; F21304232ECDAD3500735580 /* Build configuration list for PBXProject "KeyboardLocker" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -816,6 +993,10 @@ isa = XCSwiftPackageProductDependency; productName = Client; }; + B2000000000000000000000A /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; F2BFFCFE2EEFAF0B0065F041 /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; diff --git a/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift new file mode 100644 index 0000000..7ef55e4 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift @@ -0,0 +1,77 @@ +import Client +import Foundation +import XCTest + +final class KeyboardLockerWidgetTimelineTests: XCTestCase { + private let now = Date(timeIntervalSinceReferenceDate: 10000) + + func testLoaderPublishesAuthoritativeSnapshot() async { + let snapshot = LockStatusSnapshot( + capturedAt: now, + isLocked: true, + startedAt: now.addingTimeInterval(-10), + autoUnlockTargetDate: now.addingTimeInterval(50), + settings: .default + ) + let loader = KeyboardLockerWidgetSnapshotLoader { + snapshot + } + + let entry = await loader.entry(at: now) + + XCTAssertEqual(entry, KeyboardLockerWidgetEntry(date: now, state: .available(snapshot))) + } + + func testLoaderPublishesExplicitUnavailableState() async { + let loader = KeyboardLockerWidgetSnapshotLoader { + throw XPCClientError.serviceUnavailable + } + + let entry = await loader.entry(at: now) + + guard case let .unavailable(message) = entry.state else { + return XCTFail("Expected an unavailable entry.") + } + XCTAssertTrue(message.contains("The KeyboardLocker agent is not reachable.")) + XCTAssertTrue(message.contains("Open KeyboardLocker once")) + } + + func testRefreshReconcilesImmediatelyAfterEarlierAutoUnlockDeadline() { + let deadline = now.addingTimeInterval(20) + let snapshot = LockStatusSnapshot( + capturedAt: now, + isLocked: true, + startedAt: now.addingTimeInterval(-10), + autoUnlockTargetDate: deadline, + settings: .default + ) + let entry = KeyboardLockerWidgetEntry(date: now, state: .available(snapshot)) + let loader = KeyboardLockerWidgetSnapshotLoader { + snapshot + } + + XCTAssertEqual( + loader.nextRefreshDate(after: entry, now: now), + deadline.addingTimeInterval(1) + ) + } + + func testRefreshUsesRegularIntervalWithoutEarlierDeadline() { + let snapshot = LockStatusSnapshot( + capturedAt: now, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + let entry = KeyboardLockerWidgetEntry(date: now, state: .available(snapshot)) + let loader = KeyboardLockerWidgetSnapshotLoader { + snapshot + } + + XCTAssertEqual( + loader.nextRefreshDate(after: entry, now: now), + now.addingTimeInterval(KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval) + ) + } +} diff --git a/KeyboardLockerWidgets/Info.plist b/KeyboardLockerWidgets/Info.plist new file mode 100644 index 0000000..b515054 --- /dev/null +++ b/KeyboardLockerWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + KeyboardLocker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift new file mode 100644 index 0000000..033b2aa --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift @@ -0,0 +1,83 @@ +import Client +import SwiftUI +import WidgetKit + +struct KeyboardLockerStatusWidget: Widget { + static let kind = "io.lzhlovesjyq.keyboardlocker.status" + + var body: some WidgetConfiguration { + StaticConfiguration( + kind: Self.kind, + provider: KeyboardLockerTimelineProvider() + ) { entry in + KeyboardLockerWidgetEntryView(entry: entry) + } + .configurationDisplayName("Keyboard Lock Status") + .description("Shows the authoritative KeyboardLocker state and auto-unlock deadline.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +private struct KeyboardLockerWidgetEntryView: View { + let entry: KeyboardLockerWidgetEntry + + var body: some View { + content + .padding() + .modifier(KeyboardLockerWidgetBackground()) + } + + @ViewBuilder + private var content: some View { + switch entry.state { + case let .available(snapshot): + VStack(alignment: .leading, spacing: 8) { + Label("KeyboardLocker", systemImage: snapshot.isLocked ? "keyboard.fill" : "keyboard") + .font(.caption) + .foregroundStyle(.secondary) + + Text(snapshot.isLocked ? "Locked" : "Unlocked") + .font(.title2.weight(.semibold)) + + if snapshot.isLocked { + if let deadline = snapshot.autoUnlockTargetDate { + Label { + Text(deadline, style: .timer) + .monospacedDigit() + } icon: { + Image(systemName: "timer") + } + } else { + Label("Manual unlock", systemImage: "infinity") + } + + Label(snapshot.settings.unlockHotkey.displayString, systemImage: "command") + } else { + Label("Ready", systemImage: "checkmark.circle") + } + } + + case let .unavailable(message): + VStack(alignment: .leading, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Agent Unavailable") + .font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } +} + +private struct KeyboardLockerWidgetBackground: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content.containerBackground(.fill.tertiary, for: .widget) + } else { + content.background(.background) + } + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift new file mode 100644 index 0000000..8ac3346 --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift @@ -0,0 +1,127 @@ +import Client +import Foundation +import WidgetKit + +struct KeyboardLockerWidgetEntry: TimelineEntry, Equatable { + enum State: Equatable { + case available(LockStatusSnapshot) + case unavailable(message: String) + } + + let date: Date + let state: State + + static func placeholder(at date: Date) -> Self { + Self( + date: date, + state: .available( + LockStatusSnapshot( + capturedAt: date, + isLocked: true, + startedAt: date.addingTimeInterval(-5 * 60), + autoUnlockTargetDate: date.addingTimeInterval(60), + settings: .default + ) + ) + ) + } +} + +struct KeyboardLockerWidgetSnapshotLoader: Sendable { + typealias FetchSnapshot = @Sendable () async throws -> LockStatusSnapshot + + static let regularRefreshInterval: TimeInterval = 60 + + private let fetchSnapshot: FetchSnapshot + + init(fetchSnapshot: @escaping FetchSnapshot) { + self.fetchSnapshot = fetchSnapshot + } + + static var live: Self { + Self { + try await XPCClient.shared.lockStatusSnapshot() + } + } + + func entry(at date: Date) async -> KeyboardLockerWidgetEntry { + do { + return try await KeyboardLockerWidgetEntry( + date: date, + state: .available(fetchSnapshot()) + ) + } catch { + return KeyboardLockerWidgetEntry( + date: date, + state: .unavailable(message: Self.message(for: error)) + ) + } + } + + func nextRefreshDate( + after entry: KeyboardLockerWidgetEntry, + now: Date + ) -> Date { + let regularRefresh = now.addingTimeInterval(Self.regularRefreshInterval) + guard case let .available(snapshot) = entry.state, + snapshot.isLocked, + let deadline = snapshot.autoUnlockTargetDate + else { + return regularRefresh + } + + // Reconcile just after the Agent's deadline instead of presenting an expired countdown until + // the next regular refresh. WidgetKit remains free to coalesce the requested reload. + let deadlineRefresh = max( + now.addingTimeInterval(1), + deadline.addingTimeInterval(1) + ) + return min(regularRefresh, deadlineRefresh) + } + + private static func message(for error: Error) -> String { + let error = error as NSError + return [error.localizedDescription, error.localizedRecoverySuggestion] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + } +} + +struct KeyboardLockerTimelineProvider: TimelineProvider { + private let loader: KeyboardLockerWidgetSnapshotLoader + + init(loader: KeyboardLockerWidgetSnapshotLoader = .live) { + self.loader = loader + } + + func placeholder(in _: Context) -> KeyboardLockerWidgetEntry { + .placeholder(at: Date()) + } + + func getSnapshot( + in context: Context, + completion: @escaping @Sendable (KeyboardLockerWidgetEntry) -> Void + ) { + guard !context.isPreview else { + completion(.placeholder(at: Date())) + return + } + + Task { + await completion(loader.entry(at: Date())) + } + } + + func getTimeline( + in _: Context, + completion: @escaping @Sendable (Timeline) -> Void + ) { + Task { + let now = Date() + let entry = await loader.entry(at: now) + let refreshDate = loader.nextRefreshDate(after: entry, now: now) + completion(Timeline(entries: [entry], policy: .after(refreshDate))) + } + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift new file mode 100644 index 0000000..d74123b --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct KeyboardLockerWidgetsBundle: WidgetBundle { + var body: some Widget { + KeyboardLockerStatusWidget() + } +} diff --git a/docs/automation.md b/docs/automation.md index 2630161..2749a4f 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,6 +1,6 @@ # 自动化 -KeyboardLocker 的 Shortcuts、AppleScript 与 CLI 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 +KeyboardLocker 的 Shortcuts、AppleScript、CLI 与 Widget 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 所有入口共享以下语义: @@ -8,7 +8,7 @@ KeyboardLocker 的 Shortcuts、AppleScript 与 CLI 都是同一个 Agent capabil - `status` 每次都从 Agent 查询权威状态;wrapper 不维护本地副本。 - lock 是全局状态,不属于发起它的 Shortcut、AppleScript 或 shell process。任意入口的 `unlock` 都会解开同一个全局锁。 - 当前没有暴露 client-side `toggle`。需要确定结果时,显式选择 `lock` 或 `unlock`。 -- Agent 不可达、权限不足或结果无法确认时,wrapper 会明确失败,不会把失败猜成 unlocked。 +- Agent 不可达、权限不足或结果无法确认时,action wrapper 会明确失败,Widget 会显示 unavailable;任何入口都不会把失败猜成 unlocked。 ## Shortcuts @@ -20,6 +20,12 @@ KeyboardLocker 提供三个可组合 action: `Get Keyboard Lock Status` 返回 typed Boolean,可直接接入 Shortcuts 的条件分支。当前版本没有声明 `AppShortcutsProvider`;这些 action 从 Shortcuts 的 action library 中选择,不会作为 promoted shortcut 自动出现,也没有预注册的 invocation phrase。 +## Widget + +`Keyboard Lock Status` Widget 支持 small 和 medium family。每次生成 timeline 时,extension 都通过 XPC 读取 Agent 的 `LockStatusSnapshot`,并显示 locked/unlocked、auto-unlock countdown 或 manual unlock,以及当前解锁热键。它不使用 App Group、`UserDefaults` 或自己的状态缓存;Agent 不可达时会明确显示 `Agent Unavailable`。 + +Widget 会请求每分钟校准一次,若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。该时间只是 WidgetKit timeline policy;系统可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本会由系统持续渲染,但从其他 wrapper 发起的手动 lock/unlock 可能要到下一次获准刷新才显示。 + ## AppleScript 应用 scripting dictionary 提供三个 command: diff --git a/docs/development.md b/docs/development.md index 940a610..87fb377 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,6 +52,11 @@ - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 - `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。 +**WidgetKit extension**(`KeyboardLockerWidgets/`)—— sandboxed、按需运行的只读状态 wrapper,只调用 Client +- `KeyboardLockerWidgetTimeline.swift`:每次 timeline execution 经 `XPCClient.lockStatusSnapshot()` 读取 Agent 的权威原子快照。loader 把 transport failure 建模为显式 unavailable entry,并请求 regular refresh 或更早的 auto-unlock deadline reconciliation;不订阅长命通知、不维护第二份状态。 +- `KeyboardLockerStatusWidget.swift`:small/medium 状态 presentation,显示 locked/unlocked、deadline、解锁热键与 Agent unavailable。WidgetKit 可以合并 timeline policy,因此该 UI 不承诺实时刷新。 +- `KeyboardLockerWidgets.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent 仍以同 Team + 精确 extension identifier 的 listener requirement 独立认证调用方。 + ## 常见任务 ### 新增一个设置项 @@ -89,7 +94,7 @@ `klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 -Shortcuts、AppleScript 与 CLI 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 +Shortcuts、AppleScript、CLI 与 Widget 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 ### Homebrew Cask 发布计划(尚未实现) diff --git a/docs/xpc.md b/docs/xpc.md index 897f0cf..5de1e07 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -40,6 +40,7 @@ Core KeyboardLocker -> Client -> Common klock -> Client -> Common +KeyboardLockerWidgets -> Client -> Common KeyboardLockerAgent -> Service -> Common ``` @@ -55,7 +56,7 @@ Xcode 会按 package 名把这些 dependency 都显示在 `Core` 下面,因此 `Client/Exports.swift` 和 `Service/Exports.swift` 使用 `@_exported import Common`,所以 App 只写 `import Client`、Agent 只写 `import Service`,也能看到 `Common` 中的类型。这只是 import 便利,不会让 App 获得 `Service`,也不会让两边共享内存。 -### 运行时:真正存在的是三个独立进程 +### 运行时:各 wrapper 与 Agent 是独立进程 ```mermaid flowchart LR @@ -67,8 +68,13 @@ flowchart LR CLI["Command handler"] --> CLIClient["XPCClient"] end + subgraph WidgetProcess["WidgetKit extension process"] + Timeline["Timeline provider"] --> WidgetClient["XPCClient"] + end + AppClient -->|"NSXPCConnection"| Mach["Mach service"] CLIClient -->|"NSXPCConnection"| Mach + WidgetClient -->|"NSXPCConnection"| Mach subgraph AgentProcess["KeyboardLockerAgent process"] Listener["NSXPCListener"] --> AgentService["AgentService"] AgentService --> Engine["LockEngine.shared"] @@ -82,7 +88,7 @@ flowchart LR `Core` 不会作为第四个进程出现。它的代码分别被链接进上图中的 executable: -- App 和 CLI 各自包含一份 `Client + Common` 代码。 +- App、CLI 与按需启动的 WidgetKit extension 各自包含一份 `Client + Common` 代码。 - Agent 包含一份 `Service + Common` 代码。 - 只有 Agent 的 executable 包含 `LockEngine`,因此 App/CLI 不可能绕过 XPC 直接操作 event tap。 From 108e8b2ad29ef947060e71aae021c8766e26ff9d Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 16:52:33 +0800 Subject: [PATCH 046/106] feat(control): add desired-state lock control --- CLAUDE.md | 2 +- KeyboardLocker.xcodeproj/project.pbxproj | 1 + .../KeyboardLockerControlModelTests.swift | 107 ++++++++++++++++++ .../KeyboardLockerControl.swift | 89 +++++++++++++++ .../KeyboardLockerControlModel.swift | 44 +++++++ .../KeyboardLockerStatusWidget.swift | 2 +- .../KeyboardLockerWidgetsBundle.swift | 4 + docs/automation.md | 8 +- docs/development.md | 6 +- 9 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift create mode 100644 KeyboardLockerWidgets/KeyboardLockerControl.swift create mode 100644 KeyboardLockerWidgets/KeyboardLockerControlModel.swift diff --git a/CLAUDE.md b/CLAUDE.md index 36e079f..9e9e4e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `… **Before adding or changing any feature (App, CLI, Shortcuts, AppleScript, Widgets, …), read [docs/architecture.md](docs/architecture.md).** It defines the non-negotiable contract: the Agent is the single executor, all other surfaces are thin wrappers, the lock is one global state. When a change conflicts with it, the contract wins. -For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). For Shortcuts, AppleScript, CLI, and Widget usage, see [docs/automation.md](docs/automation.md). +For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). For Shortcuts, AppleScript, CLI, Widget, and Control usage, see [docs/automation.md](docs/automation.md). ## Project-Specific Rules diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index d95bc11..e5aebe5 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -138,6 +138,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, + KeyboardLockerControl.swift, KeyboardLockerStatusWidget.swift, KeyboardLockerWidgets.entitlements, KeyboardLockerWidgetsBundle.swift, diff --git a/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift b/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift new file mode 100644 index 0000000..c1e27cb --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift @@ -0,0 +1,107 @@ +import Foundation +import XCTest + +final class KeyboardLockerControlModelTests: XCTestCase { + func testValueLoaderReturnsAuthoritativeValue() async throws { + let loader = KeyboardLockerControlValueLoader { + true + } + + let value = try await loader.currentValue() + + XCTAssertTrue(value) + } + + func testValueLoaderPropagatesFailure() async { + let loader = KeyboardLockerControlValueLoader { + throw TestError.expected + } + + do { + _ = try await loader.currentValue() + XCTFail("Expected the loader to propagate the error.") + } catch { + XCTAssertEqual(error as? TestError, .expected) + } + } + + func testSetLockedCallsOnlyLockThenReload() async throws { + let recorder = CallRecorder() + let action = makeAction(recorder: recorder) + + try await action.setLocked(true) + + let calls = await recorder.calls + XCTAssertEqual(calls, [.lock, .reload]) + } + + func testSetUnlockedCallsOnlyUnlockThenReload() async throws { + let recorder = CallRecorder() + let action = makeAction(recorder: recorder) + + try await action.setLocked(false) + + let calls = await recorder.calls + XCTAssertEqual(calls, [.unlock, .reload]) + } + + func testFailedActionDoesNotReload() async { + let recorder = CallRecorder() + let action = KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + throw TestError.expected + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + + do { + try await action.setLocked(true) + XCTFail("Expected the action to propagate the error.") + } catch { + XCTAssertEqual(error as? TestError, .expected) + } + + let calls = await recorder.calls + XCTAssertEqual(calls, [.lock]) + } + + private func makeAction(recorder: CallRecorder) -> KeyboardLockerControlAction { + KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + } +} + +private extension KeyboardLockerControlModelTests { + enum TestError: Error { + case expected + } + + enum Call: Equatable { + case lock + case unlock + case reload + } + + actor CallRecorder { + private(set) var calls: [Call] = [] + + func record(_ call: Call) { + calls.append(call) + } + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerControl.swift b/KeyboardLockerWidgets/KeyboardLockerControl.swift new file mode 100644 index 0000000..9fe0ebf --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerControl.swift @@ -0,0 +1,89 @@ +import AppIntents +import Client +import SwiftUI +import WidgetKit + +@available(macOS 26.0, *) +struct KeyboardLockerControl: ControlWidget { + nonisolated static let kind = "io.lzhlovesjyq.keyboardlocker.control" + + var body: some ControlWidgetConfiguration { + StaticControlConfiguration( + kind: Self.kind, + provider: KeyboardLockerControlValueProvider() + ) { isLocked in + ControlWidgetToggle( + "Keyboard Lock", + isOn: isLocked, + action: SetKeyboardLockControlIntent() + ) { isLocked in + Label( + isLocked ? "Locked" : "Unlocked", + systemImage: isLocked ? "keyboard.fill" : "keyboard" + ) + } + } + .displayName("Keyboard Lock") + .description("Locks or unlocks the keyboard through the KeyboardLocker agent.") + } +} + +@available(macOS 26.0, *) +private struct KeyboardLockerControlValueProvider: ControlValueProvider { + let previewValue = false + + private let loader: KeyboardLockerControlValueLoader + + init(loader: KeyboardLockerControlValueLoader = .live) { + self.loader = loader + } + + func currentValue() async throws -> Bool { + try await loader.currentValue() + } +} + +@available(macOS 26.0, *) +struct SetKeyboardLockControlIntent: SetValueIntent { + static let title: LocalizedStringResource = "Set Keyboard Lock" + static let description = IntentDescription( + "Sets the global keyboard lock to the requested state." + ) + + @Parameter(title: "Locked") + var value: Bool + + init() {} + + func perform() async throws -> some IntentResult { + try await KeyboardLockerControlAction.live.setLocked(value) + return .result() + } +} + +@available(macOS 26.0, *) +private extension KeyboardLockerControlValueLoader { + static var live: Self { + Self { + try await XPCClient.shared.status() + } + } +} + +@available(macOS 26.0, *) +private extension KeyboardLockerControlAction { + static var live: Self { + Self( + lock: { + try await XPCClient.shared.lock() + }, + unlock: { + try await XPCClient.shared.unlock() + }, + reload: { + WidgetCenter.shared.reloadTimelines(ofKind: KeyboardLockerStatusWidget.kind) + ControlCenter.shared.reloadControls(ofKind: KeyboardLockerControl.kind) + } + ) + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerControlModel.swift b/KeyboardLockerWidgets/KeyboardLockerControlModel.swift new file mode 100644 index 0000000..f229e99 --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerControlModel.swift @@ -0,0 +1,44 @@ +import Foundation + +struct KeyboardLockerControlValueLoader: Sendable { + typealias FetchValue = @Sendable () async throws -> Bool + + private let fetchValue: FetchValue + + init(fetchValue: @escaping FetchValue) { + self.fetchValue = fetchValue + } + + func currentValue() async throws -> Bool { + try await fetchValue() + } +} + +struct KeyboardLockerControlAction: Sendable { + typealias Operation = @Sendable () async throws -> Void + typealias Reload = @Sendable () async -> Void + + private let lock: Operation + private let unlock: Operation + private let reload: Reload + + init( + lock: @escaping Operation, + unlock: @escaping Operation, + reload: @escaping Reload + ) { + self.lock = lock + self.unlock = unlock + self.reload = reload + } + + func setLocked(_ isLocked: Bool) async throws { + if isLocked { + try await lock() + } else { + try await unlock() + } + + await reload() + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift index 033b2aa..603f2e6 100644 --- a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift +++ b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift @@ -3,7 +3,7 @@ import SwiftUI import WidgetKit struct KeyboardLockerStatusWidget: Widget { - static let kind = "io.lzhlovesjyq.keyboardlocker.status" + nonisolated static let kind = "io.lzhlovesjyq.keyboardlocker.status" var body: some WidgetConfiguration { StaticConfiguration( diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift index d74123b..59a27be 100644 --- a/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift @@ -5,5 +5,9 @@ import WidgetKit struct KeyboardLockerWidgetsBundle: WidgetBundle { var body: some Widget { KeyboardLockerStatusWidget() + + if #available(macOS 26.0, *) { + KeyboardLockerControl() + } } } diff --git a/docs/automation.md b/docs/automation.md index 2749a4f..b83daa3 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,6 +1,6 @@ # 自动化 -KeyboardLocker 的 Shortcuts、AppleScript、CLI 与 Widget 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 +KeyboardLocker 的 Shortcuts、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 所有入口共享以下语义: @@ -26,6 +26,12 @@ KeyboardLocker 提供三个可组合 action: Widget 会请求每分钟校准一次,若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。该时间只是 WidgetKit timeline policy;系统可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本会由系统持续渲染,但从其他 wrapper 发起的手动 lock/unlock 可能要到下一次获准刷新才显示。 +## Control + +macOS 26 及以上提供 `Keyboard Lock` Control。Control 的 value provider 每次经 XPC 查询 Agent 的权威 Boolean;用户操作产生的是明确的 desired state:on 调用 `lock`,off 调用 `unlock`。它不会先读取旧值再做 client-side toggle,因此并发状态变化不会把一次操作翻译成错误方向。 + +Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` Widget timeline 和 Control value。调用失败会由 App Intent 明确返回错误,也不会先刷新出未经确认的 UI 状态。Control API 在当前 macOS SDK 中从 macOS 26 起可用;macOS 13–15 仍只注册状态 Widget。 + ## AppleScript 应用 scripting dictionary 提供三个 command: diff --git a/docs/development.md b/docs/development.md index 87fb377..36ca5c8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,7 +52,9 @@ - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 - `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。 -**WidgetKit extension**(`KeyboardLockerWidgets/`)—— sandboxed、按需运行的只读状态 wrapper,只调用 Client +**WidgetKit extension**(`KeyboardLockerWidgets/`)—— sandboxed、按需运行的 Widget/Control wrapper,只调用 Client +- `KeyboardLockerControlModel.swift`:Control 的可测试纯协调模型。value loader 直接返回 Agent 查询结果;desired-state action 只在 XPC lock/unlock 成功后请求 reload,不做 client-side toggle。 +- `KeyboardLockerControl.swift`:macOS 26+ `Keyboard Lock` Control、XPC live adapters 与 `SetValueIntent`。on/off 分别映射到幂等 `lock`/`unlock`,成功后刷新 Control value 和状态 Widget timeline。 - `KeyboardLockerWidgetTimeline.swift`:每次 timeline execution 经 `XPCClient.lockStatusSnapshot()` 读取 Agent 的权威原子快照。loader 把 transport failure 建模为显式 unavailable entry,并请求 regular refresh 或更早的 auto-unlock deadline reconciliation;不订阅长命通知、不维护第二份状态。 - `KeyboardLockerStatusWidget.swift`:small/medium 状态 presentation,显示 locked/unlocked、deadline、解锁热键与 Agent unavailable。WidgetKit 可以合并 timeline policy,因此该 UI 不承诺实时刷新。 - `KeyboardLockerWidgets.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent 仍以同 Team + 精确 extension identifier 的 listener requirement 独立认证调用方。 @@ -94,7 +96,7 @@ `klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 -Shortcuts、AppleScript、CLI 与 Widget 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 +Shortcuts、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 ### Homebrew Cask 发布计划(尚未实现) From 9738109e6aab80de4890683c7c975db9673d5a66 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 17:18:54 +0800 Subject: [PATCH 047/106] feat(klock): add JSON status output --- KeyboardLocker.xcodeproj/project.pbxproj | 11 +++++++++ .../KlockStatusOutputTests.swift | 24 +++++++++++++++++++ docs/automation.md | 3 ++- klock/KlockStatusOutput.swift | 16 +++++++++++++ klock/main.swift | 19 ++++++++++----- 5 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 KeyboardLockerModelTests/KlockStatusOutputTests.swift create mode 100644 klock/KlockStatusOutput.swift diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index e5aebe5..334ad82 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -145,6 +145,13 @@ ); target = A10000000000000000000003 /* KeyboardLockerModelTests */; }; + C30000000000000000000001 /* Exceptions for "klock" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + main.swift, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -192,6 +199,9 @@ }; F21304B52ECDC26400735580 /* klock */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C30000000000000000000001 /* Exceptions for "klock" folder in "KeyboardLockerModelTests" target */, + ); path = klock; sourceTree = ""; }; @@ -285,6 +295,7 @@ A10000000000000000000002 /* KeyboardLockerModelTests */, F213042A2ECDAD3500735580 /* KeyboardLocker */, B20000000000000000000002 /* KeyboardLockerWidgets */, + F21304B52ECDC26400735580 /* klock */, ); name = KeyboardLockerModelTests; packageProductDependencies = ( diff --git a/KeyboardLockerModelTests/KlockStatusOutputTests.swift b/KeyboardLockerModelTests/KlockStatusOutputTests.swift new file mode 100644 index 0000000..1e7c9ea --- /dev/null +++ b/KeyboardLockerModelTests/KlockStatusOutputTests.swift @@ -0,0 +1,24 @@ +import Foundation +import XCTest + +final class KlockStatusOutputTests: XCTestCase { + func testHumanReadableOutputPreservesExistingContract() { + XCTAssertEqual(KlockStatusOutput.humanReadable.render(isLocked: true), "Locked") + XCTAssertEqual(KlockStatusOutput.humanReadable.render(isLocked: false), "Unlocked") + } + + func testJSONOutputUsesStableBooleanField() throws { + let locked = KlockStatusOutput.json.render(isLocked: true) + let unlocked = KlockStatusOutput.json.render(isLocked: false) + + XCTAssertEqual(locked, #"{"locked":true}"#) + XCTAssertEqual(unlocked, #"{"locked":false}"#) + XCTAssertEqual(try decodedLockedValue(from: locked), true) + XCTAssertEqual(try decodedLockedValue(from: unlocked), false) + } + + private func decodedLockedValue(from output: String) throws -> Bool? { + let object = try JSONSerialization.jsonObject(with: Data(output.utf8)) + return (object as? [String: Bool])?["locked"] + } +} diff --git a/docs/automation.md b/docs/automation.md index b83daa3..01a4154 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -73,9 +73,10 @@ klock lock --no-wait ```bash klock status +klock status --json klock unlock ``` -`status` 当前输出供人阅读的 `Locked` 或 `Unlocked`。在增加明确的 machine-readable output contract 前,脚本不要把这段文本当成稳定 JSON/API。所有命令在调用成功时返回 exit code `0`,参数或 Agent 调用失败时返回 `1`。 +`status` 默认输出供人阅读的 `Locked` 或 `Unlocked`。自动化使用 `status --json`,其稳定 contract 是单行 `{"locked":true}` 或 `{"locked":false}`。两种状态都表示查询成功并返回 exit code `0`;参数或 Agent 调用失败返回 `1`,因此脚本不需要把 unlocked 与 transport failure 混在同一个 exit code 中。 不要把 `lock --no-wait` / `unlock` 当成 acquire/release pair:若调用 `lock --no-wait` 时全局状态本来已经 locked,后续无条件执行 `unlock` 会解除其他 wrapper 建立的锁。需要这种 ownership 语义时,应先在 Agent contract 中设计明确的 lease/session capability,而不是在 shell 中模拟。 diff --git a/klock/KlockStatusOutput.swift b/klock/KlockStatusOutput.swift new file mode 100644 index 0000000..9c95659 --- /dev/null +++ b/klock/KlockStatusOutput.swift @@ -0,0 +1,16 @@ +import Foundation + +enum KlockStatusOutput: Equatable { + case humanReadable + case json + + func render(isLocked: Bool) -> String { + switch self { + case .humanReadable: + isLocked ? "Locked" : "Unlocked" + + case .json: + "{\"locked\":\(isLocked ? "true" : "false")}" + } + } +} diff --git a/klock/main.swift b/klock/main.swift index 9767094..7552611 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -54,10 +54,16 @@ enum KlockCLI { await executeUnlock() case "status": - guard arguments.count == 1 else { - reportUnexpectedArguments(Array(arguments.dropFirst())) + switch Array(arguments.dropFirst()) { + case []: + await executeStatus(output: .humanReadable) + + case ["--json"]: + await executeStatus(output: .json) + + case let unexpectedArguments: + reportUnexpectedArguments(unexpectedArguments) } - await executeStatus() default: printError("Unknown command: \(command)") @@ -132,10 +138,10 @@ enum KlockCLI { } } - private static func executeStatus() async { + private static func executeStatus(output: KlockStatusOutput) async { do { let isLocked = try await XPCClient.shared.status() - print(isLocked ? "Locked" : "Unlocked") + print(output.render(isLocked: isLocked)) exit(ExitCode.success) } catch { reportFailure(error) @@ -180,12 +186,13 @@ enum KlockCLI { COMMANDS: lock [--no-wait] Lock the keyboard; by default, wait until it is unlocked. unlock Unlock the keyboard. - status Print the current lock state. + status [--json] Print the current lock state. help Show this help message. version Show the klock version. OPTIONS: --no-wait Return after lock is confirmed; do not enable Ctrl+C unlock. + --json Emit a stable JSON object for status automation. -h, --help Show this help message. -v, --version Show the klock version. """ From b2c13ce76da7bdc485a6a6679cb9e1338596ba02 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 17:26:23 +0800 Subject: [PATCH 048/106] feat(core): add focus-owned lock generations --- Core/Sources/Client/XPCClient.swift | 45 +++++++++- Core/Sources/Common/ServiceDescriptor.swift | 4 +- Core/Sources/Common/Shared.swift | 8 ++ Core/Sources/Service/LockEngine.swift | 83 ++++++++++++++++++- .../ServiceCompatibilityTests.swift | 21 +++++ .../ServiceTests/LockRuntimeStateTests.swift | 61 ++++++++++++++ KeyboardLockerAgent/AgentService.swift | 34 ++++++-- docs/architecture.md | 3 +- docs/xpc.md | 6 +- 9 files changed, 248 insertions(+), 17 deletions(-) diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 4a52cd0..9fcafaa 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -96,11 +96,23 @@ public final class XPCClient: @unchecked Sendable { public func lock() async throws { do { - try await withProxy { service, resume in - service.lockKeyboard { resume($0) } - } + try await lockOnce() } catch XPCClientError.timedOut { - try await confirmTimedOutMutation(expectedIsLocked: true) + // A general desired-lock can take persistence over from a Focus-created lock even when the + // physical state is already locked. Status cannot prove that this semantic mutation ran, + // so retry the idempotent request and require a reply from a fresh connection. + resetConnection() + do { + try await lockOnce() + } catch XPCClientError.timedOut { + throw XPCClientError.operationOutcomeUnknown + } + } + } + + private func lockOnce() async throws { + try await withProxy { service, resume in + service.lockKeyboard { resume($0) } } } @@ -128,6 +140,31 @@ public final class XPCClient: @unchecked Sendable { } } + /// Applies the system Focus Filter state through an ownership-aware Agent operation. + public func setFocusFilterLockEnabled(_ enabled: Bool) async throws { + do { + try await setFocusFilterLockEnabledOnce(enabled) + } catch XPCClientError.timedOut { + // Both enable and disable are idempotent, but status alone cannot reveal whether Focus owns + // the current generation. Re-negotiate and require one confirmed reply. + resetConnection() + do { + try await setFocusFilterLockEnabledOnce(enabled) + } catch XPCClientError.timedOut { + throw XPCClientError.operationOutcomeUnknown + } + } + } + + private func setFocusFilterLockEnabledOnce(_ enabled: Bool) async throws { + let connection = try await negotiatedConnection( + requiring: [.focusFilterLock] + ) + try await withProxy(using: connection) { service, resume in + service.setFocusFilterLockEnabled(enabled, reply: resume) + } + } + public func unlock() async throws { do { try await withProxy { service, resume in diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift index 522a3db..f8dd893 100644 --- a/Core/Sources/Common/ServiceDescriptor.swift +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -44,6 +44,7 @@ public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, public static let committedReplacementDrain = Self( rawValue: "committed-replacement-drain" ) + public static let focusFilterLock = Self(rawValue: "focus-filter-lock") public static let interactiveLock = Self(rawValue: "interactive-lock") public static let lockControl = Self(rawValue: "lock-control") public static let lockStatusSnapshot = Self(rawValue: "lock-status-snapshot") @@ -76,7 +77,7 @@ public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sen /// Current bootstrap contract advertised by the bundled Agent. public enum ServiceContract { - public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 4) + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 5) public static let requiredCapabilities: Set = [ .accessibilityPrompt, @@ -84,6 +85,7 @@ public enum ServiceContract { .committedReplacementDrain, .currentSettings, .currentSettingsWithError, + .focusFilterLock, .interactiveLock, .lockControl, .lockStatusSnapshot, diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 8cbac6b..7cbd9de 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -68,6 +68,14 @@ public protocol KeyboardLockerServiceProtocol { reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void ) + /// Applies the Focus Filter's desired state. Disabling releases only a lock generation that + /// the Focus Filter itself created; it never unlocks a pre-existing or subsequently claimed + /// global lock. + func setFocusFilterLockEnabled( + _ enabled: Bool, + reply: @escaping (Error?) -> Void + ) + func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 8a94b48..ae9e899 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -40,9 +40,13 @@ struct LockRuntimeState: Equatable { private(set) var activeSettings: KeyboardLockerSettings = .default private(set) var allowsControlCUnlock = false private(set) var autoUnlockTargetDate: Date? + private(set) var focusOwnedLockGeneration: UInt64? private(set) var isLocked = false + private(set) var lockGeneration: UInt64? private(set) var startedAt: Date? + private var nextLockGeneration: UInt64 = 0 + mutating func begin( settings: KeyboardLockerSettings, allowsControlCUnlock: Bool, @@ -55,11 +59,24 @@ struct LockRuntimeState: Equatable { activeSettings = settings self.allowsControlCUnlock = allowsControlCUnlock autoUnlockTargetDate = nil + focusOwnedLockGeneration = nil isLocked = true + nextLockGeneration &+= 1 + lockGeneration = nextLockGeneration startedAt = date return .acquired } + mutating func markCurrentLockAsFocusOwned() { + focusOwnedLockGeneration = lockGeneration + } + + /// Converts a Focus-created lock into an ordinary global desired lock without changing any + /// physical runtime state. A later Focus deactivation must no longer release it. + mutating func takeOverFocusOwnedLock() { + focusOwnedLockGeneration = nil + } + mutating func updateSettings(_ settings: KeyboardLockerSettings) { activeSettings = settings } @@ -71,7 +88,9 @@ struct LockRuntimeState: Equatable { mutating func end() { allowsControlCUnlock = false autoUnlockTargetDate = nil + focusOwnedLockGeneration = nil isLocked = false + lockGeneration = nil startedAt = nil } @@ -244,6 +263,11 @@ public final class LockEngine { private var autoUnlockTimer: DispatchSourceTimer? private var runtimeState = LockRuntimeState() + private enum LockRequestSource { + case focusFilter + case general + } + public var isLocked: Bool { runtimeState.isLocked } @@ -268,9 +292,42 @@ public final class LockEngine { settings: KeyboardLockerSettings = .default, allowsControlCUnlock: Bool = false ) throws -> LockRequestOutcome { - // A duplicate request is a strict no-op. In particular, it must not extend the authoritative - // auto-unlock deadline merely because another wrapper repeated the same global intent. + try acquireLock( + settings: settings, + allowsControlCUnlock: allowsControlCUnlock, + source: .general + ) + } + + /// Applies the Focus Filter's desired state without giving Focus ownership of a pre-existing + /// global lock. Deactivation releases only the exact lock generation created by Focus. + public func setFocusFilterLockEnabled( + _ enabled: Bool, + settings: KeyboardLockerSettings = .default + ) throws { + if enabled { + _ = try acquireLock( + settings: settings, + allowsControlCUnlock: false, + source: .focusFilter + ) + } else if let generation = runtimeState.focusOwnedLockGeneration { + unlock(ifLockGeneration: generation) + } + } + + private func acquireLock( + settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool, + source: LockRequestSource + ) throws -> LockRequestOutcome { + // A duplicate never mutates the physical lock, its settings, gestures, start time, or + // deadline. An explicit non-Focus desired-lock does take over persistence from Focus so a + // later Focus deactivation cannot undo the user's newer intent. if runtimeState.isLocked { + if source == .general { + runtimeState.takeOverFocusOwnedLock() + } return .alreadyLocked } @@ -290,6 +347,9 @@ public final class LockEngine { teardownEventTap() return outcome } + if source == .focusFilter { + runtimeState.markCurrentLockAsFocusOwned() + } markLocked() return outcome } @@ -379,10 +439,14 @@ public final class LockEngine { runtimeState.setAutoUnlockTargetDate(schedule.deadline) + guard let lockGeneration = runtimeState.lockGeneration else { + return + } + let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + schedule.delay) timer.setEventHandler { [weak self] in - self?.unlock() + self?.unlock(ifLockGeneration: lockGeneration) } timer.resume() autoUnlockTimer = timer @@ -404,6 +468,13 @@ public final class LockEngine { resetLockState() } + private func unlock(ifLockGeneration generation: UInt64) { + guard runtimeState.lockGeneration == generation else { + return + } + unlock() + } + private func teardownEventTap() { let tap = eventTap if let tap { @@ -467,8 +538,12 @@ public final class LockEngine { } if shouldTriggerUnlock(for: type, event: event) { + let lockGeneration = runtimeState.lockGeneration DispatchQueue.main.async { [weak self] in - self?.unlock() + guard let lockGeneration else { + return + } + self?.unlock(ifLockGeneration: lockGeneration) } } diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift index e7e2621..e088223 100644 --- a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -111,6 +111,13 @@ final class ServiceCompatibilityTests: XCTestCase { XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 4) } + func testCurrentContractRequiresFocusFilterLockSelector() { + XCTAssertTrue( + ServiceContract.requiredCapabilities.contains(.focusFilterLock) + ) + XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 5) + } + func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( @@ -167,6 +174,20 @@ final class ServiceCompatibilityTests: XCTestCase { ) } + func testAgentWithoutFocusFilterLockIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .focusFilterLock, + ]) + ) + + XCTAssertEqual( + descriptor.compatibilityIssues(against: requirements), + [.missingCapabilities([.focusFilterLock])] + ) + } + func testBuildMismatchRequiresReplacement() { let requirements = makeRequirements(agentBuild: "42") let descriptor = makeDescriptor(agentBuild: "41") diff --git a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift index f8cab49..b826869 100644 --- a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift +++ b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift @@ -99,6 +99,67 @@ final class LockRuntimeStateTests: XCTestCase { XCTAssertEqual(state.startedAt, secondStart) } + func testManualDesiredLockTakesOverFocusOwnershipWithoutChangingRuntimeState() throws { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + + XCTAssertEqual( + state.begin( + settings: settings, + allowsControlCUnlock: false, + at: startedAt + ), + .acquired + ) + state.markCurrentLockAsFocusOwned() + state.setAutoUnlockTargetDate(deadline) + let generation = try XCTUnwrap(state.lockGeneration) + + state.takeOverFocusOwnedLock() + + XCTAssertNil(state.focusOwnedLockGeneration) + XCTAssertEqual(state.lockGeneration, generation) + XCTAssertEqual(state.activeSettings, settings) + XCTAssertFalse(state.allowsControlCUnlock) + XCTAssertEqual(state.startedAt, startedAt) + XCTAssertEqual(state.autoUnlockTargetDate, deadline) + XCTAssertTrue(state.isLocked) + } + + func testEndingFocusOwnedLockClearsOwnershipAndNextLockUsesNewGeneration() throws { + let firstStart = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: firstStart + ) + state.markCurrentLockAsFocusOwned() + let firstGeneration = try XCTUnwrap(state.lockGeneration) + + XCTAssertEqual(state.focusOwnedLockGeneration, firstGeneration) + + state.end() + + XCTAssertNil(state.focusOwnedLockGeneration) + XCTAssertNil(state.lockGeneration) + + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: firstStart.addingTimeInterval(1) + ) + + XCTAssertNotEqual(state.lockGeneration, firstGeneration) + XCTAssertNil(state.focusOwnedLockGeneration) + } + func testSnapshotCapturesOneCoherentRuntimeState() { let settings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index ed487f1..252a37b 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -78,6 +78,26 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + func setFocusFilterLockEnabled( + _ enabled: Bool, + reply: @escaping (Error?) -> Void + ) { + executeOnMainActor { + do { + if enabled { + try self.ensureAcceptingLockRequests() + } + try LockEngine.shared.setFocusFilterLockEnabled( + enabled, + settings: self.settings + ) + reply(nil) + } catch { + reply(error) + } + } + } + func unlockKeyboard(reply: @escaping (Error?) -> Void) { executeOnMainActor { LockEngine.shared.unlock() @@ -270,17 +290,21 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { @MainActor private func performLock( allowsControlCUnlock: Bool ) throws -> LockRequestOutcome { + try ensureAcceptingLockRequests() + + return try LockEngine.shared.lock( + settings: settings, + allowsControlCUnlock: allowsControlCUnlock + ) + } + + @MainActor private func ensureAcceptingLockRequests() throws { guard !replacement.isPending else { throw Self.replacementError( code: 1, description: "The agent is preparing to be replaced and is not accepting new lock requests." ) } - - return try LockEngine.shared.lock( - settings: settings, - allowsControlCUnlock: allowsControlCUnlock - ) } private static func makeServiceDescriptor(bundle: Bundle = .main) throws -> ServiceDescriptor { diff --git a/docs/architecture.md b/docs/architecture.md index fc424bb..fb5e44b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,8 +56,9 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 任何 wrapper 都可以锁;任何 wrapper 都可以解锁。CLI 发起的锁,App 可以解开,反之亦然。 - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 -- `lock` 是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改当前设置、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。 +- `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 - interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。 +- Focus Filter 使用 Agent 内部的 lock generation fence,而不是通用 client lease:Focus 只在自己完成 `unlocked → locked` 时记录该代锁;Focus 关闭只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领,自动解锁、热键、显式 unlock 或 event-tap failure 都会清除 generation marker。这个 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 - `status()` 保留为兼容旧 wrapper 的最小布尔查询;需要呈现锁定时长、auto-unlock deadline 或 active settings 的 wrapper 使用 capability-gated `lockStatusSnapshot()`。snapshot 传输权威时间点,不传会立即过期的 duration/countdown counter。 - 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 diff --git a/docs/xpc.md b/docs/xpc.md index 5de1e07..a396d74 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -509,7 +509,7 @@ sequenceDiagram 6. `XPCServerConnection` 为 Agent-side connection 设置 protocol,并把同一个 `AgentService` 实例设置为 `exportedObject`。 7. 对 remote proxy 的 legacy `lockKeyboard(reply:)` 或 additive `lockKeyboardInteractively(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 8. `AgentService` 把领域操作切到 `MainActor`,Agent 可变状态、event tap 和 CFRunLoop 生命周期都在这里维护。 -9. `LockEngine.shared.lock(settings:allowsControlCUnlock:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap;strict duplicate 在创建资源前直接返回 already locked。 +9. `LockEngine.shared.lock(settings:allowsControlCUnlock:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap;physical duplicate 在创建资源前直接返回 already locked,且只可能清除 Focus 的内部 release marker。 10. reply 通过 XPC 返回,`XPCClient` 把 callback bridge 成 Swift async/throwing API。 `AgentService` 是 executable target 中的 adapter:它把 wire protocol 翻译成 `Service` 模块里的领域调用。`Service` product 本身不会启动进程;真正创建 listener、保持 RunLoop 的是 `KeyboardLockerAgent/main.swift`。 @@ -521,6 +521,7 @@ sequenceDiagram | `serviceDescriptor` | `serviceDescriptor()` | `AgentService` | bootstrap query;返回 protocol、capability、bundled metadata 和 process instance ID | | `lockKeyboard` | `lock()` | `LockEngine` | 严格幂等地进入全局 locked 状态;已锁时直接成功且不修改设置、锁定起点或 auto-unlock deadline | | `lockKeyboardInteractively` | `lockInteractively()` | `LockEngine` | 原子返回本次请求是否创建全局锁;仅 acquired 的新锁临时接受 `Ctrl+C` 作为额外解锁手势 | +| `setFocusFilterLockEnabled` | `setFocusFilterLockEnabled(_:)` | `LockEngine` | capability-gated desired state;启用只认领自己新建的 lock generation,停用只条件性释放仍属 Focus 的同一代 | | `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | | `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | | `lockStatusSnapshot` | `lockStatusSnapshot()` | `LockEngine` / `AgentService` | capability-gated query;在一个 Agent execution turn 中读取布尔状态、capture time、锁定起点、auto-unlock deadline 与 active settings,并以有大小上限的 format-1 JSON payload 返回 | @@ -562,8 +563,9 @@ Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使 对 mutation 而言,timeout 不等于“Agent 没执行”。请求可能已经到达 Agent,只是 reply 没有及时返回。因此: -- `lock()` timeout 后通过一条新 connection 查询 `status()`;如果已经 locked,就按成功处理。 +- `lock()` timeout 后通过一条新 connection 重发一次幂等 desired-lock,并要求收到 reply;仅查询 `status()` 不能证明 Focus persistence takeover 已经发生。 - `unlock()` 对称地确认是否已经 unlocked。 +- `setFocusFilterLockEnabled(_:)` timeout 后同样在 fresh connection 上重新协商 capability 并重发同一 desired state;布尔状态无法证明当前 generation 是否由 Focus 创建。 - 无法确认最终状态时抛出 `operationOutcomeUnknown`,而不是谎称操作一定失败。 - Accessibility prompt 请求 timeout 同样只表示最终结果未知;不能据此断言 prompt 没有发出。 From ba195884d19cb59210cf0bfed83c1a9eb85a8dd3 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 17:37:02 +0800 Subject: [PATCH 049/106] feat(focus): add keyboard lock focus filter --- Core/Sources/Client/XPCClient.swift | 2 +- Core/Sources/Common/Shared.swift | 8 +- .../ClientTests/XPCClientErrorTests.swift | 4 +- .../XPCCodeSigningRequirementTests.swift | 1 + KeyboardLocker.xcodeproj/project.pbxproj | 180 ++++++++++++++++++ .../AppIntentsExtension.swift | 5 + KeyboardLockerFocusIntents/Info.plist | 29 +++ .../KeyboardLockFocusFilterIntent.swift | 54 ++++++ .../KeyboardLockerFocusIntents.entitlements | 12 ++ .../KeyboardLockFocusFilterIntentTests.swift | 62 ++++++ docs/automation.md | 19 +- docs/development.md | 15 +- docs/xpc.md | 24 ++- 13 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 KeyboardLockerFocusIntents/AppIntentsExtension.swift create mode 100644 KeyboardLockerFocusIntents/Info.plist create mode 100644 KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift create mode 100644 KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements create mode 100644 KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 9fcafaa..3990c4c 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -41,7 +41,7 @@ public enum XPCClientError: Error, LocalizedError { "Open KeyboardLocker to update its background agent, then retry." case .operationOutcomeUnknown: - "Run `klock status` to inspect the authoritative state, then use `klock unlock` if needed." + "Inspect the current state, then repeat the intended lock or unlock action if needed." case .serviceUnavailable: "Open KeyboardLocker once to register its background agent, then retry. If KeyboardLocker is already running, choose Show Details… from its menu." diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 7cbd9de..eb5f36e 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -13,6 +13,10 @@ public enum SharedConstants { private static let commandLineToolBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.klock" + /// App Intents extension that applies Focus changes while the main App is not running. + private static let focusIntentsExtensionBundleIdentifier = + "io.lzhlovesjyq.keyboardlocker.focus-intents" + /// One WidgetKit extension process hosts both the status Widget and macOS Control. private static let widgetKitExtensionBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.widgets" @@ -23,6 +27,7 @@ public enum SharedConstants { public static let authorizedClientBundleIdentifiers: Set = [ appBundleIdentifier, commandLineToolBundleIdentifier, + focusIntentsExtensionBundleIdentifier, widgetKitExtensionBundleIdentifier, ] } @@ -63,7 +68,8 @@ public protocol KeyboardLockerServiceProtocol { func lockKeyboard(reply: @escaping (Error?) -> Void) /// Requests a lock that treats Control-C as an additional unlock gesture only when this call - /// atomically creates the global lock. `didAcquireLock` is false for a strict duplicate no-op. + /// atomically creates the global lock. `didAcquireLock` is false when the global lock already + /// exists; the request may still take persistence over from a Focus-owned generation. func lockKeyboardInteractively( reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void ) diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index fa5272c..22e66ea 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -35,7 +35,7 @@ final class XPCClientErrorTests: XCTestCase { XPCClientError.operationOutcomeUnknown.recoverySuggestion ) - XCTAssertTrue(suggestion.contains("klock status")) - XCTAssertTrue(suggestion.contains("klock unlock")) + XCTAssertTrue(suggestion.contains("Inspect the current state")) + XCTAssertTrue(suggestion.contains("repeat the intended lock or unlock action")) } } diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift index 1588ab9..d2c66da 100644 --- a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift @@ -8,6 +8,7 @@ final class XPCCodeSigningRequirementTests: XCTestCase { SharedConstants.authorizedClientBundleIdentifiers, [ "io.lzhlovesjyq.keyboardlocker", + "io.lzhlovesjyq.keyboardlocker.focus-intents", "io.lzhlovesjyq.keyboardlocker.klock", "io.lzhlovesjyq.keyboardlocker.widgets", ] diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index 334ad82..012bf95 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ A1000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000A /* Client */; }; B2000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = B2000000000000000000000A /* Client */; }; B2000000000000000000000C /* KeyboardLockerWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D4000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = D4000000000000000000000A /* Client */; }; + D4000000000000000000000C /* KeyboardLockerFocusIntents.appex in Embed App Intents Extensions */ = {isa = PBXBuildFile; fileRef = D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F21304CF2ECDC2E200735580 /* KeyboardLockerAgent.app in Copy Agent App */ = {isa = PBXBuildFile; fileRef = F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F21304D32ECDC51F00735580 /* klock in Copy CLI */ = {isa = PBXBuildFile; fileRef = F21304B42ECDC26400735580 /* klock */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFCFE2EEFAF0B0065F041 /* Client */; }; @@ -39,6 +41,13 @@ remoteGlobalIDString = B20000000000000000000003; remoteInfo = KeyboardLockerWidgets; }; + D4000000000000000000000E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F21304202ECDAD3500735580 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D40000000000000000000003; + remoteInfo = KeyboardLockerFocusIntents; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -53,6 +62,17 @@ name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + D4000000000000000000000D /* Embed App Intents Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + D4000000000000000000000C /* KeyboardLockerFocusIntents.appex in Embed App Intents Extensions */, + ); + name = "Embed App Intents Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; F21304B22ECDC26400735580 /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -99,6 +119,7 @@ /* Begin PBXFileReference section */ A10000000000000000000001 /* KeyboardLockerModelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KeyboardLockerModelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B20000000000000000000001 /* KeyboardLockerWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KeyboardLockerWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = KeyboardLockerFocusIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F21304282ECDAD3500735580 /* KeyboardLocker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLocker.app; sourceTree = BUILT_PRODUCTS_DIR; }; F21304362ECDADC900735580 /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLockerAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -152,6 +173,23 @@ ); target = A10000000000000000000003 /* KeyboardLockerModelTests */; }; + D40000000000000000000010 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerFocusIntents" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + KeyboardLockerFocusIntents.entitlements, + ); + target = D40000000000000000000003 /* KeyboardLockerFocusIntents */; + }; + D40000000000000000000011 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + AppIntentsExtension.swift, + Info.plist, + KeyboardLockerFocusIntents.entitlements, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -182,6 +220,15 @@ path = KeyboardLockerWidgets; sourceTree = ""; }; + D40000000000000000000002 /* KeyboardLockerFocusIntents */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + D40000000000000000000010 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerFocusIntents" target */, + D40000000000000000000011 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerModelTests" target */, + ); + path = KeyboardLockerFocusIntents; + sourceTree = ""; + }; F213042A2ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -224,6 +271,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D40000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D4000000000000000000000B /* Client in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304252ECDAD3500735580 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -256,6 +311,7 @@ children = ( F21304362ECDADC900735580 /* Core */, F213042A2ECDAD3500735580 /* KeyboardLocker */, + D40000000000000000000002 /* KeyboardLockerFocusIntents */, B20000000000000000000002 /* KeyboardLockerWidgets */, F213049C2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B52ECDC26400735580 /* klock */, @@ -268,6 +324,7 @@ isa = PBXGroup; children = ( F21304282ECDAD3500735580 /* KeyboardLocker.app */, + D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */, B20000000000000000000001 /* KeyboardLockerWidgets.appex */, F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */, F21304B42ECDC26400735580 /* klock */, @@ -294,6 +351,7 @@ fileSystemSynchronizedGroups = ( A10000000000000000000002 /* KeyboardLockerModelTests */, F213042A2ECDAD3500735580 /* KeyboardLocker */, + D40000000000000000000002 /* KeyboardLockerFocusIntents */, B20000000000000000000002 /* KeyboardLockerWidgets */, F21304B52ECDC26400735580 /* klock */, ); @@ -328,6 +386,29 @@ productReference = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; productType = "com.apple.product-type.app-extension"; }; + D40000000000000000000003 /* KeyboardLockerFocusIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = D40000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerFocusIntents" */; + buildPhases = ( + D40000000000000000000004 /* Sources */, + D40000000000000000000005 /* Frameworks */, + D40000000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D40000000000000000000002 /* KeyboardLockerFocusIntents */, + ); + name = KeyboardLockerFocusIntents; + packageProductDependencies = ( + D4000000000000000000000A /* Client */, + ); + productName = KeyboardLockerFocusIntents; + productReference = D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */; + productType = "com.apple.product-type.extensionkit-extension"; + }; F21304272ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXNativeTarget; buildConfigurationList = F21304332ECDAD3600735580 /* Build configuration list for PBXNativeTarget "KeyboardLocker" */; @@ -339,6 +420,7 @@ F21304C02ECDC2A400735580 /* Copy Agent App */, F21304D22ECDC50400735580 /* Copy CLI */, B2000000000000000000000D /* Embed App Extensions */, + D4000000000000000000000D /* Embed App Intents Extensions */, ); buildRules = ( ); @@ -346,6 +428,7 @@ F21304D52ECDC53D00735580 /* PBXTargetDependency */, F21304D72ECDC53D00735580 /* PBXTargetDependency */, B2000000000000000000000F /* PBXTargetDependency */, + D4000000000000000000000F /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( F213042A2ECDAD3500735580 /* KeyboardLocker */, @@ -420,6 +503,9 @@ B20000000000000000000003 = { CreatedOnToolsVersion = 26.3; }; + D40000000000000000000003 = { + CreatedOnToolsVersion = 26.3; + }; F21304272ECDAD3500735580 = { CreatedOnToolsVersion = 26.1.1; }; @@ -446,6 +532,7 @@ projectRoot = ""; targets = ( F21304272ECDAD3500735580 /* KeyboardLocker */, + D40000000000000000000003 /* KeyboardLockerFocusIntents */, B20000000000000000000003 /* KeyboardLockerWidgets */, F213049A2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B32ECDC26400735580 /* klock */, @@ -469,6 +556,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D40000000000000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304262ECDAD3500735580 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -500,6 +594,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D40000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304242ECDAD3500735580 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -539,6 +640,11 @@ target = B20000000000000000000003 /* KeyboardLockerWidgets */; targetProxy = B2000000000000000000000E /* PBXContainerItemProxy */; }; + D4000000000000000000000F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D40000000000000000000003 /* KeyboardLockerFocusIntents */; + targetProxy = D4000000000000000000000E /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -635,6 +741,67 @@ }; name = Release; }; + D40000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerFocusIntents/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.lzhlovesjyq.keyboardlocker.focus-intents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + D40000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerFocusIntents/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.lzhlovesjyq.keyboardlocker.focus-intents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; F21304312ECDAD3600735580 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -962,6 +1129,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D40000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerFocusIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D40000000000000000000007 /* Debug */, + D40000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; F21304232ECDAD3500735580 /* Build configuration list for PBXProject "KeyboardLocker" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1009,6 +1185,10 @@ isa = XCSwiftPackageProductDependency; productName = Client; }; + D4000000000000000000000A /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; F2BFFCFE2EEFAF0B0065F041 /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; diff --git a/KeyboardLockerFocusIntents/AppIntentsExtension.swift b/KeyboardLockerFocusIntents/AppIntentsExtension.swift new file mode 100644 index 0000000..e612739 --- /dev/null +++ b/KeyboardLockerFocusIntents/AppIntentsExtension.swift @@ -0,0 +1,5 @@ +import AppIntents +import ExtensionFoundation + +@main +struct KeyboardLockerFocusIntentsExtension: AppIntentsExtension {} diff --git a/KeyboardLockerFocusIntents/Info.plist b/KeyboardLockerFocusIntents/Info.plist new file mode 100644 index 0000000..5e9f303 --- /dev/null +++ b/KeyboardLockerFocusIntents/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + KeyboardLocker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.appintents-extension + + + diff --git a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift new file mode 100644 index 0000000..3dde45f --- /dev/null +++ b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift @@ -0,0 +1,54 @@ +import AppIntents +import Client + +protocol AgentFocusLockServing: Sendable { + func setFocusFilterLockEnabled(_ enabled: Bool) async throws +} + +struct LiveAgentFocusLockClient: AgentFocusLockServing { + func setFocusFilterLockEnabled(_ enabled: Bool) async throws { + try await XPCClient.shared.setFocusFilterLockEnabled(enabled) + } +} + +struct KeyboardLockFocusFilterIntent: SetFocusFilterIntent { + static let title: LocalizedStringResource = "Keyboard Lock" + static let description = IntentDescription( + "Locks keyboard input while this Focus is active." + ) + + @Parameter(title: "Lock Keyboard", default: false) + var lockKeyboard: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: lockKeyboard ? "Lock Keyboard" : "Do Not Lock Keyboard" + ) + } + + private let client: any AgentFocusLockServing + + init() { + client = LiveAgentFocusLockClient() + lockKeyboard = false + } + + init( + lockKeyboard: Bool, + client: any AgentFocusLockServing = LiveAgentFocusLockClient() + ) { + self.client = client + self.lockKeyboard = lockKeyboard + } + + static func suggestedFocusFilters( + for _: FocusFilterSuggestionContext + ) async -> [Self] { + [Self(lockKeyboard: true)] + } + + func perform() async throws -> some IntentResult { + try await client.setFocusFilterLockEnabled(lockKeyboard) + return .result() + } +} diff --git a/KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements b/KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements new file mode 100644 index 0000000..631a0dc --- /dev/null +++ b/KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.temporary-exception.mach-lookup.global-name + + io.lzhlovesjyq.keyboardlocker.agent + + + diff --git a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift new file mode 100644 index 0000000..36acda6 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift @@ -0,0 +1,62 @@ +import XCTest + +final class KeyboardLockFocusFilterIntentTests: XCTestCase { + func testEnabledFilterForwardsTrueToAgent() async throws { + let client = FakeAgentFocusLockClient() + + _ = try await KeyboardLockFocusFilterIntent( + lockKeyboard: true, + client: client + ).perform() + + let receivedValues = await client.receivedValues + XCTAssertEqual(receivedValues, [true]) + } + + func testDisabledFilterForwardsFalseToAgent() async throws { + let client = FakeAgentFocusLockClient() + + _ = try await KeyboardLockFocusFilterIntent( + lockKeyboard: false, + client: client + ).perform() + + let receivedValues = await client.receivedValues + XCTAssertEqual(receivedValues, [false]) + } + + func testAgentFailureIsPropagated() async { + let client = FakeAgentFocusLockClient(error: FocusIntentTestError.unavailable) + + do { + _ = try await KeyboardLockFocusFilterIntent( + lockKeyboard: true, + client: client + ).perform() + XCTFail("Expected the Agent error to be propagated.") + } catch { + XCTAssertEqual(error as? FocusIntentTestError, .unavailable) + } + } +} + +private actor FakeAgentFocusLockClient: AgentFocusLockServing { + private(set) var receivedValues: [Bool] = [] + + private let error: Error? + + init(error: Error? = nil) { + self.error = error + } + + func setFocusFilterLockEnabled(_ enabled: Bool) async throws { + if let error { + throw error + } + receivedValues.append(enabled) + } +} + +private enum FocusIntentTestError: Error, Equatable { + case unavailable +} diff --git a/docs/automation.md b/docs/automation.md index 01a4154..3ac5529 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,12 +1,12 @@ # 自动化 -KeyboardLocker 的 Shortcuts、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 +KeyboardLocker 的 Shortcuts、Focus Filter、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 所有入口共享以下语义: - `lock` 与 `unlock` 是幂等的 desired-state action。 - `status` 每次都从 Agent 查询权威状态;wrapper 不维护本地副本。 -- lock 是全局状态,不属于发起它的 Shortcut、AppleScript 或 shell process。任意入口的 `unlock` 都会解开同一个全局锁。 +- lock 是全局状态,不属于发起它的 Shortcut、AppleScript 或 shell process。任意普通入口的显式 `unlock` 都会解开同一个全局锁。Focus Filter 的关闭事件使用更窄的条件释放规则,只解除它自己创建且未被后续显式 `lock` 接管的锁。 - 当前没有暴露 client-side `toggle`。需要确定结果时,显式选择 `lock` 或 `unlock`。 - Agent 不可达、权限不足或结果无法确认时,action wrapper 会明确失败,Widget 会显示 unavailable;任何入口都不会把失败猜成 unlocked。 @@ -18,7 +18,20 @@ KeyboardLocker 提供三个可组合 action: - `Unlock Keyboard` - `Get Keyboard Lock Status` -`Get Keyboard Lock Status` 返回 typed Boolean,可直接接入 Shortcuts 的条件分支。当前版本没有声明 `AppShortcutsProvider`;这些 action 从 Shortcuts 的 action library 中选择,不会作为 promoted shortcut 自动出现,也没有预注册的 invocation phrase。 +`Get Keyboard Lock Status` 返回 typed Boolean,可直接接入 Shortcuts 的条件分支。当前版本没有声明 `AppShortcutsProvider`;这些 action 从 Shortcuts 的 action library 中选择。Apple 当前不在 macOS 支持 promoted App Shortcuts,因此应用不会注册 invocation phrase。 + +## Focus Filter + +macOS 13 及以上可在 **System Settings > Focus** 中为某个 Focus 添加 KeyboardLocker 的 `Keyboard Lock` filter。启用该 Focus 时,系统把 `Lock Keyboard = true` 发送给独立的 App Intents extension;关闭时会用参数默认值 `false` 再次执行 intent,因此主 App 未运行时也能经 XPC 把 desired state 交给 Agent。 + +Focus Filter 采用条件 ownership,不会把全局 lock 误当成自己的资源: + +- 若 Focus 启用时已经由其他入口锁定,它不会认领该锁;Focus 关闭时保持 locked。 +- 若 Focus 从 unlocked 创建锁,它只拥有该次 lock generation。 +- 若随后任一普通 wrapper 再次显式调用 `lock`,该用户意图会接管同一 generation 的持久性,但不会重建 event tap、改变活动设置或延长 auto-unlock deadline;Focus 关闭时保持 locked。 +- 若原 generation 已经由显式 unlock、热键或 timeout 结束,迟到的 Focus 关闭事件不能解除之后新建的锁。 + +Focus extension 保持 sandbox,只获得访问 KeyboardLocker Agent Mach service 的 lookup 权限;Agent 仍要求同 Team 与精确 extension signing identifier。Agent 或 Accessibility 不可用时,intent 会明确失败,不会伪造 Focus 已经应用。 ## Widget diff --git a/docs/development.md b/docs/development.md index 36ca5c8..8b554d7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,14 +15,14 @@ 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 6. wrapper 通过 `LockStateSubscriber.subscribe(initialState:_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态。subscriber 会在 observer 安装完成后立即拉取一次权威初始状态,后续信号串行合并并再次查询 —— 绝不从"我这次调用是否成功"或通知 payload 推断。 -> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 是**严格幂等**的:已锁时重复调用会直接成功,不修改当前设置、锁定起点或 auto-unlock deadline。只有显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window。不存在客户端拥有的"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 +> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 对物理运行状态是**严格幂等**的:已锁时重复调用不会重建 event tap、修改当前设置、锁定起点或 auto-unlock deadline。唯一的 metadata 变化是普通 wrapper 的显式 `lock` 会接管 Focus 创建的当前 generation,使之后的 Focus disable 不再撤销这个更新的用户意图。只有显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window。不存在客户端持有的通用"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 ## 组件地图 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`,旧 `status` / `lockKeyboard` ABI 保持不变。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive/Focus 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`;protocol 1.5 新增 capability-gated `setFocusFilterLockEnabled`,旧 `status` / `lockKeyboard` ABI 保持不变。 - `LockStatusSnapshot.swift`:`LockStatusSnapshot` format 1 和有大小上限的 JSON XPC 编解码。snapshot 原子携带 capture time、布尔状态、锁定起点、auto-unlock deadline 与 active settings;duration/countdown 由 consumer 根据权威时间点派生,不作为会迅速过期的 transport 字段。 - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成并预编译同 Team + 精确 identifier 的双向 XPC requirement;unsigned/ad-hoc 进程 fail closed。 @@ -30,13 +30,13 @@ - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` -- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。legacy `lock` / `unlock` 超时后重新查询权威状态,无法确认时明确报告 outcome unknown;`lockInteractively()` 与 `lockStatusSnapshot()` 都在同一 connection 上完成 descriptor/capability gate 后调用对应 selector。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。`unlock` 超时后可用权威 Boolean 状态校准;普通 `lock` 与 Focus selector 的成功还包含本地状态看不到的 provenance,因此首次 timeout 后会在 fresh connection 上重发同一个幂等请求,第二次仍超时则明确报告 outcome unknown。`lockInteractively()`、`lockStatusSnapshot()` 与 `setFocusFilterLockEnabled()` 都在同一 connection 上完成 descriptor/capability gate 后调用对应 selector。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 - `ServiceCompatibility.swift`:纯值兼容性规则与任意精度 dotted-numeric `ServiceBuildVersion` —— major 必须相等、running minor 不得低于最低版本、required capabilities 必须齐全,且运行中 Agent 的 identifier/version/build 必须与 bundled Agent 一致。 - `LockStateSubscriber.swift`:先安装 Darwin + Distributed observer,再立即拉取一次权威状态;后续把每个信号当作提示,通过 `XPCClient.status()` 串行校准(带重试、signal coalescing、去重和 cancellation fence)→ `ObserverToken`,另有会先产出当前权威状态的 `stateChanges`(`AsyncStream`)。取消会丢弃尚未进入 handler 的结果,但不会回溯撤销已经开始执行的 handler。长命 UI 使用 snapshot seed 避免重复呈现相同状态;一次性面直接读 `status()`(见 architecture 的"状态同步")。 - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、严格幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。重复 `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline;资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 +- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 @@ -59,6 +59,11 @@ - `KeyboardLockerStatusWidget.swift`:small/medium 状态 presentation,显示 locked/unlocked、deadline、解锁热键与 Agent unavailable。WidgetKit 可以合并 timeline policy,因此该 UI 不承诺实时刷新。 - `KeyboardLockerWidgets.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent 仍以同 Team + 精确 extension identifier 的 listener requirement 独立认证调用方。 +**App Intents extension**(`KeyboardLockerFocusIntents/`)—— sandboxed、按需运行的 Focus Filter wrapper,只调用 Client +- `KeyboardLockFocusFilterIntent.swift`:macOS 13+ `SetFocusFilterIntent`;参数默认值为 `false`,使 Focus 关闭时向 Agent 发送 disable。`perform()` 只调用 capability-gated `setFocusFilterLockEnabled`,不以普通 `unlock` 模拟条件释放。 +- `AppIntentsExtension.swift` / `Info.plist`:独立 `com.apple.appintents-extension` 入口,使主 App 未运行时系统仍可执行 Focus 生命周期事件。 +- `KeyboardLockerFocusIntents.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent allowlist 只新增 `io.lzhlovesjyq.keyboardlocker.focus-intents` 精确 signing identifier。 + ## 常见任务 ### 新增一个设置项 @@ -96,7 +101,7 @@ `klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 -Shortcuts、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 +Shortcuts、Focus Filter、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 ### Homebrew Cask 发布计划(尚未实现) diff --git a/docs/xpc.md b/docs/xpc.md index a396d74..28ec041 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -41,6 +41,7 @@ Core KeyboardLocker -> Client -> Common klock -> Client -> Common KeyboardLockerWidgets -> Client -> Common +KeyboardLockerFocusIntents -> Client -> Common KeyboardLockerAgent -> Service -> Common ``` @@ -72,9 +73,14 @@ flowchart LR Timeline["Timeline provider"] --> WidgetClient["XPCClient"] end + subgraph FocusProcess["App Intents extension process"] + Focus["Focus Filter intent"] --> FocusClient["XPCClient"] + end + AppClient -->|"NSXPCConnection"| Mach["Mach service"] CLIClient -->|"NSXPCConnection"| Mach WidgetClient -->|"NSXPCConnection"| Mach + FocusClient -->|"NSXPCConnection"| Mach subgraph AgentProcess["KeyboardLockerAgent process"] Listener["NSXPCListener"] --> AgentService["AgentService"] AgentService --> Engine["LockEngine.shared"] @@ -88,7 +94,7 @@ flowchart LR `Core` 不会作为第四个进程出现。它的代码分别被链接进上图中的 executable: -- App、CLI 与按需启动的 WidgetKit extension 各自包含一份 `Client + Common` 代码。 +- App、CLI、按需启动的 WidgetKit extension 与 App Intents extension 各自包含一份 `Client + Common` 代码。 - Agent 包含一份 `Service + Common` 代码。 - 只有 Agent 的 executable 包含 `LockEngine`,因此 App/CLI 不可能绕过 XPC 直接操作 event tap。 @@ -104,6 +110,10 @@ public protocol KeyboardLockerServiceProtocol { func lockKeyboardInteractively( reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void ) + func setFocusFilterLockEnabled( + _ enabled: Bool, + reply: @escaping (Error?) -> Void + ) func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) @@ -519,7 +529,7 @@ sequenceDiagram | Wire method | Client API | 权威执行方 | 语义 | |---|---|---|---| | `serviceDescriptor` | `serviceDescriptor()` | `AgentService` | bootstrap query;返回 protocol、capability、bundled metadata 和 process instance ID | -| `lockKeyboard` | `lock()` | `LockEngine` | 严格幂等地进入全局 locked 状态;已锁时直接成功且不修改设置、锁定起点或 auto-unlock deadline | +| `lockKeyboard` | `lock()` | `LockEngine` | 对物理状态幂等地进入全局 locked;已锁时不修改设置、锁定起点或 auto-unlock deadline,但会接管当前 Focus-owned generation 的持久性 | | `lockKeyboardInteractively` | `lockInteractively()` | `LockEngine` | 原子返回本次请求是否创建全局锁;仅 acquired 的新锁临时接受 `Ctrl+C` 作为额外解锁手势 | | `setFocusFilterLockEnabled` | `setFocusFilterLockEnabled(_:)` | `LockEngine` | capability-gated desired state;启用只认领自己新建的 lock generation,停用只条件性释放仍属 Focus 的同一代 | | `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | @@ -543,7 +553,7 @@ Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使 - App 和 CLI 各有自己的 connection。 - 某条 connection interruption 后,Client 会将其 invalidate 并清除缓存;invalidation 同样清除缓存。下一次调用创建新 connection 并重新握手。 - App 退出、CLI 退出或 connection 断开,不会触发 unlock。 -- `klock lock` 只有在 interactive request 返回 acquired 时才继续等待并打印后续的 `Unlocked.`;该轮锁由 Agent 额外识别并消费 `Ctrl+C` 解锁手势。already locked 表示严格 duplicate no-op,CLI 立即退出,不会解除或修改 App/其他 CLI 建立的锁。直接杀死 CLI 进程仍不会自动解除锁;等待期间 notification subscriber 提供及时更新,周期性 `status()` 提供丢通知与 Agent 重启后的恢复;连续无法取得权威状态时 CLI 报错退出。 +- `klock lock` 只有在 interactive request 返回 acquired 时才继续等待并打印后续的 `Unlocked.`;该轮锁由 Agent 额外识别并消费 `Ctrl+C` 解锁手势。already locked 表示 physical duplicate,CLI 立即退出,不会解除锁、改变 event tap 或修改活动设置;若当前 generation 由 Focus 创建,这次显式 desired-lock 会接管其持久性。直接杀死 CLI 进程仍不会自动解除锁;等待期间 notification subscriber 提供及时更新,周期性 `status()` 提供丢通知与 Agent 重启后的恢复;连续无法取得权威状态时 CLI 报错退出。 - Agent 退出则不同:event tap 属于 Agent 进程,进程退出会由系统释放它,内存中的 locked 状态也会消失。 因此项目刻意没有 `LockSession`、client ownership 或“连接释放时自动 unlock”之类的抽象。领域事实是一个物理键盘对应一个由 Agent 持有的全局状态。 @@ -613,15 +623,17 @@ sequenceDiagram 身份认证是双向且由 XPC runtime 强制执行: -- Agent 的 `NSXPCListener` 在 activate 前调用 `setConnectionCodeSigningRequirement`,只接受与 Agent 同 Team 且 signing identifier 精确匹配主 App、bundled CLI 或 WidgetKit extension 的 Client。系统在调用 delegate 前完成检查,因此未认证 peer 看不到任何 exported selector。 -- App、CLI 与 WidgetKit extension 的 `NSXPCConnection` 在 activate 前调用 `setCodeSigningRequirement`,只接受与 Client 同 Team 且 signing identifier 精确匹配 bundled Agent 的服务进程。 +- Agent 的 `NSXPCListener` 在 activate 前调用 `setConnectionCodeSigningRequirement`,只接受与 Agent 同 Team 且 signing identifier 精确匹配主 App、bundled CLI、WidgetKit extension 或 Focus App Intents extension 的 Client。系统在调用 delegate 前完成检查,因此未认证 peer 看不到任何 exported selector。 +- App、CLI、WidgetKit extension 与 Focus App Intents extension 的 `NSXPCConnection` 在 activate 前调用 `setCodeSigningRequirement`,只接受与 Client 同 Team 且 signing identifier 精确匹配 bundled Agent 的服务进程。 - requirement 使用 `anchor apple generic`、Apple 签名证书 `subject.OU` 中的 Team ID 和精确 `identifier`;Team ID 从当前进程已经验证的签名动态读取,不在源码中重复硬编码。 - Debug 和 Release 使用同一条安全边界。Xcode Debug target 已配置 Apple Development 签名;unsigned、ad-hoc、错误 Team 或仅伪造 identifier 的进程都会 fail closed。 -当前允许的调用方只有主 App、bundled CLI 与 `io.lzhlovesjyq.keyboardlocker.widgets` WidgetKit extension 的 namespaced signing identifier;不再接受通用的 `klock` identifier。仅仅知道 Mach service 名称,或在自己的签名中复制 bundle identifier,都不能调用 Agent。descriptor 中的 bundle/version/build 仍只用于兼容性判断,不参与身份认证。 +当前允许的调用方只有主 App、bundled CLI、`io.lzhlovesjyq.keyboardlocker.widgets` WidgetKit extension 与 `io.lzhlovesjyq.keyboardlocker.focus-intents` App Intents extension 的 namespaced signing identifier;不再接受通用的 `klock` identifier。仅仅知道 Mach service 名称,或在自己的签名中复制 bundle identifier,都不能调用 Agent。descriptor 中的 bundle/version/build 仍只用于兼容性判断,不参与身份认证。 WidgetKit extension 保持 App Sandbox 与 `APPLICATION_EXTENSION_API_ONLY`;它的 entitlements 只为 `io.lzhlovesjyq.keyboardlocker.agent` 声明 global Mach lookup temporary exception。这个 sandbox permission 只允许发起 lookup,不能代替 Listener 的同 Team + 精确 identifier 检查;Client 仍会反向验证 Agent。Widget 与 macOS 26+ Control 共用这一 extension process 和 signing identity,但每次 timeline/value/action execution 都建立在 Agent 权威 snapshot/action 上,不共享另一份业务状态。 +Focus App Intents extension 同样保持 App Sandbox 与 `APPLICATION_EXTENSION_API_ONLY`,并只声明同一个 Agent Mach lookup exception。它使用独立 signing identity,不会与 WidgetKit extension 合并;Focus disable 调用专用 selector,由 Agent 在串行隔离域内比较 Focus-owned lock generation 后条件释放,而不是先读 `status()` 再调用普通 `unlock()`。 + `klock` 是裸 Mach-O command-line tool,因此 target 必须生成并嵌入 `__TEXT,__info_plist`;否则 `PRODUCT_BUNDLE_IDENTIFIER` 不会成为实际 code-signing identifier,`codesign` 会退回可执行文件名 `klock`,并被 Listener requirement 正确拒绝。`CREATE_INFOPLIST_SECTION_IN_BINARY` 和 `GENERATE_INFOPLIST_FILE` 属于 XPC 身份契约,不能当作无关构建设置移除。 ## `SMAppService` 状态和 XPC 状态不要混为一谈 From b97b5b55dc619a520031144c4ac8ec27cb4ab1db Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 17:41:20 +0800 Subject: [PATCH 050/106] feat(services): add native keyboard actions --- .../AppKitExternalAutomationPresenter.swift | 44 ++++ .../Automation/ExternalAutomationAction.swift | 32 +++ .../ExternalAutomationController.swift | 83 +++++++ .../KeyboardLockerServicesProvider.swift | 36 +++ KeyboardLocker/Info.plist | 42 ++++ .../KeyboardLockerApplication.swift | 14 ++ .../ExternalAutomationControllerTests.swift | 212 ++++++++++++++++++ .../KeyboardLockerServicesProviderTests.swift | 14 ++ docs/automation.md | 14 +- docs/development.md | 5 +- 10 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift create mode 100644 KeyboardLocker/Automation/ExternalAutomationAction.swift create mode 100644 KeyboardLocker/Automation/ExternalAutomationController.swift create mode 100644 KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift create mode 100644 KeyboardLockerModelTests/ExternalAutomationControllerTests.swift create mode 100644 KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift diff --git a/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift b/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift new file mode 100644 index 0000000..ffcd885 --- /dev/null +++ b/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift @@ -0,0 +1,44 @@ +import AppKit + +@MainActor +struct AppKitExternalAutomationPresenter: ExternalAutomationPresenting { + func presentStatus(isLocked: Bool, source _: ExternalAutomationSource) { + let state = isLocked ? "Locked" : "Unlocked" + presentAlert( + style: .informational, + message: "Keyboard is \(state)", + information: "This status was read from the KeyboardLocker Agent." + ) + } + + func presentFailures( + _ failures: [ExternalAutomationFailure], + source: ExternalAutomationSource + ) { + let uniqueMessages = failures.reduce(into: [String]()) { messages, failure in + if !messages.contains(failure.message) { + messages.append(failure.message) + } + } + + presentAlert( + style: .warning, + message: "KeyboardLocker \(source.rawValue) Failed", + information: uniqueMessages.joined(separator: "\n\n") + ) + } + + private func presentAlert( + style: NSAlert.Style, + message: String, + information: String + ) { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = message + alert.informativeText = information + alert.addButton(withTitle: "OK") + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } +} diff --git a/KeyboardLocker/Automation/ExternalAutomationAction.swift b/KeyboardLocker/Automation/ExternalAutomationAction.swift new file mode 100644 index 0000000..c142eb4 --- /dev/null +++ b/KeyboardLocker/Automation/ExternalAutomationAction.swift @@ -0,0 +1,32 @@ +import Foundation + +nonisolated enum ExternalAutomationAction: Equatable, Sendable { + case lock + case unlock + case status +} + +nonisolated enum ExternalAutomationSource: String, Equatable, Sendable { + case service = "Service" + case urlScheme = "URL" +} + +nonisolated struct ExternalAutomationFailure: Equatable, Sendable { + let message: String + + init(message: String) { + self.message = message + } + + init(error: Error) { + let error = error as NSError + guard let recoverySuggestion = error.localizedRecoverySuggestion, + !recoverySuggestion.isEmpty + else { + self.init(message: error.localizedDescription) + return + } + + self.init(message: "\(error.localizedDescription) \(recoverySuggestion)") + } +} diff --git a/KeyboardLocker/Automation/ExternalAutomationController.swift b/KeyboardLocker/Automation/ExternalAutomationController.swift new file mode 100644 index 0000000..1624362 --- /dev/null +++ b/KeyboardLocker/Automation/ExternalAutomationController.swift @@ -0,0 +1,83 @@ +import Foundation + +@MainActor +protocol ExternalAutomationPresenting: Sendable { + func presentStatus(isLocked: Bool, source: ExternalAutomationSource) + func presentFailures( + _ failures: [ExternalAutomationFailure], + source: ExternalAutomationSource + ) +} + +@MainActor +final class ExternalAutomationController { + private let client: any AgentLockActionServing + private let presenter: any ExternalAutomationPresenting + private var tail: Task? + + init( + client: any AgentLockActionServing, + presenter: any ExternalAutomationPresenting + ) { + self.client = client + self.presenter = presenter + } + + convenience init() { + self.init( + client: LiveAgentClient(), + presenter: AppKitExternalAutomationPresenter() + ) + } + + func submit( + _ action: ExternalAutomationAction, + source: ExternalAutomationSource + ) { + submit([action], source: source) + } + + func submit( + _ actions: [ExternalAutomationAction], + source: ExternalAutomationSource + ) { + guard !actions.isEmpty else { + return + } + + let previous = tail + let client = client + let presenter = presenter + tail = Task { @MainActor in + await previous?.value + + var failures: [ExternalAutomationFailure] = [] + for action in actions { + do { + switch action { + case .lock: + try await client.lock() + case .unlock: + try await client.unlock() + case .status: + let isLocked = try await client.status() + presenter.presentStatus( + isLocked: isLocked, + source: source + ) + } + } catch { + failures.append(ExternalAutomationFailure(error: error)) + } + } + + if !failures.isEmpty { + presenter.presentFailures(failures, source: source) + } + } + } + + func waitUntilIdle() async { + await tail?.value + } +} diff --git a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift new file mode 100644 index 0000000..3ce5271 --- /dev/null +++ b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift @@ -0,0 +1,36 @@ +import AppKit + +final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sendable { + private let submit: @Sendable (ExternalAutomationAction) -> Void + + init(submit: @escaping @Sendable (ExternalAutomationAction) -> Void) { + self.submit = submit + } + + @objc(lockKeyboard:userData:error:) + nonisolated func lockKeyboard( + _: NSPasteboard, + userData _: String?, + error _: AutoreleasingUnsafeMutablePointer + ) { + submit(.lock) + } + + @objc(unlockKeyboard:userData:error:) + nonisolated func unlockKeyboard( + _: NSPasteboard, + userData _: String?, + error _: AutoreleasingUnsafeMutablePointer + ) { + submit(.unlock) + } + + @objc(showKeyboardLockStatus:userData:error:) + nonisolated func showKeyboardLockStatus( + _: NSPasteboard, + userData _: String?, + error _: AutoreleasingUnsafeMutablePointer + ) { + submit(.status) + } +} diff --git a/KeyboardLocker/Info.plist b/KeyboardLocker/Info.plist index 4c9fef6..6a84690 100644 --- a/KeyboardLocker/Info.plist +++ b/KeyboardLocker/Info.plist @@ -26,6 +26,48 @@ NSHumanReadableCopyright + NSServices + + + NSMenuItem + + default + Lock Keyboard + + NSMessage + lockKeyboard + NSPortName + KeyboardLocker + NSRequiredContext + + + + NSMenuItem + + default + Unlock Keyboard + + NSMessage + unlockKeyboard + NSPortName + KeyboardLocker + NSRequiredContext + + + + NSMenuItem + + default + Show Keyboard Lock Status + + NSMessage + showKeyboardLockStatus + NSPortName + KeyboardLocker + NSRequiredContext + + + OSAScriptingDefinition KeyboardLocker.sdef diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index 2d44228..daea661 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -73,9 +73,23 @@ enum KeyboardLockerApplication { @MainActor private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDelegate { + private let automationController: ExternalAutomationController + private let servicesProvider: KeyboardLockerServicesProvider private var statusMenuController: StatusMenuController? + override init() { + let automationController = ExternalAutomationController() + self.automationController = automationController + servicesProvider = KeyboardLockerServicesProvider { action in + Task { @MainActor in + automationController.submit(action, source: .service) + } + } + super.init() + } + func applicationDidFinishLaunching(_: Notification) { + NSApp.servicesProvider = servicesProvider statusMenuController = StatusMenuController(coordinator: AppCoordinator()) } } diff --git a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift new file mode 100644 index 0000000..3d9675c --- /dev/null +++ b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift @@ -0,0 +1,212 @@ +import Client +import XCTest + +final class ExternalAutomationControllerTests: XCTestCase { + @MainActor + func testLockInvokesOnlyLockCapability() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.lock, source: .service) + await controller.waitUntilIdle() + + XCTAssertEqual(client.calls, [.lock]) + XCTAssertTrue(client.isLocked) + XCTAssertTrue(presenter.statuses.isEmpty) + XCTAssertTrue(presenter.failureBatches.isEmpty) + } + + @MainActor + func testUnlockInvokesOnlyUnlockCapability() async { + let client = RecordingExternalAutomationClient(isLocked: true) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.unlock, source: .service) + await controller.waitUntilIdle() + + XCTAssertEqual(client.calls, [.unlock]) + XCTAssertFalse(client.isLocked) + } + + @MainActor + func testStatusPresentsAuthoritativeAgentValue() async { + let client = RecordingExternalAutomationClient(isLocked: true) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.status, source: .service) + await controller.waitUntilIdle() + + XCTAssertEqual(client.calls, [.status]) + XCTAssertEqual(presenter.statuses, [.init(isLocked: true, source: .service)]) + XCTAssertTrue(presenter.failureBatches.isEmpty) + } + + @MainActor + func testAgentFailureIncludesRecoverySuggestion() async { + let client = RecordingExternalAutomationClient( + isLocked: false, + error: XPCClientError.serviceUnavailable + ) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.lock, source: .service) + await controller.waitUntilIdle() + + let batch = presenter.failureBatches.first + XCTAssertEqual(batch?.source, .service) + XCTAssertTrue(batch?.failures.first?.message.contains("not reachable") == true) + XCTAssertTrue(batch?.failures.first?.message.contains("Open KeyboardLocker once") == true) + } + + @MainActor + func testBatchPreservesDesiredStateOrder() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let controller = ExternalAutomationController( + client: client, + presenter: RecordingExternalAutomationPresenter() + ) + + controller.submit([.lock, .status, .unlock], source: .urlScheme) + await controller.waitUntilIdle() + + XCTAssertEqual(client.calls, [.lock, .status, .unlock]) + XCTAssertFalse(client.isLocked) + } + + @MainActor + func testSeparateSubmissionsStaySerializedWhileFirstActionIsSuspended() async { + let gate = AsyncGate() + let client = RecordingExternalAutomationClient(isLocked: false, lockGate: gate) + let controller = ExternalAutomationController( + client: client, + presenter: RecordingExternalAutomationPresenter() + ) + + controller.submit(.lock, source: .service) + await gate.waitUntilEntered() + controller.submit(.unlock, source: .service) + await Task.yield() + + XCTAssertEqual(client.calls, [.lock]) + + await gate.open() + await controller.waitUntilIdle() + + XCTAssertEqual(client.calls, [.lock, .unlock]) + XCTAssertFalse(client.isLocked) + } +} + +@MainActor +private final class RecordingExternalAutomationClient: AgentLockActionServing { + private(set) var calls: [ExternalAutomationAction] = [] + private(set) var isLocked: Bool + + private let error: Error? + private let lockGate: AsyncGate? + + init( + isLocked: Bool, + error: Error? = nil, + lockGate: AsyncGate? = nil + ) { + self.isLocked = isLocked + self.error = error + self.lockGate = lockGate + } + + func lock() async throws { + calls.append(.lock) + if let lockGate { + await lockGate.enterAndWait() + } + try throwConfiguredError() + isLocked = true + } + + func unlock() async throws { + calls.append(.unlock) + try throwConfiguredError() + isLocked = false + } + + func status() async throws -> Bool { + calls.append(.status) + try throwConfiguredError() + return isLocked + } + + private func throwConfiguredError() throws { + if let error { + throw error + } + } +} + +@MainActor +private final class RecordingExternalAutomationPresenter: ExternalAutomationPresenting { + struct Status: Equatable { + let isLocked: Bool + let source: ExternalAutomationSource + } + + struct FailureBatch: Equatable { + let failures: [ExternalAutomationFailure] + let source: ExternalAutomationSource + } + + private(set) var statuses: [Status] = [] + private(set) var failureBatches: [FailureBatch] = [] + + func presentStatus(isLocked: Bool, source: ExternalAutomationSource) { + statuses.append(.init(isLocked: isLocked, source: source)) + } + + func presentFailures( + _ failures: [ExternalAutomationFailure], + source: ExternalAutomationSource + ) { + failureBatches.append(.init(failures: failures, source: source)) + } +} + +private actor AsyncGate { + private var entered = false + private var isOpen = false + private var entryWaiters: [CheckedContinuation] = [] + private var openWaiters: [CheckedContinuation] = [] + + func enterAndWait() async { + entered = true + entryWaiters.forEach { $0.resume() } + entryWaiters.removeAll() + + guard !isOpen else { + return + } + + await withCheckedContinuation { continuation in + openWaiters.append(continuation) + } + } + + func waitUntilEntered() async { + guard !entered else { + return + } + + await withCheckedContinuation { continuation in + entryWaiters.append(continuation) + } + } + + func open() { + isOpen = true + openWaiters.forEach { $0.resume() } + openWaiters.removeAll() + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift new file mode 100644 index 0000000..53ac8a0 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -0,0 +1,14 @@ +import AppKit +import XCTest + +final class KeyboardLockerServicesProviderTests: XCTestCase { + func testProviderExportsEveryDeclaredServiceSelector() { + let provider = KeyboardLockerServicesProvider { _ in } + + XCTAssertTrue(provider.responds(to: NSSelectorFromString("lockKeyboard:userData:error:"))) + XCTAssertTrue(provider.responds(to: NSSelectorFromString("unlockKeyboard:userData:error:"))) + XCTAssertTrue( + provider.responds(to: NSSelectorFromString("showKeyboardLockStatus:userData:error:")) + ) + } +} diff --git a/docs/automation.md b/docs/automation.md index 3ac5529..970932f 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,6 +1,6 @@ # 自动化 -KeyboardLocker 的 Shortcuts、Focus Filter、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 +KeyboardLocker 的 Shortcuts、Focus Filter、Services、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 所有入口共享以下语义: @@ -33,6 +33,18 @@ Focus Filter 采用条件 ownership,不会把全局 lock 误当成自己的资 Focus extension 保持 sandbox,只获得访问 KeyboardLocker Agent Mach service 的 lookup 权限;Agent 仍要求同 Team 与精确 extension signing identifier。Agent 或 Accessibility 不可用时,intent 会明确失败,不会伪造 Focus 已经应用。 +## Services + +KeyboardLocker 在 macOS 的 **Services** 菜单注册三个不依赖当前选中文本的原生 action: + +- `Lock Keyboard` +- `Unlock Keyboard` +- `Show Keyboard Lock Status` + +它们可以从其他 App 的 Services 菜单调用,也可以在 **System Settings > Keyboard > Keyboard Shortcuts > Services** 中绑定全局快捷键。三个入口都交给同一个串行 executor:lock/unlock 直接发送 desired state,status 从 Agent 读取权威 Boolean 后显示提示;并发到达的 action 按接收顺序执行。 + +AppKit Services 的 handler 没有与异步 XPC 对应的 suspend/resume contract,因此 provider 只同步受理请求并立即返回,不会阻塞主线程等待 Agent。后续失败由 KeyboardLocker 激活并显示明确错误。需要调用方拿到事务级结果或 machine-readable status 时,使用 AppleScript、Shortcuts action 或 `klock status --json`,不要把 Services 的返回当作完成确认。 + ## Widget `Keyboard Lock Status` Widget 支持 small 和 medium family。每次生成 timeline 时,extension 都通过 XPC 读取 Agent 的 `LockStatusSnapshot`,并显示 locked/unlocked、auto-unlock countdown 或 manual unlock,以及当前解锁热键。它不使用 App Group、`UserDefaults` 或自己的状态缓存;Agent 不可达时会明确显示 `Agent Unavailable`。 diff --git a/docs/development.md b/docs/development.md index 8b554d7..f496b8b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,6 +47,9 @@ - `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 - `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。当前没有声明 `AppShortcutsProvider`,因此不会生成 promoted App Shortcuts 或 invocation phrases。 - `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 +- `Automation/ExternalAutomationController.swift`:Services 与 URL event 共用的串行 one-shot executor。每个 action 只调用 `AgentLockActionServing` 的对应 desired-state/query method;跨多次 submit 也保持接收顺序,并把 authoritative status 或合并后的 failure 交给 presentation boundary。 +- `Automation/KeyboardLockerServicesProvider.swift`:将 `NSServices` 的三个 Objective-C selector 适配成 `.lock` / `.unlock` / `.status`。Services handler 只同步受理,不把短生命周期的 error pointer 或 pasteboard 捕获进异步 Task,也不阻塞 AppKit 线程等待 XPC。 +- `Automation/AppKitExternalAutomationPresenter.swift`:主 App 内统一呈现外部 automation 的 status 与异步 failure;显示 alert 前激活 accessory App,避免提示留在后台。 - `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 @@ -101,7 +104,7 @@ `klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 -Shortcuts、Focus Filter、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 +Shortcuts、Focus Filter、Services、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 ### Homebrew Cask 发布计划(尚未实现) From 5a98272cfbd5abf693414aa5a4c997061892ba6e Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 17 Jul 2026 17:44:48 +0800 Subject: [PATCH 051/106] feat(url): add keyboard automation deep links --- .../Automation/ExternalAutomationAction.swift | 5 ++ .../ExternalAutomationController.swift | 46 ++++++---- .../Automation/KeyboardLockerURLRoute.swift | 58 ++++++++++++ KeyboardLocker/Info.plist | 13 +++ .../KeyboardLockerApplication.swift | 7 ++ .../ExternalAutomationControllerTests.swift | 19 ++++ .../KeyboardLockerURLRouteTests.swift | 88 +++++++++++++++++++ docs/architecture.md | 22 ++--- docs/automation.md | 18 +++- docs/development.md | 3 +- 10 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 KeyboardLocker/Automation/KeyboardLockerURLRoute.swift create mode 100644 KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift diff --git a/KeyboardLocker/Automation/ExternalAutomationAction.swift b/KeyboardLocker/Automation/ExternalAutomationAction.swift index c142eb4..1f74c9d 100644 --- a/KeyboardLocker/Automation/ExternalAutomationAction.swift +++ b/KeyboardLocker/Automation/ExternalAutomationAction.swift @@ -6,6 +6,11 @@ nonisolated enum ExternalAutomationAction: Equatable, Sendable { case status } +nonisolated enum ExternalAutomationRequest: Equatable, Sendable { + case action(ExternalAutomationAction) + case failure(ExternalAutomationFailure) +} + nonisolated enum ExternalAutomationSource: String, Equatable, Sendable { case service = "Service" case urlScheme = "URL" diff --git a/KeyboardLocker/Automation/ExternalAutomationController.swift b/KeyboardLocker/Automation/ExternalAutomationController.swift index 1624362..0310c4f 100644 --- a/KeyboardLocker/Automation/ExternalAutomationController.swift +++ b/KeyboardLocker/Automation/ExternalAutomationController.swift @@ -34,14 +34,21 @@ final class ExternalAutomationController { _ action: ExternalAutomationAction, source: ExternalAutomationSource ) { - submit([action], source: source) + submit([.action(action)], source: source) } func submit( _ actions: [ExternalAutomationAction], source: ExternalAutomationSource ) { - guard !actions.isEmpty else { + submit(actions.map(ExternalAutomationRequest.action), source: source) + } + + func submit( + _ requests: [ExternalAutomationRequest], + source: ExternalAutomationSource + ) { + guard !requests.isEmpty else { return } @@ -52,22 +59,27 @@ final class ExternalAutomationController { await previous?.value var failures: [ExternalAutomationFailure] = [] - for action in actions { - do { - switch action { - case .lock: - try await client.lock() - case .unlock: - try await client.unlock() - case .status: - let isLocked = try await client.status() - presenter.presentStatus( - isLocked: isLocked, - source: source - ) + for request in requests { + switch request { + case let .failure(failure): + failures.append(failure) + case let .action(action): + do { + switch action { + case .lock: + try await client.lock() + case .unlock: + try await client.unlock() + case .status: + let isLocked = try await client.status() + presenter.presentStatus( + isLocked: isLocked, + source: source + ) + } + } catch { + failures.append(ExternalAutomationFailure(error: error)) } - } catch { - failures.append(ExternalAutomationFailure(error: error)) } } diff --git a/KeyboardLocker/Automation/KeyboardLockerURLRoute.swift b/KeyboardLocker/Automation/KeyboardLockerURLRoute.swift new file mode 100644 index 0000000..4970f3d --- /dev/null +++ b/KeyboardLocker/Automation/KeyboardLockerURLRoute.swift @@ -0,0 +1,58 @@ +import Foundation + +nonisolated struct KeyboardLockerURLRoute: Equatable, Sendable { + static let scheme = "keyboardlocker" + + let action: ExternalAutomationAction + + init(url: URL) throws { + guard let components = URLComponents( + url: url, + resolvingAgainstBaseURL: false + ), + components.scheme?.caseInsensitiveCompare(Self.scheme) == .orderedSame, + components.user == nil, + components.password == nil, + components.port == nil, + components.percentEncodedPath.isEmpty, + components.percentEncodedQuery == nil, + components.percentEncodedFragment == nil, + let host = components.percentEncodedHost?.lowercased() + else { + throw KeyboardLockerURLRouteError.invalidURL + } + + action = switch host { + case "lock": + .lock + case "unlock": + .unlock + case "status": + .status + default: + throw KeyboardLockerURLRouteError.invalidURL + } + } + + static func requests(for urls: [URL]) -> [ExternalAutomationRequest] { + urls.map { url in + do { + return try .action(Self(url: url).action) + } catch { + return .failure(ExternalAutomationFailure(error: error)) + } + } + } +} + +nonisolated enum KeyboardLockerURLRouteError: LocalizedError, Equatable, Sendable { + case invalidURL + + var errorDescription: String? { + "KeyboardLocker rejected an invalid automation URL." + } + + var recoverySuggestion: String? { + "Use keyboardlocker://lock, keyboardlocker://unlock, or keyboardlocker://status without additional URL components." + } +} diff --git a/KeyboardLocker/Info.plist b/KeyboardLocker/Info.plist index 6a84690..1b5cb5c 100644 --- a/KeyboardLocker/Info.plist +++ b/KeyboardLocker/Info.plist @@ -16,6 +16,19 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + io.lzhlovesjyq.keyboardlocker.automation + CFBundleURLSchemes + + keyboardlocker + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) LSApplicationCategoryType diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index daea661..13331a4 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -92,4 +92,11 @@ private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDe NSApp.servicesProvider = servicesProvider statusMenuController = StatusMenuController(coordinator: AppCoordinator()) } + + func application(_: NSApplication, open urls: [URL]) { + automationController.submit( + KeyboardLockerURLRoute.requests(for: urls), + source: .urlScheme + ) + } } diff --git a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift index 3d9675c..4607104 100644 --- a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift +++ b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift @@ -77,6 +77,25 @@ final class ExternalAutomationControllerTests: XCTestCase { XCTAssertFalse(client.isLocked) } + @MainActor + func testRejectedRequestIsPresentedWithoutCallingAgent() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit( + [.failure(.init(message: "Invalid URL"))], + source: .urlScheme + ) + await controller.waitUntilIdle() + + XCTAssertTrue(client.calls.isEmpty) + XCTAssertEqual( + presenter.failureBatches, + [.init(failures: [.init(message: "Invalid URL")], source: .urlScheme)] + ) + } + @MainActor func testSeparateSubmissionsStaySerializedWhileFirstActionIsSuspended() async { let gate = AsyncGate() diff --git a/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift b/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift new file mode 100644 index 0000000..db18eee --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift @@ -0,0 +1,88 @@ +import Foundation +import XCTest + +final class KeyboardLockerURLRouteTests: XCTestCase { + func testCanonicalURLsMapToDesiredStateActions() throws { + XCTAssertEqual( + try KeyboardLockerURLRoute(url: url("keyboardlocker://lock")).action, + .lock + ) + XCTAssertEqual( + try KeyboardLockerURLRoute(url: url("keyboardlocker://unlock")).action, + .unlock + ) + XCTAssertEqual( + try KeyboardLockerURLRoute(url: url("keyboardlocker://status")).action, + .status + ) + } + + func testSchemeAndHostFollowURLCaseInsensitivity() throws { + XCTAssertEqual( + try KeyboardLockerURLRoute(url: url("KEYBOARDLOCKER://LOCK")).action, + .lock + ) + } + + func testInvalidOrAmbiguousShapesAreRejected() { + let invalidURLs = [ + "https://lock", + "keyboardlocker:lock", + "keyboardlocker://", + "keyboardlocker://toggle", + "keyboardlocker://lock/", + "keyboardlocker://lock/path", + "keyboardlocker://lock?", + "keyboardlocker://lock?source=test", + "keyboardlocker://lock#fragment", + "keyboardlocker://lock:1234", + "keyboardlocker://user@lock", + "keyboardlocker://user:password@lock", + "keyboardlocker://%6cock", + ] + + for value in invalidURLs { + XCTAssertThrowsError( + try KeyboardLockerURLRoute(url: url(value)), + "Expected \(value) to be rejected." + ) { error in + XCTAssertEqual(error as? KeyboardLockerURLRouteError, .invalidURL) + } + } + } + + func testMultipleURLsPreserveDeliveryOrderAndRepresentFailures() { + let requests = KeyboardLockerURLRoute.requests(for: [ + url("keyboardlocker://lock"), + url("keyboardlocker://invalid"), + url("keyboardlocker://unlock"), + ]) + + XCTAssertEqual(requests.count, 3) + XCTAssertEqual(requests[0], .action(.lock)) + guard case let .failure(failure) = requests[1] else { + return XCTFail("Expected the invalid route to become a failure request.") + } + XCTAssertTrue(failure.message.contains("invalid automation URL")) + XCTAssertFalse(failure.message.contains("keyboardlocker://invalid")) + XCTAssertEqual(requests[2], .action(.unlock)) + } + + func testFailureMessageNeverEchoesQueryData() { + let requests = KeyboardLockerURLRoute.requests(for: [ + url("keyboardlocker://lock?token=do-not-echo"), + ]) + + guard case let .failure(failure) = requests.first else { + return XCTFail("Expected the URL to be rejected.") + } + XCTAssertFalse(failure.message.contains("do-not-echo")) + } + + private func url(_ value: String) -> URL { + guard let url = URL(string: value) else { + preconditionFailure("The test URL must be syntactically representable: \(value)") + } + return url + } +} diff --git a/docs/architecture.md b/docs/architecture.md index fb5e44b..dcdb091 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # 核心架构契约 -**这是项目的根本规则。每一个功能面 —— App、`klock`、Shortcuts、AppleScript、Widgets 以及后续新增的任何形态 —— 都必须遵守它。当某个改动与本契约冲突时,契约优先;修改的是那个改动,而不是契约。** +**这是项目的根本规则。每一个功能面 —— App、`klock`、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets 以及后续新增的任何形态 —— 都必须遵守它。当某个改动与本契约冲突时,契约优先;修改的是那个改动,而不是契约。** ## 为什么是单核(从旧方案学到的) @@ -19,7 +19,7 @@ ## 单核原则 -**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、AppleScript、Widgets、扩展)都是**薄 wrapper**,职责仅限两件事: +**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、扩展)都是**薄 wrapper**,职责仅限两件事: 1. 把用户意图翻译成对 Agent 的一次 XPC 调用; 2. 通过通知观察全局状态。 @@ -27,12 +27,14 @@ wrapper 永远不拥有行为或状态。如果两个 wrapper 看起来需要同一段逻辑,那段逻辑属于核心,而不该复制进每个 wrapper。 ``` -App ─────┐ -CLI ─────┤ -Shortcut ┼── XPC ──▶ Agent ◀── the ONLY executor -AppleScript┤ ├─ LockEngine (event tap, lock lifecycle) -Widget ──┘ ├─ Settings ownership (source of truth) - └─ Accessibility (permission gate) +App ─────────┐ +CLI ─────────┤ +Shortcut ────┤ +Services/URL ┤ +Focus ───────┼── XPC ──▶ Agent ◀── the ONLY executor +AppleScript ─┤ ├─ LockEngine (event tap, lock lifecycle) +Widget ──────┘ ├─ Settings ownership (source of truth) + └─ Accessibility (permission gate) ``` 这里的 `Core`、SwiftPM product、运行进程和 XPC connection 是不同层次。完整的构建依赖图、进程图和调用时序见 [XPC 实现与使用指南](xpc.md)。 @@ -66,7 +68,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management 因为状态只存在于唯一一处(Agent),wrapper 保持同步的方式就是始终以它为准。wrapper 有两种形态,读取状态的方式不同: -- **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()` 或 `lockStatusSnapshot()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 +- **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、Services、URL action 与任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()` 或 `lockStatusSnapshot()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 - **长命的状态反映面** —— menu-bar App 等持续运行的 UI。它们必须既接收状态变化 signal,又在**变为可见 / 启动时校准** —— 因为进程被挂起期间可能错过某次广播。只显示布尔状态的现有 App 可以继续使用 `LockStateSubscriber`;需要起点、deadline 与 active settings 的界面在 signal 或重新可见时重新拉取 `lockStatusSnapshot()`。通知与本地缓存都不是真相源。 - **系统托管的 snapshot 面** —— Widget、Control 等由系统按需启动的 provider。它们不假定进程常驻,也不安装长命 subscription;每次生成 timeline/value 或处理 action 时读取 `lockStatusSnapshot()`,把本地值只当 presentation cache。 @@ -95,7 +97,7 @@ App 必须区分 `SMAppService` 的 enabled、requires-approval、not-found 和 App 在调用新能力前必须先读取 `ServiceDescriptor`,分别验证 XPC protocol major/minor、required capabilities 和 bundled Agent 的 identifier/version/build。descriptor 中的身份字段只用于兼容性与更新判断,不能代替 XPC connection 的代码签名认证。descriptor 必须在 fresh connection 上重试后才能降级;若两次 descriptor 都失败而旧 `status` 成功,只能断言 base contract 可达,不能断言远端一定是 legacy Agent。此时只允许使用旧 `status` / `unlock` 做显式安全迁移,不得继续调用新 selector。 -XPC peer authentication 必须由系统双向执行。Agent Listener 在 activate 前安装同 Team + 每个 wrapper 精确 signing identifier 的 connection requirement;wrapper connection 在 activate 前安装同 Team + 精确 Agent signing identifier 的 requirement。WidgetKit extension 使用单独的 `io.lzhlovesjyq.keyboardlocker.widgets` 身份承载 Widget 与 Control;它保持 App Sandbox,并只通过 Mach lookup temporary exception 访问 KeyboardLocker Agent 的 global service。Debug 不得降级成 identifier-only,不能通过 PID 后查静态签名来代替 XPC runtime 的 requirement。Team ID 从各进程自身已验证的签名读取;unsigned/ad-hoc 进程 fail closed。 +XPC peer authentication 必须由系统双向执行。Agent Listener 在 activate 前安装同 Team + 每个 wrapper 精确 signing identifier 的 connection requirement;wrapper connection 在 activate 前安装同 Team + 精确 Agent signing identifier 的 requirement。WidgetKit extension 使用单独的 `io.lzhlovesjyq.keyboardlocker.widgets` 身份承载 Widget 与 Control;Focus App Intents extension 使用 `io.lzhlovesjyq.keyboardlocker.focus-intents`;两者保持 App Sandbox,并只通过 Mach lookup temporary exception 访问 KeyboardLocker Agent 的 global service。Services 与 URL event 在主 App 进程内执行,因此 XPC peer 仍是主 App;这只认证 KeyboardLocker,不认证最初触发 Service 或 custom URL 的外部 caller。Debug 不得降级成 identifier-only,不能通过 PID 后查静态签名来代替 XPC runtime 的 requirement。Team ID 从各进程自身已验证的签名读取;unsigned/ad-hoc 进程 fail closed。 capability grant 必须绑定到读取 descriptor 的同一条 client-side connection generation。所有 optional method 都要在一条具体 connection 上完成 handshake 与 capability check,再经同一个 `NSXPCConnection` 发出;connection 失效就同时失去 grant。named connection object 仍可能在 interruption 后面对新进程,因此 replacement wire request 必须携带 expected `agentInstanceID`,由 Agent 在副作用前原子验证,Client 也必须验证返回 ticket。 diff --git a/docs/automation.md b/docs/automation.md index 970932f..074e96b 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,6 +1,6 @@ # 自动化 -KeyboardLocker 的 Shortcuts、Focus Filter、Services、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 +KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 所有入口共享以下语义: @@ -45,6 +45,22 @@ KeyboardLocker 在 macOS 的 **Services** 菜单注册三个不依赖当前选 AppKit Services 的 handler 没有与异步 XPC 对应的 suspend/resume contract,因此 provider 只同步受理请求并立即返回,不会阻塞主线程等待 Agent。后续失败由 KeyboardLocker 激活并显示明确错误。需要调用方拿到事务级结果或 machine-readable status 时,使用 AppleScript、Shortcuts action 或 `klock status --json`,不要把 Services 的返回当作完成确认。 +## URL Scheme + +Launcher、浏览器或其他本地 App 可以打开三个 canonical deep link: + +```text +keyboardlocker://lock +keyboardlocker://unlock +keyboardlocker://status +``` + +Parser 对 scheme 与 host 使用 URL 规定的大小写不敏感比较,但只接受上述 `scheme://host` 结构。userinfo、port、path(包括尾随 `/`)、query、fragment、percent-encoded action 与未知 host 都会被拒绝;错误提示不会回显原始 URL,避免意外暴露调用方放入 query 的数据。一次收到多个 URL 时按交付顺序串行执行。 + +`lock` / `unlock` 成功时静默完成;`status` 读取 Agent 的权威 Boolean 并显示 alert。custom URL scheme 没有结果返回通道,因此 machine-readable 查询仍使用 `klock status --json`。 + +安全边界必须明确:macOS custom URL scheme 不提供 caller authentication,任何能请求系统打开 URL 的本地 App 或网页都可能触发这些 action。Agent 只会看到经过签名认证的 KeyboardLocker App,无法识别最初调用方;严格 parsing 解决的是输入歧义,不是授权。不要在 query 中加入 token 来伪装鉴权。当前实现把这个 unauthenticated convenience surface 作为公开 contract,包括 `unlock`。 + ## Widget `Keyboard Lock Status` Widget 支持 small 和 medium family。每次生成 timeline 时,extension 都通过 XPC 读取 Agent 的 `LockStatusSnapshot`,并显示 locked/unlocked、auto-unlock countdown 或 manual unlock,以及当前解锁热键。它不使用 App Group、`UserDefaults` 或自己的状态缓存;Agent 不可达时会明确显示 `Agent Unavailable`。 diff --git a/docs/development.md b/docs/development.md index f496b8b..a1d8f6a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,6 +49,7 @@ - `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 - `Automation/ExternalAutomationController.swift`:Services 与 URL event 共用的串行 one-shot executor。每个 action 只调用 `AgentLockActionServing` 的对应 desired-state/query method;跨多次 submit 也保持接收顺序,并把 authoritative status 或合并后的 failure 交给 presentation boundary。 - `Automation/KeyboardLockerServicesProvider.swift`:将 `NSServices` 的三个 Objective-C selector 适配成 `.lock` / `.unlock` / `.status`。Services handler 只同步受理,不把短生命周期的 error pointer 或 pasteboard 捕获进异步 Task,也不阻塞 AppKit 线程等待 XPC。 +- `Automation/KeyboardLockerURLRoute.swift`:只把 `keyboardlocker://lock|unlock|status` 的严格 canonical URL 映射为 action。它拒绝额外 URL component,不回显原始输入,并把多个 URL 转成保序的 action/failure request;custom scheme 不声称 caller authentication。 - `Automation/AppKitExternalAutomationPresenter.swift`:主 App 内统一呈现外部 automation 的 status 与异步 failure;显示 alert 前激活 accessory App,避免提示留在后台。 - `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 @@ -104,7 +105,7 @@ `klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 -Shortcuts、Focus Filter、Services、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 +Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 ### Homebrew Cask 发布计划(尚未实现) From 11197959f1f95cecbd1aa839e16a8bc6ec8fb1f4 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 15:49:26 +0800 Subject: [PATCH 052/106] fix(focus): align activation semantics --- .../KeyboardLockFocusFilterIntent.swift | 2 +- docs/architecture.md | 2 +- docs/automation.md | 9 +++++---- docs/development.md | 6 +++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift index 3dde45f..419a45d 100644 --- a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift +++ b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift @@ -14,7 +14,7 @@ struct LiveAgentFocusLockClient: AgentFocusLockServing { struct KeyboardLockFocusFilterIntent: SetFocusFilterIntent { static let title: LocalizedStringResource = "Keyboard Lock" static let description = IntentDescription( - "Locks keyboard input while this Focus is active." + "Creates a keyboard lock when this Focus activates. It can unlock normally and won't automatically re-lock while the Focus remains active." ) @Parameter(title: "Lock Keyboard", default: false) diff --git a/docs/architecture.md b/docs/architecture.md index dcdb091..fc62a25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,7 +60,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 - interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。 -- Focus Filter 使用 Agent 内部的 lock generation fence,而不是通用 client lease:Focus 只在自己完成 `unlocked → locked` 时记录该代锁;Focus 关闭只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领,自动解锁、热键、显式 unlock 或 event-tap failure 都会清除 generation marker。这个 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 +- Focus Filter 是 **activation-triggered acquisition**,不是“Focus active 期间持续保持 locked”的 policy。Focus 激活时的 `true` 最多尝试一次 `unlocked → locked`,并只在成功创建锁时记录该 Focus-owned generation;Focus 关闭时的 `false` 只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领。显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束该 generation;实现不会查询当前 Focus 后自动重建锁,也不会在同一次 Focus activation 内持续 relock。这个进程内 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 - `status()` 保留为兼容旧 wrapper 的最小布尔查询;需要呈现锁定时长、auto-unlock deadline 或 active settings 的 wrapper 使用 capability-gated `lockStatusSnapshot()`。snapshot 传输权威时间点,不传会立即过期的 duration/countdown counter。 - 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 diff --git a/docs/automation.md b/docs/automation.md index 074e96b..b1a637f 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -22,14 +22,15 @@ KeyboardLocker 提供三个可组合 action: ## Focus Filter -macOS 13 及以上可在 **System Settings > Focus** 中为某个 Focus 添加 KeyboardLocker 的 `Keyboard Lock` filter。启用该 Focus 时,系统把 `Lock Keyboard = true` 发送给独立的 App Intents extension;关闭时会用参数默认值 `false` 再次执行 intent,因此主 App 未运行时也能经 XPC 把 desired state 交给 Agent。 +macOS 13 及以上可在 **System Settings > Focus** 中为某个 Focus 添加 KeyboardLocker 的 `Keyboard Lock` filter。启用该 Focus 时,系统把 `Lock Keyboard = true` 发送给独立的 App Intents extension,触发一次 lock acquisition;关闭时会用参数默认值 `false` 再次执行 intent,对该次 activation 创建的 generation 做条件清理。因此主 App 未运行时,系统交付的 Focus 生命周期事件仍能经 XPC 到达 Agent。 -Focus Filter 采用条件 ownership,不会把全局 lock 误当成自己的资源: +Focus Filter 是 activation-triggered wrapper,不是“只要 Focus active 就持续保持 locked”的 policy。它采用条件 ownership,不会把全局 lock 误当成自己的资源: - 若 Focus 启用时已经由其他入口锁定,它不会认领该锁;Focus 关闭时保持 locked。 -- 若 Focus 从 unlocked 创建锁,它只拥有该次 lock generation。 +- 若 Focus 从 unlocked 创建锁,它只拥有该次 activation 创建的一个 lock generation;使用 Agent 当前 settings,包括现有 auto-unlock policy。 - 若随后任一普通 wrapper 再次显式调用 `lock`,该用户意图会接管同一 generation 的持久性,但不会重建 event tap、改变活动设置或延长 auto-unlock deadline;Focus 关闭时保持 locked。 -- 若原 generation 已经由显式 unlock、热键或 timeout 结束,迟到的 Focus 关闭事件不能解除之后新建的锁。 +- 显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束原 generation;迟到的 Focus 关闭事件不能解除之后新建的锁。 +- generation 提前结束后,KeyboardLocker 不会因为同一个 Focus 仍 active 而自动 relock;Agent 重启也不会查询或 replay 当前 Focus。需要重新触发时,切换 Focus 或从任一普通 wrapper 显式执行 `lock`。 Focus extension 保持 sandbox,只获得访问 KeyboardLocker Agent Mach service 的 lookup 权限;Agent 仍要求同 Team 与精确 extension signing identifier。Agent 或 Accessibility 不可用时,intent 会明确失败,不会伪造 Focus 已经应用。 diff --git a/docs/development.md b/docs/development.md index a1d8f6a..a6b1ebc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,7 +15,7 @@ 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 6. wrapper 通过 `LockStateSubscriber.subscribe(initialState:_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态。subscriber 会在 observer 安装完成后立即拉取一次权威初始状态,后续信号串行合并并再次查询 —— 绝不从"我这次调用是否成功"或通知 payload 推断。 -> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 对物理运行状态是**严格幂等**的:已锁时重复调用不会重建 event tap、修改当前设置、锁定起点或 auto-unlock deadline。唯一的 metadata 变化是普通 wrapper 的显式 `lock` 会接管 Focus 创建的当前 generation,使之后的 Focus disable 不再撤销这个更新的用户意图。只有显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window。不存在客户端持有的通用"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 +> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 对物理运行状态是**严格幂等**的:已锁时重复调用不会重建 event tap、修改当前设置、锁定起点或 auto-unlock deadline。唯一的 metadata 变化是普通 wrapper 的显式 `lock` 会接管 Focus 创建的当前 generation,使之后的 Focus disable 不再撤销这个更新的用户意图。Focus 本身是 activation-triggered:一次 activation 最多创建一个 Focus-owned generation,不承诺在 Focus active 期间持续 relock;显式 unlock、热键、timeout、event-tap failure 或 Agent restart 都可以让它提前结束。只有显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window。不存在客户端持有的通用"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 ## 组件地图 @@ -36,7 +36,7 @@ - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 +- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 @@ -64,7 +64,7 @@ - `KeyboardLockerWidgets.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent 仍以同 Team + 精确 extension identifier 的 listener requirement 独立认证调用方。 **App Intents extension**(`KeyboardLockerFocusIntents/`)—— sandboxed、按需运行的 Focus Filter wrapper,只调用 Client -- `KeyboardLockFocusFilterIntent.swift`:macOS 13+ `SetFocusFilterIntent`;参数默认值为 `false`,使 Focus 关闭时向 Agent 发送 disable。`perform()` 只调用 capability-gated `setFocusFilterLockEnabled`,不以普通 `unlock` 模拟条件释放。 +- `KeyboardLockFocusFilterIntent.swift`:macOS 13+ `SetFocusFilterIntent`;参数默认值为 `false`,使 Focus 关闭时向 Agent 发送该 activation generation 的条件 disable。`true` 是一次 activation-triggered acquisition,不是 while-active keep-alive;`perform()` 只调用 capability-gated `setFocusFilterLockEnabled`,不以普通 `unlock` 模拟条件释放,也不在 Agent restart 后查询或 replay 当前 Focus。 - `AppIntentsExtension.swift` / `Info.plist`:独立 `com.apple.appintents-extension` 入口,使主 App 未运行时系统仍可执行 Focus 生命周期事件。 - `KeyboardLockerFocusIntents.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent allowlist 只新增 `io.lzhlovesjyq.keyboardlocker.focus-intents` 精确 signing identifier。 From 8eb06a6e937c3f753415750ea9b754de44b83ab6 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 15:52:20 +0800 Subject: [PATCH 053/106] test(focus): cover lock ownership lifecycle --- Core/Sources/Service/LockEngine.swift | 50 ++++++-- .../ServiceTests/LockRuntimeStateTests.swift | 108 +++++++++++++++++- .../KeyboardLockFocusFilterIntentTests.swift | 4 + 3 files changed, 149 insertions(+), 13 deletions(-) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index ae9e899..52ec0c7 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -36,6 +36,11 @@ struct AutoUnlockSchedule: Equatable { } } +enum LockRequestSource { + case focusFilter + case general +} + struct LockRuntimeState: Equatable { private(set) var activeSettings: KeyboardLockerSettings = .default private(set) var allowsControlCUnlock = false @@ -71,12 +76,41 @@ struct LockRuntimeState: Equatable { focusOwnedLockGeneration = lockGeneration } + /// Handles a request that arrives while the physical lock already exists. Focus observes an + /// existing lock without claiming it; an explicit general request takes persistence over from + /// a Focus-created generation without changing the physical runtime state. + mutating func handleDuplicateLockRequest( + from source: LockRequestSource + ) -> LockRequestOutcome? { + guard isLocked else { + return nil + } + + if source == .general { + takeOverFocusOwnedLock() + } + return .alreadyLocked + } + /// Converts a Focus-created lock into an ordinary global desired lock without changing any /// physical runtime state. A later Focus deactivation must no longer release it. mutating func takeOverFocusOwnedLock() { focusOwnedLockGeneration = nil } + var focusOwnedGenerationForRelease: UInt64? { + guard isLocked, + focusOwnedLockGeneration == lockGeneration + else { + return nil + } + return focusOwnedLockGeneration + } + + func matchesCurrentLockGeneration(_ generation: UInt64) -> Bool { + isLocked && lockGeneration == generation + } + mutating func updateSettings(_ settings: KeyboardLockerSettings) { activeSettings = settings } @@ -263,11 +297,6 @@ public final class LockEngine { private var autoUnlockTimer: DispatchSourceTimer? private var runtimeState = LockRuntimeState() - private enum LockRequestSource { - case focusFilter - case general - } - public var isLocked: Bool { runtimeState.isLocked } @@ -311,7 +340,7 @@ public final class LockEngine { allowsControlCUnlock: false, source: .focusFilter ) - } else if let generation = runtimeState.focusOwnedLockGeneration { + } else if let generation = runtimeState.focusOwnedGenerationForRelease { unlock(ifLockGeneration: generation) } } @@ -324,11 +353,8 @@ public final class LockEngine { // A duplicate never mutates the physical lock, its settings, gestures, start time, or // deadline. An explicit non-Focus desired-lock does take over persistence from Focus so a // later Focus deactivation cannot undo the user's newer intent. - if runtimeState.isLocked { - if source == .general { - runtimeState.takeOverFocusOwnedLock() - } - return .alreadyLocked + if let duplicateOutcome = runtimeState.handleDuplicateLockRequest(from: source) { + return duplicateOutcome } // Verify Accessibility permission before attempting to create event tap. @@ -469,7 +495,7 @@ public final class LockEngine { } private func unlock(ifLockGeneration generation: UInt64) { - guard runtimeState.lockGeneration == generation else { + guard runtimeState.matchesCurrentLockGeneration(generation) else { return } unlock() diff --git a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift index b826869..31e1679 100644 --- a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift +++ b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift @@ -99,6 +99,70 @@ final class LockRuntimeStateTests: XCTestCase { XCTAssertEqual(state.startedAt, secondStart) } + func testNewFocusLockOwnsItsGeneration() throws { + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + + XCTAssertEqual( + state.begin( + settings: .default, + allowsControlCUnlock: false, + at: startedAt + ), + .acquired + ) + state.markCurrentLockAsFocusOwned() + + let generation = try XCTUnwrap(state.lockGeneration) + XCTAssertEqual(state.focusOwnedGenerationForRelease, generation) + XCTAssertTrue(state.matchesCurrentLockGeneration(generation)) + } + + func testFocusDuplicateDoesNotClaimExistingGeneralLock() throws { + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: startedAt + ) + let generation = try XCTUnwrap(state.lockGeneration) + + XCTAssertEqual( + state.handleDuplicateLockRequest(from: .focusFilter), + .alreadyLocked + ) + + XCTAssertNil(state.focusOwnedGenerationForRelease) + XCTAssertEqual(state.lockGeneration, generation) + XCTAssertEqual(state.startedAt, startedAt) + } + + func testRepeatedFocusEnablePreservesOwnedRuntimeState() { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + _ = state.begin( + settings: settings, + allowsControlCUnlock: false, + at: startedAt + ) + state.markCurrentLockAsFocusOwned() + state.setAutoUnlockTargetDate(deadline) + let originalState = state + + XCTAssertEqual( + state.handleDuplicateLockRequest(from: .focusFilter), + .alreadyLocked + ) + + XCTAssertEqual(state, originalState) + } + func testManualDesiredLockTakesOverFocusOwnershipWithoutChangingRuntimeState() throws { let settings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), @@ -120,7 +184,10 @@ final class LockRuntimeStateTests: XCTestCase { state.setAutoUnlockTargetDate(deadline) let generation = try XCTUnwrap(state.lockGeneration) - state.takeOverFocusOwnedLock() + XCTAssertEqual( + state.handleDuplicateLockRequest(from: .general), + .alreadyLocked + ) XCTAssertNil(state.focusOwnedLockGeneration) XCTAssertEqual(state.lockGeneration, generation) @@ -158,6 +225,45 @@ final class LockRuntimeStateTests: XCTestCase { XCTAssertNotEqual(state.lockGeneration, firstGeneration) XCTAssertNil(state.focusOwnedLockGeneration) + XCTAssertNil(state.focusOwnedGenerationForRelease) + XCTAssertFalse(state.matchesCurrentLockGeneration(firstGeneration)) + } + + func testEarlyUnlockClearsFocusReleaseTarget() throws { + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: Date(timeIntervalSinceReferenceDate: 1000) + ) + state.markCurrentLockAsFocusOwned() + let generation = try XCTUnwrap(state.focusOwnedGenerationForRelease) + + state.end() + + XCTAssertNil(state.focusOwnedGenerationForRelease) + XCTAssertFalse(state.matchesCurrentLockGeneration(generation)) + } + + func testStaleFocusReleaseCannotMatchLaterLockGeneration() throws { + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: Date(timeIntervalSinceReferenceDate: 1000) + ) + state.markCurrentLockAsFocusOwned() + let focusGeneration = try XCTUnwrap(state.focusOwnedGenerationForRelease) + + state.end() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: Date(timeIntervalSinceReferenceDate: 1001) + ) + + XCTAssertFalse(state.matchesCurrentLockGeneration(focusGeneration)) + XCTAssertNil(state.focusOwnedGenerationForRelease) } func testSnapshotCapturesOneCoherentRuntimeState() { diff --git a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift index 36acda6..5cd29f0 100644 --- a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift @@ -1,6 +1,10 @@ import XCTest final class KeyboardLockFocusFilterIntentTests: XCTestCase { + func testDefaultFilterDoesNotRequestALock() { + XCTAssertFalse(KeyboardLockFocusFilterIntent().lockKeyboard) + } + func testEnabledFilterForwardsTrueToAgent() async throws { let client = FakeAgentFocusLockClient() From 86f799b7ba13a37d6bb4256f13da3f8f6a138f7f Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 15:52:27 +0800 Subject: [PATCH 054/106] test(client): cover idempotent mutation retries --- .../Client/IdempotentMutationRetrier.swift | 19 +++ Core/Sources/Client/XPCClient.swift | 38 +++-- .../IdempotentMutationRetrierTests.swift | 138 ++++++++++++++++++ 3 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 Core/Sources/Client/IdempotentMutationRetrier.swift create mode 100644 Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift diff --git a/Core/Sources/Client/IdempotentMutationRetrier.swift b/Core/Sources/Client/IdempotentMutationRetrier.swift new file mode 100644 index 0000000..d4ccdf0 --- /dev/null +++ b/Core/Sources/Client/IdempotentMutationRetrier.swift @@ -0,0 +1,19 @@ +/// Retries a mutation only when the first reply times out and resets the transport boundary before +/// doing so. Callers use this only for operations whose complete semantic effect is idempotent. +enum IdempotentMutationRetrier { + static func perform( + operation: @escaping @Sendable () async throws -> Void, + resetConnection: @escaping @Sendable () -> Void + ) async throws { + do { + try await operation() + } catch XPCClientError.timedOut { + resetConnection() + do { + try await operation() + } catch XPCClientError.timedOut { + throw XPCClientError.operationOutcomeUnknown + } + } + } +} diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 3990c4c..f404b5a 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -95,19 +95,17 @@ public final class XPCClient: @unchecked Sendable { } public func lock() async throws { - do { - try await lockOnce() - } catch XPCClientError.timedOut { - // A general desired-lock can take persistence over from a Focus-created lock even when the - // physical state is already locked. Status cannot prove that this semantic mutation ran, - // so retry the idempotent request and require a reply from a fresh connection. - resetConnection() - do { + // A general desired-lock can take persistence over from a Focus-created lock even when the + // physical state is already locked. Status cannot prove that this semantic mutation ran, + // so retry the idempotent request and require a reply from a fresh connection. + try await IdempotentMutationRetrier.perform( + operation: { [self] in try await lockOnce() - } catch XPCClientError.timedOut { - throw XPCClientError.operationOutcomeUnknown + }, + resetConnection: { [self] in + resetConnection() } - } + ) } private func lockOnce() async throws { @@ -142,18 +140,16 @@ public final class XPCClient: @unchecked Sendable { /// Applies the system Focus Filter state through an ownership-aware Agent operation. public func setFocusFilterLockEnabled(_ enabled: Bool) async throws { - do { - try await setFocusFilterLockEnabledOnce(enabled) - } catch XPCClientError.timedOut { - // Both enable and disable are idempotent, but status alone cannot reveal whether Focus owns - // the current generation. Re-negotiate and require one confirmed reply. - resetConnection() - do { + // Both enable and disable are idempotent, but status alone cannot reveal whether Focus owns + // the current generation. Re-negotiate and require one confirmed reply. + try await IdempotentMutationRetrier.perform( + operation: { [self] in try await setFocusFilterLockEnabledOnce(enabled) - } catch XPCClientError.timedOut { - throw XPCClientError.operationOutcomeUnknown + }, + resetConnection: { [self] in + resetConnection() } - } + ) } private func setFocusFilterLockEnabledOnce(_ enabled: Bool) async throws { diff --git a/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift new file mode 100644 index 0000000..8173320 --- /dev/null +++ b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift @@ -0,0 +1,138 @@ +@testable import Client +import Foundation +import XCTest + +final class IdempotentMutationRetrierTests: XCTestCase { + func testSuccessfulMutationDoesNotResetOrRetry() async throws { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.success], recorder: recorder) + + try await perform(operation: operation, recorder: recorder) + + XCTAssertEqual(recorder.calls, [.attempt]) + } + + func testTimeoutResetsConnectionBeforeSingleRetry() async throws { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .success], recorder: recorder) + + try await perform(operation: operation, recorder: recorder) + + XCTAssertEqual(recorder.calls, [.attempt, .reset, .attempt]) + } + + func testSecondTimeoutBecomesUnknownWithoutAnotherRetry() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .timedOut], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + XCTFail("Expected the second timeout to become an unknown outcome.") + } catch XPCClientError.operationOutcomeUnknown { + XCTAssertEqual(recorder.calls, [.attempt, .reset, .attempt]) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testNonTimeoutFailureDoesNotResetOrRetry() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.failure], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + XCTFail("Expected the original failure to propagate.") + } catch MutationTestError.expected { + XCTAssertEqual(recorder.calls, [.attempt]) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testRetryPropagatesNonTimeoutFailure() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .failure], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + XCTFail("Expected the retry failure to propagate.") + } catch MutationTestError.expected { + XCTAssertEqual(recorder.calls, [.attempt, .reset, .attempt]) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + private func perform( + operation: ScriptedOperation, + recorder: CallRecorder + ) async throws { + try await IdempotentMutationRetrier.perform( + operation: { + try await operation.perform() + }, + resetConnection: { + recorder.record(.reset) + } + ) + } +} + +private actor ScriptedOperation { + private var outcomes: [MutationOutcome] + private let recorder: CallRecorder + + init(outcomes: [MutationOutcome], recorder: CallRecorder) { + self.outcomes = outcomes + self.recorder = recorder + } + + func perform() throws { + recorder.record(.attempt) + guard !outcomes.isEmpty else { + XCTFail("The operation ran more times than expected.") + return + } + + switch outcomes.removeFirst() { + case .failure: + throw MutationTestError.expected + case .success: + return + case .timedOut: + throw XPCClientError.timedOut + } + } +} + +private enum MutationOutcome: Sendable { + case failure + case success + case timedOut +} + +private enum MutationTestError: Error { + case expected +} + +private final class CallRecorder: @unchecked Sendable { + enum Call: Equatable { + case attempt + case reset + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} From dbe9a50f81526dfff8774baefb13d0818cd9153b Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 15:55:22 +0800 Subject: [PATCH 055/106] feat(widget): add interactive lock actions --- ...KeyboardLockerInteractiveWidgetTests.swift | 94 +++++++++++++++++++ .../KeyboardLockerStatusWidget.swift | 32 ++++++- .../KeyboardLockerWidgetAction.swift | 52 ++++++++++ .../KeyboardLockerWidgetTimeline.swift | 4 + docs/automation.md | 4 +- docs/development.md | 5 +- 6 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift create mode 100644 KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift diff --git a/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift b/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift new file mode 100644 index 0000000..e82b265 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift @@ -0,0 +1,94 @@ +import XCTest + +@available(macOS 14.0, *) +final class KeyboardLockerInteractiveWidgetTests: XCTestCase { + func testIntentUsesExplicitLockedStateThenReloads() async throws { + let recorder = CallRecorder() + let intent = SetKeyboardLockWidgetIntent( + desiredIsLocked: true, + action: makeAction(recorder: recorder) + ) + + _ = try await intent.perform() + + let calls = await recorder.calls + XCTAssertEqual(calls, [.lock, .reload]) + } + + func testIntentUsesExplicitUnlockedStateThenReloads() async throws { + let recorder = CallRecorder() + let intent = SetKeyboardLockWidgetIntent( + desiredIsLocked: false, + action: makeAction(recorder: recorder) + ) + + _ = try await intent.perform() + + let calls = await recorder.calls + XCTAssertEqual(calls, [.unlock, .reload]) + } + + func testIntentPropagatesFailureWithoutReloading() async { + let recorder = CallRecorder() + let intent = SetKeyboardLockWidgetIntent( + desiredIsLocked: true, + action: KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + throw TestError.expected + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + ) + + do { + _ = try await intent.perform() + XCTFail("Expected the intent to propagate the Agent failure.") + } catch { + XCTAssertEqual(error as? TestError, .expected) + } + + let calls = await recorder.calls + XCTAssertEqual(calls, [.lock]) + } + + private func makeAction(recorder: CallRecorder) -> KeyboardLockerControlAction { + KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + } +} + +@available(macOS 14.0, *) +private extension KeyboardLockerInteractiveWidgetTests { + enum TestError: Error { + case expected + } + + enum Call: Equatable { + case lock + case unlock + case reload + } + + actor CallRecorder { + private(set) var calls: [Call] = [] + + func record(_ call: Call) { + calls.append(call) + } + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift index 603f2e6..30f16cd 100644 --- a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift +++ b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift @@ -1,9 +1,10 @@ +import AppIntents import Client import SwiftUI import WidgetKit struct KeyboardLockerStatusWidget: Widget { - nonisolated static let kind = "io.lzhlovesjyq.keyboardlocker.status" + nonisolated static let kind = KeyboardLockerWidgetKind.status var body: some WidgetConfiguration { StaticConfiguration( @@ -55,6 +56,10 @@ private struct KeyboardLockerWidgetEntryView: View { } else { Label("Ready", systemImage: "checkmark.circle") } + + if #available(macOS 14.0, *) { + KeyboardLockerWidgetActionButton(isLocked: snapshot.isLocked) + } } case let .unavailable(message): @@ -72,6 +77,31 @@ private struct KeyboardLockerWidgetEntryView: View { } } +@available(macOS 14.0, *) +private struct KeyboardLockerWidgetActionButton: View { + let isLocked: Bool + + var body: some View { + Group { + if isLocked { + Button( + intent: SetKeyboardLockWidgetIntent(desiredIsLocked: false) + ) { + Label("Unlock", systemImage: "lock.open") + } + } else { + Button( + intent: SetKeyboardLockWidgetIntent(desiredIsLocked: true) + ) { + Label("Lock", systemImage: "lock") + } + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } +} + private struct KeyboardLockerWidgetBackground: ViewModifier { func body(content: Content) -> some View { if #available(macOS 14.0, *) { diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift new file mode 100644 index 0000000..2a0eaa8 --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift @@ -0,0 +1,52 @@ +import AppIntents +import Client +import WidgetKit + +@available(macOS 14.0, *) +struct SetKeyboardLockWidgetIntent: AppIntent { + static let title: LocalizedStringResource = "Set Keyboard Lock" + static let description = IntentDescription( + "Sets the global keyboard lock to the requested state from the widget." + ) + static let isDiscoverable = false + static let openAppWhenRun = false + + @Parameter(title: "Locked") + var desiredIsLocked: Bool + + private let action: KeyboardLockerControlAction + + init() { + action = .widgetLive + } + + init( + desiredIsLocked: Bool, + action: KeyboardLockerControlAction = .widgetLive + ) { + self.action = action + self.desiredIsLocked = desiredIsLocked + } + + func perform() async throws -> some IntentResult { + try await action.setLocked(desiredIsLocked) + return .result() + } +} + +@available(macOS 14.0, *) +private extension KeyboardLockerControlAction { + static var widgetLive: Self { + Self( + lock: { + try await XPCClient.shared.lock() + }, + unlock: { + try await XPCClient.shared.unlock() + }, + reload: { + WidgetCenter.shared.reloadTimelines(ofKind: KeyboardLockerWidgetKind.status) + } + ) + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift index 8ac3346..0185d12 100644 --- a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift @@ -2,6 +2,10 @@ import Client import Foundation import WidgetKit +enum KeyboardLockerWidgetKind { + static let status = "io.lzhlovesjyq.keyboardlocker.status" +} + struct KeyboardLockerWidgetEntry: TimelineEntry, Equatable { enum State: Equatable { case available(LockStatusSnapshot) diff --git a/docs/automation.md b/docs/automation.md index b1a637f..b22c780 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -66,13 +66,15 @@ Parser 对 scheme 与 host 使用 URL 规定的大小写不敏感比较,但只 `Keyboard Lock Status` Widget 支持 small 和 medium family。每次生成 timeline 时,extension 都通过 XPC 读取 Agent 的 `LockStatusSnapshot`,并显示 locked/unlocked、auto-unlock countdown 或 manual unlock,以及当前解锁热键。它不使用 App Group、`UserDefaults` 或自己的状态缓存;Agent 不可达时会明确显示 `Agent Unavailable`。 +macOS 14 及以上的 Widget 提供 `Lock` / `Unlock` 按钮。按钮把当前展示的 snapshot 翻译成明确的 desired state:`Lock` 只调用幂等 `lock`,`Unlock` 只调用幂等 `unlock`,不会先重新读取状态再执行 client-side toggle。即使用户点击时 snapshot 已过期,action 也不会被翻译成相反方向。Agent 确认成功后才请求刷新 timeline;调用失败会由 App Intent 明确返回错误,不做 optimistic update。macOS 13 上保持只读。 + Widget 会请求每分钟校准一次,若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。该时间只是 WidgetKit timeline policy;系统可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本会由系统持续渲染,但从其他 wrapper 发起的手动 lock/unlock 可能要到下一次获准刷新才显示。 ## Control macOS 26 及以上提供 `Keyboard Lock` Control。Control 的 value provider 每次经 XPC 查询 Agent 的权威 Boolean;用户操作产生的是明确的 desired state:on 调用 `lock`,off 调用 `unlock`。它不会先读取旧值再做 client-side toggle,因此并发状态变化不会把一次操作翻译成错误方向。 -Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` Widget timeline 和 Control value。调用失败会由 App Intent 明确返回错误,也不会先刷新出未经确认的 UI 状态。Control API 在当前 macOS SDK 中从 macOS 26 起可用;macOS 13–15 仍只注册状态 Widget。 +Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` Widget timeline 和 Control value。调用失败会由 App Intent 明确返回错误,也不会先刷新出未经确认的 UI 状态。Control API 在当前 macOS SDK 中从 macOS 26 起可用;macOS 13–15 不注册 Control,其中 macOS 14–15 的状态 Widget 可交互,macOS 13 的状态 Widget 只读。 ## AppleScript diff --git a/docs/development.md b/docs/development.md index a6b1ebc..f9d05b2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,10 +57,11 @@ - `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。 **WidgetKit extension**(`KeyboardLockerWidgets/`)—— sandboxed、按需运行的 Widget/Control wrapper,只调用 Client -- `KeyboardLockerControlModel.swift`:Control 的可测试纯协调模型。value loader 直接返回 Agent 查询结果;desired-state action 只在 XPC lock/unlock 成功后请求 reload,不做 client-side toggle。 +- `KeyboardLockerControlModel.swift`:Widget/Control 共用的可测试纯协调模型。Control value loader 直接返回 Agent 查询结果;desired-state action 只在 XPC lock/unlock 成功后请求 reload,不做 client-side toggle。 - `KeyboardLockerControl.swift`:macOS 26+ `Keyboard Lock` Control、XPC live adapters 与 `SetValueIntent`。on/off 分别映射到幂等 `lock`/`unlock`,成功后刷新 Control value 和状态 Widget timeline。 +- `KeyboardLockerWidgetAction.swift`:macOS 14+ 状态 Widget 的内部、不可发现 `AppIntent`;`Lock` / `Unlock` 映射到明确 desired state,成功后请求刷新 timeline,失败则原样传播。 - `KeyboardLockerWidgetTimeline.swift`:每次 timeline execution 经 `XPCClient.lockStatusSnapshot()` 读取 Agent 的权威原子快照。loader 把 transport failure 建模为显式 unavailable entry,并请求 regular refresh 或更早的 auto-unlock deadline reconciliation;不订阅长命通知、不维护第二份状态。 -- `KeyboardLockerStatusWidget.swift`:small/medium 状态 presentation,显示 locked/unlocked、deadline、解锁热键与 Agent unavailable。WidgetKit 可以合并 timeline policy,因此该 UI 不承诺实时刷新。 +- `KeyboardLockerStatusWidget.swift`:small/medium 状态 presentation,显示 locked/unlocked、deadline、解锁热键与 Agent unavailable;macOS 14+ 提供 explicit desired-state action,macOS 13 保持只读。WidgetKit 可以合并 timeline policy,因此该 UI 不承诺实时刷新。 - `KeyboardLockerWidgets.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent 仍以同 Team + 精确 extension identifier 的 listener requirement 独立认证调用方。 **App Intents extension**(`KeyboardLockerFocusIntents/`)—— sandboxed、按需运行的 Focus Filter wrapper,只调用 Client From 32e991d505c88729d4dc926d37233c303307c1f7 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 16:05:47 +0800 Subject: [PATCH 056/106] feat(surfaces): centralize WidgetKit invalidation --- CLAUDE.md | 7 +-- Core/Package.swift | 3 ++ .../LockStateSurfaceInvalidator.swift | 42 +++++++++++++++ .../LockStateSurfaceInvalidatorTests.swift | 54 +++++++++++++++++++ KeyboardLocker.xcodeproj/project.pbxproj | 28 ++++++++++ .../KeyboardLockerControl.swift | 21 +------- .../KeyboardLockerControlModel.swift | 18 +++++++ .../KeyboardLockerStatusWidget.swift | 3 +- .../KeyboardLockerWidgetAction.swift | 23 +------- .../KeyboardLockerWidgetTimeline.swift | 4 -- docs/development.md | 3 ++ docs/xpc.md | 33 +++++++----- 12 files changed, 177 insertions(+), 62 deletions(-) create mode 100644 Core/Sources/SystemSurfaces/LockStateSurfaceInvalidator.swift create mode 100644 Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 9e9e4e8..a81c5d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,11 +6,12 @@ Guidance for Claude Code when working in this repository. KeyboardLocker suppresses standard keyboard events via CGEventTap on macOS. Mouse input and system-defined media keys remain available. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): -- **KeyboardLocker** (menu-bar App, Agent lifecycle coordinator, and one-shot system actions) — imports `Client` +- **KeyboardLocker** (menu-bar App, Agent lifecycle coordinator, and one-shot system actions) — imports `Client` and `SystemSurfaces` - **KeyboardLockerAgent** (XPC service, runs the lock engine, holds Accessibility permission) — imports `Service` - **klock** (CLI) — imports `Client` -- **KeyboardLockerWidgets** (WidgetKit extension hosting Widget and macOS Control) — imports `Client` -- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, wrappers), `Service` (lock engine, Agent). `Client`/`Service` both `@_exported import Common`. +- **KeyboardLockerWidgets** (WidgetKit extension hosting Widget and macOS Control) — imports `Client` and `SystemSurfaces` +- **KeyboardLockerFocusIntents** (Focus Filter App Intents extension) — imports `Client` and `SystemSurfaces` +- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, wrappers), `Service` (lock engine, Agent), `SystemSurfaces` (presentation-only Widget/Control identifiers and invalidation). `Client`/`Service` both `@_exported import Common`; `SystemSurfaces` is linked only by the containing App and its extensions, never by the Agent or CLI. Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`, WidgetKit extension `…​.widgets`. diff --git a/Core/Package.swift b/Core/Package.swift index 4592a81..301dada 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -11,13 +11,16 @@ let package = Package( .library(name: "Common", targets: ["Common"]), .library(name: "Client", targets: ["Client"]), .library(name: "Service", targets: ["Service"]), + .library(name: "SystemSurfaces", targets: ["SystemSurfaces"]), ], targets: [ .target(name: "Common"), .target(name: "Client", dependencies: ["Common"]), .target(name: "Service", dependencies: ["Common"]), + .target(name: "SystemSurfaces"), .testTarget(name: "CommonTests", dependencies: ["Common"]), .testTarget(name: "ClientTests", dependencies: ["Client"]), .testTarget(name: "ServiceTests", dependencies: ["Service"]), + .testTarget(name: "SystemSurfacesTests", dependencies: ["SystemSurfaces"]), ] ) diff --git a/Core/Sources/SystemSurfaces/LockStateSurfaceInvalidator.swift b/Core/Sources/SystemSurfaces/LockStateSurfaceInvalidator.swift new file mode 100644 index 0000000..365f8d0 --- /dev/null +++ b/Core/Sources/SystemSurfaces/LockStateSurfaceInvalidator.swift @@ -0,0 +1,42 @@ +import WidgetKit + +public enum KeyboardLockerSurfaceKind { + public static let keyboardLockControl = "io.lzhlovesjyq.keyboardlocker.control" + public static let statusWidget = "io.lzhlovesjyq.keyboardlocker.status" +} + +/// Requests fresh system-owned presentation without becoming another lock-state authority. +/// Reload requests are deliberately nonthrowing: a confirmed Agent mutation remains successful +/// even when WidgetKit defers or coalesces the corresponding presentation refresh. +public struct LockStateSurfaceInvalidator: Sendable { + public typealias Reload = @Sendable () -> Void + + private let reloadWidget: Reload + private let reloadControl: Reload + + public init( + reloadWidget: @escaping Reload, + reloadControl: @escaping Reload + ) { + self.reloadWidget = reloadWidget + self.reloadControl = reloadControl + } + + public func invalidate() { + reloadWidget() + reloadControl() + } + + public static let live = Self( + reloadWidget: { + WidgetCenter.shared.reloadTimelines(ofKind: KeyboardLockerSurfaceKind.statusWidget) + }, + reloadControl: { + if #available(macOS 26.0, *) { + ControlCenter.shared.reloadControls( + ofKind: KeyboardLockerSurfaceKind.keyboardLockControl + ) + } + } + ) +} diff --git a/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift b/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift new file mode 100644 index 0000000..0f22565 --- /dev/null +++ b/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import SystemSurfaces +import XCTest + +final class LockStateSurfaceInvalidatorTests: XCTestCase { + func testSurfaceKindsRemainStable() { + XCTAssertEqual( + KeyboardLockerSurfaceKind.statusWidget, + "io.lzhlovesjyq.keyboardlocker.status" + ) + XCTAssertEqual( + KeyboardLockerSurfaceKind.keyboardLockControl, + "io.lzhlovesjyq.keyboardlocker.control" + ) + } + + func testInvalidationReloadsWidgetThenControlExactlyOnce() { + let recorder = CallRecorder() + let invalidator = LockStateSurfaceInvalidator( + reloadWidget: { + recorder.record(.widget) + }, + reloadControl: { + recorder.record(.control) + } + ) + + invalidator.invalidate() + + XCTAssertEqual(recorder.calls, [.widget, .control]) + } +} + +private final class CallRecorder: @unchecked Sendable { + enum Call: Equatable { + case control + case widget + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index 012bf95..679e734 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -8,15 +8,19 @@ /* Begin PBXBuildFile section */ A1000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000A /* Client */; }; + A1000000000000000000000D /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000E /* SystemSurfaces */; }; B2000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = B2000000000000000000000A /* Client */; }; B2000000000000000000000C /* KeyboardLockerWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + B20000000000000000000012 /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000013 /* SystemSurfaces */; }; D4000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = D4000000000000000000000A /* Client */; }; D4000000000000000000000C /* KeyboardLockerFocusIntents.appex in Embed App Intents Extensions */ = {isa = PBXBuildFile; fileRef = D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D40000000000000000000012 /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = D40000000000000000000013 /* SystemSurfaces */; }; F21304CF2ECDC2E200735580 /* KeyboardLockerAgent.app in Copy Agent App */ = {isa = PBXBuildFile; fileRef = F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F21304D32ECDC51F00735580 /* klock in Copy CLI */ = {isa = PBXBuildFile; fileRef = F21304B42ECDC26400735580 /* klock */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFCFE2EEFAF0B0065F041 /* Client */; }; F2BFFD012EEFAF1A0065F041 /* Service in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD002EEFAF1A0065F041 /* Service */; }; F2BFFD032EEFAF200065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD022EEFAF200065F041 /* Client */; }; + F2BFFD042EEFAF210065F041 /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD052EEFAF210065F041 /* SystemSurfaces */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -260,6 +264,7 @@ buildActionMask = 2147483647; files = ( A1000000000000000000000B /* Client in Frameworks */, + A1000000000000000000000D /* SystemSurfaces in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -268,6 +273,7 @@ buildActionMask = 2147483647; files = ( B2000000000000000000000B /* Client in Frameworks */, + B20000000000000000000012 /* SystemSurfaces in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -276,6 +282,7 @@ buildActionMask = 2147483647; files = ( D4000000000000000000000B /* Client in Frameworks */, + D40000000000000000000012 /* SystemSurfaces in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -284,6 +291,7 @@ buildActionMask = 2147483647; files = ( F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */, + F2BFFD042EEFAF210065F041 /* SystemSurfaces in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -358,6 +366,7 @@ name = KeyboardLockerModelTests; packageProductDependencies = ( A1000000000000000000000A /* Client */, + A1000000000000000000000E /* SystemSurfaces */, ); productName = KeyboardLockerModelTests; productReference = A10000000000000000000001 /* KeyboardLockerModelTests.xctest */; @@ -381,6 +390,7 @@ name = KeyboardLockerWidgets; packageProductDependencies = ( B2000000000000000000000A /* Client */, + B20000000000000000000013 /* SystemSurfaces */, ); productName = KeyboardLockerWidgets; productReference = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; @@ -404,6 +414,7 @@ name = KeyboardLockerFocusIntents; packageProductDependencies = ( D4000000000000000000000A /* Client */, + D40000000000000000000013 /* SystemSurfaces */, ); productName = KeyboardLockerFocusIntents; productReference = D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */; @@ -436,6 +447,7 @@ name = KeyboardLocker; packageProductDependencies = ( F2BFFCFE2EEFAF0B0065F041 /* Client */, + F2BFFD052EEFAF210065F041 /* SystemSurfaces */, ); productName = KeyboardLocker; productReference = F21304282ECDAD3500735580 /* KeyboardLocker.app */; @@ -1181,14 +1193,26 @@ isa = XCSwiftPackageProductDependency; productName = Client; }; + A1000000000000000000000E /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; B2000000000000000000000A /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; }; + B20000000000000000000013 /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; D4000000000000000000000A /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; }; + D40000000000000000000013 /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; F2BFFCFE2EEFAF0B0065F041 /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; @@ -1201,6 +1225,10 @@ isa = XCSwiftPackageProductDependency; productName = Client; }; + F2BFFD052EEFAF210065F041 /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F21304202ECDAD3500735580 /* Project object */; diff --git a/KeyboardLockerWidgets/KeyboardLockerControl.swift b/KeyboardLockerWidgets/KeyboardLockerControl.swift index 9fe0ebf..4a60c9e 100644 --- a/KeyboardLockerWidgets/KeyboardLockerControl.swift +++ b/KeyboardLockerWidgets/KeyboardLockerControl.swift @@ -1,11 +1,12 @@ import AppIntents import Client import SwiftUI +import SystemSurfaces import WidgetKit @available(macOS 26.0, *) struct KeyboardLockerControl: ControlWidget { - nonisolated static let kind = "io.lzhlovesjyq.keyboardlocker.control" + nonisolated static let kind = KeyboardLockerSurfaceKind.keyboardLockControl var body: some ControlWidgetConfiguration { StaticControlConfiguration( @@ -69,21 +70,3 @@ private extension KeyboardLockerControlValueLoader { } } } - -@available(macOS 26.0, *) -private extension KeyboardLockerControlAction { - static var live: Self { - Self( - lock: { - try await XPCClient.shared.lock() - }, - unlock: { - try await XPCClient.shared.unlock() - }, - reload: { - WidgetCenter.shared.reloadTimelines(ofKind: KeyboardLockerStatusWidget.kind) - ControlCenter.shared.reloadControls(ofKind: KeyboardLockerControl.kind) - } - ) - } -} diff --git a/KeyboardLockerWidgets/KeyboardLockerControlModel.swift b/KeyboardLockerWidgets/KeyboardLockerControlModel.swift index f229e99..c36639c 100644 --- a/KeyboardLockerWidgets/KeyboardLockerControlModel.swift +++ b/KeyboardLockerWidgets/KeyboardLockerControlModel.swift @@ -1,4 +1,6 @@ +import Client import Foundation +import SystemSurfaces struct KeyboardLockerControlValueLoader: Sendable { typealias FetchValue = @Sendable () async throws -> Bool @@ -42,3 +44,19 @@ struct KeyboardLockerControlAction: Sendable { await reload() } } + +extension KeyboardLockerControlAction { + static var live: Self { + Self( + lock: { + try await XPCClient.shared.lock() + }, + unlock: { + try await XPCClient.shared.unlock() + }, + reload: { + LockStateSurfaceInvalidator.live.invalidate() + } + ) + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift index 30f16cd..0c585cb 100644 --- a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift +++ b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift @@ -1,10 +1,11 @@ import AppIntents import Client import SwiftUI +import SystemSurfaces import WidgetKit struct KeyboardLockerStatusWidget: Widget { - nonisolated static let kind = KeyboardLockerWidgetKind.status + nonisolated static let kind = KeyboardLockerSurfaceKind.statusWidget var body: some WidgetConfiguration { StaticConfiguration( diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift index 2a0eaa8..4abdc9a 100644 --- a/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift @@ -1,6 +1,4 @@ import AppIntents -import Client -import WidgetKit @available(macOS 14.0, *) struct SetKeyboardLockWidgetIntent: AppIntent { @@ -17,12 +15,12 @@ struct SetKeyboardLockWidgetIntent: AppIntent { private let action: KeyboardLockerControlAction init() { - action = .widgetLive + action = .live } init( desiredIsLocked: Bool, - action: KeyboardLockerControlAction = .widgetLive + action: KeyboardLockerControlAction = .live ) { self.action = action self.desiredIsLocked = desiredIsLocked @@ -33,20 +31,3 @@ struct SetKeyboardLockWidgetIntent: AppIntent { return .result() } } - -@available(macOS 14.0, *) -private extension KeyboardLockerControlAction { - static var widgetLive: Self { - Self( - lock: { - try await XPCClient.shared.lock() - }, - unlock: { - try await XPCClient.shared.unlock() - }, - reload: { - WidgetCenter.shared.reloadTimelines(ofKind: KeyboardLockerWidgetKind.status) - } - ) - } -} diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift index 0185d12..8ac3346 100644 --- a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift @@ -2,10 +2,6 @@ import Client import Foundation import WidgetKit -enum KeyboardLockerWidgetKind { - static let status = "io.lzhlovesjyq.keyboardlocker.status" -} - struct KeyboardLockerWidgetEntry: TimelineEntry, Equatable { enum State: Equatable { case available(LockStatusSnapshot) diff --git a/docs/development.md b/docs/development.md index f9d05b2..e457f79 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,6 +42,9 @@ - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 +**SystemSurfaces**(`Core/Sources/SystemSurfaces/`)—— 仅 containing App、Widget 与 Focus extension 使用的 presentation adapter +- `LockStateSurfaceInvalidator.swift`:集中定义稳定的 Widget / Control kind,并把 `WidgetCenter` 与 macOS 26+ `ControlCenter` 包装成 nonthrowing reload request。它不 import `Client` / `Service`,不查询或携带锁状态,也不链接到 Agent / CLI;系统可以延后或合并请求,因此它不是状态广播或第二份真相源。 + **App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;两者都只调用 Client - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 - `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 diff --git a/docs/xpc.md b/docs/xpc.md index 28ec041..eb0bd68 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -19,7 +19,7 @@ | 名字 | 实际含义 | |---|---| -| `Core` | 容纳三种 library product 的 Swift Package;不是“正在运行的核心” | +| `Core` | 容纳四种 library product 的 Swift Package;不是“正在运行的核心” | | `Common` | Client/Agent 两侧共同理解的 wire contract 与 value definitions | | `Client` | wrapper 侧的 outgoing XPC adapter | | `Service` | Agent 侧的 domain/runtime library;它本身不是进程 | @@ -28,34 +28,39 @@ ## 构建依赖和运行进程是两张不同的图 -### 构建时:`Core` 提供三个 library product +### 构建时:`Core` 提供四个 library product -`Core/Package.swift` 定义三个独立 product: +`Core/Package.swift` 定义四个独立 product: ```text Core ├── Common ├── Client -> Common -└── Service -> Common - -KeyboardLocker -> Client -> Common -klock -> Client -> Common -KeyboardLockerWidgets -> Client -> Common +├── Service -> Common +└── SystemSurfaces + +KeyboardLocker -> Client -> Common +KeyboardLocker -> SystemSurfaces +klock -> Client -> Common +KeyboardLockerWidgets -> Client -> Common +KeyboardLockerWidgets -> SystemSurfaces KeyboardLockerFocusIntents -> Client -> Common -KeyboardLockerAgent -> Service -> Common +KeyboardLockerFocusIntents -> SystemSurfaces +KeyboardLockerAgent -> Service -> Common ``` -Xcode 会按 package 名把这些 dependency 都显示在 `Core` 下面,因此看起来像 App 和 Agent “都引用了 Core”。但 target 实际链接的是不同 product:主 App 和 `klock` 只链接 `Client`,Agent 只链接 `Service`;没有 target 把整个 package 的全部代码一起引入。 +Xcode 会按 package 名把这些 dependency 都显示在 `Core` 下面,因此看起来像 App 和 Agent “都引用了 Core”。但 target 实际链接的是不同 product:主 App、Widget 与 Focus extension 链接 `Client` 和 `SystemSurfaces`,`klock` 只链接 `Client`,Agent 只链接 `Service`;没有 target 把整个 package 的全部代码一起引入。 它们的职责是: | Product | 被谁链接 | 内容 | 不包含什么 | |---|---|---|---| | `Common` | `Client`、`Service` | XPC 协议、Mach service 名、通知名、跨进程值类型 | connection、listener、锁状态、event tap | -| `Client` | App、CLI | `NSXPCConnection`、async 封装、状态订阅 | `LockEngine`、设置存储、Accessibility 执行 | +| `Client` | App、CLI、Widget、Focus extension | `NSXPCConnection`、async 封装、状态订阅 | `LockEngine`、设置存储、Accessibility 执行 | | `Service` | Agent | listener connection 配置、访问控制、锁引擎、设置真相源、状态广播 | App UI、CLI 命令、客户端 connection | +| `SystemSurfaces` | App、Widget、Focus extension | Widget / Control kind 与 nonthrowing presentation reload adapter | XPC、锁状态、状态广播、Agent 领域逻辑 | -`Client/Exports.swift` 和 `Service/Exports.swift` 使用 `@_exported import Common`,所以 App 只写 `import Client`、Agent 只写 `import Service`,也能看到 `Common` 中的类型。这只是 import 便利,不会让 App 获得 `Service`,也不会让两边共享内存。 +`Client/Exports.swift` 和 `Service/Exports.swift` 使用 `@_exported import Common`,所以 wrapper 的 XPC 代码只写 `import Client`、Agent 只写 `import Service`,也能看到 `Common` 中的类型。这只是 import 便利,不会让 wrapper 获得 `Service`,也不会让两边共享内存。 ### 运行时:各 wrapper 与 Agent 是独立进程 @@ -92,9 +97,9 @@ flowchart LR Mach --> Listener ``` -`Core` 不会作为第四个进程出现。它的代码分别被链接进上图中的 executable: +`Core` 不会作为额外进程出现。它的代码分别被链接进上图中的 executable: -- App、CLI、按需启动的 WidgetKit extension 与 App Intents extension 各自包含一份 `Client + Common` 代码。 +- App、按需启动的 WidgetKit extension 与 Focus App Intents extension 各自包含一份 `Client + Common + SystemSurfaces` 代码;CLI 只包含 `Client + Common`。 - Agent 包含一份 `Service + Common` 代码。 - 只有 Agent 的 executable 包含 `LockEngine`,因此 App/CLI 不可能绕过 XPC 直接操作 event tap。 From ca18222fa1551e69818a301e31e0aca23191881d Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 16:14:36 +0800 Subject: [PATCH 057/106] feat(automation): refresh system surfaces after mutations --- .../AgentCoordinationServices.swift | 87 +++++++- .../KeyboardLockFocusFilterIntent.swift | 8 +- .../AgentCoordinationServicesTests.swift | 198 ++++++++++++++++++ .../KeyboardLockFocusFilterIntentTests.swift | 51 ++++- .../KeyboardLockerWidgetTimelineTests.swift | 7 + .../KeyboardLockerWidgetTimeline.swift | 2 +- docs/architecture.md | 8 +- docs/automation.md | 2 +- docs/development.md | 12 +- 9 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 KeyboardLockerModelTests/AgentCoordinationServicesTests.swift diff --git a/KeyboardLocker/AgentCoordinationServices.swift b/KeyboardLocker/AgentCoordinationServices.swift index 57efeb3..c48d4e3 100644 --- a/KeyboardLocker/AgentCoordinationServices.swift +++ b/KeyboardLocker/AgentCoordinationServices.swift @@ -1,5 +1,6 @@ import Client import Foundation +import SystemSurfaces @MainActor protocol AgentControlServing: Sendable { @@ -64,32 +65,71 @@ protocol AgentClientServing: @MainActor struct LiveAgentClient: AgentClientServing { - nonisolated init() {} + typealias Mutation = @Sendable () async throws -> Void + typealias StatusQuery = @Sendable () async throws -> Bool + + private let lockMutation: Mutation + private let statusQuery: StatusQuery + private let surfaceInvalidator: LockStateSurfaceInvalidator + private let unlockMutation: Mutation + + nonisolated init() { + self.init( + lock: { + try await XPCClient.shared.lock() + }, + unlock: { + try await XPCClient.shared.unlock() + }, + status: { + try await XPCClient.shared.status() + }, + surfaceInvalidator: .live + ) + } + + nonisolated init( + lock: @escaping Mutation, + unlock: @escaping Mutation, + status: @escaping StatusQuery, + surfaceInvalidator: LockStateSurfaceInvalidator + ) { + lockMutation = lock + unlockMutation = unlock + statusQuery = status + self.surfaceInvalidator = surfaceInvalidator + } func serviceDescriptor() async throws -> ServiceDescriptor { try await XPCClient.shared.serviceDescriptor() } func lock() async throws { - try await XPCClient.shared.lock() + try await lockMutation() + surfaceInvalidator.invalidate() } func unlock() async throws { - try await XPCClient.shared.unlock() + try await unlockMutation() + surfaceInvalidator.invalidate() } func status() async throws -> Bool { - try await XPCClient.shared.status() + try await statusQuery() } func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID ) async throws -> ServiceReplacementTicket { - try await XPCClient.shared.prepareForReplacement( + let ticket = try await XPCClient.shared.prepareForReplacement( unlockIfNeeded: unlockIfNeeded, expectedAgentInstanceID: expectedAgentInstanceID ) + if unlockIfNeeded { + surfaceInvalidator.invalidate() + } + return ticket } func cancelReplacementPreparation( @@ -119,6 +159,43 @@ struct LiveAgentClient: AgentClientServing { @MainActor struct LiveAgentLockStateObserver: AgentLockStateObserving { + private let observer: any AgentLockStateObserving + private let surfaceInvalidator: LockStateSurfaceInvalidator + + init() { + self.init( + observer: LockStateSubscriberObserver(), + surfaceInvalidator: .live + ) + } + + init( + observer: any AgentLockStateObserving, + surfaceInvalidator: LockStateSurfaceInvalidator + ) { + self.observer = observer + self.surfaceInvalidator = surfaceInvalidator + } + + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + // A fresh App observation lifetime calibrates the system-owned surfaces even when the + // authoritative Boolean matches the App's seed and the subscriber emits no callback. + surfaceInvalidator.invalidate() + return observer.subscribe(initialState: initialState) { isLocked in + // The underlying subscriber already coalesces signals and re-queries the Agent. This is a + // presentation refresh hint only; the Boolean forwarded to the coordinator remains the + // authoritative state. + surfaceInvalidator.invalidate() + handler(isLocked) + } + } +} + +@MainActor +private struct LockStateSubscriberObserver: AgentLockStateObserving { func subscribe( initialState: Bool?, _ handler: @escaping (Bool) -> Void diff --git a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift index 419a45d..f866420 100644 --- a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift +++ b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift @@ -1,5 +1,6 @@ import AppIntents import Client +import SystemSurfaces protocol AgentFocusLockServing: Sendable { func setFocusFilterLockEnabled(_ enabled: Bool) async throws @@ -27,17 +28,21 @@ struct KeyboardLockFocusFilterIntent: SetFocusFilterIntent { } private let client: any AgentFocusLockServing + private let surfaceInvalidator: LockStateSurfaceInvalidator init() { client = LiveAgentFocusLockClient() + surfaceInvalidator = .live lockKeyboard = false } init( lockKeyboard: Bool, - client: any AgentFocusLockServing = LiveAgentFocusLockClient() + client: any AgentFocusLockServing = LiveAgentFocusLockClient(), + surfaceInvalidator: LockStateSurfaceInvalidator = .live ) { self.client = client + self.surfaceInvalidator = surfaceInvalidator self.lockKeyboard = lockKeyboard } @@ -49,6 +54,7 @@ struct KeyboardLockFocusFilterIntent: SetFocusFilterIntent { func perform() async throws -> some IntentResult { try await client.setFocusFilterLockEnabled(lockKeyboard) + surfaceInvalidator.invalidate() return .result() } } diff --git a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift new file mode 100644 index 0000000..828ce43 --- /dev/null +++ b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift @@ -0,0 +1,198 @@ +import Client +import Foundation +import SystemSurfaces +import XCTest + +final class AgentCoordinationServicesTests: XCTestCase { + @MainActor + func testSuccessfulLockInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + try await client.lock() + + XCTAssertEqual(recorder.calls, [.lock, .widget, .control]) + } + + @MainActor + func testSuccessfulUnlockInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + try await client.unlock() + + XCTAssertEqual(recorder.calls, [.unlock, .widget, .control]) + } + + @MainActor + func testFailedMutationDoesNotInvalidateSurfaces() async { + let recorder = CallRecorder() + let client = LiveAgentClient( + lock: { + recorder.record(.lock) + throw TestError.expected + }, + unlock: { + recorder.record(.unlock) + }, + status: { + recorder.record(.status) + return false + }, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + + do { + try await client.lock() + XCTFail("Expected the Agent failure to propagate.") + } catch { + XCTAssertEqual(error as? TestError, .expected) + } + + XCTAssertEqual(recorder.calls, [.lock]) + } + + @MainActor + func testFailedUnlockDoesNotInvalidateSurfaces() async { + let recorder = CallRecorder() + let client = LiveAgentClient( + lock: { + recorder.record(.lock) + }, + unlock: { + recorder.record(.unlock) + throw TestError.expected + }, + status: { + recorder.record(.status) + return false + }, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + + do { + try await client.unlock() + XCTFail("Expected the Agent failure to propagate.") + } catch { + XCTAssertEqual(error as? TestError, .expected) + } + + XCTAssertEqual(recorder.calls, [.unlock]) + } + + @MainActor + func testStatusDoesNotInvalidateSurfaces() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder, status: true) + + let isLocked = try await client.status() + + XCTAssertTrue(isLocked) + XCTAssertEqual(recorder.calls, [.status]) + } + + @MainActor + func testLiveObserverInvalidatesAtSubscriptionAndBeforeForwardingAChange() { + let recorder = CallRecorder() + let base = RecordingLockStateObserver() + let observer = LiveAgentLockStateObserver( + observer: base, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + var receivedValues: [Bool] = [] + + let token = observer.subscribe(initialState: false) { isLocked in + receivedValues.append(isLocked) + } + + XCTAssertEqual(base.initialStates, [false]) + XCTAssertEqual(recorder.calls, [.widget, .control]) + + base.send(true) + + XCTAssertEqual(receivedValues, [true]) + XCTAssertEqual(recorder.calls, [.widget, .control, .widget, .control]) + withExtendedLifetime(token) {} + } + + @MainActor + private func makeClient( + recorder: CallRecorder, + status: Bool = false + ) -> LiveAgentClient { + LiveAgentClient( + lock: { + recorder.record(.lock) + }, + unlock: { + recorder.record(.unlock) + }, + status: { + recorder.record(.status) + return status + }, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + } +} + +private extension AgentCoordinationServicesTests { + enum TestError: Error { + case expected + } + + func makeInvalidator(recorder: CallRecorder) -> LockStateSurfaceInvalidator { + LockStateSurfaceInvalidator( + reloadWidget: { + recorder.record(.widget) + }, + reloadControl: { + recorder.record(.control) + } + ) + } +} + +private final class CallRecorder: @unchecked Sendable { + enum Call: Equatable { + case control + case lock + case status + case unlock + case widget + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} + +@MainActor +private final class RecordingLockStateObserver: AgentLockStateObserving { + private(set) var initialStates: [Bool?] = [] + private var handler: ((Bool) -> Void)? + + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + initialStates.append(initialState) + self.handler = handler + return ObserverToken {} + } + + func send(_ isLocked: Bool) { + handler?(isLocked) + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift index 5cd29f0..744c61f 100644 --- a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift @@ -1,3 +1,5 @@ +import Foundation +import SystemSurfaces import XCTest final class KeyboardLockFocusFilterIntentTests: XCTestCase { @@ -7,40 +9,50 @@ final class KeyboardLockFocusFilterIntentTests: XCTestCase { func testEnabledFilterForwardsTrueToAgent() async throws { let client = FakeAgentFocusLockClient() + let surfaces = FocusSurfaceRecorder() _ = try await KeyboardLockFocusFilterIntent( lockKeyboard: true, - client: client + client: client, + surfaceInvalidator: surfaces.invalidator ).perform() let receivedValues = await client.receivedValues XCTAssertEqual(receivedValues, [true]) + XCTAssertEqual(surfaces.calls, [.widget, .control]) } func testDisabledFilterForwardsFalseToAgent() async throws { let client = FakeAgentFocusLockClient() + let surfaces = FocusSurfaceRecorder() _ = try await KeyboardLockFocusFilterIntent( lockKeyboard: false, - client: client + client: client, + surfaceInvalidator: surfaces.invalidator ).perform() let receivedValues = await client.receivedValues XCTAssertEqual(receivedValues, [false]) + XCTAssertEqual(surfaces.calls, [.widget, .control]) } func testAgentFailureIsPropagated() async { let client = FakeAgentFocusLockClient(error: FocusIntentTestError.unavailable) + let surfaces = FocusSurfaceRecorder() do { _ = try await KeyboardLockFocusFilterIntent( lockKeyboard: true, - client: client + client: client, + surfaceInvalidator: surfaces.invalidator ).perform() XCTFail("Expected the Agent error to be propagated.") } catch { XCTAssertEqual(error as? FocusIntentTestError, .unavailable) } + + XCTAssertTrue(surfaces.calls.isEmpty) } } @@ -64,3 +76,36 @@ private actor FakeAgentFocusLockClient: AgentFocusLockServing { private enum FocusIntentTestError: Error, Equatable { case unavailable } + +private final class FocusSurfaceRecorder: @unchecked Sendable { + enum Call: Equatable { + case control + case widget + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + var invalidator: LockStateSurfaceInvalidator { + LockStateSurfaceInvalidator( + reloadWidget: { + self.record(.widget) + }, + reloadControl: { + self.record(.control) + } + ) + } + + private func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift index 7ef55e4..7b46fce 100644 --- a/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift @@ -5,6 +5,13 @@ import XCTest final class KeyboardLockerWidgetTimelineTests: XCTestCase { private let now = Date(timeIntervalSinceReferenceDate: 10000) + func testRegularRefreshUsesBudgetFriendlyFallbackInterval() { + XCTAssertEqual( + KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval, + 15 * 60 + ) + } + func testLoaderPublishesAuthoritativeSnapshot() async { let snapshot = LockStatusSnapshot( capturedAt: now, diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift index 8ac3346..b9a9e48 100644 --- a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift @@ -30,7 +30,7 @@ struct KeyboardLockerWidgetEntry: TimelineEntry, Equatable { struct KeyboardLockerWidgetSnapshotLoader: Sendable { typealias FetchSnapshot = @Sendable () async throws -> LockStatusSnapshot - static let regularRefreshInterval: TimeInterval = 60 + static let regularRefreshInterval: TimeInterval = 15 * 60 private let fetchSnapshot: FetchSnapshot diff --git a/docs/architecture.md b/docs/architecture.md index fc62a25..e09a957 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,10 +70,12 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、Services、URL action 与任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()` 或 `lockStatusSnapshot()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 - **长命的状态反映面** —— menu-bar App 等持续运行的 UI。它们必须既接收状态变化 signal,又在**变为可见 / 启动时校准** —— 因为进程被挂起期间可能错过某次广播。只显示布尔状态的现有 App 可以继续使用 `LockStateSubscriber`;需要起点、deadline 与 active settings 的界面在 signal 或重新可见时重新拉取 `lockStatusSnapshot()`。通知与本地缓存都不是真相源。 -- **系统托管的 snapshot 面** —— Widget、Control 等由系统按需启动的 provider。它们不假定进程常驻,也不安装长命 subscription;每次生成 timeline/value 或处理 action 时读取 `lockStatusSnapshot()`,把本地值只当 presentation cache。 +- **系统托管的 snapshot 面** —— Widget、Control 等由系统按需启动的 provider。它们不假定进程常驻,也不安装长命 subscription;每次生成 timeline/value 时读取 `lockStatusSnapshot()`,把本地值只当 presentation cache。交互 action 发送明确的 desired state,不从 cache 做 client-side toggle;Agent 确认 mutation 后可以请求系统重新运行 provider。 `LockStateSubscriber` 把通知当作*提示*而非真相:订阅后的初始校准和收到的任一信号(Darwin 或 Distributed)都会向 Agent 拉取权威状态。多个并发信号被串行合并,相同状态被去重;subscription 取消后,尚未进入 handler 的在途结果会被丢弃。已经开始执行的 handler 不可被回溯撤销。Agent 之所以在两个通道都广播,只是为了让被挂起的 App 能被唤醒(Darwin)、让正在运行的 App 能及时更新(Distributed)。通知的载荷永远不是真相源。 +`SystemSurfaces` 的 reload request 是 presentation invalidation,不是领域状态广播:它不携带状态、不触碰 Agent,只请求 WidgetKit / ControlCenter 重新执行 provider。主 App、Widget 与 Focus extension 可以在 Agent 确认 mutation 后请求刷新;主 App 运行时还把已经由 `LockStateSubscriber` 回拉并去重的外部变化桥接为刷新提示。CLI 与 Agent 不链接 presentation module。主 App 未运行时,CLI、热键或 event-tap failure 造成的变化只能等待 WidgetKit 的下一次 provider execution;系统始终可以合并或延后 reload,因此任何 wrapper 都不得承诺即时 UI 同步。 + `klock lock` 先发出 atomic interactive lock request。只有 outcome 为 acquired 时,它才等待并报告后续解锁;本轮 event tap 会把 `Ctrl+C` 当作额外解锁手势并在 Agent 内消费该事件。若 outcome 为 already locked,CLI 必须说明本命令没有创建新锁并立即退出,不能把 App 或另一个 CLI 已建立的锁变成自己的可取消 session。acquired 后的等待同时使用 `LockStateSubscriber` 获得及时更新,并周期性查询 `status()` 以恢复双通道通知都丢失或 Agent 重启的场景;连续无法取得权威状态时必须报错退出,不能把 transport failure 猜成 unlocked。 `klock lock --no-wait` 是独立的 one-shot desired-state action:它调用幂等 `lock()`,在 Agent 确认 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待后续 unlock。它同样不拥有 lock;之后执行的 `klock unlock` 会解开全局锁,而不是只撤销某个脚本创建的锁。 @@ -85,9 +87,9 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management 1. **只 import `Client`,绝不 import `Service`。** wrapper 只通过 XPC 客户端与核心通信。把 `Service`(LockEngine、event tap)引入 wrapper 是违约。 2. **不新增领域逻辑。** 如果 wrapper 需要核心尚未暴露的行为,先把它加到 Agent + `KeyboardLockerServiceProtocol`,再调用;不要在 wrapper 侧实现。 3. **不持有独立状态。** 没有私有 `UserDefaults`,没有本地锁标志。设置和锁状态都从核心读取。 -4. **不向其他进程发出任何东西。** 只有 Agent 广播状态。 +4. **不广播业务状态。** 只有 Agent 发出状态变化 signal;wrapper 不得发送带 payload 的状态通知或把本地值当真相。containing App / extension 在 Agent 确认 mutation 后可以通过 `SystemSurfaces` 请求系统刷新 Widget / Control,但该请求只是无状态 presentation hint。 5. **Agent 不可达时诚实降级。** 每个 wrapper 都必须处理"核心不可达"(见下方 Agent 生命周期要求),而不是假定调用成功。 -6. **按自身形态读取状态。** 判断这个 wrapper 属于"状态同步"里的哪一种形态:一次性面直接 query,**不加**任何通知处理;长命 UI 订阅 signal 并重新 query;系统托管的 snapshot 面则在每次 provider/action execution 中读取 `lockStatusSnapshot()`。 +6. **按自身形态读取状态。** 判断这个 wrapper 属于"状态同步"里的哪一种形态:一次性面直接 query,**不加**任何通知处理;长命 UI 订阅 signal 并重新 query;系统托管的 snapshot provider 每次 execution 读取 `lockStatusSnapshot()`,交互 action 则发送 explicit desired state。 ## Agent 生命周期要求 diff --git a/docs/automation.md b/docs/automation.md index b22c780..d3682f2 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -68,7 +68,7 @@ Parser 对 scheme 与 host 使用 URL 规定的大小写不敏感比较,但只 macOS 14 及以上的 Widget 提供 `Lock` / `Unlock` 按钮。按钮把当前展示的 snapshot 翻译成明确的 desired state:`Lock` 只调用幂等 `lock`,`Unlock` 只调用幂等 `unlock`,不会先重新读取状态再执行 client-side toggle。即使用户点击时 snapshot 已过期,action 也不会被翻译成相反方向。Agent 确认成功后才请求刷新 timeline;调用失败会由 App Intent 明确返回错误,不做 optimistic update。macOS 13 上保持只读。 -Widget 会请求每分钟校准一次,若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。该时间只是 WidgetKit timeline policy;系统可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本会由系统持续渲染,但从其他 wrapper 发起的手动 lock/unlock 可能要到下一次获准刷新才显示。 +Widget 使用 15 分钟 regular fallback;若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。主 App、Shortcuts、AppleScript、Services、URL、Focus、Widget 与 Control 在 Agent 确认 mutation 后都会请求刷新状态 Widget 和可用的 Control;主 App 运行时,其权威状态订阅还会把 CLI、auto-unlock、解锁热键与 event-tap fail-open 的变化桥接成 presentation reload。主 App 未运行时,CLI、热键或 event-tap failure 只能等待下一次 WidgetKit provider execution。timeline 与显式 reload 都只是系统调度提示;WidgetKit 可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本仍由系统持续渲染。 ## Control diff --git a/docs/development.md b/docs/development.md index e457f79..aed3987 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,7 @@ **SystemSurfaces**(`Core/Sources/SystemSurfaces/`)—— 仅 containing App、Widget 与 Focus extension 使用的 presentation adapter - `LockStateSurfaceInvalidator.swift`:集中定义稳定的 Widget / Control kind,并把 `WidgetCenter` 与 macOS 26+ `ControlCenter` 包装成 nonthrowing reload request。它不 import `Client` / `Service`,不查询或携带锁状态,也不链接到 Agent / CLI;系统可以延后或合并请求,因此它不是状态广播或第二份真相源。 -**App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;两者都只调用 Client +**App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 - `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 - `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。当前没有声明 `AppShortcutsProvider`,因此不会生成 promoted App Shortcuts 或 invocation phrases。 @@ -54,21 +54,21 @@ - `Automation/KeyboardLockerServicesProvider.swift`:将 `NSServices` 的三个 Objective-C selector 适配成 `.lock` / `.unlock` / `.status`。Services handler 只同步受理,不把短生命周期的 error pointer 或 pasteboard 捕获进异步 Task,也不阻塞 AppKit 线程等待 XPC。 - `Automation/KeyboardLockerURLRoute.swift`:只把 `keyboardlocker://lock|unlock|status` 的严格 canonical URL 映射为 action。它拒绝额外 URL component,不回显原始输入,并把多个 URL 转成保序的 action/failure request;custom scheme 不声称 caller authentication。 - `Automation/AppKitExternalAutomationPresenter.swift`:主 App 内统一呈现外部 automation 的 status 与异步 failure;显示 alert 前激活 accessory App,避免提示留在后台。 -- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。 +- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。live lock/unlock 在 Agent 确认后请求 system-surface reload,status 不请求;live state observer 在新 observation lifetime 和每个已回拉、去重的权威变化上提交 presentation hint,从而覆盖 App 运行期间的 CLI、timer、热键与 fail-open 变化。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 - `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。 -**WidgetKit extension**(`KeyboardLockerWidgets/`)—— sandboxed、按需运行的 Widget/Control wrapper,只调用 Client +**WidgetKit extension**(`KeyboardLockerWidgets/`)—— sandboxed、按需运行的 Widget/Control wrapper;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces - `KeyboardLockerControlModel.swift`:Widget/Control 共用的可测试纯协调模型。Control value loader 直接返回 Agent 查询结果;desired-state action 只在 XPC lock/unlock 成功后请求 reload,不做 client-side toggle。 - `KeyboardLockerControl.swift`:macOS 26+ `Keyboard Lock` Control、XPC live adapters 与 `SetValueIntent`。on/off 分别映射到幂等 `lock`/`unlock`,成功后刷新 Control value 和状态 Widget timeline。 - `KeyboardLockerWidgetAction.swift`:macOS 14+ 状态 Widget 的内部、不可发现 `AppIntent`;`Lock` / `Unlock` 映射到明确 desired state,成功后请求刷新 timeline,失败则原样传播。 -- `KeyboardLockerWidgetTimeline.swift`:每次 timeline execution 经 `XPCClient.lockStatusSnapshot()` 读取 Agent 的权威原子快照。loader 把 transport failure 建模为显式 unavailable entry,并请求 regular refresh 或更早的 auto-unlock deadline reconciliation;不订阅长命通知、不维护第二份状态。 +- `KeyboardLockerWidgetTimeline.swift`:每次 timeline execution 经 `XPCClient.lockStatusSnapshot()` 读取 Agent 的权威原子快照。loader 把 transport failure 建模为显式 unavailable entry,并请求 15 分钟 regular fallback 或更早的 auto-unlock deadline reconciliation;不订阅长命通知、不维护第二份状态。 - `KeyboardLockerStatusWidget.swift`:small/medium 状态 presentation,显示 locked/unlocked、deadline、解锁热键与 Agent unavailable;macOS 14+ 提供 explicit desired-state action,macOS 13 保持只读。WidgetKit 可以合并 timeline policy,因此该 UI 不承诺实时刷新。 - `KeyboardLockerWidgets.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent 仍以同 Team + 精确 extension identifier 的 listener requirement 独立认证调用方。 -**App Intents extension**(`KeyboardLockerFocusIntents/`)—— sandboxed、按需运行的 Focus Filter wrapper,只调用 Client -- `KeyboardLockFocusFilterIntent.swift`:macOS 13+ `SetFocusFilterIntent`;参数默认值为 `false`,使 Focus 关闭时向 Agent 发送该 activation generation 的条件 disable。`true` 是一次 activation-triggered acquisition,不是 while-active keep-alive;`perform()` 只调用 capability-gated `setFocusFilterLockEnabled`,不以普通 `unlock` 模拟条件释放,也不在 Agent restart 后查询或 replay 当前 Focus。 +**App Intents extension**(`KeyboardLockerFocusIntents/`)—— sandboxed、按需运行的 Focus Filter wrapper;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces +- `KeyboardLockFocusFilterIntent.swift`:macOS 13+ `SetFocusFilterIntent`;参数默认值为 `false`,使 Focus 关闭时向 Agent 发送该 activation generation 的条件 disable。`true` 是一次 activation-triggered acquisition,不是 while-active keep-alive;`perform()` 只调用 capability-gated `setFocusFilterLockEnabled`,成功后请求 system-surface reload,不以普通 `unlock` 模拟条件释放,也不在 Agent restart 后查询或 replay 当前 Focus。 - `AppIntentsExtension.swift` / `Info.plist`:独立 `com.apple.appintents-extension` 入口,使主 App 未运行时系统仍可执行 Focus 生命周期事件。 - `KeyboardLockerFocusIntents.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent allowlist 只新增 `io.lzhlovesjyq.keyboardlocker.focus-intents` 精确 signing identifier。 From 6463d6b336c521c4392838ad0431b26c8825924e Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 17:07:26 +0800 Subject: [PATCH 058/106] docs(core): explain focus lock generation identity check --- Core/Sources/Service/LockEngine.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 52ec0c7..a12192e 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -98,6 +98,10 @@ struct LockRuntimeState: Equatable { focusOwnedLockGeneration = nil } + /// The generation to release on Focus deactivation, or nil. The `== lockGeneration` identity + /// check is the reason ownership is a generation and not a bool: a late Focus-off event may + /// arrive after arbitrary unlock/relock cycles, and it must release only the exact lock Focus + /// created — never a newer, unrelated global lock. var focusOwnedGenerationForRelease: UInt64? { guard isLocked, focusOwnedLockGeneration == lockGeneration From 0b875be49b87ab36f119a734e729fa5ff2aef6c3 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 18:22:34 +0800 Subject: [PATCH 059/106] style(core): apply SwiftFormat conventions - Normalize operator spacing (..., ..<) - Consolidate multi-line condition braces - Reorder imports (@testable before Foundation) - Use implicit return for single-expression functions - Convert UnlockGestureMatcher from struct to enum (static-only type) --- Core/Sources/Client/LockStateSubscriber.swift | 2 +- .../Sources/Client/ServiceCompatibility.swift | 2 +- Core/Sources/Client/XPCClient.swift | 11 +++----- Core/Sources/Service/LockEngine.swift | 26 +++++++++---------- .../ClientTests/XPCClientErrorTests.swift | 2 +- 5 files changed, 19 insertions(+), 24 deletions(-) diff --git a/Core/Sources/Client/LockStateSubscriber.swift b/Core/Sources/Client/LockStateSubscriber.swift index 9cbcd39..7a9ac3f 100644 --- a/Core/Sources/Client/LockStateSubscriber.swift +++ b/Core/Sources/Client/LockStateSubscriber.swift @@ -208,7 +208,7 @@ final class StateReconciler: @unchecked Sendable { } private func fetchAuthoritativeState() async -> Bool? { - for attempt in 1 ... Self.fetchAttempts { + for attempt in 1...Self.fetchAttempts { guard shouldContinue else { return nil } diff --git a/Core/Sources/Client/ServiceCompatibility.swift b/Core/Sources/Client/ServiceCompatibility.swift index c9d19d6..4d49735 100644 --- a/Core/Sources/Client/ServiceCompatibility.swift +++ b/Core/Sources/Client/ServiceCompatibility.swift @@ -44,7 +44,7 @@ public struct ServiceBuildVersion: Comparable, Sendable { _ lhs: ServiceBuildVersion, _ rhs: ServiceBuildVersion ) -> Int { - for index in 0 ..< max(lhs.components.count, rhs.components.count) { + for index in 0.. Error { - return XPCClientError.serviceUnavailable + XPCClientError.serviceUnavailable } } diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index a12192e..6427a0c 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -14,12 +14,11 @@ struct AutoUnlockSchedule: Equatable { referenceDate: Date, currentDate: Date ) -> Self? { - guard - let timeout, - timeout.isFinite, - timeout > 0, - referenceDate.timeIntervalSinceReferenceDate.isFinite, - currentDate.timeIntervalSinceReferenceDate.isFinite + guard let timeout, + timeout.isFinite, + timeout > 0, + referenceDate.timeIntervalSinceReferenceDate.isFinite, + currentDate.timeIntervalSinceReferenceDate.isFinite else { return nil } @@ -143,7 +142,7 @@ struct LockRuntimeState: Equatable { } } -struct UnlockGestureMatcher { +enum UnlockGestureMatcher { static let controlCKeyCode = CGKeyCode(kVK_ANSI_C) private static let controlCHotkey = KeyboardLockerSettings.Hotkey( @@ -456,13 +455,12 @@ public final class LockEngine { // An explicit settings update starts a fresh timeout window without changing when the // authoritative lock began. A duplicate lock request never reaches this path. let referenceDate = Date() - guard - runtimeState.isLocked, - let schedule = AutoUnlockSchedule.make( - timeout: timeout, - referenceDate: referenceDate, - currentDate: Date() - ) + guard runtimeState.isLocked, + let schedule = AutoUnlockSchedule.make( + timeout: timeout, + referenceDate: referenceDate, + currentDate: Date() + ) else { return } diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index 22e66ea..d06c483 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -1,6 +1,6 @@ +@testable import Client import Foundation import XCTest -@testable import Client final class XPCClientErrorTests: XCTestCase { func testProxyErrorsNormalizeToServiceUnavailable() { From 84cddbd848647aae344ed31459625411960fe323 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 18:22:38 +0800 Subject: [PATCH 060/106] style(app): apply SwiftFormat conventions - Consolidate multi-line condition braces - Reorder case statements (nil first, alphabetical) - Place @objc and @MainActor on separate lines - Use keyPath syntax for closure shorthand - Add blank lines after non-fatal case bodies --- .../AgentReadinessCoordinator.swift | 3 +- KeyboardLocker/AgentRegistrar.swift | 4 ++- .../AgentReplacementCoordinator.swift | 3 +- KeyboardLocker/AppCoordinator.swift | 9 ++--- .../KeyboardLockerScriptCommands.swift | 3 +- .../CommandLineToolLinkManager.swift | 2 +- .../KeyboardLockerApplication.swift | 2 +- KeyboardLocker/StatusMenuController.swift | 33 ++++++++++++------- 8 files changed, 32 insertions(+), 27 deletions(-) diff --git a/KeyboardLocker/AgentReadinessCoordinator.swift b/KeyboardLocker/AgentReadinessCoordinator.swift index a175659..950b1bd 100644 --- a/KeyboardLocker/AgentReadinessCoordinator.swift +++ b/KeyboardLocker/AgentReadinessCoordinator.swift @@ -68,8 +68,7 @@ struct AgentReadinessCoordinator { return .cancelled } if let expectedPreviousAgentInstanceID, - descriptor.agentInstanceID == expectedPreviousAgentInstanceID - { + descriptor.agentInstanceID == expectedPreviousAgentInstanceID { return .agentDidNotRestart } diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index bde425e..62a45c1 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -164,7 +164,7 @@ enum AgentRegistrar { supportsSafeReplacement: supportsSafeReplacement ) - case .orderedAscending, .orderedSame, nil: + case nil, .orderedAscending, .orderedSame: return .updateRequired( message: """ The running background agent does not match the version bundled with this app. \(details) @@ -242,9 +242,11 @@ enum AgentRegistrar { switch service.status { case .notFound, .notRegistered: return + case .enabled, .requiresApproval: logger.error("Failed to unregister agent: \(error.localizedDescription, privacy: .public)") throw error + @unknown default: throw error } diff --git a/KeyboardLocker/AgentReplacementCoordinator.swift b/KeyboardLocker/AgentReplacementCoordinator.swift index bb363fc..11a8087 100644 --- a/KeyboardLocker/AgentReplacementCoordinator.swift +++ b/KeyboardLocker/AgentReplacementCoordinator.swift @@ -132,8 +132,7 @@ struct AgentReplacementCoordinator { } if plan.supportsSafeReplacement, - let descriptor = await reportedReplacementInProgress() - { + let descriptor = await reportedReplacementInProgress() { return .replacementInProgress(descriptor) } diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift index 018d8dd..53f8df0 100644 --- a/KeyboardLocker/AppCoordinator.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -344,8 +344,7 @@ final class AppCoordinator { case let .updateAvailable(descriptor, message, bundledBuild, isLocked): if !isLocked, allowsAutomaticAgentUpdate, - !attemptedAutomaticUpdateBuilds.contains(bundledBuild) - { + !attemptedAutomaticUpdateBuilds.contains(bundledBuild) { attemptedAutomaticUpdateBuilds.insert(bundledBuild) await replaceAgentAutomatically( descriptor, @@ -483,8 +482,7 @@ final class AppCoordinator { stopStateObservation() if let clientError = error as? XPCClientError, - case .peerAuthenticationUnavailable = clientError - { + case .peerAuthenticationUnavailable = clientError { pendingUpdatePlan = nil state = .unavailable( message: """ @@ -564,8 +562,7 @@ final class AppCoordinator { case .agentUpdateRequired: if let pendingUpdatePlan, - pendingUpdatePlan.canReadLockState - { + pendingUpdatePlan.canReadLockState { let updatedPlan = pendingUpdatePlan.updatingLockState(isLocked) self.pendingUpdatePlan = updatedPlan state = .agentUpdateRequired( diff --git a/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift index dc2ec5e..afd50b1 100644 --- a/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift +++ b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift @@ -70,8 +70,7 @@ final nonisolated class GetKeyboardLockStatusScriptCommand: AsyncKeyboardLockerS enum AppleScriptErrorPresentation { static func errorNumber(for error: Error) -> Int { if let clientError = error as? XPCClientError, - case .timedOut = clientError - { + case .timedOut = clientError { return Int(errAETimeout) } return Int(errAEEventFailed) diff --git a/KeyboardLocker/CommandLineToolLinkManager.swift b/KeyboardLocker/CommandLineToolLinkManager.swift index cb7af62..caec084 100644 --- a/KeyboardLocker/CommandLineToolLinkManager.swift +++ b/KeyboardLocker/CommandLineToolLinkManager.swift @@ -101,7 +101,7 @@ final class CommandLineToolLinkManager { self.preferredDestination = preferredDestination.standardizedFileURL self.managedDestinations = (managedDestinations ?? [preferredDestination]) .map(\.standardizedFileURL) - pathDirectoryPaths = Set(pathDirectories.map { $0.standardizedFileURL.path }) + pathDirectoryPaths = Set(pathDirectories.map(\.standardizedFileURL.path)) self.fileManager = fileManager } diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index 13331a4..b675f1a 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -32,7 +32,7 @@ enum KeyboardLockerApplication { } Task { @MainActor in - exit(await resetAgentRegistration()) + await exit(resetAgentRegistration()) } RunLoop.main.run() return true diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index 2beb371..461c8e9 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -90,7 +90,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { private func addStateActions(for state: AppCoordinator.State) { switch state { - case .checking, .agentReplacementInProgress: + case .agentReplacementInProgress, .checking: break case .agentApprovalRequired: @@ -240,19 +240,23 @@ final class StatusMenuController: NSObject, NSMenuDelegate { return messages.isEmpty ? nil : messages.joined(separator: "\n\n") } - @objc private func toggleLock() { + @objc + private func toggleLock() { coordinator.toggle() } - @objc private func requestAccessibilityPermission() { + @objc + private func requestAccessibilityPermission() { coordinator.requestAccessibilityPermission() } - @objc private func openLoginItemsSettings() { + @objc + private func openLoginItemsSettings() { SMAppService.openSystemSettingsLoginItems() } - @objc private func updateAgent() { + @objc + private func updateAgent() { guard case let .agentUpdateRequired(isLocked, message) = currentSnapshot.state else { return } @@ -274,7 +278,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } } - @objc private func restartAgent() { + @objc + private func restartAgent() { let confirmation = NSAlert() confirmation.alertStyle = .warning confirmation.messageText = "Restart the KeyboardLocker Agent?" @@ -288,7 +293,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } } - @objc private func showDetails() { + @objc + private func showDetails() { guard let detailMessage else { return } @@ -301,11 +307,13 @@ final class StatusMenuController: NSObject, NSMenuDelegate { alert.runModal() } - @objc private func refresh() { + @objc + private func refresh() { coordinator.reconcile() } - @objc private func manageCommandLineTool() { + @objc + private func manageCommandLineTool() { NSApp.activate(ignoringOtherApps: true) switch commandLineToolManager.state { @@ -336,8 +344,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { showCommandLineToolError(error) } - case .alertSecondButtonReturn where canRemove, - .alertFirstButtonReturn: + case .alertFirstButtonReturn, + .alertSecondButtonReturn where canRemove: copyPathCommand(for: destination) default: @@ -434,7 +442,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { alert.runModal() } - @objc private func quit() { + @objc + private func quit() { NSApp.terminate(nil) } } From deea144aaccd5e25da43e79a9989ddf89dbeee67 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 18:22:42 +0800 Subject: [PATCH 061/106] style(agent,cli,widgets): apply SwiftFormat conventions - Place @MainActor on separate line - Consolidate @ViewBuilder onto same line as property - Reorder case statements (flag forms before aliases) - Use implicit enum inference for Result type --- KeyboardLockerAgent/AgentService.swift | 15 ++++++++++----- .../KeyboardLockerStatusWidget.swift | 3 +-- klock/main.swift | 8 ++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift index 252a37b..b6a851c 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/KeyboardLockerAgent/AgentService.swift @@ -12,7 +12,8 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { @MainActor private var replacement = ReplacementTransaction() @MainActor private var replacementPreparationExpiration: DispatchWorkItem? - @MainActor override init() { + @MainActor + override init() { let loaded = KeyboardLockerSettingsStore().load() descriptorResult = Result { try Self.makeServiceDescriptor() @@ -287,7 +288,8 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Helpers - @MainActor private func performLock( + @MainActor + private func performLock( allowsControlCUnlock: Bool ) throws -> LockRequestOutcome { try ensureAcceptingLockRequests() @@ -298,7 +300,8 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { ) } - @MainActor private func ensureAcceptingLockRequests() throws { + @MainActor + private func ensureAcceptingLockRequests() throws { guard !replacement.isPending else { throw Self.replacementError( code: 1, @@ -354,7 +357,8 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { return replacementError(code: code, description: error.localizedDescription) } - @MainActor private func scheduleExpiration( + @MainActor + private func scheduleExpiration( for preparation: ReplacementTransaction.Preparation ) { replacementPreparationExpiration?.cancel() @@ -398,7 +402,8 @@ private final class MainActorOperation: @unchecked Sendable { self.body = body } - @MainActor func callAsFunction() { + @MainActor + func callAsFunction() { body() } } diff --git a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift index 0c585cb..e16291a 100644 --- a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift +++ b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift @@ -29,8 +29,7 @@ private struct KeyboardLockerWidgetEntryView: View { .modifier(KeyboardLockerWidgetBackground()) } - @ViewBuilder - private var content: some View { + @ViewBuilder private var content: some View { switch entry.state { case let .available(snapshot): VStack(alignment: .leading, spacing: 8) { diff --git a/klock/main.swift b/klock/main.swift index 7552611..0de249e 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -21,14 +21,14 @@ enum KlockCLI { } switch command { - case "help", "--help", "-h": + case "--help", "-h", "help": guard arguments.count == 1 else { reportUnexpectedArguments(Array(arguments.dropFirst())) } printUsage() exit(ExitCode.success) - case "version", "--version", "-v": + case "--version", "-v", "version": guard arguments.count == 1 else { reportUnexpectedArguments(Array(arguments.dropFirst())) } @@ -77,8 +77,8 @@ enum KlockCLI { private static func executeInteractiveLock() async { let unlockHotkey: Result do { - unlockHotkey = .success( - try await XPCClient.shared.currentSettings().unlockHotkey.displayString + unlockHotkey = try await .success( + XPCClient.shared.currentSettings().unlockHotkey.displayString ) } catch { unlockHotkey = .failure(error) From b310c4a8d38c35a9773fa659745613d2c2455f3e Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 18:26:17 +0800 Subject: [PATCH 062/106] chore: remove .swift-version and .swiftformat SwiftFormat conventions have been applied across all modules; the config files are no longer needed in-tree. --- .swift-version | 1 - .swiftformat | 97 -------------------------------------------------- 2 files changed, 98 deletions(-) delete mode 100644 .swift-version delete mode 100644 .swiftformat diff --git a/.swift-version b/.swift-version deleted file mode 100644 index 7e58507..0000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -xcode diff --git a/.swiftformat b/.swiftformat deleted file mode 100644 index 863b006..0000000 --- a/.swiftformat +++ /dev/null @@ -1,97 +0,0 @@ ---acronyms ID,URL,UUID ---allman false ---anonymousforeach convert ---assetliterals visual-width ---asynccapturing ---beforemarks ---binarygrouping 4,8 ---callsiteparen default ---categorymark "MARK: %c" ---classthreshold 0 ---closingparen balanced ---closurevoid remove ---commas always ---complexattrs preserve ---computedvarattrs preserve ---condassignment after-property ---conflictmarkers reject ---dateformat system ---decimalgrouping 3,6 ---doccomments before-declarations ---elseposition same-line ---emptybraces no-space ---enumnamespaces always ---enumthreshold 0 ---exponentcase lowercase ---exponentgrouping disabled ---extensionacl on-extension ---extensionlength 0 ---extensionmark "MARK: - %t + %c" ---fractiongrouping disabled ---fragment false ---funcattributes preserve ---generictypes ---groupedextension "MARK: %c" ---guardelse auto ---header ignore ---hexgrouping 4,8 ---hexliteralcase uppercase ---ifdef indent ---importgrouping alpha ---indent 2 ---indentcase false ---indentstrings false ---initcodernil false ---lifecycle ---lineaftermarks true ---linebreaks lf ---markcategories true ---markextensions always ---marktypes always ---maxwidth none ---modifierorder ---nevertrailing ---nilinit remove ---noncomplexattrs ---nospaceoperators ---nowrapoperators ---octalgrouping 4,8 ---onelineforeach ignore ---operatorfunc spaced ---organizationmode visibility ---organizetypes actor,class,enum,struct ---patternlet hoist ---ranges spaced ---redundanttype infer-locals-only ---self remove ---selfrequired ---semicolons inline ---shortoptionals except-properties ---smarttabs enabled ---someany true ---storedvarattrs preserve ---stripunusedargs always ---structthreshold 0 ---swift-version 5.10 ---tabwidth unspecified ---throwcapturing ---timezone system ---trailingclosures ---trimwhitespace always ---typeattributes preserve ---typeblanklines remove ---typedelimiter space-after ---typemark "MARK: - %t" ---voidtype void ---wraparguments preserve ---wrapcollections preserve ---wrapconditions preserve ---wrapeffects preserve ---wrapenumcases always ---wrapparameters default ---wrapreturntype preserve ---wrapternary before-operators ---wraptypealiases preserve ---xcodeindentation disabled ---yodaswap always ---enable blankLineAfterSwitchCase From 03f411fe8c9c984bd35372aab0d1181fcb0733cc Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 19:51:19 +0800 Subject: [PATCH 063/106] fix(locking): suppress keyboard system controls Observe system-defined keyboard events so volume, brightness, media, eject, and power controls are consumed while locked. Keep pointer and auxiliary mouse input available, add policy coverage, and align automation/docs with the updated contract. --- CLAUDE.md | 2 +- Core/Sources/Service/LockEngine.swift | 53 ++++++- .../LockedKeyboardEventPolicyTests.swift | 139 ++++++++++++++++++ .../AppIntents/KeyboardLockAppIntents.swift | 8 +- KeyboardLocker/KeyboardLocker.sdef | 8 +- docs/architecture.md | 1 + docs/automation.md | 1 + docs/development.md | 2 +- 8 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index a81c5d9..1051ca4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for Claude Code when working in this repository. ## Project Overview -KeyboardLocker suppresses standard keyboard events via CGEventTap on macOS. Mouse input and system-defined media keys remain available. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): +KeyboardLocker suppresses keyboard-originated events exposed through CGEventTap on macOS, including standard keys and system-defined controls such as volume, brightness, media playback, eject, and power. Mouse and trackpad input remain available. Hardware or system-reserved input that macOS does not expose to CGEventTap is outside this guarantee. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): - **KeyboardLocker** (menu-bar App, Agent lifecycle coordinator, and one-shot system actions) — imports `Client` and `SystemSurfaces` - **KeyboardLockerAgent** (XPC service, runs the lock engine, holds Accessibility permission) — imports `Service` diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 6427a0c..c7466d1 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -3,6 +3,7 @@ import Carbon import Common @preconcurrency import CoreGraphics import Foundation +import IOKit import os struct AutoUnlockSchedule: Equatable { @@ -171,6 +172,49 @@ enum UnlockGestureMatcher { } } +/// System-defined events share a channel with auxiliary mouse buttons. Classify them explicitly so +/// keyboard controls are consumed without broadening the lock to pointer input. +enum LockedKeyboardEventPolicy { + static let systemDefinedEventType: CGEventType = { + guard let type = CGEventType(rawValue: UInt32(NX_SYSDEFINED)) else { + preconditionFailure("NX_SYSDEFINED must map to a CGEventType") + } + return type + }() + + private static let suppressibleSystemDefinedSubtypes: Set = [ + Int16(NX_SUBTYPE_AUX_CONTROL_BUTTONS), + Int16(NX_SUBTYPE_EJECT_KEY), + Int16(NX_SUBTYPE_POWER_KEY), + ] + + static func shouldSuppress( + type: CGEventType, + systemDefinedSubtype: Int16? = nil + ) -> Bool { + switch type { + case .flagsChanged, .keyDown, .keyUp: + return true + + case systemDefinedEventType: + guard let systemDefinedSubtype else { + return false + } + return suppressibleSystemDefinedSubtypes.contains(systemDefinedSubtype) + + default: + return false + } + } + + static func shouldSuppress(type: CGEventType, event: CGEvent) -> Bool { + let systemDefinedSubtype = type == systemDefinedEventType + ? NSEvent(cgEvent: event)?.subtype.rawValue + : nil + return shouldSuppress(type: type, systemDefinedSubtype: systemDefinedSubtype) + } +} + enum EventTapInstallationError: Error, Equatable { case eventTapCreationFailed case eventTapEnableFailed @@ -285,11 +329,12 @@ public final class LockEngine { } } - /// Mouse and system-defined events remain available under the keyboard-only product contract. + /// Suppress standard keys and keyboard-originated system controls without observing pointer input. static let eventMasks: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | - (1 << CGEventType.flagsChanged.rawValue) + (1 << CGEventType.flagsChanged.rawValue) | + (1 << LockedKeyboardEventPolicy.systemDefinedEventType.rawValue) private static let runLoopSourceOrder: CFIndex = 0 private static let autoRepeatFlagValue: Int64 = 1 @@ -565,6 +610,10 @@ public final class LockEngine { return Unmanaged.passUnretained(event) } + guard LockedKeyboardEventPolicy.shouldSuppress(type: type, event: event) else { + return Unmanaged.passUnretained(event) + } + if shouldTriggerUnlock(for: type, event: event) { let lockGeneration = runtimeState.lockGeneration DispatchQueue.main.async { [weak self] in diff --git a/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift b/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift new file mode 100644 index 0000000..423e269 --- /dev/null +++ b/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift @@ -0,0 +1,139 @@ +import AppKit +import CoreGraphics +import IOKit +@testable import Service +import XCTest + +final class LockedKeyboardEventPolicyTests: XCTestCase { + func testSuppressesStandardKeyboardEvents() { + for type in [CGEventType.keyDown, .keyUp, .flagsChanged] { + XCTAssertTrue(LockedKeyboardEventPolicy.shouldSuppress(type: type)) + } + } + + func testSuppressesKeyboardSystemControls() { + let subtypes = [ + NX_SUBTYPE_AUX_CONTROL_BUTTONS, + NX_SUBTYPE_EJECT_KEY, + NX_SUBTYPE_POWER_KEY, + ] + + for subtype in subtypes { + XCTAssertTrue( + LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: Int16(subtype) + ) + ) + } + } + + @MainActor + func testReadsSystemDefinedSubtypeFromEventPayload() throws { + let keyboardControl = try makeSystemDefinedEvent( + subtype: NX_SUBTYPE_AUX_CONTROL_BUTTONS + ) + let auxiliaryMouseButton = try makeSystemDefinedEvent( + subtype: NX_SUBTYPE_AUX_MOUSE_BUTTONS + ) + + XCTAssertEqual( + keyboardControl.type, + LockedKeyboardEventPolicy.systemDefinedEventType + ) + XCTAssertTrue( + LockedKeyboardEventPolicy.shouldSuppress( + type: keyboardControl.type, + event: keyboardControl + ) + ) + XCTAssertFalse( + LockedKeyboardEventPolicy.shouldSuppress( + type: auxiliaryMouseButton.type, + event: auxiliaryMouseButton + ) + ) + } + + func testPreservesPointerAndUnclassifiedSystemEvents() { + XCTAssertFalse( + LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: Int16(NX_SUBTYPE_AUX_MOUSE_BUTTONS) + ) + ) + XCTAssertFalse( + LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: Int16(NX_SUBTYPE_SLEEP_EVENT) + ) + ) + XCTAssertFalse( + LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: nil + ) + ) + XCTAssertFalse( + LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: .max + ) + ) + } + + func testPreservesPointerEventTypes() { + for type in [ + CGEventType.leftMouseDown, + .otherMouseDown, + .rightMouseDown, + .scrollWheel, + ] { + XCTAssertFalse(LockedKeyboardEventPolicy.shouldSuppress(type: type)) + } + } + + @MainActor + func testEventTapObservesEverySuppressibleEventClassWithoutObservingPointers() { + for type in [ + CGEventType.keyDown, + .keyUp, + .flagsChanged, + LockedKeyboardEventPolicy.systemDefinedEventType, + ] { + XCTAssertTrue(eventMaskContains(type)) + } + + for type in [ + CGEventType.leftMouseDown, + .otherMouseDown, + .rightMouseDown, + .scrollWheel, + ] { + XCTAssertFalse(eventMaskContains(type)) + } + } + + @MainActor + private func eventMaskContains(_ type: CGEventType) -> Bool { + LockEngine.eventMasks & (CGEventMask(1) << type.rawValue) != 0 + } + + @MainActor + private func makeSystemDefinedEvent(subtype: Int32) throws -> CGEvent { + let event = try XCTUnwrap( + NSEvent.otherEvent( + with: .systemDefined, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: Int16(subtype), + data1: 0, + data2: 0 + ) + ) + return try XCTUnwrap(event.cgEvent) + } +} diff --git a/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift index 1430072..8677a89 100644 --- a/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift +++ b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift @@ -5,7 +5,7 @@ import Foundation struct LockKeyboardIntent: nonisolated AppIntent { nonisolated static let title: LocalizedStringResource = "Lock Keyboard" nonisolated static let description = IntentDescription( - "Locks standard keyboard input while keeping mouse and media keys available." + "Locks keyboard input, including volume, brightness, and media controls, while keeping mouse and trackpad input available." ) private let client: any AgentLockActionServing @@ -27,7 +27,9 @@ struct LockKeyboardIntent: nonisolated AppIntent { @MainActor struct UnlockKeyboardIntent: nonisolated AppIntent { nonisolated static let title: LocalizedStringResource = "Unlock Keyboard" - nonisolated static let description = IntentDescription("Unlocks standard keyboard input.") + nonisolated static let description = IntentDescription( + "Unlocks keyboard input and keyboard system controls." + ) private let client: any AgentLockActionServing @@ -49,7 +51,7 @@ struct UnlockKeyboardIntent: nonisolated AppIntent { struct GetKeyboardLockStatusIntent: nonisolated AppIntent { nonisolated static let title: LocalizedStringResource = "Get Keyboard Lock Status" nonisolated static let description = IntentDescription( - "Returns whether standard keyboard input is currently locked." + "Returns whether keyboard input and keyboard system controls are currently locked." ) private let client: any AgentLockActionServing diff --git a/KeyboardLocker/KeyboardLocker.sdef b/KeyboardLocker/KeyboardLocker.sdef index aa7ffcd..577250a 100644 --- a/KeyboardLocker/KeyboardLocker.sdef +++ b/KeyboardLocker/KeyboardLocker.sdef @@ -2,15 +2,15 @@ - + - + - + - + diff --git a/docs/architecture.md b/docs/architecture.md index e09a957..3f075a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,6 +57,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management 物理键盘只有一个,所以锁状态是**一个由 Agent 拥有的全局布尔值**。不存在按客户端或按会话划分的锁归属。 - 任何 wrapper 都可以锁;任何 wrapper 都可以解锁。CLI 发起的锁,App 可以解开,反之亦然。 +- locked 的输入范围是 macOS 交付给 `CGEventTap` 的键盘来源事件:标准 `keyDown` / `keyUp` / `flagsChanged`,以及 `NX_SUBTYPE_AUX_CONTROL_BUTTONS`、`NX_SUBTYPE_EJECT_KEY`、`NX_SUBTYPE_POWER_KEY` 这三类 system-defined keyboard control。音量、亮度和播放控制因此必须被消费;鼠标 / 触控板 pointer event(包括 `NX_SUBTYPE_AUX_MOUSE_BUTTONS`)必须继续可用。macOS 没有交付给 event tap 的硬件或系统保留路径不在可保证范围内。 - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 - interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。 diff --git a/docs/automation.md b/docs/automation.md index d3682f2..d51a11d 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -5,6 +5,7 @@ KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScrip 所有入口共享以下语义: - `lock` 与 `unlock` 是幂等的 desired-state action。 +- `lock` 会消费 macOS 交付给 Agent event tap 的标准按键和键盘 system control,包括音量、亮度、播放控制、eject 与 power;鼠标和触控板输入继续可用。macOS 没有交付给 event tap 的硬件或系统保留路径不在可保证范围内。 - `status` 每次都从 Agent 查询权威状态;wrapper 不维护本地副本。 - lock 是全局状态,不属于发起它的 Shortcut、AppleScript 或 shell process。任意普通入口的显式 `unlock` 都会解开同一个全局锁。Focus Filter 的关闭事件使用更窄的条件释放规则,只解除它自己创建且未被后续显式 `lock` 接管的锁。 - 当前没有暴露 client-side `toggle`。需要确定结果时,显式选择 `lock` 或 `unlock`。 diff --git a/docs/development.md b/docs/development.md index aed3987..6b755fc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,7 +36,7 @@ - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 +- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。event policy 消费标准按键及 system-defined keyboard control,同时明确放行鼠标 / 触控板 pointer event。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 From 511b39a114490c215124f1d1666872a2dc0aa751 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 20 Jul 2026 20:19:23 +0800 Subject: [PATCH 064/106] chore(core): drop dead lock-timing helpers Remove LockEngine's unread startedAt/deadline accessors and the duplicated lockDuration/remainingAutoUnlockTime derivations on both LockEngine and LockStatusSnapshot; snapshot payloads remain the single home for authoritative dates. Point the Core test command at xcrun so it selects the Xcode toolchain after the .swift-version removal. --- CLAUDE.md | 7 +++--- Core/Sources/Common/LockStatusSnapshot.swift | 16 -------------- Core/Sources/Service/LockEngine.swift | 22 ------------------- .../LockStatusSnapshotCodingTests.swift | 7 ++---- 4 files changed, 5 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1051ca4..be68171 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,8 @@ These override the model's defaults — the rest of Swift style is left to stand ```bash xcodebuild -scheme KeyboardLocker -configuration Debug build # or KeyboardLockerAgent / klock / Core -swift test --package-path Core -swiftformat . # 2-space indent, alpha-sorted imports; run before committing +xcrun swift test --package-path Core # xcrun pins the Xcode toolchain; a bare `swift` may resolve to swiftly's older default, which cannot consume the current SDK ``` -`.swift-version` intentionally selects the active Xcode toolchain. `Core/Package.swift` -and SwiftFormat keep Swift 5.10 source semantics. +`Core/Package.swift` keeps Swift 5.10 source semantics. Code style is 2-space indent with +alpha-sorted imports. diff --git a/Core/Sources/Common/LockStatusSnapshot.swift b/Core/Sources/Common/LockStatusSnapshot.swift index c4f64b7..15f4f85 100644 --- a/Core/Sources/Common/LockStatusSnapshot.swift +++ b/Core/Sources/Common/LockStatusSnapshot.swift @@ -31,22 +31,6 @@ public struct LockStatusSnapshot: Codable, Equatable, Sendable { self.settings = settings } - /// Duration derived from the authoritative start time rather than a stale transported counter. - public func lockDuration(at date: Date = Date()) -> TimeInterval? { - guard isLocked, let startedAt else { - return nil - } - return max(0, date.timeIntervalSince(startedAt)) - } - - /// Remaining time derived from the authoritative deadline rather than a stale transported value. - public func remainingAutoUnlockTime(at date: Date = Date()) -> TimeInterval? { - guard isLocked, let autoUnlockTargetDate else { - return nil - } - return max(0, autoUnlockTargetDate.timeIntervalSince(date)) - } - fileprivate var hasConsistentLockState: Bool { if isLocked { return startedAt != nil diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index c7466d1..28bf889 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -354,14 +354,6 @@ public final class LockEngine { runtimeState.statusSnapshot(capturedAt: Date()) } - var lockStartedAt: Date? { - runtimeState.startedAt - } - - var autoUnlockTargetDate: Date? { - runtimeState.autoUnlockTargetDate - } - private init() {} @discardableResult @@ -638,18 +630,4 @@ public final class LockEngine { allowsControlCUnlock: runtimeState.allowsControlCUnlock ) } - - func lockDuration(at date: Date = Date()) -> TimeInterval? { - guard let start = runtimeState.startedAt else { - return nil - } - return max(0, date.timeIntervalSince(start)) - } - - func remainingAutoUnlockTime(at date: Date = Date()) -> TimeInterval? { - guard let deadline = runtimeState.autoUnlockTargetDate else { - return nil - } - return max(0, deadline.timeIntervalSince(date)) - } } diff --git a/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift index 7fa5aea..d2731a3 100644 --- a/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift +++ b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift @@ -26,11 +26,9 @@ final class LockStatusSnapshotCodingTests: XCTestCase { let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) XCTAssertEqual(decoded, snapshot) - XCTAssertEqual(decoded.lockDuration(at: capturedAt), 10) - XCTAssertEqual(decoded.remainingAutoUnlockTime(at: capturedAt), 50) } - func testUnlockedSnapshotHasNoDerivedTimingValues() throws { + func testUnlockedSnapshotRoundTrip() throws { let snapshot = LockStatusSnapshot( capturedAt: capturedAt, isLocked: false, @@ -41,8 +39,7 @@ final class LockStatusSnapshotCodingTests: XCTestCase { let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) - XCTAssertNil(decoded.lockDuration(at: capturedAt)) - XCTAssertNil(decoded.remainingAutoUnlockTime(at: capturedAt)) + XCTAssertEqual(decoded, snapshot) } func testMissingPayloadIsRejected() { From 47fdba4551ed9ca9dadc0e648b314b7b3946cded Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 10 Aug 2026 19:58:11 +0800 Subject: [PATCH 065/106] docs: align long-lived docs with verified implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: add focus-intents bundle ID, drop non-shared Core scheme, document the KeyboardLockerModelTests command - xpc.md: proxy failures always normalize to serviceUnavailable - development.md: requirement strings are compile-validated once, not pre-compiled; add coverage boundary / known-limitations section - automation.md: AppleScript -1712 only for raw status() timeout; retried mutations surface as -10000 - Shared.swift: correct stale comment — both notification channels are payload-free --- CLAUDE.md | 5 +++-- Core/Sources/Common/Shared.swift | 3 ++- docs/automation.md | 2 +- docs/development.md | 14 +++++++++++++- docs/xpc.md | 2 +- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be68171..5c1e83e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ KeyboardLocker suppresses keyboard-originated events exposed through CGEventTap - **KeyboardLockerFocusIntents** (Focus Filter App Intents extension) — imports `Client` and `SystemSurfaces` - **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, wrappers), `Service` (lock engine, Agent), `SystemSurfaces` (presentation-only Widget/Control identifiers and invalidation). `Client`/`Service` both `@_exported import Common`; `SystemSurfaces` is linked only by the containing App and its extensions, never by the Agent or CLI. -Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`, WidgetKit extension `…​.widgets`. +Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`, WidgetKit extension `…​.widgets`, Focus App Intents extension `…​.focus-intents`. ## Architecture Contract — read first @@ -33,8 +33,9 @@ These override the model's defaults — the rest of Swift style is left to stand ## Build & Format ```bash -xcodebuild -scheme KeyboardLocker -configuration Debug build # or KeyboardLockerAgent / klock / Core +xcodebuild -scheme KeyboardLocker -configuration Debug build # or KeyboardLockerAgent / klock (Core has no shared Xcode scheme; use the SwiftPM commands below) xcrun swift test --package-path Core # xcrun pins the Xcode toolchain; a bare `swift` may resolve to swiftly's older default, which cannot consume the current SDK +xcodebuild -scheme KeyboardLocker -configuration Debug -destination 'platform=macOS' test -only-testing:KeyboardLockerModelTests # app-level coordination tests (fake Client/lifecycle; no live SMAppService/XPC) ``` `Core/Package.swift` keeps Swift 5.10 source semantics. Code style is 2-space indent with diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index eb5f36e..47f3916 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -48,7 +48,8 @@ public enum LockRequestOutcome: Equatable, Sendable { /// Shared notification identifiers for cross-process communication. public enum NotificationNames { /// Notification name for lock state changes. - /// Used by Darwin (lightweight, no payload) and Distributed (with payload) notifications. + /// Posted on both the Darwin and Distributed centers without any payload; the signal is only + /// a hint, and subscribers always re-query the Agent for the authoritative state. public static let stateChanged = "io.lzhlovesjyq.keyboardlocker.state.changed" } diff --git a/docs/automation.md b/docs/automation.md index d51a11d..7aacaeb 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -98,7 +98,7 @@ tell application "/absolute/path/to/KeyboardLocker.app" end tell ``` -命令会等待异步 Agent 调用完成后再回复 Apple event。XPC timeout 返回 Apple event timeout (`-1712`),其余 Agent 调用失败返回 Apple event failed (`-10000`),并保留 Client 提供的恢复建议。 +命令会等待异步 Agent 调用完成后再回复 Apple event。只有原始 `status()` 查询(如 `get keyboard lock status`)的 XPC timeout 返回 Apple event timeout (`-1712`);`lock keyboard` / `unlock keyboard` 在 Client 内会先做一次幂等重试,最终超时以 outcome unknown 归入 Apple event failed (`-10000`),其余 Agent 调用失败同样返回 `-10000`,并保留 Client 提供的恢复建议。 ## CLI diff --git a/docs/development.md b/docs/development.md index 6b755fc..b280d3c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,7 +25,7 @@ - `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive/Focus 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`;protocol 1.5 新增 capability-gated `setFocusFilterLockEnabled`,旧 `status` / `lockKeyboard` ABI 保持不变。 - `LockStatusSnapshot.swift`:`LockStatusSnapshot` format 1 和有大小上限的 JSON XPC 编解码。snapshot 原子携带 capture time、布尔状态、锁定起点、auto-unlock deadline 与 active settings;duration/countdown 由 consumer 根据权威时间点派生,不作为会迅速过期的 transport 字段。 - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 -- `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成并预编译同 Team + 精确 identifier 的双向 XPC requirement;unsigned/ad-hoc 进程 fail closed。 +- `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成同 Team + 精确 identifier 的双向 XPC requirement 字符串,并用 `SecRequirementCreateWithString` 校验其可编译(编译结果仅作一次性校验后丢弃;安装到连接时由系统重新解析字符串);unsigned/ad-hoc 进程 fail closed。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。 - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 @@ -136,3 +136,15 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget - `AgentService` 是 nonisolated XPC adapter;它把所有 Agent 可变状态与引擎操作切到 `MainActor`,以便在同一隔离域维护 CFRunLoop、timer、settings 和 replacement transaction。 - 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。若重新启用仍失败,会清理 tap/timer、切换为 unlocked 并广播,绝不继续报告虚假的 locked 状态。 - App 协调器通过 protocol injection 与 presentation layer 解耦。`KeyboardLockerModelTests` 是 non-hosted test target,通过 fake Client/lifecycle/state observer 覆盖确定性协调流程,不触碰 live `SMAppService`/XPC。 + +### 覆盖边界与已知限制 + +测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: + +- `AgentService` 的 XPC wiring(replacement barrier、instance-ID fence、locked refusal、错误码映射、30 秒 prepare 过期定时器)直接命中 `LockEngine.shared`,无注入缝,未被单测覆盖;`ReplacementTransaction` 纯状态机(exclusivity、stale ticket/timer、cancel、expiry、committed fail-closed)已在 `ServiceTests` 覆盖。 +- `LockEngine` 依赖真实 CGEventTap 与时钟的路径(auto-unlock 触发、tap-disable fail-open、热键分发、`updateSettings` 重新排程、Accessibility 预检)未单测;纯策略部分(event policy、schedule 计算、runtime state、tap 安装事务)已覆盖。 +- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 虽按 protocol injection 设计,目前无单测;`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)同样未覆盖,`KlockStatusOutput` 渲染已覆盖。 +- auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 +- `KeyboardLockerSettingsStore` 在本地持久化数据损坏时静默回退 `.default`(仅 Agent 写该 key,风险有界);跨进程的 wire codec 保持严格显式失败,不受影响。 +- Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 +- unsigned/ad-hoc 进程 fail closed 的运行路径(测试 bundle 本身已签名,难以构造)与 CI 自动化(`.github/workflows/` 只有 Claude 机器人,无 macOS 构建/测试门禁)是当前验证体系的边界;合并前必须本地跑过 `CLAUDE.md` 的 build 与两套 test 命令。 diff --git a/docs/xpc.md b/docs/xpc.md index eb0bd68..581a4dd 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -573,7 +573,7 @@ Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使 |---|---| | Agent reply 成功 | async 方法正常返回 | | Agent reply 领域错误 | 抛出 Agent 返回的错误,例如缺少 Accessibility 权限 | -| proxy 创建或传输失败 | 抛出 transport error / `serviceUnavailable` | +| proxy 创建或传输失败 | 归一化为 `serviceUnavailable`(Foundation transport 细节不跨出 Client 边界) | | 5 秒内没有任何结果 | connection 失效并抛出 `timedOut`,或由 mutation API 转换为 outcome unknown | 对 mutation 而言,timeout 不等于“Agent 没执行”。请求可能已经到达 Agent,只是 reply 没有及时返回。因此: From 778f4ea67bd5393f06e76317dba743c0e7fa2b47 Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 10:24:54 +0800 Subject: [PATCH 066/106] fix(service): log settings-store corruption instead of swallowing it - Fall back to .default with an os.Logger error when the persisted settings payload is undecodable, so a corrupted key is diagnosable instead of silent - Cover the corruption fallback path in ServiceTests --- .../Service/KeyboardLockerSettingsStore.swift | 30 +++++++-- .../KeyboardLockerSettingsStoreTests.swift | 62 +++++++++++++++++++ docs/development.md | 2 +- 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift diff --git a/Core/Sources/Service/KeyboardLockerSettingsStore.swift b/Core/Sources/Service/KeyboardLockerSettingsStore.swift index e74cfca..a3fb226 100644 --- a/Core/Sources/Service/KeyboardLockerSettingsStore.swift +++ b/Core/Sources/Service/KeyboardLockerSettingsStore.swift @@ -1,7 +1,13 @@ import Foundation +import os /// Persists `KeyboardLockerSettings` to `UserDefaults` public final class KeyboardLockerSettingsStore { + private static let logger = Logger( + subsystem: SharedConstants.machServiceName, + category: "SettingsStore" + ) + private let userDefaults: UserDefaults private let storageKey: String private let encoder = JSONEncoder() @@ -16,22 +22,34 @@ public final class KeyboardLockerSettingsStore { registerDefaultsIfNeeded() } - /// Loads settings from storage, returns default settings if none exist + /// Loads settings from storage, returning default settings when none exist. + /// + /// A present-but-undecodable payload means the stored bytes no longer match the schema. + /// The Agent stays functional by falling back to defaults, but the corruption is always + /// logged instead of being silently swallowed. public func load() -> KeyboardLockerSettings { - guard let data = userDefaults.data(forKey: storageKey), - let settings = try? decoder.decode(KeyboardLockerSettings.self, from: data) - else { + guard let data = userDefaults.data(forKey: storageKey) else { + return .default + } + do { + return try decoder.decode(KeyboardLockerSettings.self, from: data) + } catch { + Self.logger.error( + "Stored settings are undecodable; falling back to defaults: \(error.localizedDescription)" + ) return .default } - return settings } private func registerDefaultsIfNeeded() { guard userDefaults.object(forKey: storageKey) == nil else { return } - if let data = try? encoder.encode(KeyboardLockerSettings.default) { + do { + let data = try encoder.encode(KeyboardLockerSettings.default) userDefaults.set(data, forKey: storageKey) + } catch { + Self.logger.error("Could not encode default settings: \(error.localizedDescription)") } } } diff --git a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift new file mode 100644 index 0000000..24c8961 --- /dev/null +++ b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift @@ -0,0 +1,62 @@ +import Common +import Service +import XCTest + +final class KeyboardLockerSettingsStoreTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "KeyboardLockerSettingsStoreTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testFreshStoreRegistersAndLoadsDefaults() { + _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + XCTAssertNotNil(defaults.data(forKey: "settings")) + + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + XCTAssertEqual(store.load(), .default) + } + + func testLoadReturnsPersistedSettings() throws { + let custom = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 120), + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: 4, + modifierFlags: [.maskControl, .maskAlternate] + ) + ) + defaults.set(try JSONEncoder().encode(custom), forKey: "settings") + + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + XCTAssertEqual(store.load(), custom) + } + + func testCorruptPayloadFallsBackToDefaults() { + defaults.set(Data([0xFF, 0xFE, 0x00]), forKey: "settings") + + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + XCTAssertEqual(store.load(), .default) + } + + func testRegistrationDoesNotOverwriteExistingPayload() throws { + let custom = KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) + ) + defaults.set(try JSONEncoder().encode(custom), forKey: "settings") + + _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + let persisted = try XCTUnwrap(defaults.data(forKey: "settings")) + XCTAssertEqual(try JSONDecoder().decode(KeyboardLockerSettings.self, from: persisted), custom) + } +} diff --git a/docs/development.md b/docs/development.md index b280d3c..c8f1bae 100644 --- a/docs/development.md +++ b/docs/development.md @@ -145,6 +145,6 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget - `LockEngine` 依赖真实 CGEventTap 与时钟的路径(auto-unlock 触发、tap-disable fail-open、热键分发、`updateSettings` 重新排程、Accessibility 预检)未单测;纯策略部分(event policy、schedule 计算、runtime state、tap 安装事务)已覆盖。 - App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 虽按 protocol injection 设计,目前无单测;`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)同样未覆盖,`KlockStatusOutput` 渲染已覆盖。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 -- `KeyboardLockerSettingsStore` 在本地持久化数据损坏时静默回退 `.default`(仅 Agent 写该 key,风险有界);跨进程的 wire codec 保持严格显式失败,不受影响。 +- `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 - unsigned/ad-hoc 进程 fail closed 的运行路径(测试 bundle 本身已签名,难以构造)与 CI 自动化(`.github/workflows/` 只有 Claude 机器人,无 macOS 构建/测试门禁)是当前验证体系的边界;合并前必须本地跑过 `CLAUDE.md` 的 build 与两套 test 命令。 From 90125355c8d2370051b94207e6979f5d8646d4de Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 10:25:33 +0800 Subject: [PATCH 067/106] refactor(service): move AgentService into Service module for testability - Relocate the XPC wire adapter from the agent executable into the Service library, injecting engine, descriptor result, settings, and the prepare-expiration scheduler as seams - Cover replacement barrier, instance-ID fence, locked refusal, error-code mapping, and expiry/cancel semantics deterministically in ServiceTests - Update xpc.md and development.md to the new location and coverage --- .../Sources/Service}/AgentService.swift | 136 ++++-- .../ServiceTests/AgentServiceTests.swift | 461 ++++++++++++++++++ docs/development.md | 4 +- docs/xpc.md | 6 +- 4 files changed, 557 insertions(+), 50 deletions(-) rename {KeyboardLockerAgent => Core/Sources/Service}/AgentService.swift (69%) create mode 100644 Core/Tests/ServiceTests/AgentServiceTests.swift diff --git a/KeyboardLockerAgent/AgentService.swift b/Core/Sources/Service/AgentService.swift similarity index 69% rename from KeyboardLockerAgent/AgentService.swift rename to Core/Sources/Service/AgentService.swift index b6a851c..52d90f5 100644 --- a/KeyboardLockerAgent/AgentService.swift +++ b/Core/Sources/Service/AgentService.swift @@ -1,32 +1,73 @@ +import Common import Foundation -import os -import Service + +/// The `LockEngine` surface `AgentService` drives. Seamed so ServiceTests can substitute a +/// deterministic double; production wires the real singleton. +@MainActor +protocol LockEngineServing { + var isLocked: Bool { get } + var statusSnapshot: LockStatusSnapshot { get } + func lock( + settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool + ) throws -> LockRequestOutcome + func setFocusFilterLockEnabled(_ enabled: Bool, settings: KeyboardLockerSettings) throws + func unlock() + func updateSettings(_ settings: KeyboardLockerSettings) +} + +extension LockEngine: LockEngineServing {} + +/// Schedules the short-lived replacement-preparation expiry. Returns a cancellation closure. +typealias ReplacementExpirationScheduler = ( + _ interval: TimeInterval, + _ fire: @escaping @MainActor () -> Void +) -> () -> Void /// XPC service implementation. Owns the settings source of truth and drives the single /// global `LockEngine`. All wrappers reach the lock exclusively through this object. -final class AgentService: NSObject, KeyboardLockerServiceProtocol { +/// +/// Lives in the `Service` library so the barrier, generation-fence, and error-mapping wiring +/// is unit-testable; the `KeyboardLockerAgent` executable only bootstraps the listener. +public final class AgentService: NSObject, KeyboardLockerServiceProtocol { private static let replacementPreparationDuration: TimeInterval = 30 @MainActor private let descriptorResult: Result @MainActor private let settings: KeyboardLockerSettings + @MainActor private let engine: any LockEngineServing + @MainActor private let expirationScheduler: ReplacementExpirationScheduler @MainActor private var replacement = ReplacementTransaction() - @MainActor private var replacementPreparationExpiration: DispatchWorkItem? + @MainActor private var replacementPreparationCancellation: (() -> Void)? @MainActor - override init() { - let loaded = KeyboardLockerSettingsStore().load() - descriptorResult = Result { - try Self.makeServiceDescriptor() - } - settings = loaded + init( + descriptorResult: Result, + settings: KeyboardLockerSettings, + engine: any LockEngineServing, + expirationScheduler: @escaping ReplacementExpirationScheduler + ) { + self.descriptorResult = descriptorResult + self.settings = settings + self.engine = engine + self.expirationScheduler = expirationScheduler super.init() // Seed the engine so a lock uses persisted values. - LockEngine.shared.updateSettings(loaded) + engine.updateSettings(settings) + } + + @MainActor + public convenience override init() { + self.init( + descriptorResult: Result { try Self.makeServiceDescriptor() }, + settings: KeyboardLockerSettingsStore().load(), + engine: LockEngine.shared, + expirationScheduler: Self.liveExpirationScheduler + ) } // MARK: - Bootstrap - func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) { + public func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) { executeOnMainActor { switch self.descriptorResult { case let .success(base): @@ -55,7 +96,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Locking - func lockKeyboard(reply: @escaping (Error?) -> Void) { + public func lockKeyboard(reply: @escaping (Error?) -> Void) { executeOnMainActor { do { _ = try self.performLock(allowsControlCUnlock: false) @@ -66,7 +107,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } - func lockKeyboardInteractively( + public func lockKeyboardInteractively( reply: @escaping (Bool, Error?) -> Void ) { executeOnMainActor { @@ -79,7 +120,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } - func setFocusFilterLockEnabled( + public func setFocusFilterLockEnabled( _ enabled: Bool, reply: @escaping (Error?) -> Void ) { @@ -88,7 +129,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { if enabled { try self.ensureAcceptingLockRequests() } - try LockEngine.shared.setFocusFilterLockEnabled( + try self.engine.setFocusFilterLockEnabled( enabled, settings: self.settings ) @@ -99,23 +140,23 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } - func unlockKeyboard(reply: @escaping (Error?) -> Void) { + public func unlockKeyboard(reply: @escaping (Error?) -> Void) { executeOnMainActor { - LockEngine.shared.unlock() + self.engine.unlock() reply(nil) } } - func status(reply: @escaping (Bool, Error?) -> Void) { + public func status(reply: @escaping (Bool, Error?) -> Void) { executeOnMainActor { - reply(LockEngine.shared.isLocked, nil) + reply(self.engine.isLocked, nil) } } - func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) { + public func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) { executeOnMainActor { do { - let data = try LockEngine.shared.statusSnapshot.encodedForXPC() + let data = try self.engine.statusSnapshot.encodedForXPC() reply(data, nil) } catch { reply(nil, error) @@ -123,7 +164,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } - func prepareForReplacement( + public func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID, reply: @escaping (Data?, Error?) -> Void @@ -145,7 +186,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { return } - guard unlockIfNeeded || !LockEngine.shared.isLocked else { + guard unlockIfNeeded || !self.engine.isLocked else { reply(nil, Self.replacementError( code: 3, description: "The keyboard must be unlocked before the agent can be replaced." @@ -176,13 +217,13 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // Install the barrier before unlocking so no queued client can re-lock in between. self.scheduleExpiration(for: preparation) if unlockIfNeeded { - LockEngine.shared.unlock() + self.engine.unlock() } reply(data, nil) } } - func commitReplacement( + public func commitReplacement( ticket data: Data, reply: @escaping (Error?) -> Void ) { @@ -197,8 +238,8 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { executeOnMainActor { do { try self.replacement.commit(ticket: candidate) - self.replacementPreparationExpiration?.cancel() - self.replacementPreparationExpiration = nil + self.replacementPreparationCancellation?() + self.replacementPreparationCancellation = nil reply(nil) } catch { reply(Self.replacementError(error)) @@ -206,7 +247,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } - func replacementStatus( + public func replacementStatus( ticket data: Data, reply: @escaping (Data?, Error?) -> Void ) { @@ -228,7 +269,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } - func cancelReplacementPreparation( + public func cancelReplacementPreparation( ticket data: Data, reply: @escaping (Error?) -> Void ) { @@ -243,8 +284,8 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { executeOnMainActor { do { try self.replacement.cancel(ticket: candidate) - self.replacementPreparationExpiration?.cancel() - self.replacementPreparationExpiration = nil + self.replacementPreparationCancellation?() + self.replacementPreparationCancellation = nil reply(nil) } catch { reply(Self.replacementError(error)) @@ -254,13 +295,13 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Accessibility - func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) { + public func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) { executeOnMainActor { reply(AccessibilityManager.hasPermission()) } } - func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) { + public func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) { executeOnMainActor { AccessibilityManager.requestPermission() reply(nil) @@ -269,13 +310,13 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Settings - func currentSettings(reply: @escaping (Data?) -> Void) { + public func currentSettings(reply: @escaping (Data?) -> Void) { executeOnMainActor { reply(try? self.settings.encodedForXPC()) } } - func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) { + public func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) { executeOnMainActor { do { let data = try self.settings.encodedForXPC() @@ -294,7 +335,7 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { ) throws -> LockRequestOutcome { try ensureAcceptingLockRequests() - return try LockEngine.shared.lock( + return try engine.lock( settings: settings, allowsControlCUnlock: allowsControlCUnlock ) @@ -357,25 +398,30 @@ final class AgentService: NSObject, KeyboardLockerServiceProtocol { return replacementError(code: code, description: error.localizedDescription) } + private static let liveExpirationScheduler: ReplacementExpirationScheduler = { interval, fire in + let item = DispatchWorkItem { + MainActor.assumeIsolated { fire() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: item) + return { item.cancel() } + } + @MainActor private func scheduleExpiration( for preparation: ReplacementTransaction.Preparation ) { - replacementPreparationExpiration?.cancel() + replacementPreparationCancellation?() - let expiration = DispatchWorkItem { [weak self] in + replacementPreparationCancellation = expirationScheduler( + Self.replacementPreparationDuration + ) { [weak self] in guard let self else { return } if replacement.expire(preparation: preparation) { - replacementPreparationExpiration = nil + replacementPreparationCancellation = nil } } - replacementPreparationExpiration = expiration - DispatchQueue.main.asyncAfter( - deadline: .now() + Self.replacementPreparationDuration, - execute: expiration - ) } private func executeOnMainActor(_ operation: @escaping @MainActor () -> Void) { diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift new file mode 100644 index 0000000..1947be5 --- /dev/null +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -0,0 +1,461 @@ +import Common +@testable import Service +import XCTest + +@MainActor +final class AgentServiceTests: XCTestCase { + private var engine: FakeLockEngine! + private var scheduler: ManualExpirationScheduler! + private var instanceID: UUID! + private var customSettings: KeyboardLockerSettings! + + override func setUp() { + super.setUp() + engine = FakeLockEngine() + scheduler = ManualExpirationScheduler() + instanceID = UUID() + customSettings = KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) + ) + } + + // MARK: - Replacement barrier + + func testPreparedDrainRejectsNewLockRequests() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + assertReplacementError(lockError, code: 1) + + var interactiveOutcome: Bool? + var interactiveError: Error? + service.lockKeyboardInteractively { outcome, error in + interactiveOutcome = outcome + interactiveError = error + } + XCTAssertEqual(interactiveOutcome, false) + assertReplacementError(interactiveError, code: 1) + + var focusError: Error? + service.setFocusFilterLockEnabled(true) { focusError = $0 } + assertReplacementError(focusError, code: 1) + + XCTAssertTrue(engine.lockCalls.isEmpty) + XCTAssertTrue(engine.focusCalls.isEmpty) + } + + func testDrainKeepsUnlockStatusAndSnapshotAvailable() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var unlockError: Error? + service.unlockKeyboard { unlockError = $0 } + XCTAssertNil(unlockError) + XCTAssertEqual(engine.unlockCallCount, 1) + + var isLocked: Bool? + service.status { locked, _ in isLocked = locked } + XCTAssertEqual(isLocked, false) + + var snapshotData: Data? + var snapshotError: Error? + service.lockStatusSnapshot { data, error in + snapshotData = data + snapshotError = error + } + XCTAssertNil(snapshotError) + XCTAssertNotNil(snapshotData) + } + + func testPrepareRejectsMismatchedInstanceID() { + let service = makeService() + + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: UUID() + ) { _, replyError in + error = replyError + } + assertReplacementError(error, code: 8) + + // The transaction stays idle, so a correctly fenced prepare still succeeds. + prepareTicket(on: service, instanceID: instanceID) + } + + func testPrepareRejectsLockedEngineUnlessUnlockRequested() { + let lockedEngine = FakeLockEngine(isLocked: true) + let service = makeService(engineOverride: lockedEngine) + + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: instanceID + ) { _, replyError in + error = replyError + } + assertReplacementError(error, code: 3) + XCTAssertEqual(lockedEngine.unlockCallCount, 0) + + let ticket = prepareTicket(on: service, unlockIfNeeded: true, instanceID: instanceID) + XCTAssertNotNil(ticket) + XCTAssertEqual(lockedEngine.unlockCallCount, 1) + } + + func testPrepareInstallsBarrierBeforeUnlocking() { + let lockedEngine = FakeLockEngine(isLocked: true) + let service = makeService(engineOverride: lockedEngine) + + var pendingDuringUnlock: Bool? + lockedEngine.onUnlock = { [weak service] in + guard let service else { + return + } + pendingDuringUnlock = self.replacementPending(on: service) + } + + prepareTicket(on: service, unlockIfNeeded: true, instanceID: instanceID) + XCTAssertEqual(pendingDuringUnlock, true) + } + + func testSecondPrepareIsRejectedAndFirstTicketStillCommits() { + let service = makeService() + let first = prepareTicket(on: service, instanceID: instanceID) + + var secondError: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: instanceID + ) { _, replyError in + secondError = replyError + } + assertReplacementError(secondError, code: 5) + + var commitError: Error? + service.commitReplacement(ticket: encoded(first)) { commitError = $0 } + XCTAssertNil(commitError) + XCTAssertEqual(phase(of: first, on: service), .committed) + } + + func testCommitWithForeignTicketFails() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + let foreign = ServiceReplacementTicket(id: UUID(), agentInstanceID: instanceID) + var commitError: Error? + service.commitReplacement(ticket: encoded(foreign)) { commitError = $0 } + assertReplacementError(commitError, code: 4) + } + + func testCommittedDrainCannotBeCancelled() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + + var commitError: Error? + service.commitReplacement(ticket: encoded(ticket)) { commitError = $0 } + XCTAssertNil(commitError) + + var cancelError: Error? + service.cancelReplacementPreparation(ticket: encoded(ticket)) { cancelError = $0 } + assertReplacementError(cancelError, code: 6) + + // A committed drain rejects every cancel, even for a foreign ticket. + var foreignCancelError: Error? + service.cancelReplacementPreparation( + ticket: encoded(ServiceReplacementTicket(id: UUID(), agentInstanceID: instanceID)) + ) { foreignCancelError = $0 } + assertReplacementError(foreignCancelError, code: 6) + } + + func testCancelWithForeignTicketFailsWhilePrepared() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var error: Error? + service.cancelReplacementPreparation( + ticket: encoded(ServiceReplacementTicket(id: UUID(), agentInstanceID: instanceID)) + ) { error = $0 } + assertReplacementError(error, code: 4) + } + + func testMalformedTicketDataSurfacesDecodeError() { + let service = makeService() + + var commitError: Error? + service.commitReplacement(ticket: Data([0x00, 0x01])) { commitError = $0 } + XCTAssertNotNil(commitError) + + var statusError: Error? + service.replacementStatus(ticket: Data([0x00, 0x01])) { _, error in + statusError = error + } + XCTAssertNotNil(statusError) + } + + // MARK: - Expiration scheduling + + func testCommitCancelsScheduledExpiration() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + XCTAssertEqual(scheduler.scheduledCount, 1) + XCTAssertEqual(scheduler.lastInterval, 30) + + var commitError: Error? + service.commitReplacement(ticket: encoded(ticket)) { commitError = $0 } + XCTAssertNil(commitError) + XCTAssertEqual(scheduler.cancelCount, 1) + + // A canceled expiration must not clear the committed drain. + scheduler.firePending() + XCTAssertEqual(phase(of: ticket, on: service), .committed) + } + + func testCancelCancelsScheduledExpiration() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + + var cancelError: Error? + service.cancelReplacementPreparation(ticket: encoded(ticket)) { cancelError = $0 } + XCTAssertNil(cancelError) + XCTAssertEqual(scheduler.cancelCount, 1) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + XCTAssertNil(lockError) + XCTAssertEqual(engine.lockCalls.count, 1) + } + + func testExpirationReturnsAgentToAcceptingLocks() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + XCTAssertEqual(replacementPending(on: service), true) + + scheduler.firePending() + XCTAssertEqual(phase(of: ticket, on: service), .inactive) + XCTAssertEqual(replacementPending(on: service), false) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + XCTAssertNil(lockError) + } + + // MARK: - Wiring + + func testPrepareFailsWithoutDescriptor() { + let service = makeService( + descriptorResult: .failure(StubError.descriptorUnavailable) + ) + + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: instanceID + ) { _, replyError in + error = replyError + } + assertReplacementError(error, code: 2) + } + + func testDescriptorReportsReplacementPending() { + let service = makeService() + XCTAssertEqual(replacementPending(on: service), false) + + prepareTicket(on: service, instanceID: instanceID) + XCTAssertEqual(replacementPending(on: service), true) + } + + func testPersistedSettingsSeedEngineAndLockCalls() { + let service = makeService() + XCTAssertEqual(engine.seededSettings, [customSettings]) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + XCTAssertNil(lockError) + XCTAssertEqual(engine.lockCalls.count, 1) + XCTAssertEqual(engine.lockCalls.first?.settings, customSettings) + XCTAssertEqual(engine.lockCalls.first?.allowsControlCUnlock, false) + + var outcome: Bool? + service.lockKeyboardInteractively { acquired, _ in outcome = acquired } + XCTAssertEqual(outcome, true) + XCTAssertEqual(engine.lockCalls.last?.allowsControlCUnlock, true) + + var focusError: Error? + service.setFocusFilterLockEnabled(true) { focusError = $0 } + XCTAssertNil(focusError) + XCTAssertEqual(engine.focusCalls.first?.settings, customSettings) + } + + // MARK: - Fixtures + + private func makeService( + descriptorResult: Result? = nil, + engineOverride: FakeLockEngine? = nil + ) -> AgentService { + AgentService( + descriptorResult: descriptorResult ?? .success(makeDescriptor(instanceID: instanceID)), + settings: customSettings, + engine: engineOverride ?? engine, + expirationScheduler: scheduler.scheduler + ) + } + + private func makeDescriptor(instanceID: UUID) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "100", + agentInstanceID: instanceID, + replacementPending: false, + replacementPhase: .inactive + ) + } + + @discardableResult + private func prepareTicket( + on service: AgentService, + unlockIfNeeded: Bool = false, + instanceID: UUID + ) -> ServiceReplacementTicket? { + var data: Data? + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: unlockIfNeeded, + expectedAgentInstanceID: instanceID + ) { ticketData, replyError in + data = ticketData + error = replyError + } + XCTAssertNil(error) + return data.flatMap { try? ServiceReplacementTicket.decodedFromXPC($0) } + } + + private func encoded(_ ticket: ServiceReplacementTicket?) -> Data { + (try? ticket?.encodedForXPC()) ?? Data() + } + + private func phase( + of ticket: ServiceReplacementTicket?, + on service: AgentService + ) -> ServiceReplacementPhase? { + var data: Data? + service.replacementStatus(ticket: encoded(ticket)) { statusData, _ in + data = statusData + } + return data.flatMap { try? ServiceReplacementStatus.decodedFromXPC($0) }?.phase + } + + private func replacementPending(on service: AgentService) -> Bool? { + var data: Data? + service.serviceDescriptor { descriptorData, _ in + data = descriptorData + } + return data.flatMap { try? ServiceDescriptor.decodedFromXPC($0) }?.replacementPending + } + + private func assertReplacementError( + _ error: Error?, + code: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + let nsError = error as NSError? + XCTAssertEqual( + nsError?.domain, + "\(SharedConstants.agentBundleIdentifier).replacement", + file: file, + line: line + ) + XCTAssertEqual(nsError?.code, code, file: file, line: line) + } +} + +private enum StubError: Error { + case descriptorUnavailable +} + +@MainActor +private final class FakeLockEngine: LockEngineServing { + var isLocked: Bool + var onUnlock: (() -> Void)? + + private let snapshot: LockStatusSnapshot + private(set) var lockCalls: + [(settings: KeyboardLockerSettings, allowsControlCUnlock: Bool)] = [] + private(set) var unlockCallCount = 0 + private(set) var focusCalls: [(enabled: Bool, settings: KeyboardLockerSettings)] = [] + private(set) var seededSettings: [KeyboardLockerSettings] = [] + + init(isLocked: Bool = false) { + self.isLocked = isLocked + snapshot = LockStatusSnapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: 0), + isLocked: isLocked, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + } + + var statusSnapshot: LockStatusSnapshot { + snapshot + } + + func lock( + settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool + ) throws -> LockRequestOutcome { + lockCalls.append((settings, allowsControlCUnlock)) + isLocked = true + return .acquired + } + + func setFocusFilterLockEnabled( + _ enabled: Bool, + settings: KeyboardLockerSettings + ) throws { + focusCalls.append((enabled, settings)) + } + + func unlock() { + unlockCallCount += 1 + isLocked = false + onUnlock?() + } + + func updateSettings(_ settings: KeyboardLockerSettings) { + seededSettings.append(settings) + } +} + +private final class ManualExpirationScheduler { + private(set) var scheduledCount = 0 + private(set) var lastInterval: TimeInterval? + private(set) var cancelCount = 0 + private var pendingFire: (@MainActor () -> Void)? + + var scheduler: ReplacementExpirationScheduler { + { interval, fire in + self.scheduledCount += 1 + self.lastInterval = interval + self.pendingFire = fire + return { [weak self] in + guard let self else { + return + } + cancelCount += 1 + pendingFire = nil + } + } + } + + @MainActor + func firePending() { + pendingFire?() + } +} diff --git a/docs/development.md b/docs/development.md index c8f1bae..b6e9ad3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -40,6 +40,7 @@ - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 +- `AgentService.swift`:nonisolated XPC wire adapter,持有设置快照与 replacement transaction,并把所有可变状态与引擎操作切到 `MainActor`。引擎、descriptor 与 prepare 过期调度都经注入缝进入;replacement barrier、instance-ID fence、locked refusal、错误码映射与过期/取消语义由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 **SystemSurfaces**(`Core/Sources/SystemSurfaces/`)—— 仅 containing App、Widget 与 Focus extension 使用的 presentation adapter @@ -82,7 +83,7 @@ ### 新增一个 XPC 方法 1. 先判断它是否 optional/additive。不得在同一 protocol major 内修改或移除既有 selector、参数顺序或 reply 形状;破坏性变化需要新 major,无法保留 selector union 时需要新 Mach service。 2. 在 `Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 里加签名,同时在 `ServiceCapability` 增加一个从不复用的稳定名字,并按兼容需求更新 protocol minor / required capability。无法用 `@objc` 表达的值(如 `KeyboardLockerSettings`)以有大小上限的 JSON `Data` 跨界。 -3. 在 `KeyboardLockerAgent/AgentService.swift` 中实现它并在 descriptor 中声明 capability。 +3. 在 `Core/Sources/Service/AgentService.swift` 中实现它并在 descriptor 中声明 capability。 4. 在 `Client/` 的 `XPCClient` 上加一个薄的异步封装;调用前必须完成 descriptor handshake 并检查 capability。 5. 在 `Core/Tests/ClientTests/` 补充 old/future descriptor fixture、round-trip、build ordering 与兼容性测试;Server 状态机在 `Core/Tests/ServiceTests/` 覆盖 exclusivity、stale ticket/timer、cancel、expiry 与 committed fail-closed 语义。 @@ -141,7 +142,6 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: -- `AgentService` 的 XPC wiring(replacement barrier、instance-ID fence、locked refusal、错误码映射、30 秒 prepare 过期定时器)直接命中 `LockEngine.shared`,无注入缝,未被单测覆盖;`ReplacementTransaction` 纯状态机(exclusivity、stale ticket/timer、cancel、expiry、committed fail-closed)已在 `ServiceTests` 覆盖。 - `LockEngine` 依赖真实 CGEventTap 与时钟的路径(auto-unlock 触发、tap-disable fail-open、热键分发、`updateSettings` 重新排程、Accessibility 预检)未单测;纯策略部分(event policy、schedule 计算、runtime state、tap 安装事务)已覆盖。 - App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 虽按 protocol injection 设计,目前无单测;`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)同样未覆盖,`KlockStatusOutput` 渲染已覆盖。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 diff --git a/docs/xpc.md b/docs/xpc.md index 581a4dd..b5c81cf 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -527,7 +527,7 @@ sequenceDiagram 9. `LockEngine.shared.lock(settings:allowsControlCUnlock:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap;physical duplicate 在创建资源前直接返回 already locked,且只可能清除 Focus 的内部 release marker。 10. reply 通过 XPC 返回,`XPCClient` 把 callback bridge 成 Swift async/throwing API。 -`AgentService` 是 executable target 中的 adapter:它把 wire protocol 翻译成 `Service` 模块里的领域调用。`Service` product 本身不会启动进程;真正创建 listener、保持 RunLoop 的是 `KeyboardLockerAgent/main.swift`。 +`AgentService` 位于 `Service` 模块,是把 wire protocol 翻译成领域调用的 adapter;放在 library 里是为了让 barrier、generation fence 与错误码映射可以被 `ServiceTests` 确定性覆盖。`Service` product 本身不会启动进程;真正创建 listener、保持 RunLoop 的是 `KeyboardLockerAgent/main.swift` 这个极薄的 bootstrap。 ## 当前 XPC 方法分别表达什么 @@ -664,7 +664,7 @@ locked Agent 不能自动 restart,因为 Agent 退出本身会释放正在工作 1. 在同一 protocol major 内只允许新增 selector;不得修改/移除既有 selector、参数顺序、类型或 reply 形状。无法长期保留 selector union 的破坏性升级需要新的 Mach service name。 2. 在 `Core/Sources/Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 增加最小 wire method,在 `ServiceCapability` 增加一个稳定且永不复用的名字,并按需要提高 protocol minor。 3. 只使用 Objective-C/XPC 能表达的参数和 reply 类型;复杂 Swift value 编码成有明确 schema 和大小上限的 `Data`。 -4. 在 `KeyboardLockerAgent/AgentService.swift` 实现 protocol adapter,并在 descriptor 声明 capability。 +4. 在 `Core/Sources/Service/AgentService.swift` 实现 protocol adapter,并在 descriptor 声明 capability。 5. 把真实领域行为放在 `Service`,不要堆进 App、CLI 或 wire adapter。 6. 在 `Core/Sources/Client/XPCClient.swift` 增加薄的 async/throwing 封装;调用方先 handshake 并检查 capability。 7. wrapper 只调用 `XPCClient`;App/CLI 不得 import `Service`。 @@ -704,7 +704,7 @@ locked Agent 不能自动 restart,因为 Agent 退出本身会释放正在工作 | Client connection、async bridge、timeout | [`Core/Sources/Client/XPCClient.swift`](../Core/Sources/Client/XPCClient.swift) | | 长命 UI 的状态订阅 | [`Core/Sources/Client/LockStateSubscriber.swift`](../Core/Sources/Client/LockStateSubscriber.swift) | | Agent listener 与进程入口 | [`KeyboardLockerAgent/main.swift`](../KeyboardLockerAgent/main.swift) | -| Wire method 到领域方法的适配 | [`KeyboardLockerAgent/AgentService.swift`](../KeyboardLockerAgent/AgentService.swift) | +| Wire method 到领域方法的适配 | [`Core/Sources/Service/AgentService.swift`](../Core/Sources/Service/AgentService.swift) | | 新 connection 的 server 配置 | [`Core/Sources/Service/XPCServerConnection.swift`](../Core/Sources/Service/XPCServerConnection.swift) | | 双向 code-signing requirement | [`Core/Sources/Common/XPCCodeSigningRequirement.swift`](../Core/Sources/Common/XPCCodeSigningRequirement.swift)、[`Core/Sources/Service/XPCAccessControl.swift`](../Core/Sources/Service/XPCAccessControl.swift) | | 全局锁与 event tap | [`Core/Sources/Service/LockEngine.swift`](../Core/Sources/Service/LockEngine.swift) | From 698be0a2afcb80e743fd04ff3ad6aee0b18809d9 Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 10:25:53 +0800 Subject: [PATCH 068/106] test(app): cover readiness and replacement coordinators - Exercise AgentReadinessCoordinator and AgentReplacementCoordinator through fake client/lifecycle: handshake retry, unverified fallback, safe/forced planning, prepare commit restart ordering, cancel and redetection --- .../AgentReadinessCoordinatorTests.swift | 499 ++++++++++++++++++ .../AgentReplacementCoordinatorTests.swift | 422 +++++++++++++++ docs/development.md | 2 +- 3 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift create mode 100644 KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift diff --git a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift new file mode 100644 index 0000000..80675f0 --- /dev/null +++ b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift @@ -0,0 +1,499 @@ +import Client +import Foundation +import XCTest + +final class AgentReadinessCoordinatorTests: XCTestCase { + @MainActor + func testNonEnabledRegistrationSkipsXPCAndReturnsRegistration() async { + let client = FakeAgentReadinessClient( + descriptorResults: [.failure(TestError.handshake)] + ) + let lifecycle = FakeAgentReadinessLifecycle( + ensureEnabledResult: .approvalRequired + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .registration(state) = outcome else { + XCTFail("Expected .registration, got \(outcome).") + return + } + XCTAssertEqual(state, .approvalRequired) + XCTAssertEqual(client.descriptorCallCount, 0) + XCTAssertEqual(client.statusCallCount, 0) + XCTAssertEqual(client.resetConnectionCallCount, 0) + XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) + } + + @MainActor + func testHandshakeFailureResetsConnectionOnceAndRetries() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.failure(TestError.handshake), .success(descriptor)], + statusResult: .success(true), + permissionResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .ready(isLocked, hasAccessibilityPermission) = outcome else { + XCTFail("Expected .ready, got \(outcome).") + return + } + XCTAssertTrue(isLocked) + XCTAssertTrue(hasAccessibilityPermission) + XCTAssertEqual(client.descriptorCallCount, 2) + XCTAssertEqual(client.resetConnectionCallCount, 1) + XCTAssertEqual( + Array(client.events.prefix(3)), + [.serviceDescriptor, .resetConnection, .serviceDescriptor] + ) + } + + @MainActor + func testDoubleHandshakeFailureFallsBackToForcedUpdateWithStatusLockState() async { + let client = FakeAgentReadinessClient( + descriptorResults: [ + .failure(TestError.handshake), + .failure(TestError.handshake), + ], + statusResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + XCTFail("Expected .updateRequired, got \(outcome).") + return + } + guard case let .forced(descriptor, isLocked) = plan.mode else { + XCTFail("Expected a forced plan, got \(plan.mode).") + return + } + XCTAssertNil(descriptor) + XCTAssertEqual(isLocked, true) + XCTAssertTrue(plan.message.contains("could not be verified")) + XCTAssertEqual(client.descriptorCallCount, 2) + XCTAssertEqual(client.statusCallCount, 1) + XCTAssertEqual(client.resetConnectionCallCount, 2) + XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) + } + + @MainActor + func testDoubleHandshakeFailureWithUnreadableStatusReportsHandshakeContext() async { + let client = FakeAgentReadinessClient( + descriptorResults: [ + .failure(TestError.handshake), + .failure(TestError.handshake), + ], + statusResult: .failure(TestError.status) + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .failure(error, context) = outcome else { + XCTFail("Expected .failure, got \(outcome).") + return + } + XCTAssertEqual(error as? TestError, .status) + XCTAssertTrue(context?.contains("Descriptor handshake failed") == true) + } + + @MainActor + func testCompatibleDescriptorReturnsReadySnapshot() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(false), + permissionResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .ready(isLocked, hasAccessibilityPermission) = outcome else { + XCTFail("Expected .ready, got \(outcome).") + return + } + XCTAssertFalse(isLocked) + XCTAssertTrue(hasAccessibilityPermission) + XCTAssertEqual(client.descriptorCallCount, 1) + XCTAssertEqual(client.resetConnectionCallCount, 0) + XCTAssertEqual(lifecycle.checkedDescriptors, [descriptor]) + } + + @MainActor + func testMatchingPreviousInstanceIDReportsAgentDidNotRestart() async { + let instanceID = UUID() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor(agentInstanceID: instanceID))] + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect( + expectedPreviousAgentInstanceID: instanceID + ) + + guard case .agentDidNotRestart = outcome else { + XCTFail("Expected .agentDidNotRestart, got \(outcome).") + return + } + XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) + XCTAssertEqual(client.statusCallCount, 0) + } + + @MainActor + func testBundledUpgradeAvailableCarriesDescriptorMessageBuildAndLockState() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .bundledAgentUpgradeAvailable( + message: "Upgrade available.", + bundledBuild: "99" + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateAvailable( + reportedDescriptor, + message, + bundledBuild, + isLocked + ) = outcome else { + XCTFail("Expected .updateAvailable, got \(outcome).") + return + } + XCTAssertEqual(reportedDescriptor, descriptor) + XCTAssertEqual(message, "Upgrade available.") + XCTAssertEqual(bundledBuild, "99") + XCTAssertTrue(isLocked) + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testSafeReplacementWithoutReadableLockStateFails() async { + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())] + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: false, + supportsSafeReplacement: true + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .failure(error, context) = outcome else { + XCTFail("Expected .failure, got \(outcome).") + return + } + XCTAssertEqual( + error.localizedDescription, + "Safe Agent replacement requires a readable authoritative lock state." + ) + XCTAssertNil(context) + XCTAssertEqual(client.statusCallCount, 0) + } + + @MainActor + func testUpdateRequiredWithoutLockStateOrSafeSupportForcesUpdate() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: false, + supportsSafeReplacement: false + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + XCTFail("Expected .updateRequired, got \(outcome).") + return + } + XCTAssertEqual( + plan, + AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: nil), + message: "Update required." + ) + ) + XCTAssertEqual(client.statusCallCount, 0) + } + + @MainActor + func testUpdateRequiredWithLockStateAndSafeSupportChoosesSafeMode() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: true, + supportsSafeReplacement: true + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + XCTFail("Expected .updateRequired, got \(outcome).") + return + } + XCTAssertEqual( + plan, + AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + ) + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testUpdateRequiredWithLockStateWithoutSafeSupportChoosesForcedMode() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(false) + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: true, + supportsSafeReplacement: false + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + XCTFail("Expected .updateRequired, got \(outcome).") + return + } + XCTAssertEqual( + plan, + AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: false), + message: "Update required." + ) + ) + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testTrustedReplacementInProgressShortCircuitsCompatibilityOutcome() async { + let descriptor = makeDescriptor(replacementPending: true) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .replacementInProgress(reported) = outcome else { + XCTFail("Expected .replacementInProgress, got \(outcome).") + return + } + XCTAssertEqual(reported, descriptor) + XCTAssertEqual(client.statusCallCount, 0) + } + + @MainActor + func testReplacementPendingWithWrongBundleIdentifierFallsThroughToCompatibility() async { + let descriptor = makeDescriptor( + agentBundleIdentifier: "com.example.unrelated.agent", + replacementPending: true + ) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case .ready = outcome else { + XCTFail("Expected .ready, got \(outcome).") + return + } + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testReplacementPendingWithoutDrainCapabilityFallsThroughToCompatibility() async { + var capabilities = ServiceContract.requiredCapabilities + capabilities.remove(.committedReplacementDrain) + let descriptor = makeDescriptor( + capabilities: capabilities, + replacementPending: true + ) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case .ready = outcome else { + XCTFail("Expected .ready, got \(outcome).") + return + } + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testReplacementPendingWithInvalidBundleCompatibilityFallsThrough() async { + let descriptor = makeDescriptor(replacementPending: true) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .invalidBundle(.invalidBundle("Corrupt bundle.")) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .invalidBundle(failure) = outcome else { + XCTFail("Expected .invalidBundle, got \(outcome).") + return + } + XCTAssertEqual(failure, .invalidBundle("Corrupt bundle.")) + XCTAssertEqual(client.statusCallCount, 0) + } +} + +@MainActor +private final class FakeAgentReadinessClient: AgentReadinessServing { + enum Event: Equatable { + case hasAccessibilityPermission + case resetConnection + case serviceDescriptor + case status + } + + private var descriptorResults: [Result] + var statusResult: Result + var permissionResult: Result + + private(set) var events: [Event] = [] + private(set) var descriptorCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var resetConnectionCallCount = 0 + + init( + descriptorResults: [Result], + statusResult: Result = .success(false), + permissionResult: Result = .success(true) + ) { + precondition( + !descriptorResults.isEmpty, + "At least one scripted descriptor result is required." + ) + self.descriptorResults = descriptorResults + self.statusResult = statusResult + self.permissionResult = permissionResult + } + + func serviceDescriptor() async throws -> ServiceDescriptor { + events.append(.serviceDescriptor) + descriptorCallCount += 1 + let result = descriptorResults.count > 1 + ? descriptorResults.removeFirst() + : descriptorResults[0] + return try result.get() + } + + func status() async throws -> Bool { + events.append(.status) + statusCallCount += 1 + return try statusResult.get() + } + + func hasAccessibilityPermission() async throws -> Bool { + events.append(.hasAccessibilityPermission) + return try permissionResult.get() + } + + func resetConnection() { + events.append(.resetConnection) + resetConnectionCallCount += 1 + } +} + +@MainActor +private final class FakeAgentReadinessLifecycle: AgentReadinessLifecycleServing { + var ensureEnabledResult: AgentRegistrar.State + var compatibilityResult: AgentRegistrar.Compatibility + private(set) var checkedDescriptors: [ServiceDescriptor] = [] + + init( + ensureEnabledResult: AgentRegistrar.State = .enabled, + compatibilityResult: AgentRegistrar.Compatibility = .compatible + ) { + self.ensureEnabledResult = ensureEnabledResult + self.compatibilityResult = compatibilityResult + } + + func ensureEnabled() -> AgentRegistrar.State { + ensureEnabledResult + } + + func compatibility( + of descriptor: ServiceDescriptor + ) -> AgentRegistrar.Compatibility { + checkedDescriptors.append(descriptor) + return compatibilityResult + } +} + +private func makeDescriptor( + agentInstanceID: UUID = UUID(), + capabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String = SharedConstants.agentBundleIdentifier, + replacementPending: Bool = false +) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: capabilities, + agentBundleIdentifier: agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: agentInstanceID, + replacementPending: replacementPending + ) +} + +private enum TestError: Error, Equatable { + case handshake + case status +} diff --git a/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift new file mode 100644 index 0000000..5030ab9 --- /dev/null +++ b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift @@ -0,0 +1,422 @@ +import Client +import Foundation +import XCTest + +final class AgentReplacementCoordinatorTests: XCTestCase { + @MainActor + func testSafePlanPreparesAndCommitsBeforeRestarting() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + _ = await coordinator.replace(plan) + + XCTAssertEqual(log.steps, [.prepare, .commit, .restart, .resetConnection]) + XCTAssertEqual(client.prepareCalls.count, 1) + XCTAssertEqual(client.prepareCalls.first?.unlockIfNeeded, true) + XCTAssertEqual( + client.prepareCalls.first?.expectedAgentInstanceID, + descriptor.agentInstanceID + ) + XCTAssertEqual(client.committedTickets, [ticket]) + XCTAssertTrue(client.cancelledTickets.isEmpty) + } + + @MainActor + func testSafePlanRestartSuccessReturnsRestartedAndResetsConnection() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: false), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .restarted(previousAgentInstanceID) = outcome else { + XCTFail("Expected .restarted, got \(outcome).") + return + } + XCTAssertEqual(previousAgentInstanceID, descriptor.agentInstanceID) + XCTAssertEqual(client.resetConnectionCallCount, 1) + } + + @MainActor + func testCommittedSafePlanRestartFailureDoesNotCancelPreparation() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + lifecycle.restartResult = .approvalRequired + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .registration(state) = outcome else { + XCTFail("Expected .registration, got \(outcome).") + return + } + XCTAssertEqual(state, .approvalRequired) + XCTAssertEqual(client.committedTickets, [ticket]) + XCTAssertTrue(client.cancelledTickets.isEmpty) + XCTAssertEqual(client.resetConnectionCallCount, 1) + XCTAssertEqual(log.steps, [.prepare, .commit, .restart, .resetConnection]) + } + + @MainActor + func testPrepareFailureReportsFailedWithCurrentLockState() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.prepareError = TestError.expected + client.statusResult = .success(true) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .failed(error, currentLockState) = outcome else { + XCTFail("Expected .failed, got \(outcome).") + return + } + XCTAssertEqual(error as? TestError, .expected) + XCTAssertEqual(currentLockState, true) + XCTAssertTrue(client.cancelledTickets.isEmpty) + XCTAssertEqual(client.statusCallCount, 1) + XCTAssertEqual(log.steps, [.prepare, .serviceDescriptor, .status]) + } + + @MainActor + func testCommitFailureCancelsPreparedTicketThenReportsFailed() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.commitError = TestError.expected + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: false), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .failed(error, currentLockState) = outcome else { + XCTFail("Expected .failed, got \(outcome).") + return + } + XCTAssertEqual(error as? TestError, .expected) + XCTAssertEqual(currentLockState, false) + XCTAssertEqual(client.cancelledTickets, [ticket]) + XCTAssertTrue(client.committedTickets.isEmpty) + XCTAssertEqual( + log.steps, + [.prepare, .commit, .cancel, .serviceDescriptor, .status] + ) + } + + @MainActor + func testSafePlanFailureRedetectsTrustedReplacementInProgress() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let pendingDescriptor = makeDescriptor(replacementPending: true) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.commitError = TestError.expected + client.descriptorResult = .success(pendingDescriptor) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .replacementInProgress(reported) = outcome else { + XCTFail("Expected .replacementInProgress, got \(outcome).") + return + } + XCTAssertEqual(reported, pendingDescriptor) + XCTAssertEqual(client.cancelledTickets, [ticket]) + XCTAssertEqual(client.statusCallCount, 0) + } + + @MainActor + func testForcedPlanFailureDoesNotRedetectReplacementInProgress() async { + let log = ReplacementCallLog() + let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.unlockError = TestError.expected + client.descriptorResult = .success(makeDescriptor(replacementPending: true)) + client.statusResult = .success(true) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: makeDescriptor(), isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .failed(error, currentLockState) = outcome else { + XCTFail("Expected .failed, got \(outcome).") + return + } + XCTAssertEqual(error as? TestError, .expected) + XCTAssertEqual(currentLockState, true) + XCTAssertEqual(client.descriptorCallCount, 0) + XCTAssertEqual(log.steps, [.unlock, .status]) + } + + @MainActor + func testForcedPlanWithKnownLockStateUnlocksBeforeRestarting() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .restarted(previousAgentInstanceID) = outcome else { + XCTFail("Expected .restarted, got \(outcome).") + return + } + XCTAssertEqual(previousAgentInstanceID, descriptor.agentInstanceID) + XCTAssertEqual(client.unlockCallCount, 1) + XCTAssertTrue(client.prepareCalls.isEmpty) + XCTAssertTrue(client.committedTickets.isEmpty) + XCTAssertTrue(client.cancelledTickets.isEmpty) + XCTAssertEqual(log.steps, [.unlock, .restart, .resetConnection]) + } + + @MainActor + func testForcedPlanWithUnknownLockStateSkipsUnlock() async { + let log = ReplacementCallLog() + let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: nil, isLocked: nil), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .restarted(previousAgentInstanceID) = outcome else { + XCTFail("Expected .restarted, got \(outcome).") + return + } + XCTAssertNil(previousAgentInstanceID) + XCTAssertEqual(client.unlockCallCount, 0) + XCTAssertEqual(client.statusCallCount, 0) + XCTAssertEqual(log.steps, [.restart, .resetConnection]) + } + + @MainActor + func testForcedPlanRestartFailureReturnsRegistration() async { + let log = ReplacementCallLog() + let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + lifecycle.restartResult = .unavailable(.restartFailed("Restart failed for testing.")) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: nil, isLocked: nil), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .registration(state) = outcome else { + XCTFail("Expected .registration, got \(outcome).") + return + } + XCTAssertEqual(state, .unavailable(.restartFailed("Restart failed for testing."))) + XCTAssertEqual(client.resetConnectionCallCount, 1) + XCTAssertEqual(log.steps, [.restart, .resetConnection]) + } +} + +@MainActor +private final class ReplacementCallLog { + enum Step: Equatable { + case cancel + case commit + case prepare + case resetConnection + case restart + case serviceDescriptor + case status + case unlock + } + + private(set) var steps: [Step] = [] + + func record(_ step: Step) { + steps.append(step) + } +} + +@MainActor +private final class FakeAgentReplacementClient: AgentReplacementServing { + let log: ReplacementCallLog + let ticket: ServiceReplacementTicket + + var descriptorResult: Result = .failure(TestError.expected) + var statusResult: Result = .success(false) + var prepareError: Error? + var commitError: Error? + var unlockError: Error? + + private(set) var prepareCalls: [(unlockIfNeeded: Bool, expectedAgentInstanceID: UUID)] = [] + private(set) var committedTickets: [ServiceReplacementTicket] = [] + private(set) var cancelledTickets: [ServiceReplacementTicket] = [] + private(set) var descriptorCallCount = 0 + private(set) var resetConnectionCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var unlockCallCount = 0 + + init(log: ReplacementCallLog, ticket: ServiceReplacementTicket) { + self.log = log + self.ticket = ticket + } + + func serviceDescriptor() async throws -> ServiceDescriptor { + log.record(.serviceDescriptor) + descriptorCallCount += 1 + return try descriptorResult.get() + } + + func unlock() async throws { + log.record(.unlock) + unlockCallCount += 1 + if let unlockError { + throw unlockError + } + } + + func status() async throws -> Bool { + log.record(.status) + statusCallCount += 1 + return try statusResult.get() + } + + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + log.record(.prepare) + prepareCalls.append((unlockIfNeeded, expectedAgentInstanceID)) + if let prepareError { + throw prepareError + } + return ticket + } + + func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws { + log.record(.cancel) + cancelledTickets.append(ticket) + } + + func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws { + log.record(.commit) + if let commitError { + throw commitError + } + committedTickets.append(ticket) + } + + func resetConnection() { + log.record(.resetConnection) + resetConnectionCallCount += 1 + } +} + +@MainActor +private final class FakeAgentReplacementLifecycle: AgentReplacementLifecycleServing { + let log: ReplacementCallLog + var restartResult: AgentRegistrar.State = .enabled + + init(log: ReplacementCallLog) { + self.log = log + } + + func restart() async -> AgentRegistrar.State { + log.record(.restart) + return restartResult + } +} + +private func makeDescriptor( + agentInstanceID: UUID = UUID(), + replacementPending: Bool = false +) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: agentInstanceID, + replacementPending: replacementPending + ) +} + +private enum TestError: Error, Equatable { + case expected +} diff --git a/docs/development.md b/docs/development.md index b6e9ad3..1620958 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,7 +143,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: - `LockEngine` 依赖真实 CGEventTap 与时钟的路径(auto-unlock 触发、tap-disable fail-open、热键分发、`updateSettings` 重新排程、Accessibility 预检)未单测;纯策略部分(event policy、schedule 计算、runtime state、tap 安装事务)已覆盖。 -- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 虽按 protocol injection 设计,目前无单测;`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)同样未覆盖,`KlockStatusOutput` 渲染已覆盖。 +- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection);`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)未覆盖,`KlockStatusOutput` 渲染已覆盖。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 From 299a66001728f7a297054cccc41725dc488a8382 Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 10:26:15 +0800 Subject: [PATCH 069/106] ci: add macOS build-and-test workflow - Run Core tests, a full build, and KeyboardLockerModelTests on a macOS runner - App build and ModelTests run with CODE_SIGNING_ALLOWED=NO since runners have no signing identities; signed runtime behavior stays a local pre-release gate --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ docs/development.md | 3 ++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a901b60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Show toolchain versions + run: | + xcodebuild -version + xcrun swift --version + + - name: Core package tests + run: xcrun swift test --package-path Core + + # CI runners carry no signing identities, so the app build and the app-level tests run + # with code signing disabled. The mutual XPC code-signing requirement is a runtime + # property exercised by signed local builds; its construction rules are covered by + # XPCCodeSigningRequirementTests in the Core package above. + - name: Build app, agent, CLI, and extensions + run: >- + xcodebuild -scheme KeyboardLocker -configuration Debug build + CODE_SIGNING_ALLOWED=NO + + - name: App-level model tests + run: >- + xcodebuild -scheme KeyboardLocker -configuration Debug + -destination 'platform=macOS' + test -only-testing:KeyboardLockerModelTests + CODE_SIGNING_ALLOWED=NO diff --git a/docs/development.md b/docs/development.md index 1620958..38264d1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -147,4 +147,5 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 -- unsigned/ad-hoc 进程 fail closed 的运行路径(测试 bundle 本身已签名,难以构造)与 CI 自动化(`.github/workflows/` 只有 Claude 机器人,无 macOS 构建/测试门禁)是当前验证体系的边界;合并前必须本地跑过 `CLAUDE.md` 的 build 与两套 test 命令。 +- unsigned/ad-hoc 进程 fail closed 的运行路径未自动化覆盖:测试 bundle 本身已签名,难以在单测中构造未签名进程。 +- CI 由 `.github/workflows/ci.yml` 承载(macOS runner,依次跑 Core 测试、全量 build、`KeyboardLockerModelTests`)。runner 没有签名身份,app build 与 ModelTests 以 `CODE_SIGNING_ALLOWED=NO` 运行;因此签名认证的**运行时**行为不属于 CI 门禁,发布前仍需本地签名构建验证。 From cbb9fe1026268f1598e461b64fe9efb15c9a22dd Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 11:54:23 +0800 Subject: [PATCH 070/106] refactor(service): make LockEngine engine paths testable via injected seams - Wrap the Quartz event tap in InstalledEventTap and inject Accessibility checks, tap installation, timer scheduling, broadcasting, and the clock through LockEngineDependencies; LockEngine.shared keeps the live wiring - Share the one-shot MainActorTimerScheduler with AgentService, replacing its private live scheduler - Cover accessibility precheck, tap install/teardown, auto-unlock scheduling/firing/stale-generation rejection, hotkey and Control-C dispatch, tap-disable re-enable/fail-open, and Focus generation takeover in LockEngineTests --- Core/Sources/Service/AgentService.swift | 20 +- Core/Sources/Service/LockEngine.swift | 212 ++++++---- .../Service/MainActorTimerScheduler.swift | 20 + .../ServiceTests/AgentServiceTests.swift | 2 +- Core/Tests/ServiceTests/LockEngineTests.swift | 384 ++++++++++++++++++ docs/development.md | 5 +- 6 files changed, 541 insertions(+), 102 deletions(-) create mode 100644 Core/Sources/Service/MainActorTimerScheduler.swift create mode 100644 Core/Tests/ServiceTests/LockEngineTests.swift diff --git a/Core/Sources/Service/AgentService.swift b/Core/Sources/Service/AgentService.swift index 52d90f5..4da259a 100644 --- a/Core/Sources/Service/AgentService.swift +++ b/Core/Sources/Service/AgentService.swift @@ -18,12 +18,6 @@ protocol LockEngineServing { extension LockEngine: LockEngineServing {} -/// Schedules the short-lived replacement-preparation expiry. Returns a cancellation closure. -typealias ReplacementExpirationScheduler = ( - _ interval: TimeInterval, - _ fire: @escaping @MainActor () -> Void -) -> () -> Void - /// XPC service implementation. Owns the settings source of truth and drives the single /// global `LockEngine`. All wrappers reach the lock exclusively through this object. /// @@ -35,7 +29,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { @MainActor private let descriptorResult: Result @MainActor private let settings: KeyboardLockerSettings @MainActor private let engine: any LockEngineServing - @MainActor private let expirationScheduler: ReplacementExpirationScheduler + @MainActor private let expirationScheduler: MainActorTimerScheduler @MainActor private var replacement = ReplacementTransaction() @MainActor private var replacementPreparationCancellation: (() -> Void)? @@ -44,7 +38,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { descriptorResult: Result, settings: KeyboardLockerSettings, engine: any LockEngineServing, - expirationScheduler: @escaping ReplacementExpirationScheduler + expirationScheduler: @escaping MainActorTimerScheduler ) { self.descriptorResult = descriptorResult self.settings = settings @@ -61,7 +55,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { descriptorResult: Result { try Self.makeServiceDescriptor() }, settings: KeyboardLockerSettingsStore().load(), engine: LockEngine.shared, - expirationScheduler: Self.liveExpirationScheduler + expirationScheduler: liveMainActorTimerScheduler ) } @@ -398,14 +392,6 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { return replacementError(code: code, description: error.localizedDescription) } - private static let liveExpirationScheduler: ReplacementExpirationScheduler = { interval, fire in - let item = DispatchWorkItem { - MainActor.assumeIsolated { fire() } - } - DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: item) - return { item.cancel() } - } - @MainActor private func scheduleExpiration( for preparation: ReplacementTransaction.Preparation diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 28bf889..1b63750 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -267,6 +267,37 @@ struct EventTapInstallation { } } +/// The installed-tap operations `LockEngine` relies on. The live implementation wraps the real +/// `CFMachPort` tap and its run-loop source; tests substitute a fake to drive the tap-disable +/// and teardown paths deterministically. +protocol InstalledEventTap: AnyObject { + var isEnabled: Bool { get } + func setEnabled(_ enabled: Bool) + /// Disables the tap, detaches its run-loop source, and invalidates the port. + func teardown() +} + +/// Every side effect `LockEngine` performs on the outside world, injected so `ServiceTests` +/// can drive the engine without Quartz, TCC, real timers, or system notifications. +/// `LockEngine.shared` runs on `.live`. +struct LockEngineDependencies { + var hasAccessibilityPermission: @MainActor () -> Bool + var installEventTap: @MainActor (LockEngine) throws -> any InstalledEventTap + var scheduleTimer: MainActorTimerScheduler + var broadcastStateChange: () -> Void + var now: () -> Date + + static var live: Self { + Self( + hasAccessibilityPermission: { AccessibilityManager.hasPermission() }, + installEventTap: { engine in try CoreGraphicsEventTap.install(engine: engine) }, + scheduleTimer: liveMainActorTimerScheduler, + broadcastStateChange: { LockStateBroadcaster.broadcast() }, + now: { Date() } + ) + } +} + /// Use refcon to bridge C callback to Swift instance since CGEventTap requires C function pointer private func eventTapCallback( proxy _: CGEventTapProxy, @@ -290,6 +321,80 @@ private func eventTapCallback( } } +/// Live `InstalledEventTap` backed by a Quartz session event tap on the main run loop. +private final class CoreGraphicsEventTap: InstalledEventTap { + private static let runLoopSourceOrder: CFIndex = 0 + + private let tap: CFMachPort + private let source: CFRunLoopSource + + private init(installation: EventTapInstallation) { + tap = installation.tap + source = installation.source + } + + var isEnabled: Bool { + CGEvent.tapIsEnabled(tap: tap) + } + + func setEnabled(_ enabled: Bool) { + CGEvent.tapEnable(tap: tap, enable: enabled) + } + + func teardown() { + CGEvent.tapEnable(tap: tap, enable: false) + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + CFMachPortInvalidate(tap) + } + + /// Acquires the tap and its run-loop source as one transaction, surfacing engine errors. + static func install(engine: LockEngine) throws -> CoreGraphicsEventTap { + let installation: EventTapInstallation + do { + installation = try EventTapInstallation.make( + createTap: { + CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: LockEngine.eventMasks, + callback: eventTapCallback, + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(engine).toOpaque()) + ) + }, + createSource: { + CFMachPortCreateRunLoopSource( + kCFAllocatorDefault, + $0, + runLoopSourceOrder + ) + }, + attachSource: { + CFRunLoopAddSource(CFRunLoopGetMain(), $0, .commonModes) + }, + enableTap: { + CGEvent.tapEnable(tap: $0, enable: true) + }, + isTapEnabled: { + CGEvent.tapIsEnabled(tap: $0) + }, + detachSource: { + CFRunLoopRemoveSource(CFRunLoopGetMain(), $0, .commonModes) + }, + invalidateTap: CFMachPortInvalidate + ) + } catch EventTapInstallationError.eventTapCreationFailed { + throw LockEngine.LockEngineError.eventTapCreationFailed + } catch EventTapInstallationError.runLoopSourceCreationFailed { + throw LockEngine.LockEngineError.runLoopSourceCreationFailed + } catch EventTapInstallationError.eventTapEnableFailed { + throw LockEngine.LockEngineError.eventTapEnableFailed + } + + return CoreGraphicsEventTap(installation: installation) + } +} + @MainActor public final class LockEngine { public static let shared = LockEngine() @@ -336,13 +441,12 @@ public final class LockEngine { (1 << CGEventType.flagsChanged.rawValue) | (1 << LockedKeyboardEventPolicy.systemDefinedEventType.rawValue) - private static let runLoopSourceOrder: CFIndex = 0 private static let autoRepeatFlagValue: Int64 = 1 - private var eventTap: CFMachPort? + private let dependencies: LockEngineDependencies + private var installedTap: (any InstalledEventTap)? private var eventTapGeneration: UInt64 = 0 - private var runLoopSource: CFRunLoopSource? - private var autoUnlockTimer: DispatchSourceTimer? + private var cancelScheduledAutoUnlock: (() -> Void)? private var runtimeState = LockRuntimeState() public var isLocked: Bool { @@ -351,10 +455,13 @@ public final class LockEngine { /// One coherent Agent-owned snapshot for read-only wrappers and presentation surfaces. public var statusSnapshot: LockStatusSnapshot { - runtimeState.statusSnapshot(capturedAt: Date()) + runtimeState.statusSnapshot(capturedAt: dependencies.now()) } - private init() {} + /// `shared` runs on live system dependencies; tests inject deterministic fakes. + init(dependencies: LockEngineDependencies = .live) { + self.dependencies = dependencies + } @discardableResult public func lock( @@ -398,7 +505,7 @@ public final class LockEngine { } // Verify Accessibility permission before attempting to create event tap. - guard AccessibilityManager.hasPermission() else { + guard dependencies.hasAccessibilityPermission() else { throw LockEngineError.accessibilityPermissionDenied } @@ -407,7 +514,7 @@ public final class LockEngine { let outcome = runtimeState.begin( settings: settings, allowsControlCUnlock: allowsControlCUnlock, - at: Date() + at: dependencies.now() ) guard outcome == .acquired else { teardownEventTap() @@ -431,56 +538,13 @@ public final class LockEngine { } private func startEventTap() throws { - let installation: EventTapInstallation - do { - installation = try EventTapInstallation.make( - createTap: { - CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: Self.eventMasks, - callback: eventTapCallback, - userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) - }, - createSource: { - CFMachPortCreateRunLoopSource( - kCFAllocatorDefault, - $0, - Self.runLoopSourceOrder - ) - }, - attachSource: { - CFRunLoopAddSource(CFRunLoopGetMain(), $0, .commonModes) - }, - enableTap: { - CGEvent.tapEnable(tap: $0, enable: true) - }, - isTapEnabled: { - CGEvent.tapIsEnabled(tap: $0) - }, - detachSource: { - CFRunLoopRemoveSource(CFRunLoopGetMain(), $0, .commonModes) - }, - invalidateTap: CFMachPortInvalidate - ) - } catch EventTapInstallationError.eventTapCreationFailed { - throw LockEngineError.eventTapCreationFailed - } catch EventTapInstallationError.runLoopSourceCreationFailed { - throw LockEngineError.runLoopSourceCreationFailed - } catch EventTapInstallationError.eventTapEnableFailed { - throw LockEngineError.eventTapEnableFailed - } - + installedTap = try dependencies.installEventTap(self) eventTapGeneration &+= 1 - eventTap = installation.tap - runLoopSource = installation.source } private func markLocked() { configureAutoUnlockTimerIfNeeded() - LockStateBroadcaster.broadcast() + dependencies.broadcastStateChange() Self.logger.info("Locked") } @@ -491,12 +555,12 @@ public final class LockEngine { // An explicit settings update starts a fresh timeout window without changing when the // authoritative lock began. A duplicate lock request never reaches this path. - let referenceDate = Date() + let referenceDate = dependencies.now() guard runtimeState.isLocked, let schedule = AutoUnlockSchedule.make( timeout: timeout, referenceDate: referenceDate, - currentDate: Date() + currentDate: dependencies.now() ) else { return @@ -508,18 +572,14 @@ public final class LockEngine { return } - let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now() + schedule.delay) - timer.setEventHandler { [weak self] in + cancelScheduledAutoUnlock = dependencies.scheduleTimer(schedule.delay) { [weak self] in self?.unlock(ifLockGeneration: lockGeneration) } - timer.resume() - autoUnlockTimer = timer } private func cancelAutoUnlockTimer() { - autoUnlockTimer?.cancel() - autoUnlockTimer = nil + cancelScheduledAutoUnlock?() + cancelScheduledAutoUnlock = nil runtimeState.setAutoUnlockTargetDate(nil) } @@ -541,38 +601,26 @@ public final class LockEngine { } private func teardownEventTap() { - let tap = eventTap - if let tap { - CGEvent.tapEnable(tap: tap, enable: false) - } - - if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) - runLoopSource = nil - } - - if let tap { - CFMachPortInvalidate(tap) - eventTap = nil - } + installedTap?.teardown() + installedTap = nil } private func resetLockState() { runtimeState.end() - LockStateBroadcaster.broadcast() + dependencies.broadcastStateChange() Self.logger.info("Unlocked") } - fileprivate func handleDisabledEvent(_ event: CGEvent) -> Unmanaged? { - guard runtimeState.isLocked, let tap = eventTap else { + func handleDisabledEvent(_ event: CGEvent) -> Unmanaged? { + guard runtimeState.isLocked, let tap = installedTap else { return Unmanaged.passUnretained(event) } Self.logger.warning("Event tap disabled by system, attempting to re-enable") - CGEvent.tapEnable(tap: tap, enable: true) + tap.setEnabled(true) - guard !CGEvent.tapIsEnabled(tap: tap) else { + guard !tap.isEnabled else { return Unmanaged.passUnretained(event) } @@ -594,7 +642,7 @@ public final class LockEngine { resetLockState() } - fileprivate func handleEvent( + func handleEvent( type: CGEventType, event: CGEvent ) -> Unmanaged? { diff --git a/Core/Sources/Service/MainActorTimerScheduler.swift b/Core/Sources/Service/MainActorTimerScheduler.swift new file mode 100644 index 0000000..6e6ffca --- /dev/null +++ b/Core/Sources/Service/MainActorTimerScheduler.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Schedules a one-shot main-actor action after an interval and returns a cancellation closure. +/// +/// Shared by `LockEngine` (auto-unlock) and `AgentService` (replacement-preparation expiry) so +/// `ServiceTests` can substitute a deterministic manual scheduler for either consumer. +typealias MainActorTimerScheduler = ( + _ interval: TimeInterval, + _ fire: @escaping @MainActor () -> Void +) -> () -> Void + +/// Live scheduler backed by the main dispatch queue. The deadline uses the monotonic clock, +/// so it pauses while the system is asleep. +let liveMainActorTimerScheduler: MainActorTimerScheduler = { interval, fire in + let item = DispatchWorkItem { + MainActor.assumeIsolated { fire() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: item) + return { item.cancel() } +} diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift index 1947be5..22b9047 100644 --- a/Core/Tests/ServiceTests/AgentServiceTests.swift +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -439,7 +439,7 @@ private final class ManualExpirationScheduler { private(set) var cancelCount = 0 private var pendingFire: (@MainActor () -> Void)? - var scheduler: ReplacementExpirationScheduler { + var scheduler: MainActorTimerScheduler { { interval, fire in self.scheduledCount += 1 self.lastInterval = interval diff --git a/Core/Tests/ServiceTests/LockEngineTests.swift b/Core/Tests/ServiceTests/LockEngineTests.swift new file mode 100644 index 0000000..dbb7adb --- /dev/null +++ b/Core/Tests/ServiceTests/LockEngineTests.swift @@ -0,0 +1,384 @@ +import Common +import CoreGraphics +@testable import Service +import XCTest + +@MainActor +final class LockEngineTests: XCTestCase { + private var tap: FakeInstalledEventTap! + private var scheduler: ManualTimerScheduler! + private var installCount = 0 + private var broadcastCount = 0 + private var hasAccessibilityPermission = true + private var now: Date! + + override func setUp() { + super.setUp() + tap = FakeInstalledEventTap() + scheduler = ManualTimerScheduler() + installCount = 0 + broadcastCount = 0 + hasAccessibilityPermission = true + now = Date(timeIntervalSinceReferenceDate: 1_000) + } + + // MARK: - Acquisition + + func testLockWithoutAccessibilityPermissionThrowsBeforeInstallingTap() { + hasAccessibilityPermission = false + let engine = makeEngine() + + XCTAssertThrowsError(try engine.lock(settings: makeSettings())) { error in + XCTAssertEqual(error as? LockEngine.LockEngineError, .accessibilityPermissionDenied) + } + XCTAssertEqual(installCount, 0) + XCTAssertFalse(engine.isLocked) + XCTAssertEqual(broadcastCount, 0) + } + + func testLockInstallsTapSchedulesAutoUnlockAndBroadcasts() throws { + let engine = makeEngine() + + let outcome = try engine.lock(settings: makeSettings()) + + XCTAssertEqual(outcome, .acquired) + XCTAssertTrue(engine.isLocked) + XCTAssertEqual(installCount, 1) + XCTAssertEqual(broadcastCount, 1) + XCTAssertEqual(scheduler.timers.map(\.interval), [60]) + XCTAssertEqual( + engine.statusSnapshot.autoUnlockTargetDate, + now.addingTimeInterval(60) + ) + } + + func testDuplicateLockNeverMutatesTheRunningLock() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + let duplicate = try engine.lock( + settings: makeSettings(autoUnlockPolicy: .timed(seconds: 120)) + ) + + XCTAssertEqual(duplicate, .alreadyLocked) + XCTAssertEqual(installCount, 1) + XCTAssertEqual(scheduler.timers.count, 1) + XCTAssertEqual(broadcastCount, 1) + XCTAssertEqual( + engine.statusSnapshot.autoUnlockTargetDate, + now.addingTimeInterval(60) + ) + } + + // MARK: - Auto-unlock scheduling + + func testAutoUnlockTimerFireUnlocks() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + scheduler.timers[0].fire() + + XCTAssertFalse(engine.isLocked) + XCTAssertEqual(tap.teardownCallCount, 1) + XCTAssertEqual(broadcastCount, 2) + XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + } + + func testStaleTimerFireKeepsNewerLock() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + let staleTimer = scheduler.timers[0] + + engine.unlock() + XCTAssertTrue(staleTimer.isCancelled) + + _ = try engine.lock(settings: makeSettings()) + XCTAssertEqual(scheduler.timers.count, 2) + + // A cancel that lost the race against an already-queued fire must not unlock the new lock. + staleTimer.fire() + XCTAssertTrue(engine.isLocked) + } + + func testUpdateSettingsWhileLockedRearmsAutoUnlock() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + engine.updateSettings(makeSettings(autoUnlockPolicy: .timed(seconds: 120))) + + XCTAssertEqual(scheduler.timers.map(\.interval), [60, 120]) + XCTAssertTrue(scheduler.timers[0].isCancelled) + XCTAssertFalse(scheduler.timers[1].isCancelled) + XCTAssertEqual( + engine.statusSnapshot.autoUnlockTargetDate, + now.addingTimeInterval(120) + ) + } + + func testUpdateSettingsWhileUnlockedSchedulesNoTimer() { + let engine = makeEngine() + + engine.updateSettings(makeSettings()) + + XCTAssertTrue(scheduler.timers.isEmpty) + XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + } + + func testLockWithDisabledAutoUnlockSchedulesNoTimer() throws { + let engine = makeEngine() + + _ = try engine.lock(settings: makeSettings(autoUnlockPolicy: .disabled)) + + XCTAssertTrue(engine.isLocked) + XCTAssertTrue(scheduler.timers.isEmpty) + XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + } + + // MARK: - Unlock gestures + + func testUnlockHotkeyEventUnlocksAfterDispatch() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + let event = makeKeyEvent(keyCode: 4, flags: .maskShift) + XCTAssertNil(engine.handleEvent(type: .keyDown, event: event)) + XCTAssertTrue(engine.isLocked) + + await flushMainQueue() + + XCTAssertFalse(engine.isLocked) + XCTAssertEqual(tap.teardownCallCount, 1) + XCTAssertEqual(broadcastCount, 2) + } + + func testUnrelatedKeyEventIsConsumedWithoutUnlocking() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + let event = makeKeyEvent(keyCode: 40) + XCTAssertNil(engine.handleEvent(type: .keyDown, event: event)) + + await flushMainQueue() + + XCTAssertTrue(engine.isLocked) + XCTAssertEqual(tap.teardownCallCount, 0) + } + + func testControlCUnlocksOnlyWhenAllowed() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings(), allowsControlCUnlock: false) + + let controlC = makeKeyEvent( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: .maskControl + ) + XCTAssertNil(engine.handleEvent(type: .keyDown, event: controlC)) + await flushMainQueue() + XCTAssertTrue(engine.isLocked) + + engine.unlock() + _ = try engine.lock(settings: makeSettings(), allowsControlCUnlock: true) + XCTAssertNil(engine.handleEvent(type: .keyDown, event: controlC)) + await flushMainQueue() + XCTAssertFalse(engine.isLocked) + } + + // MARK: - Event tap failure + + func testTapDisabledAndReenabledKeepsLock() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + tap.isEnabled = false + let event = makeKeyEvent(keyCode: 0) + XCTAssertNotNil(engine.handleDisabledEvent(event)) + + await flushMainQueue() + + XCTAssertTrue(engine.isLocked) + XCTAssertEqual(tap.setEnabledCalls, [true]) + XCTAssertEqual(tap.teardownCallCount, 0) + XCTAssertEqual(broadcastCount, 1) + } + + func testTapDisabledBeyondRecoveryUnlocksFailOpen() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + tap.isEnabled = false + tap.allowsReEnable = false + let event = makeKeyEvent(keyCode: 0) + XCTAssertNotNil(engine.handleDisabledEvent(event)) + XCTAssertTrue(engine.isLocked) + + await flushMainQueue() + + XCTAssertFalse(engine.isLocked) + XCTAssertEqual(tap.teardownCallCount, 1) + XCTAssertEqual(broadcastCount, 2) + } + + func testStaleTapFailureKeepsNewerLock() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + tap.isEnabled = false + tap.allowsReEnable = false + let event = makeKeyEvent(keyCode: 0) + XCTAssertNotNil(engine.handleDisabledEvent(event)) + + engine.unlock() + tap.isEnabled = true + tap.allowsReEnable = true + _ = try engine.lock(settings: makeSettings()) + + await flushMainQueue() + + XCTAssertTrue(engine.isLocked) + } + + // MARK: - Unlock + + func testUnlockCancelsTimerTearsDownAndBroadcasts() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + engine.unlock() + + XCTAssertFalse(engine.isLocked) + XCTAssertTrue(scheduler.timers[0].isCancelled) + XCTAssertEqual(tap.teardownCallCount, 1) + XCTAssertEqual(broadcastCount, 2) + XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + } + + // MARK: - Focus ownership + + func testFocusDeactivationReleasesFocusOwnedLock() throws { + let engine = makeEngine() + try engine.setFocusFilterLockEnabled(true, settings: makeSettings()) + XCTAssertTrue(engine.isLocked) + + try engine.setFocusFilterLockEnabled(false, settings: makeSettings()) + + XCTAssertFalse(engine.isLocked) + XCTAssertEqual(tap.teardownCallCount, 1) + } + + func testFocusDeactivationKeepsLockTakenOverByGeneralRequest() throws { + let engine = makeEngine() + try engine.setFocusFilterLockEnabled(true, settings: makeSettings()) + + // An explicit lock takes persistence over from the Focus-created generation. + let takeover = try engine.lock(settings: makeSettings()) + XCTAssertEqual(takeover, .alreadyLocked) + + try engine.setFocusFilterLockEnabled(false, settings: makeSettings()) + + XCTAssertTrue(engine.isLocked) + XCTAssertEqual(tap.teardownCallCount, 0) + } + + // MARK: - Helpers + + private func makeEngine() -> LockEngine { + LockEngine(dependencies: LockEngineDependencies( + hasAccessibilityPermission: { self.hasAccessibilityPermission }, + installEventTap: { _ in + self.installCount += 1 + return self.tap + }, + scheduleTimer: scheduler.scheduler, + broadcastStateChange: { self.broadcastCount += 1 }, + now: { self.now } + )) + } + + private func makeSettings( + autoUnlockPolicy: KeyboardLockerSettings.AutoUnlockPolicy = .timed(seconds: 60) + ) -> KeyboardLockerSettings { + KeyboardLockerSettings( + autoUnlockPolicy: autoUnlockPolicy, + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) + ) + } + + private func makeKeyEvent( + keyCode: CGKeyCode, + flags: CGEventFlags = [] + ) -> CGEvent { + // Fabrication only builds an in-memory event value; nothing is posted to the HID system. + // Bind the result to a local when asserting on a handler's return: handlers hand the event + // back unretained, so it must outlive the returned reference. + let event = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)! + event.flags = flags + return event + } + + /// Runs the main-queue hop the engine uses to dispatch unlock and tap-failure work. + private func flushMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } +} + +private final class FakeInstalledEventTap: InstalledEventTap { + var isEnabled = true + var allowsReEnable = true + private(set) var setEnabledCalls: [Bool] = [] + private(set) var teardownCallCount = 0 + + func setEnabled(_ enabled: Bool) { + setEnabledCalls.append(enabled) + guard enabled else { + isEnabled = false + return + } + if allowsReEnable { + isEnabled = true + } + } + + func teardown() { + teardownCallCount += 1 + isEnabled = false + } +} + +/// Manual scheduler whose timers stay fireable after cancellation: a cancelled dispatch block +/// may already be in flight, and the engine's generation guard must reject exactly that stale +/// fire, so tests need to replay it. +private final class ManualTimerScheduler { + final class Timer { + let interval: TimeInterval + private let fireAction: @MainActor () -> Void + private(set) var isCancelled = false + + init(interval: TimeInterval, fire: @escaping @MainActor () -> Void) { + self.interval = interval + fireAction = fire + } + + func cancel() { + isCancelled = true + } + + @MainActor + func fire() { + fireAction() + } + } + + private(set) var timers: [Timer] = [] + + var scheduler: MainActorTimerScheduler { + { interval, fire in + let timer = Timer(interval: interval, fire: fire) + self.timers.append(timer) + return { timer.cancel() } + } + } +} diff --git a/docs/development.md b/docs/development.md index 38264d1..04494c7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,10 +36,11 @@ - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。event policy 消费标准按键及 system-defined keyboard control,同时明确放行鼠标 / 触控板 pointer event。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。 +- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。event policy 消费标准按键及 system-defined keyboard control,同时明确放行鼠标 / 触控板 pointer event。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。引擎的全部外部副作用(Accessibility 查询、tap 安装、定时器排程、状态广播、时钟)经 `LockEngineDependencies` / `InstalledEventTap` 注入缝进入,`LockEngineTests` 因此能确定性驱动这些路径。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 +- `MainActorTimerScheduler.swift`:单次 MainActor 定时器抽象(返回取消闭包)及其 live 实现;`LockEngine` 的 auto-unlock 与 `AgentService` 的 replacement 过期共用,测试中替换为手动调度。 - `AgentService.swift`:nonisolated XPC wire adapter,持有设置快照与 replacement transaction,并把所有可变状态与引擎操作切到 `MainActor`。引擎、descriptor 与 prepare 过期调度都经注入缝进入;replacement barrier、instance-ID fence、locked refusal、错误码映射与过期/取消语义由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 @@ -142,7 +143,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: -- `LockEngine` 依赖真实 CGEventTap 与时钟的路径(auto-unlock 触发、tap-disable fail-open、热键分发、`updateSettings` 重新排程、Accessibility 预检)未单测;纯策略部分(event policy、schedule 计算、runtime state、tap 安装事务)已覆盖。 +- `LockEngine` 的纯策略(event policy、schedule 计算、runtime state、tap 安装事务)与引擎语义(Accessibility 预检、tap 安装/拆除、auto-unlock 排程/触发/陈旧代际拒绝、热键与 Control-C 分发、tap-disable re-enable/fail-open、Focus 代际所有权)均已单测 —— 后者由 `LockEngineTests` 经注入缝(`InstalledEventTap`、`MainActorTimerScheduler`、权限/广播/时钟闭包)确定性驱动;剩余不可单测的只有 Quartz 对真实硬件事件的拦截本身(平台集成事实,靠真机验证)。 - App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection);`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)未覆盖,`KlockStatusOutput` 渲染已覆盖。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 From a47815a3d372a3f63d2628d7b6139a427b0570af Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 11:54:51 +0800 Subject: [PATCH 071/106] refactor(app): make forced replacement failure structurally impossible - Split replace(_:) into transaction and forced paths so a forced plan without a readable lock state contains no throwing call and can never produce .failed; every other path is behavior-identical, proven by all pre-existing coordinator tests passing unmodified - Cover every Task-cancellation checkpoint in AgentReadinessCoordinator and the forced restart-failure combination in AgentReplacementCoordinator --- .../AgentReplacementCoordinator.swift | 110 +++++++----- .../AgentReadinessCoordinatorTests.swift | 162 ++++++++++++++++++ .../AgentReplacementCoordinatorTests.swift | 29 ++++ docs/development.md | 2 +- 4 files changed, 262 insertions(+), 41 deletions(-) diff --git a/KeyboardLocker/AgentReplacementCoordinator.swift b/KeyboardLocker/AgentReplacementCoordinator.swift index 11a8087..777eac2 100644 --- a/KeyboardLocker/AgentReplacementCoordinator.swift +++ b/KeyboardLocker/AgentReplacementCoordinator.swift @@ -86,44 +86,30 @@ struct AgentReplacementCoordinator { } func replace(_ plan: AgentUpdatePlan) async -> Outcome { + switch plan.mode { + case let .safe(descriptor, isLocked): + return await replaceWithTransaction(descriptor: descriptor, isLocked: isLocked) + case let .forced(descriptor, isLocked): + return await replaceForced(descriptor: descriptor, isLocked: isLocked) + } + } + + private func replaceWithTransaction( + descriptor: ServiceDescriptor, + isLocked: Bool + ) async -> Outcome { var replacementTicket: ServiceReplacementTicket? var didCommitReplacement = false do { - switch plan.mode { - case let .safe(descriptor, isLocked): - replacementTicket = try await client.prepareForReplacement( - unlockIfNeeded: isLocked, - expectedAgentInstanceID: descriptor.agentInstanceID - ) - if let replacementTicket { - try await client.commitReplacement(ticket: replacementTicket) - didCommitReplacement = true - } - - case let .forced(_, isLocked): - if isLocked != nil { - // The legacy path has no cross-client drain. The explicit user action authorizes - // releasing the known lock immediately before the shortest possible restart window. - try await client.unlock() - } - } - - let registrationState = await lifecycle.restart() - guard case .enabled = registrationState else { - if let replacementTicket, !didCommitReplacement { - try? await client.cancelReplacementPreparation( - ticket: replacementTicket - ) - } - client.resetConnection() - return .registration(registrationState) - } - - client.resetConnection() - return .restarted( - previousAgentInstanceID: plan.descriptor?.agentInstanceID + replacementTicket = try await client.prepareForReplacement( + unlockIfNeeded: isLocked, + expectedAgentInstanceID: descriptor.agentInstanceID ) + if let replacementTicket { + try await client.commitReplacement(ticket: replacementTicket) + didCommitReplacement = true + } } catch { if let replacementTicket, !didCommitReplacement { try? await client.cancelReplacementPreparation( @@ -131,18 +117,62 @@ struct AgentReplacementCoordinator { ) } - if plan.supportsSafeReplacement, - let descriptor = await reportedReplacementInProgress() { + if let descriptor = await reportedReplacementInProgress() { return .replacementInProgress(descriptor) } - let currentLockState: Bool? = if plan.canReadLockState { - try? await client.status() - } else { - nil + return .failed(error: error, currentLockState: try? await client.status()) + } + + return await restart( + reporting: descriptor, + cancellingUncommittedTicket: replacementTicket, + didCommit: didCommitReplacement + ) + } + + /// The forced path has no transaction to roll back and no trusted replacement to re-detect. + /// A plan without a readable lock state performs no throwing call at all, so it can never + /// produce `.failed`. + private func replaceForced( + descriptor: ServiceDescriptor?, + isLocked: Bool? + ) async -> Outcome { + if isLocked != nil { + // The legacy path has no cross-client drain. The explicit user action authorizes + // releasing the known lock immediately before the shortest possible restart window. + do { + try await client.unlock() + } catch { + return .failed(error: error, currentLockState: try? await client.status()) + } + } + + return await restart( + reporting: descriptor, + cancellingUncommittedTicket: nil, + didCommit: false + ) + } + + private func restart( + reporting descriptor: ServiceDescriptor?, + cancellingUncommittedTicket replacementTicket: ServiceReplacementTicket?, + didCommit didCommitReplacement: Bool + ) async -> Outcome { + let registrationState = await lifecycle.restart() + guard case .enabled = registrationState else { + if let replacementTicket, !didCommitReplacement { + try? await client.cancelReplacementPreparation( + ticket: replacementTicket + ) } - return .failed(error: error, currentLockState: currentLockState) + client.resetConnection() + return .registration(registrationState) } + + client.resetConnection() + return .restarted(previousAgentInstanceID: descriptor?.agentInstanceID) } private func reportedReplacementInProgress() async -> ServiceDescriptor? { diff --git a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift index 80675f0..a680266 100644 --- a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift @@ -390,6 +390,133 @@ final class AgentReadinessCoordinatorTests: XCTestCase { XCTAssertEqual(failure, .invalidBundle("Corrupt bundle.")) XCTAssertEqual(client.statusCallCount, 0) } + + @MainActor + func testCancellationAfterEnsureEnabledReturnsCancelled() async { + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())] + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + inspection.cancel() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + XCTFail("Expected .cancelled, got \(outcome).") + return + } + XCTAssertEqual(client.descriptorCallCount, 0) + } + + @MainActor + func testCancellationDuringInitialDescriptorFetchReturnsCancelled() async { + let descriptorGate = ReadinessTestGate() + let descriptorStarted = expectation(description: "Descriptor fetch started.") + descriptorGate.onEntered = { descriptorStarted.fulfill() } + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())] + ) + client.descriptorGate = descriptorGate + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await fulfillment(of: [descriptorStarted], timeout: 5) + inspection.cancel() + descriptorGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + XCTFail("Expected .cancelled, got \(outcome).") + return + } + XCTAssertEqual(client.descriptorCallCount, 1) + XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) + } + + @MainActor + func testCancellationAfterDescriptorFailureSkipsRetryAndReturnsCancelled() async { + let descriptorGate = ReadinessTestGate() + let descriptorStarted = expectation(description: "Descriptor fetch started.") + descriptorGate.onEntered = { descriptorStarted.fulfill() } + let client = FakeAgentReadinessClient( + descriptorResults: [.failure(TestError.handshake)] + ) + client.descriptorGate = descriptorGate + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await fulfillment(of: [descriptorStarted], timeout: 5) + inspection.cancel() + descriptorGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + XCTFail("Expected .cancelled, got \(outcome).") + return + } + XCTAssertEqual(client.descriptorCallCount, 1) + XCTAssertEqual(client.resetConnectionCallCount, 0) + } + + @MainActor + func testCancellationDuringCompatibleStatusFetchReturnsCancelled() async { + let statusGate = ReadinessTestGate() + let statusStarted = expectation(description: "Status fetch started.") + statusGate.onEntered = { statusStarted.fulfill() } + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())], + statusResult: .success(true) + ) + client.statusGate = statusGate + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await fulfillment(of: [statusStarted], timeout: 5) + inspection.cancel() + statusGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + XCTFail("Expected .cancelled, got \(outcome).") + return + } + XCTAssertEqual(client.statusCallCount, 1) + } + + @MainActor + func testCancellationDuringUnverifiedAgentStatusFetchReturnsCancelled() async { + let statusGate = ReadinessTestGate() + let statusStarted = expectation(description: "Status fetch started.") + statusGate.onEntered = { statusStarted.fulfill() } + let client = FakeAgentReadinessClient( + descriptorResults: [ + .failure(TestError.handshake), + .failure(TestError.handshake), + ], + statusResult: .success(true) + ) + client.statusGate = statusGate + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await fulfillment(of: [statusStarted], timeout: 5) + inspection.cancel() + statusGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + XCTFail("Expected .cancelled, got \(outcome).") + return + } + XCTAssertEqual(client.descriptorCallCount, 2) + XCTAssertEqual(client.statusCallCount, 1) + } } @MainActor @@ -404,6 +531,8 @@ private final class FakeAgentReadinessClient: AgentReadinessServing { private var descriptorResults: [Result] var statusResult: Result var permissionResult: Result + var descriptorGate: ReadinessTestGate? + var statusGate: ReadinessTestGate? private(set) var events: [Event] = [] private(set) var descriptorCallCount = 0 @@ -427,6 +556,9 @@ private final class FakeAgentReadinessClient: AgentReadinessServing { func serviceDescriptor() async throws -> ServiceDescriptor { events.append(.serviceDescriptor) descriptorCallCount += 1 + if let descriptorGate { + await descriptorGate.wait() + } let result = descriptorResults.count > 1 ? descriptorResults.removeFirst() : descriptorResults[0] @@ -436,6 +568,9 @@ private final class FakeAgentReadinessClient: AgentReadinessServing { func status() async throws -> Bool { events.append(.status) statusCallCount += 1 + if let statusGate { + await statusGate.wait() + } return try statusResult.get() } @@ -476,6 +611,33 @@ private final class FakeAgentReadinessLifecycle: AgentReadinessLifecycleServing } } +/// Holds a fake XPC response until the test releases it, so cancellation can land while +/// the coordinator is suspended at a specific call. +@MainActor +private final class ReadinessTestGate { + private var waiters: [CheckedContinuation] = [] + private var isOpen = false + + var onEntered: (() -> Void)? + + func wait() async { + onEntered?() + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + isOpen = true + let pending = waiters + waiters.removeAll() + pending.forEach { $0.resume() } + } +} + private func makeDescriptor( agentInstanceID: UUID = UUID(), capabilities: Set = ServiceContract.requiredCapabilities, diff --git a/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift index 5030ab9..5917b1a 100644 --- a/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift @@ -239,6 +239,35 @@ final class AgentReplacementCoordinatorTests: XCTestCase { XCTAssertEqual(log.steps, [.unlock, .restart, .resetConnection]) } + @MainActor + func testForcedPlanWithKnownLockStateRestartFailureReturnsRegistration() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + lifecycle.restartResult = .approvalRequired + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .registration(state) = outcome else { + XCTFail("Expected .registration, got \(outcome).") + return + } + XCTAssertEqual(state, .approvalRequired) + XCTAssertEqual(client.unlockCallCount, 1) + XCTAssertTrue(client.cancelledTickets.isEmpty) + XCTAssertEqual(log.steps, [.unlock, .restart, .resetConnection]) + } + @MainActor func testForcedPlanWithUnknownLockStateSkipsUnlock() async { let log = ReplacementCallLog() diff --git a/docs/development.md b/docs/development.md index 04494c7..d25c790 100644 --- a/docs/development.md +++ b/docs/development.md @@ -144,7 +144,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: - `LockEngine` 的纯策略(event policy、schedule 计算、runtime state、tap 安装事务)与引擎语义(Accessibility 预检、tap 安装/拆除、auto-unlock 排程/触发/陈旧代际拒绝、热键与 Control-C 分发、tap-disable re-enable/fail-open、Focus 代际所有权)均已单测 —— 后者由 `LockEngineTests` 经注入缝(`InstalledEventTap`、`MainActorTimerScheduler`、权限/广播/时钟闭包)确定性驱动;剩余不可单测的只有 Quartz 对真实硬件事件的拦截本身(平台集成事实,靠真机验证)。 -- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection);`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)未覆盖,`KlockStatusOutput` 渲染已覆盖。 +- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)未覆盖,`KlockStatusOutput` 渲染已覆盖。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 From 31bf0fe92fb958de4656f71ac8ade64f85fba951 Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 11:55:12 +0800 Subject: [PATCH 072/106] refactor(klock): extract argument parser and testable CLI core - Move argument acceptance into the pure KlockCommandLineParser and make KlockCLI return exit codes with injected client/stdout/stderr; main.swift stays a one-line entry, and CLI text and exit codes are byte-identical - Cover the full argument matrix and command execution paths (unknown commands, extra arguments, already-locked exit, output routing) in KlockCommandLineTests; version execution stays untested because it reads Bundle.main --- .../KlockCommandLineTests.swift | 320 ++++++++++++++++++ docs/development.md | 2 +- klock/KlockCLI.swift | 241 +++++++++++++ klock/KlockCommand.swift | 72 ++++ klock/main.swift | 210 ------------ 5 files changed, 634 insertions(+), 211 deletions(-) create mode 100644 KeyboardLockerModelTests/KlockCommandLineTests.swift create mode 100644 klock/KlockCLI.swift create mode 100644 klock/KlockCommand.swift diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift new file mode 100644 index 0000000..3b3bbb1 --- /dev/null +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -0,0 +1,320 @@ +import Client +import CoreGraphics +import Foundation +import XCTest + +final class KlockCommandLineTests: XCTestCase { + // MARK: - Parser + + func testParseReturnsNilWhenNoArgumentsGiven() throws { + XCTAssertNil(try KlockCommandLineParser.parse([])) + } + + func testParseAcceptsEveryValidCommandSpelling() throws { + let cases: [(arguments: [String], expected: KlockCommand)] = [ + (["--help"], .help), + (["-h"], .help), + (["help"], .help), + (["--version"], .version), + (["-v"], .version), + (["version"], .version), + (["lock"], .lock(wait: true)), + (["lock", "--no-wait"], .lock(wait: false)), + (["unlock"], .unlock), + (["status"], .status(output: .humanReadable)), + (["status", "--json"], .status(output: .json)), + ] + + for (arguments, expected) in cases { + XCTAssertEqual( + try KlockCommandLineParser.parse(arguments), + expected, + "arguments: \(arguments)" + ) + } + } + + func testParseRejectsUnknownCommands() { + let cases = [["bogus"], ["--unknown-flag"]] + + for arguments in cases { + XCTAssertThrowsError( + try KlockCommandLineParser.parse(arguments), + "arguments: \(arguments)" + ) { error in + XCTAssertEqual( + error as? KlockCommandLineError, + .unknownCommand(arguments[0]), + "arguments: \(arguments)" + ) + } + } + } + + func testParseRejectsUnexpectedArguments() { + let cases: [(arguments: [String], expected: KlockCommandLineError)] = [ + (["lock", "extra"], .unexpectedArguments(["extra"])), + (["lock", "--no-wait", "extra"], .unexpectedArguments(["--no-wait", "extra"])), + (["status", "--xml"], .unexpectedArguments(["--xml"])), + (["unlock", "now"], .unexpectedArguments(["now"])), + (["--help", "extra"], .unexpectedArguments(["extra"])), + (["version", "extra"], .unexpectedArguments(["extra"])), + ] + + for (arguments, expected) in cases { + XCTAssertThrowsError( + try KlockCommandLineParser.parse(arguments), + "arguments: \(arguments)" + ) { error in + XCTAssertEqual(error as? KlockCommandLineError, expected, "arguments: \(arguments)") + } + } + } + + // MARK: - Command Execution + + func testInteractiveLockReportsAlreadyLockedWithoutWaiting() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .success(.alreadyLocked) + + let result = await runKlock(arguments: ["lock"], client: client) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Already locked. This command did not create a new lock."]) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.lockInteractivelyCalls, 1) + XCTAssertEqual(client.waitUntilUnlockedCalls, 0) + } + + func testInteractiveLockPrintsHotkeyWaitsAndConfirmsUnlock() async { + let client = FakeKlockClient() + let hotkey = KeyboardLockerSettings.testFixture.unlockHotkey.displayString + + let result = await runKlock(arguments: ["lock"], client: client) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + result.stdout, + [ + "Locked. Press \(hotkey) or Ctrl+C to unlock.", + "Unlocked.", + ] + ) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.currentSettingsCalls, 1) + XCTAssertEqual(client.waitUntilUnlockedCalls, 1) + } + + func testInteractiveLockFallsBackToManualHintWhenSettingsUnavailable() async { + let client = FakeKlockClient() + client.settingsResult = .failure(Self.makeError("settings unavailable")) + + let result = await runKlock(arguments: ["lock"], client: client) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + result.stdout, + [ + "Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.", + "Unlocked.", + ] + ) + XCTAssertEqual( + result.stderr, + ["Warning: Could not read the configured unlock shortcut: settings unavailable"] + ) + } + + func testInteractiveLockFailureReportsErrorOnStandardError() async throws { + let client = FakeKlockClient() + let error = XPCClientError.serviceUnavailable + client.lockInteractivelyResult = .failure(error) + + let result = await runKlock(arguments: ["lock"], client: client) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, []) + XCTAssertEqual( + result.stderr, + [ + "Error: \(error.localizedDescription)", + " \(try XCTUnwrap(error.recoverySuggestion))", + ] + ) + XCTAssertEqual(client.waitUntilUnlockedCalls, 0) + } + + func testNonInteractiveLockPrintsLockedWithoutWaiting() async { + let client = FakeKlockClient() + + let result = await runKlock(arguments: ["lock", "--no-wait"], client: client) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Locked."]) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.lockCalls, 1) + XCTAssertEqual(client.lockInteractivelyCalls, 0) + XCTAssertEqual(client.waitUntilUnlockedCalls, 0) + } + + func testNonInteractiveLockFailureExitsWithError() async { + let client = FakeKlockClient() + client.lockResult = .failure(Self.makeError("lock failed")) + + let result = await runKlock(arguments: ["lock", "--no-wait"], client: client) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, []) + XCTAssertEqual(result.stderr, ["Error: lock failed"]) + } + + func testUnlockPrintsUnlockedOnSuccess() async { + let client = FakeKlockClient() + + let result = await runKlock(arguments: ["unlock"], client: client) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Unlocked."]) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.unlockCalls, 1) + } + + func testUnlockFailureReportsErrorOnStandardError() async { + let client = FakeKlockClient() + client.unlockResult = .failure(Self.makeError("unlock failed")) + + let result = await runKlock(arguments: ["unlock"], client: client) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, []) + XCTAssertEqual(result.stderr, ["Error: unlock failed"]) + } + + func testStatusPrintsHumanReadableState() async { + let client = FakeKlockClient() + + client.statusResult = .success(true) + var result = await runKlock(arguments: ["status"], client: client) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Locked"]) + + client.statusResult = .success(false) + result = await runKlock(arguments: ["status"], client: client) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Unlocked"]) + + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.statusCalls, 2) + } + + func testStatusPrintsJSONState() async { + let client = FakeKlockClient() + + client.statusResult = .success(true) + var result = await runKlock(arguments: ["status", "--json"], client: client) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, [#"{"locked":true}"#]) + + client.statusResult = .success(false) + result = await runKlock(arguments: ["status", "--json"], client: client) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, [#"{"locked":false}"#]) + } + + func testInteractiveLockExitsWithErrorWhenWaitingForUnlockFails() async throws { + let client = FakeKlockClient() + let error = XPCClientError.serviceUnavailable + client.waitUntilUnlockedResult = .failure(error) + let hotkey = KeyboardLockerSettings.testFixture.unlockHotkey.displayString + + let result = await runKlock(arguments: ["lock"], client: client) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, ["Locked. Press \(hotkey) or Ctrl+C to unlock."]) + XCTAssertEqual( + result.stderr, + [ + "Error: \(error.localizedDescription)", + " \(try XCTUnwrap(error.recoverySuggestion))", + ] + ) + } + + // MARK: - Helpers + + private static func makeError(_ message: String) -> NSError { + NSError( + domain: "KlockCommandLineTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + + private func runKlock( + arguments: [String], + client: FakeKlockClient + ) async -> (exitCode: Int32, stdout: [String], stderr: [String]) { + var stdout: [String] = [] + var stderr: [String] = [] + let exitCode = await KlockCLI.run( + arguments: arguments, + client: client, + printOut: { stdout.append($0) }, + printError: { stderr.append($0) } + ) + return (exitCode, stdout, stderr) + } +} + +private extension KeyboardLockerSettings { + static let testFixture = KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: Hotkey(keyCode: 37, modifierFlags: [.maskCommand, .maskControl]) + ) +} + +private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { + var settingsResult: Result = .success(.testFixture) + var lockInteractivelyResult: Result = .success(.acquired) + var lockResult: Result = .success(()) + var unlockResult: Result = .success(()) + var statusResult: Result = .success(false) + var waitUntilUnlockedResult: Result = .success(()) + + private(set) var currentSettingsCalls = 0 + private(set) var lockInteractivelyCalls = 0 + private(set) var lockCalls = 0 + private(set) var unlockCalls = 0 + private(set) var statusCalls = 0 + private(set) var waitUntilUnlockedCalls = 0 + + func currentSettings() async throws -> KeyboardLockerSettings { + currentSettingsCalls += 1 + return try settingsResult.get() + } + + func lockInteractively() async throws -> LockRequestOutcome { + lockInteractivelyCalls += 1 + return try lockInteractivelyResult.get() + } + + func lock() async throws { + lockCalls += 1 + try lockResult.get() + } + + func unlock() async throws { + unlockCalls += 1 + try unlockResult.get() + } + + func status() async throws -> Bool { + statusCalls += 1 + return try statusResult.get() + } + + func waitUntilUnlocked() async throws { + waitUntilUnlockedCalls += 1 + try waitUntilUnlockedResult.get() + } +} diff --git a/docs/development.md b/docs/development.md index d25c790..bb3fa8b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -144,7 +144,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: - `LockEngine` 的纯策略(event policy、schedule 计算、runtime state、tap 安装事务)与引擎语义(Accessibility 预检、tap 安装/拆除、auto-unlock 排程/触发/陈旧代际拒绝、热键与 Control-C 分发、tap-disable re-enable/fail-open、Focus 代际所有权)均已单测 —— 后者由 `LockEngineTests` 经注入缝(`InstalledEventTap`、`MainActorTimerScheduler`、权限/广播/时钟闭包)确定性驱动;剩余不可单测的只有 Quartz 对真实硬件事件的拦截本身(平台集成事实,靠真机验证)。 -- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵(未知命令、多余参数、already-locked 退出路径)未覆盖,`KlockStatusOutput` 渲染已覆盖。 +- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵与命令执行路径(未知命令、多余参数、already-locked 退出、stdout/stderr 路由与退出码)已由 `KlockCommandLineTests` 覆盖;`version` 的执行路径依赖 `Bundle.main` 未测(其解析已覆盖)。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift new file mode 100644 index 0000000..a08f55b --- /dev/null +++ b/klock/KlockCLI.swift @@ -0,0 +1,241 @@ +import Client +import Foundation + +/// The Agent operations `klock` depends on, mirroring the `XPCClient` signatures one-to-one so +/// command execution can be driven by a fake in tests. +protocol KlockClientServing { + func currentSettings() async throws -> KeyboardLockerSettings + func lockInteractively() async throws -> LockRequestOutcome + func lock() async throws + func unlock() async throws + func status() async throws -> Bool + func waitUntilUnlocked() async throws +} + +extension XPCClient: KlockClientServing {} + +// MARK: - CLI + +enum KlockCLI { + /// Production entry point: wires the real process arguments, the live Agent client, and the + /// real stdout/stderr, then terminates the process with the command's exit code. + static func run() async { + let exitCode = await run( + arguments: Array(CommandLine.arguments.dropFirst()), + client: XPCClient.shared, + printOut: { print($0) }, + printError: { FileHandle.standardError.write(Data("\($0)\n".utf8)) } + ) + exit(exitCode) + } + + /// Testable core: runs one invocation and returns its exit code instead of calling `exit`. + static func run( + arguments: [String], + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + let command: KlockCommand + do { + guard let parsedCommand = try KlockCommandLineParser.parse(arguments) else { + printUsage(to: printOut) + return ExitCode.success + } + command = parsedCommand + } catch KlockCommandLineError.unknownCommand(let name) { + reportError("Unknown command: \(name)", printError: printError) + printUsage(to: printOut) + return ExitCode.error + } catch KlockCommandLineError.unexpectedArguments(let unexpected) { + reportError( + "Unexpected argument\(unexpected.count == 1 ? "" : "s"): \(unexpected.joined(separator: " "))", + printError: printError + ) + printUsage(to: printOut) + return ExitCode.error + } catch { + // KlockCommandLineParser only throws KlockCommandLineError. + return ExitCode.error + } + + switch command { + case .help: + printUsage(to: printOut) + return ExitCode.success + + case .version: + return printVersion(printOut: printOut, printError: printError) + + case let .lock(wait): + if wait { + return await executeInteractiveLock(client: client, printOut: printOut, printError: printError) + } + return await executeNonInteractiveLock(client: client, printOut: printOut, printError: printError) + + case .unlock: + return await executeUnlock(client: client, printOut: printOut, printError: printError) + + case let .status(output): + return await executeStatus(output: output, client: client, printOut: printOut, printError: printError) + } + } + + // MARK: - Commands + + private static func executeInteractiveLock( + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + let unlockHotkey: Result + do { + unlockHotkey = try await .success( + client.currentSettings().unlockHotkey.displayString + ) + } catch { + unlockHotkey = .failure(error) + } + + let outcome: LockRequestOutcome + do { + outcome = try await client.lockInteractively() + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + + guard outcome == .acquired else { + printOut("Already locked. This command did not create a new lock.") + return ExitCode.success + } + + switch unlockHotkey { + case let .success(hotkey): + printOut("Locked. Press \(hotkey) or Ctrl+C to unlock.") + + case let .failure(error): + printOut("Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.") + reportWarning( + "Could not read the configured unlock shortcut: \(error.localizedDescription)", + printError: printError + ) + } + + do { + try await client.waitUntilUnlocked() + printOut("Unlocked.") + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + private static func executeNonInteractiveLock( + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + try await client.lock() + printOut("Locked.") + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + private static func executeUnlock( + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + try await client.unlock() + printOut("Unlocked.") + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + private static func executeStatus( + output: KlockStatusOutput, + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + let isLocked = try await client.status() + printOut(output.render(isLocked: isLocked)) + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + // MARK: - Helpers + + private static func reportFailure(_ error: Error, printError: (String) -> Void) { + reportError(error.localizedDescription, printError: printError) + if let suggestion = (error as? LocalizedError)?.recoverySuggestion { + printError(" \(suggestion)") + } + } + + private static func reportError(_ message: String, printError: (String) -> Void) { + printError("Error: \(message)") + } + + private static func reportWarning(_ message: String, printError: (String) -> Void) { + printError("Warning: \(message)") + } + + private static func printVersion( + printOut: (String) -> Void, + printError: (String) -> Void + ) -> Int32 { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + guard let version, !version.isEmpty, let build, !build.isEmpty else { + reportError("Version metadata is unavailable in this build.", printError: printError) + return ExitCode.error + } + + printOut("klock \(version) (\(build))") + return ExitCode.success + } + + private static func printUsage(to printOut: (String) -> Void) { + printOut( + """ + OVERVIEW: Control KeyboardLocker from Terminal. + + USAGE: klock [options] + + COMMANDS: + lock [--no-wait] Lock the keyboard; by default, wait until it is unlocked. + unlock Unlock the keyboard. + status [--json] Print the current lock state. + help Show this help message. + version Show the klock version. + + OPTIONS: + --no-wait Return after lock is confirmed; do not enable Ctrl+C unlock. + --json Emit a stable JSON object for status automation. + -h, --help Show this help message. + -v, --version Show the klock version. + """ + ) + } + + private enum ExitCode { + static let success: Int32 = 0 + static let error: Int32 = 1 + } +} diff --git a/klock/KlockCommand.swift b/klock/KlockCommand.swift new file mode 100644 index 0000000..1ccd6fa --- /dev/null +++ b/klock/KlockCommand.swift @@ -0,0 +1,72 @@ +import Foundation + +/// One parsed `klock` invocation. A `nil` parse result means no arguments were given at all. +enum KlockCommand: Equatable { + case help + case version + case lock(wait: Bool) + case unlock + case status(output: KlockStatusOutput) +} + +enum KlockCommandLineError: Error, Equatable { + case unknownCommand(String) + case unexpectedArguments([String]) +} + +/// Pure argument parsing for `klock`, kept free of I/O and process exit so the accepted +/// command/flag matrix can be exhaustively unit-tested. +enum KlockCommandLineParser { + static func parse(_ arguments: [String]) throws -> KlockCommand? { + guard let command = arguments.first else { + return nil + } + + switch command { + case "--help", "-h", "help": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .help + + case "--version", "-v", "version": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .version + + case "lock": + switch Array(arguments.dropFirst()) { + case []: + return .lock(wait: true) + + case ["--no-wait"]: + return .lock(wait: false) + + case let unexpectedArguments: + throw KlockCommandLineError.unexpectedArguments(unexpectedArguments) + } + + case "unlock": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .unlock + + case "status": + switch Array(arguments.dropFirst()) { + case []: + return .status(output: .humanReadable) + + case ["--json"]: + return .status(output: .json) + + case let unexpectedArguments: + throw KlockCommandLineError.unexpectedArguments(unexpectedArguments) + } + + default: + throw KlockCommandLineError.unknownCommand(command) + } + } + + private static func rejectUnexpectedArguments(_ arguments: [String]) throws { + guard arguments.isEmpty else { + throw KlockCommandLineError.unexpectedArguments(arguments) + } + } +} diff --git a/klock/main.swift b/klock/main.swift index 0de249e..dc71ae1 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -5,214 +5,4 @@ // Created by Eden on 2025/11/19. // -import Client -import Foundation - await KlockCLI.run() - -// MARK: - CLI - -enum KlockCLI { - static func run() async { - let arguments = Array(CommandLine.arguments.dropFirst()) - guard let command = arguments.first else { - printUsage() - exit(ExitCode.success) - } - - switch command { - case "--help", "-h", "help": - guard arguments.count == 1 else { - reportUnexpectedArguments(Array(arguments.dropFirst())) - } - printUsage() - exit(ExitCode.success) - - case "--version", "-v", "version": - guard arguments.count == 1 else { - reportUnexpectedArguments(Array(arguments.dropFirst())) - } - printVersion() - exit(ExitCode.success) - - case "lock": - switch Array(arguments.dropFirst()) { - case []: - await executeInteractiveLock() - - case ["--no-wait"]: - await executeNonInteractiveLock() - - case let unexpectedArguments: - reportUnexpectedArguments(unexpectedArguments) - } - - case "unlock": - guard arguments.count == 1 else { - reportUnexpectedArguments(Array(arguments.dropFirst())) - } - await executeUnlock() - - case "status": - switch Array(arguments.dropFirst()) { - case []: - await executeStatus(output: .humanReadable) - - case ["--json"]: - await executeStatus(output: .json) - - case let unexpectedArguments: - reportUnexpectedArguments(unexpectedArguments) - } - - default: - printError("Unknown command: \(command)") - printUsage() - exit(ExitCode.error) - } - } - - // MARK: - Commands - - private static func executeInteractiveLock() async { - let unlockHotkey: Result - do { - unlockHotkey = try await .success( - XPCClient.shared.currentSettings().unlockHotkey.displayString - ) - } catch { - unlockHotkey = .failure(error) - } - - let outcome: LockRequestOutcome - do { - outcome = try await XPCClient.shared.lockInteractively() - } catch { - reportFailure(error) - exit(ExitCode.error) - } - - guard outcome == .acquired else { - print("Already locked. This command did not create a new lock.") - exit(ExitCode.success) - } - - switch unlockHotkey { - case let .success(hotkey): - print("Locked. Press \(hotkey) or Ctrl+C to unlock.") - - case let .failure(error): - print("Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.") - printWarning("Could not read the configured unlock shortcut: \(error.localizedDescription)") - } - - do { - try await XPCClient.shared.waitUntilUnlocked() - print("Unlocked.") - exit(ExitCode.success) - } catch { - reportFailure(error) - exit(ExitCode.error) - } - } - - private static func executeNonInteractiveLock() async { - do { - try await XPCClient.shared.lock() - print("Locked.") - exit(ExitCode.success) - } catch { - reportFailure(error) - exit(ExitCode.error) - } - } - - private static func executeUnlock() async { - do { - try await XPCClient.shared.unlock() - print("Unlocked.") - exit(ExitCode.success) - } catch { - reportFailure(error) - exit(ExitCode.error) - } - } - - private static func executeStatus(output: KlockStatusOutput) async { - do { - let isLocked = try await XPCClient.shared.status() - print(output.render(isLocked: isLocked)) - exit(ExitCode.success) - } catch { - reportFailure(error) - exit(ExitCode.error) - } - } - - // MARK: - Helpers - - private static func reportFailure(_ error: Error) { - printError(error.localizedDescription) - if let suggestion = (error as? LocalizedError)?.recoverySuggestion { - printStandardError(" \(suggestion)") - } - } - - private static func reportUnexpectedArguments(_ arguments: [String]) -> Never { - printError("Unexpected argument\(arguments.count == 1 ? "" : "s"): \(arguments.joined(separator: " "))") - printUsage() - exit(ExitCode.error) - } - - private static func printVersion() { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - - guard let version, !version.isEmpty, let build, !build.isEmpty else { - printError("Version metadata is unavailable in this build.") - exit(ExitCode.error) - } - - print("klock \(version) (\(build))") - } - - private static func printUsage() { - print( - """ - OVERVIEW: Control KeyboardLocker from Terminal. - - USAGE: klock [options] - - COMMANDS: - lock [--no-wait] Lock the keyboard; by default, wait until it is unlocked. - unlock Unlock the keyboard. - status [--json] Print the current lock state. - help Show this help message. - version Show the klock version. - - OPTIONS: - --no-wait Return after lock is confirmed; do not enable Ctrl+C unlock. - --json Emit a stable JSON object for status automation. - -h, --help Show this help message. - -v, --version Show the klock version. - """ - ) - } - - private static func printError(_ message: String) { - printStandardError("Error: \(message)") - } - - private static func printWarning(_ message: String) { - printStandardError("Warning: \(message)") - } - - private static func printStandardError(_ message: String) { - FileHandle.standardError.write(Data("\(message)\n".utf8)) - } - - private enum ExitCode { - static let success: Int32 = 0 - static let error: Int32 = 1 - } -} From 0ce83e5d95fcc6b41759fefb0889ad604947387b Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 11:55:25 +0800 Subject: [PATCH 073/106] test(core): pin signing-requirement rejection against real signatures - Evaluate team requirements against on-disk signatures: foreign Apple-signed, ad-hoc, and unsigned fixtures are all rejected via SecStaticCodeCheckValidity, with an anchor-apple control proving the evaluation harness itself is correct - Verify the listener requirement fails closed by name in XPCAccessControlTests - Add scripts/verify-unsigned-client-refusal.sh: runbook proof that an ad-hoc probe is refused by the live Agent (PASS observed on this machine); not wired into CI --- ...odeSigningRequirementEvaluationTests.swift | 117 ++++++++++++++++++ .../ServiceTests/XPCAccessControlTests.swift | 41 ++++++ docs/development.md | 4 +- scripts/unsigned-client-probe/main.swift | 76 ++++++++++++ scripts/verify-unsigned-client-refusal.sh | 77 ++++++++++++ 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift create mode 100644 Core/Tests/ServiceTests/XPCAccessControlTests.swift create mode 100644 scripts/unsigned-client-probe/main.swift create mode 100755 scripts/verify-unsigned-client-refusal.sh diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift new file mode 100644 index 0000000..bc48577 --- /dev/null +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift @@ -0,0 +1,117 @@ +@testable import Common +import Foundation +import Security +import XCTest + +/// Evaluates XPC peer requirements against real on-disk code signatures. +/// +/// `XPCCodeSigningRequirementTests` covers requirement-string construction only; these tests +/// prove the fail-closed direction end to end. Using static evaluation (no process execution), +/// code that is not signed by our team — a foreign Apple signature, an ad-hoc signature, or no +/// signature at all — must be rejected by the same Security framework check the Agent's +/// listener performs. A plain `anchor apple` control requirement proves the harness evaluates +/// correctly, so failures are requirement mismatches rather than evaluation errors. +final class XPCCodeSigningRequirementEvaluationTests: XCTestCase { + // MARK: - Team requirement vs. real signatures + + func testAppleSignedSystemToolFailsTeamRequirementButPassesAnchorApple() throws { + let tool = try makeSystemToolCopy() + defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } + + let requirement = try makeTeamRequirement() + + // Control: the copy keeps the embedded Apple signature, so the anchor check passes. + XCTAssertEqual(evaluate("anchor apple", against: tool), errSecSuccess) + // Rejected: valid Apple signature, but not our Team ID or identifier. + XCTAssertEqual(evaluate(requirement, against: tool), errSecCSReqFailed) + } + + func testAdHocSignedToolFailsTeamRequirement() throws { + let tool = try makeSystemToolCopy() + defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } + try runCodesign(["--force", "--sign", "-", tool.path]) + + let requirement = try makeTeamRequirement() + + // Control: ad-hoc signatures never anchor to Apple. + XCTAssertEqual(evaluate("anchor apple", against: tool), errSecCSReqFailed) + // Rejected: an ad-hoc signature carries no Apple Team ID. + XCTAssertEqual(evaluate(requirement, against: tool), errSecCSReqFailed) + } + + func testUnsignedToolFailsTeamRequirement() throws { + let tool = try makeSystemToolCopy() + defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } + try runCodesign(["--remove-signature", tool.path]) + + let requirement = try makeTeamRequirement() + + // Rejected: unsigned code fails before any requirement clause is even considered. + XCTAssertEqual(evaluate("anchor apple", against: tool), errSecCSUnsigned) + XCTAssertEqual(evaluate(requirement, against: tool), errSecCSUnsigned) + } + + // MARK: - Fixtures + + /// Copies the Apple-signed system tool into a fresh per-test directory so tests may mutate + /// its signature freely. The caller removes the directory with `defer`. + private func makeSystemToolCopy() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let copy = directory.appendingPathComponent("ls") + try FileManager.default.copyItem(at: URL(fileURLWithPath: "/bin/ls"), to: copy) + return copy + } + + private func makeTeamRequirement() throws -> String { + try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: ["io.example.tool"] + ) + } + + private func runCodesign(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = arguments + let standardError = Pipe() + process.standardError = standardError + try process.run() + // Drain before reaping so a full pipe can never deadlock the child. + let output = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + process.waitUntilExit() + XCTAssertEqual( + process.terminationStatus, + 0, + "codesign \(arguments.joined(separator: " ")) failed: \(output)" + ) + } + + /// Runs the same static requirement evaluation the Agent's listener performs. + private func evaluate(_ requirement: String, against url: URL) -> OSStatus { + var staticCode: SecStaticCode? + let createStatus = SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(), &staticCode) + guard createStatus == errSecSuccess, let staticCode else { + return createStatus + } + + var compiledRequirement: SecRequirement? + let compileStatus = SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ) + guard compileStatus == errSecSuccess, let compiledRequirement else { + return compileStatus + } + + return SecStaticCodeCheckValidity(staticCode, SecCSFlags(), compiledRequirement) + } +} diff --git a/Core/Tests/ServiceTests/XPCAccessControlTests.swift b/Core/Tests/ServiceTests/XPCAccessControlTests.swift new file mode 100644 index 0000000..2074c4e --- /dev/null +++ b/Core/Tests/ServiceTests/XPCAccessControlTests.swift @@ -0,0 +1,41 @@ +@testable import Common +import Security +import Service +import XCTest + +/// The listener's client requirement must fail closed on any host that cannot prove our +/// Team ID, and must name every authorized client whenever construction is possible. +final class XPCAccessControlTests: XCTestCase { + func testAuthorizedClientRequirementFailsClosedWithoutAppleTeamSignature() { + do { + let requirement = try XPCAccessControl.authorizedClientRequirement() + + // Reachable only from a team-signed test host: construction succeeds, and the + // requirement must still name every authorized client and compile. Both branches are + // acceptable because each proves a distinct half of the contract — a signed host builds + // the exact requirement, an unsigned host can build none. + for identifier in SharedConstants.authorizedClientBundleIdentifiers { + XCTAssertTrue(requirement.contains(#"identifier "\#(identifier)""#)) + } + var compiledRequirement: SecRequirement? + XCTAssertEqual( + SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ), + errSecSuccess + ) + XCTAssertNotNil(compiledRequirement) + } catch let error as XPCCodeSigningRequirementError { + // `swift test` binaries are ad-hoc signed, so the process has no Apple Team ID and the + // requirement must fail closed rather than emit something an unauthorized client could + // satisfy. + guard case .missingTeamIdentifier = error else { + return XCTFail("Expected missingTeamIdentifier, got \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/docs/development.md b/docs/development.md index bb3fa8b..2f4c9e1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -148,5 +148,5 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 -- unsigned/ad-hoc 进程 fail closed 的运行路径未自动化覆盖:测试 bundle 本身已签名,难以在单测中构造未签名进程。 -- CI 由 `.github/workflows/ci.yml` 承载(macOS runner,依次跑 Core 测试、全量 build、`KeyboardLockerModelTests`)。runner 没有签名身份,app build 与 ModelTests 以 `CODE_SIGNING_ALLOWED=NO` 运行;因此签名认证的**运行时**行为不属于 CI 门禁,发布前仍需本地签名构建验证。 +- unsigned/ad-hoc fail-closed:requirement 对异类签名的拒绝已由 `XPCCodeSigningRequirementEvaluationTests` 经 `SecStaticCodeCheckValidity` 静态评估确定性覆盖(Apple 系统签名、ad-hoc、完全未签名三类 fixture 均被拒;`anchor apple` 对照证明评估管线本身正确),`XPCAccessControlTests` 按名验证 listener requirement 的 fail-closed 构造。端到端(非 team 进程调用真实 Agent)由 `scripts/verify-unsigned-client-refusal.sh` 承载 —— 需 Agent 运行中手动执行的 runbook 验证,不进 CI。 +- CI 由 `.github/workflows/ci.yml` 承载(macOS runner,依次跑 Core 测试、全量 build、`KeyboardLockerModelTests`)。runner 没有签名身份,app build 与 ModelTests 以 `CODE_SIGNING_ALLOWED=NO` 运行;因此签名认证的**运行时**行为不属于 CI 门禁,发布前仍需本地签名构建验证(签名拒绝的端到端验证用上一条的 runbook 脚本)。 diff --git a/scripts/unsigned-client-probe/main.swift b/scripts/unsigned-client-probe/main.swift new file mode 100644 index 0000000..0ebc8f6 --- /dev/null +++ b/scripts/unsigned-client-probe/main.swift @@ -0,0 +1,76 @@ +import Foundation + +// Runbook probe: prove that an ad-hoc-signed (non-team) process cannot call the live Agent's +// Mach service. Compiled together with the Common module by verify-unsigned-client-refusal.sh, +// so it uses the real protocol and service name. swiftc output on arm64 is ad-hoc signed, +// which is exactly the class of client the Agent's code-signing requirement must refuse. +// +// Prints one RESULT line and exits: 0 = REFUSED, 1 = REPLIED, 2 = INDETERMINATE. + +/// First outcome wins: refusal may surface as a proxy error, an interruption, and an +/// invalidation all at once, while a reply must never be overwritten by later teardown. +final class Outcome { + private let lock = NSLock() + private var recorded: (code: Int32, line: String)? + + func record(_ code: Int32, _ line: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard recorded == nil else { + return false + } + recorded = (code, line) + return true + } + + func current() -> (code: Int32, line: String)? { + lock.lock() + defer { lock.unlock() } + return recorded + } +} + +let outcome = Outcome() +let finished = DispatchSemaphore(value: 0) + +func finish(_ code: Int32, _ line: String) { + if outcome.record(code, line) { + finished.signal() + } +} + +let connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) +connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) +connection.invalidationHandler = { + finish(0, "RESULT:REFUSED connection invalidated before any reply") +} +connection.interruptionHandler = { + finish(0, "RESULT:REFUSED connection interrupted before any reply") +} +connection.activate() + +guard + let service = connection.remoteObjectProxyWithErrorHandler({ error in + finish(0, "RESULT:REFUSED proxy error: \(error.localizedDescription)") + }) as? KeyboardLockerServiceProtocol +else { + print("RESULT:INDETERMINATE could not create a remote object proxy") + exit(2) +} + +// Simplest read method: any reply at all means the Agent accepted this client. +service.status { _, _ in + finish(1, "RESULT:REPLIED agent answered a status request") +} + +if finished.wait(timeout: .now() + 10) == .timedOut { + print("RESULT:INDETERMINATE no reply, error, or invalidation within 10s") + exit(2) +} + +guard let result = outcome.current() else { + print("RESULT:INDETERMINATE semaphore signalled without an outcome") + exit(2) +} +print(result.line) +exit(result.code) diff --git a/scripts/verify-unsigned-client-refusal.sh b/scripts/verify-unsigned-client-refusal.sh new file mode 100755 index 0000000..858906d --- /dev/null +++ b/scripts/verify-unsigned-client-refusal.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# verify-unsigned-client-refusal.sh — runbook proof that the Agent refuses non-team clients. +# +# Purpose: end-to-end verification of the documented fail-closed guarantee: a process without +# the KeyboardLocker Apple Team signature must not be able to call the live Agent's Mach +# service (io.lzhlovesjyq.keyboardlocker.agent). The probe compiled here is ad-hoc signed +# (swiftc output on arm64 is always linker-signed ad-hoc), which is exactly the class of +# client the listener's code-signing requirement must reject. +# +# Precondition: the KeyboardLocker Agent must be running. A refusal without a live Agent would +# be vacuous — a connection to a dead Mach service fails regardless of any code-signing +# requirement, proving nothing. When the Agent is absent this script prints SKIP and exits 2. +# +# This is a manual/runbook verification, not wired into CI: it requires a locally installed, +# team-signed Agent and a GUI user session. It does not modify the Agent or the system. +# +# Exit codes: 0 = PASS (client refused), 1 = FAIL (Agent answered), 2 = SKIP/INDETERMINATE. + +set -euo pipefail + +AGENT_PROCESS="KeyboardLockerAgent" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROBE_SOURCE="$REPO_ROOT/scripts/unsigned-client-probe/main.swift" +COMMON_SOURCES_DIR="$REPO_ROOT/Core/Sources/Common" + +WORK_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +echo "==> Checking precondition: agent process '$AGENT_PROCESS' is running" +if ! pgrep -x "$AGENT_PROCESS" >/dev/null 2>&1; then + echo "SKIP: $AGENT_PROCESS is not running." + echo " A refusal without a live Agent would be vacuous: connections to a dead Mach" + echo " service fail regardless of the code-signing requirement." + echo " Launch KeyboardLocker (which registers io.lzhlovesjyq.keyboardlocker.agent)" + echo " and re-run this script." + exit 2 +fi +echo " agent is running (pid $(pgrep -x "$AGENT_PROCESS" | head -1))" + +echo "==> Compiling the probe (ad-hoc signed, real protocol from Core/Sources/Common)" +xcrun swiftc \ + "$PROBE_SOURCE" \ + "$COMMON_SOURCES_DIR"/*.swift \ + -o "$WORK_DIR/unsigned-client-probe" + +echo "==> Confirming the probe is not team-signed" +if codesign -dv "$WORK_DIR/unsigned-client-probe" 2>&1 | grep -q "^TeamIdentifier=not set"; then + codesign -dv "$WORK_DIR/unsigned-client-probe" 2>&1 \ + | grep -E "^(Signature|TeamIdentifier)" | sed 's/^/ /' +else + echo "FAIL: probe unexpectedly carries a Team ID; it would not exercise the refusal path." + exit 2 +fi + +echo "==> Running the probe against the live Agent" +set +e +PROBE_OUTPUT="$("$WORK_DIR/unsigned-client-probe" 2>&1)" +PROBE_EXIT=$? +set -e +echo "$PROBE_OUTPUT" | sed 's/^/ /' + +case "$PROBE_EXIT" in + 0) + echo "PASS: the Agent refused an ad-hoc (non-team) client." + ;; + 1) + echo "FAIL: the Agent replied to an ad-hoc (non-team) client — fail-closed is broken." + exit 1 + ;; + *) + echo "INDETERMINATE: probe could not distinguish refusal from reply (exit $PROBE_EXIT)." + exit 2 + ;; +esac From aa9c0992e905c0c037cd777f0253e51764ee0c65 Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 14:49:22 +0800 Subject: [PATCH 074/106] feat(core,app): add atomic lock toggle and macOS 26 App Shortcuts Toggle flips the global lock inside the Agent's serial execution boundary and replies with the resulting state, so wrappers never compose a racy status-then-act client-side toggle. The lock direction keeps plain non-interactive lock semantics behind the replacement drain barrier; the unlock direction is an explicit unlock. Exposed as capability-gated toggleKeyboard (lock-toggle, protocol 1.6); the client maps a lost reply to operationOutcomeUnknown because toggle is not idempotent. Toggle Keyboard Lock joins the existing Shortcuts actions, and a new AppShortcutsProvider registers promoted App Shortcuts with invocation phrases on macOS 26+, where Spotlight, Quick Keys, and Siri can run them; earlier macOS versions keep library-only discovery. --- Core/Sources/Client/XPCClient.swift | 18 +++++ Core/Sources/Common/ServiceDescriptor.swift | 4 +- Core/Sources/Common/Shared.swift | 5 ++ Core/Sources/Service/AgentService.swift | 23 +++++++ .../ServiceCompatibilityTests.swift | 7 ++ .../ServiceTests/AgentServiceTests.swift | 68 +++++++++++++++++++ .../AgentCoordinationServices.swift | 15 ++++ .../AppIntents/KeyboardLockAppIntents.swift | 22 ++++++ .../AppIntents/KeyboardLockerShortcuts.swift | 39 +++++++++++ .../AgentCoordinationServicesTests.swift | 55 +++++++++++++++ .../AppCoordinatorTests.swift | 5 ++ .../ExternalAutomationControllerTests.swift | 6 ++ .../KeyboardLockAppIntentTests.swift | 49 +++++++++++++ .../KeyboardLockerScriptCommandTests.swift | 6 ++ docs/architecture.md | 3 +- docs/automation.md | 9 ++- docs/development.md | 6 +- docs/xpc.md | 2 + 18 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 KeyboardLocker/AppIntents/KeyboardLockerShortcuts.swift diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 452120d..ccd6f6e 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -178,6 +178,24 @@ public final class XPCClient: @unchecked Sendable { } } + /// Atomically flips the global lock inside the Agent and returns the resulting state. + /// + /// Toggle is not idempotent: after a lost reply, re-sending it could flip the state twice, + /// and the current status alone cannot prove whether this call ran. Keep the outcome + /// explicitly unknown instead. + public func toggle() async throws -> Bool { + let connection = try await negotiatedConnection( + requiring: [.lockToggle] + ) + do { + return try await withProxyReturning(using: connection) { service, resume in + service.toggleKeyboard { resume($0, $1) } + } + } catch XPCClientError.timedOut { + throw XPCClientError.operationOutcomeUnknown + } + } + /// One authoritative point-in-time snapshot of the global lock and its active settings. public func lockStatusSnapshot() async throws -> LockStatusSnapshot { let connection = try await negotiatedConnection( diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift index f8dd893..e01221a 100644 --- a/Core/Sources/Common/ServiceDescriptor.swift +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -48,6 +48,7 @@ public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, public static let interactiveLock = Self(rawValue: "interactive-lock") public static let lockControl = Self(rawValue: "lock-control") public static let lockStatusSnapshot = Self(rawValue: "lock-status-snapshot") + public static let lockToggle = Self(rawValue: "lock-toggle") public static let prepareForReplacement = Self(rawValue: "prepare-for-replacement") } @@ -77,7 +78,7 @@ public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sen /// Current bootstrap contract advertised by the bundled Agent. public enum ServiceContract { - public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 5) + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 6) public static let requiredCapabilities: Set = [ .accessibilityPrompt, @@ -89,6 +90,7 @@ public enum ServiceContract { .interactiveLock, .lockControl, .lockStatusSnapshot, + .lockToggle, .prepareForReplacement, ] } diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 47f3916..2f131e1 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -86,6 +86,11 @@ public protocol KeyboardLockerServiceProtocol { func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) + /// Atomically flips the global lock inside the Agent's serial execution boundary and replies + /// with the resulting state. The lock direction uses plain (non-interactive) lock semantics; + /// the unlock direction is an explicit unlock that releases any generation. + func toggleKeyboard(reply: @escaping (_ isLocked: Bool, _ error: Error?) -> Void) + /// Returns one authoritative point-in-time lock snapshot as JSON-encoded /// `LockStatusSnapshot`. This is additive; legacy clients continue using `status`. func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) diff --git a/Core/Sources/Service/AgentService.swift b/Core/Sources/Service/AgentService.swift index 4da259a..4a7b8d3 100644 --- a/Core/Sources/Service/AgentService.swift +++ b/Core/Sources/Service/AgentService.swift @@ -147,6 +147,16 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + public func toggleKeyboard(reply: @escaping (Bool, Error?) -> Void) { + executeOnMainActor { + do { + reply(try self.performToggle(), nil) + } catch { + reply(false, error) + } + } + } + public func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) { executeOnMainActor { do { @@ -335,6 +345,19 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { ) } + /// The flip is atomic because the state read and the mutation run in one MainActor turn; no + /// other client call can interleave and translate the request into the wrong direction. + @MainActor + private func performToggle() throws -> Bool { + if engine.isLocked { + // Symmetric with an explicit unlock: releases any generation, including a Focus-owned one. + engine.unlock() + return false + } + + return try performLock(allowsControlCUnlock: false) == .acquired + } + @MainActor private func ensureAcceptingLockRequests() throws { guard !replacement.isPending else { diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift index e088223..08cab7e 100644 --- a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -118,6 +118,13 @@ final class ServiceCompatibilityTests: XCTestCase { XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 5) } + func testCurrentContractRequiresLockToggleSelector() { + XCTAssertTrue( + ServiceContract.requiredCapabilities.contains(.lockToggle) + ) + XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 6) + } + func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift index 22b9047..c628871 100644 --- a/Core/Tests/ServiceTests/AgentServiceTests.swift +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -289,6 +289,70 @@ final class AgentServiceTests: XCTestCase { XCTAssertEqual(engine.focusCalls.first?.settings, customSettings) } + // MARK: - Toggle + + func testToggleLocksUnlockedEngineWithPersistedSettings() { + let service = makeService() + + var isLocked: Bool? + var toggleError: Error? + service.toggleKeyboard { locked, error in + isLocked = locked + toggleError = error + } + + XCTAssertNil(toggleError) + XCTAssertEqual(isLocked, true) + XCTAssertEqual(engine.lockCalls.count, 1) + XCTAssertEqual(engine.lockCalls.first?.settings, customSettings) + XCTAssertEqual(engine.lockCalls.first?.allowsControlCUnlock, false) + XCTAssertEqual(engine.unlockCallCount, 0) + } + + func testToggleUnlocksLockedEngine() { + let lockedEngine = FakeLockEngine(isLocked: true) + let service = makeService(engineOverride: lockedEngine) + + var isLocked: Bool? + var toggleError: Error? + service.toggleKeyboard { locked, error in + isLocked = locked + toggleError = error + } + + XCTAssertNil(toggleError) + XCTAssertEqual(isLocked, false) + XCTAssertEqual(lockedEngine.unlockCallCount, 1) + XCTAssertTrue(lockedEngine.lockCalls.isEmpty) + } + + func testTogglePropagatesLockFailureWithoutChangingState() { + engine.lockError = StubError.descriptorUnavailable + let service = makeService() + + var isLocked: Bool? + var toggleError: Error? + service.toggleKeyboard { locked, error in + isLocked = locked + toggleError = error + } + + XCTAssertEqual(toggleError as? StubError, .descriptorUnavailable) + XCTAssertEqual(isLocked, false) + XCTAssertFalse(engine.isLocked) + } + + func testPreparedDrainRejectsOnlyTheLockDirectionOfToggle() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var toggleError: Error? + service.toggleKeyboard { _, error in toggleError = error } + assertReplacementError(toggleError, code: 1) + XCTAssertTrue(engine.lockCalls.isEmpty) + XCTAssertEqual(engine.unlockCallCount, 0) + } + // MARK: - Fixtures private func makeService( @@ -382,6 +446,7 @@ private enum StubError: Error { @MainActor private final class FakeLockEngine: LockEngineServing { var isLocked: Bool + var lockError: Error? var onUnlock: (() -> Void)? private let snapshot: LockStatusSnapshot @@ -411,6 +476,9 @@ private final class FakeLockEngine: LockEngineServing { allowsControlCUnlock: Bool ) throws -> LockRequestOutcome { lockCalls.append((settings, allowsControlCUnlock)) + if let lockError { + throw lockError + } isLocked = true return .acquired } diff --git a/KeyboardLocker/AgentCoordinationServices.swift b/KeyboardLocker/AgentCoordinationServices.swift index c48d4e3..10cd01f 100644 --- a/KeyboardLocker/AgentCoordinationServices.swift +++ b/KeyboardLocker/AgentCoordinationServices.swift @@ -16,6 +16,8 @@ protocol AgentLockActionServing: Sendable { func lock() async throws func unlock() async throws func status() async throws -> Bool + /// Atomically flips the global lock in the Agent and returns the resulting state. + func toggle() async throws -> Bool } @MainActor @@ -67,10 +69,12 @@ protocol AgentClientServing: struct LiveAgentClient: AgentClientServing { typealias Mutation = @Sendable () async throws -> Void typealias StatusQuery = @Sendable () async throws -> Bool + typealias ToggleMutation = @Sendable () async throws -> Bool private let lockMutation: Mutation private let statusQuery: StatusQuery private let surfaceInvalidator: LockStateSurfaceInvalidator + private let toggleMutation: ToggleMutation private let unlockMutation: Mutation nonisolated init() { @@ -84,6 +88,9 @@ struct LiveAgentClient: AgentClientServing { status: { try await XPCClient.shared.status() }, + toggle: { + try await XPCClient.shared.toggle() + }, surfaceInvalidator: .live ) } @@ -92,11 +99,13 @@ struct LiveAgentClient: AgentClientServing { lock: @escaping Mutation, unlock: @escaping Mutation, status: @escaping StatusQuery, + toggle: @escaping ToggleMutation, surfaceInvalidator: LockStateSurfaceInvalidator ) { lockMutation = lock unlockMutation = unlock statusQuery = status + toggleMutation = toggle self.surfaceInvalidator = surfaceInvalidator } @@ -118,6 +127,12 @@ struct LiveAgentClient: AgentClientServing { try await statusQuery() } + func toggle() async throws -> Bool { + let isLocked = try await toggleMutation() + surfaceInvalidator.invalidate() + return isLocked + } + func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID diff --git a/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift index 8677a89..5f48e35 100644 --- a/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift +++ b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift @@ -47,6 +47,28 @@ struct UnlockKeyboardIntent: nonisolated AppIntent { } } +@MainActor +struct ToggleKeyboardLockIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Toggle Keyboard Lock" + nonisolated static let description = IntentDescription( + "Atomically flips the keyboard lock state in the Agent — locking when unlocked and unlocking when locked — and returns the resulting state." + ) + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult & ReturnsValue { + try await .result(value: client.toggle()) + } +} + @MainActor struct GetKeyboardLockStatusIntent: nonisolated AppIntent { nonisolated static let title: LocalizedStringResource = "Get Keyboard Lock Status" diff --git a/KeyboardLocker/AppIntents/KeyboardLockerShortcuts.swift b/KeyboardLocker/AppIntents/KeyboardLockerShortcuts.swift new file mode 100644 index 0000000..7f24b85 --- /dev/null +++ b/KeyboardLocker/AppIntents/KeyboardLockerShortcuts.swift @@ -0,0 +1,39 @@ +import AppIntents + +/// Promoted App Shortcuts for Spotlight, Quick Keys, and Siri. App Shortcuts only run on +/// macOS 26 and later; earlier versions keep exposing the same intents through the Shortcuts +/// action library without invocation phrases. +@available(macOS 26.0, *) +struct KeyboardLockerShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: LockKeyboardIntent(), + phrases: [ + "Lock keyboard in \(.applicationName)", + "Lock my keyboard with \(.applicationName)", + ], + shortTitle: "Lock Keyboard", + systemImageName: "lock" + ) + + AppShortcut( + intent: UnlockKeyboardIntent(), + phrases: [ + "Unlock keyboard in \(.applicationName)", + "Unlock my keyboard with \(.applicationName)", + ], + shortTitle: "Unlock Keyboard", + systemImageName: "lock.open" + ) + + AppShortcut( + intent: ToggleKeyboardLockIntent(), + phrases: [ + "Toggle keyboard lock in \(.applicationName)", + "Toggle my keyboard with \(.applicationName)", + ], + shortTitle: "Toggle Keyboard Lock", + systemImageName: "lock.rotation" + ) + } +} diff --git a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift index 828ce43..15b7f1d 100644 --- a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift +++ b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift @@ -39,6 +39,10 @@ final class AgentCoordinationServicesTests: XCTestCase { recorder.record(.status) return false }, + toggle: { + recorder.record(.toggle) + return true + }, surfaceInvalidator: makeInvalidator(recorder: recorder) ) @@ -67,6 +71,10 @@ final class AgentCoordinationServicesTests: XCTestCase { recorder.record(.status) return false }, + toggle: { + recorder.record(.toggle) + return true + }, surfaceInvalidator: makeInvalidator(recorder: recorder) ) @@ -91,6 +99,48 @@ final class AgentCoordinationServicesTests: XCTestCase { XCTAssertEqual(recorder.calls, [.status]) } + @MainActor + func testSuccessfulToggleInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + let isLocked = try await client.toggle() + + XCTAssertTrue(isLocked) + XCTAssertEqual(recorder.calls, [.toggle, .widget, .control]) + } + + @MainActor + func testFailedToggleDoesNotInvalidateSurfaces() async { + let recorder = CallRecorder() + let client = LiveAgentClient( + lock: { + recorder.record(.lock) + }, + unlock: { + recorder.record(.unlock) + }, + status: { + recorder.record(.status) + return false + }, + toggle: { + recorder.record(.toggle) + throw TestError.expected + }, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + + do { + _ = try await client.toggle() + XCTFail("Expected the Agent failure to propagate.") + } catch { + XCTAssertEqual(error as? TestError, .expected) + } + + XCTAssertEqual(recorder.calls, [.toggle]) + } + @MainActor func testLiveObserverInvalidatesAtSubscriptionAndBeforeForwardingAChange() { let recorder = CallRecorder() @@ -131,6 +181,10 @@ final class AgentCoordinationServicesTests: XCTestCase { recorder.record(.status) return status }, + toggle: { + recorder.record(.toggle) + return true + }, surfaceInvalidator: makeInvalidator(recorder: recorder) ) } @@ -158,6 +212,7 @@ private final class CallRecorder: @unchecked Sendable { case control case lock case status + case toggle case unlock case widget } diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift index 3936ba3..a681747 100644 --- a/KeyboardLockerModelTests/AppCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -154,6 +154,11 @@ private final class FakeAgentClient: AgentClientServing { isLocked } + func toggle() async throws -> Bool { + isLocked.toggle() + return isLocked + } + func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID diff --git a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift index 4607104..aa8e758 100644 --- a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift +++ b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift @@ -159,6 +159,12 @@ private final class RecordingExternalAutomationClient: AgentLockActionServing { return isLocked } + func toggle() async throws -> Bool { + try throwConfiguredError() + isLocked.toggle() + return isLocked + } + private func throwConfiguredError() throws { if let error { throw error diff --git a/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift index 1b8ca00..3eceb63 100644 --- a/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift @@ -52,12 +52,54 @@ final class KeyboardLockAppIntentTests: XCTestCase { XCTAssertEqual(error as? IntentClientError, .unavailable) } } + + @MainActor + func testToggleIntentFromUnlockedReturnsLocked() async throws { + let client = FakeAgentLockActionClient(isLocked: false) + + let result = try await ToggleKeyboardLockIntent(client: client).perform() + + XCTAssertEqual(result.value, true) + XCTAssertEqual(client.toggleCallCount, 1) + XCTAssertEqual(client.lockCallCount, 0) + XCTAssertEqual(client.unlockCallCount, 0) + XCTAssertEqual(client.statusCallCount, 0) + XCTAssertTrue(client.isLocked) + } + + @MainActor + func testToggleIntentFromLockedReturnsUnlocked() async throws { + let client = FakeAgentLockActionClient(isLocked: true) + + let result = try await ToggleKeyboardLockIntent(client: client).perform() + + XCTAssertEqual(result.value, false) + XCTAssertEqual(client.toggleCallCount, 1) + XCTAssertFalse(client.isLocked) + } + + @MainActor + func testToggleIntentPropagatesAgentFailureWithoutFallback() async { + let client = FakeAgentLockActionClient( + isLocked: false, + error: IntentClientError.unavailable + ) + + do { + _ = try await ToggleKeyboardLockIntent(client: client).perform() + XCTFail("Expected the Agent error to be propagated.") + } catch { + XCTAssertEqual(error as? IntentClientError, .unavailable) + XCTAssertFalse(client.isLocked) + } + } } @MainActor private final class FakeAgentLockActionClient: AgentLockActionServing { private(set) var lockCallCount = 0 private(set) var statusCallCount = 0 + private(set) var toggleCallCount = 0 private(set) var unlockCallCount = 0 private(set) var isLocked: Bool @@ -86,6 +128,13 @@ private final class FakeAgentLockActionClient: AgentLockActionServing { return isLocked } + func toggle() async throws -> Bool { + try throwConfiguredError() + toggleCallCount += 1 + isLocked.toggle() + return isLocked + } + private func throwConfiguredError() throws { if let error { throw error diff --git a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift index 0a6f87e..f66513a 100644 --- a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift @@ -111,6 +111,12 @@ private final class FakeAppleScriptAgentClient: AgentLockActionServing { return isLocked } + func toggle() async throws -> Bool { + try throwConfiguredError() + isLocked.toggle() + return isLocked + } + private func throwConfiguredError() throws { if let error { throw error diff --git a/docs/architecture.md b/docs/architecture.md index 3f075a9..b3d6eda 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,8 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 任何 wrapper 都可以锁;任何 wrapper 都可以解锁。CLI 发起的锁,App 可以解开,反之亦然。 - locked 的输入范围是 macOS 交付给 `CGEventTap` 的键盘来源事件:标准 `keyDown` / `keyUp` / `flagsChanged`,以及 `NX_SUBTYPE_AUX_CONTROL_BUTTONS`、`NX_SUBTYPE_EJECT_KEY`、`NX_SUBTYPE_POWER_KEY` 这三类 system-defined keyboard control。音量、亮度和播放控制因此必须被消费;鼠标 / 触控板 pointer event(包括 `NX_SUBTYPE_AUX_MOUSE_BUTTONS`)必须继续可用。macOS 没有交付给 event tap 的硬件或系统保留路径不在可保证范围内。 -- 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 +- 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `toggle` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 +- `toggle` 是同一全局锁的原子翻转:在 Agent 的串行执行边界内于同一 execution turn 完成"读当前状态 + 反向 mutation",并向调用方返回翻转后的布尔值。锁定方向使用普通非交互 `lock` 语义(含 Focus persistence takeover),解锁方向等价于显式 `unlock`;wrapper 不得先读 `status()` 再自行决定方向来模拟 toggle。 - `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 - interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。 - Focus Filter 是 **activation-triggered acquisition**,不是“Focus active 期间持续保持 locked”的 policy。Focus 激活时的 `true` 最多尝试一次 `unlocked → locked`,并只在成功创建锁时记录该 Focus-owned generation;Focus 关闭时的 `false` 只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领。显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束该 generation;实现不会查询当前 Focus 后自动重建锁,也不会在同一次 Focus activation 内持续 relock。这个进程内 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 diff --git a/docs/automation.md b/docs/automation.md index 7aacaeb..e484e34 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -8,18 +8,21 @@ KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScrip - `lock` 会消费 macOS 交付给 Agent event tap 的标准按键和键盘 system control,包括音量、亮度、播放控制、eject 与 power;鼠标和触控板输入继续可用。macOS 没有交付给 event tap 的硬件或系统保留路径不在可保证范围内。 - `status` 每次都从 Agent 查询权威状态;wrapper 不维护本地副本。 - lock 是全局状态,不属于发起它的 Shortcut、AppleScript 或 shell process。任意普通入口的显式 `unlock` 都会解开同一个全局锁。Focus Filter 的关闭事件使用更窄的条件释放规则,只解除它自己创建且未被后续显式 `lock` 接管的锁。 -- 当前没有暴露 client-side `toggle`。需要确定结果时,显式选择 `lock` 或 `unlock`。 +- `toggle` 在 Agent 的串行执行边界内原子翻转全局锁并返回翻转后的状态;它不是 wrapper 先读 `status()` 再决定方向的 client-side 组合,因此不存在并发竞态。自动化需要确定终态时,仍应显式选择 `lock` 或 `unlock`。 - Agent 不可达、权限不足或结果无法确认时,action wrapper 会明确失败,Widget 会显示 unavailable;任何入口都不会把失败猜成 unlocked。 ## Shortcuts -KeyboardLocker 提供三个可组合 action: +KeyboardLocker 提供四个可组合 action: - `Lock Keyboard` - `Unlock Keyboard` +- `Toggle Keyboard Lock` - `Get Keyboard Lock Status` -`Get Keyboard Lock Status` 返回 typed Boolean,可直接接入 Shortcuts 的条件分支。当前版本没有声明 `AppShortcutsProvider`;这些 action 从 Shortcuts 的 action library 中选择。Apple 当前不在 macOS 支持 promoted App Shortcuts,因此应用不会注册 invocation phrase。 +`Toggle Keyboard Lock` 把翻转交给 Agent 原子执行,并返回翻转后的 Boolean;`Get Keyboard Lock Status` 返回当前权威 Boolean,两者都可直接接入 Shortcuts 的条件分支。 + +macOS 26 及以上,应用声明了 `AppShortcutsProvider`,为 lock / unlock / toggle 注册 promoted App Shortcuts,无需手动配置即可从 Spotlight、Quick Keys 与 Siri 调用。更早的 macOS 版本不生成 promoted App Shortcuts 或 invocation phrase,这些 action 从 Shortcuts 的 action library 中选择。 ## Focus Filter diff --git a/docs/development.md b/docs/development.md index 2f4c9e1..255dcc5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,7 +22,7 @@ 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive/Focus 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`;protocol 1.5 新增 capability-gated `setFocusFilterLockEnabled`,旧 `status` / `lockKeyboard` ABI 保持不变。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive/Focus 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`;protocol 1.5 新增 capability-gated `setFocusFilterLockEnabled`,旧 `status` / `lockKeyboard` ABI 保持不变。protocol 1.6 新增 capability-gated `toggleKeyboard`,把原子翻转放进 Agent 串行边界并返回翻转后状态。 - `LockStatusSnapshot.swift`:`LockStatusSnapshot` format 1 和有大小上限的 JSON XPC 编解码。snapshot 原子携带 capture time、布尔状态、锁定起点、auto-unlock deadline 与 active settings;duration/countdown 由 consumer 根据权威时间点派生,不作为会迅速过期的 transport 字段。 - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成同 Team + 精确 identifier 的双向 XPC requirement 字符串,并用 `SecRequirementCreateWithString` 校验其可编译(编译结果仅作一次性校验后丢弃;安装到连接时由系统重新解析字符串);unsigned/ad-hoc 进程 fail closed。 @@ -50,13 +50,13 @@ **App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 - `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 -- `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。当前没有声明 `AppShortcutsProvider`,因此不会生成 promoted App Shortcuts 或 invocation phrases。 +- `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard`、`Toggle Keyboard Lock` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。`Toggle Keyboard Lock` 调用 Agent 串行边界内的原子 `toggle` 并返回翻转后的 `Bool`,不做 client-side 状态合成。`AppIntents/KeyboardLockerShortcuts.swift` 在 macOS 26+ 声明 `AppShortcutsProvider`,为 lock / unlock / toggle 注册 promoted App Shortcuts 与 invocation phrases;更早的 macOS 只从 Shortcuts action library 暴露这些 intent。 - `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 - `Automation/ExternalAutomationController.swift`:Services 与 URL event 共用的串行 one-shot executor。每个 action 只调用 `AgentLockActionServing` 的对应 desired-state/query method;跨多次 submit 也保持接收顺序,并把 authoritative status 或合并后的 failure 交给 presentation boundary。 - `Automation/KeyboardLockerServicesProvider.swift`:将 `NSServices` 的三个 Objective-C selector 适配成 `.lock` / `.unlock` / `.status`。Services handler 只同步受理,不把短生命周期的 error pointer 或 pasteboard 捕获进异步 Task,也不阻塞 AppKit 线程等待 XPC。 - `Automation/KeyboardLockerURLRoute.swift`:只把 `keyboardlocker://lock|unlock|status` 的严格 canonical URL 映射为 action。它拒绝额外 URL component,不回显原始输入,并把多个 URL 转成保序的 action/failure request;custom scheme 不声称 caller authentication。 - `Automation/AppKitExternalAutomationPresenter.swift`:主 App 内统一呈现外部 automation 的 status 与异步 failure;显示 alert 前激活 accessory App,避免提示留在后台。 -- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `status`,协调器和系统 action 都无需依赖无关 Client surface。live lock/unlock 在 Agent 确认后请求 system-surface reload,status 不请求;live state observer 在新 observation lifetime 和每个已回拉、去重的权威变化上提交 presentation hint,从而覆盖 App 运行期间的 CLI、timer、热键与 fail-open 变化。 +- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `toggle` / `status`,协调器和系统 action 都无需依赖无关 Client surface。live lock/unlock/toggle 在 Agent 确认后请求 system-surface reload,status 不请求;live state observer 在新 observation lifetime 和每个已回拉、去重的权威变化上提交 presentation hint,从而覆盖 App 运行期间的 CLI、timer、热键与 fail-open 变化。 - `AgentReadinessCoordinator.swift`:一次性收集 registration、descriptor handshake/重连、兼容性、replacement phase、Accessibility 与权威锁状态,返回不含 UI 的 domain outcome。 - `AgentReplacementCoordinator.swift`:执行 App 侧 Agent 替换顺序。`AgentUpdatePlan` 用类型区分已协商的 safe replacement 与需要用户授权的 forced fallback,所有自动/手动更新共用 prepare → commit → restart → reconnect 边界。 - `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。 diff --git a/docs/xpc.md b/docs/xpc.md index b5c81cf..ad7670f 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -121,6 +121,7 @@ public protocol KeyboardLockerServiceProtocol { ) func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) + func toggleKeyboard(reply: @escaping (_ isLocked: Bool, _ error: Error?) -> Void) func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) func prepareForReplacement( unlockIfNeeded: Bool, @@ -539,6 +540,7 @@ sequenceDiagram | `setFocusFilterLockEnabled` | `setFocusFilterLockEnabled(_:)` | `LockEngine` | capability-gated desired state;启用只认领自己新建的 lock generation,停用只条件性释放仍属 Focus 的同一代 | | `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | | `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | +| `toggleKeyboard` | `toggle()` | `AgentService` / `LockEngine` | capability-gated;在同一 MainActor turn 内原子翻转全局锁并返回翻转后状态;非幂等,丢失 reply 只能归入 outcome unknown | | `lockStatusSnapshot` | `lockStatusSnapshot()` | `LockEngine` / `AgentService` | capability-gated query;在一个 Agent execution turn 中读取布尔状态、capture time、锁定起点、auto-unlock deadline 与 active settings,并以有大小上限的 format-1 JSON payload 返回 | | `prepareForReplacement` | `prepareForReplacement(unlockIfNeeded:expectedAgentInstanceID:)` | `AgentService` | Agent 原子校验 expected instance,安装短期 prepared drain,可在同一 execution turn 解锁,返回同 generation ticket | | `commitReplacement` | `commitReplacement(ticket:)` | `AgentService` | 在提交 unregister 前把 prepared drain 幂等切换为不可过期的 committed drain | From 921fc292cb14267337eba4bed675615c6157e51f Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 16:51:55 +0800 Subject: [PATCH 075/106] feat(app): add locked notification with Unlock Now action While the App observes the keyboard as locked it posts a single notification showing the unlock hotkey and the auto-unlock deadline, with an Unlock Now action that performs an idempotent desired-state unlock over XPC. The notification uses a fixed identifier so re-locks replace rather than stack, any unlock path removes it, and notification work is serialized so a rapid lock/unlock flip cannot post after the removal. Presentation-only wrapper: content is re-queried from the Agent snapshot on every locked signal, authorization denial degrades silently, and snapshot failure falls back to minimal content. --- .../KeyboardLockerApplication.swift | 11 + .../LockNotificationController.swift | 228 +++++++++++++ .../LockNotificationControllerTests.swift | 321 ++++++++++++++++++ docs/architecture.md | 8 +- docs/automation.md | 10 +- 5 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 KeyboardLocker/Notifications/LockNotificationController.swift create mode 100644 KeyboardLockerModelTests/LockNotificationControllerTests.swift diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index b675f1a..1b90760 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -75,6 +75,7 @@ enum KeyboardLockerApplication { private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDelegate { private let automationController: ExternalAutomationController private let servicesProvider: KeyboardLockerServicesProvider + private let lockNotificationController: LockNotificationController private var statusMenuController: StatusMenuController? override init() { @@ -85,12 +86,22 @@ private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDe automationController.submit(action, source: .service) } } + // Constructed before launch completes so its UNUserNotificationCenter delegate catches an + // Unlock Now response that relaunched the App. + let client = LiveAgentClient() + lockNotificationController = LockNotificationController( + notifications: LiveLockNotificationService(), + lockStateObserver: LiveAgentLockStateObserver(), + snapshotQuery: { try await XPCClient.shared.lockStatusSnapshot() }, + unlock: { try await client.unlock() } + ) super.init() } func applicationDidFinishLaunching(_: Notification) { NSApp.servicesProvider = servicesProvider statusMenuController = StatusMenuController(coordinator: AppCoordinator()) + lockNotificationController.start() } func application(_: NSApplication, open urls: [URL]) { diff --git a/KeyboardLocker/Notifications/LockNotificationController.swift b/KeyboardLocker/Notifications/LockNotificationController.swift new file mode 100644 index 0000000..ce667f8 --- /dev/null +++ b/KeyboardLocker/Notifications/LockNotificationController.swift @@ -0,0 +1,228 @@ +import Client +import Foundation +import UserNotifications + +/// Notification-center surface used by `LockNotificationController`. +/// The live implementation wraps `UNUserNotificationCenter`; tests drive a recording fake. +/// Class-constrained so the controller can install its action handler on a `let` reference. +@MainActor +protocol LockNotificationServing: AnyObject, Sendable { + /// Invoked (from any thread) when the user chooses the notification's Unlock Now action. + var actionHandler: (@Sendable (String) -> Void)? { get set } + /// Registers the locked category and its action with the system. + func installUnlockActionCategory() + /// Asks the system for permission to post alerts. This surface degrades silently on denial. + func requestAuthorization() async -> Bool + /// Whether the app may currently deliver user-visible notifications. + func canPost() async -> Bool + /// Posts the locked notification immediately; a repeated identifier replaces the previous one. + func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) + /// Removes the locked notification from Notification Center and any pending delivery. + func removeLocked(identifier: String) +} + +/// Presentation-only wrapper: while the App observes the keyboard as locked it keeps a single +/// notification on screen whose Unlock Now action performs an idempotent desired-state unlock. +/// The notification never carries authoritative state; its content is a presentation cache +/// refreshed from the Agent's snapshot on every locked signal. +@MainActor +final class LockNotificationController { + static let notificationIdentifier = "keyboard-locked" + static let categoryIdentifier = "KEYBOARD_LOCKED" + static let unlockActionIdentifier = "UNLOCK_NOW" + + private let notifications: any LockNotificationServing + private let lockStateObserver: any AgentLockStateObserving + private let snapshotQuery: @Sendable () async throws -> LockStatusSnapshot + private let unlock: @Sendable () async throws -> Void + private var stateToken: ObserverToken? + private var tail: Task? + + init( + notifications: any LockNotificationServing, + lockStateObserver: any AgentLockStateObserving, + snapshotQuery: @escaping @Sendable () async throws -> LockStatusSnapshot, + unlock: @escaping @Sendable () async throws -> Void + ) { + self.notifications = notifications + self.lockStateObserver = lockStateObserver + self.snapshotQuery = snapshotQuery + self.unlock = unlock + notifications.actionHandler = { @Sendable actionIdentifier in + Task { @MainActor [weak self] in + self?.handleAction(actionIdentifier) + } + } + } + + func start() { + notifications.installUnlockActionCategory() + // Long-lived wrapper form: subscribe to the signal, then re-query for presentation content. + // The subscription outlives the controller via the App delegate's ownership. + stateToken = lockStateObserver.subscribe(initialState: nil) { [weak self] isLocked in + self?.handleLockStateChange(isLocked) + } + enqueue { [notifications] in + _ = await notifications.requestAuthorization() + } + } + + private func handleLockStateChange(_ isLocked: Bool) { + if isLocked { + enqueue { [weak self] in + await self?.postLockedNotification() + } + } else { + enqueue { [notifications] in + notifications.removeLocked(identifier: LockNotificationController.notificationIdentifier) + } + } + } + + /// Internal so tests can drive action delivery deterministically; production reaches it only + /// through the notification delegate's `actionHandler` hop. + func handleAction(_ actionIdentifier: String) { + guard actionIdentifier == Self.unlockActionIdentifier else { + return + } + // Desired-state unlock is idempotent; on failure the lock (and its notification) stays. + enqueue { [weak self] in + try? await self?.unlock() + } + } + + private func postLockedNotification() async { + guard await notifications.canPost() else { + return + } + // The action remains valid without snapshot details: it re-proves state at the Agent. + let snapshot = try? await snapshotQuery() + notifications.postLocked( + identifier: Self.notificationIdentifier, + categoryIdentifier: Self.categoryIdentifier, + title: "Keyboard Locked", + body: Self.makeBody(snapshot: snapshot) + ) + } + + private static func makeBody(snapshot: LockStatusSnapshot?) -> String { + guard let snapshot else { + return "Choose Unlock Now to unlock." + } + var lines = ["Press \(snapshot.settings.unlockHotkey.displayString) or choose Unlock Now."] + if let target = snapshot.autoUnlockTargetDate { + lines.append("Auto-unlocks at \(target.formatted(date: .omitted, time: .shortened)).") + } + return lines.joined(separator: " ") + } + + /// Serializes notification work so a quick lock/unlock flip can never post after the removal. + private func enqueue(_ work: @escaping @MainActor () async -> Void) { + let previous = tail + tail = Task { @MainActor in + await previous?.value + await work() + } + } + + /// Test support: waits for queued notification work to settle. + func waitUntilIdle() async { + await tail?.value + } +} + +/// Production `LockNotificationServing` backed by `UNUserNotificationCenter`. +@MainActor +final class LiveLockNotificationService: LockNotificationServing { + private let center: UNUserNotificationCenter + private let delegateBridge: NotificationDelegateBridge + + var actionHandler: (@Sendable (String) -> Void)? { + get { + delegateBridge.actionHandler + } + set { + delegateBridge.actionHandler = newValue + } + } + + init(center: UNUserNotificationCenter = .current()) { + self.center = center + delegateBridge = NotificationDelegateBridge() + // Installed during App-delegate init — before launch completes — so an Unlock Now response + // that relaunched the App is still delivered. + center.delegate = delegateBridge + } + + func installUnlockActionCategory() { + let unlockAction = UNNotificationAction( + identifier: LockNotificationController.unlockActionIdentifier, + title: "Unlock Now" + ) + let category = UNNotificationCategory( + identifier: LockNotificationController.categoryIdentifier, + actions: [unlockAction], + intentIdentifiers: [] + ) + center.setNotificationCategories([category]) + } + + func requestAuthorization() async -> Bool { + (try? await center.requestAuthorization(options: [.alert])) ?? false + } + + func canPost() async -> Bool { + let settings = await center.notificationSettings() + return switch settings.authorizationStatus { + case .authorized, .provisional: + true + case .denied, .notDetermined: + false + @unknown default: + false + } + } + + func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.categoryIdentifier = categoryIdentifier + // A nil trigger delivers immediately; the fixed identifier replaces the previous copy. + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + center.add(request, withCompletionHandler: nil) + } + + func removeLocked(identifier: String) { + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + } +} + +/// `UNUserNotificationCenter.delegate` is weak and its callbacks are nonisolated; this bridge +/// owns the Objective-C surface and forwards the Unlock Now action to the main actor. +private final class NotificationDelegateBridge: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable { + var actionHandler: (@Sendable (String) -> Void)? + + nonisolated func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let actionIdentifier = response.actionIdentifier + if actionIdentifier != UNNotificationDefaultActionIdentifier, + actionIdentifier != UNNotificationDismissActionIdentifier { + actionHandler?(actionIdentifier) + } + completionHandler() + } + + nonisolated func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // The App is a menu-bar accessory; show the banner even while it is active. + completionHandler([.banner, .list]) + } +} diff --git a/KeyboardLockerModelTests/LockNotificationControllerTests.swift b/KeyboardLockerModelTests/LockNotificationControllerTests.swift new file mode 100644 index 0000000..796c03f --- /dev/null +++ b/KeyboardLockerModelTests/LockNotificationControllerTests.swift @@ -0,0 +1,321 @@ +import Client +import Foundation +import XCTest + +final class LockNotificationControllerTests: XCTestCase { + @MainActor + func testLockedSignalPostsNotificationWithHotkeyAndDeadline() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let deadline = Date(timeIntervalSince1970: 1_900_000_000) + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot(autoUnlockTargetDate: deadline) }, + unlock: {} + ) + + controller.start() + // The subscriber emits the current state once after subscription, so this single signal + // doubles as the "App launched while already locked" initial calibration. + observer.emit(true) + await controller.waitUntilIdle() + + let hotkey = KeyboardLockerSettings.default.unlockHotkey.displayString + let expectedBody = "Press \(hotkey) or choose Unlock Now. Auto-unlocks at " + + deadline.formatted(date: .omitted, time: .shortened) + "." + XCTAssertEqual(service.events, [ + .installCategory, + .requestAuthorization, + .post( + identifier: LockNotificationController.notificationIdentifier, + category: LockNotificationController.categoryIdentifier, + title: "Keyboard Locked", + body: expectedBody + ), + ]) + } + + @MainActor + func testManualLockBodyOmitsDeadline() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot(autoUnlockTargetDate: nil) }, + unlock: {} + ) + + controller.start() + observer.emit(true) + await controller.waitUntilIdle() + + let hotkey = KeyboardLockerSettings.default.unlockHotkey.displayString + XCTAssertEqual(service.postedBodies, ["Press \(hotkey) or choose Unlock Now."]) + } + + @MainActor + func testUnlockedSignalRemovesNotification() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot() }, + unlock: {} + ) + + controller.start() + observer.emit(true) + await controller.waitUntilIdle() + observer.emit(false) + await controller.waitUntilIdle() + + XCTAssertEqual( + service.events.last, + .remove(identifier: LockNotificationController.notificationIdentifier) + ) + } + + @MainActor + func testRapidLockUnlockNeverLeavesNotificationPosted() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { + try await Task.sleep(for: .milliseconds(50)) + return Self.makeSnapshot() + }, + unlock: {} + ) + + controller.start() + observer.emit(true) + observer.emit(false) + await controller.waitUntilIdle() + + XCTAssertEqual( + service.events.last, + .remove(identifier: LockNotificationController.notificationIdentifier) + ) + } + + @MainActor + func testRepeatedLockSignalsReuseTheSameIdentifier() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot() }, + unlock: {} + ) + + controller.start() + observer.emit(true) + observer.emit(true) + await controller.waitUntilIdle() + + XCTAssertEqual(service.postedIdentifiers, [ + LockNotificationController.notificationIdentifier, + LockNotificationController.notificationIdentifier, + ]) + } + + @MainActor + func testSnapshotFailureStillPostsMinimalContent() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { throw TestError.expected }, + unlock: {} + ) + + controller.start() + observer.emit(true) + await controller.waitUntilIdle() + + XCTAssertEqual(service.postedBodies, ["Choose Unlock Now to unlock."]) + } + + @MainActor + func testDeniedAuthorizationSkipsPosting() async { + let service = FakeNotificationService() + service.canPostResult = false + let observer = FakeLockStateObserver() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot() }, + unlock: {} + ) + + controller.start() + observer.emit(true) + await controller.waitUntilIdle() + + XCTAssertEqual(service.events, [.installCategory, .requestAuthorization]) + } + + @MainActor + func testUnlockNowActionPerformsIdempotentUnlock() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let unlockRecorder = UnlockRecorder() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot() }, + unlock: { unlockRecorder.record() } + ) + + controller.start() + XCTAssertNotNil(service.actionHandler) + controller.handleAction(LockNotificationController.unlockActionIdentifier) + await controller.waitUntilIdle() + + XCTAssertEqual(unlockRecorder.count, 1) + } + + @MainActor + func testUnlockFailureLeavesPresentationUntouched() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot() }, + unlock: { throw TestError.expected } + ) + + controller.start() + observer.emit(true) + await controller.waitUntilIdle() + let eventsBeforeAction = service.events + + controller.handleAction(LockNotificationController.unlockActionIdentifier) + await controller.waitUntilIdle() + + XCTAssertEqual(service.events, eventsBeforeAction) + } + + @MainActor + func testUnknownActionIsIgnored() async { + let service = FakeNotificationService() + let observer = FakeLockStateObserver() + let unlockRecorder = UnlockRecorder() + let controller = LockNotificationController( + notifications: service, + lockStateObserver: observer, + snapshotQuery: { Self.makeSnapshot() }, + unlock: { unlockRecorder.record() } + ) + + controller.start() + controller.handleAction("SOME_OTHER_ACTION") + await controller.waitUntilIdle() + + XCTAssertEqual(unlockRecorder.count, 0) + XCTAssertEqual(service.events, [.installCategory, .requestAuthorization]) + } + + private static func makeSnapshot( + autoUnlockTargetDate: Date? = Date(timeIntervalSince1970: 1_900_000_000) + ) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 1_899_999_000), + isLocked: true, + startedAt: Date(timeIntervalSince1970: 1_899_999_000), + autoUnlockTargetDate: autoUnlockTargetDate, + settings: .default + ) + } +} + +private enum TestError: Error { + case expected +} + +@MainActor +private final class FakeNotificationService: LockNotificationServing { + enum Event: Equatable { + case installCategory + case requestAuthorization + case post(identifier: String, category: String, title: String, body: String) + case remove(identifier: String) + } + + private(set) var events: [Event] = [] + var actionHandler: (@Sendable (String) -> Void)? + var authorizationGranted = true + var canPostResult = true + + var postedIdentifiers: [String] { + events.compactMap { event in + guard case let .post(identifier, _, _, _) = event else { + return nil + } + return identifier + } + } + + var postedBodies: [String] { + events.compactMap { event in + guard case let .post(_, _, _, body) = event else { + return nil + } + return body + } + } + + func installUnlockActionCategory() { + events.append(.installCategory) + } + + func requestAuthorization() async -> Bool { + events.append(.requestAuthorization) + return authorizationGranted + } + + func canPost() async -> Bool { + canPostResult + } + + func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) { + events.append(.post(identifier: identifier, category: categoryIdentifier, title: title, body: body)) + } + + func removeLocked(identifier: String) { + events.append(.remove(identifier: identifier)) + } +} + +@MainActor +private final class FakeLockStateObserver: AgentLockStateObserving { + private var handler: ((Bool) -> Void)? + + func subscribe( + initialState _: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + self.handler = handler + return ObserverToken {} + } + + func emit(_ isLocked: Bool) { + handler?(isLocked) + } +} + +private final class UnlockRecorder: @unchecked Sendable { + private(set) var count = 0 + + func record() { + count += 1 + } +} diff --git a/docs/architecture.md b/docs/architecture.md index b3d6eda..c6fea0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # 核心架构契约 -**这是项目的根本规则。每一个功能面 —— App、`klock`、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets 以及后续新增的任何形态 —— 都必须遵守它。当某个改动与本契约冲突时,契约优先;修改的是那个改动,而不是契约。** +**这是项目的根本规则。每一个功能面 —— App、`klock`、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、Notifications 以及后续新增的任何形态 —— 都必须遵守它。当某个改动与本契约冲突时,契约优先;修改的是那个改动,而不是契约。** ## 为什么是单核(从旧方案学到的) @@ -19,7 +19,7 @@ ## 单核原则 -**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、扩展)都是**薄 wrapper**,职责仅限两件事: +**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、Notifications、扩展)都是**薄 wrapper**,职责仅限两件事: 1. 把用户意图翻译成对 Agent 的一次 XPC 调用; 2. 通过通知观察全局状态。 @@ -33,8 +33,8 @@ Shortcut ────┤ Services/URL ┤ Focus ───────┼── XPC ──▶ Agent ◀── the ONLY executor AppleScript ─┤ ├─ LockEngine (event tap, lock lifecycle) -Widget ──────┘ ├─ Settings ownership (source of truth) - └─ Accessibility (permission gate) +Notifications┤ ├─ Settings ownership (source of truth) +Widget ──────┘ └─ Accessibility (permission gate) ``` 这里的 `Core`、SwiftPM product、运行进程和 XPC connection 是不同层次。完整的构建依赖图、进程图和调用时序见 [XPC 实现与使用指南](xpc.md)。 diff --git a/docs/automation.md b/docs/automation.md index e484e34..f6e2ce7 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,6 +1,6 @@ # 自动化 -KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 +KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget、Control 与 Notifications 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 所有入口共享以下语义: @@ -80,6 +80,14 @@ macOS 26 及以上提供 `Keyboard Lock` Control。Control 的 value provider Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` Widget timeline 和 Control value。调用失败会由 App Intent 明确返回错误,也不会先刷新出未经确认的 UI 状态。Control API 在当前 macOS SDK 中从 macOS 26 起可用;macOS 13–15 不注册 Control,其中 macOS 14–15 的状态 Widget 可交互,macOS 13 的状态 Widget 只读。 +## Notifications + +主 App 运行期间,任一入口使键盘进入 locked 时,App 会发布一条 `Keyboard Locked` 通知:内容来自该时刻向 Agent 重新查询的 `LockStatusSnapshot`,展示当前解锁热键,并在 timed auto-unlock policy 下附带截止时间。通知使用固定 identifier,重复锁定只替换内容,不会堆叠;任一入口解开同一个全局锁后,通知被移除。 + +通知携带 `Unlock Now` 操作按钮。键盘被锁时鼠标与触控板仍然可用,点击按钮经 XPC 执行幂等 `unlock`;它不依据通知内容做 client-side 状态判断,调用失败时锁与通知都保持原样。点击按钮时 App 未在运行,系统会先拉起 App 再交付该操作。 + +通知是 presentation-only 的便利面:首次发布前向系统请求通知权限,被拒绝或未授予时不发送、不报错;通知内容永远不是状态源。主 App 未运行时,锁状态变化不会产生通知。 + ## AppleScript 应用 scripting dictionary 提供三个 command: From bb106f0a971967177337a59c653260a4cfd8925c Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 17:47:26 +0800 Subject: [PATCH 076/106] chore: ignore LLVM profraw coverage data --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f30c2c8..7d9689c 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,9 @@ iOSInjectionProject/ # macOS .DS_Store +# LLVM coverage data dropped into the working directory by xcodebuild test runs +*.profraw + # xcode-build-server files buildServer.json .compile From 4626aa72f098ea15ef00ef70a146fe7f50df37dd Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 17:47:53 +0800 Subject: [PATCH 077/106] feat(klock): add register-agent command for first-run agent setup Only the App bundle can register the background agent with SMAppService, so a terminal-only user previously had to know to open the app once. register-agent locates the containing KeyboardLocker.app from the resolved executable path (falling back to Launch Services by bundle id), launches it, then briefly polls so registration success is reported immediately. --- Core/Sources/Common/Shared.swift | 3 +- .../KlockCommandLineTests.swift | 102 +++++++++++++++++- docs/automation.md | 5 +- docs/development.md | 4 +- klock/KlockAppOpener.swift | 69 ++++++++++++ klock/KlockCLI.swift | 51 +++++++++ klock/KlockCommand.swift | 5 + 7 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 klock/KlockAppOpener.swift diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 2f131e1..733d694 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -6,7 +6,8 @@ public enum SharedConstants { /// Mach service name for XPC communication between the Agent and authorized wrappers. public static let machServiceName = "io.lzhlovesjyq.keyboardlocker.agent" - private static let appBundleIdentifier = "io.lzhlovesjyq.keyboardlocker" + /// Bundle identifier of the menu-bar App, used by `klock` as a Launch Services fallback. + public static let appBundleIdentifier = "io.lzhlovesjyq.keyboardlocker" /// Bundle identifier of the launchd-managed Agent bundled with the App. public static let agentBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.agent" diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index 3b3bbb1..4d2a30d 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -21,6 +21,7 @@ final class KlockCommandLineTests: XCTestCase { (["lock"], .lock(wait: true)), (["lock", "--no-wait"], .lock(wait: false)), (["unlock"], .unlock), + (["register-agent"], .registerAgent), (["status"], .status(output: .humanReadable)), (["status", "--json"], .status(output: .json)), ] @@ -57,6 +58,7 @@ final class KlockCommandLineTests: XCTestCase { (["lock", "--no-wait", "extra"], .unexpectedArguments(["--no-wait", "extra"])), (["status", "--xml"], .unexpectedArguments(["--xml"])), (["unlock", "now"], .unexpectedArguments(["now"])), + (["register-agent", "now"], .unexpectedArguments(["now"])), (["--help", "extra"], .unexpectedArguments(["extra"])), (["version", "extra"], .unexpectedArguments(["extra"])), ] @@ -240,6 +242,94 @@ final class KlockCommandLineTests: XCTestCase { ) } + // MARK: - Register Agent + + func testRegisterAgentReportsAlreadyReachableWithoutOpeningApp() async { + let client = FakeKlockClient() + var openCalls = 0 + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { openCalls += 1 } + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["The KeyboardLocker agent is already registered and reachable."]) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(openCalls, 0) + XCTAssertEqual(client.statusCalls, 1) + } + + func testRegisterAgentOpensAppAndConfirmsWhenAgentBecomesReachable() async { + let client = FakeKlockClient() + client.statusResults = [ + .failure(Self.makeError("unreachable")), + .success(false), + ] + var openCalls = 0 + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { openCalls += 1 }, + agentPoll: (attempts: 3, interval: .zero) + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + result.stdout, + [ + "Launched KeyboardLocker to register its background agent.", + "The background agent is registered and reachable.", + ] + ) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(openCalls, 1) + XCTAssertEqual(client.statusCalls, 2) + } + + func testRegisterAgentFailsWhenAgentStaysUnreachable() async { + let client = FakeKlockClient() + client.statusResult = .failure(Self.makeError("unreachable")) + var openCalls = 0 + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { openCalls += 1 }, + agentPoll: (attempts: 2, interval: .zero) + ) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, ["Launched KeyboardLocker to register its background agent."]) + XCTAssertEqual(result.stderr.first, "Error: The background agent is not reachable yet.") + XCTAssertEqual(openCalls, 1) + XCTAssertEqual(client.statusCalls, 3) + } + + func testRegisterAgentReportsOpenFailure() async { + let client = FakeKlockClient() + client.statusResult = .failure(Self.makeError("unreachable")) + let error = KlockAppOpener.OpenerError.appNotFound + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { throw error } + ) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, []) + XCTAssertEqual( + result.stderr, + [ + "Error: \(error.localizedDescription)", + " \(try XCTUnwrap(error.recoverySuggestion))", + ] + ) + } + // MARK: - Helpers private static func makeError(_ message: String) -> NSError { @@ -252,13 +342,17 @@ final class KlockCommandLineTests: XCTestCase { private func runKlock( arguments: [String], - client: FakeKlockClient + client: FakeKlockClient, + openKeyboardLockerApp: @escaping () throws -> Void = {}, + agentPoll: (attempts: Int, interval: Duration) = (attempts: 1, interval: .zero) ) async -> (exitCode: Int32, stdout: [String], stderr: [String]) { var stdout: [String] = [] var stderr: [String] = [] let exitCode = await KlockCLI.run( arguments: arguments, client: client, + openKeyboardLockerApp: openKeyboardLockerApp, + agentPoll: agentPoll, printOut: { stdout.append($0) }, printError: { stderr.append($0) } ) @@ -279,6 +373,9 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { var lockResult: Result = .success(()) var unlockResult: Result = .success(()) var statusResult: Result = .success(false) + /// When set, each `status()` call consumes the next queued result before falling back to + /// `statusResult`, so tests can script an Agent that becomes reachable after registration. + var statusResults: [Result]? var waitUntilUnlockedResult: Result = .success(()) private(set) var currentSettingsCalls = 0 @@ -310,6 +407,9 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { func status() async throws -> Bool { statusCalls += 1 + if statusResults?.isEmpty == false { + return try statusResults!.removeFirst().get() + } return try statusResult.get() } diff --git a/docs/automation.md b/docs/automation.md index f6e2ce7..81a714a 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,6 +1,6 @@ # 自动化 -KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget、Control 与 Notifications 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App,让它注册后台 Agent。 +KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget、Control 与 Notifications 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App 或运行 `klock register-agent`,让 App 注册后台 Agent。 所有入口共享以下语义: @@ -131,8 +131,11 @@ klock lock --no-wait klock status klock status --json klock unlock +klock register-agent ``` +`register-agent` 在 Agent 不可达时启动一次 KeyboardLocker App 来完成 `SMAppService` 注册(注册只能由 App bundle 执行),随后短暂轮询确认 Agent 可达;若出现 Login Items 批准或 Accessibility 授权要求,命令会指出对应的系统设置入口。Agent 已经可达时它不启动 App,直接报告。 + `status` 默认输出供人阅读的 `Locked` 或 `Unlocked`。自动化使用 `status --json`,其稳定 contract 是单行 `{"locked":true}` 或 `{"locked":false}`。两种状态都表示查询成功并返回 exit code `0`;参数或 Agent 调用失败返回 `1`,因此脚本不需要把 unlocked 与 transport failure 混在同一个 exit code 中。 不要把 `lock --no-wait` / `unlock` 当成 acquire/release pair:若调用 `lock --no-wait` 时全局状态本来已经 locked,后续无条件执行 `unlock` 会解除其他 wrapper 建立的锁。需要这种 ownership 语义时,应先在 Agent contract 中设计明确的 lease/session capability,而不是在 shell 中模拟。 diff --git a/docs/development.md b/docs/development.md index 255dcc5..7ba9bb1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -107,7 +107,7 @@ 已构建的 App 也在 status menu 中提供 **Command Line Tool…**。该入口遵循同一所有权边界:只创建或移除指向当前 App bundle 的 link;需要配置 `PATH` 时仅提供可复制命令,不会静默修改 dotfile。 -`klock` 自身不注册后台 Agent。首次使用前必须至少启动一次 KeyboardLocker App;Agent 未注册时,CLI 会给出对应恢复提示。 +`klock` 自身不注册后台 Agent——`SMAppService` 注册只能由 App bundle 执行。首次使用前运行 `klock register-agent` 或至少启动一次 KeyboardLocker App;Agent 未注册时,CLI 各命令也会给出对应恢复提示。 `klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 @@ -144,7 +144,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: - `LockEngine` 的纯策略(event policy、schedule 计算、runtime state、tap 安装事务)与引擎语义(Accessibility 预检、tap 安装/拆除、auto-unlock 排程/触发/陈旧代际拒绝、热键与 Control-C 分发、tap-disable re-enable/fail-open、Focus 代际所有权)均已单测 —— 后者由 `LockEngineTests` 经注入缝(`InstalledEventTap`、`MainActorTimerScheduler`、权限/广播/时钟闭包)确定性驱动;剩余不可单测的只有 Quartz 对真实硬件事件的拦截本身(平台集成事实,靠真机验证)。 -- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵与命令执行路径(未知命令、多余参数、already-locked 退出、stdout/stderr 路由与退出码)已由 `KlockCommandLineTests` 覆盖;`version` 的执行路径依赖 `Bundle.main` 未测(其解析已覆盖)。 +- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵与命令执行路径(未知命令、多余参数、already-locked 退出、stdout/stderr 路由与退出码、`register-agent` 的探测/启动/轮询路径)已由 `KlockCommandLineTests` 覆盖;`version` 的执行路径依赖 `Bundle.main` 未测(其解析已覆盖),`KlockAppOpener` 的 bundle 定位与 `/usr/bin/open` 调用依赖真实进程环境,经手动验证。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 diff --git a/klock/KlockAppOpener.swift b/klock/KlockAppOpener.swift new file mode 100644 index 0000000..88d5267 --- /dev/null +++ b/klock/KlockAppOpener.swift @@ -0,0 +1,69 @@ +import Client +import Foundation + +/// Launches the containing KeyboardLocker app so it can register the background Agent with +/// `SMAppService`. Only the App bundle can perform that registration: the launchd plist must +/// live in the main bundle, and `klock`'s main bundle is not KeyboardLocker.app. +enum KlockAppOpener { + enum OpenerError: LocalizedError { + case appNotFound + case launchFailed(String) + + var errorDescription: String? { + switch self { + case .appNotFound: + "KeyboardLocker.app could not be located from this klock executable or by Launch Services." + case let .launchFailed(details): + "KeyboardLocker could not be launched. \(details)" + } + } + + var recoverySuggestion: String? { + "Open KeyboardLocker manually once to register its background agent, then retry." + } + } + + /// klock ships at `Contents/MacOS/klock` inside the signed App bundle and PATH entries are + /// symlinks to it, so the resolved executable path identifies the exact App copy to launch. + /// That matters when a dev build and an installed copy share the same bundle identifier. + static func openContainingApp() throws { + if let appURL = containingAppURL() { + try runOpen(arguments: [appURL.path]) + return + } + try runOpen(arguments: ["-b", SharedConstants.appBundleIdentifier]) + } + + private static func containingAppURL() -> URL? { + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + var size = UInt32(buffer.count) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { + return nil + } + + var url = URL(fileURLWithPath: String(cString: buffer)) + .standardizedFileURL + .resolvingSymlinksInPath() + while url.pathExtension != "app", url.pathComponents.count > 1 { + url.deleteLastPathComponent() + } + return url.pathExtension == "app" ? url : nil + } + + private static func runOpen(arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = arguments + do { + try process.run() + } catch { + throw OpenerError.launchFailed(error.localizedDescription) + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw OpenerError.launchFailed( + "`open \(arguments.joined(separator: " "))` exited with status \(process.terminationStatus)." + ) + } + } +} diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index a08f55b..c07cd6c 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -33,6 +33,8 @@ enum KlockCLI { static func run( arguments: [String], client: any KlockClientServing, + openKeyboardLockerApp: @escaping () throws -> Void = KlockAppOpener.openContainingApp, + agentPoll: (attempts: Int, interval: Duration) = (attempts: 5, interval: .seconds(1)), printOut: (String) -> Void, printError: (String) -> Void ) async -> Int32 { @@ -76,6 +78,15 @@ enum KlockCLI { case .unlock: return await executeUnlock(client: client, printOut: printOut, printError: printError) + case .registerAgent: + return await executeRegisterAgent( + client: client, + openKeyboardLockerApp: openKeyboardLockerApp, + agentPoll: agentPoll, + printOut: printOut, + printError: printError + ) + case let .status(output): return await executeStatus(output: output, client: client, printOut: printOut, printError: printError) } @@ -162,6 +173,45 @@ enum KlockCLI { } } + /// Registers the background Agent by launching the containing App — only the App bundle can + /// hand its launchd plist to `SMAppService`. The command then polls briefly so a terminal + /// user learns immediately whether registration produced a reachable Agent. + private static func executeRegisterAgent( + client: any KlockClientServing, + openKeyboardLockerApp: () throws -> Void, + agentPoll: (attempts: Int, interval: Duration), + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + if (try? await client.status()) != nil { + printOut("The KeyboardLocker agent is already registered and reachable.") + return ExitCode.success + } + + do { + try openKeyboardLockerApp() + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + printOut("Launched KeyboardLocker to register its background agent.") + + for _ in 0 ..< agentPoll.attempts { + try? await Task.sleep(for: agentPoll.interval) + if (try? await client.status()) != nil { + printOut("The background agent is registered and reachable.") + return ExitCode.success + } + } + + reportError("The background agent is not reachable yet.", printError: printError) + printError( + " If KeyboardLocker appears in System Settings → General → Login Items, enable it, " + + "then retry. Locking also requires Accessibility access for the agent." + ) + return ExitCode.error + } + private static func executeStatus( output: KlockStatusOutput, client: any KlockClientServing, @@ -222,6 +272,7 @@ enum KlockCLI { lock [--no-wait] Lock the keyboard; by default, wait until it is unlocked. unlock Unlock the keyboard. status [--json] Print the current lock state. + register-agent Launch KeyboardLocker once to register its background agent. help Show this help message. version Show the klock version. diff --git a/klock/KlockCommand.swift b/klock/KlockCommand.swift index 1ccd6fa..7f043ee 100644 --- a/klock/KlockCommand.swift +++ b/klock/KlockCommand.swift @@ -6,6 +6,7 @@ enum KlockCommand: Equatable { case version case lock(wait: Bool) case unlock + case registerAgent case status(output: KlockStatusOutput) } @@ -47,6 +48,10 @@ enum KlockCommandLineParser { try rejectUnexpectedArguments(Array(arguments.dropFirst())) return .unlock + case "register-agent": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .registerAgent + case "status": switch Array(arguments.dropFirst()) { case []: From 380a64c19d3de0ef2f2bd21ad58620ff6197473f Mon Sep 17 00:00:00 2001 From: Eden Date: Tue, 11 Aug 2026 17:48:01 +0800 Subject: [PATCH 078/106] docs: record chronod reload restriction for non-container processes Verified on macOS 26 that chronod ignores WidgetCenter reload requests from processes that are not registered extension containers ('Ignoring restricted or unknown extension'), so the CLI cannot trigger widget refreshes; App-not-running staleness remains documented. --- CLAUDE.md | 2 +- docs/architecture.md | 2 +- docs/automation.md | 2 +- docs/development.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5c1e83e..6bfb255 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ KeyboardLocker suppresses keyboard-originated events exposed through CGEventTap - **klock** (CLI) — imports `Client` - **KeyboardLockerWidgets** (WidgetKit extension hosting Widget and macOS Control) — imports `Client` and `SystemSurfaces` - **KeyboardLockerFocusIntents** (Focus Filter App Intents extension) — imports `Client` and `SystemSurfaces` -- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, wrappers), `Service` (lock engine, Agent), `SystemSurfaces` (presentation-only Widget/Control identifiers and invalidation). `Client`/`Service` both `@_exported import Common`; `SystemSurfaces` is linked only by the containing App and its extensions, never by the Agent or CLI. +- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, wrappers), `Service` (lock engine, Agent), `SystemSurfaces` (presentation-only Widget/Control identifiers and invalidation). `Client`/`Service` both `@_exported import Common`; `SystemSurfaces` is linked only by the containing App and its extensions, never by the Agent or CLI. (Verified on macOS 26: `chronod` ignores reload requests from processes that are not a registered extension container — `Ignoring restricted or unknown extension` — so linking `SystemSurfaces` from `klock` cannot actually refresh widgets.) Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`, WidgetKit extension `…​.widgets`, Focus App Intents extension `…​.focus-intents`. diff --git a/docs/architecture.md b/docs/architecture.md index c6fea0b..7a4ab38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management `LockStateSubscriber` 把通知当作*提示*而非真相:订阅后的初始校准和收到的任一信号(Darwin 或 Distributed)都会向 Agent 拉取权威状态。多个并发信号被串行合并,相同状态被去重;subscription 取消后,尚未进入 handler 的在途结果会被丢弃。已经开始执行的 handler 不可被回溯撤销。Agent 之所以在两个通道都广播,只是为了让被挂起的 App 能被唤醒(Darwin)、让正在运行的 App 能及时更新(Distributed)。通知的载荷永远不是真相源。 -`SystemSurfaces` 的 reload request 是 presentation invalidation,不是领域状态广播:它不携带状态、不触碰 Agent,只请求 WidgetKit / ControlCenter 重新执行 provider。主 App、Widget 与 Focus extension 可以在 Agent 确认 mutation 后请求刷新;主 App 运行时还把已经由 `LockStateSubscriber` 回拉并去重的外部变化桥接为刷新提示。CLI 与 Agent 不链接 presentation module。主 App 未运行时,CLI、热键或 event-tap failure 造成的变化只能等待 WidgetKit 的下一次 provider execution;系统始终可以合并或延后 reload,因此任何 wrapper 都不得承诺即时 UI 同步。 +`SystemSurfaces` 的 reload request 是 presentation invalidation,不是领域状态广播:它不携带状态、不触碰 Agent,只请求 WidgetKit / ControlCenter 重新执行 provider。主 App、Widget 与 Focus extension 可以在 Agent 确认 mutation 后请求刷新;主 App 运行时还把已经由 `LockStateSubscriber` 回拉并去重的外部变化桥接为刷新提示。CLI 与 Agent 不链接 presentation module——这不只是边界约定:实测(macOS 26)`chronod` 会把非 extension 容器进程的 reload 请求按 `Ignoring restricted or unknown extension` 忽略,因此 `klock` 即使链接 `SystemSurfaces` 也无法真正触发刷新。主 App 未运行时,CLI、热键或 event-tap failure 造成的变化只能等待 WidgetKit 的下一次 provider execution;系统始终可以合并或延后 reload,因此任何 wrapper 都不得承诺即时 UI 同步。 `klock lock` 先发出 atomic interactive lock request。只有 outcome 为 acquired 时,它才等待并报告后续解锁;本轮 event tap 会把 `Ctrl+C` 当作额外解锁手势并在 Agent 内消费该事件。若 outcome 为 already locked,CLI 必须说明本命令没有创建新锁并立即退出,不能把 App 或另一个 CLI 已建立的锁变成自己的可取消 session。acquired 后的等待同时使用 `LockStateSubscriber` 获得及时更新,并周期性查询 `status()` 以恢复双通道通知都丢失或 Agent 重启的场景;连续无法取得权威状态时必须报错退出,不能把 transport failure 猜成 unlocked。 diff --git a/docs/automation.md b/docs/automation.md index 81a714a..52316c4 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -72,7 +72,7 @@ Parser 对 scheme 与 host 使用 URL 规定的大小写不敏感比较,但只 macOS 14 及以上的 Widget 提供 `Lock` / `Unlock` 按钮。按钮把当前展示的 snapshot 翻译成明确的 desired state:`Lock` 只调用幂等 `lock`,`Unlock` 只调用幂等 `unlock`,不会先重新读取状态再执行 client-side toggle。即使用户点击时 snapshot 已过期,action 也不会被翻译成相反方向。Agent 确认成功后才请求刷新 timeline;调用失败会由 App Intent 明确返回错误,不做 optimistic update。macOS 13 上保持只读。 -Widget 使用 15 分钟 regular fallback;若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。主 App、Shortcuts、AppleScript、Services、URL、Focus、Widget 与 Control 在 Agent 确认 mutation 后都会请求刷新状态 Widget 和可用的 Control;主 App 运行时,其权威状态订阅还会把 CLI、auto-unlock、解锁热键与 event-tap fail-open 的变化桥接成 presentation reload。主 App 未运行时,CLI、热键或 event-tap failure 只能等待下一次 WidgetKit provider execution。timeline 与显式 reload 都只是系统调度提示;WidgetKit 可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本仍由系统持续渲染。 +Widget 使用 15 分钟 regular fallback;若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。主 App、Shortcuts、AppleScript、Services、URL、Focus、Widget 与 Control 在 Agent 确认 mutation 后都会请求刷新状态 Widget 和可用的 Control;主 App 运行时,其权威状态订阅还会把 CLI、auto-unlock、解锁热键与 event-tap fail-open 的变化桥接成 presentation reload。主 App 未运行时,CLI、热键或 event-tap failure 只能等待下一次 WidgetKit provider execution——CLI 自身无法请求刷新:实测(macOS 26)`chronod` 会把非 extension 容器进程的 reload 请求按 `Ignoring restricted or unknown extension` 忽略。timeline 与显式 reload 都只是系统调度提示;WidgetKit 可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本仍由系统持续渲染。 ## Control diff --git a/docs/development.md b/docs/development.md index 7ba9bb1..f30ce75 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,7 @@ - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 **SystemSurfaces**(`Core/Sources/SystemSurfaces/`)—— 仅 containing App、Widget 与 Focus extension 使用的 presentation adapter -- `LockStateSurfaceInvalidator.swift`:集中定义稳定的 Widget / Control kind,并把 `WidgetCenter` 与 macOS 26+ `ControlCenter` 包装成 nonthrowing reload request。它不 import `Client` / `Service`,不查询或携带锁状态,也不链接到 Agent / CLI;系统可以延后或合并请求,因此它不是状态广播或第二份真相源。 +- `LockStateSurfaceInvalidator.swift`:集中定义稳定的 Widget / Control kind,并把 `WidgetCenter` 与 macOS 26+ `ControlCenter` 包装成 nonthrowing reload request。它不 import `Client` / `Service`,不查询或携带锁状态,也不链接到 Agent / CLI;系统可以延后或合并请求,因此它不是状态广播或第二份真相源。(CLI 不链接还有实证依据:`chronod` 会忽略来自非 extension 容器进程的 reload 请求——`Ignoring restricted or unknown extension io.lzhlovesjyq.keyboardlocker.klock`,macOS 26 实测。) **App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 From 5536bd2135ebeb7acdbbfc8feb90a89b7e78dafb Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 10:42:30 +0800 Subject: [PATCH 079/106] fix(common): render hotkeys from the ASCII-capable keyboard layout TISCopyCurrentKeyboardInputSource follows the active input method; input methods like Pinyin carry no Unicode layout data, so the unlock hotkey rendered as "?" in notifications, widgets, and CLI output exactly while the user typed in a non-Latin context. --- Core/Sources/Common/KeyCodeConverter.swift | 8 ++++++-- docs/development.md | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Core/Sources/Common/KeyCodeConverter.swift b/Core/Sources/Common/KeyCodeConverter.swift index 03b21b1..844765f 100644 --- a/Core/Sources/Common/KeyCodeConverter.swift +++ b/Core/Sources/Common/KeyCodeConverter.swift @@ -56,11 +56,15 @@ public enum KeyCodeConverter { characterFromKeyboardLayout(keyCode)?.uppercased() ?? "?" } - /// Get character from system keyboard layout using UCKeyTranslate + /// Get character from the system keyboard layout using UCKeyTranslate. + /// + /// Reads the ASCII-capable layout rather than the current input source: an active input + /// method (e.g. Pinyin) may carry no Unicode layout data, which would render the hotkey as + /// "?" precisely while the user is typing in a non-Latin context. /// - Parameter keyCode: CGKeyCode value /// - Returns: Character string or nil private static func characterFromKeyboardLayout(_ keyCode: CGKeyCode) -> String? { - let source = TISCopyCurrentKeyboardInputSource().takeRetainedValue() + let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource().takeRetainedValue() guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil } diff --git a/docs/development.md b/docs/development.md index f30ce75..8ed159d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成同 Team + 精确 identifier 的双向 XPC requirement 字符串,并用 `SecRequirementCreateWithString` 校验其可编译(编译结果仅作一次性校验后丢弃;安装到连接时由系统重新解析字符串);unsigned/ad-hoc 进程 fail closed。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。 -- `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。 +- `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。读取 **ASCII-capable keyboard layout**(`TISCopyCurrentASCIICapableKeyboardLayoutInputSource`)而非当前 input source——拼音等输入法不带 Unicode layout data,会把热键渲染成 `?`。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` - `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。`unlock` 超时后可用权威 Boolean 状态校准;普通 `lock` 与 Focus selector 的成功还包含本地状态看不到的 provenance,因此首次 timeout 后会在 fresh connection 上重发同一个幂等请求,第二次仍超时则明确报告 outcome unknown。`lockInteractively()`、`lockStatusSnapshot()` 与 `setFocusFilterLockEnabled()` 都在同一 connection 上完成 descriptor/capability gate 后调用对应 selector。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 From 313ad2e3d87b5beb92e74e980a1346d16444e3b1 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 10:42:52 +0800 Subject: [PATCH 080/106] refactor(app): replace soft-deprecated activate(ignoringOtherApps:) The SDK marks it "will be deprecated in a future release" (API_TO_BE_DEPRECATED), with activate() as the designated replacement. activate() requires macOS 14, so the legacy call stays on the macOS 13 path behind an availability check in NSApplication.activateForUserPresentation(). --- KeyboardLocker/AppActivation.swift | 16 ++++++++++++++++ .../AppKitExternalAutomationPresenter.swift | 2 +- KeyboardLocker/StatusMenuController.swift | 8 ++++---- 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 KeyboardLocker/AppActivation.swift diff --git a/KeyboardLocker/AppActivation.swift b/KeyboardLocker/AppActivation.swift new file mode 100644 index 0000000..7969e25 --- /dev/null +++ b/KeyboardLocker/AppActivation.swift @@ -0,0 +1,16 @@ +import AppKit + +extension NSApplication { + /// Requests activation before presenting an alert or other user-facing UI. + /// + /// `activate(ignoringOtherApps:)` is marked "will be deprecated in a future release" in the + /// SDK; `activate()` is its designated replacement but requires macOS 14, so the legacy call + /// remains on the macOS 13 path. + func activateForUserPresentation() { + if #available(macOS 14.0, *) { + activate() + } else { + activate(ignoringOtherApps: true) + } + } +} diff --git a/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift b/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift index ffcd885..305c227 100644 --- a/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift +++ b/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift @@ -38,7 +38,7 @@ struct AppKitExternalAutomationPresenter: ExternalAutomationPresenting { alert.messageText = message alert.informativeText = information alert.addButton(withTitle: "OK") - NSApp.activate(ignoringOtherApps: true) + NSApp.activateForUserPresentation() alert.runModal() } } diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index 461c8e9..84872a3 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -272,7 +272,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { confirmation.addButton(withTitle: "Update Agent") confirmation.addButton(withTitle: "Cancel") - NSApp.activate(ignoringOtherApps: true) + NSApp.activateForUserPresentation() if confirmation.runModal() == .alertFirstButtonReturn { coordinator.updateAgent() } @@ -287,7 +287,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { confirmation.addButton(withTitle: "Restart Agent") confirmation.addButton(withTitle: "Cancel") - NSApp.activate(ignoringOtherApps: true) + NSApp.activateForUserPresentation() if confirmation.runModal() == .alertFirstButtonReturn { coordinator.restartAgent() } @@ -303,7 +303,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { alert.messageText = "KeyboardLocker" alert.informativeText = detailMessage alert.addButton(withTitle: "OK") - NSApp.activate(ignoringOtherApps: true) + NSApp.activateForUserPresentation() alert.runModal() } @@ -314,7 +314,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { @objc private func manageCommandLineTool() { - NSApp.activate(ignoringOtherApps: true) + NSApp.activateForUserPresentation() switch commandLineToolManager.state { case let .installed(destination): From 16c9f140e4df4c3647a81ab9f0c8802e33ff898a Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 10:43:04 +0800 Subject: [PATCH 081/106] fix(service): mark LockEngine.eventMasks nonisolated The immutable value is read from the nonisolated tap-installation context, which becomes an error in the Swift 6 language mode. No behavioral change. --- Core/Sources/Service/LockEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 1b63750..0224758 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -435,7 +435,7 @@ public final class LockEngine { } /// Suppress standard keys and keyboard-originated system controls without observing pointer input. - static let eventMasks: CGEventMask = + nonisolated static let eventMasks: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue) | From acdd19f2994212029115967c4d6cda7ec012c995 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 10:43:59 +0800 Subject: [PATCH 082/106] feat(service): post the locked notification from the Agent process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock's discoverability surface now lives and dies with the lock itself: the Agent posts "Keyboard Locked" on any lock transition and removes it on any unlock path, regardless of which wrapper acted or whether the app is running. Unlock Now executes locally in the Agent, stale notifications are cleared at bootstrap, and the agent bundle's display name now reads KeyboardLocker. This also fixes notification staleness in passing — delivery and removal share the state transition. --- Core/Sources/Service/LockEngine.swift | 8 +- .../Sources/Service/LockStatusNotifier.swift | 140 ++++---- .../LockStatusNotifierTests.swift | 192 +++++++++++ KeyboardLocker.xcodeproj/project.pbxproj | 70 ++-- .../KeyboardLockerApplication.swift | 11 - KeyboardLockerAgent/main.swift | 3 + .../LockNotificationControllerTests.swift | 321 ------------------ docs/architecture.md | 8 +- docs/automation.md | 8 +- docs/development.md | 1 + 10 files changed, 310 insertions(+), 452 deletions(-) rename KeyboardLocker/Notifications/LockNotificationController.swift => Core/Sources/Service/LockStatusNotifier.swift (54%) create mode 100644 Core/Tests/ServiceTests/LockStatusNotifierTests.swift delete mode 100644 KeyboardLockerModelTests/LockNotificationControllerTests.swift diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 0224758..dd5272c 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -292,7 +292,13 @@ struct LockEngineDependencies { hasAccessibilityPermission: { AccessibilityManager.hasPermission() }, installEventTap: { engine in try CoreGraphicsEventTap.install(engine: engine) }, scheduleTimer: liveMainActorTimerScheduler, - broadcastStateChange: { LockStateBroadcaster.broadcast() }, + broadcastStateChange: { + LockStateBroadcaster.broadcast() + // Both call sites run on the main actor after the state mutation completed. + MainActor.assumeIsolated { + LockStatusNotifier.shared.lockStateDidChange() + } + }, now: { Date() } ) } diff --git a/KeyboardLocker/Notifications/LockNotificationController.swift b/Core/Sources/Service/LockStatusNotifier.swift similarity index 54% rename from KeyboardLocker/Notifications/LockNotificationController.swift rename to Core/Sources/Service/LockStatusNotifier.swift index ce667f8..bcaa542 100644 --- a/KeyboardLocker/Notifications/LockNotificationController.swift +++ b/Core/Sources/Service/LockStatusNotifier.swift @@ -1,52 +1,62 @@ -import Client +import Common import Foundation import UserNotifications -/// Notification-center surface used by `LockNotificationController`. -/// The live implementation wraps `UNUserNotificationCenter`; tests drive a recording fake. -/// Class-constrained so the controller can install its action handler on a `let` reference. +/// Notification-center surface used by `LockStatusNotifier`. The live implementation wraps +/// `UNUserNotificationCenter`; tests drive a recording fake. Class-constrained so the notifier +/// can install its action handler on a `let` reference. @MainActor -protocol LockNotificationServing: AnyObject, Sendable { +protocol LockStatusNotifying: AnyObject, Sendable { /// Invoked (from any thread) when the user chooses the notification's Unlock Now action. var actionHandler: (@Sendable (String) -> Void)? { get set } /// Registers the locked category and its action with the system. func installUnlockActionCategory() - /// Asks the system for permission to post alerts. This surface degrades silently on denial. - func requestAuthorization() async -> Bool - /// Whether the app may currently deliver user-visible notifications. - func canPost() async -> Bool + /// Returns whether the Agent may deliver a user-visible alert, requesting authorization on + /// first use. A denied or silent-only status degrades to no notification without an error. + func requestAlertAuthorization() async -> Bool /// Posts the locked notification immediately; a repeated identifier replaces the previous one. func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) /// Removes the locked notification from Notification Center and any pending delivery. func removeLocked(identifier: String) } -/// Presentation-only wrapper: while the App observes the keyboard as locked it keeps a single -/// notification on screen whose Unlock Now action performs an idempotent desired-state unlock. -/// The notification never carries authoritative state; its content is a presentation cache -/// refreshed from the Agent's snapshot on every locked signal. +/// Owns the single "Keyboard Locked" notification from inside the Agent process. +/// +/// The lock's discoverability surface must live and die with the lock itself, and only the +/// Agent is guaranteed to be alive for the lock's whole lifetime — menu-bar App, CLI, or Focus +/// extension may come and go. Posting here also pairs delivery with removal on the same state +/// transition, so a stale notification cannot outlive the lock (an Agent that exits while +/// locked drops its event tap, and the next launch clears the leftover via `start()`). +/// +/// The notification is a presentation-only convenience: it carries a presentation cache of the +/// unlock affordances, never authoritative state, and a denied authorization degrades silently. @MainActor -final class LockNotificationController { +public final class LockStatusNotifier { static let notificationIdentifier = "keyboard-locked" static let categoryIdentifier = "KEYBOARD_LOCKED" static let unlockActionIdentifier = "UNLOCK_NOW" - private let notifications: any LockNotificationServing - private let lockStateObserver: any AgentLockStateObserving - private let snapshotQuery: @Sendable () async throws -> LockStatusSnapshot - private let unlock: @Sendable () async throws -> Void - private var stateToken: ObserverToken? + /// Process-wide instance wired to the shared engine. Created lazily; `KeyboardLockerAgent` + /// calls `start()` during bootstrap so the action category and stale-notification cleanup + /// are in place before any lock can be created. + public static let shared = LockStatusNotifier( + notifications: LiveLockStatusNotificationService(), + snapshot: { LockEngine.shared.statusSnapshot }, + unlock: { LockEngine.shared.unlock() } + ) + + private let notifications: any LockStatusNotifying + private let snapshot: @MainActor () -> LockStatusSnapshot + private let unlock: @MainActor () -> Void private var tail: Task? init( - notifications: any LockNotificationServing, - lockStateObserver: any AgentLockStateObserving, - snapshotQuery: @escaping @Sendable () async throws -> LockStatusSnapshot, - unlock: @escaping @Sendable () async throws -> Void + notifications: any LockStatusNotifying, + snapshot: @escaping @MainActor () -> LockStatusSnapshot, + unlock: @escaping @MainActor () -> Void ) { self.notifications = notifications - self.lockStateObserver = lockStateObserver - self.snapshotQuery = snapshotQuery + self.snapshot = snapshot self.unlock = unlock notifications.actionHandler = { @Sendable actionIdentifier in Task { @MainActor [weak self] in @@ -55,60 +65,51 @@ final class LockNotificationController { } } - func start() { + /// Installs the action category and removes any notification left behind by a previous Agent + /// generation — its exit already released the lock, so the leftover can only be stale. + public func start() { notifications.installUnlockActionCategory() - // Long-lived wrapper form: subscribe to the signal, then re-query for presentation content. - // The subscription outlives the controller via the App delegate's ownership. - stateToken = lockStateObserver.subscribe(initialState: nil) { [weak self] isLocked in - self?.handleLockStateChange(isLocked) - } enqueue { [notifications] in - _ = await notifications.requestAuthorization() + notifications.removeLocked(identifier: LockStatusNotifier.notificationIdentifier) } } - private func handleLockStateChange(_ isLocked: Bool) { - if isLocked { + /// Engine state-change hook, invoked after the mutation completed so the snapshot read here + /// already describes the new state. + func lockStateDidChange() { + if snapshot().isLocked { enqueue { [weak self] in await self?.postLockedNotification() } } else { enqueue { [notifications] in - notifications.removeLocked(identifier: LockNotificationController.notificationIdentifier) + notifications.removeLocked(identifier: LockStatusNotifier.notificationIdentifier) } } } - /// Internal so tests can drive action delivery deterministically; production reaches it only - /// through the notification delegate's `actionHandler` hop. - func handleAction(_ actionIdentifier: String) { + private func handleAction(_ actionIdentifier: String) { guard actionIdentifier == Self.unlockActionIdentifier else { return } - // Desired-state unlock is idempotent; on failure the lock (and its notification) stays. - enqueue { [weak self] in - try? await self?.unlock() - } + // Desired-state unlock is idempotent; the engine's state-change hook removes the + // notification once the unlock lands. + unlock() } private func postLockedNotification() async { - guard await notifications.canPost() else { + guard await notifications.requestAlertAuthorization() else { return } - // The action remains valid without snapshot details: it re-proves state at the Agent. - let snapshot = try? await snapshotQuery() notifications.postLocked( identifier: Self.notificationIdentifier, categoryIdentifier: Self.categoryIdentifier, title: "Keyboard Locked", - body: Self.makeBody(snapshot: snapshot) + body: Self.makeBody(snapshot: snapshot()) ) } - private static func makeBody(snapshot: LockStatusSnapshot?) -> String { - guard let snapshot else { - return "Choose Unlock Now to unlock." - } + private static func makeBody(snapshot: LockStatusSnapshot) -> String { var lines = ["Press \(snapshot.settings.unlockHotkey.displayString) or choose Unlock Now."] if let target = snapshot.autoUnlockTargetDate { lines.append("Auto-unlocks at \(target.formatted(date: .omitted, time: .shortened)).") @@ -131,9 +132,9 @@ final class LockNotificationController { } } -/// Production `LockNotificationServing` backed by `UNUserNotificationCenter`. +/// Production `LockStatusNotifying` backed by `UNUserNotificationCenter`. @MainActor -final class LiveLockNotificationService: LockNotificationServing { +final class LiveLockStatusNotificationService: LockStatusNotifying { private let center: UNUserNotificationCenter private let delegateBridge: NotificationDelegateBridge @@ -149,37 +150,33 @@ final class LiveLockNotificationService: LockNotificationServing { init(center: UNUserNotificationCenter = .current()) { self.center = center delegateBridge = NotificationDelegateBridge() - // Installed during App-delegate init — before launch completes — so an Unlock Now response - // that relaunched the App is still delivered. center.delegate = delegateBridge } func installUnlockActionCategory() { let unlockAction = UNNotificationAction( - identifier: LockNotificationController.unlockActionIdentifier, + identifier: LockStatusNotifier.unlockActionIdentifier, title: "Unlock Now" ) let category = UNNotificationCategory( - identifier: LockNotificationController.categoryIdentifier, + identifier: LockStatusNotifier.categoryIdentifier, actions: [unlockAction], intentIdentifiers: [] ) center.setNotificationCategories([category]) } - func requestAuthorization() async -> Bool { - (try? await center.requestAuthorization(options: [.alert])) ?? false - } - - func canPost() async -> Bool { + func requestAlertAuthorization() async -> Bool { let settings = await center.notificationSettings() - return switch settings.authorizationStatus { - case .authorized, .provisional: - true - case .denied, .notDetermined: - false + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .denied: + return false + case .notDetermined: + return (try? await center.requestAuthorization(options: [.alert])) ?? false @unknown default: - false + return false } } @@ -216,13 +213,4 @@ private final class NotificationDelegateBridge: NSObject, UNUserNotificationCent } completionHandler() } - - nonisolated func userNotificationCenter( - _: UNUserNotificationCenter, - willPresent _: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - // The App is a menu-bar accessory; show the banner even while it is active. - completionHandler([.banner, .list]) - } } diff --git a/Core/Tests/ServiceTests/LockStatusNotifierTests.swift b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift new file mode 100644 index 0000000..2a206fb --- /dev/null +++ b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift @@ -0,0 +1,192 @@ +import Common +import Foundation +@testable import Service +import XCTest + +@MainActor +final class LockStatusNotifierTests: XCTestCase { + private var notifications: FakeLockStatusNotifications! + private var snapshot: LockStatusSnapshot! + private var unlockCalls = 0 + + override func setUp() { + super.setUp() + notifications = FakeLockStatusNotifications() + snapshot = Self.makeSnapshot(isLocked: false) + unlockCalls = 0 + } + + func testStartInstallsCategoryAndClearsStaleNotification() async { + let notifier = makeNotifier() + + notifier.start() + await notifier.waitUntilIdle() + + XCTAssertEqual(notifications.installedCategoryCount, 1) + XCTAssertEqual(notifications.removedIdentifiers, [LockStatusNotifier.notificationIdentifier]) + } + + func testLockTransitionPostsNotificationWithHotkeyAndDeadline() async throws { + snapshot = Self.makeSnapshot( + isLocked: true, + autoUnlockTargetDate: Date(timeIntervalSinceReferenceDate: 1_060) + ) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + XCTAssertEqual(notifications.authorizationRequests, 1) + XCTAssertEqual(notifications.posts.count, 1) + let post = try XCTUnwrap(notifications.posts.first) + XCTAssertEqual(post.identifier, LockStatusNotifier.notificationIdentifier) + XCTAssertEqual(post.categoryIdentifier, LockStatusNotifier.categoryIdentifier) + XCTAssertEqual(post.title, "Keyboard Locked") + XCTAssertTrue(post.body.contains("⌃⌘L"), "body: \(post.body)") + XCTAssertTrue(post.body.contains("Unlock Now"), "body: \(post.body)") + XCTAssertTrue(post.body.contains("Auto-unlocks at"), "body: \(post.body)") + XCTAssertTrue(notifications.removedIdentifiers.isEmpty) + } + + func testLockTransitionWithoutDeadlineOmitsAutoUnlockLine() async throws { + snapshot = Self.makeSnapshot(isLocked: true, autoUnlockTargetDate: nil) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + let post = try XCTUnwrap(notifications.posts.first) + XCTAssertFalse(post.body.contains("Auto-unlocks"), "body: \(post.body)") + } + + func testUnlockTransitionRemovesNotificationWithoutRequestingAuthorization() async { + snapshot = Self.makeSnapshot(isLocked: false) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + XCTAssertEqual(notifications.removedIdentifiers, [LockStatusNotifier.notificationIdentifier]) + XCTAssertEqual(notifications.authorizationRequests, 0) + XCTAssertTrue(notifications.posts.isEmpty) + } + + func testDeniedAuthorizationSkipsPostSilently() async { + notifications.authorizationGranted = false + snapshot = Self.makeSnapshot(isLocked: true) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + XCTAssertEqual(notifications.authorizationRequests, 1) + XCTAssertTrue(notifications.posts.isEmpty) + } + + func testUnlockActionRunsLocalUnlock() async { + _ = makeNotifier() + + notifications.actionHandler?(LockStatusNotifier.unlockActionIdentifier) + await waitForCondition { self.unlockCalls == 1 } + + XCTAssertEqual(unlockCalls, 1) + } + + func testUnrelatedActionIsIgnored() async { + _ = makeNotifier() + + notifications.actionHandler?("some-other-action") + await waitForCondition { false } + + XCTAssertEqual(unlockCalls, 0) + } + + /// A rapid lock/unlock flip must serialize post before remove; the remove cannot overtake + /// the in-flight post and leave a stale notification behind. + func testQuickFlipSerializesPostThenRemove() async { + let notifier = makeNotifier() + + snapshot = Self.makeSnapshot(isLocked: true) + notifier.lockStateDidChange() + snapshot = Self.makeSnapshot(isLocked: false) + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + XCTAssertEqual(notifications.events, [ + .post, + .remove(LockStatusNotifier.notificationIdentifier), + ]) + } + + // MARK: - Helpers + + private func makeNotifier() -> LockStatusNotifier { + LockStatusNotifier( + notifications: notifications, + snapshot: { [self] in snapshot }, + unlock: { [self] in unlockCalls += 1 } + ) + } + + private static func makeSnapshot( + isLocked: Bool, + autoUnlockTargetDate: Date? = nil + ) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: 1_000), + isLocked: isLocked, + startedAt: isLocked ? Date(timeIntervalSinceReferenceDate: 1_000) : nil, + autoUnlockTargetDate: autoUnlockTargetDate, + settings: .default + ) + } + + /// The action handler hops through a `Task` onto the main actor, so tests poll with a + /// generous ceiling: the hop lands in microseconds on a healthy machine, and only a truly + /// broken hop burns the full budget before failing. + private func waitForCondition( + timeout: TimeInterval = 1, + _ condition: @MainActor () -> Bool + ) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + await Task.yield() + } + } +} + +@MainActor +private final class FakeLockStatusNotifications: LockStatusNotifying { + enum Event: Equatable { + case post + case remove(String) + } + + var actionHandler: (@Sendable (String) -> Void)? + var authorizationGranted = true + + private(set) var installedCategoryCount = 0 + private(set) var authorizationRequests = 0 + private(set) var posts: [(identifier: String, categoryIdentifier: String, title: String, body: String)] = [] + private(set) var removedIdentifiers: [String] = [] + private(set) var events: [Event] = [] + + func installUnlockActionCategory() { + installedCategoryCount += 1 + } + + func requestAlertAuthorization() async -> Bool { + authorizationRequests += 1 + return authorizationGranted + } + + func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) { + posts.append((identifier, categoryIdentifier, title, body)) + events.append(.post) + } + + func removeLocked(identifier: String) { + removedIdentifiers.append(identifier) + events.append(.remove(identifier)) + } +} diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index 679e734..06d1ca3 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -24,33 +24,33 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - F21304D42ECDC53D00735580 /* PBXContainerItemProxy */ = { + B2000000000000000000000E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = F21304202ECDAD3500735580 /* Project object */; proxyType = 1; - remoteGlobalIDString = F213049A2ECDC1AA00735580; - remoteInfo = KeyboardLockerAgent; + remoteGlobalIDString = B20000000000000000000003; + remoteInfo = KeyboardLockerWidgets; }; - F21304D62ECDC53D00735580 /* PBXContainerItemProxy */ = { + D4000000000000000000000E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = F21304202ECDAD3500735580 /* Project object */; proxyType = 1; - remoteGlobalIDString = F21304B32ECDC26400735580; - remoteInfo = klock; + remoteGlobalIDString = D40000000000000000000003; + remoteInfo = KeyboardLockerFocusIntents; }; - B2000000000000000000000E /* PBXContainerItemProxy */ = { + F21304D42ECDC53D00735580 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = F21304202ECDAD3500735580 /* Project object */; proxyType = 1; - remoteGlobalIDString = B20000000000000000000003; - remoteInfo = KeyboardLockerWidgets; + remoteGlobalIDString = F213049A2ECDC1AA00735580; + remoteInfo = KeyboardLockerAgent; }; - D4000000000000000000000E /* PBXContainerItemProxy */ = { + F21304D62ECDC53D00735580 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = F21304202ECDAD3500735580 /* Project object */; proxyType = 1; - remoteGlobalIDString = D40000000000000000000003; - remoteInfo = KeyboardLockerFocusIntents; + remoteGlobalIDString = F21304B32ECDC26400735580; + remoteInfo = klock; }; /* End PBXContainerItemProxy section */ @@ -143,19 +143,10 @@ ); target = A10000000000000000000003 /* KeyboardLockerModelTests */; }; - F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - io.lzhlovesjyq.keyboardlocker.agent.plist, - ); - target = F21304272ECDAD3500735580 /* KeyboardLocker */; - }; B20000000000000000000010 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerWidgets" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, - KeyboardLockerWidgets.entitlements, ); target = B20000000000000000000003 /* KeyboardLockerWidgets */; }; @@ -165,7 +156,6 @@ Info.plist, KeyboardLockerControl.swift, KeyboardLockerStatusWidget.swift, - KeyboardLockerWidgets.entitlements, KeyboardLockerWidgetsBundle.swift, ); target = A10000000000000000000003 /* KeyboardLockerModelTests */; @@ -181,7 +171,6 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, - KeyboardLockerFocusIntents.entitlements, ); target = D40000000000000000000003 /* KeyboardLockerFocusIntents */; }; @@ -190,10 +179,17 @@ membershipExceptions = ( AppIntentsExtension.swift, Info.plist, - KeyboardLockerFocusIntents.entitlements, ); target = A10000000000000000000003 /* KeyboardLockerModelTests */; }; + F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + io.lzhlovesjyq.keyboardlocker.agent.plist, + ); + target = F21304272ECDAD3500735580 /* KeyboardLocker */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -358,9 +354,9 @@ ); fileSystemSynchronizedGroups = ( A10000000000000000000002 /* KeyboardLockerModelTests */, - F213042A2ECDAD3500735580 /* KeyboardLocker */, - D40000000000000000000002 /* KeyboardLockerFocusIntents */, B20000000000000000000002 /* KeyboardLockerWidgets */, + D40000000000000000000002 /* KeyboardLockerFocusIntents */, + F213042A2ECDAD3500735580 /* KeyboardLocker */, F21304B52ECDC26400735580 /* klock */, ); name = KeyboardLockerModelTests; @@ -637,16 +633,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - F21304D52ECDC53D00735580 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = F213049A2ECDC1AA00735580 /* KeyboardLockerAgent */; - targetProxy = F21304D42ECDC53D00735580 /* PBXContainerItemProxy */; - }; - F21304D72ECDC53D00735580 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = F21304B32ECDC26400735580 /* klock */; - targetProxy = F21304D62ECDC53D00735580 /* PBXContainerItemProxy */; - }; B2000000000000000000000F /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B20000000000000000000003 /* KeyboardLockerWidgets */; @@ -657,6 +643,16 @@ target = D40000000000000000000003 /* KeyboardLockerFocusIntents */; targetProxy = D4000000000000000000000E /* PBXContainerItemProxy */; }; + F21304D52ECDC53D00735580 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F213049A2ECDC1AA00735580 /* KeyboardLockerAgent */; + targetProxy = F21304D42ECDC53D00735580 /* PBXContainerItemProxy */; + }; + F21304D72ECDC53D00735580 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F21304B32ECDC26400735580 /* klock */; + targetProxy = F21304D62ECDC53D00735580 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1016,6 +1012,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ""; + INFOPLIST_KEY_CFBundleDisplayName = KeyboardLocker; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -1054,6 +1051,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ""; + INFOPLIST_KEY_CFBundleDisplayName = KeyboardLocker; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index 1b90760..b675f1a 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -75,7 +75,6 @@ enum KeyboardLockerApplication { private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDelegate { private let automationController: ExternalAutomationController private let servicesProvider: KeyboardLockerServicesProvider - private let lockNotificationController: LockNotificationController private var statusMenuController: StatusMenuController? override init() { @@ -86,22 +85,12 @@ private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDe automationController.submit(action, source: .service) } } - // Constructed before launch completes so its UNUserNotificationCenter delegate catches an - // Unlock Now response that relaunched the App. - let client = LiveAgentClient() - lockNotificationController = LockNotificationController( - notifications: LiveLockNotificationService(), - lockStateObserver: LiveAgentLockStateObserver(), - snapshotQuery: { try await XPCClient.shared.lockStatusSnapshot() }, - unlock: { try await client.unlock() } - ) super.init() } func applicationDidFinishLaunching(_: Notification) { NSApp.servicesProvider = servicesProvider statusMenuController = StatusMenuController(coordinator: AppCoordinator()) - lockNotificationController.start() } func application(_: NSApplication, open urls: [URL]) { diff --git a/KeyboardLockerAgent/main.swift b/KeyboardLockerAgent/main.swift index 43a77ca..35ffed0 100644 --- a/KeyboardLockerAgent/main.swift +++ b/KeyboardLockerAgent/main.swift @@ -24,6 +24,9 @@ private func startAgent() { // One process-wide service owns the authoritative settings, replacement transaction, and // LockEngine. Every accepted connection receives only a proxy to this same instance. let sharedService = AgentService() + // Install the notification category and clear any stale locked notification before the + // listener can accept a lock request. + LockStatusNotifier.shared.start() let listener = NSXPCListener(machServiceName: SharedConstants.machServiceName) do { try listener.setConnectionCodeSigningRequirement( diff --git a/KeyboardLockerModelTests/LockNotificationControllerTests.swift b/KeyboardLockerModelTests/LockNotificationControllerTests.swift deleted file mode 100644 index 796c03f..0000000 --- a/KeyboardLockerModelTests/LockNotificationControllerTests.swift +++ /dev/null @@ -1,321 +0,0 @@ -import Client -import Foundation -import XCTest - -final class LockNotificationControllerTests: XCTestCase { - @MainActor - func testLockedSignalPostsNotificationWithHotkeyAndDeadline() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let deadline = Date(timeIntervalSince1970: 1_900_000_000) - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot(autoUnlockTargetDate: deadline) }, - unlock: {} - ) - - controller.start() - // The subscriber emits the current state once after subscription, so this single signal - // doubles as the "App launched while already locked" initial calibration. - observer.emit(true) - await controller.waitUntilIdle() - - let hotkey = KeyboardLockerSettings.default.unlockHotkey.displayString - let expectedBody = "Press \(hotkey) or choose Unlock Now. Auto-unlocks at " - + deadline.formatted(date: .omitted, time: .shortened) + "." - XCTAssertEqual(service.events, [ - .installCategory, - .requestAuthorization, - .post( - identifier: LockNotificationController.notificationIdentifier, - category: LockNotificationController.categoryIdentifier, - title: "Keyboard Locked", - body: expectedBody - ), - ]) - } - - @MainActor - func testManualLockBodyOmitsDeadline() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot(autoUnlockTargetDate: nil) }, - unlock: {} - ) - - controller.start() - observer.emit(true) - await controller.waitUntilIdle() - - let hotkey = KeyboardLockerSettings.default.unlockHotkey.displayString - XCTAssertEqual(service.postedBodies, ["Press \(hotkey) or choose Unlock Now."]) - } - - @MainActor - func testUnlockedSignalRemovesNotification() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot() }, - unlock: {} - ) - - controller.start() - observer.emit(true) - await controller.waitUntilIdle() - observer.emit(false) - await controller.waitUntilIdle() - - XCTAssertEqual( - service.events.last, - .remove(identifier: LockNotificationController.notificationIdentifier) - ) - } - - @MainActor - func testRapidLockUnlockNeverLeavesNotificationPosted() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { - try await Task.sleep(for: .milliseconds(50)) - return Self.makeSnapshot() - }, - unlock: {} - ) - - controller.start() - observer.emit(true) - observer.emit(false) - await controller.waitUntilIdle() - - XCTAssertEqual( - service.events.last, - .remove(identifier: LockNotificationController.notificationIdentifier) - ) - } - - @MainActor - func testRepeatedLockSignalsReuseTheSameIdentifier() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot() }, - unlock: {} - ) - - controller.start() - observer.emit(true) - observer.emit(true) - await controller.waitUntilIdle() - - XCTAssertEqual(service.postedIdentifiers, [ - LockNotificationController.notificationIdentifier, - LockNotificationController.notificationIdentifier, - ]) - } - - @MainActor - func testSnapshotFailureStillPostsMinimalContent() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { throw TestError.expected }, - unlock: {} - ) - - controller.start() - observer.emit(true) - await controller.waitUntilIdle() - - XCTAssertEqual(service.postedBodies, ["Choose Unlock Now to unlock."]) - } - - @MainActor - func testDeniedAuthorizationSkipsPosting() async { - let service = FakeNotificationService() - service.canPostResult = false - let observer = FakeLockStateObserver() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot() }, - unlock: {} - ) - - controller.start() - observer.emit(true) - await controller.waitUntilIdle() - - XCTAssertEqual(service.events, [.installCategory, .requestAuthorization]) - } - - @MainActor - func testUnlockNowActionPerformsIdempotentUnlock() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let unlockRecorder = UnlockRecorder() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot() }, - unlock: { unlockRecorder.record() } - ) - - controller.start() - XCTAssertNotNil(service.actionHandler) - controller.handleAction(LockNotificationController.unlockActionIdentifier) - await controller.waitUntilIdle() - - XCTAssertEqual(unlockRecorder.count, 1) - } - - @MainActor - func testUnlockFailureLeavesPresentationUntouched() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot() }, - unlock: { throw TestError.expected } - ) - - controller.start() - observer.emit(true) - await controller.waitUntilIdle() - let eventsBeforeAction = service.events - - controller.handleAction(LockNotificationController.unlockActionIdentifier) - await controller.waitUntilIdle() - - XCTAssertEqual(service.events, eventsBeforeAction) - } - - @MainActor - func testUnknownActionIsIgnored() async { - let service = FakeNotificationService() - let observer = FakeLockStateObserver() - let unlockRecorder = UnlockRecorder() - let controller = LockNotificationController( - notifications: service, - lockStateObserver: observer, - snapshotQuery: { Self.makeSnapshot() }, - unlock: { unlockRecorder.record() } - ) - - controller.start() - controller.handleAction("SOME_OTHER_ACTION") - await controller.waitUntilIdle() - - XCTAssertEqual(unlockRecorder.count, 0) - XCTAssertEqual(service.events, [.installCategory, .requestAuthorization]) - } - - private static func makeSnapshot( - autoUnlockTargetDate: Date? = Date(timeIntervalSince1970: 1_900_000_000) - ) -> LockStatusSnapshot { - LockStatusSnapshot( - capturedAt: Date(timeIntervalSince1970: 1_899_999_000), - isLocked: true, - startedAt: Date(timeIntervalSince1970: 1_899_999_000), - autoUnlockTargetDate: autoUnlockTargetDate, - settings: .default - ) - } -} - -private enum TestError: Error { - case expected -} - -@MainActor -private final class FakeNotificationService: LockNotificationServing { - enum Event: Equatable { - case installCategory - case requestAuthorization - case post(identifier: String, category: String, title: String, body: String) - case remove(identifier: String) - } - - private(set) var events: [Event] = [] - var actionHandler: (@Sendable (String) -> Void)? - var authorizationGranted = true - var canPostResult = true - - var postedIdentifiers: [String] { - events.compactMap { event in - guard case let .post(identifier, _, _, _) = event else { - return nil - } - return identifier - } - } - - var postedBodies: [String] { - events.compactMap { event in - guard case let .post(_, _, _, body) = event else { - return nil - } - return body - } - } - - func installUnlockActionCategory() { - events.append(.installCategory) - } - - func requestAuthorization() async -> Bool { - events.append(.requestAuthorization) - return authorizationGranted - } - - func canPost() async -> Bool { - canPostResult - } - - func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) { - events.append(.post(identifier: identifier, category: categoryIdentifier, title: title, body: body)) - } - - func removeLocked(identifier: String) { - events.append(.remove(identifier: identifier)) - } -} - -@MainActor -private final class FakeLockStateObserver: AgentLockStateObserving { - private var handler: ((Bool) -> Void)? - - func subscribe( - initialState _: Bool?, - _ handler: @escaping (Bool) -> Void - ) -> ObserverToken { - self.handler = handler - return ObserverToken {} - } - - func emit(_ isLocked: Bool) { - handler?(isLocked) - } -} - -private final class UnlockRecorder: @unchecked Sendable { - private(set) var count = 0 - - func record() { - count += 1 - } -} diff --git a/docs/architecture.md b/docs/architecture.md index 7a4ab38..18134d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ ## 单核原则 -**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、Notifications、扩展)都是**薄 wrapper**,职责仅限两件事: +**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、扩展)都是**薄 wrapper**,职责仅限两件事: 1. 把用户意图翻译成对 Agent 的一次 XPC 调用; 2. 通过通知观察全局状态。 @@ -33,8 +33,9 @@ Shortcut ────┤ Services/URL ┤ Focus ───────┼── XPC ──▶ Agent ◀── the ONLY executor AppleScript ─┤ ├─ LockEngine (event tap, lock lifecycle) -Notifications┤ ├─ Settings ownership (source of truth) -Widget ──────┘ └─ Accessibility (permission gate) +Widget ──────┘ ├─ Settings ownership (source of truth) + ├─ Accessibility (permission gate) + └─ Lock notification (discoverability surface) ``` 这里的 `Core`、SwiftPM product、运行进程和 XPC connection 是不同层次。完整的构建依赖图、进程图和调用时序见 [XPC 实现与使用指南](xpc.md)。 @@ -48,6 +49,7 @@ Widget ──────┘ └─ Accessibility (permission | 锁状态快照 | **Agent**(`Service/LockEngine`) | `XPCClient.lockStatusSnapshot()` 一次返回同一 execution turn 中的 `isLocked`、锁定起点、auto-unlock deadline 与 active settings。wrapper 可以缓存它用于呈现,但不能从缓存反推或修改 Agent 状态。 | | Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。wrapper 只能经 XPC 查询状态或请求 Agent 触发系统 prompt,不得自行调用 Accessibility API。权限 prompt 是异步的;请求完成不代表已授权,wrapper 必须重新查询。 | | 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | +| 锁定可发现性通知 | **Agent**(`LockStatusNotifier`) | "Keyboard Locked" 通知的投递与移除跟随引擎的同一次状态转换,任何入口、App 是否运行都覆盖;wrapper 不发布锁状态通知。Agent 启动时清除上一代退出留下的残留。 | | UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,并协调只存在于自身进程的系统边界(例如 App 的 `SMAppService` 生命周期);不得复制 Agent 的锁、设置或 Accessibility 领域逻辑。 | App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management、XPC 两个系统边界之间的应用层编排,不是第二份锁核心。它必须保持为无锁状态真相源的薄协调器:`AppCoordinator` 只消费 Agent/系统查询并暴露应用状态与动作,不依赖 presentation framework;后续 UI 只能映射这些状态与动作。任何 CGEventTap、设置持久化和 Accessibility 判定仍只在 Agent 内执行。 diff --git a/docs/automation.md b/docs/automation.md index 52316c4..09387b7 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,6 +1,6 @@ # 自动化 -KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget、Control 与 Notifications 都是同一个 Agent capability 的薄 wrapper。首次使用任一入口前,先启动一次 KeyboardLocker App 或运行 `klock register-agent`,让 App 注册后台 Agent。 +KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper;Notifications 由 Agent 进程自己发布(见下文)。首次使用任一入口前,先启动一次 KeyboardLocker App 或运行 `klock register-agent`,让 App 注册后台 Agent。 所有入口共享以下语义: @@ -82,11 +82,11 @@ Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` W ## Notifications -主 App 运行期间,任一入口使键盘进入 locked 时,App 会发布一条 `Keyboard Locked` 通知:内容来自该时刻向 Agent 重新查询的 `LockStatusSnapshot`,展示当前解锁热键,并在 timed auto-unlock policy 下附带截止时间。通知使用固定 identifier,重复锁定只替换内容,不会堆叠;任一入口解开同一个全局锁后,通知被移除。 +任一入口使键盘进入 locked 时,**Agent 进程自己**发布一条 `Keyboard Locked` 通知:内容直接来自 Agent 持有的 active settings 与 auto-unlock deadline,展示当前解锁热键,并在 timed auto-unlock policy 下附带截止时间。通知使用固定 identifier,重复锁定只替换内容,不会堆叠;任一入口解开同一个全局锁——显式 unlock、解锁热键、auto-unlock timeout 或 event-tap fail-open——通知都随同一次状态转换移除。因为投递/移除与锁状态转换发生在同一进程,通知不会比锁活得更久,与哪个 wrapper 在运行无关;若 Agent 在 locked 时退出(event tap 随进程释放),残留通知由下一次 Agent 启动时清除。 -通知携带 `Unlock Now` 操作按钮。键盘被锁时鼠标与触控板仍然可用,点击按钮经 XPC 执行幂等 `unlock`;它不依据通知内容做 client-side 状态判断,调用失败时锁与通知都保持原样。点击按钮时 App 未在运行,系统会先拉起 App 再交付该操作。 +通知携带 `Unlock Now` 操作按钮。键盘被锁时鼠标与触控板仍然可用,点击按钮由 Agent 本地执行幂等 `unlock`,不需要拉起任何 App。 -通知是 presentation-only 的便利面:首次发布前向系统请求通知权限,被拒绝或未授予时不发送、不报错;通知内容永远不是状态源。主 App 未运行时,锁状态变化不会产生通知。 +通知是 presentation-only 的便利面:首次锁定时向系统请求通知权限,被拒绝或未授予时不发送、不报错;通知内容永远不是状态源。通知以 Agent 的 bundle 身份发布,在系统设置中显示为 KeyboardLocker(display name 与主 App 对齐)。 ## AppleScript diff --git a/docs/development.md b/docs/development.md index 8ed159d..bef72a4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,6 +39,7 @@ - `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。event policy 消费标准按键及 system-defined keyboard control,同时明确放行鼠标 / 触控板 pointer event。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。引擎的全部外部副作用(Accessibility 查询、tap 安装、定时器排程、状态广播、时钟)经 `LockEngineDependencies` / `InstalledEventTap` 注入缝进入,`LockEngineTests` 因此能确定性驱动这些路径。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 +- `LockStatusNotifier.swift`:Agent 进程内的 "Keyboard Locked" 系统通知 owner。挂在 engine 的 `broadcastStateChange` 副作用缝上(live 实现里先广播再驱动 notifier),状态转换完成后读取进程内 `statusSnapshot` 拼装正文(解锁热键 + auto-unlock 截止时间),经 `UNUserNotificationCenter` 投递/移除固定 identifier 的通知;`Unlock Now` action 由 Agent 本地执行幂等 unlock。首次锁定时请求通知权限,拒绝则静默降级。`start()` 在 bootstrap 时安装 action category 并清除上一代 Agent 退出留下的残留通知。通知生命周期与锁生命周期由此绑定在同一进程,不会比锁活得更久。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 - `MainActorTimerScheduler.swift`:单次 MainActor 定时器抽象(返回取消闭包)及其 live 实现;`LockEngine` 的 auto-unlock 与 `AgentService` 的 replacement 过期共用,测试中替换为手动调度。 - `AgentService.swift`:nonisolated XPC wire adapter,持有设置快照与 replacement transaction,并把所有可变状态与引擎操作切到 `MainActor`。引擎、descriptor 与 prepare 过期调度都经注入缝进入;replacement barrier、instance-ID fence、locked refusal、错误码映射与过期/取消语义由 `ServiceTests` 确定性覆盖。 From 240a0e90f7aebab6a8eff75014a41cf028918db8 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 10:44:13 +0800 Subject: [PATCH 083/106] feat(app): confirm before quitting while the keyboard is locked Quitting removes the only in-App lock indicator while the Agent-owned lock stays active. The alert names the remaining exits: the configured unlock hotkey, the notification's Unlock Now button, and klock unlock. --- KeyboardLocker/StatusMenuController.swift | 34 ++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index 84872a3..488e451 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -444,6 +444,38 @@ final class StatusMenuController: NSObject, NSMenuDelegate { @objc private func quit() { - NSApp.terminate(nil) + guard snapshotShowsLockedKeyboard else { + NSApp.terminate(nil) + return + } + + // Quitting removes the only in-App lock indicator while the Agent-owned lock stays active. + // Make sure the user leaves knowing how to get out of the lock. + let confirmation = NSAlert() + confirmation.alertStyle = .warning + confirmation.messageText = "Quit KeyboardLocker while the keyboard is locked?" + confirmation.informativeText = """ + The lock stays active without the menu bar indicator. You can still unlock with the \ + configured unlock hotkey, the notification's Unlock Now button, or `klock unlock`. + """ + confirmation.addButton(withTitle: "Quit") + confirmation.addButton(withTitle: "Cancel") + + NSApp.activateForUserPresentation() + if confirmation.runModal() == .alertFirstButtonReturn { + NSApp.terminate(nil) + } + } + + private var snapshotShowsLockedKeyboard: Bool { + switch currentSnapshot.state { + case let .ready(isLocked), + let .accessibilityRequired(isLocked): + isLocked + case let .agentUpdateRequired(isLocked, _): + isLocked ?? false + case .checking, .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + false + } } } From 03c79745e7bdca8ccd9827f984d6cbf39ee866cf Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 10:44:40 +0800 Subject: [PATCH 084/106] feat(klock): release a created lock on termination signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interactive klock lock that acquired the global lock now observes SIGTERM/SIGHUP/SIGINT via GCD signal sources and makes a bounded, best-effort attempt to release the lock it created before exiting with the conventional 128+signal code — closing a terminal or killing the process no longer strands the keyboard. SIGKILL/SIGSTOP remain uncovered by design; the hotkey, notification action, and auto-unlock cover them. --no-wait and already-locked invocations never touch an existing lock. --- .../KlockCommandLineTests.swift | 125 +++++++++++++++++- docs/architecture.md | 2 +- docs/automation.md | 4 +- docs/development.md | 4 +- klock/KlockCLI.swift | 48 ++++++- klock/KlockTerminationGuard.swift | 34 +++++ 6 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 klock/KlockTerminationGuard.swift diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index 4d2a30d..9ef0021 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -330,6 +330,107 @@ final class KlockCommandLineTests: XCTestCase { ) } + // MARK: - Termination Guard + + func testInteractiveLockInstallsAndCancelsTerminationGuardWhileWaiting() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) + } + + func testInteractiveLockAlreadyLockedSkipsTerminationGuard() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .success(.alreadyLocked) + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(terminationGuard.installCalls, 0) + } + + func testInteractiveLockFailureSkipsTerminationGuard() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .failure(Self.makeError("lock failed")) + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(terminationGuard.installCalls, 0) + } + + func testInteractiveLockWaitFailureCancelsTerminationGuard() async { + let client = FakeKlockClient() + client.waitUntilUnlockedResult = .failure(Self.makeError("wait failed")) + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) + } + + func testNonInteractiveLockSkipsTerminationGuard() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock", "--no-wait"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(terminationGuard.installCalls, 0) + } + + func testUnlockBeforeTerminationReleasesCreatedLock() async { + let client = FakeKlockClient() + var stderr: [String] = [] + + await KlockCLI.unlockBeforeTermination(client: client, printError: { stderr.append($0) }) + + XCTAssertEqual(client.unlockCalls, 1) + XCTAssertEqual(stderr, ["Terminated; released the keyboard lock created by this command."]) + } + + func testUnlockBeforeTerminationReportsFailureWithRecoveryHint() async { + let client = FakeKlockClient() + client.unlockResult = .failure(Self.makeError("agent gone")) + var stderr: [String] = [] + + await KlockCLI.unlockBeforeTermination(client: client, printError: { stderr.append($0) }) + + XCTAssertEqual(client.unlockCalls, 1) + XCTAssertEqual(stderr.count, 1) + XCTAssertTrue(stderr[0].contains("could not release"), "stderr: \(stderr)") + XCTAssertTrue(stderr[0].contains("agent gone"), "stderr: \(stderr)") + XCTAssertTrue(stderr[0].contains("Unlock Now"), "stderr: \(stderr)") + } + // MARK: - Helpers private static func makeError(_ message: String) -> NSError { @@ -344,7 +445,8 @@ final class KlockCommandLineTests: XCTestCase { arguments: [String], client: FakeKlockClient, openKeyboardLockerApp: @escaping () throws -> Void = {}, - agentPoll: (attempts: Int, interval: Duration) = (attempts: 1, interval: .zero) + agentPoll: (attempts: Int, interval: Duration) = (attempts: 1, interval: .zero), + terminationGuard: any KlockTerminationGuarding = NullKlockTerminationGuard() ) async -> (exitCode: Int32, stdout: [String], stderr: [String]) { var stdout: [String] = [] var stderr: [String] = [] @@ -353,6 +455,7 @@ final class KlockCommandLineTests: XCTestCase { client: client, openKeyboardLockerApp: openKeyboardLockerApp, agentPoll: agentPoll, + terminationGuard: terminationGuard, printOut: { stdout.append($0) }, printError: { stderr.append($0) } ) @@ -418,3 +521,23 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { try waitUntilUnlockedResult.get() } } + +private final class FakeKlockTerminationGuard: KlockTerminationGuarding, @unchecked Sendable { + private(set) var installCalls = 0 + private(set) var cancelCalls = 0 + + func install(onTermination _: @escaping (Int32) -> Void) -> () -> Void { + installCalls += 1 + return { [self] in + cancelCalls += 1 + } + } +} + +/// Default guard for tests that do not exercise the termination path: never touches process +/// signal state, so the XCTest process keeps its default signal dispositions. +private struct NullKlockTerminationGuard: KlockTerminationGuarding { + func install(onTermination _: @escaping (Int32) -> Void) -> () -> Void { + {} + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 18134d3..0fa7779 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `toggle` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `toggle` 是同一全局锁的原子翻转:在 Agent 的串行执行边界内于同一 execution turn 完成"读当前状态 + 反向 mutation",并向调用方返回翻转后的布尔值。锁定方向使用普通非交互 `lock` 语义(含 Focus persistence takeover),解锁方向等价于显式 `unlock`;wrapper 不得先读 `status()` 再自行决定方向来模拟 toggle。 - `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 -- interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。 +- interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。发起并等待中的 CLI 进程对本轮锁负有退出清理责任:它观察 SIGTERM/SIGHUP/SIGINT,退出前尽力释放自己创建的这轮锁(有界等待后无论如何退出);SIGKILL/SIGSTOP 不可观察,遗留锁仍由解锁热键、通知 Unlock Now、auto-unlock 等全局途径解开。这是输入手势语义的进程侧延伸,不构成 session 或所有权。 - Focus Filter 是 **activation-triggered acquisition**,不是“Focus active 期间持续保持 locked”的 policy。Focus 激活时的 `true` 最多尝试一次 `unlocked → locked`,并只在成功创建锁时记录该 Focus-owned generation;Focus 关闭时的 `false` 只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领。显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束该 generation;实现不会查询当前 Focus 后自动重建锁,也不会在同一次 Focus activation 内持续 relock。这个进程内 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 - `status()` 保留为兼容旧 wrapper 的最小布尔查询;需要呈现锁定时长、auto-unlock deadline 或 active settings 的 wrapper 使用 capability-gated `lockStatusSnapshot()`。snapshot 传输权威时间点,不传会立即过期的 duration/countdown counter。 - 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 diff --git a/docs/automation.md b/docs/automation.md index 09387b7..49bb47f 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -119,7 +119,9 @@ end tell klock lock ``` -自动化应使用 one-shot 模式。它在 Agent 确认 locked 后立即退出,不会启用 `Ctrl+C`,也不会等待: +等待期间进程被终止(关闭终端窗口的 `SIGHUP`、`kill` 的 `SIGTERM`)时,`klock` 会在退出前尽力释放它创建的这轮锁(有界等待,随后无论如何退出)。`kill -9` 无法被捕获,此时遗留的锁仍由解锁热键、通知的 Unlock Now 或 auto-unlock 解开。该清理只适用于真正完成 `unlocked → locked` 转换的调用;`Already locked` 退出的命令不触碰既有锁。 + +自动化应使用 one-shot 模式。它在 Agent 确认 locked 后立即退出,不会启用 `Ctrl+C`,也不参与终止清理: ```bash klock lock --no-wait diff --git a/docs/development.md b/docs/development.md index bef72a4..93d73fb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -110,7 +110,7 @@ `klock` 自身不注册后台 Agent——`SMAppService` 注册只能由 App bundle 执行。首次使用前运行 `klock register-agent` 或至少启动一次 KeyboardLocker App;Agent 未注册时,CLI 各命令也会给出对应恢复提示。 -`klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 +`klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。等待期间进程收到 SIGTERM/SIGHUP/SIGINT 时,会在退出前尽力释放它创建的锁(GCD 信号源 + 有界等待;`KlockTerminationGuard` 为可注入缝),退出码保持 `128 + signal` 惯例;SIGKILL/SIGSTOP 不可捕获,遗留锁由全局解锁途径兜底。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 @@ -145,7 +145,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: - `LockEngine` 的纯策略(event policy、schedule 计算、runtime state、tap 安装事务)与引擎语义(Accessibility 预检、tap 安装/拆除、auto-unlock 排程/触发/陈旧代际拒绝、热键与 Control-C 分发、tap-disable re-enable/fail-open、Focus 代际所有权)均已单测 —— 后者由 `LockEngineTests` 经注入缝(`InstalledEventTap`、`MainActorTimerScheduler`、权限/广播/时钟闭包)确定性驱动;剩余不可单测的只有 Quartz 对真实硬件事件的拦截本身(平台集成事实,靠真机验证)。 -- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵与命令执行路径(未知命令、多余参数、already-locked 退出、stdout/stderr 路由与退出码、`register-agent` 的探测/启动/轮询路径)已由 `KlockCommandLineTests` 覆盖;`version` 的执行路径依赖 `Bundle.main` 未测(其解析已覆盖),`KlockAppOpener` 的 bundle 定位与 `/usr/bin/open` 调用依赖真实进程环境,经手动验证。 +- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵与命令执行路径(未知命令、多余参数、already-locked 退出、stdout/stderr 路由与退出码、`register-agent` 的探测/启动/轮询路径、termination guard 的安装/取消矩阵与信号后解锁逻辑)已由 `KlockCommandLineTests` 覆盖;`version` 的执行路径依赖 `Bundle.main` 未测(其解析已覆盖),`KlockAppOpener` 的 bundle 定位与 `/usr/bin/open` 调用、live termination guard 的真实信号投递依赖真实进程环境,经手动验证(kill/kill -HUP 实测解锁与退出码)。 - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index c07cd6c..a81e90b 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -35,8 +35,9 @@ enum KlockCLI { client: any KlockClientServing, openKeyboardLockerApp: @escaping () throws -> Void = KlockAppOpener.openContainingApp, agentPoll: (attempts: Int, interval: Duration) = (attempts: 5, interval: .seconds(1)), + terminationGuard: any KlockTerminationGuarding = LiveKlockTerminationGuard(), printOut: (String) -> Void, - printError: (String) -> Void + printError: @escaping (String) -> Void ) async -> Int32 { let command: KlockCommand do { @@ -71,7 +72,12 @@ enum KlockCLI { case let .lock(wait): if wait { - return await executeInteractiveLock(client: client, printOut: printOut, printError: printError) + return await executeInteractiveLock( + client: client, + terminationGuard: terminationGuard, + printOut: printOut, + printError: printError + ) } return await executeNonInteractiveLock(client: client, printOut: printOut, printError: printError) @@ -96,8 +102,9 @@ enum KlockCLI { private static func executeInteractiveLock( client: any KlockClientServing, + terminationGuard: any KlockTerminationGuarding, printOut: (String) -> Void, - printError: (String) -> Void + printError: @escaping (String) -> Void ) async -> Int32 { let unlockHotkey: Result do { @@ -121,6 +128,23 @@ enum KlockCLI { return ExitCode.success } + // The wait below keeps this process responsible for the lock it just created: a covered + // termination signal (terminal closed, kill) must release it rather than leave the keyboard + // locked with no visible owner. Ctrl+C never reaches the process — the Agent consumes it + // as an unlock gesture — so SIGINT is only a fallback for a tap that failed to engage. + let cancelTerminationGuard = terminationGuard.install { signal in + let finished = DispatchSemaphore(value: 0) + Task { + await Self.unlockBeforeTermination(client: client, printError: printError) + finished.signal() + } + _ = finished.wait(timeout: .now() + .seconds(2)) + exit(128 + signal) + } + defer { + cancelTerminationGuard() + } + switch unlockHotkey { case let .success(hotkey): printOut("Locked. Press \(hotkey) or Ctrl+C to unlock.") @@ -143,6 +167,24 @@ enum KlockCLI { } } + /// Best-effort release of the lock this command created, bounded by the caller so the + /// process still exits when the Agent is unreachable. Internal (not private) so tests can + /// drive it directly — production reaches it only via the signal handler. + static func unlockBeforeTermination( + client: any KlockClientServing, + printError: (String) -> Void + ) async { + do { + try await client.unlock() + printError("Terminated; released the keyboard lock created by this command.") + } catch { + printError( + "Terminated; could not release the keyboard lock (\(error.localizedDescription)). " + + "Unlock with the configured hotkey or the notification's Unlock Now button." + ) + } + } + private static func executeNonInteractiveLock( client: any KlockClientServing, printOut: (String) -> Void, diff --git a/klock/KlockTerminationGuard.swift b/klock/KlockTerminationGuard.swift new file mode 100644 index 0000000..4e46517 --- /dev/null +++ b/klock/KlockTerminationGuard.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Signal-observer seam for the interactive-lock wait path, injectable so tests can assert +/// installation and cancellation without touching process signal state. +protocol KlockTerminationGuarding { + /// Observes the covered termination signals and returns a cancellation closure for the + /// normal completion path. `onTermination` receives the caught signal number. + func install(onTermination: @escaping (Int32) -> Void) -> () -> Void +} + +/// Watches SIGTERM/SIGHUP/SIGINT via GCD signal sources, whose handlers run on a dispatch +/// queue and may therefore perform ordinary work (unlike raw `signal()` handlers). +/// +/// SIGKILL/SIGSTOP cannot be observed by design; a lock orphaned that way is still reachable +/// through the Agent-side affordances (unlock hotkey, notification action, auto-unlock). +struct LiveKlockTerminationGuard: KlockTerminationGuarding { + private static let coveredSignals: [Int32] = [SIGTERM, SIGHUP, SIGINT] + + func install(onTermination: @escaping (Int32) -> Void) -> () -> Void { + let sources = Self.coveredSignals.map { signalNumber -> DispatchSourceSignal in + // GCD signal delivery requires the default action to be ignored first. + signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .global()) + source.setEventHandler { + onTermination(signalNumber) + } + source.resume() + return source + } + return { + sources.forEach { $0.cancel() } + } + } +} From df2a79e2a445be1e575db3189a819ffe83a15515 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 13:14:45 +0800 Subject: [PATCH 085/106] fix: align cross-surface UX consistency - Treat the last-known-locked checking state as locked in the quit warning, matching the toggle action's evidence standard - Name the KeyboardLocker agent consistently across errors, menus, and accessibility recovery guidance, and add an Open Accessibility Settings menu entry beside the grant action - Describe the Focus filter's conditional release on deactivation in the intent description - Point CLI service-unavailable failures at klock register-agent and give timedOut a recovery suggestion - Keep locked-with-condition states on the lock icon and rename the steady replacement state to Agent Update in Progress - Fail key-code conversion to nil for unmappable codes so hotkey copy falls back to a verbal phrase instead of a raw "?" - Match the Control gallery preview to the widget's locked sample, and document termination-cleanup generation blindness plus degraded-state limits for Control and silent surfaces --- Core/Sources/Client/XPCClient.swift | 3 ++ Core/Sources/Common/KeyCodeConverter.swift | 12 ++++-- .../Common/KeyboardLockerSettings.swift | 6 ++- Core/Sources/Service/LockEngine.swift | 6 +-- .../ClientTests/XPCClientErrorTests.swift | 7 ++++ .../CommonTests/HotkeyDisplayTests.swift | 21 ++++++++++ .../AgentReadinessCoordinator.swift | 10 ++--- KeyboardLocker/AgentRegistrar.swift | 6 +-- KeyboardLocker/AppCoordinator.swift | 21 +++++----- KeyboardLocker/StatusMenuController.swift | 39 ++++++++++++++----- .../KeyboardLockFocusFilterIntent.swift | 2 +- .../AgentReadinessCoordinatorTests.swift | 2 +- .../KlockCommandLineTests.swift | 6 ++- .../KeyboardLockerControl.swift | 4 +- docs/architecture.md | 2 +- docs/automation.md | 6 ++- klock/KlockCLI.swift | 17 ++++++-- 17 files changed, 122 insertions(+), 48 deletions(-) create mode 100644 Core/Tests/CommonTests/HotkeyDisplayTests.swift diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index ccd6f6e..41847b2 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -46,6 +46,9 @@ public enum XPCClientError: Error, LocalizedError { case .serviceUnavailable: "Open KeyboardLocker once to register its background agent, then retry. If KeyboardLocker is already running, choose Show Details… from its menu." + case .timedOut: + "Retry the action. If the problem persists, open KeyboardLocker and choose Show Details… from its menu." + default: nil } diff --git a/Core/Sources/Common/KeyCodeConverter.swift b/Core/Sources/Common/KeyCodeConverter.swift index 844765f..ab2f83d 100644 --- a/Core/Sources/Common/KeyCodeConverter.swift +++ b/Core/Sources/Common/KeyCodeConverter.swift @@ -12,7 +12,11 @@ public enum KeyCodeConverter { /// - Returns: Complete shortcut string (e.g., "⌥⌘L" or "⌥ ⌘ L") or nil if conversion fails public static func stringFromKeyCode(_ keyCode: CGKeyCode, modifiers: CGEventFlags, separator: String = "") -> String? { let modifierString = modifierSymbols(from: modifiers, separator: separator) - let keyChar = keyCharacter(for: keyCode) + // An unmappable key code must fail the whole conversion instead of surfacing a raw + // "?" glyph, so callers can fall back to verbal copy. + guard let keyChar = keyCharacter(for: keyCode) else { + return nil + } let result: String = if !modifierString.isEmpty, !separator.isEmpty { modifierString + separator + keyChar @@ -51,9 +55,9 @@ public enum KeyCodeConverter { /// Get character representation for a key code /// - Parameter keyCode: CGKeyCode value - /// - Returns: Uppercase character or symbol - private static func keyCharacter(for keyCode: CGKeyCode) -> String { - characterFromKeyboardLayout(keyCode)?.uppercased() ?? "?" + /// - Returns: Uppercase character or symbol, nil when the keyboard layout cannot map it + private static func keyCharacter(for keyCode: CGKeyCode) -> String? { + characterFromKeyboardLayout(keyCode)?.uppercased() } /// Get character from the system keyboard layout using UCKeyTranslate. diff --git a/Core/Sources/Common/KeyboardLockerSettings.swift b/Core/Sources/Common/KeyboardLockerSettings.swift index f50dc2f..f48b19b 100644 --- a/Core/Sources/Common/KeyboardLockerSettings.swift +++ b/Core/Sources/Common/KeyboardLockerSettings.swift @@ -106,9 +106,11 @@ extension KeyboardLockerSettings.Hotkey: Codable { // MARK: - Hotkey Display public extension KeyboardLockerSettings.Hotkey { - /// Human-readable representation of the hotkey (e.g., "⌃⌘L") + /// Human-readable representation of the hotkey (e.g., "⌃⌘L"). Falls back to a verbal + /// phrase — grammatical inside "Press … to unlock" sentences — when the key code has no + /// known glyph, so a raw "?" never reaches notification, widget, or CLI copy. var displayString: String { - KeyCodeConverter.stringFromKeyCode(keyCode, modifiers: modifierFlags) ?? "?" + KeyCodeConverter.stringFromKeyCode(keyCode, modifiers: modifierFlags) ?? "the configured unlock hotkey" } } diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index dd5272c..192ab1d 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -429,11 +429,11 @@ public final class LockEngine { public var recoverySuggestion: String? { switch self { case .accessibilityPermissionDenied: - "Open System Settings → Privacy & Security → Accessibility and enable access for this application." + "Open System Settings → Privacy & Security → Accessibility and enable access for the KeyboardLocker agent." case .eventTapCreationFailed: - "Try restarting the application. If the problem persists, check Accessibility permissions in System Settings." + "Try restarting KeyboardLocker. If the problem persists, check Accessibility permissions for the KeyboardLocker agent in System Settings → Privacy & Security → Accessibility." case .eventTapEnableFailed: - "Check Accessibility permissions in System Settings, then try locking again." + "Check Accessibility permissions for the KeyboardLocker agent in System Settings → Privacy & Security → Accessibility, then try locking again." case .runLoopSourceCreationFailed: "This is a system-level error. Please contact support if it persists." } diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index d06c483..6dd8f95 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -22,6 +22,13 @@ final class XPCClientErrorTests: XCTestCase { XCTAssertTrue(suggestion.contains("Show Details")) } + func testTimeoutExplainsRetryRecovery() throws { + let suggestion = try XCTUnwrap(XPCClientError.timedOut.recoverySuggestion) + + XCTAssertTrue(suggestion.contains("Retry")) + XCTAssertTrue(suggestion.contains("Show Details")) + } + func testMissingCapabilityExplainsAgentUpdateRecovery() throws { let suggestion = try XCTUnwrap( XPCClientError.missingCapability(.interactiveLock).recoverySuggestion diff --git a/Core/Tests/CommonTests/HotkeyDisplayTests.swift b/Core/Tests/CommonTests/HotkeyDisplayTests.swift new file mode 100644 index 0000000..f31c85b --- /dev/null +++ b/Core/Tests/CommonTests/HotkeyDisplayTests.swift @@ -0,0 +1,21 @@ +import Common +import CoreGraphics +import XCTest + +final class HotkeyDisplayTests: XCTestCase { + func testDisplayStringRendersGlyphsForMappedKeyCode() { + let hotkey = KeyboardLockerSettings.Hotkey(keyCode: 37, modifierFlags: [.maskCommand, .maskControl]) + + XCTAssertFalse(hotkey.displayString.isEmpty) + XCTAssertFalse(hotkey.displayString.contains("?")) + XCTAssertNotEqual(hotkey.displayString, "the configured unlock hotkey") + } + + func testDisplayStringFallsBackToVerbalPhraseForUnmappedKeyCode() { + // 255 is outside the virtual key-code range of any keyboard layout, so UCKeyTranslate + // cannot map it and the verbal fallback must replace the old raw "?" glyph. + let hotkey = KeyboardLockerSettings.Hotkey(keyCode: 255, modifierFlags: [.maskCommand]) + + XCTAssertEqual(hotkey.displayString, "the configured unlock hotkey") + } +} diff --git a/KeyboardLocker/AgentReadinessCoordinator.swift b/KeyboardLocker/AgentReadinessCoordinator.swift index 950b1bd..6512155 100644 --- a/KeyboardLocker/AgentReadinessCoordinator.swift +++ b/KeyboardLocker/AgentReadinessCoordinator.swift @@ -165,10 +165,10 @@ struct AgentReadinessCoordinator { return .updateRequired(AgentUpdatePlan( mode: .forced(descriptor: nil, isLocked: isLocked), message: """ - The running background agent's XPC contract could not be verified after reconnecting. \ - It may use an earlier contract, or its descriptor handshake may be failing. Replacing \ - this unverified Agent cannot block another client from locking during the transition, \ - so a new lock created in that brief window may be released. + The running KeyboardLocker agent's XPC contract could not be verified after \ + reconnecting. It may use an earlier contract, or its descriptor handshake may be \ + failing. Replacing this unverified agent cannot block another client from locking \ + during the transition, so a new lock created in that brief window may be released. """ )) } catch { @@ -208,7 +208,7 @@ private enum AgentReadinessError: Error, LocalizedError { var errorDescription: String? { switch self { case .safeReplacementRequiresReadableLockState: - "Safe Agent replacement requires a readable authoritative lock state." + "Safe KeyboardLocker agent replacement requires a readable authoritative lock state." } } } diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index 62a45c1..2bb377c 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -149,7 +149,7 @@ enum AgentRegistrar { case .orderedAscending where supportsSafeReplacement: return .bundledAgentUpgradeAvailable( message: """ - The running background agent is older than the version bundled with this app. \(details) + The running KeyboardLocker agent is older than the version bundled with this app. \(details) """, bundledBuild: requirements.agentBuild ) @@ -157,7 +157,7 @@ enum AgentRegistrar { case .orderedDescending: return .updateRequired( message: """ - A newer background agent is running than the version bundled with this app. \ + A newer KeyboardLocker agent is running than the version bundled with this app. \ Replacing it will use the older bundled version. \(details) """, canReadLockState: canReadLockState, @@ -167,7 +167,7 @@ enum AgentRegistrar { case nil, .orderedAscending, .orderedSame: return .updateRequired( message: """ - The running background agent does not match the version bundled with this app. \(details) + The running KeyboardLocker agent does not match the version bundled with this app. \(details) """, canReadLockState: canReadLockState, supportsSafeReplacement: supportsSafeReplacement diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift index 53f8df0..4d1a65b 100644 --- a/KeyboardLocker/AppCoordinator.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -424,22 +424,23 @@ final class AppCoordinator { let message = switch descriptor.replacementPhase { case .committed: """ - An Agent replacement has been committed. New lock requests remain blocked while its \ - coordinator finishes and the old Agent exits. If this state persists, restart macOS; \ - another app instance cannot safely take over an unregister that may still be in flight. + A KeyboardLocker agent replacement has been committed. New lock requests remain \ + blocked while its coordinator finishes and the old agent exits. If this state \ + persists, restart macOS; another app instance cannot safely take over an unregister \ + that may still be in flight. """ case .prepared: """ - An Agent replacement is being prepared. New lock requests remain blocked until its \ - coordinator commits or the short preparation expires. + A KeyboardLocker agent replacement is being prepared. New lock requests remain \ + blocked until its coordinator commits or the short preparation expires. """ default: """ - The Agent reports a replacement state this app does not recognize. New lock requests \ - remain blocked to protect the current lock state. Update the app, or restart macOS if \ - this state persists. + The KeyboardLocker agent reports a replacement state this app does not recognize. \ + New lock requests remain blocked to protect the current lock state. Update the app, \ + or restart macOS if this state persists. """ } @@ -507,7 +508,7 @@ final class AppCoordinator { let contextMessage = context.map { " \($0)" } ?? "" state = .unavailable( message: """ - The background agent is enabled but could not be reached. \ + The KeyboardLocker agent is enabled but could not be reached. \ \(error.localizedDescription)\(contextMessage) """, canRestartAgent: true @@ -624,7 +625,7 @@ private enum AgentUpdateError: Error, LocalizedError { var errorDescription: String? { switch self { case .agentDidNotRestart: - "The background agent did not restart into a new process." + "The KeyboardLocker agent did not restart into a new process." } } } diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index 488e451..0738144 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -103,7 +103,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { if isLocked == true { addAction(title: "Unlock Keyboard", action: #selector(toggleLock)) } - addAction(title: "Update Background Agent…", action: #selector(updateAgent)) + addAction(title: "Update KeyboardLocker Agent…", action: #selector(updateAgent)) case let .accessibilityRequired(isLocked): if isLocked { @@ -113,6 +113,10 @@ final class StatusMenuController: NSObject, NSMenuDelegate { title: "Grant Accessibility Access…", action: #selector(requestAccessibilityPermission) ) + addAction( + title: "Open Accessibility Settings…", + action: #selector(openAccessibilitySettings) + ) case let .ready(isLocked): addAction( @@ -122,7 +126,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { case let .unavailable(_, canRestartAgent): if canRestartAgent { - addAction(title: "Restart Background Agent…", action: #selector(restartAgent)) + addAction(title: "Restart KeyboardLocker Agent…", action: #selector(restartAgent)) } } } @@ -173,18 +177,20 @@ final class StatusMenuController: NSObject, NSMenuDelegate { case .agentReplacementInProgress: return ( - "KeyboardLocker — Updating Agent", + "KeyboardLocker — Agent Update in Progress", "arrow.triangle.2.circlepath", "Background agent update in progress" ) case let .agentUpdateRequired(isLocked, _): + // Locked variants keep the lock icon: the warning triangle is reserved for states + // where attention is needed and the keyboard is not known to be locked. return ( isLocked == true ? "KeyboardLocker — Locked (Update Required)" : "KeyboardLocker — Agent Update Required", - "exclamationmark.triangle.fill", - "Background agent update required" + isLocked == true ? "lock.fill" : "exclamationmark.triangle.fill", + isLocked == true ? "Keyboard locked, agent update required" : "Background agent update required" ) case let .accessibilityRequired(isLocked): @@ -229,7 +235,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { case .accessibilityRequired: messages.append( - "The background Agent needs Accessibility access before it can filter keyboard events." + "The KeyboardLocker agent needs Accessibility access before it can filter keyboard events. Grant access from the KeyboardLocker menu, or enable KeyboardLocker in System Settings → Privacy & Security → Accessibility." ) } @@ -255,6 +261,17 @@ final class StatusMenuController: NSObject, NSMenuDelegate { SMAppService.openSystemSettingsLoginItems() } + @objc + private func openAccessibilitySettings() { + // Only Login Items has a public System Settings entry point; once the one-shot + // Accessibility prompt has been dismissed, the privacy-pane deep link is the + // established path to land the user on the right pane. + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { + return + } + NSWorkspace.shared.open(url) + } + @objc private func updateAgent() { guard case let .agentUpdateRequired(isLocked, message) = currentSnapshot.state else { @@ -265,7 +282,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { confirmation.alertStyle = .warning confirmation.messageText = "Update the KeyboardLocker Agent?" confirmation.informativeText = if isLocked == true { - "The keyboard will be unlocked before the Agent is replaced.\n\n\(message)" + "The keyboard will be unlocked before the agent is replaced.\n\n\(message)" } else { message } @@ -283,7 +300,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { let confirmation = NSAlert() confirmation.alertStyle = .warning confirmation.messageText = "Restart the KeyboardLocker Agent?" - confirmation.informativeText = "Restarting an unresponsive Agent may release an active keyboard lock." + confirmation.informativeText = "Restarting an unresponsive agent may release an active keyboard lock." confirmation.addButton(withTitle: "Restart Agent") confirmation.addButton(withTitle: "Cancel") @@ -472,9 +489,13 @@ final class StatusMenuController: NSObject, NSMenuDelegate { case let .ready(isLocked), let .accessibilityRequired(isLocked): isLocked + case let .checking(lastKnownLock): + // Same evidence standard as `AppCoordinator.toggle()`: a last-known-locked reading + // means the keyboard may still be locked, so quitting must keep the warning. + lastKnownLock ?? false case let .agentUpdateRequired(isLocked, _): isLocked ?? false - case .checking, .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: false } } diff --git a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift index f866420..8349126 100644 --- a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift +++ b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift @@ -15,7 +15,7 @@ struct LiveAgentFocusLockClient: AgentFocusLockServing { struct KeyboardLockFocusFilterIntent: SetFocusFilterIntent { static let title: LocalizedStringResource = "Keyboard Lock" static let description = IntentDescription( - "Creates a keyboard lock when this Focus activates. It can unlock normally and won't automatically re-lock while the Focus remains active." + "Creates a keyboard lock when this Focus activates and releases it when the Focus turns off. If you unlock it while the Focus is active, it won't automatically re-lock. A lock taken over by another action stays locked." ) @Parameter(title: "Lock Keyboard", default: false) diff --git a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift index a680266..cbbf413 100644 --- a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift @@ -206,7 +206,7 @@ final class AgentReadinessCoordinatorTests: XCTestCase { } XCTAssertEqual( error.localizedDescription, - "Safe Agent replacement requires a readable authoritative lock state." + "Safe KeyboardLocker agent replacement requires a readable authoritative lock state." ) XCTAssertNil(context) XCTAssertEqual(client.statusCallCount, 0) diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index 9ef0021..5e0ab97 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -141,6 +141,7 @@ final class KlockCommandLineTests: XCTestCase { [ "Error: \(error.localizedDescription)", " \(try XCTUnwrap(error.recoverySuggestion))", + " Or run `klock register-agent` to register it from Terminal.", ] ) XCTAssertEqual(client.waitUntilUnlockedCalls, 0) @@ -238,6 +239,7 @@ final class KlockCommandLineTests: XCTestCase { [ "Error: \(error.localizedDescription)", " \(try XCTUnwrap(error.recoverySuggestion))", + " Or run `klock register-agent` to register it from Terminal.", ] ) } @@ -281,7 +283,7 @@ final class KlockCommandLineTests: XCTestCase { result.stdout, [ "Launched KeyboardLocker to register its background agent.", - "The background agent is registered and reachable.", + "The KeyboardLocker agent is registered and reachable.", ] ) XCTAssertEqual(result.stderr, []) @@ -303,7 +305,7 @@ final class KlockCommandLineTests: XCTestCase { XCTAssertEqual(result.exitCode, 1) XCTAssertEqual(result.stdout, ["Launched KeyboardLocker to register its background agent."]) - XCTAssertEqual(result.stderr.first, "Error: The background agent is not reachable yet.") + XCTAssertEqual(result.stderr.first, "Error: The KeyboardLocker agent is not reachable yet.") XCTAssertEqual(openCalls, 1) XCTAssertEqual(client.statusCalls, 3) } diff --git a/KeyboardLockerWidgets/KeyboardLockerControl.swift b/KeyboardLockerWidgets/KeyboardLockerControl.swift index 4a60c9e..242fed9 100644 --- a/KeyboardLockerWidgets/KeyboardLockerControl.swift +++ b/KeyboardLockerWidgets/KeyboardLockerControl.swift @@ -31,7 +31,9 @@ struct KeyboardLockerControl: ControlWidget { @available(macOS 26.0, *) private struct KeyboardLockerControlValueProvider: ControlValueProvider { - let previewValue = false + // Gallery preview matches the status widget's locked sample so both system surfaces + // advertise the same state. + let previewValue = true private let loader: KeyboardLockerControlValueLoader diff --git a/docs/architecture.md b/docs/architecture.md index 0fa7779..28cf525 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `toggle` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `toggle` 是同一全局锁的原子翻转:在 Agent 的串行执行边界内于同一 execution turn 完成"读当前状态 + 反向 mutation",并向调用方返回翻转后的布尔值。锁定方向使用普通非交互 `lock` 语义(含 Focus persistence takeover),解锁方向等价于显式 `unlock`;wrapper 不得先读 `status()` 再自行决定方向来模拟 toggle。 - `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 -- interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。发起并等待中的 CLI 进程对本轮锁负有退出清理责任:它观察 SIGTERM/SIGHUP/SIGINT,退出前尽力释放自己创建的这轮锁(有界等待后无论如何退出);SIGKILL/SIGSTOP 不可观察,遗留锁仍由解锁热键、通知 Unlock Now、auto-unlock 等全局途径解开。这是输入手势语义的进程侧延伸,不构成 session 或所有权。 +- interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。发起并等待中的 CLI 进程对本轮锁负有退出清理责任:它观察 SIGTERM/SIGHUP/SIGINT,退出前尽力释放锁(有界等待后无论如何退出)。wire protocol 不携带 interactive lock 的代际令牌,清理无法区分自己创建的锁与等待期间由他入口重建的锁——若本轮锁已被解开且另一入口重新锁定,清理会释放较新的锁,这与任意入口可解锁同一全局锁的契约一致;SIGKILL/SIGSTOP 不可观察,遗留锁仍由解锁热键、通知 Unlock Now、auto-unlock 等全局途径解开。这是输入手势语义的进程侧延伸,不构成 session 或所有权。 - Focus Filter 是 **activation-triggered acquisition**,不是“Focus active 期间持续保持 locked”的 policy。Focus 激活时的 `true` 最多尝试一次 `unlocked → locked`,并只在成功创建锁时记录该 Focus-owned generation;Focus 关闭时的 `false` 只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领。显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束该 generation;实现不会查询当前 Focus 后自动重建锁,也不会在同一次 Focus activation 内持续 relock。这个进程内 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 - `status()` 保留为兼容旧 wrapper 的最小布尔查询;需要呈现锁定时长、auto-unlock deadline 或 active settings 的 wrapper 使用 capability-gated `lockStatusSnapshot()`。snapshot 传输权威时间点,不传会立即过期的 duration/countdown counter。 - 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 diff --git a/docs/automation.md b/docs/automation.md index 49bb47f..9da42bf 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -78,7 +78,7 @@ Widget 使用 15 分钟 regular fallback;若 auto-unlock deadline 更早,则 macOS 26 及以上提供 `Keyboard Lock` Control。Control 的 value provider 每次经 XPC 查询 Agent 的权威 Boolean;用户操作产生的是明确的 desired state:on 调用 `lock`,off 调用 `unlock`。它不会先读取旧值再做 client-side toggle,因此并发状态变化不会把一次操作翻译成错误方向。 -Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` Widget timeline 和 Control value。调用失败会由 App Intent 明确返回错误,也不会先刷新出未经确认的 UI 状态。Control API 在当前 macOS SDK 中从 macOS 26 起可用;macOS 13–15 不注册 Control,其中 macOS 14–15 的状态 Widget 可交互,macOS 13 的状态 Widget 只读。 +Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` Widget timeline 和 Control value。调用失败会由 App Intent 明确返回错误,也不会先刷新出未经确认的 UI 状态。Control 没有 extension 自绘的 unavailable 状态:value provider 失败时的呈现由系统决定,与 Widget 显式的 `Agent Unavailable` 布局不同。Control API 在当前 macOS SDK 中从 macOS 26 起可用;macOS 13–15 不注册 Control,其中 macOS 14–15 的状态 Widget 可交互,macOS 13 的状态 Widget 只读。 ## Notifications @@ -88,6 +88,8 @@ Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` W 通知是 presentation-only 的便利面:首次锁定时向系统请求通知权限,被拒绝或未授予时不发送、不报错;通知内容永远不是状态源。通知以 Agent 的 bundle 身份发布,在系统设置中显示为 KeyboardLocker(display name 与主 App 对齐)。 +因此权限被拒时,按设计静默的入口(Services、URL、AppleScript 的 `lock` / `unlock`)没有任何用户可见的成功反馈。需要确认结果的调用方应使用 `klock status --json`、Shortcuts 返回值或 AppleScript 返回值。 + ## AppleScript 应用 scripting dictionary 提供三个 command: @@ -119,7 +121,7 @@ end tell klock lock ``` -等待期间进程被终止(关闭终端窗口的 `SIGHUP`、`kill` 的 `SIGTERM`)时,`klock` 会在退出前尽力释放它创建的这轮锁(有界等待,随后无论如何退出)。`kill -9` 无法被捕获,此时遗留的锁仍由解锁热键、通知的 Unlock Now 或 auto-unlock 解开。该清理只适用于真正完成 `unlocked → locked` 转换的调用;`Already locked` 退出的命令不触碰既有锁。 +等待期间进程被终止(关闭终端窗口的 `SIGHUP`、`kill` 的 `SIGTERM`)时,`klock` 会在退出前尽力释放它创建的这轮锁(有界等待,随后无论如何退出)。`kill -9` 无法被捕获,此时遗留的锁仍由解锁热键、通知的 Unlock Now 或 auto-unlock 解开。该清理只适用于真正完成 `unlocked → locked` 转换的调用;`Already locked` 退出的命令不触碰既有锁。清理是不带 generation 标记的尽力释放:等待期间若本轮锁已被解开、另一入口随后重新锁定,退出清理会解开这个较新的锁——这与全局锁契约一致,但需要严格 ownership 语义时不应依赖它(见文末 lease/session 说明)。 自动化应使用 one-shot 模式。它在 Agent 确认 locked 后立即退出,不会启用 `Ctrl+C`,也不参与终止清理: diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index a81e90b..06ef22c 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -168,8 +168,12 @@ enum KlockCLI { } /// Best-effort release of the lock this command created, bounded by the caller so the - /// process still exits when the Agent is unreachable. Internal (not private) so tests can - /// drive it directly — production reaches it only via the signal handler. + /// process still exits when the Agent is unreachable. The wire protocol carries no + /// generation token for interactive locks, so if this command's lock was already released + /// and another wrapper re-locked while this process waited, cleanup releases that newer + /// lock — permitted by the global-lock contract (any entry may unlock), and documented + /// here so the approximation stays explicit. Internal (not private) so tests can drive + /// it directly — production reaches it only via the signal handler. static func unlockBeforeTermination( client: any KlockClientServing, printError: (String) -> Void @@ -241,12 +245,12 @@ enum KlockCLI { for _ in 0 ..< agentPoll.attempts { try? await Task.sleep(for: agentPoll.interval) if (try? await client.status()) != nil { - printOut("The background agent is registered and reachable.") + printOut("The KeyboardLocker agent is registered and reachable.") return ExitCode.success } } - reportError("The background agent is not reachable yet.", printError: printError) + reportError("The KeyboardLocker agent is not reachable yet.", printError: printError) printError( " If KeyboardLocker appears in System Settings → General → Login Items, enable it, " + "then retry. Locking also requires Accessibility access for the agent." @@ -277,6 +281,11 @@ enum KlockCLI { if let suggestion = (error as? LocalizedError)?.recoverySuggestion { printError(" \(suggestion)") } + // The shared recovery text names the GUI registration path; point CLI users at the + // native equivalent so the hint stays actionable from Terminal. + if let clientError = error as? XPCClientError, case .serviceUnavailable = clientError { + printError(" Or run `klock register-agent` to register it from Terminal.") + } } private static func reportError(_ message: String, printError: (String) -> Void) { From f786b1d9610a6993e2b869db59a7f916b5821f72 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 14:40:28 +0800 Subject: [PATCH 086/106] fix(app): report Services request failures via the error out-pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services handlers only accepted requests synchronously and executed them async, so the AppKit error out-pointer — the one Services channel that can carry a failure reason back to the requesting app — went unused. Handlers now wait, bounded (15s, covering the XPC reply window plus the idempotent retry), on a new ExternalAutomationController.submitAndWait that runs through the same serial chain and returns the failure. When AppKit invokes a handler on the main thread, the wait pumps the run loop instead of hard-blocking, so the main-actor execution chain can proceed. The in-app failure alert is unchanged. Out pointers and pasteboards are still never captured into async tasks. --- .../ExternalAutomationController.swift | 74 ++++++++++++---- .../KeyboardLockerServicesProvider.swift | 88 +++++++++++++++++-- .../KeyboardLockerApplication.swift | 4 +- .../ExternalAutomationControllerTests.swift | 56 ++++++++++++ .../KeyboardLockerServicesProviderTests.swift | 36 +++++++- docs/automation.md | 2 +- docs/development.md | 2 +- 7 files changed, 233 insertions(+), 29 deletions(-) diff --git a/KeyboardLocker/Automation/ExternalAutomationController.swift b/KeyboardLocker/Automation/ExternalAutomationController.swift index 0310c4f..6ab4f19 100644 --- a/KeyboardLocker/Automation/ExternalAutomationController.swift +++ b/KeyboardLocker/Automation/ExternalAutomationController.swift @@ -64,21 +64,13 @@ final class ExternalAutomationController { case let .failure(failure): failures.append(failure) case let .action(action): - do { - switch action { - case .lock: - try await client.lock() - case .unlock: - try await client.unlock() - case .status: - let isLocked = try await client.status() - presenter.presentStatus( - isLocked: isLocked, - source: source - ) - } - } catch { - failures.append(ExternalAutomationFailure(error: error)) + if let failure = await Self.execute( + action, + client: client, + presenter: presenter, + source: source + ) { + failures.append(failure) } } } @@ -89,6 +81,58 @@ final class ExternalAutomationController { } } + /// Submits one action through the same serial chain and reports the outcome to the caller. + /// Sources with a synchronous result channel (Services) use this to surface failures + /// themselves; the in-app failure alert is still presented. + func submitAndWait( + _ action: ExternalAutomationAction, + source: ExternalAutomationSource + ) async -> ExternalAutomationFailure? { + let previous = tail + let client = client + let presenter = presenter + let task = Task { @MainActor in + await previous?.value + + let failure = await Self.execute( + action, + client: client, + presenter: presenter, + source: source + ) + if let failure { + presenter.presentFailures([failure], source: source) + } + return failure + } + tail = Task { @MainActor in + _ = await task.value + } + return await task.value + } + + private static func execute( + _ action: ExternalAutomationAction, + client: any AgentLockActionServing, + presenter: any ExternalAutomationPresenting, + source: ExternalAutomationSource + ) async -> ExternalAutomationFailure? { + do { + switch action { + case .lock: + try await client.lock() + case .unlock: + try await client.unlock() + case .status: + let isLocked = try await client.status() + presenter.presentStatus(isLocked: isLocked, source: source) + } + return nil + } catch { + return ExternalAutomationFailure(error: error) + } + } + func waitUntilIdle() async { await tail?.value } diff --git a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift index 3ce5271..25fd7af 100644 --- a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift +++ b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift @@ -1,9 +1,23 @@ import AppKit +/// AppKit invokes service handlers synchronously and marshals the `error` out-pointer back to +/// the requesting app — the Services channel is the only external entry point that can carry a +/// failure reason (the URL scheme has none). Execution itself is async (the serial XPC chain), +/// so each handler waits, with a bound, for the outcome and reports it here; the in-app failure +/// alert is presented as before. final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sendable { - private let submit: @Sendable (ExternalAutomationAction) -> Void + /// Outlasts the worst-case Client mutation: the initial 5s XPC reply window plus the + /// idempotent-retry window, with margin. + private static let defaultWaitTimeout: TimeInterval = 15 - init(submit: @escaping @Sendable (ExternalAutomationAction) -> Void) { + private let submit: @Sendable (ExternalAutomationAction) async -> ExternalAutomationFailure? + private let waitTimeout: TimeInterval + + init( + waitTimeout: TimeInterval = KeyboardLockerServicesProvider.defaultWaitTimeout, + submit: @escaping @Sendable (ExternalAutomationAction) async -> ExternalAutomationFailure? + ) { + self.waitTimeout = waitTimeout self.submit = submit } @@ -11,26 +25,84 @@ final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sen nonisolated func lockKeyboard( _: NSPasteboard, userData _: String?, - error _: AutoreleasingUnsafeMutablePointer + error: AutoreleasingUnsafeMutablePointer ) { - submit(.lock) + perform(.lock, error: error) } @objc(unlockKeyboard:userData:error:) nonisolated func unlockKeyboard( _: NSPasteboard, userData _: String?, - error _: AutoreleasingUnsafeMutablePointer + error: AutoreleasingUnsafeMutablePointer ) { - submit(.unlock) + perform(.unlock, error: error) } @objc(showKeyboardLockStatus:userData:error:) nonisolated func showKeyboardLockStatus( _: NSPasteboard, userData _: String?, - error _: AutoreleasingUnsafeMutablePointer + error: AutoreleasingUnsafeMutablePointer ) { - submit(.status) + perform(.status, error: error) + } + + private func perform( + _ action: ExternalAutomationAction, + error: AutoreleasingUnsafeMutablePointer + ) { + let completion = ServicesRequestCompletion() + let submit = submit + Task { + let failure = await submit(action) + completion.complete(failureMessage: failure?.message) + } + + switch completion.awaitOutcome(timeout: waitTimeout) { + case .succeeded: + break + case let .failed(message): + error.pointee = message as NSString + case .timedOut: + error.pointee = """ + KeyboardLocker did not finish the service request within \(Int(waitTimeout)) seconds; \ + it may still complete. + """ as NSString + } + } +} + +/// One-shot cell carrying a service request's outcome back to the thread AppKit blocked on the +/// handler. +private final class ServicesRequestCompletion: @unchecked Sendable { + enum Outcome: Equatable { + case succeeded + case failed(String) + case timedOut + } + + private let semaphore = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var outcome: Outcome? + + func complete(failureMessage: String?) { + let outcome: Outcome = failureMessage.map(Outcome.failed) ?? .succeeded + lock.withLock { self.outcome = outcome } + semaphore.signal() + } + + func awaitOutcome(timeout: TimeInterval) -> Outcome { + if Thread.isMainThread { + // The execution chain is main-actor; blocking the main thread outright would starve the + // very work being awaited. Pump the run loop — like a modal session — so it can proceed. + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline, lock.withLock({ outcome }) == nil { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + } + } else { + _ = semaphore.wait(timeout: .now() + timeout) + } + return lock.withLock { outcome } ?? .timedOut } } diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index b675f1a..897b25a 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -81,9 +81,7 @@ private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDe let automationController = ExternalAutomationController() self.automationController = automationController servicesProvider = KeyboardLockerServicesProvider { action in - Task { @MainActor in - automationController.submit(action, source: .service) - } + await automationController.submitAndWait(action, source: .service) } super.init() } diff --git a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift index aa8e758..ba1fc12 100644 --- a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift +++ b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift @@ -118,6 +118,62 @@ final class ExternalAutomationControllerTests: XCTestCase { XCTAssertEqual(client.calls, [.lock, .unlock]) XCTAssertFalse(client.isLocked) } + + @MainActor + func testSubmitAndWaitReturnsNilWhenActionSucceeds() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + let failure = await controller.submitAndWait(.lock, source: .service) + + XCTAssertNil(failure) + XCTAssertEqual(client.calls, [.lock]) + XCTAssertTrue(client.isLocked) + XCTAssertTrue(presenter.failureBatches.isEmpty) + } + + @MainActor + func testSubmitAndWaitReturnsAndPresentsFailure() async { + let client = RecordingExternalAutomationClient( + isLocked: false, + error: XPCClientError.serviceUnavailable + ) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + let failure = await controller.submitAndWait(.lock, source: .service) + + XCTAssertTrue(failure?.message.contains("not reachable") == true) + XCTAssertEqual(presenter.failureBatches.count, 1) + XCTAssertEqual(presenter.failureBatches.first?.failures.first, failure) + XCTAssertEqual(presenter.failureBatches.first?.source, .service) + } + + @MainActor + func testSubmitAndWaitStaysSerializedBehindPendingSubmission() async { + let gate = AsyncGate() + let client = RecordingExternalAutomationClient(isLocked: false, lockGate: gate) + let controller = ExternalAutomationController( + client: client, + presenter: RecordingExternalAutomationPresenter() + ) + + controller.submit(.lock, source: .service) + await gate.waitUntilEntered() + + async let awaitedFailure = controller.submitAndWait(.unlock, source: .service) + await Task.yield() + + XCTAssertEqual(client.calls, [.lock]) + + await gate.open() + let failure = await awaitedFailure + + XCTAssertNil(failure) + XCTAssertEqual(client.calls, [.lock, .unlock]) + XCTAssertFalse(client.isLocked) + } } @MainActor diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift index 53ac8a0..5a33003 100644 --- a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -2,8 +2,10 @@ import AppKit import XCTest final class KeyboardLockerServicesProviderTests: XCTestCase { + private let pasteboard = NSPasteboard(name: NSPasteboard.Name("KeyboardLockerServicesProviderTests")) + func testProviderExportsEveryDeclaredServiceSelector() { - let provider = KeyboardLockerServicesProvider { _ in } + let provider = KeyboardLockerServicesProvider { _ in nil } XCTAssertTrue(provider.responds(to: NSSelectorFromString("lockKeyboard:userData:error:"))) XCTAssertTrue(provider.responds(to: NSSelectorFromString("unlockKeyboard:userData:error:"))) @@ -11,4 +13,36 @@ final class KeyboardLockerServicesProviderTests: XCTestCase { provider.responds(to: NSSelectorFromString("showKeyboardLockStatus:userData:error:")) ) } + + func testHandlerLeavesErrorUntouchedWhenActionSucceeds() { + let provider = KeyboardLockerServicesProvider { _ in nil } + + var error: NSString? + provider.lockKeyboard(pasteboard, userData: nil, error: &error) + + XCTAssertNil(error) + } + + func testHandlerCopiesFailureMessageIntoErrorOutPointer() { + let provider = KeyboardLockerServicesProvider { _ in + ExternalAutomationFailure(message: "Agent not reachable") + } + + var error: NSString? + provider.unlockKeyboard(pasteboard, userData: nil, error: &error) + + XCTAssertEqual(error, "Agent not reachable") + } + + func testHandlerReportsTimeoutWhenActionDoesNotFinish() { + let provider = KeyboardLockerServicesProvider(waitTimeout: 0.1) { _ in + try? await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + + var error: NSString? + provider.showKeyboardLockStatus(pasteboard, userData: nil, error: &error) + + XCTAssertTrue(error?.contains("did not finish") == true) + } } diff --git a/docs/automation.md b/docs/automation.md index 9da42bf..23ec5b8 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -48,7 +48,7 @@ KeyboardLocker 在 macOS 的 **Services** 菜单注册三个不依赖当前选 它们可以从其他 App 的 Services 菜单调用,也可以在 **System Settings > Keyboard > Keyboard Shortcuts > Services** 中绑定全局快捷键。三个入口都交给同一个串行 executor:lock/unlock 直接发送 desired state,status 从 Agent 读取权威 Boolean 后显示提示;并发到达的 action 按接收顺序执行。 -AppKit Services 的 handler 没有与异步 XPC 对应的 suspend/resume contract,因此 provider 只同步受理请求并立即返回,不会阻塞主线程等待 Agent。后续失败由 KeyboardLocker 激活并显示明确错误。需要调用方拿到事务级结果或 machine-readable status 时,使用 AppleScript、Shortcuts action 或 `klock status --json`,不要把 Services 的返回当作完成确认。 +handler 会在有界超时(默认 15s,覆盖 XPC 应答窗口加一次幂等重试)内同步等待串行 executor 的结果:失败经 AppKit Services 的 error out-pointer 回传调用方 App,超时则报告"请求未完成、可能仍会完成";KeyboardLocker 内的失败 alert 同时保留。error 通道只承载失败文本,status 的权威结果仍只在 KeyboardLocker 内提示;需要 machine-readable status 时使用 AppleScript、Shortcuts action 或 `klock status --json`。 ## URL Scheme diff --git a/docs/development.md b/docs/development.md index 93d73fb..387ca2b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -54,7 +54,7 @@ - `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard`、`Toggle Keyboard Lock` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。`Toggle Keyboard Lock` 调用 Agent 串行边界内的原子 `toggle` 并返回翻转后的 `Bool`,不做 client-side 状态合成。`AppIntents/KeyboardLockerShortcuts.swift` 在 macOS 26+ 声明 `AppShortcutsProvider`,为 lock / unlock / toggle 注册 promoted App Shortcuts 与 invocation phrases;更早的 macOS 只从 Shortcuts action library 暴露这些 intent。 - `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 - `Automation/ExternalAutomationController.swift`:Services 与 URL event 共用的串行 one-shot executor。每个 action 只调用 `AgentLockActionServing` 的对应 desired-state/query method;跨多次 submit 也保持接收顺序,并把 authoritative status 或合并后的 failure 交给 presentation boundary。 -- `Automation/KeyboardLockerServicesProvider.swift`:将 `NSServices` 的三个 Objective-C selector 适配成 `.lock` / `.unlock` / `.status`。Services handler 只同步受理,不把短生命周期的 error pointer 或 pasteboard 捕获进异步 Task,也不阻塞 AppKit 线程等待 XPC。 +- `Automation/KeyboardLockerServicesProvider.swift`:将 `NSServices` 的三个 Objective-C selector 适配成 `.lock` / `.unlock` / `.status`。handler 经 `ExternalAutomationController.submitAndWait` 有界等待串行链结果(默认 15s,覆盖 XPC 应答窗口加幂等重试),失败或超时同步写入 error out-pointer 回传调用方;短生命周期的 pointer 与 pasteboard 不会被捕获进异步 Task。AppKit 在主线程调用 handler 时泵 run loop 等待,避免硬阻塞饿死 main-actor 执行链。 - `Automation/KeyboardLockerURLRoute.swift`:只把 `keyboardlocker://lock|unlock|status` 的严格 canonical URL 映射为 action。它拒绝额外 URL component,不回显原始输入,并把多个 URL 转成保序的 action/failure request;custom scheme 不声称 caller authentication。 - `Automation/AppKitExternalAutomationPresenter.swift`:主 App 内统一呈现外部 automation 的 status 与异步 failure;显示 alert 前激活 accessory App,避免提示留在后台。 - `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `toggle` / `status`,协调器和系统 action 都无需依赖无关 Client surface。live lock/unlock/toggle 在 Agent 确认后请求 system-surface reload,status 不请求;live state observer 在新 observation lifetime 和每个已回拉、去重的权威变化上提交 presentation hint,从而覆盖 App 运行期间的 CLI、timer、热键与 fail-open 变化。 From 8b555111b064eea4db88a0d1a749e0cfc7e705a3 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 14:59:58 +0800 Subject: [PATCH 087/106] refactor(app): rename menu lock action to performDisplayedLockAction The menu item shows a direction verb (Lock Keyboard / Unlock Keyboard), so AppCoordinator performs the displayed direction as an explicit idempotent lock/unlock. That is deliberately not the atomic toggle(): the escape-hatch states must only ever unlock, explicit verbs work on legacy agents without the lockToggle capability, and idempotent mutations survive timeout retries. The old toggle() name hid that distinction; rename it at the coordinator, the menu selector, and the test. No behavior change. --- KeyboardLocker/AppCoordinator.swift | 5 ++++- KeyboardLocker/StatusMenuController.swift | 13 +++++++------ KeyboardLockerModelTests/AppCoordinatorTests.swift | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift index 4d1a65b..f0f47ef 100644 --- a/KeyboardLocker/AppCoordinator.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -108,7 +108,10 @@ final class AppCoordinator { startReconciliation() } - func toggle() { + /// Performs the direction the menu currently displays, as an explicit idempotent lock/unlock. + /// Deliberately not the atomic `toggle()`: the escape-hatch states below must only ever + /// unlock, and explicit verbs still work on legacy agents without the `lockToggle` capability. + func performDisplayedLockAction() { guard activity == nil else { return } diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index 0738144..9c096ba 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -101,13 +101,13 @@ final class StatusMenuController: NSObject, NSMenuDelegate { case let .agentUpdateRequired(isLocked, _): if isLocked == true { - addAction(title: "Unlock Keyboard", action: #selector(toggleLock)) + addAction(title: "Unlock Keyboard", action: #selector(performDisplayedLockAction)) } addAction(title: "Update KeyboardLocker Agent…", action: #selector(updateAgent)) case let .accessibilityRequired(isLocked): if isLocked { - addAction(title: "Unlock Keyboard", action: #selector(toggleLock)) + addAction(title: "Unlock Keyboard", action: #selector(performDisplayedLockAction)) } addAction( title: "Grant Accessibility Access…", @@ -121,7 +121,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { case let .ready(isLocked): addAction( title: isLocked ? "Unlock Keyboard" : "Lock Keyboard", - action: #selector(toggleLock) + action: #selector(performDisplayedLockAction) ) case let .unavailable(_, canRestartAgent): @@ -247,8 +247,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } @objc - private func toggleLock() { - coordinator.toggle() + private func performDisplayedLockAction() { + coordinator.performDisplayedLockAction() } @objc @@ -490,7 +490,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { let .accessibilityRequired(isLocked): isLocked case let .checking(lastKnownLock): - // Same evidence standard as `AppCoordinator.toggle()`: a last-known-locked reading + // Same evidence standard as `AppCoordinator.performDisplayedLockAction()`: a + // last-known-locked reading // means the keyboard may still be locked, so quitting must keep the warning. lastKnownLock ?? false case let .agentUpdateRequired(isLocked, _): diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift index a681747..a9e045e 100644 --- a/KeyboardLockerModelTests/AppCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -31,7 +31,7 @@ final class AppCoordinatorTests: XCTestCase { } @MainActor - func testToggleLocksThroughClientThenReconcilesAuthoritativeState() async throws { + func testDisplayedLockActionLocksThroughClientThenReconcilesAuthoritativeState() async throws { let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) let lifecycle = FakeAgentLifecycle() let observer = FakeLockStateObserver() @@ -44,7 +44,7 @@ final class AppCoordinatorTests: XCTestCase { var activities: [AppCoordinator.Activity?] = [] coordinator.onSnapshotChange = { activities.append($0.activity) } - coordinator.toggle() + coordinator.performDisplayedLockAction() try await waitUntil { coordinator.state == .ready(isLocked: true) && coordinator.activity == nil } From bb699e4d4bd85614c0d7221a9da5bc67ff2966b5 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 15:11:44 +0800 Subject: [PATCH 088/106] feat(klock): add the toggle command Wires the atomic Agent toggle into the CLI: klock toggle flips the global lock inside the Agent serial boundary and prints the resulting authoritative state (Locked. / Unlocked.), the same wire method the Shortcuts Toggle Keyboard Lock action returns. Exit codes stay 0 on success and 1 on failure; legacy agents without the lockToggle capability fail explicitly instead of falling back to a client-side read-then-mutate race. --- .../KlockCommandLineTests.swift | 42 +++++++++++++++++++ docs/automation.md | 3 ++ klock/KlockCLI.swift | 20 +++++++++ klock/KlockCommand.swift | 5 +++ 4 files changed, 70 insertions(+) diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index 5e0ab97..8fbd15a 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -21,6 +21,7 @@ final class KlockCommandLineTests: XCTestCase { (["lock"], .lock(wait: true)), (["lock", "--no-wait"], .lock(wait: false)), (["unlock"], .unlock), + (["toggle"], .toggle), (["register-agent"], .registerAgent), (["status"], .status(output: .humanReadable)), (["status", "--json"], .status(output: .json)), @@ -58,6 +59,7 @@ final class KlockCommandLineTests: XCTestCase { (["lock", "--no-wait", "extra"], .unexpectedArguments(["--no-wait", "extra"])), (["status", "--xml"], .unexpectedArguments(["--xml"])), (["unlock", "now"], .unexpectedArguments(["now"])), + (["toggle", "now"], .unexpectedArguments(["now"])), (["register-agent", "now"], .unexpectedArguments(["now"])), (["--help", "extra"], .unexpectedArguments(["extra"])), (["version", "extra"], .unexpectedArguments(["extra"])), @@ -193,6 +195,33 @@ final class KlockCommandLineTests: XCTestCase { XCTAssertEqual(result.stderr, ["Error: unlock failed"]) } + func testTogglePrintsResultingStateAcrossRoundTrip() async { + let client = FakeKlockClient() + client.toggleResults = [.success(true), .success(false)] + + var result = await runKlock(arguments: ["toggle"], client: client) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Locked."]) + + result = await runKlock(arguments: ["toggle"], client: client) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Unlocked."]) + + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.toggleCalls, 2) + } + + func testToggleFailureReportsErrorOnStandardError() async { + let client = FakeKlockClient() + client.toggleResult = .failure(Self.makeError("toggle failed")) + + let result = await runKlock(arguments: ["toggle"], client: client) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, []) + XCTAssertEqual(result.stderr, ["Error: toggle failed"]) + } + func testStatusPrintsHumanReadableState() async { let client = FakeKlockClient() @@ -477,6 +506,10 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { var lockInteractivelyResult: Result = .success(.acquired) var lockResult: Result = .success(()) var unlockResult: Result = .success(()) + var toggleResult: Result = .success(true) + /// Same scripting seam as `statusResults`: each `toggle()` consumes the next queued result + /// before falling back to `toggleResult`, so tests can drive a state round trip. + var toggleResults: [Result]? var statusResult: Result = .success(false) /// When set, each `status()` call consumes the next queued result before falling back to /// `statusResult`, so tests can script an Agent that becomes reachable after registration. @@ -487,6 +520,7 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { private(set) var lockInteractivelyCalls = 0 private(set) var lockCalls = 0 private(set) var unlockCalls = 0 + private(set) var toggleCalls = 0 private(set) var statusCalls = 0 private(set) var waitUntilUnlockedCalls = 0 @@ -510,6 +544,14 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { try unlockResult.get() } + func toggle() async throws -> Bool { + toggleCalls += 1 + if toggleResults?.isEmpty == false { + return try toggleResults!.removeFirst().get() + } + return try toggleResult.get() + } + func status() async throws -> Bool { statusCalls += 1 if statusResults?.isEmpty == false { diff --git a/docs/automation.md b/docs/automation.md index 23ec5b8..30ea05b 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -135,9 +135,12 @@ klock lock --no-wait klock status klock status --json klock unlock +klock toggle klock register-agent ``` +`toggle` 在 Agent 串行边界内原子翻转并打印翻转后的权威状态(与 Shortcuts 的 `Toggle Keyboard Lock` 同一 wire method);老 Agent 无 toggle capability 时命令明确报错,不做 client-side 方向合成。 + `register-agent` 在 Agent 不可达时启动一次 KeyboardLocker App 来完成 `SMAppService` 注册(注册只能由 App bundle 执行),随后短暂轮询确认 Agent 可达;若出现 Login Items 批准或 Accessibility 授权要求,命令会指出对应的系统设置入口。Agent 已经可达时它不启动 App,直接报告。 `status` 默认输出供人阅读的 `Locked` 或 `Unlocked`。自动化使用 `status --json`,其稳定 contract 是单行 `{"locked":true}` 或 `{"locked":false}`。两种状态都表示查询成功并返回 exit code `0`;参数或 Agent 调用失败返回 `1`,因此脚本不需要把 unlocked 与 transport failure 混在同一个 exit code 中。 diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index 06ef22c..b355a32 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -8,6 +8,7 @@ protocol KlockClientServing { func lockInteractively() async throws -> LockRequestOutcome func lock() async throws func unlock() async throws + func toggle() async throws -> Bool func status() async throws -> Bool func waitUntilUnlocked() async throws } @@ -84,6 +85,9 @@ enum KlockCLI { case .unlock: return await executeUnlock(client: client, printOut: printOut, printError: printError) + case .toggle: + return await executeToggle(client: client, printOut: printOut, printError: printError) + case .registerAgent: return await executeRegisterAgent( client: client, @@ -219,6 +223,21 @@ enum KlockCLI { } } + private static func executeToggle( + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + let isLocked = try await client.toggle() + printOut(isLocked ? "Locked." : "Unlocked.") + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + /// Registers the background Agent by launching the containing App — only the App bundle can /// hand its launchd plist to `SMAppService`. The command then polls briefly so a terminal /// user learns immediately whether registration produced a reachable Agent. @@ -322,6 +341,7 @@ enum KlockCLI { COMMANDS: lock [--no-wait] Lock the keyboard; by default, wait until it is unlocked. unlock Unlock the keyboard. + toggle Toggle the lock state and print the new state. status [--json] Print the current lock state. register-agent Launch KeyboardLocker once to register its background agent. help Show this help message. diff --git a/klock/KlockCommand.swift b/klock/KlockCommand.swift index 7f043ee..ef18fe1 100644 --- a/klock/KlockCommand.swift +++ b/klock/KlockCommand.swift @@ -6,6 +6,7 @@ enum KlockCommand: Equatable { case version case lock(wait: Bool) case unlock + case toggle case registerAgent case status(output: KlockStatusOutput) } @@ -48,6 +49,10 @@ enum KlockCommandLineParser { try rejectUnexpectedArguments(Array(arguments.dropFirst())) return .unlock + case "toggle": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .toggle + case "register-agent": try rejectUnexpectedArguments(Array(arguments.dropFirst())) return .registerAgent From a04d55ba094eb1ee7e17bed22b4e703830d249bb Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 15:30:01 +0800 Subject: [PATCH 089/106] feat(klock): add the request-access command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gives CLI users the Accessibility-permission path that previously existed only in the app menu. The command short-circuits when the Agent already has access, otherwise triggers the system prompt through the Agent and polls (bounded, 30s by default) for the authoritative grant state — a sent prompt request proves nothing about the grant. Granted exits 0; a pending grant at the end of the window exits 1 with guidance on stdout, kept distinct from stderr error reporting. --- .../KlockCommandLineTests.swift | 91 +++++++++++++++++++ docs/automation.md | 3 + klock/KlockCLI.swift | 53 +++++++++++ klock/KlockCommand.swift | 5 + 4 files changed, 152 insertions(+) diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index 8fbd15a..c8020fd 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -23,6 +23,7 @@ final class KlockCommandLineTests: XCTestCase { (["unlock"], .unlock), (["toggle"], .toggle), (["register-agent"], .registerAgent), + (["request-access"], .requestAccess), (["status"], .status(output: .humanReadable)), (["status", "--json"], .status(output: .json)), ] @@ -61,6 +62,7 @@ final class KlockCommandLineTests: XCTestCase { (["unlock", "now"], .unexpectedArguments(["now"])), (["toggle", "now"], .unexpectedArguments(["now"])), (["register-agent", "now"], .unexpectedArguments(["now"])), + (["request-access", "now"], .unexpectedArguments(["now"])), (["--help", "extra"], .unexpectedArguments(["extra"])), (["version", "extra"], .unexpectedArguments(["extra"])), ] @@ -273,6 +275,74 @@ final class KlockCommandLineTests: XCTestCase { ) } + // MARK: - Request Access + + func testRequestAccessReportsAlreadyGrantedWithoutPrompting() async { + let client = FakeKlockClient() + client.hasAccessibilityPermissionResult = .success(true) + + let result = await runKlock(arguments: ["request-access"], client: client) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stdout, ["Accessibility access is already granted."]) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.requestAccessibilityPermissionCalls, 0) + } + + func testRequestAccessPromptsThenPollsUntilGranted() async { + let client = FakeKlockClient() + client.hasAccessibilityPermissionResults = [ + .success(false), + .success(false), + .success(true), + ] + + let result = await runKlock( + arguments: ["request-access"], + client: client, + accessPoll: (attempts: 5, interval: .zero) + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + result.stdout, + [ + "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…", + "Accessibility access granted.", + ] + ) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.requestAccessibilityPermissionCalls, 1) + XCTAssertEqual(client.hasAccessibilityPermissionCalls, 3) + } + + func testRequestAccessReportsPendingWhenWindowExpires() async { + let client = FakeKlockClient() + + let result = await runKlock( + arguments: ["request-access"], + client: client, + accessPoll: (attempts: 2, interval: .zero) + ) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout.first, "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…") + XCTAssertEqual(result.stdout.last, "Accessibility access is not granted yet. Enable KeyboardLocker in System Settings → Privacy & Security → Accessibility, then re-run `klock request-access`.") + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.requestAccessibilityPermissionCalls, 1) + } + + func testRequestAccessFailureReportsErrorOnStandardError() async { + let client = FakeKlockClient() + client.requestAccessibilityPermissionResult = .failure(Self.makeError("prompt failed")) + + let result = await runKlock(arguments: ["request-access"], client: client) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, []) + XCTAssertEqual(result.stderr, ["Error: prompt failed"]) + } + // MARK: - Register Agent func testRegisterAgentReportsAlreadyReachableWithoutOpeningApp() async { @@ -477,6 +547,7 @@ final class KlockCommandLineTests: XCTestCase { client: FakeKlockClient, openKeyboardLockerApp: @escaping () throws -> Void = {}, agentPoll: (attempts: Int, interval: Duration) = (attempts: 1, interval: .zero), + accessPoll: (attempts: Int, interval: Duration) = (attempts: 1, interval: .zero), terminationGuard: any KlockTerminationGuarding = NullKlockTerminationGuard() ) async -> (exitCode: Int32, stdout: [String], stderr: [String]) { var stdout: [String] = [] @@ -486,6 +557,7 @@ final class KlockCommandLineTests: XCTestCase { client: client, openKeyboardLockerApp: openKeyboardLockerApp, agentPoll: agentPoll, + accessPoll: accessPoll, terminationGuard: terminationGuard, printOut: { stdout.append($0) }, printError: { stderr.append($0) } @@ -515,6 +587,10 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { /// `statusResult`, so tests can script an Agent that becomes reachable after registration. var statusResults: [Result]? var waitUntilUnlockedResult: Result = .success(()) + var hasAccessibilityPermissionResult: Result = .success(false) + /// Same scripting seam as `statusResults`: drives the request-access pre-check and polls. + var hasAccessibilityPermissionResults: [Result]? + var requestAccessibilityPermissionResult: Result = .success(()) private(set) var currentSettingsCalls = 0 private(set) var lockInteractivelyCalls = 0 @@ -523,6 +599,8 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { private(set) var toggleCalls = 0 private(set) var statusCalls = 0 private(set) var waitUntilUnlockedCalls = 0 + private(set) var hasAccessibilityPermissionCalls = 0 + private(set) var requestAccessibilityPermissionCalls = 0 func currentSettings() async throws -> KeyboardLockerSettings { currentSettingsCalls += 1 @@ -564,6 +642,19 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { waitUntilUnlockedCalls += 1 try waitUntilUnlockedResult.get() } + + func hasAccessibilityPermission() async throws -> Bool { + hasAccessibilityPermissionCalls += 1 + if hasAccessibilityPermissionResults?.isEmpty == false { + return try hasAccessibilityPermissionResults!.removeFirst().get() + } + return try hasAccessibilityPermissionResult.get() + } + + func requestAccessibilityPermission() async throws { + requestAccessibilityPermissionCalls += 1 + try requestAccessibilityPermissionResult.get() + } } private final class FakeKlockTerminationGuard: KlockTerminationGuarding, @unchecked Sendable { diff --git a/docs/automation.md b/docs/automation.md index 30ea05b..2a35683 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -137,10 +137,13 @@ klock status --json klock unlock klock toggle klock register-agent +klock request-access ``` `toggle` 在 Agent 串行边界内原子翻转并打印翻转后的权威状态(与 Shortcuts 的 `Toggle Keyboard Lock` 同一 wire method);老 Agent 无 toggle capability 时命令明确报错,不做 client-side 方向合成。 +`request-access` 先报告已授权状态;未授权时经 Agent 触发系统 Accessibility prompt,随后有界轮询(默认 30s)并如实报告终态:授权打印 granted 退出 0,窗口内未授权打印 pending 说明退出 1(stdout 文本,区别于 stderr 的 Error)。prompt 是异步的,请求返回不代表已授权。 + `register-agent` 在 Agent 不可达时启动一次 KeyboardLocker App 来完成 `SMAppService` 注册(注册只能由 App bundle 执行),随后短暂轮询确认 Agent 可达;若出现 Login Items 批准或 Accessibility 授权要求,命令会指出对应的系统设置入口。Agent 已经可达时它不启动 App,直接报告。 `status` 默认输出供人阅读的 `Locked` 或 `Unlocked`。自动化使用 `status --json`,其稳定 contract 是单行 `{"locked":true}` 或 `{"locked":false}`。两种状态都表示查询成功并返回 exit code `0`;参数或 Agent 调用失败返回 `1`,因此脚本不需要把 unlocked 与 transport failure 混在同一个 exit code 中。 diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index b355a32..f039d8f 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -11,6 +11,8 @@ protocol KlockClientServing { func toggle() async throws -> Bool func status() async throws -> Bool func waitUntilUnlocked() async throws + func hasAccessibilityPermission() async throws -> Bool + func requestAccessibilityPermission() async throws } extension XPCClient: KlockClientServing {} @@ -36,6 +38,7 @@ enum KlockCLI { client: any KlockClientServing, openKeyboardLockerApp: @escaping () throws -> Void = KlockAppOpener.openContainingApp, agentPoll: (attempts: Int, interval: Duration) = (attempts: 5, interval: .seconds(1)), + accessPoll: (attempts: Int, interval: Duration) = (attempts: 30, interval: .seconds(1)), terminationGuard: any KlockTerminationGuarding = LiveKlockTerminationGuard(), printOut: (String) -> Void, printError: @escaping (String) -> Void @@ -88,6 +91,14 @@ enum KlockCLI { case .toggle: return await executeToggle(client: client, printOut: printOut, printError: printError) + case .requestAccess: + return await executeRequestAccess( + client: client, + accessPoll: accessPoll, + printOut: printOut, + printError: printError + ) + case .registerAgent: return await executeRegisterAgent( client: client, @@ -277,6 +288,47 @@ enum KlockCLI { return ExitCode.error } + /// Requests the system Accessibility prompt through the Agent, then polls briefly for the + /// authoritative state: the prompt is asynchronous, so a sent request proves nothing about + /// the grant. Exit 0 only when the Agent reports permission within the poll window. + private static func executeRequestAccess( + client: any KlockClientServing, + accessPoll: (attempts: Int, interval: Duration), + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + if try await client.hasAccessibilityPermission() { + printOut("Accessibility access is already granted.") + return ExitCode.success + } + + try await client.requestAccessibilityPermission() + printOut( + "Requested the system Accessibility prompt for the KeyboardLocker agent; " + + "waiting for the grant…" + ) + + for _ in 0 ..< accessPoll.attempts { + try? await Task.sleep(for: accessPoll.interval) + if try await client.hasAccessibilityPermission() { + printOut("Accessibility access granted.") + return ExitCode.success + } + } + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + + printOut( + "Accessibility access is not granted yet. " + + "Enable KeyboardLocker in System Settings → Privacy & Security → Accessibility, " + + "then re-run `klock request-access`." + ) + return ExitCode.error + } + private static func executeStatus( output: KlockStatusOutput, client: any KlockClientServing, @@ -344,6 +396,7 @@ enum KlockCLI { toggle Toggle the lock state and print the new state. status [--json] Print the current lock state. register-agent Launch KeyboardLocker once to register its background agent. + request-access Request Accessibility access for the agent. help Show this help message. version Show the klock version. diff --git a/klock/KlockCommand.swift b/klock/KlockCommand.swift index ef18fe1..1dd8bfe 100644 --- a/klock/KlockCommand.swift +++ b/klock/KlockCommand.swift @@ -8,6 +8,7 @@ enum KlockCommand: Equatable { case unlock case toggle case registerAgent + case requestAccess case status(output: KlockStatusOutput) } @@ -57,6 +58,10 @@ enum KlockCommandLineParser { try rejectUnexpectedArguments(Array(arguments.dropFirst())) return .registerAgent + case "request-access": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .requestAccess + case "status": switch Array(arguments.dropFirst()) { case []: From efe2b8de7d530c28180f94a98719b0a4d28f55d4 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 15:50:27 +0800 Subject: [PATCH 090/106] feat(klock): add status --snapshot output Exposes the Agent lockStatusSnapshot through the CLI as a separate JSON contract, leaving the stable single-line --json object byte-identical. The snapshot emits locked, startedAt, autoUnlockTargetDate, and unlockHotkey with alphabetical keys and ISO-8601 GMT dates (absent dates are null) so automation can compare output verbatim. The call stays capability-gated: legacy agents without lockStatusSnapshot fail explicitly instead of falling back to fields fabricated from status. --- .../KlockCommandLineTests.swift | 57 +++++++++++++++++++ .../KlockStatusOutputTests.swift | 49 ++++++++++++++++ docs/automation.md | 3 + klock/KlockCLI.swift | 10 +++- klock/KlockCommand.swift | 3 + klock/KlockStatusOutput.swift | 30 ++++++++++ 6 files changed, 150 insertions(+), 2 deletions(-) diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index c8020fd..e6532f5 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -26,6 +26,7 @@ final class KlockCommandLineTests: XCTestCase { (["request-access"], .requestAccess), (["status"], .status(output: .humanReadable)), (["status", "--json"], .status(output: .json)), + (["status", "--snapshot"], .status(output: .snapshot)), ] for (arguments, expected) in cases { @@ -59,6 +60,7 @@ final class KlockCommandLineTests: XCTestCase { (["lock", "extra"], .unexpectedArguments(["extra"])), (["lock", "--no-wait", "extra"], .unexpectedArguments(["--no-wait", "extra"])), (["status", "--xml"], .unexpectedArguments(["--xml"])), + (["status", "--json", "--snapshot"], .unexpectedArguments(["--json", "--snapshot"])), (["unlock", "now"], .unexpectedArguments(["now"])), (["toggle", "now"], .unexpectedArguments(["now"])), (["register-agent", "now"], .unexpectedArguments(["now"])), @@ -255,6 +257,46 @@ final class KlockCommandLineTests: XCTestCase { XCTAssertEqual(result.stdout, [#"{"locked":false}"#]) } + func testStatusSnapshotPrintsSnapshotContract() async { + let client = FakeKlockClient() + client.lockStatusSnapshotResult = .success( + LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 1_700_000_030), + isLocked: true, + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + autoUnlockTargetDate: Date(timeIntervalSince1970: 1_700_000_060), + settings: .testFixture + ) + ) + + let result = await runKlock(arguments: ["status", "--snapshot"], client: client) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + result.stdout, + [ + """ + {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} + """, + ] + ) + XCTAssertEqual(result.stderr, []) + XCTAssertEqual(client.lockStatusSnapshotCalls, 1) + XCTAssertEqual(client.statusCalls, 0) + } + + func testStatusSnapshotFailureReportsErrorWithoutFallback() async { + let client = FakeKlockClient() + client.lockStatusSnapshotResult = .failure(Self.makeError("snapshot unsupported")) + + let result = await runKlock(arguments: ["status", "--snapshot"], client: client) + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, []) + XCTAssertEqual(result.stderr, ["Error: snapshot unsupported"]) + XCTAssertEqual(client.statusCalls, 0) + } + func testInteractiveLockExitsWithErrorWhenWaitingForUnlockFails() async throws { let client = FakeKlockClient() let error = XPCClientError.serviceUnavailable @@ -586,6 +628,15 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { /// When set, each `status()` call consumes the next queued result before falling back to /// `statusResult`, so tests can script an Agent that becomes reachable after registration. var statusResults: [Result]? + var lockStatusSnapshotResult: Result = .success( + LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 0), + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .testFixture + ) + ) var waitUntilUnlockedResult: Result = .success(()) var hasAccessibilityPermissionResult: Result = .success(false) /// Same scripting seam as `statusResults`: drives the request-access pre-check and polls. @@ -598,6 +649,7 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { private(set) var unlockCalls = 0 private(set) var toggleCalls = 0 private(set) var statusCalls = 0 + private(set) var lockStatusSnapshotCalls = 0 private(set) var waitUntilUnlockedCalls = 0 private(set) var hasAccessibilityPermissionCalls = 0 private(set) var requestAccessibilityPermissionCalls = 0 @@ -638,6 +690,11 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { return try statusResult.get() } + func lockStatusSnapshot() async throws -> LockStatusSnapshot { + lockStatusSnapshotCalls += 1 + return try lockStatusSnapshotResult.get() + } + func waitUntilUnlocked() async throws { waitUntilUnlockedCalls += 1 try waitUntilUnlockedResult.get() diff --git a/KeyboardLockerModelTests/KlockStatusOutputTests.swift b/KeyboardLockerModelTests/KlockStatusOutputTests.swift index 1e7c9ea..f057bdc 100644 --- a/KeyboardLockerModelTests/KlockStatusOutputTests.swift +++ b/KeyboardLockerModelTests/KlockStatusOutputTests.swift @@ -1,3 +1,4 @@ +import Client import Foundation import XCTest @@ -17,6 +18,54 @@ final class KlockStatusOutputTests: XCTestCase { XCTAssertEqual(try decodedLockedValue(from: unlocked), false) } + func testSnapshotOutputRendersLockedSnapshotWithFixedKeyOrder() { + let output = KlockStatusOutput.render( + snapshot: makeSnapshot( + isLocked: true, + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + autoUnlockTargetDate: Date(timeIntervalSince1970: 1_700_000_060) + ) + ) + + XCTAssertEqual( + output, + """ + {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} + """ + ) + } + + func testSnapshotOutputRendersUnlockedSnapshotWithNullDates() { + let output = KlockStatusOutput.render( + snapshot: makeSnapshot(isLocked: false, startedAt: nil, autoUnlockTargetDate: nil) + ) + + XCTAssertEqual( + output, + #"{"autoUnlockTargetDate":null,"locked":false,"startedAt":null,"unlockHotkey":"⌃⌘L"}"# + ) + } + + private func makeSnapshot( + isLocked: Bool, + startedAt: Date?, + autoUnlockTargetDate: Date? + ) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 1_700_000_030), + isLocked: isLocked, + startedAt: startedAt, + autoUnlockTargetDate: autoUnlockTargetDate, + settings: KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: 37, + modifierFlags: [.maskCommand, .maskControl] + ) + ) + ) + } + private func decodedLockedValue(from output: String) throws -> Bool? { let object = try JSONSerialization.jsonObject(with: Data(output.utf8)) return (object as? [String: Bool])?["locked"] diff --git a/docs/automation.md b/docs/automation.md index 2a35683..135cbfd 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -134,6 +134,7 @@ klock lock --no-wait ```bash klock status klock status --json +klock status --snapshot klock unlock klock toggle klock register-agent @@ -144,6 +145,8 @@ klock request-access `request-access` 先报告已授权状态;未授权时经 Agent 触发系统 Accessibility prompt,随后有界轮询(默认 30s)并如实报告终态:授权打印 granted 退出 0,窗口内未授权打印 pending 说明退出 1(stdout 文本,区别于 stderr 的 Error)。prompt 是异步的,请求返回不代表已授权。 +`status --snapshot` 输出扩展 JSON contract(`locked` / `startedAt` / `autoUnlockTargetDate` / `unlockHotkey`,键按字母序固定,日期为 ISO-8601 GMT,无值日期为 `null`);`--json` 的单行 contract 保持字节稳定。老 Agent 无 snapshot capability 时命令显式报错,不回退用 `status` 伪造字段。 + `register-agent` 在 Agent 不可达时启动一次 KeyboardLocker App 来完成 `SMAppService` 注册(注册只能由 App bundle 执行),随后短暂轮询确认 Agent 可达;若出现 Login Items 批准或 Accessibility 授权要求,命令会指出对应的系统设置入口。Agent 已经可达时它不启动 App,直接报告。 `status` 默认输出供人阅读的 `Locked` 或 `Unlocked`。自动化使用 `status --json`,其稳定 contract 是单行 `{"locked":true}` 或 `{"locked":false}`。两种状态都表示查询成功并返回 exit code `0`;参数或 Agent 调用失败返回 `1`,因此脚本不需要把 unlocked 与 transport failure 混在同一个 exit code 中。 diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index f039d8f..5344c48 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -10,6 +10,7 @@ protocol KlockClientServing { func unlock() async throws func toggle() async throws -> Bool func status() async throws -> Bool + func lockStatusSnapshot() async throws -> LockStatusSnapshot func waitUntilUnlocked() async throws func hasAccessibilityPermission() async throws -> Bool func requestAccessibilityPermission() async throws @@ -336,8 +337,12 @@ enum KlockCLI { printError: (String) -> Void ) async -> Int32 { do { - let isLocked = try await client.status() - printOut(output.render(isLocked: isLocked)) + switch output { + case .humanReadable, .json: + printOut(output.render(isLocked: try await client.status())) + case .snapshot: + printOut(KlockStatusOutput.render(snapshot: try await client.lockStatusSnapshot())) + } return ExitCode.success } catch { reportFailure(error, printError: printError) @@ -403,6 +408,7 @@ enum KlockCLI { OPTIONS: --no-wait Return after lock is confirmed; do not enable Ctrl+C unlock. --json Emit a stable JSON object for status automation. + --snapshot Emit the full lock snapshot as JSON. -h, --help Show this help message. -v, --version Show the klock version. """ diff --git a/klock/KlockCommand.swift b/klock/KlockCommand.swift index 1dd8bfe..41026ca 100644 --- a/klock/KlockCommand.swift +++ b/klock/KlockCommand.swift @@ -70,6 +70,9 @@ enum KlockCommandLineParser { case ["--json"]: return .status(output: .json) + case ["--snapshot"]: + return .status(output: .snapshot) + case let unexpectedArguments: throw KlockCommandLineError.unexpectedArguments(unexpectedArguments) } diff --git a/klock/KlockStatusOutput.swift b/klock/KlockStatusOutput.swift index 9c95659..5f02168 100644 --- a/klock/KlockStatusOutput.swift +++ b/klock/KlockStatusOutput.swift @@ -1,8 +1,10 @@ +import Client import Foundation enum KlockStatusOutput: Equatable { case humanReadable case json + case snapshot func render(isLocked: Bool) -> String { switch self { @@ -11,6 +13,34 @@ enum KlockStatusOutput: Equatable { case .json: "{\"locked\":\(isLocked ? "true" : "false")}" + + case .snapshot: + preconditionFailure("snapshot output renders from the full LockStatusSnapshot") } } + + /// The `--snapshot` payload is a separate, richer contract: `--json` stays byte-stable. + /// Keys are emitted in alphabetical order and dates as ISO-8601 in GMT (absent dates are + /// `null`) so automation can compare output verbatim. + static func render(snapshot: LockStatusSnapshot) -> String { + let object: [String: Any] = [ + "autoUnlockTargetDate": snapshot.autoUnlockTargetDate.map(iso8601String) ?? NSNull(), + "locked": snapshot.isLocked, + "startedAt": snapshot.startedAt.map(iso8601String) ?? NSNull(), + "unlockHotkey": snapshot.settings.unlockHotkey.displayString, + ] + // Only strings, a Boolean, and NSNull reach this point, so serialization cannot fail. + let data = try! JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + return String(decoding: data, as: UTF8.self) + } + + private static func iso8601String(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(identifier: "GMT") + return formatter.string(from: date) + } } From 3b76f6d641d4f39ff5effa88c92e78200f7ac44b Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 16:57:52 +0800 Subject: [PATCH 091/106] fix(klock): arbitrate termination during locking Install signal handling before the interactive mutation so termination cannot race past lock acquisition. Resolve acquisition, wait completion, and bounded cleanup through one terminal result without unlocking a lock owned by another caller. --- .../KlockCommandLineTests.swift | 104 +++++++++- klock/KlockCLI.swift | 49 +++-- klock/KlockTerminationGuard.swift | 182 ++++++++++++++++++ 3 files changed, 312 insertions(+), 23 deletions(-) diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index e6532f5..060799b 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -490,7 +490,46 @@ final class KlockCommandLineTests: XCTestCase { XCTAssertEqual(terminationGuard.cancelCalls, 1) } - func testInteractiveLockAlreadyLockedSkipsTerminationGuard() async { + func testSignalDuringAcquisitionReleasesConfirmedLockBeforeTerminating() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + client.onLockInteractively = { + terminationGuard.send(SIGTERM) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 128 + SIGTERM) + XCTAssertEqual(client.unlockCalls, 1) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) + } + + func testSignalDuringUnlockWaitWinsOverConcurrentNormalCompletion() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + client.onWaitUntilUnlocked = { + terminationGuard.send(SIGTERM) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 128 + SIGTERM) + XCTAssertEqual(client.waitUntilUnlockedCalls, 1) + XCTAssertEqual(client.unlockCalls, 1) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) + } + + func testInteractiveLockAlreadyLockedInstallsAndCancelsTerminationGuard() async { let client = FakeKlockClient() client.lockInteractivelyResult = .success(.alreadyLocked) let terminationGuard = FakeKlockTerminationGuard() @@ -502,10 +541,12 @@ final class KlockCommandLineTests: XCTestCase { ) XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(terminationGuard.installCalls, 0) + XCTAssertEqual(client.unlockCalls, 0) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) } - func testInteractiveLockFailureSkipsTerminationGuard() async { + func testInteractiveLockFailureInstallsAndCancelsTerminationGuard() async { let client = FakeKlockClient() client.lockInteractivelyResult = .failure(Self.makeError("lock failed")) let terminationGuard = FakeKlockTerminationGuard() @@ -517,7 +558,49 @@ final class KlockCommandLineTests: XCTestCase { ) XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(terminationGuard.installCalls, 0) + XCTAssertEqual(client.unlockCalls, 0) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) + } + + func testSignalDuringAlreadyLockedAcquisitionDoesNotUnlockExistingLock() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .success(.alreadyLocked) + let terminationGuard = FakeKlockTerminationGuard() + client.onLockInteractively = { + terminationGuard.send(SIGTERM) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 128 + SIGTERM) + XCTAssertEqual(client.unlockCalls, 0) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) + } + + func testSignalDuringFailedAcquisitionDoesNotUnlock() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .failure(Self.makeError("lock failed")) + let terminationGuard = FakeKlockTerminationGuard() + client.onLockInteractively = { + terminationGuard.send(SIGHUP) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + XCTAssertEqual(result.exitCode, 128 + SIGHUP) + XCTAssertEqual(client.unlockCalls, 0) + XCTAssertEqual(terminationGuard.installCalls, 1) + XCTAssertEqual(terminationGuard.cancelCalls, 1) } func testInteractiveLockWaitFailureCancelsTerminationGuard() async { @@ -642,6 +725,8 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { /// Same scripting seam as `statusResults`: drives the request-access pre-check and polls. var hasAccessibilityPermissionResults: [Result]? var requestAccessibilityPermissionResult: Result = .success(()) + var onLockInteractively: (() -> Void)? + var onWaitUntilUnlocked: (() -> Void)? private(set) var currentSettingsCalls = 0 private(set) var lockInteractivelyCalls = 0 @@ -661,6 +746,7 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { func lockInteractively() async throws -> LockRequestOutcome { lockInteractivelyCalls += 1 + onLockInteractively?() return try lockInteractivelyResult.get() } @@ -697,6 +783,7 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { func waitUntilUnlocked() async throws { waitUntilUnlockedCalls += 1 + onWaitUntilUnlocked?() try waitUntilUnlockedResult.get() } @@ -717,13 +804,20 @@ private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { private final class FakeKlockTerminationGuard: KlockTerminationGuarding, @unchecked Sendable { private(set) var installCalls = 0 private(set) var cancelCalls = 0 + private var onTermination: ((Int32) -> Void)? - func install(onTermination _: @escaping (Int32) -> Void) -> () -> Void { + func install(onTermination: @escaping (Int32) -> Void) -> () -> Void { installCalls += 1 + self.onTermination = onTermination return { [self] in cancelCalls += 1 + self.onTermination = nil } } + + func send(_ signal: Int32) { + onTermination?(signal) + } } /// Default guard for tests that do not exercise the termination path: never touches process diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index 5344c48..ee6c171 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -131,34 +131,41 @@ enum KlockCLI { unlockHotkey = .failure(error) } + // Observe termination before sending the mutation. If a signal arrives while the XPC reply is + // in flight, the coordinator waits for the atomic acquisition outcome before deciding whether + // cleanup may touch the global lock. + let terminationCoordinator = KlockInteractiveTerminationCoordinator( + cleanup: { + await Self.unlockBeforeTermination(client: client, printError: printError) + } + ) + let cancelTerminationGuard = terminationGuard.install { signal in + terminationCoordinator.receive(signal: signal) + } + defer { + cancelTerminationGuard() + } + let outcome: LockRequestOutcome do { outcome = try await client.lockInteractively() } catch { + if let terminationExitCode = terminationCoordinator.resolveWithoutAcquisition() { + return terminationExitCode + } reportFailure(error, printError: printError) return ExitCode.error } guard outcome == .acquired else { + if let terminationExitCode = terminationCoordinator.resolveWithoutAcquisition() { + return terminationExitCode + } printOut("Already locked. This command did not create a new lock.") return ExitCode.success } - - // The wait below keeps this process responsible for the lock it just created: a covered - // termination signal (terminal closed, kill) must release it rather than leave the keyboard - // locked with no visible owner. Ctrl+C never reaches the process — the Agent consumes it - // as an unlock gesture — so SIGINT is only a fallback for a tap that failed to engage. - let cancelTerminationGuard = terminationGuard.install { signal in - let finished = DispatchSemaphore(value: 0) - Task { - await Self.unlockBeforeTermination(client: client, printError: printError) - finished.signal() - } - _ = finished.wait(timeout: .now() + .seconds(2)) - exit(128 + signal) - } - defer { - cancelTerminationGuard() + if let terminationExitCode = await terminationCoordinator.resolveAcquired() { + return terminationExitCode } switch unlockHotkey { @@ -173,13 +180,19 @@ enum KlockCLI { ) } - do { + switch await terminationCoordinator.waitUntilUnlocked({ try await client.waitUntilUnlocked() + }) { + case .unlocked: printOut("Unlocked.") return ExitCode.success - } catch { + + case let .failed(error): reportFailure(error, printError: printError) return ExitCode.error + + case let .signaled(exitCode): + return exitCode } } diff --git a/klock/KlockTerminationGuard.swift b/klock/KlockTerminationGuard.swift index 4e46517..0a5317a 100644 --- a/klock/KlockTerminationGuard.swift +++ b/klock/KlockTerminationGuard.swift @@ -8,6 +8,188 @@ protocol KlockTerminationGuarding { func install(onTermination: @escaping (Int32) -> Void) -> () -> Void } +/// Reconciles process termination with interactive acquisition and the subsequent unlock wait. +/// The first accepted signal owns the command result, so a concurrent normal wait completion can +/// never return success or failure before bounded signal cleanup finishes. +final class KlockInteractiveTerminationCoordinator: @unchecked Sendable { + enum WaitResult { + case failed(any Error) + case signaled(Int32) + case unlocked + } + + typealias Cleanup = () async -> Void + + private enum Phase { + case acquiring + case acquired + case finished + } + + private struct State { + var phase = Phase.acquiring + var signal: Int32? + var signalReady = false + var waitContinuation: CheckedContinuation? + } + + private static let cleanupTimeout: Duration = .seconds(2) + + private let cleanup: Cleanup + private let lock = NSLock() + private var state = State() + + init(cleanup: @escaping Cleanup) { + self.cleanup = cleanup + } + + /// Records only the first signal. During acquisition it remains disarmed until the Agent reply + /// establishes whether cleanup may touch the global lock. + func receive(signal: Int32) { + let shouldStartCleanup = lock.withLock { + guard state.phase != .finished, state.signal == nil else { + return false + } + state.signal = signal + guard state.phase == .acquired else { + return false + } + return true + } + + if shouldStartCleanup { + Task { [self] in + await finishSignal(signal) + } + } + } + + /// Atomically closes an acquisition that did not create a lock. A signal already accepted in + /// the acquisition window wins; otherwise later signals are ignored by the finished command. + func resolveWithoutAcquisition() -> Int32? { + lock.withLock { + guard state.phase == .acquiring else { + return nil + } + state.phase = .finished + return state.signal.map { 128 + $0 } + } + } + + /// Arms cleanup after confirmed acquisition and handles a signal retained from the request. + func resolveAcquired() async -> Int32? { + let pendingSignal = lock.withLock { () -> Int32? in + guard state.phase == .acquiring else { + return nil + } + state.phase = .acquired + guard let signal = state.signal else { + return nil + } + return signal + } + + guard let pendingSignal else { + return nil + } + await waitForCleanupOrTimeout() + lock.withLock { state.phase = .finished } + return 128 + pendingSignal + } + + /// Races the normal Agent wait against termination without structured-concurrency cancellation + /// holding the command open. Once `receive(signal:)` accepts a signal, normal completion loses. + func waitUntilUnlocked( + _ operation: @escaping @Sendable () async throws -> Void + ) async -> WaitResult { + await withCheckedContinuation { continuation in + let preparation = lock.withLock { () -> (WaitResult?, Bool) in + guard state.phase == .acquired else { + return (.unlocked, false) + } + if state.signalReady, let signal = state.signal { + state.phase = .finished + return (.signaled(128 + signal), false) + } + state.waitContinuation = continuation + return (nil, state.signal == nil) + } + + if let result = preparation.0 { + continuation.resume(returning: result) + } else if preparation.1 { + Task { [self] in + do { + try await operation() + finishWait(.unlocked) + } catch { + finishWait(.failed(error)) + } + } + } + } + } + + private func finishSignal(_ signal: Int32) async { + await waitForCleanupOrTimeout() + let continuation = lock.withLock { () -> CheckedContinuation? in + state.signalReady = true + guard let continuation = state.waitContinuation else { + return nil + } + state.phase = .finished + state.waitContinuation = nil + return continuation + } + continuation?.resume(returning: .signaled(128 + signal)) + } + + private func finishWait(_ result: WaitResult) { + let continuation = lock.withLock { () -> CheckedContinuation? in + guard state.phase == .acquired, state.signal == nil else { + return nil + } + state.phase = .finished + defer { state.waitContinuation = nil } + return state.waitContinuation + } + continuation?.resume(returning: result) + } + + /// Returns as soon as cleanup completes or after the hard deadline. The losing unstructured + /// task may finish later, but `CleanupWaiter` makes continuation resumption one-shot. + private func waitForCleanupOrTimeout() async { + await withCheckedContinuation { continuation in + let waiter = CleanupWaiter(continuation: continuation) + Task { [self] in + await cleanup() + waiter.finish() + } + Task { + try? await Task.sleep(for: Self.cleanupTimeout) + waiter.finish() + } + } + } +} + +private final class CleanupWaiter: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func finish() { + let continuation = lock.withLock { + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume() + } +} + /// Watches SIGTERM/SIGHUP/SIGINT via GCD signal sources, whose handlers run on a dispatch /// queue and may therefore perform ordinary work (unlike raw `signal()` handlers). /// From c4c8312da6cdc30092b93dff4f58dfd1b4425a70 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 16:58:02 +0800 Subject: [PATCH 092/106] fix(client): enforce selector protocol versions Reject incompatible protocol majors before interpreting capability names, and require each optional selector's introduction minor before invocation. Keep capability checks bound to the descriptor's connection generation. --- Core/Sources/Client/XPCClient.swift | 60 +++++++++- .../ClientTests/XPCClientErrorTests.swift | 110 ++++++++++++++++++ 2 files changed, 164 insertions(+), 6 deletions(-) diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 41847b2..3b67e02 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -369,6 +369,11 @@ public final class XPCClient: @unchecked Sendable { let connection = try currentConnection() let descriptor = try await serviceDescriptor(using: connection) + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: capabilities + ) + if let expectedAgentInstanceID, descriptor.agentInstanceID != expectedAgentInstanceID { throw XPCClientError.agentChanged( @@ -376,12 +381,6 @@ public final class XPCClient: @unchecked Sendable { actual: descriptor.agentInstanceID ) } - if let missingCapability = capabilities - .subtracting(descriptor.capabilities) - .sorted(by: { $0.rawValue < $1.rawValue }) - .first { - throw XPCClientError.missingCapability(missingCapability) - } return connection } @@ -515,6 +514,55 @@ public final class XPCClient: @unchecked Sendable { } } +/// Validates one optional selector grant before its negotiated connection is used. +/// +/// Capability names are only meaningful within the protocol major that defined them. The minor +/// floor prevents an inconsistent descriptor from granting a selector before its wire shape was +/// introduced, even if it advertises the capability string. +enum XPCFeatureNegotiation { + private static let minimumMinorByCapability: [ServiceCapability: Int] = [ + .accessibilityPrompt: 0, + .accessibilityStatus: 0, + .committedReplacementDrain: 1, + .currentSettings: 1, + .currentSettingsWithError: 2, + .focusFilterLock: 5, + .interactiveLock: 3, + .lockControl: 0, + .lockStatusSnapshot: 4, + .lockToggle: 6, + .prepareForReplacement: 0, + ] + + static func validate( + descriptor: ServiceDescriptor, + requiring capabilities: Set + ) throws { + let expectedMajor = ServiceContract.protocolVersion.major + guard descriptor.protocolVersion.major == expectedMajor else { + throw ServiceCompatibilityIssue.protocolMajorMismatch( + expected: expectedMajor, + actual: descriptor.protocolVersion.major + ) + } + + let requiredMinor = capabilities.compactMap { minimumMinorByCapability[$0] }.max() ?? 0 + guard descriptor.protocolVersion.minor >= requiredMinor else { + throw ServiceCompatibilityIssue.protocolMinorTooOld( + required: requiredMinor, + actual: descriptor.protocolVersion.minor + ) + } + + if let missingCapability = capabilities + .subtracting(descriptor.capabilities) + .sorted(by: { $0.rawValue < $1.rawValue }) + .first { + throw XPCClientError.missingCapability(missingCapability) + } + } +} + /// Foundation documents `NSXPCConnection` as supporting calls from multiple threads. This box /// makes that external synchronization contract explicit when a connection is shared with the /// timeout task that can invalidate it. diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index 6dd8f95..bb3778f 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -45,4 +45,114 @@ final class XPCClientErrorTests: XCTestCase { XCTAssertTrue(suggestion.contains("Inspect the current state")) XCTAssertTrue(suggestion.contains("repeat the intended lock or unlock action")) } + + func testFeatureNegotiationRejectsProtocolMajorBeforeCapabilities() { + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 2, minor: 99), + capabilities: [] + ) + + XCTAssertThrowsError( + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.lockToggle] + ) + ) { error in + XCTAssertEqual( + error as? ServiceCompatibilityIssue, + .protocolMajorMismatch(expected: 1, actual: 2) + ) + } + } + + func testFeatureNegotiationEnforcesSelectorIntroductionMinor() { + let introductionMinorByCapability: [ServiceCapability: Int] = [ + .accessibilityPrompt: 0, + .accessibilityStatus: 0, + .committedReplacementDrain: 1, + .currentSettings: 1, + .currentSettingsWithError: 2, + .focusFilterLock: 5, + .interactiveLock: 3, + .lockControl: 0, + .lockStatusSnapshot: 4, + .lockToggle: 6, + .prepareForReplacement: 0, + ] + + for (capability, introductionMinor) in introductionMinorByCapability { + guard introductionMinor > 0 else { + continue + } + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion( + major: ServiceContract.protocolVersion.major, + minor: introductionMinor - 1 + ), + capabilities: [capability] + ) + + XCTAssertThrowsError( + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [capability] + ), + "Expected \(capability.rawValue) to require protocol minor \(introductionMinor)." + ) { error in + XCTAssertEqual( + error as? ServiceCompatibilityIssue, + .protocolMinorTooOld(required: introductionMinor, actual: introductionMinor - 1) + ) + } + } + } + + func testFeatureNegotiationAcceptsDeclaredCapabilityAtIntroductionMinor() { + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion( + major: ServiceContract.protocolVersion.major, + minor: 3 + ), + capabilities: [.interactiveLock] + ) + + XCTAssertNoThrow( + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.interactiveLock] + ) + ) + } + + func testFeatureNegotiationChecksCapabilityAfterCompatibleVersion() { + let descriptor = makeDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: [] + ) + + XCTAssertThrowsError( + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.interactiveLock] + ) + ) { error in + guard case XPCClientError.missingCapability(.interactiveLock) = error else { + return XCTFail("Expected missing interactive-lock capability, got \(error).") + } + } + } + + private func makeDescriptor( + protocolVersion: ServiceProtocolVersion, + capabilities: Set + ) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: protocolVersion, + capabilities: capabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: UUID() + ) + } } From 77d2ac4e99fb0e432885261a2ac1a16eae30716c Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 16:58:17 +0800 Subject: [PATCH 093/106] refactor(service): make AgentService own runtime Create the process-lifetime LockEngine and notifier at the Agent composition root, then export the same service to every connection. Remove language-level singletons and keep implementation-only service types internal while preserving test injection seams. --- Core/Sources/Service/AgentService.swift | 17 ++++++- .../Service/KeyboardLockerSettingsStore.swift | 6 +-- Core/Sources/Service/LockEngine.swift | 48 ++++++++++--------- Core/Sources/Service/LockStatusNotifier.swift | 13 +---- .../Service/ReplacementTransaction.swift | 28 +++++------ .../KeyboardLockerSettingsStoreTests.swift | 2 +- Core/Tests/ServiceTests/LockEngineTests.swift | 15 +++++- .../ReplacementTransactionTests.swift | 2 +- KeyboardLockerAgent/main.swift | 6 +-- 9 files changed, 77 insertions(+), 60 deletions(-) diff --git a/Core/Sources/Service/AgentService.swift b/Core/Sources/Service/AgentService.swift index 4a7b8d3..221f787 100644 --- a/Core/Sources/Service/AgentService.swift +++ b/Core/Sources/Service/AgentService.swift @@ -2,7 +2,7 @@ import Common import Foundation /// The `LockEngine` surface `AgentService` drives. Seamed so ServiceTests can substitute a -/// deterministic double; production wires the real singleton. +/// deterministic double; production creates one engine owned by the process-wide service. @MainActor protocol LockEngineServing { var isLocked: Bool { get } @@ -29,6 +29,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { @MainActor private let descriptorResult: Result @MainActor private let settings: KeyboardLockerSettings @MainActor private let engine: any LockEngineServing + @MainActor private var lockStatusNotifier: LockStatusNotifier? @MainActor private let expirationScheduler: MainActorTimerScheduler @MainActor private var replacement = ReplacementTransaction() @MainActor private var replacementPreparationCancellation: (() -> Void)? @@ -51,12 +52,24 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { @MainActor public convenience override init() { + let engine = LockEngine(dependencies: .live) + let notifier = LockStatusNotifier( + notifications: LiveLockStatusNotificationService(), + snapshot: { engine.statusSnapshot }, + unlock: { engine.unlock() } + ) + engine.setStateChangeHandler { [weak notifier] in + notifier?.lockStateDidChange() + } + self.init( descriptorResult: Result { try Self.makeServiceDescriptor() }, settings: KeyboardLockerSettingsStore().load(), - engine: LockEngine.shared, + engine: engine, expirationScheduler: liveMainActorTimerScheduler ) + lockStatusNotifier = notifier + notifier.start() } // MARK: - Bootstrap diff --git a/Core/Sources/Service/KeyboardLockerSettingsStore.swift b/Core/Sources/Service/KeyboardLockerSettingsStore.swift index a3fb226..10f352c 100644 --- a/Core/Sources/Service/KeyboardLockerSettingsStore.swift +++ b/Core/Sources/Service/KeyboardLockerSettingsStore.swift @@ -2,7 +2,7 @@ import Foundation import os /// Persists `KeyboardLockerSettings` to `UserDefaults` -public final class KeyboardLockerSettingsStore { +final class KeyboardLockerSettingsStore { private static let logger = Logger( subsystem: SharedConstants.machServiceName, category: "SettingsStore" @@ -13,7 +13,7 @@ public final class KeyboardLockerSettingsStore { private let encoder = JSONEncoder() private let decoder = JSONDecoder() - public init( + init( userDefaults: UserDefaults = .standard, storageKey: String = "keyboardlocker.settings" ) { @@ -27,7 +27,7 @@ public final class KeyboardLockerSettingsStore { /// A present-but-undecodable payload means the stored bytes no longer match the schema. /// The Agent stays functional by falling back to defaults, but the corruption is always /// logged instead of being silently swallowed. - public func load() -> KeyboardLockerSettings { + func load() -> KeyboardLockerSettings { guard let data = userDefaults.data(forKey: storageKey) else { return .default } diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 192ab1d..1bc054a 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -279,7 +279,6 @@ protocol InstalledEventTap: AnyObject { /// Every side effect `LockEngine` performs on the outside world, injected so `ServiceTests` /// can drive the engine without Quartz, TCC, real timers, or system notifications. -/// `LockEngine.shared` runs on `.live`. struct LockEngineDependencies { var hasAccessibilityPermission: @MainActor () -> Bool var installEventTap: @MainActor (LockEngine) throws -> any InstalledEventTap @@ -292,13 +291,7 @@ struct LockEngineDependencies { hasAccessibilityPermission: { AccessibilityManager.hasPermission() }, installEventTap: { engine in try CoreGraphicsEventTap.install(engine: engine) }, scheduleTimer: liveMainActorTimerScheduler, - broadcastStateChange: { - LockStateBroadcaster.broadcast() - // Both call sites run on the main actor after the state mutation completed. - MainActor.assumeIsolated { - LockStatusNotifier.shared.lockStateDidChange() - } - }, + broadcastStateChange: { LockStateBroadcaster.broadcast() }, now: { Date() } ) } @@ -402,18 +395,16 @@ private final class CoreGraphicsEventTap: InstalledEventTap { } @MainActor -public final class LockEngine { - public static let shared = LockEngine() - +final class LockEngine { static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "LockEngine") - public enum LockEngineError: Error, LocalizedError { + enum LockEngineError: Error, LocalizedError { case accessibilityPermissionDenied case eventTapCreationFailed case eventTapEnableFailed case runLoopSourceCreationFailed - public var errorDescription: String? { + var errorDescription: String? { switch self { case .accessibilityPermissionDenied: "Accessibility permission is required to lock keyboard input." @@ -426,7 +417,7 @@ public final class LockEngine { } } - public var recoverySuggestion: String? { + var recoverySuggestion: String? { switch self { case .accessibilityPermissionDenied: "Open System Settings → Privacy & Security → Accessibility and enable access for the KeyboardLocker agent." @@ -454,23 +445,29 @@ public final class LockEngine { private var eventTapGeneration: UInt64 = 0 private var cancelScheduledAutoUnlock: (() -> Void)? private var runtimeState = LockRuntimeState() + private var stateChangeHandler: () -> Void = {} - public var isLocked: Bool { + var isLocked: Bool { runtimeState.isLocked } /// One coherent Agent-owned snapshot for read-only wrappers and presentation surfaces. - public var statusSnapshot: LockStatusSnapshot { + var statusSnapshot: LockStatusSnapshot { runtimeState.statusSnapshot(capturedAt: dependencies.now()) } - /// `shared` runs on live system dependencies; tests inject deterministic fakes. init(dependencies: LockEngineDependencies = .live) { self.dependencies = dependencies } + /// Wires Agent-owned presentation after both the engine and its observer exist. The production + /// composition root installs exactly one handler before exposing the XPC listener. + func setStateChangeHandler(_ handler: @escaping () -> Void) { + stateChangeHandler = handler + } + @discardableResult - public func lock( + func lock( settings: KeyboardLockerSettings = .default, allowsControlCUnlock: Bool = false ) throws -> LockRequestOutcome { @@ -483,7 +480,7 @@ public final class LockEngine { /// Applies the Focus Filter's desired state without giving Focus ownership of a pre-existing /// global lock. Deactivation releases only the exact lock generation created by Focus. - public func setFocusFilterLockEnabled( + func setFocusFilterLockEnabled( _ enabled: Bool, settings: KeyboardLockerSettings = .default ) throws { @@ -535,7 +532,7 @@ public final class LockEngine { /// Updates the active settings. If a lock is running, changes take effect immediately /// (e.g. a new auto-unlock timeout re-arms the timer); otherwise they seed the next lock. - public func updateSettings(_ settings: KeyboardLockerSettings) { + func updateSettings(_ settings: KeyboardLockerSettings) { runtimeState.updateSettings(settings) if runtimeState.isLocked { @@ -550,7 +547,7 @@ public final class LockEngine { private func markLocked() { configureAutoUnlockTimerIfNeeded() - dependencies.broadcastStateChange() + publishStateChange() Self.logger.info("Locked") } @@ -589,7 +586,7 @@ public final class LockEngine { runtimeState.setAutoUnlockTargetDate(nil) } - public func unlock() { + func unlock() { guard runtimeState.isLocked else { return } @@ -614,10 +611,15 @@ public final class LockEngine { private func resetLockState() { runtimeState.end() - dependencies.broadcastStateChange() + publishStateChange() Self.logger.info("Unlocked") } + private func publishStateChange() { + dependencies.broadcastStateChange() + stateChangeHandler() + } + func handleDisabledEvent(_ event: CGEvent) -> Unmanaged? { guard runtimeState.isLocked, let tap = installedTap else { return Unmanaged.passUnretained(event) diff --git a/Core/Sources/Service/LockStatusNotifier.swift b/Core/Sources/Service/LockStatusNotifier.swift index bcaa542..49687de 100644 --- a/Core/Sources/Service/LockStatusNotifier.swift +++ b/Core/Sources/Service/LockStatusNotifier.swift @@ -31,20 +31,11 @@ protocol LockStatusNotifying: AnyObject, Sendable { /// The notification is a presentation-only convenience: it carries a presentation cache of the /// unlock affordances, never authoritative state, and a denied authorization degrades silently. @MainActor -public final class LockStatusNotifier { +final class LockStatusNotifier { static let notificationIdentifier = "keyboard-locked" static let categoryIdentifier = "KEYBOARD_LOCKED" static let unlockActionIdentifier = "UNLOCK_NOW" - /// Process-wide instance wired to the shared engine. Created lazily; `KeyboardLockerAgent` - /// calls `start()` during bootstrap so the action category and stale-notification cleanup - /// are in place before any lock can be created. - public static let shared = LockStatusNotifier( - notifications: LiveLockStatusNotificationService(), - snapshot: { LockEngine.shared.statusSnapshot }, - unlock: { LockEngine.shared.unlock() } - ) - private let notifications: any LockStatusNotifying private let snapshot: @MainActor () -> LockStatusSnapshot private let unlock: @MainActor () -> Void @@ -67,7 +58,7 @@ public final class LockStatusNotifier { /// Installs the action category and removes any notification left behind by a previous Agent /// generation — its exit already released the lock, so the leftover can only be stale. - public func start() { + func start() { notifications.installUnlockActionCategory() enqueue { [notifications] in notifications.removeLocked(identifier: LockStatusNotifier.notificationIdentifier) diff --git a/Core/Sources/Service/ReplacementTransaction.swift b/Core/Sources/Service/ReplacementTransaction.swift index 04fc1e5..c5f2632 100644 --- a/Core/Sources/Service/ReplacementTransaction.swift +++ b/Core/Sources/Service/ReplacementTransaction.swift @@ -4,9 +4,9 @@ import Foundation /// Server-side state machine for replacing the launchd-managed Agent. /// /// The caller must serialize access. `AgentService` does this on the main queue. -public struct ReplacementTransaction { - public struct Preparation: Equatable, Sendable { - public let ticket: ServiceReplacementTicket +struct ReplacementTransaction { + struct Preparation: Equatable, Sendable { + let ticket: ServiceReplacementTicket fileprivate let generation: UInt64 } @@ -20,9 +20,9 @@ public struct ReplacementTransaction { private var generation: UInt64 = 0 private var phase: Phase = .idle - public init() {} + init() {} - public var isPending: Bool { + var isPending: Bool { switch phase { case .committed, .prepared: true @@ -31,14 +31,14 @@ public struct ReplacementTransaction { } } - public var isCommitted: Bool { + var isCommitted: Bool { if case .committed = phase { return true } return false } - public var servicePhase: ServiceReplacementPhase { + var servicePhase: ServiceReplacementPhase { switch phase { case .committed: .committed @@ -49,7 +49,7 @@ public struct ReplacementTransaction { } } - public mutating func prepare( + mutating func prepare( ticket: ServiceReplacementTicket ) throws -> Preparation { guard case .idle = phase else { @@ -63,7 +63,7 @@ public struct ReplacementTransaction { } /// Moves the drain into its fail-closed phase. Repeating the same commit is idempotent. - public mutating func commit(ticket: ServiceReplacementTicket) throws { + mutating func commit(ticket: ServiceReplacementTicket) throws { switch phase { case let .prepared(preparation) where preparation.ticket == ticket: phase = .committed(ticket) @@ -76,7 +76,7 @@ public struct ReplacementTransaction { } } - public func status(for ticket: ServiceReplacementTicket) -> ServiceReplacementStatus { + func status(for ticket: ServiceReplacementTicket) -> ServiceReplacementStatus { let statusPhase: ServiceReplacementPhase = switch phase { case let .committed(activeTicket) where activeTicket == ticket: .committed @@ -89,7 +89,7 @@ public struct ReplacementTransaction { } /// Cancels only work that has not yet been committed to Service Management. - public mutating func cancel(ticket: ServiceReplacementTicket) throws { + mutating func cancel(ticket: ServiceReplacementTicket) throws { switch phase { case let .prepared(preparation) where preparation.ticket == ticket: phase = .idle @@ -104,7 +104,7 @@ public struct ReplacementTransaction { /// Expires exactly the scheduled preparation. A stale timer cannot clear newer state. @discardableResult - public mutating func expire( + mutating func expire( preparation: Preparation ) -> Bool { guard case let .prepared(activePreparation) = phase, @@ -118,12 +118,12 @@ public struct ReplacementTransaction { } } -public enum ReplacementTransactionError: Error, Equatable, LocalizedError { +enum ReplacementTransactionError: Error, Equatable, LocalizedError { case alreadyInProgress case committedCannotCancel case ticketInactive - public var errorDescription: String? { + var errorDescription: String? { switch self { case .alreadyInProgress: "Another agent replacement is already in progress." diff --git a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift index 24c8961..20c6a6d 100644 --- a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift +++ b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift @@ -1,5 +1,5 @@ import Common -import Service +@testable import Service import XCTest final class KeyboardLockerSettingsStoreTests: XCTestCase { diff --git a/Core/Tests/ServiceTests/LockEngineTests.swift b/Core/Tests/ServiceTests/LockEngineTests.swift index dbb7adb..2be9a8f 100644 --- a/Core/Tests/ServiceTests/LockEngineTests.swift +++ b/Core/Tests/ServiceTests/LockEngineTests.swift @@ -9,6 +9,7 @@ final class LockEngineTests: XCTestCase { private var scheduler: ManualTimerScheduler! private var installCount = 0 private var broadcastCount = 0 + private var stateChangeCount = 0 private var hasAccessibilityPermission = true private var now: Date! @@ -18,6 +19,7 @@ final class LockEngineTests: XCTestCase { scheduler = ManualTimerScheduler() installCount = 0 broadcastCount = 0 + stateChangeCount = 0 hasAccessibilityPermission = true now = Date(timeIntervalSinceReferenceDate: 1_000) } @@ -252,6 +254,15 @@ final class LockEngineTests: XCTestCase { XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) } + func testStateChangeHandlerFollowsCommittedLockTransitions() throws { + let engine = makeEngine() + + _ = try engine.lock(settings: makeSettings()) + engine.unlock() + + XCTAssertEqual(stateChangeCount, 2) + } + // MARK: - Focus ownership func testFocusDeactivationReleasesFocusOwnedLock() throws { @@ -282,7 +293,7 @@ final class LockEngineTests: XCTestCase { // MARK: - Helpers private func makeEngine() -> LockEngine { - LockEngine(dependencies: LockEngineDependencies( + let engine = LockEngine(dependencies: LockEngineDependencies( hasAccessibilityPermission: { self.hasAccessibilityPermission }, installEventTap: { _ in self.installCount += 1 @@ -292,6 +303,8 @@ final class LockEngineTests: XCTestCase { broadcastStateChange: { self.broadcastCount += 1 }, now: { self.now } )) + engine.setStateChangeHandler { self.stateChangeCount += 1 } + return engine } private func makeSettings( diff --git a/Core/Tests/ServiceTests/ReplacementTransactionTests.swift b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift index ec7ee80..87c8fd2 100644 --- a/Core/Tests/ServiceTests/ReplacementTransactionTests.swift +++ b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift @@ -1,5 +1,5 @@ import Common -import Service +@testable import Service import XCTest final class ReplacementTransactionTests: XCTestCase { diff --git a/KeyboardLockerAgent/main.swift b/KeyboardLockerAgent/main.swift index 35ffed0..870bf50 100644 --- a/KeyboardLockerAgent/main.swift +++ b/KeyboardLockerAgent/main.swift @@ -22,11 +22,9 @@ private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { @MainActor private func startAgent() { // One process-wide service owns the authoritative settings, replacement transaction, and - // LockEngine. Every accepted connection receives only a proxy to this same instance. + // LockEngine, including notification presentation. Every accepted connection receives only a + // proxy to this same instance. let sharedService = AgentService() - // Install the notification category and clear any stale locked notification before the - // listener can accept a lock request. - LockStatusNotifier.shared.start() let listener = NSXPCListener(machServiceName: SharedConstants.machServiceName) do { try listener.setConnectionCodeSigningRequirement( From 0fe258611eb8324fdaea058f63ba821859503a08 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 16:58:58 +0800 Subject: [PATCH 094/106] fix(services): publish errors before alerts Populate the synchronous Services result channel before scheduling in-app presentation so modal UI cannot delay the caller. Preserve late failure alerts after a timeout while claiming presentation exactly once. --- .../ExternalAutomationController.swift | 10 +-- .../KeyboardLockerServicesProvider.swift | 61 ++++++++++++++++--- .../ExternalAutomationControllerTests.swift | 6 +- .../KeyboardLockerServicesProviderTests.swift | 44 ++++++++++++- 4 files changed, 99 insertions(+), 22 deletions(-) diff --git a/KeyboardLocker/Automation/ExternalAutomationController.swift b/KeyboardLocker/Automation/ExternalAutomationController.swift index 6ab4f19..a62e30b 100644 --- a/KeyboardLocker/Automation/ExternalAutomationController.swift +++ b/KeyboardLocker/Automation/ExternalAutomationController.swift @@ -82,8 +82,8 @@ final class ExternalAutomationController { } /// Submits one action through the same serial chain and reports the outcome to the caller. - /// Sources with a synchronous result channel (Services) use this to surface failures - /// themselves; the in-app failure alert is still presented. + /// Sources with a synchronous result channel (Services) publish that result before scheduling + /// their presentation, so a modal alert cannot delay the result channel. func submitAndWait( _ action: ExternalAutomationAction, source: ExternalAutomationSource @@ -94,16 +94,12 @@ final class ExternalAutomationController { let task = Task { @MainActor in await previous?.value - let failure = await Self.execute( + return await Self.execute( action, client: client, presenter: presenter, source: source ) - if let failure { - presenter.presentFailures([failure], source: source) - } - return failure } tail = Task { @MainActor in _ = await task.value diff --git a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift index 25fd7af..4272a7a 100644 --- a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift +++ b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift @@ -3,20 +3,25 @@ import AppKit /// AppKit invokes service handlers synchronously and marshals the `error` out-pointer back to /// the requesting app — the Services channel is the only external entry point that can carry a /// failure reason (the URL scheme has none). Execution itself is async (the serial XPC chain), -/// so each handler waits, with a bound, for the outcome and reports it here; the in-app failure -/// alert is presented as before. +/// so each handler waits, with a bound, for the outcome and reports it here. Failure presentation +/// is scheduled only after the out-pointer is populated, so a modal alert cannot delay the caller. final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sendable { /// Outlasts the worst-case Client mutation: the initial 5s XPC reply window plus the /// idempotent-retry window, with margin. private static let defaultWaitTimeout: TimeInterval = 15 + private let presentFailure: @MainActor @Sendable (ExternalAutomationFailure) -> Void private let submit: @Sendable (ExternalAutomationAction) async -> ExternalAutomationFailure? private let waitTimeout: TimeInterval init( waitTimeout: TimeInterval = KeyboardLockerServicesProvider.defaultWaitTimeout, + presentFailure: @escaping @MainActor @Sendable (ExternalAutomationFailure) -> Void = { + AppKitExternalAutomationPresenter().presentFailures([$0], source: .service) + }, submit: @escaping @Sendable (ExternalAutomationAction) async -> ExternalAutomationFailure? ) { + self.presentFailure = presentFailure self.waitTimeout = waitTimeout self.submit = submit } @@ -54,22 +59,33 @@ final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sen ) { let completion = ServicesRequestCompletion() let submit = submit + let presentFailure = presentFailure Task { let failure = await submit(action) - completion.complete(failureMessage: failure?.message) + if let failure = completion.complete(failure: failure) { + Task { @MainActor in + presentFailure(failure) + } + } } switch completion.awaitOutcome(timeout: waitTimeout) { case .succeeded: break - case let .failed(message): - error.pointee = message as NSString + case let .failed(failure): + error.pointee = failure.message as NSString case .timedOut: error.pointee = """ KeyboardLocker did not finish the service request within \(Int(waitTimeout)) seconds; \ it may still complete. """ as NSString } + + if let failure = completion.publishResultChannel() { + Task { @MainActor in + presentFailure(failure) + } + } } } @@ -78,18 +94,35 @@ final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sen private final class ServicesRequestCompletion: @unchecked Sendable { enum Outcome: Equatable { case succeeded - case failed(String) + case failed(ExternalAutomationFailure) case timedOut } private let semaphore = DispatchSemaphore(value: 0) private let lock = NSLock() private var outcome: Outcome? + private var resultChannelPublished = false + private var presentationClaimed = false - func complete(failureMessage: String?) { - let outcome: Outcome = failureMessage.map(Outcome.failed) ?? .succeeded - lock.withLock { self.outcome = outcome } + /// Returns a late failure only when the handler has already published its result channel. + /// Exactly one caller can claim presentation. + func complete(failure: ExternalAutomationFailure?) -> ExternalAutomationFailure? { + let outcome: Outcome = failure.map(Outcome.failed) ?? .succeeded + let failureToPresent = lock.withLock { + self.outcome = outcome + return claimFailureForPresentation() + } semaphore.signal() + return failureToPresent + } + + /// Marks the Services error out-pointer as populated (or intentionally untouched on success). + /// A failure that completed first is returned to the handler for deferred presentation. + func publishResultChannel() -> ExternalAutomationFailure? { + lock.withLock { + resultChannelPublished = true + return claimFailureForPresentation() + } } func awaitOutcome(timeout: TimeInterval) -> Outcome { @@ -105,4 +138,14 @@ private final class ServicesRequestCompletion: @unchecked Sendable { } return lock.withLock { outcome } ?? .timedOut } + + private func claimFailureForPresentation() -> ExternalAutomationFailure? { + guard resultChannelPublished, !presentationClaimed, + case let .failed(failure)? = outcome + else { + return nil + } + presentationClaimed = true + return failure + } } diff --git a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift index ba1fc12..d8de3f1 100644 --- a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift +++ b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift @@ -134,7 +134,7 @@ final class ExternalAutomationControllerTests: XCTestCase { } @MainActor - func testSubmitAndWaitReturnsAndPresentsFailure() async { + func testSubmitAndWaitReturnsFailureWithoutPresentingIt() async { let client = RecordingExternalAutomationClient( isLocked: false, error: XPCClientError.serviceUnavailable @@ -145,9 +145,7 @@ final class ExternalAutomationControllerTests: XCTestCase { let failure = await controller.submitAndWait(.lock, source: .service) XCTAssertTrue(failure?.message.contains("not reachable") == true) - XCTAssertEqual(presenter.failureBatches.count, 1) - XCTAssertEqual(presenter.failureBatches.first?.failures.first, failure) - XCTAssertEqual(presenter.failureBatches.first?.source, .service) + XCTAssertTrue(presenter.failureBatches.isEmpty) } @MainActor diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift index 5a33003..8ca01f9 100644 --- a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -23,8 +23,12 @@ final class KeyboardLockerServicesProviderTests: XCTestCase { XCTAssertNil(error) } - func testHandlerCopiesFailureMessageIntoErrorOutPointer() { - let provider = KeyboardLockerServicesProvider { _ in + @MainActor + func testHandlerPublishesFailureBeforeScheduledPresentationRuns() async { + let presenter = RecordingServicesFailurePresenter() + let provider = KeyboardLockerServicesProvider( + presentFailure: { presenter.present($0) } + ) { _ in ExternalAutomationFailure(message: "Agent not reachable") } @@ -32,6 +36,11 @@ final class KeyboardLockerServicesProviderTests: XCTestCase { provider.unlockKeyboard(pasteboard, userData: nil, error: &error) XCTAssertEqual(error, "Agent not reachable") + XCTAssertTrue(presenter.failures.isEmpty) + + await Task.yield() + + XCTAssertEqual(presenter.failures, [.init(message: "Agent not reachable")]) } func testHandlerReportsTimeoutWhenActionDoesNotFinish() { @@ -45,4 +54,35 @@ final class KeyboardLockerServicesProviderTests: XCTestCase { XCTAssertTrue(error?.contains("did not finish") == true) } + + @MainActor + func testTimedOutHandlerPresentsLateFailureAfterPublishingTimeout() async { + let presenter = RecordingServicesFailurePresenter() + let provider = KeyboardLockerServicesProvider( + waitTimeout: 0.01, + presentFailure: { presenter.present($0) } + ) { _ in + try? await Task.sleep(nanoseconds: 50_000_000) + return ExternalAutomationFailure(message: "Late agent failure") + } + + var error: NSString? + provider.lockKeyboard(pasteboard, userData: nil, error: &error) + + XCTAssertTrue(error?.contains("did not finish") == true) + XCTAssertTrue(presenter.failures.isEmpty) + + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(presenter.failures, [.init(message: "Late agent failure")]) + } +} + +@MainActor +private final class RecordingServicesFailurePresenter { + private(set) var failures: [ExternalAutomationFailure] = [] + + func present(_ failure: ExternalAutomationFailure) { + failures.append(failure) + } } From a9c8f1ec9a2c1ef26bc0ae2c96c57abaef2486ea Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 16:59:11 +0800 Subject: [PATCH 095/106] refactor(app): centralize shared projections Give AppCoordinator.State one lock-evidence projection for reconciliation and observation. Reuse one NSError description and recovery formatter across automation and AppleScript so wrapper surfaces cannot drift. --- KeyboardLocker/AppCoordinator.swift | 40 ++++++++----------- .../KeyboardLockerScriptCommands.swift | 8 +--- .../Automation/ExternalAutomationAction.swift | 23 ++++++----- .../AppCoordinatorTests.swift | 18 +++++++++ .../KeyboardLockerScriptCommandTests.swift | 4 ++ 5 files changed, 53 insertions(+), 40 deletions(-) diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift index f0f47ef..4a01ed2 100644 --- a/KeyboardLocker/AppCoordinator.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -12,6 +12,20 @@ final class AppCoordinator { case accessibilityRequired(isLocked: Bool) case ready(isLocked: Bool) case unavailable(message: String, canRestartAgent: Bool) + + var knownLockState: Bool? { + switch self { + case let .checking(lastKnownLock): + lastKnownLock + case let .agentUpdateRequired(isLocked, _): + isLocked + case let .accessibilityRequired(isLocked), + let .ready(isLocked): + isLocked + case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + nil + } + } } enum Activity: Equatable { @@ -271,17 +285,7 @@ final class AppCoordinator { reconciliationTask?.cancel() stopStateObservation() - let lastKnownLock: Bool? = switch state { - case let .checking(lastKnownLock): - lastKnownLock - case let .agentUpdateRequired(isLocked, _): - isLocked - case let .accessibilityRequired(isLocked), - let .ready(isLocked): - isLocked - case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: - nil - } + let lastKnownLock = state.knownLockState needsFollowUpReconciliation = false state = .checking(lastKnownLock: lastKnownLock) @@ -590,19 +594,7 @@ final class AppCoordinator { return } - let initialState: Bool? = switch state { - case let .checking(lastKnownLock): - lastKnownLock - case let .agentUpdateRequired(isLocked, _): - isLocked - case let .accessibilityRequired(isLocked), - let .ready(isLocked): - isLocked - case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: - nil - } - - stateToken = lockStateObserver.subscribe(initialState: initialState) { [weak self] isLocked in + stateToken = lockStateObserver.subscribe(initialState: state.knownLockState) { [weak self] isLocked in self?.receiveLockState(isLocked) } } diff --git a/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift index afd50b1..aa756a7 100644 --- a/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift +++ b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift @@ -77,12 +77,6 @@ enum AppleScriptErrorPresentation { } static func message(for error: Error) -> String { - let error = error as NSError - guard let recoverySuggestion = error.localizedRecoverySuggestion, - !recoverySuggestion.isEmpty - else { - return error.localizedDescription - } - return "\(error.localizedDescription) \(recoverySuggestion)" + NSErrorMessageFormatter.message(for: error) } } diff --git a/KeyboardLocker/Automation/ExternalAutomationAction.swift b/KeyboardLocker/Automation/ExternalAutomationAction.swift index 1f74c9d..e0ce415 100644 --- a/KeyboardLocker/Automation/ExternalAutomationAction.swift +++ b/KeyboardLocker/Automation/ExternalAutomationAction.swift @@ -16,6 +16,19 @@ nonisolated enum ExternalAutomationSource: String, Equatable, Sendable { case urlScheme = "URL" } +nonisolated enum NSErrorMessageFormatter { + static func message(for error: Error) -> String { + let error = error as NSError + guard let recoverySuggestion = error.localizedRecoverySuggestion, + !recoverySuggestion.isEmpty + else { + return error.localizedDescription + } + + return "\(error.localizedDescription) \(recoverySuggestion)" + } +} + nonisolated struct ExternalAutomationFailure: Equatable, Sendable { let message: String @@ -24,14 +37,6 @@ nonisolated struct ExternalAutomationFailure: Equatable, Sendable { } init(error: Error) { - let error = error as NSError - guard let recoverySuggestion = error.localizedRecoverySuggestion, - !recoverySuggestion.isEmpty - else { - self.init(message: error.localizedDescription) - return - } - - self.init(message: "\(error.localizedDescription) \(recoverySuggestion)") + self.init(message: NSErrorMessageFormatter.message(for: error)) } } diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift index a9e045e..9c173b8 100644 --- a/KeyboardLockerModelTests/AppCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -3,6 +3,24 @@ import Foundation import XCTest final class AppCoordinatorTests: XCTestCase { + func testStateProjectsKnownLockEvidenceConsistently() { + let cases: [(AppCoordinator.State, Bool?)] = [ + (.checking(lastKnownLock: true), true), + (.checking(lastKnownLock: nil), nil), + (.agentApprovalRequired, nil), + (.agentReplacementInProgress(message: "Replacing"), nil), + (.agentUpdateRequired(isLocked: false, message: "Update"), false), + (.agentUpdateRequired(isLocked: nil, message: "Update"), nil), + (.accessibilityRequired(isLocked: true), true), + (.ready(isLocked: false), false), + (.unavailable(message: "Unavailable", canRestartAgent: true), nil), + ] + + for (state, expected) in cases { + XCTAssertEqual(state.knownLockState, expected, "state: \(state)") + } + } + @MainActor func testReconcilePublishesAuthoritativeReadySnapshot() async throws { let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) diff --git a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift index f66513a..aa70449 100644 --- a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift @@ -62,6 +62,10 @@ final class KeyboardLockerScriptCommandTests: XCTestCase { for: XPCClientError.serviceUnavailable ) + XCTAssertEqual( + message, + ExternalAutomationFailure(error: XPCClientError.serviceUnavailable).message + ) XCTAssertTrue(message.contains("The KeyboardLocker agent is not reachable.")) XCTAssertTrue(message.contains("Open KeyboardLocker once")) } From 8ce933c67ad2dae06b75b2c756f2cd6a321ebc75 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 16:59:24 +0800 Subject: [PATCH 096/106] fix(security): require live refusal controls Bracket the ad-hoc probe with an authorized control signed from the bundled klock certificate and require one unchanged Agent instance. Classify unavailable, replacement, signing, and compilation uncertainty as indeterminate instead of false security evidence. --- scripts/signed-client-control/main.swift | 101 ++++++++++ scripts/unsigned-client-probe/main.swift | 16 +- scripts/verify-unsigned-client-refusal.sh | 220 +++++++++++++++++----- 3 files changed, 290 insertions(+), 47 deletions(-) create mode 100644 scripts/signed-client-control/main.swift diff --git a/scripts/signed-client-control/main.swift b/scripts/signed-client-control/main.swift new file mode 100644 index 0000000..fd6373d --- /dev/null +++ b/scripts/signed-client-control/main.swift @@ -0,0 +1,101 @@ +import Foundation + +// Positive control for verify-unsigned-client-refusal.sh. The script signs this temporary binary +// with the same local identity and exact signing identifier as the bundled klock executable. A +// decoded descriptor proves that an authorized client reached the authenticated live Agent. + +final class Outcome { + private let lock = NSLock() + private var recorded: (code: Int32, line: String)? + + func record(_ code: Int32, _ line: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard recorded == nil else { + return false + } + recorded = (code, line) + return true + } + + func current() -> (code: Int32, line: String)? { + lock.lock() + defer { lock.unlock() } + return recorded + } +} + +let outcome = Outcome() +let finished = DispatchSemaphore(value: 0) + +func finish(_ code: Int32, _ line: String) { + if outcome.record(code, line) { + finished.signal() + } +} + +let agentRequirement: String +do { + agentRequirement = try XPCCodeSigningRequirement.sameTeam( + identifiers: [SharedConstants.agentBundleIdentifier] + ) +} catch { + print("RESULT:INDETERMINATE could not construct the Agent requirement: \(error)") + exit(2) +} + +let connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) +connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) +connection.setCodeSigningRequirement(agentRequirement) +connection.invalidationHandler = { + finish(2, "RESULT:INDETERMINATE authorized connection invalidated before any reply") +} + +connection.interruptionHandler = { + finish(2, "RESULT:INDETERMINATE authorized connection interrupted before any reply") +} + +connection.activate() + +guard + let service = connection.remoteObjectProxyWithErrorHandler({ error in + finish(2, "RESULT:INDETERMINATE authorized proxy error: \(error.localizedDescription)") + }) as? KeyboardLockerServiceProtocol +else { + print("RESULT:INDETERMINATE could not create an authorized remote object proxy") + exit(2) +} + +service.serviceDescriptor { data, error in + if let error { + finish(2, "RESULT:INDETERMINATE Agent returned an error: \(error.localizedDescription)") + return + } + + do { + let descriptor = try ServiceDescriptor.decodedFromXPC(data) + guard descriptor.agentBundleIdentifier == SharedConstants.agentBundleIdentifier else { + finish( + 2, + "RESULT:INDETERMINATE unexpected Agent identifier: \(descriptor.agentBundleIdentifier)" + ) + return + } + finish(0, "RESULT:LIVE \(descriptor.agentInstanceID.uuidString)") + } catch { + finish(2, "RESULT:INDETERMINATE invalid Agent descriptor: \(error.localizedDescription)") + } +} + +if finished.wait(timeout: .now() + 10) == .timedOut { + print("RESULT:INDETERMINATE authorized Agent did not respond within 10s") + exit(2) +} + +guard let result = outcome.current() else { + print("RESULT:INDETERMINATE semaphore signalled without an outcome") + exit(2) +} + +print(result.line) +exit(result.code) diff --git a/scripts/unsigned-client-probe/main.swift b/scripts/unsigned-client-probe/main.swift index 0ebc8f6..34e5b9b 100644 --- a/scripts/unsigned-client-probe/main.swift +++ b/scripts/unsigned-client-probe/main.swift @@ -44,9 +44,11 @@ connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServicePro connection.invalidationHandler = { finish(0, "RESULT:REFUSED connection invalidated before any reply") } + connection.interruptionHandler = { finish(0, "RESULT:REFUSED connection interrupted before any reply") } + connection.activate() guard @@ -58,9 +60,16 @@ else { exit(2) } -// Simplest read method: any reply at all means the Agent accepted this client. -service.status { _, _ in - finish(1, "RESULT:REPLIED agent answered a status request") +// A descriptor reply proves acceptance and identifies the Agent generation if the listener's +// fail-closed requirement is broken. Refusal still surfaces before any exported selector runs. +service.serviceDescriptor { data, error in + if let descriptor = try? ServiceDescriptor.decodedFromXPC(data) { + finish(1, "RESULT:REPLIED \(descriptor.agentInstanceID.uuidString)") + } else if let error { + finish(1, "RESULT:REPLIED agent returned an error: \(error.localizedDescription)") + } else { + finish(1, "RESULT:REPLIED agent returned an invalid descriptor") + } } if finished.wait(timeout: .now() + 10) == .timedOut { @@ -72,5 +81,6 @@ guard let result = outcome.current() else { print("RESULT:INDETERMINATE semaphore signalled without an outcome") exit(2) } + print(result.line) exit(result.code) diff --git a/scripts/verify-unsigned-client-refusal.sh b/scripts/verify-unsigned-client-refusal.sh index 858906d..74615e1 100755 --- a/scripts/verify-unsigned-client-refusal.sh +++ b/scripts/verify-unsigned-client-refusal.sh @@ -1,77 +1,209 @@ #!/bin/bash -# verify-unsigned-client-refusal.sh — runbook proof that the Agent refuses non-team clients. +# verify-unsigned-client-refusal.sh — prove that the live Agent refuses non-team clients. # -# Purpose: end-to-end verification of the documented fail-closed guarantee: a process without -# the KeyboardLocker Apple Team signature must not be able to call the live Agent's Mach -# service (io.lzhlovesjyq.keyboardlocker.agent). The probe compiled here is ad-hoc signed -# (swiftc output on arm64 is always linker-signed ad-hoc), which is exactly the class of -# client the listener's code-signing requirement must reject. +# The refusal probe is meaningful only when a known authorized client can call the same Agent +# generation immediately before and after it. This script compiles a temporary control, signs it +# with the local bundled klock identity, and reads ServiceDescriptor.agentInstanceID on both sides +# of the ad-hoc probe. An unavailable or replaced Agent is INDETERMINATE, never PASS. # -# Precondition: the KeyboardLocker Agent must be running. A refusal without a live Agent would -# be vacuous — a connection to a dead Mach service fails regardless of any code-signing -# requirement, proving nothing. When the Agent is absent this script prints SKIP and exits 2. +# Precondition: a locally team-signed Debug App has been built and its Agent is running. Override +# the bundled klock discovery with KEYBOARDLOCKER_AUTHORIZED_CLIENT=/absolute/path/to/klock. If +# the bundled certificate's exact SHA-1 identity is unavailable to the local keychain, set +# KEYBOARDLOCKER_SIGNING_IDENTITY to a usable identity name or SHA-1 hash for the same Team ID. # -# This is a manual/runbook verification, not wired into CI: it requires a locally installed, -# team-signed Agent and a GUI user session. It does not modify the Agent or the system. +# This is a manual/runbook verification, not wired into CI. It creates only temporary binaries. # -# Exit codes: 0 = PASS (client refused), 1 = FAIL (Agent answered), 2 = SKIP/INDETERMINATE. +# Exit codes: 0 = PASS (client refused), 1 = FAIL (Agent answered), 2 = INDETERMINATE. set -euo pipefail -AGENT_PROCESS="KeyboardLockerAgent" -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PROBE_SOURCE="$REPO_ROOT/scripts/unsigned-client-probe/main.swift" -COMMON_SOURCES_DIR="$REPO_ROOT/Core/Sources/Common" +readonly AGENT_PROCESS="KeyboardLockerAgent" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +readonly REPO_ROOT +readonly PROJECT_PATH="$REPO_ROOT/KeyboardLocker.xcodeproj" +readonly COMMON_SOURCES_DIR="$REPO_ROOT/Core/Sources/Common" +readonly CONTROL_SOURCE="$REPO_ROOT/scripts/signed-client-control/main.swift" +readonly PROBE_SOURCE="$REPO_ROOT/scripts/unsigned-client-probe/main.swift" +readonly EXPECTED_CLIENT_IDENTIFIER="io.lzhlovesjyq.keyboardlocker.klock" WORK_DIR="$(mktemp -d)" +readonly WORK_DIR cleanup() { rm -rf "$WORK_DIR" } trap cleanup EXIT -echo "==> Checking precondition: agent process '$AGENT_PROCESS' is running" -if ! pgrep -x "$AGENT_PROCESS" >/dev/null 2>&1; then - echo "SKIP: $AGENT_PROCESS is not running." - echo " A refusal without a live Agent would be vacuous: connections to a dead Mach" - echo " service fail regardless of the code-signing requirement." - echo " Launch KeyboardLocker (which registers io.lzhlovesjyq.keyboardlocker.agent)" - echo " and re-run this script." +indeterminate() { + printf 'INDETERMINATE: %s\n' "$*" >&2 exit 2 +} + +signing_field() { + local binary="$1" + local field="$2" + codesign -dvvv "$binary" 2>&1 \ + | awk -F= -v field="$field" '$1 == field && !found { print $2; found = 1 }' +} + +resolve_bundled_klock() { + local build_settings + local target_build_dir + local full_product_name + + build_settings="$( + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme KeyboardLocker \ + -configuration Debug \ + -showBuildSettings 2>/dev/null + )" + target_build_dir="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' \ + '/^[[:space:]]*TARGET_BUILD_DIR = / && !found { print $2; found = 1 }' + )" + full_product_name="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' \ + '/^[[:space:]]*FULL_PRODUCT_NAME = / && !found { print $2; found = 1 }' + )" + [[ -n "$target_build_dir" && -n "$full_product_name" ]] || return 1 + printf '%s/%s/Contents/MacOS/klock\n' "$target_build_dir" "$full_product_name" +} + +run_control() { + local output + local control_exit + + set +e + output="$("$WORK_DIR/signed-client-control" 2>&1)" + control_exit=$? + set -e + printf '%s\n' "$output" | sed 's/^/ /' >&2 + [[ "$control_exit" -eq 0 ]] || return 1 + printf '%s\n' "$output" | awk '/^RESULT:LIVE / && !found { print $2; found = 1 }' +} + +printf "==> Checking precondition: agent process '%s' is running\n" "$AGENT_PROCESS" +agent_pid="$(pgrep -x "$AGENT_PROCESS" | head -1 || true)" +[[ -n "$agent_pid" ]] || indeterminate \ + "$AGENT_PROCESS is not running; an unavailable service cannot prove refusal." +printf ' agent process exists (pid %s); the signed control will prove XPC liveness\n' "$agent_pid" + +authorized_client="${KEYBOARDLOCKER_AUTHORIZED_CLIENT:-}" +if [[ -z "$authorized_client" ]]; then + authorized_client="$(resolve_bundled_klock)" || indeterminate \ + "Could not resolve the Debug App's bundled klock executable." fi -echo " agent is running (pid $(pgrep -x "$AGENT_PROCESS" | head -1))" +[[ "$authorized_client" == /* ]] || indeterminate \ + "KEYBOARDLOCKER_AUTHORIZED_CLIENT must be an absolute path." +[[ -x "$authorized_client" ]] || indeterminate \ + "The authorized client is not executable: $authorized_client" +codesign --verify --strict "$authorized_client" >/dev/null 2>&1 || indeterminate \ + "The authorized client has no valid code signature: $authorized_client" -echo "==> Compiling the probe (ad-hoc signed, real protocol from Core/Sources/Common)" +client_identifier="$(signing_field "$authorized_client" Identifier)" || indeterminate \ + "Could not inspect the authorized client's signing identifier." +client_team="$(signing_field "$authorized_client" TeamIdentifier)" || indeterminate \ + "Could not inspect the authorized client's Team identifier." +[[ "$client_identifier" == "$EXPECTED_CLIENT_IDENTIFIER" ]] || indeterminate \ + "Expected signing identifier $EXPECTED_CLIENT_IDENTIFIER, found ${client_identifier:-missing}." +[[ -n "$client_team" && "$client_team" != "not set" ]] || indeterminate \ + "The authorized client does not carry a Team identifier." + +signing_identity="${KEYBOARDLOCKER_SIGNING_IDENTITY:-}" +if [[ -z "$signing_identity" ]]; then + certificate_prefix="$WORK_DIR/authorized-client-certificate" + certificate_error="" + if ! certificate_error="$( + codesign -d --extract-certificates="$certificate_prefix" "$authorized_client" 2>&1 + )"; then + indeterminate \ + "Could not extract the authorized client's signing certificate: ${certificate_error:-unknown error}" + fi + [[ -f "${certificate_prefix}0" ]] || indeterminate \ + "The authorized client did not expose a leaf signing certificate." + if ! signing_identity="$( + openssl x509 \ + -inform DER \ + -in "${certificate_prefix}0" \ + -noout \ + -fingerprint \ + -sha1 2>/dev/null \ + | awk -F= 'NF == 2 { gsub(":", "", $2); print $2 }' + )"; then + indeterminate "Could not inspect the authorized client's leaf certificate." + fi +fi +[[ "$signing_identity" =~ ^[0-9A-Fa-f]{40}$ || -n "${KEYBOARDLOCKER_SIGNING_IDENTITY:-}" ]] \ + || indeterminate \ + "Could not determine the authorized client's exact signing identity." + +printf '==> Compiling and team-signing the authorized liveness control\n' +xcrun swiftc \ + "$CONTROL_SOURCE" \ + "$COMMON_SOURCES_DIR"/*.swift \ + -o "$WORK_DIR/signed-client-control" || indeterminate \ + "Could not compile the authorized liveness control." +codesign \ + --force \ + --identifier "$EXPECTED_CLIENT_IDENTIFIER" \ + --sign "$signing_identity" \ + --timestamp=none \ + "$WORK_DIR/signed-client-control" >/dev/null 2>&1 || indeterminate \ + "Could not sign the liveness control with '$signing_identity'." +codesign --verify --strict "$WORK_DIR/signed-client-control" >/dev/null 2>&1 || indeterminate \ + "The signed liveness control failed signature verification." +control_identifier="$(signing_field "$WORK_DIR/signed-client-control" Identifier)" \ + || indeterminate "Could not inspect the liveness control's signing identifier." +control_team="$(signing_field "$WORK_DIR/signed-client-control" TeamIdentifier)" \ + || indeterminate "Could not inspect the liveness control's Team identifier." +[[ "$control_identifier" == "$EXPECTED_CLIENT_IDENTIFIER" && "$control_team" == "$client_team" ]] \ + || indeterminate "The liveness control does not match the authorized client's identity." +printf ' control identity: %s / Team %s\n' "$control_identifier" "$control_team" + +printf '==> Positive control before refusal probe\n' +before_instance="$(run_control)" || indeterminate \ + "An authorized client could not prove that the Agent was live before the refusal probe." +[[ "$before_instance" =~ ^[0-9A-Fa-f-]{36}$ ]] || indeterminate \ + "The first positive control did not return a valid Agent instance ID." + +printf '==> Compiling the ad-hoc refusal probe\n' xcrun swiftc \ "$PROBE_SOURCE" \ "$COMMON_SOURCES_DIR"/*.swift \ - -o "$WORK_DIR/unsigned-client-probe" - -echo "==> Confirming the probe is not team-signed" -if codesign -dv "$WORK_DIR/unsigned-client-probe" 2>&1 | grep -q "^TeamIdentifier=not set"; then - codesign -dv "$WORK_DIR/unsigned-client-probe" 2>&1 \ - | grep -E "^(Signature|TeamIdentifier)" | sed 's/^/ /' -else - echo "FAIL: probe unexpectedly carries a Team ID; it would not exercise the refusal path." - exit 2 -fi + -o "$WORK_DIR/unsigned-client-probe" || indeterminate \ + "Could not compile the ad-hoc refusal probe." +probe_team="$(signing_field "$WORK_DIR/unsigned-client-probe" TeamIdentifier)" \ + || indeterminate "Could not inspect the refusal probe's Team identifier." +[[ "$probe_team" == "not set" ]] || indeterminate \ + "The refusal probe unexpectedly carries Team identifier ${probe_team:-missing}." -echo "==> Running the probe against the live Agent" +printf '==> Running the ad-hoc probe against Agent instance %s\n' "$before_instance" set +e -PROBE_OUTPUT="$("$WORK_DIR/unsigned-client-probe" 2>&1)" -PROBE_EXIT=$? +probe_output="$("$WORK_DIR/unsigned-client-probe" 2>&1)" +probe_exit=$? set -e -echo "$PROBE_OUTPUT" | sed 's/^/ /' +printf '%s\n' "$probe_output" | sed 's/^/ /' + +printf '==> Positive control after refusal probe\n' +after_instance="$(run_control)" || indeterminate \ + "An authorized client could not prove that the Agent remained live after the refusal probe." +[[ "$after_instance" == "$before_instance" ]] || indeterminate \ + "The Agent changed during the probe ($before_instance -> ${after_instance:-unknown})." -case "$PROBE_EXIT" in +case "$probe_exit" in 0) - echo "PASS: the Agent refused an ad-hoc (non-team) client." + printf 'PASS: Agent instance %s refused an ad-hoc non-team client; matching authorized controls succeeded immediately before and after.\n' \ + "$before_instance" ;; 1) - echo "FAIL: the Agent replied to an ad-hoc (non-team) client — fail-closed is broken." + printf 'FAIL: Agent instance %s replied to an ad-hoc non-team client.\n' "$before_instance" >&2 exit 1 ;; *) - echo "INDETERMINATE: probe could not distinguish refusal from reply (exit $PROBE_EXIT)." - exit 2 + indeterminate "The refusal probe produced no decisive result (exit $probe_exit)." ;; esac From 85a7a5a12e47dedf7f75d5b6fe0d0dc085eea7f2 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 16:59:40 +0800 Subject: [PATCH 097/106] docs(xpc): align repaired runtime contracts Document selector-specific protocol negotiation, process-lifetime service ownership, persisted-settings fallback versus strict wire decoding, and the bracketed evidence boundary for non-team client refusal. --- docs/development.md | 14 +++++++------- docs/xpc.md | 22 ++++++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/development.md b/docs/development.md index 387ca2b..545f5c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,10 +8,10 @@ 所有面都通过 `KeyboardLockerServiceProtocol` 与 Agent 通信(Mach 服务 `io.lzhlovesjyq.keyboardlocker.agent`)。 -1. App 建立 connection 后先调用 `XPCClient.serviceDescriptor()`,验证 protocol version、required capabilities 和 bundled Agent build;只有兼容后才调用新增 selector。descriptor 在 fresh connection 上重试后仍失败、但旧 `status` 成功时,只能按 unverified base contract 处理,不能断言它一定是 legacy Agent。capability-gated Client API 会在同一条具体 connection generation 上重新握手并发送 feature selector,connection 变化后不能复用旧 grant。 +1. App 建立 connection 后先调用 `XPCClient.serviceDescriptor()`,验证 protocol version、required capabilities 和 bundled Agent build;只有兼容后才调用新增 selector。descriptor 在 fresh connection 上重试后仍失败、但旧 `status` 成功时,只能按 unverified base contract 处理,不能断言它一定是 legacy Agent。capability-gated Client API 会在同一条具体 connection generation 上重新握手,先验证 major 相同及该 selector 的引入 minor,再检查 capability 并发送 selector;connection 变化后不能复用旧 grant。 2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `lockStatusSnapshot` / `currentSettings`。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 3. Client 与 Listener 都在 activate 前安装同 Team + 精确 signing identifier 的 XPC requirement;系统完成双向认证后,Agent 的 `ServiceDelegate` 才接受连接并路由到 `AgentService`。 -4. `AgentService` 拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`),并驱动 `LockEngine.shared.lock(settings:allowsControlCUnlock:)` / `unlock()`。 +4. Agent bootstrap 创建一个贯穿进程生命周期的 `AgentService`,它拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`)和唯一的 `LockEngine` 实例,并驱动 `lock(settings:allowsControlCUnlock:)` / `unlock()`;所有 listener connection 都接收同一个 service object。 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 6. wrapper 通过 `LockStateSubscriber.subscribe(initialState:_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态。subscriber 会在 observer 安装完成后立即拉取一次权威初始状态,后续信号串行合并并再次查询 —— 绝不从"我这次调用是否成功"或通知 payload 推断。 @@ -30,19 +30,19 @@ - `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。读取 **ASCII-capable keyboard layout**(`TISCopyCurrentASCIICapableKeyboardLayoutInputSource`)而非当前 input source——拼音等输入法不带 Unicode layout data,会把热键渲染成 `?`。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` -- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。`unlock` 超时后可用权威 Boolean 状态校准;普通 `lock` 与 Focus selector 的成功还包含本地状态看不到的 provenance,因此首次 timeout 后会在 fresh connection 上重发同一个幂等请求,第二次仍超时则明确报告 outcome unknown。`lockInteractively()`、`lockStatusSnapshot()` 与 `setFocusFilterLockEnabled()` 都在同一 connection 上完成 descriptor/capability gate 后调用对应 selector。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 +- `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。`unlock` 超时后可用权威 Boolean 状态校准;普通 `lock` 与 Focus selector 的成功还包含本地状态看不到的 provenance,因此首次 timeout 后会在 fresh connection 上重发同一个幂等请求,第二次仍超时则明确报告 outcome unknown。每个 capability-gated API 都在同一 connection 上读取 descriptor,先拒绝不同 major 或低于 selector 引入版本的 minor,再检查 capability 后调用 selector。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 - `ServiceCompatibility.swift`:纯值兼容性规则与任意精度 dotted-numeric `ServiceBuildVersion` —— major 必须相等、running minor 不得低于最低版本、required capabilities 必须齐全,且运行中 Agent 的 identifier/version/build 必须与 bundled Agent 一致。 - `LockStateSubscriber.swift`:先安装 Darwin + Distributed observer,再立即拉取一次权威状态;后续把每个信号当作提示,通过 `XPCClient.status()` 串行校准(带重试、signal coalescing、去重和 cancellation fence)→ `ObserverToken`,另有会先产出当前权威状态的 `stateChanges`(`AsyncStream`)。取消会丢弃尚未进入 handler 的结果,但不会回溯撤销已经开始执行的 handler。长命 UI 使用 snapshot seed 避免重复呈现相同状态;一次性面直接读 `status()`(见 architecture 的"状态同步")。 - `UnlockStatusPoller.swift`:`XPCClient.waitUntilUnlocked()` 的可测试等待组合。notification stream 提供及时更新,内部 poller 周期性查询权威状态以恢复丢通知和 Agent 重启;transport failure 后 reset connection,连续三次失败则抛错,不把不可达猜成 unlocked。任一路径确认解锁后都会取消另一条路径,取消 polling 时主动失效本轮 connection,避免等待完整 XPC response timeout。 **Service**(`Core/Sources/Service/`)—— 仅 Agent 使用 -- `LockEngine.swift`:`@MainActor` 隔离的 CGEventTap 单例、物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。event policy 消费标准按键及 system-defined keyboard control,同时明确放行鼠标 / 触控板 pointer event。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。引擎的全部外部副作用(Accessibility 查询、tap 安装、定时器排程、状态广播、时钟)经 `LockEngineDependencies` / `InstalledEventTap` 注入缝进入,`LockEngineTests` 因此能确定性驱动这些路径。 +- `LockEngine.swift`:`@MainActor` 隔离、由进程生命周期 `AgentService` 实例独占的 CGEventTap 引擎;提供物理状态幂等且返回 atomic outcome 的 `lock(settings:allowsControlCUnlock:)`、Focus-owned generation、显式 `updateSettings(_:)`、同一 runtime turn 生成的 `statusSnapshot`、自动解锁定时器、热键检测、事务式 tap/source 安装与 `os.Logger`。event policy 消费标准按键及 system-defined keyboard control,同时明确放行鼠标 / 触控板 pointer event。Focus activation 最多创建并标记一个 generation;普通 duplicate `lock` 不会修改活动设置、临时 `Ctrl+C` 手势或 deadline,但会清除当前 Focus ownership marker;Focus disable、timer 与热键回调都使用 generation fence,不能解除后续新锁。marker 只存在于 Agent 进程内,显式 unlock、热键、timeout、event-tap failure 或 Agent restart 可以提前结束 generation,且不会因 Focus 仍 active 而自动 relock。资源未全部可用前不提交 locked 状态;运行中 tap 无法重新启用时 fail open 到 unlocked 并广播权威状态。引擎的全部外部副作用(Accessibility 查询、tap 安装、定时器排程、状态广播、时钟)经 `LockEngineDependencies` / `InstalledEventTap` 注入缝进入,`LockEngineTests` 因此能确定性驱动这些路径。 - `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 -- `LockStatusNotifier.swift`:Agent 进程内的 "Keyboard Locked" 系统通知 owner。挂在 engine 的 `broadcastStateChange` 副作用缝上(live 实现里先广播再驱动 notifier),状态转换完成后读取进程内 `statusSnapshot` 拼装正文(解锁热键 + auto-unlock 截止时间),经 `UNUserNotificationCenter` 投递/移除固定 identifier 的通知;`Unlock Now` action 由 Agent 本地执行幂等 unlock。首次锁定时请求通知权限,拒绝则静默降级。`start()` 在 bootstrap 时安装 action category 并清除上一代 Agent 退出留下的残留通知。通知生命周期与锁生命周期由此绑定在同一进程,不会比锁活得更久。 +- `LockStatusNotifier.swift`:Agent 进程内的 "Keyboard Locked" 系统通知 owner。`AgentService` composition root 把它接到 engine 的 state-change handler;live 路径在状态转换完成后先广播状态信号,再由 notifier 读取进程内 `statusSnapshot` 拼装正文(解锁热键 + auto-unlock 截止时间),经 `UNUserNotificationCenter` 投递/移除固定 identifier 的通知;`Unlock Now` action 由 Agent 本地执行幂等 unlock。首次锁定时请求通知权限,拒绝则静默降级。`start()` 在 bootstrap 时安装 action category 并清除上一代 Agent 退出留下的残留通知。通知生命周期与锁生命周期由此绑定在同一进程,不会比锁活得更久。 - `ReplacementTransaction.swift`:纯 `idle → prepared → committed` 状态机;prepared 可 cancel/expire,committed 不可 cancel/expire,只能由 Agent 进程退出终止。它不触碰 TCC 或 Service Management,可由 `ServiceTests` 确定性覆盖。 - `MainActorTimerScheduler.swift`:单次 MainActor 定时器抽象(返回取消闭包)及其 live 实现;`LockEngine` 的 auto-unlock 与 `AgentService` 的 replacement 过期共用,测试中替换为手动调度。 -- `AgentService.swift`:nonisolated XPC wire adapter,持有设置快照与 replacement transaction,并把所有可变状态与引擎操作切到 `MainActor`。引擎、descriptor 与 prepare 过期调度都经注入缝进入;replacement barrier、instance-ID fence、locked refusal、错误码映射与过期/取消语义由 `ServiceTests` 确定性覆盖。 +- `AgentService.swift`:nonisolated XPC wire adapter;Agent bootstrap 只创建一个贯穿进程生命周期的实例,并把它交给所有 listener connection。这个实例私有创建并持有唯一的 `LockEngine`、`LockStatusNotifier`、设置快照与 replacement transaction,把所有可变状态与引擎操作切到 `MainActor`;它不是语言层 static singleton。测试 initializer 仍可注入普通引擎 double、descriptor 与 prepare 过期调度;replacement barrier、instance-ID fence、locked refusal、错误码映射与过期/取消语义由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 **SystemSurfaces**(`Core/Sources/SystemSurfaces/`)—— 仅 containing App、Widget 与 Focus extension 使用的 presentation adapter @@ -149,5 +149,5 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget - auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 - `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 - Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 -- unsigned/ad-hoc fail-closed:requirement 对异类签名的拒绝已由 `XPCCodeSigningRequirementEvaluationTests` 经 `SecStaticCodeCheckValidity` 静态评估确定性覆盖(Apple 系统签名、ad-hoc、完全未签名三类 fixture 均被拒;`anchor apple` 对照证明评估管线本身正确),`XPCAccessControlTests` 按名验证 listener requirement 的 fail-closed 构造。端到端(非 team 进程调用真实 Agent)由 `scripts/verify-unsigned-client-refusal.sh` 承载 —— 需 Agent 运行中手动执行的 runbook 验证,不进 CI。 +- unsigned/ad-hoc fail-closed:requirement 对异类签名的拒绝已由 `XPCCodeSigningRequirementEvaluationTests` 经 `SecStaticCodeCheckValidity` 静态评估确定性覆盖(Apple 系统签名、ad-hoc、完全未签名三类 fixture 均被拒;`anchor apple` 对照证明评估管线本身正确),`XPCAccessControlTests` 按名验证 listener requirement 的 fail-closed 构造。端到端由 `scripts/verify-unsigned-client-refusal.sh` 承载:临时 control 使用本地 bundled `klock` 的 Team 与精确 identifier 签名,在 ad-hoc probe 前后读取同一个 `agentInstanceID`;只有 control 两次都可达同一 Agent generation 且 ad-hoc client 被拒绝才 PASS。Agent 不可达、发生 replacement 或无法取得本地签名 identity 都返回 exit 2 (`INDETERMINATE`),不会把 service unavailability 当作拒绝证据。该 runbook 需 locally team-signed build 和 GUI session,手动执行,不进 CI。 - CI 由 `.github/workflows/ci.yml` 承载(macOS runner,依次跑 Core 测试、全量 build、`KeyboardLockerModelTests`)。runner 没有签名身份,app build 与 ModelTests 以 `CODE_SIGNING_ALLOWED=NO` 运行;因此签名认证的**运行时**行为不属于 CI 门禁,发布前仍需本地签名构建验证(签名拒绝的端到端验证用上一条的 runbook 脚本)。 diff --git a/docs/xpc.md b/docs/xpc.md index ad7670f..9b14df5 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -11,7 +11,7 @@ ## 先记住四个结论 1. **`Core` 不是进程,也不是后台服务。** 它只是一个本地 Swift Package,构建时向不同 executable 提供代码模块。 -2. **两侧共享的是协议和数据定义,不是内存或单例。** `Common` 会编译进 wrapper 和 Agent,但唯一的锁状态只存在于 Agent 进程的 `LockEngine.shared` 中。 +2. **两侧共享的是协议和数据定义,不是内存或单例。** `Common` 会编译进 wrapper 和 Agent,但唯一的锁状态只存在于 Agent bootstrap 创建的进程生命周期 `AgentService` 所私有持有的那一个 `LockEngine` 实例中;所有 listener connection 共用这个 service object。 3. **wrapper 与 Agent 没有互相直接调用 Swift 对象。** wrapper 取得的是 `KeyboardLockerServiceProtocol` 的远程 proxy;macOS 把方法参数和 reply 序列化后跨进程传输。 4. **XPC connection 不拥有锁。** connection、App 或 CLI 退出后,锁仍由 Agent 持有;只有显式/自动 unlock、解锁热键或 Agent 退出才会释放 event tap。 @@ -88,7 +88,7 @@ flowchart LR FocusClient -->|"NSXPCConnection"| Mach subgraph AgentProcess["KeyboardLockerAgent process"] Listener["NSXPCListener"] --> AgentService["AgentService"] - AgentService --> Engine["LockEngine.shared"] + AgentService --> Engine["one LockEngine instance"] AgentService --> Settings["Settings store"] AgentService --> Accessibility["Accessibility APIs"] end @@ -196,7 +196,7 @@ KeyboardLocker.app | 字段 | 用途 | |---|---| -| `protocolVersion.major/minor` | major 必须相等;running minor 必须不低于 Client 要求 | +| `protocolVersion.major/minor` | major 必须相等;App 完整兼容性检查要求 running minor 不低于 Client contract,每个 feature call 另要求 running minor 不低于对应 selector 的引入版本 | | `capabilities` | Client 只调用 Agent 明确声明支持的 optional selector;未知字符串由旧 Client 保留并忽略 | | `agentBundleIdentifier` | 与 bundled Agent metadata 比较,发现错误/旧 executable | | `agentVersion` / `agentBuild` | 判断运行中 Agent 是否就是 App 当前携带的构建 | @@ -206,7 +206,7 @@ KeyboardLocker.app `agentBundleIdentifier`、version 和 build 是远端自报的兼容性信息,不是安全凭证。它们只有在 connection 已完成 peer authentication 后才有意义;不能用 descriptor 替代代码签名 requirement。 -descriptor/capability grant 还必须绑定到读取它的 **同一条 client-side connection generation**。`XPCClient` 的 capability-gated API 会先在一条具体 connection 上读取 descriptor,再用同一个 `NSXPCConnection` 发送 feature selector;若这条 connection interruption/invalidation,Client 会 invalidate 该 object,后续调用以新 connection 重新协商,不能把 Agent A 的 capability grant 用于 Agent B。replacement request 还把 expected `agentInstanceID` 放进 wire message:Agent 在安装 barrier 前原子比较自己的 instance ID,Client 再校验返回 ticket,形成两侧 generation fence。 +descriptor/capability grant 还必须绑定到读取它的 **同一条 client-side connection generation**。`XPCClient` 的 capability-gated API 会先在一条具体 connection 上读取 descriptor,按顺序验证 protocol major、selector-specific minimum minor 与 capability,再用同一个 `NSXPCConnection` 发送 feature selector;major 不兼容时 capability string 没有可复用的语义,不能授权 selector。若这条 connection interruption/invalidation,Client 会 invalidate 该 object,后续调用以新 connection 重新协商,不能把 Agent A 的 capability grant 用于 Agent B。replacement request 还把 expected `agentInstanceID` 放进 wire message:Agent 在安装 barrier 前原子比较自己的 instance ID,Client 再校验返回 ticket,形成两侧 generation fence。 App 的 readiness 顺序是: @@ -278,7 +278,7 @@ flowchart LR AgentConnectionA --> SharedService["Shared AgentService"] AgentConnectionB --> SharedService - SharedService --> Engine["LockEngine.shared"] + SharedService --> Engine["one LockEngine instance"] ``` | 对象 | 所在进程 | 当前数量 | 作用 | @@ -346,7 +346,7 @@ serverConnection.exportedObject │ └─ shared AgentService │ - └─ LockEngine.shared + └─ one LockEngine instance ``` Client 的 `remoteObjectProxy` 也不是 Agent 的 `AgentService`。前者是 wrapper 进程中的本地动态代理;对它调用 method 时,Foundation 才把 invocation 编码并传到 Agent,由后者真正执行。 @@ -457,7 +457,7 @@ Agent peer connection 7. Agent Listener 收到 connection request 后,XPC runtime 先执行 Listener 的 code-signing requirement;只有通过的 peer 才会创建 Agent-side `NSXPCConnection` 并进入 delegate。 8. delegate 设置 `exportedInterface/object`、activate connection 并返回 `true`;返回 `false` 会拒绝并 invalidate 它。 9. Foundation 在 Agent-side connection 上解码 invocation,并调用 `AgentService.status(reply:)`。 -10. `AgentService` 读取 `LockEngine.shared.isLocked`,再调用 `reply(isLocked, nil)`。 +10. 进程生命周期的 `AgentService` 读取自己持有的 `LockEngine.isLocked`,再调用 `reply(isLocked, nil)`。 11. 这里的 reply closure 不是把 Client 的 Swift closure 复制到 Agent 执行;它是 Foundation 提供的跨进程 reply capability。调用它会生成响应 message。 12. Client connection 收到响应后,在自己的 connection queue 上执行 reply handler;`XPCClient` 再恢复 Swift continuation。 @@ -525,7 +525,7 @@ sequenceDiagram 6. `XPCServerConnection` 为 Agent-side connection 设置 protocol,并把同一个 `AgentService` 实例设置为 `exportedObject`。 7. 对 remote proxy 的 legacy `lockKeyboard(reply:)` 或 additive `lockKeyboardInteractively(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 8. `AgentService` 把领域操作切到 `MainActor`,Agent 可变状态、event tap 和 CFRunLoop 生命周期都在这里维护。 -9. `LockEngine.shared.lock(settings:allowsControlCUnlock:)` 检查 Agent 自己的 Accessibility 权限并创建 event tap;physical duplicate 在创建资源前直接返回 already locked,且只可能清除 Focus 的内部 release marker。 +9. 进程生命周期 `AgentService` 持有的 `LockEngine` 调用 `lock(settings:allowsControlCUnlock:)`,检查 Agent 自己的 Accessibility 权限并创建 event tap;physical duplicate 在创建资源前直接返回 already locked,且只可能清除 Focus 的内部 release marker。 10. reply 通过 XPC 返回,`XPCClient` 把 callback bridge 成 Swift async/throwing API。 `AgentService` 位于 `Service` 模块,是把 wire protocol 翻译成领域调用的 adapter;放在 library 里是为了让 barrier、generation fence 与错误码映射可以被 `ServiceTests` 确定性覆盖。`Service` product 本身不会启动进程;真正创建 listener、保持 RunLoop 的是 `KeyboardLockerAgent/main.swift` 这个极薄的 bootstrap。 @@ -549,7 +549,7 @@ sequenceDiagram | `hasAccessibilityPermission` | `hasAccessibilityPermission()` | `AccessibilityManager` | 查询 **Agent 进程** 当前是否受信任 | | `requestAccessibilityPermission` | `requestAccessibilityPermission()` | `AccessibilityManager` | 请求系统异步显示 Agent 的授权 prompt;reply 不代表用户已授权 | | `currentSettings` | protocol 1.1 legacy Client only | `AgentService` | 为既有 selector ABI 保留;编码失败只能返回 `nil` | -| `currentSettingsWithError` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 持有的设置快照;缺失、损坏、过大或编码失败均显式抛错,不在 wrapper 侧回退默认值 | +| `currentSettingsWithError` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 启动时取得并持有的设置快照;本地 persisted payload 损坏时仅 Agent store 记录错误并回退 `.default`,而跨进程 payload 的缺失、损坏、过大或编码失败保持严格显式失败,wrapper 不自行回退默认值 | Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使用 Accessibility API 的进程身份。App 获得 Accessibility 权限并不能让 Agent 创建 event tap。 @@ -643,6 +643,8 @@ Focus App Intents extension 同样保持 App Sandbox 与 `APPLICATION_EXTENSION_ `klock` 是裸 Mach-O command-line tool,因此 target 必须生成并嵌入 `__TEXT,__info_plist`;否则 `PRODUCT_BUNDLE_IDENTIFIER` 不会成为实际 code-signing identifier,`codesign` 会退回可执行文件名 `klock`,并被 Listener requirement 正确拒绝。`CREATE_INFOPLIST_SECTION_IN_BINARY` 和 `GENERATE_INFOPLIST_FILE` 属于 XPC 身份契约,不能当作无关构建设置移除。 +端到端拒绝证据由 `scripts/verify-unsigned-client-refusal.sh` 手动验证。脚本不会把同名进程存在或一次 connection failure 当作 PASS:它先用本地 bundled `klock` 的 Team signing identity 和精确 identifier 签出临时 authorized control,在 ad-hoc probe 前后立即分别读取 descriptor,并要求两次 `agentInstanceID` 相同。只有同一 Agent generation 在 probe 前后都响应 authorized control、同时 ad-hoc client 被拒绝才 PASS;这两个 bracket sample 不声称证明采样间隔内的连续可用性。authorized control 不可达、Agent 被替换或本地 signing identity 不可用均以 exit 2 报告 `INDETERMINATE`。 + ## `SMAppService` 状态和 XPC 状态不要混为一谈 | 观察结果 | 含义 | App 行为 | @@ -668,7 +670,7 @@ locked Agent 不能自动 restart,因为 Agent 退出本身会释放正在工作 3. 只使用 Objective-C/XPC 能表达的参数和 reply 类型;复杂 Swift value 编码成有明确 schema 和大小上限的 `Data`。 4. 在 `Core/Sources/Service/AgentService.swift` 实现 protocol adapter,并在 descriptor 声明 capability。 5. 把真实领域行为放在 `Service`,不要堆进 App、CLI 或 wire adapter。 -6. 在 `Core/Sources/Client/XPCClient.swift` 增加薄的 async/throwing 封装;调用方先 handshake 并检查 capability。 +6. 在 `Core/Sources/Client/XPCClient.swift` 增加薄的 async/throwing 封装,在 selector minimum-minor 表登记引入版本;调用方在同一 connection 上先验证 major、minimum minor 和 capability,再发出 feature call。 7. wrapper 只调用 `XPCClient`;App/CLI 不得 import `Service`。 8. 明确方法是 query 还是 mutation,并定义 timeout 后能否确认最终结果。 9. 如果状态可能在调用之外变化,继续用“notification signal + XPC query”,不要把通知 payload 变成第二个状态源。 From 4516082ff5d54081da2122654526fbb157235c5e Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 22:22:23 +0800 Subject: [PATCH 098/106] feat(app): add first-run safety check Keep the ten-second fail-safe inside the Agent so the keyboard unlocks even if the App exits. Add first-run guidance, privacy-conscious diagnostics, compatibility negotiation, tests, and architecture documentation. --- Core/Sources/Client/XPCClient.swift | 22 ++ Core/Sources/Common/ServiceDescriptor.swift | 4 +- Core/Sources/Common/Shared.swift | 9 + Core/Sources/Service/AgentService.swift | 21 ++ .../ServiceCompatibilityTests.swift | 7 + .../ClientTests/XPCClientErrorTests.swift | 1 + .../ServiceTests/AgentServiceTests.swift | 56 ++++- .../AgentCoordinationServices.swift | 31 +++ KeyboardLocker/AgentRegistrar.swift | 16 ++ KeyboardLocker/AppCoordinator.swift | 75 +++++- .../KeyboardLockerDiagnostics.swift | 225 ++++++++++++++++++ .../SafetyCheckExperienceStore.swift | 25 ++ KeyboardLocker/StatusMenuController.swift | 117 ++++++++- .../AgentCoordinationServicesTests.swift | 17 ++ .../AppCoordinatorTests.swift | 66 ++++- .../KeyboardLockerDiagnosticsTests.swift | 99 ++++++++ .../SafetyCheckExperienceStoreTests.swift | 22 ++ docs/architecture.md | 3 + docs/development.md | 9 +- 19 files changed, 815 insertions(+), 10 deletions(-) create mode 100644 KeyboardLocker/KeyboardLockerDiagnostics.swift create mode 100644 KeyboardLocker/SafetyCheckExperienceStore.swift create mode 100644 KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift create mode 100644 KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 3b67e02..f0509d1 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -141,6 +141,27 @@ public final class XPCClient: @unchecked Sendable { } } + /// Starts the Agent-owned first-run safety lock. + /// + /// The reply describes whether this request created the lock. A timeout remains unknown because + /// observing `locked` cannot distinguish this request from a concurrent wrapper's acquisition. + public func beginSafetyCheck() async throws -> LockRequestOutcome { + let connection = try await negotiatedConnection( + requiring: [.safetyCheckLock] + ) + + do { + let didStart: Bool = try await withProxyReturning( + using: connection + ) { service, resume in + service.beginSafetyCheck { resume($0, $1) } + } + return didStart ? .acquired : .alreadyLocked + } catch XPCClientError.timedOut { + throw XPCClientError.operationOutcomeUnknown + } + } + /// Applies the system Focus Filter state through an ownership-aware Agent operation. public func setFocusFilterLockEnabled(_ enabled: Bool) async throws { // Both enable and disable are idempotent, but status alone cannot reveal whether Focus owns @@ -532,6 +553,7 @@ enum XPCFeatureNegotiation { .lockStatusSnapshot: 4, .lockToggle: 6, .prepareForReplacement: 0, + .safetyCheckLock: 7, ] static func validate( diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift index e01221a..6252749 100644 --- a/Core/Sources/Common/ServiceDescriptor.swift +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -50,6 +50,7 @@ public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, public static let lockStatusSnapshot = Self(rawValue: "lock-status-snapshot") public static let lockToggle = Self(rawValue: "lock-toggle") public static let prepareForReplacement = Self(rawValue: "prepare-for-replacement") + public static let safetyCheckLock = Self(rawValue: "safety-check-lock") } /// Additive replacement phase exposed for recovery without revealing the ownership ticket. @@ -78,7 +79,7 @@ public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sen /// Current bootstrap contract advertised by the bundled Agent. public enum ServiceContract { - public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 6) + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 7) public static let requiredCapabilities: Set = [ .accessibilityPrompt, @@ -92,6 +93,7 @@ public enum ServiceContract { .lockStatusSnapshot, .lockToggle, .prepareForReplacement, + .safetyCheckLock, ] } diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 733d694..9b48cc9 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -24,6 +24,9 @@ public enum SharedConstants { /// Default unlock key code for 'L' key (⌃⌘L) public static let defaultUnlockKeyCode: UInt16 = 37 + /// Fixed fail-safe window used by the first-run safety check. + public static let safetyCheckDuration: TimeInterval = 10 + /// Exact code-signing identifiers allowed to talk to the Agent's Mach service. public static let authorizedClientBundleIdentifiers: Set = [ appBundleIdentifier, @@ -76,6 +79,12 @@ public protocol KeyboardLockerServiceProtocol { reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void ) + /// Atomically starts a fixed-duration lock for first-run recovery verification. The Agent owns + /// the timer, so the lock still releases if the requesting App exits after receiving the reply. + func beginSafetyCheck( + reply: @escaping (_ didStart: Bool, _ error: Error?) -> Void + ) + /// Applies the Focus Filter's desired state. Disabling releases only a lock generation that /// the Focus Filter itself created; it never unlocks a pre-existing or subsequently claimed /// global lock. diff --git a/Core/Sources/Service/AgentService.swift b/Core/Sources/Service/AgentService.swift index 221f787..3c3b002 100644 --- a/Core/Sources/Service/AgentService.swift +++ b/Core/Sources/Service/AgentService.swift @@ -127,6 +127,27 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } + public func beginSafetyCheck( + reply: @escaping (Bool, Error?) -> Void + ) { + executeOnMainActor { + do { + try self.ensureAcceptingLockRequests() + var safetySettings = self.settings + safetySettings.autoUnlockPolicy = .timed( + seconds: SharedConstants.safetyCheckDuration + ) + let outcome = try self.engine.lock( + settings: safetySettings, + allowsControlCUnlock: false + ) + reply(outcome == .acquired, nil) + } catch { + reply(false, error) + } + } + } + public func setFocusFilterLockEnabled( _ enabled: Bool, reply: @escaping (Error?) -> Void diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift index 08cab7e..ddbb332 100644 --- a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -125,6 +125,13 @@ final class ServiceCompatibilityTests: XCTestCase { XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 6) } + func testCurrentContractRequiresSafetyCheckSelector() { + XCTAssertTrue( + ServiceContract.requiredCapabilities.contains(.safetyCheckLock) + ) + XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 7) + } + func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index bb3778f..c42cca4 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -78,6 +78,7 @@ final class XPCClientErrorTests: XCTestCase { .lockStatusSnapshot: 4, .lockToggle: 6, .prepareForReplacement: 0, + .safetyCheckLock: 7, ] for (capability, introductionMinor) in introductionMinorByCapability { diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift index c628871..b08b73a 100644 --- a/Core/Tests/ServiceTests/AgentServiceTests.swift +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -39,6 +39,10 @@ final class AgentServiceTests: XCTestCase { XCTAssertEqual(interactiveOutcome, false) assertReplacementError(interactiveError, code: 1) + var safetyCheckError: Error? + service.beginSafetyCheck { _, error in safetyCheckError = error } + assertReplacementError(safetyCheckError, code: 1) + var focusError: Error? service.setFocusFilterLockEnabled(true) { focusError = $0 } assertReplacementError(focusError, code: 1) @@ -289,6 +293,51 @@ final class AgentServiceTests: XCTestCase { XCTAssertEqual(engine.focusCalls.first?.settings, customSettings) } + func testSafetyCheckUsesFixedTimedOverrideWithoutChangingPersistedSettings() { + let service = makeService() + + var didStart: Bool? + var safetyError: Error? + service.beginSafetyCheck { started, error in + didStart = started + safetyError = error + } + + XCTAssertNil(safetyError) + XCTAssertEqual(didStart, true) + XCTAssertEqual( + engine.lockCalls.first?.settings.autoUnlockPolicy, + .timed(seconds: SharedConstants.safetyCheckDuration) + ) + XCTAssertEqual( + engine.lockCalls.first?.settings.unlockHotkey, + customSettings.unlockHotkey + ) + XCTAssertEqual(engine.lockCalls.first?.allowsControlCUnlock, false) + + engine.unlock() + var lockError: Error? + service.lockKeyboard { lockError = $0 } + XCTAssertNil(lockError) + XCTAssertEqual(engine.lockCalls.last?.settings, customSettings) + } + + func testSafetyCheckReportsAnExistingLockWithoutChangingIt() { + engine.lockOutcome = .alreadyLocked + let service = makeService() + + var didStart: Bool? + var safetyError: Error? + service.beginSafetyCheck { started, error in + didStart = started + safetyError = error + } + + XCTAssertNil(safetyError) + XCTAssertEqual(didStart, false) + XCTAssertEqual(engine.unlockCallCount, 0) + } + // MARK: - Toggle func testToggleLocksUnlockedEngineWithPersistedSettings() { @@ -447,6 +496,7 @@ private enum StubError: Error { private final class FakeLockEngine: LockEngineServing { var isLocked: Bool var lockError: Error? + var lockOutcome: LockRequestOutcome = .acquired var onUnlock: (() -> Void)? private let snapshot: LockStatusSnapshot @@ -479,8 +529,10 @@ private final class FakeLockEngine: LockEngineServing { if let lockError { throw lockError } - isLocked = true - return .acquired + if lockOutcome == .acquired { + isLocked = true + } + return lockOutcome } func setFocusFilterLockEnabled( diff --git a/KeyboardLocker/AgentCoordinationServices.swift b/KeyboardLocker/AgentCoordinationServices.swift index 10cd01f..5ef1830 100644 --- a/KeyboardLocker/AgentCoordinationServices.swift +++ b/KeyboardLocker/AgentCoordinationServices.swift @@ -4,8 +4,10 @@ import SystemSurfaces @MainActor protocol AgentControlServing: Sendable { + func beginSafetyCheck() async throws -> LockRequestOutcome func lock() async throws func unlock() async throws + func waitUntilUnlocked() async throws func requestAccessibilityPermission() async throws func resetConnection() } @@ -68,14 +70,17 @@ protocol AgentClientServing: @MainActor struct LiveAgentClient: AgentClientServing { typealias Mutation = @Sendable () async throws -> Void + typealias SafetyCheckMutation = @Sendable () async throws -> LockRequestOutcome typealias StatusQuery = @Sendable () async throws -> Bool typealias ToggleMutation = @Sendable () async throws -> Bool + private let beginSafetyCheckMutation: SafetyCheckMutation private let lockMutation: Mutation private let statusQuery: StatusQuery private let surfaceInvalidator: LockStateSurfaceInvalidator private let toggleMutation: ToggleMutation private let unlockMutation: Mutation + private let waitUntilUnlockedOperation: Mutation nonisolated init() { self.init( @@ -91,6 +96,12 @@ struct LiveAgentClient: AgentClientServing { toggle: { try await XPCClient.shared.toggle() }, + beginSafetyCheck: { + try await XPCClient.shared.beginSafetyCheck() + }, + waitUntilUnlocked: { + try await XPCClient.shared.waitUntilUnlocked() + }, surfaceInvalidator: .live ) } @@ -100,12 +111,20 @@ struct LiveAgentClient: AgentClientServing { unlock: @escaping Mutation, status: @escaping StatusQuery, toggle: @escaping ToggleMutation, + beginSafetyCheck: @escaping SafetyCheckMutation = { + try await XPCClient.shared.beginSafetyCheck() + }, + waitUntilUnlocked: @escaping Mutation = { + try await XPCClient.shared.waitUntilUnlocked() + }, surfaceInvalidator: LockStateSurfaceInvalidator ) { + beginSafetyCheckMutation = beginSafetyCheck lockMutation = lock unlockMutation = unlock statusQuery = status toggleMutation = toggle + waitUntilUnlockedOperation = waitUntilUnlocked self.surfaceInvalidator = surfaceInvalidator } @@ -118,6 +137,14 @@ struct LiveAgentClient: AgentClientServing { surfaceInvalidator.invalidate() } + func beginSafetyCheck() async throws -> LockRequestOutcome { + let outcome = try await beginSafetyCheckMutation() + if outcome == .acquired { + surfaceInvalidator.invalidate() + } + return outcome + } + func unlock() async throws { try await unlockMutation() surfaceInvalidator.invalidate() @@ -133,6 +160,10 @@ struct LiveAgentClient: AgentClientServing { return isLocked } + func waitUntilUnlocked() async throws { + try await waitUntilUnlockedOperation() + } + func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift index 2bb377c..f0bcb05 100644 --- a/KeyboardLocker/AgentRegistrar.swift +++ b/KeyboardLocker/AgentRegistrar.swift @@ -74,6 +74,22 @@ enum AgentRegistrar { return false } + /// Read-only Service Management state for user-initiated diagnostics. + static var registrationStatusDescription: String { + switch agent.status { + case .requiresApproval: + "approval-required" + case .enabled: + "enabled" + case .notFound: + "not-found" + case .notRegistered: + "not-registered" + @unknown default: + "unknown" + } + } + /// Ensures the Agent is registered and returns the user-visible lifecycle state. static func ensureEnabled() -> State { ensureEnabled( diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift index 4a01ed2..a9ed267 100644 --- a/KeyboardLocker/AppCoordinator.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -32,14 +32,23 @@ final class AppCoordinator { case locking case requestingAccessibility case restartingAgent + case startingSafetyCheck case unlocking case updatingAgent } + enum SafetyCheckState: Equatable { + case completed + case failed(String) + case idle + case running + } + struct Snapshot: Equatable { let state: State let activity: Activity? let lastError: String? + let safetyCheckState: SafetyCheckState } private(set) var state: State = .checking(lastKnownLock: nil) { @@ -60,8 +69,19 @@ final class AppCoordinator { } } + private(set) var safetyCheckState: SafetyCheckState = .idle { + didSet { + publishSnapshotIfNeeded() + } + } + var snapshot: Snapshot { - Snapshot(state: state, activity: activity, lastError: lastError) + Snapshot( + state: state, + activity: activity, + lastError: lastError, + safetyCheckState: safetyCheckState + ) } var onSnapshotChange: ((Snapshot) -> Void)? { @@ -183,6 +203,48 @@ final class AppCoordinator { } } + /// Starts the Agent-owned ten-second recovery check, then waits for authoritative unlock. + /// The App never owns the timer; quitting after acquisition still leaves the Agent fail-safe. + func startSafetyCheck() { + guard case .ready(isLocked: false) = state, + activity == nil, + safetyCheckState != .running + else { + return + } + + reconciliationTask?.cancel() + activity = .startingSafetyCheck + safetyCheckState = .running + lastError = nil + + Task { [weak self] in + guard let self else { + return + } + + do { + let outcome = try await client.beginSafetyCheck() + guard outcome == .acquired else { + throw SafetyCheckError.lockAlreadyActive + } + + // Acquisition is complete, so ordinary global unlock actions must remain available while + // this task only waits for the Agent's authoritative state to return to unlocked. + activity = nil + startReconciliation() + try await client.waitUntilUnlocked() + safetyCheckState = .completed + startReconciliation() + } catch { + let message = error.localizedDescription + activity = nil + safetyCheckState = .failed(message) + startReconciliation(preserving: message) + } + } + } + func requestAccessibilityPermission() { guard case .accessibilityRequired = state, activity == nil else { return @@ -624,3 +686,14 @@ private enum AgentUpdateError: Error, LocalizedError { } } } + +private enum SafetyCheckError: Error, LocalizedError { + case lockAlreadyActive + + var errorDescription: String? { + switch self { + case .lockAlreadyActive: + "The safety check did not start because the keyboard was already locked. Unlock it and try again." + } + } +} diff --git a/KeyboardLocker/KeyboardLockerDiagnostics.swift b/KeyboardLocker/KeyboardLockerDiagnostics.swift new file mode 100644 index 0000000..36d6463 --- /dev/null +++ b/KeyboardLocker/KeyboardLockerDiagnostics.swift @@ -0,0 +1,225 @@ +import Client +import Foundation + +@MainActor +struct KeyboardLockerDiagnosticsCollector { + struct AppIdentity: Equatable { + let build: String + let bundleIdentifier: String + let version: String + } + + typealias AccessibilityQuery = @MainActor @Sendable () async throws -> Bool + typealias DescriptorQuery = @MainActor @Sendable () async throws -> ServiceDescriptor + typealias LockSnapshotQuery = @MainActor @Sendable () async throws -> LockStatusSnapshot + + private let accessibilityQuery: AccessibilityQuery + private let appIdentity: AppIdentity + private let descriptorQuery: DescriptorQuery + private let lockSnapshotQuery: LockSnapshotQuery + private let now: @MainActor @Sendable () -> Date + private let operatingSystemVersion: String + private let registrationStatus: @MainActor @Sendable () -> String + + static var live: Self { + let bundle = Bundle.main + return Self( + appIdentity: AppIdentity( + build: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown", + bundleIdentifier: bundle.bundleIdentifier ?? "unknown", + version: bundle.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "unknown" + ), + operatingSystemVersion: ProcessInfo.processInfo.operatingSystemVersionString, + registrationStatus: { + AgentRegistrar.registrationStatusDescription + }, + descriptor: { + try await XPCClient.shared.serviceDescriptor() + }, + accessibility: { + try await XPCClient.shared.hasAccessibilityPermission() + }, + lockSnapshot: { + try await XPCClient.shared.lockStatusSnapshot() + }, + now: Date.init + ) + } + + init( + appIdentity: AppIdentity, + operatingSystemVersion: String, + registrationStatus: @escaping @MainActor @Sendable () -> String, + descriptor: @escaping DescriptorQuery, + accessibility: @escaping AccessibilityQuery, + lockSnapshot: @escaping LockSnapshotQuery, + now: @escaping @MainActor @Sendable () -> Date + ) { + self.appIdentity = appIdentity + self.operatingSystemVersion = operatingSystemVersion + self.registrationStatus = registrationStatus + descriptorQuery = descriptor + accessibilityQuery = accessibility + lockSnapshotQuery = lockSnapshot + self.now = now + } + + func report(appSnapshot: AppCoordinator.Snapshot) async -> String { + let descriptor = await capture(descriptorQuery) + let accessibility = await capture(accessibilityQuery) + let lockSnapshot = await capture(lockSnapshotQuery) + + var lines = [ + "KeyboardLocker Diagnostics", + "Generated: \(format(now()))", + "App: \(appIdentity.version) (\(appIdentity.build))", + "App Identifier: \(appIdentity.bundleIdentifier)", + "macOS: \(operatingSystemVersion)", + "Agent Registration: \(registrationStatus())", + "App State: \(appSnapshot.state.diagnosticDescription)", + "App Activity: \(appSnapshot.activity?.diagnosticDescription ?? "none")", + "Safety Check: \(appSnapshot.safetyCheckState.diagnosticDescription)", + "Last Error: \(redact(appSnapshot.lastError) ?? "none")", + "", + "Agent", + ] + + switch descriptor { + case let .success(value): + lines.append(contentsOf: [ + "Status: available", + "Identifier: \(value.agentBundleIdentifier)", + "Version: \(value.agentVersion) (\(value.agentBuild))", + "Protocol: \(value.protocolVersion.major).\(value.protocolVersion.minor)", + "Instance: \(value.agentInstanceID.uuidString)", + "Replacement: \(value.replacementPhase.rawValue)", + "Capabilities: \(value.capabilities.map(\.rawValue).sorted().joined(separator: ", "))", + ]) + case let .failure(error): + lines.append("Status: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") + } + + switch accessibility { + case let .success(isGranted): + lines.append("Accessibility: \(isGranted ? "granted" : "not-granted")") + case let .failure(error): + lines.append("Accessibility: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") + } + + lines.append(contentsOf: ["", "Lock"]) + switch lockSnapshot { + case let .success(value): + lines.append(contentsOf: [ + "Status: \(value.isLocked ? "locked" : "unlocked")", + "Started: \(value.startedAt.map(format) ?? "none")", + "Auto Unlock: \(value.autoUnlockTargetDate.map(format) ?? "none")", + "Policy: \(value.settings.autoUnlockPolicy.diagnosticDescription)", + "Unlock Hotkey: \(value.settings.unlockHotkey.displayString)", + ]) + case let .failure(error): + lines.append("Status: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") + } + + return lines.joined(separator: "\n") + } + + private func capture( + _ operation: @MainActor @Sendable () async throws -> Value + ) async -> Result { + do { + return .success(try await operation()) + } catch { + return .failure(error) + } + } + + private func format(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + /// Keeps reports useful while excluding path-like fragments that may carry a user name. + private func redact(_ text: String?) -> String? { + guard let text else { + return nil + } + + let redacted = text.replacingOccurrences( + of: #"(?:file://|~/|/)[^\s]+"#, + with: "", + options: .regularExpression + ) + let collapsed = redacted + .split(whereSeparator: \.isWhitespace) + .map(String.init) + .joined(separator: " ") + return collapsed.isEmpty ? nil : collapsed + } +} + +private extension AppCoordinator.State { + var diagnosticDescription: String { + switch self { + case let .checking(lastKnownLock): + "checking (last-known: \(lastKnownLock.map(String.init) ?? "unknown"))" + case .agentApprovalRequired: + "agent-approval-required" + case .agentReplacementInProgress: + "agent-replacement-in-progress" + case let .agentUpdateRequired(isLocked, _): + "agent-update-required (locked: \(isLocked.map(String.init) ?? "unknown"))" + case let .accessibilityRequired(isLocked): + "accessibility-required (locked: \(isLocked))" + case let .ready(isLocked): + "ready (locked: \(isLocked))" + case let .unavailable(_, canRestartAgent): + "unavailable (restart-available: \(canRestartAgent))" + } + } +} + +private extension AppCoordinator.Activity { + var diagnosticDescription: String { + switch self { + case .locking: + "locking" + case .requestingAccessibility: + "requesting-accessibility" + case .restartingAgent: + "restarting-agent" + case .startingSafetyCheck: + "starting-safety-check" + case .unlocking: + "unlocking" + case .updatingAgent: + "updating-agent" + } + } +} + +private extension AppCoordinator.SafetyCheckState { + var diagnosticDescription: String { + switch self { + case .completed: + "completed" + case .failed: + "failed" + case .idle: + "idle" + case .running: + "running" + } + } +} + +private extension KeyboardLockerSettings.AutoUnlockPolicy { + var diagnosticDescription: String { + switch self { + case .disabled: + "disabled" + case let .timed(seconds): + "timed (\(seconds.formatted()) seconds)" + } + } +} diff --git a/KeyboardLocker/SafetyCheckExperienceStore.swift b/KeyboardLocker/SafetyCheckExperienceStore.swift new file mode 100644 index 0000000..4b9531c --- /dev/null +++ b/KeyboardLocker/SafetyCheckExperienceStore.swift @@ -0,0 +1,25 @@ +import Foundation + +/// App-owned presentation preference. This does not mirror Agent settings or lock state. +struct SafetyCheckExperienceStore { + private static let completionKey = "keyboardlocker.safety-check.completed" + + private let completionKey: String + private let userDefaults: UserDefaults + + init( + userDefaults: UserDefaults = .standard, + completionKey: String = Self.completionKey + ) { + self.userDefaults = userDefaults + self.completionKey = completionKey + } + + var hasCompletedSafetyCheck: Bool { + userDefaults.bool(forKey: completionKey) + } + + func markCompleted() { + userDefaults.set(true, forKey: completionKey) + } +} diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index 9c096ba..a6533ad 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -7,24 +7,34 @@ import ServiceManagement final class StatusMenuController: NSObject, NSMenuDelegate { private let commandLineToolManager: CommandLineToolLinkManager private let coordinator: AppCoordinator + private let diagnosticsCollector: KeyboardLockerDiagnosticsCollector private let menu = NSMenu() + private let safetyCheckStore: SafetyCheckExperienceStore private let statusItem: NSStatusItem private var currentSnapshot: AppCoordinator.Snapshot private var detailMessage: String? + private var hasOfferedSafetyCheckThisLaunch = false + private var lastHandledSafetyCheckState: AppCoordinator.SafetyCheckState = .idle convenience init(coordinator: AppCoordinator) { self.init( coordinator: coordinator, - commandLineToolManager: CommandLineToolLinkManager() + commandLineToolManager: CommandLineToolLinkManager(), + diagnosticsCollector: .live, + safetyCheckStore: SafetyCheckExperienceStore() ) } init( coordinator: AppCoordinator, - commandLineToolManager: CommandLineToolLinkManager + commandLineToolManager: CommandLineToolLinkManager, + diagnosticsCollector: KeyboardLockerDiagnosticsCollector, + safetyCheckStore: SafetyCheckExperienceStore ) { self.coordinator = coordinator self.commandLineToolManager = commandLineToolManager + self.diagnosticsCollector = diagnosticsCollector + self.safetyCheckStore = safetyCheckStore currentSnapshot = coordinator.snapshot statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) super.init() @@ -58,6 +68,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } rebuildMenu(statusTitle: appearance.title) + handleSafetyCheckExperience(snapshot) } private func rebuildMenu(statusTitle: String) { @@ -76,12 +87,22 @@ final class StatusMenuController: NSObject, NSMenuDelegate { addAction(title: "Show Details…", action: #selector(showDetails)) } + if currentSnapshot.activity == nil, + case .ready(isLocked: false) = currentSnapshot.state, + currentSnapshot.safetyCheckState != .running { + addAction( + title: "Run 10-Second Safety Check…", + action: #selector(runSafetyCheck) + ) + } + menu.addItem(.separator()) addAction( title: "Refresh Status", action: #selector(refresh), isEnabled: currentSnapshot.activity == nil ) + addAction(title: "Copy Diagnostics", action: #selector(copyDiagnostics)) menu.addItem(.separator()) addAction(title: "Command Line Tool…", action: #selector(manageCommandLineTool)) menu.addItem(.separator()) @@ -153,6 +174,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { "KeyboardLocker — Requesting Access…" case .restartingAgent: "KeyboardLocker — Restarting Agent…" + case .startingSafetyCheck: + "KeyboardLocker — Starting Safety Check…" case .unlocking: "KeyboardLocker — Unlocking…" case .updatingAgent: @@ -246,6 +269,54 @@ final class StatusMenuController: NSObject, NSMenuDelegate { return messages.isEmpty ? nil : messages.joined(separator: "\n\n") } + private func handleSafetyCheckExperience(_ snapshot: AppCoordinator.Snapshot) { + let previousState = lastHandledSafetyCheckState + lastHandledSafetyCheckState = snapshot.safetyCheckState + + switch snapshot.safetyCheckState { + case .completed where previousState != .completed: + safetyCheckStore.markCompleted() + presentSafetyCheckResult( + title: "Safety Check Complete", + message: "Keyboard input is available again. KeyboardLocker is ready to use." + ) + + case let .failed(message) where snapshot.safetyCheckState != previousState: + presentSafetyCheckResult( + title: "Safety Check Failed", + message: message + ) + + case .completed, .failed, .idle, .running: + break + } + + guard !safetyCheckStore.hasCompletedSafetyCheck, + !hasOfferedSafetyCheckThisLaunch, + snapshot.activity == nil, + snapshot.safetyCheckState == .idle, + case .ready(isLocked: false) = snapshot.state + else { + return + } + + hasOfferedSafetyCheckThisLaunch = true + Task { @MainActor [weak self] in + self?.runSafetyCheck() + } + } + + private func presentSafetyCheckResult(title: String, message: String) { + Task { @MainActor in + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + NSApp.activateForUserPresentation() + alert.runModal() + } + } + @objc private func performDisplayedLockAction() { coordinator.performDisplayedLockAction() @@ -256,6 +327,48 @@ final class StatusMenuController: NSObject, NSMenuDelegate { coordinator.requestAccessibilityPermission() } + @objc + private func runSafetyCheck() { + let alert = NSAlert() + alert.messageText = "Run a 10-Second Safety Check?" + alert.informativeText = """ + KeyboardLocker will temporarily block keyboard input and keyboard system controls. The mouse \ + and trackpad remain available, and the Agent will unlock automatically after 10 seconds even \ + if this App exits. You can also unlock earlier with the configured hotkey or the notification's \ + Unlock Now action. + """ + alert.addButton(withTitle: "Start Safety Check") + alert.addButton(withTitle: "Not Now") + + NSApp.activateForUserPresentation() + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + coordinator.startSafetyCheck() + } + + @objc + private func copyDiagnostics() { + let snapshot = currentSnapshot + Task { @MainActor [weak self] in + guard let self else { + return + } + + let report = await diagnosticsCollector.report(appSnapshot: snapshot) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(report, forType: .string) + + let alert = NSAlert() + alert.messageText = "Diagnostics Copied" + alert.informativeText = "The report contains runtime state and version information, but no keyboard input, user name, host name, or file paths." + alert.addButton(withTitle: "OK") + NSApp.activateForUserPresentation() + alert.runModal() + } + } + @objc private func openLoginItemsSettings() { SMAppService.openSystemSettingsLoginItems() diff --git a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift index 15b7f1d..d2d381e 100644 --- a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift +++ b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift @@ -110,6 +110,17 @@ final class AgentCoordinationServicesTests: XCTestCase { XCTAssertEqual(recorder.calls, [.toggle, .widget, .control]) } + @MainActor + func testSuccessfulSafetyCheckInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + let outcome = try await client.beginSafetyCheck() + + XCTAssertEqual(outcome, .acquired) + XCTAssertEqual(recorder.calls, [.safetyCheck, .widget, .control]) + } + @MainActor func testFailedToggleDoesNotInvalidateSurfaces() async { let recorder = CallRecorder() @@ -185,6 +196,11 @@ final class AgentCoordinationServicesTests: XCTestCase { recorder.record(.toggle) return true }, + beginSafetyCheck: { + recorder.record(.safetyCheck) + return .acquired + }, + waitUntilUnlocked: {}, surfaceInvalidator: makeInvalidator(recorder: recorder) ) } @@ -211,6 +227,7 @@ private final class CallRecorder: @unchecked Sendable { enum Call: Equatable { case control case lock + case safetyCheck case status case toggle case unlock diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift index 9c173b8..0aeb2ae 100644 --- a/KeyboardLockerModelTests/AppCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -42,7 +42,8 @@ final class AppCoordinatorTests: XCTestCase { XCTAssertEqual(coordinator.snapshot, AppCoordinator.Snapshot( state: .ready(isLocked: false), activity: nil, - lastError: nil + lastError: nil, + safetyCheckState: .idle )) XCTAssertEqual(observer.initialStates, [false]) XCTAssertEqual(snapshots.last, coordinator.snapshot) @@ -100,6 +101,53 @@ final class AppCoordinatorTests: XCTestCase { XCTAssertEqual(snapshots.last?.state, .ready(isLocked: true)) } + @MainActor + func testSafetyCheckWaitsForAuthoritativeUnlockAndCompletes() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver(), + initialState: .ready(isLocked: false) + ) + + coordinator.startSafetyCheck() + try await waitUntil { + coordinator.safetyCheckState == .completed + } + + XCTAssertEqual(client.beginSafetyCheckCallCount, 1) + XCTAssertEqual(client.waitUntilUnlockedCallCount, 1) + XCTAssertFalse(client.isLocked) + XCTAssertNil(coordinator.activity) + } + + @MainActor + func testSafetyCheckReportsConcurrentExistingLock() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + client.safetyCheckOutcome = .alreadyLocked + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver(), + initialState: .ready(isLocked: false) + ) + + coordinator.startSafetyCheck() + try await waitUntil { + if case .failed = coordinator.safetyCheckState { + return true + } + return false + } + + guard case let .failed(message) = coordinator.safetyCheckState else { + return XCTFail("Expected a failed safety check.") + } + XCTAssertTrue(message.contains("already locked")) + XCTAssertEqual(client.waitUntilUnlockedCallCount, 0) + } + @MainActor private func makeCoordinator( client: FakeAgentClient, @@ -135,10 +183,13 @@ final class AppCoordinatorTests: XCTestCase { @MainActor private final class FakeAgentClient: AgentClientServing { + private(set) var beginSafetyCheckCallCount = 0 private(set) var lockCallCount = 0 private(set) var unlockCallCount = 0 + private(set) var waitUntilUnlockedCallCount = 0 var accessibilityPermissionGranted: Bool var isLocked: Bool + var safetyCheckOutcome: LockRequestOutcome = .acquired private let descriptor = ServiceDescriptor( protocolVersion: ServiceContract.protocolVersion, @@ -163,6 +214,14 @@ private final class FakeAgentClient: AgentClientServing { isLocked = true } + func beginSafetyCheck() async throws -> LockRequestOutcome { + beginSafetyCheckCallCount += 1 + if safetyCheckOutcome == .acquired { + isLocked = true + } + return safetyCheckOutcome + } + func unlock() async throws { unlockCallCount += 1 isLocked = false @@ -200,6 +259,11 @@ private final class FakeAgentClient: AgentClientServing { func requestAccessibilityPermission() async throws {} + func waitUntilUnlocked() async throws { + waitUntilUnlockedCallCount += 1 + isLocked = false + } + func resetConnection() {} } diff --git a/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift b/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift new file mode 100644 index 0000000..324cacf --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift @@ -0,0 +1,99 @@ +import Client +import Foundation +import XCTest + +final class KeyboardLockerDiagnosticsTests: XCTestCase { + @MainActor + func testReportIncludesRuntimeFactsWithoutMachineIdentity() async { + let descriptor = ServiceDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 7), + capabilities: [.lockControl, .safetyCheckLock], + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.2", + agentBuild: "34", + agentInstanceID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + ) + let lockSnapshot = LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + let collector = KeyboardLockerDiagnosticsCollector( + appIdentity: .init( + build: "12", + bundleIdentifier: SharedConstants.appBundleIdentifier, + version: "1.1" + ), + operatingSystemVersion: "macOS 26.0", + registrationStatus: { "enabled" }, + descriptor: { descriptor }, + accessibility: { true }, + lockSnapshot: { lockSnapshot }, + now: { Date(timeIntervalSince1970: 1_700_000_100) } + ) + let appSnapshot = AppCoordinator.Snapshot( + state: .ready(isLocked: false), + activity: nil, + lastError: nil, + safetyCheckState: .completed + ) + + let report = await collector.report(appSnapshot: appSnapshot) + + XCTAssertTrue(report.contains("App: 1.1 (12)")) + XCTAssertTrue(report.contains("Agent Registration: enabled")) + XCTAssertTrue(report.contains("Protocol: 1.7")) + XCTAssertTrue(report.contains("Capabilities: lock-control, safety-check-lock")) + XCTAssertTrue(report.contains("Accessibility: granted")) + XCTAssertTrue(report.contains("Status: unlocked")) + XCTAssertTrue(report.contains("Safety Check: completed")) + XCTAssertFalse(report.localizedCaseInsensitiveContains("user name")) + XCTAssertFalse(report.localizedCaseInsensitiveContains("host name")) + } + + @MainActor + func testReportKeepsPartialDiagnosticsAndRedactsPaths() async { + let collector = KeyboardLockerDiagnosticsCollector( + appIdentity: .init(build: "1", bundleIdentifier: "test.app", version: "1.0"), + operatingSystemVersion: "macOS test", + registrationStatus: { "not-registered" }, + descriptor: { + throw TestError.message("Failed at /Users/example/Library/Agent.app") + }, + accessibility: { false }, + lockSnapshot: { + throw TestError.message("Could not open file:///private/tmp/KeyboardLocker/state") + }, + now: { Date(timeIntervalSince1970: 0) } + ) + let appSnapshot = AppCoordinator.Snapshot( + state: .unavailable(message: "Missing /Applications/KeyboardLocker.app", canRestartAgent: true), + activity: nil, + lastError: "Missing /Applications/KeyboardLocker.app", + safetyCheckState: .failed("Expected") + ) + + let report = await collector.report(appSnapshot: appSnapshot) + + XCTAssertTrue(report.contains("Agent Registration: not-registered")) + XCTAssertTrue(report.contains("Accessibility: not-granted")) + XCTAssertTrue(report.contains("")) + XCTAssertFalse(report.contains("/Users/example")) + XCTAssertFalse(report.contains("/private/tmp")) + XCTAssertFalse(report.contains("/Applications/KeyboardLocker.app")) + } +} + +private struct TestError: Error, LocalizedError { + let value: String + + static func message(_ value: String) -> Self { + Self(value: value) + } + + var errorDescription: String? { + value + } +} diff --git a/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift b/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift new file mode 100644 index 0000000..62ae5f0 --- /dev/null +++ b/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift @@ -0,0 +1,22 @@ +import Foundation +import XCTest + +final class SafetyCheckExperienceStoreTests: XCTestCase { + func testCompletionPersistsOnlyAfterBeingMarked() throws { + let suiteName = "SafetyCheckExperienceStoreTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let store = SafetyCheckExperienceStore( + userDefaults: defaults, + completionKey: "completed" + ) + + XCTAssertFalse(store.hasCompletedSafetyCheck) + + store.markCompleted() + + XCTAssertTrue(store.hasCompletedSafetyCheck) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 28cf525..311f0e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,8 @@ Widget ──────┘ ├─ Settings ownership (source of t App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management、XPC 两个系统边界之间的应用层编排,不是第二份锁核心。它必须保持为无锁状态真相源的薄协调器:`AppCoordinator` 只消费 Agent/系统查询并暴露应用状态与动作,不依赖 presentation framework;后续 UI 只能映射这些状态与动作。任何 CGEventTap、设置持久化和 Accessibility 判定仍只在 Agent 内执行。 +App 可以持久化纯 presentation 状态,例如“是否已完成首次安全测试”;这类标记不得包含或推导锁状态、Agent settings 或 Accessibility 状态,也不能参与任何领域行为判定。诊断报告同样只是对当前权威查询的只读、脱敏呈现,不得缓存成另一份真相源。 + ## 全局锁语义 物理键盘只有一个,所以锁状态是**一个由 Agent 拥有的全局布尔值**。不存在按客户端或按会话划分的锁归属。 @@ -63,6 +65,7 @@ App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `toggle` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `toggle` 是同一全局锁的原子翻转:在 Agent 的串行执行边界内于同一 execution turn 完成"读当前状态 + 反向 mutation",并向调用方返回翻转后的布尔值。锁定方向使用普通非交互 `lock` 语义(含 Focus persistence takeover),解锁方向等价于显式 `unlock`;wrapper 不得先读 `status()` 再自行决定方向来模拟 toggle。 - `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 +- safety check 是 capability-gated 的一次性 Agent 操作:仅在 unlocked 时创建新锁,沿用 Agent 持久化的解锁热键,但把该轮 active settings 的 auto-unlock 强制为固定 10 秒。fail-safe timer 由 Agent/`LockEngine` 持有,因此 App 退出、崩溃或 XPC 断开都不能让测试失去自动解锁兜底;该 override 不写回 settings store。若调用时已 locked,Agent 原子返回 already locked 且不修改既有锁。App 只在重新查询到权威 unlocked 后把 presentation completion 标记为完成。 - interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。发起并等待中的 CLI 进程对本轮锁负有退出清理责任:它观察 SIGTERM/SIGHUP/SIGINT,退出前尽力释放锁(有界等待后无论如何退出)。wire protocol 不携带 interactive lock 的代际令牌,清理无法区分自己创建的锁与等待期间由他入口重建的锁——若本轮锁已被解开且另一入口重新锁定,清理会释放较新的锁,这与任意入口可解锁同一全局锁的契约一致;SIGKILL/SIGSTOP 不可观察,遗留锁仍由解锁热键、通知 Unlock Now、auto-unlock 等全局途径解开。这是输入手势语义的进程侧延伸,不构成 session 或所有权。 - Focus Filter 是 **activation-triggered acquisition**,不是“Focus active 期间持续保持 locked”的 policy。Focus 激活时的 `true` 最多尝试一次 `unlocked → locked`,并只在成功创建锁时记录该 Focus-owned generation;Focus 关闭时的 `false` 只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领。显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束该 generation;实现不会查询当前 Focus 后自动重建锁,也不会在同一次 Focus activation 内持续 relock。这个进程内 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 - `status()` 保留为兼容旧 wrapper 的最小布尔查询;需要呈现锁定时长、auto-unlock deadline 或 active settings 的 wrapper 使用 capability-gated `lockStatusSnapshot()`。snapshot 传输权威时间点,不传会立即过期的 duration/countdown counter。 diff --git a/docs/development.md b/docs/development.md index 545f5c6..f8ce3fd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ 所有面都通过 `KeyboardLockerServiceProtocol` 与 Agent 通信(Mach 服务 `io.lzhlovesjyq.keyboardlocker.agent`)。 1. App 建立 connection 后先调用 `XPCClient.serviceDescriptor()`,验证 protocol version、required capabilities 和 bundled Agent build;只有兼容后才调用新增 selector。descriptor 在 fresh connection 上重试后仍失败、但旧 `status` 成功时,只能按 unverified base contract 处理,不能断言它一定是 legacy Agent。capability-gated Client API 会在同一条具体 connection generation 上重新握手,先验证 major 相同及该 selector 的引入 minor,再检查 capability 并发送 selector;connection 变化后不能复用旧 grant。 -2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `lockStatusSnapshot` / `currentSettings`。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 +2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `lockStatusSnapshot` / `currentSettings`。App 的首次安全测试使用独立的 `beginSafetyCheck`,由 Agent 强制该轮锁在 10 秒后自动解锁。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 3. Client 与 Listener 都在 activate 前安装同 Team + 精确 signing identifier 的 XPC requirement;系统完成双向认证后,Agent 的 `ServiceDelegate` 才接受连接并路由到 `AgentService`。 4. Agent bootstrap 创建一个贯穿进程生命周期的 `AgentService`,它拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`)和唯一的 `LockEngine` 实例,并驱动 `lock(settings:allowsControlCUnlock:)` / `unlock()`;所有 listener connection 都接收同一个 service object。 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 @@ -22,7 +22,7 @@ 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive/Focus 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`;protocol 1.5 新增 capability-gated `setFocusFilterLockEnabled`,旧 `status` / `lockKeyboard` ABI 保持不变。protocol 1.6 新增 capability-gated `toggleKeyboard`,把原子翻转放进 Agent 串行边界并返回翻转后状态。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive/Focus/safety-check 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings snapshot)、`LockRequestOutcome`、`SharedConstants`(Mach 名、Agent ID、client allowlist)、`NotificationNames.stateChanged`。protocol 1.1 的 `currentSettings` 只为旧 Client 保留;当前 Client 经 additive `currentSettingsWithError` 读取并接收显式编码错误。protocol 1.3 新增 capability-gated interactive lock selector;protocol 1.4 新增 capability-gated `lockStatusSnapshot`;protocol 1.5 新增 capability-gated `setFocusFilterLockEnabled`,旧 `status` / `lockKeyboard` ABI 保持不变。protocol 1.6 新增 capability-gated `toggleKeyboard`,把原子翻转放进 Agent 串行边界并返回翻转后状态;protocol 1.7 新增 capability-gated `beginSafetyCheck`,由 Agent 为该轮锁强制安装固定 10 秒 auto-unlock fail-safe。 - `LockStatusSnapshot.swift`:`LockStatusSnapshot` format 1 和有大小上限的 JSON XPC 编解码。snapshot 原子携带 capture time、布尔状态、锁定起点、auto-unlock deadline 与 active settings;duration/countdown 由 consumer 根据权威时间点派生,不作为会迅速过期的 transport 字段。 - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成同 Team + 精确 identifier 的双向 XPC requirement 字符串,并用 `SecRequirementCreateWithString` 校验其可编译(编译结果仅作一次性校验后丢弃;安装到连接时由系统重新解析字符串);unsigned/ad-hoc 进程 fail closed。 @@ -50,7 +50,9 @@ **App**(`KeyboardLocker/`)—— 长命的 menu-bar 薄 wrapper,并承载一次性系统 action;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces - `AgentRegistrar.swift`:通过 `SMAppService.agent(plistName:)` 确保注册,读取 bundled Agent metadata 并比较运行中 descriptor;replacement 会等待旧 Agent 退出后重新注册 bundled 版本。 -- `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。 +- `KeyboardLockerApplication.swift` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。首次 readiness 达到 ready + unlocked 后,menu controller 会提示一次可跳过的 10 秒安全测试;菜单也始终保留手动入口。 +- `SafetyCheckExperienceStore.swift`:只持久化 App presentation 层的“安全测试已完成”标记,不存储或推导锁状态、settings 或 Accessibility 状态。只有 Agent 已创建测试锁、且 App 后续重新取得权威 unlocked 时才写入完成。 +- `KeyboardLockerDiagnostics.swift`:按用户动作实时收集 App/Agent 版本、protocol/capability、Service Management、Accessibility、权威 lock snapshot 与最近错误,生成可复制的稳定文本报告。报告不采集 keyboard input、username、hostname 或 file path;错误文本还会对 path-like token 做二次脱敏。单项查询失败会成为显式 unavailable/error 行,不阻断其余诊断。 - `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard`、`Toggle Keyboard Lock` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。`Toggle Keyboard Lock` 调用 Agent 串行边界内的原子 `toggle` 并返回翻转后的 `Bool`,不做 client-side 状态合成。`AppIntents/KeyboardLockerShortcuts.swift` 在 macOS 26+ 声明 `AppShortcutsProvider`,为 lock / unlock / toggle 注册 promoted App Shortcuts 与 invocation phrases;更早的 macOS 只从 Shortcuts action library 暴露这些 intent。 - `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 - `Automation/ExternalAutomationController.swift`:Services 与 URL event 共用的串行 one-shot executor。每个 action 只调用 `AgentLockActionServing` 的对应 desired-state/query method;跨多次 submit 也保持接收顺序,并把 authoritative status 或合并后的 failure 交给 presentation boundary。 @@ -139,6 +141,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget - `AgentService` 是 nonisolated XPC adapter;它把所有 Agent 可变状态与引擎操作切到 `MainActor`,以便在同一隔离域维护 CFRunLoop、timer、settings 和 replacement transaction。 - 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。若重新启用仍失败,会清理 tap/timer、切换为 unlocked 并广播,绝不继续报告虚假的 locked 状态。 - App 协调器通过 protocol injection 与 presentation layer 解耦。`KeyboardLockerModelTests` 是 non-hosted test target,通过 fake Client/lifecycle/state observer 覆盖确定性协调流程,不触碰 live `SMAppService`/XPC。 +- 首次安全测试的 ModelTests 覆盖 Agent 成功创建后等待权威 unlocked 才完成、并发既有锁拒绝与 presentation invalidation;ServiceTests 覆盖固定 10 秒 override、沿用持久化热键、不写回 settings store 及 replacement drain 拒绝。真实 Quartz event tap、TCC prompt 和用户按键解锁仍属于真机手动验证边界。 ### 覆盖边界与已知限制 From 744c56f33a54a53bc1d1ab47ae2d088f10a22aa0 Mon Sep 17 00:00:00 2001 From: Eden Date: Wed, 12 Aug 2026 22:47:40 +0800 Subject: [PATCH 099/106] test: migrate suites to Swift Testing Convert all Core and app model tests to Testing macros while preserving lifecycle cleanup and deterministic async gates. Serialize suite-local state and isolate keyboard-layout tests on MainActor so Swift Testing concurrency remains safe. --- .../IdempotentMutationRetrierTests.swift | 44 +- .../LockStateSubscriberTests.swift | 269 ++++----- .../ServiceCompatibilityTests.swift | 261 +++++---- .../ClientTests/UnlockStatusPollerTests.swift | 56 +- .../Tests/ClientTests/UnlockWaiterTests.swift | 38 +- .../ClientTests/XPCClientErrorTests.swift | 97 ++-- .../CommonTests/HotkeyDisplayTests.swift | 20 +- .../KeyboardLockerSettingsCodingTests.swift | 43 +- .../LockStatusSnapshotCodingTests.swift | 55 +- ...odeSigningRequirementEvaluationTests.swift | 31 +- .../XPCCodeSigningRequirementTests.swift | 64 ++- .../ServiceTests/AgentServiceTests.swift | 239 ++++---- .../EventTapInstallationTests.swift | 62 +-- .../KeyboardLockerSettingsStoreTests.swift | 90 ++-- .../LockEngineAutoUnlockScheduleTests.swift | 46 +- Core/Tests/ServiceTests/LockEngineTests.swift | 224 ++++---- .../ServiceTests/LockRuntimeStateTests.swift | 197 ++++--- .../LockStatusNotifierTests.swift | 82 +-- .../LockedKeyboardEventPolicyTests.swift | 64 ++- .../ReplacementTransactionTests.swift | 79 +-- .../UnlockGestureMatcherTests.swift | 45 +- .../ServiceTests/XPCAccessControlTests.swift | 22 +- .../LockStateSurfaceInvalidatorTests.swift | 25 +- .../AgentCoordinationServicesTests.swift | 74 +-- .../AgentReadinessCoordinatorTests.swift | 282 +++++----- .../AgentRegistrarTests.swift | 53 +- .../AgentReplacementCoordinatorTests.swift | 160 +++--- .../AppCoordinatorTests.swift | 68 ++- .../CommandLineToolLinkManagerTests.swift | 96 ++-- .../ExternalAutomationControllerTests.swift | 97 ++-- .../KeyboardLockAppIntentTests.swift | 78 +-- .../KeyboardLockFocusFilterIntentTests.swift | 33 +- .../KeyboardLockerControlModelTests.swift | 36 +- .../KeyboardLockerDiagnosticsTests.swift | 45 +- ...KeyboardLockerInteractiveWidgetTests.swift | 26 +- .../KeyboardLockerScriptCommandTests.swift | 77 +-- .../KeyboardLockerServicesProviderTests.swift | 42 +- .../KeyboardLockerURLRouteTests.swift | 70 +-- .../KeyboardLockerWidgetTimelineTests.swift | 47 +- .../KlockCommandLineTests.swift | 510 +++++++++--------- .../KlockStatusOutputTests.swift | 45 +- .../SafetyCheckExperienceStoreTests.swift | 14 +- docs/development.md | 2 + 43 files changed, 2121 insertions(+), 1887 deletions(-) diff --git a/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift index 8173320..10015ba 100644 --- a/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift +++ b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift @@ -1,65 +1,71 @@ @testable import Client import Foundation -import XCTest +import Testing -final class IdempotentMutationRetrierTests: XCTestCase { - func testSuccessfulMutationDoesNotResetOrRetry() async throws { +@Suite(.serialized) +struct IdempotentMutationRetrierTests { + @Test + func successfulMutationDoesNotResetOrRetry() async throws { let recorder = CallRecorder() let operation = ScriptedOperation(outcomes: [.success], recorder: recorder) try await perform(operation: operation, recorder: recorder) - XCTAssertEqual(recorder.calls, [.attempt]) + #expect(recorder.calls == [.attempt]) } - func testTimeoutResetsConnectionBeforeSingleRetry() async throws { + @Test + func timeoutResetsConnectionBeforeSingleRetry() async throws { let recorder = CallRecorder() let operation = ScriptedOperation(outcomes: [.timedOut, .success], recorder: recorder) try await perform(operation: operation, recorder: recorder) - XCTAssertEqual(recorder.calls, [.attempt, .reset, .attempt]) + #expect(recorder.calls == [.attempt, .reset, .attempt]) } - func testSecondTimeoutBecomesUnknownWithoutAnotherRetry() async { + @Test + func secondTimeoutBecomesUnknownWithoutAnotherRetry() async { let recorder = CallRecorder() let operation = ScriptedOperation(outcomes: [.timedOut, .timedOut], recorder: recorder) do { try await perform(operation: operation, recorder: recorder) - XCTFail("Expected the second timeout to become an unknown outcome.") + Issue.record("Expected the second timeout to become an unknown outcome.") } catch XPCClientError.operationOutcomeUnknown { - XCTAssertEqual(recorder.calls, [.attempt, .reset, .attempt]) + #expect(recorder.calls == [.attempt, .reset, .attempt]) } catch { - XCTFail("Unexpected error: \(error)") + Issue.record("Unexpected error: \(error)") } } - func testNonTimeoutFailureDoesNotResetOrRetry() async { + @Test + func nonTimeoutFailureDoesNotResetOrRetry() async { let recorder = CallRecorder() let operation = ScriptedOperation(outcomes: [.failure], recorder: recorder) do { try await perform(operation: operation, recorder: recorder) - XCTFail("Expected the original failure to propagate.") + Issue.record("Expected the original failure to propagate.") } catch MutationTestError.expected { - XCTAssertEqual(recorder.calls, [.attempt]) + #expect(recorder.calls == [.attempt]) } catch { - XCTFail("Unexpected error: \(error)") + Issue.record("Unexpected error: \(error)") } } - func testRetryPropagatesNonTimeoutFailure() async { + @Test + func retryPropagatesNonTimeoutFailure() async { let recorder = CallRecorder() let operation = ScriptedOperation(outcomes: [.timedOut, .failure], recorder: recorder) do { try await perform(operation: operation, recorder: recorder) - XCTFail("Expected the retry failure to propagate.") + Issue.record("Expected the retry failure to propagate.") } catch MutationTestError.expected { - XCTAssertEqual(recorder.calls, [.attempt, .reset, .attempt]) + #expect(recorder.calls == [.attempt, .reset, .attempt]) } catch { - XCTFail("Unexpected error: \(error)") + Issue.record("Unexpected error: \(error)") } } @@ -90,7 +96,7 @@ private actor ScriptedOperation { func perform() throws { recorder.record(.attempt) guard !outcomes.isEmpty else { - XCTFail("The operation ran more times than expected.") + Issue.record("The operation ran more times than expected.") return } diff --git a/Core/Tests/ClientTests/LockStateSubscriberTests.swift b/Core/Tests/ClientTests/LockStateSubscriberTests.swift index 47fb4e1..1dbd3e7 100644 --- a/Core/Tests/ClientTests/LockStateSubscriberTests.swift +++ b/Core/Tests/ClientTests/LockStateSubscriberTests.swift @@ -1,169 +1,179 @@ @testable import Client -import XCTest +import Foundation +import Testing -final class LockStateSubscriberTests: XCTestCase { - func testSubscriptionCalibratesImmediatelyAfterInstallingObservers() async { +@Suite(.serialized) +struct LockStateSubscriberTests { + @Test + func subscriptionCalibratesImmediatelyAfterInstallingObservers() async throws { let fetcher = ControlledStateFetcher() - let receivedInitialState = expectation(description: "Received initial authoritative state") - let recorder = StateRecorder { - receivedInitialState.fulfill() - } + try await confirmation("Received initial authoritative state") { receivedInitialState in + let recorder = StateRecorder { + receivedInitialState() + } - let token = LockStateSubscriber.subscribe( - fetchState: { try await fetcher.fetch() } - ) { isLocked in - recorder.record(isLocked) - } + let token = LockStateSubscriber.subscribe( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } - await fetcher.waitUntilStarted(1) - await fetcher.resolveFetch(0, with: true) - await fulfillment(of: [receivedInitialState], timeout: 1) + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + await recorder.waitUntilCount(1) - XCTAssertEqual(recorder.values, [true]) - withExtendedLifetime(token) {} + #expect(recorder.values == [true]) + withExtendedLifetime(token) {} + } } - func testInitialCalibrationMatchingSeedIsNotRedelivered() async { + @Test + func initialCalibrationMatchingSeedIsNotRedelivered() async throws { let fetcher = ControlledStateFetcher() - let receivedState = expectation(description: "Did not redeliver seeded state") - receivedState.isInverted = true - let recorder = StateRecorder { - receivedState.fulfill() - } + try await confirmation("Did not redeliver seeded state", expectedCount: 0) { receivedState in + let recorder = StateRecorder { + receivedState() + } - let token = LockStateSubscriber.subscribe( - initialState: true, - fetchState: { try await fetcher.fetch() } - ) { isLocked in - recorder.record(isLocked) - } + let token = LockStateSubscriber.subscribe( + initialState: true, + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } - await fetcher.waitUntilStarted(1) - await fetcher.resolveFetch(0, with: true) - await fulfillment(of: [receivedState], timeout: 0.1) + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + try? await Task.sleep(for: .milliseconds(100)) - XCTAssertTrue(recorder.values.isEmpty) - withExtendedLifetime(token) {} + #expect(recorder.values.isEmpty) + withExtendedLifetime(token) {} + } } - func testInitialCalibrationDifferingFromSeedIsDelivered() async { + @Test + func initialCalibrationDifferingFromSeedIsDelivered() async throws { let fetcher = ControlledStateFetcher() - let receivedState = expectation(description: "Received state differing from seed") - let recorder = StateRecorder { - receivedState.fulfill() - } + try await confirmation("Received state differing from seed") { receivedState in + let recorder = StateRecorder { + receivedState() + } - let token = LockStateSubscriber.subscribe( - initialState: false, - fetchState: { try await fetcher.fetch() } - ) { isLocked in - recorder.record(isLocked) - } + let token = LockStateSubscriber.subscribe( + initialState: false, + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } - await fetcher.waitUntilStarted(1) - await fetcher.resolveFetch(0, with: true) - await fulfillment(of: [receivedState], timeout: 1) + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + await recorder.waitUntilCount(1) - XCTAssertEqual(recorder.values, [true]) - withExtendedLifetime(token) {} + #expect(recorder.values == [true]) + withExtendedLifetime(token) {} + } } - func testReleasingTokenSuppressesPendingCalibrationDelivery() async { + @Test + func releasingTokenSuppressesPendingCalibrationDelivery() async throws { let fetcher = ControlledStateFetcher() - let receivedState = expectation(description: "Did not receive state after cancellation") - receivedState.isInverted = true - let recorder = StateRecorder { - receivedState.fulfill() - } + try await confirmation("Did not receive state after cancellation", expectedCount: 0) { receivedState in + let recorder = StateRecorder { + receivedState() + } - var token: ObserverToken? = LockStateSubscriber.subscribe( - fetchState: { try await fetcher.fetch() } - ) { isLocked in - recorder.record(isLocked) - } + var token: ObserverToken? = LockStateSubscriber.subscribe( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } - await fetcher.waitUntilStarted(1) - token = nil - await fetcher.resolveFetch(0, with: true) - await fulfillment(of: [receivedState], timeout: 0.1) + await fetcher.waitUntilStarted(1) + token = nil + try await fetcher.resolveFetch(0, with: true) + try? await Task.sleep(for: .milliseconds(100)) - let fetchMetrics = await fetcher.metrics - XCTAssertNil(token) - XCTAssertTrue(recorder.values.isEmpty) - XCTAssertEqual(fetchMetrics.startedCount, 1) + let fetchMetrics = await fetcher.metrics + #expect(token == nil) + #expect(recorder.values.isEmpty) + #expect(fetchMetrics.startedCount == 1) + } } - func testSignalsDuringFetchAreSerializedAndCoalescedIntoOneFollowUp() async { + @Test + func signalsDuringFetchAreSerializedAndCoalescedIntoOneFollowUp() async throws { let fetcher = ControlledStateFetcher() - let receivedStates = expectation(description: "Received reconciled states") - receivedStates.expectedFulfillmentCount = 2 - let recorder = StateRecorder { - receivedStates.fulfill() - } - let reconciler = StateReconciler( - fetchState: { try await fetcher.fetch() } - ) { isLocked in - recorder.record(isLocked) - } + try await confirmation("Received reconciled states", expectedCount: 2) { receivedStates in + let recorder = StateRecorder { + receivedStates() + } + let reconciler = StateReconciler( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } - reconciler.signal() - await fetcher.waitUntilStarted(1) + reconciler.signal() + await fetcher.waitUntilStarted(1) - reconciler.signal() - reconciler.signal() - reconciler.signal() - await Task.yield() + reconciler.signal() + reconciler.signal() + reconciler.signal() + await Task.yield() - var fetchMetrics = await fetcher.metrics - XCTAssertEqual(fetchMetrics.startedCount, 1) - XCTAssertEqual(fetchMetrics.maximumConcurrentFetches, 1) + var fetchMetrics = await fetcher.metrics + #expect(fetchMetrics.startedCount == 1) + #expect(fetchMetrics.maximumConcurrentFetches == 1) - await fetcher.resolveFetch(0, with: false) - await fetcher.waitUntilStarted(2) + try await fetcher.resolveFetch(0, with: false) + await fetcher.waitUntilStarted(2) - fetchMetrics = await fetcher.metrics - XCTAssertEqual(fetchMetrics.startedCount, 2) - XCTAssertEqual(fetchMetrics.maximumConcurrentFetches, 1) + fetchMetrics = await fetcher.metrics + #expect(fetchMetrics.startedCount == 2) + #expect(fetchMetrics.maximumConcurrentFetches == 1) - await fetcher.resolveFetch(1, with: true) - await fulfillment(of: [receivedStates], timeout: 1) - await Task.yield() + try await fetcher.resolveFetch(1, with: true) + await recorder.waitUntilCount(2) + await Task.yield() - fetchMetrics = await fetcher.metrics - XCTAssertEqual(recorder.values, [false, true]) - XCTAssertEqual(fetchMetrics.startedCount, 2) - XCTAssertEqual(fetchMetrics.maximumConcurrentFetches, 1) + fetchMetrics = await fetcher.metrics + #expect(recorder.values == [false, true]) + #expect(fetchMetrics.startedCount == 2) + #expect(fetchMetrics.maximumConcurrentFetches == 1) + } } - func testConsecutiveEqualAuthoritativeStatesAreDeduplicated() async { + @Test + func consecutiveEqualAuthoritativeStatesAreDeduplicated() async throws { let fetcher = ControlledStateFetcher() - let receivedStates = expectation(description: "Received distinct states") - receivedStates.expectedFulfillmentCount = 2 - let recorder = StateRecorder { - receivedStates.fulfill() - } - let reconciler = StateReconciler( - fetchState: { try await fetcher.fetch() } - ) { isLocked in - recorder.record(isLocked) - } + try await confirmation("Received distinct states", expectedCount: 2) { receivedStates in + let recorder = StateRecorder { + receivedStates() + } + let reconciler = StateReconciler( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } - reconciler.signal() - await fetcher.waitUntilStarted(1) - await fetcher.resolveFetch(0, with: true) - await recorder.waitUntilCount(1) + reconciler.signal() + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + await recorder.waitUntilCount(1) - reconciler.signal() - await fetcher.waitUntilStarted(2) - await fetcher.resolveFetch(1, with: true) - await fetcher.waitUntilCompleted(2) + reconciler.signal() + await fetcher.waitUntilStarted(2) + try await fetcher.resolveFetch(1, with: true) + await fetcher.waitUntilCompleted(2) - reconciler.signal() - await fetcher.waitUntilStarted(3) - await fetcher.resolveFetch(2, with: false) - await fulfillment(of: [receivedStates], timeout: 1) + reconciler.signal() + await fetcher.waitUntilStarted(3) + try await fetcher.resolveFetch(2, with: false) + await recorder.waitUntilCount(2) - XCTAssertEqual(recorder.values, [true, false]) + #expect(recorder.values == [true, false]) + } } } @@ -194,12 +204,11 @@ private actor ControlledStateFetcher { } } - func resolveFetch(_ index: Int, with value: Bool) { + func resolveFetch(_ index: Int, with value: Bool) throws { guard continuations.indices.contains(index), let continuation = continuations[index] else { - XCTFail("No pending fetch at index \(index)") - return + throw ControlledStateFetcherError.noPendingFetch(index) } continuations[index] = nil @@ -233,6 +242,10 @@ private actor ControlledStateFetcher { } } +private enum ControlledStateFetcherError: Error { + case noPendingFetch(Int) +} + private final class StateRecorder: @unchecked Sendable { private typealias CountWaiter = ( count: Int, diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift index ddbb332..0c0a80e 100644 --- a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -1,24 +1,28 @@ import Client import Foundation -import XCTest - -final class ServiceCompatibilityTests: XCTestCase { - func testBuildVersionsUseNumericComponentOrdering() throws { - let older = try XCTUnwrap(ServiceBuildVersion("1.9")) - let newer = try XCTUnwrap(ServiceBuildVersion("1.10")) - let equivalent = try XCTUnwrap(ServiceBuildVersion("01.010.0")) - - XCTAssertLessThan(older, newer) - XCTAssertEqual(newer, equivalent) +import Testing + +@Suite(.serialized) +struct ServiceCompatibilityTests { + @Test + func buildVersionsUseNumericComponentOrdering() throws { + let older = try #require(ServiceBuildVersion("1.9")) + let newer = try #require(ServiceBuildVersion("1.10")) + let equivalent = try #require(ServiceBuildVersion("01.010.0")) + + #expect(older < newer) + #expect(newer == equivalent) } - func testBuildVersionRejectsNonNumericComponents() { - XCTAssertNil(ServiceBuildVersion("")) - XCTAssertNil(ServiceBuildVersion("1.beta")) - XCTAssertNil(ServiceBuildVersion("1..2")) + @Test + func buildVersionRejectsNonNumericComponents() { + #expect(ServiceBuildVersion("") == nil) + #expect(ServiceBuildVersion("1.beta") == nil) + #expect(ServiceBuildVersion("1..2") == nil) } - func testMatchingDescriptorIsCompatible() throws { + @Test + func matchingDescriptorIsCompatible() throws { let requirements = makeRequirements() let descriptor = makeDescriptor( capabilities: requirements.requiredCapabilities.union([ @@ -26,11 +30,12 @@ final class ServiceCompatibilityTests: XCTestCase { ]) ) - XCTAssertTrue(descriptor.compatibilityIssues(against: requirements).isEmpty) - XCTAssertNoThrow(try descriptor.validateCompatibility(against: requirements)) + #expect(descriptor.compatibilityIssues(against: requirements).isEmpty) + try descriptor.validateCompatibility(against: requirements) } - func testNewerProtocolMinorIsCompatible() { + @Test + func newerProtocolMinorIsCompatible() { let requirements = makeRequirements( minimumProtocolVersion: ServiceProtocolVersion(major: 1, minor: 2) ) @@ -38,10 +43,11 @@ final class ServiceCompatibilityTests: XCTestCase { protocolVersion: ServiceProtocolVersion(major: 1, minor: 3) ) - XCTAssertTrue(descriptor.compatibilityIssues(against: requirements).isEmpty) + #expect(descriptor.compatibilityIssues(against: requirements).isEmpty) } - func testDifferentProtocolMajorIsIncompatible() { + @Test + func differentProtocolMajorIsIncompatible() { let requirements = makeRequirements( minimumProtocolVersion: ServiceProtocolVersion(major: 2, minor: 0) ) @@ -49,13 +55,13 @@ final class ServiceCompatibilityTests: XCTestCase { protocolVersion: ServiceProtocolVersion(major: 1, minor: 99) ) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.protocolMajorMismatch(expected: 2, actual: 1)] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .protocolMajorMismatch(expected: 2, actual: 1), + ]) } - func testOlderProtocolMinorIsIncompatible() { + @Test + func olderProtocolMinorIsIncompatible() { let requirements = makeRequirements( minimumProtocolVersion: ServiceProtocolVersion(major: 1, minor: 2) ) @@ -63,13 +69,13 @@ final class ServiceCompatibilityTests: XCTestCase { protocolVersion: ServiceProtocolVersion(major: 1, minor: 1) ) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.protocolMinorTooOld(required: 2, actual: 1)] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .protocolMinorTooOld(required: 2, actual: 1), + ]) } - func testMissingCapabilitiesAreReportedInStableOrder() { + @Test + func missingCapabilitiesAreReportedInStableOrder() { let requirements = makeRequirements(requiredCapabilities: [ .currentSettings, .lockControl, @@ -77,62 +83,55 @@ final class ServiceCompatibilityTests: XCTestCase { ]) let descriptor = makeDescriptor(capabilities: [.lockControl]) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.missingCapabilities([.currentSettings, .prepareForReplacement])] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.currentSettings, .prepareForReplacement]), + ]) } - func testCurrentContractRequiresCommittedReplacementDrain() { - XCTAssertTrue( - ServiceContract.requiredCapabilities.contains(.committedReplacementDrain) - ) - XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 1) + @Test + func currentContractRequiresCommittedReplacementDrain() { + #expect(ServiceContract.requiredCapabilities.contains(.committedReplacementDrain)) + #expect(ServiceContract.protocolVersion.minor >= 1) } - func testCurrentContractRequiresErrorReportingSettingsSelector() { - XCTAssertTrue( - ServiceContract.requiredCapabilities.contains(.currentSettingsWithError) - ) - XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 2) + @Test + func currentContractRequiresErrorReportingSettingsSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.currentSettingsWithError)) + #expect(ServiceContract.protocolVersion.minor >= 2) } - func testCurrentContractRequiresInteractiveLockSelector() { - XCTAssertTrue( - ServiceContract.requiredCapabilities.contains(.interactiveLock) - ) - XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 3) + @Test + func currentContractRequiresInteractiveLockSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.interactiveLock)) + #expect(ServiceContract.protocolVersion.minor >= 3) } - func testCurrentContractRequiresLockStatusSnapshotSelector() { - XCTAssertTrue( - ServiceContract.requiredCapabilities.contains(.lockStatusSnapshot) - ) - XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 4) + @Test + func currentContractRequiresLockStatusSnapshotSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.lockStatusSnapshot)) + #expect(ServiceContract.protocolVersion.minor >= 4) } - func testCurrentContractRequiresFocusFilterLockSelector() { - XCTAssertTrue( - ServiceContract.requiredCapabilities.contains(.focusFilterLock) - ) - XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 5) + @Test + func currentContractRequiresFocusFilterLockSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.focusFilterLock)) + #expect(ServiceContract.protocolVersion.minor >= 5) } - func testCurrentContractRequiresLockToggleSelector() { - XCTAssertTrue( - ServiceContract.requiredCapabilities.contains(.lockToggle) - ) - XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 6) + @Test + func currentContractRequiresLockToggleSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.lockToggle)) + #expect(ServiceContract.protocolVersion.minor >= 6) } - func testCurrentContractRequiresSafetyCheckSelector() { - XCTAssertTrue( - ServiceContract.requiredCapabilities.contains(.safetyCheckLock) - ) - XCTAssertGreaterThanOrEqual(ServiceContract.protocolVersion.minor, 7) + @Test + func currentContractRequiresSafetyCheckSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.safetyCheckLock)) + #expect(ServiceContract.protocolVersion.minor >= 7) } - func testAgentWithoutCommittedDrainIsNotCurrentContractCompatible() { + @Test + func agentWithoutCommittedDrainIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( capabilities: requirements.requiredCapabilities.subtracting([ @@ -140,13 +139,13 @@ final class ServiceCompatibilityTests: XCTestCase { ]) ) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.missingCapabilities([.committedReplacementDrain])] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.committedReplacementDrain]), + ]) } - func testAgentWithoutErrorReportingSettingsIsNotCurrentContractCompatible() { + @Test + func agentWithoutErrorReportingSettingsIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( capabilities: requirements.requiredCapabilities.subtracting([ @@ -154,13 +153,13 @@ final class ServiceCompatibilityTests: XCTestCase { ]) ) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.missingCapabilities([.currentSettingsWithError])] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.currentSettingsWithError]), + ]) } - func testAgentWithoutInteractiveLockIsNotCurrentContractCompatible() { + @Test + func agentWithoutInteractiveLockIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( capabilities: requirements.requiredCapabilities.subtracting([ @@ -168,13 +167,13 @@ final class ServiceCompatibilityTests: XCTestCase { ]) ) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.missingCapabilities([.interactiveLock])] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.interactiveLock]), + ]) } - func testAgentWithoutLockStatusSnapshotIsNotCurrentContractCompatible() { + @Test + func agentWithoutLockStatusSnapshotIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( capabilities: requirements.requiredCapabilities.subtracting([ @@ -182,13 +181,13 @@ final class ServiceCompatibilityTests: XCTestCase { ]) ) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.missingCapabilities([.lockStatusSnapshot])] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.lockStatusSnapshot]), + ]) } - func testAgentWithoutFocusFilterLockIsNotCurrentContractCompatible() { + @Test + func agentWithoutFocusFilterLockIsNotCurrentContractCompatible() { let requirements = makeRequirements() let descriptor = makeDescriptor( capabilities: requirements.requiredCapabilities.subtracting([ @@ -196,23 +195,23 @@ final class ServiceCompatibilityTests: XCTestCase { ]) ) - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.missingCapabilities([.focusFilterLock])] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.focusFilterLock]), + ]) } - func testBuildMismatchRequiresReplacement() { + @Test + func buildMismatchRequiresReplacement() { let requirements = makeRequirements(agentBuild: "42") let descriptor = makeDescriptor(agentBuild: "41") - XCTAssertEqual( - descriptor.compatibilityIssues(against: requirements), - [.agentBuildMismatch(expected: "42", actual: "41")] - ) + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .agentBuildMismatch(expected: "42", actual: "41"), + ]) } - func testDescriptorRoundTripPreservesUnknownCapabilities() throws { + @Test + func descriptorRoundTripPreservesUnknownCapabilities() throws { let descriptor = makeDescriptor(capabilities: [ .lockControl, ServiceCapability(rawValue: "capability-added-by-a-newer-agent"), @@ -220,10 +219,11 @@ final class ServiceCompatibilityTests: XCTestCase { let decoded = try ServiceDescriptor.decodedFromXPC(descriptor.encodedForXPC()) - XCTAssertEqual(decoded, descriptor) + #expect(decoded == descriptor) } - func testDescriptorDecodesLegacyPayloadWithoutAdditiveField() throws { + @Test + func descriptorDecodesLegacyPayloadWithoutAdditiveField() throws { let instanceID = UUID() let payload = """ { @@ -238,28 +238,27 @@ final class ServiceCompatibilityTests: XCTestCase { let descriptor = try ServiceDescriptor.decodedFromXPC(payload) - XCTAssertEqual(descriptor.agentInstanceID, instanceID) - XCTAssertFalse(descriptor.replacementPending) - XCTAssertEqual(descriptor.replacementPhase, .inactive) + #expect(descriptor.agentInstanceID == instanceID) + #expect(!descriptor.replacementPending) + #expect(descriptor.replacementPhase == .inactive) } - func testDescriptorIgnoresFutureFields() throws { + @Test + func descriptorIgnoresFutureFields() throws { let descriptor = makeDescriptor() - var object = try XCTUnwrap( + var object = try #require( JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] ) object["fieldAddedByFutureMinorVersion"] = ["value": 42] let futurePayload = try JSONSerialization.data(withJSONObject: object) - XCTAssertEqual( - try ServiceDescriptor.decodedFromXPC(futurePayload), - descriptor - ) + #expect(try ServiceDescriptor.decodedFromXPC(futurePayload) == descriptor) } - func testDescriptorMapsPendingLegacyPayloadToUnknownPhase() throws { + @Test + func descriptorMapsPendingLegacyPayloadToUnknownPhase() throws { let descriptor = makeDescriptor() - var object = try XCTUnwrap( + var object = try #require( JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] ) object["replacementPending"] = true @@ -268,50 +267,50 @@ final class ServiceCompatibilityTests: XCTestCase { let decoded = try ServiceDescriptor.decodedFromXPC(legacyPayload) - XCTAssertTrue(decoded.replacementPending) - XCTAssertEqual(decoded.replacementPhase, .unknown) + #expect(decoded.replacementPending) + #expect(decoded.replacementPhase == .unknown) } - func testDescriptorPreservesFutureReplacementPhase() throws { + @Test + func descriptorPreservesFutureReplacementPhase() throws { let futurePhase = ServiceReplacementPhase(rawValue: "future-phase") let descriptor = makeDescriptor(replacementPhase: futurePhase) let decoded = try ServiceDescriptor.decodedFromXPC(descriptor.encodedForXPC()) - XCTAssertEqual(decoded.replacementPhase, futurePhase) - XCTAssertTrue(decoded.replacementPending) + #expect(decoded.replacementPhase == futurePhase) + #expect(decoded.replacementPending) } - func testDescriptorRejectsMissingRequiredBootstrapField() throws { + @Test + func descriptorRejectsMissingRequiredBootstrapField() throws { let descriptor = makeDescriptor() - var object = try XCTUnwrap( + var object = try #require( JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] ) object.removeValue(forKey: "protocolVersion") let invalidPayload = try JSONSerialization.data(withJSONObject: object) - XCTAssertThrowsError(try ServiceDescriptor.decodedFromXPC(invalidPayload)) + #expect(throws: (any Error).self) { + try ServiceDescriptor.decodedFromXPC(invalidPayload) + } } - func testReplacementTicketRoundTrip() throws { + @Test + func replacementTicketRoundTrip() throws { let ticket = ServiceReplacementTicket( id: UUID(), agentInstanceID: UUID() ) - XCTAssertEqual( - try ServiceReplacementTicket.decodedFromXPC(ticket.encodedForXPC()), - ticket - ) + #expect(try ServiceReplacementTicket.decodedFromXPC(ticket.encodedForXPC()) == ticket) } - func testReplacementStatusRoundTrip() throws { + @Test + func replacementStatusRoundTrip() throws { let status = ServiceReplacementStatus(phase: .committed) - XCTAssertEqual( - try ServiceReplacementStatus.decodedFromXPC(status.encodedForXPC()), - status - ) + #expect(try ServiceReplacementStatus.decodedFromXPC(status.encodedForXPC()) == status) } private func makeRequirements( diff --git a/Core/Tests/ClientTests/UnlockStatusPollerTests.swift b/Core/Tests/ClientTests/UnlockStatusPollerTests.swift index 13607d3..384ac54 100644 --- a/Core/Tests/ClientTests/UnlockStatusPollerTests.swift +++ b/Core/Tests/ClientTests/UnlockStatusPollerTests.swift @@ -1,9 +1,11 @@ @testable import Client import Foundation -import XCTest +import Testing -final class UnlockStatusPollerTests: XCTestCase { - func testReturnsImmediatelyWhenAlreadyUnlocked() async throws { +@Suite(.serialized) +struct UnlockStatusPollerTests { + @Test + func returnsImmediatelyWhenAlreadyUnlocked() async throws { let source = ScriptedStatusSource([.value(false)]) let resetRecorder = ResetRecorder() let sleepRecorder = SleepRecorder() @@ -17,12 +19,13 @@ final class UnlockStatusPollerTests: XCTestCase { let fetchCount = await source.fetchCount let sleepCount = await sleepRecorder.count - XCTAssertEqual(fetchCount, 1) - XCTAssertEqual(resetRecorder.count, 0) - XCTAssertEqual(sleepCount, 0) + #expect(fetchCount == 1) + #expect(resetRecorder.count == 0) + #expect(sleepCount == 0) } - func testKeepsPollingUntilAuthoritativeStateIsUnlocked() async throws { + @Test + func keepsPollingUntilAuthoritativeStateIsUnlocked() async throws { let source = ScriptedStatusSource([.value(true), .value(false)]) let resetRecorder = ResetRecorder() let sleepRecorder = SleepRecorder() @@ -36,12 +39,13 @@ final class UnlockStatusPollerTests: XCTestCase { let fetchCount = await source.fetchCount let sleepCount = await sleepRecorder.count - XCTAssertEqual(fetchCount, 2) - XCTAssertEqual(resetRecorder.count, 0) - XCTAssertEqual(sleepCount, 1) + #expect(fetchCount == 2) + #expect(resetRecorder.count == 0) + #expect(sleepCount == 1) } - func testTransientFailureResetsConnectionAndCanRecoverAsUnlocked() async throws { + @Test + func transientFailureResetsConnectionAndCanRecoverAsUnlocked() async throws { let source = ScriptedStatusSource([ .failure(.first), .value(false), @@ -58,12 +62,13 @@ final class UnlockStatusPollerTests: XCTestCase { let fetchCount = await source.fetchCount let sleepCount = await sleepRecorder.count - XCTAssertEqual(fetchCount, 2) - XCTAssertEqual(resetRecorder.count, 1) - XCTAssertEqual(sleepCount, 1) + #expect(fetchCount == 2) + #expect(resetRecorder.count == 1) + #expect(sleepCount == 1) } - func testSuccessfulLockedReadClearsConsecutiveFailureCount() async throws { + @Test + func successfulLockedReadClearsConsecutiveFailureCount() async throws { let source = ScriptedStatusSource([ .failure(.first), .value(true), @@ -85,12 +90,13 @@ final class UnlockStatusPollerTests: XCTestCase { let fetchCount = await source.fetchCount let sleepCount = await sleepRecorder.count - XCTAssertEqual(fetchCount, 6) - XCTAssertEqual(resetRecorder.count, 3) - XCTAssertEqual(sleepCount, 5) + #expect(fetchCount == 6) + #expect(resetRecorder.count == 3) + #expect(sleepCount == 5) } - func testThrowsLastErrorAfterMaximumConsecutiveFailures() async { + @Test + func throwsLastErrorAfterMaximumConsecutiveFailures() async { let source = ScriptedStatusSource([ .failure(.first), .failure(.second), @@ -106,16 +112,16 @@ final class UnlockStatusPollerTests: XCTestCase { do { try await poller.waitUntilUnlocked() - XCTFail("Expected polling to fail") + Issue.record("Expected polling to fail") } catch { - XCTAssertEqual(error as? TestFailure, .third) + #expect(error as? TestFailure == .third) } let fetchCount = await source.fetchCount let sleepCount = await sleepRecorder.count - XCTAssertEqual(fetchCount, 3) - XCTAssertEqual(resetRecorder.count, 3) - XCTAssertEqual(sleepCount, 2) + #expect(fetchCount == 3) + #expect(resetRecorder.count == 3) + #expect(sleepCount == 2) } private func makePoller( @@ -156,7 +162,7 @@ private actor ScriptedStatusSource { func fetch() throws -> Bool { fetchCount += 1 guard !outcomes.isEmpty else { - XCTFail("Status source exhausted") + Issue.record("Status source exhausted") throw TestFailure.sourceExhausted } diff --git a/Core/Tests/ClientTests/UnlockWaiterTests.swift b/Core/Tests/ClientTests/UnlockWaiterTests.swift index 7d28a6e..450fb06 100644 --- a/Core/Tests/ClientTests/UnlockWaiterTests.swift +++ b/Core/Tests/ClientTests/UnlockWaiterTests.swift @@ -1,9 +1,11 @@ @testable import Client import Foundation -import XCTest +import Testing -final class UnlockWaiterTests: XCTestCase { - func testNotificationUnlockCancelsInFlightPolling() async throws { +@Suite(.serialized) +struct UnlockWaiterTests { + @Test + func notificationUnlockCancelsInFlightPolling() async throws { let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) let polling = ControlledPollingOperation() let waiter = makeWaiter( @@ -19,11 +21,12 @@ final class UnlockWaiterTests: XCTestCase { stateContinuation.yield(false) try await waitTask.value - XCTAssertEqual(polling.cancellationCount, 1) + #expect(polling.cancellationCount == 1) stateContinuation.finish() } - func testPollingUnlockCancelsObservation() async throws { + @Test + func pollingUnlockCancelsObservation() async throws { let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) let polling = ControlledPollingOperation() let waiter = makeWaiter( @@ -39,11 +42,12 @@ final class UnlockWaiterTests: XCTestCase { polling.succeed() try await waitTask.value - XCTAssertEqual(polling.cancellationCount, 0) + #expect(polling.cancellationCount == 0) stateContinuation.finish() } - func testEndedObservationKeepsWaitingForPollingFallback() async throws { + @Test + func endedObservationKeepsWaitingForPollingFallback() async throws { let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) let polling = ControlledPollingOperation() let waiter = makeWaiter( @@ -60,10 +64,11 @@ final class UnlockWaiterTests: XCTestCase { polling.succeed() try await waitTask.value - XCTAssertEqual(polling.cancellationCount, 0) + #expect(polling.cancellationCount == 0) } - func testPollingFailureIsPropagated() async { + @Test + func pollingFailureIsPropagated() async { let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) let polling = ControlledPollingOperation() let waiter = makeWaiter( @@ -79,16 +84,17 @@ final class UnlockWaiterTests: XCTestCase { do { try await waitTask.value - XCTFail("Expected polling failure") + Issue.record("Expected polling failure") } catch { - XCTAssertEqual(error as? TestFailure, .pollFailed) + #expect(error as? TestFailure == .pollFailed) } - XCTAssertEqual(polling.cancellationCount, 0) + #expect(polling.cancellationCount == 0) stateContinuation.finish() } - func testCancellingWaitCancelsInFlightPolling() async { + @Test + func cancellingWaitCancelsInFlightPolling() async { let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) let polling = ControlledPollingOperation() let waiter = makeWaiter( @@ -104,12 +110,12 @@ final class UnlockWaiterTests: XCTestCase { do { try await waitTask.value - XCTFail("Expected cancellation") + Issue.record("Expected cancellation") } catch { - XCTAssertTrue(error is CancellationError) + #expect(error is CancellationError) } - XCTAssertEqual(polling.cancellationCount, 1) + #expect(polling.cancellationCount == 1) stateContinuation.finish() } diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index c42cca4..268fa5e 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -1,9 +1,11 @@ @testable import Client import Foundation -import XCTest +import Testing -final class XPCClientErrorTests: XCTestCase { - func testProxyErrorsNormalizeToServiceUnavailable() { +@Suite(.serialized) +struct XPCClientErrorTests { + @Test + func proxyErrorsNormalizeToServiceUnavailable() { let transportError = NSError( domain: NSCocoaErrorDomain, code: CocoaError.xpcConnectionInvalid.rawValue @@ -11,61 +13,63 @@ final class XPCClientErrorTests: XCTestCase { guard case XPCClientError.serviceUnavailable = XPCClient.normalizedProxyError(transportError) else { - return XCTFail("Expected the proxy error to normalize to serviceUnavailable.") + Issue.record("Expected the proxy error to normalize to serviceUnavailable.") + return } } - func testServiceUnavailableExplainsFirstUseRecovery() throws { - let suggestion = try XCTUnwrap(XPCClientError.serviceUnavailable.recoverySuggestion) + @Test + func serviceUnavailableExplainsFirstUseRecovery() throws { + let suggestion = try #require(XPCClientError.serviceUnavailable.recoverySuggestion) - XCTAssertTrue(suggestion.contains("Open KeyboardLocker once")) - XCTAssertTrue(suggestion.contains("Show Details")) + #expect(suggestion.contains("Open KeyboardLocker once")) + #expect(suggestion.contains("Show Details")) } - func testTimeoutExplainsRetryRecovery() throws { - let suggestion = try XCTUnwrap(XPCClientError.timedOut.recoverySuggestion) + @Test + func timeoutExplainsRetryRecovery() throws { + let suggestion = try #require(XPCClientError.timedOut.recoverySuggestion) - XCTAssertTrue(suggestion.contains("Retry")) - XCTAssertTrue(suggestion.contains("Show Details")) + #expect(suggestion.contains("Retry")) + #expect(suggestion.contains("Show Details")) } - func testMissingCapabilityExplainsAgentUpdateRecovery() throws { - let suggestion = try XCTUnwrap( + @Test + func missingCapabilityExplainsAgentUpdateRecovery() throws { + let suggestion = try #require( XPCClientError.missingCapability(.interactiveLock).recoverySuggestion ) - XCTAssertTrue(suggestion.contains("update its background agent")) + #expect(suggestion.contains("update its background agent")) } - func testUnknownMutationOutcomeExplainsStateRecovery() throws { - let suggestion = try XCTUnwrap( + @Test + func unknownMutationOutcomeExplainsStateRecovery() throws { + let suggestion = try #require( XPCClientError.operationOutcomeUnknown.recoverySuggestion ) - XCTAssertTrue(suggestion.contains("Inspect the current state")) - XCTAssertTrue(suggestion.contains("repeat the intended lock or unlock action")) + #expect(suggestion.contains("Inspect the current state")) + #expect(suggestion.contains("repeat the intended lock or unlock action")) } - func testFeatureNegotiationRejectsProtocolMajorBeforeCapabilities() { + @Test + func featureNegotiationRejectsProtocolMajorBeforeCapabilities() { let descriptor = makeDescriptor( protocolVersion: ServiceProtocolVersion(major: 2, minor: 99), capabilities: [] ) - XCTAssertThrowsError( + #expect(throws: ServiceCompatibilityIssue.protocolMajorMismatch(expected: 1, actual: 2)) { try XPCFeatureNegotiation.validate( descriptor: descriptor, requiring: [.lockToggle] ) - ) { error in - XCTAssertEqual( - error as? ServiceCompatibilityIssue, - .protocolMajorMismatch(expected: 1, actual: 2) - ) } } - func testFeatureNegotiationEnforcesSelectorIntroductionMinor() { + @Test + func featureNegotiationEnforcesSelectorIntroductionMinor() { let introductionMinorByCapability: [ServiceCapability: Int] = [ .accessibilityPrompt: 0, .accessibilityStatus: 0, @@ -93,22 +97,23 @@ final class XPCClientErrorTests: XCTestCase { capabilities: [capability] ) - XCTAssertThrowsError( + #expect( + throws: ServiceCompatibilityIssue.protocolMinorTooOld( + required: introductionMinor, + actual: introductionMinor - 1 + ), + "Expected \(capability.rawValue) to require protocol minor \(introductionMinor)." + ) { try XPCFeatureNegotiation.validate( descriptor: descriptor, requiring: [capability] - ), - "Expected \(capability.rawValue) to require protocol minor \(introductionMinor)." - ) { error in - XCTAssertEqual( - error as? ServiceCompatibilityIssue, - .protocolMinorTooOld(required: introductionMinor, actual: introductionMinor - 1) ) } } } - func testFeatureNegotiationAcceptsDeclaredCapabilityAtIntroductionMinor() { + @Test + func featureNegotiationAcceptsDeclaredCapabilityAtIntroductionMinor() throws { let descriptor = makeDescriptor( protocolVersion: ServiceProtocolVersion( major: ServiceContract.protocolVersion.major, @@ -117,29 +122,29 @@ final class XPCClientErrorTests: XCTestCase { capabilities: [.interactiveLock] ) - XCTAssertNoThrow( - try XPCFeatureNegotiation.validate( - descriptor: descriptor, - requiring: [.interactiveLock] - ) + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.interactiveLock] ) } - func testFeatureNegotiationChecksCapabilityAfterCompatibleVersion() { + @Test + func featureNegotiationChecksCapabilityAfterCompatibleVersion() { let descriptor = makeDescriptor( protocolVersion: ServiceContract.protocolVersion, capabilities: [] ) - XCTAssertThrowsError( + do { try XPCFeatureNegotiation.validate( descriptor: descriptor, requiring: [.interactiveLock] ) - ) { error in - guard case XPCClientError.missingCapability(.interactiveLock) = error else { - return XCTFail("Expected missing interactive-lock capability, got \(error).") - } + Issue.record("Expected missing interactive-lock capability.") + } catch XPCClientError.missingCapability(.interactiveLock) { + // Expected. + } catch { + Issue.record("Expected missing interactive-lock capability, got \(error).") } } diff --git a/Core/Tests/CommonTests/HotkeyDisplayTests.swift b/Core/Tests/CommonTests/HotkeyDisplayTests.swift index f31c85b..5494bfa 100644 --- a/Core/Tests/CommonTests/HotkeyDisplayTests.swift +++ b/Core/Tests/CommonTests/HotkeyDisplayTests.swift @@ -1,21 +1,25 @@ import Common import CoreGraphics -import XCTest +import Testing -final class HotkeyDisplayTests: XCTestCase { - func testDisplayStringRendersGlyphsForMappedKeyCode() { +@MainActor +@Suite(.serialized) +struct HotkeyDisplayTests { + @Test + func displayStringRendersGlyphsForMappedKeyCode() { let hotkey = KeyboardLockerSettings.Hotkey(keyCode: 37, modifierFlags: [.maskCommand, .maskControl]) - XCTAssertFalse(hotkey.displayString.isEmpty) - XCTAssertFalse(hotkey.displayString.contains("?")) - XCTAssertNotEqual(hotkey.displayString, "the configured unlock hotkey") + #expect(!hotkey.displayString.isEmpty) + #expect(!hotkey.displayString.contains("?")) + #expect(hotkey.displayString != "the configured unlock hotkey") } - func testDisplayStringFallsBackToVerbalPhraseForUnmappedKeyCode() { + @Test + func displayStringFallsBackToVerbalPhraseForUnmappedKeyCode() { // 255 is outside the virtual key-code range of any keyboard layout, so UCKeyTranslate // cannot map it and the verbal fallback must replace the old raw "?" glyph. let hotkey = KeyboardLockerSettings.Hotkey(keyCode: 255, modifierFlags: [.maskCommand]) - XCTAssertEqual(hotkey.displayString, "the configured unlock hotkey") + #expect(hotkey.displayString == "the configured unlock hotkey") } } diff --git a/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift b/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift index e8c8506..4dbfc69 100644 --- a/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift +++ b/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift @@ -1,9 +1,11 @@ import Common import Foundation -import XCTest +import Testing -final class KeyboardLockerSettingsCodingTests: XCTestCase { - func testRoundTripPreservesSettings() throws { +@Suite(.serialized) +struct KeyboardLockerSettingsCodingTests { + @Test + func roundTripPreservesSettings() throws { let settings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 42), unlockHotkey: .init( @@ -12,43 +14,32 @@ final class KeyboardLockerSettingsCodingTests: XCTestCase { ) ) - XCTAssertEqual( - try KeyboardLockerSettings.decodedFromXPC(settings.encodedForXPC()), - settings - ) + #expect(try KeyboardLockerSettings.decodedFromXPC(settings.encodedForXPC()) == settings) } - func testMissingPayloadIsRejected() { - XCTAssertThrowsError(try KeyboardLockerSettings.decodedFromXPC(nil)) { error in - XCTAssertEqual( - error as? KeyboardLockerSettingsCodingError, - .missingPayload - ) + @Test + func missingPayloadIsRejected() { + #expect(throws: KeyboardLockerSettingsCodingError.missingPayload) { + try KeyboardLockerSettings.decodedFromXPC(nil) } } - func testInvalidPayloadIsRejected() { - XCTAssertThrowsError( + @Test + func invalidPayloadIsRejected() { + #expect(throws: KeyboardLockerSettingsCodingError.invalidPayload) { try KeyboardLockerSettings.decodedFromXPC(Data("not-json".utf8)) - ) { error in - XCTAssertEqual( - error as? KeyboardLockerSettingsCodingError, - .invalidPayload - ) } } - func testOversizedPayloadIsRejectedBeforeDecoding() { + @Test + func oversizedPayloadIsRejectedBeforeDecoding() { let payload = Data( repeating: 0, count: KeyboardLockerSettings.maximumEncodedSize + 1 ) - XCTAssertThrowsError(try KeyboardLockerSettings.decodedFromXPC(payload)) { error in - XCTAssertEqual( - error as? KeyboardLockerSettingsCodingError, - .payloadTooLarge - ) + #expect(throws: KeyboardLockerSettingsCodingError.payloadTooLarge) { + try KeyboardLockerSettings.decodedFromXPC(payload) } } } diff --git a/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift index d2731a3..e05d812 100644 --- a/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift +++ b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift @@ -1,11 +1,13 @@ import Common import Foundation -import XCTest +import Testing -final class LockStatusSnapshotCodingTests: XCTestCase { +@Suite(.serialized) +struct LockStatusSnapshotCodingTests { private let capturedAt = Date(timeIntervalSinceReferenceDate: 1000) - func testRoundTripPreservesAuthoritativeRuntimeDatesAndSettings() throws { + @Test + func roundTripPreservesAuthoritativeRuntimeDatesAndSettings() throws { let startedAt = capturedAt.addingTimeInterval(-10) let deadline = capturedAt.addingTimeInterval(50) let settings = KeyboardLockerSettings( @@ -25,10 +27,11 @@ final class LockStatusSnapshotCodingTests: XCTestCase { let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) - XCTAssertEqual(decoded, snapshot) + #expect(decoded == snapshot) } - func testUnlockedSnapshotRoundTrip() throws { + @Test + func unlockedSnapshotRoundTrip() throws { let snapshot = LockStatusSnapshot( capturedAt: capturedAt, isLocked: false, @@ -39,24 +42,25 @@ final class LockStatusSnapshotCodingTests: XCTestCase { let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) - XCTAssertEqual(decoded, snapshot) + #expect(decoded == snapshot) } - func testMissingPayloadIsRejected() { - XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(nil)) { error in - XCTAssertEqual(error as? LockStatusSnapshotCodingError, .missingPayload) + @Test + func missingPayloadIsRejected() { + #expect(throws: LockStatusSnapshotCodingError.missingPayload) { + try LockStatusSnapshot.decodedFromXPC(nil) } } - func testInvalidPayloadIsRejected() { - XCTAssertThrowsError( + @Test + func invalidPayloadIsRejected() { + #expect(throws: LockStatusSnapshotCodingError.invalidPayload) { try LockStatusSnapshot.decodedFromXPC(Data("not-json".utf8)) - ) { error in - XCTAssertEqual(error as? LockStatusSnapshotCodingError, .invalidPayload) } } - func testUnsupportedFormatIsRejected() throws { + @Test + func unsupportedFormatIsRejected() throws { let snapshot = LockStatusSnapshot( formatVersion: LockStatusSnapshot.currentFormatVersion + 1, capturedAt: capturedAt, @@ -67,15 +71,17 @@ final class LockStatusSnapshotCodingTests: XCTestCase { ) let payload = try JSONEncoder().encode(snapshot) - XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(payload)) { error in - XCTAssertEqual( - error as? LockStatusSnapshotCodingError, - .unsupportedFormat(LockStatusSnapshot.currentFormatVersion + 1) + #expect( + throws: LockStatusSnapshotCodingError.unsupportedFormat( + LockStatusSnapshot.currentFormatVersion + 1 ) + ) { + try LockStatusSnapshot.decodedFromXPC(payload) } } - func testInconsistentUnlockedRuntimeDatesAreRejected() throws { + @Test + func inconsistentUnlockedRuntimeDatesAreRejected() throws { let snapshot = LockStatusSnapshot( capturedAt: capturedAt, isLocked: false, @@ -85,19 +91,20 @@ final class LockStatusSnapshotCodingTests: XCTestCase { ) let payload = try JSONEncoder().encode(snapshot) - XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(payload)) { error in - XCTAssertEqual(error as? LockStatusSnapshotCodingError, .invalidPayload) + #expect(throws: LockStatusSnapshotCodingError.invalidPayload) { + try LockStatusSnapshot.decodedFromXPC(payload) } } - func testOversizedPayloadIsRejectedBeforeDecoding() { + @Test + func oversizedPayloadIsRejectedBeforeDecoding() { let payload = Data( repeating: 0, count: LockStatusSnapshot.maximumEncodedSize + 1 ) - XCTAssertThrowsError(try LockStatusSnapshot.decodedFromXPC(payload)) { error in - XCTAssertEqual(error as? LockStatusSnapshotCodingError, .payloadTooLarge) + #expect(throws: LockStatusSnapshotCodingError.payloadTooLarge) { + try LockStatusSnapshot.decodedFromXPC(payload) } } } diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift index bc48577..7c76863 100644 --- a/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift @@ -1,7 +1,7 @@ @testable import Common import Foundation import Security -import XCTest +import Testing /// Evaluates XPC peer requirements against real on-disk code signatures. /// @@ -11,22 +11,25 @@ import XCTest /// signature at all — must be rejected by the same Security framework check the Agent's /// listener performs. A plain `anchor apple` control requirement proves the harness evaluates /// correctly, so failures are requirement mismatches rather than evaluation errors. -final class XPCCodeSigningRequirementEvaluationTests: XCTestCase { +@Suite(.serialized) +struct XPCCodeSigningRequirementEvaluationTests { // MARK: - Team requirement vs. real signatures - func testAppleSignedSystemToolFailsTeamRequirementButPassesAnchorApple() throws { + @Test + func appleSignedSystemToolFailsTeamRequirementButPassesAnchorApple() throws { let tool = try makeSystemToolCopy() defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } let requirement = try makeTeamRequirement() // Control: the copy keeps the embedded Apple signature, so the anchor check passes. - XCTAssertEqual(evaluate("anchor apple", against: tool), errSecSuccess) + #expect(evaluate("anchor apple", against: tool) == errSecSuccess) // Rejected: valid Apple signature, but not our Team ID or identifier. - XCTAssertEqual(evaluate(requirement, against: tool), errSecCSReqFailed) + #expect(evaluate(requirement, against: tool) == errSecCSReqFailed) } - func testAdHocSignedToolFailsTeamRequirement() throws { + @Test + func adHocSignedToolFailsTeamRequirement() throws { let tool = try makeSystemToolCopy() defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } try runCodesign(["--force", "--sign", "-", tool.path]) @@ -34,12 +37,13 @@ final class XPCCodeSigningRequirementEvaluationTests: XCTestCase { let requirement = try makeTeamRequirement() // Control: ad-hoc signatures never anchor to Apple. - XCTAssertEqual(evaluate("anchor apple", against: tool), errSecCSReqFailed) + #expect(evaluate("anchor apple", against: tool) == errSecCSReqFailed) // Rejected: an ad-hoc signature carries no Apple Team ID. - XCTAssertEqual(evaluate(requirement, against: tool), errSecCSReqFailed) + #expect(evaluate(requirement, against: tool) == errSecCSReqFailed) } - func testUnsignedToolFailsTeamRequirement() throws { + @Test + func unsignedToolFailsTeamRequirement() throws { let tool = try makeSystemToolCopy() defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } try runCodesign(["--remove-signature", tool.path]) @@ -47,8 +51,8 @@ final class XPCCodeSigningRequirementEvaluationTests: XCTestCase { let requirement = try makeTeamRequirement() // Rejected: unsigned code fails before any requirement clause is even considered. - XCTAssertEqual(evaluate("anchor apple", against: tool), errSecCSUnsigned) - XCTAssertEqual(evaluate(requirement, against: tool), errSecCSUnsigned) + #expect(evaluate("anchor apple", against: tool) == errSecCSUnsigned) + #expect(evaluate(requirement, against: tool) == errSecCSUnsigned) } // MARK: - Fixtures @@ -87,9 +91,8 @@ final class XPCCodeSigningRequirementEvaluationTests: XCTestCase { encoding: .utf8 ) ?? "" process.waitUntilExit() - XCTAssertEqual( - process.terminationStatus, - 0, + #expect( + process.terminationStatus == 0, "codesign \(arguments.joined(separator: " ")) failed: \(output)" ) } diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift index d2c66da..aab8194 100644 --- a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift @@ -1,25 +1,22 @@ @testable import Common import Security -import XCTest +import Testing -final class XPCCodeSigningRequirementTests: XCTestCase { - func testAuthorizedClientIdentifiersAreExactAndNamespaced() { - XCTAssertEqual( - SharedConstants.authorizedClientBundleIdentifiers, - [ - "io.lzhlovesjyq.keyboardlocker", - "io.lzhlovesjyq.keyboardlocker.focus-intents", - "io.lzhlovesjyq.keyboardlocker.klock", - "io.lzhlovesjyq.keyboardlocker.widgets", - ] - ) - XCTAssertEqual( - SharedConstants.agentBundleIdentifier, - "io.lzhlovesjyq.keyboardlocker.agent" - ) +@Suite(.serialized) +struct XPCCodeSigningRequirementTests { + @Test + func authorizedClientIdentifiersAreExactAndNamespaced() { + #expect(SharedConstants.authorizedClientBundleIdentifiers == [ + "io.lzhlovesjyq.keyboardlocker", + "io.lzhlovesjyq.keyboardlocker.focus-intents", + "io.lzhlovesjyq.keyboardlocker.klock", + "io.lzhlovesjyq.keyboardlocker.widgets", + ]) + #expect(SharedConstants.agentBundleIdentifier == "io.lzhlovesjyq.keyboardlocker.agent") } - func testRequirementUsesExactTeamAndIdentifiersInStableOrder() throws { + @Test + func requirementUsesExactTeamAndIdentifiersInStableOrder() throws { let requirement = try XPCCodeSigningRequirement.make( teamIdentifier: "A1B2C3D4E5", identifiers: [ @@ -28,53 +25,52 @@ final class XPCCodeSigningRequirementTests: XCTestCase { ] ) - XCTAssertEqual( - requirement, - """ + #expect(requirement == """ anchor apple generic and certificate leaf[subject.OU] = "A1B2C3D4E5" and \ (identifier "io.example.app" or identifier "io.example.tool") """ ) var compiledRequirement: SecRequirement? - XCTAssertEqual( + #expect( SecRequirementCreateWithString( requirement as CFString, SecCSFlags(), &compiledRequirement - ), - errSecSuccess + ) == errSecSuccess ) - XCTAssertNotNil(compiledRequirement) + #expect(compiledRequirement != nil) } - func testRequirementRejectsInvalidTeamIdentifiers() { - XCTAssertThrowsError( + @Test + func requirementRejectsInvalidTeamIdentifiers() { + #expect(throws: (any Error).self) { try XPCCodeSigningRequirement.make( teamIdentifier: "too-short", identifiers: ["io.example.app"] ) - ) - XCTAssertThrowsError( + } + #expect(throws: (any Error).self) { try XPCCodeSigningRequirement.make( teamIdentifier: "a1b2c3d4e5", identifiers: ["io.example.app"] ) - ) + } } - func testRequirementRejectsMissingOrUnsafeSigningIdentifiers() { - XCTAssertThrowsError( + @Test + func requirementRejectsMissingOrUnsafeSigningIdentifiers() { + #expect(throws: (any Error).self) { try XPCCodeSigningRequirement.make( teamIdentifier: "A1B2C3D4E5", identifiers: [] ) - ) - XCTAssertThrowsError( + } + #expect(throws: (any Error).self) { try XPCCodeSigningRequirement.make( teamIdentifier: "A1B2C3D4E5", identifiers: [#"io.example."injected"#] ) - ) + } } } diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift index b08b73a..d7edb38 100644 --- a/Core/Tests/ServiceTests/AgentServiceTests.swift +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -1,16 +1,17 @@ import Common +import Foundation @testable import Service -import XCTest +import Testing @MainActor -final class AgentServiceTests: XCTestCase { - private var engine: FakeLockEngine! - private var scheduler: ManualExpirationScheduler! - private var instanceID: UUID! - private var customSettings: KeyboardLockerSettings! - - override func setUp() { - super.setUp() +@Suite(.serialized) +final class AgentServiceTests { + private let engine: FakeLockEngine + private let scheduler: ManualExpirationScheduler + private let instanceID: UUID + private let customSettings: KeyboardLockerSettings + + init() { engine = FakeLockEngine() scheduler = ManualExpirationScheduler() instanceID = UUID() @@ -22,7 +23,8 @@ final class AgentServiceTests: XCTestCase { // MARK: - Replacement barrier - func testPreparedDrainRejectsNewLockRequests() { + @Test + func preparedDrainRejectsNewLockRequests() { let service = makeService() prepareTicket(on: service, instanceID: instanceID) @@ -36,7 +38,7 @@ final class AgentServiceTests: XCTestCase { interactiveOutcome = outcome interactiveError = error } - XCTAssertEqual(interactiveOutcome, false) + #expect(interactiveOutcome == false) assertReplacementError(interactiveError, code: 1) var safetyCheckError: Error? @@ -47,22 +49,23 @@ final class AgentServiceTests: XCTestCase { service.setFocusFilterLockEnabled(true) { focusError = $0 } assertReplacementError(focusError, code: 1) - XCTAssertTrue(engine.lockCalls.isEmpty) - XCTAssertTrue(engine.focusCalls.isEmpty) + #expect(engine.lockCalls.isEmpty) + #expect(engine.focusCalls.isEmpty) } - func testDrainKeepsUnlockStatusAndSnapshotAvailable() { + @Test + func drainKeepsUnlockStatusAndSnapshotAvailable() { let service = makeService() prepareTicket(on: service, instanceID: instanceID) var unlockError: Error? service.unlockKeyboard { unlockError = $0 } - XCTAssertNil(unlockError) - XCTAssertEqual(engine.unlockCallCount, 1) + #expect(unlockError == nil) + #expect(engine.unlockCallCount == 1) var isLocked: Bool? service.status { locked, _ in isLocked = locked } - XCTAssertEqual(isLocked, false) + #expect(isLocked == false) var snapshotData: Data? var snapshotError: Error? @@ -70,11 +73,12 @@ final class AgentServiceTests: XCTestCase { snapshotData = data snapshotError = error } - XCTAssertNil(snapshotError) - XCTAssertNotNil(snapshotData) + #expect(snapshotError == nil) + #expect(snapshotData != nil) } - func testPrepareRejectsMismatchedInstanceID() { + @Test + func prepareRejectsMismatchedInstanceID() { let service = makeService() var error: Error? @@ -90,7 +94,8 @@ final class AgentServiceTests: XCTestCase { prepareTicket(on: service, instanceID: instanceID) } - func testPrepareRejectsLockedEngineUnlessUnlockRequested() { + @Test + func prepareRejectsLockedEngineUnlessUnlockRequested() { let lockedEngine = FakeLockEngine(isLocked: true) let service = makeService(engineOverride: lockedEngine) @@ -102,14 +107,15 @@ final class AgentServiceTests: XCTestCase { error = replyError } assertReplacementError(error, code: 3) - XCTAssertEqual(lockedEngine.unlockCallCount, 0) + #expect(lockedEngine.unlockCallCount == 0) let ticket = prepareTicket(on: service, unlockIfNeeded: true, instanceID: instanceID) - XCTAssertNotNil(ticket) - XCTAssertEqual(lockedEngine.unlockCallCount, 1) + #expect(ticket != nil) + #expect(lockedEngine.unlockCallCount == 1) } - func testPrepareInstallsBarrierBeforeUnlocking() { + @Test + func prepareInstallsBarrierBeforeUnlocking() { let lockedEngine = FakeLockEngine(isLocked: true) let service = makeService(engineOverride: lockedEngine) @@ -122,10 +128,11 @@ final class AgentServiceTests: XCTestCase { } prepareTicket(on: service, unlockIfNeeded: true, instanceID: instanceID) - XCTAssertEqual(pendingDuringUnlock, true) + #expect(pendingDuringUnlock == true) } - func testSecondPrepareIsRejectedAndFirstTicketStillCommits() { + @Test + func secondPrepareIsRejectedAndFirstTicketStillCommits() { let service = makeService() let first = prepareTicket(on: service, instanceID: instanceID) @@ -140,11 +147,12 @@ final class AgentServiceTests: XCTestCase { var commitError: Error? service.commitReplacement(ticket: encoded(first)) { commitError = $0 } - XCTAssertNil(commitError) - XCTAssertEqual(phase(of: first, on: service), .committed) + #expect(commitError == nil) + #expect(phase(of: first, on: service) == .committed) } - func testCommitWithForeignTicketFails() { + @Test + func commitWithForeignTicketFails() { let service = makeService() prepareTicket(on: service, instanceID: instanceID) @@ -154,13 +162,14 @@ final class AgentServiceTests: XCTestCase { assertReplacementError(commitError, code: 4) } - func testCommittedDrainCannotBeCancelled() { + @Test + func committedDrainCannotBeCancelled() { let service = makeService() let ticket = prepareTicket(on: service, instanceID: instanceID) var commitError: Error? service.commitReplacement(ticket: encoded(ticket)) { commitError = $0 } - XCTAssertNil(commitError) + #expect(commitError == nil) var cancelError: Error? service.cancelReplacementPreparation(ticket: encoded(ticket)) { cancelError = $0 } @@ -174,7 +183,8 @@ final class AgentServiceTests: XCTestCase { assertReplacementError(foreignCancelError, code: 6) } - func testCancelWithForeignTicketFailsWhilePrepared() { + @Test + func cancelWithForeignTicketFailsWhilePrepared() { let service = makeService() prepareTicket(on: service, instanceID: instanceID) @@ -185,70 +195,75 @@ final class AgentServiceTests: XCTestCase { assertReplacementError(error, code: 4) } - func testMalformedTicketDataSurfacesDecodeError() { + @Test + func malformedTicketDataSurfacesDecodeError() { let service = makeService() var commitError: Error? service.commitReplacement(ticket: Data([0x00, 0x01])) { commitError = $0 } - XCTAssertNotNil(commitError) + #expect(commitError != nil) var statusError: Error? service.replacementStatus(ticket: Data([0x00, 0x01])) { _, error in statusError = error } - XCTAssertNotNil(statusError) + #expect(statusError != nil) } // MARK: - Expiration scheduling - func testCommitCancelsScheduledExpiration() { + @Test + func commitCancelsScheduledExpiration() { let service = makeService() let ticket = prepareTicket(on: service, instanceID: instanceID) - XCTAssertEqual(scheduler.scheduledCount, 1) - XCTAssertEqual(scheduler.lastInterval, 30) + #expect(scheduler.scheduledCount == 1) + #expect(scheduler.lastInterval == 30) var commitError: Error? service.commitReplacement(ticket: encoded(ticket)) { commitError = $0 } - XCTAssertNil(commitError) - XCTAssertEqual(scheduler.cancelCount, 1) + #expect(commitError == nil) + #expect(scheduler.cancelCount == 1) // A canceled expiration must not clear the committed drain. scheduler.firePending() - XCTAssertEqual(phase(of: ticket, on: service), .committed) + #expect(phase(of: ticket, on: service) == .committed) } - func testCancelCancelsScheduledExpiration() { + @Test + func cancelCancelsScheduledExpiration() { let service = makeService() let ticket = prepareTicket(on: service, instanceID: instanceID) var cancelError: Error? service.cancelReplacementPreparation(ticket: encoded(ticket)) { cancelError = $0 } - XCTAssertNil(cancelError) - XCTAssertEqual(scheduler.cancelCount, 1) + #expect(cancelError == nil) + #expect(scheduler.cancelCount == 1) var lockError: Error? service.lockKeyboard { lockError = $0 } - XCTAssertNil(lockError) - XCTAssertEqual(engine.lockCalls.count, 1) + #expect(lockError == nil) + #expect(engine.lockCalls.count == 1) } - func testExpirationReturnsAgentToAcceptingLocks() { + @Test + func expirationReturnsAgentToAcceptingLocks() { let service = makeService() let ticket = prepareTicket(on: service, instanceID: instanceID) - XCTAssertEqual(replacementPending(on: service), true) + #expect(replacementPending(on: service) == true) scheduler.firePending() - XCTAssertEqual(phase(of: ticket, on: service), .inactive) - XCTAssertEqual(replacementPending(on: service), false) + #expect(phase(of: ticket, on: service) == .inactive) + #expect(replacementPending(on: service) == false) var lockError: Error? service.lockKeyboard { lockError = $0 } - XCTAssertNil(lockError) + #expect(lockError == nil) } // MARK: - Wiring - func testPrepareFailsWithoutDescriptor() { + @Test + func prepareFailsWithoutDescriptor() { let service = makeService( descriptorResult: .failure(StubError.descriptorUnavailable) ) @@ -263,37 +278,40 @@ final class AgentServiceTests: XCTestCase { assertReplacementError(error, code: 2) } - func testDescriptorReportsReplacementPending() { + @Test + func descriptorReportsReplacementPending() { let service = makeService() - XCTAssertEqual(replacementPending(on: service), false) + #expect(replacementPending(on: service) == false) prepareTicket(on: service, instanceID: instanceID) - XCTAssertEqual(replacementPending(on: service), true) + #expect(replacementPending(on: service) == true) } - func testPersistedSettingsSeedEngineAndLockCalls() { + @Test + func persistedSettingsSeedEngineAndLockCalls() { let service = makeService() - XCTAssertEqual(engine.seededSettings, [customSettings]) + #expect(engine.seededSettings == [customSettings]) var lockError: Error? service.lockKeyboard { lockError = $0 } - XCTAssertNil(lockError) - XCTAssertEqual(engine.lockCalls.count, 1) - XCTAssertEqual(engine.lockCalls.first?.settings, customSettings) - XCTAssertEqual(engine.lockCalls.first?.allowsControlCUnlock, false) + #expect(lockError == nil) + #expect(engine.lockCalls.count == 1) + #expect(engine.lockCalls.first?.settings == customSettings) + #expect(engine.lockCalls.first?.allowsControlCUnlock == false) var outcome: Bool? service.lockKeyboardInteractively { acquired, _ in outcome = acquired } - XCTAssertEqual(outcome, true) - XCTAssertEqual(engine.lockCalls.last?.allowsControlCUnlock, true) + #expect(outcome == true) + #expect(engine.lockCalls.last?.allowsControlCUnlock == true) var focusError: Error? service.setFocusFilterLockEnabled(true) { focusError = $0 } - XCTAssertNil(focusError) - XCTAssertEqual(engine.focusCalls.first?.settings, customSettings) + #expect(focusError == nil) + #expect(engine.focusCalls.first?.settings == customSettings) } - func testSafetyCheckUsesFixedTimedOverrideWithoutChangingPersistedSettings() { + @Test + func safetyCheckUsesFixedTimedOverrideWithoutChangingPersistedSettings() { let service = makeService() var didStart: Bool? @@ -303,26 +321,24 @@ final class AgentServiceTests: XCTestCase { safetyError = error } - XCTAssertNil(safetyError) - XCTAssertEqual(didStart, true) - XCTAssertEqual( - engine.lockCalls.first?.settings.autoUnlockPolicy, - .timed(seconds: SharedConstants.safetyCheckDuration) + #expect(safetyError == nil) + #expect(didStart == true) + #expect( + engine.lockCalls.first?.settings.autoUnlockPolicy == + .timed(seconds: SharedConstants.safetyCheckDuration) ) - XCTAssertEqual( - engine.lockCalls.first?.settings.unlockHotkey, - customSettings.unlockHotkey - ) - XCTAssertEqual(engine.lockCalls.first?.allowsControlCUnlock, false) + #expect(engine.lockCalls.first?.settings.unlockHotkey == customSettings.unlockHotkey) + #expect(engine.lockCalls.first?.allowsControlCUnlock == false) engine.unlock() var lockError: Error? service.lockKeyboard { lockError = $0 } - XCTAssertNil(lockError) - XCTAssertEqual(engine.lockCalls.last?.settings, customSettings) + #expect(lockError == nil) + #expect(engine.lockCalls.last?.settings == customSettings) } - func testSafetyCheckReportsAnExistingLockWithoutChangingIt() { + @Test + func safetyCheckReportsAnExistingLockWithoutChangingIt() { engine.lockOutcome = .alreadyLocked let service = makeService() @@ -333,14 +349,15 @@ final class AgentServiceTests: XCTestCase { safetyError = error } - XCTAssertNil(safetyError) - XCTAssertEqual(didStart, false) - XCTAssertEqual(engine.unlockCallCount, 0) + #expect(safetyError == nil) + #expect(didStart == false) + #expect(engine.unlockCallCount == 0) } // MARK: - Toggle - func testToggleLocksUnlockedEngineWithPersistedSettings() { + @Test + func toggleLocksUnlockedEngineWithPersistedSettings() { let service = makeService() var isLocked: Bool? @@ -350,15 +367,16 @@ final class AgentServiceTests: XCTestCase { toggleError = error } - XCTAssertNil(toggleError) - XCTAssertEqual(isLocked, true) - XCTAssertEqual(engine.lockCalls.count, 1) - XCTAssertEqual(engine.lockCalls.first?.settings, customSettings) - XCTAssertEqual(engine.lockCalls.first?.allowsControlCUnlock, false) - XCTAssertEqual(engine.unlockCallCount, 0) + #expect(toggleError == nil) + #expect(isLocked == true) + #expect(engine.lockCalls.count == 1) + #expect(engine.lockCalls.first?.settings == customSettings) + #expect(engine.lockCalls.first?.allowsControlCUnlock == false) + #expect(engine.unlockCallCount == 0) } - func testToggleUnlocksLockedEngine() { + @Test + func toggleUnlocksLockedEngine() { let lockedEngine = FakeLockEngine(isLocked: true) let service = makeService(engineOverride: lockedEngine) @@ -369,13 +387,14 @@ final class AgentServiceTests: XCTestCase { toggleError = error } - XCTAssertNil(toggleError) - XCTAssertEqual(isLocked, false) - XCTAssertEqual(lockedEngine.unlockCallCount, 1) - XCTAssertTrue(lockedEngine.lockCalls.isEmpty) + #expect(toggleError == nil) + #expect(isLocked == false) + #expect(lockedEngine.unlockCallCount == 1) + #expect(lockedEngine.lockCalls.isEmpty) } - func testTogglePropagatesLockFailureWithoutChangingState() { + @Test + func togglePropagatesLockFailureWithoutChangingState() { engine.lockError = StubError.descriptorUnavailable let service = makeService() @@ -386,20 +405,21 @@ final class AgentServiceTests: XCTestCase { toggleError = error } - XCTAssertEqual(toggleError as? StubError, .descriptorUnavailable) - XCTAssertEqual(isLocked, false) - XCTAssertFalse(engine.isLocked) + #expect(toggleError as? StubError == .descriptorUnavailable) + #expect(isLocked == false) + #expect(!engine.isLocked) } - func testPreparedDrainRejectsOnlyTheLockDirectionOfToggle() { + @Test + func preparedDrainRejectsOnlyTheLockDirectionOfToggle() { let service = makeService() prepareTicket(on: service, instanceID: instanceID) var toggleError: Error? service.toggleKeyboard { _, error in toggleError = error } assertReplacementError(toggleError, code: 1) - XCTAssertTrue(engine.lockCalls.isEmpty) - XCTAssertEqual(engine.unlockCallCount, 0) + #expect(engine.lockCalls.isEmpty) + #expect(engine.unlockCallCount == 0) } // MARK: - Fixtures @@ -444,7 +464,7 @@ final class AgentServiceTests: XCTestCase { data = ticketData error = replyError } - XCTAssertNil(error) + #expect(error == nil) return data.flatMap { try? ServiceReplacementTicket.decodedFromXPC($0) } } @@ -474,17 +494,14 @@ final class AgentServiceTests: XCTestCase { private func assertReplacementError( _ error: Error?, code: Int, - file: StaticString = #filePath, - line: UInt = #line + sourceLocation: SourceLocation = #_sourceLocation ) { let nsError = error as NSError? - XCTAssertEqual( - nsError?.domain, - "\(SharedConstants.agentBundleIdentifier).replacement", - file: file, - line: line + #expect( + nsError?.domain == "\(SharedConstants.agentBundleIdentifier).replacement", + sourceLocation: sourceLocation ) - XCTAssertEqual(nsError?.code, code, file: file, line: line) + #expect(nsError?.code == code, sourceLocation: sourceLocation) } } diff --git a/Core/Tests/ServiceTests/EventTapInstallationTests.swift b/Core/Tests/ServiceTests/EventTapInstallationTests.swift index 81f5594..be029ab 100644 --- a/Core/Tests/ServiceTests/EventTapInstallationTests.swift +++ b/Core/Tests/ServiceTests/EventTapInstallationTests.swift @@ -1,64 +1,52 @@ @testable import Service -import XCTest +import Testing -final class EventTapInstallationTests: XCTestCase { - func testMissingTapHasNoResourcesToRollBack() { +@Suite(.serialized) +struct EventTapInstallationTests { + @Test + func missingTapHasNoResourcesToRollBack() { var actions: [String] = [] - XCTAssertThrowsError( - try makeInstallation( + #expect(throws: EventTapInstallationError.eventTapCreationFailed) { + try self.makeInstallation( actions: &actions, tap: nil, source: 2, isEnabled: true ) - ) { error in - XCTAssertEqual( - error as? EventTapInstallationError, - .eventTapCreationFailed - ) } - XCTAssertEqual(actions, ["createTap"]) + #expect(actions == ["createTap"]) } - func testMissingSourceInvalidatesCreatedTap() { + @Test + func missingSourceInvalidatesCreatedTap() { var actions: [String] = [] - XCTAssertThrowsError( - try makeInstallation( + #expect(throws: EventTapInstallationError.runLoopSourceCreationFailed) { + try self.makeInstallation( actions: &actions, tap: 1, source: nil, isEnabled: true ) - ) { error in - XCTAssertEqual( - error as? EventTapInstallationError, - .runLoopSourceCreationFailed - ) } - XCTAssertEqual(actions, ["createTap", "createSource", "invalidateTap"]) + #expect(actions == ["createTap", "createSource", "invalidateTap"]) } - func testDisabledTapDetachesSourceBeforeInvalidatingTap() { + @Test + func disabledTapDetachesSourceBeforeInvalidatingTap() { var actions: [String] = [] - XCTAssertThrowsError( - try makeInstallation( + #expect(throws: EventTapInstallationError.eventTapEnableFailed) { + try self.makeInstallation( actions: &actions, tap: 1, source: 2, isEnabled: false ) - ) { error in - XCTAssertEqual( - error as? EventTapInstallationError, - .eventTapEnableFailed - ) } - XCTAssertEqual( - actions, - [ + #expect( + actions == [ "createTap", "createSource", "attachSource", @@ -70,7 +58,8 @@ final class EventTapInstallationTests: XCTestCase { ) } - func testSuccessfulInstallationTransfersResourceOwnership() throws { + @Test + func successfulInstallationTransfersResourceOwnership() throws { var actions: [String] = [] let installation = try makeInstallation( @@ -80,11 +69,10 @@ final class EventTapInstallationTests: XCTestCase { isEnabled: true ) - XCTAssertEqual(installation.tap, 1) - XCTAssertEqual(installation.source, 2) - XCTAssertEqual( - actions, - [ + #expect(installation.tap == 1) + #expect(installation.source == 2) + #expect( + actions == [ "createTap", "createSource", "attachSource", diff --git a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift index 20c6a6d..9dfdff2 100644 --- a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift +++ b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift @@ -1,62 +1,74 @@ import Common +import Foundation @testable import Service -import XCTest +import Testing -final class KeyboardLockerSettingsStoreTests: XCTestCase { - private var suiteName: String! - private var defaults: UserDefaults! +@Suite(.serialized) +final class KeyboardLockerSettingsStoreTests { + private let suiteName: String + private let defaults: UserDefaults - override func setUp() { - super.setUp() + init() { suiteName = "KeyboardLockerSettingsStoreTests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName) + defaults = UserDefaults(suiteName: suiteName)! } - override func tearDown() { + private func withCleanDefaults(_ body: () throws -> Void) rethrows { defaults.removePersistentDomain(forName: suiteName) - defaults = nil - suiteName = nil - super.tearDown() + defer { defaults.removePersistentDomain(forName: suiteName) } + try body() } - func testFreshStoreRegistersAndLoadsDefaults() { - _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") - XCTAssertNotNil(defaults.data(forKey: "settings")) + @Test + func freshStoreRegistersAndLoadsDefaults() { + withCleanDefaults { + _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(defaults.data(forKey: "settings") != nil) - let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") - XCTAssertEqual(store.load(), .default) + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(store.load() == .default) + } } - func testLoadReturnsPersistedSettings() throws { - let custom = KeyboardLockerSettings( - autoUnlockPolicy: .timed(seconds: 120), - unlockHotkey: KeyboardLockerSettings.Hotkey( - keyCode: 4, - modifierFlags: [.maskControl, .maskAlternate] + @Test + func loadReturnsPersistedSettings() throws { + try withCleanDefaults { + let custom = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 120), + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: 4, + modifierFlags: [.maskControl, .maskAlternate] + ) ) - ) - defaults.set(try JSONEncoder().encode(custom), forKey: "settings") + defaults.set(try JSONEncoder().encode(custom), forKey: "settings") - let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") - XCTAssertEqual(store.load(), custom) + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(store.load() == custom) + } } - func testCorruptPayloadFallsBackToDefaults() { - defaults.set(Data([0xFF, 0xFE, 0x00]), forKey: "settings") + @Test + func corruptPayloadFallsBackToDefaults() { + withCleanDefaults { + defaults.set(Data([0xFF, 0xFE, 0x00]), forKey: "settings") - let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") - XCTAssertEqual(store.load(), .default) + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(store.load() == .default) + } } - func testRegistrationDoesNotOverwriteExistingPayload() throws { - let custom = KeyboardLockerSettings( - autoUnlockPolicy: .disabled, - unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) - ) - defaults.set(try JSONEncoder().encode(custom), forKey: "settings") + @Test + func registrationDoesNotOverwriteExistingPayload() throws { + try withCleanDefaults { + let custom = KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) + ) + defaults.set(try JSONEncoder().encode(custom), forKey: "settings") - _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") - let persisted = try XCTUnwrap(defaults.data(forKey: "settings")) - XCTAssertEqual(try JSONDecoder().decode(KeyboardLockerSettings.self, from: persisted), custom) + _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + let persisted = try #require(defaults.data(forKey: "settings")) + #expect(try JSONDecoder().decode(KeyboardLockerSettings.self, from: persisted) == custom) + } } } diff --git a/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift index 5a159fa..6258505 100644 --- a/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift +++ b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift @@ -1,12 +1,14 @@ import Foundation @testable import Service -import XCTest +import Testing -final class LockEngineAutoUnlockScheduleTests: XCTestCase { +@Suite(.serialized) +struct LockEngineAutoUnlockScheduleTests { private let initialDate = Date(timeIntervalSinceReferenceDate: 1000) - func testInitialScheduleUsesTimeoutForDeadlineAndDelay() throws { - let schedule = try XCTUnwrap( + @Test + func initialScheduleUsesTimeoutForDeadlineAndDelay() throws { + let schedule = try #require( AutoUnlockSchedule.make( timeout: 60, referenceDate: initialDate, @@ -14,12 +16,13 @@ final class LockEngineAutoUnlockScheduleTests: XCTestCase { ) ) - XCTAssertEqual(schedule.deadline, initialDate.addingTimeInterval(60)) - XCTAssertEqual(schedule.delay, 60) + #expect(schedule.deadline == initialDate.addingTimeInterval(60)) + #expect(schedule.delay == 60) } - func testExplicitRearmStartsANewTimeoutWindow() throws { - let initialSchedule = try XCTUnwrap( + @Test + func explicitRearmStartsANewTimeoutWindow() throws { + let initialSchedule = try #require( AutoUnlockSchedule.make( timeout: 60, referenceDate: initialDate, @@ -27,7 +30,7 @@ final class LockEngineAutoUnlockScheduleTests: XCTestCase { ) ) let rearmDate = initialDate.addingTimeInterval(15) - let rearmedSchedule = try XCTUnwrap( + let rearmedSchedule = try #require( AutoUnlockSchedule.make( timeout: 60, referenceDate: rearmDate, @@ -35,16 +38,16 @@ final class LockEngineAutoUnlockScheduleTests: XCTestCase { ) ) - XCTAssertEqual(rearmedSchedule.deadline, rearmDate.addingTimeInterval(60)) - XCTAssertEqual(rearmedSchedule.delay, 60) - XCTAssertEqual( - rearmedSchedule.deadline.timeIntervalSince(initialSchedule.deadline), - 15 + #expect(rearmedSchedule.deadline == rearmDate.addingTimeInterval(60)) + #expect(rearmedSchedule.delay == 60) + #expect( + rearmedSchedule.deadline.timeIntervalSince(initialSchedule.deadline) == 15 ) } - func testPastDeadlineSchedulesImmediateUnlock() throws { - let schedule = try XCTUnwrap( + @Test + func pastDeadlineSchedulesImmediateUnlock() throws { + let schedule = try #require( AutoUnlockSchedule.make( timeout: 10, referenceDate: initialDate, @@ -52,11 +55,12 @@ final class LockEngineAutoUnlockScheduleTests: XCTestCase { ) ) - XCTAssertEqual(schedule.deadline, initialDate.addingTimeInterval(10)) - XCTAssertEqual(schedule.delay, 0) + #expect(schedule.deadline == initialDate.addingTimeInterval(10)) + #expect(schedule.delay == 0) } - func testInvalidTimeoutDoesNotCreateSchedule() { + @Test + func invalidTimeoutDoesNotCreateSchedule() { let invalidTimeouts: [TimeInterval?] = [ nil, 0, @@ -67,12 +71,12 @@ final class LockEngineAutoUnlockScheduleTests: XCTestCase { ] for timeout in invalidTimeouts { - XCTAssertNil( + #expect( AutoUnlockSchedule.make( timeout: timeout, referenceDate: initialDate, currentDate: initialDate - ), + ) == nil, "Expected \(String(describing: timeout)) to be rejected" ) } diff --git a/Core/Tests/ServiceTests/LockEngineTests.swift b/Core/Tests/ServiceTests/LockEngineTests.swift index 2be9a8f..8b93e09 100644 --- a/Core/Tests/ServiceTests/LockEngineTests.swift +++ b/Core/Tests/ServiceTests/LockEngineTests.swift @@ -1,20 +1,21 @@ import Common import CoreGraphics +import Foundation @testable import Service -import XCTest +import Testing @MainActor -final class LockEngineTests: XCTestCase { - private var tap: FakeInstalledEventTap! - private var scheduler: ManualTimerScheduler! - private var installCount = 0 - private var broadcastCount = 0 - private var stateChangeCount = 0 - private var hasAccessibilityPermission = true - private var now: Date! - - override func setUp() { - super.setUp() +@Suite(.serialized) +final class LockEngineTests { + private let tap: FakeInstalledEventTap + private let scheduler: ManualTimerScheduler + private var installCount: Int + private var broadcastCount: Int + private var stateChangeCount: Int + private var hasAccessibilityPermission: Bool + private let now: Date + + init() { tap = FakeInstalledEventTap() scheduler = ManualTimerScheduler() installCount = 0 @@ -26,35 +27,35 @@ final class LockEngineTests: XCTestCase { // MARK: - Acquisition - func testLockWithoutAccessibilityPermissionThrowsBeforeInstallingTap() { + @Test + func lockWithoutAccessibilityPermissionThrowsBeforeInstallingTap() { hasAccessibilityPermission = false let engine = makeEngine() - XCTAssertThrowsError(try engine.lock(settings: makeSettings())) { error in - XCTAssertEqual(error as? LockEngine.LockEngineError, .accessibilityPermissionDenied) + #expect(throws: LockEngine.LockEngineError.accessibilityPermissionDenied) { + try engine.lock(settings: makeSettings()) } - XCTAssertEqual(installCount, 0) - XCTAssertFalse(engine.isLocked) - XCTAssertEqual(broadcastCount, 0) + #expect(installCount == 0) + #expect(!engine.isLocked) + #expect(broadcastCount == 0) } - func testLockInstallsTapSchedulesAutoUnlockAndBroadcasts() throws { + @Test + func lockInstallsTapSchedulesAutoUnlockAndBroadcasts() throws { let engine = makeEngine() let outcome = try engine.lock(settings: makeSettings()) - XCTAssertEqual(outcome, .acquired) - XCTAssertTrue(engine.isLocked) - XCTAssertEqual(installCount, 1) - XCTAssertEqual(broadcastCount, 1) - XCTAssertEqual(scheduler.timers.map(\.interval), [60]) - XCTAssertEqual( - engine.statusSnapshot.autoUnlockTargetDate, - now.addingTimeInterval(60) - ) + #expect(outcome == .acquired) + #expect(engine.isLocked) + #expect(installCount == 1) + #expect(broadcastCount == 1) + #expect(scheduler.timers.map(\.interval) == [60]) + #expect(engine.statusSnapshot.autoUnlockTargetDate == now.addingTimeInterval(60)) } - func testDuplicateLockNeverMutatesTheRunningLock() throws { + @Test + func duplicateLockNeverMutatesTheRunningLock() throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) @@ -62,111 +63,113 @@ final class LockEngineTests: XCTestCase { settings: makeSettings(autoUnlockPolicy: .timed(seconds: 120)) ) - XCTAssertEqual(duplicate, .alreadyLocked) - XCTAssertEqual(installCount, 1) - XCTAssertEqual(scheduler.timers.count, 1) - XCTAssertEqual(broadcastCount, 1) - XCTAssertEqual( - engine.statusSnapshot.autoUnlockTargetDate, - now.addingTimeInterval(60) - ) + #expect(duplicate == .alreadyLocked) + #expect(installCount == 1) + #expect(scheduler.timers.count == 1) + #expect(broadcastCount == 1) + #expect(engine.statusSnapshot.autoUnlockTargetDate == now.addingTimeInterval(60)) } // MARK: - Auto-unlock scheduling - func testAutoUnlockTimerFireUnlocks() throws { + @Test + func autoUnlockTimerFireUnlocks() throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) scheduler.timers[0].fire() - XCTAssertFalse(engine.isLocked) - XCTAssertEqual(tap.teardownCallCount, 1) - XCTAssertEqual(broadcastCount, 2) - XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) } - func testStaleTimerFireKeepsNewerLock() throws { + @Test + func staleTimerFireKeepsNewerLock() throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) let staleTimer = scheduler.timers[0] engine.unlock() - XCTAssertTrue(staleTimer.isCancelled) + #expect(staleTimer.isCancelled) _ = try engine.lock(settings: makeSettings()) - XCTAssertEqual(scheduler.timers.count, 2) + #expect(scheduler.timers.count == 2) // A cancel that lost the race against an already-queued fire must not unlock the new lock. staleTimer.fire() - XCTAssertTrue(engine.isLocked) + #expect(engine.isLocked) } - func testUpdateSettingsWhileLockedRearmsAutoUnlock() throws { + @Test + func updateSettingsWhileLockedRearmsAutoUnlock() throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) engine.updateSettings(makeSettings(autoUnlockPolicy: .timed(seconds: 120))) - XCTAssertEqual(scheduler.timers.map(\.interval), [60, 120]) - XCTAssertTrue(scheduler.timers[0].isCancelled) - XCTAssertFalse(scheduler.timers[1].isCancelled) - XCTAssertEqual( - engine.statusSnapshot.autoUnlockTargetDate, - now.addingTimeInterval(120) - ) + #expect(scheduler.timers.map(\.interval) == [60, 120]) + #expect(scheduler.timers[0].isCancelled) + #expect(!scheduler.timers[1].isCancelled) + #expect(engine.statusSnapshot.autoUnlockTargetDate == now.addingTimeInterval(120)) } - func testUpdateSettingsWhileUnlockedSchedulesNoTimer() { + @Test + func updateSettingsWhileUnlockedSchedulesNoTimer() { let engine = makeEngine() engine.updateSettings(makeSettings()) - XCTAssertTrue(scheduler.timers.isEmpty) - XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + #expect(scheduler.timers.isEmpty) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) } - func testLockWithDisabledAutoUnlockSchedulesNoTimer() throws { + @Test + func lockWithDisabledAutoUnlockSchedulesNoTimer() throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings(autoUnlockPolicy: .disabled)) - XCTAssertTrue(engine.isLocked) - XCTAssertTrue(scheduler.timers.isEmpty) - XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + #expect(engine.isLocked) + #expect(scheduler.timers.isEmpty) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) } // MARK: - Unlock gestures - func testUnlockHotkeyEventUnlocksAfterDispatch() async throws { + @Test + func unlockHotkeyEventUnlocksAfterDispatch() async throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) let event = makeKeyEvent(keyCode: 4, flags: .maskShift) - XCTAssertNil(engine.handleEvent(type: .keyDown, event: event)) - XCTAssertTrue(engine.isLocked) + #expect(engine.handleEvent(type: .keyDown, event: event) == nil) + #expect(engine.isLocked) await flushMainQueue() - XCTAssertFalse(engine.isLocked) - XCTAssertEqual(tap.teardownCallCount, 1) - XCTAssertEqual(broadcastCount, 2) + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) } - func testUnrelatedKeyEventIsConsumedWithoutUnlocking() async throws { + @Test + func unrelatedKeyEventIsConsumedWithoutUnlocking() async throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) let event = makeKeyEvent(keyCode: 40) - XCTAssertNil(engine.handleEvent(type: .keyDown, event: event)) + #expect(engine.handleEvent(type: .keyDown, event: event) == nil) await flushMainQueue() - XCTAssertTrue(engine.isLocked) - XCTAssertEqual(tap.teardownCallCount, 0) + #expect(engine.isLocked) + #expect(tap.teardownCallCount == 0) } - func testControlCUnlocksOnlyWhenAllowed() async throws { + @Test + func controlCUnlocksOnlyWhenAllowed() async throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings(), allowsControlCUnlock: false) @@ -174,60 +177,63 @@ final class LockEngineTests: XCTestCase { keyCode: UnlockGestureMatcher.controlCKeyCode, flags: .maskControl ) - XCTAssertNil(engine.handleEvent(type: .keyDown, event: controlC)) + #expect(engine.handleEvent(type: .keyDown, event: controlC) == nil) await flushMainQueue() - XCTAssertTrue(engine.isLocked) + #expect(engine.isLocked) engine.unlock() _ = try engine.lock(settings: makeSettings(), allowsControlCUnlock: true) - XCTAssertNil(engine.handleEvent(type: .keyDown, event: controlC)) + #expect(engine.handleEvent(type: .keyDown, event: controlC) == nil) await flushMainQueue() - XCTAssertFalse(engine.isLocked) + #expect(!engine.isLocked) } // MARK: - Event tap failure - func testTapDisabledAndReenabledKeepsLock() async throws { + @Test + func tapDisabledAndReenabledKeepsLock() async throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) tap.isEnabled = false let event = makeKeyEvent(keyCode: 0) - XCTAssertNotNil(engine.handleDisabledEvent(event)) + #expect(engine.handleDisabledEvent(event) != nil) await flushMainQueue() - XCTAssertTrue(engine.isLocked) - XCTAssertEqual(tap.setEnabledCalls, [true]) - XCTAssertEqual(tap.teardownCallCount, 0) - XCTAssertEqual(broadcastCount, 1) + #expect(engine.isLocked) + #expect(tap.setEnabledCalls == [true]) + #expect(tap.teardownCallCount == 0) + #expect(broadcastCount == 1) } - func testTapDisabledBeyondRecoveryUnlocksFailOpen() async throws { + @Test + func tapDisabledBeyondRecoveryUnlocksFailOpen() async throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) tap.isEnabled = false tap.allowsReEnable = false let event = makeKeyEvent(keyCode: 0) - XCTAssertNotNil(engine.handleDisabledEvent(event)) - XCTAssertTrue(engine.isLocked) + #expect(engine.handleDisabledEvent(event) != nil) + #expect(engine.isLocked) await flushMainQueue() - XCTAssertFalse(engine.isLocked) - XCTAssertEqual(tap.teardownCallCount, 1) - XCTAssertEqual(broadcastCount, 2) + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) } - func testStaleTapFailureKeepsNewerLock() async throws { + @Test + func staleTapFailureKeepsNewerLock() async throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) tap.isEnabled = false tap.allowsReEnable = false let event = makeKeyEvent(keyCode: 0) - XCTAssertNotNil(engine.handleDisabledEvent(event)) + #expect(engine.handleDisabledEvent(event) != nil) engine.unlock() tap.isEnabled = true @@ -236,58 +242,62 @@ final class LockEngineTests: XCTestCase { await flushMainQueue() - XCTAssertTrue(engine.isLocked) + #expect(engine.isLocked) } // MARK: - Unlock - func testUnlockCancelsTimerTearsDownAndBroadcasts() throws { + @Test + func unlockCancelsTimerTearsDownAndBroadcasts() throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) engine.unlock() - XCTAssertFalse(engine.isLocked) - XCTAssertTrue(scheduler.timers[0].isCancelled) - XCTAssertEqual(tap.teardownCallCount, 1) - XCTAssertEqual(broadcastCount, 2) - XCTAssertNil(engine.statusSnapshot.autoUnlockTargetDate) + #expect(!engine.isLocked) + #expect(scheduler.timers[0].isCancelled) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) } - func testStateChangeHandlerFollowsCommittedLockTransitions() throws { + @Test + func stateChangeHandlerFollowsCommittedLockTransitions() throws { let engine = makeEngine() _ = try engine.lock(settings: makeSettings()) engine.unlock() - XCTAssertEqual(stateChangeCount, 2) + #expect(stateChangeCount == 2) } // MARK: - Focus ownership - func testFocusDeactivationReleasesFocusOwnedLock() throws { + @Test + func focusDeactivationReleasesFocusOwnedLock() throws { let engine = makeEngine() try engine.setFocusFilterLockEnabled(true, settings: makeSettings()) - XCTAssertTrue(engine.isLocked) + #expect(engine.isLocked) try engine.setFocusFilterLockEnabled(false, settings: makeSettings()) - XCTAssertFalse(engine.isLocked) - XCTAssertEqual(tap.teardownCallCount, 1) + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) } - func testFocusDeactivationKeepsLockTakenOverByGeneralRequest() throws { + @Test + func focusDeactivationKeepsLockTakenOverByGeneralRequest() throws { let engine = makeEngine() try engine.setFocusFilterLockEnabled(true, settings: makeSettings()) // An explicit lock takes persistence over from the Focus-created generation. let takeover = try engine.lock(settings: makeSettings()) - XCTAssertEqual(takeover, .alreadyLocked) + #expect(takeover == .alreadyLocked) try engine.setFocusFilterLockEnabled(false, settings: makeSettings()) - XCTAssertTrue(engine.isLocked) - XCTAssertEqual(tap.teardownCallCount, 0) + #expect(engine.isLocked) + #expect(tap.teardownCallCount == 0) } // MARK: - Helpers diff --git a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift index 31e1679..98e8b41 100644 --- a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift +++ b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift @@ -1,35 +1,36 @@ import Common import Foundation @testable import Service -import XCTest +import Testing -final class LockRuntimeStateTests: XCTestCase { - func testInteractiveDuplicateDoesNotEnableControlCForExistingLock() { +@Suite(.serialized) +struct LockRuntimeStateTests { + @Test + func interactiveDuplicateDoesNotEnableControlCForExistingLock() { let startedAt = Date(timeIntervalSinceReferenceDate: 1000) var state = LockRuntimeState() - XCTAssertEqual( + #expect( state.begin( settings: .default, allowsControlCUnlock: false, at: startedAt - ), - .acquired + ) == .acquired ) - XCTAssertEqual( + #expect( state.begin( settings: .default, allowsControlCUnlock: true, at: startedAt.addingTimeInterval(1) - ), - .alreadyLocked + ) == .alreadyLocked ) - XCTAssertFalse(state.allowsControlCUnlock) - XCTAssertEqual(state.startedAt, startedAt) + #expect(!state.allowsControlCUnlock) + #expect(state.startedAt == startedAt) } - func testDuplicateBeginPreservesOriginalRuntimeStateAndDeadline() { + @Test + func duplicateBeginPreservesOriginalRuntimeStateAndDeadline() { let originalSettings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) @@ -42,83 +43,81 @@ final class LockRuntimeStateTests: XCTestCase { let deadline = startedAt.addingTimeInterval(60) var state = LockRuntimeState() - XCTAssertEqual( + #expect( state.begin( settings: originalSettings, allowsControlCUnlock: true, at: startedAt - ), - .acquired + ) == .acquired ) state.setAutoUnlockTargetDate(deadline) - XCTAssertEqual( + #expect( state.begin( settings: replacementSettings, allowsControlCUnlock: false, at: startedAt.addingTimeInterval(10) - ), - .alreadyLocked + ) == .alreadyLocked ) - XCTAssertEqual(state.activeSettings, originalSettings) - XCTAssertTrue(state.allowsControlCUnlock) - XCTAssertEqual(state.startedAt, startedAt) - XCTAssertEqual(state.autoUnlockTargetDate, deadline) - XCTAssertTrue(state.isLocked) + #expect(state.activeSettings == originalSettings) + #expect(state.allowsControlCUnlock) + #expect(state.startedAt == startedAt) + #expect(state.autoUnlockTargetDate == deadline) + #expect(state.isLocked) } - func testEndedLockCanBeginAgainWithoutRetainingTimingState() { + @Test + func endedLockCanBeginAgainWithoutRetainingTimingState() { let firstStart = Date(timeIntervalSinceReferenceDate: 1000) let secondStart = firstStart.addingTimeInterval(90) var state = LockRuntimeState() - XCTAssertEqual( + #expect( state.begin( settings: .default, allowsControlCUnlock: true, at: firstStart - ), - .acquired + ) == .acquired ) state.setAutoUnlockTargetDate(firstStart.addingTimeInterval(60)) state.end() - XCTAssertFalse(state.allowsControlCUnlock) - XCTAssertFalse(state.isLocked) - XCTAssertNil(state.startedAt) - XCTAssertNil(state.autoUnlockTargetDate) - XCTAssertEqual( + #expect(!state.allowsControlCUnlock) + #expect(!state.isLocked) + #expect(state.startedAt == nil) + #expect(state.autoUnlockTargetDate == nil) + #expect( state.begin( settings: .default, allowsControlCUnlock: false, at: secondStart - ), - .acquired + ) == .acquired ) - XCTAssertFalse(state.allowsControlCUnlock) - XCTAssertEqual(state.startedAt, secondStart) + #expect(!state.allowsControlCUnlock) + #expect(state.startedAt == secondStart) } - func testNewFocusLockOwnsItsGeneration() throws { + @Test + func newFocusLockOwnsItsGeneration() throws { let startedAt = Date(timeIntervalSinceReferenceDate: 1000) var state = LockRuntimeState() - XCTAssertEqual( + #expect( state.begin( settings: .default, allowsControlCUnlock: false, at: startedAt - ), - .acquired + ) == .acquired ) state.markCurrentLockAsFocusOwned() - let generation = try XCTUnwrap(state.lockGeneration) - XCTAssertEqual(state.focusOwnedGenerationForRelease, generation) - XCTAssertTrue(state.matchesCurrentLockGeneration(generation)) + let generation = try #require(state.lockGeneration) + #expect(state.focusOwnedGenerationForRelease == generation) + #expect(state.matchesCurrentLockGeneration(generation)) } - func testFocusDuplicateDoesNotClaimExistingGeneralLock() throws { + @Test + func focusDuplicateDoesNotClaimExistingGeneralLock() throws { let startedAt = Date(timeIntervalSinceReferenceDate: 1000) var state = LockRuntimeState() _ = state.begin( @@ -126,19 +125,19 @@ final class LockRuntimeStateTests: XCTestCase { allowsControlCUnlock: false, at: startedAt ) - let generation = try XCTUnwrap(state.lockGeneration) + let generation = try #require(state.lockGeneration) - XCTAssertEqual( - state.handleDuplicateLockRequest(from: .focusFilter), - .alreadyLocked + #expect( + state.handleDuplicateLockRequest(from: .focusFilter) == .alreadyLocked ) - XCTAssertNil(state.focusOwnedGenerationForRelease) - XCTAssertEqual(state.lockGeneration, generation) - XCTAssertEqual(state.startedAt, startedAt) + #expect(state.focusOwnedGenerationForRelease == nil) + #expect(state.lockGeneration == generation) + #expect(state.startedAt == startedAt) } - func testRepeatedFocusEnablePreservesOwnedRuntimeState() { + @Test + func repeatedFocusEnablePreservesOwnedRuntimeState() { let settings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) @@ -155,15 +154,15 @@ final class LockRuntimeStateTests: XCTestCase { state.setAutoUnlockTargetDate(deadline) let originalState = state - XCTAssertEqual( - state.handleDuplicateLockRequest(from: .focusFilter), - .alreadyLocked + #expect( + state.handleDuplicateLockRequest(from: .focusFilter) == .alreadyLocked ) - XCTAssertEqual(state, originalState) + #expect(state == originalState) } - func testManualDesiredLockTakesOverFocusOwnershipWithoutChangingRuntimeState() throws { + @Test + func manualDesiredLockTakesOverFocusOwnershipWithoutChangingRuntimeState() throws { let settings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) @@ -172,33 +171,32 @@ final class LockRuntimeStateTests: XCTestCase { let deadline = startedAt.addingTimeInterval(60) var state = LockRuntimeState() - XCTAssertEqual( + #expect( state.begin( settings: settings, allowsControlCUnlock: false, at: startedAt - ), - .acquired + ) == .acquired ) state.markCurrentLockAsFocusOwned() state.setAutoUnlockTargetDate(deadline) - let generation = try XCTUnwrap(state.lockGeneration) + let generation = try #require(state.lockGeneration) - XCTAssertEqual( - state.handleDuplicateLockRequest(from: .general), - .alreadyLocked + #expect( + state.handleDuplicateLockRequest(from: .general) == .alreadyLocked ) - XCTAssertNil(state.focusOwnedLockGeneration) - XCTAssertEqual(state.lockGeneration, generation) - XCTAssertEqual(state.activeSettings, settings) - XCTAssertFalse(state.allowsControlCUnlock) - XCTAssertEqual(state.startedAt, startedAt) - XCTAssertEqual(state.autoUnlockTargetDate, deadline) - XCTAssertTrue(state.isLocked) + #expect(state.focusOwnedLockGeneration == nil) + #expect(state.lockGeneration == generation) + #expect(state.activeSettings == settings) + #expect(!state.allowsControlCUnlock) + #expect(state.startedAt == startedAt) + #expect(state.autoUnlockTargetDate == deadline) + #expect(state.isLocked) } - func testEndingFocusOwnedLockClearsOwnershipAndNextLockUsesNewGeneration() throws { + @Test + func endingFocusOwnedLockClearsOwnershipAndNextLockUsesNewGeneration() throws { let firstStart = Date(timeIntervalSinceReferenceDate: 1000) var state = LockRuntimeState() @@ -208,14 +206,14 @@ final class LockRuntimeStateTests: XCTestCase { at: firstStart ) state.markCurrentLockAsFocusOwned() - let firstGeneration = try XCTUnwrap(state.lockGeneration) + let firstGeneration = try #require(state.lockGeneration) - XCTAssertEqual(state.focusOwnedLockGeneration, firstGeneration) + #expect(state.focusOwnedLockGeneration == firstGeneration) state.end() - XCTAssertNil(state.focusOwnedLockGeneration) - XCTAssertNil(state.lockGeneration) + #expect(state.focusOwnedLockGeneration == nil) + #expect(state.lockGeneration == nil) _ = state.begin( settings: .default, @@ -223,13 +221,14 @@ final class LockRuntimeStateTests: XCTestCase { at: firstStart.addingTimeInterval(1) ) - XCTAssertNotEqual(state.lockGeneration, firstGeneration) - XCTAssertNil(state.focusOwnedLockGeneration) - XCTAssertNil(state.focusOwnedGenerationForRelease) - XCTAssertFalse(state.matchesCurrentLockGeneration(firstGeneration)) + #expect(state.lockGeneration != firstGeneration) + #expect(state.focusOwnedLockGeneration == nil) + #expect(state.focusOwnedGenerationForRelease == nil) + #expect(!state.matchesCurrentLockGeneration(firstGeneration)) } - func testEarlyUnlockClearsFocusReleaseTarget() throws { + @Test + func earlyUnlockClearsFocusReleaseTarget() throws { var state = LockRuntimeState() _ = state.begin( settings: .default, @@ -237,15 +236,16 @@ final class LockRuntimeStateTests: XCTestCase { at: Date(timeIntervalSinceReferenceDate: 1000) ) state.markCurrentLockAsFocusOwned() - let generation = try XCTUnwrap(state.focusOwnedGenerationForRelease) + let generation = try #require(state.focusOwnedGenerationForRelease) state.end() - XCTAssertNil(state.focusOwnedGenerationForRelease) - XCTAssertFalse(state.matchesCurrentLockGeneration(generation)) + #expect(state.focusOwnedGenerationForRelease == nil) + #expect(!state.matchesCurrentLockGeneration(generation)) } - func testStaleFocusReleaseCannotMatchLaterLockGeneration() throws { + @Test + func staleFocusReleaseCannotMatchLaterLockGeneration() throws { var state = LockRuntimeState() _ = state.begin( settings: .default, @@ -253,7 +253,7 @@ final class LockRuntimeStateTests: XCTestCase { at: Date(timeIntervalSinceReferenceDate: 1000) ) state.markCurrentLockAsFocusOwned() - let focusGeneration = try XCTUnwrap(state.focusOwnedGenerationForRelease) + let focusGeneration = try #require(state.focusOwnedGenerationForRelease) state.end() _ = state.begin( @@ -262,11 +262,12 @@ final class LockRuntimeStateTests: XCTestCase { at: Date(timeIntervalSinceReferenceDate: 1001) ) - XCTAssertFalse(state.matchesCurrentLockGeneration(focusGeneration)) - XCTAssertNil(state.focusOwnedGenerationForRelease) + #expect(!state.matchesCurrentLockGeneration(focusGeneration)) + #expect(state.focusOwnedGenerationForRelease == nil) } - func testSnapshotCapturesOneCoherentRuntimeState() { + @Test + func snapshotCapturesOneCoherentRuntimeState() { let settings = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) @@ -276,19 +277,17 @@ final class LockRuntimeStateTests: XCTestCase { let deadline = startedAt.addingTimeInterval(60) var state = LockRuntimeState() - XCTAssertEqual( + #expect( state.begin( settings: settings, allowsControlCUnlock: false, at: startedAt - ), - .acquired + ) == .acquired ) state.setAutoUnlockTargetDate(deadline) - XCTAssertEqual( - state.statusSnapshot(capturedAt: capturedAt), - LockStatusSnapshot( + #expect( + state.statusSnapshot(capturedAt: capturedAt) == LockStatusSnapshot( capturedAt: capturedAt, isLocked: true, startedAt: startedAt, @@ -298,7 +297,8 @@ final class LockRuntimeStateTests: XCTestCase { ) } - func testSnapshotClearsRuntimeDatesAfterUnlock() { + @Test + func snapshotClearsRuntimeDatesAfterUnlock() { let capturedAt = Date(timeIntervalSinceReferenceDate: 1000) var state = LockRuntimeState() _ = state.begin( @@ -309,9 +309,8 @@ final class LockRuntimeStateTests: XCTestCase { state.setAutoUnlockTargetDate(capturedAt.addingTimeInterval(50)) state.end() - XCTAssertEqual( - state.statusSnapshot(capturedAt: capturedAt), - LockStatusSnapshot( + #expect( + state.statusSnapshot(capturedAt: capturedAt) == LockStatusSnapshot( capturedAt: capturedAt, isLocked: false, startedAt: nil, diff --git a/Core/Tests/ServiceTests/LockStatusNotifierTests.swift b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift index 2a206fb..0eb4394 100644 --- a/Core/Tests/ServiceTests/LockStatusNotifierTests.swift +++ b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift @@ -1,32 +1,34 @@ import Common import Foundation @testable import Service -import XCTest +import Testing @MainActor -final class LockStatusNotifierTests: XCTestCase { - private var notifications: FakeLockStatusNotifications! - private var snapshot: LockStatusSnapshot! - private var unlockCalls = 0 +@Suite(.serialized) +final class LockStatusNotifierTests { + private let notifications: FakeLockStatusNotifications + private var snapshot: LockStatusSnapshot + private var unlockCalls: Int - override func setUp() { - super.setUp() + init() { notifications = FakeLockStatusNotifications() snapshot = Self.makeSnapshot(isLocked: false) unlockCalls = 0 } - func testStartInstallsCategoryAndClearsStaleNotification() async { + @Test + func startInstallsCategoryAndClearsStaleNotification() async { let notifier = makeNotifier() notifier.start() await notifier.waitUntilIdle() - XCTAssertEqual(notifications.installedCategoryCount, 1) - XCTAssertEqual(notifications.removedIdentifiers, [LockStatusNotifier.notificationIdentifier]) + #expect(notifications.installedCategoryCount == 1) + #expect(notifications.removedIdentifiers == [LockStatusNotifier.notificationIdentifier]) } - func testLockTransitionPostsNotificationWithHotkeyAndDeadline() async throws { + @Test + func lockTransitionPostsNotificationWithHotkeyAndDeadline() async throws { snapshot = Self.makeSnapshot( isLocked: true, autoUnlockTargetDate: Date(timeIntervalSinceReferenceDate: 1_060) @@ -36,42 +38,45 @@ final class LockStatusNotifierTests: XCTestCase { notifier.lockStateDidChange() await notifier.waitUntilIdle() - XCTAssertEqual(notifications.authorizationRequests, 1) - XCTAssertEqual(notifications.posts.count, 1) - let post = try XCTUnwrap(notifications.posts.first) - XCTAssertEqual(post.identifier, LockStatusNotifier.notificationIdentifier) - XCTAssertEqual(post.categoryIdentifier, LockStatusNotifier.categoryIdentifier) - XCTAssertEqual(post.title, "Keyboard Locked") - XCTAssertTrue(post.body.contains("⌃⌘L"), "body: \(post.body)") - XCTAssertTrue(post.body.contains("Unlock Now"), "body: \(post.body)") - XCTAssertTrue(post.body.contains("Auto-unlocks at"), "body: \(post.body)") - XCTAssertTrue(notifications.removedIdentifiers.isEmpty) + #expect(notifications.authorizationRequests == 1) + #expect(notifications.posts.count == 1) + let post = try #require(notifications.posts.first) + #expect(post.identifier == LockStatusNotifier.notificationIdentifier) + #expect(post.categoryIdentifier == LockStatusNotifier.categoryIdentifier) + #expect(post.title == "Keyboard Locked") + #expect(post.body.contains("⌃⌘L"), "body: \(post.body)") + #expect(post.body.contains("Unlock Now"), "body: \(post.body)") + #expect(post.body.contains("Auto-unlocks at"), "body: \(post.body)") + #expect(notifications.removedIdentifiers.isEmpty) } - func testLockTransitionWithoutDeadlineOmitsAutoUnlockLine() async throws { + @Test + func lockTransitionWithoutDeadlineOmitsAutoUnlockLine() async throws { snapshot = Self.makeSnapshot(isLocked: true, autoUnlockTargetDate: nil) let notifier = makeNotifier() notifier.lockStateDidChange() await notifier.waitUntilIdle() - let post = try XCTUnwrap(notifications.posts.first) - XCTAssertFalse(post.body.contains("Auto-unlocks"), "body: \(post.body)") + let post = try #require(notifications.posts.first) + #expect(!post.body.contains("Auto-unlocks"), "body: \(post.body)") } - func testUnlockTransitionRemovesNotificationWithoutRequestingAuthorization() async { + @Test + func unlockTransitionRemovesNotificationWithoutRequestingAuthorization() async { snapshot = Self.makeSnapshot(isLocked: false) let notifier = makeNotifier() notifier.lockStateDidChange() await notifier.waitUntilIdle() - XCTAssertEqual(notifications.removedIdentifiers, [LockStatusNotifier.notificationIdentifier]) - XCTAssertEqual(notifications.authorizationRequests, 0) - XCTAssertTrue(notifications.posts.isEmpty) + #expect(notifications.removedIdentifiers == [LockStatusNotifier.notificationIdentifier]) + #expect(notifications.authorizationRequests == 0) + #expect(notifications.posts.isEmpty) } - func testDeniedAuthorizationSkipsPostSilently() async { + @Test + func deniedAuthorizationSkipsPostSilently() async { notifications.authorizationGranted = false snapshot = Self.makeSnapshot(isLocked: true) let notifier = makeNotifier() @@ -79,31 +84,34 @@ final class LockStatusNotifierTests: XCTestCase { notifier.lockStateDidChange() await notifier.waitUntilIdle() - XCTAssertEqual(notifications.authorizationRequests, 1) - XCTAssertTrue(notifications.posts.isEmpty) + #expect(notifications.authorizationRequests == 1) + #expect(notifications.posts.isEmpty) } - func testUnlockActionRunsLocalUnlock() async { + @Test + func unlockActionRunsLocalUnlock() async { _ = makeNotifier() notifications.actionHandler?(LockStatusNotifier.unlockActionIdentifier) await waitForCondition { self.unlockCalls == 1 } - XCTAssertEqual(unlockCalls, 1) + #expect(unlockCalls == 1) } - func testUnrelatedActionIsIgnored() async { + @Test + func unrelatedActionIsIgnored() async { _ = makeNotifier() notifications.actionHandler?("some-other-action") await waitForCondition { false } - XCTAssertEqual(unlockCalls, 0) + #expect(unlockCalls == 0) } /// A rapid lock/unlock flip must serialize post before remove; the remove cannot overtake /// the in-flight post and leave a stale notification behind. - func testQuickFlipSerializesPostThenRemove() async { + @Test + func quickFlipSerializesPostThenRemove() async { let notifier = makeNotifier() snapshot = Self.makeSnapshot(isLocked: true) @@ -112,7 +120,7 @@ final class LockStatusNotifierTests: XCTestCase { notifier.lockStateDidChange() await notifier.waitUntilIdle() - XCTAssertEqual(notifications.events, [ + #expect(notifications.events == [ .post, .remove(LockStatusNotifier.notificationIdentifier), ]) diff --git a/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift b/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift index 423e269..f8b094e 100644 --- a/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift +++ b/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift @@ -2,16 +2,19 @@ import AppKit import CoreGraphics import IOKit @testable import Service -import XCTest +import Testing -final class LockedKeyboardEventPolicyTests: XCTestCase { - func testSuppressesStandardKeyboardEvents() { +@Suite(.serialized) +struct LockedKeyboardEventPolicyTests { + @Test + func suppressesStandardKeyboardEvents() { for type in [CGEventType.keyDown, .keyUp, .flagsChanged] { - XCTAssertTrue(LockedKeyboardEventPolicy.shouldSuppress(type: type)) + #expect(LockedKeyboardEventPolicy.shouldSuppress(type: type)) } } - func testSuppressesKeyboardSystemControls() { + @Test + func suppressesKeyboardSystemControls() { let subtypes = [ NX_SUBTYPE_AUX_CONTROL_BUTTONS, NX_SUBTYPE_EJECT_KEY, @@ -19,7 +22,7 @@ final class LockedKeyboardEventPolicyTests: XCTestCase { ] for subtype in subtypes { - XCTAssertTrue( + #expect( LockedKeyboardEventPolicy.shouldSuppress( type: LockedKeyboardEventPolicy.systemDefinedEventType, systemDefinedSubtype: Int16(subtype) @@ -28,8 +31,9 @@ final class LockedKeyboardEventPolicyTests: XCTestCase { } } + @Test @MainActor - func testReadsSystemDefinedSubtypeFromEventPayload() throws { + func readsSystemDefinedSubtypeFromEventPayload() throws { let keyboardControl = try makeSystemDefinedEvent( subtype: NX_SUBTYPE_AUX_CONTROL_BUTTONS ) @@ -37,71 +41,73 @@ final class LockedKeyboardEventPolicyTests: XCTestCase { subtype: NX_SUBTYPE_AUX_MOUSE_BUTTONS ) - XCTAssertEqual( - keyboardControl.type, - LockedKeyboardEventPolicy.systemDefinedEventType + #expect( + keyboardControl.type == LockedKeyboardEventPolicy.systemDefinedEventType ) - XCTAssertTrue( + #expect( LockedKeyboardEventPolicy.shouldSuppress( type: keyboardControl.type, event: keyboardControl ) ) - XCTAssertFalse( - LockedKeyboardEventPolicy.shouldSuppress( + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( type: auxiliaryMouseButton.type, event: auxiliaryMouseButton ) ) } - func testPreservesPointerAndUnclassifiedSystemEvents() { - XCTAssertFalse( - LockedKeyboardEventPolicy.shouldSuppress( + @Test + func preservesPointerAndUnclassifiedSystemEvents() { + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( type: LockedKeyboardEventPolicy.systemDefinedEventType, systemDefinedSubtype: Int16(NX_SUBTYPE_AUX_MOUSE_BUTTONS) ) ) - XCTAssertFalse( - LockedKeyboardEventPolicy.shouldSuppress( + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( type: LockedKeyboardEventPolicy.systemDefinedEventType, systemDefinedSubtype: Int16(NX_SUBTYPE_SLEEP_EVENT) ) ) - XCTAssertFalse( - LockedKeyboardEventPolicy.shouldSuppress( + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( type: LockedKeyboardEventPolicy.systemDefinedEventType, systemDefinedSubtype: nil ) ) - XCTAssertFalse( - LockedKeyboardEventPolicy.shouldSuppress( + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( type: LockedKeyboardEventPolicy.systemDefinedEventType, systemDefinedSubtype: .max ) ) } - func testPreservesPointerEventTypes() { + @Test + func preservesPointerEventTypes() { for type in [ CGEventType.leftMouseDown, .otherMouseDown, .rightMouseDown, .scrollWheel, ] { - XCTAssertFalse(LockedKeyboardEventPolicy.shouldSuppress(type: type)) + #expect(!LockedKeyboardEventPolicy.shouldSuppress(type: type)) } } + @Test @MainActor - func testEventTapObservesEverySuppressibleEventClassWithoutObservingPointers() { + func eventTapObservesEverySuppressibleEventClassWithoutObservingPointers() { for type in [ CGEventType.keyDown, .keyUp, .flagsChanged, LockedKeyboardEventPolicy.systemDefinedEventType, ] { - XCTAssertTrue(eventMaskContains(type)) + #expect(eventMaskContains(type)) } for type in [ @@ -110,7 +116,7 @@ final class LockedKeyboardEventPolicyTests: XCTestCase { .rightMouseDown, .scrollWheel, ] { - XCTAssertFalse(eventMaskContains(type)) + #expect(!eventMaskContains(type)) } } @@ -121,7 +127,7 @@ final class LockedKeyboardEventPolicyTests: XCTestCase { @MainActor private func makeSystemDefinedEvent(subtype: Int32) throws -> CGEvent { - let event = try XCTUnwrap( + let event = try #require( NSEvent.otherEvent( with: .systemDefined, location: .zero, @@ -134,6 +140,6 @@ final class LockedKeyboardEventPolicyTests: XCTestCase { data2: 0 ) ) - return try XCTUnwrap(event.cgEvent) + return try #require(event.cgEvent) } } diff --git a/Core/Tests/ServiceTests/ReplacementTransactionTests.swift b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift index 87c8fd2..76325db 100644 --- a/Core/Tests/ServiceTests/ReplacementTransactionTests.swift +++ b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift @@ -1,83 +1,94 @@ import Common +import Foundation @testable import Service -import XCTest +import Testing -final class ReplacementTransactionTests: XCTestCase { - func testPrepareIsExclusive() throws { +@Suite(.serialized) +struct ReplacementTransactionTests { + @Test + func prepareIsExclusive() throws { var transaction = ReplacementTransaction() let first = makeTicket() _ = try transaction.prepare(ticket: first) - XCTAssertTrue(transaction.isPending) - XCTAssertThrowsError(try transaction.prepare(ticket: makeTicket())) { error in - XCTAssertEqual(error as? ReplacementTransactionError, .alreadyInProgress) + #expect(transaction.isPending) + #expect(throws: ReplacementTransactionError.alreadyInProgress) { + try transaction.prepare(ticket: makeTicket()) } } - func testPreparedTransactionCanBeCancelledOnlyByItsOwner() throws { + @Test + func preparedTransactionCanBeCancelledOnlyByItsOwner() throws { var transaction = ReplacementTransaction() let owner = makeTicket() _ = try transaction.prepare(ticket: owner) - XCTAssertThrowsError(try transaction.cancel(ticket: makeTicket())) { error in - XCTAssertEqual(error as? ReplacementTransactionError, .ticketInactive) + #expect(throws: ReplacementTransactionError.ticketInactive) { + try transaction.cancel(ticket: makeTicket()) } try transaction.cancel(ticket: owner) - XCTAssertFalse(transaction.isPending) + #expect(!transaction.isPending) } - func testPreparationExpires() throws { + @Test + func preparationExpires() throws { var transaction = ReplacementTransaction() let preparation = try transaction.prepare(ticket: makeTicket()) - XCTAssertEqual(transaction.status(for: preparation.ticket).phase, .prepared) - XCTAssertEqual(transaction.status(for: makeTicket()).phase, .inactive) - XCTAssertTrue(transaction.expire(preparation: preparation)) - XCTAssertEqual(transaction.status(for: preparation.ticket).phase, .inactive) - XCTAssertFalse(transaction.isPending) + #expect(transaction.status(for: preparation.ticket).phase == .prepared) + #expect(transaction.status(for: makeTicket()).phase == .inactive) + let didExpire = transaction.expire(preparation: preparation) + #expect(didExpire) + #expect(transaction.status(for: preparation.ticket).phase == .inactive) + #expect(!transaction.isPending) } - func testStaleExpirationCannotClearNewPreparation() throws { + @Test + func staleExpirationCannotClearNewPreparation() throws { var transaction = ReplacementTransaction() let first = try transaction.prepare(ticket: makeTicket()) try transaction.cancel(ticket: first.ticket) let second = try transaction.prepare(ticket: makeTicket()) - XCTAssertFalse(transaction.expire(preparation: first)) - XCTAssertTrue(transaction.isPending) - XCTAssertTrue(transaction.expire(preparation: second)) + let didExpireFirst = transaction.expire(preparation: first) + #expect(!didExpireFirst) + #expect(transaction.isPending) + let didExpireSecond = transaction.expire(preparation: second) + #expect(didExpireSecond) } - func testCommitIsIdempotentAndNonExpiring() throws { + @Test + func commitIsIdempotentAndNonExpiring() throws { var transaction = ReplacementTransaction() let preparation = try transaction.prepare(ticket: makeTicket()) try transaction.commit(ticket: preparation.ticket) try transaction.commit(ticket: preparation.ticket) - XCTAssertTrue(transaction.isPending) - XCTAssertTrue(transaction.isCommitted) - XCTAssertEqual( - transaction.status(for: preparation.ticket).phase, - .committed + #expect(transaction.isPending) + #expect(transaction.isCommitted) + #expect( + transaction.status(for: preparation.ticket).phase == .committed ) - XCTAssertFalse(transaction.expire(preparation: preparation)) - XCTAssertThrowsError(try transaction.cancel(ticket: preparation.ticket)) { error in - XCTAssertEqual(error as? ReplacementTransactionError, .committedCannotCancel) + let didExpire = transaction.expire(preparation: preparation) + #expect(!didExpire) + #expect(throws: ReplacementTransactionError.committedCannotCancel) { + try transaction.cancel(ticket: preparation.ticket) } - XCTAssertTrue(transaction.isPending) + #expect(transaction.isPending) } - func testWrongTicketCannotCommit() throws { + @Test + func wrongTicketCannotCommit() throws { var transaction = ReplacementTransaction() _ = try transaction.prepare(ticket: makeTicket()) - XCTAssertThrowsError(try transaction.commit(ticket: makeTicket())) { error in - XCTAssertEqual(error as? ReplacementTransactionError, .ticketInactive) + #expect(throws: ReplacementTransactionError.ticketInactive) { + try transaction.commit(ticket: makeTicket()) } - XCTAssertFalse(transaction.isCommitted) + #expect(!transaction.isCommitted) } private func makeTicket() -> ServiceReplacementTicket { diff --git a/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift b/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift index 27e5fca..f6761f0 100644 --- a/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift +++ b/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift @@ -1,16 +1,18 @@ import Common import CoreGraphics @testable import Service -import XCTest +import Testing -final class UnlockGestureMatcherTests: XCTestCase { +@Suite(.serialized) +struct UnlockGestureMatcherTests { private let configuredHotkey = KeyboardLockerSettings.Hotkey( keyCode: 37, modifierFlags: [.maskControl, .maskCommand] ) - func testConfiguredHotkeyAlwaysUnlocks() { - XCTAssertTrue( + @Test + func configuredHotkeyAlwaysUnlocks() { + #expect( matches( keyCode: configuredHotkey.keyCode, flags: configuredHotkey.modifierFlags, @@ -19,8 +21,9 @@ final class UnlockGestureMatcherTests: XCTestCase { ) } - func testControlCUnlocksInteractiveLock() { - XCTAssertTrue( + @Test + func controlCUnlocksInteractiveLock() { + #expect( matches( keyCode: UnlockGestureMatcher.controlCKeyCode, flags: [.maskControl], @@ -29,9 +32,10 @@ final class UnlockGestureMatcherTests: XCTestCase { ) } - func testControlCDoesNotUnlockNonInteractiveLock() { - XCTAssertFalse( - matches( + @Test + func controlCDoesNotUnlockNonInteractiveLock() { + #expect( + !matches( keyCode: UnlockGestureMatcher.controlCKeyCode, flags: [.maskControl], allowsControlCUnlock: false @@ -39,9 +43,10 @@ final class UnlockGestureMatcherTests: XCTestCase { ) } - func testControlCRejectsAdditionalRelevantModifiers() { - XCTAssertFalse( - matches( + @Test + func controlCRejectsAdditionalRelevantModifiers() { + #expect( + !matches( keyCode: UnlockGestureMatcher.controlCKeyCode, flags: [.maskControl, .maskCommand], allowsControlCUnlock: true @@ -49,8 +54,9 @@ final class UnlockGestureMatcherTests: XCTestCase { ) } - func testControlCIgnoresCapsLock() { - XCTAssertTrue( + @Test + func controlCIgnoresCapsLock() { + #expect( matches( keyCode: UnlockGestureMatcher.controlCKeyCode, flags: [.maskControl, .maskAlphaShift], @@ -59,17 +65,18 @@ final class UnlockGestureMatcherTests: XCTestCase { ) } - func testAutoRepeatAndKeyUpNeverUnlock() { - XCTAssertFalse( - matches( + @Test + func autoRepeatAndKeyUpNeverUnlock() { + #expect( + !matches( keyCode: UnlockGestureMatcher.controlCKeyCode, flags: [.maskControl], allowsControlCUnlock: true, isAutoRepeat: true ) ) - XCTAssertFalse( - matches( + #expect( + !matches( type: .keyUp, keyCode: UnlockGestureMatcher.controlCKeyCode, flags: [.maskControl], diff --git a/Core/Tests/ServiceTests/XPCAccessControlTests.swift b/Core/Tests/ServiceTests/XPCAccessControlTests.swift index 2074c4e..624f42e 100644 --- a/Core/Tests/ServiceTests/XPCAccessControlTests.swift +++ b/Core/Tests/ServiceTests/XPCAccessControlTests.swift @@ -1,12 +1,14 @@ @testable import Common import Security import Service -import XCTest +import Testing /// The listener's client requirement must fail closed on any host that cannot prove our /// Team ID, and must name every authorized client whenever construction is possible. -final class XPCAccessControlTests: XCTestCase { - func testAuthorizedClientRequirementFailsClosedWithoutAppleTeamSignature() { +@Suite(.serialized) +struct XPCAccessControlTests { + @Test + func authorizedClientRequirementFailsClosedWithoutAppleTeamSignature() { do { let requirement = try XPCAccessControl.authorizedClientRequirement() @@ -15,27 +17,27 @@ final class XPCAccessControlTests: XCTestCase { // acceptable because each proves a distinct half of the contract — a signed host builds // the exact requirement, an unsigned host can build none. for identifier in SharedConstants.authorizedClientBundleIdentifiers { - XCTAssertTrue(requirement.contains(#"identifier "\#(identifier)""#)) + #expect(requirement.contains(#"identifier "\#(identifier)""#)) } var compiledRequirement: SecRequirement? - XCTAssertEqual( + #expect( SecRequirementCreateWithString( requirement as CFString, SecCSFlags(), &compiledRequirement - ), - errSecSuccess + ) == errSecSuccess ) - XCTAssertNotNil(compiledRequirement) + #expect(compiledRequirement != nil) } catch let error as XPCCodeSigningRequirementError { // `swift test` binaries are ad-hoc signed, so the process has no Apple Team ID and the // requirement must fail closed rather than emit something an unauthorized client could // satisfy. guard case .missingTeamIdentifier = error else { - return XCTFail("Expected missingTeamIdentifier, got \(error)") + Issue.record("Expected missingTeamIdentifier, got \(error)") + return } } catch { - XCTFail("Unexpected error: \(error)") + Issue.record(error, "Unexpected error") } } } diff --git a/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift b/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift index 0f22565..0b851c4 100644 --- a/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift +++ b/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift @@ -1,20 +1,21 @@ import Foundation @testable import SystemSurfaces -import XCTest - -final class LockStateSurfaceInvalidatorTests: XCTestCase { - func testSurfaceKindsRemainStable() { - XCTAssertEqual( - KeyboardLockerSurfaceKind.statusWidget, - "io.lzhlovesjyq.keyboardlocker.status" +import Testing + +@Suite(.serialized) +struct LockStateSurfaceInvalidatorTests { + @Test + func surfaceKindsRemainStable() { + #expect( + KeyboardLockerSurfaceKind.statusWidget == "io.lzhlovesjyq.keyboardlocker.status" ) - XCTAssertEqual( - KeyboardLockerSurfaceKind.keyboardLockControl, - "io.lzhlovesjyq.keyboardlocker.control" + #expect( + KeyboardLockerSurfaceKind.keyboardLockControl == "io.lzhlovesjyq.keyboardlocker.control" ) } - func testInvalidationReloadsWidgetThenControlExactlyOnce() { + @Test + func invalidationReloadsWidgetThenControlExactlyOnce() { let recorder = CallRecorder() let invalidator = LockStateSurfaceInvalidator( reloadWidget: { @@ -27,7 +28,7 @@ final class LockStateSurfaceInvalidatorTests: XCTestCase { invalidator.invalidate() - XCTAssertEqual(recorder.calls, [.widget, .control]) + #expect(recorder.calls == [.widget, .control]) } } diff --git a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift index d2d381e..f28ec0f 100644 --- a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift +++ b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift @@ -1,31 +1,35 @@ import Client import Foundation import SystemSurfaces -import XCTest +import Testing -final class AgentCoordinationServicesTests: XCTestCase { +@Suite(.serialized) +struct AgentCoordinationServicesTests { + @Test @MainActor - func testSuccessfulLockInvalidatesWidgetThenControl() async throws { + func successfulLockInvalidatesWidgetThenControl() async throws { let recorder = CallRecorder() let client = makeClient(recorder: recorder) try await client.lock() - XCTAssertEqual(recorder.calls, [.lock, .widget, .control]) + #expect(recorder.calls == [.lock, .widget, .control]) } + @Test @MainActor - func testSuccessfulUnlockInvalidatesWidgetThenControl() async throws { + func successfulUnlockInvalidatesWidgetThenControl() async throws { let recorder = CallRecorder() let client = makeClient(recorder: recorder) try await client.unlock() - XCTAssertEqual(recorder.calls, [.unlock, .widget, .control]) + #expect(recorder.calls == [.unlock, .widget, .control]) } + @Test @MainActor - func testFailedMutationDoesNotInvalidateSurfaces() async { + func failedMutationDoesNotInvalidateSurfaces() async { let recorder = CallRecorder() let client = LiveAgentClient( lock: { @@ -48,16 +52,17 @@ final class AgentCoordinationServicesTests: XCTestCase { do { try await client.lock() - XCTFail("Expected the Agent failure to propagate.") + Issue.record("Expected the Agent failure to propagate.") } catch { - XCTAssertEqual(error as? TestError, .expected) + #expect(error as? TestError == .expected) } - XCTAssertEqual(recorder.calls, [.lock]) + #expect(recorder.calls == [.lock]) } + @Test @MainActor - func testFailedUnlockDoesNotInvalidateSurfaces() async { + func failedUnlockDoesNotInvalidateSurfaces() async { let recorder = CallRecorder() let client = LiveAgentClient( lock: { @@ -80,49 +85,53 @@ final class AgentCoordinationServicesTests: XCTestCase { do { try await client.unlock() - XCTFail("Expected the Agent failure to propagate.") + Issue.record("Expected the Agent failure to propagate.") } catch { - XCTAssertEqual(error as? TestError, .expected) + #expect(error as? TestError == .expected) } - XCTAssertEqual(recorder.calls, [.unlock]) + #expect(recorder.calls == [.unlock]) } + @Test @MainActor - func testStatusDoesNotInvalidateSurfaces() async throws { + func statusDoesNotInvalidateSurfaces() async throws { let recorder = CallRecorder() let client = makeClient(recorder: recorder, status: true) let isLocked = try await client.status() - XCTAssertTrue(isLocked) - XCTAssertEqual(recorder.calls, [.status]) + #expect(isLocked) + #expect(recorder.calls == [.status]) } + @Test @MainActor - func testSuccessfulToggleInvalidatesWidgetThenControl() async throws { + func successfulToggleInvalidatesWidgetThenControl() async throws { let recorder = CallRecorder() let client = makeClient(recorder: recorder) let isLocked = try await client.toggle() - XCTAssertTrue(isLocked) - XCTAssertEqual(recorder.calls, [.toggle, .widget, .control]) + #expect(isLocked) + #expect(recorder.calls == [.toggle, .widget, .control]) } + @Test @MainActor - func testSuccessfulSafetyCheckInvalidatesWidgetThenControl() async throws { + func successfulSafetyCheckInvalidatesWidgetThenControl() async throws { let recorder = CallRecorder() let client = makeClient(recorder: recorder) let outcome = try await client.beginSafetyCheck() - XCTAssertEqual(outcome, .acquired) - XCTAssertEqual(recorder.calls, [.safetyCheck, .widget, .control]) + #expect(outcome == .acquired) + #expect(recorder.calls == [.safetyCheck, .widget, .control]) } + @Test @MainActor - func testFailedToggleDoesNotInvalidateSurfaces() async { + func failedToggleDoesNotInvalidateSurfaces() async { let recorder = CallRecorder() let client = LiveAgentClient( lock: { @@ -144,16 +153,17 @@ final class AgentCoordinationServicesTests: XCTestCase { do { _ = try await client.toggle() - XCTFail("Expected the Agent failure to propagate.") + Issue.record("Expected the Agent failure to propagate.") } catch { - XCTAssertEqual(error as? TestError, .expected) + #expect(error as? TestError == .expected) } - XCTAssertEqual(recorder.calls, [.toggle]) + #expect(recorder.calls == [.toggle]) } + @Test @MainActor - func testLiveObserverInvalidatesAtSubscriptionAndBeforeForwardingAChange() { + func liveObserverInvalidatesAtSubscriptionAndBeforeForwardingAChange() { let recorder = CallRecorder() let base = RecordingLockStateObserver() let observer = LiveAgentLockStateObserver( @@ -166,13 +176,13 @@ final class AgentCoordinationServicesTests: XCTestCase { receivedValues.append(isLocked) } - XCTAssertEqual(base.initialStates, [false]) - XCTAssertEqual(recorder.calls, [.widget, .control]) + #expect(base.initialStates == [false]) + #expect(recorder.calls == [.widget, .control]) base.send(true) - XCTAssertEqual(receivedValues, [true]) - XCTAssertEqual(recorder.calls, [.widget, .control, .widget, .control]) + #expect(receivedValues == [true]) + #expect(recorder.calls == [.widget, .control, .widget, .control]) withExtendedLifetime(token) {} } diff --git a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift index cbbf413..aed277a 100644 --- a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift @@ -1,10 +1,12 @@ import Client import Foundation -import XCTest +import Testing -final class AgentReadinessCoordinatorTests: XCTestCase { +@Suite(.serialized) +struct AgentReadinessCoordinatorTests { @MainActor - func testNonEnabledRegistrationSkipsXPCAndReturnsRegistration() async { + @Test + func nonEnabledRegistrationSkipsXPCAndReturnsRegistration() async { let client = FakeAgentReadinessClient( descriptorResults: [.failure(TestError.handshake)] ) @@ -16,18 +18,19 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .registration(state) = outcome else { - XCTFail("Expected .registration, got \(outcome).") + Issue.record("Expected .registration, got \(outcome).") return } - XCTAssertEqual(state, .approvalRequired) - XCTAssertEqual(client.descriptorCallCount, 0) - XCTAssertEqual(client.statusCallCount, 0) - XCTAssertEqual(client.resetConnectionCallCount, 0) - XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) + #expect(state == .approvalRequired) + #expect(client.descriptorCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.resetConnectionCallCount == 0) + #expect(lifecycle.checkedDescriptors.isEmpty) } @MainActor - func testHandshakeFailureResetsConnectionOnceAndRetries() async { + @Test + func handshakeFailureResetsConnectionOnceAndRetries() async { let descriptor = makeDescriptor() let client = FakeAgentReadinessClient( descriptorResults: [.failure(TestError.handshake), .success(descriptor)], @@ -40,21 +43,19 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .ready(isLocked, hasAccessibilityPermission) = outcome else { - XCTFail("Expected .ready, got \(outcome).") + Issue.record("Expected .ready, got \(outcome).") return } - XCTAssertTrue(isLocked) - XCTAssertTrue(hasAccessibilityPermission) - XCTAssertEqual(client.descriptorCallCount, 2) - XCTAssertEqual(client.resetConnectionCallCount, 1) - XCTAssertEqual( - Array(client.events.prefix(3)), - [.serviceDescriptor, .resetConnection, .serviceDescriptor] - ) + #expect(isLocked) + #expect(hasAccessibilityPermission) + #expect(client.descriptorCallCount == 2) + #expect(client.resetConnectionCallCount == 1) + #expect(Array(client.events.prefix(3)) == [.serviceDescriptor, .resetConnection, .serviceDescriptor]) } @MainActor - func testDoubleHandshakeFailureFallsBackToForcedUpdateWithStatusLockState() async { + @Test + func doubleHandshakeFailureFallsBackToForcedUpdateWithStatusLockState() async { let client = FakeAgentReadinessClient( descriptorResults: [ .failure(TestError.handshake), @@ -68,24 +69,25 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .updateRequired(plan) = outcome else { - XCTFail("Expected .updateRequired, got \(outcome).") + Issue.record("Expected .updateRequired, got \(outcome).") return } guard case let .forced(descriptor, isLocked) = plan.mode else { - XCTFail("Expected a forced plan, got \(plan.mode).") + Issue.record("Expected a forced plan, got \(plan.mode).") return } - XCTAssertNil(descriptor) - XCTAssertEqual(isLocked, true) - XCTAssertTrue(plan.message.contains("could not be verified")) - XCTAssertEqual(client.descriptorCallCount, 2) - XCTAssertEqual(client.statusCallCount, 1) - XCTAssertEqual(client.resetConnectionCallCount, 2) - XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) + #expect(descriptor == nil) + #expect(isLocked == true) + #expect(plan.message.contains("could not be verified")) + #expect(client.descriptorCallCount == 2) + #expect(client.statusCallCount == 1) + #expect(client.resetConnectionCallCount == 2) + #expect(lifecycle.checkedDescriptors.isEmpty) } @MainActor - func testDoubleHandshakeFailureWithUnreadableStatusReportsHandshakeContext() async { + @Test + func doubleHandshakeFailureWithUnreadableStatusReportsHandshakeContext() async { let client = FakeAgentReadinessClient( descriptorResults: [ .failure(TestError.handshake), @@ -99,15 +101,16 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .failure(error, context) = outcome else { - XCTFail("Expected .failure, got \(outcome).") + Issue.record("Expected .failure, got \(outcome).") return } - XCTAssertEqual(error as? TestError, .status) - XCTAssertTrue(context?.contains("Descriptor handshake failed") == true) + #expect(error as? TestError == .status) + #expect(context?.contains("Descriptor handshake failed") == true) } @MainActor - func testCompatibleDescriptorReturnsReadySnapshot() async { + @Test + func compatibleDescriptorReturnsReadySnapshot() async { let descriptor = makeDescriptor() let client = FakeAgentReadinessClient( descriptorResults: [.success(descriptor)], @@ -120,18 +123,19 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .ready(isLocked, hasAccessibilityPermission) = outcome else { - XCTFail("Expected .ready, got \(outcome).") + Issue.record("Expected .ready, got \(outcome).") return } - XCTAssertFalse(isLocked) - XCTAssertTrue(hasAccessibilityPermission) - XCTAssertEqual(client.descriptorCallCount, 1) - XCTAssertEqual(client.resetConnectionCallCount, 0) - XCTAssertEqual(lifecycle.checkedDescriptors, [descriptor]) + #expect(!isLocked) + #expect(hasAccessibilityPermission) + #expect(client.descriptorCallCount == 1) + #expect(client.resetConnectionCallCount == 0) + #expect(lifecycle.checkedDescriptors == [descriptor]) } @MainActor - func testMatchingPreviousInstanceIDReportsAgentDidNotRestart() async { + @Test + func matchingPreviousInstanceIDReportsAgentDidNotRestart() async { let instanceID = UUID() let client = FakeAgentReadinessClient( descriptorResults: [.success(makeDescriptor(agentInstanceID: instanceID))] @@ -144,15 +148,16 @@ final class AgentReadinessCoordinatorTests: XCTestCase { ) guard case .agentDidNotRestart = outcome else { - XCTFail("Expected .agentDidNotRestart, got \(outcome).") + Issue.record("Expected .agentDidNotRestart, got \(outcome).") return } - XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) - XCTAssertEqual(client.statusCallCount, 0) + #expect(lifecycle.checkedDescriptors.isEmpty) + #expect(client.statusCallCount == 0) } @MainActor - func testBundledUpgradeAvailableCarriesDescriptorMessageBuildAndLockState() async { + @Test + func bundledUpgradeAvailableCarriesDescriptorMessageBuildAndLockState() async { let descriptor = makeDescriptor() let client = FakeAgentReadinessClient( descriptorResults: [.success(descriptor)], @@ -174,18 +179,19 @@ final class AgentReadinessCoordinatorTests: XCTestCase { bundledBuild, isLocked ) = outcome else { - XCTFail("Expected .updateAvailable, got \(outcome).") + Issue.record("Expected .updateAvailable, got \(outcome).") return } - XCTAssertEqual(reportedDescriptor, descriptor) - XCTAssertEqual(message, "Upgrade available.") - XCTAssertEqual(bundledBuild, "99") - XCTAssertTrue(isLocked) - XCTAssertEqual(client.statusCallCount, 1) + #expect(reportedDescriptor == descriptor) + #expect(message == "Upgrade available.") + #expect(bundledBuild == "99") + #expect(isLocked) + #expect(client.statusCallCount == 1) } @MainActor - func testSafeReplacementWithoutReadableLockStateFails() async { + @Test + func safeReplacementWithoutReadableLockStateFails() async { let client = FakeAgentReadinessClient( descriptorResults: [.success(makeDescriptor())] ) @@ -201,19 +207,17 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .failure(error, context) = outcome else { - XCTFail("Expected .failure, got \(outcome).") + Issue.record("Expected .failure, got \(outcome).") return } - XCTAssertEqual( - error.localizedDescription, - "Safe KeyboardLocker agent replacement requires a readable authoritative lock state." - ) - XCTAssertNil(context) - XCTAssertEqual(client.statusCallCount, 0) + #expect(error.localizedDescription == "Safe KeyboardLocker agent replacement requires a readable authoritative lock state.") + #expect(context == nil) + #expect(client.statusCallCount == 0) } @MainActor - func testUpdateRequiredWithoutLockStateOrSafeSupportForcesUpdate() async { + @Test + func updateRequiredWithoutLockStateOrSafeSupportForcesUpdate() async { let descriptor = makeDescriptor() let client = FakeAgentReadinessClient( descriptorResults: [.success(descriptor)] @@ -230,21 +234,19 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .updateRequired(plan) = outcome else { - XCTFail("Expected .updateRequired, got \(outcome).") + Issue.record("Expected .updateRequired, got \(outcome).") return } - XCTAssertEqual( - plan, - AgentUpdatePlan( - mode: .forced(descriptor: descriptor, isLocked: nil), - message: "Update required." - ) - ) - XCTAssertEqual(client.statusCallCount, 0) + #expect(plan == AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: nil), + message: "Update required." + )) + #expect(client.statusCallCount == 0) } @MainActor - func testUpdateRequiredWithLockStateAndSafeSupportChoosesSafeMode() async { + @Test + func updateRequiredWithLockStateAndSafeSupportChoosesSafeMode() async { let descriptor = makeDescriptor() let client = FakeAgentReadinessClient( descriptorResults: [.success(descriptor)], @@ -262,21 +264,19 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .updateRequired(plan) = outcome else { - XCTFail("Expected .updateRequired, got \(outcome).") + Issue.record("Expected .updateRequired, got \(outcome).") return } - XCTAssertEqual( - plan, - AgentUpdatePlan( - mode: .safe(descriptor: descriptor, isLocked: true), - message: "Update required." - ) - ) - XCTAssertEqual(client.statusCallCount, 1) + #expect(plan == AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + )) + #expect(client.statusCallCount == 1) } @MainActor - func testUpdateRequiredWithLockStateWithoutSafeSupportChoosesForcedMode() async { + @Test + func updateRequiredWithLockStateWithoutSafeSupportChoosesForcedMode() async { let descriptor = makeDescriptor() let client = FakeAgentReadinessClient( descriptorResults: [.success(descriptor)], @@ -294,21 +294,19 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .updateRequired(plan) = outcome else { - XCTFail("Expected .updateRequired, got \(outcome).") + Issue.record("Expected .updateRequired, got \(outcome).") return } - XCTAssertEqual( - plan, - AgentUpdatePlan( - mode: .forced(descriptor: descriptor, isLocked: false), - message: "Update required." - ) - ) - XCTAssertEqual(client.statusCallCount, 1) + #expect(plan == AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: false), + message: "Update required." + )) + #expect(client.statusCallCount == 1) } @MainActor - func testTrustedReplacementInProgressShortCircuitsCompatibilityOutcome() async { + @Test + func trustedReplacementInProgressShortCircuitsCompatibilityOutcome() async { let descriptor = makeDescriptor(replacementPending: true) let client = FakeAgentReadinessClient( descriptorResults: [.success(descriptor)] @@ -319,15 +317,16 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .replacementInProgress(reported) = outcome else { - XCTFail("Expected .replacementInProgress, got \(outcome).") + Issue.record("Expected .replacementInProgress, got \(outcome).") return } - XCTAssertEqual(reported, descriptor) - XCTAssertEqual(client.statusCallCount, 0) + #expect(reported == descriptor) + #expect(client.statusCallCount == 0) } @MainActor - func testReplacementPendingWithWrongBundleIdentifierFallsThroughToCompatibility() async { + @Test + func replacementPendingWithWrongBundleIdentifierFallsThroughToCompatibility() async { let descriptor = makeDescriptor( agentBundleIdentifier: "com.example.unrelated.agent", replacementPending: true @@ -341,14 +340,15 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case .ready = outcome else { - XCTFail("Expected .ready, got \(outcome).") + Issue.record("Expected .ready, got \(outcome).") return } - XCTAssertEqual(client.statusCallCount, 1) + #expect(client.statusCallCount == 1) } @MainActor - func testReplacementPendingWithoutDrainCapabilityFallsThroughToCompatibility() async { + @Test + func replacementPendingWithoutDrainCapabilityFallsThroughToCompatibility() async { var capabilities = ServiceContract.requiredCapabilities capabilities.remove(.committedReplacementDrain) let descriptor = makeDescriptor( @@ -364,14 +364,15 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case .ready = outcome else { - XCTFail("Expected .ready, got \(outcome).") + Issue.record("Expected .ready, got \(outcome).") return } - XCTAssertEqual(client.statusCallCount, 1) + #expect(client.statusCallCount == 1) } @MainActor - func testReplacementPendingWithInvalidBundleCompatibilityFallsThrough() async { + @Test + func replacementPendingWithInvalidBundleCompatibilityFallsThrough() async { let descriptor = makeDescriptor(replacementPending: true) let client = FakeAgentReadinessClient( descriptorResults: [.success(descriptor)] @@ -384,15 +385,16 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await coordinator.inspect() guard case let .invalidBundle(failure) = outcome else { - XCTFail("Expected .invalidBundle, got \(outcome).") + Issue.record("Expected .invalidBundle, got \(outcome).") return } - XCTAssertEqual(failure, .invalidBundle("Corrupt bundle.")) - XCTAssertEqual(client.statusCallCount, 0) + #expect(failure == .invalidBundle("Corrupt bundle.")) + #expect(client.statusCallCount == 0) } @MainActor - func testCancellationAfterEnsureEnabledReturnsCancelled() async { + @Test + func cancellationAfterEnsureEnabledReturnsCancelled() async { let client = FakeAgentReadinessClient( descriptorResults: [.success(makeDescriptor())] ) @@ -404,17 +406,16 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let outcome = await inspection.value guard case .cancelled = outcome else { - XCTFail("Expected .cancelled, got \(outcome).") + Issue.record("Expected .cancelled, got \(outcome).") return } - XCTAssertEqual(client.descriptorCallCount, 0) + #expect(client.descriptorCallCount == 0) } @MainActor - func testCancellationDuringInitialDescriptorFetchReturnsCancelled() async { + @Test + func cancellationDuringInitialDescriptorFetchReturnsCancelled() async { let descriptorGate = ReadinessTestGate() - let descriptorStarted = expectation(description: "Descriptor fetch started.") - descriptorGate.onEntered = { descriptorStarted.fulfill() } let client = FakeAgentReadinessClient( descriptorResults: [.success(makeDescriptor())] ) @@ -423,24 +424,23 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) let inspection = Task { await coordinator.inspect() } - await fulfillment(of: [descriptorStarted], timeout: 5) + await descriptorGate.waitUntilEntered() inspection.cancel() descriptorGate.open() let outcome = await inspection.value guard case .cancelled = outcome else { - XCTFail("Expected .cancelled, got \(outcome).") + Issue.record("Expected .cancelled, got \(outcome).") return } - XCTAssertEqual(client.descriptorCallCount, 1) - XCTAssertTrue(lifecycle.checkedDescriptors.isEmpty) + #expect(client.descriptorCallCount == 1) + #expect(lifecycle.checkedDescriptors.isEmpty) } @MainActor - func testCancellationAfterDescriptorFailureSkipsRetryAndReturnsCancelled() async { + @Test + func cancellationAfterDescriptorFailureSkipsRetryAndReturnsCancelled() async { let descriptorGate = ReadinessTestGate() - let descriptorStarted = expectation(description: "Descriptor fetch started.") - descriptorGate.onEntered = { descriptorStarted.fulfill() } let client = FakeAgentReadinessClient( descriptorResults: [.failure(TestError.handshake)] ) @@ -449,24 +449,23 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) let inspection = Task { await coordinator.inspect() } - await fulfillment(of: [descriptorStarted], timeout: 5) + await descriptorGate.waitUntilEntered() inspection.cancel() descriptorGate.open() let outcome = await inspection.value guard case .cancelled = outcome else { - XCTFail("Expected .cancelled, got \(outcome).") + Issue.record("Expected .cancelled, got \(outcome).") return } - XCTAssertEqual(client.descriptorCallCount, 1) - XCTAssertEqual(client.resetConnectionCallCount, 0) + #expect(client.descriptorCallCount == 1) + #expect(client.resetConnectionCallCount == 0) } @MainActor - func testCancellationDuringCompatibleStatusFetchReturnsCancelled() async { + @Test + func cancellationDuringCompatibleStatusFetchReturnsCancelled() async { let statusGate = ReadinessTestGate() - let statusStarted = expectation(description: "Status fetch started.") - statusGate.onEntered = { statusStarted.fulfill() } let client = FakeAgentReadinessClient( descriptorResults: [.success(makeDescriptor())], statusResult: .success(true) @@ -476,23 +475,22 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) let inspection = Task { await coordinator.inspect() } - await fulfillment(of: [statusStarted], timeout: 5) + await statusGate.waitUntilEntered() inspection.cancel() statusGate.open() let outcome = await inspection.value guard case .cancelled = outcome else { - XCTFail("Expected .cancelled, got \(outcome).") + Issue.record("Expected .cancelled, got \(outcome).") return } - XCTAssertEqual(client.statusCallCount, 1) + #expect(client.statusCallCount == 1) } @MainActor - func testCancellationDuringUnverifiedAgentStatusFetchReturnsCancelled() async { + @Test + func cancellationDuringUnverifiedAgentStatusFetchReturnsCancelled() async { let statusGate = ReadinessTestGate() - let statusStarted = expectation(description: "Status fetch started.") - statusGate.onEntered = { statusStarted.fulfill() } let client = FakeAgentReadinessClient( descriptorResults: [ .failure(TestError.handshake), @@ -505,17 +503,17 @@ final class AgentReadinessCoordinatorTests: XCTestCase { let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) let inspection = Task { await coordinator.inspect() } - await fulfillment(of: [statusStarted], timeout: 5) + await statusGate.waitUntilEntered() inspection.cancel() statusGate.open() let outcome = await inspection.value guard case .cancelled = outcome else { - XCTFail("Expected .cancelled, got \(outcome).") + Issue.record("Expected .cancelled, got \(outcome).") return } - XCTAssertEqual(client.descriptorCallCount, 2) - XCTAssertEqual(client.statusCallCount, 1) + #expect(client.descriptorCallCount == 2) + #expect(client.statusCallCount == 1) } } @@ -615,13 +613,16 @@ private final class FakeAgentReadinessLifecycle: AgentReadinessLifecycleServing /// the coordinator is suspended at a specific call. @MainActor private final class ReadinessTestGate { + private var entryWaiters: [CheckedContinuation] = [] + private var hasEntered = false private var waiters: [CheckedContinuation] = [] private var isOpen = false - var onEntered: (() -> Void)? - func wait() async { - onEntered?() + hasEntered = true + let pendingEntryWaiters = entryWaiters + entryWaiters.removeAll() + pendingEntryWaiters.forEach { $0.resume() } guard !isOpen else { return } @@ -630,6 +631,15 @@ private final class ReadinessTestGate { } } + func waitUntilEntered() async { + guard !hasEntered else { + return + } + await withCheckedContinuation { continuation in + entryWaiters.append(continuation) + } + } + func open() { isOpen = true let pending = waiters diff --git a/KeyboardLockerModelTests/AgentRegistrarTests.swift b/KeyboardLockerModelTests/AgentRegistrarTests.swift index e78df22..09b1bbd 100644 --- a/KeyboardLockerModelTests/AgentRegistrarTests.swift +++ b/KeyboardLockerModelTests/AgentRegistrarTests.swift @@ -1,9 +1,11 @@ import ServiceManagement -import XCTest +import Testing -final class AgentRegistrarTests: XCTestCase { +@Suite(.serialized) +struct AgentRegistrarTests { + @Test @MainActor - func testNotFoundWithValidBundledAssetsAttemptsRegistration() { + func notFoundWithValidBundledAssetsAttemptsRegistration() { let service = FakeAgentRegistrationService( status: .notFound, statusAfterRegistration: .enabled @@ -14,12 +16,13 @@ final class AgentRegistrarTests: XCTestCase { validateBundledRegistration: {} ) - XCTAssertEqual(state, .enabled) - XCTAssertEqual(service.registrationCallCount, 1) + #expect(state == .enabled) + #expect(service.registrationCallCount == 1) } + @Test @MainActor - func testNotFoundWithMissingBundledAssetsDoesNotAttemptRegistration() { + func notFoundWithMissingBundledAssetsDoesNotAttemptRegistration() { let service = FakeAgentRegistrationService( status: .notFound, statusAfterRegistration: .enabled @@ -32,12 +35,13 @@ final class AgentRegistrarTests: XCTestCase { } ) - XCTAssertEqual(state, .unavailable(.notFound)) - XCTAssertEqual(service.registrationCallCount, 0) + #expect(state == .unavailable(.notFound)) + #expect(service.registrationCallCount == 0) } + @Test @MainActor - func testNotFoundRegistrationFailurePreservesUnderlyingError() { + func notFoundRegistrationFailurePreservesUnderlyingError() { let service = FakeAgentRegistrationService( status: .notFound, statusAfterRegistration: .notFound, @@ -49,15 +53,16 @@ final class AgentRegistrarTests: XCTestCase { validateBundledRegistration: {} ) - XCTAssertEqual( - state, - .unavailable(.registrationFailed("Registration failed for testing.")) + #expect( + state == + .unavailable(.registrationFailed("Registration failed for testing.")) ) - XCTAssertEqual(service.registrationCallCount, 1) + #expect(service.registrationCallCount == 1) } + @Test @MainActor - func testUnregisterRemovesEnabledService() async throws { + func unregisterRemovesEnabledService() async throws { let service = FakeAgentRegistrationService( status: .enabled, statusAfterRegistration: .enabled, @@ -66,12 +71,13 @@ final class AgentRegistrarTests: XCTestCase { try await AgentRegistrar.unregister(service: service) - XCTAssertEqual(service.status, .notRegistered) - XCTAssertEqual(service.unregistrationCallCount, 1) + #expect(service.status == .notRegistered) + #expect(service.unregistrationCallCount == 1) } + @Test @MainActor - func testUnregisterIsIdempotentWhenServiceIsNotFound() async throws { + func unregisterIsIdempotentWhenServiceIsNotFound() async throws { let service = FakeAgentRegistrationService( status: .notFound, statusAfterRegistration: .enabled @@ -79,12 +85,13 @@ final class AgentRegistrarTests: XCTestCase { try await AgentRegistrar.unregister(service: service) - XCTAssertEqual(service.status, .notFound) - XCTAssertEqual(service.unregistrationCallCount, 0) + #expect(service.status == .notFound) + #expect(service.unregistrationCallCount == 0) } + @Test @MainActor - func testUnregisterPreservesUnderlyingError() async { + func unregisterPreservesUnderlyingError() async { let service = FakeAgentRegistrationService( status: .enabled, statusAfterRegistration: .enabled, @@ -93,11 +100,11 @@ final class AgentRegistrarTests: XCTestCase { do { try await AgentRegistrar.unregister(service: service) - XCTFail("Expected unregister to throw.") + Issue.record("Expected unregister to throw.") } catch { - XCTAssertEqual(error.localizedDescription, "Registration failed for testing.") + #expect(error.localizedDescription == "Registration failed for testing.") } - XCTAssertEqual(service.unregistrationCallCount, 1) + #expect(service.unregistrationCallCount == 1) } } diff --git a/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift index 5917b1a..9717575 100644 --- a/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift @@ -1,10 +1,12 @@ import Client import Foundation -import XCTest +import Testing -final class AgentReplacementCoordinatorTests: XCTestCase { +@Suite(.serialized) +struct AgentReplacementCoordinatorTests { + @Test @MainActor - func testSafePlanPreparesAndCommitsBeforeRestarting() async { + func safePlanPreparesAndCommitsBeforeRestarting() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -21,19 +23,20 @@ final class AgentReplacementCoordinatorTests: XCTestCase { _ = await coordinator.replace(plan) - XCTAssertEqual(log.steps, [.prepare, .commit, .restart, .resetConnection]) - XCTAssertEqual(client.prepareCalls.count, 1) - XCTAssertEqual(client.prepareCalls.first?.unlockIfNeeded, true) - XCTAssertEqual( - client.prepareCalls.first?.expectedAgentInstanceID, - descriptor.agentInstanceID + #expect(log.steps == [.prepare, .commit, .restart, .resetConnection]) + #expect(client.prepareCalls.count == 1) + #expect(client.prepareCalls.first?.unlockIfNeeded == true) + #expect( + client.prepareCalls.first?.expectedAgentInstanceID == + descriptor.agentInstanceID ) - XCTAssertEqual(client.committedTickets, [ticket]) - XCTAssertTrue(client.cancelledTickets.isEmpty) + #expect(client.committedTickets == [ticket]) + #expect(client.cancelledTickets.isEmpty) } + @Test @MainActor - func testSafePlanRestartSuccessReturnsRestartedAndResetsConnection() async { + func safePlanRestartSuccessReturnsRestartedAndResetsConnection() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -51,15 +54,16 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .restarted(previousAgentInstanceID) = outcome else { - XCTFail("Expected .restarted, got \(outcome).") + Issue.record("Expected .restarted, got \(outcome).") return } - XCTAssertEqual(previousAgentInstanceID, descriptor.agentInstanceID) - XCTAssertEqual(client.resetConnectionCallCount, 1) + #expect(previousAgentInstanceID == descriptor.agentInstanceID) + #expect(client.resetConnectionCallCount == 1) } + @Test @MainActor - func testCommittedSafePlanRestartFailureDoesNotCancelPreparation() async { + func committedSafePlanRestartFailureDoesNotCancelPreparation() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -78,18 +82,19 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .registration(state) = outcome else { - XCTFail("Expected .registration, got \(outcome).") + Issue.record("Expected .registration, got \(outcome).") return } - XCTAssertEqual(state, .approvalRequired) - XCTAssertEqual(client.committedTickets, [ticket]) - XCTAssertTrue(client.cancelledTickets.isEmpty) - XCTAssertEqual(client.resetConnectionCallCount, 1) - XCTAssertEqual(log.steps, [.prepare, .commit, .restart, .resetConnection]) + #expect(state == .approvalRequired) + #expect(client.committedTickets == [ticket]) + #expect(client.cancelledTickets.isEmpty) + #expect(client.resetConnectionCallCount == 1) + #expect(log.steps == [.prepare, .commit, .restart, .resetConnection]) } + @Test @MainActor - func testPrepareFailureReportsFailedWithCurrentLockState() async { + func prepareFailureReportsFailedWithCurrentLockState() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -109,18 +114,19 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .failed(error, currentLockState) = outcome else { - XCTFail("Expected .failed, got \(outcome).") + Issue.record("Expected .failed, got \(outcome).") return } - XCTAssertEqual(error as? TestError, .expected) - XCTAssertEqual(currentLockState, true) - XCTAssertTrue(client.cancelledTickets.isEmpty) - XCTAssertEqual(client.statusCallCount, 1) - XCTAssertEqual(log.steps, [.prepare, .serviceDescriptor, .status]) + #expect(error as? TestError == .expected) + #expect(currentLockState == true) + #expect(client.cancelledTickets.isEmpty) + #expect(client.statusCallCount == 1) + #expect(log.steps == [.prepare, .serviceDescriptor, .status]) } + @Test @MainActor - func testCommitFailureCancelsPreparedTicketThenReportsFailed() async { + func commitFailureCancelsPreparedTicketThenReportsFailed() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -139,21 +145,22 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .failed(error, currentLockState) = outcome else { - XCTFail("Expected .failed, got \(outcome).") + Issue.record("Expected .failed, got \(outcome).") return } - XCTAssertEqual(error as? TestError, .expected) - XCTAssertEqual(currentLockState, false) - XCTAssertEqual(client.cancelledTickets, [ticket]) - XCTAssertTrue(client.committedTickets.isEmpty) - XCTAssertEqual( - log.steps, - [.prepare, .commit, .cancel, .serviceDescriptor, .status] + #expect(error as? TestError == .expected) + #expect(currentLockState == false) + #expect(client.cancelledTickets == [ticket]) + #expect(client.committedTickets.isEmpty) + #expect( + log.steps == + [.prepare, .commit, .cancel, .serviceDescriptor, .status] ) } + @Test @MainActor - func testSafePlanFailureRedetectsTrustedReplacementInProgress() async { + func safePlanFailureRedetectsTrustedReplacementInProgress() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -174,16 +181,17 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .replacementInProgress(reported) = outcome else { - XCTFail("Expected .replacementInProgress, got \(outcome).") + Issue.record("Expected .replacementInProgress, got \(outcome).") return } - XCTAssertEqual(reported, pendingDescriptor) - XCTAssertEqual(client.cancelledTickets, [ticket]) - XCTAssertEqual(client.statusCallCount, 0) + #expect(reported == pendingDescriptor) + #expect(client.cancelledTickets == [ticket]) + #expect(client.statusCallCount == 0) } + @Test @MainActor - func testForcedPlanFailureDoesNotRedetectReplacementInProgress() async { + func forcedPlanFailureDoesNotRedetectReplacementInProgress() async { let log = ReplacementCallLog() let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) let client = FakeAgentReplacementClient(log: log, ticket: ticket) @@ -200,17 +208,18 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .failed(error, currentLockState) = outcome else { - XCTFail("Expected .failed, got \(outcome).") + Issue.record("Expected .failed, got \(outcome).") return } - XCTAssertEqual(error as? TestError, .expected) - XCTAssertEqual(currentLockState, true) - XCTAssertEqual(client.descriptorCallCount, 0) - XCTAssertEqual(log.steps, [.unlock, .status]) + #expect(error as? TestError == .expected) + #expect(currentLockState == true) + #expect(client.descriptorCallCount == 0) + #expect(log.steps == [.unlock, .status]) } + @Test @MainActor - func testForcedPlanWithKnownLockStateUnlocksBeforeRestarting() async { + func forcedPlanWithKnownLockStateUnlocksBeforeRestarting() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -228,19 +237,20 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .restarted(previousAgentInstanceID) = outcome else { - XCTFail("Expected .restarted, got \(outcome).") + Issue.record("Expected .restarted, got \(outcome).") return } - XCTAssertEqual(previousAgentInstanceID, descriptor.agentInstanceID) - XCTAssertEqual(client.unlockCallCount, 1) - XCTAssertTrue(client.prepareCalls.isEmpty) - XCTAssertTrue(client.committedTickets.isEmpty) - XCTAssertTrue(client.cancelledTickets.isEmpty) - XCTAssertEqual(log.steps, [.unlock, .restart, .resetConnection]) + #expect(previousAgentInstanceID == descriptor.agentInstanceID) + #expect(client.unlockCallCount == 1) + #expect(client.prepareCalls.isEmpty) + #expect(client.committedTickets.isEmpty) + #expect(client.cancelledTickets.isEmpty) + #expect(log.steps == [.unlock, .restart, .resetConnection]) } + @Test @MainActor - func testForcedPlanWithKnownLockStateRestartFailureReturnsRegistration() async { + func forcedPlanWithKnownLockStateRestartFailureReturnsRegistration() async { let log = ReplacementCallLog() let descriptor = makeDescriptor() let ticket = ServiceReplacementTicket( @@ -259,17 +269,18 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .registration(state) = outcome else { - XCTFail("Expected .registration, got \(outcome).") + Issue.record("Expected .registration, got \(outcome).") return } - XCTAssertEqual(state, .approvalRequired) - XCTAssertEqual(client.unlockCallCount, 1) - XCTAssertTrue(client.cancelledTickets.isEmpty) - XCTAssertEqual(log.steps, [.unlock, .restart, .resetConnection]) + #expect(state == .approvalRequired) + #expect(client.unlockCallCount == 1) + #expect(client.cancelledTickets.isEmpty) + #expect(log.steps == [.unlock, .restart, .resetConnection]) } + @Test @MainActor - func testForcedPlanWithUnknownLockStateSkipsUnlock() async { + func forcedPlanWithUnknownLockStateSkipsUnlock() async { let log = ReplacementCallLog() let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) let client = FakeAgentReplacementClient(log: log, ticket: ticket) @@ -283,17 +294,18 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .restarted(previousAgentInstanceID) = outcome else { - XCTFail("Expected .restarted, got \(outcome).") + Issue.record("Expected .restarted, got \(outcome).") return } - XCTAssertNil(previousAgentInstanceID) - XCTAssertEqual(client.unlockCallCount, 0) - XCTAssertEqual(client.statusCallCount, 0) - XCTAssertEqual(log.steps, [.restart, .resetConnection]) + #expect(previousAgentInstanceID == nil) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(log.steps == [.restart, .resetConnection]) } + @Test @MainActor - func testForcedPlanRestartFailureReturnsRegistration() async { + func forcedPlanRestartFailureReturnsRegistration() async { let log = ReplacementCallLog() let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) let client = FakeAgentReplacementClient(log: log, ticket: ticket) @@ -308,12 +320,12 @@ final class AgentReplacementCoordinatorTests: XCTestCase { let outcome = await coordinator.replace(plan) guard case let .registration(state) = outcome else { - XCTFail("Expected .registration, got \(outcome).") + Issue.record("Expected .registration, got \(outcome).") return } - XCTAssertEqual(state, .unavailable(.restartFailed("Restart failed for testing."))) - XCTAssertEqual(client.resetConnectionCallCount, 1) - XCTAssertEqual(log.steps, [.restart, .resetConnection]) + #expect(state == .unavailable(.restartFailed("Restart failed for testing."))) + #expect(client.resetConnectionCallCount == 1) + #expect(log.steps == [.restart, .resetConnection]) } } diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift index 0aeb2ae..d08c0f5 100644 --- a/KeyboardLockerModelTests/AppCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -1,9 +1,11 @@ import Client import Foundation -import XCTest +import Testing -final class AppCoordinatorTests: XCTestCase { - func testStateProjectsKnownLockEvidenceConsistently() { +@Suite(.serialized) +struct AppCoordinatorTests { + @Test + func stateProjectsKnownLockEvidenceConsistently() { let cases: [(AppCoordinator.State, Bool?)] = [ (.checking(lastKnownLock: true), true), (.checking(lastKnownLock: nil), nil), @@ -17,12 +19,13 @@ final class AppCoordinatorTests: XCTestCase { ] for (state, expected) in cases { - XCTAssertEqual(state.knownLockState, expected, "state: \(state)") + #expect(state.knownLockState == expected, "state: \(state)") } } + @Test @MainActor - func testReconcilePublishesAuthoritativeReadySnapshot() async throws { + func reconcilePublishesAuthoritativeReadySnapshot() async throws { let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) let lifecycle = FakeAgentLifecycle() let observer = FakeLockStateObserver() @@ -39,18 +42,19 @@ final class AppCoordinatorTests: XCTestCase { coordinator.state == .ready(isLocked: false) } - XCTAssertEqual(coordinator.snapshot, AppCoordinator.Snapshot( + #expect(coordinator.snapshot == AppCoordinator.Snapshot( state: .ready(isLocked: false), activity: nil, lastError: nil, safetyCheckState: .idle )) - XCTAssertEqual(observer.initialStates, [false]) - XCTAssertEqual(snapshots.last, coordinator.snapshot) + #expect(observer.initialStates == [false]) + #expect(snapshots.last == coordinator.snapshot) } + @Test @MainActor - func testDisplayedLockActionLocksThroughClientThenReconcilesAuthoritativeState() async throws { + func displayedLockActionLocksThroughClientThenReconcilesAuthoritativeState() async throws { let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) let lifecycle = FakeAgentLifecycle() let observer = FakeLockStateObserver() @@ -68,14 +72,15 @@ final class AppCoordinatorTests: XCTestCase { coordinator.state == .ready(isLocked: true) && coordinator.activity == nil } - XCTAssertEqual(client.lockCallCount, 1) - XCTAssertEqual(client.unlockCallCount, 0) - XCTAssertTrue(activities.contains(.locking)) - XCTAssertEqual(observer.initialStates, [true]) + #expect(client.lockCallCount == 1) + #expect(client.unlockCallCount == 0) + #expect(activities.contains(.locking)) + #expect(observer.initialStates == [true]) } + @Test @MainActor - func testObservedAuthoritativeStateUpdatesReadySnapshotWithoutDuplicatePublication() async throws { + func observedAuthoritativeStateUpdatesReadySnapshotWithoutDuplicatePublication() async throws { let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) let lifecycle = FakeAgentLifecycle() let observer = FakeLockStateObserver() @@ -94,15 +99,16 @@ final class AppCoordinatorTests: XCTestCase { let publicationCountBeforeDuplicate = snapshots.count observer.send(false) - XCTAssertEqual(snapshots.count, publicationCountBeforeDuplicate) + #expect(snapshots.count == publicationCountBeforeDuplicate) observer.send(true) - XCTAssertEqual(coordinator.state, .ready(isLocked: true)) - XCTAssertEqual(snapshots.last?.state, .ready(isLocked: true)) + #expect(coordinator.state == .ready(isLocked: true)) + #expect(snapshots.last?.state == .ready(isLocked: true)) } + @Test @MainActor - func testSafetyCheckWaitsForAuthoritativeUnlockAndCompletes() async throws { + func safetyCheckWaitsForAuthoritativeUnlockAndCompletes() async throws { let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) let coordinator = makeCoordinator( client: client, @@ -116,14 +122,15 @@ final class AppCoordinatorTests: XCTestCase { coordinator.safetyCheckState == .completed } - XCTAssertEqual(client.beginSafetyCheckCallCount, 1) - XCTAssertEqual(client.waitUntilUnlockedCallCount, 1) - XCTAssertFalse(client.isLocked) - XCTAssertNil(coordinator.activity) + #expect(client.beginSafetyCheckCallCount == 1) + #expect(client.waitUntilUnlockedCallCount == 1) + #expect(!client.isLocked) + #expect(coordinator.activity == nil) } + @Test @MainActor - func testSafetyCheckReportsConcurrentExistingLock() async throws { + func safetyCheckReportsConcurrentExistingLock() async throws { let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) client.safetyCheckOutcome = .alreadyLocked let coordinator = makeCoordinator( @@ -142,10 +149,11 @@ final class AppCoordinatorTests: XCTestCase { } guard case let .failed(message) = coordinator.safetyCheckState else { - return XCTFail("Expected a failed safety check.") + Issue.record("Expected a failed safety check.") + return } - XCTAssertTrue(message.contains("already locked")) - XCTAssertEqual(client.waitUntilUnlockedCallCount, 0) + #expect(message.contains("already locked")) + #expect(client.waitUntilUnlockedCallCount == 0) } @MainActor @@ -173,14 +181,18 @@ final class AppCoordinatorTests: XCTestCase { while !condition() { guard clock.now < deadline else { - XCTFail("Timed out waiting for AppCoordinator state") - return + Issue.record("Timed out waiting for AppCoordinator state") + throw AppCoordinatorTestError.timedOut } try await Task.sleep(for: .milliseconds(10)) } } } +private enum AppCoordinatorTestError: Error { + case timedOut +} + @MainActor private final class FakeAgentClient: AgentClientServing { private(set) var beginSafetyCheckCallCount = 0 diff --git a/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift index 9898cda..eee48ec 100644 --- a/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift +++ b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift @@ -1,10 +1,11 @@ import Foundation -import XCTest +import Testing -final class CommandLineToolLinkManagerTests: XCTestCase { - private var temporaryDirectory: URL! +@Suite(.serialized) +final class CommandLineToolLinkManagerTests { + private let temporaryDirectory: URL - override func setUpWithError() throws { + init() throws { temporaryDirectory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( @@ -13,15 +14,13 @@ final class CommandLineToolLinkManagerTests: XCTestCase { ) } - override func tearDownWithError() throws { - if let temporaryDirectory { - try? FileManager.default.removeItem(at: temporaryDirectory) - } - temporaryDirectory = nil + deinit { + try? FileManager.default.removeItem(at: temporaryDirectory) } + @Test @MainActor - func testInstallAndUninstallManageOnlySymbolicLink() throws { + func installAndUninstallManageOnlySymbolicLink() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) let destination = binDirectory.appendingPathComponent("klock") @@ -31,28 +30,29 @@ final class CommandLineToolLinkManagerTests: XCTestCase { pathDirectories: [binDirectory] ) - XCTAssertEqual( - manager.state, - .notInstalled(destination: destination, requiresPathSetup: false) + #expect( + manager.state == + .notInstalled(destination: destination, requiresPathSetup: false) ) - XCTAssertEqual(try manager.install(), .installed(destination: destination)) - XCTAssertTrue(manager.canRemoveLink(at: destination)) - XCTAssertEqual( - try FileManager.default.destinationOfSymbolicLink(atPath: destination.path), - source.path + #expect(try manager.install() == .installed(destination: destination)) + #expect(manager.canRemoveLink(at: destination)) + #expect( + try FileManager.default.destinationOfSymbolicLink(atPath: destination.path) == + source.path ) - XCTAssertEqual( - try manager.uninstall(), - .notInstalled(destination: destination, requiresPathSetup: false) + #expect( + try manager.uninstall() == + .notInstalled(destination: destination, requiresPathSetup: false) ) - XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: source.path)) + #expect(!FileManager.default.fileExists(atPath: destination.path)) + #expect(FileManager.default.fileExists(atPath: source.path)) } + @Test @MainActor - func testInstallIsIdempotentForOwnedSymbolicLink() throws { + func installIsIdempotentForOwnedSymbolicLink() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) let destination = binDirectory.appendingPathComponent("klock") @@ -63,11 +63,12 @@ final class CommandLineToolLinkManagerTests: XCTestCase { ) _ = try manager.install() - XCTAssertEqual(try manager.install(), .installed(destination: destination)) + #expect(try manager.install() == .installed(destination: destination)) } + @Test @MainActor - func testOwnedSymbolicLinkCanBeRemovedWhenBundledExecutableDisappears() throws { + func ownedSymbolicLinkCanBeRemovedWhenBundledExecutableDisappears() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) let destination = binDirectory.appendingPathComponent("klock") @@ -80,18 +81,19 @@ final class CommandLineToolLinkManagerTests: XCTestCase { _ = try manager.install() try FileManager.default.removeItem(at: source) - XCTAssertEqual(manager.state, .installed(destination: destination)) - XCTAssertEqual( - try manager.uninstall(), - .sourceUnavailable(source) + #expect(manager.state == .installed(destination: destination)) + #expect( + try manager.uninstall() == + .sourceUnavailable(source) ) - XCTAssertThrowsError( + #expect(throws: (any Error).self) { try FileManager.default.destinationOfSymbolicLink(atPath: destination.path) - ) + } } + @Test @MainActor - func testForeignItemIsReportedAndNeverOverwritten() throws { + func foreignItemIsReportedAndNeverOverwritten() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) @@ -103,19 +105,21 @@ final class CommandLineToolLinkManagerTests: XCTestCase { pathDirectories: [binDirectory] ) - XCTAssertEqual(manager.state, .conflict(destination: destination)) - XCTAssertThrowsError(try manager.install()) { error in - guard let installationError = error as? CommandLineToolLinkManager.InstallationError, - case .destinationConflict = installationError - else { - return XCTFail("Expected a destination conflict, found \(error).") - } + #expect(manager.state == .conflict(destination: destination)) + let error = #expect(throws: CommandLineToolLinkManager.InstallationError.self) { + try manager.install() + } + guard let error else { return } + guard case .destinationConflict = error else { + Issue.record("Expected a destination conflict, found \(error).") + return } - XCTAssertEqual(try Data(contentsOf: destination), Data("foreign".utf8)) + #expect(try Data(contentsOf: destination) == Data("foreign".utf8)) } + @Test @MainActor - func testMissingPathDirectoryIsReportedWithoutEditingShellConfiguration() throws { + func missingPathDirectoryIsReportedWithoutEditingShellConfiguration() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) let destination = binDirectory.appendingPathComponent("klock") @@ -125,11 +129,11 @@ final class CommandLineToolLinkManagerTests: XCTestCase { pathDirectories: [] ) - XCTAssertEqual( - manager.state, - .notInstalled(destination: destination, requiresPathSetup: true) + #expect( + manager.state == + .notInstalled(destination: destination, requiresPathSetup: true) ) - XCTAssertTrue(manager.pathSetupCommand(for: destination).contains("export PATH=")) + #expect(manager.pathSetupCommand(for: destination).contains("export PATH=")) } @MainActor diff --git a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift index d8de3f1..8649cc8 100644 --- a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift +++ b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift @@ -1,9 +1,11 @@ import Client -import XCTest +import Testing -final class ExternalAutomationControllerTests: XCTestCase { +@Suite(.serialized) +struct ExternalAutomationControllerTests { + @Test @MainActor - func testLockInvokesOnlyLockCapability() async { + func lockInvokesOnlyLockCapability() async { let client = RecordingExternalAutomationClient(isLocked: false) let presenter = RecordingExternalAutomationPresenter() let controller = ExternalAutomationController(client: client, presenter: presenter) @@ -11,14 +13,15 @@ final class ExternalAutomationControllerTests: XCTestCase { controller.submit(.lock, source: .service) await controller.waitUntilIdle() - XCTAssertEqual(client.calls, [.lock]) - XCTAssertTrue(client.isLocked) - XCTAssertTrue(presenter.statuses.isEmpty) - XCTAssertTrue(presenter.failureBatches.isEmpty) + #expect(client.calls == [.lock]) + #expect(client.isLocked) + #expect(presenter.statuses.isEmpty) + #expect(presenter.failureBatches.isEmpty) } + @Test @MainActor - func testUnlockInvokesOnlyUnlockCapability() async { + func unlockInvokesOnlyUnlockCapability() async { let client = RecordingExternalAutomationClient(isLocked: true) let presenter = RecordingExternalAutomationPresenter() let controller = ExternalAutomationController(client: client, presenter: presenter) @@ -26,12 +29,13 @@ final class ExternalAutomationControllerTests: XCTestCase { controller.submit(.unlock, source: .service) await controller.waitUntilIdle() - XCTAssertEqual(client.calls, [.unlock]) - XCTAssertFalse(client.isLocked) + #expect(client.calls == [.unlock]) + #expect(!client.isLocked) } + @Test @MainActor - func testStatusPresentsAuthoritativeAgentValue() async { + func statusPresentsAuthoritativeAgentValue() async { let client = RecordingExternalAutomationClient(isLocked: true) let presenter = RecordingExternalAutomationPresenter() let controller = ExternalAutomationController(client: client, presenter: presenter) @@ -39,13 +43,14 @@ final class ExternalAutomationControllerTests: XCTestCase { controller.submit(.status, source: .service) await controller.waitUntilIdle() - XCTAssertEqual(client.calls, [.status]) - XCTAssertEqual(presenter.statuses, [.init(isLocked: true, source: .service)]) - XCTAssertTrue(presenter.failureBatches.isEmpty) + #expect(client.calls == [.status]) + #expect(presenter.statuses == [.init(isLocked: true, source: .service)]) + #expect(presenter.failureBatches.isEmpty) } + @Test @MainActor - func testAgentFailureIncludesRecoverySuggestion() async { + func agentFailureIncludesRecoverySuggestion() async { let client = RecordingExternalAutomationClient( isLocked: false, error: XPCClientError.serviceUnavailable @@ -57,13 +62,14 @@ final class ExternalAutomationControllerTests: XCTestCase { await controller.waitUntilIdle() let batch = presenter.failureBatches.first - XCTAssertEqual(batch?.source, .service) - XCTAssertTrue(batch?.failures.first?.message.contains("not reachable") == true) - XCTAssertTrue(batch?.failures.first?.message.contains("Open KeyboardLocker once") == true) + #expect(batch?.source == .service) + #expect(batch?.failures.first?.message.contains("not reachable") == true) + #expect(batch?.failures.first?.message.contains("Open KeyboardLocker once") == true) } + @Test @MainActor - func testBatchPreservesDesiredStateOrder() async { + func batchPreservesDesiredStateOrder() async { let client = RecordingExternalAutomationClient(isLocked: false) let controller = ExternalAutomationController( client: client, @@ -73,12 +79,13 @@ final class ExternalAutomationControllerTests: XCTestCase { controller.submit([.lock, .status, .unlock], source: .urlScheme) await controller.waitUntilIdle() - XCTAssertEqual(client.calls, [.lock, .status, .unlock]) - XCTAssertFalse(client.isLocked) + #expect(client.calls == [.lock, .status, .unlock]) + #expect(!client.isLocked) } + @Test @MainActor - func testRejectedRequestIsPresentedWithoutCallingAgent() async { + func rejectedRequestIsPresentedWithoutCallingAgent() async { let client = RecordingExternalAutomationClient(isLocked: false) let presenter = RecordingExternalAutomationPresenter() let controller = ExternalAutomationController(client: client, presenter: presenter) @@ -89,15 +96,16 @@ final class ExternalAutomationControllerTests: XCTestCase { ) await controller.waitUntilIdle() - XCTAssertTrue(client.calls.isEmpty) - XCTAssertEqual( - presenter.failureBatches, - [.init(failures: [.init(message: "Invalid URL")], source: .urlScheme)] + #expect(client.calls.isEmpty) + #expect( + presenter.failureBatches == + [.init(failures: [.init(message: "Invalid URL")], source: .urlScheme)] ) } + @Test @MainActor - func testSeparateSubmissionsStaySerializedWhileFirstActionIsSuspended() async { + func separateSubmissionsStaySerializedWhileFirstActionIsSuspended() async { let gate = AsyncGate() let client = RecordingExternalAutomationClient(isLocked: false, lockGate: gate) let controller = ExternalAutomationController( @@ -110,31 +118,33 @@ final class ExternalAutomationControllerTests: XCTestCase { controller.submit(.unlock, source: .service) await Task.yield() - XCTAssertEqual(client.calls, [.lock]) + #expect(client.calls == [.lock]) await gate.open() await controller.waitUntilIdle() - XCTAssertEqual(client.calls, [.lock, .unlock]) - XCTAssertFalse(client.isLocked) + #expect(client.calls == [.lock, .unlock]) + #expect(!client.isLocked) } + @Test @MainActor - func testSubmitAndWaitReturnsNilWhenActionSucceeds() async { + func submitAndWaitReturnsNilWhenActionSucceeds() async { let client = RecordingExternalAutomationClient(isLocked: false) let presenter = RecordingExternalAutomationPresenter() let controller = ExternalAutomationController(client: client, presenter: presenter) let failure = await controller.submitAndWait(.lock, source: .service) - XCTAssertNil(failure) - XCTAssertEqual(client.calls, [.lock]) - XCTAssertTrue(client.isLocked) - XCTAssertTrue(presenter.failureBatches.isEmpty) + #expect(failure == nil) + #expect(client.calls == [.lock]) + #expect(client.isLocked) + #expect(presenter.failureBatches.isEmpty) } + @Test @MainActor - func testSubmitAndWaitReturnsFailureWithoutPresentingIt() async { + func submitAndWaitReturnsFailureWithoutPresentingIt() async { let client = RecordingExternalAutomationClient( isLocked: false, error: XPCClientError.serviceUnavailable @@ -144,12 +154,13 @@ final class ExternalAutomationControllerTests: XCTestCase { let failure = await controller.submitAndWait(.lock, source: .service) - XCTAssertTrue(failure?.message.contains("not reachable") == true) - XCTAssertTrue(presenter.failureBatches.isEmpty) + #expect(failure?.message.contains("not reachable") == true) + #expect(presenter.failureBatches.isEmpty) } + @Test @MainActor - func testSubmitAndWaitStaysSerializedBehindPendingSubmission() async { + func submitAndWaitStaysSerializedBehindPendingSubmission() async { let gate = AsyncGate() let client = RecordingExternalAutomationClient(isLocked: false, lockGate: gate) let controller = ExternalAutomationController( @@ -163,14 +174,14 @@ final class ExternalAutomationControllerTests: XCTestCase { async let awaitedFailure = controller.submitAndWait(.unlock, source: .service) await Task.yield() - XCTAssertEqual(client.calls, [.lock]) + #expect(client.calls == [.lock]) await gate.open() let failure = await awaitedFailure - XCTAssertNil(failure) - XCTAssertEqual(client.calls, [.lock, .unlock]) - XCTAssertFalse(client.isLocked) + #expect(failure == nil) + #expect(client.calls == [.lock, .unlock]) + #expect(!client.isLocked) } } diff --git a/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift index 3eceb63..a52df50 100644 --- a/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift @@ -1,45 +1,50 @@ import AppIntents -import XCTest +import Testing -final class KeyboardLockAppIntentTests: XCTestCase { +@Suite(.serialized) +struct KeyboardLockAppIntentTests { + @Test @MainActor - func testLockIntentInvokesOnlyLockCapability() async throws { + func lockIntentInvokesOnlyLockCapability() async throws { let client = FakeAgentLockActionClient(isLocked: false) _ = try await LockKeyboardIntent(client: client).perform() - XCTAssertEqual(client.lockCallCount, 1) - XCTAssertEqual(client.unlockCallCount, 0) - XCTAssertEqual(client.statusCallCount, 0) - XCTAssertTrue(client.isLocked) + #expect(client.lockCallCount == 1) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.isLocked) } + @Test @MainActor - func testUnlockIntentInvokesOnlyUnlockCapability() async throws { + func unlockIntentInvokesOnlyUnlockCapability() async throws { let client = FakeAgentLockActionClient(isLocked: true) _ = try await UnlockKeyboardIntent(client: client).perform() - XCTAssertEqual(client.lockCallCount, 0) - XCTAssertEqual(client.unlockCallCount, 1) - XCTAssertEqual(client.statusCallCount, 0) - XCTAssertFalse(client.isLocked) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 1) + #expect(client.statusCallCount == 0) + #expect(!client.isLocked) } + @Test @MainActor - func testStatusIntentReturnsAuthoritativeClientValue() async throws { + func statusIntentReturnsAuthoritativeClientValue() async throws { let client = FakeAgentLockActionClient(isLocked: true) let result = try await GetKeyboardLockStatusIntent(client: client).perform() - XCTAssertEqual(result.value, true) - XCTAssertEqual(client.lockCallCount, 0) - XCTAssertEqual(client.unlockCallCount, 0) - XCTAssertEqual(client.statusCallCount, 1) + #expect(result.value == true) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 1) } + @Test @MainActor - func testStatusIntentPropagatesAgentFailureWithoutFallback() async { + func statusIntentPropagatesAgentFailureWithoutFallback() async { let client = FakeAgentLockActionClient( isLocked: false, error: IntentClientError.unavailable @@ -47,39 +52,42 @@ final class KeyboardLockAppIntentTests: XCTestCase { do { _ = try await GetKeyboardLockStatusIntent(client: client).perform() - XCTFail("Expected the Agent error to be propagated.") + Issue.record("Expected the Agent error to be propagated.") } catch { - XCTAssertEqual(error as? IntentClientError, .unavailable) + #expect(error as? IntentClientError == .unavailable) } } + @Test @MainActor - func testToggleIntentFromUnlockedReturnsLocked() async throws { + func toggleIntentFromUnlockedReturnsLocked() async throws { let client = FakeAgentLockActionClient(isLocked: false) let result = try await ToggleKeyboardLockIntent(client: client).perform() - XCTAssertEqual(result.value, true) - XCTAssertEqual(client.toggleCallCount, 1) - XCTAssertEqual(client.lockCallCount, 0) - XCTAssertEqual(client.unlockCallCount, 0) - XCTAssertEqual(client.statusCallCount, 0) - XCTAssertTrue(client.isLocked) + #expect(result.value == true) + #expect(client.toggleCallCount == 1) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.isLocked) } + @Test @MainActor - func testToggleIntentFromLockedReturnsUnlocked() async throws { + func toggleIntentFromLockedReturnsUnlocked() async throws { let client = FakeAgentLockActionClient(isLocked: true) let result = try await ToggleKeyboardLockIntent(client: client).perform() - XCTAssertEqual(result.value, false) - XCTAssertEqual(client.toggleCallCount, 1) - XCTAssertFalse(client.isLocked) + #expect(result.value == false) + #expect(client.toggleCallCount == 1) + #expect(!client.isLocked) } + @Test @MainActor - func testToggleIntentPropagatesAgentFailureWithoutFallback() async { + func toggleIntentPropagatesAgentFailureWithoutFallback() async { let client = FakeAgentLockActionClient( isLocked: false, error: IntentClientError.unavailable @@ -87,10 +95,10 @@ final class KeyboardLockAppIntentTests: XCTestCase { do { _ = try await ToggleKeyboardLockIntent(client: client).perform() - XCTFail("Expected the Agent error to be propagated.") + Issue.record("Expected the Agent error to be propagated.") } catch { - XCTAssertEqual(error as? IntentClientError, .unavailable) - XCTAssertFalse(client.isLocked) + #expect(error as? IntentClientError == .unavailable) + #expect(!client.isLocked) } } } diff --git a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift index 744c61f..7cc51bb 100644 --- a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift @@ -1,13 +1,16 @@ import Foundation import SystemSurfaces -import XCTest +import Testing -final class KeyboardLockFocusFilterIntentTests: XCTestCase { - func testDefaultFilterDoesNotRequestALock() { - XCTAssertFalse(KeyboardLockFocusFilterIntent().lockKeyboard) +@Suite(.serialized) +struct KeyboardLockFocusFilterIntentTests { + @Test + func defaultFilterDoesNotRequestALock() { + #expect(!KeyboardLockFocusFilterIntent().lockKeyboard) } - func testEnabledFilterForwardsTrueToAgent() async throws { + @Test + func enabledFilterForwardsTrueToAgent() async throws { let client = FakeAgentFocusLockClient() let surfaces = FocusSurfaceRecorder() @@ -18,11 +21,12 @@ final class KeyboardLockFocusFilterIntentTests: XCTestCase { ).perform() let receivedValues = await client.receivedValues - XCTAssertEqual(receivedValues, [true]) - XCTAssertEqual(surfaces.calls, [.widget, .control]) + #expect(receivedValues == [true]) + #expect(surfaces.calls == [.widget, .control]) } - func testDisabledFilterForwardsFalseToAgent() async throws { + @Test + func disabledFilterForwardsFalseToAgent() async throws { let client = FakeAgentFocusLockClient() let surfaces = FocusSurfaceRecorder() @@ -33,11 +37,12 @@ final class KeyboardLockFocusFilterIntentTests: XCTestCase { ).perform() let receivedValues = await client.receivedValues - XCTAssertEqual(receivedValues, [false]) - XCTAssertEqual(surfaces.calls, [.widget, .control]) + #expect(receivedValues == [false]) + #expect(surfaces.calls == [.widget, .control]) } - func testAgentFailureIsPropagated() async { + @Test + func agentFailureIsPropagated() async { let client = FakeAgentFocusLockClient(error: FocusIntentTestError.unavailable) let surfaces = FocusSurfaceRecorder() @@ -47,12 +52,12 @@ final class KeyboardLockFocusFilterIntentTests: XCTestCase { client: client, surfaceInvalidator: surfaces.invalidator ).perform() - XCTFail("Expected the Agent error to be propagated.") + Issue.record("Expected the Agent error to be propagated.") } catch { - XCTAssertEqual(error as? FocusIntentTestError, .unavailable) + #expect(error as? FocusIntentTestError == .unavailable) } - XCTAssertTrue(surfaces.calls.isEmpty) + #expect(surfaces.calls.isEmpty) } } diff --git a/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift b/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift index c1e27cb..2c7d134 100644 --- a/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift @@ -1,51 +1,57 @@ import Foundation -import XCTest +import Testing -final class KeyboardLockerControlModelTests: XCTestCase { - func testValueLoaderReturnsAuthoritativeValue() async throws { +@Suite(.serialized) +struct KeyboardLockerControlModelTests { + @Test + func valueLoaderReturnsAuthoritativeValue() async throws { let loader = KeyboardLockerControlValueLoader { true } let value = try await loader.currentValue() - XCTAssertTrue(value) + #expect(value) } - func testValueLoaderPropagatesFailure() async { + @Test + func valueLoaderPropagatesFailure() async { let loader = KeyboardLockerControlValueLoader { throw TestError.expected } do { _ = try await loader.currentValue() - XCTFail("Expected the loader to propagate the error.") + Issue.record("Expected the loader to propagate the error.") } catch { - XCTAssertEqual(error as? TestError, .expected) + #expect(error as? TestError == .expected) } } - func testSetLockedCallsOnlyLockThenReload() async throws { + @Test + func setLockedCallsOnlyLockThenReload() async throws { let recorder = CallRecorder() let action = makeAction(recorder: recorder) try await action.setLocked(true) let calls = await recorder.calls - XCTAssertEqual(calls, [.lock, .reload]) + #expect(calls == [.lock, .reload]) } - func testSetUnlockedCallsOnlyUnlockThenReload() async throws { + @Test + func setUnlockedCallsOnlyUnlockThenReload() async throws { let recorder = CallRecorder() let action = makeAction(recorder: recorder) try await action.setLocked(false) let calls = await recorder.calls - XCTAssertEqual(calls, [.unlock, .reload]) + #expect(calls == [.unlock, .reload]) } - func testFailedActionDoesNotReload() async { + @Test + func failedActionDoesNotReload() async { let recorder = CallRecorder() let action = KeyboardLockerControlAction( lock: { @@ -62,13 +68,13 @@ final class KeyboardLockerControlModelTests: XCTestCase { do { try await action.setLocked(true) - XCTFail("Expected the action to propagate the error.") + Issue.record("Expected the action to propagate the error.") } catch { - XCTAssertEqual(error as? TestError, .expected) + #expect(error as? TestError == .expected) } let calls = await recorder.calls - XCTAssertEqual(calls, [.lock]) + #expect(calls == [.lock]) } private func makeAction(recorder: CallRecorder) -> KeyboardLockerControlAction { diff --git a/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift b/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift index 324cacf..707aeec 100644 --- a/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift @@ -1,17 +1,19 @@ import Client import Foundation -import XCTest +import Testing -final class KeyboardLockerDiagnosticsTests: XCTestCase { +@Suite(.serialized) +struct KeyboardLockerDiagnosticsTests { + @Test @MainActor - func testReportIncludesRuntimeFactsWithoutMachineIdentity() async { - let descriptor = ServiceDescriptor( + func reportIncludesRuntimeFactsWithoutMachineIdentity() async throws { + let descriptor = try ServiceDescriptor( protocolVersion: ServiceProtocolVersion(major: 1, minor: 7), capabilities: [.lockControl, .safetyCheckLock], agentBundleIdentifier: SharedConstants.agentBundleIdentifier, agentVersion: "1.2", agentBuild: "34", - agentInstanceID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + agentInstanceID: #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) ) let lockSnapshot = LockStatusSnapshot( capturedAt: Date(timeIntervalSince1970: 1_700_000_000), @@ -42,19 +44,20 @@ final class KeyboardLockerDiagnosticsTests: XCTestCase { let report = await collector.report(appSnapshot: appSnapshot) - XCTAssertTrue(report.contains("App: 1.1 (12)")) - XCTAssertTrue(report.contains("Agent Registration: enabled")) - XCTAssertTrue(report.contains("Protocol: 1.7")) - XCTAssertTrue(report.contains("Capabilities: lock-control, safety-check-lock")) - XCTAssertTrue(report.contains("Accessibility: granted")) - XCTAssertTrue(report.contains("Status: unlocked")) - XCTAssertTrue(report.contains("Safety Check: completed")) - XCTAssertFalse(report.localizedCaseInsensitiveContains("user name")) - XCTAssertFalse(report.localizedCaseInsensitiveContains("host name")) + #expect(report.contains("App: 1.1 (12)")) + #expect(report.contains("Agent Registration: enabled")) + #expect(report.contains("Protocol: 1.7")) + #expect(report.contains("Capabilities: lock-control, safety-check-lock")) + #expect(report.contains("Accessibility: granted")) + #expect(report.contains("Status: unlocked")) + #expect(report.contains("Safety Check: completed")) + #expect(!report.localizedCaseInsensitiveContains("user name")) + #expect(!report.localizedCaseInsensitiveContains("host name")) } + @Test @MainActor - func testReportKeepsPartialDiagnosticsAndRedactsPaths() async { + func reportKeepsPartialDiagnosticsAndRedactsPaths() async { let collector = KeyboardLockerDiagnosticsCollector( appIdentity: .init(build: "1", bundleIdentifier: "test.app", version: "1.0"), operatingSystemVersion: "macOS test", @@ -77,12 +80,12 @@ final class KeyboardLockerDiagnosticsTests: XCTestCase { let report = await collector.report(appSnapshot: appSnapshot) - XCTAssertTrue(report.contains("Agent Registration: not-registered")) - XCTAssertTrue(report.contains("Accessibility: not-granted")) - XCTAssertTrue(report.contains("")) - XCTAssertFalse(report.contains("/Users/example")) - XCTAssertFalse(report.contains("/private/tmp")) - XCTAssertFalse(report.contains("/Applications/KeyboardLocker.app")) + #expect(report.contains("Agent Registration: not-registered")) + #expect(report.contains("Accessibility: not-granted")) + #expect(report.contains("")) + #expect(!report.contains("/Users/example")) + #expect(!report.contains("/private/tmp")) + #expect(!report.contains("/Applications/KeyboardLocker.app")) } } diff --git a/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift b/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift index e82b265..bae508e 100644 --- a/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift @@ -1,8 +1,9 @@ -import XCTest +import Testing -@available(macOS 14.0, *) -final class KeyboardLockerInteractiveWidgetTests: XCTestCase { - func testIntentUsesExplicitLockedStateThenReloads() async throws { +@Suite(.serialized) +struct KeyboardLockerInteractiveWidgetTests { + @Test + func intentUsesExplicitLockedStateThenReloads() async throws { let recorder = CallRecorder() let intent = SetKeyboardLockWidgetIntent( desiredIsLocked: true, @@ -12,10 +13,11 @@ final class KeyboardLockerInteractiveWidgetTests: XCTestCase { _ = try await intent.perform() let calls = await recorder.calls - XCTAssertEqual(calls, [.lock, .reload]) + #expect(calls == [.lock, .reload]) } - func testIntentUsesExplicitUnlockedStateThenReloads() async throws { + @Test + func intentUsesExplicitUnlockedStateThenReloads() async throws { let recorder = CallRecorder() let intent = SetKeyboardLockWidgetIntent( desiredIsLocked: false, @@ -25,10 +27,11 @@ final class KeyboardLockerInteractiveWidgetTests: XCTestCase { _ = try await intent.perform() let calls = await recorder.calls - XCTAssertEqual(calls, [.unlock, .reload]) + #expect(calls == [.unlock, .reload]) } - func testIntentPropagatesFailureWithoutReloading() async { + @Test + func intentPropagatesFailureWithoutReloading() async { let recorder = CallRecorder() let intent = SetKeyboardLockWidgetIntent( desiredIsLocked: true, @@ -48,13 +51,13 @@ final class KeyboardLockerInteractiveWidgetTests: XCTestCase { do { _ = try await intent.perform() - XCTFail("Expected the intent to propagate the Agent failure.") + Issue.record("Expected the intent to propagate the Agent failure.") } catch { - XCTAssertEqual(error as? TestError, .expected) + #expect(error as? TestError == .expected) } let calls = await recorder.calls - XCTAssertEqual(calls, [.lock]) + #expect(calls == [.lock]) } private func makeAction(recorder: CallRecorder) -> KeyboardLockerControlAction { @@ -72,7 +75,6 @@ final class KeyboardLockerInteractiveWidgetTests: XCTestCase { } } -@available(macOS 14.0, *) private extension KeyboardLockerInteractiveWidgetTests { enum TestError: Error { case expected diff --git a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift index aa70449..71e7fea 100644 --- a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift @@ -1,48 +1,53 @@ import Client import Foundation -import XCTest +import Testing -final class KeyboardLockerScriptCommandTests: XCTestCase { +@Suite(.serialized) +struct KeyboardLockerScriptCommandTests { + @Test @MainActor - func testLockCommandInvokesOnlyLockCapability() async throws { + func lockCommandInvokesOnlyLockCapability() async throws { let client = FakeAppleScriptAgentClient(isLocked: false) let result = try await LockKeyboardScriptCommand.perform(using: client) - XCTAssertNil(result) - XCTAssertEqual(client.lockCallCount, 1) - XCTAssertEqual(client.unlockCallCount, 0) - XCTAssertEqual(client.statusCallCount, 0) - XCTAssertTrue(client.isLocked) + #expect(result == nil) + #expect(client.lockCallCount == 1) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.isLocked) } + @Test @MainActor - func testUnlockCommandInvokesOnlyUnlockCapability() async throws { + func unlockCommandInvokesOnlyUnlockCapability() async throws { let client = FakeAppleScriptAgentClient(isLocked: true) let result = try await UnlockKeyboardScriptCommand.perform(using: client) - XCTAssertNil(result) - XCTAssertEqual(client.lockCallCount, 0) - XCTAssertEqual(client.unlockCallCount, 1) - XCTAssertEqual(client.statusCallCount, 0) - XCTAssertFalse(client.isLocked) + #expect(result == nil) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 1) + #expect(client.statusCallCount == 0) + #expect(!client.isLocked) } + @Test @MainActor - func testStatusCommandReturnsAuthoritativeBoolean() async throws { + func statusCommandReturnsAuthoritativeBoolean() async throws { let client = FakeAppleScriptAgentClient(isLocked: true) let result = try await GetKeyboardLockStatusScriptCommand.perform(using: client) - XCTAssertEqual(result as? NSNumber, NSNumber(value: true)) - XCTAssertEqual(client.lockCallCount, 0) - XCTAssertEqual(client.unlockCallCount, 0) - XCTAssertEqual(client.statusCallCount, 1) + #expect(result as? NSNumber == NSNumber(value: true)) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 1) } + @Test @MainActor - func testCommandPropagatesAgentFailure() async { + func commandPropagatesAgentFailure() async { let client = FakeAppleScriptAgentClient( isLocked: false, error: AppleScriptClientError.unavailable @@ -50,35 +55,37 @@ final class KeyboardLockerScriptCommandTests: XCTestCase { do { _ = try await LockKeyboardScriptCommand.perform(using: client) - XCTFail("Expected the Agent error to be propagated.") + Issue.record("Expected the Agent error to be propagated.") } catch { - XCTAssertEqual(error as? AppleScriptClientError, .unavailable) + #expect(error as? AppleScriptClientError == .unavailable) } } + @Test @MainActor - func testErrorPresentationIncludesClientRecoverySuggestion() { + func errorPresentationIncludesClientRecoverySuggestion() { let message = AppleScriptErrorPresentation.message( for: XPCClientError.serviceUnavailable ) - XCTAssertEqual( - message, - ExternalAutomationFailure(error: XPCClientError.serviceUnavailable).message + #expect( + message == + ExternalAutomationFailure(error: XPCClientError.serviceUnavailable).message ) - XCTAssertTrue(message.contains("The KeyboardLocker agent is not reachable.")) - XCTAssertTrue(message.contains("Open KeyboardLocker once")) + #expect(message.contains("The KeyboardLocker agent is not reachable.")) + #expect(message.contains("Open KeyboardLocker once")) } + @Test @MainActor - func testErrorPresentationUsesAppleEventTimeoutOnlyForClientTimeout() { - XCTAssertEqual( - AppleScriptErrorPresentation.errorNumber(for: XPCClientError.timedOut), - Int(errAETimeout) + func errorPresentationUsesAppleEventTimeoutOnlyForClientTimeout() { + #expect( + AppleScriptErrorPresentation.errorNumber(for: XPCClientError.timedOut) == + Int(errAETimeout) ) - XCTAssertEqual( - AppleScriptErrorPresentation.errorNumber(for: XPCClientError.serviceUnavailable), - Int(errAEEventFailed) + #expect( + AppleScriptErrorPresentation.errorNumber(for: XPCClientError.serviceUnavailable) == + Int(errAEEventFailed) ) } } diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift index 8ca01f9..0114398 100644 --- a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -1,30 +1,34 @@ import AppKit -import XCTest +import Testing -final class KeyboardLockerServicesProviderTests: XCTestCase { +@Suite(.serialized) +struct KeyboardLockerServicesProviderTests { private let pasteboard = NSPasteboard(name: NSPasteboard.Name("KeyboardLockerServicesProviderTests")) - func testProviderExportsEveryDeclaredServiceSelector() { + @Test + func providerExportsEveryDeclaredServiceSelector() { let provider = KeyboardLockerServicesProvider { _ in nil } - XCTAssertTrue(provider.responds(to: NSSelectorFromString("lockKeyboard:userData:error:"))) - XCTAssertTrue(provider.responds(to: NSSelectorFromString("unlockKeyboard:userData:error:"))) - XCTAssertTrue( + #expect(provider.responds(to: NSSelectorFromString("lockKeyboard:userData:error:"))) + #expect(provider.responds(to: NSSelectorFromString("unlockKeyboard:userData:error:"))) + #expect( provider.responds(to: NSSelectorFromString("showKeyboardLockStatus:userData:error:")) ) } - func testHandlerLeavesErrorUntouchedWhenActionSucceeds() { + @Test + func handlerLeavesErrorUntouchedWhenActionSucceeds() { let provider = KeyboardLockerServicesProvider { _ in nil } var error: NSString? provider.lockKeyboard(pasteboard, userData: nil, error: &error) - XCTAssertNil(error) + #expect(error == nil) } + @Test @MainActor - func testHandlerPublishesFailureBeforeScheduledPresentationRuns() async { + func handlerPublishesFailureBeforeScheduledPresentationRuns() async { let presenter = RecordingServicesFailurePresenter() let provider = KeyboardLockerServicesProvider( presentFailure: { presenter.present($0) } @@ -35,15 +39,16 @@ final class KeyboardLockerServicesProviderTests: XCTestCase { var error: NSString? provider.unlockKeyboard(pasteboard, userData: nil, error: &error) - XCTAssertEqual(error, "Agent not reachable") - XCTAssertTrue(presenter.failures.isEmpty) + #expect(error == "Agent not reachable") + #expect(presenter.failures.isEmpty) await Task.yield() - XCTAssertEqual(presenter.failures, [.init(message: "Agent not reachable")]) + #expect(presenter.failures == [.init(message: "Agent not reachable")]) } - func testHandlerReportsTimeoutWhenActionDoesNotFinish() { + @Test + func handlerReportsTimeoutWhenActionDoesNotFinish() { let provider = KeyboardLockerServicesProvider(waitTimeout: 0.1) { _ in try? await Task.sleep(nanoseconds: 10_000_000_000) return nil @@ -52,11 +57,12 @@ final class KeyboardLockerServicesProviderTests: XCTestCase { var error: NSString? provider.showKeyboardLockStatus(pasteboard, userData: nil, error: &error) - XCTAssertTrue(error?.contains("did not finish") == true) + #expect(error?.contains("did not finish") == true) } + @Test @MainActor - func testTimedOutHandlerPresentsLateFailureAfterPublishingTimeout() async { + func timedOutHandlerPresentsLateFailureAfterPublishingTimeout() async { let presenter = RecordingServicesFailurePresenter() let provider = KeyboardLockerServicesProvider( waitTimeout: 0.01, @@ -69,12 +75,12 @@ final class KeyboardLockerServicesProviderTests: XCTestCase { var error: NSString? provider.lockKeyboard(pasteboard, userData: nil, error: &error) - XCTAssertTrue(error?.contains("did not finish") == true) - XCTAssertTrue(presenter.failures.isEmpty) + #expect(error?.contains("did not finish") == true) + #expect(presenter.failures.isEmpty) try? await Task.sleep(nanoseconds: 100_000_000) - XCTAssertEqual(presenter.failures, [.init(message: "Late agent failure")]) + #expect(presenter.failures == [.init(message: "Late agent failure")]) } } diff --git a/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift b/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift index db18eee..30c9ae3 100644 --- a/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift @@ -1,30 +1,34 @@ import Foundation -import XCTest +import Testing -final class KeyboardLockerURLRouteTests: XCTestCase { - func testCanonicalURLsMapToDesiredStateActions() throws { - XCTAssertEqual( - try KeyboardLockerURLRoute(url: url("keyboardlocker://lock")).action, - .lock +@Suite(.serialized) +struct KeyboardLockerURLRouteTests { + @Test + func canonicalURLsMapToDesiredStateActions() throws { + #expect( + try KeyboardLockerURLRoute(url: url("keyboardlocker://lock")).action == + .lock ) - XCTAssertEqual( - try KeyboardLockerURLRoute(url: url("keyboardlocker://unlock")).action, - .unlock + #expect( + try KeyboardLockerURLRoute(url: url("keyboardlocker://unlock")).action == + .unlock ) - XCTAssertEqual( - try KeyboardLockerURLRoute(url: url("keyboardlocker://status")).action, - .status + #expect( + try KeyboardLockerURLRoute(url: url("keyboardlocker://status")).action == + .status ) } - func testSchemeAndHostFollowURLCaseInsensitivity() throws { - XCTAssertEqual( - try KeyboardLockerURLRoute(url: url("KEYBOARDLOCKER://LOCK")).action, - .lock + @Test + func schemeAndHostFollowURLCaseInsensitivity() throws { + #expect( + try KeyboardLockerURLRoute(url: url("KEYBOARDLOCKER://LOCK")).action == + .lock ) } - func testInvalidOrAmbiguousShapesAreRejected() { + @Test + func invalidOrAmbiguousShapesAreRejected() { let invalidURLs = [ "https://lock", "keyboardlocker:lock", @@ -42,41 +46,45 @@ final class KeyboardLockerURLRouteTests: XCTestCase { ] for value in invalidURLs { - XCTAssertThrowsError( - try KeyboardLockerURLRoute(url: url(value)), + #expect( + throws: KeyboardLockerURLRouteError.invalidURL, "Expected \(value) to be rejected." - ) { error in - XCTAssertEqual(error as? KeyboardLockerURLRouteError, .invalidURL) + ) { + try KeyboardLockerURLRoute(url: url(value)) } } } - func testMultipleURLsPreserveDeliveryOrderAndRepresentFailures() { + @Test + func multipleURLsPreserveDeliveryOrderAndRepresentFailures() { let requests = KeyboardLockerURLRoute.requests(for: [ url("keyboardlocker://lock"), url("keyboardlocker://invalid"), url("keyboardlocker://unlock"), ]) - XCTAssertEqual(requests.count, 3) - XCTAssertEqual(requests[0], .action(.lock)) + #expect(requests.count == 3) + #expect(requests[0] == .action(.lock)) guard case let .failure(failure) = requests[1] else { - return XCTFail("Expected the invalid route to become a failure request.") + Issue.record("Expected the invalid route to become a failure request.") + return } - XCTAssertTrue(failure.message.contains("invalid automation URL")) - XCTAssertFalse(failure.message.contains("keyboardlocker://invalid")) - XCTAssertEqual(requests[2], .action(.unlock)) + #expect(failure.message.contains("invalid automation URL")) + #expect(!failure.message.contains("keyboardlocker://invalid")) + #expect(requests[2] == .action(.unlock)) } - func testFailureMessageNeverEchoesQueryData() { + @Test + func failureMessageNeverEchoesQueryData() { let requests = KeyboardLockerURLRoute.requests(for: [ url("keyboardlocker://lock?token=do-not-echo"), ]) guard case let .failure(failure) = requests.first else { - return XCTFail("Expected the URL to be rejected.") + Issue.record("Expected the URL to be rejected.") + return } - XCTAssertFalse(failure.message.contains("do-not-echo")) + #expect(!failure.message.contains("do-not-echo")) } private func url(_ value: String) -> URL { diff --git a/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift index 7b46fce..ed41d86 100644 --- a/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift @@ -1,18 +1,21 @@ import Client import Foundation -import XCTest +import Testing -final class KeyboardLockerWidgetTimelineTests: XCTestCase { +@Suite(.serialized) +struct KeyboardLockerWidgetTimelineTests { private let now = Date(timeIntervalSinceReferenceDate: 10000) - func testRegularRefreshUsesBudgetFriendlyFallbackInterval() { - XCTAssertEqual( - KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval, - 15 * 60 + @Test + func regularRefreshUsesBudgetFriendlyFallbackInterval() { + #expect( + KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval == + 15 * 60 ) } - func testLoaderPublishesAuthoritativeSnapshot() async { + @Test + func loaderPublishesAuthoritativeSnapshot() async { let snapshot = LockStatusSnapshot( capturedAt: now, isLocked: true, @@ -26,10 +29,11 @@ final class KeyboardLockerWidgetTimelineTests: XCTestCase { let entry = await loader.entry(at: now) - XCTAssertEqual(entry, KeyboardLockerWidgetEntry(date: now, state: .available(snapshot))) + #expect(entry == KeyboardLockerWidgetEntry(date: now, state: .available(snapshot))) } - func testLoaderPublishesExplicitUnavailableState() async { + @Test + func loaderPublishesExplicitUnavailableState() async { let loader = KeyboardLockerWidgetSnapshotLoader { throw XPCClientError.serviceUnavailable } @@ -37,13 +41,15 @@ final class KeyboardLockerWidgetTimelineTests: XCTestCase { let entry = await loader.entry(at: now) guard case let .unavailable(message) = entry.state else { - return XCTFail("Expected an unavailable entry.") + Issue.record("Expected an unavailable entry.") + return } - XCTAssertTrue(message.contains("The KeyboardLocker agent is not reachable.")) - XCTAssertTrue(message.contains("Open KeyboardLocker once")) + #expect(message.contains("The KeyboardLocker agent is not reachable.")) + #expect(message.contains("Open KeyboardLocker once")) } - func testRefreshReconcilesImmediatelyAfterEarlierAutoUnlockDeadline() { + @Test + func refreshReconcilesImmediatelyAfterEarlierAutoUnlockDeadline() { let deadline = now.addingTimeInterval(20) let snapshot = LockStatusSnapshot( capturedAt: now, @@ -57,13 +63,14 @@ final class KeyboardLockerWidgetTimelineTests: XCTestCase { snapshot } - XCTAssertEqual( - loader.nextRefreshDate(after: entry, now: now), - deadline.addingTimeInterval(1) + #expect( + loader.nextRefreshDate(after: entry, now: now) == + deadline.addingTimeInterval(1) ) } - func testRefreshUsesRegularIntervalWithoutEarlierDeadline() { + @Test + func refreshUsesRegularIntervalWithoutEarlierDeadline() { let snapshot = LockStatusSnapshot( capturedAt: now, isLocked: false, @@ -76,9 +83,9 @@ final class KeyboardLockerWidgetTimelineTests: XCTestCase { snapshot } - XCTAssertEqual( - loader.nextRefreshDate(after: entry, now: now), - now.addingTimeInterval(KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval) + #expect( + loader.nextRefreshDate(after: entry, now: now) == + now.addingTimeInterval(KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval) ) } } diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift index 060799b..3b141ab 100644 --- a/KeyboardLockerModelTests/KlockCommandLineTests.swift +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -1,16 +1,19 @@ import Client import CoreGraphics import Foundation -import XCTest +import Testing -final class KlockCommandLineTests: XCTestCase { +@Suite(.serialized) +struct KlockCommandLineTests { // MARK: - Parser - func testParseReturnsNilWhenNoArgumentsGiven() throws { - XCTAssertNil(try KlockCommandLineParser.parse([])) + @Test + func parseReturnsNilWhenNoArgumentsGiven() throws { + #expect(try KlockCommandLineParser.parse([]) == nil) } - func testParseAcceptsEveryValidCommandSpelling() throws { + @Test + func parseAcceptsEveryValidCommandSpelling() throws { let cases: [(arguments: [String], expected: KlockCommand)] = [ (["--help"], .help), (["-h"], .help), @@ -30,32 +33,27 @@ final class KlockCommandLineTests: XCTestCase { ] for (arguments, expected) in cases { - XCTAssertEqual( - try KlockCommandLineParser.parse(arguments), - expected, - "arguments: \(arguments)" - ) + #expect(try KlockCommandLineParser.parse(arguments) == expected, "arguments: \(arguments)") } } - func testParseRejectsUnknownCommands() { + @Test + func parseRejectsUnknownCommands() { let cases = [["bogus"], ["--unknown-flag"]] for arguments in cases { - XCTAssertThrowsError( - try KlockCommandLineParser.parse(arguments), + let error = #expect( + throws: KlockCommandLineError.self, "arguments: \(arguments)" - ) { error in - XCTAssertEqual( - error as? KlockCommandLineError, - .unknownCommand(arguments[0]), - "arguments: \(arguments)" - ) + ) { + try KlockCommandLineParser.parse(arguments) } + #expect(error == .unknownCommand(arguments[0]), "arguments: \(arguments)") } } - func testParseRejectsUnexpectedArguments() { + @Test + func parseRejectsUnexpectedArguments() { let cases: [(arguments: [String], expected: KlockCommandLineError)] = [ (["lock", "extra"], .unexpectedArguments(["extra"])), (["lock", "--no-wait", "extra"], .unexpectedArguments(["--no-wait", "extra"])), @@ -70,194 +68,196 @@ final class KlockCommandLineTests: XCTestCase { ] for (arguments, expected) in cases { - XCTAssertThrowsError( - try KlockCommandLineParser.parse(arguments), + let error = #expect( + throws: KlockCommandLineError.self, "arguments: \(arguments)" - ) { error in - XCTAssertEqual(error as? KlockCommandLineError, expected, "arguments: \(arguments)") + ) { + try KlockCommandLineParser.parse(arguments) } + #expect(error == expected, "arguments: \(arguments)") } } // MARK: - Command Execution - func testInteractiveLockReportsAlreadyLockedWithoutWaiting() async { + @Test + func interactiveLockReportsAlreadyLockedWithoutWaiting() async { let client = FakeKlockClient() client.lockInteractivelyResult = .success(.alreadyLocked) let result = await runKlock(arguments: ["lock"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Already locked. This command did not create a new lock."]) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.lockInteractivelyCalls, 1) - XCTAssertEqual(client.waitUntilUnlockedCalls, 0) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Already locked. This command did not create a new lock."]) + #expect(result.stderr == []) + #expect(client.lockInteractivelyCalls == 1) + #expect(client.waitUntilUnlockedCalls == 0) } - func testInteractiveLockPrintsHotkeyWaitsAndConfirmsUnlock() async { + @Test + func interactiveLockPrintsHotkeyWaitsAndConfirmsUnlock() async { let client = FakeKlockClient() let hotkey = KeyboardLockerSettings.testFixture.unlockHotkey.displayString let result = await runKlock(arguments: ["lock"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual( - result.stdout, - [ - "Locked. Press \(hotkey) or Ctrl+C to unlock.", - "Unlocked.", - ] - ) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.currentSettingsCalls, 1) - XCTAssertEqual(client.waitUntilUnlockedCalls, 1) + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Locked. Press \(hotkey) or Ctrl+C to unlock.", + "Unlocked.", + ]) + #expect(result.stderr == []) + #expect(client.currentSettingsCalls == 1) + #expect(client.waitUntilUnlockedCalls == 1) } - func testInteractiveLockFallsBackToManualHintWhenSettingsUnavailable() async { + @Test + func interactiveLockFallsBackToManualHintWhenSettingsUnavailable() async { let client = FakeKlockClient() client.settingsResult = .failure(Self.makeError("settings unavailable")) let result = await runKlock(arguments: ["lock"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual( - result.stdout, - [ - "Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.", - "Unlocked.", - ] - ) - XCTAssertEqual( - result.stderr, - ["Warning: Could not read the configured unlock shortcut: settings unavailable"] - ) + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.", + "Unlocked.", + ]) + #expect(result.stderr == ["Warning: Could not read the configured unlock shortcut: settings unavailable"]) } - func testInteractiveLockFailureReportsErrorOnStandardError() async throws { + @Test + func interactiveLockFailureReportsErrorOnStandardError() async throws { let client = FakeKlockClient() let error = XPCClientError.serviceUnavailable client.lockInteractivelyResult = .failure(error) let result = await runKlock(arguments: ["lock"], client: client) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, []) - XCTAssertEqual( - result.stderr, - [ - "Error: \(error.localizedDescription)", - " \(try XCTUnwrap(error.recoverySuggestion))", - " Or run `klock register-agent` to register it from Terminal.", - ] - ) - XCTAssertEqual(client.waitUntilUnlockedCalls, 0) + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(try result.stderr == [ + "Error: \(error.localizedDescription)", + " \(#require(error.recoverySuggestion))", + " Or run `klock register-agent` to register it from Terminal.", + ]) + #expect(client.waitUntilUnlockedCalls == 0) } - func testNonInteractiveLockPrintsLockedWithoutWaiting() async { + @Test + func nonInteractiveLockPrintsLockedWithoutWaiting() async { let client = FakeKlockClient() let result = await runKlock(arguments: ["lock", "--no-wait"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Locked."]) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.lockCalls, 1) - XCTAssertEqual(client.lockInteractivelyCalls, 0) - XCTAssertEqual(client.waitUntilUnlockedCalls, 0) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Locked."]) + #expect(result.stderr == []) + #expect(client.lockCalls == 1) + #expect(client.lockInteractivelyCalls == 0) + #expect(client.waitUntilUnlockedCalls == 0) } - func testNonInteractiveLockFailureExitsWithError() async { + @Test + func nonInteractiveLockFailureExitsWithError() async { let client = FakeKlockClient() client.lockResult = .failure(Self.makeError("lock failed")) let result = await runKlock(arguments: ["lock", "--no-wait"], client: client) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, []) - XCTAssertEqual(result.stderr, ["Error: lock failed"]) + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: lock failed"]) } - func testUnlockPrintsUnlockedOnSuccess() async { + @Test + func unlockPrintsUnlockedOnSuccess() async { let client = FakeKlockClient() let result = await runKlock(arguments: ["unlock"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Unlocked."]) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.unlockCalls, 1) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Unlocked."]) + #expect(result.stderr == []) + #expect(client.unlockCalls == 1) } - func testUnlockFailureReportsErrorOnStandardError() async { + @Test + func unlockFailureReportsErrorOnStandardError() async { let client = FakeKlockClient() client.unlockResult = .failure(Self.makeError("unlock failed")) let result = await runKlock(arguments: ["unlock"], client: client) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, []) - XCTAssertEqual(result.stderr, ["Error: unlock failed"]) + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: unlock failed"]) } - func testTogglePrintsResultingStateAcrossRoundTrip() async { + @Test + func togglePrintsResultingStateAcrossRoundTrip() async { let client = FakeKlockClient() client.toggleResults = [.success(true), .success(false)] var result = await runKlock(arguments: ["toggle"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Locked."]) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Locked."]) result = await runKlock(arguments: ["toggle"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Unlocked."]) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Unlocked."]) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.toggleCalls, 2) + #expect(result.stderr == []) + #expect(client.toggleCalls == 2) } - func testToggleFailureReportsErrorOnStandardError() async { + @Test + func toggleFailureReportsErrorOnStandardError() async { let client = FakeKlockClient() client.toggleResult = .failure(Self.makeError("toggle failed")) let result = await runKlock(arguments: ["toggle"], client: client) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, []) - XCTAssertEqual(result.stderr, ["Error: toggle failed"]) + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: toggle failed"]) } - func testStatusPrintsHumanReadableState() async { + @Test + func statusPrintsHumanReadableState() async { let client = FakeKlockClient() client.statusResult = .success(true) var result = await runKlock(arguments: ["status"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Locked"]) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Locked"]) client.statusResult = .success(false) result = await runKlock(arguments: ["status"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Unlocked"]) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Unlocked"]) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.statusCalls, 2) + #expect(result.stderr == []) + #expect(client.statusCalls == 2) } - func testStatusPrintsJSONState() async { + @Test + func statusPrintsJSONState() async { let client = FakeKlockClient() client.statusResult = .success(true) var result = await runKlock(arguments: ["status", "--json"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, [#"{"locked":true}"#]) + #expect(result.exitCode == 0) + #expect(result.stdout == [#"{"locked":true}"#]) client.statusResult = .success(false) result = await runKlock(arguments: ["status", "--json"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, [#"{"locked":false}"#]) + #expect(result.exitCode == 0) + #expect(result.stdout == [#"{"locked":false}"#]) } - func testStatusSnapshotPrintsSnapshotContract() async { + @Test + func statusSnapshotPrintsSnapshotContract() async { let client = FakeKlockClient() client.lockStatusSnapshotResult = .success( LockStatusSnapshot( @@ -271,33 +271,32 @@ final class KlockCommandLineTests: XCTestCase { let result = await runKlock(arguments: ["status", "--snapshot"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual( - result.stdout, - [ - """ - {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} - """, - ] - ) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.lockStatusSnapshotCalls, 1) - XCTAssertEqual(client.statusCalls, 0) + #expect(result.exitCode == 0) + #expect(result.stdout == [ + """ + {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} + """, + ]) + #expect(result.stderr == []) + #expect(client.lockStatusSnapshotCalls == 1) + #expect(client.statusCalls == 0) } - func testStatusSnapshotFailureReportsErrorWithoutFallback() async { + @Test + func statusSnapshotFailureReportsErrorWithoutFallback() async { let client = FakeKlockClient() client.lockStatusSnapshotResult = .failure(Self.makeError("snapshot unsupported")) let result = await runKlock(arguments: ["status", "--snapshot"], client: client) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, []) - XCTAssertEqual(result.stderr, ["Error: snapshot unsupported"]) - XCTAssertEqual(client.statusCalls, 0) + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: snapshot unsupported"]) + #expect(client.statusCalls == 0) } - func testInteractiveLockExitsWithErrorWhenWaitingForUnlockFails() async throws { + @Test + func interactiveLockExitsWithErrorWhenWaitingForUnlockFails() async throws { let client = FakeKlockClient() let error = XPCClientError.serviceUnavailable client.waitUntilUnlockedResult = .failure(error) @@ -305,33 +304,32 @@ final class KlockCommandLineTests: XCTestCase { let result = await runKlock(arguments: ["lock"], client: client) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, ["Locked. Press \(hotkey) or Ctrl+C to unlock."]) - XCTAssertEqual( - result.stderr, - [ - "Error: \(error.localizedDescription)", - " \(try XCTUnwrap(error.recoverySuggestion))", - " Or run `klock register-agent` to register it from Terminal.", - ] - ) + #expect(result.exitCode == 1) + #expect(result.stdout == ["Locked. Press \(hotkey) or Ctrl+C to unlock."]) + #expect(try result.stderr == [ + "Error: \(error.localizedDescription)", + " \(#require(error.recoverySuggestion))", + " Or run `klock register-agent` to register it from Terminal.", + ]) } // MARK: - Request Access - func testRequestAccessReportsAlreadyGrantedWithoutPrompting() async { + @Test + func requestAccessReportsAlreadyGrantedWithoutPrompting() async { let client = FakeKlockClient() client.hasAccessibilityPermissionResult = .success(true) let result = await runKlock(arguments: ["request-access"], client: client) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["Accessibility access is already granted."]) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.requestAccessibilityPermissionCalls, 0) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Accessibility access is already granted."]) + #expect(result.stderr == []) + #expect(client.requestAccessibilityPermissionCalls == 0) } - func testRequestAccessPromptsThenPollsUntilGranted() async { + @Test + func requestAccessPromptsThenPollsUntilGranted() async { let client = FakeKlockClient() client.hasAccessibilityPermissionResults = [ .success(false), @@ -345,20 +343,18 @@ final class KlockCommandLineTests: XCTestCase { accessPoll: (attempts: 5, interval: .zero) ) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual( - result.stdout, - [ - "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…", - "Accessibility access granted.", - ] - ) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.requestAccessibilityPermissionCalls, 1) - XCTAssertEqual(client.hasAccessibilityPermissionCalls, 3) + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…", + "Accessibility access granted.", + ]) + #expect(result.stderr == []) + #expect(client.requestAccessibilityPermissionCalls == 1) + #expect(client.hasAccessibilityPermissionCalls == 3) } - func testRequestAccessReportsPendingWhenWindowExpires() async { + @Test + func requestAccessReportsPendingWhenWindowExpires() async { let client = FakeKlockClient() let result = await runKlock( @@ -367,27 +363,29 @@ final class KlockCommandLineTests: XCTestCase { accessPoll: (attempts: 2, interval: .zero) ) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout.first, "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…") - XCTAssertEqual(result.stdout.last, "Accessibility access is not granted yet. Enable KeyboardLocker in System Settings → Privacy & Security → Accessibility, then re-run `klock request-access`.") - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(client.requestAccessibilityPermissionCalls, 1) + #expect(result.exitCode == 1) + #expect(result.stdout.first == "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…") + #expect(result.stdout.last == "Accessibility access is not granted yet. Enable KeyboardLocker in System Settings → Privacy & Security → Accessibility, then re-run `klock request-access`.") + #expect(result.stderr == []) + #expect(client.requestAccessibilityPermissionCalls == 1) } - func testRequestAccessFailureReportsErrorOnStandardError() async { + @Test + func requestAccessFailureReportsErrorOnStandardError() async { let client = FakeKlockClient() client.requestAccessibilityPermissionResult = .failure(Self.makeError("prompt failed")) let result = await runKlock(arguments: ["request-access"], client: client) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, []) - XCTAssertEqual(result.stderr, ["Error: prompt failed"]) + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: prompt failed"]) } // MARK: - Register Agent - func testRegisterAgentReportsAlreadyReachableWithoutOpeningApp() async { + @Test + func registerAgentReportsAlreadyReachableWithoutOpeningApp() async { let client = FakeKlockClient() var openCalls = 0 @@ -397,14 +395,15 @@ final class KlockCommandLineTests: XCTestCase { openKeyboardLockerApp: { openCalls += 1 } ) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(result.stdout, ["The KeyboardLocker agent is already registered and reachable."]) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(openCalls, 0) - XCTAssertEqual(client.statusCalls, 1) + #expect(result.exitCode == 0) + #expect(result.stdout == ["The KeyboardLocker agent is already registered and reachable."]) + #expect(result.stderr == []) + #expect(openCalls == 0) + #expect(client.statusCalls == 1) } - func testRegisterAgentOpensAppAndConfirmsWhenAgentBecomesReachable() async { + @Test + func registerAgentOpensAppAndConfirmsWhenAgentBecomesReachable() async { let client = FakeKlockClient() client.statusResults = [ .failure(Self.makeError("unreachable")), @@ -419,20 +418,18 @@ final class KlockCommandLineTests: XCTestCase { agentPoll: (attempts: 3, interval: .zero) ) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual( - result.stdout, - [ - "Launched KeyboardLocker to register its background agent.", - "The KeyboardLocker agent is registered and reachable.", - ] - ) - XCTAssertEqual(result.stderr, []) - XCTAssertEqual(openCalls, 1) - XCTAssertEqual(client.statusCalls, 2) + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Launched KeyboardLocker to register its background agent.", + "The KeyboardLocker agent is registered and reachable.", + ]) + #expect(result.stderr == []) + #expect(openCalls == 1) + #expect(client.statusCalls == 2) } - func testRegisterAgentFailsWhenAgentStaysUnreachable() async { + @Test + func registerAgentFailsWhenAgentStaysUnreachable() async { let client = FakeKlockClient() client.statusResult = .failure(Self.makeError("unreachable")) var openCalls = 0 @@ -444,14 +441,15 @@ final class KlockCommandLineTests: XCTestCase { agentPoll: (attempts: 2, interval: .zero) ) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, ["Launched KeyboardLocker to register its background agent."]) - XCTAssertEqual(result.stderr.first, "Error: The KeyboardLocker agent is not reachable yet.") - XCTAssertEqual(openCalls, 1) - XCTAssertEqual(client.statusCalls, 3) + #expect(result.exitCode == 1) + #expect(result.stdout == ["Launched KeyboardLocker to register its background agent."]) + #expect(result.stderr.first == "Error: The KeyboardLocker agent is not reachable yet.") + #expect(openCalls == 1) + #expect(client.statusCalls == 3) } - func testRegisterAgentReportsOpenFailure() async { + @Test + func registerAgentReportsOpenFailure() async throws { let client = FakeKlockClient() client.statusResult = .failure(Self.makeError("unreachable")) let error = KlockAppOpener.OpenerError.appNotFound @@ -462,20 +460,18 @@ final class KlockCommandLineTests: XCTestCase { openKeyboardLockerApp: { throw error } ) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(result.stdout, []) - XCTAssertEqual( - result.stderr, - [ - "Error: \(error.localizedDescription)", - " \(try XCTUnwrap(error.recoverySuggestion))", - ] - ) + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(try result.stderr == [ + "Error: \(error.localizedDescription)", + " \(#require(error.recoverySuggestion))", + ]) } // MARK: - Termination Guard - func testInteractiveLockInstallsAndCancelsTerminationGuardWhileWaiting() async { + @Test + func interactiveLockInstallsAndCancelsTerminationGuardWhileWaiting() async { let client = FakeKlockClient() let terminationGuard = FakeKlockTerminationGuard() @@ -485,12 +481,13 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testSignalDuringAcquisitionReleasesConfirmedLockBeforeTerminating() async { + @Test + func signalDuringAcquisitionReleasesConfirmedLockBeforeTerminating() async { let client = FakeKlockClient() let terminationGuard = FakeKlockTerminationGuard() client.onLockInteractively = { @@ -503,13 +500,14 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 128 + SIGTERM) - XCTAssertEqual(client.unlockCalls, 1) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 128 + SIGTERM) + #expect(client.unlockCalls == 1) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testSignalDuringUnlockWaitWinsOverConcurrentNormalCompletion() async { + @Test + func signalDuringUnlockWaitWinsOverConcurrentNormalCompletion() async { let client = FakeKlockClient() let terminationGuard = FakeKlockTerminationGuard() client.onWaitUntilUnlocked = { @@ -522,14 +520,15 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 128 + SIGTERM) - XCTAssertEqual(client.waitUntilUnlockedCalls, 1) - XCTAssertEqual(client.unlockCalls, 1) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 128 + SIGTERM) + #expect(client.waitUntilUnlockedCalls == 1) + #expect(client.unlockCalls == 1) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testInteractiveLockAlreadyLockedInstallsAndCancelsTerminationGuard() async { + @Test + func interactiveLockAlreadyLockedInstallsAndCancelsTerminationGuard() async { let client = FakeKlockClient() client.lockInteractivelyResult = .success(.alreadyLocked) let terminationGuard = FakeKlockTerminationGuard() @@ -540,13 +539,14 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(client.unlockCalls, 0) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 0) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testInteractiveLockFailureInstallsAndCancelsTerminationGuard() async { + @Test + func interactiveLockFailureInstallsAndCancelsTerminationGuard() async { let client = FakeKlockClient() client.lockInteractivelyResult = .failure(Self.makeError("lock failed")) let terminationGuard = FakeKlockTerminationGuard() @@ -557,13 +557,14 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(client.unlockCalls, 0) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 1) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testSignalDuringAlreadyLockedAcquisitionDoesNotUnlockExistingLock() async { + @Test + func signalDuringAlreadyLockedAcquisitionDoesNotUnlockExistingLock() async { let client = FakeKlockClient() client.lockInteractivelyResult = .success(.alreadyLocked) let terminationGuard = FakeKlockTerminationGuard() @@ -577,13 +578,14 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 128 + SIGTERM) - XCTAssertEqual(client.unlockCalls, 0) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 128 + SIGTERM) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testSignalDuringFailedAcquisitionDoesNotUnlock() async { + @Test + func signalDuringFailedAcquisitionDoesNotUnlock() async { let client = FakeKlockClient() client.lockInteractivelyResult = .failure(Self.makeError("lock failed")) let terminationGuard = FakeKlockTerminationGuard() @@ -597,13 +599,14 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 128 + SIGHUP) - XCTAssertEqual(client.unlockCalls, 0) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 128 + SIGHUP) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testInteractiveLockWaitFailureCancelsTerminationGuard() async { + @Test + func interactiveLockWaitFailureCancelsTerminationGuard() async { let client = FakeKlockClient() client.waitUntilUnlockedResult = .failure(Self.makeError("wait failed")) let terminationGuard = FakeKlockTerminationGuard() @@ -614,12 +617,13 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(terminationGuard.installCalls, 1) - XCTAssertEqual(terminationGuard.cancelCalls, 1) + #expect(result.exitCode == 1) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) } - func testNonInteractiveLockSkipsTerminationGuard() async { + @Test + func nonInteractiveLockSkipsTerminationGuard() async { let client = FakeKlockClient() let terminationGuard = FakeKlockTerminationGuard() @@ -629,32 +633,34 @@ final class KlockCommandLineTests: XCTestCase { terminationGuard: terminationGuard ) - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(terminationGuard.installCalls, 0) + #expect(result.exitCode == 0) + #expect(terminationGuard.installCalls == 0) } - func testUnlockBeforeTerminationReleasesCreatedLock() async { + @Test + func unlockBeforeTerminationReleasesCreatedLock() async { let client = FakeKlockClient() var stderr: [String] = [] await KlockCLI.unlockBeforeTermination(client: client, printError: { stderr.append($0) }) - XCTAssertEqual(client.unlockCalls, 1) - XCTAssertEqual(stderr, ["Terminated; released the keyboard lock created by this command."]) + #expect(client.unlockCalls == 1) + #expect(stderr == ["Terminated; released the keyboard lock created by this command."]) } - func testUnlockBeforeTerminationReportsFailureWithRecoveryHint() async { + @Test + func unlockBeforeTerminationReportsFailureWithRecoveryHint() async { let client = FakeKlockClient() client.unlockResult = .failure(Self.makeError("agent gone")) var stderr: [String] = [] await KlockCLI.unlockBeforeTermination(client: client, printError: { stderr.append($0) }) - XCTAssertEqual(client.unlockCalls, 1) - XCTAssertEqual(stderr.count, 1) - XCTAssertTrue(stderr[0].contains("could not release"), "stderr: \(stderr)") - XCTAssertTrue(stderr[0].contains("agent gone"), "stderr: \(stderr)") - XCTAssertTrue(stderr[0].contains("Unlock Now"), "stderr: \(stderr)") + #expect(client.unlockCalls == 1) + #expect(stderr.count == 1) + #expect(stderr[0].contains("could not release"), "stderr: \(stderr)") + #expect(stderr[0].contains("agent gone"), "stderr: \(stderr)") + #expect(stderr[0].contains("Unlock Now"), "stderr: \(stderr)") } // MARK: - Helpers @@ -821,7 +827,7 @@ private final class FakeKlockTerminationGuard: KlockTerminationGuarding, @unchec } /// Default guard for tests that do not exercise the termination path: never touches process -/// signal state, so the XCTest process keeps its default signal dispositions. +/// signal state, so the test process keeps its default signal dispositions. private struct NullKlockTerminationGuard: KlockTerminationGuarding { func install(onTermination _: @escaping (Int32) -> Void) -> () -> Void { {} diff --git a/KeyboardLockerModelTests/KlockStatusOutputTests.swift b/KeyboardLockerModelTests/KlockStatusOutputTests.swift index f057bdc..bbe503f 100644 --- a/KeyboardLockerModelTests/KlockStatusOutputTests.swift +++ b/KeyboardLockerModelTests/KlockStatusOutputTests.swift @@ -1,24 +1,28 @@ import Client import Foundation -import XCTest +import Testing -final class KlockStatusOutputTests: XCTestCase { - func testHumanReadableOutputPreservesExistingContract() { - XCTAssertEqual(KlockStatusOutput.humanReadable.render(isLocked: true), "Locked") - XCTAssertEqual(KlockStatusOutput.humanReadable.render(isLocked: false), "Unlocked") +@Suite(.serialized) +struct KlockStatusOutputTests { + @Test + func humanReadableOutputPreservesExistingContract() { + #expect(KlockStatusOutput.humanReadable.render(isLocked: true) == "Locked") + #expect(KlockStatusOutput.humanReadable.render(isLocked: false) == "Unlocked") } - func testJSONOutputUsesStableBooleanField() throws { + @Test + func jSONOutputUsesStableBooleanField() throws { let locked = KlockStatusOutput.json.render(isLocked: true) let unlocked = KlockStatusOutput.json.render(isLocked: false) - XCTAssertEqual(locked, #"{"locked":true}"#) - XCTAssertEqual(unlocked, #"{"locked":false}"#) - XCTAssertEqual(try decodedLockedValue(from: locked), true) - XCTAssertEqual(try decodedLockedValue(from: unlocked), false) + #expect(locked == #"{"locked":true}"#) + #expect(unlocked == #"{"locked":false}"#) + #expect(try decodedLockedValue(from: locked) == true) + #expect(try decodedLockedValue(from: unlocked) == false) } - func testSnapshotOutputRendersLockedSnapshotWithFixedKeyOrder() { + @Test + func snapshotOutputRendersLockedSnapshotWithFixedKeyOrder() { let output = KlockStatusOutput.render( snapshot: makeSnapshot( isLocked: true, @@ -27,22 +31,23 @@ final class KlockStatusOutputTests: XCTestCase { ) ) - XCTAssertEqual( - output, - """ - {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} - """ + #expect( + output == + """ + {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} + """ ) } - func testSnapshotOutputRendersUnlockedSnapshotWithNullDates() { + @Test + func snapshotOutputRendersUnlockedSnapshotWithNullDates() { let output = KlockStatusOutput.render( snapshot: makeSnapshot(isLocked: false, startedAt: nil, autoUnlockTargetDate: nil) ) - XCTAssertEqual( - output, - #"{"autoUnlockTargetDate":null,"locked":false,"startedAt":null,"unlockHotkey":"⌃⌘L"}"# + #expect( + output == + #"{"autoUnlockTargetDate":null,"locked":false,"startedAt":null,"unlockHotkey":"⌃⌘L"}"# ) } diff --git a/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift b/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift index 62ae5f0..2109bbe 100644 --- a/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift +++ b/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift @@ -1,10 +1,12 @@ import Foundation -import XCTest +import Testing -final class SafetyCheckExperienceStoreTests: XCTestCase { - func testCompletionPersistsOnlyAfterBeingMarked() throws { +@Suite(.serialized) +struct SafetyCheckExperienceStoreTests { + @Test + func completionPersistsOnlyAfterBeingMarked() throws { let suiteName = "SafetyCheckExperienceStoreTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } @@ -13,10 +15,10 @@ final class SafetyCheckExperienceStoreTests: XCTestCase { completionKey: "completed" ) - XCTAssertFalse(store.hasCompletedSafetyCheck) + #expect(!store.hasCompletedSafetyCheck) store.markCompleted() - XCTAssertTrue(store.hasCompletedSafetyCheck) + #expect(store.hasCompletedSafetyCheck) } } diff --git a/docs/development.md b/docs/development.md index f8ce3fd..dd584db 100644 --- a/docs/development.md +++ b/docs/development.md @@ -96,6 +96,8 @@ ## 测试须知 +`Core/Tests` 与 `KeyboardLockerModelTests` 统一使用 Swift Testing (`import Testing`, `@Suite`, `@Test`, `#expect` / `#require`);不要在新测试中重新引入 XCTest。现有 `swift test`、`xcodebuild test` 与 CI entrypoint 不变。 + ### 在 Terminal 中使用开发版 `klock` 开发阶段可用脚本安装、检查或移除指向 Debug App 内已签名 `klock` 的 symbolic link: From f8638131b1c7c638be3b3a4cd33dc9cdb1ba717d Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 13 Aug 2026 00:05:33 +0800 Subject: [PATCH 100/106] docs: move editable settings beyond MVP Define the MVP settings contract as read-only and keep Agent-owned writes as a post-MVP extension. Clarify that LockEngine's update hook is internal rather than a missing user capability. --- docs/architecture.md | 6 +++--- docs/development.md | 6 +++--- docs/xpc.md | 2 ++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 311f0e5..b012228 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ - **分层分对了,轴选错了。** 旧方案按"关注点"垂直分层非常漂亮,却没有先按"进程边界"水平切分,于是每一层都被复制进三个进程。 → **教训**:干净的类型分层 ≠ 正确的系统架构。有多个入口进程时,**第一刀切在进程边界(谁执行 / 谁只是 wrapper),再谈类型优雅**。 - **各进程独立读 `UserDefaults` → 设置漂移。** 旧方案每个进程各持一份配置,CLI 改了热键 App 不知道。 - → **教训**:设置也只能有一个真相源(Agent),wrapper 只经 XPC 读写(见下文"各职责的归属")。 + → **教训**:设置也只能有一个真相源(Agent)。MVP 中 wrapper 只经 XPC 读取;post-MVP 若加入写入,也必须经同一 XPC 边界(见下文"各职责的归属")。 一句话:**有进程边界时,先按进程切,再谈类型优雅;单例和回调是进程内工具,不要拿来跨进程。** 下面的规则都是这句话的展开。 @@ -45,7 +45,7 @@ Widget ──────┘ ├─ Settings ownership (source of t | 关注点 | 归属 | 规则 | |---------|------|------| | 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | -| 设置(真相源) | **Agent** | Agent 加载并拥有设置、负责应用它们。wrapper 绝不自己持有 `UserDefaults`,只经 XPC 访问。当前仅暴露读取(`XPCClient.currentSettings()`);写入(`applySettings`)尚未接线,加入时同样必须经 Agent,绝不在 wrapper 侧落地。读取失败必须显式呈现为 unavailable,不能把 wrapper 的 `.default` 冒充为 Agent 当前值。 | +| 设置(真相源) | **Agent** | Agent 加载并拥有默认/持久化设置、负责应用它们。MVP 有意只向 wrapper 暴露读取(`XPCClient.currentSettings()`),不包含用户可编辑设置的 API 或 UI;因此没有 `applySettings` 不构成 MVP 缺口。设置编辑属于 post-MVP extension,未来加入时仍必须经 Agent XPC 写入并由 Agent 持久化,绝不在 wrapper 侧落地。读取失败必须显式呈现为 unavailable,不能把 wrapper 的 `.default` 冒充为 Agent 当前值。 | | 锁状态快照 | **Agent**(`Service/LockEngine`) | `XPCClient.lockStatusSnapshot()` 一次返回同一 execution turn 中的 `isLocked`、锁定起点、auto-unlock deadline 与 active settings。wrapper 可以缓存它用于呈现,但不能从缓存反推或修改 Agent 状态。 | | Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。wrapper 只能经 XPC 查询状态或请求 Agent 触发系统 prompt,不得自行调用 Accessibility API。权限 prompt 是异步的;请求完成不代表已授权,wrapper 必须重新查询。 | | 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | @@ -64,7 +64,7 @@ App 可以持久化纯 presentation 状态,例如“是否已完成首次安全 - locked 的输入范围是 macOS 交付给 `CGEventTap` 的键盘来源事件:标准 `keyDown` / `keyUp` / `flagsChanged`,以及 `NX_SUBTYPE_AUX_CONTROL_BUTTONS`、`NX_SUBTYPE_EJECT_KEY`、`NX_SUBTYPE_POWER_KEY` 这三类 system-defined keyboard control。音量、亮度和播放控制因此必须被消费;鼠标 / 触控板 pointer event(包括 `NX_SUBTYPE_AUX_MOUSE_BUTTONS`)必须继续可用。macOS 没有交付给 event tap 的硬件或系统保留路径不在可保证范围内。 - 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `toggle` / `status`),彼此对称。不要把锁建模成客户端"拥有"的"会话"—— wrapper 的连接生命周期与锁的生命周期无关。 - `toggle` 是同一全局锁的原子翻转:在 Agent 的串行执行边界内于同一 execution turn 完成"读当前状态 + 反向 mutation",并向调用方返回翻转后的布尔值。锁定方向使用普通非交互 `lock` 语义(含 Focus persistence takeover),解锁方向等价于显式 `unlock`;wrapper 不得先读 `status()` 再自行决定方向来模拟 toggle。 -- `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有显式 settings update 才能重新应用设置并重新计算 timeout window。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 +- `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有 `LockEngine` 的显式 settings update 才能重新应用设置并重新计算 timeout window。它是当前的内部语义与 post-MVP 写入接入点,不是 MVP 对用户暴露的 capability。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 - safety check 是 capability-gated 的一次性 Agent 操作:仅在 unlocked 时创建新锁,沿用 Agent 持久化的解锁热键,但把该轮 active settings 的 auto-unlock 强制为固定 10 秒。fail-safe timer 由 Agent/`LockEngine` 持有,因此 App 退出、崩溃或 XPC 断开都不能让测试失去自动解锁兜底;该 override 不写回 settings store。若调用时已 locked,Agent 原子返回 already locked 且不修改既有锁。App 只在重新查询到权威 unlocked 后把 presentation completion 标记为完成。 - interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。发起并等待中的 CLI 进程对本轮锁负有退出清理责任:它观察 SIGTERM/SIGHUP/SIGINT,退出前尽力释放锁(有界等待后无论如何退出)。wire protocol 不携带 interactive lock 的代际令牌,清理无法区分自己创建的锁与等待期间由他入口重建的锁——若本轮锁已被解开且另一入口重新锁定,清理会释放较新的锁,这与任意入口可解锁同一全局锁的契约一致;SIGKILL/SIGSTOP 不可观察,遗留锁仍由解锁热键、通知 Unlock Now、auto-unlock 等全局途径解开。这是输入手势语义的进程侧延伸,不构成 session 或所有权。 - Focus Filter 是 **activation-triggered acquisition**,不是“Focus active 期间持续保持 locked”的 policy。Focus 激活时的 `true` 最多尝试一次 `unlocked → locked`,并只在成功创建锁时记录该 Focus-owned generation;Focus 关闭时的 `false` 只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领。显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束该 generation;实现不会查询当前 Focus 后自动重建锁,也不会在同一次 Focus activation 内持续 relock。这个进程内 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 diff --git a/docs/development.md b/docs/development.md index dd584db..c6619ba 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,7 +15,7 @@ 5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 6. wrapper 通过 `LockStateSubscriber.subscribe(initialState:_:)`(返回 `ObserverToken`)或 `LockStateSubscriber.stateChanges`(`AsyncStream`)观察状态。subscriber 会在 observer 安装完成后立即拉取一次权威初始状态,后续信号串行合并并再次查询 —— 绝不从"我这次调用是否成功"或通知 payload 推断。 -> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 对物理运行状态是**严格幂等**的:已锁时重复调用不会重建 event tap、修改当前设置、锁定起点或 auto-unlock deadline。唯一的 metadata 变化是普通 wrapper 的显式 `lock` 会接管 Focus 创建的当前 generation,使之后的 Focus disable 不再撤销这个更新的用户意图。Focus 本身是 activation-triggered:一次 activation 最多创建一个 Focus-owned generation,不承诺在 Focus active 期间持续 relock;显式 unlock、热键、timeout、event-tap failure 或 Agent restart 都可以让它提前结束。只有显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window。不存在客户端持有的通用"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 +> 锁是一个由 Agent 拥有的全局布尔值,且 `lock()` 对物理运行状态是**严格幂等**的:已锁时重复调用不会重建 event tap、修改当前设置、锁定起点或 auto-unlock deadline。唯一的 metadata 变化是普通 wrapper 的显式 `lock` 会接管 Focus 创建的当前 generation,使之后的 Focus disable 不再撤销这个更新的用户意图。Focus 本身是 activation-triggered:一次 activation 最多创建一个 Focus-owned generation,不承诺在 Focus active 期间持续 relock;显式 unlock、热键、timeout、event-tap failure 或 Agent restart 都可以让它提前结束。只有 `LockEngine` 的显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window;这是内部语义与 post-MVP 写入接入点,不是 MVP 用户 capability。不存在客户端持有的通用"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 ## 组件地图 @@ -79,10 +79,10 @@ ## 常见任务 -### 新增一个设置项 +### Post-MVP:新增用户可编辑设置 1. 给 `KeyboardLockerSettings` 加一个 `Codable`/`Sendable` 属性,并更新 `.default`。 2. 如果引擎会消费它,在 `LockEngine.lock(settings:allowsControlCUnlock:)` / `updateSettings(_:)` 中读取。 -3. 目前 wrapper 只能通过 `XPCClient.currentSettings()` **读取**设置(由 Agent 经 `KeyboardLockerSettingsStore` 加载)。写入路径(`applySettings` + UI)尚未接线;要让用户改设置,先在 Agent + `KeyboardLockerServiceProtocol` 上补写入方法(见"新增一个 XPC 方法"),再由 Agent 持久化 —— wrapper **不得**拥有 store(见架构契约)。 +3. 当前 MVP 有意只支持 wrapper 通过 `XPCClient.currentSettings()` **读取** Agent-owned 默认/持久化设置,不包含写入 API 或 UI。扩展到用户可编辑设置时,先在 Agent + `KeyboardLockerServiceProtocol` 上新增写入方法(见"新增一个 XPC 方法"),再由 Agent 持久化;wrapper **不得**拥有 store(见架构契约)。 ### 新增一个 XPC 方法 1. 先判断它是否 optional/additive。不得在同一 protocol major 内修改或移除既有 selector、参数顺序或 reply 形状;破坏性变化需要新 major,无法保留 selector union 时需要新 Mach service。 diff --git a/docs/xpc.md b/docs/xpc.md index 9b14df5..fb3570e 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -551,6 +551,8 @@ sequenceDiagram | `currentSettings` | protocol 1.1 legacy Client only | `AgentService` | 为既有 selector ABI 保留;编码失败只能返回 `nil` | | `currentSettingsWithError` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 启动时取得并持有的设置快照;本地 persisted payload 损坏时仅 Agent store 记录错误并回退 `.default`,而跨进程 payload 的缺失、损坏、过大或编码失败保持严格显式失败,wrapper 不自行回退默认值 | +MVP 的 settings contract 有意保持只读,不包含 write selector 或设置编辑 UI;用户可编辑设置属于 post-MVP extension。未来新增写入能力时仍必须由 Agent XPC protocol 承载并由 Agent 持久化。 + Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使用 Accessibility API 的进程身份。App 获得 Accessibility 权限并不能让 Agent 创建 event tap。 ## Connection 生命周期不等于锁生命周期 From b3a09be0acea96a0fb8464e38c41ce2a53da7610 Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 13 Aug 2026 00:07:08 +0800 Subject: [PATCH 101/106] fix(core): enforce concurrency boundaries Serialize TIS keyboard-layout access on the main thread to prevent HIToolbox aborts under parallel callers. Make XPC reply transfer, timer scheduling, and wrapper test isolation explicit under complete strict concurrency, with a concurrent display regression. --- Core/Sources/Common/KeyCodeConverter.swift | 12 ++- Core/Sources/Service/AgentService.swift | 79 ++++++++++++------- Core/Sources/Service/LockEngine.swift | 4 +- .../Service/MainActorTimerScheduler.swift | 8 +- .../CommonTests/HotkeyDisplayTests.swift | 25 ++++++ .../ServiceTests/AgentServiceTests.swift | 3 +- Core/Tests/ServiceTests/LockEngineTests.swift | 5 +- .../KeyboardLockerServicesProvider.swift | 2 +- .../CommandLineToolLinkManagerTests.swift | 6 +- .../KeyboardLockerServicesProviderTests.swift | 3 +- docs/development.md | 2 +- klock/KlockCLI.swift | 2 +- 12 files changed, 104 insertions(+), 47 deletions(-) diff --git a/Core/Sources/Common/KeyCodeConverter.swift b/Core/Sources/Common/KeyCodeConverter.swift index ab2f83d..8f6b2c5 100644 --- a/Core/Sources/Common/KeyCodeConverter.swift +++ b/Core/Sources/Common/KeyCodeConverter.swift @@ -57,7 +57,14 @@ public enum KeyCodeConverter { /// - Parameter keyCode: CGKeyCode value /// - Returns: Uppercase character or symbol, nil when the keyboard layout cannot map it private static func keyCharacter(for keyCode: CGKeyCode) -> String? { - characterFromKeyboardLayout(keyCode)?.uppercased() + let character: String? = if Thread.isMainThread { + characterFromKeyboardLayout(keyCode) + } else { + DispatchQueue.main.sync { + characterFromKeyboardLayout(keyCode) + } + } + return character?.uppercased() } /// Get character from the system keyboard layout using UCKeyTranslate. @@ -67,6 +74,9 @@ public enum KeyCodeConverter { /// "?" precisely while the user is typing in a non-Latin context. /// - Parameter keyCode: CGKeyCode value /// - Returns: Character string or nil + /// TIS/TSM APIs abort the process when a UI process calls them concurrently. Keep the complete + /// input-source lookup and translation on the main thread so every wrapper shares one safe + /// process-local serialization boundary. private static func characterFromKeyboardLayout(_ keyCode: CGKeyCode) -> String? { let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource().takeRetainedValue() guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { diff --git a/Core/Sources/Service/AgentService.swift b/Core/Sources/Service/AgentService.swift index 3c3b002..8c797e0 100644 --- a/Core/Sources/Service/AgentService.swift +++ b/Core/Sources/Service/AgentService.swift @@ -23,7 +23,7 @@ extension LockEngine: LockEngineServing {} /// /// Lives in the `Service` library so the barrier, generation-fence, and error-mapping wiring /// is unit-testable; the `KeyboardLockerAgent` executable only bootstraps the listener. -public final class AgentService: NSObject, KeyboardLockerServiceProtocol { +public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unchecked Sendable { private static let replacementPreparationDuration: TimeInterval = 30 @MainActor private let descriptorResult: Result @@ -51,7 +51,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { } @MainActor - public convenience override init() { + override public convenience init() { let engine = LockEngine(dependencies: .live) let notifier = LockStatusNotifier( notifications: LiveLockStatusNotificationService(), @@ -75,7 +75,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Bootstrap public func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in switch self.descriptorResult { case let .success(base): let descriptor = ServiceDescriptor( @@ -104,7 +104,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Locking public func lockKeyboard(reply: @escaping (Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in do { _ = try self.performLock(allowsControlCUnlock: false) reply(nil) @@ -117,7 +117,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { public func lockKeyboardInteractively( reply: @escaping (Bool, Error?) -> Void ) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in do { let outcome = try self.performLock(allowsControlCUnlock: true) reply(outcome == .acquired, nil) @@ -130,7 +130,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { public func beginSafetyCheck( reply: @escaping (Bool, Error?) -> Void ) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in do { try self.ensureAcceptingLockRequests() var safetySettings = self.settings @@ -152,7 +152,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { _ enabled: Bool, reply: @escaping (Error?) -> Void ) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in do { if enabled { try self.ensureAcceptingLockRequests() @@ -169,22 +169,22 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { } public func unlockKeyboard(reply: @escaping (Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in self.engine.unlock() reply(nil) } } public func status(reply: @escaping (Bool, Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in reply(self.engine.isLocked, nil) } } public func toggleKeyboard(reply: @escaping (Bool, Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in do { - reply(try self.performToggle(), nil) + try reply(self.performToggle(), nil) } catch { reply(false, error) } @@ -192,7 +192,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { } public func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in do { let data = try self.engine.statusSnapshot.encodedForXPC() reply(data, nil) @@ -207,7 +207,7 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { expectedAgentInstanceID: UUID, reply: @escaping (Data?, Error?) -> Void ) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in guard case let .success(descriptor) = self.descriptorResult else { reply(nil, Self.replacementError( code: 2, @@ -265,15 +265,16 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { ticket data: Data, reply: @escaping (Error?) -> Void ) { + let transferredReply = TransferredXPCReply(reply) let candidate: ServiceReplacementTicket do { candidate = try ServiceReplacementTicket.decodedFromXPC(data) } catch { - reply(error) + transferredReply.body(error) return } - executeOnMainActor { + executeOnMainActor(reply: transferredReply) { reply in do { try self.replacement.commit(ticket: candidate) self.replacementPreparationCancellation?() @@ -289,15 +290,16 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { ticket data: Data, reply: @escaping (Data?, Error?) -> Void ) { + let transferredReply = TransferredXPCReply(reply) let candidate: ServiceReplacementTicket do { candidate = try ServiceReplacementTicket.decodedFromXPC(data) } catch { - reply(nil, error) + transferredReply.body(nil, error) return } - executeOnMainActor { + executeOnMainActor(reply: transferredReply) { reply in do { let data = try self.replacement.status(for: candidate).encodedForXPC() reply(data, nil) @@ -311,15 +313,16 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { ticket data: Data, reply: @escaping (Error?) -> Void ) { + let transferredReply = TransferredXPCReply(reply) let candidate: ServiceReplacementTicket do { candidate = try ServiceReplacementTicket.decodedFromXPC(data) } catch { - reply(error) + transferredReply.body(error) return } - executeOnMainActor { + executeOnMainActor(reply: transferredReply) { reply in do { try self.replacement.cancel(ticket: candidate) self.replacementPreparationCancellation?() @@ -334,13 +337,13 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Accessibility public func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in reply(AccessibilityManager.hasPermission()) } } public func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in AccessibilityManager.requestPermission() reply(nil) } @@ -349,13 +352,13 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { // MARK: - Settings public func currentSettings(reply: @escaping (Data?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in reply(try? self.settings.encodedForXPC()) } } public func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) { - executeOnMainActor { + executeOnMainActor(reply: reply) { reply in do { let data = try self.settings.encodedForXPC() reply(data, nil) @@ -467,8 +470,20 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { } } - private func executeOnMainActor(_ operation: @escaping @MainActor () -> Void) { - let operation = MainActorOperation(operation) + private func executeOnMainActor( + reply: Reply, + _ operation: @escaping @MainActor @Sendable (Reply) -> Void + ) { + executeOnMainActor(reply: TransferredXPCReply(reply), operation) + } + + private func executeOnMainActor( + reply: TransferredXPCReply, + _ operation: @escaping @MainActor @Sendable (Reply) -> Void + ) { + let operation = MainActorOperation { + operation(reply.body) + } if Thread.isMainThread { MainActor.assumeIsolated { operation() @@ -485,9 +500,9 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol { /// Immutable transfer object used to move an Objective-C XPC reply operation onto `MainActor`. private final class MainActorOperation: @unchecked Sendable { - private let body: @MainActor () -> Void + private let body: @MainActor @Sendable () -> Void - init(_ body: @escaping @MainActor () -> Void) { + init(_ body: @escaping @MainActor @Sendable () -> Void) { self.body = body } @@ -497,6 +512,16 @@ private final class MainActorOperation: @unchecked Sendable { } } +/// Objective-C XPC reply blocks are not imported as `Sendable`, even though the connection owns +/// their lifetime and supports invocation from the Agent's serialized main-actor boundary. +private struct TransferredXPCReply: @unchecked Sendable { + let body: Reply + + init(_ body: Reply) { + self.body = body + } +} + private enum AgentDescriptorError: Error, LocalizedError { case missingMetadata(String) diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 1bc054a..30076a4 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -286,7 +286,7 @@ struct LockEngineDependencies { var broadcastStateChange: () -> Void var now: () -> Date - static var live: Self { + @MainActor static var live: Self { Self( hasAccessibilityPermission: { AccessibilityManager.hasPermission() }, installEventTap: { engine in try CoreGraphicsEventTap.install(engine: engine) }, @@ -456,7 +456,7 @@ final class LockEngine { runtimeState.statusSnapshot(capturedAt: dependencies.now()) } - init(dependencies: LockEngineDependencies = .live) { + init(dependencies: LockEngineDependencies) { self.dependencies = dependencies } diff --git a/Core/Sources/Service/MainActorTimerScheduler.swift b/Core/Sources/Service/MainActorTimerScheduler.swift index 6e6ffca..c4d006a 100644 --- a/Core/Sources/Service/MainActorTimerScheduler.swift +++ b/Core/Sources/Service/MainActorTimerScheduler.swift @@ -4,14 +4,14 @@ import Foundation /// /// Shared by `LockEngine` (auto-unlock) and `AgentService` (replacement-preparation expiry) so /// `ServiceTests` can substitute a deterministic manual scheduler for either consumer. -typealias MainActorTimerScheduler = ( +typealias MainActorTimerScheduler = @MainActor ( _ interval: TimeInterval, - _ fire: @escaping @MainActor () -> Void -) -> () -> Void + _ fire: @escaping @MainActor @Sendable () -> Void +) -> @MainActor () -> Void /// Live scheduler backed by the main dispatch queue. The deadline uses the monotonic clock, /// so it pauses while the system is asleep. -let liveMainActorTimerScheduler: MainActorTimerScheduler = { interval, fire in +@MainActor let liveMainActorTimerScheduler: MainActorTimerScheduler = { interval, fire in let item = DispatchWorkItem { MainActor.assumeIsolated { fire() } } diff --git a/Core/Tests/CommonTests/HotkeyDisplayTests.swift b/Core/Tests/CommonTests/HotkeyDisplayTests.swift index 5494bfa..387e41c 100644 --- a/Core/Tests/CommonTests/HotkeyDisplayTests.swift +++ b/Core/Tests/CommonTests/HotkeyDisplayTests.swift @@ -22,4 +22,29 @@ struct HotkeyDisplayTests { #expect(hotkey.displayString == "the configured unlock hotkey") } + + @Test + func concurrentDisplayRequestsSerializeKeyboardLayoutAccess() async { + let hotkey = KeyboardLockerSettings.Hotkey( + keyCode: 37, + modifierFlags: [.maskCommand, .maskControl] + ) + + let values = await withTaskGroup(of: String.self, returning: [String].self) { group in + for _ in 0..<64 { + group.addTask { + hotkey.displayString + } + } + + var values: [String] = [] + for await value in group { + values.append(value) + } + return values + } + + #expect(values.count == 64) + #expect(values.allSatisfy { $0 == "⌃⌘L" }) + } } diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift index d7edb38..78ee43f 100644 --- a/Core/Tests/ServiceTests/AgentServiceTests.swift +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -570,11 +570,12 @@ private final class FakeLockEngine: LockEngineServing { } } +@MainActor private final class ManualExpirationScheduler { private(set) var scheduledCount = 0 private(set) var lastInterval: TimeInterval? private(set) var cancelCount = 0 - private var pendingFire: (@MainActor () -> Void)? + private var pendingFire: (@MainActor @Sendable () -> Void)? var scheduler: MainActorTimerScheduler { { interval, fire in diff --git a/Core/Tests/ServiceTests/LockEngineTests.swift b/Core/Tests/ServiceTests/LockEngineTests.swift index 8b93e09..e26eb4e 100644 --- a/Core/Tests/ServiceTests/LockEngineTests.swift +++ b/Core/Tests/ServiceTests/LockEngineTests.swift @@ -374,13 +374,14 @@ private final class FakeInstalledEventTap: InstalledEventTap { /// Manual scheduler whose timers stay fireable after cancellation: a cancelled dispatch block /// may already be in flight, and the engine's generation guard must reject exactly that stale /// fire, so tests need to replay it. +@MainActor private final class ManualTimerScheduler { final class Timer { let interval: TimeInterval - private let fireAction: @MainActor () -> Void + private let fireAction: @MainActor @Sendable () -> Void private(set) var isCancelled = false - init(interval: TimeInterval, fire: @escaping @MainActor () -> Void) { + init(interval: TimeInterval, fire: @escaping @MainActor @Sendable () -> Void) { self.interval = interval fireAction = fire } diff --git a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift index 4272a7a..d572fd4 100644 --- a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift +++ b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift @@ -91,7 +91,7 @@ final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sen /// One-shot cell carrying a service request's outcome back to the thread AppKit blocked on the /// handler. -private final class ServicesRequestCompletion: @unchecked Sendable { +private final nonisolated class ServicesRequestCompletion: @unchecked Sendable { enum Outcome: Equatable { case succeeded case failed(ExternalAutomationFailure) diff --git a/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift index eee48ec..1383af1 100644 --- a/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift +++ b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@MainActor @Suite(.serialized) final class CommandLineToolLinkManagerTests { private let temporaryDirectory: URL @@ -19,7 +20,6 @@ final class CommandLineToolLinkManagerTests { } @Test - @MainActor func installAndUninstallManageOnlySymbolicLink() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) @@ -51,7 +51,6 @@ final class CommandLineToolLinkManagerTests { } @Test - @MainActor func installIsIdempotentForOwnedSymbolicLink() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) @@ -67,7 +66,6 @@ final class CommandLineToolLinkManagerTests { } @Test - @MainActor func ownedSymbolicLinkCanBeRemovedWhenBundledExecutableDisappears() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) @@ -92,7 +90,6 @@ final class CommandLineToolLinkManagerTests { } @Test - @MainActor func foreignItemIsReportedAndNeverOverwritten() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) @@ -118,7 +115,6 @@ final class CommandLineToolLinkManagerTests { } @Test - @MainActor func missingPathDirectoryIsReportedWithoutEditingShellConfiguration() throws { let source = try makeExecutable(named: "klock") let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift index 0114398..695816f 100644 --- a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -1,6 +1,7 @@ import AppKit import Testing +@MainActor @Suite(.serialized) struct KeyboardLockerServicesProviderTests { private let pasteboard = NSPasteboard(name: NSPasteboard.Name("KeyboardLockerServicesProviderTests")) @@ -27,7 +28,6 @@ struct KeyboardLockerServicesProviderTests { } @Test - @MainActor func handlerPublishesFailureBeforeScheduledPresentationRuns() async { let presenter = RecordingServicesFailurePresenter() let provider = KeyboardLockerServicesProvider( @@ -61,7 +61,6 @@ struct KeyboardLockerServicesProviderTests { } @Test - @MainActor func timedOutHandlerPresentsLateFailureAfterPublishingTimeout() async { let presenter = RecordingServicesFailurePresenter() let provider = KeyboardLockerServicesProvider( diff --git a/docs/development.md b/docs/development.md index c6619ba..15b8c04 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ - `ServiceDescriptor.swift`:`ServiceDescriptor`、protocol version、稳定字符串 capability、additive replacement phase、opaque `ServiceReplacementTicket` 与 ticket-specific status;以有大小上限的 JSON `Data` 跨 XPC。descriptor 显式 decode 永久 bootstrap 字段,为 additive 字段提供默认值,未知字段/capability 可由旧 Client 忽略。 - `XPCCodeSigningRequirement.swift`:从当前进程已验证的 Apple 签名读取 Team ID,生成同 Team + 精确 identifier 的双向 XPC requirement 字符串,并用 `SecRequirementCreateWithString` 校验其可编译(编译结果仅作一次性校验后丢弃;安装到连接时由系统重新解析字符串);unsigned/ad-hoc 进程 fail closed。 - `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。 -- `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。读取 **ASCII-capable keyboard layout**(`TISCopyCurrentASCIICapableKeyboardLayoutInputSource`)而非当前 input source——拼音等输入法不带 Unicode layout data,会把热键渲染成 `?`。 +- `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。读取 **ASCII-capable keyboard layout**(`TISCopyCurrentASCIICapableKeyboardLayoutInputSource`)而非当前 input source——拼音等输入法不带 Unicode layout data,会把热键渲染成 `?`。TIS/TSM 查询统一串行到 main thread,避免并发测试或多个 wrapper 同时生成显示文本时触发 HIToolbox abort。 **Client**(`Core/Sources/Client/`)—— App/CLI 使用,绝不 import `Service` - `XPCClient.swift`:异步 / 可抛错的 `XPCClient.shared`,持有一条按需重建的连接;interruption 会主动 invalidate 当前 object,阻止它透明附着到另一代 Agent 后复用旧 capability grant。所有调用共享有界响应超时,超时只失效对应连接。`unlock` 超时后可用权威 Boolean 状态校准;普通 `lock` 与 Focus selector 的成功还包含本地状态看不到的 provenance,因此首次 timeout 后会在 fresh connection 上重发同一个幂等请求,第二次仍超时则明确报告 outcome unknown。每个 capability-gated API 都在同一 connection 上读取 descriptor,先拒绝不同 major 或低于 selector 引入版本的 minor,再检查 capability 后调用 selector。replacement wire request 与 ticket 双重绑定 `agentInstanceID`,并提供 prepare/commit/status/cancel 与显式 connection reset。没有业务 "session" 类型。 diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index ee6c171..3223713 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -3,7 +3,7 @@ import Foundation /// The Agent operations `klock` depends on, mirroring the `XPCClient` signatures one-to-one so /// command execution can be driven by a fake in tests. -protocol KlockClientServing { +protocol KlockClientServing: Sendable { func currentSettings() async throws -> KeyboardLockerSettings func lockInteractively() async throws -> LockRequestOutcome func lock() async throws From 7674a87369e3bc8e73100eb66e8b94266dbcf10e Mon Sep 17 00:00:00 2001 From: Eden Date: Thu, 13 Aug 2026 00:12:59 +0800 Subject: [PATCH 102/106] chore: apply SwiftFormat and normalize async try/await ordering - Reorder switch cases, drop redundant self/numeric-separator literals, and fix range/guard/multiline-string/closure spacing per SwiftFormat. - Rewrite 'try? await x()' as 'await (try? x())'; drop redundant 'return await'/'return try await' where the call is already the value. --- Core/Sources/Service/LockStatusNotifier.swift | 4 ++-- .../XPCCodeSigningRequirementTests.swift | 7 +++---- .../ServiceTests/EventTapInstallationTests.swift | 6 +++--- .../KeyboardLockerSettingsStoreTests.swift | 4 ++-- Core/Tests/ServiceTests/LockEngineTests.swift | 2 +- .../ServiceTests/LockStatusNotifierTests.swift | 6 +++--- KeyboardLocker/AgentReplacementCoordinator.swift | 8 ++++---- .../KeyboardLockerServicesProvider.swift | 6 +++--- KeyboardLocker/KeyboardLockerDiagnostics.swift | 4 +++- KeyboardLocker/StatusMenuController.swift | 10 +++++----- .../KeyboardLockerControl.swift | 4 ++-- klock/KlockCLI.swift | 16 ++++++++-------- scripts/signed-client-control/main.swift | 7 +++---- scripts/unsigned-client-probe/main.swift | 7 +++---- 14 files changed, 45 insertions(+), 46 deletions(-) diff --git a/Core/Sources/Service/LockStatusNotifier.swift b/Core/Sources/Service/LockStatusNotifier.swift index 49687de..e61c686 100644 --- a/Core/Sources/Service/LockStatusNotifier.swift +++ b/Core/Sources/Service/LockStatusNotifier.swift @@ -160,12 +160,12 @@ final class LiveLockStatusNotificationService: LockStatusNotifying { func requestAlertAuthorization() async -> Bool { let settings = await center.notificationSettings() switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: + case .authorized, .ephemeral, .provisional: return true case .denied: return false case .notDetermined: - return (try? await center.requestAuthorization(options: [.alert])) ?? false + return await (try? center.requestAuthorization(options: [.alert])) ?? false @unknown default: return false } diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift index aab8194..dd26de3 100644 --- a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift @@ -26,10 +26,9 @@ struct XPCCodeSigningRequirementTests { ) #expect(requirement == """ - anchor apple generic and certificate leaf[subject.OU] = "A1B2C3D4E5" and \ - (identifier "io.example.app" or identifier "io.example.tool") - """ - ) + anchor apple generic and certificate leaf[subject.OU] = "A1B2C3D4E5" and \ + (identifier "io.example.app" or identifier "io.example.tool") + """) var compiledRequirement: SecRequirement? #expect( diff --git a/Core/Tests/ServiceTests/EventTapInstallationTests.swift b/Core/Tests/ServiceTests/EventTapInstallationTests.swift index be029ab..17d238b 100644 --- a/Core/Tests/ServiceTests/EventTapInstallationTests.swift +++ b/Core/Tests/ServiceTests/EventTapInstallationTests.swift @@ -8,7 +8,7 @@ struct EventTapInstallationTests { var actions: [String] = [] #expect(throws: EventTapInstallationError.eventTapCreationFailed) { - try self.makeInstallation( + try makeInstallation( actions: &actions, tap: nil, source: 2, @@ -23,7 +23,7 @@ struct EventTapInstallationTests { var actions: [String] = [] #expect(throws: EventTapInstallationError.runLoopSourceCreationFailed) { - try self.makeInstallation( + try makeInstallation( actions: &actions, tap: 1, source: nil, @@ -38,7 +38,7 @@ struct EventTapInstallationTests { var actions: [String] = [] #expect(throws: EventTapInstallationError.eventTapEnableFailed) { - try self.makeInstallation( + try makeInstallation( actions: &actions, tap: 1, source: 2, diff --git a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift index 9dfdff2..84758af 100644 --- a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift +++ b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift @@ -40,7 +40,7 @@ final class KeyboardLockerSettingsStoreTests { modifierFlags: [.maskControl, .maskAlternate] ) ) - defaults.set(try JSONEncoder().encode(custom), forKey: "settings") + try defaults.set(JSONEncoder().encode(custom), forKey: "settings") let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") #expect(store.load() == custom) @@ -64,7 +64,7 @@ final class KeyboardLockerSettingsStoreTests { autoUnlockPolicy: .disabled, unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) ) - defaults.set(try JSONEncoder().encode(custom), forKey: "settings") + try defaults.set(JSONEncoder().encode(custom), forKey: "settings") _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") let persisted = try #require(defaults.data(forKey: "settings")) diff --git a/Core/Tests/ServiceTests/LockEngineTests.swift b/Core/Tests/ServiceTests/LockEngineTests.swift index e26eb4e..fc19d14 100644 --- a/Core/Tests/ServiceTests/LockEngineTests.swift +++ b/Core/Tests/ServiceTests/LockEngineTests.swift @@ -22,7 +22,7 @@ final class LockEngineTests { broadcastCount = 0 stateChangeCount = 0 hasAccessibilityPermission = true - now = Date(timeIntervalSinceReferenceDate: 1_000) + now = Date(timeIntervalSinceReferenceDate: 1000) } // MARK: - Acquisition diff --git a/Core/Tests/ServiceTests/LockStatusNotifierTests.swift b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift index 0eb4394..9ccea38 100644 --- a/Core/Tests/ServiceTests/LockStatusNotifierTests.swift +++ b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift @@ -31,7 +31,7 @@ final class LockStatusNotifierTests { func lockTransitionPostsNotificationWithHotkeyAndDeadline() async throws { snapshot = Self.makeSnapshot( isLocked: true, - autoUnlockTargetDate: Date(timeIntervalSinceReferenceDate: 1_060) + autoUnlockTargetDate: Date(timeIntervalSinceReferenceDate: 1060) ) let notifier = makeNotifier() @@ -141,9 +141,9 @@ final class LockStatusNotifierTests { autoUnlockTargetDate: Date? = nil ) -> LockStatusSnapshot { LockStatusSnapshot( - capturedAt: Date(timeIntervalSinceReferenceDate: 1_000), + capturedAt: Date(timeIntervalSinceReferenceDate: 1000), isLocked: isLocked, - startedAt: isLocked ? Date(timeIntervalSinceReferenceDate: 1_000) : nil, + startedAt: isLocked ? Date(timeIntervalSinceReferenceDate: 1000) : nil, autoUnlockTargetDate: autoUnlockTargetDate, settings: .default ) diff --git a/KeyboardLocker/AgentReplacementCoordinator.swift b/KeyboardLocker/AgentReplacementCoordinator.swift index 777eac2..c7583ba 100644 --- a/KeyboardLocker/AgentReplacementCoordinator.swift +++ b/KeyboardLocker/AgentReplacementCoordinator.swift @@ -88,9 +88,9 @@ struct AgentReplacementCoordinator { func replace(_ plan: AgentUpdatePlan) async -> Outcome { switch plan.mode { case let .safe(descriptor, isLocked): - return await replaceWithTransaction(descriptor: descriptor, isLocked: isLocked) + await replaceWithTransaction(descriptor: descriptor, isLocked: isLocked) case let .forced(descriptor, isLocked): - return await replaceForced(descriptor: descriptor, isLocked: isLocked) + await replaceForced(descriptor: descriptor, isLocked: isLocked) } } @@ -121,7 +121,7 @@ struct AgentReplacementCoordinator { return .replacementInProgress(descriptor) } - return .failed(error: error, currentLockState: try? await client.status()) + return await .failed(error: error, currentLockState: try? client.status()) } return await restart( @@ -144,7 +144,7 @@ struct AgentReplacementCoordinator { do { try await client.unlock() } catch { - return .failed(error: error, currentLockState: try? await client.status()) + return await .failed(error: error, currentLockState: try? client.status()) } } diff --git a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift index d572fd4..8e06428 100644 --- a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift +++ b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift @@ -76,9 +76,9 @@ final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sen error.pointee = failure.message as NSString case .timedOut: error.pointee = """ - KeyboardLocker did not finish the service request within \(Int(waitTimeout)) seconds; \ - it may still complete. - """ as NSString + KeyboardLocker did not finish the service request within \(Int(waitTimeout)) seconds; \ + it may still complete. + """ as NSString } if let failure = completion.publishResultChannel() { diff --git a/KeyboardLocker/KeyboardLockerDiagnostics.swift b/KeyboardLocker/KeyboardLockerDiagnostics.swift index 36d6463..9d03f3a 100644 --- a/KeyboardLocker/KeyboardLockerDiagnostics.swift +++ b/KeyboardLocker/KeyboardLockerDiagnostics.swift @@ -97,6 +97,7 @@ struct KeyboardLockerDiagnosticsCollector { "Replacement: \(value.replacementPhase.rawValue)", "Capabilities: \(value.capabilities.map(\.rawValue).sorted().joined(separator: ", "))", ]) + case let .failure(error): lines.append("Status: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") } @@ -118,6 +119,7 @@ struct KeyboardLockerDiagnosticsCollector { "Policy: \(value.settings.autoUnlockPolicy.diagnosticDescription)", "Unlock Hotkey: \(value.settings.unlockHotkey.displayString)", ]) + case let .failure(error): lines.append("Status: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") } @@ -129,7 +131,7 @@ struct KeyboardLockerDiagnosticsCollector { _ operation: @MainActor @Sendable () async throws -> Value ) async -> Result { do { - return .success(try await operation()) + return try await .success(operation()) } catch { return .failure(error) } diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index a6533ad..b30ece6 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -585,9 +585,9 @@ final class StatusMenuController: NSObject, NSMenuDelegate { confirmation.alertStyle = .warning confirmation.messageText = "Quit KeyboardLocker while the keyboard is locked?" confirmation.informativeText = """ - The lock stays active without the menu bar indicator. You can still unlock with the \ - configured unlock hotkey, the notification's Unlock Now button, or `klock unlock`. - """ + The lock stays active without the menu bar indicator. You can still unlock with the \ + configured unlock hotkey, the notification's Unlock Now button, or `klock unlock`. + """ confirmation.addButton(withTitle: "Quit") confirmation.addButton(withTitle: "Cancel") @@ -599,8 +599,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { private var snapshotShowsLockedKeyboard: Bool { switch currentSnapshot.state { - case let .ready(isLocked), - let .accessibilityRequired(isLocked): + case let .accessibilityRequired(isLocked), + let .ready(isLocked): isLocked case let .checking(lastKnownLock): // Same evidence standard as `AppCoordinator.performDisplayedLockAction()`: a diff --git a/KeyboardLockerWidgets/KeyboardLockerControl.swift b/KeyboardLockerWidgets/KeyboardLockerControl.swift index 242fed9..2e49c7d 100644 --- a/KeyboardLockerWidgets/KeyboardLockerControl.swift +++ b/KeyboardLockerWidgets/KeyboardLockerControl.swift @@ -31,8 +31,8 @@ struct KeyboardLockerControl: ControlWidget { @available(macOS 26.0, *) private struct KeyboardLockerControlValueProvider: ControlValueProvider { - // Gallery preview matches the status widget's locked sample so both system surfaces - // advertise the same state. + /// Gallery preview matches the status widget's locked sample so both system surfaces + /// advertise the same state. let previewValue = true private let loader: KeyboardLockerControlValueLoader diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift index 3223713..4af9ead 100644 --- a/klock/KlockCLI.swift +++ b/klock/KlockCLI.swift @@ -51,11 +51,11 @@ enum KlockCLI { return ExitCode.success } command = parsedCommand - } catch KlockCommandLineError.unknownCommand(let name) { + } catch let KlockCommandLineError.unknownCommand(name) { reportError("Unknown command: \(name)", printError: printError) printUsage(to: printOut) return ExitCode.error - } catch KlockCommandLineError.unexpectedArguments(let unexpected) { + } catch let KlockCommandLineError.unexpectedArguments(unexpected) { reportError( "Unexpected argument\(unexpected.count == 1 ? "" : "s"): \(unexpected.joined(separator: " "))", printError: printError @@ -273,7 +273,7 @@ enum KlockCLI { printOut: (String) -> Void, printError: (String) -> Void ) async -> Int32 { - if (try? await client.status()) != nil { + if await (try? client.status()) != nil { printOut("The KeyboardLocker agent is already registered and reachable.") return ExitCode.success } @@ -286,9 +286,9 @@ enum KlockCLI { } printOut("Launched KeyboardLocker to register its background agent.") - for _ in 0 ..< agentPoll.attempts { + for _ in 0.. Date: Fri, 14 Aug 2026 17:08:59 +0800 Subject: [PATCH 103/106] ci: remove Claude Code review workflows --- .github/workflows/claude-code-review.yml | 57 ------------------------ .github/workflows/claude.yml | 50 --------------------- 2 files changed, 107 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index a4c8f6a..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 79fe056..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - From d672d01a092732df54d4485633cbb4be51744010 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 14 Aug 2026 17:21:10 +0800 Subject: [PATCH 104/106] test(services): poll for late failure presentation past the CI timing margin --- .../KeyboardLockerServicesProviderTests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift index 695816f..2edc326 100644 --- a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -77,7 +77,12 @@ struct KeyboardLockerServicesProviderTests { #expect(error?.contains("did not finish") == true) #expect(presenter.failures.isEmpty) - try? await Task.sleep(nanoseconds: 100_000_000) + // The late presentation crosses executor hops after the action completes; poll with a + // generous deadline instead of assuming a fixed wall-clock margin on a loaded runner. + let deadline = ContinuousClock.now + .seconds(5) + while presenter.failures.isEmpty, ContinuousClock.now < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } #expect(presenter.failures == [.init(message: "Late agent failure")]) } From fa96cd0c71dc038cd8c3808c291cf760117e4381 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 14 Aug 2026 17:45:17 +0800 Subject: [PATCH 105/106] test(services): gate late action completion on result publication --- .../KeyboardLockerServicesProviderTests.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift index 2edc326..07472c5 100644 --- a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -63,11 +63,15 @@ struct KeyboardLockerServicesProviderTests { @Test func timedOutHandlerPresentsLateFailureAfterPublishingTimeout() async { let presenter = RecordingServicesFailurePresenter() + let gate = ServicesResultChannelGate() let provider = KeyboardLockerServicesProvider( waitTimeout: 0.01, presentFailure: { presenter.present($0) } ) { _ in - try? await Task.sleep(nanoseconds: 50_000_000) + // Complete only after the handler has published its timeout. A fixed sleep cannot + // guarantee this ordering: the main-thread run-loop pump may service a source past the + // wait deadline on a loaded runner, letting a "late" action still win the wait. + await gate.waitUntilPublished() return ExternalAutomationFailure(message: "Late agent failure") } @@ -77,6 +81,8 @@ struct KeyboardLockerServicesProviderTests { #expect(error?.contains("did not finish") == true) #expect(presenter.failures.isEmpty) + await gate.publish() + // The late presentation crosses executor hops after the action completes; poll with a // generous deadline instead of assuming a fixed wall-clock margin on a loaded runner. let deadline = ContinuousClock.now + .seconds(5) @@ -88,6 +94,31 @@ struct KeyboardLockerServicesProviderTests { } } +/// Holds a service action open until the handler has published its outcome, so the +/// late-completion path is exercised deterministically without a production seam. +private actor ServicesResultChannelGate { + private var published = false + private var waiters: [CheckedContinuation] = [] + + func publish() { + published = true + let pending = waiters + waiters = [] + for waiter in pending { + waiter.resume() + } + } + + func waitUntilPublished() async { + if published { + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + @MainActor private final class RecordingServicesFailurePresenter { private(set) var failures: [ExternalAutomationFailure] = [] From a9ee6c85d829dffe03cd61a851b7d77447dbba90 Mon Sep 17 00:00:00 2001 From: Eden Date: Fri, 14 Aug 2026 17:45:17 +0800 Subject: [PATCH 106/106] fix(client): keep interrupted mutation replies explicitly ambiguous --- .../Client/IdempotentMutationRetrier.swift | 9 +- Core/Sources/Client/XPCClient.swift | 82 ++++++++++++++----- .../IdempotentMutationRetrierTests.swift | 28 +++++++ .../ClientTests/XPCClientErrorTests.swift | 34 ++++++++ docs/xpc.md | 12 +-- 5 files changed, 135 insertions(+), 30 deletions(-) diff --git a/Core/Sources/Client/IdempotentMutationRetrier.swift b/Core/Sources/Client/IdempotentMutationRetrier.swift index d4ccdf0..a30e34b 100644 --- a/Core/Sources/Client/IdempotentMutationRetrier.swift +++ b/Core/Sources/Client/IdempotentMutationRetrier.swift @@ -1,5 +1,6 @@ -/// Retries a mutation only when the first reply times out and resets the transport boundary before -/// doing so. Callers use this only for operations whose complete semantic effect is idempotent. +/// Retries a mutation only when its reply is lost — timed out, or dropped with a dying peer — +/// and resets the transport boundary before doing so. Callers use this only for operations +/// whose complete semantic effect is idempotent. enum IdempotentMutationRetrier { static func perform( operation: @escaping @Sendable () async throws -> Void, @@ -7,11 +8,11 @@ enum IdempotentMutationRetrier { ) async throws { do { try await operation() - } catch XPCClientError.timedOut { + } catch XPCClientError.timedOut, is LostReplyError { resetConnection() do { try await operation() - } catch XPCClientError.timedOut { + } catch XPCClientError.timedOut, is LostReplyError { throw XPCClientError.operationOutcomeUnknown } } diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index f0509d1..3336d6f 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -55,6 +55,18 @@ public enum XPCClientError: Error, LocalizedError { } } +/// The reply to a dispatched request was lost because the peer process died mid-flight +/// (`NSXPCConnectionInterrupted`): the mutation may already have executed. Callers translate +/// this into their ambiguous-outcome path instead of reporting a definite failure. +struct LostReplyError: Error, Equatable {} + +/// How a proxy-level transport failure is reported. Queries see plain unavailability; +/// mutations see a lost reply whose outcome stays explicitly ambiguous. +enum ProxyErrorDisposition { + case unavailable + case lostReply +} + /// Async client for the KeyboardLocker Agent. /// /// Every operation is a **stateless one-off call** to the single global lock owned by the Agent — @@ -112,7 +124,7 @@ public final class XPCClient: @unchecked Sendable { } private func lockOnce() async throws { - try await withProxy { service, resume in + try await withProxy(proxyErrorDisposition: .lostReply) { service, resume in service.lockKeyboard { resume($0) } } } @@ -129,12 +141,13 @@ public final class XPCClient: @unchecked Sendable { do { let didAcquireLock: Bool = try await withProxyReturning( - using: connection + using: connection, + proxyErrorDisposition: .lostReply ) { service, resume in service.lockKeyboardInteractively { resume($0, $1) } } return didAcquireLock ? .acquired : .alreadyLocked - } catch XPCClientError.timedOut { + } catch XPCClientError.timedOut, is LostReplyError { // Status alone cannot recover whether this request created a lock or observed an existing // one, so a lost mutation reply must remain explicitly unknown. throw XPCClientError.operationOutcomeUnknown @@ -152,12 +165,13 @@ public final class XPCClient: @unchecked Sendable { do { let didStart: Bool = try await withProxyReturning( - using: connection + using: connection, + proxyErrorDisposition: .lostReply ) { service, resume in service.beginSafetyCheck { resume($0, $1) } } return didStart ? .acquired : .alreadyLocked - } catch XPCClientError.timedOut { + } catch XPCClientError.timedOut, is LostReplyError { throw XPCClientError.operationOutcomeUnknown } } @@ -180,18 +194,18 @@ public final class XPCClient: @unchecked Sendable { let connection = try await negotiatedConnection( requiring: [.focusFilterLock] ) - try await withProxy(using: connection) { service, resume in + try await withProxy(using: connection, proxyErrorDisposition: .lostReply) { service, resume in service.setFocusFilterLockEnabled(enabled, reply: resume) } } public func unlock() async throws { do { - try await withProxy { service, resume in + try await withProxy(proxyErrorDisposition: .lostReply) { service, resume in service.unlockKeyboard { resume($0) } } - } catch XPCClientError.timedOut { - try await confirmTimedOutMutation(expectedIsLocked: false) + } catch XPCClientError.timedOut, is LostReplyError { + try await confirmAmbiguousMutation(expectedIsLocked: false) } } @@ -212,10 +226,13 @@ public final class XPCClient: @unchecked Sendable { requiring: [.lockToggle] ) do { - return try await withProxyReturning(using: connection) { service, resume in + return try await withProxyReturning( + using: connection, + proxyErrorDisposition: .lostReply + ) { service, resume in service.toggleKeyboard { resume($0, $1) } } - } catch XPCClientError.timedOut { + } catch XPCClientError.timedOut, is LostReplyError { throw XPCClientError.operationOutcomeUnknown } } @@ -341,10 +358,13 @@ public final class XPCClient: @unchecked Sendable { requiring: [.accessibilityPrompt] ) do { - try await withProxy(using: connection) { service, resume in + try await withProxy( + using: connection, + proxyErrorDisposition: .lostReply + ) { service, resume in service.requestAccessibilityPermission(reply: resume) } - } catch XPCClientError.timedOut { + } catch XPCClientError.timedOut, is LostReplyError { throw XPCClientError.operationOutcomeUnknown } } @@ -372,9 +392,10 @@ public final class XPCClient: @unchecked Sendable { candidate?.invalidate() } - /// Lock mutations are idempotent, so a timed-out reply can be recovered by checking whether - /// the Agent reached the requested global state through a fresh connection. - private func confirmTimedOutMutation(expectedIsLocked: Bool) async throws { + /// Lock mutations are idempotent, so a lost reply — timed out or dropped with a dying peer — + /// can be recovered by checking whether the Agent reached the requested global state through + /// a fresh connection. + private func confirmAmbiguousMutation(expectedIsLocked: Bool) async throws { if let isLocked = try? await status(), isLocked == expectedIsLocked { return } @@ -464,9 +485,13 @@ public final class XPCClient: @unchecked Sendable { /// or a missing proxy — so a dead Agent throws rather than hanging forever. private func withProxy( using connection: NSXPCConnection? = nil, + proxyErrorDisposition: ProxyErrorDisposition = .unavailable, _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (Error?) -> Void) -> Void ) async throws { - let _: Void = try await withProxyReturning(using: connection) { service, resume in + let _: Void = try await withProxyReturning( + using: connection, + proxyErrorDisposition: proxyErrorDisposition + ) { service, resume in body(service) { error in resume((), error) } @@ -477,6 +502,7 @@ public final class XPCClient: @unchecked Sendable { /// across reply, proxy-error, missing-proxy, and timeout races. private func withProxyReturning( using providedConnection: NSXPCConnection? = nil, + proxyErrorDisposition: ProxyErrorDisposition = .unavailable, _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (T, Error?) -> Void) -> Void ) async throws -> T { let connection = if let providedConnection { @@ -502,7 +528,9 @@ public final class XPCClient: @unchecked Sendable { let proxy = connectionReference.value.remoteObjectProxyWithErrorHandler { error in once.run { timeoutTask.cancel() - continuation.resume(throwing: Self.normalizedProxyError(error)) + continuation.resume( + throwing: Self.normalizedProxyError(error, disposition: proxyErrorDisposition) + ) } } @@ -530,8 +558,22 @@ public final class XPCClient: @unchecked Sendable { /// A proxy error means the request did not reach a callable XPC peer. Keep Foundation's /// transport details behind the Client boundary so every wrapper can present one actionable /// service-availability failure. - static func normalizedProxyError(_: Error) -> Error { - XPCClientError.serviceUnavailable + /// + /// Interruption is the exception: the peer died while the connection was live, so a mutation + /// dispatched just before may already have executed. Mutations ask for `.lostReply` to keep + /// that ambiguity explicit; `NSXPCConnectionInvalid` still reports unavailability because it + /// is also how a never-established connection (Agent not registered) fails. + static func normalizedProxyError( + _ error: Error, + disposition: ProxyErrorDisposition = .unavailable + ) -> Error { + let cocoaError = error as NSError + if disposition == .lostReply, + cocoaError.domain == NSCocoaErrorDomain, + cocoaError.code == CocoaError.xpcConnectionInterrupted.rawValue { + return LostReplyError() + } + return XPCClientError.serviceUnavailable } } diff --git a/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift index 10015ba..8511835 100644 --- a/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift +++ b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift @@ -24,6 +24,31 @@ struct IdempotentMutationRetrierTests { #expect(recorder.calls == [.attempt, .reset, .attempt]) } + @Test + func lostReplyResetsConnectionBeforeSingleRetry() async throws { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.lostReply, .success], recorder: recorder) + + try await perform(operation: operation, recorder: recorder) + + #expect(recorder.calls == [.attempt, .reset, .attempt]) + } + + @Test + func mixedAmbiguousFailuresBecomeUnknownAfterOneRetry() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .lostReply], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + Issue.record("Expected the second lost reply to become an unknown outcome.") + } catch XPCClientError.operationOutcomeUnknown { + #expect(recorder.calls == [.attempt, .reset, .attempt]) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + @Test func secondTimeoutBecomesUnknownWithoutAnotherRetry() async { let recorder = CallRecorder() @@ -103,6 +128,8 @@ private actor ScriptedOperation { switch outcomes.removeFirst() { case .failure: throw MutationTestError.expected + case .lostReply: + throw LostReplyError() case .success: return case .timedOut: @@ -113,6 +140,7 @@ private actor ScriptedOperation { private enum MutationOutcome: Sendable { case failure + case lostReply case success case timedOut } diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index 268fa5e..5926f59 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -18,6 +18,40 @@ struct XPCClientErrorTests { } } + @Test + func interruptedProxyErrorIsLostReplyOnlyForMutations() { + let interruption = NSError( + domain: NSCocoaErrorDomain, + code: CocoaError.xpcConnectionInterrupted.rawValue + ) + + #expect( + XPCClient.normalizedProxyError(interruption, disposition: .lostReply) is LostReplyError + ) + + guard case XPCClientError.serviceUnavailable = XPCClient.normalizedProxyError(interruption) + else { + Issue.record("Expected queries to keep serviceUnavailable for an interruption.") + return + } + } + + @Test + func neverConnectedProxyErrorStaysUnavailableForMutations() { + let invalid = NSError( + domain: NSCocoaErrorDomain, + code: CocoaError.xpcConnectionInvalid.rawValue + ) + + guard case XPCClientError.serviceUnavailable = XPCClient.normalizedProxyError( + invalid, + disposition: .lostReply + ) else { + Issue.record("Expected a never-established connection to report serviceUnavailable.") + return + } + } + @Test func serviceUnavailableExplainsFirstUseRecovery() throws { let suggestion = try #require(XPCClientError.serviceUnavailable.recoverySuggestion) diff --git a/docs/xpc.md b/docs/xpc.md index fb3570e..19745bb 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -577,16 +577,16 @@ Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使 |---|---| | Agent reply 成功 | async 方法正常返回 | | Agent reply 领域错误 | 抛出 Agent 返回的错误,例如缺少 Accessibility 权限 | -| proxy 创建或传输失败 | 归一化为 `serviceUnavailable`(Foundation transport 细节不跨出 Client 边界) | +| proxy 创建或传输失败 | query 归一化为 `serviceUnavailable`(Foundation transport 细节不跨出 Client 边界);但对端进程在连接存活期间死亡(`NSXPCConnectionInterrupted`)时,mutation 把它视为 reply 丢失(见下),因为已派发的请求可能执行过 | | 5 秒内没有任何结果 | connection 失效并抛出 `timedOut`,或由 mutation API 转换为 outcome unknown | -对 mutation 而言,timeout 不等于“Agent 没执行”。请求可能已经到达 Agent,只是 reply 没有及时返回。因此: +对 mutation 而言,reply 丢失(5 秒 timeout 或对端中断)不等于“Agent 没执行”。请求可能已经到达 Agent,只是 reply 没有返回。因此: -- `lock()` timeout 后通过一条新 connection 重发一次幂等 desired-lock,并要求收到 reply;仅查询 `status()` 不能证明 Focus persistence takeover 已经发生。 +- `lock()` reply 丢失后通过一条新 connection 重发一次幂等 desired-lock,并要求收到 reply;仅查询 `status()` 不能证明 Focus persistence takeover 已经发生。 - `unlock()` 对称地确认是否已经 unlocked。 -- `setFocusFilterLockEnabled(_:)` timeout 后同样在 fresh connection 上重新协商 capability 并重发同一 desired state;布尔状态无法证明当前 generation 是否由 Focus 创建。 -- 无法确认最终状态时抛出 `operationOutcomeUnknown`,而不是谎称操作一定失败。 -- Accessibility prompt 请求 timeout 同样只表示最终结果未知;不能据此断言 prompt 没有发出。 +- `setFocusFilterLockEnabled(_:)` reply 丢失后同样在 fresh connection 上重新协商 capability 并重发同一 desired state;布尔状态无法证明当前 generation 是否由 Focus 创建。 +- 无法确认最终状态时抛出 `operationOutcomeUnknown`,而不是谎称操作一定失败。非幂等的 `toggle` / interactive lock / safety check 只能走这条路,不能重发。 +- Accessibility prompt 请求 reply 丢失同样只表示最终结果未知;不能据此断言 prompt 没有发出。 App 在动作结束后仍会做完整 reconciliation,因为跨进程系统中应以 Agent 的当前查询结果为准,而不是长期相信某次历史 reply。