From d2dbf992d9b3ace9c22bfcc2631af8d628ee4394 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 17 Aug 2026 17:06:47 +0800 Subject: [PATCH 1/6] feat(core): add a guarded settings write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 1.8 introduces the capability-gated `applySettings` selector so the Agent stays the only writer of its own settings source of truth. Settings were readable but immutable, which left the unlock hotkey and auto-unlock timeout fixed at their defaults. Two lock-out guardrails shape the design: `LockEngine.updateSettings` re-arms the auto-unlock timer from the moment it is called, and cancels it outright for a `.disabled` policy. Applying settings during an active lock could therefore extend or remove the fail-safe that recovers a locked keyboard, so a running lock keeps its active configuration, gesture, start time, and deadline; new values seed the next lock. Validation lives in `Common` and is enforced by the Agent rather than by a wrapper, since every future write surface reaches the same entry point. A hotkey needs at least one modifier that survives event-tap normalization and a key code the ASCII-capable layout can display; a timed policy is bounded and finite. `.disabled` stays reachable — the UI carries the warning. The reply returns the values actually stored, not the request, because validation normalizes them. A lost reply re-reads the authoritative settings instead of re-sending, keeping the outcome explicit rather than assumed. Also removes the duplicated capability/minor table from ClientTests, which had already drifted from the implementation by omitting this selector. --- Core/Sources/Client/XPCClient.swift | 49 ++++- .../Common/KeyboardLockerSettings.swift | 101 +++++++++- Core/Sources/Common/ServiceDescriptor.swift | 4 +- Core/Sources/Common/Shared.swift | 8 + Core/Sources/Service/AgentService.swift | 57 +++++- .../Service/KeyboardLockerSettingsStore.swift | 10 + .../ClientTests/XPCClientErrorTests.swift | 89 +++++++-- ...eyboardLockerSettingsValidationTests.swift | 136 +++++++++++++ .../ServiceTests/AgentServiceTests.swift | 187 ++++++++++++++++++ 9 files changed, 619 insertions(+), 22 deletions(-) create mode 100644 Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift 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..452c9e2 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,102 @@ 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 { + guard unlockHotkey.hasModifier else { + throw KeyboardLockerSettingsValidationError.hotkeyMissingModifier + } + guard KeyCodeConverter.stringFromKeyCode( + unlockHotkey.keyCode, + modifiers: unlockHotkey.modifierFlags + ) != nil else { + throw KeyboardLockerSettingsValidationError.hotkeyUnmappable + } + + 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 + } +} + // 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..46092e2 --- /dev/null +++ b/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift @@ -0,0 +1,136 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import Common + +@Suite +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 = 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..a640051 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() { + let service = makeService() + let invalid = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 37, modifierFlags: []) + ) + + var error: Error? + var data: Data? + service.applySettings(try! 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? + service.applySettings( + try 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? + service.applySettings( + try 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? + service.applySettings(try 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 From 73cf23ed94c24927a58d8850ca7f4856a7be3c2a Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 17 Aug 2026 17:15:07 +0800 Subject: [PATCH 2/6] feat(app): expose agent settings and lock detail to the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordinator only carried a lock Boolean, which is not enough to present auto-unlock deadlines or let the user edit the configuration. It now also publishes the authoritative lock snapshot and the persisted settings. The two settings values answer different questions and are kept separate on purpose: `currentSettings()` is the stored configuration the next lock will use and is what the settings UI edits, while `lockStatusSnapshot().settings` is what a running lock is enforcing. They diverge while locked, because a write leaves an active lock alone — `hasSettingsPendingNextLock` reports exactly that state so the UI can say so rather than implying the change is already in effect. A settings read failure becomes explicitly unavailable instead of falling back to `.default`, which would invent a second source of truth and could display an unlock hotkey the Agent is not using. Detail is dropped whenever the Agent stops being trustworthy for it — including when an update is required, since such an agent may not implement the write selector at all — and a detail failure never downgrades an otherwise ready state. --- .../AgentCoordinationServices.swift | 36 ++- KeyboardLocker/AppCoordinator.swift | 165 +++++++++++++- .../KeyboardLockerDiagnostics.swift | 2 + KeyboardLocker/StatusMenuController.swift | 2 + .../AppCoordinatorTests.swift | 209 +++++++++++++++++- 5 files changed, 410 insertions(+), 4 deletions(-) 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/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/StatusMenuController.swift index b30ece6..cee2ff2 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -168,6 +168,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { ) -> (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: 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( From 732f980ab74d0bc9cf87b3335cddd6b686cbacb1 Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 17 Aug 2026 17:25:59 +0800 Subject: [PATCH 3/6] feat(app): add a status window and editable settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app had no window at all: every interaction lived in menu items and modal alerts, which cannot express a countdown, a lock start time, or an editable hotkey. This adds two SwiftUI windows hosted in AppKit controllers. The entry point stays an AppKit accessory process. Converting it to a SwiftUI `App` to gain a `Settings` scene would put the activation policy, services provider, and URL handling — three already-tested paths — at risk for no user-visible gain, so `NSHostingController` provides the same windows without touching startup. The status menu keeps every existing item. It remains the surface reachable while the keyboard is locked and only the mouse works, so the window is a parallel entry point rather than a replacement; the window reuses the menu's diagnostics and CLI flows instead of duplicating them. Settings enforcement stays in the Agent. `HotkeyCapture` defers to `Hotkey.validated()`, so the picker cannot offer a combination the write path would reject, and it drops CapsLock, Fn, and numeric-pad flags that the event tap's matcher discards. Escape and Return leave the recorder rather than being recorded, keeping a keyboard-only exit. Turning auto-unlock off stays reachable but requires confirmation naming the remaining unlock routes. While locked, the window and settings both state that stored values apply to the next lock, since a write deliberately leaves an active lock untouched. --- .../Common/KeyboardLockerSettings.swift | 25 +- ...eyboardLockerSettingsValidationTests.swift | 8 +- .../ServiceTests/AgentServiceTests.swift | 14 +- KeyboardLocker/StatusMenuController.swift | 50 ++- KeyboardLocker/UI/AppUIStore.swift | 113 ++++++ KeyboardLocker/UI/AppWindowPresenter.swift | 85 +++++ KeyboardLocker/UI/HotkeyCapture.swift | 76 ++++ KeyboardLocker/UI/HotkeyRecorderField.swift | 183 ++++++++++ KeyboardLocker/UI/MainView.swift | 205 +++++++++++ KeyboardLocker/UI/SettingsView.swift | 256 +++++++++++++ .../AppUIStoreTests.swift | 337 ++++++++++++++++++ .../HotkeyCaptureTests.swift | 114 ++++++ 12 files changed, 1442 insertions(+), 24 deletions(-) create mode 100644 KeyboardLocker/UI/AppUIStore.swift create mode 100644 KeyboardLocker/UI/AppWindowPresenter.swift create mode 100644 KeyboardLocker/UI/HotkeyCapture.swift create mode 100644 KeyboardLocker/UI/HotkeyRecorderField.swift create mode 100644 KeyboardLocker/UI/MainView.swift create mode 100644 KeyboardLocker/UI/SettingsView.swift create mode 100644 KeyboardLockerModelTests/AppUIStoreTests.swift create mode 100644 KeyboardLockerModelTests/HotkeyCaptureTests.swift diff --git a/Core/Sources/Common/KeyboardLockerSettings.swift b/Core/Sources/Common/KeyboardLockerSettings.swift index 452c9e2..c308c13 100644 --- a/Core/Sources/Common/KeyboardLockerSettings.swift +++ b/Core/Sources/Common/KeyboardLockerSettings.swift @@ -164,7 +164,7 @@ 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 + 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. @@ -172,15 +172,7 @@ public extension KeyboardLockerSettings { /// 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 { - guard unlockHotkey.hasModifier else { - throw KeyboardLockerSettingsValidationError.hotkeyMissingModifier - } - guard KeyCodeConverter.stringFromKeyCode( - unlockHotkey.keyCode, - modifiers: unlockHotkey.modifierFlags - ) != nil else { - throw KeyboardLockerSettingsValidationError.hotkeyUnmappable - } + _ = try unlockHotkey.validated() var normalized = self if case let .timed(seconds) = autoUnlockPolicy { @@ -209,6 +201,19 @@ public extension KeyboardLockerSettings.Hotkey { 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 diff --git a/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift b/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift index 46092e2..eeffc98 100644 --- a/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift +++ b/Core/Tests/CommonTests/KeyboardLockerSettingsValidationTests.swift @@ -1,13 +1,11 @@ +@testable import Common import CoreGraphics import Foundation import Testing -@testable import Common - -@Suite 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 = CGKeyCode(SharedConstants.defaultUnlockKeyCode) + /// '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, diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift index a640051..8e5809d 100644 --- a/Core/Tests/ServiceTests/AgentServiceTests.swift +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -490,7 +490,7 @@ final class AgentServiceTests { } @Test - func applySettingsRejectsAnInvalidPayloadWithoutPersisting() { + func applySettingsRejectsAnInvalidPayloadWithoutPersisting() throws { let service = makeService() let invalid = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), @@ -499,7 +499,7 @@ final class AgentServiceTests { var error: Error? var data: Data? - service.applySettings(try! invalid.encodedForXPC()) { replyData, replyError in + try service.applySettings(invalid.encodedForXPC()) { replyData, replyError in data = replyData error = replyError } @@ -532,8 +532,8 @@ final class AgentServiceTests { settingsStore.saveError = StubError.descriptorUnavailable var error: Error? - service.applySettings( - try makeValidSettings(autoUnlockPolicy: .timed(seconds: 30)).encodedForXPC() + try service.applySettings( + makeValidSettings(autoUnlockPolicy: .timed(seconds: 30)).encodedForXPC() ) { _, replyError in error = replyError } @@ -555,8 +555,8 @@ final class AgentServiceTests { prepareTicket(on: service, instanceID: instanceID) var error: Error? - service.applySettings( - try makeValidSettings(autoUnlockPolicy: .timed(seconds: 30)).encodedForXPC() + try service.applySettings( + makeValidSettings(autoUnlockPolicy: .timed(seconds: 30)).encodedForXPC() ) { _, replyError in error = replyError } @@ -585,7 +585,7 @@ final class AgentServiceTests { ) throws -> KeyboardLockerSettings { var data: Data? var error: Error? - service.applySettings(try settings.encodedForXPC()) { replyData, replyError in + try service.applySettings(settings.encodedForXPC()) { replyData, replyError in data = replyData error = replyError } diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift index cee2ff2..196f02a 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusMenuController.swift @@ -11,6 +11,8 @@ final class StatusMenuController: NSObject, NSMenuDelegate { private let menu = NSMenu() private let safetyCheckStore: SafetyCheckExperienceStore private let statusItem: NSStatusItem + private let uiStore: AppUIStore + private let windowPresenter: AppWindowPresenter private var currentSnapshot: AppCoordinator.Snapshot private var detailMessage: String? private var hasOfferedSafetyCheckThisLaunch = false @@ -37,10 +39,30 @@ 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 + var presentedDiagnostics: (() -> Void)? + var presentedCommandLineTool: (() -> Void)? + windowPresenter = AppWindowPresenter( + store: store, + copyDiagnostics: { presentedDiagnostics?() }, + manageCommandLineTool: { presentedCommandLineTool?() } + ) super.init() + // The window reuses the menu's existing diagnostics and CLI flows rather than duplicating them. + presentedDiagnostics = { [weak self] in + self?.copyDiagnostics() + } + presentedCommandLineTool = { [weak self] in + self?.manageCommandLineTool() + } + menu.delegate = self statusItem.menu = menu + // This controller owns the coordinator's single observer slot and fans each snapshot out to + // both presentations. coordinator.onSnapshotChange = { [weak self] snapshot in self?.render(snapshot) } @@ -54,6 +76,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { private func render(_ snapshot: AppCoordinator.Snapshot) { currentSnapshot = snapshot + uiStore.receive(snapshot) detailMessage = makeDetailMessage(for: snapshot) let appearance = makeAppearance(for: snapshot) @@ -79,6 +102,18 @@ final class StatusMenuController: NSObject, NSMenuDelegate { menu.addItem(status) menu.addItem(.separator()) + addAction( + title: "Open KeyboardLocker", + action: #selector(openMainWindow), + keyEquivalent: "0" + ) + addAction( + title: "Settings…", + action: #selector(openSettingsWindow), + keyEquivalent: "," + ) + menu.addItem(.separator()) + if currentSnapshot.activity == nil { addStateActions(for: currentSnapshot.state) } @@ -155,9 +190,10 @@ final class StatusMenuController: NSObject, NSMenuDelegate { private func addAction( title: String, action: Selector, - isEnabled: Bool = true + isEnabled: Bool = true, + keyEquivalent: String = "" ) { - let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) item.target = self item.isEnabled = isEnabled menu.addItem(item) @@ -319,6 +355,16 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } } + @objc + private func openMainWindow() { + windowPresenter.showMainWindow() + } + + @objc + private func openSettingsWindow() { + windowPresenter.showSettingsWindow() + } + @objc private func performDisplayedLockAction() { coordinator.performDisplayedLockAction() diff --git a/KeyboardLocker/UI/AppUIStore.swift b/KeyboardLocker/UI/AppUIStore.swift new file mode 100644 index 0000000..9220036 --- /dev/null +++ b/KeyboardLocker/UI/AppUIStore.swift @@ -0,0 +1,113 @@ +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 + } + } + + // 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() + } +} diff --git a/KeyboardLocker/UI/AppWindowPresenter.swift b/KeyboardLocker/UI/AppWindowPresenter.swift new file mode 100644 index 0000000..fecac60 --- /dev/null +++ b/KeyboardLocker/UI/AppWindowPresenter.swift @@ -0,0 +1,85 @@ +import AppKit +import SwiftUI + +/// Owns the app's two SwiftUI windows and their lifetime. +/// +/// The app stays an AppKit accessory process: its activation policy, services provider, and URL +/// handling are established paths, and converting the entry point to a SwiftUI `App` to gain a +/// `Settings` scene would put all three at risk for no user-visible benefit. Hosting controllers +/// give the same windows without touching startup. +@MainActor +final class AppWindowPresenter { + private let store: AppUIStore + private let copyDiagnostics: () -> Void + private let manageCommandLineTool: () -> Void + + private var mainWindowController: NSWindowController? + private var settingsWindowController: NSWindowController? + + init( + store: AppUIStore, + copyDiagnostics: @escaping () -> Void, + manageCommandLineTool: @escaping () -> Void + ) { + self.store = store + self.copyDiagnostics = copyDiagnostics + self.manageCommandLineTool = manageCommandLineTool + } + + func showMainWindow() { + if let controller = mainWindowController { + present(controller) + return + } + + let view = MainView( + store: store, + openSettings: { [weak self] in + self?.showSettingsWindow() + }, + copyDiagnostics: copyDiagnostics, + manageCommandLineTool: manageCommandLineTool + ) + let controller = makeWindowController( + title: "KeyboardLocker", + content: view + ) + mainWindowController = controller + present(controller) + } + + func showSettingsWindow() { + if let controller = settingsWindowController { + present(controller) + return + } + + let controller = makeWindowController( + title: "KeyboardLocker Settings", + content: SettingsView(store: store) + ) + settingsWindowController = controller + present(controller) + } + + private func makeWindowController( + title: String, + content: some View + ) -> NSWindowController { + let hostingController = NSHostingController(rootView: content) + let window = NSWindow(contentViewController: hostingController) + window.title = title + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.center() + window.setFrameAutosaveName("io.lzhlovesjyq.keyboardlocker.\(title)") + return NSWindowController(window: window) + } + + /// An accessory app has no Dock icon, so a window would otherwise open behind the frontmost app. + private func present(_ controller: NSWindowController) { + NSApp.activateForUserPresentation() + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + } +} 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/MainView.swift b/KeyboardLocker/UI/MainView.swift new file mode 100644 index 0000000..ea05655 --- /dev/null +++ b/KeyboardLocker/UI/MainView.swift @@ -0,0 +1,205 @@ +import Client +import SwiftUI + +/// The app's status window: what the lock is doing right now, and the actions that change it. +/// +/// Everything shown here comes from the coordinator's authoritative snapshot. The window is a +/// parallel entry point to the status menu, not a replacement — the menu stays the reachable +/// surface while the keyboard is locked and only the mouse works. +struct MainView: View { + @ObservedObject var store: AppUIStore + + let openSettings: () -> Void + let copyDiagnostics: () -> Void + let manageCommandLineTool: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + detail + Divider() + footer + } + .frame(width: 420) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + // A window that was hidden may have missed a broadcast, so recalibrate on becoming visible. + store.reconcile() + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: store.isLocked ? "lock.fill" : "lock.open.fill") + .font(.system(size: 28)) + .foregroundStyle(store.isLocked ? Color.accentColor : .secondary) + .frame(width: 36) + + VStack(alignment: .leading, spacing: 4) { + Text(store.isLocked ? "Keyboard Locked" : "Keyboard Unlocked") + .font(.title3.weight(.semibold)) + + if let activity = store.snapshot.activity { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(Self.activityLabel(activity)).foregroundStyle(.secondary) + } + .font(.callout) + } else { + Text(Self.stateSummary(store.snapshot.state)) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 0) + } + .padding(16) + } + + // MARK: - Detail + + private var detail: some View { + VStack(alignment: .leading, spacing: 10) { + if store.isLocked { + if let deadline = store.autoUnlockDeadline { + LabeledContent("Unlocks automatically") { + // A live countdown from the authoritative deadline; the deadline itself is transported, + // never a counter that would be stale on arrival. + Text(timerInterval: Date()...deadline, countsDown: true) + .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 { + LabeledContent("Unlocks automatically") { + Text("Never").foregroundStyle(.secondary) + } + } + + if let started = store.lockStartDate { + LabeledContent("Locked since") { + Text(started, style: .time).monospacedDigit() + } + } + } + + if let hotkey = store.activeSettings?.unlockHotkey ?? store.editableSettings?.unlockHotkey { + LabeledContent("Unlock hotkey") { + Text(hotkey.displayString).monospacedDigit() + } + } + + if store.snapshot.hasSettingsPendingNextLock { + Label( + "Saved settings take effect on the next lock.", + systemImage: "clock.badge.checkmark" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let error = store.snapshot.lastError { + Label { + Text(error).fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + } + .font(.callout) + } + + primaryActions + } + .padding(16) + } + + private var primaryActions: some View { + HStack(spacing: 8) { + if store.canPerformLockAction { + Button(store.isLocked ? "Unlock Keyboard" : "Lock Keyboard") { + store.performDisplayedLockAction() + } + .keyboardShortcut(.defaultAction) + } + + if case .accessibilityRequired = store.snapshot.state { + Button("Grant Accessibility Access…") { + store.requestAccessibilityPermission() + } + .disabled(store.isBusy) + } + + if case .ready(isLocked: false) = store.snapshot.state, + store.snapshot.safetyCheckState != .running { + Button("Run 10-Second Safety Check…") { + store.startSafetyCheck() + } + .disabled(store.isBusy) + } + + Spacer(minLength: 0) + } + .padding(.top, 2) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 8) { + Button("Settings…", action: openSettings) + .disabled(store.isBusy) + Spacer(minLength: 0) + Button("Command Line Tool…", action: manageCommandLineTool) + Button("Copy Diagnostics", action: copyDiagnostics) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: - Copy + + private static func activityLabel(_ activity: AppCoordinator.Activity) -> String { + switch activity { + case .applyingSettings: + "Saving settings…" + case .locking: + "Locking…" + case .requestingAccessibility: + "Requesting Accessibility access…" + case .restartingAgent: + "Restarting the background agent…" + case .startingSafetyCheck: + "Starting the safety check…" + case .unlocking: + "Unlocking…" + case .updatingAgent: + "Updating the background agent…" + } + } + + private static func stateSummary(_ state: AppCoordinator.State) -> String { + switch state { + case .checking: + "Checking the background agent…" + + case .agentApprovalRequired: + "Enable KeyboardLocker in System Settings → General → Login Items." + + case let .agentReplacementInProgress(message), + let .agentUpdateRequired(_, message), + let .unavailable(message, _): + message + + case .accessibilityRequired: + "The background agent needs Accessibility access before it can filter keyboard events." + + case .ready: + "The background agent is ready. Mouse and trackpad stay available while locked." + } + } +} diff --git a/KeyboardLocker/UI/SettingsView.swift b/KeyboardLocker/UI/SettingsView.swift new file mode 100644 index 0000000..2863e75 --- /dev/null +++ b/KeyboardLocker/UI/SettingsView.swift @@ -0,0 +1,256 @@ +import Client +import SwiftUI + +/// Edits the Agent's persisted configuration. +/// +/// 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 SettingsView: View { + @ObservedObject var store: AppUIStore + + @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 { + Form { + if let message = store.settingsUnavailableMessage { + Section { + UnavailableRow(message: message) { + store.reconcile() + } + } + } else if let draft { + hotkeySection(draft: draft) + autoUnlockSection(draft: draft) + footerSection(draft: draft) + } else { + Section { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Reading settings from the background agent…") + .foregroundStyle(.secondary) + } + } + } + } + .formStyle(.grouped) + .frame(width: 460) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + // Re-read on appear: a long-lived window may have missed changes made from another surface. + 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: - Sections + + private func hotkeySection(draft: KeyboardLockerSettings) -> some View { + Section { + LabeledContent("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 + } + } + } + } header: { + Text("Unlocking") + } footer: { + VStack(alignment: .leading, spacing: 4) { + 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(.callout) + } else { + Text("Click the field, then press the shortcut you want to use to unlock the keyboard.") + .foregroundStyle(.secondary) + } + } + } + } + + private func autoUnlockSection(draft: KeyboardLockerSettings) -> some View { + Section { + 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) + } + .disabled(!store.canEditSettings) + } footer: { + Text(Self.autoUnlockFooter(for: draft.autoUnlockPolicy)) + .foregroundStyle(.secondary) + } + } + + private func footerSection(draft _: KeyboardLockerSettings) -> some View { + Section { + if store.snapshot.hasSettingsPendingNextLock { + Label { + Text("Saved. The keyboard is locked right now, so these values take effect on the next lock.") + } icon: { + Image(systemName: "clock.badge.checkmark") + } + .font(.callout) + .foregroundStyle(.secondary) + } else if store.isLocked { + Text("The keyboard is locked. Changes take effect on the next lock.") + .font(.callout) + .foregroundStyle(.secondary) + } + + if let error = store.snapshot.lastError { + Label { + Text(error) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + .font(.callout) + } + } + } + + // 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. + """ + } + } +} + +/// 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) + } + } +} 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)) + } +} From cbf69f20d28f92c0016cfb5c58858d134bccbbfb Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 17 Aug 2026 17:33:10 +0800 Subject: [PATCH 4/6] docs: record the settings write path and app UI layer Settings were documented as intentionally read-only for the MVP. Protocol 1.8 adds `applySettings`, so the architecture contract, XPC guide, development map, and project overview now describe the write path and its boundaries: validation lives in `Common` and is enforced by the Agent, a locked keyboard keeps its active configuration while new values seed the next lock, and reads that fail stay unavailable rather than falling back to defaults. Also documents the new SwiftUI UI layer, the settings-store save path, the coordinator's snapshot detail, and the test coverage for all of the above, including the on-device checks that remain manual. --- CLAUDE.md | 2 +- docs/architecture.md | 6 +++--- docs/development.md | 26 +++++++++++++++----------- docs/xpc.md | 4 +++- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6bfb255..c3fe3ec 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 status/settings windows, 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/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..3a07eeb 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` / `StatusMenuController.swift`:进程级 AppKit 生命周期与 status-menu presentation。它们只渲染 `AppCoordinator.Snapshot` 并把用户动作转发给 coordinator,不直接读取或持有锁/设置状态。首次 readiness 达到 ready + unlocked 后,menu controller 会提示一次可跳过的 10 秒安全测试;菜单也始终保留手动入口。菜单顶部新增 `Open KeyboardLocker` 与 `Settings…` 入口;controller 拥有 coordinator 的唯一 observer 槽位,把每个 snapshot 同时扇出给菜单与 `AppUIStore`,并把窗口的诊断/CLI 动作转接回既有菜单流程,不复制逻辑。锁定时菜单仍是鼠标可达的唯一出口,窗口是并行入口而非替代。 +- `UI/`:SwiftUI 窗口层,承载于 AppKit `NSHostingController`(App 入口保持 accessory `@main`,不改成 SwiftUI `App`,以免动到 activation policy、services provider 与 URL handling 三条已测路径)。`AppUIStore`(`ObservableObject`,部署到 macOS 13)只把 `AppCoordinator.Snapshot` 桥成可观察状态并转发动作,不含领域逻辑,由 controller 调 `receive(_:)` 推送而非自行抢占 observer。`MainView` 显示锁状态、由权威 `autoUnlockTargetDate` 派生的倒计时(标注休眠会推迟实际解锁)、起点、active 解锁热键与主动作。`SettingsView` 编辑持久化设置:热键录制、auto-unlock 档位、`.disabled` 二次确认;读取失败显示 unavailable 而非默认值,locked 时提示"下次锁生效"。`HotkeyCapture` 是纯翻译(捕获→候选热键,复用 `Hotkey.validated()`,丢弃 matcher 忽略的修饰键,Escape/Return 结束录制),`HotkeyRecorderField`/`HotkeyRecorderView` 是其 AppKit 捕获宿主;`AppWindowPresenter` 复用两个窗口 controller。 - `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 写入并持久化,`SettingsView` 提供 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. 在 `SettingsView` 暴露编辑控件,写入仍只经 `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` 捕获、`NSHostingController` 窗口呈现、热键实机解锁与 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。 From 11f8dd25d60b2c8753ff994bb13ad6672bbff44e Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 17 Aug 2026 18:29:58 +0800 Subject: [PATCH 5/6] refactor(app): replace the menu and windows with a single popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the menu-bar icon opened a plain NSMenu, and status/settings lived in separate NSWindows — text-heavy and visually rough. The icon now toggles one SwiftUI popover that carries the whole UI: a status card, the primary action, and settings behind an in-popover back navigation. Low-frequency actions (diagnostics, CLI, quit) move into a bottom "⋯" menu. This is a presentation-layer change only. The domain layer — AppUIStore, AppCoordinator, the Client, and protocol 1.8 — is untouched, which is exactly what keeping the UI a thin layer bought us. The store gains only pure presentation routing (recovery-action list, safety-check availability) derived from the same snapshot. Constraints preserved: - The popover stays a mouse-reachable unlock surface while locked: a left-click opens it and it carries the Unlock button, needing no keyboard. - Destructive or system-level actions (agent update/restart, quit while locked, auto-unlock off, CLI management) stay modal NSAlerts; the popover closes before each so it never covers the alert. These live in StatusItemController and reach the SwiftUI layer through PopoverActions, keeping the views free of AppKit presentation. - Every AppCoordinator.State/Activity branch is mapped in the status page, so approval-required, accessibility-required, update-required, replacement, and unavailable states keep their explanation and recovery button. StatusMenuController becomes StatusItemController; AppWindowPresenter becomes PopoverPresenter; MainView/SettingsView become StatusPage/SettingsPage under a PopoverRootView that owns the status↔settings navigation. --- .../KeyboardLockerApplication.swift | 4 +- ...oller.swift => StatusItemController.swift} | 390 ++++++------------ KeyboardLocker/UI/AppUIStore.swift | 50 +++ KeyboardLocker/UI/AppWindowPresenter.swift | 85 ---- KeyboardLocker/UI/MainView.swift | 205 --------- KeyboardLocker/UI/PopoverActions.swift | 39 ++ KeyboardLocker/UI/PopoverPresenter.swift | 45 ++ KeyboardLocker/UI/PopoverRootView.swift | 51 +++ ...{SettingsView.swift => SettingsPage.swift} | 226 ++++++---- KeyboardLocker/UI/StatusPage.swift | 370 +++++++++++++++++ 10 files changed, 814 insertions(+), 651 deletions(-) rename KeyboardLocker/{StatusMenuController.swift => StatusItemController.swift} (69%) delete mode 100644 KeyboardLocker/UI/AppWindowPresenter.swift delete mode 100644 KeyboardLocker/UI/MainView.swift create mode 100644 KeyboardLocker/UI/PopoverActions.swift create mode 100644 KeyboardLocker/UI/PopoverPresenter.swift create mode 100644 KeyboardLocker/UI/PopoverRootView.swift rename KeyboardLocker/UI/{SettingsView.swift => SettingsPage.swift} (55%) create mode 100644 KeyboardLocker/UI/StatusPage.swift 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/StatusMenuController.swift b/KeyboardLocker/StatusItemController.swift similarity index 69% rename from KeyboardLocker/StatusMenuController.swift rename to KeyboardLocker/StatusItemController.swift index 196f02a..3ad8380 100644 --- a/KeyboardLocker/StatusMenuController.swift +++ b/KeyboardLocker/StatusItemController.swift @@ -1,20 +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 let windowPresenter: AppWindowPresenter + private var popoverPresenter: PopoverPresenter! private var currentSnapshot: AppCoordinator.Snapshot - private var detailMessage: String? private var hasOfferedSafetyCheckThisLaunch = false private var lastHandledSafetyCheckState: AppCoordinator.SafetyCheckState = .idle @@ -42,42 +44,54 @@ final class StatusMenuController: NSObject, NSMenuDelegate { let store = AppUIStore(coordinator: coordinator) uiStore = store - var presentedDiagnostics: (() -> Void)? - var presentedCommandLineTool: (() -> Void)? - windowPresenter = AppWindowPresenter( - store: store, - copyDiagnostics: { presentedDiagnostics?() }, - manageCommandLineTool: { presentedCommandLineTool?() } - ) super.init() - // The window reuses the menu's existing diagnostics and CLI flows rather than duplicating them. - presentedDiagnostics = { [weak self] in - self?.copyDiagnostics() - } - presentedCommandLineTool = { [weak self] in - self?.manageCommandLineTool() + // 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 } - menu.delegate = self - statusItem.menu = menu // This controller owns the coordinator's single observer slot and fans each snapshot out to - // both presentations. + // both the menu-bar icon and the popover's store. coordinator.onSnapshotChange = { [weak self] snapshot in self?.render(snapshot) } + render(coordinator.snapshot) + } + + 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() } + ) } - 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. + // 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 uiStore.receive(snapshot) - detailMessage = makeDetailMessage(for: snapshot) let appearance = makeAppearance(for: snapshot) if let button = statusItem.button { @@ -90,115 +104,9 @@ 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()) - - addAction( - title: "Open KeyboardLocker", - action: #selector(openMainWindow), - keyEquivalent: "0" - ) - addAction( - title: "Settings…", - action: #selector(openSettingsWindow), - keyEquivalent: "," - ) - 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, - keyEquivalent: String = "" - ) { - let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) - item.target = self - item.isEnabled = isEnabled - menu.addItem(item) - } - private func makeAppearance( for snapshot: AppCoordinator.Snapshot ) -> (title: String, symbolName: String, accessibilityDescription: String) { @@ -277,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 @@ -340,7 +218,7 @@ final class StatusMenuController: NSObject, NSMenuDelegate { hasOfferedSafetyCheckThisLaunch = true Task { @MainActor [weak self] in - self?.runSafetyCheck() + self?.confirmSafetyCheck() } } @@ -355,28 +233,10 @@ final class StatusMenuController: NSObject, NSMenuDelegate { } } - @objc - private func openMainWindow() { - windowPresenter.showMainWindow() - } + // MARK: - Confirmed actions (carried into the popover via PopoverActions) - @objc - private func openSettingsWindow() { - windowPresenter.showSettingsWindow() - } - - @objc - private func performDisplayedLockAction() { - coordinator.performDisplayedLockAction() - } - - @objc - private func requestAccessibilityPermission() { - coordinator.requestAccessibilityPermission() - } - - @objc - private func runSafetyCheck() { + fileprivate func confirmSafetyCheck() { + popoverPresenter.close() let alert = NSAlert() alert.messageText = "Run a 10-Second Safety Check?" alert.informativeText = """ @@ -395,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 { @@ -417,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. @@ -433,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 { @@ -619,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 index 9220036..fcd23a7 100644 --- a/KeyboardLocker/UI/AppUIStore.swift +++ b/KeyboardLocker/UI/AppUIStore.swift @@ -89,6 +89,48 @@ final class AppUIStore: ObservableObject { } } + /// 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() { @@ -110,4 +152,12 @@ final class AppUIStore: ObservableObject { func startSafetyCheck() { coordinator.startSafetyCheck() } + + func updateAgent() { + coordinator.updateAgent() + } + + func restartAgent() { + coordinator.restartAgent() + } } diff --git a/KeyboardLocker/UI/AppWindowPresenter.swift b/KeyboardLocker/UI/AppWindowPresenter.swift deleted file mode 100644 index fecac60..0000000 --- a/KeyboardLocker/UI/AppWindowPresenter.swift +++ /dev/null @@ -1,85 +0,0 @@ -import AppKit -import SwiftUI - -/// Owns the app's two SwiftUI windows and their lifetime. -/// -/// The app stays an AppKit accessory process: its activation policy, services provider, and URL -/// handling are established paths, and converting the entry point to a SwiftUI `App` to gain a -/// `Settings` scene would put all three at risk for no user-visible benefit. Hosting controllers -/// give the same windows without touching startup. -@MainActor -final class AppWindowPresenter { - private let store: AppUIStore - private let copyDiagnostics: () -> Void - private let manageCommandLineTool: () -> Void - - private var mainWindowController: NSWindowController? - private var settingsWindowController: NSWindowController? - - init( - store: AppUIStore, - copyDiagnostics: @escaping () -> Void, - manageCommandLineTool: @escaping () -> Void - ) { - self.store = store - self.copyDiagnostics = copyDiagnostics - self.manageCommandLineTool = manageCommandLineTool - } - - func showMainWindow() { - if let controller = mainWindowController { - present(controller) - return - } - - let view = MainView( - store: store, - openSettings: { [weak self] in - self?.showSettingsWindow() - }, - copyDiagnostics: copyDiagnostics, - manageCommandLineTool: manageCommandLineTool - ) - let controller = makeWindowController( - title: "KeyboardLocker", - content: view - ) - mainWindowController = controller - present(controller) - } - - func showSettingsWindow() { - if let controller = settingsWindowController { - present(controller) - return - } - - let controller = makeWindowController( - title: "KeyboardLocker Settings", - content: SettingsView(store: store) - ) - settingsWindowController = controller - present(controller) - } - - private func makeWindowController( - title: String, - content: some View - ) -> NSWindowController { - let hostingController = NSHostingController(rootView: content) - let window = NSWindow(contentViewController: hostingController) - window.title = title - window.styleMask = [.titled, .closable, .miniaturizable] - window.isReleasedWhenClosed = false - window.center() - window.setFrameAutosaveName("io.lzhlovesjyq.keyboardlocker.\(title)") - return NSWindowController(window: window) - } - - /// An accessory app has no Dock icon, so a window would otherwise open behind the frontmost app. - private func present(_ controller: NSWindowController) { - NSApp.activateForUserPresentation() - controller.showWindow(nil) - controller.window?.makeKeyAndOrderFront(nil) - } -} diff --git a/KeyboardLocker/UI/MainView.swift b/KeyboardLocker/UI/MainView.swift deleted file mode 100644 index ea05655..0000000 --- a/KeyboardLocker/UI/MainView.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Client -import SwiftUI - -/// The app's status window: what the lock is doing right now, and the actions that change it. -/// -/// Everything shown here comes from the coordinator's authoritative snapshot. The window is a -/// parallel entry point to the status menu, not a replacement — the menu stays the reachable -/// surface while the keyboard is locked and only the mouse works. -struct MainView: View { - @ObservedObject var store: AppUIStore - - let openSettings: () -> Void - let copyDiagnostics: () -> Void - let manageCommandLineTool: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - header - Divider() - detail - Divider() - footer - } - .frame(width: 420) - .fixedSize(horizontal: false, vertical: true) - .onAppear { - // A window that was hidden may have missed a broadcast, so recalibrate on becoming visible. - store.reconcile() - } - } - - // MARK: - Header - - private var header: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: store.isLocked ? "lock.fill" : "lock.open.fill") - .font(.system(size: 28)) - .foregroundStyle(store.isLocked ? Color.accentColor : .secondary) - .frame(width: 36) - - VStack(alignment: .leading, spacing: 4) { - Text(store.isLocked ? "Keyboard Locked" : "Keyboard Unlocked") - .font(.title3.weight(.semibold)) - - if let activity = store.snapshot.activity { - HStack(spacing: 6) { - ProgressView().controlSize(.small) - Text(Self.activityLabel(activity)).foregroundStyle(.secondary) - } - .font(.callout) - } else { - Text(Self.stateSummary(store.snapshot.state)) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - Spacer(minLength: 0) - } - .padding(16) - } - - // MARK: - Detail - - private var detail: some View { - VStack(alignment: .leading, spacing: 10) { - if store.isLocked { - if let deadline = store.autoUnlockDeadline { - LabeledContent("Unlocks automatically") { - // A live countdown from the authoritative deadline; the deadline itself is transported, - // never a counter that would be stale on arrival. - Text(timerInterval: Date()...deadline, countsDown: true) - .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 { - LabeledContent("Unlocks automatically") { - Text("Never").foregroundStyle(.secondary) - } - } - - if let started = store.lockStartDate { - LabeledContent("Locked since") { - Text(started, style: .time).monospacedDigit() - } - } - } - - if let hotkey = store.activeSettings?.unlockHotkey ?? store.editableSettings?.unlockHotkey { - LabeledContent("Unlock hotkey") { - Text(hotkey.displayString).monospacedDigit() - } - } - - if store.snapshot.hasSettingsPendingNextLock { - Label( - "Saved settings take effect on the next lock.", - systemImage: "clock.badge.checkmark" - ) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let error = store.snapshot.lastError { - Label { - Text(error).fixedSize(horizontal: false, vertical: true) - } icon: { - Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) - } - .font(.callout) - } - - primaryActions - } - .padding(16) - } - - private var primaryActions: some View { - HStack(spacing: 8) { - if store.canPerformLockAction { - Button(store.isLocked ? "Unlock Keyboard" : "Lock Keyboard") { - store.performDisplayedLockAction() - } - .keyboardShortcut(.defaultAction) - } - - if case .accessibilityRequired = store.snapshot.state { - Button("Grant Accessibility Access…") { - store.requestAccessibilityPermission() - } - .disabled(store.isBusy) - } - - if case .ready(isLocked: false) = store.snapshot.state, - store.snapshot.safetyCheckState != .running { - Button("Run 10-Second Safety Check…") { - store.startSafetyCheck() - } - .disabled(store.isBusy) - } - - Spacer(minLength: 0) - } - .padding(.top, 2) - } - - // MARK: - Footer - - private var footer: some View { - HStack(spacing: 8) { - Button("Settings…", action: openSettings) - .disabled(store.isBusy) - Spacer(minLength: 0) - Button("Command Line Tool…", action: manageCommandLineTool) - Button("Copy Diagnostics", action: copyDiagnostics) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - - // MARK: - Copy - - private static func activityLabel(_ activity: AppCoordinator.Activity) -> String { - switch activity { - case .applyingSettings: - "Saving settings…" - case .locking: - "Locking…" - case .requestingAccessibility: - "Requesting Accessibility access…" - case .restartingAgent: - "Restarting the background agent…" - case .startingSafetyCheck: - "Starting the safety check…" - case .unlocking: - "Unlocking…" - case .updatingAgent: - "Updating the background agent…" - } - } - - private static func stateSummary(_ state: AppCoordinator.State) -> String { - switch state { - case .checking: - "Checking the background agent…" - - case .agentApprovalRequired: - "Enable KeyboardLocker in System Settings → General → Login Items." - - case let .agentReplacementInProgress(message), - let .agentUpdateRequired(_, message), - let .unavailable(message, _): - message - - case .accessibilityRequired: - "The background agent needs Accessibility access before it can filter keyboard events." - - case .ready: - "The background agent is ready. Mouse and trackpad stay available while locked." - } - } -} 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/SettingsView.swift b/KeyboardLocker/UI/SettingsPage.swift similarity index 55% rename from KeyboardLocker/UI/SettingsView.swift rename to KeyboardLocker/UI/SettingsPage.swift index 2863e75..2ad52f6 100644 --- a/KeyboardLocker/UI/SettingsView.swift +++ b/KeyboardLocker/UI/SettingsPage.swift @@ -1,13 +1,15 @@ import Client import SwiftUI -/// Edits the Agent's persisted configuration. +/// 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 SettingsView: View { +struct SettingsPage: View { @ObservedObject var store: AppUIStore + let goBack: () -> Void @State private var draft: KeyboardLockerSettings? @State private var hotkeyRejection: KeyboardLockerSettingsValidationError? @@ -16,32 +18,14 @@ struct SettingsView: View { private static let timeoutChoices: [TimeInterval] = [15, 30, 60, 120, 300, 600, 1800, 3600] var body: some View { - Form { - if let message = store.settingsUnavailableMessage { - Section { - UnavailableRow(message: message) { - store.reconcile() - } - } - } else if let draft { - hotkeySection(draft: draft) - autoUnlockSection(draft: draft) - footerSection(draft: draft) - } else { - Section { - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Reading settings from the background agent…") - .foregroundStyle(.secondary) - } - } - } + VStack(alignment: .leading, spacing: 0) { + header + Divider() + body(for: store.settingsUnavailableMessage) + .padding(16) } - .formStyle(.grouped) - .frame(width: 460) - .fixedSize(horizontal: false, vertical: true) .onAppear { - // Re-read on appear: a long-lived window may have missed changes made from another surface. + // Re-read on appear in case a change was made from another surface while this was closed. store.reconcile() syncDraft() } @@ -67,93 +51,150 @@ struct SettingsView: View { } } + // 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 { - Section { - LabeledContent("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 - } + 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 } } - } header: { - Text("Unlocking") - } footer: { - VStack(alignment: .leading, spacing: 4) { - if let hotkeyRejection { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(hotkeyRejection.localizedDescription) - if let suggestion = hotkeyRejection.recoverySuggestion { - Text(suggestion).foregroundStyle(.secondary) - } + .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(.callout) - } else { - Text("Click the field, then press the shortcut you want to use to unlock the keyboard.") - .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 { - Section { - Picker("Unlock Automatically", selection: autoUnlockSelection(draft: draft)) { + 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) - } footer: { + Text(Self.autoUnlockFooter(for: draft.autoUnlockPolicy)) + .font(.caption) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } - private func footerSection(draft _: KeyboardLockerSettings) -> some View { - Section { - if store.snapshot.hasSettingsPendingNextLock { - Label { - Text("Saved. The keyboard is locked right now, so these values take effect on the next lock.") - } icon: { - Image(systemName: "clock.badge.checkmark") - } - .font(.callout) - .foregroundStyle(.secondary) - } else if store.isLocked { - Text("The keyboard is locked. Changes take effect on the next lock.") - .font(.callout) - .foregroundStyle(.secondary) - } + @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 { - Label { - Text(error) - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - .font(.callout) - } + 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( @@ -232,6 +273,22 @@ struct SettingsView: View { } } +// 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 @@ -252,5 +309,6 @@ private struct UnavailableRow: View { } 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) + } +} From ca0ecf87c73ffaffb48f91d3b3baae4fb52e78ef Mon Sep 17 00:00:00 2001 From: Eden Date: Mon, 17 Aug 2026 18:32:03 +0800 Subject: [PATCH 6/6] docs: describe the popover UI The component map and app-role notes still described the NSMenu and the separate status/settings windows. Update them to the single NSPopover surface, the StatusItemController/PopoverPresenter split, the StatusPage/SettingsPage navigation, and the PopoverActions seam, and adjust the manual-verification boundary to popover presentation. --- CLAUDE.md | 2 +- docs/development.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c3fe3ec..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 with status/settings windows, 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/docs/development.md b/docs/development.md index 3a07eeb..8de7c66 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,8 +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 秒安全测试;菜单也始终保留手动入口。菜单顶部新增 `Open KeyboardLocker` 与 `Settings…` 入口;controller 拥有 coordinator 的唯一 observer 槽位,把每个 snapshot 同时扇出给菜单与 `AppUIStore`,并把窗口的诊断/CLI 动作转接回既有菜单流程,不复制逻辑。锁定时菜单仍是鼠标可达的唯一出口,窗口是并行入口而非替代。 -- `UI/`:SwiftUI 窗口层,承载于 AppKit `NSHostingController`(App 入口保持 accessory `@main`,不改成 SwiftUI `App`,以免动到 activation policy、services provider 与 URL handling 三条已测路径)。`AppUIStore`(`ObservableObject`,部署到 macOS 13)只把 `AppCoordinator.Snapshot` 桥成可观察状态并转发动作,不含领域逻辑,由 controller 调 `receive(_:)` 推送而非自行抢占 observer。`MainView` 显示锁状态、由权威 `autoUnlockTargetDate` 派生的倒计时(标注休眠会推迟实际解锁)、起点、active 解锁热键与主动作。`SettingsView` 编辑持久化设置:热键录制、auto-unlock 档位、`.disabled` 二次确认;读取失败显示 unavailable 而非默认值,locked 时提示"下次锁生效"。`HotkeyCapture` 是纯翻译(捕获→候选热键,复用 `Hotkey.validated()`,丢弃 matcher 忽略的修饰键,Escape/Return 结束录制),`HotkeyRecorderField`/`HotkeyRecorderView` 是其 AppKit 捕获宿主;`AppWindowPresenter` 复用两个窗口 controller。 +- `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。 @@ -81,11 +81,11 @@ ## 常见任务 ### 新增一个用户可编辑设置 -用户可编辑设置已接入:`applySettings`(protocol 1.8)经 Agent XPC 写入并持久化,`SettingsView` 提供 UI。新增一个字段时: +用户可编辑设置已接入:`applySettings`(protocol 1.8)经 Agent XPC 写入并持久化,`SettingsPage` 提供 UI。新增一个字段时: 1. 给 `KeyboardLockerSettings` 加一个 `Codable`/`Sendable` 属性,并更新 `.default`。 2. 如果引擎会消费它,在 `LockEngine.lock(settings:allowsControlCUnlock:)` / `updateSettings(_:)` 中读取。 3. 如果它有锁死或无效风险,在 `KeyboardLockerSettings.validated()` 补一条护栏并加 `CommonTests`——Agent 的 `applySettings` 已强制该方法,无需在 UI 侧另写校验。 -4. 在 `SettingsView` 暴露编辑控件,写入仍只经 `AppCoordinator.applySettings(_:)` → `XPCClient.applySettings(_:)`;wrapper **不得**拥有 store(见架构契约)。locked 时是否立即生效沿用现有语义:Agent 只在 unlocked 时把新值应用到引擎。 +4. 在 `SettingsPage` 暴露编辑控件,写入仍只经 `AppCoordinator.applySettings(_:)` → `XPCClient.applySettings(_:)`;wrapper **不得**拥有 store(见架构契约)。locked 时是否立即生效沿用现有语义:Agent 只在 unlocked 时把新值应用到引擎。 ### 新增一个 XPC 方法 1. 先判断它是否 optional/additive。不得在同一 protocol major 内修改或移除既有 selector、参数顺序或 reply 形状;破坏性变化需要新 major,无法保留 selector union 时需要新 Mach service。 @@ -147,7 +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` 捕获、`NSHostingController` 窗口呈现、热键实机解锁与 Widget 刷新属于真机手动验证边界(见方案的手动验证清单:改热键后用新热键解锁、locked 时改 Never 仍在原 deadline 解锁、设置改动后 Widget 跟随、Agent 不可达时设置窗口显示 unavailable)。 +- 设置写入路径:`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)。 ### 覆盖边界与已知限制