diff --git a/CLAUDE.md b/CLAUDE.md index 6bfb255..266d3fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in this repository. 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` +- **KeyboardLocker** (menu-bar App with a status/settings popover, 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` and `SystemSurfaces` diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index 3336d6f..ecfa2ab 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -380,6 +380,50 @@ public final class XPCClient: @unchecked Sendable { return try KeyboardLockerSettings.decodedFromXPC(data) } + /// Validates and persists settings in the Agent, returning the values it actually stored. + /// + /// The reply — not the request — is authoritative: the Agent normalizes values, so adopting the + /// locally sent payload would show the user a configuration that is not what is stored. + /// + /// A running lock keeps its active configuration; the new values seed the next lock. + public func applySettings( + _ settings: KeyboardLockerSettings + ) async throws -> KeyboardLockerSettings { + let connection = try await negotiatedConnection( + requiring: [.applySettings] + ) + let payload = try settings.encodedForXPC() + + do { + let data: Data? = try await withProxyReturning( + using: connection, + proxyErrorDisposition: .lostReply + ) { service, resume in + service.applySettings(payload) { resume($0, $1) } + } + return try KeyboardLockerSettings.decodedFromXPC(data) + } catch XPCClientError.timedOut, is LostReplyError { + // The write is idempotent, but re-sending it would hide whether the first attempt landed. + // Read the authoritative value instead: matching the request proves the write took effect. + return try await confirmAmbiguousSettingsWrite(expecting: settings) + } + } + + /// Recovers a lost `applySettings` reply by comparing the Agent's stored settings with the + /// request. Validation normalizes values, so the comparison uses the same normalization rather + /// than requiring byte equality with the raw request. + private func confirmAmbiguousSettingsWrite( + expecting settings: KeyboardLockerSettings + ) async throws -> KeyboardLockerSettings { + guard let expected = try? settings.validated(), + let stored = try? await currentSettings(), + stored == expected + else { + throw XPCClientError.operationOutcomeUnknown + } + return stored + } + // MARK: - Connection Management /// Invalidates the cached connection so a subsequent call resolves the currently registered @@ -583,9 +627,12 @@ public final class XPCClient: @unchecked Sendable { /// 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] = [ + /// Visible to `ClientTests` so the introduction-minor guard can assert against this table + /// directly instead of maintaining a second copy that would silently drift out of date. + static let minimumMinorByCapability: [ServiceCapability: Int] = [ .accessibilityPrompt: 0, .accessibilityStatus: 0, + .applySettings: 8, .committedReplacementDrain: 1, .currentSettings: 1, .currentSettingsWithError: 2, diff --git a/Core/Sources/Common/KeyboardLockerSettings.swift b/Core/Sources/Common/KeyboardLockerSettings.swift index f48b19b..c308c13 100644 --- a/Core/Sources/Common/KeyboardLockerSettings.swift +++ b/Core/Sources/Common/KeyboardLockerSettings.swift @@ -38,8 +38,9 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { self.modifierFlags = modifierFlags } - /// Filters CapsLock and other irrelevant modifiers to ensure reliable matching - private static let relevantModifierMask: CGEventFlags = [ + /// Filters CapsLock and other irrelevant modifiers to ensure reliable matching. + /// Shared with `hasModifier` so validation and matching normalize against one mask. + static let relevantModifierMask: CGEventFlags = [ .maskCommand, .maskControl, .maskAlternate, @@ -114,6 +115,107 @@ public extension KeyboardLockerSettings.Hotkey { } } +// MARK: - Validation + +/// Why a candidate settings value cannot become the Agent's active configuration. +/// +/// These are lock-out guardrails, not style preferences: each rejected case describes a +/// configuration from which a locked keyboard could not be recovered by the intended gesture. +public enum KeyboardLockerSettingsValidationError: Error, Equatable, LocalizedError { + case autoUnlockNotFinite + case autoUnlockOutOfRange(ClosedRange) + case hotkeyMissingModifier + case hotkeyUnmappable + + public var errorDescription: String? { + switch self { + case .autoUnlockNotFinite: + "The auto-unlock timeout is not a finite number of seconds." + + case let .autoUnlockOutOfRange(range): + """ + The auto-unlock timeout must be between \(Int(range.lowerBound)) and \ + \(Int(range.upperBound)) seconds. + """ + + case .hotkeyMissingModifier: + "The unlock hotkey needs at least one modifier key (⌃, ⌥, ⇧, or ⌘)." + + case .hotkeyUnmappable: + "The unlock hotkey uses a key this keyboard layout cannot display." + } + } + + public var recoverySuggestion: String? { + switch self { + case .hotkeyMissingModifier: + "A hotkey without modifiers can be consumed by the frontmost app or an input method before it reaches KeyboardLocker." + + case .hotkeyUnmappable: + "Choose a key that appears on the current keyboard layout so the hotkey can be shown in the app, its notification, and the widget." + + case .autoUnlockNotFinite, .autoUnlockOutOfRange: + nil + } + } +} + +public extension KeyboardLockerSettings { + /// Bounds for a timed auto-unlock. The lower bound keeps a deliberate lock from expiring before + /// the user has finished what they locked the keyboard for; the upper bound keeps the fail-safe + /// within a window a user would actually wait out rather than force-restarting the machine. + static let allowedAutoUnlockRange: ClosedRange = 5...3600 + + /// Returns the normalized settings the Agent may store, or throws when a value would leave a + /// locked keyboard unrecoverable by its configured gesture. + /// + /// Lives in `Common` because the Agent owns enforcement while wrappers need the same rules for + /// immediate feedback — duplicating them in a wrapper would let the two drift apart. + func validated() throws -> Self { + _ = try unlockHotkey.validated() + + var normalized = self + if case let .timed(seconds) = autoUnlockPolicy { + guard seconds.isFinite else { + throw KeyboardLockerSettingsValidationError.autoUnlockNotFinite + } + // Round before the range check so a value that only differs sub-second from a bound is + // accepted rather than rejected for a difference the UI cannot even express. + let wholeSeconds = seconds.rounded() + guard Self.allowedAutoUnlockRange.contains(wholeSeconds) else { + throw KeyboardLockerSettingsValidationError.autoUnlockOutOfRange( + Self.allowedAutoUnlockRange + ) + } + normalized.autoUnlockPolicy = .timed(seconds: wholeSeconds) + } + return normalized + } +} + +public extension KeyboardLockerSettings.Hotkey { + /// Whether the hotkey carries at least one modifier that survives event-tap normalization. + /// + /// Deliberately measured against the same mask `matches(keyCode:flags:)` normalizes with, so + /// validation can never accept a modifier the matcher would then discard. + var hasModifier: Bool { + !modifierFlags.intersection(Self.relevantModifierMask).isEmpty + } + + /// Returns the hotkey when it can actually unlock a locked keyboard, or throws explaining why + /// it cannot. Split out from `KeyboardLockerSettings.validated()` so a hotkey picker can give + /// per-keystroke feedback against the same rule the Agent enforces. + func validated() throws -> Self { + guard hasModifier else { + throw KeyboardLockerSettingsValidationError.hotkeyMissingModifier + } + guard KeyCodeConverter.stringFromKeyCode(keyCode, modifiers: modifierFlags) != nil else { + throw KeyboardLockerSettingsValidationError.hotkeyUnmappable + } + return self + } +} + // MARK: - XPC Serialization public enum KeyboardLockerSettingsCodingError: Error, Equatable, LocalizedError { diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift index 6252749..325dadf 100644 --- a/Core/Sources/Common/ServiceDescriptor.swift +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -37,6 +37,7 @@ 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 applySettings = Self(rawValue: "apply-settings") public static let currentSettings = Self(rawValue: "current-settings") public static let currentSettingsWithError = Self( rawValue: "current-settings-with-error" @@ -79,11 +80,12 @@ 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: 7) + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 8) public static let requiredCapabilities: Set = [ .accessibilityPrompt, .accessibilityStatus, + .applySettings, .committedReplacementDrain, .currentSettings, .currentSettingsWithError, diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index 9b48cc9..a77eb81 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -149,4 +149,12 @@ public protocol KeyboardLockerServiceProtocol { /// Returns the Agent's current settings and reports serialization failures explicitly. func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) + + /// Validates and persists JSON-encoded `KeyboardLockerSettings`, replying with the values the + /// Agent actually stored so callers never assume their own payload became authoritative. + /// + /// A running lock deliberately keeps its active settings, unlock gesture, start time, and + /// auto-unlock deadline: re-applying settings mid-lock would restart the fail-safe window, and + /// disabling auto-unlock would cancel it outright. New values seed the next lock instead. + func applySettings(_ data: Data, reply: @escaping (Data?, Error?) -> Void) } diff --git a/Core/Sources/Service/AgentService.swift b/Core/Sources/Service/AgentService.swift index 8c797e0..7aed680 100644 --- a/Core/Sources/Service/AgentService.swift +++ b/Core/Sources/Service/AgentService.swift @@ -18,6 +18,14 @@ protocol LockEngineServing { extension LockEngine: LockEngineServing {} +/// The settings persistence surface `AgentService` writes through. Seamed so `ServiceTests` can +/// assert what actually reached storage instead of driving `UserDefaults.standard`. +protocol SettingsPersisting { + func save(_ settings: KeyboardLockerSettings) throws +} + +extension KeyboardLockerSettingsStore: SettingsPersisting {} + /// XPC service implementation. Owns the settings source of truth and drives the single /// global `LockEngine`. All wrappers reach the lock exclusively through this object. /// @@ -27,7 +35,8 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche private static let replacementPreparationDuration: TimeInterval = 30 @MainActor private let descriptorResult: Result - @MainActor private let settings: KeyboardLockerSettings + @MainActor private var settings: KeyboardLockerSettings + @MainActor private let settingsStore: any SettingsPersisting @MainActor private let engine: any LockEngineServing @MainActor private var lockStatusNotifier: LockStatusNotifier? @MainActor private let expirationScheduler: MainActorTimerScheduler @@ -38,11 +47,13 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche init( descriptorResult: Result, settings: KeyboardLockerSettings, + settingsStore: any SettingsPersisting, engine: any LockEngineServing, expirationScheduler: @escaping MainActorTimerScheduler ) { self.descriptorResult = descriptorResult self.settings = settings + self.settingsStore = settingsStore self.engine = engine self.expirationScheduler = expirationScheduler super.init() @@ -62,9 +73,11 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche notifier?.lockStateDidChange() } + let settingsStore = KeyboardLockerSettingsStore() self.init( descriptorResult: Result { try Self.makeServiceDescriptor() }, - settings: KeyboardLockerSettingsStore().load(), + settings: settingsStore.load(), + settingsStore: settingsStore, engine: engine, expirationScheduler: liveMainActorTimerScheduler ) @@ -368,6 +381,46 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche } } + /// Validates, persists, and adopts new settings, replying with the stored values. + /// + /// A running lock keeps its active configuration on purpose. `LockEngine.updateSettings` re-arms + /// the auto-unlock timer from the moment it is called — and cancels it outright for a `.disabled` + /// policy — so applying settings mid-lock could extend or entirely remove the fail-safe that + /// recovers a locked keyboard. New values seed the next lock instead. + public func applySettings( + _ data: Data, + reply: @escaping (Data?, Error?) -> Void + ) { + let transferredReply = TransferredXPCReply(reply) + let candidate: KeyboardLockerSettings + do { + candidate = try KeyboardLockerSettings.decodedFromXPC(data) + } catch { + transferredReply.body(nil, error) + return + } + + executeOnMainActor(reply: transferredReply) { reply in + do { + // Validate before touching storage so a rejected payload leaves no partial state. + let validated = try candidate.validated() + // A pending replacement refuses configuration changes for the same reason it refuses new + // locks: the agent is about to be torn down and must not adopt state it cannot honor. + try self.ensureAcceptingLockRequests() + let encoded = try validated.encodedForXPC() + + try self.settingsStore.save(validated) + self.settings = validated + if !self.engine.isLocked { + self.engine.updateSettings(validated) + } + reply(encoded, nil) + } catch { + reply(nil, error) + } + } + } + // MARK: - Helpers @MainActor diff --git a/Core/Sources/Service/KeyboardLockerSettingsStore.swift b/Core/Sources/Service/KeyboardLockerSettingsStore.swift index 10f352c..d65e475 100644 --- a/Core/Sources/Service/KeyboardLockerSettingsStore.swift +++ b/Core/Sources/Service/KeyboardLockerSettingsStore.swift @@ -41,6 +41,16 @@ final class KeyboardLockerSettingsStore { } } + /// Persists settings as the Agent's new source of truth. + /// + /// Unlike `load()`, a failure here must propagate: the read path falls back to defaults so the + /// Agent can still start, but silently discarding a write would leave the caller believing its + /// configuration is active while the Agent keeps using the previous one. + 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/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift index 5926f59..6b4ab00 100644 --- a/Core/Tests/ClientTests/XPCClientErrorTests.swift +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -102,24 +102,40 @@ struct XPCClientErrorTests { } } + /// Every capability the bundled Agent advertises must have an introduction minor, otherwise + /// `validate` would silently grant a new selector at minor 0 on an Agent too old to implement it. + @Test + func everyAdvertisedCapabilityDeclaresAnIntroductionMinor() { + let withoutIntroductionMinor = ServiceContract.requiredCapabilities + .filter { XPCFeatureNegotiation.minimumMinorByCapability[$0] == nil } + .map(\.rawValue) + .sorted() + + #expect( + withoutIntroductionMinor.isEmpty, + "Add an introduction minor for: \(withoutIntroductionMinor.joined(separator: ", "))." + ) + } + + /// No capability may claim a minor above the contract the bundled Agent actually advertises, + /// which would make its selector permanently unreachable. + @Test + func noIntroductionMinorExceedsTheAdvertisedContract() { + let currentMinor = ServiceContract.protocolVersion.minor + let unreachable = XPCFeatureNegotiation.minimumMinorByCapability + .filter { $0.value > currentMinor } + .map { "\($0.key.rawValue) (minor \($0.value))" } + .sorted() + + #expect( + unreachable.isEmpty, + "Contract advertises minor \(currentMinor); unreachable: \(unreachable.joined(separator: ", "))." + ) + } + @Test func featureNegotiationEnforcesSelectorIntroductionMinor() { - 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, - .safetyCheckLock: 7, - ] - - for (capability, introductionMinor) in introductionMinorByCapability { + for (capability, introductionMinor) in XPCFeatureNegotiation.minimumMinorByCapability { guard introductionMinor > 0 else { continue } @@ -162,6 +178,47 @@ struct XPCClientErrorTests { ) } + /// A 1.7 Agent predates the settings-write selector, so the App must be refused before it can + /// believe a configuration change was stored. + @Test + func settingsWriteIsRefusedByAnAgentPredatingTheSelector() { + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 7), + capabilities: ServiceContract.requiredCapabilities.subtracting([.applySettings]) + ) + + #expect( + throws: ServiceCompatibilityIssue.protocolMinorTooOld(required: 8, actual: 7) + ) { + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.applySettings] + ) + } + } + + /// A same-minor Agent that does not advertise the capability must also be refused: the minor + /// floor alone is not evidence the selector is implemented. + @Test + func settingsWriteIsRefusedWithoutTheAdvertisedCapability() { + let descriptor = makeDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities.subtracting([.applySettings]) + ) + + do { + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.applySettings] + ) + Issue.record("Expected missing apply-settings capability.") + } catch XPCClientError.missingCapability(.applySettings) { + // Expected. + } catch { + Issue.record("Expected missing apply-settings capability, got \(error).") + } + } + @Test func featureNegotiationChecksCapabilityAfterCompatibleVersion() { let descriptor = makeDescriptor( diff --git a/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift b/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift new file mode 100644 index 0000000..eeffc98 --- /dev/null +++ b/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift @@ -0,0 +1,134 @@ +@testable import Common +import CoreGraphics +import Foundation +import Testing + +struct KeyboardLockerSettingsValidationTests { + /// 'L' is used by the shipped default, so it is known to be mappable on any ASCII-capable layout. + private static let mappableKeyCode = CGKeyCode(SharedConstants.defaultUnlockKeyCode) + + private static func settings( + keyCode: CGKeyCode = mappableKeyCode, + modifiers: CGEventFlags = [.maskControl, .maskCommand], + policy: KeyboardLockerSettings.AutoUnlockPolicy = .timed(seconds: 60) + ) -> KeyboardLockerSettings { + KeyboardLockerSettings( + autoUnlockPolicy: policy, + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: keyCode, + modifierFlags: modifiers + ) + ) + } + + // MARK: - Hotkey guardrails + + @Test + func defaultSettingsAreValid() throws { + let validated = try KeyboardLockerSettings.default.validated() + + #expect(validated == .default) + } + + @Test + func hotkeyWithoutModifiersIsRejected() { + let candidate = Self.settings(modifiers: []) + + #expect(throws: KeyboardLockerSettingsValidationError.hotkeyMissingModifier) { + try candidate.validated() + } + } + + @Test + func capsLockAloneDoesNotCountAsAModifier() { + // CapsLock is stripped by `matches(keyCode:flags:)`, so accepting it would store a hotkey + // the event tap could never match. + let candidate = Self.settings(modifiers: [.maskAlphaShift]) + + #expect(throws: KeyboardLockerSettingsValidationError.hotkeyMissingModifier) { + try candidate.validated() + } + } + + @Test(arguments: [ + CGEventFlags.maskCommand, + .maskControl, + .maskAlternate, + .maskShift, + ]) + func eachRelevantModifierAloneIsAccepted(modifier: CGEventFlags) throws { + let validated = try Self.settings(modifiers: modifier).validated() + + #expect(validated.unlockHotkey.modifierFlags == modifier) + } + + @Test + func unmappableKeyCodeIsRejected() { + // 0xFF is not a key on any physical layout, so UCKeyTranslate cannot produce a glyph. + let candidate = Self.settings(keyCode: 0xFF) + + #expect(throws: KeyboardLockerSettingsValidationError.hotkeyUnmappable) { + try candidate.validated() + } + } + + // MARK: - Auto-unlock guardrails + + @Test + func disabledAutoUnlockIsAccepted() throws { + // Allowed on purpose: the UI marks it as not recommended and confirms it, but a long + // unattended lock is a legitimate use. + let validated = try Self.settings(policy: .disabled).validated() + + #expect(validated.autoUnlockPolicy == .disabled) + } + + @Test(arguments: [5.0, 60.0, 3600.0]) + func inRangeTimeoutsAreAccepted(seconds: TimeInterval) throws { + let validated = try Self.settings(policy: .timed(seconds: seconds)).validated() + + #expect(validated.autoUnlockPolicy == .timed(seconds: seconds)) + } + + @Test(arguments: [4.0, 0.0, -1.0, 3601.0, 86400.0]) + func outOfRangeTimeoutsAreRejected(seconds: TimeInterval) { + #expect( + throws: KeyboardLockerSettingsValidationError.autoUnlockOutOfRange( + KeyboardLockerSettings.allowedAutoUnlockRange + ) + ) { + try Self.settings(policy: .timed(seconds: seconds)).validated() + } + } + + @Test(arguments: [Double.nan, .infinity, -.infinity]) + func nonFiniteTimeoutsAreRejected(seconds: TimeInterval) { + #expect(throws: KeyboardLockerSettingsValidationError.autoUnlockNotFinite) { + try Self.settings(policy: .timed(seconds: seconds)).validated() + } + } + + @Test + func fractionalTimeoutIsRoundedToWholeSeconds() throws { + let validated = try Self.settings(policy: .timed(seconds: 59.6)).validated() + + #expect(validated.autoUnlockPolicy == .timed(seconds: 60)) + } + + @Test + func aTimeoutJustBelowTheLowerBoundRoundsIntoRange() throws { + // 4.7 rounds to 5, which is in range. Checking the bound after rounding avoids rejecting a + // value for a sub-second difference the UI cannot express. + let validated = try Self.settings(policy: .timed(seconds: 4.7)).validated() + + #expect(validated.autoUnlockPolicy == .timed(seconds: 5)) + } + + @Test + func validationIsIdempotent() throws { + let once = try Self.settings(policy: .timed(seconds: 59.6)).validated() + let twice = try once.validated() + + #expect(once == twice) + } +} diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift index 78ee43f..8e5809d 100644 --- a/Core/Tests/ServiceTests/AgentServiceTests.swift +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -1,4 +1,5 @@ import Common +import CoreGraphics import Foundation @testable import Service import Testing @@ -10,11 +11,13 @@ final class AgentServiceTests { private let scheduler: ManualExpirationScheduler private let instanceID: UUID private let customSettings: KeyboardLockerSettings + private let settingsStore: FakeSettingsStore init() { engine = FakeLockEngine() scheduler = ManualExpirationScheduler() instanceID = UUID() + settingsStore = FakeSettingsStore() customSettings = KeyboardLockerSettings( autoUnlockPolicy: .disabled, unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) @@ -422,8 +425,174 @@ final class AgentServiceTests { #expect(engine.unlockCallCount == 0) } + // MARK: - Settings writes + + @Test + func applySettingsPersistsAndSeedsTheEngineWhileUnlocked() throws { + let service = makeService() + let desired = makeValidSettings(autoUnlockPolicy: .timed(seconds: 90)) + + let applied = try applySettings(desired, on: service) + + #expect(applied == desired) + #expect(settingsStore.saved == [desired]) + // Unlocked, so the new values may seed the engine immediately for the next lock. + #expect(engine.seededSettings.last == desired) + } + + /// Regression gate for the lock-out path: `LockEngine.updateSettings` re-arms the auto-unlock + /// timer from the moment it is called, and cancels it entirely for `.disabled`. Applying settings + /// during an active lock must therefore never reach the engine. + @Test + func applySettingsWhileLockedPersistsWithoutTouchingTheRunningLock() throws { + let engine = FakeLockEngine(isLocked: true) + let service = makeService(engineOverride: engine) + let seededBeforeWrite = engine.seededSettings + let desired = makeValidSettings(autoUnlockPolicy: .disabled) + + let applied = try applySettings(desired, on: service) + + #expect(applied == desired) + #expect(settingsStore.saved == [desired]) + // The running lock keeps its active settings, gesture, start time, and deadline. + #expect(engine.seededSettings == seededBeforeWrite) + #expect(engine.unlockCallCount == 0) + #expect(engine.lockCalls.isEmpty) + #expect(engine.isLocked) + } + + @Test + func appliedSettingsBecomeTheValuesReportedAndUsedForTheNextLock() throws { + let service = makeService() + let desired = makeValidSettings(autoUnlockPolicy: .timed(seconds: 30)) + + _ = try applySettings(desired, on: service) + + var reported: KeyboardLockerSettings? + service.currentSettingsWithError { data, _ in + reported = try? KeyboardLockerSettings.decodedFromXPC(data) + } + #expect(reported == desired) + + service.lockKeyboard { _ in } + #expect(engine.lockCalls.last?.settings == desired) + } + + @Test + func applySettingsNormalizesBeforeReplyingSoCallersAdoptTheStoredValues() throws { + let service = makeService() + let desired = makeValidSettings(autoUnlockPolicy: .timed(seconds: 59.6)) + + let applied = try applySettings(desired, on: service) + + #expect(applied.autoUnlockPolicy == .timed(seconds: 60)) + #expect(settingsStore.saved == [applied]) + } + + @Test + func applySettingsRejectsAnInvalidPayloadWithoutPersisting() throws { + let service = makeService() + let invalid = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 37, modifierFlags: []) + ) + + var error: Error? + var data: Data? + try service.applySettings(invalid.encodedForXPC()) { replyData, replyError in + data = replyData + error = replyError + } + + #expect(data == nil) + #expect( + error as? KeyboardLockerSettingsValidationError == .hotkeyMissingModifier + ) + #expect(settingsStore.saved.isEmpty) + } + + @Test + func applySettingsRejectsUndecodablePayloadWithoutPersisting() { + let service = makeService() + + var error: Error? + service.applySettings(Data("not settings".utf8)) { _, replyError in + error = replyError + } + + #expect( + error as? KeyboardLockerSettingsCodingError == .invalidPayload + ) + #expect(settingsStore.saved.isEmpty) + } + + @Test + func applySettingsKeepsPreviousValuesWhenPersistenceFails() throws { + let service = makeService() + settingsStore.saveError = StubError.descriptorUnavailable + + var error: Error? + try service.applySettings( + makeValidSettings(autoUnlockPolicy: .timed(seconds: 30)).encodedForXPC() + ) { _, replyError in + error = replyError + } + + #expect(error as? StubError == .descriptorUnavailable) + #expect(settingsStore.saved.isEmpty) + + // A failed write must not become the in-memory source of truth. + var reported: KeyboardLockerSettings? + service.currentSettingsWithError { data, _ in + reported = try? KeyboardLockerSettings.decodedFromXPC(data) + } + #expect(reported == customSettings) + } + + @Test + func preparedDrainRejectsSettingsWrites() throws { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var error: Error? + try service.applySettings( + makeValidSettings(autoUnlockPolicy: .timed(seconds: 30)).encodedForXPC() + ) { _, replyError in + error = replyError + } + + assertReplacementError(error, code: 1) + #expect(settingsStore.saved.isEmpty) + } + // MARK: - Fixtures + private func makeValidSettings( + autoUnlockPolicy: KeyboardLockerSettings.AutoUnlockPolicy + ) -> KeyboardLockerSettings { + KeyboardLockerSettings( + autoUnlockPolicy: autoUnlockPolicy, + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: CGKeyCode(SharedConstants.defaultUnlockKeyCode), + modifierFlags: [.maskControl, .maskAlternate] + ) + ) + } + + private func applySettings( + _ settings: KeyboardLockerSettings, + on service: AgentService + ) throws -> KeyboardLockerSettings { + var data: Data? + var error: Error? + try service.applySettings(settings.encodedForXPC()) { replyData, replyError in + data = replyData + error = replyError + } + #expect(error == nil) + return try KeyboardLockerSettings.decodedFromXPC(data) + } + private func makeService( descriptorResult: Result? = nil, engineOverride: FakeLockEngine? = nil @@ -431,6 +600,7 @@ final class AgentServiceTests { AgentService( descriptorResult: descriptorResult ?? .success(makeDescriptor(instanceID: instanceID)), settings: customSettings, + settingsStore: settingsStore, engine: engineOverride ?? engine, expirationScheduler: scheduler.scheduler ) @@ -570,6 +740,23 @@ private final class FakeLockEngine: LockEngineServing { } } +@MainActor +private final class FakeSettingsStore: SettingsPersisting { + var saveError: Error? + private(set) var saved: [KeyboardLockerSettings] = [] + + nonisolated init() {} + + nonisolated func save(_ settings: KeyboardLockerSettings) throws { + try MainActor.assumeIsolated { + if let saveError { + throw saveError + } + saved.append(settings) + } + } +} + @MainActor private final class ManualExpirationScheduler { private(set) var scheduledCount = 0 diff --git a/KeyboardLocker/AgentCoordinationServices.swift b/KeyboardLocker/AgentCoordinationServices.swift index 5ef1830..576279b 100644 --- a/KeyboardLocker/AgentCoordinationServices.swift +++ b/KeyboardLocker/AgentCoordinationServices.swift @@ -48,6 +48,21 @@ protocol AgentReplacementServing: Sendable { func resetConnection() } +/// Settings reads and writes, plus the authoritative lock snapshot the UI presents. +/// +/// `currentSettings()` and `lockStatusSnapshot().settings` deliberately answer different +/// questions: the former is the persisted configuration the next lock will use — what the settings +/// UI edits — while the latter is what a running lock is actually enforcing. They differ while the +/// keyboard is locked, because a write does not disturb an active lock. +@MainActor +protocol AgentSettingsServing: Sendable { + func currentSettings() async throws -> KeyboardLockerSettings + func applySettings( + _ settings: KeyboardLockerSettings + ) async throws -> KeyboardLockerSettings + func lockStatusSnapshot() async throws -> LockStatusSnapshot +} + @MainActor protocol AgentLockStateObserving: Sendable { func subscribe( @@ -65,7 +80,8 @@ protocol AgentClientServing: AgentControlServing, AgentLockActionServing, AgentReadinessServing, - AgentReplacementServing {} + AgentReplacementServing, + AgentSettingsServing {} @MainActor struct LiveAgentClient: AgentClientServing { @@ -194,6 +210,24 @@ struct LiveAgentClient: AgentClientServing { try await XPCClient.shared.hasAccessibilityPermission() } + func currentSettings() async throws -> KeyboardLockerSettings { + try await XPCClient.shared.currentSettings() + } + + func applySettings( + _ settings: KeyboardLockerSettings + ) async throws -> KeyboardLockerSettings { + let stored = try await XPCClient.shared.applySettings(settings) + // The widget and control render the unlock hotkey, so a stored configuration change has to + // reach the system-managed surfaces the same way a lock state change does. + surfaceInvalidator.invalidate() + return stored + } + + func lockStatusSnapshot() async throws -> LockStatusSnapshot { + try await XPCClient.shared.lockStatusSnapshot() + } + func requestAccessibilityPermission() async throws { try await XPCClient.shared.requestAccessibilityPermission() } diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift index a9ed267..7755a89 100644 --- a/KeyboardLocker/AppCoordinator.swift +++ b/KeyboardLocker/AppCoordinator.swift @@ -29,6 +29,7 @@ final class AppCoordinator { } enum Activity: Equatable { + case applyingSettings case locking case requestingAccessibility case restartingAgent @@ -44,11 +45,60 @@ final class AppCoordinator { case running } + /// The Agent's persisted configuration — what the next lock will use, and what the settings UI + /// edits. A read failure stays `unavailable`: presenting `.default` would invent a second + /// source of truth and could show an unlock hotkey the Agent is not actually using. + enum SettingsState: Equatable { + case loading + case loaded(KeyboardLockerSettings) + case unavailable(String) + + var settings: KeyboardLockerSettings? { + guard case let .loaded(settings) = self else { + return nil + } + return settings + } + } + struct Snapshot: Equatable { let state: State let activity: Activity? let lastError: String? let safetyCheckState: SafetyCheckState + /// Authoritative point-in-time lock detail: start time, deadline, and the settings a running + /// lock is enforcing. `nil` when the Agent could not be reached for it. + let lockSnapshot: LockStatusSnapshot? + let settingsState: SettingsState + + /// Agent-supplied detail defaults to absent so callers that only care about readiness — tests + /// and previews — do not have to describe a lock snapshot they are not exercising. + init( + state: State, + activity: Activity?, + lastError: String?, + safetyCheckState: SafetyCheckState, + lockSnapshot: LockStatusSnapshot? = nil, + settingsState: SettingsState = .loading + ) { + self.state = state + self.activity = activity + self.lastError = lastError + self.safetyCheckState = safetyCheckState + self.lockSnapshot = lockSnapshot + self.settingsState = settingsState + } + + /// Whether stored settings differ from what the running lock enforces, i.e. a write landed + /// while locked and takes effect on the next lock. + var hasSettingsPendingNextLock: Bool { + guard let lockSnapshot, lockSnapshot.isLocked, + let stored = settingsState.settings + else { + return false + } + return stored != lockSnapshot.settings + } } private(set) var state: State = .checking(lastKnownLock: nil) { @@ -75,12 +125,26 @@ final class AppCoordinator { } } + private(set) var lockSnapshot: LockStatusSnapshot? { + didSet { + publishSnapshotIfNeeded() + } + } + + private(set) var settingsState: SettingsState = .loading { + didSet { + publishSnapshotIfNeeded() + } + } + var snapshot: Snapshot { Snapshot( state: state, activity: activity, lastError: lastError, - safetyCheckState: safetyCheckState + safetyCheckState: safetyCheckState, + lockSnapshot: lockSnapshot, + settingsState: settingsState ) } @@ -90,6 +154,7 @@ final class AppCoordinator { } } + private var agentDetailTask: Task? private var reconciliationTask: Task? private var needsFollowUpReconciliation = false private var pendingUpdatePlan: AgentUpdatePlan? @@ -245,6 +310,41 @@ final class AppCoordinator { } } + /// Stores a new configuration in the Agent and adopts the values it reports back. + /// + /// Callers must pass values that already satisfy `KeyboardLockerSettings.validated()`; the Agent + /// enforces the same rules and rejects anything else. A running lock is unaffected — the stored + /// values take effect on the next lock. + func applySettings(_ settings: KeyboardLockerSettings) { + guard activity == nil else { + return + } + + reconciliationTask?.cancel() + activity = .applyingSettings + lastError = nil + + Task { [weak self] in + guard let self else { + return + } + + var actionError: String? + do { + // The reply is authoritative: validation normalizes values, so the request is not + // necessarily what ended up stored. + settingsState = .loaded(try await client.applySettings(settings)) + } catch { + actionError = error.localizedDescription + } + + activity = nil + // Re-reads the authoritative lock snapshot so a locked keyboard's unchanged active settings + // become visible next to the newly stored ones. + startReconciliation(preserving: actionError) + } + } + func requestAccessibilityPermission() { guard case .accessibilityRequired = state, activity == nil else { return @@ -386,6 +486,7 @@ final class AppCoordinator { case let .invalidBundle(failure): pendingUpdatePlan = nil + clearAgentDetail(reason: failure.message) state = .unavailable(message: failure.message, canRestartAgent: false) lastError = nil @@ -396,6 +497,7 @@ final class AppCoordinator { : .accessibilityRequired(isLocked: isLocked) lastError = actionError startStateObservation() + startAgentDetailRefresh() if needsFollowUpReconciliation { startReconciliation( @@ -515,6 +617,7 @@ final class AppCoordinator { pendingUpdatePlan = nil stopStateObservation() + clearAgentDetail(reason: message) state = .agentReplacementInProgress(message: message) lastError = nil reconciliationTask?.cancel() @@ -533,6 +636,9 @@ final class AppCoordinator { private func showAgentUpdateRequired(plan: AgentUpdatePlan) { pendingUpdatePlan = plan + // An agent needing an update may not implement the settings-write selector at all, so the + // settings UI must not present an editable configuration until the handshake succeeds again. + clearAgentDetail(reason: plan.message) state = .agentUpdateRequired( isLocked: plan.isLocked, message: plan.message @@ -554,6 +660,7 @@ final class AppCoordinator { if let clientError = error as? XPCClientError, case .peerAuthenticationUnavailable = clientError { pendingUpdatePlan = nil + clearAgentDetail(reason: clientError.localizedDescription) state = .unavailable( message: """ This copy of KeyboardLocker cannot establish its signed XPC identity. \ @@ -574,6 +681,7 @@ final class AppCoordinator { if applyRegistrationState(currentRegistrationState) { pendingUpdatePlan = nil + clearAgentDetail(reason: error.localizedDescription) let contextMessage = context.map { " \($0)" } ?? "" state = .unavailable( message: """ @@ -595,6 +703,9 @@ final class AppCoordinator { case .approvalRequired: stopStateObservation() pendingUpdatePlan = nil + clearAgentDetail( + reason: "The KeyboardLocker agent needs approval in Login Items before it can be reached." + ) state = .agentApprovalRequired lastError = nil return false @@ -602,6 +713,7 @@ final class AppCoordinator { case let .unavailable(failure): stopStateObservation() pendingUpdatePlan = nil + clearAgentDetail(reason: failure.message) let canRestartAgent = if case .restartFailed = failure { true } else { @@ -651,6 +763,57 @@ final class AppCoordinator { } } + /// Fetches the authoritative lock snapshot and the persisted settings once the handshake has + /// established that the Agent is reachable and compatible. + /// + /// Kept out of `AgentReadinessCoordinator`: readiness decides whether the Agent can be trusted at + /// all, while this is presentation detail whose failure must not downgrade a ready state. + private func startAgentDetailRefresh() { + agentDetailTask?.cancel() + agentDetailTask = Task { [weak self] in + guard let self else { + return + } + + do { + let snapshot = try await client.lockStatusSnapshot() + guard !Task.isCancelled else { + return + } + lockSnapshot = snapshot + } catch { + guard !Task.isCancelled else { + return + } + lockSnapshot = nil + } + + do { + let settings = try await client.currentSettings() + guard !Task.isCancelled else { + return + } + settingsState = .loaded(settings) + } catch { + guard !Task.isCancelled else { + return + } + settingsState = .unavailable(error.localizedDescription) + } + } + } + + /// Drops presentation detail whenever the Agent stops being a trustworthy source for it. + /// + /// Settings become explicitly unavailable rather than falling back to `.default`, which would + /// show the user a configuration the Agent is not actually using. + private func clearAgentDetail(reason: String) { + agentDetailTask?.cancel() + agentDetailTask = nil + lockSnapshot = nil + settingsState = .unavailable(reason) + } + private func startStateObservation() { guard stateToken == nil else { return diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift index 897b25a..b2f853f 100644 --- a/KeyboardLocker/KeyboardLockerApplication.swift +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -75,7 +75,7 @@ enum KeyboardLockerApplication { private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDelegate { private let automationController: ExternalAutomationController private let servicesProvider: KeyboardLockerServicesProvider - private var statusMenuController: StatusMenuController? + private var statusItemController: StatusItemController? override init() { let automationController = ExternalAutomationController() @@ -88,7 +88,7 @@ private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDe func applicationDidFinishLaunching(_: Notification) { NSApp.servicesProvider = servicesProvider - statusMenuController = StatusMenuController(coordinator: AppCoordinator()) + statusItemController = StatusItemController(coordinator: AppCoordinator()) } func application(_: NSApplication, open urls: [URL]) { diff --git a/KeyboardLocker/KeyboardLockerDiagnostics.swift b/KeyboardLocker/KeyboardLockerDiagnostics.swift index 9d03f3a..6eb7001 100644 --- a/KeyboardLocker/KeyboardLockerDiagnostics.swift +++ b/KeyboardLocker/KeyboardLockerDiagnostics.swift @@ -184,6 +184,8 @@ private extension AppCoordinator.State { private extension AppCoordinator.Activity { var diagnosticDescription: String { switch self { + case .applyingSettings: + "applying-settings" case .locking: "locking" case .requestingAccessibility: diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusItemController.swift similarity index 73% rename from KeyboardLocker/StatusMenuController.swift rename to KeyboardLocker/StatusItemController.swift index b30ece6..3ad8380 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusItemController.swift @@ -1,18 +1,22 @@ 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. +/// Owns the menu-bar status item and the popover it toggles. +/// +/// It holds the coordinator's single snapshot observer and fans each update out to the menu-bar +/// icon and to `AppUIStore` (which drives the popover's SwiftUI content). Actions that need a modal +/// `NSAlert` or a System Settings jump live here and are handed to the popover through +/// `PopoverActions`, so the SwiftUI layer stays free of AppKit presentation. @MainActor -final class StatusMenuController: NSObject, NSMenuDelegate { +final class StatusItemController: NSObject { 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 let uiStore: AppUIStore + private var popoverPresenter: PopoverPresenter! private var currentSnapshot: AppCoordinator.Snapshot - private var detailMessage: String? private var hasOfferedSafetyCheckThisLaunch = false private var lastHandledSafetyCheckState: AppCoordinator.SafetyCheckState = .idle @@ -37,24 +41,57 @@ final class StatusMenuController: NSObject, NSMenuDelegate { self.safetyCheckStore = safetyCheckStore currentSnapshot = coordinator.snapshot statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + + let store = AppUIStore(coordinator: coordinator) + uiStore = store super.init() - menu.delegate = self - statusItem.menu = menu + // Actions capture `self`, so the popover can only be built once `self` exists. + popoverPresenter = PopoverPresenter( + rootView: PopoverRootView(store: store, actions: makeActions()) + ) + + if let button = statusItem.button { + button.action = #selector(statusItemClicked) + button.target = self + } + + // This controller owns the coordinator's single observer slot and fans each snapshot out to + // both the menu-bar icon and the popover's store. coordinator.onSnapshotChange = { [weak self] snapshot in self?.render(snapshot) } + render(coordinator.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. + private func makeActions() -> PopoverActions { + PopoverActions( + confirmUpdateAgent: { [weak self] in self?.confirmUpdateAgent() }, + confirmRestartAgent: { [weak self] in self?.confirmRestartAgent() }, + confirmSafetyCheck: { [weak self] in self?.confirmSafetyCheck() }, + copyDiagnostics: { [weak self] in self?.copyDiagnostics() }, + manageCommandLineTool: { [weak self] in self?.manageCommandLineTool() }, + openLoginItemsSettings: { [weak self] in self?.openLoginItemsSettings() }, + openAccessibilitySettings: { [weak self] in self?.openAccessibilitySettings() }, + quit: { [weak self] in self?.quit() } + ) + } + + // MARK: - Status item + + @objc + private func statusItemClicked() { + guard let button = statusItem.button else { + return + } + // Recalibrate before showing: a suspended app may have missed a broadcast. coordinator.reconcile() + popoverPresenter.toggle(relativeTo: button) } private func render(_ snapshot: AppCoordinator.Snapshot) { currentSnapshot = snapshot - detailMessage = makeDetailMessage(for: snapshot) + uiStore.receive(snapshot) let appearance = makeAppearance(for: snapshot) if let button = statusItem.button { @@ -67,107 +104,16 @@ final class StatusMenuController: NSObject, NSMenuDelegate { button.toolTip = appearance.title } - rebuildMenu(statusTitle: appearance.title) handleSafetyCheckExperience(snapshot) } - 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)) - } - - 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()) - addAction(title: "Quit KeyboardLocker", action: #selector(quit)) - } - - private func addStateActions(for state: AppCoordinator.State) { - switch state { - case .agentReplacementInProgress, .checking: - 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(performDisplayedLockAction)) - } - addAction(title: "Update KeyboardLocker Agent…", action: #selector(updateAgent)) - - case let .accessibilityRequired(isLocked): - if isLocked { - addAction(title: "Unlock Keyboard", action: #selector(performDisplayedLockAction)) - } - addAction( - title: "Grant Accessibility Access…", - action: #selector(requestAccessibilityPermission) - ) - addAction( - title: "Open Accessibility Settings…", - action: #selector(openAccessibilitySettings) - ) - - case let .ready(isLocked): - addAction( - title: isLocked ? "Unlock Keyboard" : "Lock Keyboard", - action: #selector(performDisplayedLockAction) - ) - - case let .unavailable(_, canRestartAgent): - if canRestartAgent { - addAction(title: "Restart KeyboardLocker 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 .applyingSettings: + "KeyboardLocker — Saving Settings…" case .locking: "KeyboardLocker — Locking…" case .requestingAccessibility: @@ -239,36 +185,6 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } } - 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 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." - ) - } - - if let lastError = snapshot.lastError { - messages.append(lastError) - } - - return messages.isEmpty ? nil : messages.joined(separator: "\n\n") - } - private func handleSafetyCheckExperience(_ snapshot: AppCoordinator.Snapshot) { let previousState = lastHandledSafetyCheckState lastHandledSafetyCheckState = snapshot.safetyCheckState @@ -302,7 +218,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { hasOfferedSafetyCheckThisLaunch = true Task { @MainActor [weak self] in - self?.runSafetyCheck() + self?.confirmSafetyCheck() } } @@ -317,18 +233,10 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } } - @objc - private func performDisplayedLockAction() { - coordinator.performDisplayedLockAction() - } - - @objc - private func requestAccessibilityPermission() { - coordinator.requestAccessibilityPermission() - } + // MARK: - Confirmed actions (carried into the popover via PopoverActions) - @objc - private func runSafetyCheck() { + fileprivate func confirmSafetyCheck() { + popoverPresenter.close() let alert = NSAlert() alert.messageText = "Run a 10-Second Safety Check?" alert.informativeText = """ @@ -347,8 +255,46 @@ final class StatusMenuController: NSObject, NSMenuDelegate { coordinator.startSafetyCheck() } - @objc - private func copyDiagnostics() { + fileprivate func confirmUpdateAgent() { + guard case let .agentUpdateRequired(isLocked, message) = currentSnapshot.state else { + return + } + popoverPresenter.close() + + 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.activateForUserPresentation() + if confirmation.runModal() == .alertFirstButtonReturn { + coordinator.updateAgent() + } + } + + fileprivate func confirmRestartAgent() { + popoverPresenter.close() + 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.activateForUserPresentation() + if confirmation.runModal() == .alertFirstButtonReturn { + coordinator.restartAgent() + } + } + + fileprivate func copyDiagnostics() { + popoverPresenter.close() let snapshot = currentSnapshot Task { @MainActor [weak self] in guard let self else { @@ -369,13 +315,11 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } } - @objc - private func openLoginItemsSettings() { + fileprivate func openLoginItemsSettings() { SMAppService.openSystemSettingsLoginItems() } - @objc - private func openAccessibilitySettings() { + fileprivate 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. @@ -385,65 +329,51 @@ final class StatusMenuController: NSObject, NSMenuDelegate { NSWorkspace.shared.open(url) } - @objc - private func updateAgent() { - guard case let .agentUpdateRequired(isLocked, message) = currentSnapshot.state else { + fileprivate func quit() { + guard snapshotShowsLockedKeyboard else { + NSApp.terminate(nil) return } + popoverPresenter.close() + // 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 = "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.activateForUserPresentation() - 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.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 { - coordinator.restartAgent() + NSApp.terminate(nil) } } - @objc - private func showDetails() { - guard let detailMessage else { - return + private var snapshotShowsLockedKeyboard: Bool { + switch currentSnapshot.state { + case let .accessibilityRequired(isLocked), + let .ready(isLocked): + isLocked + case let .checking(lastKnownLock): + // Same evidence standard as `AppCoordinator.performDisplayedLockAction()`: a + // last-known-locked reading means the keyboard may still be locked, so quitting must warn. + lastKnownLock ?? false + case let .agentUpdateRequired(isLocked, _): + isLocked ?? false + case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + false } - - let alert = NSAlert() - alert.messageText = "KeyboardLocker" - alert.informativeText = detailMessage - alert.addButton(withTitle: "OK") - NSApp.activateForUserPresentation() - alert.runModal() } - @objc - private func refresh() { - coordinator.reconcile() - } + // MARK: - Command line tool - @objc - private func manageCommandLineTool() { + fileprivate func manageCommandLineTool() { + popoverPresenter.close() NSApp.activateForUserPresentation() switch commandLineToolManager.state { @@ -571,46 +501,4 @@ final class StatusMenuController: NSObject, NSMenuDelegate { alert.addButton(withTitle: "OK") alert.runModal() } - - @objc - private func quit() { - 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 .accessibilityRequired(isLocked), - let .ready(isLocked): - isLocked - case let .checking(lastKnownLock): - // 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, _): - isLocked ?? false - case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: - false - } - } } diff --git a/KeyboardLocker/UI/AppUIStore.swift b/KeyboardLocker/UI/AppUIStore.swift new file mode 100644 index 0000000..fcd23a7 --- /dev/null +++ b/KeyboardLocker/UI/AppUIStore.swift @@ -0,0 +1,163 @@ +import Client +import Combine +import Foundation +import SwiftUI + +/// Publishes `AppCoordinator` snapshots to SwiftUI and forwards user intent back. +/// +/// Deliberately holds no domain logic and no lock or settings state of its own: everything it +/// exposes is derived from the coordinator's latest published snapshot, so the views cannot drift +/// from the Agent's authoritative state. +/// +/// `ObservableObject` rather than `@Observable` because the app deploys to macOS 13. +@MainActor +final class AppUIStore: ObservableObject { + @Published private(set) var snapshot: AppCoordinator.Snapshot + + private let coordinator: AppCoordinator + + init(coordinator: AppCoordinator) { + self.coordinator = coordinator + snapshot = coordinator.snapshot + } + + /// Accepts a coordinator snapshot from whoever owns the coordinator's single observer slot. + /// + /// The store deliberately does not install itself as that observer: the status menu owns it, and + /// two presentations competing for one slot would silently leave one of them stale. + func receive(_ snapshot: AppCoordinator.Snapshot) { + self.snapshot = snapshot + } + + // MARK: - Derived presentation state + + var isBusy: Bool { + snapshot.activity != nil + } + + /// The settings the user is editing: the Agent's persisted configuration, never a local default. + var editableSettings: KeyboardLockerSettings? { + snapshot.settingsState.settings + } + + var settingsUnavailableMessage: String? { + guard case let .unavailable(message) = snapshot.settingsState else { + return nil + } + return message + } + + /// What a running lock is enforcing right now, which can differ from `editableSettings` after a + /// write landed during that lock. + var activeSettings: KeyboardLockerSettings? { + snapshot.lockSnapshot?.settings + } + + var isLocked: Bool { + snapshot.state.knownLockState ?? false + } + + var autoUnlockDeadline: Date? { + snapshot.lockSnapshot?.autoUnlockTargetDate + } + + var lockStartDate: Date? { + snapshot.lockSnapshot?.startedAt + } + + var canEditSettings: Bool { + editableSettings != nil && !isBusy + } + + /// Whether the primary lock/unlock control should be offered at all. + var canPerformLockAction: Bool { + guard !isBusy else { + return false + } + switch snapshot.state { + case .ready: + return true + case .accessibilityRequired(isLocked: true), + .checking(lastKnownLock: true): + // Unlock must stay reachable even when the agent is otherwise degraded. + return true + case let .agentUpdateRequired(isLocked, _): + return isLocked == true + case .accessibilityRequired, .agentApprovalRequired, .agentReplacementInProgress, .checking, + .unavailable: + return false + } + } + + /// The one recovery action the current degraded state calls for, or `nil` when the state is a + /// normal ready/checking/in-progress one that needs no extra button. + /// + /// This is pure presentation routing over `AppCoordinator.State`; the actual work (each of which + /// needs an AppKit confirmation or a System Settings jump) is carried by `PopoverActions`. + enum RecoveryAction: Equatable { + case openLoginItems + case grantAccessibility + case openAccessibilitySettings + case updateAgent + case restartAgent + } + + var recoveryActions: [RecoveryAction] { + guard !isBusy else { + return [] + } + switch snapshot.state { + case .agentApprovalRequired: + return [.openLoginItems] + case .accessibilityRequired: + return [.grantAccessibility, .openAccessibilitySettings] + case .agentUpdateRequired: + return [.updateAgent] + case let .unavailable(_, canRestartAgent): + return canRestartAgent ? [.restartAgent] : [] + case .agentReplacementInProgress, .checking, .ready: + return [] + } + } + + /// Whether the first-run safety check button belongs in the current state. + var canRunSafetyCheck: Bool { + guard !isBusy, snapshot.safetyCheckState != .running else { + return false + } + if case .ready(isLocked: false) = snapshot.state { + return true + } + return false + } + + // MARK: - Actions + + func reconcile() { + coordinator.reconcile() + } + + func performDisplayedLockAction() { + coordinator.performDisplayedLockAction() + } + + func applySettings(_ settings: KeyboardLockerSettings) { + coordinator.applySettings(settings) + } + + func requestAccessibilityPermission() { + coordinator.requestAccessibilityPermission() + } + + func startSafetyCheck() { + coordinator.startSafetyCheck() + } + + func updateAgent() { + coordinator.updateAgent() + } + + func restartAgent() { + coordinator.restartAgent() + } +} diff --git a/KeyboardLocker/UI/HotkeyCapture.swift b/KeyboardLocker/UI/HotkeyCapture.swift new file mode 100644 index 0000000..434e4cf --- /dev/null +++ b/KeyboardLocker/UI/HotkeyCapture.swift @@ -0,0 +1,76 @@ +import AppKit +import Client + +/// Pure translation from a captured key event to a candidate unlock hotkey. +/// +/// Separated from the recorder view so the accept/reject matrix is testable without synthesizing +/// `NSEvent`s. Validation defers to `KeyboardLockerSettings.Hotkey.validated()`, so the picker can +/// never accept a combination the Agent would refuse. +enum HotkeyCapture { + enum Outcome: Equatable { + case accepted(KeyboardLockerSettings.Hotkey) + case rejected(KeyboardLockerSettingsValidationError) + + var hotkey: KeyboardLockerSettings.Hotkey? { + guard case let .accepted(hotkey) = self else { + return nil + } + return hotkey + } + } + + /// Maps AppKit's modifier flags onto the Quartz flags the event tap matches against. + /// + /// Only the four the matcher normalizes to are carried over; CapsLock, Fn, and the numeric-pad + /// flag are dropped here rather than being stored and silently ignored at match time. + static func eventFlags(from modifierFlags: NSEvent.ModifierFlags) -> CGEventFlags { + var flags: CGEventFlags = [] + if modifierFlags.contains(.command) { + flags.insert(.maskCommand) + } + if modifierFlags.contains(.control) { + flags.insert(.maskControl) + } + if modifierFlags.contains(.option) { + flags.insert(.maskAlternate) + } + if modifierFlags.contains(.shift) { + flags.insert(.maskShift) + } + return flags + } + + static func outcome( + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags + ) -> Outcome { + let candidate = KeyboardLockerSettings.Hotkey( + keyCode: CGKeyCode(keyCode), + modifierFlags: eventFlags(from: modifierFlags) + ) + + do { + return try .accepted(candidate.validated()) + } catch let error as KeyboardLockerSettingsValidationError { + return .rejected(error) + } catch { + // `Hotkey.validated()` only throws validation errors; treat anything else as unmappable + // rather than presenting an unrecognized failure in a hotkey picker. + return .rejected(.hotkeyUnmappable) + } + } + + /// Whether a key event should end recording instead of becoming a hotkey. + /// + /// Escape cancels and Return commits by convention; neither can be recorded, so treating them + /// as candidates would trap the user in a recorder they cannot leave with the keyboard. + static func isRecordingTerminator(keyCode: UInt16) -> Bool { + keyCode == kVK_Escape || keyCode == kVK_Return || keyCode == kVK_ANSI_KeypadEnter + } +} + +// `kVK_*` constants live in Carbon.HIToolbox; these three are named locally to avoid importing +// Carbon into the UI layer for nothing more than three integers. +private let kVK_Escape: UInt16 = 53 +private let kVK_Return: UInt16 = 36 +private let kVK_ANSI_KeypadEnter: UInt16 = 76 diff --git a/KeyboardLocker/UI/HotkeyRecorderField.swift b/KeyboardLocker/UI/HotkeyRecorderField.swift new file mode 100644 index 0000000..bc01129 --- /dev/null +++ b/KeyboardLocker/UI/HotkeyRecorderField.swift @@ -0,0 +1,183 @@ +import AppKit +import Client +import SwiftUI + +/// A focusable field that records the next modifier+key combination. +/// +/// Uses an `NSView` because SwiftUI has no first-class key-capture primitive on macOS 13: the field +/// must see raw `keyDown` before the app turns it into a menu shortcut or an insertion. +struct HotkeyRecorderField: NSViewRepresentable { + let hotkey: KeyboardLockerSettings.Hotkey? + let isEnabled: Bool + let onCapture: (HotkeyCapture.Outcome) -> Void + + func makeNSView(context _: Context) -> HotkeyRecorderView { + let view = HotkeyRecorderView() + view.onCapture = onCapture + return view + } + + func updateNSView(_ view: HotkeyRecorderView, context _: Context) { + view.onCapture = onCapture + view.isEnabled = isEnabled + view.displayedHotkey = hotkey + } +} + +/// Records one hotkey at a time and reports the outcome without deciding whether it is valid. +final class HotkeyRecorderView: NSView { + var onCapture: ((HotkeyCapture.Outcome) -> Void)? + var displayedHotkey: KeyboardLockerSettings.Hotkey? { + didSet { + guard displayedHotkey != oldValue else { + return + } + refreshTitle() + } + } + + var isEnabled = true { + didSet { + guard isEnabled != oldValue else { + return + } + if !isEnabled, isRecording { + endRecording() + } + refreshTitle() + } + } + + private let label = NSTextField(labelWithString: "") + private var isRecording = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 6 + layer?.borderWidth = 1 + + label.alignment = .center + label.font = .systemFont(ofSize: NSFont.systemFontSize) + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 8), + label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + heightAnchor.constraint(equalToConstant: 24), + widthAnchor.constraint(greaterThanOrEqualToConstant: 120), + ]) + refreshAppearance() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override var acceptsFirstResponder: Bool { + isEnabled + } + + override func mouseDown(with _: NSEvent) { + guard isEnabled else { + return + } + if isRecording { + endRecording() + } else { + window?.makeFirstResponder(self) + beginRecording() + } + } + + override func becomeFirstResponder() -> Bool { + let accepted = super.becomeFirstResponder() + if accepted { + refreshAppearance() + } + return accepted + } + + override func resignFirstResponder() -> Bool { + if isRecording { + endRecording() + } + refreshAppearance() + return super.resignFirstResponder() + } + + override func keyDown(with event: NSEvent) { + guard isRecording else { + super.keyDown(with: event) + return + } + + // Escape and Return leave the recorder rather than being recorded, so the keyboard alone is + // always enough to get out of it. + if HotkeyCapture.isRecordingTerminator(keyCode: event.keyCode) { + endRecording() + return + } + + onCapture?( + HotkeyCapture.outcome( + keyCode: event.keyCode, + modifierFlags: event.modifierFlags + ) + ) + endRecording() + } + + /// Swallows system shortcuts while recording so combinations like ⌘Q reach `keyDown` instead of + /// activating a menu item. + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard isRecording else { + return super.performKeyEquivalent(with: event) + } + keyDown(with: event) + return true + } + + override func drawFocusRingMask() { + bounds.insetBy(dx: 1, dy: 1).fill() + } + + override var focusRingMaskBounds: NSRect { + bounds + } + + private func beginRecording() { + isRecording = true + refreshAppearance() + } + + private func endRecording() { + isRecording = false + refreshAppearance() + } + + private func refreshAppearance() { + refreshTitle() + layer?.borderColor = (isRecording ? NSColor.controlAccentColor : NSColor.separatorColor).cgColor + layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + needsDisplay = true + } + + private func refreshTitle() { + if isRecording { + label.stringValue = "Press a shortcut…" + label.textColor = .secondaryLabelColor + return + } + + label.textColor = isEnabled ? .labelColor : .disabledControlTextColor + guard let displayedHotkey else { + label.stringValue = "Not set" + return + } + label.stringValue = displayedHotkey.displayString + } +} diff --git a/KeyboardLocker/UI/PopoverActions.swift b/KeyboardLocker/UI/PopoverActions.swift new file mode 100644 index 0000000..68afc1a --- /dev/null +++ b/KeyboardLocker/UI/PopoverActions.swift @@ -0,0 +1,39 @@ +import Foundation + +/// AppKit-backed actions the popover triggers but does not implement. +/// +/// These either need a modal `NSAlert` confirmation (agent update/restart, quit while locked, CLI +/// management) or jump to System Settings — both of which belong in the AppKit layer, not in the +/// SwiftUI view. The view stays declarative: it decides *when* to offer an action; the controller +/// decides *how* it is presented. Keeping them here also keeps `MainView` free of `NSAlert` so its +/// state-to-action mapping remains the only thing it expresses. +@MainActor +struct PopoverActions { + /// Agent update/restart route through the coordinator, but only after a warning alert the + /// controller presents — a running lock may be released. + var confirmUpdateAgent: () -> Void + var confirmRestartAgent: () -> Void + /// The first-run safety check is offered inline, but still gated by a confirmation alert. + var confirmSafetyCheck: () -> Void + var copyDiagnostics: () -> Void + var manageCommandLineTool: () -> Void + var openLoginItemsSettings: () -> Void + var openAccessibilitySettings: () -> Void + /// Quit is destructive while locked (removes the only menu-bar indicator), so the controller + /// confirms before terminating. + var quit: () -> Void + + #if DEBUG + /// A no-op set for previews. + static let preview = PopoverActions( + confirmUpdateAgent: {}, + confirmRestartAgent: {}, + confirmSafetyCheck: {}, + copyDiagnostics: {}, + manageCommandLineTool: {}, + openLoginItemsSettings: {}, + openAccessibilitySettings: {}, + quit: {} + ) + #endif +} diff --git a/KeyboardLocker/UI/PopoverPresenter.swift b/KeyboardLocker/UI/PopoverPresenter.swift new file mode 100644 index 0000000..a86d74a --- /dev/null +++ b/KeyboardLocker/UI/PopoverPresenter.swift @@ -0,0 +1,45 @@ +import AppKit +import SwiftUI + +/// Owns the menu-bar popover and its lifetime. +/// +/// The app stays an AppKit accessory process: converting the entry point to a SwiftUI `App` to gain +/// a `MenuBarExtra` would put the activation policy, services provider, and URL handling — all +/// established paths — at risk for no user-visible benefit. An `NSPopover` hosting a SwiftUI view +/// gives the same UI without touching startup. +@MainActor +final class PopoverPresenter { + private let popover = NSPopover() + + init(rootView: PopoverRootView) { + popover.behavior = .transient + popover.animates = true + popover.contentViewController = NSHostingController(rootView: rootView) + } + + var isShown: Bool { + popover.isShown + } + + /// Shows the popover anchored to the status item button, or closes it if already open. + func toggle(relativeTo button: NSStatusBarButton) { + if popover.isShown { + popover.performClose(nil) + return + } + show(relativeTo: button) + } + + func show(relativeTo button: NSStatusBarButton) { + // An accessory app is not active by default, so the popover could not take keyboard focus for + // the hotkey recorder without this. + NSApp.activateForUserPresentation() + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + + /// Closes the popover before a modal alert is presented, so the alert is never covered by it. + func close() { + popover.performClose(nil) + } +} diff --git a/KeyboardLocker/UI/PopoverRootView.swift b/KeyboardLocker/UI/PopoverRootView.swift new file mode 100644 index 0000000..7b691e8 --- /dev/null +++ b/KeyboardLocker/UI/PopoverRootView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// Root of the menu-bar popover: a single surface that swaps between the status page and the +/// settings page, so the user never leaves the popover to change configuration. +/// +/// Navigation is a lightweight `@State` enum rather than `NavigationStack`: the popover is a fixed, +/// small surface with no need for a navigation bar or a growing back stack, and a plain crossfade +/// reads better than a push transition inside a bubble. +struct PopoverRootView: View { + @ObservedObject var store: AppUIStore + let actions: PopoverActions + + @State private var page: Page = .status + + enum Page { + case status + case settings + } + + var body: some View { + Group { + switch page { + case .status: + StatusPage( + store: store, + actions: actions, + openSettings: { navigate(to: .settings) } + ) + .transition(.opacity) + + case .settings: + SettingsPage( + store: store, + goBack: { navigate(to: .status) } + ) + .transition(.opacity) + } + } + .frame(width: 320) + .animation(.easeInOut(duration: 0.15), value: page) + .onAppear { + // The popover is created lazily and shown on demand; recalibrate each time it appears in + // case a broadcast was missed while it was closed. + store.reconcile() + } + } + + private func navigate(to page: Page) { + self.page = page + } +} diff --git a/KeyboardLocker/UI/SettingsPage.swift b/KeyboardLocker/UI/SettingsPage.swift new file mode 100644 index 0000000..2ad52f6 --- /dev/null +++ b/KeyboardLocker/UI/SettingsPage.swift @@ -0,0 +1,314 @@ +import Client +import SwiftUI + +/// The popover's settings page: edits the Agent's persisted configuration, with a header that +/// returns to the status page. +/// +/// The view never stores settings of its own beyond the in-progress edit: `draft` is seeded from +/// the Agent's published values and reset whenever they change, so a failed or superseded write +/// cannot leave the form showing a configuration that is not stored. +struct SettingsPage: View { + @ObservedObject var store: AppUIStore + let goBack: () -> Void + + @State private var draft: KeyboardLockerSettings? + @State private var hotkeyRejection: KeyboardLockerSettingsValidationError? + @State private var isConfirmingDisabledAutoUnlock = false + + private static let timeoutChoices: [TimeInterval] = [15, 30, 60, 120, 300, 600, 1800, 3600] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + body(for: store.settingsUnavailableMessage) + .padding(16) + } + .onAppear { + // Re-read on appear in case a change was made from another surface while this was closed. + store.reconcile() + syncDraft() + } + .onChange(of: store.editableSettings) { _ in + syncDraft() + } + .confirmationDialog( + "Turn off auto-unlock?", + isPresented: $isConfirmingDisabledAutoUnlock + ) { + Button("Turn Off Auto-Unlock", role: .destructive) { + commit(policy: .disabled) + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + """ + The keyboard will stay locked until you unlock it with the hotkey, the notification's \ + Unlock Now button, the menu bar, a widget, or `klock unlock`. Without a timeout there is \ + no automatic recovery if the hotkey does not reach KeyboardLocker. + """ + ) + } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 6) { + Button(action: goBack) { + Label("Back", systemImage: "chevron.backward") + .labelStyle(.titleAndIcon) + } + .buttonStyle(.borderless) + + Spacer(minLength: 0) + + Text("Settings") + .font(.headline) + + Spacer(minLength: 0) + + // Balances the leading back button so the title stays centered. + Label("Back", systemImage: "chevron.backward") + .labelStyle(.titleAndIcon) + .hidden() + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + // MARK: - Body + + @ViewBuilder + private func body(for unavailableMessage: String?) -> some View { + if let unavailableMessage { + UnavailableRow(message: unavailableMessage) { + store.reconcile() + } + } else if let draft { + VStack(alignment: .leading, spacing: 18) { + hotkeySection(draft: draft) + autoUnlockSection(draft: draft) + statusFootnotes + } + } else { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Reading settings from the background agent…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Sections + + private func hotkeySection(draft: KeyboardLockerSettings) -> some View { + VStack(alignment: .leading, spacing: 6) { + SectionHeader("Unlock Hotkey") + + HotkeyRecorderField( + hotkey: draft.unlockHotkey, + isEnabled: store.canEditSettings + ) { outcome in + switch outcome { + case let .accepted(hotkey): + hotkeyRejection = nil + commit(hotkey: hotkey) + case let .rejected(error): + hotkeyRejection = error + } + } + .frame(maxWidth: .infinity) + + if let hotkeyRejection { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(hotkeyRejection.localizedDescription) + if let suggestion = hotkeyRejection.recoverySuggestion { + Text(suggestion).foregroundStyle(.secondary) + } + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + } + .font(.caption) + } else { + Text("Click the field, then press the shortcut you want to use to unlock the keyboard.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func autoUnlockSection(draft: KeyboardLockerSettings) -> some View { + VStack(alignment: .leading, spacing: 6) { + SectionHeader("Auto-Unlock") + + Picker("Unlock automatically", selection: autoUnlockSelection(draft: draft)) { + ForEach(Self.timeoutChoices, id: \.self) { seconds in + Text(Self.timeoutLabel(seconds)).tag(TimeInterval?.some(seconds)) + } + Divider() + Text("Never (not recommended)").tag(TimeInterval?.none) + } + .labelsHidden() + .disabled(!store.canEditSettings) + + Text(Self.autoUnlockFooter(for: draft.autoUnlockPolicy)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private var statusFootnotes: some View { + if store.snapshot.hasSettingsPendingNextLock { + footnote( + "Saved. The keyboard is locked right now, so these values take effect on the next lock.", + systemImage: "clock.badge.checkmark" + ) + } else if store.isLocked { + footnote( + "The keyboard is locked. Changes take effect on the next lock.", + systemImage: "clock" + ) + } + + if let error = store.snapshot.lastError { + footnote(error, systemImage: "exclamationmark.triangle.fill", tint: .orange) + } + } + + private func footnote( + _ text: String, + systemImage: String, + tint: Color = .secondary + ) -> some View { + Label { + Text(text).fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: systemImage).foregroundStyle(tint) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: - Editing + + private func autoUnlockSelection( + draft: KeyboardLockerSettings + ) -> Binding { + Binding( + get: { draft.autoUnlockPolicy.timeout }, + set: { newValue in + guard let seconds = newValue else { + // Reachable but gated: removing the fail-safe deserves an explicit decision. + isConfirmingDisabledAutoUnlock = true + return + } + commit(policy: .timed(seconds: seconds)) + } + ) + } + + private func commit(hotkey: KeyboardLockerSettings.Hotkey) { + guard var updated = draft else { + return + } + updated.unlockHotkey = hotkey + commit(updated) + } + + private func commit(policy: KeyboardLockerSettings.AutoUnlockPolicy) { + guard var updated = draft else { + return + } + updated.autoUnlockPolicy = policy + commit(updated) + } + + private func commit(_ settings: KeyboardLockerSettings) { + // Show the intent immediately, but the Agent's reply is what ultimately lands in `draft` via + // `store.editableSettings`. + draft = settings + store.applySettings(settings) + } + + private func syncDraft() { + draft = store.editableSettings + hotkeyRejection = nil + } + + // MARK: - Copy + + private static func timeoutLabel(_ seconds: TimeInterval) -> String { + let minutes = Int(seconds) / 60 + let remainder = Int(seconds) % 60 + if minutes == 0 { + return "After \(remainder) seconds" + } + if remainder == 0 { + return minutes == 1 ? "After 1 minute" : "After \(minutes) minutes" + } + return "After \(minutes)m \(remainder)s" + } + + private static func autoUnlockFooter( + for policy: KeyboardLockerSettings.AutoUnlockPolicy + ) -> String { + switch policy { + case .disabled: + """ + Auto-unlock is off. Unlock with the hotkey, the notification's Unlock Now button, the menu \ + bar, a widget, or `klock unlock`. + """ + case .timed: + """ + The background agent owns this timer, so it still releases the keyboard if KeyboardLocker \ + quits. The countdown pauses while the Mac is asleep. + """ + } + } +} + +// MARK: - Shared small views + +private struct SectionHeader: View { + let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } +} + +/// Shared presentation for an agent value the app could not read. +private struct UnavailableRow: View { + let message: String + let retry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Settings are unavailable") + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + Button("Try Again", action: retry) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/KeyboardLocker/UI/StatusPage.swift b/KeyboardLocker/UI/StatusPage.swift new file mode 100644 index 0000000..7a576b1 --- /dev/null +++ b/KeyboardLocker/UI/StatusPage.swift @@ -0,0 +1,370 @@ +import Client +import SwiftUI + +/// The popover's status page: what the lock is doing now, the primary action, and any recovery +/// the current agent state calls for. +/// +/// Every value comes from the coordinator's authoritative snapshot via `store`; the view only maps +/// state to presentation. Actions that need a modal confirmation or a System Settings jump are +/// carried by `PopoverActions`, keeping this view free of `NSAlert`. +struct StatusPage: View { + @ObservedObject var store: AppUIStore + let actions: PopoverActions + let openSettings: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + .padding(.horizontal, 16) + .padding(.vertical, 14) + Divider() + toolbar + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .center, spacing: 12) { + ZStack { + Circle() + .fill(statusTint.opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: statusSymbol) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(statusTint) + .symbolRenderingMode(.hierarchical) + } + + VStack(alignment: .leading, spacing: 2) { + Text(statusTitle) + .font(.headline) + Text(statusSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(16) + } + + // MARK: - Content + + private var content: some View { + VStack(alignment: .leading, spacing: 14) { + if store.isLocked { + lockDetail + } + + if let hotkey = displayedHotkey { + InfoRow(label: "Unlock hotkey", systemImage: "keyboard") { + Text(hotkey.displayString) + .font(.callout.monospaced()) + .foregroundStyle(.primary) + } + } + + if store.snapshot.hasSettingsPendingNextLock { + FootnoteLabel( + "Saved settings take effect on the next lock.", + systemImage: "clock.badge.checkmark" + ) + } + + if let error = store.snapshot.lastError { + FootnoteLabel(error, systemImage: "exclamationmark.triangle.fill", tint: .orange) + } + + actionButtons + } + } + + private var lockDetail: some View { + VStack(alignment: .leading, spacing: 10) { + if let deadline = store.autoUnlockDeadline { + InfoRow(label: "Unlocks in", systemImage: "timer") { + // The authoritative deadline is transported; the countdown is derived locally so no + // stale counter crosses XPC. + Text(timerInterval: Date()...deadline, countsDown: true) + .font(.callout.monospacedDigit()) + } + Text("The countdown pauses while the Mac is asleep, so the keyboard may stay locked longer than shown.") + .font(.caption) + .foregroundStyle(.secondary) + } else if store.activeSettings?.autoUnlockPolicy == .disabled { + InfoRow(label: "Unlocks automatically", systemImage: "timer") { + Text("Never").foregroundStyle(.secondary) + } + } + + if let started = store.lockStartDate { + InfoRow(label: "Locked since", systemImage: "clock") { + Text(started, style: .time).font(.callout.monospacedDigit()) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .quaternaryLabelColor).opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) + } + + private var actionButtons: some View { + VStack(spacing: 8) { + if store.canPerformLockAction { + Button { + store.performDisplayedLockAction() + } label: { + Label( + store.isLocked ? "Unlock Keyboard" : "Lock Keyboard", + systemImage: store.isLocked ? "lock.open.fill" : "lock.fill" + ) + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + + ForEach(store.recoveryActions, id: \.self) { action in + recoveryButton(action) + } + + if store.canRunSafetyCheck { + Button { + actions.confirmSafetyCheck() + } label: { + Label("Run 10-Second Safety Check", systemImage: "checkmark.shield") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .buttonStyle(.bordered) + } + } + } + + @ViewBuilder + private func recoveryButton(_ action: AppUIStore.RecoveryAction) -> some View { + switch action { + case .openLoginItems: + wideButton("Open Login Items Settings…", "arrow.up.forward.app", prominent: true) { + actions.openLoginItemsSettings() + } + case .grantAccessibility: + wideButton("Grant Accessibility Access…", "hand.raised", prominent: true) { + store.requestAccessibilityPermission() + } + case .openAccessibilitySettings: + wideButton("Open Accessibility Settings…", "gearshape") { + actions.openAccessibilitySettings() + } + case .updateAgent: + wideButton("Update KeyboardLocker Agent…", "arrow.triangle.2.circlepath", prominent: true) { + actions.confirmUpdateAgent() + } + case .restartAgent: + wideButton("Restart KeyboardLocker Agent…", "arrow.clockwise", prominent: true) { + actions.confirmRestartAgent() + } + } + } + + @ViewBuilder + private func wideButton( + _ title: String, + _ systemImage: String, + prominent: Bool = false, + action: @escaping () -> Void + ) -> some View { + let label = Button(action: action) { + Label(title, systemImage: systemImage).frame(maxWidth: .infinity) + } + .controlSize(.large) + .disabled(store.isBusy) + + if prominent { + label.buttonStyle(.borderedProminent) + } else { + label.buttonStyle(.bordered) + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 4) { + Button(action: openSettings) { + Label("Settings", systemImage: "gearshape") + } + .disabled(store.isBusy) + + Spacer(minLength: 0) + + Menu { + Button("Copy Diagnostics", action: actions.copyDiagnostics) + Button("Command Line Tool…", action: actions.manageCommandLineTool) + Divider() + Button("Quit KeyboardLocker", action: actions.quit) + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + .labelStyle(.titleAndIcon) + .buttonStyle(.borderless) + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + // MARK: - Derived presentation + + private var displayedHotkey: KeyboardLockerSettings.Hotkey? { + store.activeSettings?.unlockHotkey ?? store.editableSettings?.unlockHotkey + } + + private var statusTint: Color { + if store.snapshot.activity != nil { + return .secondary + } + switch store.snapshot.state { + case .ready(isLocked: true), .accessibilityRequired(isLocked: true): + return .accentColor + case .ready(isLocked: false): + return .green + case .agentApprovalRequired, .unavailable, .accessibilityRequired, .agentUpdateRequired: + return .orange + case .agentReplacementInProgress, .checking: + return .secondary + } + } + + private var statusSymbol: String { + if store.snapshot.activity != nil { + return "arrow.triangle.2.circlepath" + } + switch store.snapshot.state { + case .ready(isLocked: true): + return "lock.fill" + case .ready(isLocked: false): + return "lock.open.fill" + case .accessibilityRequired(isLocked: true), .agentUpdateRequired(isLocked: true, _): + return "lock.fill" + case .agentApprovalRequired, .unavailable, .accessibilityRequired, .agentUpdateRequired: + return "exclamationmark.triangle.fill" + case .agentReplacementInProgress, .checking: + return "arrow.triangle.2.circlepath" + } + } + + private var statusTitle: String { + if store.snapshot.activity != nil { + return "Working…" + } + switch store.snapshot.state { + case .ready(isLocked: true), + .accessibilityRequired(isLocked: true), + .agentUpdateRequired(isLocked: true, _): + return "Keyboard Locked" + case .ready(isLocked: false): + return "Keyboard Unlocked" + case .checking: + return "Checking…" + case .agentApprovalRequired: + return "Approval Required" + case .agentReplacementInProgress: + return "Updating Agent…" + case .agentUpdateRequired: + return "Update Required" + case .accessibilityRequired: + return "Accessibility Required" + case .unavailable: + return "Agent Unavailable" + } + } + + private var statusSubtitle: String { + if let activity = store.snapshot.activity { + return Self.activityLabel(activity) + } + switch store.snapshot.state { + case .checking: + return "Checking the background agent…" + case .agentApprovalRequired: + return "Enable KeyboardLocker in System Settings → General → Login Items." + case let .agentReplacementInProgress(message), + let .agentUpdateRequired(_, message), + let .unavailable(message, _): + return message + case .accessibilityRequired: + return "The background agent needs Accessibility access before it can filter keyboard events." + case .ready: + return "Mouse and trackpad stay available while locked." + } + } + + private static func activityLabel(_ activity: AppCoordinator.Activity) -> String { + switch activity { + case .applyingSettings: + "Saving settings…" + case .locking: + "Locking the keyboard…" + case .requestingAccessibility: + "Requesting Accessibility access…" + case .restartingAgent: + "Restarting the background agent…" + case .startingSafetyCheck: + "Starting the safety check…" + case .unlocking: + "Unlocking the keyboard…" + case .updatingAgent: + "Updating the background agent…" + } + } +} + +// MARK: - Shared small views + +/// A leading label + trailing value row, used for lock detail and hotkey. +private struct InfoRow: View { + let label: String + let systemImage: String + @ViewBuilder let value: () -> Value + + var body: some View { + HStack(spacing: 8) { + Label(label, systemImage: systemImage) + .font(.callout) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + Spacer(minLength: 12) + value() + } + } +} + +private struct FootnoteLabel: View { + let text: String + let systemImage: String + var tint: Color = .secondary + + init(_ text: String, systemImage: String, tint: Color = .secondary) { + self.text = text + self.systemImage = systemImage + self.tint = tint + } + + var body: some View { + Label { + Text(text).fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: systemImage).foregroundStyle(tint) + } + .font(.caption) + .foregroundStyle(.secondary) + } +} diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift index d08c0f5..57f5cc0 100644 --- a/KeyboardLockerModelTests/AppCoordinatorTests.swift +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -1,4 +1,5 @@ import Client +import CoreGraphics import Foundation import Testing @@ -38,15 +39,26 @@ struct AppCoordinatorTests { coordinator.onSnapshotChange = { snapshots.append($0) } coordinator.reconcile() + // Readiness and the Agent detail it unlocks arrive in separate turns; wait for both so the + // published snapshot is the settled one. try await waitUntil { coordinator.state == .ready(isLocked: false) + && coordinator.settingsState == .loaded(.default) } #expect(coordinator.snapshot == AppCoordinator.Snapshot( state: .ready(isLocked: false), activity: nil, lastError: nil, - safetyCheckState: .idle + safetyCheckState: .idle, + lockSnapshot: LockStatusSnapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: 0), + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ), + settingsState: .loaded(.default) )) #expect(observer.initialStates == [false]) #expect(snapshots.last == coordinator.snapshot) @@ -156,6 +168,155 @@ struct AppCoordinatorTests { #expect(client.waitUntilUnlockedCallCount == 0) } + // MARK: - Settings + + @Test + @MainActor + func applySettingsAdoptsTheValuesTheAgentReportsStored() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver() + ) + coordinator.reconcile() + try await waitUntil { coordinator.state == .ready(isLocked: false) } + + // A fractional timeout the Agent normalizes to whole seconds: adopting the request instead of + // the reply would leave the UI showing a value that was never stored. + coordinator.applySettings(makeSettings(autoUnlockSeconds: 59.6)) + try await waitUntil { + coordinator.settingsState == .loaded(makeSettings(autoUnlockSeconds: 60)) + } + + #expect(client.appliedSettings.count == 1) + #expect(coordinator.snapshot.lastError == nil) + #expect(coordinator.snapshot.activity == nil) + } + + @Test + @MainActor + func applySettingsWhileLockedReportsThemAsPendingTheNextLock() async throws { + let client = FakeAgentClient(isLocked: true, hasAccessibilityPermission: true) + // The running lock keeps enforcing the settings it started with. + client.activeSettings = .default + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver() + ) + coordinator.reconcile() + try await waitUntil { coordinator.state == .ready(isLocked: true) } + + let desired = makeSettings(autoUnlockSeconds: 30) + coordinator.applySettings(desired) + try await waitUntil { + coordinator.settingsState == .loaded(desired) + && coordinator.lockSnapshot != nil + } + + #expect(coordinator.snapshot.hasSettingsPendingNextLock) + #expect(coordinator.lockSnapshot?.settings == .default) + #expect(coordinator.snapshot.lastError == nil) + } + + @Test + @MainActor + func settingsPendingIsNotReportedWhenTheLockAlreadyEnforcesThem() async throws { + let client = FakeAgentClient(isLocked: true, hasAccessibilityPermission: true) + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver() + ) + coordinator.reconcile() + try await waitUntil { + coordinator.state == .ready(isLocked: true) + && coordinator.settingsState == .loaded(.default) + } + + #expect(!coordinator.snapshot.hasSettingsPendingNextLock) + } + + @Test + @MainActor + func failedSettingsWriteSurfacesTheErrorAndKeepsStoredValues() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver() + ) + coordinator.reconcile() + try await waitUntil { coordinator.settingsState == .loaded(.default) } + client.applySettingsError = AppCoordinatorTestError.expected + + coordinator.applySettings(makeSettings(autoUnlockSeconds: 30)) + try await waitUntil { coordinator.snapshot.lastError != nil } + + #expect(coordinator.settingsState == .loaded(.default)) + #expect(coordinator.snapshot.activity == nil) + } + + /// Presenting `.default` on a read failure would invent a second source of truth and could show + /// an unlock hotkey the Agent is not using. + @Test + @MainActor + func unreadableSettingsBecomeExplicitlyUnavailable() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + client.currentSettingsError = AppCoordinatorTestError.expected + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver() + ) + + coordinator.reconcile() + try await waitUntil { + if case .unavailable = coordinator.settingsState { + return true + } + return false + } + + #expect(coordinator.settingsState.settings == nil) + // A detail failure must not downgrade an otherwise ready agent. + #expect(coordinator.state == .ready(isLocked: false)) + } + + @Test + @MainActor + func anUnreachableAgentClearsSettingsInsteadOfKeepingStaleOnes() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let lifecycle = FakeAgentLifecycle() + let coordinator = makeCoordinator( + client: client, + lifecycle: lifecycle, + observer: FakeLockStateObserver() + ) + coordinator.reconcile() + try await waitUntil { coordinator.settingsState == .loaded(.default) } + + lifecycle.registrationState = .approvalRequired + coordinator.reconcile() + try await waitUntil { coordinator.state == .agentApprovalRequired } + + #expect(coordinator.settingsState.settings == nil) + #expect(coordinator.lockSnapshot == nil) + } + + private func makeSettings( + autoUnlockSeconds: TimeInterval + ) -> KeyboardLockerSettings { + KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: autoUnlockSeconds), + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: CGKeyCode(SharedConstants.defaultUnlockKeyCode), + modifierFlags: [.maskControl, .maskAlternate] + ) + ) + } + @MainActor private func makeCoordinator( client: FakeAgentClient, @@ -190,6 +351,7 @@ struct AppCoordinatorTests { } private enum AppCoordinatorTestError: Error { + case expected case timedOut } @@ -199,9 +361,17 @@ private final class FakeAgentClient: AgentClientServing { private(set) var lockCallCount = 0 private(set) var unlockCallCount = 0 private(set) var waitUntilUnlockedCallCount = 0 + private(set) var appliedSettings: [KeyboardLockerSettings] = [] var accessibilityPermissionGranted: Bool var isLocked: Bool var safetyCheckOutcome: LockRequestOutcome = .acquired + var storedSettings: KeyboardLockerSettings = .default + var currentSettingsError: Error? + var applySettingsError: Error? + var lockStatusSnapshotError: Error? + /// What a running lock is enforcing. Distinct from `storedSettings` so tests can model a write + /// that landed while locked and only takes effect on the next lock. + var activeSettings: KeyboardLockerSettings? private let descriptor = ServiceDescriptor( protocolVersion: ServiceContract.protocolVersion, @@ -248,6 +418,39 @@ private final class FakeAgentClient: AgentClientServing { return isLocked } + func currentSettings() async throws -> KeyboardLockerSettings { + if let currentSettingsError { + throw currentSettingsError + } + return storedSettings + } + + func applySettings( + _ settings: KeyboardLockerSettings + ) async throws -> KeyboardLockerSettings { + if let applySettingsError { + throw applySettingsError + } + appliedSettings.append(settings) + // Mirrors the Agent: values are normalized before being stored, and a running lock keeps + // enforcing whatever it started with. + storedSettings = try settings.validated() + return storedSettings + } + + func lockStatusSnapshot() async throws -> LockStatusSnapshot { + if let lockStatusSnapshotError { + throw lockStatusSnapshotError + } + return LockStatusSnapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: 0), + isLocked: isLocked, + startedAt: isLocked ? Date(timeIntervalSinceReferenceDate: 0) : nil, + autoUnlockTargetDate: nil, + settings: activeSettings ?? storedSettings + ) + } + func prepareForReplacement( unlockIfNeeded: Bool, expectedAgentInstanceID: UUID @@ -281,8 +484,10 @@ private final class FakeAgentClient: AgentClientServing { @MainActor private final class FakeAgentLifecycle: AgentLifecycleServing { + var registrationState: AgentRegistrar.State = .enabled + func ensureEnabled() -> AgentRegistrar.State { - .enabled + registrationState } func compatibility( diff --git a/KeyboardLockerModelTests/AppUIStoreTests.swift b/KeyboardLockerModelTests/AppUIStoreTests.swift new file mode 100644 index 0000000..1c1cada --- /dev/null +++ b/KeyboardLockerModelTests/AppUIStoreTests.swift @@ -0,0 +1,337 @@ +import Client +import CoreGraphics +import Foundation +import Testing + +@Suite(.serialized) +@MainActor +struct AppUIStoreTests { + // MARK: - Settings presentation + + @Test + func editableSettingsAreAbsentWhileTheyAreStillLoading() { + let store = makeStore(snapshot: makeSnapshot(settingsState: .loading)) + + #expect(store.editableSettings == nil) + #expect(store.settingsUnavailableMessage == nil) + #expect(!store.canEditSettings) + } + + /// A read failure must never be presented as a usable configuration: showing `.default` would + /// display an unlock hotkey the Agent is not actually using. + @Test + func unavailableSettingsExposeTheMessageAndNoValues() { + let store = makeStore( + snapshot: makeSnapshot(settingsState: .unavailable("agent unreachable")) + ) + + #expect(store.editableSettings == nil) + #expect(store.settingsUnavailableMessage == "agent unreachable") + #expect(!store.canEditSettings) + } + + @Test + func loadedSettingsBecomeEditableWhenIdle() { + let store = makeStore(snapshot: makeSnapshot(settingsState: .loaded(.default))) + + #expect(store.editableSettings == .default) + #expect(store.canEditSettings) + } + + @Test + func settingsAreNotEditableWhileAnActionIsInFlight() { + let store = makeStore( + snapshot: makeSnapshot( + activity: .applyingSettings, + settingsState: .loaded(.default) + ) + ) + + #expect(store.isBusy) + #expect(!store.canEditSettings) + } + + /// While locked, the stored values and the values the lock enforces are different things; the + /// store must expose both so the UI can say a change is pending. + @Test + func storedAndActiveSettingsAreReportedSeparatelyWhileLocked() { + let stored = makeSettings(autoUnlockSeconds: 30) + let store = makeStore( + snapshot: makeSnapshot( + state: .ready(isLocked: true), + lockSnapshot: makeLockSnapshot(isLocked: true, settings: .default), + settingsState: .loaded(stored) + ) + ) + + #expect(store.editableSettings == stored) + #expect(store.activeSettings == .default) + #expect(store.snapshot.hasSettingsPendingNextLock) + } + + @Test + func noPendingChangeIsReportedWhenTheLockAlreadyEnforcesStoredSettings() { + let store = makeStore( + snapshot: makeSnapshot( + state: .ready(isLocked: true), + lockSnapshot: makeLockSnapshot(isLocked: true, settings: .default), + settingsState: .loaded(.default) + ) + ) + + #expect(!store.snapshot.hasSettingsPendingNextLock) + } + + @Test + func noPendingChangeIsReportedWhileUnlocked() { + let store = makeStore( + snapshot: makeSnapshot( + state: .ready(isLocked: false), + lockSnapshot: makeLockSnapshot(isLocked: false, settings: .default), + settingsState: .loaded(makeSettings(autoUnlockSeconds: 30)) + ) + ) + + #expect(!store.snapshot.hasSettingsPendingNextLock) + } + + // MARK: - Lock detail + + @Test + func lockDetailIsDerivedFromTheAuthoritativeSnapshot() { + let started = Date(timeIntervalSinceReferenceDate: 100) + let deadline = Date(timeIntervalSinceReferenceDate: 160) + let store = makeStore( + snapshot: makeSnapshot( + state: .ready(isLocked: true), + lockSnapshot: LockStatusSnapshot( + capturedAt: started, + isLocked: true, + startedAt: started, + autoUnlockTargetDate: deadline, + settings: .default + ), + settingsState: .loaded(.default) + ) + ) + + #expect(store.isLocked) + #expect(store.lockStartDate == started) + #expect(store.autoUnlockDeadline == deadline) + } + + @Test + func lockDetailIsAbsentWhenTheAgentCouldNotProvideIt() { + let store = makeStore( + snapshot: makeSnapshot( + state: .unavailable(message: "unreachable", canRestartAgent: true), + settingsState: .unavailable("unreachable") + ) + ) + + #expect(store.autoUnlockDeadline == nil) + #expect(store.lockStartDate == nil) + #expect(store.activeSettings == nil) + } + + // MARK: - Lock action availability + + @Test + func lockActionIsOfferedWhenReady() { + let store = makeStore(snapshot: makeSnapshot(state: .ready(isLocked: false))) + + #expect(store.canPerformLockAction) + } + + /// Unlock has to stay reachable in degraded states, otherwise a locked keyboard loses its + /// in-window escape hatch. + @Test(arguments: [ + AppCoordinator.State.accessibilityRequired(isLocked: true), + .checking(lastKnownLock: true), + .agentUpdateRequired(isLocked: true, message: "update"), + ]) + func unlockRemainsOfferedInDegradedLockedStates(state: AppCoordinator.State) { + let store = makeStore(snapshot: makeSnapshot(state: state)) + + #expect(store.canPerformLockAction) + } + + @Test(arguments: [ + AppCoordinator.State.accessibilityRequired(isLocked: false), + .agentApprovalRequired, + .agentReplacementInProgress(message: "replacing"), + .checking(lastKnownLock: nil), + .unavailable(message: "unreachable", canRestartAgent: true), + ]) + func lockActionIsWithheldWhenTheAgentCannotHonorIt(state: AppCoordinator.State) { + let store = makeStore(snapshot: makeSnapshot(state: state)) + + #expect(!store.canPerformLockAction) + } + + @Test + func lockActionIsWithheldWhileAnActionIsInFlight() { + let store = makeStore( + snapshot: makeSnapshot(state: .ready(isLocked: false), activity: .locking) + ) + + #expect(!store.canPerformLockAction) + } + + // MARK: - Fixtures + + private func makeStore(snapshot: AppCoordinator.Snapshot) -> AppUIStore { + let store = AppUIStore( + coordinator: AppCoordinator( + client: UnusedAgentClient(), + lifecycle: UnusedAgentLifecycle(), + lockStateObserver: UnusedLockStateObserver(), + initialState: snapshot.state + ) + ) + store.receive(snapshot) + return store + } + + private func makeSnapshot( + state: AppCoordinator.State = .ready(isLocked: false), + activity: AppCoordinator.Activity? = nil, + lockSnapshot: LockStatusSnapshot? = nil, + settingsState: AppCoordinator.SettingsState = .loading + ) -> AppCoordinator.Snapshot { + AppCoordinator.Snapshot( + state: state, + activity: activity, + lastError: nil, + safetyCheckState: .idle, + lockSnapshot: lockSnapshot, + settingsState: settingsState + ) + } + + private func makeLockSnapshot( + isLocked: Bool, + settings: KeyboardLockerSettings + ) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: 0), + isLocked: isLocked, + startedAt: isLocked ? Date(timeIntervalSinceReferenceDate: 0) : nil, + autoUnlockTargetDate: nil, + settings: settings + ) + } + + private func makeSettings( + autoUnlockSeconds: TimeInterval + ) -> KeyboardLockerSettings { + KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: autoUnlockSeconds), + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: CGKeyCode(SharedConstants.defaultUnlockKeyCode), + modifierFlags: [.maskControl, .maskAlternate] + ) + ) + } +} + +/// The store's derived state is a pure function of the snapshot it is handed, so these doubles only +/// exist to satisfy the coordinator's initializer and must never be called. +@MainActor +private struct UnusedAgentClient: AgentClientServing { + func serviceDescriptor() async throws -> ServiceDescriptor { + unexpected() + } + + func lock() async throws { + unexpected() + } + + func unlock() async throws { + unexpected() + } + + func status() async throws -> Bool { + unexpected() + } + + func toggle() async throws -> Bool { + unexpected() + } + + func beginSafetyCheck() async throws -> LockRequestOutcome { + unexpected() + } + + func waitUntilUnlocked() async throws { + unexpected() + } + + func hasAccessibilityPermission() async throws -> Bool { + unexpected() + } + + func requestAccessibilityPermission() async throws { + unexpected() + } + + func currentSettings() async throws -> KeyboardLockerSettings { + unexpected() + } + + func applySettings( + _: KeyboardLockerSettings + ) async throws -> KeyboardLockerSettings { + unexpected() + } + + func lockStatusSnapshot() async throws -> LockStatusSnapshot { + unexpected() + } + + func prepareForReplacement( + unlockIfNeeded _: Bool, + expectedAgentInstanceID _: UUID + ) async throws -> ServiceReplacementTicket { + unexpected() + } + + func cancelReplacementPreparation(ticket _: ServiceReplacementTicket) async throws { + unexpected() + } + + func commitReplacement(ticket _: ServiceReplacementTicket) async throws { + unexpected() + } + + func resetConnection() {} + + private func unexpected() -> Never { + fatalError("AppUIStore must not reach the Agent to derive presentation state.") + } +} + +@MainActor +private struct UnusedAgentLifecycle: AgentLifecycleServing { + func ensureEnabled() -> AgentRegistrar.State { + .enabled + } + + func compatibility(of _: ServiceDescriptor) -> AgentRegistrar.Compatibility { + .compatible + } + + func restart() async -> AgentRegistrar.State { + .enabled + } +} + +@MainActor +private struct UnusedLockStateObserver: AgentLockStateObserving { + func subscribe( + initialState _: Bool?, + _: @escaping (Bool) -> Void + ) -> ObserverToken { + ObserverToken {} + } +} diff --git a/KeyboardLockerModelTests/HotkeyCaptureTests.swift b/KeyboardLockerModelTests/HotkeyCaptureTests.swift new file mode 100644 index 0000000..ccd661d --- /dev/null +++ b/KeyboardLockerModelTests/HotkeyCaptureTests.swift @@ -0,0 +1,114 @@ +import AppKit +import Client +import CoreGraphics +import Testing + +struct HotkeyCaptureTests { + private static let mappableKeyCode = UInt16(SharedConstants.defaultUnlockKeyCode) + + // MARK: - Modifier translation + + @Test + func translatesTheFourMatchableModifiers() { + let flags = HotkeyCapture.eventFlags( + from: [.command, .control, .option, .shift] + ) + + #expect(flags == [.maskCommand, .maskControl, .maskAlternate, .maskShift]) + } + + /// CapsLock, Fn, and the numeric-pad flag are dropped by the event tap's matcher, so storing them + /// would record a hotkey that never matches. + @Test + func dropsModifiersTheMatcherIgnores() { + let flags = HotkeyCapture.eventFlags( + from: [.capsLock, .function, .numericPad, .control] + ) + + #expect(flags == [.maskControl]) + } + + // MARK: - Acceptance + + @Test + func acceptsAModifiedMappableKey() { + let outcome = HotkeyCapture.outcome( + keyCode: Self.mappableKeyCode, + modifierFlags: [.control, .command] + ) + + let hotkey = outcome.hotkey + #expect(hotkey?.keyCode == CGKeyCode(Self.mappableKeyCode)) + #expect(hotkey?.modifierFlags == [.maskControl, .maskCommand]) + } + + @Test + func rejectsAnUnmodifiedKey() { + let outcome = HotkeyCapture.outcome( + keyCode: Self.mappableKeyCode, + modifierFlags: [] + ) + + #expect(outcome == .rejected(.hotkeyMissingModifier)) + } + + @Test + func rejectsAKeyModifiedOnlyByCapsLock() { + let outcome = HotkeyCapture.outcome( + keyCode: Self.mappableKeyCode, + modifierFlags: [.capsLock] + ) + + #expect(outcome == .rejected(.hotkeyMissingModifier)) + } + + @Test + func rejectsAnUnmappableKeyCode() { + let outcome = HotkeyCapture.outcome( + keyCode: 0xFF, + modifierFlags: [.command] + ) + + #expect(outcome == .rejected(.hotkeyUnmappable)) + } + + /// Every accepted capture must survive the Agent's own validation, or the picker would offer + /// combinations the write path then rejects. + @Test(arguments: [ + NSEvent.ModifierFlags.command, + .control, + .option, + .shift, + [.command, .shift], + ]) + func acceptedCapturesPassAgentValidation( + modifiers: NSEvent.ModifierFlags + ) throws { + let hotkey = try #require( + HotkeyCapture.outcome( + keyCode: Self.mappableKeyCode, + modifierFlags: modifiers + ).hotkey + ) + + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: hotkey + ) + #expect(try settings.validated().unlockHotkey == hotkey) + } + + // MARK: - Terminators + + /// Escape and Return must leave the recorder rather than be recorded, so the keyboard alone is + /// always enough to exit it. + @Test(arguments: [UInt16(53), 36, 76]) + func escapeAndReturnEndRecording(keyCode: UInt16) { + #expect(HotkeyCapture.isRecordingTerminator(keyCode: keyCode)) + } + + @Test + func anOrdinaryKeyDoesNotEndRecording() { + #expect(!HotkeyCapture.isRecordingTerminator(keyCode: Self.mappableKeyCode)) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index b012228..d91c816 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ - **分层分对了,轴选错了。** 旧方案按"关注点"垂直分层非常漂亮,却没有先按"进程边界"水平切分,于是每一层都被复制进三个进程。 → **教训**:干净的类型分层 ≠ 正确的系统架构。有多个入口进程时,**第一刀切在进程边界(谁执行 / 谁只是 wrapper),再谈类型优雅**。 - **各进程独立读 `UserDefaults` → 设置漂移。** 旧方案每个进程各持一份配置,CLI 改了热键 App 不知道。 - → **教训**:设置也只能有一个真相源(Agent)。MVP 中 wrapper 只经 XPC 读取;post-MVP 若加入写入,也必须经同一 XPC 边界(见下文"各职责的归属")。 + → **教训**:设置也只能有一个真相源(Agent)。读写都经同一 XPC 边界:读取用 `currentSettings`,写入用 `applySettings`(由 Agent 校验并持久化);wrapper 永不拥有 store。 一句话:**有进程边界时,先按进程切,再谈类型优雅;单例和回调是进程内工具,不要拿来跨进程。** 下面的规则都是这句话的展开。 @@ -45,7 +45,7 @@ Widget ──────┘ ├─ Settings ownership (source of t | 关注点 | 归属 | 规则 | |---------|------|------| | 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | -| 设置(真相源) | **Agent** | Agent 加载并拥有默认/持久化设置、负责应用它们。MVP 有意只向 wrapper 暴露读取(`XPCClient.currentSettings()`),不包含用户可编辑设置的 API 或 UI;因此没有 `applySettings` 不构成 MVP 缺口。设置编辑属于 post-MVP extension,未来加入时仍必须经 Agent XPC 写入并由 Agent 持久化,绝不在 wrapper 侧落地。读取失败必须显式呈现为 unavailable,不能把 wrapper 的 `.default` 冒充为 Agent 当前值。 | +| 设置(真相源) | **Agent** | Agent 加载、拥有并持久化默认/用户设置、负责应用它们。读取经 `XPCClient.currentSettings()`,写入经 capability-gated `applySettings`(protocol 1.8):Agent 先用 `KeyboardLockerSettings.validated()` 校验(护栏定义在 `Common`、由 Agent 强制,任何写入面共用)再落盘,并返回落盘后的权威值。**locked 时写入只落盘,不触碰当前锁**——`LockEngine.updateSettings` 会重算 auto-unlock window(`.disabled` 直接取消 timer),mid-lock 应用会破坏活动锁的 fail-safe,因此新值只 seed 下一次 lock;这不违反"只有 `LockEngine` 的显式 settings update 才能重新应用设置",而是选择在 locked 时不发起那次 update。设置编辑 UI 在 App 内(`SettingsView`),但绝不在 wrapper 侧落地 store。读取失败必须显式呈现为 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;只有 `LockEngine` 的显式 settings update 才能重新应用设置并重新计算 timeout window。它是当前的内部语义与 post-MVP 写入接入点,不是 MVP 对用户暴露的 capability。唯一的内部 metadata 变化是:若该代锁由 Focus Filter 创建,之后来自普通 wrapper 的显式 `lock` 会接管其持久性,使 Focus 关闭时不再解除这个更新的用户意图。 +- `lock` 对物理运行状态是严格幂等操作。Agent 已处于 locked 时,重复 `lock` 直接成功且不修改 event tap、当前设置、输入手势、锁定起点或 auto-unlock deadline;只有 `LockEngine` 的显式 settings update 才能重新应用设置并重新计算 timeout window,而 Agent **只在 unlocked 时**才为 `applySettings` 发起该 update(locked 时新设置仅落盘、seed 下一次 lock)。唯一的内部 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 15b8c04..8de7c66 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,34 +15,34 @@ 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 都可以让它提前结束。只有 `LockEngine` 的显式 settings update 才会重新应用设置并从该次更新重新开始 timeout window;这是内部语义与 post-MVP 写入接入点,不是 MVP 用户 capability。不存在客户端持有的通用"会话";每次调用都是一次性的。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;`applySettings` 只在 unlocked 时发起该 update,locked 时新值仅落盘等下次 lock。不存在客户端持有的通用"会话";每次调用都是一次性的。Agent 必须经 `SMAppService` 注册,`launchd` 才能按需拉起它 —— App 在启动时通过 `AgentRegistrar` 完成这件事(见下文)。 ## 组件地图 只列出不那么显而易见的职责;签名请读源码。 **Common**(`Core/Sources/Common/`)—— 所有 target 共享 -- `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。 +- `Shared.swift`:`KeyboardLockerServiceProtocol`(bootstrap descriptor、legacy/interactive/Focus/safety-check 锁操作、lock status snapshot、replacement drain、Accessibility 状态 / 请求、settings 读取与写入)、`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。protocol 1.8 新增 capability-gated `applySettings`:Agent 校验并持久化设置后返回落盘后的权威值,locked 时只落盘不改动当前锁。`KeyboardLockerSettings.validated()` 在此文件所在的 `Common` 层定义写入护栏,由 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。 -- `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。 +- `KeyboardLockerSettings.swift`:`KeyboardLockerSettings`(`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`、`unlockHotkey`)+ `.default` + throwing `encodedForXPC()`/`decodedFromXPC(_:)`(跨 `@objc` 边界、有大小上限的 JSON 传输)。缺失、损坏或过大的 Agent payload 会显式失败,wrapper 不会伪造 `.default` 快照。`validated()` 是写入护栏的单一真相源:热键必须含至少一个可被 matcher 保留的修饰键(与 `Hotkey.matches` 共用 `relevantModifierMask`)、keyCode 必须能被 ASCII-capable layout 映射(经 `KeyCodeConverter`)、`.timed` 秒数取整后须落在 `allowedAutoUnlockRange`(5...3600)且有限;`.disabled` 允许通过。`Hotkey.validated()` 单独暴露,供热键录制器逐次反馈,不复制规则。 - `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" 类型。 +- `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。`applySettings(_:)` capability-gated;发送前先 `encodedForXPC`,reply 携带 Agent 落盘后的权威 settings(而非请求值,因 Agent 会规范化),reply 丢失后重查 `currentSettings()` 与 `validated()` 后的期望值比对而非重发。没有业务 "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` 隔离、由进程生命周期 `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(契约的真相源规则)。 +- `KeyboardLockerSettingsStore.swift`:基于 `UserDefaults` 的设置持久化 —— 放在 `Service` 内,以确保没有 wrapper 能拥有自己的 store(契约的真相源规则)。`load()` 在本地损坏时回退 `.default` 并记录错误(读路径不能让 Agent 起不来);`save(_:)` 相反,编码失败必须抛出,不能静默丢弃写入让调用方误以为配置已生效。 - `LockStateBroadcaster.swift`:发出 Darwin + Distributed 通知(均无载荷,只是"状态已变"的信号;订阅方收到后回拉 `status()`)。 - `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;Agent bootstrap 只创建一个贯穿进程生命周期的实例,并把它交给所有 listener connection。这个实例私有创建并持有唯一的 `LockEngine`、`LockStatusNotifier`、设置快照与 replacement transaction,把所有可变状态与引擎操作切到 `MainActor`;它不是语言层 static singleton。测试 initializer 仍可注入普通引擎 double、descriptor 与 prepare 过期调度;replacement barrier、instance-ID fence、locked refusal、错误码映射与过期/取消语义由 `ServiceTests` 确定性覆盖。 +- `AgentService.swift`:nonisolated XPC wire adapter;Agent bootstrap 只创建一个贯穿进程生命周期的实例,并把它交给所有 listener connection。这个实例私有创建并持有唯一的 `LockEngine`、`LockStatusNotifier`、可变设置、注入的 `SettingsPersisting` 与 replacement transaction,把所有可变状态与引擎操作切到 `MainActor`;它不是语言层 static singleton。`applySettings` 按 decode → `validated()` → 拒绝 replacement drain → `save()` → 更新内存值 → **仅 unlocked 时** `engine.updateSettings` → 回复权威值 的顺序执行,任一步失败都不落盘。测试 initializer 仍可注入普通引擎 double、`SettingsPersisting` fake、descriptor 与 prepare 过期调度;replacement barrier、instance-ID fence、locked refusal、settings 写入(含 locked 时不触碰引擎的回归门)、错误码映射与过期/取消语义由 `ServiceTests` 确定性覆盖。 - `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 **SystemSurfaces**(`Core/Sources/SystemSurfaces/`)—— 仅 containing App、Widget 与 Focus extension 使用的 presentation adapter @@ -50,7 +50,8 @@ **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,不直接读取或持有锁/设置状态。首次 readiness 达到 ready + unlocked 后,menu controller 会提示一次可跳过的 10 秒安全测试;菜单也始终保留手动入口。 +- `KeyboardLockerApplication.swift` / `StatusItemController.swift`:进程级 AppKit 生命周期与 menu-bar status item。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。status item 左键点击 toggle 一个 SwiftUI popover(不设 `NSStatusItem.menu`);首次 readiness 达到 ready + unlocked 后提示一次可跳过的 10 秒安全测试。`StatusItemController` 拥有 coordinator 的唯一 observer 槽位,把每个 snapshot 同时扇出给菜单栏图标(symbol/tooltip)与 `AppUIStore`。需要模态 `NSAlert` 或跳系统设置的动作(agent update/restart、锁定时退出、关闭 auto-unlock、CLI 管理、诊断、开 Login Items / Accessibility pane)都留在这里,经 `PopoverActions` 注入 SwiftUI 层;呈现 alert 前先关闭 popover,使 alert 不被遮挡。锁定时点图标即弹出、含 Unlock,是鼠标可达的解锁出口。 +- `UI/`:SwiftUI popover 层,承载于 AppKit `NSPopover` + `NSHostingController`(App 入口保持 accessory `@main`,不改成 SwiftUI `App`/`MenuBarExtra`,以免动到 activation policy、services provider 与 URL handling 三条已测路径)。`PopoverPresenter` 持有 `.transient` 的 `NSPopover`,`toggle`/`show`/`close` 锚定到 status item button。`PopoverRootView` 用轻量 `@State` enum 在 `StatusPage` ↔ `SettingsPage` 间切换(非 `NavigationStack`)。`AppUIStore`(`ObservableObject`,部署到 macOS 13)只把 `AppCoordinator.Snapshot` 桥成可观察状态并转发动作,不含领域逻辑,由 controller 调 `receive(_:)` 推送而非自行抢占 observer;并提供纯呈现路由(`recoveryActions`、`canRunSafetyCheck`)。`StatusPage` 显示锁状态卡片、由权威 `autoUnlockTargetDate` 派生的倒计时(标注休眠会推迟实际解锁)、起点、active 解锁热键、主动作与逐状态恢复按钮,底部工具条含齿轮与 `⋯` overflow。`SettingsPage` 带返回导航,编辑持久化设置:热键录制、auto-unlock 档位、`.disabled` 二次确认;读取失败显示 unavailable 而非默认值,locked 时提示"下次锁生效"。`HotkeyCapture` 是纯翻译(捕获→候选热键,复用 `Hotkey.validated()`,丢弃 matcher 忽略的修饰键,Escape/Return 结束录制),`HotkeyRecorderField`/`HotkeyRecorderView` 是其 AppKit 捕获宿主;`PopoverActions` 是 AppKit 确认动作的注入缝。 - `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。 @@ -59,10 +60,10 @@ - `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 变化。 +- `AgentCoordinationServices.swift`:App 内部的可注入依赖边界。live adapter 把 `XPCClient`、`LockStateSubscriber` 与 `AgentRegistrar` 暴露为按用途拆分的最小 protocol;`AgentLockActionServing` 只提供 one-shot wrapper 所需的 `lock` / `unlock` / `toggle` / `status`,`AgentSettingsServing` 提供 `currentSettings` / `applySettings` / `lockStatusSnapshot`,协调器和系统 action 都无需依赖无关 Client surface。live lock/unlock/toggle 与 `applySettings` 成功后请求 system-surface reload(Widget 显示解锁热键,设置变更需刷新),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、锁或设置逻辑。 +- `AppCoordinator.swift`:不依赖 presentation framework 的 `@MainActor` 应用协调器 —— 持有异步任务/订阅生命周期、单次自动更新策略和 replacement progress polling,把 domain outcome 收敛为可观察的应用 snapshot;不直接实现 handshake、replacement transaction、锁或设置逻辑。`Snapshot` 除锁布尔外还携带权威 `LockStatusSnapshot`(起点/deadline/active settings)与 `SettingsState`(loading/loaded/unavailable)。设置读取失败呈现 unavailable 而非回退 `.default`;`hasSettingsPendingNextLock` 表示 locked 期间写入的 stored settings 与当前锁 enforcing 的 active settings 不同,须等下次 lock 生效。Agent 不再可信(update required、approval、unavailable、replacement)时清空这些 detail,detail 查询失败不降级 ready 状态。 **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。 @@ -79,10 +80,12 @@ ## 常见任务 -### Post-MVP:新增用户可编辑设置 +### 新增一个用户可编辑设置 +用户可编辑设置已接入:`applySettings`(protocol 1.8)经 Agent XPC 写入并持久化,`SettingsPage` 提供 UI。新增一个字段时: 1. 给 `KeyboardLockerSettings` 加一个 `Codable`/`Sendable` 属性,并更新 `.default`。 2. 如果引擎会消费它,在 `LockEngine.lock(settings:allowsControlCUnlock:)` / `updateSettings(_:)` 中读取。 -3. 当前 MVP 有意只支持 wrapper 通过 `XPCClient.currentSettings()` **读取** Agent-owned 默认/持久化设置,不包含写入 API 或 UI。扩展到用户可编辑设置时,先在 Agent + `KeyboardLockerServiceProtocol` 上新增写入方法(见"新增一个 XPC 方法"),再由 Agent 持久化;wrapper **不得**拥有 store(见架构契约)。 +3. 如果它有锁死或无效风险,在 `KeyboardLockerSettings.validated()` 补一条护栏并加 `CommonTests`——Agent 的 `applySettings` 已强制该方法,无需在 UI 侧另写校验。 +4. 在 `SettingsPage` 暴露编辑控件,写入仍只经 `AppCoordinator.applySettings(_:)` → `XPCClient.applySettings(_:)`;wrapper **不得**拥有 store(见架构契约)。locked 时是否立即生效沿用现有语义:Agent 只在 unlocked 时把新值应用到引擎。 ### 新增一个 XPC 方法 1. 先判断它是否 optional/additive。不得在同一 protocol major 内修改或移除既有 selector、参数顺序或 reply 形状;破坏性变化需要新 major,无法保留 selector union 时需要新 Mach service。 @@ -144,6 +147,7 @@ Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget - 系统可能禁用 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 和用户按键解锁仍属于真机手动验证边界。 +- 设置写入路径:`CommonTests` 覆盖 `validated()` 护栏逐条(无修饰键 / 仅 CapsLock / 不可映射 keyCode / 边界 5 与 3600 / 越界 / 非有限 / `.disabled` / 分数取整);`ServiceTests` 覆盖 `applySettings` 落盘并可 `load()` 读回、**locked 时不调用 `engine.updateSettings` 的回归门**、unlocked 时调用、非法/不可解码 payload 不落盘且报错、持久化失败保留旧值、replacement drain 期间拒绝写入;`ClientTests` 覆盖 1.7 descriptor 触发 `missingCapability`、minor floor 拒绝、以及 capability→minor 表的完整性(与实现同源,不再维护第二份副本)。`KeyboardLockerModelTests` 覆盖 `AppUIStore` 的派生状态、读取失败呈现 unavailable(不回退 `.default`)、locked 期间的 pending 语义、`HotkeyCapture` 纯函数矩阵(修饰键映射 / 接受即通过 Agent 校验 / Escape/Return 终止)。真实 `NSEvent` 捕获、`NSPopover` 呈现与 status-item 点击、热键实机解锁与 Widget 刷新属于真机手动验证边界(见方案的手动验证清单:改热键后用新热键解锁、locked 时改 Never 仍在原 deadline 解锁、设置改动后 Widget 跟随、Agent 不可达时设置页显示 unavailable)。 ### 覆盖边界与已知限制 diff --git a/docs/xpc.md b/docs/xpc.md index 19745bb..dcd2561 100644 --- a/docs/xpc.md +++ b/docs/xpc.md @@ -144,6 +144,7 @@ public protocol KeyboardLockerServiceProtocol { func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) func currentSettings(reply: @escaping (Data?) -> Void) func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) + func applySettings(_ data: Data, reply: @escaping (Data?, Error?) -> Void) } ``` @@ -550,8 +551,9 @@ sequenceDiagram | `requestAccessibilityPermission` | `requestAccessibilityPermission()` | `AccessibilityManager` | 请求系统异步显示 Agent 的授权 prompt;reply 不代表用户已授权 | | `currentSettings` | protocol 1.1 legacy Client only | `AgentService` | 为既有 selector ABI 保留;编码失败只能返回 `nil` | | `currentSettingsWithError` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 启动时取得并持有的设置快照;本地 persisted payload 损坏时仅 Agent store 记录错误并回退 `.default`,而跨进程 payload 的缺失、损坏、过大或编码失败保持严格显式失败,wrapper 不自行回退默认值 | +| `applySettings` | `applySettings(_:)` | `KeyboardLockerSettingsStore` / `AgentService` | capability-gated 写入;Agent 先 `validated()` 再持久化并返回落盘后的权威 settings。**locked 时只落盘不触碰当前锁**——`LockEngine.updateSettings` 会重算 auto-unlock window(`.disabled` 直接取消 timer),mid-lock 应用会破坏活动锁的 fail-safe,因此新值只 seed 下一次 lock。校验失败或 replacement drain 期间不落盘并显式报错;reply 丢失后重查权威 settings 比对,不重发 | -MVP 的 settings contract 有意保持只读,不包含 write selector 或设置编辑 UI;用户可编辑设置属于 post-MVP extension。未来新增写入能力时仍必须由 Agent XPC protocol 承载并由 Agent 持久化。 +用户可编辑设置经 protocol 1.8 的 `applySettings` 写入并由 Agent 持久化,wrapper 绝不拥有自己的 store。写入护栏(热键至少一个可匹配修饰键、keyCode 可被 ASCII-capable layout 映射、timed 策略有界且有限)由 `KeyboardLockerSettings.validated()` 在 `Common` 定义、由 Agent 强制,任何写入面共用同一规则。`.disabled` 仍可达,由 UI 承担 not-recommended 警告与二次确认。 Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使用 Accessibility API 的进程身份。App 获得 Accessibility 权限并不能让 Agent 创建 event tap。