diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a901b60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Show toolchain versions + run: | + xcodebuild -version + xcrun swift --version + + - name: Core package tests + run: xcrun swift test --package-path Core + + # CI runners carry no signing identities, so the app build and the app-level tests run + # with code signing disabled. The mutual XPC code-signing requirement is a runtime + # property exercised by signed local builds; its construction rules are covered by + # XPCCodeSigningRequirementTests in the Core package above. + - name: Build app, agent, CLI, and extensions + run: >- + xcodebuild -scheme KeyboardLocker -configuration Debug build + CODE_SIGNING_ALLOWED=NO + + - name: App-level model tests + run: >- + xcodebuild -scheme KeyboardLocker -configuration Debug + -destination 'platform=macOS' + test -only-testing:KeyboardLockerModelTests + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index a4c8f6a..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 79fe056..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.gitignore b/.gitignore index f30c2c8..7d9689c 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,9 @@ iOSInjectionProject/ # macOS .DS_Store +# LLVM coverage data dropped into the working directory by xcodebuild test runs +*.profraw + # xcode-build-server files buildServer.json .compile diff --git a/.swift-version b/.swift-version deleted file mode 100644 index f9ce5a9..0000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -5.10 diff --git a/.swiftformat b/.swiftformat deleted file mode 100644 index 618f612..0000000 --- a/.swiftformat +++ /dev/null @@ -1,96 +0,0 @@ ---acronyms ID,URL,UUID ---allman false ---anonymousforeach convert ---assetliterals visual-width ---asynccapturing ---beforemarks ---binarygrouping 4,8 ---callsiteparen default ---categorymark "MARK: %c" ---classthreshold 0 ---closingparen balanced ---closurevoid remove ---commas always ---complexattrs preserve ---computedvarattrs preserve ---condassignment after-property ---conflictmarkers reject ---dateformat system ---decimalgrouping 3,6 ---doccomments before-declarations ---elseposition same-line ---emptybraces no-space ---enumnamespaces always ---enumthreshold 0 ---exponentcase lowercase ---exponentgrouping disabled ---extensionacl on-extension ---extensionlength 0 ---extensionmark "MARK: - %t + %c" ---fractiongrouping disabled ---fragment false ---funcattributes preserve ---generictypes ---groupedextension "MARK: %c" ---guardelse auto ---header ignore ---hexgrouping 4,8 ---hexliteralcase uppercase ---ifdef indent ---importgrouping alpha ---indent 2 ---indentcase false ---indentstrings false ---initcodernil false ---lifecycle ---lineaftermarks true ---linebreaks lf ---markcategories true ---markextensions always ---marktypes always ---maxwidth none ---modifierorder ---nevertrailing ---nilinit remove ---noncomplexattrs ---nospaceoperators ---nowrapoperators ---octalgrouping 4,8 ---onelineforeach ignore ---operatorfunc spaced ---organizationmode visibility ---organizetypes actor,class,enum,struct ---patternlet hoist ---ranges spaced ---redundanttype infer-locals-only ---self remove ---selfrequired ---semicolons inline ---shortoptionals except-properties ---smarttabs enabled ---someany true ---storedvarattrs preserve ---stripunusedargs always ---structthreshold 0 ---tabwidth unspecified ---throwcapturing ---timezone system ---trailingclosures ---trimwhitespace always ---typeattributes preserve ---typeblanklines remove ---typedelimiter space-after ---typemark "MARK: - %t" ---voidtype void ---wraparguments preserve ---wrapcollections preserve ---wrapconditions preserve ---wrapeffects preserve ---wrapenumcases always ---wrapparameters default ---wrapreturntype preserve ---wrapternary before-operators ---wraptypealiases preserve ---xcodeindentation disabled ---yodaswap always ---enable blankLineAfterSwitchCase diff --git a/CLAUDE.md b/CLAUDE.md index 3720f15..6bfb255 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,20 +4,22 @@ Guidance for Claude Code when working in this repository. ## Project Overview -KeyboardLocker locks keyboard/mouse input via CGEventTap on macOS. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`): +KeyboardLocker suppresses 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** (App, SwiftUI) — imports `Client` +- **KeyboardLocker** (menu-bar App, Agent lifecycle coordinator, and one-shot system actions) — imports `Client` and `SystemSurfaces` - **KeyboardLockerAgent** (XPC service, runs the lock engine, holds Accessibility permission) — imports `Service` - **klock** (CLI) — imports `Client` -- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, App/CLI), `Service` (lock engine, Agent). `Client`/`Service` both `@_exported import Common`. +- **KeyboardLockerWidgets** (WidgetKit extension hosting Widget and macOS Control) — imports `Client` and `SystemSurfaces` +- **KeyboardLockerFocusIntents** (Focus Filter App Intents extension) — imports `Client` and `SystemSurfaces` +- **Core** (Swift Package): `Common` (shared protocol/settings), `Client` (XPC client, wrappers), `Service` (lock engine, Agent), `SystemSurfaces` (presentation-only Widget/Control identifiers and invalidation). `Client`/`Service` both `@_exported import Common`; `SystemSurfaces` is linked only by the containing App and its extensions, never by the Agent or CLI. (Verified on macOS 26: `chronod` ignores reload requests from processes that are not a registered extension container — `Ignoring restricted or unknown extension` — so linking `SystemSurfaces` from `klock` cannot actually refresh widgets.) -Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`. +Bundle IDs: app `io.lzhlovesjyq.keyboardlocker`, agent `…​.agent`, CLI `…​.klock`, WidgetKit extension `…​.widgets`, Focus App Intents extension `…​.focus-intents`. ## Architecture Contract — read first **Before adding or changing any feature (App, CLI, Shortcuts, AppleScript, Widgets, …), read [docs/architecture.md](docs/architecture.md).** It defines the non-negotiable contract: the Agent is the single executor, all other surfaces are thin wrappers, the lock is one global state. When a change conflicts with it, the contract wins. -For task workflows and component details, see [docs/development.md](docs/development.md). +For the process/module boundary and complete XPC call flow, see [docs/xpc.md](docs/xpc.md). For task workflows and component details, see [docs/development.md](docs/development.md). For Shortcuts, AppleScript, CLI, Widget, and Control usage, see [docs/automation.md](docs/automation.md). ## Project-Specific Rules @@ -31,6 +33,10 @@ These override the model's defaults — the rest of Swift style is left to stand ## Build & Format ```bash -xcodebuild -scheme KeyboardLocker -configuration Debug build # or KeyboardLockerAgent / klock / Core -swiftformat . # 2-space indent, alpha-sorted imports; run before committing +xcodebuild -scheme KeyboardLocker -configuration Debug build # or KeyboardLockerAgent / klock (Core has no shared Xcode scheme; use the SwiftPM commands below) +xcrun swift test --package-path Core # xcrun pins the Xcode toolchain; a bare `swift` may resolve to swiftly's older default, which cannot consume the current SDK +xcodebuild -scheme KeyboardLocker -configuration Debug -destination 'platform=macOS' test -only-testing:KeyboardLockerModelTests # app-level coordination tests (fake Client/lifecycle; no live SMAppService/XPC) ``` + +`Core/Package.swift` keeps Swift 5.10 source semantics. Code style is 2-space indent with +alpha-sorted imports. diff --git a/Core/Package.swift b/Core/Package.swift index 6af40a1..301dada 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -11,10 +11,16 @@ let package = Package( .library(name: "Common", targets: ["Common"]), .library(name: "Client", targets: ["Client"]), .library(name: "Service", targets: ["Service"]), + .library(name: "SystemSurfaces", targets: ["SystemSurfaces"]), ], targets: [ .target(name: "Common"), .target(name: "Client", dependencies: ["Common"]), .target(name: "Service", dependencies: ["Common"]), + .target(name: "SystemSurfaces"), + .testTarget(name: "CommonTests", dependencies: ["Common"]), + .testTarget(name: "ClientTests", dependencies: ["Client"]), + .testTarget(name: "ServiceTests", dependencies: ["Service"]), + .testTarget(name: "SystemSurfacesTests", dependencies: ["SystemSurfaces"]), ] ) diff --git a/Core/Sources/Client/IdempotentMutationRetrier.swift b/Core/Sources/Client/IdempotentMutationRetrier.swift new file mode 100644 index 0000000..a30e34b --- /dev/null +++ b/Core/Sources/Client/IdempotentMutationRetrier.swift @@ -0,0 +1,20 @@ +/// Retries a mutation only when its reply is lost — timed out, or dropped with a dying peer — +/// and resets the transport boundary before doing so. Callers use this only for operations +/// whose complete semantic effect is idempotent. +enum IdempotentMutationRetrier { + static func perform( + operation: @escaping @Sendable () async throws -> Void, + resetConnection: @escaping @Sendable () -> Void + ) async throws { + do { + try await operation() + } catch XPCClientError.timedOut, is LostReplyError { + resetConnection() + do { + try await operation() + } catch XPCClientError.timedOut, is LostReplyError { + throw XPCClientError.operationOutcomeUnknown + } + } + } +} diff --git a/Core/Sources/Client/LockStateSubscriber.swift b/Core/Sources/Client/LockStateSubscriber.swift index 7684c2d..7a9ac3f 100644 --- a/Core/Sources/Client/LockStateSubscriber.swift +++ b/Core/Sources/Client/LockStateSubscriber.swift @@ -1,39 +1,293 @@ import Common import Foundation +import os // MARK: - Lock State Subscriber -/// Subscribes to lock state changes via DistributedNotification. +/// Subscribes to lock state changes broadcast by the Agent (`LockStateBroadcaster`). /// -/// Use this in App/CLI processes to receive state changes from the Agent. -/// The Agent broadcasts changes using `LockStateBroadcaster`. +/// Two channels are observed for robustness, and **both converge on the same behavior**: +/// on any signal, fetch the authoritative state from the Agent via `XPCClient.status()` +/// rather than trusting a notification payload (which can be dropped, delivered out of +/// order, or arrive stale). +/// +/// - **Darwin** (`CFNotificationCenterGetDarwinNotifyCenter`): can wake an App-Napped / +/// suspended process, so a menu-bar app still learns of a lock started by the CLI while +/// it was idle. Payload-free by design. +/// - **Distributed**: retained because a pure async command-line tool (klock) does not +/// reliably run a CFRunLoop for Darwin callbacks; the distributed center delivers on the +/// main queue, which the CLI's async main drains. +/// +/// Delivered values are de-duplicated, so the initial calibration is emitted at most once and +/// redundant signals across the two channels surface as at most one handler call per actual +/// state change. public enum LockStateSubscriber { public typealias StateChangeHandler = (Bool) -> Void - /// Subscribes to lock state changes. - /// Returns a token that must be retained; subscription is cancelled when token is deallocated. - public static func subscribe(_ handler: @escaping StateChangeHandler) -> ObserverToken { - let observer = DistributedNotificationCenter.default().addObserver( + private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "LockStateSubscriber") + + /// Subscribes to the authoritative lock state. + /// After observer installation, the handler receives the current state when it differs from + /// `initialState`, then receives de-duplicated changes. Pass a trusted snapshot to avoid + /// re-delivering it; the default `nil` always emits a successful initial calibration. + /// A transiently failed initial calibration is retried on the next signal. + /// Returns a token that must be retained; subscription is cancelled when the token deallocates. + public static func subscribe( + initialState: Bool? = nil, + _ handler: @escaping StateChangeHandler + ) -> ObserverToken { + subscribe( + initialState: initialState, + fetchState: { try await XPCClient.shared.status() }, + handler + ) + } + + static func subscribe( + initialState: Bool? = nil, + fetchState: @escaping StateReconciler.FetchState, + _ handler: @escaping StateChangeHandler + ) -> ObserverToken { + let reconciler = StateReconciler( + initialState: initialState, + fetchState: fetchState, + handler: handler + ) + + let darwin = DarwinObserver(name: NotificationNames.stateChanged) { + reconciler.signal() + } + + let distributed = DistributedNotificationCenter.default().addObserver( forName: Notification.Name(NotificationNames.stateChanged), object: nil, queue: .main - ) { notification in - if let isLocked = notification.userInfo?["isLocked"] as? Bool { - handler(isLocked) - } + ) { _ in + reconciler.signal() } + // Notifications are hints rather than a durable event log. Calibrate only after both + // observers are installed so a state change racing this first fetch schedules a follow-up + // pass instead of falling into a subscribe/snapshot gap. + reconciler.signal() + return ObserverToken { - DistributedNotificationCenter.default().removeObserver(observer) + reconciler.cancel() + darwin.cancel() + DistributedNotificationCenter.default().removeObserver(distributed) + } + } + + /// The authoritative lock state as an `AsyncStream`, beginning with an initial calibration. + /// The subscription lives for the stream's lifetime and is torn down automatically when the + /// consuming task is cancelled. + public static var stateChanges: AsyncStream { + AsyncStream { continuation in + let token = subscribe(initialState: nil) { continuation.yield($0) } + continuation.onTermination = { _ in + // Retain the token until termination, then release to unsubscribe. + _ = token + } + } + } +} + +// MARK: - State Reconciler + +/// On each signal, fetches the Agent's authoritative lock state and forwards it to the handler +/// only when it differs from the last value delivered — collapsing duplicate cross-channel +/// signals into one handler call per real change. +final class StateReconciler: @unchecked Sendable { + typealias FetchState = @Sendable () async throws -> Bool + + private let fetchState: FetchState + private let handler: LockStateSubscriber.StateChangeHandler + private let lock = OSAllocatedUnfairLock() + private var lastDelivered: Bool? + private var hasPendingSignal = false + private var isReconciling = false + private var isCancelled = false + private var workerTask: Task? + + /// Transient XPC failures (e.g. the Agent being relaunched on demand) are retried so a real + /// state change is never dropped just because one fetch raced a reconnect. + private static let fetchAttempts = 3 + private static let retryDelay: Duration = .milliseconds(200) + + init( + initialState: Bool? = nil, + fetchState: @escaping FetchState, + handler: @escaping LockStateSubscriber.StateChangeHandler + ) { + lastDelivered = initialState + self.fetchState = fetchState + self.handler = handler + } + + func signal() { + lock.withLock { + guard !isCancelled else { + return + } + + hasPendingSignal = true + guard !isReconciling else { + return + } + + isReconciling = true + // Create and retain the worker while holding the same lock that protects + // `isReconciling`. The task's first state access blocks on this lock, so a completed + // worker can never overwrite the handle of a newer active worker. + workerTask = Task { [weak self] in + guard let self else { + return + } + await reconcilePendingSignals() + } + } + } + + func cancel() { + let task: Task? = lock.withLock { + guard !isCancelled else { + return nil + } + + isCancelled = true + hasPendingSignal = false + defer { workerTask = nil } + return workerTask + } + task?.cancel() + } + + private func reconcilePendingSignals() async { + while takePendingSignal() { + guard let isLocked = await fetchAuthoritativeState() else { + continue + } + + await MainActor.run { + let shouldDeliver: Bool = self.lock.withLock { + guard !self.isCancelled, + self.lastDelivered != isLocked + else { + return false + } + + self.lastDelivered = isLocked + return true + } + guard shouldDeliver else { + return + } + + self.handler(isLocked) + } + } + } + + /// Atomically claims one pending pass. Clearing the worker flag in the same critical section + /// prevents a signal from being stranded between the worker's final check and its exit. + private func takePendingSignal() -> Bool { + lock.withLock { + guard !isCancelled else { + isReconciling = false + return false + } + + guard hasPendingSignal else { + isReconciling = false + return false + } + + hasPendingSignal = false + return true + } + } + + private func fetchAuthoritativeState() async -> Bool? { + for attempt in 1...Self.fetchAttempts { + guard shouldContinue else { + return nil + } + + do { + let isLocked = try await fetchState() + return shouldContinue ? isLocked : nil + } catch { + guard attempt < Self.fetchAttempts, + shouldContinue + else { + return nil + } + + do { + try await Task.sleep(for: Self.retryDelay) + } catch { + return nil + } + } + } + return nil + } + + private var shouldContinue: Bool { + guard !Task.isCancelled else { + return false + } + + return lock.withLock { + !isCancelled } } } +// MARK: - Darwin Observer + +/// RAII wrapper around a Darwin notification observer. The C callback cannot capture context, +/// so the observing instance is passed through the opaque observer pointer. +private final class DarwinObserver { + private let name: CFNotificationName + private let onNotify: () -> Void + + init(name: String, onNotify: @escaping () -> Void) { + self.name = CFNotificationName(name as CFString) + self.onNotify = onNotify + + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + observer, + { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged.fromOpaque(observer).takeUnretainedValue().onNotify() + }, + self.name.rawValue, + nil, + .deliverImmediately + ) + } + + func cancel() { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + name, + nil + ) + } +} + // MARK: - Observer Token /// Token that controls subscription lifecycle. /// Subscription is automatically cancelled when token is deallocated. -public final class ObserverToken { +/// +/// The token is immutable and its teardown path is thread-safe, so releasing the final reference +/// from an `AsyncStream` termination callback is safe on any executor. +public final class ObserverToken: @unchecked Sendable { private let onDeinit: () -> Void public init(onDeinit: @escaping () -> Void) { diff --git a/Core/Sources/Client/ServiceCompatibility.swift b/Core/Sources/Client/ServiceCompatibility.swift new file mode 100644 index 0000000..4d49735 --- /dev/null +++ b/Core/Sources/Client/ServiceCompatibility.swift @@ -0,0 +1,171 @@ +import Common +import Foundation + +/// Monotonic `CFBundleVersion` value with arbitrary-precision dotted numeric comparison. +public struct ServiceBuildVersion: Comparable, Sendable { + public let rawValue: String + + private let components: [String] + + public init?(_ rawValue: String) { + let sourceComponents = rawValue.split( + separator: ".", + omittingEmptySubsequences: false + ) + guard !sourceComponents.isEmpty else { + return nil + } + + var normalized: [String] = [] + for component in sourceComponents { + guard !component.isEmpty, + component.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) + else { + return nil + } + + let significantDigits = component.drop(while: { $0 == "0" }) + normalized.append(significantDigits.isEmpty ? "0" : String(significantDigits)) + } + + self.rawValue = rawValue + components = normalized + } + + public static func == (lhs: ServiceBuildVersion, rhs: ServiceBuildVersion) -> Bool { + compare(lhs, rhs) == 0 + } + + public static func < (lhs: ServiceBuildVersion, rhs: ServiceBuildVersion) -> Bool { + compare(lhs, rhs) < 0 + } + + private static func compare( + _ lhs: ServiceBuildVersion, + _ rhs: ServiceBuildVersion + ) -> Int { + for index in 0.. + public let agentBundleIdentifier: String + public let agentVersion: String + public let agentBuild: String + + public init( + minimumProtocolVersion: ServiceProtocolVersion = ServiceContract.protocolVersion, + requiredCapabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String, + agentVersion: String, + agentBuild: String + ) { + self.minimumProtocolVersion = minimumProtocolVersion + self.requiredCapabilities = requiredCapabilities + self.agentBundleIdentifier = agentBundleIdentifier + self.agentVersion = agentVersion + self.agentBuild = agentBuild + } +} + +public enum ServiceCompatibilityIssue: Error, Equatable, LocalizedError { + case agentBuildMismatch(expected: String, actual: String) + case agentBundleIdentifierMismatch(expected: String, actual: String) + case agentVersionMismatch(expected: String, actual: String) + case missingCapabilities([ServiceCapability]) + case protocolMajorMismatch(expected: Int, actual: Int) + case protocolMinorTooOld(required: Int, actual: Int) + + public var errorDescription: String? { + switch self { + case let .agentBuildMismatch(expected, actual): + return "The running agent build is \(actual); the bundled build is \(expected)." + + case let .agentBundleIdentifierMismatch(expected, actual): + return "The running agent identifier is \(actual); the bundled identifier is \(expected)." + + case let .agentVersionMismatch(expected, actual): + return "The running agent version is \(actual); the bundled version is \(expected)." + + case let .missingCapabilities(capabilities): + let names = capabilities.map(\.rawValue).sorted().joined(separator: ", ") + return "The running agent is missing required capabilities: \(names)." + + case let .protocolMajorMismatch(expected, actual): + return "The running agent uses XPC protocol major version \(actual); this app requires \(expected)." + + case let .protocolMinorTooOld(required, actual): + return "The running agent uses XPC protocol minor version \(actual); this app requires at least \(required)." + } + } +} + +public extension ServiceDescriptor { + func compatibilityIssues( + against requirements: ServiceCompatibilityRequirements + ) -> [ServiceCompatibilityIssue] { + var issues: [ServiceCompatibilityIssue] = [] + + if protocolVersion.major != requirements.minimumProtocolVersion.major { + issues.append(.protocolMajorMismatch( + expected: requirements.minimumProtocolVersion.major, + actual: protocolVersion.major + )) + } else if protocolVersion.minor < requirements.minimumProtocolVersion.minor { + issues.append(.protocolMinorTooOld( + required: requirements.minimumProtocolVersion.minor, + actual: protocolVersion.minor + )) + } + + let missingCapabilities = requirements.requiredCapabilities.subtracting(capabilities) + if !missingCapabilities.isEmpty { + issues.append(.missingCapabilities(missingCapabilities.sorted { + $0.rawValue < $1.rawValue + })) + } + + if agentBundleIdentifier != requirements.agentBundleIdentifier { + issues.append(.agentBundleIdentifierMismatch( + expected: requirements.agentBundleIdentifier, + actual: agentBundleIdentifier + )) + } + if agentVersion != requirements.agentVersion { + issues.append(.agentVersionMismatch( + expected: requirements.agentVersion, + actual: agentVersion + )) + } + if agentBuild != requirements.agentBuild { + issues.append(.agentBuildMismatch( + expected: requirements.agentBuild, + actual: agentBuild + )) + } + + return issues + } + + func validateCompatibility( + against requirements: ServiceCompatibilityRequirements + ) throws { + if let issue = compatibilityIssues(against: requirements).first { + throw issue + } + } +} diff --git a/Core/Sources/Client/UnlockStatusPoller.swift b/Core/Sources/Client/UnlockStatusPoller.swift new file mode 100644 index 0000000..bfc4b41 --- /dev/null +++ b/Core/Sources/Client/UnlockStatusPoller.swift @@ -0,0 +1,157 @@ +import Foundation + +struct UnlockWaiter: Sendable { + typealias CancelPolling = @Sendable () -> Void + typealias PollUntilUnlocked = @Sendable () async throws -> Void + + private let cancelPolling: CancelPolling + private let pollUntilUnlocked: PollUntilUnlocked + private let stateChanges: AsyncStream + + init( + stateChanges: AsyncStream, + pollUntilUnlocked: @escaping PollUntilUnlocked, + cancelPolling: @escaping CancelPolling + ) { + self.stateChanges = stateChanges + self.pollUntilUnlocked = pollUntilUnlocked + self.cancelPolling = cancelPolling + } + + func waitUntilUnlocked() async throws { + try await withThrowingTaskGroup(of: ObservationResult.self) { group in + group.addTask { + for await isLocked in stateChanges { + if !isLocked { + return .unlocked + } + } + return .observationEnded + } + + group.addTask { + try await withTaskCancellationHandler { + try await pollUntilUnlocked() + } onCancel: { + // XPC reply continuations do not observe Swift task cancellation by themselves. + // Invalidating this client's cached connection makes an in-flight status request + // complete through its proxy error handler instead of delaying group teardown until + // the response timeout. + cancelPolling() + } + return .unlocked + } + + defer { + group.cancelAll() + } + + while let result = try await group.next() { + switch result { + case .unlocked: + return + case .observationEnded: + continue + } + } + + throw XPCClientError.serviceUnavailable + } + } + + private enum ObservationResult: Sendable { + case observationEnded + case unlocked + } +} + +struct UnlockStatusPoller: Sendable { + typealias FetchStatus = @Sendable () async throws -> Bool + typealias ResetConnection = @Sendable () -> Void + typealias Sleep = @Sendable (Duration) async throws -> Void + + private let fetchStatus: FetchStatus + private let maximumConsecutiveFailures: Int + private let pollInterval: Duration + private let resetConnection: ResetConnection + private let sleep: Sleep + + init( + pollInterval: Duration, + maximumConsecutiveFailures: Int, + fetchStatus: @escaping FetchStatus, + resetConnection: @escaping ResetConnection, + sleep: @escaping Sleep + ) { + precondition(maximumConsecutiveFailures > 0) + self.pollInterval = pollInterval + self.maximumConsecutiveFailures = maximumConsecutiveFailures + self.fetchStatus = fetchStatus + self.resetConnection = resetConnection + self.sleep = sleep + } + + func waitUntilUnlocked() async throws { + var consecutiveFailures = 0 + + while true { + try Task.checkCancellation() + + do { + let isLocked = try await fetchStatus() + if !isLocked { + return + } + consecutiveFailures = 0 + } catch is CancellationError { + throw CancellationError() + } catch { + consecutiveFailures += 1 + resetConnection() + if consecutiveFailures >= maximumConsecutiveFailures { + throw error + } + } + + try await sleep(pollInterval) + } + } +} + +extension XPCClient { + /// Waits until the Agent reports that the global keyboard lock has been released. + /// + /// This observer does not own the lock. Notifications provide prompt updates, while periodic + /// authoritative reads recover when notifications are lost or an Agent restart releases the + /// event tap without broadcasting. Persistent query failures are surfaced instead of being + /// misreported as an unlock. + public func waitUntilUnlocked() async throws { + let waiter = UnlockWaiter( + stateChanges: LockStateSubscriber.stateChanges, + pollUntilUnlocked: { [self] in + try await pollUntilAuthoritativeUnlock() + }, + cancelPolling: { [self] in + resetConnection() + } + ) + try await waiter.waitUntilUnlocked() + } + + private func pollUntilAuthoritativeUnlock() async throws { + let poller = UnlockStatusPoller( + pollInterval: .seconds(1), + maximumConsecutiveFailures: 3, + fetchStatus: { [self] in + try await status() + }, + resetConnection: { [self] in + resetConnection() + }, + sleep: { interval in + try await Task.sleep(for: interval) + } + ) + try await poller.waitUntilUnlocked() + } +} diff --git a/Core/Sources/Client/XPCClient.swift b/Core/Sources/Client/XPCClient.swift index ce81e9d..3336d6f 100644 --- a/Core/Sources/Client/XPCClient.swift +++ b/Core/Sources/Client/XPCClient.swift @@ -1,151 +1,655 @@ import Common import Foundation +import os -public enum XPCClientError: Error { +public enum XPCClientError: Error, LocalizedError { + case agentChanged(expected: UUID, actual: UUID) + case missingCapability(ServiceCapability) + case operationOutcomeUnknown + case peerAuthenticationUnavailable(String) case serviceUnavailable + case timedOut + + public var errorDescription: String? { + switch self { + case let .agentChanged(expected, actual): + """ + The KeyboardLocker agent changed after negotiation \ + (expected \(expected.uuidString), found \(actual.uuidString)). + """ + + case let .missingCapability(capability): + "The KeyboardLocker agent does not support \(capability.rawValue)." + + case .operationOutcomeUnknown: + "The KeyboardLocker agent did not confirm the operation. Its final outcome is unknown." + + case let .peerAuthenticationUnavailable(message): + "XPC peer authentication could not be configured. \(message)" + + case .serviceUnavailable: + "The KeyboardLocker agent is not reachable." + + case .timedOut: + "The KeyboardLocker agent did not respond in time." + } + } + + public var recoverySuggestion: String? { + switch self { + case .missingCapability: + "Open KeyboardLocker to update its background agent, then retry." + + case .operationOutcomeUnknown: + "Inspect the current state, then repeat the intended lock or unlock action if needed." + + case .serviceUnavailable: + "Open KeyboardLocker once to register its background agent, then retry. If KeyboardLocker is already running, choose Show Details… from its menu." + + case .timedOut: + "Retry the action. If the problem persists, open KeyboardLocker and choose Show Details… from its menu." + + default: + nil + } + } } -/// XPC client for communicating with the KeyboardLocker agent service. -public enum XPCClient { - // MARK: - One-off Operations - - /// Queries the current lock state from the agent. - public static func status(reply: @escaping (Bool, Error?) -> Void) { - executeOneOff( - onError: { reply(false, $0) }, - operation: { service, invalidate in - service.status { isLocked, error in - reply(isLocked, error) - invalidate() - } +/// The reply to a dispatched request was lost because the peer process died mid-flight +/// (`NSXPCConnectionInterrupted`): the mutation may already have executed. Callers translate +/// this into their ambiguous-outcome path instead of reporting a definite failure. +struct LostReplyError: Error, Equatable {} + +/// How a proxy-level transport failure is reported. Queries see plain unavailability; +/// mutations see a lost reply whose outcome stays explicitly ambiguous. +enum ProxyErrorDisposition { + case unavailable + case lostReply +} + +/// Async client for the KeyboardLocker Agent. +/// +/// Every operation is a **stateless one-off call** to the single global lock owned by the Agent — +/// there is no client-owned "session". State is observed via `LockStateSubscriber`, never inferred +/// from whether a call succeeded. +/// +/// A single connection is reused and lazily recreated after invalidation, so callers survive the +/// Agent being relaunched on demand by `launchd`. +public final class XPCClient: @unchecked Sendable { + public static let shared = XPCClient() + + private static let agentCodeSigningRequirement = Result { + try XPCCodeSigningRequirement.sameTeam( + identifiers: [SharedConstants.agentBundleIdentifier] + ) + } + + private static let responseTimeout: Duration = .seconds(5) + + private let lock = OSAllocatedUnfairLock() + private var connection: NSXPCConnection? + + private init() {} + + // MARK: - Operations + + /// Version and capabilities of the Agent process behind the current connection. + /// + /// Capability-gated methods repeat this negotiation on the exact connection they invoke. + public func serviceDescriptor() async throws -> ServiceDescriptor { + try await serviceDescriptor(using: currentConnection()) + } + + private func serviceDescriptor( + using connection: NSXPCConnection + ) async throws -> ServiceDescriptor { + let data: Data? = try await withProxyReturning(using: connection) { service, resume in + service.serviceDescriptor { resume($0, $1) } + } + return try ServiceDescriptor.decodedFromXPC(data) + } + + public func lock() async throws { + // A general desired-lock can take persistence over from a Focus-created lock even when the + // physical state is already locked. Status cannot prove that this semantic mutation ran, + // so retry the idempotent request and require a reply from a fresh connection. + try await IdempotentMutationRetrier.perform( + operation: { [self] in + try await lockOnce() + }, + resetConnection: { [self] in + resetConnection() } ) } - /// Requests Accessibility permission. - /// - Parameters: - /// - showPrompt: Whether to trigger macOS system prompt - /// - reply: Callback with current permission status - public static func requestAccessibilityPermission(showPrompt: Bool = true, reply: @escaping (Bool) -> Void) { - executeOneOff( - onError: { _ in reply(false) }, - operation: { service, invalidate in - service.requestAccessibilityPermission(showPrompt: showPrompt) { granted in - reply(granted) - invalidate() - } + private func lockOnce() async throws { + try await withProxy(proxyErrorDisposition: .lostReply) { service, resume in + service.lockKeyboard { resume($0) } + } + } + + /// Atomically creates an interactive global lock or reports that one was already active. + /// + /// A newly acquired lock treats Control-C as an additional Agent-side unlock gesture. This + /// outcome does not grant client ownership; it only describes whether this request performed + /// the unlocked-to-locked transition. + public func lockInteractively() async throws -> LockRequestOutcome { + let connection = try await negotiatedConnection( + requiring: [.interactiveLock] + ) + + do { + let didAcquireLock: Bool = try await withProxyReturning( + using: connection, + proxyErrorDisposition: .lostReply + ) { service, resume in + service.lockKeyboardInteractively { resume($0, $1) } } + return didAcquireLock ? .acquired : .alreadyLocked + } catch XPCClientError.timedOut, is LostReplyError { + // Status alone cannot recover whether this request created a lock or observed an existing + // one, so a lost mutation reply must remain explicitly unknown. + throw XPCClientError.operationOutcomeUnknown + } + } + + /// Starts the Agent-owned first-run safety lock. + /// + /// The reply describes whether this request created the lock. A timeout remains unknown because + /// observing `locked` cannot distinguish this request from a concurrent wrapper's acquisition. + public func beginSafetyCheck() async throws -> LockRequestOutcome { + let connection = try await negotiatedConnection( + requiring: [.safetyCheckLock] ) + + do { + let didStart: Bool = try await withProxyReturning( + using: connection, + proxyErrorDisposition: .lostReply + ) { service, resume in + service.beginSafetyCheck { resume($0, $1) } + } + return didStart ? .acquired : .alreadyLocked + } catch XPCClientError.timedOut, is LostReplyError { + throw XPCClientError.operationOutcomeUnknown + } } - /// Queries whether the agent has Accessibility permission. - public static func accessibilityStatus(reply: @escaping (Bool) -> Void) { - executeOneOff( - onError: { _ in reply(false) }, - operation: { service, invalidate in - service.accessibilityStatus { granted in - reply(granted) - invalidate() - } + /// Applies the system Focus Filter state through an ownership-aware Agent operation. + public func setFocusFilterLockEnabled(_ enabled: Bool) async throws { + // Both enable and disable are idempotent, but status alone cannot reveal whether Focus owns + // the current generation. Re-negotiate and require one confirmed reply. + try await IdempotentMutationRetrier.perform( + operation: { [self] in + try await setFocusFilterLockEnabledOnce(enabled) + }, + resetConnection: { [self] in + resetConnection() + } + ) + } + + private func setFocusFilterLockEnabledOnce(_ enabled: Bool) async throws { + let connection = try await negotiatedConnection( + requiring: [.focusFilterLock] + ) + try await withProxy(using: connection, proxyErrorDisposition: .lostReply) { service, resume in + service.setFocusFilterLockEnabled(enabled, reply: resume) + } + } + + public func unlock() async throws { + do { + try await withProxy(proxyErrorDisposition: .lostReply) { service, resume in + service.unlockKeyboard { resume($0) } + } + } catch XPCClientError.timedOut, is LostReplyError { + try await confirmAmbiguousMutation(expectedIsLocked: false) + } + } + + /// Current global lock state. + public func status() async throws -> Bool { + try await withProxyReturning { service, resume in + service.status { isLocked, error in resume(isLocked, error) } + } + } + + /// Atomically flips the global lock inside the Agent and returns the resulting state. + /// + /// Toggle is not idempotent: after a lost reply, re-sending it could flip the state twice, + /// and the current status alone cannot prove whether this call ran. Keep the outcome + /// explicitly unknown instead. + public func toggle() async throws -> Bool { + let connection = try await negotiatedConnection( + requiring: [.lockToggle] + ) + do { + return try await withProxyReturning( + using: connection, + proxyErrorDisposition: .lostReply + ) { service, resume in + service.toggleKeyboard { resume($0, $1) } } + } catch XPCClientError.timedOut, is LostReplyError { + throw XPCClientError.operationOutcomeUnknown + } + } + + /// One authoritative point-in-time snapshot of the global lock and its active settings. + public func lockStatusSnapshot() async throws -> LockStatusSnapshot { + let connection = try await negotiatedConnection( + requiring: [.lockStatusSnapshot] ) + let data: Data? = try await withProxyReturning(using: connection) { service, resume in + service.lockStatusSnapshot { resume($0, $1) } + } + return try LockStatusSnapshot.decodedFromXPC(data) } - /// Force unlock (one-off operation). - public static func unlock(reply: @escaping (Error?) -> Void) { - executeOneOff( - onError: { reply($0) }, - operation: { service, invalidate in - service.unlockKeyboard { error in - reply(error) - invalidate() + /// Enters the Agent's fail-safe replacement drain and returns its ownership ticket. + public func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + let connection = try await negotiatedConnection( + requiring: [.committedReplacementDrain, .prepareForReplacement], + expectedAgentInstanceID: expectedAgentInstanceID + ) + let data: Data? = try await withProxyReturning(using: connection) { service, resume in + service.prepareForReplacement( + unlockIfNeeded: unlockIfNeeded, + expectedAgentInstanceID: expectedAgentInstanceID + ) { + resume($0, $1) + } + } + let ticket = try ServiceReplacementTicket.decodedFromXPC(data) + guard ticket.agentInstanceID == expectedAgentInstanceID else { + try? await cancelReplacementPreparation(ticket: ticket) + throw XPCClientError.agentChanged( + expected: expectedAgentInstanceID, + actual: ticket.agentInstanceID + ) + } + return ticket + } + + /// Releases a replacement preparation before it has been committed. + public func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws { + let connection = try await negotiatedConnection( + requiring: [.prepareForReplacement], + expectedAgentInstanceID: ticket.agentInstanceID + ) + let data = try ticket.encodedForXPC() + try await withProxy(using: connection) { service, resume in + service.cancelReplacementPreparation(ticket: data, reply: resume) + } + } + + /// Makes a prepared drain non-expiring immediately before unregister is submitted. + public func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws { + do { + try await commitReplacementOnce(ticket: ticket) + } catch { + // Commit is idempotent. Re-negotiate once so a lost reply cannot strand a preparation + // merely because the original connection was interrupted. + resetConnection() + do { + try await commitReplacementOnce(ticket: ticket) + } catch let retryError { + if let status = try? await replacementStatus(ticket: ticket), + status.phase == .committed { + return } + throw retryError } + } + } + + private func commitReplacementOnce( + ticket: ServiceReplacementTicket + ) async throws { + let connection = try await negotiatedConnection( + requiring: [.committedReplacementDrain], + expectedAgentInstanceID: ticket.agentInstanceID + ) + let data = try ticket.encodedForXPC() + try await withProxy(using: connection) { service, resume in + service.commitReplacement(ticket: data, reply: resume) + } + } + + public func replacementStatus( + ticket: ServiceReplacementTicket + ) async throws -> ServiceReplacementStatus { + let connection = try await negotiatedConnection( + requiring: [.committedReplacementDrain], + expectedAgentInstanceID: ticket.agentInstanceID ) + let ticketData = try ticket.encodedForXPC() + let statusData: Data? = try await withProxyReturning(using: connection) { service, resume in + service.replacementStatus(ticket: ticketData) { + resume($0, $1) + } + } + return try ServiceReplacementStatus.decodedFromXPC(statusData) } - // MARK: - Session Management + /// Whether the Agent process currently has Accessibility permission. + public func hasAccessibilityPermission() async throws -> Bool { + let connection = try await negotiatedConnection( + requiring: [.accessibilityStatus] + ) + return try await withProxyReturning(using: connection) { service, resume in + service.hasAccessibilityPermission { resume($0, nil) } + } + } - /// Creates a persistent lock session. - public static func startLockSession() -> LockSessionController { - LockSessionController(onStateChange: nil) + /// Requests the system Accessibility prompt from the Agent process. + /// Completion means the request was sent; permission must be queried again later. + public func requestAccessibilityPermission() async throws { + let connection = try await negotiatedConnection( + requiring: [.accessibilityPrompt] + ) + do { + try await withProxy( + using: connection, + proxyErrorDisposition: .lostReply + ) { service, resume in + service.requestAccessibilityPermission(reply: resume) + } + } catch XPCClientError.timedOut, is LostReplyError { + throw XPCClientError.operationOutcomeUnknown + } } - /// Creates a persistent lock session with state change notifications. - public static func startLockSession( - onStateChange: @escaping (Bool) -> Void - ) -> LockSessionController { - LockSessionController(onStateChange: onStateChange) + /// The Agent's authoritative current settings. + public func currentSettings() async throws -> KeyboardLockerSettings { + let connection = try await negotiatedConnection( + requiring: [.currentSettingsWithError] + ) + let data: Data? = try await withProxyReturning(using: connection) { service, resume in + service.currentSettingsWithError { resume($0, $1) } + } + return try KeyboardLockerSettings.decodedFromXPC(data) } - // MARK: - Private + // MARK: - Connection Management - private static func executeOneOff( - onError: @escaping (Error) -> Void, - operation: @escaping (KeyboardLockerServiceProtocol, _ invalidate: @escaping () -> Void) -> Void - ) { - let connection = makeClientConnection() - let service = connection.remoteObjectProxyWithErrorHandler { error in - onError(error) - connection.invalidate() - } as? KeyboardLockerServiceProtocol + /// Invalidates the cached connection so a subsequent call resolves the currently registered + /// Agent instead of reusing a connection to a process being replaced. + public func resetConnection() { + lock.lock() + let candidate = connection + connection = nil + lock.unlock() + candidate?.invalidate() + } - guard let service else { - onError(XPCClientError.serviceUnavailable) - connection.invalidate() + /// Lock mutations are idempotent, so a lost reply — timed out or dropped with a dying peer — + /// can be recovered by checking whether the Agent reached the requested global state through + /// a fresh connection. + private func confirmAmbiguousMutation(expectedIsLocked: Bool) async throws { + if let isLocked = try? await status(), isLocked == expectedIsLocked { return } + throw XPCClientError.operationOutcomeUnknown + } - operation(service) { connection.invalidate() } + /// Returns one negotiated connection generation. The subsequent feature call must use this + /// exact object; transparent reconnection would invalidate the descriptor and capability grant. + private func negotiatedConnection( + requiring capabilities: Set, + expectedAgentInstanceID: UUID? = nil + ) async throws -> NSXPCConnection { + let connection = try currentConnection() + let descriptor = try await serviceDescriptor(using: connection) + + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: capabilities + ) + + if let expectedAgentInstanceID, + descriptor.agentInstanceID != expectedAgentInstanceID { + throw XPCClientError.agentChanged( + expected: expectedAgentInstanceID, + actual: descriptor.agentInstanceID + ) + } + return connection } - private static func makeClientConnection() -> NSXPCConnection { + private func currentConnection() throws -> NSXPCConnection { + let codeSigningRequirement: String + do { + codeSigningRequirement = try Self.agentCodeSigningRequirement.get() + } catch { + throw XPCClientError.peerAuthenticationUnavailable(error.localizedDescription) + } + + lock.lock() + defer { lock.unlock() } + + if let connection { + return connection + } + let connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) - connection.resume() + connection.setCodeSigningRequirement(codeSigningRequirement) + + // Drop the cached connection on teardown so the next call transparently reconnects. + let connectionID = ObjectIdentifier(connection) + let clear: @Sendable () -> Void = { [weak self] in + self?.clearConnection(ifMatching: connectionID) + } + connection.invalidationHandler = clear + connection.interruptionHandler = { [weak self, weak connection] in + guard let connection else { + return + } + + // An interrupted named connection may transparently attach to a different Agent process. + // Invalidate this object so a descriptor/capability grant can never cross process + // generations; the next operation must create a fresh connection and negotiate again. + self?.invalidateConnection(connection) + } + + connection.activate() + self.connection = connection return connection } -} -// MARK: - Lock Session Controller + private func clearConnection(ifMatching candidateID: ObjectIdentifier) { + lock.lock() + if let activeConnection = connection, ObjectIdentifier(activeConnection) == candidateID { + connection = nil + } + lock.unlock() + } -/// Manages a persistent XPC connection for lock/unlock operations. -public final class LockSessionController { - private let connection: NSXPCConnection - private let service: KeyboardLockerServiceProtocol? - private var observerToken: ObserverToken? + private func invalidateConnection(_ candidate: NSXPCConnection) { + clearConnection(ifMatching: ObjectIdentifier(candidate)) + candidate.invalidate() + } - fileprivate init(onStateChange: ((Bool) -> Void)?) { - connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) - connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) - connection.resume() - service = connection.remoteObjectProxyWithErrorHandler { _ in } as? KeyboardLockerServiceProtocol + /// Bridges a reply-based XPC call returning only an optional `Error` into async/throwing form. + /// The continuation is resumed exactly once — whether by the reply, the proxy error handler, + /// or a missing proxy — so a dead Agent throws rather than hanging forever. + private func withProxy( + using connection: NSXPCConnection? = nil, + proxyErrorDisposition: ProxyErrorDisposition = .unavailable, + _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (Error?) -> Void) -> Void + ) async throws { + let _: Void = try await withProxyReturning( + using: connection, + proxyErrorDisposition: proxyErrorDisposition + ) { service, resume in + body(service) { error in + resume((), error) + } + } + } + + /// Central reply bridge. Every operation has a bounded response time and resumes exactly once + /// across reply, proxy-error, missing-proxy, and timeout races. + private func withProxyReturning( + using providedConnection: NSXPCConnection? = nil, + proxyErrorDisposition: ProxyErrorDisposition = .unavailable, + _ body: @escaping (KeyboardLockerServiceProtocol, _ resume: @escaping (T, Error?) -> Void) -> Void + ) async throws -> T { + let connection = if let providedConnection { + providedConnection + } else { + try currentConnection() + } + + return try await withCheckedThrowingContinuation { continuation in + let once = ResumeOnce() + let connectionReference = XPCConnectionReference(connection) + let timeoutTask = Task { [weak self] in + try? await Task.sleep(for: Self.responseTimeout) + guard !Task.isCancelled else { + return + } + once.run { + self?.invalidateConnection(connectionReference.value) + continuation.resume(throwing: XPCClientError.timedOut) + } + } + + let proxy = connectionReference.value.remoteObjectProxyWithErrorHandler { error in + once.run { + timeoutTask.cancel() + continuation.resume( + throwing: Self.normalizedProxyError(error, disposition: proxyErrorDisposition) + ) + } + } - if let onStateChange { - observerToken = LockStateSubscriber.subscribe(onStateChange) + guard let service = proxy as? KeyboardLockerServiceProtocol else { + once.run { + timeoutTask.cancel() + continuation.resume(throwing: XPCClientError.serviceUnavailable) + } + return + } + + body(service) { value, error in + once.run { + timeoutTask.cancel() + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: value) + } + } + } } } - deinit { - connection.invalidate() + /// A proxy error means the request did not reach a callable XPC peer. Keep Foundation's + /// transport details behind the Client boundary so every wrapper can present one actionable + /// service-availability failure. + /// + /// Interruption is the exception: the peer died while the connection was live, so a mutation + /// dispatched just before may already have executed. Mutations ask for `.lostReply` to keep + /// that ambiguity explicit; `NSXPCConnectionInvalid` still reports unavailability because it + /// is also how a never-established connection (Agent not registered) fails. + static func normalizedProxyError( + _ error: Error, + disposition: ProxyErrorDisposition = .unavailable + ) -> Error { + let cocoaError = error as NSError + if disposition == .lostReply, + cocoaError.domain == NSCocoaErrorDomain, + cocoaError.code == CocoaError.xpcConnectionInterrupted.rawValue { + return LostReplyError() + } + return XPCClientError.serviceUnavailable } +} + +/// Validates one optional selector grant before its negotiated connection is used. +/// +/// Capability names are only meaningful within the protocol major that defined them. The minor +/// floor prevents an inconsistent descriptor from granting a selector before its wire shape was +/// introduced, even if it advertises the capability string. +enum XPCFeatureNegotiation { + private static let minimumMinorByCapability: [ServiceCapability: Int] = [ + .accessibilityPrompt: 0, + .accessibilityStatus: 0, + .committedReplacementDrain: 1, + .currentSettings: 1, + .currentSettingsWithError: 2, + .focusFilterLock: 5, + .interactiveLock: 3, + .lockControl: 0, + .lockStatusSnapshot: 4, + .lockToggle: 6, + .prepareForReplacement: 0, + .safetyCheckLock: 7, + ] + + static func validate( + descriptor: ServiceDescriptor, + requiring capabilities: Set + ) throws { + let expectedMajor = ServiceContract.protocolVersion.major + guard descriptor.protocolVersion.major == expectedMajor else { + throw ServiceCompatibilityIssue.protocolMajorMismatch( + expected: expectedMajor, + actual: descriptor.protocolVersion.major + ) + } + + let requiredMinor = capabilities.compactMap { minimumMinorByCapability[$0] }.max() ?? 0 + guard descriptor.protocolVersion.minor >= requiredMinor else { + throw ServiceCompatibilityIssue.protocolMinorTooOld( + required: requiredMinor, + actual: descriptor.protocolVersion.minor + ) + } - public func lock(reply: @escaping (Error?) -> Void) { - withService(reply: reply) { $0.lockKeyboard(reply: reply) } + if let missingCapability = capabilities + .subtracting(descriptor.capabilities) + .sorted(by: { $0.rawValue < $1.rawValue }) + .first { + throw XPCClientError.missingCapability(missingCapability) + } } +} - public func unlock(reply: @escaping (Error?) -> Void) { - withService(reply: reply) { $0.unlockKeyboard(reply: reply) } +/// Foundation documents `NSXPCConnection` as supporting calls from multiple threads. This box +/// makes that external synchronization contract explicit when a connection is shared with the +/// timeout task that can invalidate it. +private final class XPCConnectionReference: @unchecked Sendable { + let value: NSXPCConnection + + init(_ value: NSXPCConnection) { + self.value = value } +} - private func withService( - reply: @escaping (Error?) -> Void, - operation: (KeyboardLockerServiceProtocol) -> Void - ) { - guard let service else { - reply(XPCClientError.serviceUnavailable) - return +/// Guarantees a continuation is resumed at most once across the reply and error-handler races. +private final class ResumeOnce: @unchecked Sendable { + private let lock = OSAllocatedUnfairLock() + private var done = false + + func run(_ block: () -> Void) { + lock.lock() + let shouldRun = !done + done = true + lock.unlock() + if shouldRun { + block() } - operation(service) } } diff --git a/Core/Sources/Common/KeyCodeConverter.swift b/Core/Sources/Common/KeyCodeConverter.swift index 03b21b1..8f6b2c5 100644 --- a/Core/Sources/Common/KeyCodeConverter.swift +++ b/Core/Sources/Common/KeyCodeConverter.swift @@ -12,7 +12,11 @@ public enum KeyCodeConverter { /// - Returns: Complete shortcut string (e.g., "⌥⌘L" or "⌥ ⌘ L") or nil if conversion fails public static func stringFromKeyCode(_ keyCode: CGKeyCode, modifiers: CGEventFlags, separator: String = "") -> String? { let modifierString = modifierSymbols(from: modifiers, separator: separator) - let keyChar = keyCharacter(for: keyCode) + // An unmappable key code must fail the whole conversion instead of surfacing a raw + // "?" glyph, so callers can fall back to verbal copy. + guard let keyChar = keyCharacter(for: keyCode) else { + return nil + } let result: String = if !modifierString.isEmpty, !separator.isEmpty { modifierString + separator + keyChar @@ -51,16 +55,30 @@ public enum KeyCodeConverter { /// Get character representation for a key code /// - Parameter keyCode: CGKeyCode value - /// - Returns: Uppercase character or symbol - private static func keyCharacter(for keyCode: CGKeyCode) -> String { - characterFromKeyboardLayout(keyCode)?.uppercased() ?? "?" + /// - Returns: Uppercase character or symbol, nil when the keyboard layout cannot map it + private static func keyCharacter(for keyCode: CGKeyCode) -> String? { + let character: String? = if Thread.isMainThread { + characterFromKeyboardLayout(keyCode) + } else { + DispatchQueue.main.sync { + characterFromKeyboardLayout(keyCode) + } + } + return character?.uppercased() } - /// Get character from system keyboard layout using UCKeyTranslate + /// Get character from the system keyboard layout using UCKeyTranslate. + /// + /// Reads the ASCII-capable layout rather than the current input source: an active input + /// method (e.g. Pinyin) may carry no Unicode layout data, which would render the hotkey as + /// "?" precisely while the user is typing in a non-Latin context. /// - Parameter keyCode: CGKeyCode value /// - Returns: Character string or nil + /// TIS/TSM APIs abort the process when a UI process calls them concurrently. Keep the complete + /// input-source lookup and translation on the main thread so every wrapper shares one safe + /// process-local serialization boundary. private static func characterFromKeyboardLayout(_ keyCode: CGKeyCode) -> String? { - let source = TISCopyCurrentKeyboardInputSource().takeRetainedValue() + let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource().takeRetainedValue() guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil } diff --git a/Core/Sources/Common/KeyboardLockerSettings.swift b/Core/Sources/Common/KeyboardLockerSettings.swift index b8aee7f..f48b19b 100644 --- a/Core/Sources/Common/KeyboardLockerSettings.swift +++ b/Core/Sources/Common/KeyboardLockerSettings.swift @@ -13,7 +13,9 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { case timed(seconds: TimeInterval) /// Identifiable conformance using self as ID - public var id: Self { self } + public var id: Self { + self + } /// Converts policy to timeout in seconds, nil when disabled public var timeout: TimeInterval? { @@ -24,20 +26,6 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { seconds } } - - private enum PresetTimeouts { - static let short: TimeInterval = 30 - static let medium: TimeInterval = 60 - static let long: TimeInterval = 120 - } - - /// Common presets for UI binding - public static let presets: [AutoUnlockPolicy] = [ - .disabled, - .timed(seconds: PresetTimeouts.short), - .timed(seconds: PresetTimeouts.medium), - .timed(seconds: PresetTimeouts.long), - ] } /// Represents unlock hotkey combination @@ -58,11 +46,6 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { .maskShift, ] - /// Validates hotkey has at least one modifier key - public var isValid: Bool { - modifierFlags.intersection(Self.relevantModifierMask).isEmpty == false - } - /// Checks if event's keyCode and modifiers match this hotkey public func matches(keyCode: CGKeyCode, flags: CGEventFlags) -> Bool { guard keyCode == self.keyCode else { @@ -80,22 +63,18 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable { public var autoUnlockPolicy: AutoUnlockPolicy public var unlockHotkey: Hotkey - public var showsUnlockNotification: Bool public init( autoUnlockPolicy: AutoUnlockPolicy, - showsUnlockNotification: Bool, unlockHotkey: Hotkey ) { self.autoUnlockPolicy = autoUnlockPolicy - self.showsUnlockNotification = showsUnlockNotification self.unlockHotkey = unlockHotkey } /// Default settings for initial launch or reset public static let `default` = KeyboardLockerSettings( autoUnlockPolicy: .timed(seconds: 60), - showsUnlockNotification: true, unlockHotkey: Hotkey( keyCode: SharedConstants.defaultUnlockKeyCode, modifierFlags: [.maskControl, .maskCommand] @@ -126,9 +105,59 @@ extension KeyboardLockerSettings.Hotkey: Codable { // MARK: - Hotkey Display -extension KeyboardLockerSettings.Hotkey { - /// Human-readable representation of the hotkey (e.g., "⌃⌘L") - public var displayString: String { - KeyCodeConverter.stringFromKeyCode(keyCode, modifiers: modifierFlags) ?? "?" +public extension KeyboardLockerSettings.Hotkey { + /// Human-readable representation of the hotkey (e.g., "⌃⌘L"). Falls back to a verbal + /// phrase — grammatical inside "Press … to unlock" sentences — when the key code has no + /// known glyph, so a raw "?" never reaches notification, widget, or CLI copy. + var displayString: String { + KeyCodeConverter.stringFromKeyCode(keyCode, modifiers: modifierFlags) ?? "the configured unlock hotkey" + } +} + +// MARK: - XPC Serialization + +public enum KeyboardLockerSettingsCodingError: Error, Equatable, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned invalid settings." + case .missingPayload: + "The KeyboardLocker agent returned no settings." + case .payloadTooLarge: + "The KeyboardLocker agent returned oversized settings." + } + } +} + +public extension KeyboardLockerSettings { + static let maximumEncodedSize = 16 * 1024 + + /// Encodes settings for transport across the `@objc` XPC boundary as JSON. + func encodedForXPC() throws -> Data { + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw KeyboardLockerSettingsCodingError.payloadTooLarge + } + return data + } + + /// Decodes the Agent's authoritative settings snapshot without inventing a wrapper-side + /// fallback when the transport payload is absent or corrupt. + static func decodedFromXPC(_ data: Data?) throws -> KeyboardLockerSettings { + guard let data else { + throw KeyboardLockerSettingsCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw KeyboardLockerSettingsCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(KeyboardLockerSettings.self, from: data) + } catch { + throw KeyboardLockerSettingsCodingError.invalidPayload + } } } diff --git a/Core/Sources/Common/KeyboardLockerSettingsStore.swift b/Core/Sources/Common/KeyboardLockerSettingsStore.swift deleted file mode 100644 index e43996e..0000000 --- a/Core/Sources/Common/KeyboardLockerSettingsStore.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -/// Persists `KeyboardLockerSettings` to `UserDefaults` -public final class KeyboardLockerSettingsStore { - private let userDefaults: UserDefaults - private let storageKey: String - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - public init( - userDefaults: UserDefaults = .standard, - storageKey: String = "keyboardlocker.settings" - ) { - self.userDefaults = userDefaults - self.storageKey = storageKey - registerDefaultsIfNeeded() - } - - /// Loads settings from storage, returns default settings if none exist - public func load() -> KeyboardLockerSettings { - guard let data = userDefaults.data(forKey: storageKey), - let settings = try? decoder.decode(KeyboardLockerSettings.self, from: data) - else { - return .default - } - return settings - } - - /// Saves settings to storage, throws error if encoding fails - public func save(_ settings: KeyboardLockerSettings) throws { - let data = try encoder.encode(settings) - userDefaults.set(data, forKey: storageKey) - } - - private func registerDefaultsIfNeeded() { - guard userDefaults.object(forKey: storageKey) == nil else { - return - } - if let data = try? encoder.encode(KeyboardLockerSettings.default) { - userDefaults.set(data, forKey: storageKey) - } - } -} diff --git a/Core/Sources/Common/LockStatusSnapshot.swift b/Core/Sources/Common/LockStatusSnapshot.swift new file mode 100644 index 0000000..15f4f85 --- /dev/null +++ b/Core/Sources/Common/LockStatusSnapshot.swift @@ -0,0 +1,102 @@ +import Foundation + +/// One authoritative, point-in-time view of the global keyboard lock. +/// +/// Wrappers may cache this value for presentation, but the Agent remains the source of truth. +/// Format 1 may only gain backward-compatible fields; incompatible semantics require a new XPC +/// capability instead of reinterpreting this payload. +public struct LockStatusSnapshot: Codable, Equatable, Sendable { + public static let currentFormatVersion = 1 + + public let formatVersion: Int + public let capturedAt: Date + public let isLocked: Bool + public let startedAt: Date? + public let autoUnlockTargetDate: Date? + public let settings: KeyboardLockerSettings + + public init( + formatVersion: Int = Self.currentFormatVersion, + capturedAt: Date, + isLocked: Bool, + startedAt: Date?, + autoUnlockTargetDate: Date?, + settings: KeyboardLockerSettings + ) { + self.formatVersion = formatVersion + self.capturedAt = capturedAt + self.isLocked = isLocked + self.startedAt = startedAt + self.autoUnlockTargetDate = autoUnlockTargetDate + self.settings = settings + } + + fileprivate var hasConsistentLockState: Bool { + if isLocked { + return startedAt != nil + } + return startedAt == nil && autoUnlockTargetDate == nil + } +} + +public enum LockStatusSnapshotCodingError: Error, Equatable, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + case unsupportedFormat(Int) + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned an invalid lock status snapshot." + case .missingPayload: + "The KeyboardLocker agent returned no lock status snapshot." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized lock status snapshot." + case let .unsupportedFormat(version): + "The KeyboardLocker agent returned unsupported lock status format \(version)." + } + } +} + +// MARK: - XPC Serialization + +public extension LockStatusSnapshot { + static let maximumEncodedSize = 32 * 1024 + + func encodedForXPC() throws -> Data { + guard formatVersion == Self.currentFormatVersion, hasConsistentLockState else { + throw LockStatusSnapshotCodingError.invalidPayload + } + + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw LockStatusSnapshotCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> LockStatusSnapshot { + guard let data else { + throw LockStatusSnapshotCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw LockStatusSnapshotCodingError.payloadTooLarge + } + + let snapshot: LockStatusSnapshot + do { + snapshot = try JSONDecoder().decode(LockStatusSnapshot.self, from: data) + } catch { + throw LockStatusSnapshotCodingError.invalidPayload + } + + guard snapshot.formatVersion == currentFormatVersion else { + throw LockStatusSnapshotCodingError.unsupportedFormat(snapshot.formatVersion) + } + guard snapshot.hasConsistentLockState else { + throw LockStatusSnapshotCodingError.invalidPayload + } + return snapshot + } +} diff --git a/Core/Sources/Common/ServiceDescriptor.swift b/Core/Sources/Common/ServiceDescriptor.swift new file mode 100644 index 0000000..6252749 --- /dev/null +++ b/Core/Sources/Common/ServiceDescriptor.swift @@ -0,0 +1,329 @@ +import Foundation + +/// Version of the XPC method contract implemented by an Agent. +/// +/// A major version change is breaking. Minor versions may only add selectors or behavior, and +/// clients must still check capabilities before invoking an optional selector. +public struct ServiceProtocolVersion: Codable, Equatable, Hashable, Sendable { + public let major: Int + public let minor: Int + + public init(major: Int, minor: Int) { + self.major = major + self.minor = minor + } +} + +/// Stable string capability carried by `ServiceDescriptor`. +/// +/// This is intentionally not an enum: older clients must preserve and ignore capability names +/// introduced by newer Agents instead of failing to decode the descriptor. +public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static let accessibilityPrompt = Self(rawValue: "accessibility-prompt") + public static let accessibilityStatus = Self(rawValue: "accessibility-status") + public static let currentSettings = Self(rawValue: "current-settings") + public static let currentSettingsWithError = Self( + rawValue: "current-settings-with-error" + ) + public static let committedReplacementDrain = Self( + rawValue: "committed-replacement-drain" + ) + public static let focusFilterLock = Self(rawValue: "focus-filter-lock") + public static let interactiveLock = Self(rawValue: "interactive-lock") + public static let lockControl = Self(rawValue: "lock-control") + public static let lockStatusSnapshot = Self(rawValue: "lock-status-snapshot") + public static let lockToggle = Self(rawValue: "lock-toggle") + public static let prepareForReplacement = Self(rawValue: "prepare-for-replacement") + public static let safetyCheckLock = Self(rawValue: "safety-check-lock") +} + +/// Additive replacement phase exposed for recovery without revealing the ownership ticket. +public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(String.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static let committed = Self(rawValue: "committed") + public static let inactive = Self(rawValue: "inactive") + public static let prepared = Self(rawValue: "prepared") + public static let unknown = Self(rawValue: "unknown") +} + +/// Current bootstrap contract advertised by the bundled Agent. +public enum ServiceContract { + public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 7) + + public static let requiredCapabilities: Set = [ + .accessibilityPrompt, + .accessibilityStatus, + .committedReplacementDrain, + .currentSettings, + .currentSettingsWithError, + .focusFilterLock, + .interactiveLock, + .lockControl, + .lockStatusSnapshot, + .lockToggle, + .prepareForReplacement, + .safetyCheckLock, + ] +} + +/// Process-scoped description of the Agent behind an XPC connection. +/// +/// Version and build metadata are compatibility diagnostics, not peer authentication. The XPC +/// connection's code-signing requirement remains the security boundary. +public struct ServiceDescriptor: Codable, Equatable, Sendable { + public let protocolVersion: ServiceProtocolVersion + public let capabilities: Set + public let agentBundleIdentifier: String + public let agentVersion: String + public let agentBuild: String + public let agentInstanceID: UUID + public let replacementPending: Bool + public let replacementPhase: ServiceReplacementPhase + + public init( + protocolVersion: ServiceProtocolVersion, + capabilities: Set, + agentBundleIdentifier: String, + agentVersion: String, + agentBuild: String, + agentInstanceID: UUID, + replacementPending: Bool = false, + replacementPhase: ServiceReplacementPhase = .inactive + ) { + self.protocolVersion = protocolVersion + self.capabilities = capabilities + self.agentBundleIdentifier = agentBundleIdentifier + self.agentVersion = agentVersion + self.agentBuild = agentBuild + self.agentInstanceID = agentInstanceID + self.replacementPending = replacementPending || replacementPhase != .inactive + self.replacementPhase = replacementPhase + } + + private enum CodingKeys: String, CodingKey { + case agentBuild + case agentBundleIdentifier + case agentInstanceID + case agentVersion + case capabilities + case protocolVersion + case replacementPending + case replacementPhase + } + + /// Bootstrap fields are decoded explicitly so additive fields can default when an older Agent + /// omits them. Existing required keys and their types must remain stable for this Mach service. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = try container.decode(ServiceProtocolVersion.self, forKey: .protocolVersion) + capabilities = try container.decode(Set.self, forKey: .capabilities) + agentBundleIdentifier = try container.decode(String.self, forKey: .agentBundleIdentifier) + agentVersion = try container.decode(String.self, forKey: .agentVersion) + agentBuild = try container.decode(String.self, forKey: .agentBuild) + agentInstanceID = try container.decode(UUID.self, forKey: .agentInstanceID) + let decodedReplacementPending = try container.decodeIfPresent( + Bool.self, + forKey: .replacementPending + ) ?? false + let decodedReplacementPhase = try container.decodeIfPresent( + ServiceReplacementPhase.self, + forKey: .replacementPhase + ) ?? (decodedReplacementPending ? .unknown : .inactive) + replacementPending = decodedReplacementPending || decodedReplacementPhase != .inactive + replacementPhase = decodedReplacementPhase + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(protocolVersion, forKey: .protocolVersion) + try container.encode(capabilities, forKey: .capabilities) + try container.encode(agentBundleIdentifier, forKey: .agentBundleIdentifier) + try container.encode(agentVersion, forKey: .agentVersion) + try container.encode(agentBuild, forKey: .agentBuild) + try container.encode(agentInstanceID, forKey: .agentInstanceID) + try container.encode(replacementPending, forKey: .replacementPending) + try container.encode(replacementPhase, forKey: .replacementPhase) + } +} + +/// Opaque ownership token for a fail-safe Agent replacement barrier. +public struct ServiceReplacementTicket: Codable, Equatable, Sendable { + public let id: UUID + public let agentInstanceID: UUID + + public init(id: UUID, agentInstanceID: UUID) { + self.id = id + self.agentInstanceID = agentInstanceID + } +} + +/// Ticket-specific replacement state used to recover an unknown commit reply. +public struct ServiceReplacementStatus: Codable, Equatable, Sendable { + public let phase: ServiceReplacementPhase + + public init(phase: ServiceReplacementPhase) { + self.phase = phase + } +} + +public enum ServiceDescriptorCodingError: Error, LocalizedError { + case missingPayload + case payloadTooLarge + case invalidPayload + + public var errorDescription: String? { + switch self { + case .missingPayload: + "The KeyboardLocker agent returned no service descriptor." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized service descriptor." + case .invalidPayload: + "The KeyboardLocker agent returned an invalid service descriptor." + } + } +} + +public enum ServiceReplacementTicketCodingError: Error, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned an invalid replacement ticket." + case .missingPayload: + "The KeyboardLocker agent returned no replacement ticket." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized replacement ticket." + } + } +} + +public enum ServiceReplacementStatusCodingError: Error, LocalizedError { + case invalidPayload + case missingPayload + case payloadTooLarge + + public var errorDescription: String? { + switch self { + case .invalidPayload: + "The KeyboardLocker agent returned an invalid replacement status." + case .missingPayload: + "The KeyboardLocker agent returned no replacement status." + case .payloadTooLarge: + "The KeyboardLocker agent returned an oversized replacement status." + } + } +} + +// MARK: - XPC Serialization + +public extension ServiceDescriptor { + static let maximumEncodedSize = 16 * 1024 + + func encodedForXPC() throws -> Data { + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw ServiceDescriptorCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> ServiceDescriptor { + guard let data else { + throw ServiceDescriptorCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw ServiceDescriptorCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(ServiceDescriptor.self, from: data) + } catch { + throw ServiceDescriptorCodingError.invalidPayload + } + } +} + +public extension ServiceReplacementTicket { + static let maximumEncodedSize = 4 * 1024 + + func encodedForXPC() throws -> Data { + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw ServiceReplacementTicketCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> ServiceReplacementTicket { + guard let data else { + throw ServiceReplacementTicketCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw ServiceReplacementTicketCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(ServiceReplacementTicket.self, from: data) + } catch { + throw ServiceReplacementTicketCodingError.invalidPayload + } + } +} + +public extension ServiceReplacementStatus { + static let maximumEncodedSize = 4 * 1024 + + func encodedForXPC() throws -> Data { + let data = try JSONEncoder().encode(self) + guard data.count <= Self.maximumEncodedSize else { + throw ServiceReplacementStatusCodingError.payloadTooLarge + } + return data + } + + static func decodedFromXPC(_ data: Data?) throws -> ServiceReplacementStatus { + guard let data else { + throw ServiceReplacementStatusCodingError.missingPayload + } + guard data.count <= maximumEncodedSize else { + throw ServiceReplacementStatusCodingError.payloadTooLarge + } + do { + return try JSONDecoder().decode(ServiceReplacementStatus.self, from: data) + } catch { + throw ServiceReplacementStatusCodingError.invalidPayload + } + } +} diff --git a/Core/Sources/Common/Shared.swift b/Core/Sources/Common/Shared.swift index c6cd92b..9b48cc9 100644 --- a/Core/Sources/Common/Shared.swift +++ b/Core/Sources/Common/Shared.swift @@ -3,26 +3,48 @@ import Foundation // MARK: - Shared Constants public enum SharedConstants { - /// Mach service name for XPC communication between App/Agent/CLI + /// Mach service name for XPC communication between the Agent and authorized wrappers. public static let machServiceName = "io.lzhlovesjyq.keyboardlocker.agent" + /// Bundle identifier of the menu-bar App, used by `klock` as a Launch Services fallback. + public static let appBundleIdentifier = "io.lzhlovesjyq.keyboardlocker" + + /// Bundle identifier of the launchd-managed Agent bundled with the App. + public static let agentBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.agent" + + private static let commandLineToolBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.klock" + + /// App Intents extension that applies Focus changes while the main App is not running. + private static let focusIntentsExtensionBundleIdentifier = + "io.lzhlovesjyq.keyboardlocker.focus-intents" + + /// One WidgetKit extension process hosts both the status Widget and macOS Control. + private static let widgetKitExtensionBundleIdentifier = "io.lzhlovesjyq.keyboardlocker.widgets" + /// Default unlock key code for 'L' key (⌃⌘L) public static let defaultUnlockKeyCode: UInt16 = 37 - /// Bundle identifiers allowed to talk to the agent's Mach service + /// Fixed fail-safe window used by the first-run safety check. + public static let safetyCheckDuration: TimeInterval = 10 + + /// Exact code-signing identifiers allowed to talk to the Agent's Mach service. public static let authorizedClientBundleIdentifiers: Set = [ - "io.lzhlovesjyq.keyboardlocker", - "io.lzhlovesjyq.keyboardlocker.klock", - "klock", // CLI tool codesign identifier (no Info.plist = short name only) + appBundleIdentifier, + commandLineToolBundleIdentifier, + focusIntentsExtensionBundleIdentifier, + widgetKitExtensionBundleIdentifier, ] +} - // MARK: - CLI Constants - - /// CLI executable name - public static let cliName = "klock" +// MARK: - Lock Requests - /// CLI installation path - public static let cliInstallPath = "/usr/local/bin/klock" +/// Whether one atomic lock request created the global lock or found it already active. +/// +/// This describes a state transition, not client ownership. The Agent still owns one global lock, +/// and any authorized wrapper may explicitly unlock it. +public enum LockRequestOutcome: Equatable, Sendable { + case acquired + case alreadyLocked } // MARK: - Notification Names @@ -30,7 +52,8 @@ public enum SharedConstants { /// Shared notification identifiers for cross-process communication. public enum NotificationNames { /// Notification name for lock state changes. - /// Used by Darwin (lightweight, no payload) and Distributed (with payload) notifications. + /// Posted on both the Darwin and Distributed centers without any payload; the signal is only + /// a hint, and subscribers always re-query the Agent for the authoritative state. public static let stateChanged = "io.lzhlovesjyq.keyboardlocker.state.changed" } @@ -40,14 +63,90 @@ public enum NotificationNames { /// Implemented by Agent, consumed by App/CLI clients. @objc(KeyboardLockerServiceProtocol) public protocol KeyboardLockerServiceProtocol { + // MARK: Bootstrap Methods + + /// Returns a versioned process descriptor before clients invoke capability-gated methods. + func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) + // MARK: Keyboard Locking Methods func lockKeyboard(reply: @escaping (Error?) -> Void) + + /// Requests a lock that treats Control-C as an additional unlock gesture only when this call + /// atomically creates the global lock. `didAcquireLock` is false when the global lock already + /// exists; the request may still take persistence over from a Focus-owned generation. + func lockKeyboardInteractively( + reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void + ) + + /// Atomically starts a fixed-duration lock for first-run recovery verification. The Agent owns + /// the timer, so the lock still releases if the requesting App exits after receiving the reply. + func beginSafetyCheck( + reply: @escaping (_ didStart: Bool, _ error: Error?) -> Void + ) + + /// Applies the Focus Filter's desired state. Disabling releases only a lock generation that + /// the Focus Filter itself created; it never unlocks a pre-existing or subsequently claimed + /// global lock. + func setFocusFilterLockEnabled( + _ enabled: Bool, + reply: @escaping (Error?) -> Void + ) + func unlockKeyboard(reply: @escaping (Error?) -> Void) func status(reply: @escaping (Bool, Error?) -> Void) - // MARK: Accessibility Permission Methods + /// Atomically flips the global lock inside the Agent's serial execution boundary and replies + /// with the resulting state. The lock direction uses plain (non-interactive) lock semantics; + /// the unlock direction is an explicit unlock that releases any generation. + func toggleKeyboard(reply: @escaping (_ isLocked: Bool, _ error: Error?) -> Void) + + /// Returns one authoritative point-in-time lock snapshot as JSON-encoded + /// `LockStatusSnapshot`. This is additive; legacy clients continue using `status`. + func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) + + /// Atomically enters a short-lived fail-safe drain and optionally unlocks before returning its + /// exclusive ownership ticket. + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID, + reply: @escaping (Data?, Error?) -> Void + ) + + /// Commits a prepared drain immediately before Service Management unregister is submitted. + /// A committed drain cannot expire or be cancelled; only old-Agent process exit clears it. + func commitReplacement( + ticket: Data, + reply: @escaping (Error?) -> Void + ) + + /// Returns the phase of the exact replacement ticket without mutating the transaction. + func replacementStatus( + ticket: Data, + reply: @escaping (Data?, Error?) -> Void + ) + + /// Cancels a still-prepared drain before Service Management unregister is submitted. + func cancelReplacementPreparation( + ticket: Data, + reply: @escaping (Error?) -> Void + ) + + // MARK: Accessibility Methods + + /// Returns whether the Agent process currently has Accessibility permission. + func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) + + /// Asks the Agent process to trigger the system Accessibility permission prompt. + /// A successful reply means the prompt request was sent, not that access was granted. + func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) + + // MARK: Settings Methods + + /// Returns the Agent's current settings as JSON-encoded `KeyboardLockerSettings`. + /// Retained for compatibility with protocol 1.1 clients. + func currentSettings(reply: @escaping (Data?) -> Void) - func requestAccessibilityPermission(showPrompt: Bool, reply: @escaping (Bool) -> Void) - func accessibilityStatus(reply: @escaping (Bool) -> Void) + /// Returns the Agent's current settings and reports serialization failures explicitly. + func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) } diff --git a/Core/Sources/Common/SystemSettings.swift b/Core/Sources/Common/SystemSettings.swift deleted file mode 100644 index e339b7e..0000000 --- a/Core/Sources/Common/SystemSettings.swift +++ /dev/null @@ -1,13 +0,0 @@ -import AppKit - -/// Helper for opening System Settings to specific panes. -public enum SystemSettings { - /// Opens System Settings to Accessibility privacy pane. - /// Used when user needs manual guidance to grant permission. - public static func openAccessibilitySettings() { - let urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - if let url = URL(string: urlString) { - NSWorkspace.shared.open(url) - } - } -} diff --git a/Core/Sources/Common/XPCCodeSigningRequirement.swift b/Core/Sources/Common/XPCCodeSigningRequirement.swift new file mode 100644 index 0000000..b8845e6 --- /dev/null +++ b/Core/Sources/Common/XPCCodeSigningRequirement.swift @@ -0,0 +1,141 @@ +import Foundation +import Security + +/// Builds OS-enforced XPC peer requirements from the current process's signing identity. +/// +/// Every KeyboardLocker executable derives the Team ID from its own validated signature. This +/// keeps signing configuration as the source of truth while still requiring an exact peer +/// identifier. Unsigned and ad-hoc processes fail closed because they have no Apple Team ID. +public enum XPCCodeSigningRequirement { + public static func sameTeam( + identifiers: Set + ) throws -> String { + try make( + teamIdentifier: currentProcessTeamIdentifier(), + identifiers: identifiers + ) + } + + static func make( + teamIdentifier: String, + identifiers: Set + ) throws -> String { + guard teamIdentifier.utf8.count == 10, + teamIdentifier.utf8.allSatisfy(isUppercaseLetterOrDigit) + else { + throw XPCCodeSigningRequirementError.invalidTeamIdentifier + } + guard !identifiers.isEmpty else { + throw XPCCodeSigningRequirementError.missingSigningIdentifier + } + + let identifierClauses = try identifiers.sorted().map { identifier in + guard !identifier.isEmpty, + identifier.utf8.allSatisfy(Self.isIdentifierCharacter) + else { + throw XPCCodeSigningRequirementError.invalidSigningIdentifier(identifier) + } + return #"identifier "\#(identifier)""# + } + + let requirement = """ + anchor apple generic and certificate leaf[subject.OU] = "\(teamIdentifier)" and \ + (\(identifierClauses.joined(separator: " or "))) + """ + + var compiledRequirement: SecRequirement? + let status = SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ) + guard status == errSecSuccess, compiledRequirement != nil else { + throw XPCCodeSigningRequirementError.invalidRequirement(status) + } + + return requirement + } + + private static func currentProcessTeamIdentifier() throws -> String { + var code: SecCode? + let copyStatus = SecCodeCopySelf(SecCSFlags(), &code) + guard copyStatus == errSecSuccess, let code else { + throw XPCCodeSigningRequirementError.currentProcessSignatureUnavailable(copyStatus) + } + + let validityStatus = SecCodeCheckValidity(code, SecCSFlags(), nil) + guard validityStatus == errSecSuccess else { + throw XPCCodeSigningRequirementError.currentProcessSignatureInvalid(validityStatus) + } + + var staticCode: SecStaticCode? + let staticCodeStatus = SecCodeCopyStaticCode( + code, + SecCSFlags(), + &staticCode + ) + guard staticCodeStatus == errSecSuccess, let staticCode else { + throw XPCCodeSigningRequirementError.currentProcessSignatureUnavailable(staticCodeStatus) + } + + var information: CFDictionary? + let informationStatus = SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) + guard informationStatus == errSecSuccess, + let dictionary = information as? [CFString: Any], + let teamIdentifier = dictionary[kSecCodeInfoTeamIdentifier] as? String + else { + throw XPCCodeSigningRequirementError.missingTeamIdentifier + } + return teamIdentifier + } + + private static func isUppercaseLetterOrDigit(_ byte: UInt8) -> Bool { + (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) + } + + private static func isIdentifierCharacter(_ byte: UInt8) -> Bool { + isUppercaseLetterOrDigit(byte) + || (byte >= 97 && byte <= 122) + || byte == 45 + || byte == 46 + } +} + +enum XPCCodeSigningRequirementError: Error, LocalizedError { + case currentProcessSignatureInvalid(OSStatus) + case currentProcessSignatureUnavailable(OSStatus) + case invalidRequirement(OSStatus) + case invalidSigningIdentifier(String) + case invalidTeamIdentifier + case missingSigningIdentifier + case missingTeamIdentifier + + var errorDescription: String? { + switch self { + case let .currentProcessSignatureInvalid(status): + "The current process has an invalid code signature (OSStatus \(status))." + + case let .currentProcessSignatureUnavailable(status): + "The current process code signature is unavailable (OSStatus \(status))." + + case let .invalidRequirement(status): + "The XPC code-signing requirement is invalid (OSStatus \(status))." + + case let .invalidSigningIdentifier(identifier): + "The XPC signing identifier is invalid: \(identifier)." + + case .invalidTeamIdentifier: + "The current process has an invalid Apple Team ID." + + case .missingSigningIdentifier: + "The XPC code-signing requirement has no allowed identifier." + + case .missingTeamIdentifier: + "The current process is not signed with an Apple Team ID." + } + } +} diff --git a/Core/Sources/Service/AccessibilityManager.swift b/Core/Sources/Service/AccessibilityManager.swift index c43284f..3814c45 100644 --- a/Core/Sources/Service/AccessibilityManager.swift +++ b/Core/Sources/Service/AccessibilityManager.swift @@ -1,7 +1,7 @@ import AppKit -import ApplicationServices +@preconcurrency import ApplicationServices -/// Manages Accessibility permission for CGEventTap-based keyboard/mouse monitoring +/// Manages Accessibility permission for CGEventTap-based keyboard filtering public final class AccessibilityManager { private init() {} @@ -11,22 +11,12 @@ public final class AccessibilityManager { AXIsProcessTrusted() } - /// Requests Accessibility permission from the user - /// - Parameter showPrompt: Whether to trigger macOS system prompt - /// - Returns: Current permission status after request - @discardableResult - public static func requestPermission(showPrompt: Bool = true) -> Bool { - // Early return if already granted to avoid unnecessary system calls - if hasPermission() { - return true - } - - guard showPrompt else { - return false - } - - // Trigger macOS system prompt that opens Privacy & Security settings - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary - return AXIsProcessTrustedWithOptions(options) + /// Asks macOS to present the Accessibility permission prompt for the Agent process. + /// The prompt is asynchronous, so callers must query `hasPermission()` again later. + public static func requestPermission() { + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true, + ] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) } } diff --git a/Core/Sources/Service/AgentService.swift b/Core/Sources/Service/AgentService.swift new file mode 100644 index 0000000..8c797e0 --- /dev/null +++ b/Core/Sources/Service/AgentService.swift @@ -0,0 +1,534 @@ +import Common +import Foundation + +/// The `LockEngine` surface `AgentService` drives. Seamed so ServiceTests can substitute a +/// deterministic double; production creates one engine owned by the process-wide service. +@MainActor +protocol LockEngineServing { + var isLocked: Bool { get } + var statusSnapshot: LockStatusSnapshot { get } + func lock( + settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool + ) throws -> LockRequestOutcome + func setFocusFilterLockEnabled(_ enabled: Bool, settings: KeyboardLockerSettings) throws + func unlock() + func updateSettings(_ settings: KeyboardLockerSettings) +} + +extension LockEngine: LockEngineServing {} + +/// XPC service implementation. Owns the settings source of truth and drives the single +/// global `LockEngine`. All wrappers reach the lock exclusively through this object. +/// +/// Lives in the `Service` library so the barrier, generation-fence, and error-mapping wiring +/// is unit-testable; the `KeyboardLockerAgent` executable only bootstraps the listener. +public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unchecked Sendable { + private static let replacementPreparationDuration: TimeInterval = 30 + + @MainActor private let descriptorResult: Result + @MainActor private let settings: KeyboardLockerSettings + @MainActor private let engine: any LockEngineServing + @MainActor private var lockStatusNotifier: LockStatusNotifier? + @MainActor private let expirationScheduler: MainActorTimerScheduler + @MainActor private var replacement = ReplacementTransaction() + @MainActor private var replacementPreparationCancellation: (() -> Void)? + + @MainActor + init( + descriptorResult: Result, + settings: KeyboardLockerSettings, + engine: any LockEngineServing, + expirationScheduler: @escaping MainActorTimerScheduler + ) { + self.descriptorResult = descriptorResult + self.settings = settings + self.engine = engine + self.expirationScheduler = expirationScheduler + super.init() + // Seed the engine so a lock uses persisted values. + engine.updateSettings(settings) + } + + @MainActor + override public convenience init() { + let engine = LockEngine(dependencies: .live) + let notifier = LockStatusNotifier( + notifications: LiveLockStatusNotificationService(), + snapshot: { engine.statusSnapshot }, + unlock: { engine.unlock() } + ) + engine.setStateChangeHandler { [weak notifier] in + notifier?.lockStateDidChange() + } + + self.init( + descriptorResult: Result { try Self.makeServiceDescriptor() }, + settings: KeyboardLockerSettingsStore().load(), + engine: engine, + expirationScheduler: liveMainActorTimerScheduler + ) + lockStatusNotifier = notifier + notifier.start() + } + + // MARK: - Bootstrap + + public func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + switch self.descriptorResult { + case let .success(base): + let descriptor = ServiceDescriptor( + protocolVersion: base.protocolVersion, + capabilities: base.capabilities, + agentBundleIdentifier: base.agentBundleIdentifier, + agentVersion: base.agentVersion, + agentBuild: base.agentBuild, + agentInstanceID: base.agentInstanceID, + replacementPending: self.replacement.isPending, + replacementPhase: self.replacement.servicePhase + ) + do { + let data = try descriptor.encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + + case let .failure(error): + reply(nil, error) + } + } + } + + // MARK: - Locking + + public func lockKeyboard(reply: @escaping (Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + do { + _ = try self.performLock(allowsControlCUnlock: false) + reply(nil) + } catch { + reply(error) + } + } + } + + public func lockKeyboardInteractively( + reply: @escaping (Bool, Error?) -> Void + ) { + executeOnMainActor(reply: reply) { reply in + do { + let outcome = try self.performLock(allowsControlCUnlock: true) + reply(outcome == .acquired, nil) + } catch { + reply(false, error) + } + } + } + + public func beginSafetyCheck( + reply: @escaping (Bool, Error?) -> Void + ) { + executeOnMainActor(reply: reply) { reply in + do { + try self.ensureAcceptingLockRequests() + var safetySettings = self.settings + safetySettings.autoUnlockPolicy = .timed( + seconds: SharedConstants.safetyCheckDuration + ) + let outcome = try self.engine.lock( + settings: safetySettings, + allowsControlCUnlock: false + ) + reply(outcome == .acquired, nil) + } catch { + reply(false, error) + } + } + } + + public func setFocusFilterLockEnabled( + _ enabled: Bool, + reply: @escaping (Error?) -> Void + ) { + executeOnMainActor(reply: reply) { reply in + do { + if enabled { + try self.ensureAcceptingLockRequests() + } + try self.engine.setFocusFilterLockEnabled( + enabled, + settings: self.settings + ) + reply(nil) + } catch { + reply(error) + } + } + } + + public func unlockKeyboard(reply: @escaping (Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + self.engine.unlock() + reply(nil) + } + } + + public func status(reply: @escaping (Bool, Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + reply(self.engine.isLocked, nil) + } + } + + public func toggleKeyboard(reply: @escaping (Bool, Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + do { + try reply(self.performToggle(), nil) + } catch { + reply(false, error) + } + } + } + + public func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + do { + let data = try self.engine.statusSnapshot.encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + } + } + + public func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID, + reply: @escaping (Data?, Error?) -> Void + ) { + executeOnMainActor(reply: reply) { reply in + guard case let .success(descriptor) = self.descriptorResult else { + reply(nil, Self.replacementError( + code: 2, + description: "The agent could not create a replacement ticket." + )) + return + } + + guard descriptor.agentInstanceID == expectedAgentInstanceID else { + reply(nil, Self.replacementError( + code: 8, + description: "The running agent changed before replacement could be prepared." + )) + return + } + + guard unlockIfNeeded || !self.engine.isLocked else { + reply(nil, Self.replacementError( + code: 3, + description: "The keyboard must be unlocked before the agent can be replaced." + )) + return + } + + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let data: Data + do { + data = try ticket.encodedForXPC() + } catch { + reply(nil, error) + return + } + + let preparation: ReplacementTransaction.Preparation + do { + preparation = try self.replacement.prepare(ticket: ticket) + } catch { + reply(nil, Self.replacementError(error)) + return + } + + // Install the barrier before unlocking so no queued client can re-lock in between. + self.scheduleExpiration(for: preparation) + if unlockIfNeeded { + self.engine.unlock() + } + reply(data, nil) + } + } + + public func commitReplacement( + ticket data: Data, + reply: @escaping (Error?) -> Void + ) { + let transferredReply = TransferredXPCReply(reply) + let candidate: ServiceReplacementTicket + do { + candidate = try ServiceReplacementTicket.decodedFromXPC(data) + } catch { + transferredReply.body(error) + return + } + + executeOnMainActor(reply: transferredReply) { reply in + do { + try self.replacement.commit(ticket: candidate) + self.replacementPreparationCancellation?() + self.replacementPreparationCancellation = nil + reply(nil) + } catch { + reply(Self.replacementError(error)) + } + } + } + + public func replacementStatus( + ticket data: Data, + reply: @escaping (Data?, Error?) -> Void + ) { + let transferredReply = TransferredXPCReply(reply) + let candidate: ServiceReplacementTicket + do { + candidate = try ServiceReplacementTicket.decodedFromXPC(data) + } catch { + transferredReply.body(nil, error) + return + } + + executeOnMainActor(reply: transferredReply) { reply in + do { + let data = try self.replacement.status(for: candidate).encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + } + } + + public func cancelReplacementPreparation( + ticket data: Data, + reply: @escaping (Error?) -> Void + ) { + let transferredReply = TransferredXPCReply(reply) + let candidate: ServiceReplacementTicket + do { + candidate = try ServiceReplacementTicket.decodedFromXPC(data) + } catch { + transferredReply.body(error) + return + } + + executeOnMainActor(reply: transferredReply) { reply in + do { + try self.replacement.cancel(ticket: candidate) + self.replacementPreparationCancellation?() + self.replacementPreparationCancellation = nil + reply(nil) + } catch { + reply(Self.replacementError(error)) + } + } + } + + // MARK: - Accessibility + + public func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) { + executeOnMainActor(reply: reply) { reply in + reply(AccessibilityManager.hasPermission()) + } + } + + public func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + AccessibilityManager.requestPermission() + reply(nil) + } + } + + // MARK: - Settings + + public func currentSettings(reply: @escaping (Data?) -> Void) { + executeOnMainActor(reply: reply) { reply in + reply(try? self.settings.encodedForXPC()) + } + } + + public func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) { + executeOnMainActor(reply: reply) { reply in + do { + let data = try self.settings.encodedForXPC() + reply(data, nil) + } catch { + reply(nil, error) + } + } + } + + // MARK: - Helpers + + @MainActor + private func performLock( + allowsControlCUnlock: Bool + ) throws -> LockRequestOutcome { + try ensureAcceptingLockRequests() + + return try engine.lock( + settings: settings, + allowsControlCUnlock: allowsControlCUnlock + ) + } + + /// The flip is atomic because the state read and the mutation run in one MainActor turn; no + /// other client call can interleave and translate the request into the wrong direction. + @MainActor + private func performToggle() throws -> Bool { + if engine.isLocked { + // Symmetric with an explicit unlock: releases any generation, including a Focus-owned one. + engine.unlock() + return false + } + + return try performLock(allowsControlCUnlock: false) == .acquired + } + + @MainActor + private func ensureAcceptingLockRequests() throws { + guard !replacement.isPending else { + throw Self.replacementError( + code: 1, + description: "The agent is preparing to be replaced and is not accepting new lock requests." + ) + } + } + + private static func makeServiceDescriptor(bundle: Bundle = .main) throws -> ServiceDescriptor { + guard let bundleIdentifier = bundle.bundleIdentifier, !bundleIdentifier.isEmpty else { + throw AgentDescriptorError.missingMetadata("CFBundleIdentifier") + } + guard let version = bundle.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String, !version.isEmpty else { + throw AgentDescriptorError.missingMetadata("CFBundleShortVersionString") + } + guard let build = bundle.object( + forInfoDictionaryKey: "CFBundleVersion" + ) as? String, !build.isEmpty else { + throw AgentDescriptorError.missingMetadata("CFBundleVersion") + } + + return ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: bundleIdentifier, + agentVersion: version, + agentBuild: build, + agentInstanceID: UUID() + ) + } + + private static func replacementError(code: Int, description: String) -> NSError { + NSError( + domain: "\(SharedConstants.agentBundleIdentifier).replacement", + code: code, + userInfo: [NSLocalizedDescriptionKey: description] + ) + } + + private static func replacementError(_ error: Error) -> NSError { + let code = switch error as? ReplacementTransactionError { + case .alreadyInProgress: + 5 + case .committedCannotCancel: + 6 + case .ticketInactive: + 4 + case nil: + 7 + } + return replacementError(code: code, description: error.localizedDescription) + } + + @MainActor + private func scheduleExpiration( + for preparation: ReplacementTransaction.Preparation + ) { + replacementPreparationCancellation?() + + replacementPreparationCancellation = expirationScheduler( + Self.replacementPreparationDuration + ) { [weak self] in + guard let self else { + return + } + if replacement.expire(preparation: preparation) { + replacementPreparationCancellation = nil + } + } + } + + private func executeOnMainActor( + reply: Reply, + _ operation: @escaping @MainActor @Sendable (Reply) -> Void + ) { + executeOnMainActor(reply: TransferredXPCReply(reply), operation) + } + + private func executeOnMainActor( + reply: TransferredXPCReply, + _ operation: @escaping @MainActor @Sendable (Reply) -> Void + ) { + let operation = MainActorOperation { + operation(reply.body) + } + if Thread.isMainThread { + MainActor.assumeIsolated { + operation() + } + } else { + DispatchQueue.main.async { + MainActor.assumeIsolated { + operation() + } + } + } + } +} + +/// Immutable transfer object used to move an Objective-C XPC reply operation onto `MainActor`. +private final class MainActorOperation: @unchecked Sendable { + private let body: @MainActor @Sendable () -> Void + + init(_ body: @escaping @MainActor @Sendable () -> Void) { + self.body = body + } + + @MainActor + func callAsFunction() { + body() + } +} + +/// Objective-C XPC reply blocks are not imported as `Sendable`, even though the connection owns +/// their lifetime and supports invocation from the Agent's serialized main-actor boundary. +private struct TransferredXPCReply: @unchecked Sendable { + let body: Reply + + init(_ body: Reply) { + self.body = body + } +} + +private enum AgentDescriptorError: Error, LocalizedError { + case missingMetadata(String) + + var errorDescription: String? { + switch self { + case let .missingMetadata(key): + "The Agent bundle is missing required metadata: \(key)." + } + } +} diff --git a/Core/Sources/Service/KeyboardLockerSettingsStore.swift b/Core/Sources/Service/KeyboardLockerSettingsStore.swift new file mode 100644 index 0000000..10f352c --- /dev/null +++ b/Core/Sources/Service/KeyboardLockerSettingsStore.swift @@ -0,0 +1,55 @@ +import Foundation +import os + +/// Persists `KeyboardLockerSettings` to `UserDefaults` +final class KeyboardLockerSettingsStore { + private static let logger = Logger( + subsystem: SharedConstants.machServiceName, + category: "SettingsStore" + ) + + private let userDefaults: UserDefaults + private let storageKey: String + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init( + userDefaults: UserDefaults = .standard, + storageKey: String = "keyboardlocker.settings" + ) { + self.userDefaults = userDefaults + self.storageKey = storageKey + registerDefaultsIfNeeded() + } + + /// Loads settings from storage, returning default settings when none exist. + /// + /// A present-but-undecodable payload means the stored bytes no longer match the schema. + /// The Agent stays functional by falling back to defaults, but the corruption is always + /// logged instead of being silently swallowed. + func load() -> KeyboardLockerSettings { + guard let data = userDefaults.data(forKey: storageKey) else { + return .default + } + do { + return try decoder.decode(KeyboardLockerSettings.self, from: data) + } catch { + Self.logger.error( + "Stored settings are undecodable; falling back to defaults: \(error.localizedDescription)" + ) + return .default + } + } + + private func registerDefaultsIfNeeded() { + guard userDefaults.object(forKey: storageKey) == nil else { + return + } + do { + let data = try encoder.encode(KeyboardLockerSettings.default) + userDefaults.set(data, forKey: storageKey) + } catch { + Self.logger.error("Could not encode default settings: \(error.localizedDescription)") + } + } +} diff --git a/Core/Sources/Service/LockEngine.swift b/Core/Sources/Service/LockEngine.swift index 93460ca..30076a4 100644 --- a/Core/Sources/Service/LockEngine.swift +++ b/Core/Sources/Service/LockEngine.swift @@ -1,12 +1,305 @@ import AppKit +import Carbon import Common -import CoreGraphics +@preconcurrency import CoreGraphics import Foundation +import IOKit import os -// Use refcon to bridge C callback to Swift instance since CGEventTap requires C function pointer +struct AutoUnlockSchedule: Equatable { + let deadline: Date + let delay: TimeInterval + + static func make( + timeout: TimeInterval?, + referenceDate: Date, + currentDate: Date + ) -> Self? { + guard let timeout, + timeout.isFinite, + timeout > 0, + referenceDate.timeIntervalSinceReferenceDate.isFinite, + currentDate.timeIntervalSinceReferenceDate.isFinite + else { + return nil + } + + let deadline = referenceDate.addingTimeInterval(timeout) + guard deadline.timeIntervalSinceReferenceDate.isFinite else { + return nil + } + + return Self( + deadline: deadline, + delay: max(0, deadline.timeIntervalSince(currentDate)) + ) + } +} + +enum LockRequestSource { + case focusFilter + case general +} + +struct LockRuntimeState: Equatable { + private(set) var activeSettings: KeyboardLockerSettings = .default + private(set) var allowsControlCUnlock = false + private(set) var autoUnlockTargetDate: Date? + private(set) var focusOwnedLockGeneration: UInt64? + private(set) var isLocked = false + private(set) var lockGeneration: UInt64? + private(set) var startedAt: Date? + + private var nextLockGeneration: UInt64 = 0 + + mutating func begin( + settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool, + at date: Date + ) -> LockRequestOutcome { + guard !isLocked else { + return .alreadyLocked + } + + activeSettings = settings + self.allowsControlCUnlock = allowsControlCUnlock + autoUnlockTargetDate = nil + focusOwnedLockGeneration = nil + isLocked = true + nextLockGeneration &+= 1 + lockGeneration = nextLockGeneration + startedAt = date + return .acquired + } + + mutating func markCurrentLockAsFocusOwned() { + focusOwnedLockGeneration = lockGeneration + } + + /// Handles a request that arrives while the physical lock already exists. Focus observes an + /// existing lock without claiming it; an explicit general request takes persistence over from + /// a Focus-created generation without changing the physical runtime state. + mutating func handleDuplicateLockRequest( + from source: LockRequestSource + ) -> LockRequestOutcome? { + guard isLocked else { + return nil + } + + if source == .general { + takeOverFocusOwnedLock() + } + return .alreadyLocked + } + + /// Converts a Focus-created lock into an ordinary global desired lock without changing any + /// physical runtime state. A later Focus deactivation must no longer release it. + mutating func takeOverFocusOwnedLock() { + focusOwnedLockGeneration = nil + } + + /// The generation to release on Focus deactivation, or nil. The `== lockGeneration` identity + /// check is the reason ownership is a generation and not a bool: a late Focus-off event may + /// arrive after arbitrary unlock/relock cycles, and it must release only the exact lock Focus + /// created — never a newer, unrelated global lock. + var focusOwnedGenerationForRelease: UInt64? { + guard isLocked, + focusOwnedLockGeneration == lockGeneration + else { + return nil + } + return focusOwnedLockGeneration + } + + func matchesCurrentLockGeneration(_ generation: UInt64) -> Bool { + isLocked && lockGeneration == generation + } + + mutating func updateSettings(_ settings: KeyboardLockerSettings) { + activeSettings = settings + } + + mutating func setAutoUnlockTargetDate(_ date: Date?) { + autoUnlockTargetDate = date + } + + mutating func end() { + allowsControlCUnlock = false + autoUnlockTargetDate = nil + focusOwnedLockGeneration = nil + isLocked = false + lockGeneration = nil + startedAt = nil + } + + func statusSnapshot(capturedAt: Date) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: isLocked, + startedAt: startedAt, + autoUnlockTargetDate: autoUnlockTargetDate, + settings: activeSettings + ) + } +} + +enum UnlockGestureMatcher { + static let controlCKeyCode = CGKeyCode(kVK_ANSI_C) + + private static let controlCHotkey = KeyboardLockerSettings.Hotkey( + keyCode: controlCKeyCode, + modifierFlags: [.maskControl] + ) + + static func matches( + type: CGEventType, + keyCode: CGKeyCode, + flags: CGEventFlags, + isAutoRepeat: Bool, + configuredHotkey: KeyboardLockerSettings.Hotkey, + allowsControlCUnlock: Bool + ) -> Bool { + guard type == .keyDown, !isAutoRepeat else { + return false + } + + if configuredHotkey.matches(keyCode: keyCode, flags: flags) { + return true + } + + return allowsControlCUnlock + && controlCHotkey.matches(keyCode: keyCode, flags: flags) + } +} + +/// System-defined events share a channel with auxiliary mouse buttons. Classify them explicitly so +/// keyboard controls are consumed without broadening the lock to pointer input. +enum LockedKeyboardEventPolicy { + static let systemDefinedEventType: CGEventType = { + guard let type = CGEventType(rawValue: UInt32(NX_SYSDEFINED)) else { + preconditionFailure("NX_SYSDEFINED must map to a CGEventType") + } + return type + }() + + private static let suppressibleSystemDefinedSubtypes: Set = [ + Int16(NX_SUBTYPE_AUX_CONTROL_BUTTONS), + Int16(NX_SUBTYPE_EJECT_KEY), + Int16(NX_SUBTYPE_POWER_KEY), + ] + + static func shouldSuppress( + type: CGEventType, + systemDefinedSubtype: Int16? = nil + ) -> Bool { + switch type { + case .flagsChanged, .keyDown, .keyUp: + return true + + case systemDefinedEventType: + guard let systemDefinedSubtype else { + return false + } + return suppressibleSystemDefinedSubtypes.contains(systemDefinedSubtype) + + default: + return false + } + } + + static func shouldSuppress(type: CGEventType, event: CGEvent) -> Bool { + let systemDefinedSubtype = type == systemDefinedEventType + ? NSEvent(cgEvent: event)?.subtype.rawValue + : nil + return shouldSuppress(type: type, systemDefinedSubtype: systemDefinedSubtype) + } +} + +enum EventTapInstallationError: Error, Equatable { + case eventTapCreationFailed + case eventTapEnableFailed + case runLoopSourceCreationFailed +} + +/// Acquires an event tap and its run-loop source as one transaction. Any incomplete installation +/// is rolled back in reverse order, so callers never retain a half-installed input filter. +struct EventTapInstallation { + let tap: Tap + let source: Source + + static func make( + createTap: () -> Tap?, + createSource: (Tap) -> Source?, + attachSource: (Source) -> Void, + enableTap: (Tap) -> Void, + isTapEnabled: (Tap) -> Bool, + detachSource: (Source) -> Void, + invalidateTap: (Tap) -> Void + ) throws -> Self { + guard let tap = createTap() else { + throw EventTapInstallationError.eventTapCreationFailed + } + + var source: Source? + var committed = false + defer { + if !committed { + if let source { + detachSource(source) + } + invalidateTap(tap) + } + } + + guard let createdSource = createSource(tap) else { + throw EventTapInstallationError.runLoopSourceCreationFailed + } + source = createdSource + attachSource(createdSource) + enableTap(tap) + + guard isTapEnabled(tap) else { + throw EventTapInstallationError.eventTapEnableFailed + } + + committed = true + return Self(tap: tap, source: createdSource) + } +} + +/// The installed-tap operations `LockEngine` relies on. The live implementation wraps the real +/// `CFMachPort` tap and its run-loop source; tests substitute a fake to drive the tap-disable +/// and teardown paths deterministically. +protocol InstalledEventTap: AnyObject { + var isEnabled: Bool { get } + func setEnabled(_ enabled: Bool) + /// Disables the tap, detaches its run-loop source, and invalidates the port. + func teardown() +} + +/// Every side effect `LockEngine` performs on the outside world, injected so `ServiceTests` +/// can drive the engine without Quartz, TCC, real timers, or system notifications. +struct LockEngineDependencies { + var hasAccessibilityPermission: @MainActor () -> Bool + var installEventTap: @MainActor (LockEngine) throws -> any InstalledEventTap + var scheduleTimer: MainActorTimerScheduler + var broadcastStateChange: () -> Void + var now: () -> Date + + @MainActor static var live: Self { + Self( + hasAccessibilityPermission: { AccessibilityManager.hasPermission() }, + installEventTap: { engine in try CoreGraphicsEventTap.install(engine: engine) }, + scheduleTimer: liveMainActorTimerScheduler, + broadcastStateChange: { LockStateBroadcaster.broadcast() }, + now: { Date() } + ) + } +} + +/// Use refcon to bridge C callback to Swift instance since CGEventTap requires C function pointer private func eventTapCallback( - proxy: CGEventTapProxy, + proxy _: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer? @@ -16,192 +309,285 @@ private func eventTapCallback( } let engine = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + // The callback runs on the main run loop where the tap is installed. Make that runtime + // guarantee explicit so every event-tap resource remains isolated to `MainActor`. + return MainActor.assumeIsolated { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + return engine.handleDisabledEvent(event) + } - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - print("Event tap disabled by system, attemping to re-enable...") + return engine.handleEvent(type: type, event: event) + } +} - if let eventTap = engine.eventTap { - CGEvent.tapEnable(tap: eventTap, enable: true) - } +/// Live `InstalledEventTap` backed by a Quartz session event tap on the main run loop. +private final class CoreGraphicsEventTap: InstalledEventTap { + private static let runLoopSourceOrder: CFIndex = 0 - return Unmanaged.passUnretained(event) + private let tap: CFMachPort + private let source: CFRunLoopSource + + private init(installation: EventTapInstallation) { + tap = installation.tap + source = installation.source + } + + var isEnabled: Bool { + CGEvent.tapIsEnabled(tap: tap) } - return engine.handleEvent(proxy: proxy, type: type, event: event) + func setEnabled(_ enabled: Bool) { + CGEvent.tapEnable(tap: tap, enable: enabled) + } + + func teardown() { + CGEvent.tapEnable(tap: tap, enable: false) + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + CFMachPortInvalidate(tap) + } + + /// Acquires the tap and its run-loop source as one transaction, surfacing engine errors. + static func install(engine: LockEngine) throws -> CoreGraphicsEventTap { + let installation: EventTapInstallation + do { + installation = try EventTapInstallation.make( + createTap: { + CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: LockEngine.eventMasks, + callback: eventTapCallback, + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(engine).toOpaque()) + ) + }, + createSource: { + CFMachPortCreateRunLoopSource( + kCFAllocatorDefault, + $0, + runLoopSourceOrder + ) + }, + attachSource: { + CFRunLoopAddSource(CFRunLoopGetMain(), $0, .commonModes) + }, + enableTap: { + CGEvent.tapEnable(tap: $0, enable: true) + }, + isTapEnabled: { + CGEvent.tapIsEnabled(tap: $0) + }, + detachSource: { + CFRunLoopRemoveSource(CFRunLoopGetMain(), $0, .commonModes) + }, + invalidateTap: CFMachPortInvalidate + ) + } catch EventTapInstallationError.eventTapCreationFailed { + throw LockEngine.LockEngineError.eventTapCreationFailed + } catch EventTapInstallationError.runLoopSourceCreationFailed { + throw LockEngine.LockEngineError.runLoopSourceCreationFailed + } catch EventTapInstallationError.eventTapEnableFailed { + throw LockEngine.LockEngineError.eventTapEnableFailed + } + + return CoreGraphicsEventTap(installation: installation) + } } -public class LockEngine { - public static let shared = LockEngine() +@MainActor +final class LockEngine { + static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "LockEngine") - public enum LockEngineError: Error, LocalizedError { + enum LockEngineError: Error, LocalizedError { case accessibilityPermissionDenied case eventTapCreationFailed + case eventTapEnableFailed case runLoopSourceCreationFailed - case alreadyLocked - public var errorDescription: String? { + var errorDescription: String? { switch self { case .accessibilityPermissionDenied: - "Accessibility permission is required to lock keyboard and mouse input." + "Accessibility permission is required to lock keyboard input." case .eventTapCreationFailed: "Failed to create event tap. This may indicate a permissions issue or system restriction." + case .eventTapEnableFailed: + "The keyboard event tap was created but could not be enabled." case .runLoopSourceCreationFailed: "Failed to create run loop source for event tap." - case .alreadyLocked: - "The keyboard is already locked by another session." } } - public var recoverySuggestion: String? { + var recoverySuggestion: String? { switch self { case .accessibilityPermissionDenied: - "Open System Settings → Privacy & Security → Accessibility and enable access for this application." + "Open System Settings → Privacy & Security → Accessibility and enable access for the KeyboardLocker agent." case .eventTapCreationFailed: - "Try restarting the application. If the problem persists, check Accessibility permissions in System Settings." + "Try restarting KeyboardLocker. If the problem persists, check Accessibility permissions for the KeyboardLocker agent in System Settings → Privacy & Security → Accessibility." + case .eventTapEnableFailed: + "Check Accessibility permissions for the KeyboardLocker agent in System Settings → Privacy & Security → Accessibility, then try locking again." case .runLoopSourceCreationFailed: "This is a system-level error. Please contact support if it persists." - case .alreadyLocked: - "Wait for the current lock to be released or expire." } } } - static let eventMasks: CGEventMask = + /// Suppress standard keys and keyboard-originated system controls without observing pointer input. + nonisolated static let eventMasks: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue) | - (1 << CGEventType.otherMouseDown.rawValue) | - (1 << CGEventType.otherMouseUp.rawValue) + (1 << LockedKeyboardEventPolicy.systemDefinedEventType.rawValue) - private static let runLoopSourceOrder: CFIndex = 0 private static let autoRepeatFlagValue: Int64 = 1 - // Unfair lock for thread-safe state access (higher performance than DispatchQueue) - private let stateLock = OSAllocatedUnfairLock() - - // fileprivate access required for C callback to re-enable tap on system timeout - fileprivate var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - private var autoUnlockTimer: DispatchSourceTimer? + private let dependencies: LockEngineDependencies + private var installedTap: (any InstalledEventTap)? + private var eventTapGeneration: UInt64 = 0 + private var cancelScheduledAutoUnlock: (() -> Void)? + private var runtimeState = LockRuntimeState() + private var stateChangeHandler: () -> Void = {} - // Private state storage protected by stateLock - private var _isLocked = false - private var _lockStartedAt: Date? - private var _autoUnlockTargetDate: Date? - private var _activeSettings: KeyboardLockerSettings = .default - - // Thread-safe public accessors - public var isLocked: Bool { - withLock { _isLocked } + var isLocked: Bool { + runtimeState.isLocked } - public var lockStartedAt: Date? { - withLock { _lockStartedAt } + /// One coherent Agent-owned snapshot for read-only wrappers and presentation surfaces. + var statusSnapshot: LockStatusSnapshot { + runtimeState.statusSnapshot(capturedAt: dependencies.now()) } - public var autoUnlockTargetDate: Date? { - withLock { _autoUnlockTargetDate } + init(dependencies: LockEngineDependencies) { + self.dependencies = dependencies } - private init() {} + /// Wires Agent-owned presentation after both the engine and its observer exist. The production + /// composition root installs exactly one handler before exposing the XPC listener. + func setStateChangeHandler(_ handler: @escaping () -> Void) { + stateChangeHandler = handler + } - // Helper method to safely execute critical sections with automatic lock management - private func withLock(_ block: () throws -> T) rethrows -> T { - stateLock.lock() - defer { stateLock.unlock() } - return try block() + @discardableResult + func lock( + settings: KeyboardLockerSettings = .default, + allowsControlCUnlock: Bool = false + ) throws -> LockRequestOutcome { + try acquireLock( + settings: settings, + allowsControlCUnlock: allowsControlCUnlock, + source: .general + ) } - public func lock(settings: KeyboardLockerSettings = .default) throws { - // Check lock status atomically - try withLock { - guard !_isLocked else { - throw LockEngineError.alreadyLocked - } + /// Applies the Focus Filter's desired state without giving Focus ownership of a pre-existing + /// global lock. Deactivation releases only the exact lock generation created by Focus. + func setFocusFilterLockEnabled( + _ enabled: Bool, + settings: KeyboardLockerSettings = .default + ) throws { + if enabled { + _ = try acquireLock( + settings: settings, + allowsControlCUnlock: false, + source: .focusFilter + ) + } else if let generation = runtimeState.focusOwnedGenerationForRelease { + unlock(ifLockGeneration: generation) + } + } - // Verify Accessibility permission before attempting to create event tap - guard AccessibilityManager.hasPermission() else { - throw LockEngineError.accessibilityPermissionDenied - } + private func acquireLock( + settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool, + source: LockRequestSource + ) throws -> LockRequestOutcome { + // A duplicate never mutates the physical lock, its settings, gestures, start time, or + // deadline. An explicit non-Focus desired-lock does take over persistence from Focus so a + // later Focus deactivation cannot undo the user's newer intent. + if let duplicateOutcome = runtimeState.handleDuplicateLockRequest(from: source) { + return duplicateOutcome + } - _activeSettings = settings + // Verify Accessibility permission before attempting to create event tap. + guard dependencies.hasAccessibilityPermission() else { + throw LockEngineError.accessibilityPermissionDenied } // Event tap creation must happen on main thread try startEventTap() + let outcome = runtimeState.begin( + settings: settings, + allowsControlCUnlock: allowsControlCUnlock, + at: dependencies.now() + ) + guard outcome == .acquired else { + teardownEventTap() + return outcome + } + if source == .focusFilter { + runtimeState.markCurrentLockAsFocusOwned() + } markLocked() + return outcome } - private func startEventTap() throws { - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: Self.eventMasks, - callback: eventTapCallback, - userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - ) else { - throw LockEngineError.eventTapCreationFailed - } - eventTap = tap + /// Updates the active settings. If a lock is running, changes take effect immediately + /// (e.g. a new auto-unlock timeout re-arms the timer); otherwise they seed the next lock. + func updateSettings(_ settings: KeyboardLockerSettings) { + runtimeState.updateSettings(settings) - guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, Self.runLoopSourceOrder) else { - throw LockEngineError.runLoopSourceCreationFailed + if runtimeState.isLocked { + configureAutoUnlockTimerIfNeeded() } - runLoopSource = source + } - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) + private func startEventTap() throws { + installedTap = try dependencies.installEventTap(self) + eventTapGeneration &+= 1 } private func markLocked() { - withLock { - _isLocked = true - _lockStartedAt = Date() - } - configureAutoUnlockTimerIfNeeded() - LockStateBroadcaster.broadcast(isLocked: true) - print("LockEngine: Locked") + publishStateChange() + Self.logger.info("Locked") } private func configureAutoUnlockTimerIfNeeded() { cancelAutoUnlockTimer() - let (timeout, startDate) = withLock { - (_activeSettings.autoUnlockPolicy.timeout, _lockStartedAt) - } - - guard let timeout, timeout > 0, let startDate else { + let timeout = runtimeState.activeSettings.autoUnlockPolicy.timeout + + // An explicit settings update starts a fresh timeout window without changing when the + // authoritative lock began. A duplicate lock request never reaches this path. + let referenceDate = dependencies.now() + guard runtimeState.isLocked, + let schedule = AutoUnlockSchedule.make( + timeout: timeout, + referenceDate: referenceDate, + currentDate: dependencies.now() + ) + else { return } - withLock { - _autoUnlockTargetDate = startDate.addingTimeInterval(timeout) + runtimeState.setAutoUnlockTargetDate(schedule.deadline) + + guard let lockGeneration = runtimeState.lockGeneration else { + return } - let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now() + timeout) - timer.setEventHandler { [weak self] in - self?.unlock() + cancelScheduledAutoUnlock = dependencies.scheduleTimer(schedule.delay) { [weak self] in + self?.unlock(ifLockGeneration: lockGeneration) } - timer.resume() - autoUnlockTimer = timer } private func cancelAutoUnlockTimer() { - autoUnlockTimer?.cancel() - autoUnlockTimer = nil - - withLock { - _autoUnlockTargetDate = nil - } + cancelScheduledAutoUnlock?() + cancelScheduledAutoUnlock = nil + runtimeState.setAutoUnlockTargetDate(nil) } - public func unlock() { - // Check lock status atomically - let shouldUnlock = withLock { _isLocked } - guard shouldUnlock else { + func unlock() { + guard runtimeState.isLocked else { return } @@ -210,42 +596,79 @@ public class LockEngine { resetLockState() } + private func unlock(ifLockGeneration generation: UInt64) { + guard runtimeState.matchesCurrentLockGeneration(generation) else { + return + } + unlock() + } + private func teardownEventTap() { - if let tap = eventTap { - CGEvent.tapEnable(tap: tap, enable: false) - CFMachPortInvalidate(tap) - eventTap = nil + installedTap?.teardown() + installedTap = nil + } + + private func resetLockState() { + runtimeState.end() + + publishStateChange() + Self.logger.info("Unlocked") + } + + private func publishStateChange() { + dependencies.broadcastStateChange() + stateChangeHandler() + } + + func handleDisabledEvent(_ event: CGEvent) -> Unmanaged? { + guard runtimeState.isLocked, let tap = installedTap else { + return Unmanaged.passUnretained(event) } - if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes) - runLoopSource = nil + Self.logger.warning("Event tap disabled by system, attempting to re-enable") + tap.setEnabled(true) + + guard !tap.isEnabled else { + return Unmanaged.passUnretained(event) + } + + let failedGeneration = eventTapGeneration + Self.logger.error("Event tap could not be re-enabled; transitioning to unlocked") + DispatchQueue.main.async { [weak self] in + self?.handleEventTapFailure(generation: failedGeneration) } + return Unmanaged.passUnretained(event) } - private func resetLockState() { - withLock { - _isLocked = false - _lockStartedAt = nil - _autoUnlockTargetDate = nil + private func handleEventTapFailure(generation: UInt64) { + guard runtimeState.isLocked, eventTapGeneration == generation else { + return } - LockStateBroadcaster.broadcast(isLocked: false) - print("LockEngine: Unlocked") + cancelAutoUnlockTimer() + teardownEventTap() + resetLockState() } - fileprivate func handleEvent( - proxy _: CGEventTapProxy, + func handleEvent( type: CGEventType, event: CGEvent ) -> Unmanaged? { - guard isLocked else { + guard runtimeState.isLocked else { + return Unmanaged.passUnretained(event) + } + + guard LockedKeyboardEventPolicy.shouldSuppress(type: type, event: event) else { return Unmanaged.passUnretained(event) } if shouldTriggerUnlock(for: type, event: event) { + let lockGeneration = runtimeState.lockGeneration DispatchQueue.main.async { [weak self] in - self?.unlock() + guard let lockGeneration else { + return + } + self?.unlock(ifLockGeneration: lockGeneration) } } @@ -253,40 +676,14 @@ public class LockEngine { } private func shouldTriggerUnlock(for type: CGEventType, event: CGEvent) -> Bool { - let hotkey = withLock { _activeSettings.unlockHotkey } - - switch type { - case .keyDown: - let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) - guard hotkey.matches(keyCode: keyCode, flags: event.flags) else { - return false - } - - let isAutoRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) == Self.autoRepeatFlagValue - return !isAutoRepeat - - case .flagsChanged: - let keyCode = hotkey.keyCode - return CGEventSource.keyState(.hidSystemState, key: keyCode) - - default: - return false - } - } - - public func lockDuration(at date: Date = Date()) -> TimeInterval? { - let start = withLock { _lockStartedAt } - guard let start else { - return nil - } - return max(0, date.timeIntervalSince(start)) - } - - public func remainingAutoUnlockTime(at date: Date = Date()) -> TimeInterval? { - let deadline = withLock { _autoUnlockTargetDate } - guard let deadline else { - return nil - } - return max(0, deadline.timeIntervalSince(date)) + UnlockGestureMatcher.matches( + type: type, + keyCode: CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)), + flags: event.flags, + isAutoRepeat: event.getIntegerValueField(.keyboardEventAutorepeat) + == Self.autoRepeatFlagValue, + configuredHotkey: runtimeState.activeSettings.unlockHotkey, + allowsControlCUnlock: runtimeState.allowsControlCUnlock + ) } } diff --git a/Core/Sources/Service/LockStateBroadcaster.swift b/Core/Sources/Service/LockStateBroadcaster.swift index 5cd4034..b4ca91a 100644 --- a/Core/Sources/Service/LockStateBroadcaster.swift +++ b/Core/Sources/Service/LockStateBroadcaster.swift @@ -5,17 +5,21 @@ import Foundation /// Broadcasts lock state changes via system notifications. /// -/// Call `broadcast(isLocked:)` from the Agent process after lock state changes. -/// Clients subscribe using `LockStateSubscriber` in the Client module. +/// Call `broadcast()` from the Agent process after lock state changes. Only +/// **long-lived, state-reflecting** surfaces (App menu bar, Widgets) need this — they subscribe +/// via `LockStateSubscriber`. One-shot surfaces (CLI queries, AppleScript, Shortcuts) read the +/// authoritative state directly with a `status` XPC call and are synchronized by construction. /// -/// Notification channels: -/// - **Darwin**: Lightweight system-wide (no payload). For CLI, scripts, Shortcuts. -/// - **Distributed**: With state payload. For widgets, extensions, other apps. +/// Both channels are payload-free "something changed" signals — subscribers fetch the +/// authoritative state from the Agent on any signal, never trusting a notification's contents. +/// Two channels are posted for delivery robustness: +/// - **Darwin**: can wake an App-Napped / suspended process so it learns of changes made while idle. +/// - **Distributed**: delivered on the main queue; the reliable path for a running CFRunLoop. public enum LockStateBroadcaster { - /// Broadcasts lock state change to all system notification channels. - public static func broadcast(isLocked: Bool) { + /// Broadcasts a lock state change to all system notification channels. + public static func broadcast() { postDarwin() - postDistributed(isLocked: isLocked) + postDistributed() } private static func postDarwin() { @@ -28,11 +32,10 @@ public enum LockStateBroadcaster { ) } - private static func postDistributed(isLocked: Bool) { + private static func postDistributed() { DistributedNotificationCenter.default().post( name: Notification.Name(NotificationNames.stateChanged), - object: nil, - userInfo: ["isLocked": isLocked] + object: nil ) } } diff --git a/Core/Sources/Service/LockStatusNotifier.swift b/Core/Sources/Service/LockStatusNotifier.swift new file mode 100644 index 0000000..e61c686 --- /dev/null +++ b/Core/Sources/Service/LockStatusNotifier.swift @@ -0,0 +1,207 @@ +import Common +import Foundation +import UserNotifications + +/// Notification-center surface used by `LockStatusNotifier`. The live implementation wraps +/// `UNUserNotificationCenter`; tests drive a recording fake. Class-constrained so the notifier +/// can install its action handler on a `let` reference. +@MainActor +protocol LockStatusNotifying: AnyObject, Sendable { + /// Invoked (from any thread) when the user chooses the notification's Unlock Now action. + var actionHandler: (@Sendable (String) -> Void)? { get set } + /// Registers the locked category and its action with the system. + func installUnlockActionCategory() + /// Returns whether the Agent may deliver a user-visible alert, requesting authorization on + /// first use. A denied or silent-only status degrades to no notification without an error. + func requestAlertAuthorization() async -> Bool + /// Posts the locked notification immediately; a repeated identifier replaces the previous one. + func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) + /// Removes the locked notification from Notification Center and any pending delivery. + func removeLocked(identifier: String) +} + +/// Owns the single "Keyboard Locked" notification from inside the Agent process. +/// +/// The lock's discoverability surface must live and die with the lock itself, and only the +/// Agent is guaranteed to be alive for the lock's whole lifetime — menu-bar App, CLI, or Focus +/// extension may come and go. Posting here also pairs delivery with removal on the same state +/// transition, so a stale notification cannot outlive the lock (an Agent that exits while +/// locked drops its event tap, and the next launch clears the leftover via `start()`). +/// +/// The notification is a presentation-only convenience: it carries a presentation cache of the +/// unlock affordances, never authoritative state, and a denied authorization degrades silently. +@MainActor +final class LockStatusNotifier { + static let notificationIdentifier = "keyboard-locked" + static let categoryIdentifier = "KEYBOARD_LOCKED" + static let unlockActionIdentifier = "UNLOCK_NOW" + + private let notifications: any LockStatusNotifying + private let snapshot: @MainActor () -> LockStatusSnapshot + private let unlock: @MainActor () -> Void + private var tail: Task? + + init( + notifications: any LockStatusNotifying, + snapshot: @escaping @MainActor () -> LockStatusSnapshot, + unlock: @escaping @MainActor () -> Void + ) { + self.notifications = notifications + self.snapshot = snapshot + self.unlock = unlock + notifications.actionHandler = { @Sendable actionIdentifier in + Task { @MainActor [weak self] in + self?.handleAction(actionIdentifier) + } + } + } + + /// Installs the action category and removes any notification left behind by a previous Agent + /// generation — its exit already released the lock, so the leftover can only be stale. + func start() { + notifications.installUnlockActionCategory() + enqueue { [notifications] in + notifications.removeLocked(identifier: LockStatusNotifier.notificationIdentifier) + } + } + + /// Engine state-change hook, invoked after the mutation completed so the snapshot read here + /// already describes the new state. + func lockStateDidChange() { + if snapshot().isLocked { + enqueue { [weak self] in + await self?.postLockedNotification() + } + } else { + enqueue { [notifications] in + notifications.removeLocked(identifier: LockStatusNotifier.notificationIdentifier) + } + } + } + + private func handleAction(_ actionIdentifier: String) { + guard actionIdentifier == Self.unlockActionIdentifier else { + return + } + // Desired-state unlock is idempotent; the engine's state-change hook removes the + // notification once the unlock lands. + unlock() + } + + private func postLockedNotification() async { + guard await notifications.requestAlertAuthorization() else { + return + } + notifications.postLocked( + identifier: Self.notificationIdentifier, + categoryIdentifier: Self.categoryIdentifier, + title: "Keyboard Locked", + body: Self.makeBody(snapshot: snapshot()) + ) + } + + private static func makeBody(snapshot: LockStatusSnapshot) -> String { + var lines = ["Press \(snapshot.settings.unlockHotkey.displayString) or choose Unlock Now."] + if let target = snapshot.autoUnlockTargetDate { + lines.append("Auto-unlocks at \(target.formatted(date: .omitted, time: .shortened)).") + } + return lines.joined(separator: " ") + } + + /// Serializes notification work so a quick lock/unlock flip can never post after the removal. + private func enqueue(_ work: @escaping @MainActor () async -> Void) { + let previous = tail + tail = Task { @MainActor in + await previous?.value + await work() + } + } + + /// Test support: waits for queued notification work to settle. + func waitUntilIdle() async { + await tail?.value + } +} + +/// Production `LockStatusNotifying` backed by `UNUserNotificationCenter`. +@MainActor +final class LiveLockStatusNotificationService: LockStatusNotifying { + private let center: UNUserNotificationCenter + private let delegateBridge: NotificationDelegateBridge + + var actionHandler: (@Sendable (String) -> Void)? { + get { + delegateBridge.actionHandler + } + set { + delegateBridge.actionHandler = newValue + } + } + + init(center: UNUserNotificationCenter = .current()) { + self.center = center + delegateBridge = NotificationDelegateBridge() + center.delegate = delegateBridge + } + + func installUnlockActionCategory() { + let unlockAction = UNNotificationAction( + identifier: LockStatusNotifier.unlockActionIdentifier, + title: "Unlock Now" + ) + let category = UNNotificationCategory( + identifier: LockStatusNotifier.categoryIdentifier, + actions: [unlockAction], + intentIdentifiers: [] + ) + center.setNotificationCategories([category]) + } + + func requestAlertAuthorization() async -> Bool { + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .ephemeral, .provisional: + return true + case .denied: + return false + case .notDetermined: + return await (try? center.requestAuthorization(options: [.alert])) ?? false + @unknown default: + return false + } + } + + func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.categoryIdentifier = categoryIdentifier + // A nil trigger delivers immediately; the fixed identifier replaces the previous copy. + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + center.add(request, withCompletionHandler: nil) + } + + func removeLocked(identifier: String) { + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + } +} + +/// `UNUserNotificationCenter.delegate` is weak and its callbacks are nonisolated; this bridge +/// owns the Objective-C surface and forwards the Unlock Now action to the main actor. +private final class NotificationDelegateBridge: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable { + var actionHandler: (@Sendable (String) -> Void)? + + nonisolated func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let actionIdentifier = response.actionIdentifier + if actionIdentifier != UNNotificationDefaultActionIdentifier, + actionIdentifier != UNNotificationDismissActionIdentifier { + actionHandler?(actionIdentifier) + } + completionHandler() + } +} diff --git a/Core/Sources/Service/MainActorTimerScheduler.swift b/Core/Sources/Service/MainActorTimerScheduler.swift new file mode 100644 index 0000000..c4d006a --- /dev/null +++ b/Core/Sources/Service/MainActorTimerScheduler.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Schedules a one-shot main-actor action after an interval and returns a cancellation closure. +/// +/// Shared by `LockEngine` (auto-unlock) and `AgentService` (replacement-preparation expiry) so +/// `ServiceTests` can substitute a deterministic manual scheduler for either consumer. +typealias MainActorTimerScheduler = @MainActor ( + _ interval: TimeInterval, + _ fire: @escaping @MainActor @Sendable () -> Void +) -> @MainActor () -> Void + +/// Live scheduler backed by the main dispatch queue. The deadline uses the monotonic clock, +/// so it pauses while the system is asleep. +@MainActor let liveMainActorTimerScheduler: MainActorTimerScheduler = { interval, fire in + let item = DispatchWorkItem { + MainActor.assumeIsolated { fire() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: item) + return { item.cancel() } +} diff --git a/Core/Sources/Service/ReplacementTransaction.swift b/Core/Sources/Service/ReplacementTransaction.swift new file mode 100644 index 0000000..c5f2632 --- /dev/null +++ b/Core/Sources/Service/ReplacementTransaction.swift @@ -0,0 +1,138 @@ +import Common +import Foundation + +/// Server-side state machine for replacing the launchd-managed Agent. +/// +/// The caller must serialize access. `AgentService` does this on the main queue. +struct ReplacementTransaction { + struct Preparation: Equatable, Sendable { + let ticket: ServiceReplacementTicket + + fileprivate let generation: UInt64 + } + + private enum Phase { + case committed(ServiceReplacementTicket) + case idle + case prepared(Preparation) + } + + private var generation: UInt64 = 0 + private var phase: Phase = .idle + + init() {} + + var isPending: Bool { + switch phase { + case .committed, .prepared: + true + case .idle: + false + } + } + + var isCommitted: Bool { + if case .committed = phase { + return true + } + return false + } + + var servicePhase: ServiceReplacementPhase { + switch phase { + case .committed: + .committed + case .idle: + .inactive + case .prepared: + .prepared + } + } + + mutating func prepare( + ticket: ServiceReplacementTicket + ) throws -> Preparation { + guard case .idle = phase else { + throw ReplacementTransactionError.alreadyInProgress + } + + generation &+= 1 + let preparation = Preparation(ticket: ticket, generation: generation) + phase = .prepared(preparation) + return preparation + } + + /// Moves the drain into its fail-closed phase. Repeating the same commit is idempotent. + mutating func commit(ticket: ServiceReplacementTicket) throws { + switch phase { + case let .prepared(preparation) where preparation.ticket == ticket: + phase = .committed(ticket) + + case let .committed(activeTicket) where activeTicket == ticket: + return + + case .committed, .idle, .prepared: + throw ReplacementTransactionError.ticketInactive + } + } + + func status(for ticket: ServiceReplacementTicket) -> ServiceReplacementStatus { + let statusPhase: ServiceReplacementPhase = switch phase { + case let .committed(activeTicket) where activeTicket == ticket: + .committed + case let .prepared(preparation) where preparation.ticket == ticket: + .prepared + case .committed, .idle, .prepared: + .inactive + } + return ServiceReplacementStatus(phase: statusPhase) + } + + /// Cancels only work that has not yet been committed to Service Management. + mutating func cancel(ticket: ServiceReplacementTicket) throws { + switch phase { + case let .prepared(preparation) where preparation.ticket == ticket: + phase = .idle + + case .committed: + throw ReplacementTransactionError.committedCannotCancel + + case .idle, .prepared: + throw ReplacementTransactionError.ticketInactive + } + } + + /// Expires exactly the scheduled preparation. A stale timer cannot clear newer state. + @discardableResult + mutating func expire( + preparation: Preparation + ) -> Bool { + guard case let .prepared(activePreparation) = phase, + activePreparation == preparation + else { + return false + } + + phase = .idle + return true + } +} + +enum ReplacementTransactionError: Error, Equatable, LocalizedError { + case alreadyInProgress + case committedCannotCancel + case ticketInactive + + var errorDescription: String? { + switch self { + case .alreadyInProgress: + "Another agent replacement is already in progress." + + case .committedCannotCancel: + "A committed agent replacement cannot be cancelled." + + case .ticketInactive: + "The replacement ticket is no longer active." + } + } +} diff --git a/Core/Sources/Service/XPCAccessControl.swift b/Core/Sources/Service/XPCAccessControl.swift index eb9daca..bc2494f 100644 --- a/Core/Sources/Service/XPCAccessControl.swift +++ b/Core/Sources/Service/XPCAccessControl.swift @@ -1,97 +1,11 @@ import Common import Foundation -import Security -/// Validates XPC connections by verifying code signature and bundle identifier. -/// -/// - **Release**: Full verification (signature + Team ID + bundle ID allowlist) -/// - **Debug**: Relaxed verification (bundle ID allowlist only) +/// Creates the OS-enforced requirement for clients of the Agent's Mach service. public enum XPCAccessControl { - /// Apple Developer Team ID for certificate verification (Release only) - private static let expectedTeamID = "SBLX9H66X2" - - /// Returns true if the connection passes security checks. - public static func isConnectionAuthorized(_ connection: NSXPCConnection) -> Bool { - guard let staticCode = staticCode(for: connection) else { - return false - } - - #if !DEBUG - // Release: Validate signature integrity - guard SecStaticCodeCheckValidity(staticCode, SecCSFlags(), nil) == errSecSuccess else { - return false - } - #endif - - guard let info = signingInfo(for: staticCode), - let bundleID = info[kSecCodeInfoIdentifier] as? String, - SharedConstants.authorizedClientBundleIdentifiers.contains(bundleID) - else { - return false - } - - #if !DEBUG - // Release: Verify Team ID - guard extractTeamID(from: info) == expectedTeamID else { - return false - } - #endif - - return true - } - - // MARK: - Private Helpers - - private static func staticCode(for connection: NSXPCConnection) -> SecStaticCode? { - let attributes: [CFString: Any] = [kSecGuestAttributePid: connection.processIdentifier] - - var code: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, attributes as CFDictionary, SecCSFlags(), &code) == errSecSuccess, - let code - else { - return nil - } - - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess else { - return nil - } - return staticCode - } - - private static func signingInfo(for staticCode: SecStaticCode) -> [CFString: Any]? { - var info: CFDictionary? - let flags = SecCSFlags(rawValue: kSecCSSigningInformation) - guard SecCodeCopySigningInformation(staticCode, flags, &info) == errSecSuccess else { - return nil - } - return info as? [CFString: Any] - } - - private static func extractTeamID(from info: [CFString: Any]) -> String? { - if let teamID = info[kSecCodeInfoTeamIdentifier] as? String { - return teamID - } - - // Fallback: Parse from certificate Common Name - guard let certs = info[kSecCodeInfoCertificates] as? [SecCertificate], - let leafCert = certs.first - else { - return nil - } - - var commonName: CFString? - SecCertificateCopyCommonName(leafCert, &commonName) - - // Extract Team ID from "Developer Name (TEAMID)" format - if let name = commonName as String?, - let match = name.range(of: #"\(([A-Z0-9]{10})\)$"#, options: .regularExpression) - { - let start = name.index(match.lowerBound, offsetBy: 1) - let end = name.index(match.upperBound, offsetBy: -1) - return String(name[start.. String { + try XPCCodeSigningRequirement.sameTeam( + identifiers: SharedConstants.authorizedClientBundleIdentifiers + ) } } diff --git a/Core/Sources/Service/XPCServerConnection.swift b/Core/Sources/Service/XPCServerConnection.swift index 1f30021..7fc9e79 100644 --- a/Core/Sources/Service/XPCServerConnection.swift +++ b/Core/Sources/Service/XPCServerConnection.swift @@ -11,6 +11,6 @@ public enum XPCServerConnection { ) { connection.exportedInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) connection.exportedObject = exportedService - connection.resume() + connection.activate() } } diff --git a/Core/Sources/SystemSurfaces/LockStateSurfaceInvalidator.swift b/Core/Sources/SystemSurfaces/LockStateSurfaceInvalidator.swift new file mode 100644 index 0000000..365f8d0 --- /dev/null +++ b/Core/Sources/SystemSurfaces/LockStateSurfaceInvalidator.swift @@ -0,0 +1,42 @@ +import WidgetKit + +public enum KeyboardLockerSurfaceKind { + public static let keyboardLockControl = "io.lzhlovesjyq.keyboardlocker.control" + public static let statusWidget = "io.lzhlovesjyq.keyboardlocker.status" +} + +/// Requests fresh system-owned presentation without becoming another lock-state authority. +/// Reload requests are deliberately nonthrowing: a confirmed Agent mutation remains successful +/// even when WidgetKit defers or coalesces the corresponding presentation refresh. +public struct LockStateSurfaceInvalidator: Sendable { + public typealias Reload = @Sendable () -> Void + + private let reloadWidget: Reload + private let reloadControl: Reload + + public init( + reloadWidget: @escaping Reload, + reloadControl: @escaping Reload + ) { + self.reloadWidget = reloadWidget + self.reloadControl = reloadControl + } + + public func invalidate() { + reloadWidget() + reloadControl() + } + + public static let live = Self( + reloadWidget: { + WidgetCenter.shared.reloadTimelines(ofKind: KeyboardLockerSurfaceKind.statusWidget) + }, + reloadControl: { + if #available(macOS 26.0, *) { + ControlCenter.shared.reloadControls( + ofKind: KeyboardLockerSurfaceKind.keyboardLockControl + ) + } + } + ) +} diff --git a/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift new file mode 100644 index 0000000..8511835 --- /dev/null +++ b/Core/Tests/ClientTests/IdempotentMutationRetrierTests.swift @@ -0,0 +1,172 @@ +@testable import Client +import Foundation +import Testing + +@Suite(.serialized) +struct IdempotentMutationRetrierTests { + @Test + func successfulMutationDoesNotResetOrRetry() async throws { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.success], recorder: recorder) + + try await perform(operation: operation, recorder: recorder) + + #expect(recorder.calls == [.attempt]) + } + + @Test + func timeoutResetsConnectionBeforeSingleRetry() async throws { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .success], recorder: recorder) + + try await perform(operation: operation, recorder: recorder) + + #expect(recorder.calls == [.attempt, .reset, .attempt]) + } + + @Test + func lostReplyResetsConnectionBeforeSingleRetry() async throws { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.lostReply, .success], recorder: recorder) + + try await perform(operation: operation, recorder: recorder) + + #expect(recorder.calls == [.attempt, .reset, .attempt]) + } + + @Test + func mixedAmbiguousFailuresBecomeUnknownAfterOneRetry() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .lostReply], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + Issue.record("Expected the second lost reply to become an unknown outcome.") + } catch XPCClientError.operationOutcomeUnknown { + #expect(recorder.calls == [.attempt, .reset, .attempt]) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func secondTimeoutBecomesUnknownWithoutAnotherRetry() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .timedOut], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + Issue.record("Expected the second timeout to become an unknown outcome.") + } catch XPCClientError.operationOutcomeUnknown { + #expect(recorder.calls == [.attempt, .reset, .attempt]) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func nonTimeoutFailureDoesNotResetOrRetry() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.failure], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + Issue.record("Expected the original failure to propagate.") + } catch MutationTestError.expected { + #expect(recorder.calls == [.attempt]) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func retryPropagatesNonTimeoutFailure() async { + let recorder = CallRecorder() + let operation = ScriptedOperation(outcomes: [.timedOut, .failure], recorder: recorder) + + do { + try await perform(operation: operation, recorder: recorder) + Issue.record("Expected the retry failure to propagate.") + } catch MutationTestError.expected { + #expect(recorder.calls == [.attempt, .reset, .attempt]) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private func perform( + operation: ScriptedOperation, + recorder: CallRecorder + ) async throws { + try await IdempotentMutationRetrier.perform( + operation: { + try await operation.perform() + }, + resetConnection: { + recorder.record(.reset) + } + ) + } +} + +private actor ScriptedOperation { + private var outcomes: [MutationOutcome] + private let recorder: CallRecorder + + init(outcomes: [MutationOutcome], recorder: CallRecorder) { + self.outcomes = outcomes + self.recorder = recorder + } + + func perform() throws { + recorder.record(.attempt) + guard !outcomes.isEmpty else { + Issue.record("The operation ran more times than expected.") + return + } + + switch outcomes.removeFirst() { + case .failure: + throw MutationTestError.expected + case .lostReply: + throw LostReplyError() + case .success: + return + case .timedOut: + throw XPCClientError.timedOut + } + } +} + +private enum MutationOutcome: Sendable { + case failure + case lostReply + case success + case timedOut +} + +private enum MutationTestError: Error { + case expected +} + +private final class CallRecorder: @unchecked Sendable { + enum Call: Equatable { + case attempt + case reset + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} diff --git a/Core/Tests/ClientTests/LockStateSubscriberTests.swift b/Core/Tests/ClientTests/LockStateSubscriberTests.swift new file mode 100644 index 0000000..1dbd3e7 --- /dev/null +++ b/Core/Tests/ClientTests/LockStateSubscriberTests.swift @@ -0,0 +1,297 @@ +@testable import Client +import Foundation +import Testing + +@Suite(.serialized) +struct LockStateSubscriberTests { + @Test + func subscriptionCalibratesImmediatelyAfterInstallingObservers() async throws { + let fetcher = ControlledStateFetcher() + try await confirmation("Received initial authoritative state") { receivedInitialState in + let recorder = StateRecorder { + receivedInitialState() + } + + let token = LockStateSubscriber.subscribe( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + await recorder.waitUntilCount(1) + + #expect(recorder.values == [true]) + withExtendedLifetime(token) {} + } + } + + @Test + func initialCalibrationMatchingSeedIsNotRedelivered() async throws { + let fetcher = ControlledStateFetcher() + try await confirmation("Did not redeliver seeded state", expectedCount: 0) { receivedState in + let recorder = StateRecorder { + receivedState() + } + + let token = LockStateSubscriber.subscribe( + initialState: true, + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + try? await Task.sleep(for: .milliseconds(100)) + + #expect(recorder.values.isEmpty) + withExtendedLifetime(token) {} + } + } + + @Test + func initialCalibrationDifferingFromSeedIsDelivered() async throws { + let fetcher = ControlledStateFetcher() + try await confirmation("Received state differing from seed") { receivedState in + let recorder = StateRecorder { + receivedState() + } + + let token = LockStateSubscriber.subscribe( + initialState: false, + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + await recorder.waitUntilCount(1) + + #expect(recorder.values == [true]) + withExtendedLifetime(token) {} + } + } + + @Test + func releasingTokenSuppressesPendingCalibrationDelivery() async throws { + let fetcher = ControlledStateFetcher() + try await confirmation("Did not receive state after cancellation", expectedCount: 0) { receivedState in + let recorder = StateRecorder { + receivedState() + } + + var token: ObserverToken? = LockStateSubscriber.subscribe( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + await fetcher.waitUntilStarted(1) + token = nil + try await fetcher.resolveFetch(0, with: true) + try? await Task.sleep(for: .milliseconds(100)) + + let fetchMetrics = await fetcher.metrics + #expect(token == nil) + #expect(recorder.values.isEmpty) + #expect(fetchMetrics.startedCount == 1) + } + } + + @Test + func signalsDuringFetchAreSerializedAndCoalescedIntoOneFollowUp() async throws { + let fetcher = ControlledStateFetcher() + try await confirmation("Received reconciled states", expectedCount: 2) { receivedStates in + let recorder = StateRecorder { + receivedStates() + } + let reconciler = StateReconciler( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + reconciler.signal() + await fetcher.waitUntilStarted(1) + + reconciler.signal() + reconciler.signal() + reconciler.signal() + await Task.yield() + + var fetchMetrics = await fetcher.metrics + #expect(fetchMetrics.startedCount == 1) + #expect(fetchMetrics.maximumConcurrentFetches == 1) + + try await fetcher.resolveFetch(0, with: false) + await fetcher.waitUntilStarted(2) + + fetchMetrics = await fetcher.metrics + #expect(fetchMetrics.startedCount == 2) + #expect(fetchMetrics.maximumConcurrentFetches == 1) + + try await fetcher.resolveFetch(1, with: true) + await recorder.waitUntilCount(2) + await Task.yield() + + fetchMetrics = await fetcher.metrics + #expect(recorder.values == [false, true]) + #expect(fetchMetrics.startedCount == 2) + #expect(fetchMetrics.maximumConcurrentFetches == 1) + } + } + + @Test + func consecutiveEqualAuthoritativeStatesAreDeduplicated() async throws { + let fetcher = ControlledStateFetcher() + try await confirmation("Received distinct states", expectedCount: 2) { receivedStates in + let recorder = StateRecorder { + receivedStates() + } + let reconciler = StateReconciler( + fetchState: { try await fetcher.fetch() } + ) { isLocked in + recorder.record(isLocked) + } + + reconciler.signal() + await fetcher.waitUntilStarted(1) + try await fetcher.resolveFetch(0, with: true) + await recorder.waitUntilCount(1) + + reconciler.signal() + await fetcher.waitUntilStarted(2) + try await fetcher.resolveFetch(1, with: true) + await fetcher.waitUntilCompleted(2) + + reconciler.signal() + await fetcher.waitUntilStarted(3) + try await fetcher.resolveFetch(2, with: false) + await recorder.waitUntilCount(2) + + #expect(recorder.values == [true, false]) + } + } +} + +private actor ControlledStateFetcher { + private var continuations: [CheckedContinuation?] = [] + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var completionWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private(set) var startedCount = 0 + private(set) var completedCount = 0 + private(set) var concurrentFetches = 0 + private(set) var maximumConcurrentFetches = 0 + + var metrics: (startedCount: Int, maximumConcurrentFetches: Int) { + (startedCount, maximumConcurrentFetches) + } + + func fetch() async throws -> Bool { + startedCount += 1 + concurrentFetches += 1 + maximumConcurrentFetches = max(maximumConcurrentFetches, concurrentFetches) + + let readyStartWaiters = startWaiters.filter { $0.count <= startedCount } + startWaiters.removeAll { $0.count <= startedCount } + readyStartWaiters.forEach { $0.continuation.resume() } + + return try await withCheckedThrowingContinuation { continuation in + continuations.append(continuation) + } + } + + func resolveFetch(_ index: Int, with value: Bool) throws { + guard continuations.indices.contains(index), + let continuation = continuations[index] + else { + throw ControlledStateFetcherError.noPendingFetch(index) + } + + continuations[index] = nil + concurrentFetches -= 1 + completedCount += 1 + continuation.resume(returning: value) + + let readyCompletionWaiters = completionWaiters.filter { $0.count <= completedCount } + completionWaiters.removeAll { $0.count <= completedCount } + readyCompletionWaiters.forEach { $0.continuation.resume() } + } + + func waitUntilStarted(_ count: Int) async { + guard startedCount < count else { + return + } + + await withCheckedContinuation { continuation in + startWaiters.append((count, continuation)) + } + } + + func waitUntilCompleted(_ count: Int) async { + guard completedCount < count else { + return + } + + await withCheckedContinuation { continuation in + completionWaiters.append((count, continuation)) + } + } +} + +private enum ControlledStateFetcherError: Error { + case noPendingFetch(Int) +} + +private final class StateRecorder: @unchecked Sendable { + private typealias CountWaiter = ( + count: Int, + continuation: CheckedContinuation + ) + + private let lock = NSLock() + private let onRecord: () -> Void + private var recordedValues: [Bool] = [] + private var countWaiters: [CountWaiter] = [] + + init(onRecord: @escaping () -> Void = {}) { + self.onRecord = onRecord + } + + var values: [Bool] { + lock.withLock { + recordedValues + } + } + + func record(_ value: Bool) { + let readyWaiters: [CountWaiter] = lock.withLock { + recordedValues.append(value) + let ready = countWaiters.filter { $0.count <= recordedValues.count } + countWaiters.removeAll { $0.count <= recordedValues.count } + return ready + } + readyWaiters.forEach { $0.continuation.resume() } + onRecord() + } + + func waitUntilCount(_ count: Int) async { + await withCheckedContinuation { continuation in + let isAlreadySatisfied = lock.withLock { + guard recordedValues.count < count else { + return true + } + + countWaiters.append((count, continuation)) + return false + } + + if isAlreadySatisfied { + continuation.resume() + } + } + } +} diff --git a/Core/Tests/ClientTests/ServiceCompatibilityTests.swift b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift new file mode 100644 index 0000000..0c0a80e --- /dev/null +++ b/Core/Tests/ClientTests/ServiceCompatibilityTests.swift @@ -0,0 +1,352 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct ServiceCompatibilityTests { + @Test + func buildVersionsUseNumericComponentOrdering() throws { + let older = try #require(ServiceBuildVersion("1.9")) + let newer = try #require(ServiceBuildVersion("1.10")) + let equivalent = try #require(ServiceBuildVersion("01.010.0")) + + #expect(older < newer) + #expect(newer == equivalent) + } + + @Test + func buildVersionRejectsNonNumericComponents() { + #expect(ServiceBuildVersion("") == nil) + #expect(ServiceBuildVersion("1.beta") == nil) + #expect(ServiceBuildVersion("1..2") == nil) + } + + @Test + func matchingDescriptorIsCompatible() throws { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.union([ + ServiceCapability(rawValue: "future-capability"), + ]) + ) + + #expect(descriptor.compatibilityIssues(against: requirements).isEmpty) + try descriptor.validateCompatibility(against: requirements) + } + + @Test + func newerProtocolMinorIsCompatible() { + let requirements = makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion(major: 1, minor: 2) + ) + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 3) + ) + + #expect(descriptor.compatibilityIssues(against: requirements).isEmpty) + } + + @Test + func differentProtocolMajorIsIncompatible() { + let requirements = makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion(major: 2, minor: 0) + ) + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 99) + ) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .protocolMajorMismatch(expected: 2, actual: 1), + ]) + } + + @Test + func olderProtocolMinorIsIncompatible() { + let requirements = makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion(major: 1, minor: 2) + ) + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 1) + ) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .protocolMinorTooOld(required: 2, actual: 1), + ]) + } + + @Test + func missingCapabilitiesAreReportedInStableOrder() { + let requirements = makeRequirements(requiredCapabilities: [ + .currentSettings, + .lockControl, + .prepareForReplacement, + ]) + let descriptor = makeDescriptor(capabilities: [.lockControl]) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.currentSettings, .prepareForReplacement]), + ]) + } + + @Test + func currentContractRequiresCommittedReplacementDrain() { + #expect(ServiceContract.requiredCapabilities.contains(.committedReplacementDrain)) + #expect(ServiceContract.protocolVersion.minor >= 1) + } + + @Test + func currentContractRequiresErrorReportingSettingsSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.currentSettingsWithError)) + #expect(ServiceContract.protocolVersion.minor >= 2) + } + + @Test + func currentContractRequiresInteractiveLockSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.interactiveLock)) + #expect(ServiceContract.protocolVersion.minor >= 3) + } + + @Test + func currentContractRequiresLockStatusSnapshotSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.lockStatusSnapshot)) + #expect(ServiceContract.protocolVersion.minor >= 4) + } + + @Test + func currentContractRequiresFocusFilterLockSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.focusFilterLock)) + #expect(ServiceContract.protocolVersion.minor >= 5) + } + + @Test + func currentContractRequiresLockToggleSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.lockToggle)) + #expect(ServiceContract.protocolVersion.minor >= 6) + } + + @Test + func currentContractRequiresSafetyCheckSelector() { + #expect(ServiceContract.requiredCapabilities.contains(.safetyCheckLock)) + #expect(ServiceContract.protocolVersion.minor >= 7) + } + + @Test + func agentWithoutCommittedDrainIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .committedReplacementDrain, + ]) + ) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.committedReplacementDrain]), + ]) + } + + @Test + func agentWithoutErrorReportingSettingsIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .currentSettingsWithError, + ]) + ) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.currentSettingsWithError]), + ]) + } + + @Test + func agentWithoutInteractiveLockIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .interactiveLock, + ]) + ) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.interactiveLock]), + ]) + } + + @Test + func agentWithoutLockStatusSnapshotIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .lockStatusSnapshot, + ]) + ) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.lockStatusSnapshot]), + ]) + } + + @Test + func agentWithoutFocusFilterLockIsNotCurrentContractCompatible() { + let requirements = makeRequirements() + let descriptor = makeDescriptor( + capabilities: requirements.requiredCapabilities.subtracting([ + .focusFilterLock, + ]) + ) + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .missingCapabilities([.focusFilterLock]), + ]) + } + + @Test + func buildMismatchRequiresReplacement() { + let requirements = makeRequirements(agentBuild: "42") + let descriptor = makeDescriptor(agentBuild: "41") + + #expect(descriptor.compatibilityIssues(against: requirements) == [ + .agentBuildMismatch(expected: "42", actual: "41"), + ]) + } + + @Test + func descriptorRoundTripPreservesUnknownCapabilities() throws { + let descriptor = makeDescriptor(capabilities: [ + .lockControl, + ServiceCapability(rawValue: "capability-added-by-a-newer-agent"), + ], replacementPhase: .prepared) + + let decoded = try ServiceDescriptor.decodedFromXPC(descriptor.encodedForXPC()) + + #expect(decoded == descriptor) + } + + @Test + func descriptorDecodesLegacyPayloadWithoutAdditiveField() throws { + let instanceID = UUID() + let payload = """ + { + "protocolVersion": {"major": 1, "minor": 0}, + "capabilities": ["lock-control"], + "agentBundleIdentifier": "\(SharedConstants.agentBundleIdentifier)", + "agentVersion": "1.0", + "agentBuild": "1", + "agentInstanceID": "\(instanceID.uuidString)" + } + """.data(using: .utf8) + + let descriptor = try ServiceDescriptor.decodedFromXPC(payload) + + #expect(descriptor.agentInstanceID == instanceID) + #expect(!descriptor.replacementPending) + #expect(descriptor.replacementPhase == .inactive) + } + + @Test + func descriptorIgnoresFutureFields() throws { + let descriptor = makeDescriptor() + var object = try #require( + JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] + ) + object["fieldAddedByFutureMinorVersion"] = ["value": 42] + let futurePayload = try JSONSerialization.data(withJSONObject: object) + + #expect(try ServiceDescriptor.decodedFromXPC(futurePayload) == descriptor) + } + + @Test + func descriptorMapsPendingLegacyPayloadToUnknownPhase() throws { + let descriptor = makeDescriptor() + var object = try #require( + JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] + ) + object["replacementPending"] = true + object.removeValue(forKey: "replacementPhase") + let legacyPayload = try JSONSerialization.data(withJSONObject: object) + + let decoded = try ServiceDescriptor.decodedFromXPC(legacyPayload) + + #expect(decoded.replacementPending) + #expect(decoded.replacementPhase == .unknown) + } + + @Test + func descriptorPreservesFutureReplacementPhase() throws { + let futurePhase = ServiceReplacementPhase(rawValue: "future-phase") + let descriptor = makeDescriptor(replacementPhase: futurePhase) + + let decoded = try ServiceDescriptor.decodedFromXPC(descriptor.encodedForXPC()) + + #expect(decoded.replacementPhase == futurePhase) + #expect(decoded.replacementPending) + } + + @Test + func descriptorRejectsMissingRequiredBootstrapField() throws { + let descriptor = makeDescriptor() + var object = try #require( + JSONSerialization.jsonObject(with: descriptor.encodedForXPC()) as? [String: Any] + ) + object.removeValue(forKey: "protocolVersion") + let invalidPayload = try JSONSerialization.data(withJSONObject: object) + + #expect(throws: (any Error).self) { + try ServiceDescriptor.decodedFromXPC(invalidPayload) + } + } + + @Test + func replacementTicketRoundTrip() throws { + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: UUID() + ) + + #expect(try ServiceReplacementTicket.decodedFromXPC(ticket.encodedForXPC()) == ticket) + } + + @Test + func replacementStatusRoundTrip() throws { + let status = ServiceReplacementStatus(phase: .committed) + + #expect(try ServiceReplacementStatus.decodedFromXPC(status.encodedForXPC()) == status) + } + + private func makeRequirements( + minimumProtocolVersion: ServiceProtocolVersion = ServiceContract.protocolVersion, + requiredCapabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String = SharedConstants.agentBundleIdentifier, + agentVersion: String = "1.0", + agentBuild: String = "1" + ) -> ServiceCompatibilityRequirements { + ServiceCompatibilityRequirements( + minimumProtocolVersion: minimumProtocolVersion, + requiredCapabilities: requiredCapabilities, + agentBundleIdentifier: agentBundleIdentifier, + agentVersion: agentVersion, + agentBuild: agentBuild + ) + } + + private func makeDescriptor( + protocolVersion: ServiceProtocolVersion = ServiceContract.protocolVersion, + capabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String = SharedConstants.agentBundleIdentifier, + agentVersion: String = "1.0", + agentBuild: String = "1", + agentInstanceID: UUID = UUID(), + replacementPhase: ServiceReplacementPhase = .inactive + ) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: protocolVersion, + capabilities: capabilities, + agentBundleIdentifier: agentBundleIdentifier, + agentVersion: agentVersion, + agentBuild: agentBuild, + agentInstanceID: agentInstanceID, + replacementPending: replacementPhase != .inactive, + replacementPhase: replacementPhase + ) + } +} diff --git a/Core/Tests/ClientTests/UnlockStatusPollerTests.swift b/Core/Tests/ClientTests/UnlockStatusPollerTests.swift new file mode 100644 index 0000000..384ac54 --- /dev/null +++ b/Core/Tests/ClientTests/UnlockStatusPollerTests.swift @@ -0,0 +1,208 @@ +@testable import Client +import Foundation +import Testing + +@Suite(.serialized) +struct UnlockStatusPollerTests { + @Test + func returnsImmediatelyWhenAlreadyUnlocked() async throws { + let source = ScriptedStatusSource([.value(false)]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + #expect(fetchCount == 1) + #expect(resetRecorder.count == 0) + #expect(sleepCount == 0) + } + + @Test + func keepsPollingUntilAuthoritativeStateIsUnlocked() async throws { + let source = ScriptedStatusSource([.value(true), .value(false)]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + #expect(fetchCount == 2) + #expect(resetRecorder.count == 0) + #expect(sleepCount == 1) + } + + @Test + func transientFailureResetsConnectionAndCanRecoverAsUnlocked() async throws { + let source = ScriptedStatusSource([ + .failure(.first), + .value(false), + ]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + #expect(fetchCount == 2) + #expect(resetRecorder.count == 1) + #expect(sleepCount == 1) + } + + @Test + func successfulLockedReadClearsConsecutiveFailureCount() async throws { + let source = ScriptedStatusSource([ + .failure(.first), + .value(true), + .failure(.second), + .value(true), + .failure(.third), + .value(false), + ]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + maximumConsecutiveFailures: 2, + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + try await poller.waitUntilUnlocked() + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + #expect(fetchCount == 6) + #expect(resetRecorder.count == 3) + #expect(sleepCount == 5) + } + + @Test + func throwsLastErrorAfterMaximumConsecutiveFailures() async { + let source = ScriptedStatusSource([ + .failure(.first), + .failure(.second), + .failure(.third), + ]) + let resetRecorder = ResetRecorder() + let sleepRecorder = SleepRecorder() + let poller = makePoller( + source: source, + resetRecorder: resetRecorder, + sleepRecorder: sleepRecorder + ) + + do { + try await poller.waitUntilUnlocked() + Issue.record("Expected polling to fail") + } catch { + #expect(error as? TestFailure == .third) + } + + let fetchCount = await source.fetchCount + let sleepCount = await sleepRecorder.count + #expect(fetchCount == 3) + #expect(resetRecorder.count == 3) + #expect(sleepCount == 2) + } + + private func makePoller( + maximumConsecutiveFailures: Int = 3, + source: ScriptedStatusSource, + resetRecorder: ResetRecorder, + sleepRecorder: SleepRecorder + ) -> UnlockStatusPoller { + UnlockStatusPoller( + pollInterval: .seconds(1), + maximumConsecutiveFailures: maximumConsecutiveFailures, + fetchStatus: { + try await source.fetch() + }, + resetConnection: { + resetRecorder.record() + }, + sleep: { _ in + await sleepRecorder.record() + } + ) + } +} + +private actor ScriptedStatusSource { + enum Outcome: Sendable { + case failure(TestFailure) + case value(Bool) + } + + private var outcomes: [Outcome] + private(set) var fetchCount = 0 + + init(_ outcomes: [Outcome]) { + self.outcomes = outcomes + } + + func fetch() throws -> Bool { + fetchCount += 1 + guard !outcomes.isEmpty else { + Issue.record("Status source exhausted") + throw TestFailure.sourceExhausted + } + + switch outcomes.removeFirst() { + case let .failure(error): + throw error + case let .value(isLocked): + return isLocked + } + } +} + +private final class ResetRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedCount = 0 + + var count: Int { + lock.withLock { + recordedCount + } + } + + func record() { + lock.withLock { + recordedCount += 1 + } + } +} + +private actor SleepRecorder { + private(set) var count = 0 + + func record() { + count += 1 + } +} + +private enum TestFailure: Error, Equatable, Sendable { + case first + case second + case sourceExhausted + case third +} diff --git a/Core/Tests/ClientTests/UnlockWaiterTests.swift b/Core/Tests/ClientTests/UnlockWaiterTests.swift new file mode 100644 index 0000000..450fb06 --- /dev/null +++ b/Core/Tests/ClientTests/UnlockWaiterTests.swift @@ -0,0 +1,208 @@ +@testable import Client +import Foundation +import Testing + +@Suite(.serialized) +struct UnlockWaiterTests { + @Test + func notificationUnlockCancelsInFlightPolling() async throws { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + + stateContinuation.yield(false) + try await waitTask.value + + #expect(polling.cancellationCount == 1) + stateContinuation.finish() + } + + @Test + func pollingUnlockCancelsObservation() async throws { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + + polling.succeed() + try await waitTask.value + + #expect(polling.cancellationCount == 0) + stateContinuation.finish() + } + + @Test + func endedObservationKeepsWaitingForPollingFallback() async throws { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + + stateContinuation.finish() + polling.succeed() + + try await waitTask.value + #expect(polling.cancellationCount == 0) + } + + @Test + func pollingFailureIsPropagated() async { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + polling.fail(with: TestFailure.pollFailed) + + do { + try await waitTask.value + Issue.record("Expected polling failure") + } catch { + #expect(error as? TestFailure == .pollFailed) + } + + #expect(polling.cancellationCount == 0) + stateContinuation.finish() + } + + @Test + func cancellingWaitCancelsInFlightPolling() async { + let (stateChanges, stateContinuation) = AsyncStream.makeStream(of: Bool.self) + let polling = ControlledPollingOperation() + let waiter = makeWaiter( + stateChanges: stateChanges, + polling: polling + ) + + let waitTask = Task { + try await waiter.waitUntilUnlocked() + } + await polling.waitUntilStarted() + waitTask.cancel() + + do { + try await waitTask.value + Issue.record("Expected cancellation") + } catch { + #expect(error is CancellationError) + } + + #expect(polling.cancellationCount == 1) + stateContinuation.finish() + } + + private func makeWaiter( + stateChanges: AsyncStream, + polling: ControlledPollingOperation + ) -> UnlockWaiter { + UnlockWaiter( + stateChanges: stateChanges, + pollUntilUnlocked: { + try await polling.run() + }, + cancelPolling: { + polling.cancel() + } + ) + } +} + +private final class ControlledPollingOperation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var didStart = false + private var startWaiters: [CheckedContinuation] = [] + private var recordedCancellationCount = 0 + + var cancellationCount: Int { + lock.withLock { + recordedCancellationCount + } + } + + func run() async throws { + try await withCheckedThrowingContinuation { continuation in + let readyWaiters: [CheckedContinuation] = lock.withLock { + precondition(self.continuation == nil) + self.continuation = continuation + didStart = true + defer { startWaiters.removeAll() } + return startWaiters + } + readyWaiters.forEach { $0.resume() } + } + } + + func waitUntilStarted() async { + await withCheckedContinuation { continuation in + let isAlreadyStarted = lock.withLock { + guard !didStart else { + return true + } + + startWaiters.append(continuation) + return false + } + + if isAlreadyStarted { + continuation.resume() + } + } + } + + func succeed() { + takeContinuation()?.resume() + } + + func fail(with error: Error) { + takeContinuation()?.resume(throwing: error) + } + + func cancel() { + let pendingContinuation: CheckedContinuation? = lock.withLock { + recordedCancellationCount += 1 + defer { continuation = nil } + return continuation + } + pendingContinuation?.resume(throwing: CancellationError()) + } + + private func takeContinuation() -> CheckedContinuation? { + lock.withLock { + defer { continuation = nil } + return continuation + } + } +} + +private enum TestFailure: Error, Equatable { + case pollFailed +} diff --git a/Core/Tests/ClientTests/XPCClientErrorTests.swift b/Core/Tests/ClientTests/XPCClientErrorTests.swift new file mode 100644 index 0000000..5926f59 --- /dev/null +++ b/Core/Tests/ClientTests/XPCClientErrorTests.swift @@ -0,0 +1,198 @@ +@testable import Client +import Foundation +import Testing + +@Suite(.serialized) +struct XPCClientErrorTests { + @Test + func proxyErrorsNormalizeToServiceUnavailable() { + let transportError = NSError( + domain: NSCocoaErrorDomain, + code: CocoaError.xpcConnectionInvalid.rawValue + ) + + guard case XPCClientError.serviceUnavailable = XPCClient.normalizedProxyError(transportError) + else { + Issue.record("Expected the proxy error to normalize to serviceUnavailable.") + return + } + } + + @Test + func interruptedProxyErrorIsLostReplyOnlyForMutations() { + let interruption = NSError( + domain: NSCocoaErrorDomain, + code: CocoaError.xpcConnectionInterrupted.rawValue + ) + + #expect( + XPCClient.normalizedProxyError(interruption, disposition: .lostReply) is LostReplyError + ) + + guard case XPCClientError.serviceUnavailable = XPCClient.normalizedProxyError(interruption) + else { + Issue.record("Expected queries to keep serviceUnavailable for an interruption.") + return + } + } + + @Test + func neverConnectedProxyErrorStaysUnavailableForMutations() { + let invalid = NSError( + domain: NSCocoaErrorDomain, + code: CocoaError.xpcConnectionInvalid.rawValue + ) + + guard case XPCClientError.serviceUnavailable = XPCClient.normalizedProxyError( + invalid, + disposition: .lostReply + ) else { + Issue.record("Expected a never-established connection to report serviceUnavailable.") + return + } + } + + @Test + func serviceUnavailableExplainsFirstUseRecovery() throws { + let suggestion = try #require(XPCClientError.serviceUnavailable.recoverySuggestion) + + #expect(suggestion.contains("Open KeyboardLocker once")) + #expect(suggestion.contains("Show Details")) + } + + @Test + func timeoutExplainsRetryRecovery() throws { + let suggestion = try #require(XPCClientError.timedOut.recoverySuggestion) + + #expect(suggestion.contains("Retry")) + #expect(suggestion.contains("Show Details")) + } + + @Test + func missingCapabilityExplainsAgentUpdateRecovery() throws { + let suggestion = try #require( + XPCClientError.missingCapability(.interactiveLock).recoverySuggestion + ) + + #expect(suggestion.contains("update its background agent")) + } + + @Test + func unknownMutationOutcomeExplainsStateRecovery() throws { + let suggestion = try #require( + XPCClientError.operationOutcomeUnknown.recoverySuggestion + ) + + #expect(suggestion.contains("Inspect the current state")) + #expect(suggestion.contains("repeat the intended lock or unlock action")) + } + + @Test + func featureNegotiationRejectsProtocolMajorBeforeCapabilities() { + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion(major: 2, minor: 99), + capabilities: [] + ) + + #expect(throws: ServiceCompatibilityIssue.protocolMajorMismatch(expected: 1, actual: 2)) { + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.lockToggle] + ) + } + } + + @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 { + guard introductionMinor > 0 else { + continue + } + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion( + major: ServiceContract.protocolVersion.major, + minor: introductionMinor - 1 + ), + capabilities: [capability] + ) + + #expect( + throws: ServiceCompatibilityIssue.protocolMinorTooOld( + required: introductionMinor, + actual: introductionMinor - 1 + ), + "Expected \(capability.rawValue) to require protocol minor \(introductionMinor)." + ) { + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [capability] + ) + } + } + } + + @Test + func featureNegotiationAcceptsDeclaredCapabilityAtIntroductionMinor() throws { + let descriptor = makeDescriptor( + protocolVersion: ServiceProtocolVersion( + major: ServiceContract.protocolVersion.major, + minor: 3 + ), + capabilities: [.interactiveLock] + ) + + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.interactiveLock] + ) + } + + @Test + func featureNegotiationChecksCapabilityAfterCompatibleVersion() { + let descriptor = makeDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: [] + ) + + do { + try XPCFeatureNegotiation.validate( + descriptor: descriptor, + requiring: [.interactiveLock] + ) + Issue.record("Expected missing interactive-lock capability.") + } catch XPCClientError.missingCapability(.interactiveLock) { + // Expected. + } catch { + Issue.record("Expected missing interactive-lock capability, got \(error).") + } + } + + private func makeDescriptor( + protocolVersion: ServiceProtocolVersion, + capabilities: Set + ) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: protocolVersion, + capabilities: capabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: UUID() + ) + } +} diff --git a/Core/Tests/CommonTests/HotkeyDisplayTests.swift b/Core/Tests/CommonTests/HotkeyDisplayTests.swift new file mode 100644 index 0000000..387e41c --- /dev/null +++ b/Core/Tests/CommonTests/HotkeyDisplayTests.swift @@ -0,0 +1,50 @@ +import Common +import CoreGraphics +import Testing + +@MainActor +@Suite(.serialized) +struct HotkeyDisplayTests { + @Test + func displayStringRendersGlyphsForMappedKeyCode() { + let hotkey = KeyboardLockerSettings.Hotkey(keyCode: 37, modifierFlags: [.maskCommand, .maskControl]) + + #expect(!hotkey.displayString.isEmpty) + #expect(!hotkey.displayString.contains("?")) + #expect(hotkey.displayString != "the configured unlock hotkey") + } + + @Test + func displayStringFallsBackToVerbalPhraseForUnmappedKeyCode() { + // 255 is outside the virtual key-code range of any keyboard layout, so UCKeyTranslate + // cannot map it and the verbal fallback must replace the old raw "?" glyph. + let hotkey = KeyboardLockerSettings.Hotkey(keyCode: 255, modifierFlags: [.maskCommand]) + + #expect(hotkey.displayString == "the configured unlock hotkey") + } + + @Test + func concurrentDisplayRequestsSerializeKeyboardLayoutAccess() async { + let hotkey = KeyboardLockerSettings.Hotkey( + keyCode: 37, + modifierFlags: [.maskCommand, .maskControl] + ) + + let values = await withTaskGroup(of: String.self, returning: [String].self) { group in + for _ in 0..<64 { + group.addTask { + hotkey.displayString + } + } + + var values: [String] = [] + for await value in group { + values.append(value) + } + return values + } + + #expect(values.count == 64) + #expect(values.allSatisfy { $0 == "⌃⌘L" }) + } +} diff --git a/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift b/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift new file mode 100644 index 0000000..4dbfc69 --- /dev/null +++ b/Core/Tests/CommonTests/KeyboardLockerSettingsCodingTests.swift @@ -0,0 +1,45 @@ +import Common +import Foundation +import Testing + +@Suite(.serialized) +struct KeyboardLockerSettingsCodingTests { + @Test + func roundTripPreservesSettings() throws { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 42), + unlockHotkey: .init( + keyCode: 12, + modifierFlags: [.maskAlternate, .maskShift] + ) + ) + + #expect(try KeyboardLockerSettings.decodedFromXPC(settings.encodedForXPC()) == settings) + } + + @Test + func missingPayloadIsRejected() { + #expect(throws: KeyboardLockerSettingsCodingError.missingPayload) { + try KeyboardLockerSettings.decodedFromXPC(nil) + } + } + + @Test + func invalidPayloadIsRejected() { + #expect(throws: KeyboardLockerSettingsCodingError.invalidPayload) { + try KeyboardLockerSettings.decodedFromXPC(Data("not-json".utf8)) + } + } + + @Test + func oversizedPayloadIsRejectedBeforeDecoding() { + let payload = Data( + repeating: 0, + count: KeyboardLockerSettings.maximumEncodedSize + 1 + ) + + #expect(throws: KeyboardLockerSettingsCodingError.payloadTooLarge) { + try KeyboardLockerSettings.decodedFromXPC(payload) + } + } +} diff --git a/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift new file mode 100644 index 0000000..e05d812 --- /dev/null +++ b/Core/Tests/CommonTests/LockStatusSnapshotCodingTests.swift @@ -0,0 +1,110 @@ +import Common +import Foundation +import Testing + +@Suite(.serialized) +struct LockStatusSnapshotCodingTests { + private let capturedAt = Date(timeIntervalSinceReferenceDate: 1000) + + @Test + func roundTripPreservesAuthoritativeRuntimeDatesAndSettings() throws { + let startedAt = capturedAt.addingTimeInterval(-10) + let deadline = capturedAt.addingTimeInterval(50) + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init( + keyCode: 12, + modifierFlags: [.maskAlternate, .maskShift] + ) + ) + let snapshot = LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: true, + startedAt: startedAt, + autoUnlockTargetDate: deadline, + settings: settings + ) + + let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) + + #expect(decoded == snapshot) + } + + @Test + func unlockedSnapshotRoundTrip() throws { + let snapshot = LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + + let decoded = try LockStatusSnapshot.decodedFromXPC(snapshot.encodedForXPC()) + + #expect(decoded == snapshot) + } + + @Test + func missingPayloadIsRejected() { + #expect(throws: LockStatusSnapshotCodingError.missingPayload) { + try LockStatusSnapshot.decodedFromXPC(nil) + } + } + + @Test + func invalidPayloadIsRejected() { + #expect(throws: LockStatusSnapshotCodingError.invalidPayload) { + try LockStatusSnapshot.decodedFromXPC(Data("not-json".utf8)) + } + } + + @Test + func unsupportedFormatIsRejected() throws { + let snapshot = LockStatusSnapshot( + formatVersion: LockStatusSnapshot.currentFormatVersion + 1, + capturedAt: capturedAt, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + let payload = try JSONEncoder().encode(snapshot) + + #expect( + throws: LockStatusSnapshotCodingError.unsupportedFormat( + LockStatusSnapshot.currentFormatVersion + 1 + ) + ) { + try LockStatusSnapshot.decodedFromXPC(payload) + } + } + + @Test + func inconsistentUnlockedRuntimeDatesAreRejected() throws { + let snapshot = LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: false, + startedAt: capturedAt, + autoUnlockTargetDate: nil, + settings: .default + ) + let payload = try JSONEncoder().encode(snapshot) + + #expect(throws: LockStatusSnapshotCodingError.invalidPayload) { + try LockStatusSnapshot.decodedFromXPC(payload) + } + } + + @Test + func oversizedPayloadIsRejectedBeforeDecoding() { + let payload = Data( + repeating: 0, + count: LockStatusSnapshot.maximumEncodedSize + 1 + ) + + #expect(throws: LockStatusSnapshotCodingError.payloadTooLarge) { + try LockStatusSnapshot.decodedFromXPC(payload) + } + } +} diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift new file mode 100644 index 0000000..7c76863 --- /dev/null +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementEvaluationTests.swift @@ -0,0 +1,120 @@ +@testable import Common +import Foundation +import Security +import Testing + +/// Evaluates XPC peer requirements against real on-disk code signatures. +/// +/// `XPCCodeSigningRequirementTests` covers requirement-string construction only; these tests +/// prove the fail-closed direction end to end. Using static evaluation (no process execution), +/// code that is not signed by our team — a foreign Apple signature, an ad-hoc signature, or no +/// signature at all — must be rejected by the same Security framework check the Agent's +/// listener performs. A plain `anchor apple` control requirement proves the harness evaluates +/// correctly, so failures are requirement mismatches rather than evaluation errors. +@Suite(.serialized) +struct XPCCodeSigningRequirementEvaluationTests { + // MARK: - Team requirement vs. real signatures + + @Test + func appleSignedSystemToolFailsTeamRequirementButPassesAnchorApple() throws { + let tool = try makeSystemToolCopy() + defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } + + let requirement = try makeTeamRequirement() + + // Control: the copy keeps the embedded Apple signature, so the anchor check passes. + #expect(evaluate("anchor apple", against: tool) == errSecSuccess) + // Rejected: valid Apple signature, but not our Team ID or identifier. + #expect(evaluate(requirement, against: tool) == errSecCSReqFailed) + } + + @Test + func adHocSignedToolFailsTeamRequirement() throws { + let tool = try makeSystemToolCopy() + defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } + try runCodesign(["--force", "--sign", "-", tool.path]) + + let requirement = try makeTeamRequirement() + + // Control: ad-hoc signatures never anchor to Apple. + #expect(evaluate("anchor apple", against: tool) == errSecCSReqFailed) + // Rejected: an ad-hoc signature carries no Apple Team ID. + #expect(evaluate(requirement, against: tool) == errSecCSReqFailed) + } + + @Test + func unsignedToolFailsTeamRequirement() throws { + let tool = try makeSystemToolCopy() + defer { try? FileManager.default.removeItem(at: tool.deletingLastPathComponent()) } + try runCodesign(["--remove-signature", tool.path]) + + let requirement = try makeTeamRequirement() + + // Rejected: unsigned code fails before any requirement clause is even considered. + #expect(evaluate("anchor apple", against: tool) == errSecCSUnsigned) + #expect(evaluate(requirement, against: tool) == errSecCSUnsigned) + } + + // MARK: - Fixtures + + /// Copies the Apple-signed system tool into a fresh per-test directory so tests may mutate + /// its signature freely. The caller removes the directory with `defer`. + private func makeSystemToolCopy() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let copy = directory.appendingPathComponent("ls") + try FileManager.default.copyItem(at: URL(fileURLWithPath: "/bin/ls"), to: copy) + return copy + } + + private func makeTeamRequirement() throws -> String { + try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: ["io.example.tool"] + ) + } + + private func runCodesign(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = arguments + let standardError = Pipe() + process.standardError = standardError + try process.run() + // Drain before reaping so a full pipe can never deadlock the child. + let output = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + process.waitUntilExit() + #expect( + process.terminationStatus == 0, + "codesign \(arguments.joined(separator: " ")) failed: \(output)" + ) + } + + /// Runs the same static requirement evaluation the Agent's listener performs. + private func evaluate(_ requirement: String, against url: URL) -> OSStatus { + var staticCode: SecStaticCode? + let createStatus = SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(), &staticCode) + guard createStatus == errSecSuccess, let staticCode else { + return createStatus + } + + var compiledRequirement: SecRequirement? + let compileStatus = SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ) + guard compileStatus == errSecSuccess, let compiledRequirement else { + return compileStatus + } + + return SecStaticCodeCheckValidity(staticCode, SecCSFlags(), compiledRequirement) + } +} diff --git a/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift new file mode 100644 index 0000000..dd26de3 --- /dev/null +++ b/Core/Tests/CommonTests/XPCCodeSigningRequirementTests.swift @@ -0,0 +1,75 @@ +@testable import Common +import Security +import Testing + +@Suite(.serialized) +struct XPCCodeSigningRequirementTests { + @Test + func authorizedClientIdentifiersAreExactAndNamespaced() { + #expect(SharedConstants.authorizedClientBundleIdentifiers == [ + "io.lzhlovesjyq.keyboardlocker", + "io.lzhlovesjyq.keyboardlocker.focus-intents", + "io.lzhlovesjyq.keyboardlocker.klock", + "io.lzhlovesjyq.keyboardlocker.widgets", + ]) + #expect(SharedConstants.agentBundleIdentifier == "io.lzhlovesjyq.keyboardlocker.agent") + } + + @Test + func requirementUsesExactTeamAndIdentifiersInStableOrder() throws { + let requirement = try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: [ + "io.example.tool", + "io.example.app", + ] + ) + + #expect(requirement == """ + anchor apple generic and certificate leaf[subject.OU] = "A1B2C3D4E5" and \ + (identifier "io.example.app" or identifier "io.example.tool") + """) + + var compiledRequirement: SecRequirement? + #expect( + SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ) == errSecSuccess + ) + #expect(compiledRequirement != nil) + } + + @Test + func requirementRejectsInvalidTeamIdentifiers() { + #expect(throws: (any Error).self) { + try XPCCodeSigningRequirement.make( + teamIdentifier: "too-short", + identifiers: ["io.example.app"] + ) + } + #expect(throws: (any Error).self) { + try XPCCodeSigningRequirement.make( + teamIdentifier: "a1b2c3d4e5", + identifiers: ["io.example.app"] + ) + } + } + + @Test + func requirementRejectsMissingOrUnsafeSigningIdentifiers() { + #expect(throws: (any Error).self) { + try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: [] + ) + } + #expect(throws: (any Error).self) { + try XPCCodeSigningRequirement.make( + teamIdentifier: "A1B2C3D4E5", + identifiers: [#"io.example."injected"#] + ) + } + } +} diff --git a/Core/Tests/ServiceTests/AgentServiceTests.swift b/Core/Tests/ServiceTests/AgentServiceTests.swift new file mode 100644 index 0000000..78ee43f --- /dev/null +++ b/Core/Tests/ServiceTests/AgentServiceTests.swift @@ -0,0 +1,599 @@ +import Common +import Foundation +@testable import Service +import Testing + +@MainActor +@Suite(.serialized) +final class AgentServiceTests { + private let engine: FakeLockEngine + private let scheduler: ManualExpirationScheduler + private let instanceID: UUID + private let customSettings: KeyboardLockerSettings + + init() { + engine = FakeLockEngine() + scheduler = ManualExpirationScheduler() + instanceID = UUID() + customSettings = KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) + ) + } + + // MARK: - Replacement barrier + + @Test + func preparedDrainRejectsNewLockRequests() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + assertReplacementError(lockError, code: 1) + + var interactiveOutcome: Bool? + var interactiveError: Error? + service.lockKeyboardInteractively { outcome, error in + interactiveOutcome = outcome + interactiveError = error + } + #expect(interactiveOutcome == false) + assertReplacementError(interactiveError, code: 1) + + var safetyCheckError: Error? + service.beginSafetyCheck { _, error in safetyCheckError = error } + assertReplacementError(safetyCheckError, code: 1) + + var focusError: Error? + service.setFocusFilterLockEnabled(true) { focusError = $0 } + assertReplacementError(focusError, code: 1) + + #expect(engine.lockCalls.isEmpty) + #expect(engine.focusCalls.isEmpty) + } + + @Test + func drainKeepsUnlockStatusAndSnapshotAvailable() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var unlockError: Error? + service.unlockKeyboard { unlockError = $0 } + #expect(unlockError == nil) + #expect(engine.unlockCallCount == 1) + + var isLocked: Bool? + service.status { locked, _ in isLocked = locked } + #expect(isLocked == false) + + var snapshotData: Data? + var snapshotError: Error? + service.lockStatusSnapshot { data, error in + snapshotData = data + snapshotError = error + } + #expect(snapshotError == nil) + #expect(snapshotData != nil) + } + + @Test + func prepareRejectsMismatchedInstanceID() { + let service = makeService() + + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: UUID() + ) { _, replyError in + error = replyError + } + assertReplacementError(error, code: 8) + + // The transaction stays idle, so a correctly fenced prepare still succeeds. + prepareTicket(on: service, instanceID: instanceID) + } + + @Test + func prepareRejectsLockedEngineUnlessUnlockRequested() { + let lockedEngine = FakeLockEngine(isLocked: true) + let service = makeService(engineOverride: lockedEngine) + + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: instanceID + ) { _, replyError in + error = replyError + } + assertReplacementError(error, code: 3) + #expect(lockedEngine.unlockCallCount == 0) + + let ticket = prepareTicket(on: service, unlockIfNeeded: true, instanceID: instanceID) + #expect(ticket != nil) + #expect(lockedEngine.unlockCallCount == 1) + } + + @Test + func prepareInstallsBarrierBeforeUnlocking() { + let lockedEngine = FakeLockEngine(isLocked: true) + let service = makeService(engineOverride: lockedEngine) + + var pendingDuringUnlock: Bool? + lockedEngine.onUnlock = { [weak service] in + guard let service else { + return + } + pendingDuringUnlock = self.replacementPending(on: service) + } + + prepareTicket(on: service, unlockIfNeeded: true, instanceID: instanceID) + #expect(pendingDuringUnlock == true) + } + + @Test + func secondPrepareIsRejectedAndFirstTicketStillCommits() { + let service = makeService() + let first = prepareTicket(on: service, instanceID: instanceID) + + var secondError: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: instanceID + ) { _, replyError in + secondError = replyError + } + assertReplacementError(secondError, code: 5) + + var commitError: Error? + service.commitReplacement(ticket: encoded(first)) { commitError = $0 } + #expect(commitError == nil) + #expect(phase(of: first, on: service) == .committed) + } + + @Test + func commitWithForeignTicketFails() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + let foreign = ServiceReplacementTicket(id: UUID(), agentInstanceID: instanceID) + var commitError: Error? + service.commitReplacement(ticket: encoded(foreign)) { commitError = $0 } + assertReplacementError(commitError, code: 4) + } + + @Test + func committedDrainCannotBeCancelled() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + + var commitError: Error? + service.commitReplacement(ticket: encoded(ticket)) { commitError = $0 } + #expect(commitError == nil) + + var cancelError: Error? + service.cancelReplacementPreparation(ticket: encoded(ticket)) { cancelError = $0 } + assertReplacementError(cancelError, code: 6) + + // A committed drain rejects every cancel, even for a foreign ticket. + var foreignCancelError: Error? + service.cancelReplacementPreparation( + ticket: encoded(ServiceReplacementTicket(id: UUID(), agentInstanceID: instanceID)) + ) { foreignCancelError = $0 } + assertReplacementError(foreignCancelError, code: 6) + } + + @Test + func cancelWithForeignTicketFailsWhilePrepared() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var error: Error? + service.cancelReplacementPreparation( + ticket: encoded(ServiceReplacementTicket(id: UUID(), agentInstanceID: instanceID)) + ) { error = $0 } + assertReplacementError(error, code: 4) + } + + @Test + func malformedTicketDataSurfacesDecodeError() { + let service = makeService() + + var commitError: Error? + service.commitReplacement(ticket: Data([0x00, 0x01])) { commitError = $0 } + #expect(commitError != nil) + + var statusError: Error? + service.replacementStatus(ticket: Data([0x00, 0x01])) { _, error in + statusError = error + } + #expect(statusError != nil) + } + + // MARK: - Expiration scheduling + + @Test + func commitCancelsScheduledExpiration() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + #expect(scheduler.scheduledCount == 1) + #expect(scheduler.lastInterval == 30) + + var commitError: Error? + service.commitReplacement(ticket: encoded(ticket)) { commitError = $0 } + #expect(commitError == nil) + #expect(scheduler.cancelCount == 1) + + // A canceled expiration must not clear the committed drain. + scheduler.firePending() + #expect(phase(of: ticket, on: service) == .committed) + } + + @Test + func cancelCancelsScheduledExpiration() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + + var cancelError: Error? + service.cancelReplacementPreparation(ticket: encoded(ticket)) { cancelError = $0 } + #expect(cancelError == nil) + #expect(scheduler.cancelCount == 1) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + #expect(lockError == nil) + #expect(engine.lockCalls.count == 1) + } + + @Test + func expirationReturnsAgentToAcceptingLocks() { + let service = makeService() + let ticket = prepareTicket(on: service, instanceID: instanceID) + #expect(replacementPending(on: service) == true) + + scheduler.firePending() + #expect(phase(of: ticket, on: service) == .inactive) + #expect(replacementPending(on: service) == false) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + #expect(lockError == nil) + } + + // MARK: - Wiring + + @Test + func prepareFailsWithoutDescriptor() { + let service = makeService( + descriptorResult: .failure(StubError.descriptorUnavailable) + ) + + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: false, + expectedAgentInstanceID: instanceID + ) { _, replyError in + error = replyError + } + assertReplacementError(error, code: 2) + } + + @Test + func descriptorReportsReplacementPending() { + let service = makeService() + #expect(replacementPending(on: service) == false) + + prepareTicket(on: service, instanceID: instanceID) + #expect(replacementPending(on: service) == true) + } + + @Test + func persistedSettingsSeedEngineAndLockCalls() { + let service = makeService() + #expect(engine.seededSettings == [customSettings]) + + var lockError: Error? + service.lockKeyboard { lockError = $0 } + #expect(lockError == nil) + #expect(engine.lockCalls.count == 1) + #expect(engine.lockCalls.first?.settings == customSettings) + #expect(engine.lockCalls.first?.allowsControlCUnlock == false) + + var outcome: Bool? + service.lockKeyboardInteractively { acquired, _ in outcome = acquired } + #expect(outcome == true) + #expect(engine.lockCalls.last?.allowsControlCUnlock == true) + + var focusError: Error? + service.setFocusFilterLockEnabled(true) { focusError = $0 } + #expect(focusError == nil) + #expect(engine.focusCalls.first?.settings == customSettings) + } + + @Test + func safetyCheckUsesFixedTimedOverrideWithoutChangingPersistedSettings() { + let service = makeService() + + var didStart: Bool? + var safetyError: Error? + service.beginSafetyCheck { started, error in + didStart = started + safetyError = error + } + + #expect(safetyError == nil) + #expect(didStart == true) + #expect( + engine.lockCalls.first?.settings.autoUnlockPolicy == + .timed(seconds: SharedConstants.safetyCheckDuration) + ) + #expect(engine.lockCalls.first?.settings.unlockHotkey == customSettings.unlockHotkey) + #expect(engine.lockCalls.first?.allowsControlCUnlock == false) + + engine.unlock() + var lockError: Error? + service.lockKeyboard { lockError = $0 } + #expect(lockError == nil) + #expect(engine.lockCalls.last?.settings == customSettings) + } + + @Test + func safetyCheckReportsAnExistingLockWithoutChangingIt() { + engine.lockOutcome = .alreadyLocked + let service = makeService() + + var didStart: Bool? + var safetyError: Error? + service.beginSafetyCheck { started, error in + didStart = started + safetyError = error + } + + #expect(safetyError == nil) + #expect(didStart == false) + #expect(engine.unlockCallCount == 0) + } + + // MARK: - Toggle + + @Test + func toggleLocksUnlockedEngineWithPersistedSettings() { + let service = makeService() + + var isLocked: Bool? + var toggleError: Error? + service.toggleKeyboard { locked, error in + isLocked = locked + toggleError = error + } + + #expect(toggleError == nil) + #expect(isLocked == true) + #expect(engine.lockCalls.count == 1) + #expect(engine.lockCalls.first?.settings == customSettings) + #expect(engine.lockCalls.first?.allowsControlCUnlock == false) + #expect(engine.unlockCallCount == 0) + } + + @Test + func toggleUnlocksLockedEngine() { + let lockedEngine = FakeLockEngine(isLocked: true) + let service = makeService(engineOverride: lockedEngine) + + var isLocked: Bool? + var toggleError: Error? + service.toggleKeyboard { locked, error in + isLocked = locked + toggleError = error + } + + #expect(toggleError == nil) + #expect(isLocked == false) + #expect(lockedEngine.unlockCallCount == 1) + #expect(lockedEngine.lockCalls.isEmpty) + } + + @Test + func togglePropagatesLockFailureWithoutChangingState() { + engine.lockError = StubError.descriptorUnavailable + let service = makeService() + + var isLocked: Bool? + var toggleError: Error? + service.toggleKeyboard { locked, error in + isLocked = locked + toggleError = error + } + + #expect(toggleError as? StubError == .descriptorUnavailable) + #expect(isLocked == false) + #expect(!engine.isLocked) + } + + @Test + func preparedDrainRejectsOnlyTheLockDirectionOfToggle() { + let service = makeService() + prepareTicket(on: service, instanceID: instanceID) + + var toggleError: Error? + service.toggleKeyboard { _, error in toggleError = error } + assertReplacementError(toggleError, code: 1) + #expect(engine.lockCalls.isEmpty) + #expect(engine.unlockCallCount == 0) + } + + // MARK: - Fixtures + + private func makeService( + descriptorResult: Result? = nil, + engineOverride: FakeLockEngine? = nil + ) -> AgentService { + AgentService( + descriptorResult: descriptorResult ?? .success(makeDescriptor(instanceID: instanceID)), + settings: customSettings, + engine: engineOverride ?? engine, + expirationScheduler: scheduler.scheduler + ) + } + + private func makeDescriptor(instanceID: UUID) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "100", + agentInstanceID: instanceID, + replacementPending: false, + replacementPhase: .inactive + ) + } + + @discardableResult + private func prepareTicket( + on service: AgentService, + unlockIfNeeded: Bool = false, + instanceID: UUID + ) -> ServiceReplacementTicket? { + var data: Data? + var error: Error? + service.prepareForReplacement( + unlockIfNeeded: unlockIfNeeded, + expectedAgentInstanceID: instanceID + ) { ticketData, replyError in + data = ticketData + error = replyError + } + #expect(error == nil) + return data.flatMap { try? ServiceReplacementTicket.decodedFromXPC($0) } + } + + private func encoded(_ ticket: ServiceReplacementTicket?) -> Data { + (try? ticket?.encodedForXPC()) ?? Data() + } + + private func phase( + of ticket: ServiceReplacementTicket?, + on service: AgentService + ) -> ServiceReplacementPhase? { + var data: Data? + service.replacementStatus(ticket: encoded(ticket)) { statusData, _ in + data = statusData + } + return data.flatMap { try? ServiceReplacementStatus.decodedFromXPC($0) }?.phase + } + + private func replacementPending(on service: AgentService) -> Bool? { + var data: Data? + service.serviceDescriptor { descriptorData, _ in + data = descriptorData + } + return data.flatMap { try? ServiceDescriptor.decodedFromXPC($0) }?.replacementPending + } + + private func assertReplacementError( + _ error: Error?, + code: Int, + sourceLocation: SourceLocation = #_sourceLocation + ) { + let nsError = error as NSError? + #expect( + nsError?.domain == "\(SharedConstants.agentBundleIdentifier).replacement", + sourceLocation: sourceLocation + ) + #expect(nsError?.code == code, sourceLocation: sourceLocation) + } +} + +private enum StubError: Error { + case descriptorUnavailable +} + +@MainActor +private final class FakeLockEngine: LockEngineServing { + var isLocked: Bool + var lockError: Error? + var lockOutcome: LockRequestOutcome = .acquired + var onUnlock: (() -> Void)? + + private let snapshot: LockStatusSnapshot + private(set) var lockCalls: + [(settings: KeyboardLockerSettings, allowsControlCUnlock: Bool)] = [] + private(set) var unlockCallCount = 0 + private(set) var focusCalls: [(enabled: Bool, settings: KeyboardLockerSettings)] = [] + private(set) var seededSettings: [KeyboardLockerSettings] = [] + + init(isLocked: Bool = false) { + self.isLocked = isLocked + snapshot = LockStatusSnapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: 0), + isLocked: isLocked, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + } + + var statusSnapshot: LockStatusSnapshot { + snapshot + } + + func lock( + settings: KeyboardLockerSettings, + allowsControlCUnlock: Bool + ) throws -> LockRequestOutcome { + lockCalls.append((settings, allowsControlCUnlock)) + if let lockError { + throw lockError + } + if lockOutcome == .acquired { + isLocked = true + } + return lockOutcome + } + + func setFocusFilterLockEnabled( + _ enabled: Bool, + settings: KeyboardLockerSettings + ) throws { + focusCalls.append((enabled, settings)) + } + + func unlock() { + unlockCallCount += 1 + isLocked = false + onUnlock?() + } + + func updateSettings(_ settings: KeyboardLockerSettings) { + seededSettings.append(settings) + } +} + +@MainActor +private final class ManualExpirationScheduler { + private(set) var scheduledCount = 0 + private(set) var lastInterval: TimeInterval? + private(set) var cancelCount = 0 + private var pendingFire: (@MainActor @Sendable () -> Void)? + + var scheduler: MainActorTimerScheduler { + { interval, fire in + self.scheduledCount += 1 + self.lastInterval = interval + self.pendingFire = fire + return { [weak self] in + guard let self else { + return + } + cancelCount += 1 + pendingFire = nil + } + } + } + + @MainActor + func firePending() { + pendingFire?() + } +} diff --git a/Core/Tests/ServiceTests/EventTapInstallationTests.swift b/Core/Tests/ServiceTests/EventTapInstallationTests.swift new file mode 100644 index 0000000..17d238b --- /dev/null +++ b/Core/Tests/ServiceTests/EventTapInstallationTests.swift @@ -0,0 +1,118 @@ +@testable import Service +import Testing + +@Suite(.serialized) +struct EventTapInstallationTests { + @Test + func missingTapHasNoResourcesToRollBack() { + var actions: [String] = [] + + #expect(throws: EventTapInstallationError.eventTapCreationFailed) { + try makeInstallation( + actions: &actions, + tap: nil, + source: 2, + isEnabled: true + ) + } + #expect(actions == ["createTap"]) + } + + @Test + func missingSourceInvalidatesCreatedTap() { + var actions: [String] = [] + + #expect(throws: EventTapInstallationError.runLoopSourceCreationFailed) { + try makeInstallation( + actions: &actions, + tap: 1, + source: nil, + isEnabled: true + ) + } + #expect(actions == ["createTap", "createSource", "invalidateTap"]) + } + + @Test + func disabledTapDetachesSourceBeforeInvalidatingTap() { + var actions: [String] = [] + + #expect(throws: EventTapInstallationError.eventTapEnableFailed) { + try makeInstallation( + actions: &actions, + tap: 1, + source: 2, + isEnabled: false + ) + } + #expect( + actions == [ + "createTap", + "createSource", + "attachSource", + "enableTap", + "isTapEnabled", + "detachSource", + "invalidateTap", + ] + ) + } + + @Test + func successfulInstallationTransfersResourceOwnership() throws { + var actions: [String] = [] + + let installation = try makeInstallation( + actions: &actions, + tap: 1, + source: 2, + isEnabled: true + ) + + #expect(installation.tap == 1) + #expect(installation.source == 2) + #expect( + actions == [ + "createTap", + "createSource", + "attachSource", + "enableTap", + "isTapEnabled", + ] + ) + } + + private func makeInstallation( + actions: inout [String], + tap: Int?, + source: Int?, + isEnabled: Bool + ) throws -> EventTapInstallation { + try EventTapInstallation.make( + createTap: { + actions.append("createTap") + return tap + }, + createSource: { _ in + actions.append("createSource") + return source + }, + attachSource: { _ in + actions.append("attachSource") + }, + enableTap: { _ in + actions.append("enableTap") + }, + isTapEnabled: { _ in + actions.append("isTapEnabled") + return isEnabled + }, + detachSource: { _ in + actions.append("detachSource") + }, + invalidateTap: { _ in + actions.append("invalidateTap") + } + ) + } +} diff --git a/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift new file mode 100644 index 0000000..84758af --- /dev/null +++ b/Core/Tests/ServiceTests/KeyboardLockerSettingsStoreTests.swift @@ -0,0 +1,74 @@ +import Common +import Foundation +@testable import Service +import Testing + +@Suite(.serialized) +final class KeyboardLockerSettingsStoreTests { + private let suiteName: String + private let defaults: UserDefaults + + init() { + suiteName = "KeyboardLockerSettingsStoreTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + } + + private func withCleanDefaults(_ body: () throws -> Void) rethrows { + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + try body() + } + + @Test + func freshStoreRegistersAndLoadsDefaults() { + withCleanDefaults { + _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(defaults.data(forKey: "settings") != nil) + + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(store.load() == .default) + } + } + + @Test + func loadReturnsPersistedSettings() throws { + try withCleanDefaults { + let custom = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 120), + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: 4, + modifierFlags: [.maskControl, .maskAlternate] + ) + ) + try defaults.set(JSONEncoder().encode(custom), forKey: "settings") + + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(store.load() == custom) + } + } + + @Test + func corruptPayloadFallsBackToDefaults() { + withCleanDefaults { + defaults.set(Data([0xFF, 0xFE, 0x00]), forKey: "settings") + + let store = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + #expect(store.load() == .default) + } + } + + @Test + func registrationDoesNotOverwriteExistingPayload() throws { + try withCleanDefaults { + let custom = KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) + ) + try defaults.set(JSONEncoder().encode(custom), forKey: "settings") + + _ = KeyboardLockerSettingsStore(userDefaults: defaults, storageKey: "settings") + let persisted = try #require(defaults.data(forKey: "settings")) + #expect(try JSONDecoder().decode(KeyboardLockerSettings.self, from: persisted) == custom) + } + } +} diff --git a/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift new file mode 100644 index 0000000..6258505 --- /dev/null +++ b/Core/Tests/ServiceTests/LockEngineAutoUnlockScheduleTests.swift @@ -0,0 +1,84 @@ +import Foundation +@testable import Service +import Testing + +@Suite(.serialized) +struct LockEngineAutoUnlockScheduleTests { + private let initialDate = Date(timeIntervalSinceReferenceDate: 1000) + + @Test + func initialScheduleUsesTimeoutForDeadlineAndDelay() throws { + let schedule = try #require( + AutoUnlockSchedule.make( + timeout: 60, + referenceDate: initialDate, + currentDate: initialDate + ) + ) + + #expect(schedule.deadline == initialDate.addingTimeInterval(60)) + #expect(schedule.delay == 60) + } + + @Test + func explicitRearmStartsANewTimeoutWindow() throws { + let initialSchedule = try #require( + AutoUnlockSchedule.make( + timeout: 60, + referenceDate: initialDate, + currentDate: initialDate + ) + ) + let rearmDate = initialDate.addingTimeInterval(15) + let rearmedSchedule = try #require( + AutoUnlockSchedule.make( + timeout: 60, + referenceDate: rearmDate, + currentDate: rearmDate + ) + ) + + #expect(rearmedSchedule.deadline == rearmDate.addingTimeInterval(60)) + #expect(rearmedSchedule.delay == 60) + #expect( + rearmedSchedule.deadline.timeIntervalSince(initialSchedule.deadline) == 15 + ) + } + + @Test + func pastDeadlineSchedulesImmediateUnlock() throws { + let schedule = try #require( + AutoUnlockSchedule.make( + timeout: 10, + referenceDate: initialDate, + currentDate: initialDate.addingTimeInterval(15) + ) + ) + + #expect(schedule.deadline == initialDate.addingTimeInterval(10)) + #expect(schedule.delay == 0) + } + + @Test + func invalidTimeoutDoesNotCreateSchedule() { + let invalidTimeouts: [TimeInterval?] = [ + nil, + 0, + -1, + .nan, + .infinity, + -.infinity, + ] + + for timeout in invalidTimeouts { + #expect( + AutoUnlockSchedule.make( + timeout: timeout, + referenceDate: initialDate, + currentDate: initialDate + ) == nil, + "Expected \(String(describing: timeout)) to be rejected" + ) + } + } +} diff --git a/Core/Tests/ServiceTests/LockEngineTests.swift b/Core/Tests/ServiceTests/LockEngineTests.swift new file mode 100644 index 0000000..fc19d14 --- /dev/null +++ b/Core/Tests/ServiceTests/LockEngineTests.swift @@ -0,0 +1,408 @@ +import Common +import CoreGraphics +import Foundation +@testable import Service +import Testing + +@MainActor +@Suite(.serialized) +final class LockEngineTests { + private let tap: FakeInstalledEventTap + private let scheduler: ManualTimerScheduler + private var installCount: Int + private var broadcastCount: Int + private var stateChangeCount: Int + private var hasAccessibilityPermission: Bool + private let now: Date + + init() { + tap = FakeInstalledEventTap() + scheduler = ManualTimerScheduler() + installCount = 0 + broadcastCount = 0 + stateChangeCount = 0 + hasAccessibilityPermission = true + now = Date(timeIntervalSinceReferenceDate: 1000) + } + + // MARK: - Acquisition + + @Test + func lockWithoutAccessibilityPermissionThrowsBeforeInstallingTap() { + hasAccessibilityPermission = false + let engine = makeEngine() + + #expect(throws: LockEngine.LockEngineError.accessibilityPermissionDenied) { + try engine.lock(settings: makeSettings()) + } + #expect(installCount == 0) + #expect(!engine.isLocked) + #expect(broadcastCount == 0) + } + + @Test + func lockInstallsTapSchedulesAutoUnlockAndBroadcasts() throws { + let engine = makeEngine() + + let outcome = try engine.lock(settings: makeSettings()) + + #expect(outcome == .acquired) + #expect(engine.isLocked) + #expect(installCount == 1) + #expect(broadcastCount == 1) + #expect(scheduler.timers.map(\.interval) == [60]) + #expect(engine.statusSnapshot.autoUnlockTargetDate == now.addingTimeInterval(60)) + } + + @Test + func duplicateLockNeverMutatesTheRunningLock() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + let duplicate = try engine.lock( + settings: makeSettings(autoUnlockPolicy: .timed(seconds: 120)) + ) + + #expect(duplicate == .alreadyLocked) + #expect(installCount == 1) + #expect(scheduler.timers.count == 1) + #expect(broadcastCount == 1) + #expect(engine.statusSnapshot.autoUnlockTargetDate == now.addingTimeInterval(60)) + } + + // MARK: - Auto-unlock scheduling + + @Test + func autoUnlockTimerFireUnlocks() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + scheduler.timers[0].fire() + + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) + } + + @Test + func staleTimerFireKeepsNewerLock() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + let staleTimer = scheduler.timers[0] + + engine.unlock() + #expect(staleTimer.isCancelled) + + _ = try engine.lock(settings: makeSettings()) + #expect(scheduler.timers.count == 2) + + // A cancel that lost the race against an already-queued fire must not unlock the new lock. + staleTimer.fire() + #expect(engine.isLocked) + } + + @Test + func updateSettingsWhileLockedRearmsAutoUnlock() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + engine.updateSettings(makeSettings(autoUnlockPolicy: .timed(seconds: 120))) + + #expect(scheduler.timers.map(\.interval) == [60, 120]) + #expect(scheduler.timers[0].isCancelled) + #expect(!scheduler.timers[1].isCancelled) + #expect(engine.statusSnapshot.autoUnlockTargetDate == now.addingTimeInterval(120)) + } + + @Test + func updateSettingsWhileUnlockedSchedulesNoTimer() { + let engine = makeEngine() + + engine.updateSettings(makeSettings()) + + #expect(scheduler.timers.isEmpty) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) + } + + @Test + func lockWithDisabledAutoUnlockSchedulesNoTimer() throws { + let engine = makeEngine() + + _ = try engine.lock(settings: makeSettings(autoUnlockPolicy: .disabled)) + + #expect(engine.isLocked) + #expect(scheduler.timers.isEmpty) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) + } + + // MARK: - Unlock gestures + + @Test + func unlockHotkeyEventUnlocksAfterDispatch() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + let event = makeKeyEvent(keyCode: 4, flags: .maskShift) + #expect(engine.handleEvent(type: .keyDown, event: event) == nil) + #expect(engine.isLocked) + + await flushMainQueue() + + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) + } + + @Test + func unrelatedKeyEventIsConsumedWithoutUnlocking() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + let event = makeKeyEvent(keyCode: 40) + #expect(engine.handleEvent(type: .keyDown, event: event) == nil) + + await flushMainQueue() + + #expect(engine.isLocked) + #expect(tap.teardownCallCount == 0) + } + + @Test + func controlCUnlocksOnlyWhenAllowed() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings(), allowsControlCUnlock: false) + + let controlC = makeKeyEvent( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: .maskControl + ) + #expect(engine.handleEvent(type: .keyDown, event: controlC) == nil) + await flushMainQueue() + #expect(engine.isLocked) + + engine.unlock() + _ = try engine.lock(settings: makeSettings(), allowsControlCUnlock: true) + #expect(engine.handleEvent(type: .keyDown, event: controlC) == nil) + await flushMainQueue() + #expect(!engine.isLocked) + } + + // MARK: - Event tap failure + + @Test + func tapDisabledAndReenabledKeepsLock() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + tap.isEnabled = false + let event = makeKeyEvent(keyCode: 0) + #expect(engine.handleDisabledEvent(event) != nil) + + await flushMainQueue() + + #expect(engine.isLocked) + #expect(tap.setEnabledCalls == [true]) + #expect(tap.teardownCallCount == 0) + #expect(broadcastCount == 1) + } + + @Test + func tapDisabledBeyondRecoveryUnlocksFailOpen() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + tap.isEnabled = false + tap.allowsReEnable = false + let event = makeKeyEvent(keyCode: 0) + #expect(engine.handleDisabledEvent(event) != nil) + #expect(engine.isLocked) + + await flushMainQueue() + + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) + } + + @Test + func staleTapFailureKeepsNewerLock() async throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + tap.isEnabled = false + tap.allowsReEnable = false + let event = makeKeyEvent(keyCode: 0) + #expect(engine.handleDisabledEvent(event) != nil) + + engine.unlock() + tap.isEnabled = true + tap.allowsReEnable = true + _ = try engine.lock(settings: makeSettings()) + + await flushMainQueue() + + #expect(engine.isLocked) + } + + // MARK: - Unlock + + @Test + func unlockCancelsTimerTearsDownAndBroadcasts() throws { + let engine = makeEngine() + _ = try engine.lock(settings: makeSettings()) + + engine.unlock() + + #expect(!engine.isLocked) + #expect(scheduler.timers[0].isCancelled) + #expect(tap.teardownCallCount == 1) + #expect(broadcastCount == 2) + #expect(engine.statusSnapshot.autoUnlockTargetDate == nil) + } + + @Test + func stateChangeHandlerFollowsCommittedLockTransitions() throws { + let engine = makeEngine() + + _ = try engine.lock(settings: makeSettings()) + engine.unlock() + + #expect(stateChangeCount == 2) + } + + // MARK: - Focus ownership + + @Test + func focusDeactivationReleasesFocusOwnedLock() throws { + let engine = makeEngine() + try engine.setFocusFilterLockEnabled(true, settings: makeSettings()) + #expect(engine.isLocked) + + try engine.setFocusFilterLockEnabled(false, settings: makeSettings()) + + #expect(!engine.isLocked) + #expect(tap.teardownCallCount == 1) + } + + @Test + func focusDeactivationKeepsLockTakenOverByGeneralRequest() throws { + let engine = makeEngine() + try engine.setFocusFilterLockEnabled(true, settings: makeSettings()) + + // An explicit lock takes persistence over from the Focus-created generation. + let takeover = try engine.lock(settings: makeSettings()) + #expect(takeover == .alreadyLocked) + + try engine.setFocusFilterLockEnabled(false, settings: makeSettings()) + + #expect(engine.isLocked) + #expect(tap.teardownCallCount == 0) + } + + // MARK: - Helpers + + private func makeEngine() -> LockEngine { + let engine = LockEngine(dependencies: LockEngineDependencies( + hasAccessibilityPermission: { self.hasAccessibilityPermission }, + installEventTap: { _ in + self.installCount += 1 + return self.tap + }, + scheduleTimer: scheduler.scheduler, + broadcastStateChange: { self.broadcastCount += 1 }, + now: { self.now } + )) + engine.setStateChangeHandler { self.stateChangeCount += 1 } + return engine + } + + private func makeSettings( + autoUnlockPolicy: KeyboardLockerSettings.AutoUnlockPolicy = .timed(seconds: 60) + ) -> KeyboardLockerSettings { + KeyboardLockerSettings( + autoUnlockPolicy: autoUnlockPolicy, + unlockHotkey: KeyboardLockerSettings.Hotkey(keyCode: 4, modifierFlags: .maskShift) + ) + } + + private func makeKeyEvent( + keyCode: CGKeyCode, + flags: CGEventFlags = [] + ) -> CGEvent { + // Fabrication only builds an in-memory event value; nothing is posted to the HID system. + // Bind the result to a local when asserting on a handler's return: handlers hand the event + // back unretained, so it must outlive the returned reference. + let event = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)! + event.flags = flags + return event + } + + /// Runs the main-queue hop the engine uses to dispatch unlock and tap-failure work. + private func flushMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } +} + +private final class FakeInstalledEventTap: InstalledEventTap { + var isEnabled = true + var allowsReEnable = true + private(set) var setEnabledCalls: [Bool] = [] + private(set) var teardownCallCount = 0 + + func setEnabled(_ enabled: Bool) { + setEnabledCalls.append(enabled) + guard enabled else { + isEnabled = false + return + } + if allowsReEnable { + isEnabled = true + } + } + + func teardown() { + teardownCallCount += 1 + isEnabled = false + } +} + +/// Manual scheduler whose timers stay fireable after cancellation: a cancelled dispatch block +/// may already be in flight, and the engine's generation guard must reject exactly that stale +/// fire, so tests need to replay it. +@MainActor +private final class ManualTimerScheduler { + final class Timer { + let interval: TimeInterval + private let fireAction: @MainActor @Sendable () -> Void + private(set) var isCancelled = false + + init(interval: TimeInterval, fire: @escaping @MainActor @Sendable () -> Void) { + self.interval = interval + fireAction = fire + } + + func cancel() { + isCancelled = true + } + + @MainActor + func fire() { + fireAction() + } + } + + private(set) var timers: [Timer] = [] + + var scheduler: MainActorTimerScheduler { + { interval, fire in + let timer = Timer(interval: interval, fire: fire) + self.timers.append(timer) + return { timer.cancel() } + } + } +} diff --git a/Core/Tests/ServiceTests/LockRuntimeStateTests.swift b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift new file mode 100644 index 0000000..98e8b41 --- /dev/null +++ b/Core/Tests/ServiceTests/LockRuntimeStateTests.swift @@ -0,0 +1,322 @@ +import Common +import Foundation +@testable import Service +import Testing + +@Suite(.serialized) +struct LockRuntimeStateTests { + @Test + func interactiveDuplicateDoesNotEnableControlCForExistingLock() { + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + + #expect( + state.begin( + settings: .default, + allowsControlCUnlock: false, + at: startedAt + ) == .acquired + ) + #expect( + state.begin( + settings: .default, + allowsControlCUnlock: true, + at: startedAt.addingTimeInterval(1) + ) == .alreadyLocked + ) + + #expect(!state.allowsControlCUnlock) + #expect(state.startedAt == startedAt) + } + + @Test + func duplicateBeginPreservesOriginalRuntimeStateAndDeadline() { + let originalSettings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let replacementSettings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 300), + unlockHotkey: .init(keyCode: 13, modifierFlags: [.maskControl]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + + #expect( + state.begin( + settings: originalSettings, + allowsControlCUnlock: true, + at: startedAt + ) == .acquired + ) + state.setAutoUnlockTargetDate(deadline) + + #expect( + state.begin( + settings: replacementSettings, + allowsControlCUnlock: false, + at: startedAt.addingTimeInterval(10) + ) == .alreadyLocked + ) + #expect(state.activeSettings == originalSettings) + #expect(state.allowsControlCUnlock) + #expect(state.startedAt == startedAt) + #expect(state.autoUnlockTargetDate == deadline) + #expect(state.isLocked) + } + + @Test + func endedLockCanBeginAgainWithoutRetainingTimingState() { + let firstStart = Date(timeIntervalSinceReferenceDate: 1000) + let secondStart = firstStart.addingTimeInterval(90) + var state = LockRuntimeState() + + #expect( + state.begin( + settings: .default, + allowsControlCUnlock: true, + at: firstStart + ) == .acquired + ) + state.setAutoUnlockTargetDate(firstStart.addingTimeInterval(60)) + state.end() + + #expect(!state.allowsControlCUnlock) + #expect(!state.isLocked) + #expect(state.startedAt == nil) + #expect(state.autoUnlockTargetDate == nil) + #expect( + state.begin( + settings: .default, + allowsControlCUnlock: false, + at: secondStart + ) == .acquired + ) + #expect(!state.allowsControlCUnlock) + #expect(state.startedAt == secondStart) + } + + @Test + func newFocusLockOwnsItsGeneration() throws { + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + + #expect( + state.begin( + settings: .default, + allowsControlCUnlock: false, + at: startedAt + ) == .acquired + ) + state.markCurrentLockAsFocusOwned() + + let generation = try #require(state.lockGeneration) + #expect(state.focusOwnedGenerationForRelease == generation) + #expect(state.matchesCurrentLockGeneration(generation)) + } + + @Test + func focusDuplicateDoesNotClaimExistingGeneralLock() throws { + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: startedAt + ) + let generation = try #require(state.lockGeneration) + + #expect( + state.handleDuplicateLockRequest(from: .focusFilter) == .alreadyLocked + ) + + #expect(state.focusOwnedGenerationForRelease == nil) + #expect(state.lockGeneration == generation) + #expect(state.startedAt == startedAt) + } + + @Test + func repeatedFocusEnablePreservesOwnedRuntimeState() { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + _ = state.begin( + settings: settings, + allowsControlCUnlock: false, + at: startedAt + ) + state.markCurrentLockAsFocusOwned() + state.setAutoUnlockTargetDate(deadline) + let originalState = state + + #expect( + state.handleDuplicateLockRequest(from: .focusFilter) == .alreadyLocked + ) + + #expect(state == originalState) + } + + @Test + func manualDesiredLockTakesOverFocusOwnershipWithoutChangingRuntimeState() throws { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + + #expect( + state.begin( + settings: settings, + allowsControlCUnlock: false, + at: startedAt + ) == .acquired + ) + state.markCurrentLockAsFocusOwned() + state.setAutoUnlockTargetDate(deadline) + let generation = try #require(state.lockGeneration) + + #expect( + state.handleDuplicateLockRequest(from: .general) == .alreadyLocked + ) + + #expect(state.focusOwnedLockGeneration == nil) + #expect(state.lockGeneration == generation) + #expect(state.activeSettings == settings) + #expect(!state.allowsControlCUnlock) + #expect(state.startedAt == startedAt) + #expect(state.autoUnlockTargetDate == deadline) + #expect(state.isLocked) + } + + @Test + func endingFocusOwnedLockClearsOwnershipAndNextLockUsesNewGeneration() throws { + let firstStart = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: firstStart + ) + state.markCurrentLockAsFocusOwned() + let firstGeneration = try #require(state.lockGeneration) + + #expect(state.focusOwnedLockGeneration == firstGeneration) + + state.end() + + #expect(state.focusOwnedLockGeneration == nil) + #expect(state.lockGeneration == nil) + + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: firstStart.addingTimeInterval(1) + ) + + #expect(state.lockGeneration != firstGeneration) + #expect(state.focusOwnedLockGeneration == nil) + #expect(state.focusOwnedGenerationForRelease == nil) + #expect(!state.matchesCurrentLockGeneration(firstGeneration)) + } + + @Test + func earlyUnlockClearsFocusReleaseTarget() throws { + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: Date(timeIntervalSinceReferenceDate: 1000) + ) + state.markCurrentLockAsFocusOwned() + let generation = try #require(state.focusOwnedGenerationForRelease) + + state.end() + + #expect(state.focusOwnedGenerationForRelease == nil) + #expect(!state.matchesCurrentLockGeneration(generation)) + } + + @Test + func staleFocusReleaseCannotMatchLaterLockGeneration() throws { + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: Date(timeIntervalSinceReferenceDate: 1000) + ) + state.markCurrentLockAsFocusOwned() + let focusGeneration = try #require(state.focusOwnedGenerationForRelease) + + state.end() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: Date(timeIntervalSinceReferenceDate: 1001) + ) + + #expect(!state.matchesCurrentLockGeneration(focusGeneration)) + #expect(state.focusOwnedGenerationForRelease == nil) + } + + @Test + func snapshotCapturesOneCoherentRuntimeState() { + let settings = KeyboardLockerSettings( + autoUnlockPolicy: .timed(seconds: 60), + unlockHotkey: .init(keyCode: 12, modifierFlags: [.maskCommand]) + ) + let startedAt = Date(timeIntervalSinceReferenceDate: 1000) + let capturedAt = startedAt.addingTimeInterval(10) + let deadline = startedAt.addingTimeInterval(60) + var state = LockRuntimeState() + + #expect( + state.begin( + settings: settings, + allowsControlCUnlock: false, + at: startedAt + ) == .acquired + ) + state.setAutoUnlockTargetDate(deadline) + + #expect( + state.statusSnapshot(capturedAt: capturedAt) == LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: true, + startedAt: startedAt, + autoUnlockTargetDate: deadline, + settings: settings + ) + ) + } + + @Test + func snapshotClearsRuntimeDatesAfterUnlock() { + let capturedAt = Date(timeIntervalSinceReferenceDate: 1000) + var state = LockRuntimeState() + _ = state.begin( + settings: .default, + allowsControlCUnlock: false, + at: capturedAt.addingTimeInterval(-10) + ) + state.setAutoUnlockTargetDate(capturedAt.addingTimeInterval(50)) + state.end() + + #expect( + state.statusSnapshot(capturedAt: capturedAt) == LockStatusSnapshot( + capturedAt: capturedAt, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + ) + } +} diff --git a/Core/Tests/ServiceTests/LockStatusNotifierTests.swift b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift new file mode 100644 index 0000000..9ccea38 --- /dev/null +++ b/Core/Tests/ServiceTests/LockStatusNotifierTests.swift @@ -0,0 +1,200 @@ +import Common +import Foundation +@testable import Service +import Testing + +@MainActor +@Suite(.serialized) +final class LockStatusNotifierTests { + private let notifications: FakeLockStatusNotifications + private var snapshot: LockStatusSnapshot + private var unlockCalls: Int + + init() { + notifications = FakeLockStatusNotifications() + snapshot = Self.makeSnapshot(isLocked: false) + unlockCalls = 0 + } + + @Test + func startInstallsCategoryAndClearsStaleNotification() async { + let notifier = makeNotifier() + + notifier.start() + await notifier.waitUntilIdle() + + #expect(notifications.installedCategoryCount == 1) + #expect(notifications.removedIdentifiers == [LockStatusNotifier.notificationIdentifier]) + } + + @Test + func lockTransitionPostsNotificationWithHotkeyAndDeadline() async throws { + snapshot = Self.makeSnapshot( + isLocked: true, + autoUnlockTargetDate: Date(timeIntervalSinceReferenceDate: 1060) + ) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + #expect(notifications.authorizationRequests == 1) + #expect(notifications.posts.count == 1) + let post = try #require(notifications.posts.first) + #expect(post.identifier == LockStatusNotifier.notificationIdentifier) + #expect(post.categoryIdentifier == LockStatusNotifier.categoryIdentifier) + #expect(post.title == "Keyboard Locked") + #expect(post.body.contains("⌃⌘L"), "body: \(post.body)") + #expect(post.body.contains("Unlock Now"), "body: \(post.body)") + #expect(post.body.contains("Auto-unlocks at"), "body: \(post.body)") + #expect(notifications.removedIdentifiers.isEmpty) + } + + @Test + func lockTransitionWithoutDeadlineOmitsAutoUnlockLine() async throws { + snapshot = Self.makeSnapshot(isLocked: true, autoUnlockTargetDate: nil) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + let post = try #require(notifications.posts.first) + #expect(!post.body.contains("Auto-unlocks"), "body: \(post.body)") + } + + @Test + func unlockTransitionRemovesNotificationWithoutRequestingAuthorization() async { + snapshot = Self.makeSnapshot(isLocked: false) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + #expect(notifications.removedIdentifiers == [LockStatusNotifier.notificationIdentifier]) + #expect(notifications.authorizationRequests == 0) + #expect(notifications.posts.isEmpty) + } + + @Test + func deniedAuthorizationSkipsPostSilently() async { + notifications.authorizationGranted = false + snapshot = Self.makeSnapshot(isLocked: true) + let notifier = makeNotifier() + + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + #expect(notifications.authorizationRequests == 1) + #expect(notifications.posts.isEmpty) + } + + @Test + func unlockActionRunsLocalUnlock() async { + _ = makeNotifier() + + notifications.actionHandler?(LockStatusNotifier.unlockActionIdentifier) + await waitForCondition { self.unlockCalls == 1 } + + #expect(unlockCalls == 1) + } + + @Test + func unrelatedActionIsIgnored() async { + _ = makeNotifier() + + notifications.actionHandler?("some-other-action") + await waitForCondition { false } + + #expect(unlockCalls == 0) + } + + /// A rapid lock/unlock flip must serialize post before remove; the remove cannot overtake + /// the in-flight post and leave a stale notification behind. + @Test + func quickFlipSerializesPostThenRemove() async { + let notifier = makeNotifier() + + snapshot = Self.makeSnapshot(isLocked: true) + notifier.lockStateDidChange() + snapshot = Self.makeSnapshot(isLocked: false) + notifier.lockStateDidChange() + await notifier.waitUntilIdle() + + #expect(notifications.events == [ + .post, + .remove(LockStatusNotifier.notificationIdentifier), + ]) + } + + // MARK: - Helpers + + private func makeNotifier() -> LockStatusNotifier { + LockStatusNotifier( + notifications: notifications, + snapshot: { [self] in snapshot }, + unlock: { [self] in unlockCalls += 1 } + ) + } + + private static func makeSnapshot( + isLocked: Bool, + autoUnlockTargetDate: Date? = nil + ) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: Date(timeIntervalSinceReferenceDate: 1000), + isLocked: isLocked, + startedAt: isLocked ? Date(timeIntervalSinceReferenceDate: 1000) : nil, + autoUnlockTargetDate: autoUnlockTargetDate, + settings: .default + ) + } + + /// The action handler hops through a `Task` onto the main actor, so tests poll with a + /// generous ceiling: the hop lands in microseconds on a healthy machine, and only a truly + /// broken hop burns the full budget before failing. + private func waitForCondition( + timeout: TimeInterval = 1, + _ condition: @MainActor () -> Bool + ) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + await Task.yield() + } + } +} + +@MainActor +private final class FakeLockStatusNotifications: LockStatusNotifying { + enum Event: Equatable { + case post + case remove(String) + } + + var actionHandler: (@Sendable (String) -> Void)? + var authorizationGranted = true + + private(set) var installedCategoryCount = 0 + private(set) var authorizationRequests = 0 + private(set) var posts: [(identifier: String, categoryIdentifier: String, title: String, body: String)] = [] + private(set) var removedIdentifiers: [String] = [] + private(set) var events: [Event] = [] + + func installUnlockActionCategory() { + installedCategoryCount += 1 + } + + func requestAlertAuthorization() async -> Bool { + authorizationRequests += 1 + return authorizationGranted + } + + func postLocked(identifier: String, categoryIdentifier: String, title: String, body: String) { + posts.append((identifier, categoryIdentifier, title, body)) + events.append(.post) + } + + func removeLocked(identifier: String) { + removedIdentifiers.append(identifier) + events.append(.remove(identifier)) + } +} diff --git a/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift b/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift new file mode 100644 index 0000000..f8b094e --- /dev/null +++ b/Core/Tests/ServiceTests/LockedKeyboardEventPolicyTests.swift @@ -0,0 +1,145 @@ +import AppKit +import CoreGraphics +import IOKit +@testable import Service +import Testing + +@Suite(.serialized) +struct LockedKeyboardEventPolicyTests { + @Test + func suppressesStandardKeyboardEvents() { + for type in [CGEventType.keyDown, .keyUp, .flagsChanged] { + #expect(LockedKeyboardEventPolicy.shouldSuppress(type: type)) + } + } + + @Test + func suppressesKeyboardSystemControls() { + let subtypes = [ + NX_SUBTYPE_AUX_CONTROL_BUTTONS, + NX_SUBTYPE_EJECT_KEY, + NX_SUBTYPE_POWER_KEY, + ] + + for subtype in subtypes { + #expect( + LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: Int16(subtype) + ) + ) + } + } + + @Test + @MainActor + func readsSystemDefinedSubtypeFromEventPayload() throws { + let keyboardControl = try makeSystemDefinedEvent( + subtype: NX_SUBTYPE_AUX_CONTROL_BUTTONS + ) + let auxiliaryMouseButton = try makeSystemDefinedEvent( + subtype: NX_SUBTYPE_AUX_MOUSE_BUTTONS + ) + + #expect( + keyboardControl.type == LockedKeyboardEventPolicy.systemDefinedEventType + ) + #expect( + LockedKeyboardEventPolicy.shouldSuppress( + type: keyboardControl.type, + event: keyboardControl + ) + ) + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( + type: auxiliaryMouseButton.type, + event: auxiliaryMouseButton + ) + ) + } + + @Test + func preservesPointerAndUnclassifiedSystemEvents() { + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: Int16(NX_SUBTYPE_AUX_MOUSE_BUTTONS) + ) + ) + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: Int16(NX_SUBTYPE_SLEEP_EVENT) + ) + ) + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: nil + ) + ) + #expect( + !LockedKeyboardEventPolicy.shouldSuppress( + type: LockedKeyboardEventPolicy.systemDefinedEventType, + systemDefinedSubtype: .max + ) + ) + } + + @Test + func preservesPointerEventTypes() { + for type in [ + CGEventType.leftMouseDown, + .otherMouseDown, + .rightMouseDown, + .scrollWheel, + ] { + #expect(!LockedKeyboardEventPolicy.shouldSuppress(type: type)) + } + } + + @Test + @MainActor + func eventTapObservesEverySuppressibleEventClassWithoutObservingPointers() { + for type in [ + CGEventType.keyDown, + .keyUp, + .flagsChanged, + LockedKeyboardEventPolicy.systemDefinedEventType, + ] { + #expect(eventMaskContains(type)) + } + + for type in [ + CGEventType.leftMouseDown, + .otherMouseDown, + .rightMouseDown, + .scrollWheel, + ] { + #expect(!eventMaskContains(type)) + } + } + + @MainActor + private func eventMaskContains(_ type: CGEventType) -> Bool { + LockEngine.eventMasks & (CGEventMask(1) << type.rawValue) != 0 + } + + @MainActor + private func makeSystemDefinedEvent(subtype: Int32) throws -> CGEvent { + let event = try #require( + NSEvent.otherEvent( + with: .systemDefined, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: Int16(subtype), + data1: 0, + data2: 0 + ) + ) + return try #require(event.cgEvent) + } +} diff --git a/Core/Tests/ServiceTests/ReplacementTransactionTests.swift b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift new file mode 100644 index 0000000..76325db --- /dev/null +++ b/Core/Tests/ServiceTests/ReplacementTransactionTests.swift @@ -0,0 +1,97 @@ +import Common +import Foundation +@testable import Service +import Testing + +@Suite(.serialized) +struct ReplacementTransactionTests { + @Test + func prepareIsExclusive() throws { + var transaction = ReplacementTransaction() + let first = makeTicket() + + _ = try transaction.prepare(ticket: first) + + #expect(transaction.isPending) + #expect(throws: ReplacementTransactionError.alreadyInProgress) { + try transaction.prepare(ticket: makeTicket()) + } + } + + @Test + func preparedTransactionCanBeCancelledOnlyByItsOwner() throws { + var transaction = ReplacementTransaction() + let owner = makeTicket() + _ = try transaction.prepare(ticket: owner) + + #expect(throws: ReplacementTransactionError.ticketInactive) { + try transaction.cancel(ticket: makeTicket()) + } + + try transaction.cancel(ticket: owner) + #expect(!transaction.isPending) + } + + @Test + func preparationExpires() throws { + var transaction = ReplacementTransaction() + let preparation = try transaction.prepare(ticket: makeTicket()) + + #expect(transaction.status(for: preparation.ticket).phase == .prepared) + #expect(transaction.status(for: makeTicket()).phase == .inactive) + let didExpire = transaction.expire(preparation: preparation) + #expect(didExpire) + #expect(transaction.status(for: preparation.ticket).phase == .inactive) + #expect(!transaction.isPending) + } + + @Test + func staleExpirationCannotClearNewPreparation() throws { + var transaction = ReplacementTransaction() + let first = try transaction.prepare(ticket: makeTicket()) + try transaction.cancel(ticket: first.ticket) + let second = try transaction.prepare(ticket: makeTicket()) + + let didExpireFirst = transaction.expire(preparation: first) + #expect(!didExpireFirst) + #expect(transaction.isPending) + let didExpireSecond = transaction.expire(preparation: second) + #expect(didExpireSecond) + } + + @Test + func commitIsIdempotentAndNonExpiring() throws { + var transaction = ReplacementTransaction() + let preparation = try transaction.prepare(ticket: makeTicket()) + + try transaction.commit(ticket: preparation.ticket) + try transaction.commit(ticket: preparation.ticket) + + #expect(transaction.isPending) + #expect(transaction.isCommitted) + #expect( + transaction.status(for: preparation.ticket).phase == .committed + ) + let didExpire = transaction.expire(preparation: preparation) + #expect(!didExpire) + #expect(throws: ReplacementTransactionError.committedCannotCancel) { + try transaction.cancel(ticket: preparation.ticket) + } + #expect(transaction.isPending) + } + + @Test + func wrongTicketCannotCommit() throws { + var transaction = ReplacementTransaction() + _ = try transaction.prepare(ticket: makeTicket()) + + #expect(throws: ReplacementTransactionError.ticketInactive) { + try transaction.commit(ticket: makeTicket()) + } + #expect(!transaction.isCommitted) + } + + private func makeTicket() -> ServiceReplacementTicket { + ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + } +} diff --git a/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift b/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift new file mode 100644 index 0000000..f6761f0 --- /dev/null +++ b/Core/Tests/ServiceTests/UnlockGestureMatcherTests.swift @@ -0,0 +1,104 @@ +import Common +import CoreGraphics +@testable import Service +import Testing + +@Suite(.serialized) +struct UnlockGestureMatcherTests { + private let configuredHotkey = KeyboardLockerSettings.Hotkey( + keyCode: 37, + modifierFlags: [.maskControl, .maskCommand] + ) + + @Test + func configuredHotkeyAlwaysUnlocks() { + #expect( + matches( + keyCode: configuredHotkey.keyCode, + flags: configuredHotkey.modifierFlags, + allowsControlCUnlock: false + ) + ) + } + + @Test + func controlCUnlocksInteractiveLock() { + #expect( + matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: true + ) + ) + } + + @Test + func controlCDoesNotUnlockNonInteractiveLock() { + #expect( + !matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: false + ) + ) + } + + @Test + func controlCRejectsAdditionalRelevantModifiers() { + #expect( + !matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl, .maskCommand], + allowsControlCUnlock: true + ) + ) + } + + @Test + func controlCIgnoresCapsLock() { + #expect( + matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl, .maskAlphaShift], + allowsControlCUnlock: true + ) + ) + } + + @Test + func autoRepeatAndKeyUpNeverUnlock() { + #expect( + !matches( + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: true, + isAutoRepeat: true + ) + ) + #expect( + !matches( + type: .keyUp, + keyCode: UnlockGestureMatcher.controlCKeyCode, + flags: [.maskControl], + allowsControlCUnlock: true + ) + ) + } + + private func matches( + type: CGEventType = .keyDown, + keyCode: CGKeyCode, + flags: CGEventFlags, + allowsControlCUnlock: Bool, + isAutoRepeat: Bool = false + ) -> Bool { + UnlockGestureMatcher.matches( + type: type, + keyCode: keyCode, + flags: flags, + isAutoRepeat: isAutoRepeat, + configuredHotkey: configuredHotkey, + allowsControlCUnlock: allowsControlCUnlock + ) + } +} diff --git a/Core/Tests/ServiceTests/XPCAccessControlTests.swift b/Core/Tests/ServiceTests/XPCAccessControlTests.swift new file mode 100644 index 0000000..624f42e --- /dev/null +++ b/Core/Tests/ServiceTests/XPCAccessControlTests.swift @@ -0,0 +1,43 @@ +@testable import Common +import Security +import Service +import Testing + +/// The listener's client requirement must fail closed on any host that cannot prove our +/// Team ID, and must name every authorized client whenever construction is possible. +@Suite(.serialized) +struct XPCAccessControlTests { + @Test + func authorizedClientRequirementFailsClosedWithoutAppleTeamSignature() { + do { + let requirement = try XPCAccessControl.authorizedClientRequirement() + + // Reachable only from a team-signed test host: construction succeeds, and the + // requirement must still name every authorized client and compile. Both branches are + // acceptable because each proves a distinct half of the contract — a signed host builds + // the exact requirement, an unsigned host can build none. + for identifier in SharedConstants.authorizedClientBundleIdentifiers { + #expect(requirement.contains(#"identifier "\#(identifier)""#)) + } + var compiledRequirement: SecRequirement? + #expect( + SecRequirementCreateWithString( + requirement as CFString, + SecCSFlags(), + &compiledRequirement + ) == errSecSuccess + ) + #expect(compiledRequirement != nil) + } catch let error as XPCCodeSigningRequirementError { + // `swift test` binaries are ad-hoc signed, so the process has no Apple Team ID and the + // requirement must fail closed rather than emit something an unauthorized client could + // satisfy. + guard case .missingTeamIdentifier = error else { + Issue.record("Expected missingTeamIdentifier, got \(error)") + return + } + } catch { + Issue.record(error, "Unexpected error") + } + } +} diff --git a/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift b/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift new file mode 100644 index 0000000..0b851c4 --- /dev/null +++ b/Core/Tests/SystemSurfacesTests/LockStateSurfaceInvalidatorTests.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import SystemSurfaces +import Testing + +@Suite(.serialized) +struct LockStateSurfaceInvalidatorTests { + @Test + func surfaceKindsRemainStable() { + #expect( + KeyboardLockerSurfaceKind.statusWidget == "io.lzhlovesjyq.keyboardlocker.status" + ) + #expect( + KeyboardLockerSurfaceKind.keyboardLockControl == "io.lzhlovesjyq.keyboardlocker.control" + ) + } + + @Test + func invalidationReloadsWidgetThenControlExactlyOnce() { + let recorder = CallRecorder() + let invalidator = LockStateSurfaceInvalidator( + reloadWidget: { + recorder.record(.widget) + }, + reloadControl: { + recorder.record(.control) + } + ) + + invalidator.invalidate() + + #expect(recorder.calls == [.widget, .control]) + } +} + +private final class CallRecorder: @unchecked Sendable { + enum Call: Equatable { + case control + case widget + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} diff --git a/KeyboardLocker.xcodeproj/project.pbxproj b/KeyboardLocker.xcodeproj/project.pbxproj index adbc606..06d1ca3 100644 --- a/KeyboardLocker.xcodeproj/project.pbxproj +++ b/KeyboardLocker.xcodeproj/project.pbxproj @@ -7,14 +7,37 @@ objects = { /* Begin PBXBuildFile section */ + A1000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000A /* Client */; }; + A1000000000000000000000D /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000E /* SystemSurfaces */; }; + B2000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = B2000000000000000000000A /* Client */; }; + B2000000000000000000000C /* KeyboardLockerWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + B20000000000000000000012 /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = B20000000000000000000013 /* SystemSurfaces */; }; + D4000000000000000000000B /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = D4000000000000000000000A /* Client */; }; + D4000000000000000000000C /* KeyboardLockerFocusIntents.appex in Embed App Intents Extensions */ = {isa = PBXBuildFile; fileRef = D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D40000000000000000000012 /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = D40000000000000000000013 /* SystemSurfaces */; }; F21304CF2ECDC2E200735580 /* KeyboardLockerAgent.app in Copy Agent App */ = {isa = PBXBuildFile; fileRef = F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F21304D32ECDC51F00735580 /* klock in Copy CLI */ = {isa = PBXBuildFile; fileRef = F21304B42ECDC26400735580 /* klock */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFCFE2EEFAF0B0065F041 /* Client */; }; F2BFFD012EEFAF1A0065F041 /* Service in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD002EEFAF1A0065F041 /* Service */; }; F2BFFD032EEFAF200065F041 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD022EEFAF200065F041 /* Client */; }; + F2BFFD042EEFAF210065F041 /* SystemSurfaces in Frameworks */ = {isa = PBXBuildFile; productRef = F2BFFD052EEFAF210065F041 /* SystemSurfaces */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + B2000000000000000000000E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F21304202ECDAD3500735580 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B20000000000000000000003; + remoteInfo = KeyboardLockerWidgets; + }; + D4000000000000000000000E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F21304202ECDAD3500735580 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D40000000000000000000003; + remoteInfo = KeyboardLockerFocusIntents; + }; F21304D42ECDC53D00735580 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = F21304202ECDAD3500735580 /* Project object */; @@ -32,6 +55,28 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + B2000000000000000000000D /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + B2000000000000000000000C /* KeyboardLockerWidgets.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + D4000000000000000000000D /* Embed App Intents Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + D4000000000000000000000C /* KeyboardLockerFocusIntents.appex in Embed App Intents Extensions */, + ); + name = "Embed App Intents Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; F21304B22ECDC26400735580 /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -76,6 +121,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A10000000000000000000001 /* KeyboardLockerModelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KeyboardLockerModelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000001 /* KeyboardLockerWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KeyboardLockerWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = KeyboardLockerFocusIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F21304282ECDAD3500735580 /* KeyboardLocker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLocker.app; sourceTree = BUILT_PRODUCTS_DIR; }; F21304362ECDADC900735580 /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyboardLockerAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -83,9 +131,61 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A1000000000000000000000C /* Exceptions for "KeyboardLocker" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + Info.plist, + io.lzhlovesjyq.keyboardlocker.agent.plist, + KeyboardLocker.sdef, + KeyboardLockerApplication.swift, + StatusMenuController.swift, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; + B20000000000000000000010 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = B20000000000000000000003 /* KeyboardLockerWidgets */; + }; + B20000000000000000000011 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + KeyboardLockerControl.swift, + KeyboardLockerStatusWidget.swift, + KeyboardLockerWidgetsBundle.swift, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; + C30000000000000000000001 /* Exceptions for "klock" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + main.swift, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; + D40000000000000000000010 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerFocusIntents" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = D40000000000000000000003 /* KeyboardLockerFocusIntents */; + }; + D40000000000000000000011 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerModelTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + AppIntentsExtension.swift, + Info.plist, + ); + target = A10000000000000000000003 /* KeyboardLockerModelTests */; + }; F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Info.plist, io.lzhlovesjyq.keyboardlocker.agent.plist, ); target = F21304272ECDAD3500735580 /* KeyboardLocker */; @@ -106,11 +206,35 @@ /* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000002 /* KeyboardLockerModelTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = KeyboardLockerModelTests; + sourceTree = ""; + }; + B20000000000000000000002 /* KeyboardLockerWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + B20000000000000000000010 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerWidgets" target */, + B20000000000000000000011 /* Exceptions for "KeyboardLockerWidgets" folder in "KeyboardLockerModelTests" target */, + ); + path = KeyboardLockerWidgets; + sourceTree = ""; + }; + D40000000000000000000002 /* KeyboardLockerFocusIntents */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + D40000000000000000000010 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerFocusIntents" target */, + D40000000000000000000011 /* Exceptions for "KeyboardLockerFocusIntents" folder in "KeyboardLockerModelTests" target */, + ); + path = KeyboardLockerFocusIntents; + sourceTree = ""; + }; F213042A2ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( F213055D2ECE3D5B00735580 /* Exceptions for "KeyboardLocker" folder in "KeyboardLocker" target */, F213055A2ECE3CC500735580 /* Exceptions for "KeyboardLocker" folder in "Copy Agent Plist" phase from "KeyboardLocker" target */, + A1000000000000000000000C /* Exceptions for "KeyboardLocker" folder in "KeyboardLockerModelTests" target */, ); path = KeyboardLocker; sourceTree = ""; @@ -122,17 +246,48 @@ }; F21304B52ECDC26400735580 /* klock */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C30000000000000000000001 /* Exceptions for "klock" folder in "KeyboardLockerModelTests" target */, + ); path = klock; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000000B /* Client in Frameworks */, + A1000000000000000000000D /* SystemSurfaces in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B20000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B2000000000000000000000B /* Client in Frameworks */, + B20000000000000000000012 /* SystemSurfaces in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D40000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D4000000000000000000000B /* Client in Frameworks */, + D40000000000000000000012 /* SystemSurfaces in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304252ECDAD3500735580 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( F2BFFCFF2EEFAF0B0065F041 /* Client in Frameworks */, + F2BFFD042EEFAF210065F041 /* SystemSurfaces in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -160,8 +315,11 @@ children = ( F21304362ECDADC900735580 /* Core */, F213042A2ECDAD3500735580 /* KeyboardLocker */, + D40000000000000000000002 /* KeyboardLockerFocusIntents */, + B20000000000000000000002 /* KeyboardLockerWidgets */, F213049C2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B52ECDC26400735580 /* klock */, + A10000000000000000000002 /* KeyboardLockerModelTests */, F21304292ECDAD3500735580 /* Products */, ); sourceTree = ""; @@ -170,8 +328,11 @@ isa = PBXGroup; children = ( F21304282ECDAD3500735580 /* KeyboardLocker.app */, + D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */, + B20000000000000000000001 /* KeyboardLockerWidgets.appex */, F213049B2ECDC1AA00735580 /* KeyboardLockerAgent.app */, F21304B42ECDC26400735580 /* klock */, + A10000000000000000000001 /* KeyboardLockerModelTests.xctest */, ); name = Products; sourceTree = ""; @@ -179,6 +340,82 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + A10000000000000000000003 /* KeyboardLockerModelTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */; + buildPhases = ( + A10000000000000000000004 /* Sources */, + A10000000000000000000005 /* Frameworks */, + A10000000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000002 /* KeyboardLockerModelTests */, + B20000000000000000000002 /* KeyboardLockerWidgets */, + D40000000000000000000002 /* KeyboardLockerFocusIntents */, + F213042A2ECDAD3500735580 /* KeyboardLocker */, + F21304B52ECDC26400735580 /* klock */, + ); + name = KeyboardLockerModelTests; + packageProductDependencies = ( + A1000000000000000000000A /* Client */, + A1000000000000000000000E /* SystemSurfaces */, + ); + productName = KeyboardLockerModelTests; + productReference = A10000000000000000000001 /* KeyboardLockerModelTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + B20000000000000000000003 /* KeyboardLockerWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = B20000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerWidgets" */; + buildPhases = ( + B20000000000000000000004 /* Sources */, + B20000000000000000000005 /* Frameworks */, + B20000000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + B20000000000000000000002 /* KeyboardLockerWidgets */, + ); + name = KeyboardLockerWidgets; + packageProductDependencies = ( + B2000000000000000000000A /* Client */, + B20000000000000000000013 /* SystemSurfaces */, + ); + productName = KeyboardLockerWidgets; + productReference = B20000000000000000000001 /* KeyboardLockerWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + D40000000000000000000003 /* KeyboardLockerFocusIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = D40000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerFocusIntents" */; + buildPhases = ( + D40000000000000000000004 /* Sources */, + D40000000000000000000005 /* Frameworks */, + D40000000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D40000000000000000000002 /* KeyboardLockerFocusIntents */, + ); + name = KeyboardLockerFocusIntents; + packageProductDependencies = ( + D4000000000000000000000A /* Client */, + D40000000000000000000013 /* SystemSurfaces */, + ); + productName = KeyboardLockerFocusIntents; + productReference = D40000000000000000000001 /* KeyboardLockerFocusIntents.appex */; + productType = "com.apple.product-type.extensionkit-extension"; + }; F21304272ECDAD3500735580 /* KeyboardLocker */ = { isa = PBXNativeTarget; buildConfigurationList = F21304332ECDAD3600735580 /* Build configuration list for PBXNativeTarget "KeyboardLocker" */; @@ -189,12 +426,16 @@ F21305582ECE3C9B00735580 /* Copy Agent Plist */, F21304C02ECDC2A400735580 /* Copy Agent App */, F21304D22ECDC50400735580 /* Copy CLI */, + B2000000000000000000000D /* Embed App Extensions */, + D4000000000000000000000D /* Embed App Intents Extensions */, ); buildRules = ( ); dependencies = ( F21304D52ECDC53D00735580 /* PBXTargetDependency */, F21304D72ECDC53D00735580 /* PBXTargetDependency */, + B2000000000000000000000F /* PBXTargetDependency */, + D4000000000000000000000F /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( F213042A2ECDAD3500735580 /* KeyboardLocker */, @@ -202,6 +443,7 @@ name = KeyboardLocker; packageProductDependencies = ( F2BFFCFE2EEFAF0B0065F041 /* Client */, + F2BFFD052EEFAF210065F041 /* SystemSurfaces */, ); productName = KeyboardLocker; productReference = F21304282ECDAD3500735580 /* KeyboardLocker.app */; @@ -263,6 +505,15 @@ LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 2620; TargetAttributes = { + A10000000000000000000003 = { + CreatedOnToolsVersion = 26.2; + }; + B20000000000000000000003 = { + CreatedOnToolsVersion = 26.3; + }; + D40000000000000000000003 = { + CreatedOnToolsVersion = 26.3; + }; F21304272ECDAD3500735580 = { CreatedOnToolsVersion = 26.1.1; }; @@ -289,13 +540,37 @@ projectRoot = ""; targets = ( F21304272ECDAD3500735580 /* KeyboardLocker */, + D40000000000000000000003 /* KeyboardLockerFocusIntents */, + B20000000000000000000003 /* KeyboardLockerWidgets */, F213049A2ECDC1AA00735580 /* KeyboardLockerAgent */, F21304B32ECDC26400735580 /* klock */, + A10000000000000000000003 /* KeyboardLockerModelTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + A10000000000000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B20000000000000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D40000000000000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304262ECDAD3500735580 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -313,6 +588,27 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + A10000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B20000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D40000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; F21304242ECDAD3500735580 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -337,6 +633,16 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + B2000000000000000000000F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B20000000000000000000003 /* KeyboardLockerWidgets */; + targetProxy = B2000000000000000000000E /* PBXContainerItemProxy */; + }; + D4000000000000000000000F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D40000000000000000000003 /* KeyboardLockerFocusIntents */; + targetProxy = D4000000000000000000000E /* PBXContainerItemProxy */; + }; F21304D52ECDC53D00735580 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = F213049A2ECDC1AA00735580 /* KeyboardLockerAgent */; @@ -350,6 +656,160 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + A10000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = V65YCRQZ2M; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + }; + name = Debug; + }; + A10000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = ""; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = V65YCRQZ2M; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.modeltests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = ""; + }; + name = Release; + }; + B20000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + B20000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + D40000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerFocusIntents/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.lzhlovesjyq.keyboardlocker.focus-intents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + D40000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = V65YCRQZ2M; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLockerFocusIntents/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.lzhlovesjyq.keyboardlocker.focus-intents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; F21304312ECDAD3600735580 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -486,10 +946,8 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLocker/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -521,10 +979,8 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KeyboardLocker/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -556,6 +1012,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ""; + INFOPLIST_KEY_CFBundleDisplayName = KeyboardLocker; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -570,6 +1027,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.agent; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -593,6 +1051,7 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ""; + INFOPLIST_KEY_CFBundleDisplayName = KeyboardLocker; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -607,6 +1066,7 @@ PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.agent; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -620,12 +1080,17 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -636,12 +1101,17 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V65YCRQZ2M; ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.lzhlovesjyq.keyboardlocker.klock; PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; @@ -651,6 +1121,33 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A10000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerModelTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000000000000000000007 /* Debug */, + A10000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B20000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B20000000000000000000007 /* Debug */, + B20000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D40000000000000000000009 /* Build configuration list for PBXNativeTarget "KeyboardLockerFocusIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D40000000000000000000007 /* Debug */, + D40000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; F21304232ECDAD3500735580 /* Build configuration list for PBXProject "KeyboardLocker" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -690,6 +1187,30 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + A1000000000000000000000A /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; + A1000000000000000000000E /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; + B2000000000000000000000A /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; + B20000000000000000000013 /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; + D4000000000000000000000A /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; + D40000000000000000000013 /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; F2BFFCFE2EEFAF0B0065F041 /* Client */ = { isa = XCSwiftPackageProductDependency; productName = Client; @@ -702,6 +1223,10 @@ isa = XCSwiftPackageProductDependency; productName = Client; }; + F2BFFD052EEFAF210065F041 /* SystemSurfaces */ = { + isa = XCSwiftPackageProductDependency; + productName = SystemSurfaces; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F21304202ECDAD3500735580 /* Project object */; diff --git a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme index d61dbec..b688f6f 100644 --- a/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme +++ b/KeyboardLocker.xcodeproj/xcshareddata/xcschemes/KeyboardLocker.xcscheme @@ -29,6 +29,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + - - - - - - - - - - - - LockRequestOutcome + func lock() async throws + func unlock() async throws + func waitUntilUnlocked() async throws + func requestAccessibilityPermission() async throws + func resetConnection() +} + +/// One-shot lock capabilities used by system automation wrappers. +@MainActor +protocol AgentLockActionServing: Sendable { + func lock() async throws + func unlock() async throws + func status() async throws -> Bool + /// Atomically flips the global lock in the Agent and returns the resulting state. + func toggle() async throws -> Bool +} + +@MainActor +protocol AgentReadinessServing: Sendable { + func serviceDescriptor() async throws -> ServiceDescriptor + func status() async throws -> Bool + func hasAccessibilityPermission() async throws -> Bool + func resetConnection() +} + +@MainActor +protocol AgentReplacementServing: Sendable { + func serviceDescriptor() async throws -> ServiceDescriptor + func unlock() async throws + func status() async throws -> Bool + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket + func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws + func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws + func resetConnection() +} + +@MainActor +protocol AgentLockStateObserving: Sendable { + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken +} + +/// Complete live Client surface retained by `AppCoordinator`. +/// +/// Coordinators depend on the narrower protocols above, so future fakes only implement the +/// external capability under test instead of the entire App Client. +@MainActor +protocol AgentClientServing: + AgentControlServing, + AgentLockActionServing, + AgentReadinessServing, + AgentReplacementServing {} + +@MainActor +struct LiveAgentClient: AgentClientServing { + typealias Mutation = @Sendable () async throws -> Void + typealias SafetyCheckMutation = @Sendable () async throws -> LockRequestOutcome + typealias StatusQuery = @Sendable () async throws -> Bool + typealias ToggleMutation = @Sendable () async throws -> Bool + + private let beginSafetyCheckMutation: SafetyCheckMutation + private let lockMutation: Mutation + private let statusQuery: StatusQuery + private let surfaceInvalidator: LockStateSurfaceInvalidator + private let toggleMutation: ToggleMutation + private let unlockMutation: Mutation + private let waitUntilUnlockedOperation: Mutation + + nonisolated init() { + self.init( + lock: { + try await XPCClient.shared.lock() + }, + unlock: { + try await XPCClient.shared.unlock() + }, + status: { + try await XPCClient.shared.status() + }, + toggle: { + try await XPCClient.shared.toggle() + }, + beginSafetyCheck: { + try await XPCClient.shared.beginSafetyCheck() + }, + waitUntilUnlocked: { + try await XPCClient.shared.waitUntilUnlocked() + }, + surfaceInvalidator: .live + ) + } + + nonisolated init( + lock: @escaping Mutation, + unlock: @escaping Mutation, + status: @escaping StatusQuery, + toggle: @escaping ToggleMutation, + beginSafetyCheck: @escaping SafetyCheckMutation = { + try await XPCClient.shared.beginSafetyCheck() + }, + waitUntilUnlocked: @escaping Mutation = { + try await XPCClient.shared.waitUntilUnlocked() + }, + surfaceInvalidator: LockStateSurfaceInvalidator + ) { + beginSafetyCheckMutation = beginSafetyCheck + lockMutation = lock + unlockMutation = unlock + statusQuery = status + toggleMutation = toggle + waitUntilUnlockedOperation = waitUntilUnlocked + self.surfaceInvalidator = surfaceInvalidator + } + + func serviceDescriptor() async throws -> ServiceDescriptor { + try await XPCClient.shared.serviceDescriptor() + } + + func lock() async throws { + try await lockMutation() + surfaceInvalidator.invalidate() + } + + func beginSafetyCheck() async throws -> LockRequestOutcome { + let outcome = try await beginSafetyCheckMutation() + if outcome == .acquired { + surfaceInvalidator.invalidate() + } + return outcome + } + + func unlock() async throws { + try await unlockMutation() + surfaceInvalidator.invalidate() + } + + func status() async throws -> Bool { + try await statusQuery() + } + + func toggle() async throws -> Bool { + let isLocked = try await toggleMutation() + surfaceInvalidator.invalidate() + return isLocked + } + + func waitUntilUnlocked() async throws { + try await waitUntilUnlockedOperation() + } + + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + let ticket = try await XPCClient.shared.prepareForReplacement( + unlockIfNeeded: unlockIfNeeded, + expectedAgentInstanceID: expectedAgentInstanceID + ) + if unlockIfNeeded { + surfaceInvalidator.invalidate() + } + return ticket + } + + func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws { + try await XPCClient.shared.cancelReplacementPreparation(ticket: ticket) + } + + func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws { + try await XPCClient.shared.commitReplacement(ticket: ticket) + } + + func hasAccessibilityPermission() async throws -> Bool { + try await XPCClient.shared.hasAccessibilityPermission() + } + + func requestAccessibilityPermission() async throws { + try await XPCClient.shared.requestAccessibilityPermission() + } + + func resetConnection() { + XPCClient.shared.resetConnection() + } +} + +@MainActor +struct LiveAgentLockStateObserver: AgentLockStateObserving { + private let observer: any AgentLockStateObserving + private let surfaceInvalidator: LockStateSurfaceInvalidator + + init() { + self.init( + observer: LockStateSubscriberObserver(), + surfaceInvalidator: .live + ) + } + + init( + observer: any AgentLockStateObserving, + surfaceInvalidator: LockStateSurfaceInvalidator + ) { + self.observer = observer + self.surfaceInvalidator = surfaceInvalidator + } + + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + // A fresh App observation lifetime calibrates the system-owned surfaces even when the + // authoritative Boolean matches the App's seed and the subscriber emits no callback. + surfaceInvalidator.invalidate() + return observer.subscribe(initialState: initialState) { isLocked in + // The underlying subscriber already coalesces signals and re-queries the Agent. This is a + // presentation refresh hint only; the Boolean forwarded to the coordinator remains the + // authoritative state. + surfaceInvalidator.invalidate() + handler(isLocked) + } + } +} + +@MainActor +private struct LockStateSubscriberObserver: AgentLockStateObserving { + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + LockStateSubscriber.subscribe(initialState: initialState, handler) + } +} + +@MainActor +protocol AgentReadinessLifecycleServing: Sendable { + func ensureEnabled() -> AgentRegistrar.State + func compatibility( + of descriptor: ServiceDescriptor + ) -> AgentRegistrar.Compatibility +} + +@MainActor +protocol AgentReplacementLifecycleServing: Sendable { + func restart() async -> AgentRegistrar.State +} + +/// Complete lifecycle surface retained by `AppCoordinator`. +@MainActor +protocol AgentLifecycleServing: + AgentReadinessLifecycleServing, + AgentReplacementLifecycleServing {} + +@MainActor +struct LiveAgentLifecycle: AgentLifecycleServing { + func ensureEnabled() -> AgentRegistrar.State { + AgentRegistrar.ensureEnabled() + } + + func compatibility( + of descriptor: ServiceDescriptor + ) -> AgentRegistrar.Compatibility { + AgentRegistrar.compatibility(of: descriptor) + } + + func restart() async -> AgentRegistrar.State { + await AgentRegistrar.restart() + } +} diff --git a/KeyboardLocker/AgentReadinessCoordinator.swift b/KeyboardLocker/AgentReadinessCoordinator.swift new file mode 100644 index 0000000..6512155 --- /dev/null +++ b/KeyboardLocker/AgentReadinessCoordinator.swift @@ -0,0 +1,214 @@ +import Client +import Foundation + +/// Resolves Service Management, XPC contract, replacement, lock, and Accessibility facts. +/// +/// This type returns domain outcomes so presentation code can remain outside the business layer. +@MainActor +struct AgentReadinessCoordinator { + enum Outcome { + case agentDidNotRestart + case cancelled + case failure(Error, context: String?) + case invalidBundle(AgentRegistrar.Failure) + case ready(isLocked: Bool, hasAccessibilityPermission: Bool) + case registration(AgentRegistrar.State) + case replacementInProgress(ServiceDescriptor) + case updateAvailable( + descriptor: ServiceDescriptor, + message: String, + bundledBuild: String, + isLocked: Bool + ) + case updateRequired(AgentUpdatePlan) + } + + private let client: any AgentReadinessServing + private let lifecycle: any AgentReadinessLifecycleServing + + init( + client: any AgentReadinessServing, + lifecycle: any AgentReadinessLifecycleServing + ) { + self.client = client + self.lifecycle = lifecycle + } + + func inspect( + expectedPreviousAgentInstanceID: UUID? = nil + ) async -> Outcome { + let registrationState = lifecycle.ensureEnabled() + guard !Task.isCancelled else { + return .cancelled + } + guard case .enabled = registrationState else { + return .registration(registrationState) + } + + let descriptor: ServiceDescriptor + do { + descriptor = try await client.serviceDescriptor() + } catch let initialHandshakeError { + guard !Task.isCancelled else { + return .cancelled + } + + client.resetConnection() + do { + descriptor = try await client.serviceDescriptor() + } catch let retryHandshakeError { + return await inspectUnverifiedAgent( + initialError: initialHandshakeError, + retryError: retryHandshakeError + ) + } + } + + guard !Task.isCancelled else { + return .cancelled + } + if let expectedPreviousAgentInstanceID, + descriptor.agentInstanceID == expectedPreviousAgentInstanceID { + return .agentDidNotRestart + } + + let compatibility = lifecycle.compatibility(of: descriptor) + if isTrustedReplacementInProgress( + descriptor: descriptor, + compatibility: compatibility + ) { + return .replacementInProgress(descriptor) + } + + switch compatibility { + case let .bundledAgentUpgradeAvailable(message, bundledBuild): + do { + let isLocked = try await client.status() + guard !Task.isCancelled else { + return .cancelled + } + return .updateAvailable( + descriptor: descriptor, + message: message, + bundledBuild: bundledBuild, + isLocked: isLocked + ) + } catch { + return .failure(error, context: nil) + } + + case .compatible: + do { + async let lockState = client.status() + async let permissionState = client.hasAccessibilityPermission() + let (isLocked, hasAccessibilityPermission) = try await ( + lockState, + permissionState + ) + guard !Task.isCancelled else { + return .cancelled + } + return .ready( + isLocked: isLocked, + hasAccessibilityPermission: hasAccessibilityPermission + ) + } catch { + return .failure(error, context: nil) + } + + case let .invalidBundle(failure): + return .invalidBundle(failure) + + case let .updateRequired(message, canReadLockState, supportsSafeReplacement): + guard canReadLockState else { + guard !supportsSafeReplacement else { + return .failure( + AgentReadinessError.safeReplacementRequiresReadableLockState, + context: nil + ) + } + return .updateRequired(AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: nil), + message: message + )) + } + + do { + let isLocked = try await client.status() + guard !Task.isCancelled else { + return .cancelled + } + let mode: AgentUpdatePlan.Mode = supportsSafeReplacement + ? .safe(descriptor: descriptor, isLocked: isLocked) + : .forced(descriptor: descriptor, isLocked: isLocked) + return .updateRequired(AgentUpdatePlan( + mode: mode, + message: message + )) + } catch { + return .failure(error, context: nil) + } + } + } + + private func inspectUnverifiedAgent( + initialError: Error, + retryError: Error + ) async -> Outcome { + client.resetConnection() + + do { + let isLocked = try await client.status() + guard !Task.isCancelled else { + return .cancelled + } + return .updateRequired(AgentUpdatePlan( + mode: .forced(descriptor: nil, isLocked: isLocked), + message: """ + The running KeyboardLocker agent's XPC contract could not be verified after \ + reconnecting. It may use an earlier contract, or its descriptor handshake may be \ + failing. Replacing this unverified agent cannot block another client from locking \ + during the transition, so a new lock created in that brief window may be released. + """ + )) + } catch { + return .failure( + error, + context: """ + Descriptor handshake failed before and after reconnecting: \ + \(initialError.localizedDescription) \(retryError.localizedDescription) + """ + ) + } + } + + private func isTrustedReplacementInProgress( + descriptor: ServiceDescriptor, + compatibility: AgentRegistrar.Compatibility + ) -> Bool { + guard descriptor.replacementPending, + descriptor.agentBundleIdentifier == SharedConstants.agentBundleIdentifier, + descriptor.protocolVersion.major == ServiceContract.protocolVersion.major, + descriptor.capabilities.contains(.prepareForReplacement), + descriptor.capabilities.contains(.committedReplacementDrain) + else { + return false + } + + if case .invalidBundle = compatibility { + return false + } + return true + } +} + +private enum AgentReadinessError: Error, LocalizedError { + case safeReplacementRequiresReadableLockState + + var errorDescription: String? { + switch self { + case .safeReplacementRequiresReadableLockState: + "Safe KeyboardLocker agent replacement requires a readable authoritative lock state." + } + } +} diff --git a/KeyboardLocker/AgentRegistrar.swift b/KeyboardLocker/AgentRegistrar.swift new file mode 100644 index 0000000..f0bcb05 --- /dev/null +++ b/KeyboardLocker/AgentRegistrar.swift @@ -0,0 +1,359 @@ +import Client +import Foundation +import os +import ServiceManagement + +@MainActor +protocol AgentRegistrationServing: AnyObject { + var status: SMAppService.Status { get } + + func register() throws + func unregister() async throws +} + +extension SMAppService: AgentRegistrationServing {} + +/// Registers the Agent as a launchd-managed login item so `launchd` can start it on demand +/// for any XPC client — even when this App is not running. This is the contract's +/// "Agent Lifecycle Requirement": wrappers must not assume the Agent is already up. +@MainActor +enum AgentRegistrar { + enum State: Equatable { + case enabled + case approvalRequired + case unavailable(Failure) + } + + enum Failure: Error, Equatable { + case invalidBundle(String) + case notFound + case registrationFailed(String) + case restartFailed(String) + case unregistrationFailed(String) + + var message: String { + switch self { + case let .invalidBundle(message): + "The bundled KeyboardLocker agent is invalid. \(message)" + case .notFound: + "The bundled KeyboardLocker agent could not be found. Reinstall the complete application bundle." + case let .registrationFailed(message): + "The KeyboardLocker agent could not be registered. \(message)" + case let .restartFailed(message): + "The KeyboardLocker agent could not be restarted. \(message)" + case let .unregistrationFailed(message): + "The KeyboardLocker agent could not be unregistered. \(message)" + } + } + } + + enum Compatibility: Equatable { + case bundledAgentUpgradeAvailable(message: String, bundledBuild: String) + case compatible + case invalidBundle(Failure) + case updateRequired( + message: String, + canReadLockState: Bool, + supportsSafeReplacement: Bool + ) + } + + private static let logger = Logger(subsystem: SharedConstants.machServiceName, category: "AgentRegistrar") + + /// Filename of the launchd plist bundled at `Contents/Library/LaunchAgents/`. + private static let plistName = "io.lzhlovesjyq.keyboardlocker.agent.plist" + + private static var agent: SMAppService { + SMAppService.agent(plistName: plistName) + } + + static var isAgentEnabled: Bool { + if case .enabled = agent.status { + return true + } + return false + } + + /// Read-only Service Management state for user-initiated diagnostics. + static var registrationStatusDescription: String { + switch agent.status { + case .requiresApproval: + "approval-required" + case .enabled: + "enabled" + case .notFound: + "not-found" + case .notRegistered: + "not-registered" + @unknown default: + "unknown" + } + } + + /// Ensures the Agent is registered and returns the user-visible lifecycle state. + static func ensureEnabled() -> State { + ensureEnabled( + service: agent, + validateBundledRegistration: validateBundledRegistrationAssets + ) + } + + static func ensureEnabled( + service: any AgentRegistrationServing, + validateBundledRegistration: () throws -> Void + ) -> State { + switch service.status { + case .enabled: + return .enabled + + case .requiresApproval: + return .approvalRequired + + case .notFound: + do { + try validateBundledRegistration() + } catch let failure as Failure { + return .unavailable(failure) + } catch { + return .unavailable(.invalidBundle(error.localizedDescription)) + } + fallthrough + + case .notRegistered: + do { + try service.register() + logger.info("Registered agent (status: \(String(describing: service.status), privacy: .public))") + } catch { + logger.error("Failed to register agent: \(error.localizedDescription, privacy: .public)") + return stateAfterRegistration(service.status, error: error) + } + return stateAfterRegistration(service.status) + + @unknown default: + return .unavailable(.registrationFailed("The system returned an unsupported registration status.")) + } + } + + /// Compares the running Agent's self-reported contract with the Agent bundled in this App. + /// This is lifecycle compatibility only and must not be treated as peer authentication. + static func compatibility(of descriptor: ServiceDescriptor) -> Compatibility { + let requirements: ServiceCompatibilityRequirements + do { + requirements = try bundledCompatibilityRequirements() + } catch let failure as Failure { + return .invalidBundle(failure) + } catch { + return .invalidBundle(.invalidBundle(error.localizedDescription)) + } + + let issues = descriptor.compatibilityIssues(against: requirements) + guard !issues.isEmpty else { + return .compatible + } + + let details = issues.compactMap(\.errorDescription).joined(separator: " ") + let identifierMatches = descriptor.agentBundleIdentifier == requirements.agentBundleIdentifier + let sameProtocolMajor = descriptor.protocolVersion.major == requirements.minimumProtocolVersion.major + let canReadLockState = identifierMatches + && sameProtocolMajor + && descriptor.capabilities.contains(.lockControl) + let supportsSafeReplacement = canReadLockState + && descriptor.capabilities.contains(.prepareForReplacement) + && descriptor.capabilities.contains(.committedReplacementDrain) + + switch compareBuilds(running: descriptor.agentBuild, bundled: requirements.agentBuild) { + case .orderedAscending where supportsSafeReplacement: + return .bundledAgentUpgradeAvailable( + message: """ + The running KeyboardLocker agent is older than the version bundled with this app. \(details) + """, + bundledBuild: requirements.agentBuild + ) + + case .orderedDescending: + return .updateRequired( + message: """ + A newer KeyboardLocker agent is running than the version bundled with this app. \ + Replacing it will use the older bundled version. \(details) + """, + canReadLockState: canReadLockState, + supportsSafeReplacement: supportsSafeReplacement + ) + + case nil, .orderedAscending, .orderedSame: + return .updateRequired( + message: """ + The running KeyboardLocker agent does not match the version bundled with this app. \(details) + """, + canReadLockState: canReadLockState, + supportsSafeReplacement: supportsSafeReplacement + ) + } + } + + private static func compareBuilds( + running: String, + bundled: String + ) -> ComparisonResult? { + guard let runningBuild = ServiceBuildVersion(running), + let bundledBuild = ServiceBuildVersion(bundled) + else { + return nil + } + if runningBuild < bundledBuild { + return .orderedAscending + } + if runningBuild > bundledBuild { + return .orderedDescending + } + return .orderedSame + } + + /// Stops the currently registered Agent and waits for it to exit before registering the + /// bundled version. Callers must first enforce the lock-safety policy in + /// `AgentReplacementCoordinator`. + static func restart() async -> State { + let service = agent + + switch service.status { + case .notFound, .notRegistered: + return ensureEnabled( + service: service, + validateBundledRegistration: validateBundledRegistrationAssets + ) + + case .enabled, .requiresApproval: + do { + try await unregister(service: service) + } catch { + logger.error("Failed to restart agent: \(error.localizedDescription, privacy: .public)") + return .unavailable(.restartFailed(error.localizedDescription)) + } + return ensureEnabled( + service: service, + validateBundledRegistration: validateBundledRegistrationAssets + ) + + @unknown default: + return .unavailable(.restartFailed("The system returned an unsupported registration status.")) + } + } + + /// Removes only this App's Service Management registration. The caller is responsible for + /// resolving any active keyboard lock before invoking this lifecycle operation. + static func unregister() async throws { + try await unregister(service: agent) + } + + static func unregister(service: any AgentRegistrationServing) async throws { + switch service.status { + case .notFound, .notRegistered: + return + + case .enabled, .requiresApproval: + do { + try await service.unregister() + logger.info("Unregistered agent") + } catch { + switch service.status { + case .notFound, .notRegistered: + return + + case .enabled, .requiresApproval: + logger.error("Failed to unregister agent: \(error.localizedDescription, privacy: .public)") + throw error + + @unknown default: + throw error + } + } + + @unknown default: + throw Failure.unregistrationFailed( + "The system returned an unsupported registration status." + ) + } + } + + private static func stateAfterRegistration( + _ status: SMAppService.Status, + error: Error? = nil + ) -> State { + switch status { + case .enabled: + .enabled + + case .requiresApproval: + .approvalRequired + + case .notFound: + .unavailable(.registrationFailed( + error?.localizedDescription + ?? "Service Management still could not find the agent after registration." + )) + + case .notRegistered: + .unavailable(.registrationFailed(error?.localizedDescription ?? "Registration did not complete.")) + + @unknown default: + .unavailable(.registrationFailed( + error?.localizedDescription ?? "The system returned an unsupported registration status." + )) + } + } + + private static func validateBundledRegistrationAssets() throws { + let plistURL = Bundle.main.bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("LaunchAgents", isDirectory: true) + .appendingPathComponent(plistName, isDirectory: false) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: plistURL.path, isDirectory: &isDirectory), + !isDirectory.boolValue + else { + throw Failure.notFound + } + + _ = try bundledCompatibilityRequirements() + } + + private static func bundledCompatibilityRequirements() throws -> ServiceCompatibilityRequirements { + let agentURL = Bundle.main.bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("LoginItems", isDirectory: true) + .appendingPathComponent("KeyboardLockerAgent.app", isDirectory: true) + + guard FileManager.default.fileExists(atPath: agentURL.path), + let bundle = Bundle(url: agentURL) + else { + throw Failure.notFound + } + guard let bundleIdentifier = bundle.bundleIdentifier, !bundleIdentifier.isEmpty else { + throw Failure.invalidBundle("CFBundleIdentifier is missing.") + } + guard bundleIdentifier == SharedConstants.agentBundleIdentifier else { + throw Failure.invalidBundle( + "Expected identifier \(SharedConstants.agentBundleIdentifier), found \(bundleIdentifier)." + ) + } + guard let version = bundle.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String, !version.isEmpty else { + throw Failure.invalidBundle("CFBundleShortVersionString is missing.") + } + guard let build = bundle.object( + forInfoDictionaryKey: "CFBundleVersion" + ) as? String, !build.isEmpty else { + throw Failure.invalidBundle("CFBundleVersion is missing.") + } + + return ServiceCompatibilityRequirements( + agentBundleIdentifier: bundleIdentifier, + agentVersion: version, + agentBuild: build + ) + } +} diff --git a/KeyboardLocker/AgentReplacementCoordinator.swift b/KeyboardLocker/AgentReplacementCoordinator.swift new file mode 100644 index 0000000..c7583ba --- /dev/null +++ b/KeyboardLocker/AgentReplacementCoordinator.swift @@ -0,0 +1,190 @@ +import Client +import Foundation + +/// One generation-consistent replacement decision produced by readiness evaluation. +struct AgentUpdatePlan: Equatable { + enum Mode: Equatable { + case forced(descriptor: ServiceDescriptor?, isLocked: Bool?) + case safe(descriptor: ServiceDescriptor, isLocked: Bool) + } + + let mode: Mode + let message: String + + var descriptor: ServiceDescriptor? { + switch mode { + case let .forced(descriptor, _): + descriptor + case let .safe(descriptor, _): + descriptor + } + } + + var isLocked: Bool? { + switch mode { + case let .forced(_, isLocked): + isLocked + case let .safe(_, isLocked): + isLocked + } + } + + var supportsSafeReplacement: Bool { + if case .safe = mode { + return true + } + return false + } + + var canReadLockState: Bool { + isLocked != nil + } + + func updatingLockState(_ isLocked: Bool) -> Self { + switch mode { + case let .forced(descriptor, currentLockState): + guard currentLockState != nil else { + return self + } + return Self( + mode: .forced(descriptor: descriptor, isLocked: isLocked), + message: message + ) + + case let .safe(descriptor, _): + return Self( + mode: .safe(descriptor: descriptor, isLocked: isLocked), + message: message + ) + } + } +} + +/// Owns the App-side ordering for replacing a launchd-managed Agent. +/// +/// The coordinator contains no presentation state. It guarantees that Service Management restart +/// is attempted only after a safe replacement transaction has committed. Passing a forced plan is +/// a caller precondition that represents the user's explicit authorization of the legacy fallback. +@MainActor +struct AgentReplacementCoordinator { + enum Outcome { + case failed(error: Error, currentLockState: Bool?) + case registration(AgentRegistrar.State) + case replacementInProgress(ServiceDescriptor) + case restarted(previousAgentInstanceID: UUID?) + } + + private let client: any AgentReplacementServing + private let lifecycle: any AgentReplacementLifecycleServing + + init( + client: any AgentReplacementServing, + lifecycle: any AgentReplacementLifecycleServing + ) { + self.client = client + self.lifecycle = lifecycle + } + + func replace(_ plan: AgentUpdatePlan) async -> Outcome { + switch plan.mode { + case let .safe(descriptor, isLocked): + await replaceWithTransaction(descriptor: descriptor, isLocked: isLocked) + case let .forced(descriptor, isLocked): + await replaceForced(descriptor: descriptor, isLocked: isLocked) + } + } + + private func replaceWithTransaction( + descriptor: ServiceDescriptor, + isLocked: Bool + ) async -> Outcome { + var replacementTicket: ServiceReplacementTicket? + var didCommitReplacement = false + + do { + replacementTicket = try await client.prepareForReplacement( + unlockIfNeeded: isLocked, + expectedAgentInstanceID: descriptor.agentInstanceID + ) + if let replacementTicket { + try await client.commitReplacement(ticket: replacementTicket) + didCommitReplacement = true + } + } catch { + if let replacementTicket, !didCommitReplacement { + try? await client.cancelReplacementPreparation( + ticket: replacementTicket + ) + } + + if let descriptor = await reportedReplacementInProgress() { + return .replacementInProgress(descriptor) + } + + return await .failed(error: error, currentLockState: try? client.status()) + } + + return await restart( + reporting: descriptor, + cancellingUncommittedTicket: replacementTicket, + didCommit: didCommitReplacement + ) + } + + /// The forced path has no transaction to roll back and no trusted replacement to re-detect. + /// A plan without a readable lock state performs no throwing call at all, so it can never + /// produce `.failed`. + private func replaceForced( + descriptor: ServiceDescriptor?, + isLocked: Bool? + ) async -> Outcome { + if isLocked != nil { + // The legacy path has no cross-client drain. The explicit user action authorizes + // releasing the known lock immediately before the shortest possible restart window. + do { + try await client.unlock() + } catch { + return await .failed(error: error, currentLockState: try? client.status()) + } + } + + return await restart( + reporting: descriptor, + cancellingUncommittedTicket: nil, + didCommit: false + ) + } + + private func restart( + reporting descriptor: ServiceDescriptor?, + cancellingUncommittedTicket replacementTicket: ServiceReplacementTicket?, + didCommit didCommitReplacement: Bool + ) async -> Outcome { + let registrationState = await lifecycle.restart() + guard case .enabled = registrationState else { + if let replacementTicket, !didCommitReplacement { + try? await client.cancelReplacementPreparation( + ticket: replacementTicket + ) + } + client.resetConnection() + return .registration(registrationState) + } + + client.resetConnection() + return .restarted(previousAgentInstanceID: descriptor?.agentInstanceID) + } + + private func reportedReplacementInProgress() async -> ServiceDescriptor? { + guard let descriptor = try? await client.serviceDescriptor(), + descriptor.replacementPending, + descriptor.agentBundleIdentifier == SharedConstants.agentBundleIdentifier, + descriptor.protocolVersion.major == ServiceContract.protocolVersion.major, + descriptor.capabilities.contains(.prepareForReplacement), + descriptor.capabilities.contains(.committedReplacementDrain) + else { + return nil + } + return descriptor + } +} diff --git a/KeyboardLocker/AppActivation.swift b/KeyboardLocker/AppActivation.swift new file mode 100644 index 0000000..7969e25 --- /dev/null +++ b/KeyboardLocker/AppActivation.swift @@ -0,0 +1,16 @@ +import AppKit + +extension NSApplication { + /// Requests activation before presenting an alert or other user-facing UI. + /// + /// `activate(ignoringOtherApps:)` is marked "will be deprecated in a future release" in the + /// SDK; `activate()` is its designated replacement but requires macOS 14, so the legacy call + /// remains on the macOS 13 path. + func activateForUserPresentation() { + if #available(macOS 14.0, *) { + activate() + } else { + activate(ignoringOtherApps: true) + } + } +} diff --git a/KeyboardLocker/AppCoordinator.swift b/KeyboardLocker/AppCoordinator.swift new file mode 100644 index 0000000..a9ed267 --- /dev/null +++ b/KeyboardLocker/AppCoordinator.swift @@ -0,0 +1,699 @@ +import Client +import Foundation + +/// Coordinates App-side lock actions, readiness, and Agent replacement without owning domain state. +@MainActor +final class AppCoordinator { + enum State: Equatable { + case checking(lastKnownLock: Bool?) + case agentApprovalRequired + case agentReplacementInProgress(message: String) + case agentUpdateRequired(isLocked: Bool?, message: String) + case accessibilityRequired(isLocked: Bool) + case ready(isLocked: Bool) + case unavailable(message: String, canRestartAgent: Bool) + + var knownLockState: Bool? { + switch self { + case let .checking(lastKnownLock): + lastKnownLock + case let .agentUpdateRequired(isLocked, _): + isLocked + case let .accessibilityRequired(isLocked), + let .ready(isLocked): + isLocked + case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + nil + } + } + } + + enum Activity: Equatable { + case locking + case requestingAccessibility + case restartingAgent + case startingSafetyCheck + case unlocking + case updatingAgent + } + + enum SafetyCheckState: Equatable { + case completed + case failed(String) + case idle + case running + } + + struct Snapshot: Equatable { + let state: State + let activity: Activity? + let lastError: String? + let safetyCheckState: SafetyCheckState + } + + private(set) var state: State = .checking(lastKnownLock: nil) { + didSet { + publishSnapshotIfNeeded() + } + } + + private(set) var activity: Activity? { + didSet { + publishSnapshotIfNeeded() + } + } + + private(set) var lastError: String? { + didSet { + publishSnapshotIfNeeded() + } + } + + private(set) var safetyCheckState: SafetyCheckState = .idle { + didSet { + publishSnapshotIfNeeded() + } + } + + var snapshot: Snapshot { + Snapshot( + state: state, + activity: activity, + lastError: lastError, + safetyCheckState: safetyCheckState + ) + } + + var onSnapshotChange: ((Snapshot) -> Void)? { + didSet { + publishSnapshotIfNeeded(force: true) + } + } + + private var reconciliationTask: Task? + private var needsFollowUpReconciliation = false + private var pendingUpdatePlan: AgentUpdatePlan? + private var attemptedAutomaticUpdateBuilds: Set = [] + private var stateToken: ObserverToken? + private let client: any AgentClientServing + private let lifecycle: any AgentLifecycleServing + private let lockStateObserver: any AgentLockStateObserving + private let readinessCoordinator: AgentReadinessCoordinator + private let replacementCoordinator: AgentReplacementCoordinator + private var lastPublishedSnapshot: Snapshot? + + private static let replacementProgressPollInterval: Duration = .seconds(3) + + convenience init() { + self.init( + client: LiveAgentClient(), + lifecycle: LiveAgentLifecycle(), + lockStateObserver: LiveAgentLockStateObserver(), + initialState: .checking(lastKnownLock: nil) + ) + reconcile() + } + + init( + client: any AgentClientServing, + lifecycle: any AgentLifecycleServing, + lockStateObserver: any AgentLockStateObserving, + initialState: State + ) { + self.client = client + self.lifecycle = lifecycle + self.lockStateObserver = lockStateObserver + readinessCoordinator = AgentReadinessCoordinator( + client: client, + lifecycle: lifecycle + ) + replacementCoordinator = AgentReplacementCoordinator( + client: client, + lifecycle: lifecycle + ) + state = initialState + } + + /// Rebuilds the complete readiness snapshot from Service Management and the Agent. + func reconcile() { + guard activity == nil else { + return + } + startReconciliation() + } + + /// Performs the direction the menu currently displays, as an explicit idempotent lock/unlock. + /// Deliberately not the atomic `toggle()`: the escape-hatch states below must only ever + /// unlock, and explicit verbs still work on legacy agents without the `lockToggle` capability. + func performDisplayedLockAction() { + guard activity == nil else { + return + } + + let isLocked: Bool + let allowsAutomaticAgentUpdateAfterAction: Bool + switch state { + case let .ready(currentLockState): + isLocked = currentLockState + allowsAutomaticAgentUpdateAfterAction = true + + case .checking(lastKnownLock: true): + isLocked = true + allowsAutomaticAgentUpdateAfterAction = true + + case .accessibilityRequired(isLocked: true): + // Unlock must remain available if permission is revoked while the Agent still reports locked. + isLocked = true + allowsAutomaticAgentUpdateAfterAction = true + + case .agentUpdateRequired(isLocked: true, message: _): + // Unlock must remain independently available without forcing an Agent replacement. + isLocked = true + allowsAutomaticAgentUpdateAfterAction = false + + default: + return + } + + reconciliationTask?.cancel() + activity = isLocked ? .unlocking : .locking + lastError = nil + + Task { [weak self] in + guard let self else { + return + } + + var actionError: String? + do { + if isLocked { + try await client.unlock() + } else { + try await client.lock() + } + } catch { + actionError = error.localizedDescription + } + + activity = nil + startReconciliation( + preserving: actionError, + allowsAutomaticAgentUpdate: allowsAutomaticAgentUpdateAfterAction + ) + } + } + + /// Starts the Agent-owned ten-second recovery check, then waits for authoritative unlock. + /// The App never owns the timer; quitting after acquisition still leaves the Agent fail-safe. + func startSafetyCheck() { + guard case .ready(isLocked: false) = state, + activity == nil, + safetyCheckState != .running + else { + return + } + + reconciliationTask?.cancel() + activity = .startingSafetyCheck + safetyCheckState = .running + lastError = nil + + Task { [weak self] in + guard let self else { + return + } + + do { + let outcome = try await client.beginSafetyCheck() + guard outcome == .acquired else { + throw SafetyCheckError.lockAlreadyActive + } + + // Acquisition is complete, so ordinary global unlock actions must remain available while + // this task only waits for the Agent's authoritative state to return to unlocked. + activity = nil + startReconciliation() + try await client.waitUntilUnlocked() + safetyCheckState = .completed + startReconciliation() + } catch { + let message = error.localizedDescription + activity = nil + safetyCheckState = .failed(message) + startReconciliation(preserving: message) + } + } + } + + func requestAccessibilityPermission() { + guard case .accessibilityRequired = state, activity == nil else { + return + } + + reconciliationTask?.cancel() + activity = .requestingAccessibility + lastError = nil + + Task { [weak self] in + guard let self else { + return + } + + var actionError: String? + do { + try await client.requestAccessibilityPermission() + } catch { + actionError = error.localizedDescription + } + + activity = nil + // The system prompt is asynchronous; only a fresh Agent query can confirm permission. + startReconciliation(preserving: actionError) + } + } + + func restartAgent() { + guard case .unavailable(message: _, canRestartAgent: true) = state, + activity == nil + else { + return + } + + activity = .restartingAgent + lastError = nil + stopStateObservation() + + Task { [weak self] in + guard let self else { + return + } + defer { + activity = nil + } + + // Gracefully clear the logical lock when possible; unregistering the Agent still releases + // its event tap if the old process is incompatible or unresponsive. + try? await client.unlock() + client.resetConnection() + let registrationState = await lifecycle.restart() + client.resetConnection() + + guard case .enabled = registrationState else { + _ = applyRegistrationState(registrationState) + return + } + await refresh( + preserving: nil, + allowsAutomaticAgentUpdate: false + ) + } + } + + func updateAgent() { + guard case .agentUpdateRequired = state, + let updatePlan = pendingUpdatePlan, + activity == nil + else { + return + } + + reconciliationTask?.cancel() + activity = .updatingAgent + lastError = nil + stopStateObservation() + + Task { [weak self] in + guard let self else { + return + } + + defer { + activity = nil + } + + let outcome = await replacementCoordinator.replace(updatePlan) + await handleAgentReplacementOutcome( + outcome, + updatePlan: updatePlan, + preserving: nil + ) + } + } + + private func startReconciliation( + preserving actionError: String? = nil, + allowsAutomaticAgentUpdate: Bool = true + ) { + reconciliationTask?.cancel() + stopStateObservation() + + let lastKnownLock = state.knownLockState + + needsFollowUpReconciliation = false + state = .checking(lastKnownLock: lastKnownLock) + lastError = nil + + reconciliationTask = Task { [weak self] in + await self?.refresh( + preserving: actionError, + allowsAutomaticAgentUpdate: allowsAutomaticAgentUpdate, + expectedPreviousAgentInstanceID: nil + ) + } + } + + private func refresh( + preserving actionError: String?, + allowsAutomaticAgentUpdate: Bool, + expectedPreviousAgentInstanceID: UUID? = nil + ) async { + let outcome = await readinessCoordinator.inspect( + expectedPreviousAgentInstanceID: expectedPreviousAgentInstanceID + ) + guard !Task.isCancelled else { + return + } + + switch outcome { + case .agentDidNotRestart: + await showServiceFailure(AgentUpdateError.agentDidNotRestart) + + case .cancelled: + return + + case let .failure(error, context): + await showServiceFailure(error, context: context) + + case let .invalidBundle(failure): + pendingUpdatePlan = nil + state = .unavailable(message: failure.message, canRestartAgent: false) + lastError = nil + + case let .ready(isLocked, hasAccessibilityPermission): + pendingUpdatePlan = nil + state = hasAccessibilityPermission + ? .ready(isLocked: isLocked) + : .accessibilityRequired(isLocked: isLocked) + lastError = actionError + startStateObservation() + + if needsFollowUpReconciliation { + startReconciliation( + preserving: actionError, + allowsAutomaticAgentUpdate: allowsAutomaticAgentUpdate + ) + } + + case let .registration(registrationState): + _ = applyRegistrationState(registrationState) + + case let .replacementInProgress(descriptor): + showAgentReplacementInProgress(descriptor: descriptor) + + case let .updateAvailable(descriptor, message, bundledBuild, isLocked): + if !isLocked, + allowsAutomaticAgentUpdate, + !attemptedAutomaticUpdateBuilds.contains(bundledBuild) { + attemptedAutomaticUpdateBuilds.insert(bundledBuild) + await replaceAgentAutomatically( + descriptor, + updateMessage: message, + preserving: actionError + ) + } else { + showAgentUpdateRequired(plan: AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: isLocked), + message: message + )) + } + + case let .updateRequired(updatePlan): + showAgentUpdateRequired(plan: updatePlan) + } + } + + private func replaceAgentAutomatically( + _ previousDescriptor: ServiceDescriptor, + updateMessage: String, + preserving actionError: String? + ) async { + guard !Task.isCancelled else { + return + } + + activity = .updatingAgent + defer { + activity = nil + } + + let updatePlan = AgentUpdatePlan( + mode: .safe(descriptor: previousDescriptor, isLocked: false), + message: updateMessage + ) + let outcome = await replacementCoordinator.replace(updatePlan) + await handleAgentReplacementOutcome( + outcome, + updatePlan: updatePlan, + preserving: actionError + ) + } + + private func handleAgentReplacementOutcome( + _ outcome: AgentReplacementCoordinator.Outcome, + updatePlan: AgentUpdatePlan, + preserving actionError: String? + ) async { + switch outcome { + case let .failed(error, currentLockState): + if let currentLockState { + showAgentUpdateRequired( + plan: updatePlan.updatingLockState(currentLockState) + ) + lastError = error.localizedDescription + } else { + await showServiceFailure(error) + } + + case let .registration(registrationState): + _ = applyRegistrationState(registrationState) + + case let .replacementInProgress(descriptor): + showAgentReplacementInProgress(descriptor: descriptor) + + case let .restarted(previousAgentInstanceID): + await refresh( + preserving: actionError, + allowsAutomaticAgentUpdate: false, + expectedPreviousAgentInstanceID: previousAgentInstanceID + ) + } + } + + private func showAgentReplacementInProgress(descriptor: ServiceDescriptor) { + let message = switch descriptor.replacementPhase { + case .committed: + """ + A KeyboardLocker agent replacement has been committed. New lock requests remain \ + blocked while its coordinator finishes and the old agent exits. If this state \ + persists, restart macOS; another app instance cannot safely take over an unregister \ + that may still be in flight. + """ + + case .prepared: + """ + A KeyboardLocker agent replacement is being prepared. New lock requests remain \ + blocked until its coordinator commits or the short preparation expires. + """ + + default: + """ + The KeyboardLocker agent reports a replacement state this app does not recognize. \ + New lock requests remain blocked to protect the current lock state. Update the app, \ + or restart macOS if this state persists. + """ + } + + pendingUpdatePlan = nil + stopStateObservation() + state = .agentReplacementInProgress(message: message) + lastError = nil + reconciliationTask?.cancel() + reconciliationTask = Task { [weak self] in + do { + try await Task.sleep(for: Self.replacementProgressPollInterval) + } catch { + return + } + guard !Task.isCancelled else { + return + } + self?.startReconciliation(allowsAutomaticAgentUpdate: false) + } + } + + private func showAgentUpdateRequired(plan: AgentUpdatePlan) { + pendingUpdatePlan = plan + state = .agentUpdateRequired( + isLocked: plan.isLocked, + message: plan.message + ) + lastError = nil + if plan.canReadLockState { + startStateObservation() + } else { + stopStateObservation() + } + } + + private func showServiceFailure(_ error: Error, context: String? = nil) async { + guard !Task.isCancelled else { + return + } + stopStateObservation() + + if let clientError = error as? XPCClientError, + case .peerAuthenticationUnavailable = clientError { + pendingUpdatePlan = nil + state = .unavailable( + message: """ + This copy of KeyboardLocker cannot establish its signed XPC identity. \ + \(clientError.localizedDescription) Install and run the complete app bundle signed by \ + the configured Apple development team. + """, + canRestartAgent: false + ) + lastError = nil + return + } + + // An XPC failure can mean the user disabled the Agent while the App was running. + let currentRegistrationState = lifecycle.ensureEnabled() + guard !Task.isCancelled else { + return + } + + if applyRegistrationState(currentRegistrationState) { + pendingUpdatePlan = nil + let contextMessage = context.map { " \($0)" } ?? "" + state = .unavailable( + message: """ + The KeyboardLocker agent is enabled but could not be reached. \ + \(error.localizedDescription)\(contextMessage) + """, + canRestartAgent: true + ) + lastError = nil + } + } + + /// Returns `true` only when XPC readiness checks should continue. + private func applyRegistrationState(_ registrationState: AgentRegistrar.State) -> Bool { + switch registrationState { + case .enabled: + return true + + case .approvalRequired: + stopStateObservation() + pendingUpdatePlan = nil + state = .agentApprovalRequired + lastError = nil + return false + + case let .unavailable(failure): + stopStateObservation() + pendingUpdatePlan = nil + let canRestartAgent = if case .restartFailed = failure { + true + } else { + false + } + state = .unavailable( + message: failure.message, + canRestartAgent: canRestartAgent + ) + lastError = nil + return false + } + } + + private func receiveLockState(_ isLocked: Bool) { + if activity != nil { + return + } + + switch state { + case .ready: + state = .ready(isLocked: isLocked) + lastError = nil + + case .accessibilityRequired: + state = .accessibilityRequired(isLocked: isLocked) + lastError = nil + + case .agentUpdateRequired: + if let pendingUpdatePlan, + pendingUpdatePlan.canReadLockState { + let updatedPlan = pendingUpdatePlan.updatingLockState(isLocked) + self.pendingUpdatePlan = updatedPlan + state = .agentUpdateRequired( + isLocked: isLocked, + message: updatedPlan.message + ) + lastError = nil + } + + case .checking: + needsFollowUpReconciliation = true + state = .checking(lastKnownLock: isLocked) + + case .agentApprovalRequired, .agentReplacementInProgress, .unavailable: + reconcile() + } + } + + private func startStateObservation() { + guard stateToken == nil else { + return + } + + stateToken = lockStateObserver.subscribe(initialState: state.knownLockState) { [weak self] isLocked in + self?.receiveLockState(isLocked) + } + } + + private func stopStateObservation() { + stateToken = nil + } + + private func publishSnapshotIfNeeded(force: Bool = false) { + let currentSnapshot = snapshot + guard force || currentSnapshot != lastPublishedSnapshot else { + return + } + + lastPublishedSnapshot = currentSnapshot + onSnapshotChange?(currentSnapshot) + } +} + +private enum AgentUpdateError: Error, LocalizedError { + case agentDidNotRestart + + var errorDescription: String? { + switch self { + case .agentDidNotRestart: + "The KeyboardLocker agent did not restart into a new process." + } + } +} + +private enum SafetyCheckError: Error, LocalizedError { + case lockAlreadyActive + + var errorDescription: String? { + switch self { + case .lockAlreadyActive: + "The safety check did not start because the keyboard was already locked. Unlock it and try again." + } + } +} diff --git a/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift new file mode 100644 index 0000000..5f48e35 --- /dev/null +++ b/KeyboardLocker/AppIntents/KeyboardLockAppIntents.swift @@ -0,0 +1,92 @@ +import AppIntents +import Foundation + +@MainActor +struct LockKeyboardIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Lock Keyboard" + nonisolated static let description = IntentDescription( + "Locks keyboard input, including volume, brightness, and media controls, while keeping mouse and trackpad input available." + ) + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult { + try await client.lock() + return .result() + } +} + +@MainActor +struct UnlockKeyboardIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Unlock Keyboard" + nonisolated static let description = IntentDescription( + "Unlocks keyboard input and keyboard system controls." + ) + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult { + try await client.unlock() + return .result() + } +} + +@MainActor +struct ToggleKeyboardLockIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Toggle Keyboard Lock" + nonisolated static let description = IntentDescription( + "Atomically flips the keyboard lock state in the Agent — locking when unlocked and unlocking when locked — and returns the resulting state." + ) + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult & ReturnsValue { + try await .result(value: client.toggle()) + } +} + +@MainActor +struct GetKeyboardLockStatusIntent: nonisolated AppIntent { + nonisolated static let title: LocalizedStringResource = "Get Keyboard Lock Status" + nonisolated static let description = IntentDescription( + "Returns whether keyboard input and keyboard system controls are currently locked." + ) + + private let client: any AgentLockActionServing + + nonisolated init() { + client = LiveAgentClient() + } + + nonisolated init(client: any AgentLockActionServing) { + self.client = client + } + + nonisolated func perform() async throws -> some IntentResult & ReturnsValue { + try await .result(value: client.status()) + } +} diff --git a/KeyboardLocker/AppIntents/KeyboardLockerShortcuts.swift b/KeyboardLocker/AppIntents/KeyboardLockerShortcuts.swift new file mode 100644 index 0000000..7f24b85 --- /dev/null +++ b/KeyboardLocker/AppIntents/KeyboardLockerShortcuts.swift @@ -0,0 +1,39 @@ +import AppIntents + +/// Promoted App Shortcuts for Spotlight, Quick Keys, and Siri. App Shortcuts only run on +/// macOS 26 and later; earlier versions keep exposing the same intents through the Shortcuts +/// action library without invocation phrases. +@available(macOS 26.0, *) +struct KeyboardLockerShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: LockKeyboardIntent(), + phrases: [ + "Lock keyboard in \(.applicationName)", + "Lock my keyboard with \(.applicationName)", + ], + shortTitle: "Lock Keyboard", + systemImageName: "lock" + ) + + AppShortcut( + intent: UnlockKeyboardIntent(), + phrases: [ + "Unlock keyboard in \(.applicationName)", + "Unlock my keyboard with \(.applicationName)", + ], + shortTitle: "Unlock Keyboard", + systemImageName: "lock.open" + ) + + AppShortcut( + intent: ToggleKeyboardLockIntent(), + phrases: [ + "Toggle keyboard lock in \(.applicationName)", + "Toggle my keyboard with \(.applicationName)", + ], + shortTitle: "Toggle Keyboard Lock", + systemImageName: "lock.rotation" + ) + } +} diff --git a/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift new file mode 100644 index 0000000..aa756a7 --- /dev/null +++ b/KeyboardLocker/AppleScript/KeyboardLockerScriptCommands.swift @@ -0,0 +1,82 @@ +import Client +import Foundation + +nonisolated class AsyncKeyboardLockerScriptCommand: NSScriptCommand { + override final nonisolated func performDefaultImplementation() -> Any? { + suspendExecution() + let commandReference = SuspendedScriptCommandReference(self) + + // Cocoa scripting requires the handler to return before a suspended command is resumed. + DispatchQueue.main.async { + Task { @MainActor in + let command = commandReference.value + do { + let result = try await type(of: command).perform(using: LiveAgentClient()) + command.resumeExecution(withResult: result) + } catch { + command.scriptErrorNumber = AppleScriptErrorPresentation.errorNumber(for: error) + command.scriptErrorString = AppleScriptErrorPresentation.message(for: error) + command.resumeExecution(withResult: nil) + } + } + } + + return nil + } + + @MainActor + class func perform(using _: any AgentLockActionServing) async throws -> Any? { + preconditionFailure("Subclasses must implement their Agent operation.") + } +} + +/// Cocoa permits a suspended command to resume on another thread. This box only carries the +/// retained command into a main-actor task, where all post-suspension access occurs. +private final nonisolated class SuspendedScriptCommandReference: @unchecked Sendable { + let value: AsyncKeyboardLockerScriptCommand + + init(_ value: AsyncKeyboardLockerScriptCommand) { + self.value = value + } +} + +@objc(LockKeyboardScriptCommand) +final nonisolated class LockKeyboardScriptCommand: AsyncKeyboardLockerScriptCommand { + @MainActor + override class func perform(using client: any AgentLockActionServing) async throws -> Any? { + try await client.lock() + return nil + } +} + +@objc(UnlockKeyboardScriptCommand) +final nonisolated class UnlockKeyboardScriptCommand: AsyncKeyboardLockerScriptCommand { + @MainActor + override class func perform(using client: any AgentLockActionServing) async throws -> Any? { + try await client.unlock() + return nil + } +} + +@objc(GetKeyboardLockStatusScriptCommand) +final nonisolated class GetKeyboardLockStatusScriptCommand: AsyncKeyboardLockerScriptCommand { + @MainActor + override class func perform(using client: any AgentLockActionServing) async throws -> Any? { + try await NSNumber(value: client.status()) + } +} + +@MainActor +enum AppleScriptErrorPresentation { + static func errorNumber(for error: Error) -> Int { + if let clientError = error as? XPCClientError, + case .timedOut = clientError { + return Int(errAETimeout) + } + return Int(errAEEventFailed) + } + + static func message(for error: Error) -> String { + NSErrorMessageFormatter.message(for: error) + } +} diff --git a/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift b/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift new file mode 100644 index 0000000..305c227 --- /dev/null +++ b/KeyboardLocker/Automation/AppKitExternalAutomationPresenter.swift @@ -0,0 +1,44 @@ +import AppKit + +@MainActor +struct AppKitExternalAutomationPresenter: ExternalAutomationPresenting { + func presentStatus(isLocked: Bool, source _: ExternalAutomationSource) { + let state = isLocked ? "Locked" : "Unlocked" + presentAlert( + style: .informational, + message: "Keyboard is \(state)", + information: "This status was read from the KeyboardLocker Agent." + ) + } + + func presentFailures( + _ failures: [ExternalAutomationFailure], + source: ExternalAutomationSource + ) { + let uniqueMessages = failures.reduce(into: [String]()) { messages, failure in + if !messages.contains(failure.message) { + messages.append(failure.message) + } + } + + presentAlert( + style: .warning, + message: "KeyboardLocker \(source.rawValue) Failed", + information: uniqueMessages.joined(separator: "\n\n") + ) + } + + private func presentAlert( + style: NSAlert.Style, + message: String, + information: String + ) { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = message + alert.informativeText = information + alert.addButton(withTitle: "OK") + NSApp.activateForUserPresentation() + alert.runModal() + } +} diff --git a/KeyboardLocker/Automation/ExternalAutomationAction.swift b/KeyboardLocker/Automation/ExternalAutomationAction.swift new file mode 100644 index 0000000..e0ce415 --- /dev/null +++ b/KeyboardLocker/Automation/ExternalAutomationAction.swift @@ -0,0 +1,42 @@ +import Foundation + +nonisolated enum ExternalAutomationAction: Equatable, Sendable { + case lock + case unlock + case status +} + +nonisolated enum ExternalAutomationRequest: Equatable, Sendable { + case action(ExternalAutomationAction) + case failure(ExternalAutomationFailure) +} + +nonisolated enum ExternalAutomationSource: String, Equatable, Sendable { + case service = "Service" + case urlScheme = "URL" +} + +nonisolated enum NSErrorMessageFormatter { + static func message(for error: Error) -> String { + let error = error as NSError + guard let recoverySuggestion = error.localizedRecoverySuggestion, + !recoverySuggestion.isEmpty + else { + return error.localizedDescription + } + + return "\(error.localizedDescription) \(recoverySuggestion)" + } +} + +nonisolated struct ExternalAutomationFailure: Equatable, Sendable { + let message: String + + init(message: String) { + self.message = message + } + + init(error: Error) { + self.init(message: NSErrorMessageFormatter.message(for: error)) + } +} diff --git a/KeyboardLocker/Automation/ExternalAutomationController.swift b/KeyboardLocker/Automation/ExternalAutomationController.swift new file mode 100644 index 0000000..a62e30b --- /dev/null +++ b/KeyboardLocker/Automation/ExternalAutomationController.swift @@ -0,0 +1,135 @@ +import Foundation + +@MainActor +protocol ExternalAutomationPresenting: Sendable { + func presentStatus(isLocked: Bool, source: ExternalAutomationSource) + func presentFailures( + _ failures: [ExternalAutomationFailure], + source: ExternalAutomationSource + ) +} + +@MainActor +final class ExternalAutomationController { + private let client: any AgentLockActionServing + private let presenter: any ExternalAutomationPresenting + private var tail: Task? + + init( + client: any AgentLockActionServing, + presenter: any ExternalAutomationPresenting + ) { + self.client = client + self.presenter = presenter + } + + convenience init() { + self.init( + client: LiveAgentClient(), + presenter: AppKitExternalAutomationPresenter() + ) + } + + func submit( + _ action: ExternalAutomationAction, + source: ExternalAutomationSource + ) { + submit([.action(action)], source: source) + } + + func submit( + _ actions: [ExternalAutomationAction], + source: ExternalAutomationSource + ) { + submit(actions.map(ExternalAutomationRequest.action), source: source) + } + + func submit( + _ requests: [ExternalAutomationRequest], + source: ExternalAutomationSource + ) { + guard !requests.isEmpty else { + return + } + + let previous = tail + let client = client + let presenter = presenter + tail = Task { @MainActor in + await previous?.value + + var failures: [ExternalAutomationFailure] = [] + for request in requests { + switch request { + case let .failure(failure): + failures.append(failure) + case let .action(action): + if let failure = await Self.execute( + action, + client: client, + presenter: presenter, + source: source + ) { + failures.append(failure) + } + } + } + + if !failures.isEmpty { + presenter.presentFailures(failures, source: source) + } + } + } + + /// Submits one action through the same serial chain and reports the outcome to the caller. + /// Sources with a synchronous result channel (Services) publish that result before scheduling + /// their presentation, so a modal alert cannot delay the result channel. + func submitAndWait( + _ action: ExternalAutomationAction, + source: ExternalAutomationSource + ) async -> ExternalAutomationFailure? { + let previous = tail + let client = client + let presenter = presenter + let task = Task { @MainActor in + await previous?.value + + return await Self.execute( + action, + client: client, + presenter: presenter, + source: source + ) + } + tail = Task { @MainActor in + _ = await task.value + } + return await task.value + } + + private static func execute( + _ action: ExternalAutomationAction, + client: any AgentLockActionServing, + presenter: any ExternalAutomationPresenting, + source: ExternalAutomationSource + ) async -> ExternalAutomationFailure? { + do { + switch action { + case .lock: + try await client.lock() + case .unlock: + try await client.unlock() + case .status: + let isLocked = try await client.status() + presenter.presentStatus(isLocked: isLocked, source: source) + } + return nil + } catch { + return ExternalAutomationFailure(error: error) + } + } + + func waitUntilIdle() async { + await tail?.value + } +} diff --git a/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift new file mode 100644 index 0000000..8e06428 --- /dev/null +++ b/KeyboardLocker/Automation/KeyboardLockerServicesProvider.swift @@ -0,0 +1,151 @@ +import AppKit + +/// AppKit invokes service handlers synchronously and marshals the `error` out-pointer back to +/// the requesting app — the Services channel is the only external entry point that can carry a +/// failure reason (the URL scheme has none). Execution itself is async (the serial XPC chain), +/// so each handler waits, with a bound, for the outcome and reports it here. Failure presentation +/// is scheduled only after the out-pointer is populated, so a modal alert cannot delay the caller. +final nonisolated class KeyboardLockerServicesProvider: NSObject, @unchecked Sendable { + /// Outlasts the worst-case Client mutation: the initial 5s XPC reply window plus the + /// idempotent-retry window, with margin. + private static let defaultWaitTimeout: TimeInterval = 15 + + private let presentFailure: @MainActor @Sendable (ExternalAutomationFailure) -> Void + private let submit: @Sendable (ExternalAutomationAction) async -> ExternalAutomationFailure? + private let waitTimeout: TimeInterval + + init( + waitTimeout: TimeInterval = KeyboardLockerServicesProvider.defaultWaitTimeout, + presentFailure: @escaping @MainActor @Sendable (ExternalAutomationFailure) -> Void = { + AppKitExternalAutomationPresenter().presentFailures([$0], source: .service) + }, + submit: @escaping @Sendable (ExternalAutomationAction) async -> ExternalAutomationFailure? + ) { + self.presentFailure = presentFailure + self.waitTimeout = waitTimeout + self.submit = submit + } + + @objc(lockKeyboard:userData:error:) + nonisolated func lockKeyboard( + _: NSPasteboard, + userData _: String?, + error: AutoreleasingUnsafeMutablePointer + ) { + perform(.lock, error: error) + } + + @objc(unlockKeyboard:userData:error:) + nonisolated func unlockKeyboard( + _: NSPasteboard, + userData _: String?, + error: AutoreleasingUnsafeMutablePointer + ) { + perform(.unlock, error: error) + } + + @objc(showKeyboardLockStatus:userData:error:) + nonisolated func showKeyboardLockStatus( + _: NSPasteboard, + userData _: String?, + error: AutoreleasingUnsafeMutablePointer + ) { + perform(.status, error: error) + } + + private func perform( + _ action: ExternalAutomationAction, + error: AutoreleasingUnsafeMutablePointer + ) { + let completion = ServicesRequestCompletion() + let submit = submit + let presentFailure = presentFailure + Task { + let failure = await submit(action) + if let failure = completion.complete(failure: failure) { + Task { @MainActor in + presentFailure(failure) + } + } + } + + switch completion.awaitOutcome(timeout: waitTimeout) { + case .succeeded: + break + case let .failed(failure): + error.pointee = failure.message as NSString + case .timedOut: + error.pointee = """ + KeyboardLocker did not finish the service request within \(Int(waitTimeout)) seconds; \ + it may still complete. + """ as NSString + } + + if let failure = completion.publishResultChannel() { + Task { @MainActor in + presentFailure(failure) + } + } + } +} + +/// One-shot cell carrying a service request's outcome back to the thread AppKit blocked on the +/// handler. +private final nonisolated class ServicesRequestCompletion: @unchecked Sendable { + enum Outcome: Equatable { + case succeeded + case failed(ExternalAutomationFailure) + case timedOut + } + + private let semaphore = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var outcome: Outcome? + private var resultChannelPublished = false + private var presentationClaimed = false + + /// Returns a late failure only when the handler has already published its result channel. + /// Exactly one caller can claim presentation. + func complete(failure: ExternalAutomationFailure?) -> ExternalAutomationFailure? { + let outcome: Outcome = failure.map(Outcome.failed) ?? .succeeded + let failureToPresent = lock.withLock { + self.outcome = outcome + return claimFailureForPresentation() + } + semaphore.signal() + return failureToPresent + } + + /// Marks the Services error out-pointer as populated (or intentionally untouched on success). + /// A failure that completed first is returned to the handler for deferred presentation. + func publishResultChannel() -> ExternalAutomationFailure? { + lock.withLock { + resultChannelPublished = true + return claimFailureForPresentation() + } + } + + func awaitOutcome(timeout: TimeInterval) -> Outcome { + if Thread.isMainThread { + // The execution chain is main-actor; blocking the main thread outright would starve the + // very work being awaited. Pump the run loop — like a modal session — so it can proceed. + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline, lock.withLock({ outcome }) == nil { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + } + } else { + _ = semaphore.wait(timeout: .now() + timeout) + } + return lock.withLock { outcome } ?? .timedOut + } + + private func claimFailureForPresentation() -> ExternalAutomationFailure? { + guard resultChannelPublished, !presentationClaimed, + case let .failed(failure)? = outcome + else { + return nil + } + presentationClaimed = true + return failure + } +} diff --git a/KeyboardLocker/Automation/KeyboardLockerURLRoute.swift b/KeyboardLocker/Automation/KeyboardLockerURLRoute.swift new file mode 100644 index 0000000..4970f3d --- /dev/null +++ b/KeyboardLocker/Automation/KeyboardLockerURLRoute.swift @@ -0,0 +1,58 @@ +import Foundation + +nonisolated struct KeyboardLockerURLRoute: Equatable, Sendable { + static let scheme = "keyboardlocker" + + let action: ExternalAutomationAction + + init(url: URL) throws { + guard let components = URLComponents( + url: url, + resolvingAgainstBaseURL: false + ), + components.scheme?.caseInsensitiveCompare(Self.scheme) == .orderedSame, + components.user == nil, + components.password == nil, + components.port == nil, + components.percentEncodedPath.isEmpty, + components.percentEncodedQuery == nil, + components.percentEncodedFragment == nil, + let host = components.percentEncodedHost?.lowercased() + else { + throw KeyboardLockerURLRouteError.invalidURL + } + + action = switch host { + case "lock": + .lock + case "unlock": + .unlock + case "status": + .status + default: + throw KeyboardLockerURLRouteError.invalidURL + } + } + + static func requests(for urls: [URL]) -> [ExternalAutomationRequest] { + urls.map { url in + do { + return try .action(Self(url: url).action) + } catch { + return .failure(ExternalAutomationFailure(error: error)) + } + } + } +} + +nonisolated enum KeyboardLockerURLRouteError: LocalizedError, Equatable, Sendable { + case invalidURL + + var errorDescription: String? { + "KeyboardLocker rejected an invalid automation URL." + } + + var recoverySuggestion: String? { + "Use keyboardlocker://lock, keyboardlocker://unlock, or keyboardlocker://status without additional URL components." + } +} diff --git a/KeyboardLocker/CLIInstaller.swift b/KeyboardLocker/CLIInstaller.swift deleted file mode 100644 index e737a23..0000000 --- a/KeyboardLocker/CLIInstaller.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// CLIInstaller.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import AppKit -import Common -import Foundation - -enum CLIInstaller { - static var cliName: String { SharedConstants.cliName } - static var installPath: String { SharedConstants.cliInstallPath } - - enum InstallResult { - case success - case alreadyInstalled - case cancelled - case failed(Error) - } - - enum InstallError: LocalizedError { - case cliNotFoundInBundle - case scriptExecutionFailed(String) - - var errorDescription: String? { - switch self { - case .cliNotFoundInBundle: - "CLI tool not found in application bundle." - case let .scriptExecutionFailed(message): - "Installation failed: \(message)" - } - } - } - - /// Path to the CLI binary inside the app bundle - static var cliBundlePath: String? { - Bundle.main.path(forAuxiliaryExecutable: cliName) - } - - /// Check if CLI is already installed at the target path - static var isInstalled: Bool { - FileManager.default.fileExists(atPath: installPath) - } - - /// Check if the installed CLI points to the current app bundle - static var isCurrentVersionInstalled: Bool { - guard isInstalled, - let bundlePath = cliBundlePath, - let linkDest = try? FileManager.default.destinationOfSymbolicLink(atPath: installPath) - else { - return false - } - return linkDest == bundlePath - } - - /// Install the CLI tool to /usr/local/bin with admin privileges - static func install() -> InstallResult { - guard let bundlePath = cliBundlePath else { - return .failed(InstallError.cliNotFoundInBundle) - } - - // If already installed and pointing to current bundle, skip - if isCurrentVersionInstalled { - return .alreadyInstalled - } - - // Build the shell command - // Remove existing symlink if present, then create new one - let commands = [ - "mkdir -p /usr/local/bin", - "rm -f '\(installPath)'", - "ln -s '\(bundlePath)' '\(installPath)'", - ] - let script = commands.joined(separator: " && ") - - // Execute with admin privileges using AppleScript - let appleScript = """ - do shell script "\(script)" with administrator privileges - """ - - var error: NSDictionary? - if let scriptObject = NSAppleScript(source: appleScript) { - scriptObject.executeAndReturnError(&error) - - if let error { - let message = error[NSAppleScript.errorMessage] as? String ?? "Unknown error" - // Check if user cancelled - if let errorNumber = error[NSAppleScript.errorNumber] as? Int, - errorNumber == -128 - { - return .cancelled - } - return .failed(InstallError.scriptExecutionFailed(message)) - } - - return .success - } - - return .failed(InstallError.scriptExecutionFailed("Failed to create AppleScript")) - } - - /// Uninstall the CLI tool - static func uninstall() -> InstallResult { - guard isInstalled else { - return .success - } - - let script = "rm -f '\(installPath)'" - let appleScript = """ - do shell script "\(script)" with administrator privileges - """ - - var error: NSDictionary? - if let scriptObject = NSAppleScript(source: appleScript) { - scriptObject.executeAndReturnError(&error) - - if let error { - let message = error[NSAppleScript.errorMessage] as? String ?? "Unknown error" - if let errorNumber = error[NSAppleScript.errorNumber] as? Int, - errorNumber == -128 - { - return .cancelled - } - return .failed(InstallError.scriptExecutionFailed(message)) - } - - return .success - } - - return .failed(InstallError.scriptExecutionFailed("Failed to create AppleScript")) - } -} diff --git a/KeyboardLocker/CommandLineToolLinkManager.swift b/KeyboardLocker/CommandLineToolLinkManager.swift new file mode 100644 index 0000000..caec084 --- /dev/null +++ b/KeyboardLocker/CommandLineToolLinkManager.swift @@ -0,0 +1,253 @@ +import Foundation + +/// Manages a discoverability link for the bundled, signed `klock` executable. +/// +/// The executable remains inside the App bundle so updates and XPC signing identity stay aligned. +/// This type only owns a symlink in a user-writable command directory; it never copies the binary, +/// edits shell profiles, or elevates privileges. +@MainActor +final class CommandLineToolLinkManager { + enum State: Equatable { + case conflict(destination: URL) + case installed(destination: URL) + case notInstalled(destination: URL, requiresPathSetup: Bool) + case sourceUnavailable(URL) + } + + enum InstallationError: Error, Equatable, LocalizedError { + case destinationConflict(String) + case destinationNotWritable(String) + case sourceUnavailable(String) + + var errorDescription: String? { + switch self { + case let .destinationConflict(path): + "A different item already exists at \(path). KeyboardLocker did not replace it." + case let .destinationNotWritable(path): + "KeyboardLocker cannot write to the command directory at \(path)." + case let .sourceUnavailable(path): + "The bundled klock executable is missing or not executable at \(path)." + } + } + } + + private static let executableName = "klock" + + private let fileManager: FileManager + private let managedDestinations: [URL] + private let pathDirectoryPaths: Set + private let preferredDestination: URL + private let sourceURL: URL + + convenience init( + bundleURL: URL = Bundle.main.bundleURL, + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { + let homeDirectory = fileManager.homeDirectoryForCurrentUser + let localBin = homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + let homebrewBins = [ + URL(fileURLWithPath: "/opt/homebrew/bin", isDirectory: true), + URL(fileURLWithPath: "/usr/local/bin", isDirectory: true), + ] + let knownDirectories = [localBin] + homebrewBins + let pathDirectories = Self.pathDirectories(from: environment) + + let preferredDirectory = pathDirectories.first { candidate in + knownDirectories.contains(where: { Self.pathsMatch($0, candidate) }) + && Self.canInstall( + in: candidate, + localBin: localBin, + fileManager: fileManager + ) + } ?? homebrewBins.first { candidate in + let brew = candidate.appendingPathComponent("brew", isDirectory: false) + return fileManager.isExecutableFile(atPath: brew.path) + && fileManager.isWritableFile(atPath: candidate.path) + } ?? localBin + + let destinations = ([preferredDirectory] + knownDirectories) + .reduce(into: [URL]()) { result, directory in + let destination = directory.appendingPathComponent(Self.executableName) + guard !result.contains(where: { Self.pathsMatch($0, destination) }) else { + return + } + result.append(destination) + } + + self.init( + sourceURL: bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("MacOS", isDirectory: true) + .appendingPathComponent(Self.executableName, isDirectory: false), + preferredDestination: preferredDirectory + .appendingPathComponent(Self.executableName, isDirectory: false), + managedDestinations: destinations, + pathDirectories: pathDirectories, + fileManager: fileManager + ) + } + + init( + sourceURL: URL, + preferredDestination: URL, + managedDestinations: [URL]? = nil, + pathDirectories: [URL], + fileManager: FileManager = .default + ) { + self.sourceURL = sourceURL.standardizedFileURL + self.preferredDestination = preferredDestination.standardizedFileURL + self.managedDestinations = (managedDestinations ?? [preferredDestination]) + .map(\.standardizedFileURL) + pathDirectoryPaths = Set(pathDirectories.map(\.standardizedFileURL.path)) + self.fileManager = fileManager + } + + var state: State { + if let installedDestination = managedDestinations.first(where: pointsToBundledExecutable) { + return .installed(destination: installedDestination) + } + + guard fileManager.isExecutableFile(atPath: sourceURL.path) else { + return .sourceUnavailable(sourceURL) + } + + if itemExistsIncludingBrokenSymlink(at: preferredDestination) { + return .conflict(destination: preferredDestination) + } + + let directory = preferredDestination.deletingLastPathComponent().standardizedFileURL + return .notInstalled( + destination: preferredDestination, + requiresPathSetup: !pathDirectoryPaths.contains(directory.path) + ) + } + + @discardableResult + func install() throws -> State { + switch state { + case .installed: + return state + + case let .conflict(destination): + throw InstallationError.destinationConflict(displayPath(destination)) + + case let .sourceUnavailable(source): + throw InstallationError.sourceUnavailable(displayPath(source)) + + case let .notInstalled(destination, _): + let directory = destination.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } catch { + throw InstallationError.destinationNotWritable(displayPath(directory)) + } + guard fileManager.isWritableFile(atPath: directory.path) else { + throw InstallationError.destinationNotWritable(displayPath(directory)) + } + do { + try fileManager.createSymbolicLink(at: destination, withDestinationURL: sourceURL) + } catch { + if itemExistsIncludingBrokenSymlink(at: destination) { + throw InstallationError.destinationConflict(displayPath(destination)) + } + throw error + } + return state + } + } + + @discardableResult + func uninstall() throws -> State { + switch state { + case let .installed(destination): + try fileManager.removeItem(at: destination) + return state + + case .notInstalled: + return state + + case let .conflict(destination): + throw InstallationError.destinationConflict(displayPath(destination)) + + case let .sourceUnavailable(source): + throw InstallationError.sourceUnavailable(displayPath(source)) + } + } + + func displayPath(_ url: URL) -> String { + let path = url.standardizedFileURL.path + let homePath = fileManager.homeDirectoryForCurrentUser.standardizedFileURL.path + guard path == homePath || path.hasPrefix("\(homePath)/") else { + return path + } + return "~\(path.dropFirst(homePath.count))" + } + + func pathSetupCommand(for destination: URL) -> String { + let directory = destination.deletingLastPathComponent() + let displayDirectory = displayPath(directory) + let shellDirectory = displayDirectory.hasPrefix("~/") + ? "$HOME/\(displayDirectory.dropFirst(2))" + : displayDirectory + return "export PATH=\"\(shellDirectory):$PATH\"" + } + + func canRemoveLink(at destination: URL) -> Bool { + fileManager.isWritableFile( + atPath: destination.deletingLastPathComponent().path + ) + } + + private func pointsToBundledExecutable(_ destination: URL) -> Bool { + guard let target = symbolicLinkTarget(at: destination) else { + return false + } + return Self.pathsMatch(target.resolvingSymlinksInPath(), sourceURL.resolvingSymlinksInPath()) + } + + private func symbolicLinkTarget(at destination: URL) -> URL? { + guard let targetPath = try? fileManager.destinationOfSymbolicLink(atPath: destination.path) else { + return nil + } + if targetPath.hasPrefix("/") { + return URL(fileURLWithPath: targetPath).standardizedFileURL + } + return destination.deletingLastPathComponent() + .appendingPathComponent(targetPath) + .standardizedFileURL + } + + private func itemExistsIncludingBrokenSymlink(at url: URL) -> Bool { + symbolicLinkTarget(at: url) != nil || fileManager.fileExists(atPath: url.path) + } + + private static func pathDirectories(from environment: [String: String]) -> [URL] { + (environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + .filter { $0.hasPrefix("/") } + .map { URL(fileURLWithPath: $0, isDirectory: true).standardizedFileURL } + } + + private static func canInstall( + in directory: URL, + localBin: URL, + fileManager: FileManager + ) -> Bool { + if pathsMatch(directory, localBin) { + return true + } + return fileManager.fileExists(atPath: directory.path) + && fileManager.isWritableFile(atPath: directory.path) + } + + private static func pathsMatch(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.standardizedFileURL.path == rhs.standardizedFileURL.path + } +} diff --git a/KeyboardLocker/ContentView.swift b/KeyboardLocker/ContentView.swift deleted file mode 100644 index 14f8528..0000000 --- a/KeyboardLocker/ContentView.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// ContentView.swift -// KeyboardLocker -// -// Created by Eden on 2025/11/19. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - NavigationStack { - MainView() - .navigationDestination(for: AppRoute.self) { route in - switch route { - case .settings: - SettingsView() - case .about: - AboutView() - } - } - } - .frame(width: 280, height: 320) - } -} - -#Preview { - ContentView() -} diff --git a/KeyboardLocker/Info.plist b/KeyboardLocker/Info.plist new file mode 100644 index 0000000..1b5cb5c --- /dev/null +++ b/KeyboardLocker/Info.plist @@ -0,0 +1,87 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + io.lzhlovesjyq.keyboardlocker.automation + CFBundleURLSchemes + + keyboardlocker + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSUIElement + + NSAppleScriptEnabled + + NSHumanReadableCopyright + + NSServices + + + NSMenuItem + + default + Lock Keyboard + + NSMessage + lockKeyboard + NSPortName + KeyboardLocker + NSRequiredContext + + + + NSMenuItem + + default + Unlock Keyboard + + NSMessage + unlockKeyboard + NSPortName + KeyboardLocker + NSRequiredContext + + + + NSMenuItem + + default + Show Keyboard Lock Status + + NSMessage + showKeyboardLockStatus + NSPortName + KeyboardLocker + NSRequiredContext + + + + OSAScriptingDefinition + KeyboardLocker.sdef + + diff --git a/KeyboardLocker/KeyboardLocker.sdef b/KeyboardLocker/KeyboardLocker.sdef new file mode 100644 index 0000000..577250a --- /dev/null +++ b/KeyboardLocker/KeyboardLocker.sdef @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/KeyboardLocker/KeyboardLockerApp.swift b/KeyboardLocker/KeyboardLockerApp.swift deleted file mode 100644 index 6d71c2b..0000000 --- a/KeyboardLocker/KeyboardLockerApp.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// KeyboardLockerApp.swift -// KeyboardLocker -// -// Created by Eden on 2025/11/19. -// - -import SwiftUI - -@main -struct KeyboardLockerApp: App { - var body: some Scene { - MenuBarExtra("KeyboardLocker", systemImage: "lock.open.fill") { - ContentView() - } - .menuBarExtraStyle(.window) - } -} diff --git a/KeyboardLocker/KeyboardLockerApplication.swift b/KeyboardLocker/KeyboardLockerApplication.swift new file mode 100644 index 0000000..897b25a --- /dev/null +++ b/KeyboardLocker/KeyboardLockerApplication.swift @@ -0,0 +1,100 @@ +import AppKit +import Client +import Foundation + +@main +enum KeyboardLockerApplication { + @MainActor + static func main() { + #if DEBUG + if runDevelopmentCommandIfRequested() { + return + } + #endif + + let application = NSApplication.shared + let delegate = KeyboardLockerApplicationDelegate() + application.delegate = delegate + application.setActivationPolicy(.accessory) + + // `NSApplication.delegate` is weak. Keep the process-wide owner alive while the event loop + // runs so the status item and coordinator remain reachable. + withExtendedLifetime(delegate) { + application.run() + } + } + + #if DEBUG + @MainActor + private static func runDevelopmentCommandIfRequested() -> Bool { + guard CommandLine.arguments.dropFirst().elementsEqual(["--reset-agent-registration"]) else { + return false + } + + Task { @MainActor in + await exit(resetAgentRegistration()) + } + RunLoop.main.run() + return true + } + + @MainActor + private static func resetAgentRegistration() async -> Int32 { + if AgentRegistrar.isAgentEnabled { + do { + try await XPCClient.shared.unlock() + } catch { + writeStandardError( + """ + Warning: Could not unlock the agent before reset: \(error.localizedDescription) \ + Continuing with the explicit reset; stopping the agent releases its event tap. + """ + ) + } + } + + XPCClient.shared.resetConnection() + + do { + try await AgentRegistrar.unregister() + print("KeyboardLocker agent registration reset.") + return EXIT_SUCCESS + } catch { + writeStandardError("Error: Could not reset agent registration: \(error.localizedDescription)") + return EXIT_FAILURE + } + } + + private static func writeStandardError(_ message: String) { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + } + #endif +} + +@MainActor +private final class KeyboardLockerApplicationDelegate: NSObject, NSApplicationDelegate { + private let automationController: ExternalAutomationController + private let servicesProvider: KeyboardLockerServicesProvider + private var statusMenuController: StatusMenuController? + + override init() { + let automationController = ExternalAutomationController() + self.automationController = automationController + servicesProvider = KeyboardLockerServicesProvider { action in + await automationController.submitAndWait(action, source: .service) + } + super.init() + } + + func applicationDidFinishLaunching(_: Notification) { + NSApp.servicesProvider = servicesProvider + statusMenuController = StatusMenuController(coordinator: AppCoordinator()) + } + + func application(_: NSApplication, open urls: [URL]) { + automationController.submit( + KeyboardLockerURLRoute.requests(for: urls), + source: .urlScheme + ) + } +} diff --git a/KeyboardLocker/KeyboardLockerDiagnostics.swift b/KeyboardLocker/KeyboardLockerDiagnostics.swift new file mode 100644 index 0000000..9d03f3a --- /dev/null +++ b/KeyboardLocker/KeyboardLockerDiagnostics.swift @@ -0,0 +1,227 @@ +import Client +import Foundation + +@MainActor +struct KeyboardLockerDiagnosticsCollector { + struct AppIdentity: Equatable { + let build: String + let bundleIdentifier: String + let version: String + } + + typealias AccessibilityQuery = @MainActor @Sendable () async throws -> Bool + typealias DescriptorQuery = @MainActor @Sendable () async throws -> ServiceDescriptor + typealias LockSnapshotQuery = @MainActor @Sendable () async throws -> LockStatusSnapshot + + private let accessibilityQuery: AccessibilityQuery + private let appIdentity: AppIdentity + private let descriptorQuery: DescriptorQuery + private let lockSnapshotQuery: LockSnapshotQuery + private let now: @MainActor @Sendable () -> Date + private let operatingSystemVersion: String + private let registrationStatus: @MainActor @Sendable () -> String + + static var live: Self { + let bundle = Bundle.main + return Self( + appIdentity: AppIdentity( + build: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown", + bundleIdentifier: bundle.bundleIdentifier ?? "unknown", + version: bundle.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "unknown" + ), + operatingSystemVersion: ProcessInfo.processInfo.operatingSystemVersionString, + registrationStatus: { + AgentRegistrar.registrationStatusDescription + }, + descriptor: { + try await XPCClient.shared.serviceDescriptor() + }, + accessibility: { + try await XPCClient.shared.hasAccessibilityPermission() + }, + lockSnapshot: { + try await XPCClient.shared.lockStatusSnapshot() + }, + now: Date.init + ) + } + + init( + appIdentity: AppIdentity, + operatingSystemVersion: String, + registrationStatus: @escaping @MainActor @Sendable () -> String, + descriptor: @escaping DescriptorQuery, + accessibility: @escaping AccessibilityQuery, + lockSnapshot: @escaping LockSnapshotQuery, + now: @escaping @MainActor @Sendable () -> Date + ) { + self.appIdentity = appIdentity + self.operatingSystemVersion = operatingSystemVersion + self.registrationStatus = registrationStatus + descriptorQuery = descriptor + accessibilityQuery = accessibility + lockSnapshotQuery = lockSnapshot + self.now = now + } + + func report(appSnapshot: AppCoordinator.Snapshot) async -> String { + let descriptor = await capture(descriptorQuery) + let accessibility = await capture(accessibilityQuery) + let lockSnapshot = await capture(lockSnapshotQuery) + + var lines = [ + "KeyboardLocker Diagnostics", + "Generated: \(format(now()))", + "App: \(appIdentity.version) (\(appIdentity.build))", + "App Identifier: \(appIdentity.bundleIdentifier)", + "macOS: \(operatingSystemVersion)", + "Agent Registration: \(registrationStatus())", + "App State: \(appSnapshot.state.diagnosticDescription)", + "App Activity: \(appSnapshot.activity?.diagnosticDescription ?? "none")", + "Safety Check: \(appSnapshot.safetyCheckState.diagnosticDescription)", + "Last Error: \(redact(appSnapshot.lastError) ?? "none")", + "", + "Agent", + ] + + switch descriptor { + case let .success(value): + lines.append(contentsOf: [ + "Status: available", + "Identifier: \(value.agentBundleIdentifier)", + "Version: \(value.agentVersion) (\(value.agentBuild))", + "Protocol: \(value.protocolVersion.major).\(value.protocolVersion.minor)", + "Instance: \(value.agentInstanceID.uuidString)", + "Replacement: \(value.replacementPhase.rawValue)", + "Capabilities: \(value.capabilities.map(\.rawValue).sorted().joined(separator: ", "))", + ]) + + case let .failure(error): + lines.append("Status: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") + } + + switch accessibility { + case let .success(isGranted): + lines.append("Accessibility: \(isGranted ? "granted" : "not-granted")") + case let .failure(error): + lines.append("Accessibility: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") + } + + lines.append(contentsOf: ["", "Lock"]) + switch lockSnapshot { + case let .success(value): + lines.append(contentsOf: [ + "Status: \(value.isLocked ? "locked" : "unlocked")", + "Started: \(value.startedAt.map(format) ?? "none")", + "Auto Unlock: \(value.autoUnlockTargetDate.map(format) ?? "none")", + "Policy: \(value.settings.autoUnlockPolicy.diagnosticDescription)", + "Unlock Hotkey: \(value.settings.unlockHotkey.displayString)", + ]) + + case let .failure(error): + lines.append("Status: unavailable (\(redact(error.localizedDescription) ?? "unknown error"))") + } + + return lines.joined(separator: "\n") + } + + private func capture( + _ operation: @MainActor @Sendable () async throws -> Value + ) async -> Result { + do { + return try await .success(operation()) + } catch { + return .failure(error) + } + } + + private func format(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + /// Keeps reports useful while excluding path-like fragments that may carry a user name. + private func redact(_ text: String?) -> String? { + guard let text else { + return nil + } + + let redacted = text.replacingOccurrences( + of: #"(?:file://|~/|/)[^\s]+"#, + with: "", + options: .regularExpression + ) + let collapsed = redacted + .split(whereSeparator: \.isWhitespace) + .map(String.init) + .joined(separator: " ") + return collapsed.isEmpty ? nil : collapsed + } +} + +private extension AppCoordinator.State { + var diagnosticDescription: String { + switch self { + case let .checking(lastKnownLock): + "checking (last-known: \(lastKnownLock.map(String.init) ?? "unknown"))" + case .agentApprovalRequired: + "agent-approval-required" + case .agentReplacementInProgress: + "agent-replacement-in-progress" + case let .agentUpdateRequired(isLocked, _): + "agent-update-required (locked: \(isLocked.map(String.init) ?? "unknown"))" + case let .accessibilityRequired(isLocked): + "accessibility-required (locked: \(isLocked))" + case let .ready(isLocked): + "ready (locked: \(isLocked))" + case let .unavailable(_, canRestartAgent): + "unavailable (restart-available: \(canRestartAgent))" + } + } +} + +private extension AppCoordinator.Activity { + var diagnosticDescription: String { + switch self { + case .locking: + "locking" + case .requestingAccessibility: + "requesting-accessibility" + case .restartingAgent: + "restarting-agent" + case .startingSafetyCheck: + "starting-safety-check" + case .unlocking: + "unlocking" + case .updatingAgent: + "updating-agent" + } + } +} + +private extension AppCoordinator.SafetyCheckState { + var diagnosticDescription: String { + switch self { + case .completed: + "completed" + case .failed: + "failed" + case .idle: + "idle" + case .running: + "running" + } + } +} + +private extension KeyboardLockerSettings.AutoUnlockPolicy { + var diagnosticDescription: String { + switch self { + case .disabled: + "disabled" + case let .timed(seconds): + "timed (\(seconds.formatted()) seconds)" + } + } +} diff --git a/KeyboardLocker/Navigation/AppRoute.swift b/KeyboardLocker/Navigation/AppRoute.swift deleted file mode 100644 index dcc4417..0000000 --- a/KeyboardLocker/Navigation/AppRoute.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// AppRoute.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import Foundation - -/// Navigation routes for the app -enum AppRoute: Hashable { - case settings, about -} diff --git a/KeyboardLocker/SafetyCheckExperienceStore.swift b/KeyboardLocker/SafetyCheckExperienceStore.swift new file mode 100644 index 0000000..4b9531c --- /dev/null +++ b/KeyboardLocker/SafetyCheckExperienceStore.swift @@ -0,0 +1,25 @@ +import Foundation + +/// App-owned presentation preference. This does not mirror Agent settings or lock state. +struct SafetyCheckExperienceStore { + private static let completionKey = "keyboardlocker.safety-check.completed" + + private let completionKey: String + private let userDefaults: UserDefaults + + init( + userDefaults: UserDefaults = .standard, + completionKey: String = Self.completionKey + ) { + self.userDefaults = userDefaults + self.completionKey = completionKey + } + + var hasCompletedSafetyCheck: Bool { + userDefaults.bool(forKey: completionKey) + } + + func markCompleted() { + userDefaults.set(true, forKey: completionKey) + } +} diff --git a/KeyboardLocker/StatusMenuController.swift b/KeyboardLocker/StatusMenuController.swift new file mode 100644 index 0000000..b30ece6 --- /dev/null +++ b/KeyboardLocker/StatusMenuController.swift @@ -0,0 +1,616 @@ +import AppKit +import ServiceManagement + +/// Thin menu-bar presentation for `AppCoordinator` snapshots and actions. +/// It never reads or mutates lock/settings state outside the coordinator's Client boundary. +@MainActor +final class StatusMenuController: NSObject, NSMenuDelegate { + private let commandLineToolManager: CommandLineToolLinkManager + private let coordinator: AppCoordinator + private let diagnosticsCollector: KeyboardLockerDiagnosticsCollector + private let menu = NSMenu() + private let safetyCheckStore: SafetyCheckExperienceStore + private let statusItem: NSStatusItem + private var currentSnapshot: AppCoordinator.Snapshot + private var detailMessage: String? + private var hasOfferedSafetyCheckThisLaunch = false + private var lastHandledSafetyCheckState: AppCoordinator.SafetyCheckState = .idle + + convenience init(coordinator: AppCoordinator) { + self.init( + coordinator: coordinator, + commandLineToolManager: CommandLineToolLinkManager(), + diagnosticsCollector: .live, + safetyCheckStore: SafetyCheckExperienceStore() + ) + } + + init( + coordinator: AppCoordinator, + commandLineToolManager: CommandLineToolLinkManager, + diagnosticsCollector: KeyboardLockerDiagnosticsCollector, + safetyCheckStore: SafetyCheckExperienceStore + ) { + self.coordinator = coordinator + self.commandLineToolManager = commandLineToolManager + self.diagnosticsCollector = diagnosticsCollector + self.safetyCheckStore = safetyCheckStore + currentSnapshot = coordinator.snapshot + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + super.init() + + menu.delegate = self + statusItem.menu = menu + coordinator.onSnapshotChange = { [weak self] snapshot in + self?.render(snapshot) + } + } + + func menuWillOpen(_: NSMenu) { + // A long-lived wrapper must recalibrate when it becomes visible because cross-process + // notification delivery is only a refresh hint, never the authoritative state. + coordinator.reconcile() + } + + private func render(_ snapshot: AppCoordinator.Snapshot) { + currentSnapshot = snapshot + detailMessage = makeDetailMessage(for: snapshot) + + let appearance = makeAppearance(for: snapshot) + if let button = statusItem.button { + let image = NSImage( + systemSymbolName: appearance.symbolName, + accessibilityDescription: appearance.accessibilityDescription + ) + image?.isTemplate = true + button.image = image + button.toolTip = appearance.title + } + + rebuildMenu(statusTitle: appearance.title) + handleSafetyCheckExperience(snapshot) + } + + private func rebuildMenu(statusTitle: String) { + menu.removeAllItems() + + let status = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + menu.addItem(.separator()) + + if currentSnapshot.activity == nil { + addStateActions(for: currentSnapshot.state) + } + + if detailMessage != nil { + addAction(title: "Show Details…", action: #selector(showDetails)) + } + + if currentSnapshot.activity == nil, + case .ready(isLocked: false) = currentSnapshot.state, + currentSnapshot.safetyCheckState != .running { + addAction( + title: "Run 10-Second Safety Check…", + action: #selector(runSafetyCheck) + ) + } + + menu.addItem(.separator()) + addAction( + title: "Refresh Status", + action: #selector(refresh), + isEnabled: currentSnapshot.activity == nil + ) + addAction(title: "Copy Diagnostics", action: #selector(copyDiagnostics)) + menu.addItem(.separator()) + addAction(title: "Command Line Tool…", action: #selector(manageCommandLineTool)) + menu.addItem(.separator()) + addAction(title: "Quit KeyboardLocker", action: #selector(quit)) + } + + private func addStateActions(for state: AppCoordinator.State) { + switch state { + case .agentReplacementInProgress, .checking: + break + + case .agentApprovalRequired: + addAction( + title: "Open Login Items Settings…", + action: #selector(openLoginItemsSettings) + ) + + case let .agentUpdateRequired(isLocked, _): + if isLocked == true { + addAction(title: "Unlock Keyboard", action: #selector(performDisplayedLockAction)) + } + addAction(title: "Update KeyboardLocker Agent…", action: #selector(updateAgent)) + + case let .accessibilityRequired(isLocked): + if isLocked { + addAction(title: "Unlock Keyboard", action: #selector(performDisplayedLockAction)) + } + addAction( + title: "Grant Accessibility Access…", + action: #selector(requestAccessibilityPermission) + ) + addAction( + title: "Open Accessibility Settings…", + action: #selector(openAccessibilitySettings) + ) + + case let .ready(isLocked): + addAction( + title: isLocked ? "Unlock Keyboard" : "Lock Keyboard", + action: #selector(performDisplayedLockAction) + ) + + case let .unavailable(_, canRestartAgent): + if canRestartAgent { + addAction(title: "Restart KeyboardLocker Agent…", action: #selector(restartAgent)) + } + } + } + + private func addAction( + title: String, + action: Selector, + isEnabled: Bool = true + ) { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = self + item.isEnabled = isEnabled + menu.addItem(item) + } + + private func makeAppearance( + for snapshot: AppCoordinator.Snapshot + ) -> (title: String, symbolName: String, accessibilityDescription: String) { + if let activity = snapshot.activity { + let title = switch activity { + case .locking: + "KeyboardLocker — Locking…" + case .requestingAccessibility: + "KeyboardLocker — Requesting Access…" + case .restartingAgent: + "KeyboardLocker — Restarting Agent…" + case .startingSafetyCheck: + "KeyboardLocker — Starting Safety Check…" + case .unlocking: + "KeyboardLocker — Unlocking…" + case .updatingAgent: + "KeyboardLocker — Updating Agent…" + } + return (title, "arrow.triangle.2.circlepath", "Keyboard lock operation in progress") + } + + switch snapshot.state { + case let .checking(lastKnownLock): + let title = lastKnownLock == true + ? "KeyboardLocker — Checking (Locked)" + : "KeyboardLocker — Checking…" + return (title, "arrow.triangle.2.circlepath", "Checking keyboard lock status") + + case .agentApprovalRequired: + return ( + "KeyboardLocker — Approval Required", + "exclamationmark.triangle.fill", + "Background agent approval required" + ) + + case .agentReplacementInProgress: + return ( + "KeyboardLocker — Agent Update in Progress", + "arrow.triangle.2.circlepath", + "Background agent update in progress" + ) + + case let .agentUpdateRequired(isLocked, _): + // Locked variants keep the lock icon: the warning triangle is reserved for states + // where attention is needed and the keyboard is not known to be locked. + return ( + isLocked == true + ? "KeyboardLocker — Locked (Update Required)" + : "KeyboardLocker — Agent Update Required", + isLocked == true ? "lock.fill" : "exclamationmark.triangle.fill", + isLocked == true ? "Keyboard locked, agent update required" : "Background agent update required" + ) + + case let .accessibilityRequired(isLocked): + return ( + isLocked + ? "KeyboardLocker — Locked (Access Required)" + : "KeyboardLocker — Accessibility Required", + isLocked ? "lock.fill" : "exclamationmark.triangle.fill", + isLocked ? "Keyboard locked" : "Accessibility access required" + ) + + case let .ready(isLocked): + return isLocked + ? ("KeyboardLocker — Locked", "lock.fill", "Keyboard locked") + : ("KeyboardLocker — Unlocked", "lock.open.fill", "Keyboard unlocked") + + case .unavailable: + return ( + "KeyboardLocker — Agent Unavailable", + "exclamationmark.triangle.fill", + "Background agent unavailable" + ) + } + } + + private func makeDetailMessage(for snapshot: AppCoordinator.Snapshot) -> String? { + var messages: [String] = [] + + switch snapshot.state { + case .checking, .ready: + break + + case .agentApprovalRequired: + messages.append( + "Enable KeyboardLocker in System Settings → General → Login Items, then refresh status." + ) + + case let .agentReplacementInProgress(message), + let .agentUpdateRequired(_, message), + let .unavailable(message, _): + messages.append(message) + + case .accessibilityRequired: + messages.append( + "The 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 + + switch snapshot.safetyCheckState { + case .completed where previousState != .completed: + safetyCheckStore.markCompleted() + presentSafetyCheckResult( + title: "Safety Check Complete", + message: "Keyboard input is available again. KeyboardLocker is ready to use." + ) + + case let .failed(message) where snapshot.safetyCheckState != previousState: + presentSafetyCheckResult( + title: "Safety Check Failed", + message: message + ) + + case .completed, .failed, .idle, .running: + break + } + + guard !safetyCheckStore.hasCompletedSafetyCheck, + !hasOfferedSafetyCheckThisLaunch, + snapshot.activity == nil, + snapshot.safetyCheckState == .idle, + case .ready(isLocked: false) = snapshot.state + else { + return + } + + hasOfferedSafetyCheckThisLaunch = true + Task { @MainActor [weak self] in + self?.runSafetyCheck() + } + } + + private func presentSafetyCheckResult(title: String, message: String) { + Task { @MainActor in + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + NSApp.activateForUserPresentation() + alert.runModal() + } + } + + @objc + private func performDisplayedLockAction() { + coordinator.performDisplayedLockAction() + } + + @objc + private func requestAccessibilityPermission() { + coordinator.requestAccessibilityPermission() + } + + @objc + private func runSafetyCheck() { + let alert = NSAlert() + alert.messageText = "Run a 10-Second Safety Check?" + alert.informativeText = """ + KeyboardLocker will temporarily block keyboard input and keyboard system controls. The mouse \ + and trackpad remain available, and the Agent will unlock automatically after 10 seconds even \ + if this App exits. You can also unlock earlier with the configured hotkey or the notification's \ + Unlock Now action. + """ + alert.addButton(withTitle: "Start Safety Check") + alert.addButton(withTitle: "Not Now") + + NSApp.activateForUserPresentation() + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + coordinator.startSafetyCheck() + } + + @objc + private func copyDiagnostics() { + let snapshot = currentSnapshot + Task { @MainActor [weak self] in + guard let self else { + return + } + + let report = await diagnosticsCollector.report(appSnapshot: snapshot) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(report, forType: .string) + + let alert = NSAlert() + alert.messageText = "Diagnostics Copied" + alert.informativeText = "The report contains runtime state and version information, but no keyboard input, user name, host name, or file paths." + alert.addButton(withTitle: "OK") + NSApp.activateForUserPresentation() + alert.runModal() + } + } + + @objc + private func openLoginItemsSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + @objc + private func openAccessibilitySettings() { + // Only Login Items has a public System Settings entry point; once the one-shot + // Accessibility prompt has been dismissed, the privacy-pane deep link is the + // established path to land the user on the right pane. + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { + return + } + NSWorkspace.shared.open(url) + } + + @objc + private func updateAgent() { + guard case let .agentUpdateRequired(isLocked, message) = currentSnapshot.state else { + return + } + + let confirmation = NSAlert() + confirmation.alertStyle = .warning + confirmation.messageText = "Update the KeyboardLocker Agent?" + confirmation.informativeText = if isLocked == true { + "The keyboard will be unlocked before the agent is replaced.\n\n\(message)" + } else { + message + } + confirmation.addButton(withTitle: "Update Agent") + confirmation.addButton(withTitle: "Cancel") + + NSApp.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.addButton(withTitle: "Cancel") + + NSApp.activateForUserPresentation() + if confirmation.runModal() == .alertFirstButtonReturn { + coordinator.restartAgent() + } + } + + @objc + private func showDetails() { + guard let detailMessage else { + return + } + + let alert = NSAlert() + alert.messageText = "KeyboardLocker" + alert.informativeText = detailMessage + alert.addButton(withTitle: "OK") + NSApp.activateForUserPresentation() + alert.runModal() + } + + @objc + private func refresh() { + coordinator.reconcile() + } + + @objc + private func manageCommandLineTool() { + NSApp.activateForUserPresentation() + + switch commandLineToolManager.state { + case let .installed(destination): + let canRemove = commandLineToolManager.canRemoveLink(at: destination) + let alert = NSAlert() + alert.messageText = "klock Command Is Installed" + alert.informativeText = if canRemove { + "Terminal command: \(commandLineToolManager.displayPath(destination)). If Terminal cannot find it, copy the PATH command." + } else { + "Terminal command: \(commandLineToolManager.displayPath(destination)). KeyboardLocker does not have permission to remove this link." + } + if canRemove { + alert.addButton(withTitle: "Uninstall") + } + alert.addButton(withTitle: "Copy PATH Command") + alert.addButton(withTitle: "Close") + + switch alert.runModal() { + case .alertFirstButtonReturn where canRemove: + do { + _ = try commandLineToolManager.uninstall() + showCommandLineToolResult( + title: "klock Command Removed", + message: "The command link was removed. The bundled executable was not changed." + ) + } catch { + showCommandLineToolError(error) + } + + case .alertFirstButtonReturn, + .alertSecondButtonReturn where canRemove: + copyPathCommand(for: destination) + + default: + return + } + + case let .notInstalled(destination, requiresPathSetup): + let path = commandLineToolManager.displayPath(destination) + let alert = NSAlert() + alert.messageText = "Install the klock Command?" + alert.informativeText = if requiresPathSetup { + """ + KeyboardLocker will create a symbolic link at \(path). It will not modify shell profiles. \ + You may need to add its directory to PATH before Terminal can find klock. + """ + } else { + "KeyboardLocker will create a symbolic link at \(path). The signed executable stays inside the App." + } + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + do { + _ = try commandLineToolManager.install() + showCommandLineToolInstalled( + at: destination, + requiresPathSetup: requiresPathSetup + ) + } catch { + showCommandLineToolError(error) + } + + case let .conflict(destination): + showCommandLineToolResult( + title: "Cannot Install klock", + message: "A different item already exists at \(commandLineToolManager.displayPath(destination)). Nothing was changed." + ) + + case let .sourceUnavailable(source): + showCommandLineToolResult( + title: "klock Is Unavailable", + message: "The App bundle is incomplete. The expected executable was not found at \(commandLineToolManager.displayPath(source))." + ) + } + } + + private func showCommandLineToolInstalled( + at destination: URL, + requiresPathSetup: Bool + ) { + let alert = NSAlert() + alert.messageText = "klock Command Installed" + alert.informativeText = if requiresPathSetup { + """ + Installed at \(commandLineToolManager.displayPath(destination)). Add its directory to PATH, \ + then open a new Terminal and run klock --help. + """ + } else { + "Installed at \(commandLineToolManager.displayPath(destination)). Open a new Terminal and run klock --help." + } + if requiresPathSetup { + alert.addButton(withTitle: "Copy PATH Command") + alert.addButton(withTitle: "Close") + if alert.runModal() == .alertFirstButtonReturn { + copyPathCommand(for: destination) + } + } else { + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + + private func copyPathCommand(for destination: URL) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + commandLineToolManager.pathSetupCommand(for: destination), + forType: .string + ) + } + + private func showCommandLineToolError(_ error: Error) { + showCommandLineToolResult( + title: "Command Line Tool Error", + message: error.localizedDescription + ) + } + + private func showCommandLineToolResult(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } + + @objc + private func quit() { + 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/Views/AboutView.swift b/KeyboardLocker/Views/AboutView.swift deleted file mode 100644 index b55c32b..0000000 --- a/KeyboardLocker/Views/AboutView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AboutView.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import SwiftUI - -struct AboutView: View { - var body: some View { - VStack(spacing: 16) { - Text("About") - .font(.headline) - - Spacer() - - Text("About content will be implemented here.") - .foregroundStyle(.secondary) - - Spacer() - } - .padding() - .navigationTitle("About") - } -} - -#Preview { - NavigationStack { - AboutView() - } - .frame(width: 280, height: 320) -} diff --git a/KeyboardLocker/Views/MainView.swift b/KeyboardLocker/Views/MainView.swift deleted file mode 100644 index 50b4b0b..0000000 --- a/KeyboardLocker/Views/MainView.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// MainView.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import SwiftUI - -struct MainView: View { - var body: some View { - VStack(spacing: 16) { - header - Divider() - Spacer() - footer - } - .padding() - } - - private var header: some View { - HStack { - Image(systemName: "lock.open.fill") - .font(.title) - .foregroundStyle(.secondary) - Text("KeyboardLocker") - .font(.headline) - Spacer() - } - } - - private var footer: some View { - HStack { - NavigationLink(value: AppRoute.settings) { - Label("Settings", systemImage: "gear") - } - .buttonStyle(.borderless) - - NavigationLink(value: AppRoute.about) { - Label("About", systemImage: "info.circle") - } - .buttonStyle(.borderless) - - Spacer() - - Button("Quit") { - NSApplication.shared.terminate(nil) - } - } - } -} - -#Preview { - NavigationStack { - MainView() - } - .frame(width: 280, height: 320) -} diff --git a/KeyboardLocker/Views/SettingsView.swift b/KeyboardLocker/Views/SettingsView.swift deleted file mode 100644 index 3f9086b..0000000 --- a/KeyboardLocker/Views/SettingsView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// SettingsView.swift -// KeyboardLocker -// -// Created by Eden on 2025/12/16. -// - -import SwiftUI - -struct SettingsView: View { - var body: some View { - VStack(spacing: 16) { - Text("Settings") - .font(.headline) - - Spacer() - - Text("Settings content will be implemented here.") - .foregroundStyle(.secondary) - - Spacer() - } - .padding() - .navigationTitle("Settings") - } -} - -#Preview { - NavigationStack { - SettingsView() - } - .frame(width: 280, height: 320) -} diff --git a/KeyboardLockerAgent/AgentService.swift b/KeyboardLockerAgent/AgentService.swift deleted file mode 100644 index d2fb866..0000000 --- a/KeyboardLockerAgent/AgentService.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import Service - -/// XPC service implementation for keyboard lock/unlock operations. -final class AgentService: NSObject, KeyboardLockerServiceProtocol { - func lockKeyboard(reply: @escaping (Error?) -> Void) { - executeOnMainThread { - do { - try LockEngine.shared.lock() - reply(nil) - } catch { - reply(error) - } - } - } - - func unlockKeyboard(reply: @escaping (Error?) -> Void) { - executeOnMainThread { - LockEngine.shared.unlock() - reply(nil) - } - } - - func status(reply: @escaping (Bool, Error?) -> Void) { - executeOnMainThread { - reply(LockEngine.shared.isLocked, nil) - } - } - - func requestAccessibilityPermission(showPrompt: Bool, reply: @escaping (Bool) -> Void) { - executeOnMainThread { - reply(AccessibilityManager.requestPermission(showPrompt: showPrompt)) - } - } - - func accessibilityStatus(reply: @escaping (Bool) -> Void) { - executeOnMainThread { - reply(AccessibilityManager.hasPermission()) - } - } - - private func executeOnMainThread(_ operation: @escaping () -> Void) { - if Thread.isMainThread { - operation() - } else { - DispatchQueue.main.async(execute: operation) - } - } -} diff --git a/KeyboardLockerAgent/main.swift b/KeyboardLockerAgent/main.swift index 00983ea..870bf50 100644 --- a/KeyboardLockerAgent/main.swift +++ b/KeyboardLockerAgent/main.swift @@ -1,21 +1,19 @@ import Foundation import Service -/// Singleton service instance shared across all XPC connections -/// Ensures consistent state and avoids per-connection instance issues -private let sharedService = AgentService() - /// Accepts incoming XPC connections and configures them using the factory private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { + private let sharedService: AgentService + + init(sharedService: AgentService) { + self.sharedService = sharedService + } + func listener( _: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection ) -> Bool { - guard XPCAccessControl.isConnectionAuthorized(newConnection) else { - print("KeyboardLockerAgent: Rejected unauthorized connection") - return false - } - + // The listener's code-signing requirement is evaluated before this delegate is called. XPCServerConnection.configure(newConnection, exportedService: sharedService) return true } @@ -23,13 +21,29 @@ private final class ServiceDelegate: NSObject, NSXPCListenerDelegate { @MainActor private func startAgent() { + // One process-wide service owns the authoritative settings, replacement transaction, and + // LockEngine, including notification presentation. Every accepted connection receives only a + // proxy to this same instance. + let sharedService = AgentService() let listener = NSXPCListener(machServiceName: SharedConstants.machServiceName) - let delegate = ServiceDelegate() + do { + try listener.setConnectionCodeSigningRequirement( + XPCAccessControl.authorizedClientRequirement() + ) + } catch { + fatalError("KeyboardLockerAgent could not configure XPC peer authentication: \(error)") + } + + let delegate = ServiceDelegate(sharedService: sharedService) listener.delegate = delegate - listener.resume() + listener.activate() print("KeyboardLockerAgent started, listening on \(SharedConstants.machServiceName)") - RunLoop.main.run() + // `NSXPCListener.delegate` is weak. Retain both objects explicitly for the lifetime of the + // process instead of relying on optimizer-dependent local-variable lifetime extension. + withExtendedLifetime((listener, delegate)) { + RunLoop.main.run() + } } MainActor.assumeIsolated { diff --git a/KeyboardLockerFocusIntents/AppIntentsExtension.swift b/KeyboardLockerFocusIntents/AppIntentsExtension.swift new file mode 100644 index 0000000..e612739 --- /dev/null +++ b/KeyboardLockerFocusIntents/AppIntentsExtension.swift @@ -0,0 +1,5 @@ +import AppIntents +import ExtensionFoundation + +@main +struct KeyboardLockerFocusIntentsExtension: AppIntentsExtension {} diff --git a/KeyboardLockerFocusIntents/Info.plist b/KeyboardLockerFocusIntents/Info.plist new file mode 100644 index 0000000..5e9f303 --- /dev/null +++ b/KeyboardLockerFocusIntents/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + KeyboardLocker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.appintents-extension + + + diff --git a/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift new file mode 100644 index 0000000..8349126 --- /dev/null +++ b/KeyboardLockerFocusIntents/KeyboardLockFocusFilterIntent.swift @@ -0,0 +1,60 @@ +import AppIntents +import Client +import SystemSurfaces + +protocol AgentFocusLockServing: Sendable { + func setFocusFilterLockEnabled(_ enabled: Bool) async throws +} + +struct LiveAgentFocusLockClient: AgentFocusLockServing { + func setFocusFilterLockEnabled(_ enabled: Bool) async throws { + try await XPCClient.shared.setFocusFilterLockEnabled(enabled) + } +} + +struct KeyboardLockFocusFilterIntent: SetFocusFilterIntent { + static let title: LocalizedStringResource = "Keyboard Lock" + static let description = IntentDescription( + "Creates a keyboard lock when this Focus activates and releases it when the Focus turns off. If you unlock it while the Focus is active, it won't automatically re-lock. A lock taken over by another action stays locked." + ) + + @Parameter(title: "Lock Keyboard", default: false) + var lockKeyboard: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: lockKeyboard ? "Lock Keyboard" : "Do Not Lock Keyboard" + ) + } + + private let client: any AgentFocusLockServing + private let surfaceInvalidator: LockStateSurfaceInvalidator + + init() { + client = LiveAgentFocusLockClient() + surfaceInvalidator = .live + lockKeyboard = false + } + + init( + lockKeyboard: Bool, + client: any AgentFocusLockServing = LiveAgentFocusLockClient(), + surfaceInvalidator: LockStateSurfaceInvalidator = .live + ) { + self.client = client + self.surfaceInvalidator = surfaceInvalidator + self.lockKeyboard = lockKeyboard + } + + static func suggestedFocusFilters( + for _: FocusFilterSuggestionContext + ) async -> [Self] { + [Self(lockKeyboard: true)] + } + + func perform() async throws -> some IntentResult { + try await client.setFocusFilterLockEnabled(lockKeyboard) + surfaceInvalidator.invalidate() + return .result() + } +} diff --git a/KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements b/KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements new file mode 100644 index 0000000..631a0dc --- /dev/null +++ b/KeyboardLockerFocusIntents/KeyboardLockerFocusIntents.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.temporary-exception.mach-lookup.global-name + + io.lzhlovesjyq.keyboardlocker.agent + + + diff --git a/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift new file mode 100644 index 0000000..f28ec0f --- /dev/null +++ b/KeyboardLockerModelTests/AgentCoordinationServicesTests.swift @@ -0,0 +1,280 @@ +import Client +import Foundation +import SystemSurfaces +import Testing + +@Suite(.serialized) +struct AgentCoordinationServicesTests { + @Test + @MainActor + func successfulLockInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + try await client.lock() + + #expect(recorder.calls == [.lock, .widget, .control]) + } + + @Test + @MainActor + func successfulUnlockInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + try await client.unlock() + + #expect(recorder.calls == [.unlock, .widget, .control]) + } + + @Test + @MainActor + func failedMutationDoesNotInvalidateSurfaces() async { + let recorder = CallRecorder() + let client = LiveAgentClient( + lock: { + recorder.record(.lock) + throw TestError.expected + }, + unlock: { + recorder.record(.unlock) + }, + status: { + recorder.record(.status) + return false + }, + toggle: { + recorder.record(.toggle) + return true + }, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + + do { + try await client.lock() + Issue.record("Expected the Agent failure to propagate.") + } catch { + #expect(error as? TestError == .expected) + } + + #expect(recorder.calls == [.lock]) + } + + @Test + @MainActor + func failedUnlockDoesNotInvalidateSurfaces() async { + let recorder = CallRecorder() + let client = LiveAgentClient( + lock: { + recorder.record(.lock) + }, + unlock: { + recorder.record(.unlock) + throw TestError.expected + }, + status: { + recorder.record(.status) + return false + }, + toggle: { + recorder.record(.toggle) + return true + }, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + + do { + try await client.unlock() + Issue.record("Expected the Agent failure to propagate.") + } catch { + #expect(error as? TestError == .expected) + } + + #expect(recorder.calls == [.unlock]) + } + + @Test + @MainActor + func statusDoesNotInvalidateSurfaces() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder, status: true) + + let isLocked = try await client.status() + + #expect(isLocked) + #expect(recorder.calls == [.status]) + } + + @Test + @MainActor + func successfulToggleInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + let isLocked = try await client.toggle() + + #expect(isLocked) + #expect(recorder.calls == [.toggle, .widget, .control]) + } + + @Test + @MainActor + func successfulSafetyCheckInvalidatesWidgetThenControl() async throws { + let recorder = CallRecorder() + let client = makeClient(recorder: recorder) + + let outcome = try await client.beginSafetyCheck() + + #expect(outcome == .acquired) + #expect(recorder.calls == [.safetyCheck, .widget, .control]) + } + + @Test + @MainActor + func failedToggleDoesNotInvalidateSurfaces() async { + let recorder = CallRecorder() + let client = LiveAgentClient( + lock: { + recorder.record(.lock) + }, + unlock: { + recorder.record(.unlock) + }, + status: { + recorder.record(.status) + return false + }, + toggle: { + recorder.record(.toggle) + throw TestError.expected + }, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + + do { + _ = try await client.toggle() + Issue.record("Expected the Agent failure to propagate.") + } catch { + #expect(error as? TestError == .expected) + } + + #expect(recorder.calls == [.toggle]) + } + + @Test + @MainActor + func liveObserverInvalidatesAtSubscriptionAndBeforeForwardingAChange() { + let recorder = CallRecorder() + let base = RecordingLockStateObserver() + let observer = LiveAgentLockStateObserver( + observer: base, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + var receivedValues: [Bool] = [] + + let token = observer.subscribe(initialState: false) { isLocked in + receivedValues.append(isLocked) + } + + #expect(base.initialStates == [false]) + #expect(recorder.calls == [.widget, .control]) + + base.send(true) + + #expect(receivedValues == [true]) + #expect(recorder.calls == [.widget, .control, .widget, .control]) + withExtendedLifetime(token) {} + } + + @MainActor + private func makeClient( + recorder: CallRecorder, + status: Bool = false + ) -> LiveAgentClient { + LiveAgentClient( + lock: { + recorder.record(.lock) + }, + unlock: { + recorder.record(.unlock) + }, + status: { + recorder.record(.status) + return status + }, + toggle: { + recorder.record(.toggle) + return true + }, + beginSafetyCheck: { + recorder.record(.safetyCheck) + return .acquired + }, + waitUntilUnlocked: {}, + surfaceInvalidator: makeInvalidator(recorder: recorder) + ) + } +} + +private extension AgentCoordinationServicesTests { + enum TestError: Error { + case expected + } + + func makeInvalidator(recorder: CallRecorder) -> LockStateSurfaceInvalidator { + LockStateSurfaceInvalidator( + reloadWidget: { + recorder.record(.widget) + }, + reloadControl: { + recorder.record(.control) + } + ) + } +} + +private final class CallRecorder: @unchecked Sendable { + enum Call: Equatable { + case control + case lock + case safetyCheck + case status + case toggle + case unlock + case widget + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} + +@MainActor +private final class RecordingLockStateObserver: AgentLockStateObserving { + private(set) var initialStates: [Bool?] = [] + private var handler: ((Bool) -> Void)? + + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + initialStates.append(initialState) + self.handler = handler + return ObserverToken {} + } + + func send(_ isLocked: Bool) { + handler?(isLocked) + } +} diff --git a/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift new file mode 100644 index 0000000..aed277a --- /dev/null +++ b/KeyboardLockerModelTests/AgentReadinessCoordinatorTests.swift @@ -0,0 +1,671 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct AgentReadinessCoordinatorTests { + @MainActor + @Test + func nonEnabledRegistrationSkipsXPCAndReturnsRegistration() async { + let client = FakeAgentReadinessClient( + descriptorResults: [.failure(TestError.handshake)] + ) + let lifecycle = FakeAgentReadinessLifecycle( + ensureEnabledResult: .approvalRequired + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .registration(state) = outcome else { + Issue.record("Expected .registration, got \(outcome).") + return + } + #expect(state == .approvalRequired) + #expect(client.descriptorCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.resetConnectionCallCount == 0) + #expect(lifecycle.checkedDescriptors.isEmpty) + } + + @MainActor + @Test + func handshakeFailureResetsConnectionOnceAndRetries() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.failure(TestError.handshake), .success(descriptor)], + statusResult: .success(true), + permissionResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .ready(isLocked, hasAccessibilityPermission) = outcome else { + Issue.record("Expected .ready, got \(outcome).") + return + } + #expect(isLocked) + #expect(hasAccessibilityPermission) + #expect(client.descriptorCallCount == 2) + #expect(client.resetConnectionCallCount == 1) + #expect(Array(client.events.prefix(3)) == [.serviceDescriptor, .resetConnection, .serviceDescriptor]) + } + + @MainActor + @Test + func doubleHandshakeFailureFallsBackToForcedUpdateWithStatusLockState() async { + let client = FakeAgentReadinessClient( + descriptorResults: [ + .failure(TestError.handshake), + .failure(TestError.handshake), + ], + statusResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + Issue.record("Expected .updateRequired, got \(outcome).") + return + } + guard case let .forced(descriptor, isLocked) = plan.mode else { + Issue.record("Expected a forced plan, got \(plan.mode).") + return + } + #expect(descriptor == nil) + #expect(isLocked == true) + #expect(plan.message.contains("could not be verified")) + #expect(client.descriptorCallCount == 2) + #expect(client.statusCallCount == 1) + #expect(client.resetConnectionCallCount == 2) + #expect(lifecycle.checkedDescriptors.isEmpty) + } + + @MainActor + @Test + func doubleHandshakeFailureWithUnreadableStatusReportsHandshakeContext() async { + let client = FakeAgentReadinessClient( + descriptorResults: [ + .failure(TestError.handshake), + .failure(TestError.handshake), + ], + statusResult: .failure(TestError.status) + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .failure(error, context) = outcome else { + Issue.record("Expected .failure, got \(outcome).") + return + } + #expect(error as? TestError == .status) + #expect(context?.contains("Descriptor handshake failed") == true) + } + + @MainActor + @Test + func compatibleDescriptorReturnsReadySnapshot() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(false), + permissionResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .ready(isLocked, hasAccessibilityPermission) = outcome else { + Issue.record("Expected .ready, got \(outcome).") + return + } + #expect(!isLocked) + #expect(hasAccessibilityPermission) + #expect(client.descriptorCallCount == 1) + #expect(client.resetConnectionCallCount == 0) + #expect(lifecycle.checkedDescriptors == [descriptor]) + } + + @MainActor + @Test + func matchingPreviousInstanceIDReportsAgentDidNotRestart() async { + let instanceID = UUID() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor(agentInstanceID: instanceID))] + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect( + expectedPreviousAgentInstanceID: instanceID + ) + + guard case .agentDidNotRestart = outcome else { + Issue.record("Expected .agentDidNotRestart, got \(outcome).") + return + } + #expect(lifecycle.checkedDescriptors.isEmpty) + #expect(client.statusCallCount == 0) + } + + @MainActor + @Test + func bundledUpgradeAvailableCarriesDescriptorMessageBuildAndLockState() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .bundledAgentUpgradeAvailable( + message: "Upgrade available.", + bundledBuild: "99" + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateAvailable( + reportedDescriptor, + message, + bundledBuild, + isLocked + ) = outcome else { + Issue.record("Expected .updateAvailable, got \(outcome).") + return + } + #expect(reportedDescriptor == descriptor) + #expect(message == "Upgrade available.") + #expect(bundledBuild == "99") + #expect(isLocked) + #expect(client.statusCallCount == 1) + } + + @MainActor + @Test + func safeReplacementWithoutReadableLockStateFails() async { + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())] + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: false, + supportsSafeReplacement: true + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .failure(error, context) = outcome else { + Issue.record("Expected .failure, got \(outcome).") + return + } + #expect(error.localizedDescription == "Safe KeyboardLocker agent replacement requires a readable authoritative lock state.") + #expect(context == nil) + #expect(client.statusCallCount == 0) + } + + @MainActor + @Test + func updateRequiredWithoutLockStateOrSafeSupportForcesUpdate() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: false, + supportsSafeReplacement: false + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + Issue.record("Expected .updateRequired, got \(outcome).") + return + } + #expect(plan == AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: nil), + message: "Update required." + )) + #expect(client.statusCallCount == 0) + } + + @MainActor + @Test + func updateRequiredWithLockStateAndSafeSupportChoosesSafeMode() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(true) + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: true, + supportsSafeReplacement: true + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + Issue.record("Expected .updateRequired, got \(outcome).") + return + } + #expect(plan == AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + )) + #expect(client.statusCallCount == 1) + } + + @MainActor + @Test + func updateRequiredWithLockStateWithoutSafeSupportChoosesForcedMode() async { + let descriptor = makeDescriptor() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)], + statusResult: .success(false) + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .updateRequired( + message: "Update required.", + canReadLockState: true, + supportsSafeReplacement: false + ) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .updateRequired(plan) = outcome else { + Issue.record("Expected .updateRequired, got \(outcome).") + return + } + #expect(plan == AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: false), + message: "Update required." + )) + #expect(client.statusCallCount == 1) + } + + @MainActor + @Test + func trustedReplacementInProgressShortCircuitsCompatibilityOutcome() async { + let descriptor = makeDescriptor(replacementPending: true) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .replacementInProgress(reported) = outcome else { + Issue.record("Expected .replacementInProgress, got \(outcome).") + return + } + #expect(reported == descriptor) + #expect(client.statusCallCount == 0) + } + + @MainActor + @Test + func replacementPendingWithWrongBundleIdentifierFallsThroughToCompatibility() async { + let descriptor = makeDescriptor( + agentBundleIdentifier: "com.example.unrelated.agent", + replacementPending: true + ) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case .ready = outcome else { + Issue.record("Expected .ready, got \(outcome).") + return + } + #expect(client.statusCallCount == 1) + } + + @MainActor + @Test + func replacementPendingWithoutDrainCapabilityFallsThroughToCompatibility() async { + var capabilities = ServiceContract.requiredCapabilities + capabilities.remove(.committedReplacementDrain) + let descriptor = makeDescriptor( + capabilities: capabilities, + replacementPending: true + ) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case .ready = outcome else { + Issue.record("Expected .ready, got \(outcome).") + return + } + #expect(client.statusCallCount == 1) + } + + @MainActor + @Test + func replacementPendingWithInvalidBundleCompatibilityFallsThrough() async { + let descriptor = makeDescriptor(replacementPending: true) + let client = FakeAgentReadinessClient( + descriptorResults: [.success(descriptor)] + ) + let lifecycle = FakeAgentReadinessLifecycle( + compatibilityResult: .invalidBundle(.invalidBundle("Corrupt bundle.")) + ) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let outcome = await coordinator.inspect() + + guard case let .invalidBundle(failure) = outcome else { + Issue.record("Expected .invalidBundle, got \(outcome).") + return + } + #expect(failure == .invalidBundle("Corrupt bundle.")) + #expect(client.statusCallCount == 0) + } + + @MainActor + @Test + func cancellationAfterEnsureEnabledReturnsCancelled() async { + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())] + ) + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + inspection.cancel() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + Issue.record("Expected .cancelled, got \(outcome).") + return + } + #expect(client.descriptorCallCount == 0) + } + + @MainActor + @Test + func cancellationDuringInitialDescriptorFetchReturnsCancelled() async { + let descriptorGate = ReadinessTestGate() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())] + ) + client.descriptorGate = descriptorGate + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await descriptorGate.waitUntilEntered() + inspection.cancel() + descriptorGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + Issue.record("Expected .cancelled, got \(outcome).") + return + } + #expect(client.descriptorCallCount == 1) + #expect(lifecycle.checkedDescriptors.isEmpty) + } + + @MainActor + @Test + func cancellationAfterDescriptorFailureSkipsRetryAndReturnsCancelled() async { + let descriptorGate = ReadinessTestGate() + let client = FakeAgentReadinessClient( + descriptorResults: [.failure(TestError.handshake)] + ) + client.descriptorGate = descriptorGate + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await descriptorGate.waitUntilEntered() + inspection.cancel() + descriptorGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + Issue.record("Expected .cancelled, got \(outcome).") + return + } + #expect(client.descriptorCallCount == 1) + #expect(client.resetConnectionCallCount == 0) + } + + @MainActor + @Test + func cancellationDuringCompatibleStatusFetchReturnsCancelled() async { + let statusGate = ReadinessTestGate() + let client = FakeAgentReadinessClient( + descriptorResults: [.success(makeDescriptor())], + statusResult: .success(true) + ) + client.statusGate = statusGate + let lifecycle = FakeAgentReadinessLifecycle(compatibilityResult: .compatible) + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await statusGate.waitUntilEntered() + inspection.cancel() + statusGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + Issue.record("Expected .cancelled, got \(outcome).") + return + } + #expect(client.statusCallCount == 1) + } + + @MainActor + @Test + func cancellationDuringUnverifiedAgentStatusFetchReturnsCancelled() async { + let statusGate = ReadinessTestGate() + let client = FakeAgentReadinessClient( + descriptorResults: [ + .failure(TestError.handshake), + .failure(TestError.handshake), + ], + statusResult: .success(true) + ) + client.statusGate = statusGate + let lifecycle = FakeAgentReadinessLifecycle() + let coordinator = AgentReadinessCoordinator(client: client, lifecycle: lifecycle) + + let inspection = Task { await coordinator.inspect() } + await statusGate.waitUntilEntered() + inspection.cancel() + statusGate.open() + let outcome = await inspection.value + + guard case .cancelled = outcome else { + Issue.record("Expected .cancelled, got \(outcome).") + return + } + #expect(client.descriptorCallCount == 2) + #expect(client.statusCallCount == 1) + } +} + +@MainActor +private final class FakeAgentReadinessClient: AgentReadinessServing { + enum Event: Equatable { + case hasAccessibilityPermission + case resetConnection + case serviceDescriptor + case status + } + + private var descriptorResults: [Result] + var statusResult: Result + var permissionResult: Result + var descriptorGate: ReadinessTestGate? + var statusGate: ReadinessTestGate? + + private(set) var events: [Event] = [] + private(set) var descriptorCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var resetConnectionCallCount = 0 + + init( + descriptorResults: [Result], + statusResult: Result = .success(false), + permissionResult: Result = .success(true) + ) { + precondition( + !descriptorResults.isEmpty, + "At least one scripted descriptor result is required." + ) + self.descriptorResults = descriptorResults + self.statusResult = statusResult + self.permissionResult = permissionResult + } + + func serviceDescriptor() async throws -> ServiceDescriptor { + events.append(.serviceDescriptor) + descriptorCallCount += 1 + if let descriptorGate { + await descriptorGate.wait() + } + let result = descriptorResults.count > 1 + ? descriptorResults.removeFirst() + : descriptorResults[0] + return try result.get() + } + + func status() async throws -> Bool { + events.append(.status) + statusCallCount += 1 + if let statusGate { + await statusGate.wait() + } + return try statusResult.get() + } + + func hasAccessibilityPermission() async throws -> Bool { + events.append(.hasAccessibilityPermission) + return try permissionResult.get() + } + + func resetConnection() { + events.append(.resetConnection) + resetConnectionCallCount += 1 + } +} + +@MainActor +private final class FakeAgentReadinessLifecycle: AgentReadinessLifecycleServing { + var ensureEnabledResult: AgentRegistrar.State + var compatibilityResult: AgentRegistrar.Compatibility + private(set) var checkedDescriptors: [ServiceDescriptor] = [] + + init( + ensureEnabledResult: AgentRegistrar.State = .enabled, + compatibilityResult: AgentRegistrar.Compatibility = .compatible + ) { + self.ensureEnabledResult = ensureEnabledResult + self.compatibilityResult = compatibilityResult + } + + func ensureEnabled() -> AgentRegistrar.State { + ensureEnabledResult + } + + func compatibility( + of descriptor: ServiceDescriptor + ) -> AgentRegistrar.Compatibility { + checkedDescriptors.append(descriptor) + return compatibilityResult + } +} + +/// Holds a fake XPC response until the test releases it, so cancellation can land while +/// the coordinator is suspended at a specific call. +@MainActor +private final class ReadinessTestGate { + private var entryWaiters: [CheckedContinuation] = [] + private var hasEntered = false + private var waiters: [CheckedContinuation] = [] + private var isOpen = false + + func wait() async { + hasEntered = true + let pendingEntryWaiters = entryWaiters + entryWaiters.removeAll() + pendingEntryWaiters.forEach { $0.resume() } + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func waitUntilEntered() async { + guard !hasEntered else { + return + } + await withCheckedContinuation { continuation in + entryWaiters.append(continuation) + } + } + + func open() { + isOpen = true + let pending = waiters + waiters.removeAll() + pending.forEach { $0.resume() } + } +} + +private func makeDescriptor( + agentInstanceID: UUID = UUID(), + capabilities: Set = ServiceContract.requiredCapabilities, + agentBundleIdentifier: String = SharedConstants.agentBundleIdentifier, + replacementPending: Bool = false +) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: capabilities, + agentBundleIdentifier: agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: agentInstanceID, + replacementPending: replacementPending + ) +} + +private enum TestError: Error, Equatable { + case handshake + case status +} diff --git a/KeyboardLockerModelTests/AgentRegistrarTests.swift b/KeyboardLockerModelTests/AgentRegistrarTests.swift new file mode 100644 index 0000000..09b1bbd --- /dev/null +++ b/KeyboardLockerModelTests/AgentRegistrarTests.swift @@ -0,0 +1,159 @@ +import ServiceManagement +import Testing + +@Suite(.serialized) +struct AgentRegistrarTests { + @Test + @MainActor + func notFoundWithValidBundledAssetsAttemptsRegistration() { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .enabled + ) + + let state = AgentRegistrar.ensureEnabled( + service: service, + validateBundledRegistration: {} + ) + + #expect(state == .enabled) + #expect(service.registrationCallCount == 1) + } + + @Test + @MainActor + func notFoundWithMissingBundledAssetsDoesNotAttemptRegistration() { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .enabled + ) + + let state = AgentRegistrar.ensureEnabled( + service: service, + validateBundledRegistration: { + throw AgentRegistrar.Failure.notFound + } + ) + + #expect(state == .unavailable(.notFound)) + #expect(service.registrationCallCount == 0) + } + + @Test + @MainActor + func notFoundRegistrationFailurePreservesUnderlyingError() { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .notFound, + registrationError: RegistrationError.failed + ) + + let state = AgentRegistrar.ensureEnabled( + service: service, + validateBundledRegistration: {} + ) + + #expect( + state == + .unavailable(.registrationFailed("Registration failed for testing.")) + ) + #expect(service.registrationCallCount == 1) + } + + @Test + @MainActor + func unregisterRemovesEnabledService() async throws { + let service = FakeAgentRegistrationService( + status: .enabled, + statusAfterRegistration: .enabled, + statusAfterUnregistration: .notRegistered + ) + + try await AgentRegistrar.unregister(service: service) + + #expect(service.status == .notRegistered) + #expect(service.unregistrationCallCount == 1) + } + + @Test + @MainActor + func unregisterIsIdempotentWhenServiceIsNotFound() async throws { + let service = FakeAgentRegistrationService( + status: .notFound, + statusAfterRegistration: .enabled + ) + + try await AgentRegistrar.unregister(service: service) + + #expect(service.status == .notFound) + #expect(service.unregistrationCallCount == 0) + } + + @Test + @MainActor + func unregisterPreservesUnderlyingError() async { + let service = FakeAgentRegistrationService( + status: .enabled, + statusAfterRegistration: .enabled, + unregistrationError: RegistrationError.failed + ) + + do { + try await AgentRegistrar.unregister(service: service) + Issue.record("Expected unregister to throw.") + } catch { + #expect(error.localizedDescription == "Registration failed for testing.") + } + #expect(service.unregistrationCallCount == 1) + } +} + +@MainActor +private final class FakeAgentRegistrationService: AgentRegistrationServing { + private(set) var registrationCallCount = 0 + private(set) var unregistrationCallCount = 0 + private(set) var status: SMAppService.Status + + private let registrationError: Error? + private let statusAfterRegistration: SMAppService.Status + private let statusAfterUnregistration: SMAppService.Status + private let unregistrationError: Error? + + init( + status: SMAppService.Status, + statusAfterRegistration: SMAppService.Status, + registrationError: Error? = nil, + statusAfterUnregistration: SMAppService.Status = .notRegistered, + unregistrationError: Error? = nil + ) { + self.status = status + self.statusAfterRegistration = statusAfterRegistration + self.registrationError = registrationError + self.statusAfterUnregistration = statusAfterUnregistration + self.unregistrationError = unregistrationError + } + + func register() throws { + registrationCallCount += 1 + if let registrationError { + throw registrationError + } + status = statusAfterRegistration + } + + func unregister() async throws { + unregistrationCallCount += 1 + if let unregistrationError { + throw unregistrationError + } + status = statusAfterUnregistration + } +} + +private enum RegistrationError: LocalizedError { + case failed + + var errorDescription: String? { + "Registration failed for testing." + } +} diff --git a/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift new file mode 100644 index 0000000..9717575 --- /dev/null +++ b/KeyboardLockerModelTests/AgentReplacementCoordinatorTests.swift @@ -0,0 +1,463 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct AgentReplacementCoordinatorTests { + @Test + @MainActor + func safePlanPreparesAndCommitsBeforeRestarting() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + _ = await coordinator.replace(plan) + + #expect(log.steps == [.prepare, .commit, .restart, .resetConnection]) + #expect(client.prepareCalls.count == 1) + #expect(client.prepareCalls.first?.unlockIfNeeded == true) + #expect( + client.prepareCalls.first?.expectedAgentInstanceID == + descriptor.agentInstanceID + ) + #expect(client.committedTickets == [ticket]) + #expect(client.cancelledTickets.isEmpty) + } + + @Test + @MainActor + func safePlanRestartSuccessReturnsRestartedAndResetsConnection() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: false), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .restarted(previousAgentInstanceID) = outcome else { + Issue.record("Expected .restarted, got \(outcome).") + return + } + #expect(previousAgentInstanceID == descriptor.agentInstanceID) + #expect(client.resetConnectionCallCount == 1) + } + + @Test + @MainActor + func committedSafePlanRestartFailureDoesNotCancelPreparation() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + lifecycle.restartResult = .approvalRequired + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .registration(state) = outcome else { + Issue.record("Expected .registration, got \(outcome).") + return + } + #expect(state == .approvalRequired) + #expect(client.committedTickets == [ticket]) + #expect(client.cancelledTickets.isEmpty) + #expect(client.resetConnectionCallCount == 1) + #expect(log.steps == [.prepare, .commit, .restart, .resetConnection]) + } + + @Test + @MainActor + func prepareFailureReportsFailedWithCurrentLockState() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.prepareError = TestError.expected + client.statusResult = .success(true) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .failed(error, currentLockState) = outcome else { + Issue.record("Expected .failed, got \(outcome).") + return + } + #expect(error as? TestError == .expected) + #expect(currentLockState == true) + #expect(client.cancelledTickets.isEmpty) + #expect(client.statusCallCount == 1) + #expect(log.steps == [.prepare, .serviceDescriptor, .status]) + } + + @Test + @MainActor + func commitFailureCancelsPreparedTicketThenReportsFailed() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.commitError = TestError.expected + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: false), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .failed(error, currentLockState) = outcome else { + Issue.record("Expected .failed, got \(outcome).") + return + } + #expect(error as? TestError == .expected) + #expect(currentLockState == false) + #expect(client.cancelledTickets == [ticket]) + #expect(client.committedTickets.isEmpty) + #expect( + log.steps == + [.prepare, .commit, .cancel, .serviceDescriptor, .status] + ) + } + + @Test + @MainActor + func safePlanFailureRedetectsTrustedReplacementInProgress() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let pendingDescriptor = makeDescriptor(replacementPending: true) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.commitError = TestError.expected + client.descriptorResult = .success(pendingDescriptor) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .safe(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .replacementInProgress(reported) = outcome else { + Issue.record("Expected .replacementInProgress, got \(outcome).") + return + } + #expect(reported == pendingDescriptor) + #expect(client.cancelledTickets == [ticket]) + #expect(client.statusCallCount == 0) + } + + @Test + @MainActor + func forcedPlanFailureDoesNotRedetectReplacementInProgress() async { + let log = ReplacementCallLog() + let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + client.unlockError = TestError.expected + client.descriptorResult = .success(makeDescriptor(replacementPending: true)) + client.statusResult = .success(true) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: makeDescriptor(), isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .failed(error, currentLockState) = outcome else { + Issue.record("Expected .failed, got \(outcome).") + return + } + #expect(error as? TestError == .expected) + #expect(currentLockState == true) + #expect(client.descriptorCallCount == 0) + #expect(log.steps == [.unlock, .status]) + } + + @Test + @MainActor + func forcedPlanWithKnownLockStateUnlocksBeforeRestarting() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .restarted(previousAgentInstanceID) = outcome else { + Issue.record("Expected .restarted, got \(outcome).") + return + } + #expect(previousAgentInstanceID == descriptor.agentInstanceID) + #expect(client.unlockCallCount == 1) + #expect(client.prepareCalls.isEmpty) + #expect(client.committedTickets.isEmpty) + #expect(client.cancelledTickets.isEmpty) + #expect(log.steps == [.unlock, .restart, .resetConnection]) + } + + @Test + @MainActor + func forcedPlanWithKnownLockStateRestartFailureReturnsRegistration() async { + let log = ReplacementCallLog() + let descriptor = makeDescriptor() + let ticket = ServiceReplacementTicket( + id: UUID(), + agentInstanceID: descriptor.agentInstanceID + ) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + lifecycle.restartResult = .approvalRequired + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: descriptor, isLocked: true), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .registration(state) = outcome else { + Issue.record("Expected .registration, got \(outcome).") + return + } + #expect(state == .approvalRequired) + #expect(client.unlockCallCount == 1) + #expect(client.cancelledTickets.isEmpty) + #expect(log.steps == [.unlock, .restart, .resetConnection]) + } + + @Test + @MainActor + func forcedPlanWithUnknownLockStateSkipsUnlock() async { + let log = ReplacementCallLog() + let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: nil, isLocked: nil), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .restarted(previousAgentInstanceID) = outcome else { + Issue.record("Expected .restarted, got \(outcome).") + return + } + #expect(previousAgentInstanceID == nil) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(log.steps == [.restart, .resetConnection]) + } + + @Test + @MainActor + func forcedPlanRestartFailureReturnsRegistration() async { + let log = ReplacementCallLog() + let ticket = ServiceReplacementTicket(id: UUID(), agentInstanceID: UUID()) + let client = FakeAgentReplacementClient(log: log, ticket: ticket) + let lifecycle = FakeAgentReplacementLifecycle(log: log) + lifecycle.restartResult = .unavailable(.restartFailed("Restart failed for testing.")) + let coordinator = AgentReplacementCoordinator(client: client, lifecycle: lifecycle) + let plan = AgentUpdatePlan( + mode: .forced(descriptor: nil, isLocked: nil), + message: "Update required." + ) + + let outcome = await coordinator.replace(plan) + + guard case let .registration(state) = outcome else { + Issue.record("Expected .registration, got \(outcome).") + return + } + #expect(state == .unavailable(.restartFailed("Restart failed for testing."))) + #expect(client.resetConnectionCallCount == 1) + #expect(log.steps == [.restart, .resetConnection]) + } +} + +@MainActor +private final class ReplacementCallLog { + enum Step: Equatable { + case cancel + case commit + case prepare + case resetConnection + case restart + case serviceDescriptor + case status + case unlock + } + + private(set) var steps: [Step] = [] + + func record(_ step: Step) { + steps.append(step) + } +} + +@MainActor +private final class FakeAgentReplacementClient: AgentReplacementServing { + let log: ReplacementCallLog + let ticket: ServiceReplacementTicket + + var descriptorResult: Result = .failure(TestError.expected) + var statusResult: Result = .success(false) + var prepareError: Error? + var commitError: Error? + var unlockError: Error? + + private(set) var prepareCalls: [(unlockIfNeeded: Bool, expectedAgentInstanceID: UUID)] = [] + private(set) var committedTickets: [ServiceReplacementTicket] = [] + private(set) var cancelledTickets: [ServiceReplacementTicket] = [] + private(set) var descriptorCallCount = 0 + private(set) var resetConnectionCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var unlockCallCount = 0 + + init(log: ReplacementCallLog, ticket: ServiceReplacementTicket) { + self.log = log + self.ticket = ticket + } + + func serviceDescriptor() async throws -> ServiceDescriptor { + log.record(.serviceDescriptor) + descriptorCallCount += 1 + return try descriptorResult.get() + } + + func unlock() async throws { + log.record(.unlock) + unlockCallCount += 1 + if let unlockError { + throw unlockError + } + } + + func status() async throws -> Bool { + log.record(.status) + statusCallCount += 1 + return try statusResult.get() + } + + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + log.record(.prepare) + prepareCalls.append((unlockIfNeeded, expectedAgentInstanceID)) + if let prepareError { + throw prepareError + } + return ticket + } + + func cancelReplacementPreparation( + ticket: ServiceReplacementTicket + ) async throws { + log.record(.cancel) + cancelledTickets.append(ticket) + } + + func commitReplacement( + ticket: ServiceReplacementTicket + ) async throws { + log.record(.commit) + if let commitError { + throw commitError + } + committedTickets.append(ticket) + } + + func resetConnection() { + log.record(.resetConnection) + resetConnectionCallCount += 1 + } +} + +@MainActor +private final class FakeAgentReplacementLifecycle: AgentReplacementLifecycleServing { + let log: ReplacementCallLog + var restartResult: AgentRegistrar.State = .enabled + + init(log: ReplacementCallLog) { + self.log = log + } + + func restart() async -> AgentRegistrar.State { + log.record(.restart) + return restartResult + } +} + +private func makeDescriptor( + agentInstanceID: UUID = UUID(), + replacementPending: Bool = false +) -> ServiceDescriptor { + ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: agentInstanceID, + replacementPending: replacementPending + ) +} + +private enum TestError: Error, Equatable { + case expected +} diff --git a/KeyboardLockerModelTests/AppCoordinatorTests.swift b/KeyboardLockerModelTests/AppCoordinatorTests.swift new file mode 100644 index 0000000..d08c0f5 --- /dev/null +++ b/KeyboardLockerModelTests/AppCoordinatorTests.swift @@ -0,0 +1,316 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct AppCoordinatorTests { + @Test + func stateProjectsKnownLockEvidenceConsistently() { + let cases: [(AppCoordinator.State, Bool?)] = [ + (.checking(lastKnownLock: true), true), + (.checking(lastKnownLock: nil), nil), + (.agentApprovalRequired, nil), + (.agentReplacementInProgress(message: "Replacing"), nil), + (.agentUpdateRequired(isLocked: false, message: "Update"), false), + (.agentUpdateRequired(isLocked: nil, message: "Update"), nil), + (.accessibilityRequired(isLocked: true), true), + (.ready(isLocked: false), false), + (.unavailable(message: "Unavailable", canRestartAgent: true), nil), + ] + + for (state, expected) in cases { + #expect(state.knownLockState == expected, "state: \(state)") + } + } + + @Test + @MainActor + func reconcilePublishesAuthoritativeReadySnapshot() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let lifecycle = FakeAgentLifecycle() + let observer = FakeLockStateObserver() + let coordinator = makeCoordinator( + client: client, + lifecycle: lifecycle, + observer: observer + ) + var snapshots: [AppCoordinator.Snapshot] = [] + coordinator.onSnapshotChange = { snapshots.append($0) } + + coordinator.reconcile() + try await waitUntil { + coordinator.state == .ready(isLocked: false) + } + + #expect(coordinator.snapshot == AppCoordinator.Snapshot( + state: .ready(isLocked: false), + activity: nil, + lastError: nil, + safetyCheckState: .idle + )) + #expect(observer.initialStates == [false]) + #expect(snapshots.last == coordinator.snapshot) + } + + @Test + @MainActor + func displayedLockActionLocksThroughClientThenReconcilesAuthoritativeState() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let lifecycle = FakeAgentLifecycle() + let observer = FakeLockStateObserver() + let coordinator = makeCoordinator( + client: client, + lifecycle: lifecycle, + observer: observer, + initialState: .ready(isLocked: false) + ) + var activities: [AppCoordinator.Activity?] = [] + coordinator.onSnapshotChange = { activities.append($0.activity) } + + coordinator.performDisplayedLockAction() + try await waitUntil { + coordinator.state == .ready(isLocked: true) && coordinator.activity == nil + } + + #expect(client.lockCallCount == 1) + #expect(client.unlockCallCount == 0) + #expect(activities.contains(.locking)) + #expect(observer.initialStates == [true]) + } + + @Test + @MainActor + func observedAuthoritativeStateUpdatesReadySnapshotWithoutDuplicatePublication() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let lifecycle = FakeAgentLifecycle() + let observer = FakeLockStateObserver() + let coordinator = makeCoordinator( + client: client, + lifecycle: lifecycle, + observer: observer + ) + var snapshots: [AppCoordinator.Snapshot] = [] + coordinator.onSnapshotChange = { snapshots.append($0) } + + coordinator.reconcile() + try await waitUntil { + coordinator.state == .ready(isLocked: false) + } + let publicationCountBeforeDuplicate = snapshots.count + + observer.send(false) + #expect(snapshots.count == publicationCountBeforeDuplicate) + + observer.send(true) + #expect(coordinator.state == .ready(isLocked: true)) + #expect(snapshots.last?.state == .ready(isLocked: true)) + } + + @Test + @MainActor + func safetyCheckWaitsForAuthoritativeUnlockAndCompletes() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver(), + initialState: .ready(isLocked: false) + ) + + coordinator.startSafetyCheck() + try await waitUntil { + coordinator.safetyCheckState == .completed + } + + #expect(client.beginSafetyCheckCallCount == 1) + #expect(client.waitUntilUnlockedCallCount == 1) + #expect(!client.isLocked) + #expect(coordinator.activity == nil) + } + + @Test + @MainActor + func safetyCheckReportsConcurrentExistingLock() async throws { + let client = FakeAgentClient(isLocked: false, hasAccessibilityPermission: true) + client.safetyCheckOutcome = .alreadyLocked + let coordinator = makeCoordinator( + client: client, + lifecycle: FakeAgentLifecycle(), + observer: FakeLockStateObserver(), + initialState: .ready(isLocked: false) + ) + + coordinator.startSafetyCheck() + try await waitUntil { + if case .failed = coordinator.safetyCheckState { + return true + } + return false + } + + guard case let .failed(message) = coordinator.safetyCheckState else { + Issue.record("Expected a failed safety check.") + return + } + #expect(message.contains("already locked")) + #expect(client.waitUntilUnlockedCallCount == 0) + } + + @MainActor + private func makeCoordinator( + client: FakeAgentClient, + lifecycle: FakeAgentLifecycle, + observer: FakeLockStateObserver, + initialState: AppCoordinator.State = .checking(lastKnownLock: nil) + ) -> AppCoordinator { + AppCoordinator( + client: client, + lifecycle: lifecycle, + lockStateObserver: observer, + initialState: initialState + ) + } + + @MainActor + private func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @escaping @MainActor () -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while !condition() { + guard clock.now < deadline else { + Issue.record("Timed out waiting for AppCoordinator state") + throw AppCoordinatorTestError.timedOut + } + try await Task.sleep(for: .milliseconds(10)) + } + } +} + +private enum AppCoordinatorTestError: Error { + case timedOut +} + +@MainActor +private final class FakeAgentClient: AgentClientServing { + private(set) var beginSafetyCheckCallCount = 0 + private(set) var lockCallCount = 0 + private(set) var unlockCallCount = 0 + private(set) var waitUntilUnlockedCallCount = 0 + var accessibilityPermissionGranted: Bool + var isLocked: Bool + var safetyCheckOutcome: LockRequestOutcome = .acquired + + private let descriptor = ServiceDescriptor( + protocolVersion: ServiceContract.protocolVersion, + capabilities: ServiceContract.requiredCapabilities, + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.0", + agentBuild: "1", + agentInstanceID: UUID() + ) + + init(isLocked: Bool, hasAccessibilityPermission: Bool) { + self.isLocked = isLocked + accessibilityPermissionGranted = hasAccessibilityPermission + } + + func serviceDescriptor() async throws -> ServiceDescriptor { + descriptor + } + + func lock() async throws { + lockCallCount += 1 + isLocked = true + } + + func beginSafetyCheck() async throws -> LockRequestOutcome { + beginSafetyCheckCallCount += 1 + if safetyCheckOutcome == .acquired { + isLocked = true + } + return safetyCheckOutcome + } + + func unlock() async throws { + unlockCallCount += 1 + isLocked = false + } + + func status() async throws -> Bool { + isLocked + } + + func toggle() async throws -> Bool { + isLocked.toggle() + return isLocked + } + + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID + ) async throws -> ServiceReplacementTicket { + if unlockIfNeeded { + isLocked = false + } + return ServiceReplacementTicket( + id: UUID(), + agentInstanceID: expectedAgentInstanceID + ) + } + + func cancelReplacementPreparation(ticket _: ServiceReplacementTicket) async throws {} + + func commitReplacement(ticket _: ServiceReplacementTicket) async throws {} + + func hasAccessibilityPermission() async throws -> Bool { + accessibilityPermissionGranted + } + + func requestAccessibilityPermission() async throws {} + + func waitUntilUnlocked() async throws { + waitUntilUnlockedCallCount += 1 + isLocked = false + } + + func resetConnection() {} +} + +@MainActor +private final class FakeAgentLifecycle: AgentLifecycleServing { + func ensureEnabled() -> AgentRegistrar.State { + .enabled + } + + func compatibility( + of _: ServiceDescriptor + ) -> AgentRegistrar.Compatibility { + .compatible + } + + func restart() async -> AgentRegistrar.State { + .enabled + } +} + +@MainActor +private final class FakeLockStateObserver: AgentLockStateObserving { + private(set) var initialStates: [Bool?] = [] + private var handler: ((Bool) -> Void)? + + func subscribe( + initialState: Bool?, + _ handler: @escaping (Bool) -> Void + ) -> ObserverToken { + initialStates.append(initialState) + self.handler = handler + return ObserverToken {} + } + + func send(_ isLocked: Bool) { + handler?(isLocked) + } +} diff --git a/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift new file mode 100644 index 0000000..1383af1 --- /dev/null +++ b/KeyboardLockerModelTests/CommandLineToolLinkManagerTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing + +@MainActor +@Suite(.serialized) +final class CommandLineToolLinkManagerTests { + private let temporaryDirectory: URL + + init() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + } + + deinit { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + + @Test + func installAndUninstallManageOnlySymbolicLink() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + #expect( + manager.state == + .notInstalled(destination: destination, requiresPathSetup: false) + ) + + #expect(try manager.install() == .installed(destination: destination)) + #expect(manager.canRemoveLink(at: destination)) + #expect( + try FileManager.default.destinationOfSymbolicLink(atPath: destination.path) == + source.path + ) + + #expect( + try manager.uninstall() == + .notInstalled(destination: destination, requiresPathSetup: false) + ) + #expect(!FileManager.default.fileExists(atPath: destination.path)) + #expect(FileManager.default.fileExists(atPath: source.path)) + } + + @Test + func installIsIdempotentForOwnedSymbolicLink() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + _ = try manager.install() + #expect(try manager.install() == .installed(destination: destination)) + } + + @Test + func ownedSymbolicLinkCanBeRemovedWhenBundledExecutableDisappears() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + _ = try manager.install() + try FileManager.default.removeItem(at: source) + + #expect(manager.state == .installed(destination: destination)) + #expect( + try manager.uninstall() == + .sourceUnavailable(source) + ) + #expect(throws: (any Error).self) { + try FileManager.default.destinationOfSymbolicLink(atPath: destination.path) + } + } + + @Test + func foreignItemIsReportedAndNeverOverwritten() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) + let destination = binDirectory.appendingPathComponent("klock") + try Data("foreign".utf8).write(to: destination) + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [binDirectory] + ) + + #expect(manager.state == .conflict(destination: destination)) + let error = #expect(throws: CommandLineToolLinkManager.InstallationError.self) { + try manager.install() + } + guard let error else { return } + guard case .destinationConflict = error else { + Issue.record("Expected a destination conflict, found \(error).") + return + } + #expect(try Data(contentsOf: destination) == Data("foreign".utf8)) + } + + @Test + func missingPathDirectoryIsReportedWithoutEditingShellConfiguration() throws { + let source = try makeExecutable(named: "klock") + let binDirectory = temporaryDirectory.appendingPathComponent("bin", isDirectory: true) + let destination = binDirectory.appendingPathComponent("klock") + let manager = makeManager( + source: source, + destination: destination, + pathDirectories: [] + ) + + #expect( + manager.state == + .notInstalled(destination: destination, requiresPathSetup: true) + ) + #expect(manager.pathSetupCommand(for: destination).contains("export PATH=")) + } + + @MainActor + private func makeManager( + source: URL, + destination: URL, + pathDirectories: [URL] + ) -> CommandLineToolLinkManager { + CommandLineToolLinkManager( + sourceURL: source, + preferredDestination: destination, + pathDirectories: pathDirectories + ) + } + + private func makeExecutable(named name: String) throws -> URL { + let executable = temporaryDirectory.appendingPathComponent(name) + try Data("test".utf8).write(to: executable) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executable.path + ) + return executable + } +} diff --git a/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift new file mode 100644 index 0000000..8649cc8 --- /dev/null +++ b/KeyboardLockerModelTests/ExternalAutomationControllerTests.swift @@ -0,0 +1,302 @@ +import Client +import Testing + +@Suite(.serialized) +struct ExternalAutomationControllerTests { + @Test + @MainActor + func lockInvokesOnlyLockCapability() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.lock, source: .service) + await controller.waitUntilIdle() + + #expect(client.calls == [.lock]) + #expect(client.isLocked) + #expect(presenter.statuses.isEmpty) + #expect(presenter.failureBatches.isEmpty) + } + + @Test + @MainActor + func unlockInvokesOnlyUnlockCapability() async { + let client = RecordingExternalAutomationClient(isLocked: true) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.unlock, source: .service) + await controller.waitUntilIdle() + + #expect(client.calls == [.unlock]) + #expect(!client.isLocked) + } + + @Test + @MainActor + func statusPresentsAuthoritativeAgentValue() async { + let client = RecordingExternalAutomationClient(isLocked: true) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.status, source: .service) + await controller.waitUntilIdle() + + #expect(client.calls == [.status]) + #expect(presenter.statuses == [.init(isLocked: true, source: .service)]) + #expect(presenter.failureBatches.isEmpty) + } + + @Test + @MainActor + func agentFailureIncludesRecoverySuggestion() async { + let client = RecordingExternalAutomationClient( + isLocked: false, + error: XPCClientError.serviceUnavailable + ) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit(.lock, source: .service) + await controller.waitUntilIdle() + + let batch = presenter.failureBatches.first + #expect(batch?.source == .service) + #expect(batch?.failures.first?.message.contains("not reachable") == true) + #expect(batch?.failures.first?.message.contains("Open KeyboardLocker once") == true) + } + + @Test + @MainActor + func batchPreservesDesiredStateOrder() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let controller = ExternalAutomationController( + client: client, + presenter: RecordingExternalAutomationPresenter() + ) + + controller.submit([.lock, .status, .unlock], source: .urlScheme) + await controller.waitUntilIdle() + + #expect(client.calls == [.lock, .status, .unlock]) + #expect(!client.isLocked) + } + + @Test + @MainActor + func rejectedRequestIsPresentedWithoutCallingAgent() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + controller.submit( + [.failure(.init(message: "Invalid URL"))], + source: .urlScheme + ) + await controller.waitUntilIdle() + + #expect(client.calls.isEmpty) + #expect( + presenter.failureBatches == + [.init(failures: [.init(message: "Invalid URL")], source: .urlScheme)] + ) + } + + @Test + @MainActor + func separateSubmissionsStaySerializedWhileFirstActionIsSuspended() async { + let gate = AsyncGate() + let client = RecordingExternalAutomationClient(isLocked: false, lockGate: gate) + let controller = ExternalAutomationController( + client: client, + presenter: RecordingExternalAutomationPresenter() + ) + + controller.submit(.lock, source: .service) + await gate.waitUntilEntered() + controller.submit(.unlock, source: .service) + await Task.yield() + + #expect(client.calls == [.lock]) + + await gate.open() + await controller.waitUntilIdle() + + #expect(client.calls == [.lock, .unlock]) + #expect(!client.isLocked) + } + + @Test + @MainActor + func submitAndWaitReturnsNilWhenActionSucceeds() async { + let client = RecordingExternalAutomationClient(isLocked: false) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + let failure = await controller.submitAndWait(.lock, source: .service) + + #expect(failure == nil) + #expect(client.calls == [.lock]) + #expect(client.isLocked) + #expect(presenter.failureBatches.isEmpty) + } + + @Test + @MainActor + func submitAndWaitReturnsFailureWithoutPresentingIt() async { + let client = RecordingExternalAutomationClient( + isLocked: false, + error: XPCClientError.serviceUnavailable + ) + let presenter = RecordingExternalAutomationPresenter() + let controller = ExternalAutomationController(client: client, presenter: presenter) + + let failure = await controller.submitAndWait(.lock, source: .service) + + #expect(failure?.message.contains("not reachable") == true) + #expect(presenter.failureBatches.isEmpty) + } + + @Test + @MainActor + func submitAndWaitStaysSerializedBehindPendingSubmission() async { + let gate = AsyncGate() + let client = RecordingExternalAutomationClient(isLocked: false, lockGate: gate) + let controller = ExternalAutomationController( + client: client, + presenter: RecordingExternalAutomationPresenter() + ) + + controller.submit(.lock, source: .service) + await gate.waitUntilEntered() + + async let awaitedFailure = controller.submitAndWait(.unlock, source: .service) + await Task.yield() + + #expect(client.calls == [.lock]) + + await gate.open() + let failure = await awaitedFailure + + #expect(failure == nil) + #expect(client.calls == [.lock, .unlock]) + #expect(!client.isLocked) + } +} + +@MainActor +private final class RecordingExternalAutomationClient: AgentLockActionServing { + private(set) var calls: [ExternalAutomationAction] = [] + private(set) var isLocked: Bool + + private let error: Error? + private let lockGate: AsyncGate? + + init( + isLocked: Bool, + error: Error? = nil, + lockGate: AsyncGate? = nil + ) { + self.isLocked = isLocked + self.error = error + self.lockGate = lockGate + } + + func lock() async throws { + calls.append(.lock) + if let lockGate { + await lockGate.enterAndWait() + } + try throwConfiguredError() + isLocked = true + } + + func unlock() async throws { + calls.append(.unlock) + try throwConfiguredError() + isLocked = false + } + + func status() async throws -> Bool { + calls.append(.status) + try throwConfiguredError() + return isLocked + } + + func toggle() async throws -> Bool { + try throwConfiguredError() + isLocked.toggle() + return isLocked + } + + private func throwConfiguredError() throws { + if let error { + throw error + } + } +} + +@MainActor +private final class RecordingExternalAutomationPresenter: ExternalAutomationPresenting { + struct Status: Equatable { + let isLocked: Bool + let source: ExternalAutomationSource + } + + struct FailureBatch: Equatable { + let failures: [ExternalAutomationFailure] + let source: ExternalAutomationSource + } + + private(set) var statuses: [Status] = [] + private(set) var failureBatches: [FailureBatch] = [] + + func presentStatus(isLocked: Bool, source: ExternalAutomationSource) { + statuses.append(.init(isLocked: isLocked, source: source)) + } + + func presentFailures( + _ failures: [ExternalAutomationFailure], + source: ExternalAutomationSource + ) { + failureBatches.append(.init(failures: failures, source: source)) + } +} + +private actor AsyncGate { + private var entered = false + private var isOpen = false + private var entryWaiters: [CheckedContinuation] = [] + private var openWaiters: [CheckedContinuation] = [] + + func enterAndWait() async { + entered = true + entryWaiters.forEach { $0.resume() } + entryWaiters.removeAll() + + guard !isOpen else { + return + } + + await withCheckedContinuation { continuation in + openWaiters.append(continuation) + } + } + + func waitUntilEntered() async { + guard !entered else { + return + } + + await withCheckedContinuation { continuation in + entryWaiters.append(continuation) + } + } + + func open() { + isOpen = true + openWaiters.forEach { $0.resume() } + openWaiters.removeAll() + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift new file mode 100644 index 0000000..a52df50 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockAppIntentTests.swift @@ -0,0 +1,155 @@ +import AppIntents +import Testing + +@Suite(.serialized) +struct KeyboardLockAppIntentTests { + @Test + @MainActor + func lockIntentInvokesOnlyLockCapability() async throws { + let client = FakeAgentLockActionClient(isLocked: false) + + _ = try await LockKeyboardIntent(client: client).perform() + + #expect(client.lockCallCount == 1) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.isLocked) + } + + @Test + @MainActor + func unlockIntentInvokesOnlyUnlockCapability() async throws { + let client = FakeAgentLockActionClient(isLocked: true) + + _ = try await UnlockKeyboardIntent(client: client).perform() + + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 1) + #expect(client.statusCallCount == 0) + #expect(!client.isLocked) + } + + @Test + @MainActor + func statusIntentReturnsAuthoritativeClientValue() async throws { + let client = FakeAgentLockActionClient(isLocked: true) + + let result = try await GetKeyboardLockStatusIntent(client: client).perform() + + #expect(result.value == true) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 1) + } + + @Test + @MainActor + func statusIntentPropagatesAgentFailureWithoutFallback() async { + let client = FakeAgentLockActionClient( + isLocked: false, + error: IntentClientError.unavailable + ) + + do { + _ = try await GetKeyboardLockStatusIntent(client: client).perform() + Issue.record("Expected the Agent error to be propagated.") + } catch { + #expect(error as? IntentClientError == .unavailable) + } + } + + @Test + @MainActor + func toggleIntentFromUnlockedReturnsLocked() async throws { + let client = FakeAgentLockActionClient(isLocked: false) + + let result = try await ToggleKeyboardLockIntent(client: client).perform() + + #expect(result.value == true) + #expect(client.toggleCallCount == 1) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.isLocked) + } + + @Test + @MainActor + func toggleIntentFromLockedReturnsUnlocked() async throws { + let client = FakeAgentLockActionClient(isLocked: true) + + let result = try await ToggleKeyboardLockIntent(client: client).perform() + + #expect(result.value == false) + #expect(client.toggleCallCount == 1) + #expect(!client.isLocked) + } + + @Test + @MainActor + func toggleIntentPropagatesAgentFailureWithoutFallback() async { + let client = FakeAgentLockActionClient( + isLocked: false, + error: IntentClientError.unavailable + ) + + do { + _ = try await ToggleKeyboardLockIntent(client: client).perform() + Issue.record("Expected the Agent error to be propagated.") + } catch { + #expect(error as? IntentClientError == .unavailable) + #expect(!client.isLocked) + } + } +} + +@MainActor +private final class FakeAgentLockActionClient: AgentLockActionServing { + private(set) var lockCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var toggleCallCount = 0 + private(set) var unlockCallCount = 0 + private(set) var isLocked: Bool + + private let error: Error? + + init(isLocked: Bool, error: Error? = nil) { + self.isLocked = isLocked + self.error = error + } + + func lock() async throws { + try throwConfiguredError() + lockCallCount += 1 + isLocked = true + } + + func unlock() async throws { + try throwConfiguredError() + unlockCallCount += 1 + isLocked = false + } + + func status() async throws -> Bool { + try throwConfiguredError() + statusCallCount += 1 + return isLocked + } + + func toggle() async throws -> Bool { + try throwConfiguredError() + toggleCallCount += 1 + isLocked.toggle() + return isLocked + } + + private func throwConfiguredError() throws { + if let error { + throw error + } + } +} + +private enum IntentClientError: Error, Equatable { + case unavailable +} diff --git a/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift new file mode 100644 index 0000000..7cc51bb --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockFocusFilterIntentTests.swift @@ -0,0 +1,116 @@ +import Foundation +import SystemSurfaces +import Testing + +@Suite(.serialized) +struct KeyboardLockFocusFilterIntentTests { + @Test + func defaultFilterDoesNotRequestALock() { + #expect(!KeyboardLockFocusFilterIntent().lockKeyboard) + } + + @Test + func enabledFilterForwardsTrueToAgent() async throws { + let client = FakeAgentFocusLockClient() + let surfaces = FocusSurfaceRecorder() + + _ = try await KeyboardLockFocusFilterIntent( + lockKeyboard: true, + client: client, + surfaceInvalidator: surfaces.invalidator + ).perform() + + let receivedValues = await client.receivedValues + #expect(receivedValues == [true]) + #expect(surfaces.calls == [.widget, .control]) + } + + @Test + func disabledFilterForwardsFalseToAgent() async throws { + let client = FakeAgentFocusLockClient() + let surfaces = FocusSurfaceRecorder() + + _ = try await KeyboardLockFocusFilterIntent( + lockKeyboard: false, + client: client, + surfaceInvalidator: surfaces.invalidator + ).perform() + + let receivedValues = await client.receivedValues + #expect(receivedValues == [false]) + #expect(surfaces.calls == [.widget, .control]) + } + + @Test + func agentFailureIsPropagated() async { + let client = FakeAgentFocusLockClient(error: FocusIntentTestError.unavailable) + let surfaces = FocusSurfaceRecorder() + + do { + _ = try await KeyboardLockFocusFilterIntent( + lockKeyboard: true, + client: client, + surfaceInvalidator: surfaces.invalidator + ).perform() + Issue.record("Expected the Agent error to be propagated.") + } catch { + #expect(error as? FocusIntentTestError == .unavailable) + } + + #expect(surfaces.calls.isEmpty) + } +} + +private actor FakeAgentFocusLockClient: AgentFocusLockServing { + private(set) var receivedValues: [Bool] = [] + + private let error: Error? + + init(error: Error? = nil) { + self.error = error + } + + func setFocusFilterLockEnabled(_ enabled: Bool) async throws { + if let error { + throw error + } + receivedValues.append(enabled) + } +} + +private enum FocusIntentTestError: Error, Equatable { + case unavailable +} + +private final class FocusSurfaceRecorder: @unchecked Sendable { + enum Call: Equatable { + case control + case widget + } + + private let lock = NSLock() + private var recordedCalls: [Call] = [] + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + var invalidator: LockStateSurfaceInvalidator { + LockStateSurfaceInvalidator( + reloadWidget: { + self.record(.widget) + }, + reloadControl: { + self.record(.control) + } + ) + } + + private func record(_ call: Call) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift b/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift new file mode 100644 index 0000000..2c7d134 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerControlModelTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing + +@Suite(.serialized) +struct KeyboardLockerControlModelTests { + @Test + func valueLoaderReturnsAuthoritativeValue() async throws { + let loader = KeyboardLockerControlValueLoader { + true + } + + let value = try await loader.currentValue() + + #expect(value) + } + + @Test + func valueLoaderPropagatesFailure() async { + let loader = KeyboardLockerControlValueLoader { + throw TestError.expected + } + + do { + _ = try await loader.currentValue() + Issue.record("Expected the loader to propagate the error.") + } catch { + #expect(error as? TestError == .expected) + } + } + + @Test + func setLockedCallsOnlyLockThenReload() async throws { + let recorder = CallRecorder() + let action = makeAction(recorder: recorder) + + try await action.setLocked(true) + + let calls = await recorder.calls + #expect(calls == [.lock, .reload]) + } + + @Test + func setUnlockedCallsOnlyUnlockThenReload() async throws { + let recorder = CallRecorder() + let action = makeAction(recorder: recorder) + + try await action.setLocked(false) + + let calls = await recorder.calls + #expect(calls == [.unlock, .reload]) + } + + @Test + func failedActionDoesNotReload() async { + let recorder = CallRecorder() + let action = KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + throw TestError.expected + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + + do { + try await action.setLocked(true) + Issue.record("Expected the action to propagate the error.") + } catch { + #expect(error as? TestError == .expected) + } + + let calls = await recorder.calls + #expect(calls == [.lock]) + } + + private func makeAction(recorder: CallRecorder) -> KeyboardLockerControlAction { + KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + } +} + +private extension KeyboardLockerControlModelTests { + enum TestError: Error { + case expected + } + + enum Call: Equatable { + case lock + case unlock + case reload + } + + actor CallRecorder { + private(set) var calls: [Call] = [] + + func record(_ call: Call) { + calls.append(call) + } + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift b/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift new file mode 100644 index 0000000..707aeec --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerDiagnosticsTests.swift @@ -0,0 +1,102 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct KeyboardLockerDiagnosticsTests { + @Test + @MainActor + func reportIncludesRuntimeFactsWithoutMachineIdentity() async throws { + let descriptor = try ServiceDescriptor( + protocolVersion: ServiceProtocolVersion(major: 1, minor: 7), + capabilities: [.lockControl, .safetyCheckLock], + agentBundleIdentifier: SharedConstants.agentBundleIdentifier, + agentVersion: "1.2", + agentBuild: "34", + agentInstanceID: #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) + ) + let lockSnapshot = LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + let collector = KeyboardLockerDiagnosticsCollector( + appIdentity: .init( + build: "12", + bundleIdentifier: SharedConstants.appBundleIdentifier, + version: "1.1" + ), + operatingSystemVersion: "macOS 26.0", + registrationStatus: { "enabled" }, + descriptor: { descriptor }, + accessibility: { true }, + lockSnapshot: { lockSnapshot }, + now: { Date(timeIntervalSince1970: 1_700_000_100) } + ) + let appSnapshot = AppCoordinator.Snapshot( + state: .ready(isLocked: false), + activity: nil, + lastError: nil, + safetyCheckState: .completed + ) + + let report = await collector.report(appSnapshot: appSnapshot) + + #expect(report.contains("App: 1.1 (12)")) + #expect(report.contains("Agent Registration: enabled")) + #expect(report.contains("Protocol: 1.7")) + #expect(report.contains("Capabilities: lock-control, safety-check-lock")) + #expect(report.contains("Accessibility: granted")) + #expect(report.contains("Status: unlocked")) + #expect(report.contains("Safety Check: completed")) + #expect(!report.localizedCaseInsensitiveContains("user name")) + #expect(!report.localizedCaseInsensitiveContains("host name")) + } + + @Test + @MainActor + func reportKeepsPartialDiagnosticsAndRedactsPaths() async { + let collector = KeyboardLockerDiagnosticsCollector( + appIdentity: .init(build: "1", bundleIdentifier: "test.app", version: "1.0"), + operatingSystemVersion: "macOS test", + registrationStatus: { "not-registered" }, + descriptor: { + throw TestError.message("Failed at /Users/example/Library/Agent.app") + }, + accessibility: { false }, + lockSnapshot: { + throw TestError.message("Could not open file:///private/tmp/KeyboardLocker/state") + }, + now: { Date(timeIntervalSince1970: 0) } + ) + let appSnapshot = AppCoordinator.Snapshot( + state: .unavailable(message: "Missing /Applications/KeyboardLocker.app", canRestartAgent: true), + activity: nil, + lastError: "Missing /Applications/KeyboardLocker.app", + safetyCheckState: .failed("Expected") + ) + + let report = await collector.report(appSnapshot: appSnapshot) + + #expect(report.contains("Agent Registration: not-registered")) + #expect(report.contains("Accessibility: not-granted")) + #expect(report.contains("")) + #expect(!report.contains("/Users/example")) + #expect(!report.contains("/private/tmp")) + #expect(!report.contains("/Applications/KeyboardLocker.app")) + } +} + +private struct TestError: Error, LocalizedError { + let value: String + + static func message(_ value: String) -> Self { + Self(value: value) + } + + var errorDescription: String? { + value + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift b/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift new file mode 100644 index 0000000..bae508e --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerInteractiveWidgetTests.swift @@ -0,0 +1,96 @@ +import Testing + +@Suite(.serialized) +struct KeyboardLockerInteractiveWidgetTests { + @Test + func intentUsesExplicitLockedStateThenReloads() async throws { + let recorder = CallRecorder() + let intent = SetKeyboardLockWidgetIntent( + desiredIsLocked: true, + action: makeAction(recorder: recorder) + ) + + _ = try await intent.perform() + + let calls = await recorder.calls + #expect(calls == [.lock, .reload]) + } + + @Test + func intentUsesExplicitUnlockedStateThenReloads() async throws { + let recorder = CallRecorder() + let intent = SetKeyboardLockWidgetIntent( + desiredIsLocked: false, + action: makeAction(recorder: recorder) + ) + + _ = try await intent.perform() + + let calls = await recorder.calls + #expect(calls == [.unlock, .reload]) + } + + @Test + func intentPropagatesFailureWithoutReloading() async { + let recorder = CallRecorder() + let intent = SetKeyboardLockWidgetIntent( + desiredIsLocked: true, + action: KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + throw TestError.expected + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + ) + + do { + _ = try await intent.perform() + Issue.record("Expected the intent to propagate the Agent failure.") + } catch { + #expect(error as? TestError == .expected) + } + + let calls = await recorder.calls + #expect(calls == [.lock]) + } + + private func makeAction(recorder: CallRecorder) -> KeyboardLockerControlAction { + KeyboardLockerControlAction( + lock: { + await recorder.record(.lock) + }, + unlock: { + await recorder.record(.unlock) + }, + reload: { + await recorder.record(.reload) + } + ) + } +} + +private extension KeyboardLockerInteractiveWidgetTests { + enum TestError: Error { + case expected + } + + enum Call: Equatable { + case lock + case unlock + case reload + } + + actor CallRecorder { + private(set) var calls: [Call] = [] + + func record(_ call: Call) { + calls.append(call) + } + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift new file mode 100644 index 0000000..71e7fea --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerScriptCommandTests.swift @@ -0,0 +1,140 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct KeyboardLockerScriptCommandTests { + @Test + @MainActor + func lockCommandInvokesOnlyLockCapability() async throws { + let client = FakeAppleScriptAgentClient(isLocked: false) + + let result = try await LockKeyboardScriptCommand.perform(using: client) + + #expect(result == nil) + #expect(client.lockCallCount == 1) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 0) + #expect(client.isLocked) + } + + @Test + @MainActor + func unlockCommandInvokesOnlyUnlockCapability() async throws { + let client = FakeAppleScriptAgentClient(isLocked: true) + + let result = try await UnlockKeyboardScriptCommand.perform(using: client) + + #expect(result == nil) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 1) + #expect(client.statusCallCount == 0) + #expect(!client.isLocked) + } + + @Test + @MainActor + func statusCommandReturnsAuthoritativeBoolean() async throws { + let client = FakeAppleScriptAgentClient(isLocked: true) + + let result = try await GetKeyboardLockStatusScriptCommand.perform(using: client) + + #expect(result as? NSNumber == NSNumber(value: true)) + #expect(client.lockCallCount == 0) + #expect(client.unlockCallCount == 0) + #expect(client.statusCallCount == 1) + } + + @Test + @MainActor + func commandPropagatesAgentFailure() async { + let client = FakeAppleScriptAgentClient( + isLocked: false, + error: AppleScriptClientError.unavailable + ) + + do { + _ = try await LockKeyboardScriptCommand.perform(using: client) + Issue.record("Expected the Agent error to be propagated.") + } catch { + #expect(error as? AppleScriptClientError == .unavailable) + } + } + + @Test + @MainActor + func errorPresentationIncludesClientRecoverySuggestion() { + let message = AppleScriptErrorPresentation.message( + for: XPCClientError.serviceUnavailable + ) + + #expect( + message == + ExternalAutomationFailure(error: XPCClientError.serviceUnavailable).message + ) + #expect(message.contains("The KeyboardLocker agent is not reachable.")) + #expect(message.contains("Open KeyboardLocker once")) + } + + @Test + @MainActor + func errorPresentationUsesAppleEventTimeoutOnlyForClientTimeout() { + #expect( + AppleScriptErrorPresentation.errorNumber(for: XPCClientError.timedOut) == + Int(errAETimeout) + ) + #expect( + AppleScriptErrorPresentation.errorNumber(for: XPCClientError.serviceUnavailable) == + Int(errAEEventFailed) + ) + } +} + +@MainActor +private final class FakeAppleScriptAgentClient: AgentLockActionServing { + private(set) var lockCallCount = 0 + private(set) var statusCallCount = 0 + private(set) var unlockCallCount = 0 + private(set) var isLocked: Bool + + private let error: Error? + + init(isLocked: Bool, error: Error? = nil) { + self.isLocked = isLocked + self.error = error + } + + func lock() async throws { + try throwConfiguredError() + lockCallCount += 1 + isLocked = true + } + + func unlock() async throws { + try throwConfiguredError() + unlockCallCount += 1 + isLocked = false + } + + func status() async throws -> Bool { + try throwConfiguredError() + statusCallCount += 1 + return isLocked + } + + func toggle() async throws -> Bool { + try throwConfiguredError() + isLocked.toggle() + return isLocked + } + + private func throwConfiguredError() throws { + if let error { + throw error + } + } +} + +private enum AppleScriptClientError: Error, Equatable { + case unavailable +} diff --git a/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift new file mode 100644 index 0000000..07472c5 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerServicesProviderTests.swift @@ -0,0 +1,129 @@ +import AppKit +import Testing + +@MainActor +@Suite(.serialized) +struct KeyboardLockerServicesProviderTests { + private let pasteboard = NSPasteboard(name: NSPasteboard.Name("KeyboardLockerServicesProviderTests")) + + @Test + func providerExportsEveryDeclaredServiceSelector() { + let provider = KeyboardLockerServicesProvider { _ in nil } + + #expect(provider.responds(to: NSSelectorFromString("lockKeyboard:userData:error:"))) + #expect(provider.responds(to: NSSelectorFromString("unlockKeyboard:userData:error:"))) + #expect( + provider.responds(to: NSSelectorFromString("showKeyboardLockStatus:userData:error:")) + ) + } + + @Test + func handlerLeavesErrorUntouchedWhenActionSucceeds() { + let provider = KeyboardLockerServicesProvider { _ in nil } + + var error: NSString? + provider.lockKeyboard(pasteboard, userData: nil, error: &error) + + #expect(error == nil) + } + + @Test + func handlerPublishesFailureBeforeScheduledPresentationRuns() async { + let presenter = RecordingServicesFailurePresenter() + let provider = KeyboardLockerServicesProvider( + presentFailure: { presenter.present($0) } + ) { _ in + ExternalAutomationFailure(message: "Agent not reachable") + } + + var error: NSString? + provider.unlockKeyboard(pasteboard, userData: nil, error: &error) + + #expect(error == "Agent not reachable") + #expect(presenter.failures.isEmpty) + + await Task.yield() + + #expect(presenter.failures == [.init(message: "Agent not reachable")]) + } + + @Test + func handlerReportsTimeoutWhenActionDoesNotFinish() { + let provider = KeyboardLockerServicesProvider(waitTimeout: 0.1) { _ in + try? await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + + var error: NSString? + provider.showKeyboardLockStatus(pasteboard, userData: nil, error: &error) + + #expect(error?.contains("did not finish") == true) + } + + @Test + func timedOutHandlerPresentsLateFailureAfterPublishingTimeout() async { + let presenter = RecordingServicesFailurePresenter() + let gate = ServicesResultChannelGate() + let provider = KeyboardLockerServicesProvider( + waitTimeout: 0.01, + presentFailure: { presenter.present($0) } + ) { _ in + // Complete only after the handler has published its timeout. A fixed sleep cannot + // guarantee this ordering: the main-thread run-loop pump may service a source past the + // wait deadline on a loaded runner, letting a "late" action still win the wait. + await gate.waitUntilPublished() + return ExternalAutomationFailure(message: "Late agent failure") + } + + var error: NSString? + provider.lockKeyboard(pasteboard, userData: nil, error: &error) + + #expect(error?.contains("did not finish") == true) + #expect(presenter.failures.isEmpty) + + await gate.publish() + + // The late presentation crosses executor hops after the action completes; poll with a + // generous deadline instead of assuming a fixed wall-clock margin on a loaded runner. + let deadline = ContinuousClock.now + .seconds(5) + while presenter.failures.isEmpty, ContinuousClock.now < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + + #expect(presenter.failures == [.init(message: "Late agent failure")]) + } +} + +/// Holds a service action open until the handler has published its outcome, so the +/// late-completion path is exercised deterministically without a production seam. +private actor ServicesResultChannelGate { + private var published = false + private var waiters: [CheckedContinuation] = [] + + func publish() { + published = true + let pending = waiters + waiters = [] + for waiter in pending { + waiter.resume() + } + } + + func waitUntilPublished() async { + if published { + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +@MainActor +private final class RecordingServicesFailurePresenter { + private(set) var failures: [ExternalAutomationFailure] = [] + + func present(_ failure: ExternalAutomationFailure) { + failures.append(failure) + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift b/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift new file mode 100644 index 0000000..30c9ae3 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerURLRouteTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@Suite(.serialized) +struct KeyboardLockerURLRouteTests { + @Test + func canonicalURLsMapToDesiredStateActions() throws { + #expect( + try KeyboardLockerURLRoute(url: url("keyboardlocker://lock")).action == + .lock + ) + #expect( + try KeyboardLockerURLRoute(url: url("keyboardlocker://unlock")).action == + .unlock + ) + #expect( + try KeyboardLockerURLRoute(url: url("keyboardlocker://status")).action == + .status + ) + } + + @Test + func schemeAndHostFollowURLCaseInsensitivity() throws { + #expect( + try KeyboardLockerURLRoute(url: url("KEYBOARDLOCKER://LOCK")).action == + .lock + ) + } + + @Test + func invalidOrAmbiguousShapesAreRejected() { + let invalidURLs = [ + "https://lock", + "keyboardlocker:lock", + "keyboardlocker://", + "keyboardlocker://toggle", + "keyboardlocker://lock/", + "keyboardlocker://lock/path", + "keyboardlocker://lock?", + "keyboardlocker://lock?source=test", + "keyboardlocker://lock#fragment", + "keyboardlocker://lock:1234", + "keyboardlocker://user@lock", + "keyboardlocker://user:password@lock", + "keyboardlocker://%6cock", + ] + + for value in invalidURLs { + #expect( + throws: KeyboardLockerURLRouteError.invalidURL, + "Expected \(value) to be rejected." + ) { + try KeyboardLockerURLRoute(url: url(value)) + } + } + } + + @Test + func multipleURLsPreserveDeliveryOrderAndRepresentFailures() { + let requests = KeyboardLockerURLRoute.requests(for: [ + url("keyboardlocker://lock"), + url("keyboardlocker://invalid"), + url("keyboardlocker://unlock"), + ]) + + #expect(requests.count == 3) + #expect(requests[0] == .action(.lock)) + guard case let .failure(failure) = requests[1] else { + Issue.record("Expected the invalid route to become a failure request.") + return + } + #expect(failure.message.contains("invalid automation URL")) + #expect(!failure.message.contains("keyboardlocker://invalid")) + #expect(requests[2] == .action(.unlock)) + } + + @Test + func failureMessageNeverEchoesQueryData() { + let requests = KeyboardLockerURLRoute.requests(for: [ + url("keyboardlocker://lock?token=do-not-echo"), + ]) + + guard case let .failure(failure) = requests.first else { + Issue.record("Expected the URL to be rejected.") + return + } + #expect(!failure.message.contains("do-not-echo")) + } + + private func url(_ value: String) -> URL { + guard let url = URL(string: value) else { + preconditionFailure("The test URL must be syntactically representable: \(value)") + } + return url + } +} diff --git a/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift new file mode 100644 index 0000000..ed41d86 --- /dev/null +++ b/KeyboardLockerModelTests/KeyboardLockerWidgetTimelineTests.swift @@ -0,0 +1,91 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct KeyboardLockerWidgetTimelineTests { + private let now = Date(timeIntervalSinceReferenceDate: 10000) + + @Test + func regularRefreshUsesBudgetFriendlyFallbackInterval() { + #expect( + KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval == + 15 * 60 + ) + } + + @Test + func loaderPublishesAuthoritativeSnapshot() async { + let snapshot = LockStatusSnapshot( + capturedAt: now, + isLocked: true, + startedAt: now.addingTimeInterval(-10), + autoUnlockTargetDate: now.addingTimeInterval(50), + settings: .default + ) + let loader = KeyboardLockerWidgetSnapshotLoader { + snapshot + } + + let entry = await loader.entry(at: now) + + #expect(entry == KeyboardLockerWidgetEntry(date: now, state: .available(snapshot))) + } + + @Test + func loaderPublishesExplicitUnavailableState() async { + let loader = KeyboardLockerWidgetSnapshotLoader { + throw XPCClientError.serviceUnavailable + } + + let entry = await loader.entry(at: now) + + guard case let .unavailable(message) = entry.state else { + Issue.record("Expected an unavailable entry.") + return + } + #expect(message.contains("The KeyboardLocker agent is not reachable.")) + #expect(message.contains("Open KeyboardLocker once")) + } + + @Test + func refreshReconcilesImmediatelyAfterEarlierAutoUnlockDeadline() { + let deadline = now.addingTimeInterval(20) + let snapshot = LockStatusSnapshot( + capturedAt: now, + isLocked: true, + startedAt: now.addingTimeInterval(-10), + autoUnlockTargetDate: deadline, + settings: .default + ) + let entry = KeyboardLockerWidgetEntry(date: now, state: .available(snapshot)) + let loader = KeyboardLockerWidgetSnapshotLoader { + snapshot + } + + #expect( + loader.nextRefreshDate(after: entry, now: now) == + deadline.addingTimeInterval(1) + ) + } + + @Test + func refreshUsesRegularIntervalWithoutEarlierDeadline() { + let snapshot = LockStatusSnapshot( + capturedAt: now, + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .default + ) + let entry = KeyboardLockerWidgetEntry(date: now, state: .available(snapshot)) + let loader = KeyboardLockerWidgetSnapshotLoader { + snapshot + } + + #expect( + loader.nextRefreshDate(after: entry, now: now) == + now.addingTimeInterval(KeyboardLockerWidgetSnapshotLoader.regularRefreshInterval) + ) + } +} diff --git a/KeyboardLockerModelTests/KlockCommandLineTests.swift b/KeyboardLockerModelTests/KlockCommandLineTests.swift new file mode 100644 index 0000000..3b141ab --- /dev/null +++ b/KeyboardLockerModelTests/KlockCommandLineTests.swift @@ -0,0 +1,835 @@ +import Client +import CoreGraphics +import Foundation +import Testing + +@Suite(.serialized) +struct KlockCommandLineTests { + // MARK: - Parser + + @Test + func parseReturnsNilWhenNoArgumentsGiven() throws { + #expect(try KlockCommandLineParser.parse([]) == nil) + } + + @Test + func parseAcceptsEveryValidCommandSpelling() throws { + let cases: [(arguments: [String], expected: KlockCommand)] = [ + (["--help"], .help), + (["-h"], .help), + (["help"], .help), + (["--version"], .version), + (["-v"], .version), + (["version"], .version), + (["lock"], .lock(wait: true)), + (["lock", "--no-wait"], .lock(wait: false)), + (["unlock"], .unlock), + (["toggle"], .toggle), + (["register-agent"], .registerAgent), + (["request-access"], .requestAccess), + (["status"], .status(output: .humanReadable)), + (["status", "--json"], .status(output: .json)), + (["status", "--snapshot"], .status(output: .snapshot)), + ] + + for (arguments, expected) in cases { + #expect(try KlockCommandLineParser.parse(arguments) == expected, "arguments: \(arguments)") + } + } + + @Test + func parseRejectsUnknownCommands() { + let cases = [["bogus"], ["--unknown-flag"]] + + for arguments in cases { + let error = #expect( + throws: KlockCommandLineError.self, + "arguments: \(arguments)" + ) { + try KlockCommandLineParser.parse(arguments) + } + #expect(error == .unknownCommand(arguments[0]), "arguments: \(arguments)") + } + } + + @Test + func parseRejectsUnexpectedArguments() { + let cases: [(arguments: [String], expected: KlockCommandLineError)] = [ + (["lock", "extra"], .unexpectedArguments(["extra"])), + (["lock", "--no-wait", "extra"], .unexpectedArguments(["--no-wait", "extra"])), + (["status", "--xml"], .unexpectedArguments(["--xml"])), + (["status", "--json", "--snapshot"], .unexpectedArguments(["--json", "--snapshot"])), + (["unlock", "now"], .unexpectedArguments(["now"])), + (["toggle", "now"], .unexpectedArguments(["now"])), + (["register-agent", "now"], .unexpectedArguments(["now"])), + (["request-access", "now"], .unexpectedArguments(["now"])), + (["--help", "extra"], .unexpectedArguments(["extra"])), + (["version", "extra"], .unexpectedArguments(["extra"])), + ] + + for (arguments, expected) in cases { + let error = #expect( + throws: KlockCommandLineError.self, + "arguments: \(arguments)" + ) { + try KlockCommandLineParser.parse(arguments) + } + #expect(error == expected, "arguments: \(arguments)") + } + } + + // MARK: - Command Execution + + @Test + func interactiveLockReportsAlreadyLockedWithoutWaiting() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .success(.alreadyLocked) + + let result = await runKlock(arguments: ["lock"], client: client) + + #expect(result.exitCode == 0) + #expect(result.stdout == ["Already locked. This command did not create a new lock."]) + #expect(result.stderr == []) + #expect(client.lockInteractivelyCalls == 1) + #expect(client.waitUntilUnlockedCalls == 0) + } + + @Test + func interactiveLockPrintsHotkeyWaitsAndConfirmsUnlock() async { + let client = FakeKlockClient() + let hotkey = KeyboardLockerSettings.testFixture.unlockHotkey.displayString + + let result = await runKlock(arguments: ["lock"], client: client) + + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Locked. Press \(hotkey) or Ctrl+C to unlock.", + "Unlocked.", + ]) + #expect(result.stderr == []) + #expect(client.currentSettingsCalls == 1) + #expect(client.waitUntilUnlockedCalls == 1) + } + + @Test + func interactiveLockFallsBackToManualHintWhenSettingsUnavailable() async { + let client = FakeKlockClient() + client.settingsResult = .failure(Self.makeError("settings unavailable")) + + let result = await runKlock(arguments: ["lock"], client: client) + + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.", + "Unlocked.", + ]) + #expect(result.stderr == ["Warning: Could not read the configured unlock shortcut: settings unavailable"]) + } + + @Test + func interactiveLockFailureReportsErrorOnStandardError() async throws { + let client = FakeKlockClient() + let error = XPCClientError.serviceUnavailable + client.lockInteractivelyResult = .failure(error) + + let result = await runKlock(arguments: ["lock"], client: client) + + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(try result.stderr == [ + "Error: \(error.localizedDescription)", + " \(#require(error.recoverySuggestion))", + " Or run `klock register-agent` to register it from Terminal.", + ]) + #expect(client.waitUntilUnlockedCalls == 0) + } + + @Test + func nonInteractiveLockPrintsLockedWithoutWaiting() async { + let client = FakeKlockClient() + + let result = await runKlock(arguments: ["lock", "--no-wait"], client: client) + + #expect(result.exitCode == 0) + #expect(result.stdout == ["Locked."]) + #expect(result.stderr == []) + #expect(client.lockCalls == 1) + #expect(client.lockInteractivelyCalls == 0) + #expect(client.waitUntilUnlockedCalls == 0) + } + + @Test + func nonInteractiveLockFailureExitsWithError() async { + let client = FakeKlockClient() + client.lockResult = .failure(Self.makeError("lock failed")) + + let result = await runKlock(arguments: ["lock", "--no-wait"], client: client) + + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: lock failed"]) + } + + @Test + func unlockPrintsUnlockedOnSuccess() async { + let client = FakeKlockClient() + + let result = await runKlock(arguments: ["unlock"], client: client) + + #expect(result.exitCode == 0) + #expect(result.stdout == ["Unlocked."]) + #expect(result.stderr == []) + #expect(client.unlockCalls == 1) + } + + @Test + func unlockFailureReportsErrorOnStandardError() async { + let client = FakeKlockClient() + client.unlockResult = .failure(Self.makeError("unlock failed")) + + let result = await runKlock(arguments: ["unlock"], client: client) + + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: unlock failed"]) + } + + @Test + func togglePrintsResultingStateAcrossRoundTrip() async { + let client = FakeKlockClient() + client.toggleResults = [.success(true), .success(false)] + + var result = await runKlock(arguments: ["toggle"], client: client) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Locked."]) + + result = await runKlock(arguments: ["toggle"], client: client) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Unlocked."]) + + #expect(result.stderr == []) + #expect(client.toggleCalls == 2) + } + + @Test + func toggleFailureReportsErrorOnStandardError() async { + let client = FakeKlockClient() + client.toggleResult = .failure(Self.makeError("toggle failed")) + + let result = await runKlock(arguments: ["toggle"], client: client) + + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: toggle failed"]) + } + + @Test + func statusPrintsHumanReadableState() async { + let client = FakeKlockClient() + + client.statusResult = .success(true) + var result = await runKlock(arguments: ["status"], client: client) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Locked"]) + + client.statusResult = .success(false) + result = await runKlock(arguments: ["status"], client: client) + #expect(result.exitCode == 0) + #expect(result.stdout == ["Unlocked"]) + + #expect(result.stderr == []) + #expect(client.statusCalls == 2) + } + + @Test + func statusPrintsJSONState() async { + let client = FakeKlockClient() + + client.statusResult = .success(true) + var result = await runKlock(arguments: ["status", "--json"], client: client) + #expect(result.exitCode == 0) + #expect(result.stdout == [#"{"locked":true}"#]) + + client.statusResult = .success(false) + result = await runKlock(arguments: ["status", "--json"], client: client) + #expect(result.exitCode == 0) + #expect(result.stdout == [#"{"locked":false}"#]) + } + + @Test + func statusSnapshotPrintsSnapshotContract() async { + let client = FakeKlockClient() + client.lockStatusSnapshotResult = .success( + LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 1_700_000_030), + isLocked: true, + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + autoUnlockTargetDate: Date(timeIntervalSince1970: 1_700_000_060), + settings: .testFixture + ) + ) + + let result = await runKlock(arguments: ["status", "--snapshot"], client: client) + + #expect(result.exitCode == 0) + #expect(result.stdout == [ + """ + {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} + """, + ]) + #expect(result.stderr == []) + #expect(client.lockStatusSnapshotCalls == 1) + #expect(client.statusCalls == 0) + } + + @Test + func statusSnapshotFailureReportsErrorWithoutFallback() async { + let client = FakeKlockClient() + client.lockStatusSnapshotResult = .failure(Self.makeError("snapshot unsupported")) + + let result = await runKlock(arguments: ["status", "--snapshot"], client: client) + + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: snapshot unsupported"]) + #expect(client.statusCalls == 0) + } + + @Test + func interactiveLockExitsWithErrorWhenWaitingForUnlockFails() async throws { + let client = FakeKlockClient() + let error = XPCClientError.serviceUnavailable + client.waitUntilUnlockedResult = .failure(error) + let hotkey = KeyboardLockerSettings.testFixture.unlockHotkey.displayString + + let result = await runKlock(arguments: ["lock"], client: client) + + #expect(result.exitCode == 1) + #expect(result.stdout == ["Locked. Press \(hotkey) or Ctrl+C to unlock."]) + #expect(try result.stderr == [ + "Error: \(error.localizedDescription)", + " \(#require(error.recoverySuggestion))", + " Or run `klock register-agent` to register it from Terminal.", + ]) + } + + // MARK: - Request Access + + @Test + func requestAccessReportsAlreadyGrantedWithoutPrompting() async { + let client = FakeKlockClient() + client.hasAccessibilityPermissionResult = .success(true) + + let result = await runKlock(arguments: ["request-access"], client: client) + + #expect(result.exitCode == 0) + #expect(result.stdout == ["Accessibility access is already granted."]) + #expect(result.stderr == []) + #expect(client.requestAccessibilityPermissionCalls == 0) + } + + @Test + func requestAccessPromptsThenPollsUntilGranted() async { + let client = FakeKlockClient() + client.hasAccessibilityPermissionResults = [ + .success(false), + .success(false), + .success(true), + ] + + let result = await runKlock( + arguments: ["request-access"], + client: client, + accessPoll: (attempts: 5, interval: .zero) + ) + + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…", + "Accessibility access granted.", + ]) + #expect(result.stderr == []) + #expect(client.requestAccessibilityPermissionCalls == 1) + #expect(client.hasAccessibilityPermissionCalls == 3) + } + + @Test + func requestAccessReportsPendingWhenWindowExpires() async { + let client = FakeKlockClient() + + let result = await runKlock( + arguments: ["request-access"], + client: client, + accessPoll: (attempts: 2, interval: .zero) + ) + + #expect(result.exitCode == 1) + #expect(result.stdout.first == "Requested the system Accessibility prompt for the KeyboardLocker agent; waiting for the grant…") + #expect(result.stdout.last == "Accessibility access is not granted yet. Enable KeyboardLocker in System Settings → Privacy & Security → Accessibility, then re-run `klock request-access`.") + #expect(result.stderr == []) + #expect(client.requestAccessibilityPermissionCalls == 1) + } + + @Test + func requestAccessFailureReportsErrorOnStandardError() async { + let client = FakeKlockClient() + client.requestAccessibilityPermissionResult = .failure(Self.makeError("prompt failed")) + + let result = await runKlock(arguments: ["request-access"], client: client) + + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(result.stderr == ["Error: prompt failed"]) + } + + // MARK: - Register Agent + + @Test + func registerAgentReportsAlreadyReachableWithoutOpeningApp() async { + let client = FakeKlockClient() + var openCalls = 0 + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { openCalls += 1 } + ) + + #expect(result.exitCode == 0) + #expect(result.stdout == ["The KeyboardLocker agent is already registered and reachable."]) + #expect(result.stderr == []) + #expect(openCalls == 0) + #expect(client.statusCalls == 1) + } + + @Test + func registerAgentOpensAppAndConfirmsWhenAgentBecomesReachable() async { + let client = FakeKlockClient() + client.statusResults = [ + .failure(Self.makeError("unreachable")), + .success(false), + ] + var openCalls = 0 + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { openCalls += 1 }, + agentPoll: (attempts: 3, interval: .zero) + ) + + #expect(result.exitCode == 0) + #expect(result.stdout == [ + "Launched KeyboardLocker to register its background agent.", + "The KeyboardLocker agent is registered and reachable.", + ]) + #expect(result.stderr == []) + #expect(openCalls == 1) + #expect(client.statusCalls == 2) + } + + @Test + func registerAgentFailsWhenAgentStaysUnreachable() async { + let client = FakeKlockClient() + client.statusResult = .failure(Self.makeError("unreachable")) + var openCalls = 0 + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { openCalls += 1 }, + agentPoll: (attempts: 2, interval: .zero) + ) + + #expect(result.exitCode == 1) + #expect(result.stdout == ["Launched KeyboardLocker to register its background agent."]) + #expect(result.stderr.first == "Error: The KeyboardLocker agent is not reachable yet.") + #expect(openCalls == 1) + #expect(client.statusCalls == 3) + } + + @Test + func registerAgentReportsOpenFailure() async throws { + let client = FakeKlockClient() + client.statusResult = .failure(Self.makeError("unreachable")) + let error = KlockAppOpener.OpenerError.appNotFound + + let result = await runKlock( + arguments: ["register-agent"], + client: client, + openKeyboardLockerApp: { throw error } + ) + + #expect(result.exitCode == 1) + #expect(result.stdout == []) + #expect(try result.stderr == [ + "Error: \(error.localizedDescription)", + " \(#require(error.recoverySuggestion))", + ]) + } + + // MARK: - Termination Guard + + @Test + func interactiveLockInstallsAndCancelsTerminationGuardWhileWaiting() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func signalDuringAcquisitionReleasesConfirmedLockBeforeTerminating() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + client.onLockInteractively = { + terminationGuard.send(SIGTERM) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 128 + SIGTERM) + #expect(client.unlockCalls == 1) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func signalDuringUnlockWaitWinsOverConcurrentNormalCompletion() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + client.onWaitUntilUnlocked = { + terminationGuard.send(SIGTERM) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 128 + SIGTERM) + #expect(client.waitUntilUnlockedCalls == 1) + #expect(client.unlockCalls == 1) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func interactiveLockAlreadyLockedInstallsAndCancelsTerminationGuard() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .success(.alreadyLocked) + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 0) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func interactiveLockFailureInstallsAndCancelsTerminationGuard() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .failure(Self.makeError("lock failed")) + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 1) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func signalDuringAlreadyLockedAcquisitionDoesNotUnlockExistingLock() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .success(.alreadyLocked) + let terminationGuard = FakeKlockTerminationGuard() + client.onLockInteractively = { + terminationGuard.send(SIGTERM) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 128 + SIGTERM) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func signalDuringFailedAcquisitionDoesNotUnlock() async { + let client = FakeKlockClient() + client.lockInteractivelyResult = .failure(Self.makeError("lock failed")) + let terminationGuard = FakeKlockTerminationGuard() + client.onLockInteractively = { + terminationGuard.send(SIGHUP) + } + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 128 + SIGHUP) + #expect(client.unlockCalls == 0) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func interactiveLockWaitFailureCancelsTerminationGuard() async { + let client = FakeKlockClient() + client.waitUntilUnlockedResult = .failure(Self.makeError("wait failed")) + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 1) + #expect(terminationGuard.installCalls == 1) + #expect(terminationGuard.cancelCalls == 1) + } + + @Test + func nonInteractiveLockSkipsTerminationGuard() async { + let client = FakeKlockClient() + let terminationGuard = FakeKlockTerminationGuard() + + let result = await runKlock( + arguments: ["lock", "--no-wait"], + client: client, + terminationGuard: terminationGuard + ) + + #expect(result.exitCode == 0) + #expect(terminationGuard.installCalls == 0) + } + + @Test + func unlockBeforeTerminationReleasesCreatedLock() async { + let client = FakeKlockClient() + var stderr: [String] = [] + + await KlockCLI.unlockBeforeTermination(client: client, printError: { stderr.append($0) }) + + #expect(client.unlockCalls == 1) + #expect(stderr == ["Terminated; released the keyboard lock created by this command."]) + } + + @Test + func unlockBeforeTerminationReportsFailureWithRecoveryHint() async { + let client = FakeKlockClient() + client.unlockResult = .failure(Self.makeError("agent gone")) + var stderr: [String] = [] + + await KlockCLI.unlockBeforeTermination(client: client, printError: { stderr.append($0) }) + + #expect(client.unlockCalls == 1) + #expect(stderr.count == 1) + #expect(stderr[0].contains("could not release"), "stderr: \(stderr)") + #expect(stderr[0].contains("agent gone"), "stderr: \(stderr)") + #expect(stderr[0].contains("Unlock Now"), "stderr: \(stderr)") + } + + // MARK: - Helpers + + private static func makeError(_ message: String) -> NSError { + NSError( + domain: "KlockCommandLineTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + + private func runKlock( + arguments: [String], + client: FakeKlockClient, + openKeyboardLockerApp: @escaping () throws -> Void = {}, + agentPoll: (attempts: Int, interval: Duration) = (attempts: 1, interval: .zero), + accessPoll: (attempts: Int, interval: Duration) = (attempts: 1, interval: .zero), + terminationGuard: any KlockTerminationGuarding = NullKlockTerminationGuard() + ) async -> (exitCode: Int32, stdout: [String], stderr: [String]) { + var stdout: [String] = [] + var stderr: [String] = [] + let exitCode = await KlockCLI.run( + arguments: arguments, + client: client, + openKeyboardLockerApp: openKeyboardLockerApp, + agentPoll: agentPoll, + accessPoll: accessPoll, + terminationGuard: terminationGuard, + printOut: { stdout.append($0) }, + printError: { stderr.append($0) } + ) + return (exitCode, stdout, stderr) + } +} + +private extension KeyboardLockerSettings { + static let testFixture = KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: Hotkey(keyCode: 37, modifierFlags: [.maskCommand, .maskControl]) + ) +} + +private final class FakeKlockClient: KlockClientServing, @unchecked Sendable { + var settingsResult: Result = .success(.testFixture) + var lockInteractivelyResult: Result = .success(.acquired) + var lockResult: Result = .success(()) + var unlockResult: Result = .success(()) + var toggleResult: Result = .success(true) + /// Same scripting seam as `statusResults`: each `toggle()` consumes the next queued result + /// before falling back to `toggleResult`, so tests can drive a state round trip. + var toggleResults: [Result]? + var statusResult: Result = .success(false) + /// When set, each `status()` call consumes the next queued result before falling back to + /// `statusResult`, so tests can script an Agent that becomes reachable after registration. + var statusResults: [Result]? + var lockStatusSnapshotResult: Result = .success( + LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 0), + isLocked: false, + startedAt: nil, + autoUnlockTargetDate: nil, + settings: .testFixture + ) + ) + var waitUntilUnlockedResult: Result = .success(()) + var hasAccessibilityPermissionResult: Result = .success(false) + /// Same scripting seam as `statusResults`: drives the request-access pre-check and polls. + var hasAccessibilityPermissionResults: [Result]? + var requestAccessibilityPermissionResult: Result = .success(()) + var onLockInteractively: (() -> Void)? + var onWaitUntilUnlocked: (() -> Void)? + + private(set) var currentSettingsCalls = 0 + private(set) var lockInteractivelyCalls = 0 + private(set) var lockCalls = 0 + private(set) var unlockCalls = 0 + private(set) var toggleCalls = 0 + private(set) var statusCalls = 0 + private(set) var lockStatusSnapshotCalls = 0 + private(set) var waitUntilUnlockedCalls = 0 + private(set) var hasAccessibilityPermissionCalls = 0 + private(set) var requestAccessibilityPermissionCalls = 0 + + func currentSettings() async throws -> KeyboardLockerSettings { + currentSettingsCalls += 1 + return try settingsResult.get() + } + + func lockInteractively() async throws -> LockRequestOutcome { + lockInteractivelyCalls += 1 + onLockInteractively?() + return try lockInteractivelyResult.get() + } + + func lock() async throws { + lockCalls += 1 + try lockResult.get() + } + + func unlock() async throws { + unlockCalls += 1 + try unlockResult.get() + } + + func toggle() async throws -> Bool { + toggleCalls += 1 + if toggleResults?.isEmpty == false { + return try toggleResults!.removeFirst().get() + } + return try toggleResult.get() + } + + func status() async throws -> Bool { + statusCalls += 1 + if statusResults?.isEmpty == false { + return try statusResults!.removeFirst().get() + } + return try statusResult.get() + } + + func lockStatusSnapshot() async throws -> LockStatusSnapshot { + lockStatusSnapshotCalls += 1 + return try lockStatusSnapshotResult.get() + } + + func waitUntilUnlocked() async throws { + waitUntilUnlockedCalls += 1 + onWaitUntilUnlocked?() + try waitUntilUnlockedResult.get() + } + + func hasAccessibilityPermission() async throws -> Bool { + hasAccessibilityPermissionCalls += 1 + if hasAccessibilityPermissionResults?.isEmpty == false { + return try hasAccessibilityPermissionResults!.removeFirst().get() + } + return try hasAccessibilityPermissionResult.get() + } + + func requestAccessibilityPermission() async throws { + requestAccessibilityPermissionCalls += 1 + try requestAccessibilityPermissionResult.get() + } +} + +private final class FakeKlockTerminationGuard: KlockTerminationGuarding, @unchecked Sendable { + private(set) var installCalls = 0 + private(set) var cancelCalls = 0 + private var onTermination: ((Int32) -> Void)? + + func install(onTermination: @escaping (Int32) -> Void) -> () -> Void { + installCalls += 1 + self.onTermination = onTermination + return { [self] in + cancelCalls += 1 + self.onTermination = nil + } + } + + func send(_ signal: Int32) { + onTermination?(signal) + } +} + +/// Default guard for tests that do not exercise the termination path: never touches process +/// signal state, so the test process keeps its default signal dispositions. +private struct NullKlockTerminationGuard: KlockTerminationGuarding { + func install(onTermination _: @escaping (Int32) -> Void) -> () -> Void { + {} + } +} diff --git a/KeyboardLockerModelTests/KlockStatusOutputTests.swift b/KeyboardLockerModelTests/KlockStatusOutputTests.swift new file mode 100644 index 0000000..bbe503f --- /dev/null +++ b/KeyboardLockerModelTests/KlockStatusOutputTests.swift @@ -0,0 +1,78 @@ +import Client +import Foundation +import Testing + +@Suite(.serialized) +struct KlockStatusOutputTests { + @Test + func humanReadableOutputPreservesExistingContract() { + #expect(KlockStatusOutput.humanReadable.render(isLocked: true) == "Locked") + #expect(KlockStatusOutput.humanReadable.render(isLocked: false) == "Unlocked") + } + + @Test + func jSONOutputUsesStableBooleanField() throws { + let locked = KlockStatusOutput.json.render(isLocked: true) + let unlocked = KlockStatusOutput.json.render(isLocked: false) + + #expect(locked == #"{"locked":true}"#) + #expect(unlocked == #"{"locked":false}"#) + #expect(try decodedLockedValue(from: locked) == true) + #expect(try decodedLockedValue(from: unlocked) == false) + } + + @Test + func snapshotOutputRendersLockedSnapshotWithFixedKeyOrder() { + let output = KlockStatusOutput.render( + snapshot: makeSnapshot( + isLocked: true, + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + autoUnlockTargetDate: Date(timeIntervalSince1970: 1_700_000_060) + ) + ) + + #expect( + output == + """ + {"autoUnlockTargetDate":"2023-11-14T22:14:20Z","locked":true,"startedAt":"2023-11-14T22:13:20Z","unlockHotkey":"⌃⌘L"} + """ + ) + } + + @Test + func snapshotOutputRendersUnlockedSnapshotWithNullDates() { + let output = KlockStatusOutput.render( + snapshot: makeSnapshot(isLocked: false, startedAt: nil, autoUnlockTargetDate: nil) + ) + + #expect( + output == + #"{"autoUnlockTargetDate":null,"locked":false,"startedAt":null,"unlockHotkey":"⌃⌘L"}"# + ) + } + + private func makeSnapshot( + isLocked: Bool, + startedAt: Date?, + autoUnlockTargetDate: Date? + ) -> LockStatusSnapshot { + LockStatusSnapshot( + capturedAt: Date(timeIntervalSince1970: 1_700_000_030), + isLocked: isLocked, + startedAt: startedAt, + autoUnlockTargetDate: autoUnlockTargetDate, + settings: KeyboardLockerSettings( + autoUnlockPolicy: .disabled, + unlockHotkey: KeyboardLockerSettings.Hotkey( + keyCode: 37, + modifierFlags: [.maskCommand, .maskControl] + ) + ) + ) + } + + private func decodedLockedValue(from output: String) throws -> Bool? { + let object = try JSONSerialization.jsonObject(with: Data(output.utf8)) + return (object as? [String: Bool])?["locked"] + } +} diff --git a/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift b/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift new file mode 100644 index 0000000..2109bbe --- /dev/null +++ b/KeyboardLockerModelTests/SafetyCheckExperienceStoreTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing + +@Suite(.serialized) +struct SafetyCheckExperienceStoreTests { + @Test + func completionPersistsOnlyAfterBeingMarked() throws { + let suiteName = "SafetyCheckExperienceStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let store = SafetyCheckExperienceStore( + userDefaults: defaults, + completionKey: "completed" + ) + + #expect(!store.hasCompletedSafetyCheck) + + store.markCompleted() + + #expect(store.hasCompletedSafetyCheck) + } +} diff --git a/KeyboardLockerWidgets/Info.plist b/KeyboardLockerWidgets/Info.plist new file mode 100644 index 0000000..b515054 --- /dev/null +++ b/KeyboardLockerWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + KeyboardLocker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/KeyboardLockerWidgets/KeyboardLockerControl.swift b/KeyboardLockerWidgets/KeyboardLockerControl.swift new file mode 100644 index 0000000..2e49c7d --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerControl.swift @@ -0,0 +1,74 @@ +import AppIntents +import Client +import SwiftUI +import SystemSurfaces +import WidgetKit + +@available(macOS 26.0, *) +struct KeyboardLockerControl: ControlWidget { + nonisolated static let kind = KeyboardLockerSurfaceKind.keyboardLockControl + + var body: some ControlWidgetConfiguration { + StaticControlConfiguration( + kind: Self.kind, + provider: KeyboardLockerControlValueProvider() + ) { isLocked in + ControlWidgetToggle( + "Keyboard Lock", + isOn: isLocked, + action: SetKeyboardLockControlIntent() + ) { isLocked in + Label( + isLocked ? "Locked" : "Unlocked", + systemImage: isLocked ? "keyboard.fill" : "keyboard" + ) + } + } + .displayName("Keyboard Lock") + .description("Locks or unlocks the keyboard through the KeyboardLocker agent.") + } +} + +@available(macOS 26.0, *) +private struct KeyboardLockerControlValueProvider: ControlValueProvider { + /// Gallery preview matches the status widget's locked sample so both system surfaces + /// advertise the same state. + let previewValue = true + + private let loader: KeyboardLockerControlValueLoader + + init(loader: KeyboardLockerControlValueLoader = .live) { + self.loader = loader + } + + func currentValue() async throws -> Bool { + try await loader.currentValue() + } +} + +@available(macOS 26.0, *) +struct SetKeyboardLockControlIntent: SetValueIntent { + static let title: LocalizedStringResource = "Set Keyboard Lock" + static let description = IntentDescription( + "Sets the global keyboard lock to the requested state." + ) + + @Parameter(title: "Locked") + var value: Bool + + init() {} + + func perform() async throws -> some IntentResult { + try await KeyboardLockerControlAction.live.setLocked(value) + return .result() + } +} + +@available(macOS 26.0, *) +private extension KeyboardLockerControlValueLoader { + static var live: Self { + Self { + try await XPCClient.shared.status() + } + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerControlModel.swift b/KeyboardLockerWidgets/KeyboardLockerControlModel.swift new file mode 100644 index 0000000..c36639c --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerControlModel.swift @@ -0,0 +1,62 @@ +import Client +import Foundation +import SystemSurfaces + +struct KeyboardLockerControlValueLoader: Sendable { + typealias FetchValue = @Sendable () async throws -> Bool + + private let fetchValue: FetchValue + + init(fetchValue: @escaping FetchValue) { + self.fetchValue = fetchValue + } + + func currentValue() async throws -> Bool { + try await fetchValue() + } +} + +struct KeyboardLockerControlAction: Sendable { + typealias Operation = @Sendable () async throws -> Void + typealias Reload = @Sendable () async -> Void + + private let lock: Operation + private let unlock: Operation + private let reload: Reload + + init( + lock: @escaping Operation, + unlock: @escaping Operation, + reload: @escaping Reload + ) { + self.lock = lock + self.unlock = unlock + self.reload = reload + } + + func setLocked(_ isLocked: Bool) async throws { + if isLocked { + try await lock() + } else { + try await unlock() + } + + await reload() + } +} + +extension KeyboardLockerControlAction { + static var live: Self { + Self( + lock: { + try await XPCClient.shared.lock() + }, + unlock: { + try await XPCClient.shared.unlock() + }, + reload: { + LockStateSurfaceInvalidator.live.invalidate() + } + ) + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift new file mode 100644 index 0000000..e16291a --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerStatusWidget.swift @@ -0,0 +1,113 @@ +import AppIntents +import Client +import SwiftUI +import SystemSurfaces +import WidgetKit + +struct KeyboardLockerStatusWidget: Widget { + nonisolated static let kind = KeyboardLockerSurfaceKind.statusWidget + + var body: some WidgetConfiguration { + StaticConfiguration( + kind: Self.kind, + provider: KeyboardLockerTimelineProvider() + ) { entry in + KeyboardLockerWidgetEntryView(entry: entry) + } + .configurationDisplayName("Keyboard Lock Status") + .description("Shows the authoritative KeyboardLocker state and auto-unlock deadline.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +private struct KeyboardLockerWidgetEntryView: View { + let entry: KeyboardLockerWidgetEntry + + var body: some View { + content + .padding() + .modifier(KeyboardLockerWidgetBackground()) + } + + @ViewBuilder private var content: some View { + switch entry.state { + case let .available(snapshot): + VStack(alignment: .leading, spacing: 8) { + Label("KeyboardLocker", systemImage: snapshot.isLocked ? "keyboard.fill" : "keyboard") + .font(.caption) + .foregroundStyle(.secondary) + + Text(snapshot.isLocked ? "Locked" : "Unlocked") + .font(.title2.weight(.semibold)) + + if snapshot.isLocked { + if let deadline = snapshot.autoUnlockTargetDate { + Label { + Text(deadline, style: .timer) + .monospacedDigit() + } icon: { + Image(systemName: "timer") + } + } else { + Label("Manual unlock", systemImage: "infinity") + } + + Label(snapshot.settings.unlockHotkey.displayString, systemImage: "command") + } else { + Label("Ready", systemImage: "checkmark.circle") + } + + if #available(macOS 14.0, *) { + KeyboardLockerWidgetActionButton(isLocked: snapshot.isLocked) + } + } + + case let .unavailable(message): + VStack(alignment: .leading, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Agent Unavailable") + .font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } +} + +@available(macOS 14.0, *) +private struct KeyboardLockerWidgetActionButton: View { + let isLocked: Bool + + var body: some View { + Group { + if isLocked { + Button( + intent: SetKeyboardLockWidgetIntent(desiredIsLocked: false) + ) { + Label("Unlock", systemImage: "lock.open") + } + } else { + Button( + intent: SetKeyboardLockWidgetIntent(desiredIsLocked: true) + ) { + Label("Lock", systemImage: "lock") + } + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } +} + +private struct KeyboardLockerWidgetBackground: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content.containerBackground(.fill.tertiary, for: .widget) + } else { + content.background(.background) + } + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift new file mode 100644 index 0000000..4abdc9a --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetAction.swift @@ -0,0 +1,33 @@ +import AppIntents + +@available(macOS 14.0, *) +struct SetKeyboardLockWidgetIntent: AppIntent { + static let title: LocalizedStringResource = "Set Keyboard Lock" + static let description = IntentDescription( + "Sets the global keyboard lock to the requested state from the widget." + ) + static let isDiscoverable = false + static let openAppWhenRun = false + + @Parameter(title: "Locked") + var desiredIsLocked: Bool + + private let action: KeyboardLockerControlAction + + init() { + action = .live + } + + init( + desiredIsLocked: Bool, + action: KeyboardLockerControlAction = .live + ) { + self.action = action + self.desiredIsLocked = desiredIsLocked + } + + func perform() async throws -> some IntentResult { + try await action.setLocked(desiredIsLocked) + return .result() + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift new file mode 100644 index 0000000..b9a9e48 --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetTimeline.swift @@ -0,0 +1,127 @@ +import Client +import Foundation +import WidgetKit + +struct KeyboardLockerWidgetEntry: TimelineEntry, Equatable { + enum State: Equatable { + case available(LockStatusSnapshot) + case unavailable(message: String) + } + + let date: Date + let state: State + + static func placeholder(at date: Date) -> Self { + Self( + date: date, + state: .available( + LockStatusSnapshot( + capturedAt: date, + isLocked: true, + startedAt: date.addingTimeInterval(-5 * 60), + autoUnlockTargetDate: date.addingTimeInterval(60), + settings: .default + ) + ) + ) + } +} + +struct KeyboardLockerWidgetSnapshotLoader: Sendable { + typealias FetchSnapshot = @Sendable () async throws -> LockStatusSnapshot + + static let regularRefreshInterval: TimeInterval = 15 * 60 + + private let fetchSnapshot: FetchSnapshot + + init(fetchSnapshot: @escaping FetchSnapshot) { + self.fetchSnapshot = fetchSnapshot + } + + static var live: Self { + Self { + try await XPCClient.shared.lockStatusSnapshot() + } + } + + func entry(at date: Date) async -> KeyboardLockerWidgetEntry { + do { + return try await KeyboardLockerWidgetEntry( + date: date, + state: .available(fetchSnapshot()) + ) + } catch { + return KeyboardLockerWidgetEntry( + date: date, + state: .unavailable(message: Self.message(for: error)) + ) + } + } + + func nextRefreshDate( + after entry: KeyboardLockerWidgetEntry, + now: Date + ) -> Date { + let regularRefresh = now.addingTimeInterval(Self.regularRefreshInterval) + guard case let .available(snapshot) = entry.state, + snapshot.isLocked, + let deadline = snapshot.autoUnlockTargetDate + else { + return regularRefresh + } + + // Reconcile just after the Agent's deadline instead of presenting an expired countdown until + // the next regular refresh. WidgetKit remains free to coalesce the requested reload. + let deadlineRefresh = max( + now.addingTimeInterval(1), + deadline.addingTimeInterval(1) + ) + return min(regularRefresh, deadlineRefresh) + } + + private static func message(for error: Error) -> String { + let error = error as NSError + return [error.localizedDescription, error.localizedRecoverySuggestion] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + } +} + +struct KeyboardLockerTimelineProvider: TimelineProvider { + private let loader: KeyboardLockerWidgetSnapshotLoader + + init(loader: KeyboardLockerWidgetSnapshotLoader = .live) { + self.loader = loader + } + + func placeholder(in _: Context) -> KeyboardLockerWidgetEntry { + .placeholder(at: Date()) + } + + func getSnapshot( + in context: Context, + completion: @escaping @Sendable (KeyboardLockerWidgetEntry) -> Void + ) { + guard !context.isPreview else { + completion(.placeholder(at: Date())) + return + } + + Task { + await completion(loader.entry(at: Date())) + } + } + + func getTimeline( + in _: Context, + completion: @escaping @Sendable (Timeline) -> Void + ) { + Task { + let now = Date() + let entry = await loader.entry(at: now) + let refreshDate = loader.nextRefreshDate(after: entry, now: now) + completion(Timeline(entries: [entry], policy: .after(refreshDate))) + } + } +} diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements b/KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements new file mode 100644 index 0000000..631a0dc --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgets.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.temporary-exception.mach-lookup.global-name + + io.lzhlovesjyq.keyboardlocker.agent + + + diff --git a/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift b/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift new file mode 100644 index 0000000..59a27be --- /dev/null +++ b/KeyboardLockerWidgets/KeyboardLockerWidgetsBundle.swift @@ -0,0 +1,13 @@ +import SwiftUI +import WidgetKit + +@main +struct KeyboardLockerWidgetsBundle: WidgetBundle { + var body: some Widget { + KeyboardLockerStatusWidget() + + if #available(macOS 26.0, *) { + KeyboardLockerControl() + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 94c75af..b012228 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,62 +1,132 @@ -# Core Architecture Contract +# 核心架构契约 -**This is the foundational rule of the project. Every feature — the App, `klock`, Shortcuts, AppleScript, Widgets, and anything added later — MUST follow it. When a change conflicts with this contract, the contract wins; revise the change, not the contract.** +**这是项目的根本规则。每一个功能面 —— App、`klock`、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、Notifications 以及后续新增的任何形态 —— 都必须遵守它。当某个改动与本契约冲突时,契约优先;修改的是那个改动,而不是契约。** -## Single Core Principle +## 为什么是单核(从旧方案学到的) -The **Agent is the one and only place that executes real work.** All other surfaces (App, CLI, Shortcuts, AppleScript, Widgets, extensions) are **thin wrappers** whose sole job is: +本契约不是凭空的偏好,而是对一版**跑偏的旧实现**的直接回应。旧方案工程质量不差(依赖注入、职责分层、注释齐全),却选错了架构轴,导致多个功能面之间的锁状态根本无法对齐。把它的失败模式记在这里,是为了让契约的每条规则都有据可依,后来者(包括未来的自己)不再重蹈。 -1. Translate a user intent into an XPC call to the Agent, and -2. Observe global state via notifications. +- **进程内单例不是跨进程共享。** 旧方案让 App、CLI、AppleScript 各自 `import` 核心并持有 `KeyboardLockCore.shared`。`static let shared` 是**进程内**单例——三个进程三个实例,不是同一个。于是 CLI `lock` 锁的是 CLI 进程自己的 event tap,CLI 一退出锁就被 `deinit` 拆掉,锁**活不过命令**;App 与 CLI 的 `isLocked` 是两个变量,永远对不上。 + → **教训**:「一个物理键盘 = 一个全局状态」这个领域事实,必须映射为「一个**进程**持有它」(Agent + XPC),而不是「一个**类型**持有它」。语言层单例解决不了进程边界。 +- **回调式状态推送只能通知本进程。** 旧核心用 `var onLockStateChanged: ((Bool, Date?) -> Void)?` 广播变化,闭包只能绑本进程对象;CLI 改了状态,App 的回调永远收不到。旧架构连「跨进程同步」的入口都没有。 + → **教训**:多面场景下必须用系统级广播(见下文"状态同步"),而非进程内回调。 +- **分层分对了,轴选错了。** 旧方案按"关注点"垂直分层非常漂亮,却没有先按"进程边界"水平切分,于是每一层都被复制进三个进程。 + → **教训**:干净的类型分层 ≠ 正确的系统架构。有多个入口进程时,**第一刀切在进程边界(谁执行 / 谁只是 wrapper),再谈类型优雅**。 +- **各进程独立读 `UserDefaults` → 设置漂移。** 旧方案每个进程各持一份配置,CLI 改了热键 App 不知道。 + → **教训**:设置也只能有一个真相源(Agent)。MVP 中 wrapper 只经 XPC 读取;post-MVP 若加入写入,也必须经同一 XPC 边界(见下文"各职责的归属")。 -A wrapper never owns behavior or state. If two wrappers appear to need the same logic, that logic belongs in the core, not copied into each wrapper. +一句话:**有进程边界时,先按进程切,再谈类型优雅;单例和回调是进程内工具,不要拿来跨进程。** 下面的规则都是这句话的展开。 + +## 单核原则 + +**Agent 是唯一执行真实工作的地方。** 其他所有面(App、CLI、Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、Widgets、扩展)都是**薄 wrapper**,职责仅限两件事: + +1. 把用户意图翻译成对 Agent 的一次 XPC 调用; +2. 通过通知观察全局状态。 + +wrapper 永远不拥有行为或状态。如果两个 wrapper 看起来需要同一段逻辑,那段逻辑属于核心,而不该复制进每个 wrapper。 ``` -App ─────┐ -CLI ─────┤ -Shortcut ┼── XPC ──▶ Agent ◀── the ONLY executor -AppleScript┤ ├─ LockEngine (event tap, lock lifecycle) -Widget ──┘ ├─ Settings ownership (source of truth) - └─ Accessibility (permission gate) +App ─────────┐ +CLI ─────────┤ +Shortcut ────┤ +Services/URL ┤ +Focus ───────┼── XPC ──▶ Agent ◀── the ONLY executor +AppleScript ─┤ ├─ LockEngine (event tap, lock lifecycle) +Widget ──────┘ ├─ Settings ownership (source of truth) + ├─ Accessibility (permission gate) + └─ Lock notification (discoverability surface) ``` -## What Belongs Where +这里的 `Core`、SwiftPM product、运行进程和 XPC connection 是不同层次。完整的构建依赖图、进程图和调用时序见 [XPC 实现与使用指南](xpc.md)。 + +## 各职责的归属 -| Concern | Home | Rule | +| 关注点 | 归属 | 规则 | |---------|------|------| -| Lock/unlock execution (CGEventTap) | **Agent** (`Service/LockEngine`) | Never runs anywhere else. No wrapper touches CGEventTap. | -| Settings (source of truth) | **Agent** | The Agent loads/owns settings and applies them. Wrappers read/write settings only through XPC, never by holding their own `UserDefaults`. | -| Accessibility permission | **Agent** | The Agent holds the permission and answers status/requests over XPC. | -| State broadcast | **Agent** (`LockStateBroadcaster`) | Only the core emits state. Wrappers subscribe, never emit. | -| UI / intent translation | **Wrappers** | Wrappers may hold view state, but no domain logic. | +| 锁/解锁执行(CGEventTap) | **Agent**(`Service/LockEngine`) | 只在这里运行,不在别处。没有任何 wrapper 触碰 CGEventTap。 | +| 设置(真相源) | **Agent** | Agent 加载并拥有默认/持久化设置、负责应用它们。MVP 有意只向 wrapper 暴露读取(`XPCClient.currentSettings()`),不包含用户可编辑设置的 API 或 UI;因此没有 `applySettings` 不构成 MVP 缺口。设置编辑属于 post-MVP extension,未来加入时仍必须经 Agent XPC 写入并由 Agent 持久化,绝不在 wrapper 侧落地。读取失败必须显式呈现为 unavailable,不能把 wrapper 的 `.default` 冒充为 Agent 当前值。 | +| 锁状态快照 | **Agent**(`Service/LockEngine`) | `XPCClient.lockStatusSnapshot()` 一次返回同一 execution turn 中的 `isLocked`、锁定起点、auto-unlock deadline 与 active settings。wrapper 可以缓存它用于呈现,但不能从缓存反推或修改 Agent 状态。 | +| Accessibility 权限 | **Agent** | Agent 持有权限,并在执行锁定时校验(`AccessibilityManager.hasPermission()`)。wrapper 只能经 XPC 查询状态或请求 Agent 触发系统 prompt,不得自行调用 Accessibility API。权限 prompt 是异步的;请求完成不代表已授权,wrapper 必须重新查询。 | +| 状态广播 | **Agent**(`LockStateBroadcaster`) | 只有核心发出状态。wrapper 只订阅,从不发出。 | +| 锁定可发现性通知 | **Agent**(`LockStatusNotifier`) | "Keyboard Locked" 通知的投递与移除跟随引擎的同一次状态转换,任何入口、App 是否运行都覆盖;wrapper 不发布锁状态通知。Agent 启动时清除上一代退出留下的残留。 | +| UI / 意图翻译 | **Wrapper** | wrapper 可以持有视图状态,并协调只存在于自身进程的系统边界(例如 App 的 `SMAppService` 生命周期);不得复制 Agent 的锁、设置或 Accessibility 领域逻辑。 | + +App 的 Agent readiness/replacement 协调属于 wrapper 与 Service Management、XPC 两个系统边界之间的应用层编排,不是第二份锁核心。它必须保持为无锁状态真相源的薄协调器:`AppCoordinator` 只消费 Agent/系统查询并暴露应用状态与动作,不依赖 presentation framework;后续 UI 只能映射这些状态与动作。任何 CGEventTap、设置持久化和 Accessibility 判定仍只在 Agent 内执行。 + +App 可以持久化纯 presentation 状态,例如“是否已完成首次安全测试”;这类标记不得包含或推导锁状态、Agent settings 或 Accessibility 状态,也不能参与任何领域行为判定。诊断报告同样只是对当前权威查询的只读、脱敏呈现,不得缓存成另一份真相源。 + +## 全局锁语义 + +物理键盘只有一个,所以锁状态是**一个由 Agent 拥有的全局布尔值**。不存在按客户端或按会话划分的锁归属。 + +- 任何 wrapper 都可以锁;任何 wrapper 都可以解锁。CLI 发起的锁,App 可以解开,反之亦然。 +- locked 的输入范围是 macOS 交付给 `CGEventTap` 的键盘来源事件:标准 `keyDown` / `keyUp` / `flagsChanged`,以及 `NX_SUBTYPE_AUX_CONTROL_BUTTONS`、`NX_SUBTYPE_EJECT_KEY`、`NX_SUBTYPE_POWER_KEY` 这三类 system-defined keyboard control。音量、亮度和播放控制因此必须被消费;鼠标 / 触控板 pointer event(包括 `NX_SUBTYPE_AUX_MOUSE_BUTTONS`)必须继续可用。macOS 没有交付给 event tap 的硬件或系统保留路径不在可保证范围内。 +- 锁操作是**无状态的一次性 XPC 调用**(`lock` / `unlock` / `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 关闭时不再解除这个更新的用户意图。 +- safety check 是 capability-gated 的一次性 Agent 操作:仅在 unlocked 时创建新锁,沿用 Agent 持久化的解锁热键,但把该轮 active settings 的 auto-unlock 强制为固定 10 秒。fail-safe timer 由 Agent/`LockEngine` 持有,因此 App 退出、崩溃或 XPC 断开都不能让测试失去自动解锁兜底;该 override 不写回 settings store。若调用时已 locked,Agent 原子返回 already locked 且不修改既有锁。App 只在重新查询到权威 unlocked 后把 presentation completion 标记为完成。 +- interactive lock request 会原子返回本次调用是否完成 `unlocked → locked`。这个 outcome 只描述状态转换,不建立客户端所有权、引用计数或 session。只有真正完成转换的 interactive request 才会让该轮全局锁额外接受 `Ctrl+C` 解锁;重复请求不得改变既有锁的输入手势。发起并等待中的 CLI 进程对本轮锁负有退出清理责任:它观察 SIGTERM/SIGHUP/SIGINT,退出前尽力释放锁(有界等待后无论如何退出)。wire protocol 不携带 interactive lock 的代际令牌,清理无法区分自己创建的锁与等待期间由他入口重建的锁——若本轮锁已被解开且另一入口重新锁定,清理会释放较新的锁,这与任意入口可解锁同一全局锁的契约一致;SIGKILL/SIGSTOP 不可观察,遗留锁仍由解锁热键、通知 Unlock Now、auto-unlock 等全局途径解开。这是输入手势语义的进程侧延伸,不构成 session 或所有权。 +- Focus Filter 是 **activation-triggered acquisition**,不是“Focus active 期间持续保持 locked”的 policy。Focus 激活时的 `true` 最多尝试一次 `unlocked → locked`,并只在成功创建锁时记录该 Focus-owned generation;Focus 关闭时的 `false` 只条件性解除仍由它创建且未被普通 `lock` 接管的同一代。已有普通锁不会被 Focus 认领。显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束该 generation;实现不会查询当前 Focus 后自动重建锁,也不会在同一次 Focus activation 内持续 relock。这个进程内 marker 不暴露给 wrapper,也不改变“任意入口可显式 unlock 同一个全局锁”的契约。 +- `status()` 保留为兼容旧 wrapper 的最小布尔查询;需要呈现锁定时长、auto-unlock deadline 或 active settings 的 wrapper 使用 capability-gated `lockStatusSnapshot()`。snapshot 传输权威时间点,不传会立即过期的 duration/countdown counter。 +- 要对状态变化做出反应,订阅全局广播(`LockStateSubscriber`)。绝不从"我这次调用是否成功"去推断状态。 + +## 状态同步 + +因为状态只存在于唯一一处(Agent),wrapper 保持同步的方式就是始终以它为准。wrapper 有两种形态,读取状态的方式不同: + +- **一次性面(one-shot)** —— CLI 的 `status`/`unlock`、AppleScript、Shortcuts(App Intents)、Services、URL action 与任何脚本。它们在运行的那一刻向 Agent 发问(`XPCClient.status()` 或 `lockStatusSnapshot()`)并据此行动。它们不缓存任何状态,因此**天生就是同步的**,不需要订阅。不要给一次性面加通知处理。 +- **长命的状态反映面** —— menu-bar App 等持续运行的 UI。它们必须既接收状态变化 signal,又在**变为可见 / 启动时校准** —— 因为进程被挂起期间可能错过某次广播。只显示布尔状态的现有 App 可以继续使用 `LockStateSubscriber`;需要起点、deadline 与 active settings 的界面在 signal 或重新可见时重新拉取 `lockStatusSnapshot()`。通知与本地缓存都不是真相源。 +- **系统托管的 snapshot 面** —— Widget、Control 等由系统按需启动的 provider。它们不假定进程常驻,也不安装长命 subscription;每次生成 timeline/value 时读取 `lockStatusSnapshot()`,把本地值只当 presentation cache。交互 action 发送明确的 desired state,不从 cache 做 client-side toggle;Agent 确认 mutation 后可以请求系统重新运行 provider。 + +`LockStateSubscriber` 把通知当作*提示*而非真相:订阅后的初始校准和收到的任一信号(Darwin 或 Distributed)都会向 Agent 拉取权威状态。多个并发信号被串行合并,相同状态被去重;subscription 取消后,尚未进入 handler 的在途结果会被丢弃。已经开始执行的 handler 不可被回溯撤销。Agent 之所以在两个通道都广播,只是为了让被挂起的 App 能被唤醒(Darwin)、让正在运行的 App 能及时更新(Distributed)。通知的载荷永远不是真相源。 + +`SystemSurfaces` 的 reload request 是 presentation invalidation,不是领域状态广播:它不携带状态、不触碰 Agent,只请求 WidgetKit / ControlCenter 重新执行 provider。主 App、Widget 与 Focus extension 可以在 Agent 确认 mutation 后请求刷新;主 App 运行时还把已经由 `LockStateSubscriber` 回拉并去重的外部变化桥接为刷新提示。CLI 与 Agent 不链接 presentation module——这不只是边界约定:实测(macOS 26)`chronod` 会把非 extension 容器进程的 reload 请求按 `Ignoring restricted or unknown extension` 忽略,因此 `klock` 即使链接 `SystemSurfaces` 也无法真正触发刷新。主 App 未运行时,CLI、热键或 event-tap failure 造成的变化只能等待 WidgetKit 的下一次 provider execution;系统始终可以合并或延后 reload,因此任何 wrapper 都不得承诺即时 UI 同步。 + +`klock lock` 先发出 atomic interactive lock request。只有 outcome 为 acquired 时,它才等待并报告后续解锁;本轮 event tap 会把 `Ctrl+C` 当作额外解锁手势并在 Agent 内消费该事件。若 outcome 为 already locked,CLI 必须说明本命令没有创建新锁并立即退出,不能把 App 或另一个 CLI 已建立的锁变成自己的可取消 session。acquired 后的等待同时使用 `LockStateSubscriber` 获得及时更新,并周期性查询 `status()` 以恢复双通道通知都丢失或 Agent 重启的场景;连续无法取得权威状态时必须报错退出,不能把 transport failure 猜成 unlocked。 + +`klock lock --no-wait` 是独立的 one-shot desired-state action:它调用幂等 `lock()`,在 Agent 确认 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待后续 unlock。它同样不拥有 lock;之后执行的 `klock unlock` 会解开全局锁,而不是只撤销某个脚本创建的锁。 + +## 新增 wrapper 的规则(Widget、Shortcut、AppleScript……) + +编写 wrapper 之前,逐条确认以下几点: + +1. **只 import `Client`,绝不 import `Service`。** wrapper 只通过 XPC 客户端与核心通信。把 `Service`(LockEngine、event tap)引入 wrapper 是违约。 +2. **不新增领域逻辑。** 如果 wrapper 需要核心尚未暴露的行为,先把它加到 Agent + `KeyboardLockerServiceProtocol`,再调用;不要在 wrapper 侧实现。 +3. **不持有独立状态。** 没有私有 `UserDefaults`,没有本地锁标志。设置和锁状态都从核心读取。 +4. **不广播业务状态。** 只有 Agent 发出状态变化 signal;wrapper 不得发送带 payload 的状态通知或把本地值当真相。containing App / extension 在 Agent 确认 mutation 后可以通过 `SystemSurfaces` 请求系统刷新 Widget / Control,但该请求只是无状态 presentation hint。 +5. **Agent 不可达时诚实降级。** 每个 wrapper 都必须处理"核心不可达"(见下方 Agent 生命周期要求),而不是假定调用成功。 +6. **按自身形态读取状态。** 判断这个 wrapper 属于"状态同步"里的哪一种形态:一次性面直接 query,**不加**任何通知处理;长命 UI 订阅 signal 并重新 query;系统托管的 snapshot provider 每次 execution 读取 `lockStatusSnapshot()`,交互 action 则发送 explicit desired state。 -## Global Lock Semantics +## Agent 生命周期要求 -There is one physical keyboard, so lock state is a **single global boolean owned by the Agent**. There is no per-client or per-session ownership of the lock. +即使 App 没有运行,wrapper 也依赖 Agent 可达。Agent 必须向系统注册(`SMAppService`),以便 `launchd` 能按需为任何 XPC 客户端拉起它。假定 Agent 已经在运行(例如"因为 App 恰好开着")的 wrapper 是错误的。 -- Any wrapper may lock; any wrapper may unlock. A lock started by the CLI is unlockable by the App, and vice versa. -- Lock operations are **stateless one-off XPC calls** (`lock` / `unlock` / `status`), symmetric with each other. Do not model the lock as a "session" a client "owns" — a wrapper's connection lifetime is unrelated to the lock's lifetime. -- To react to state changes, subscribe to the global broadcast (`LockStateSubscriber`). Never infer state from "did my call succeed". +App 必须区分 `SMAppService` 的 enabled、requires-approval、not-found 和 registration-failure 状态。只有 enabled 才继续查询 Agent;requires-approval 必须提供 Login Items 恢复入口。Agent 已 enabled 但 XPC 失败属于不可达,不能伪装成未锁定或缺少 Accessibility 权限。`enabled` 只表示系统允许该 service 运行,不证明当前进程就是 App 内 bundled 版本。 -## Rules for Adding a New Wrapper (Widget, Shortcut, AppleScript, …) +App 在调用新能力前必须先读取 `ServiceDescriptor`,分别验证 XPC protocol major/minor、required capabilities 和 bundled Agent 的 identifier/version/build。descriptor 中的身份字段只用于兼容性与更新判断,不能代替 XPC connection 的代码签名认证。descriptor 必须在 fresh connection 上重试后才能降级;若两次 descriptor 都失败而旧 `status` 成功,只能断言 base contract 可达,不能断言远端一定是 legacy Agent。此时只允许使用旧 `status` / `unlock` 做显式安全迁移,不得继续调用新 selector。 -Before writing a wrapper, confirm all of the following: +XPC peer authentication 必须由系统双向执行。Agent Listener 在 activate 前安装同 Team + 每个 wrapper 精确 signing identifier 的 connection requirement;wrapper connection 在 activate 前安装同 Team + 精确 Agent signing identifier 的 requirement。WidgetKit extension 使用单独的 `io.lzhlovesjyq.keyboardlocker.widgets` 身份承载 Widget 与 Control;Focus App Intents extension 使用 `io.lzhlovesjyq.keyboardlocker.focus-intents`;两者保持 App Sandbox,并只通过 Mach lookup temporary exception 访问 KeyboardLocker Agent 的 global service。Services 与 URL event 在主 App 进程内执行,因此 XPC peer 仍是主 App;这只认证 KeyboardLocker,不认证最初触发 Service 或 custom URL 的外部 caller。Debug 不得降级成 identifier-only,不能通过 PID 后查静态签名来代替 XPC runtime 的 requirement。Team ID 从各进程自身已验证的签名读取;unsigned/ad-hoc 进程 fail closed。 -1. **Imports `Client`, never `Service`.** Wrappers talk to the core through the XPC client only. Importing `Service` (LockEngine, event tap) into a wrapper is a contract violation. -2. **Adds no new domain logic.** If the wrapper needs behavior the core doesn't expose, add it to the Agent + `KeyboardLockerServiceProtocol` first, then call it. Do not implement it wrapper-side. -3. **Holds no independent state.** No private `UserDefaults`, no local lock flag. Read settings and lock state from the core. -4. **Emits nothing to other processes.** Only the Agent broadcasts state. -5. **Degrades honestly when the Agent is unavailable.** Every wrapper must handle "core not reachable" (see the Agent lifecycle requirement below) rather than assuming success. +capability grant 必须绑定到读取 descriptor 的同一条 client-side connection generation。所有 optional method 都要在一条具体 connection 上完成 handshake 与 capability check,再经同一个 `NSXPCConnection` 发出;connection 失效就同时失去 grant。named connection object 仍可能在 interruption 后面对新进程,因此 replacement wire request 必须携带 expected `agentInstanceID`,由 Agent 在副作用前原子验证,Client 也必须验证返回 ticket。 -## Agent Lifecycle Requirement +Agent 更新必须遵守以下边界: -Wrappers depend on the Agent being reachable even when the App is not running. The Agent MUST be registered with the system (`SMAppService`) so `launchd` can launch it on demand for any XPC client. A wrapper that assumes the Agent is already running (e.g. because the App happens to be open) is incorrect. +- 当前锁为 locked 时绝不自动退出 Agent,因为进程退出会释放其 event tap;UI 必须明确说明并由用户选择 `Unlock and Replace Agent`。 +- unlocked 也不能仅凭一次 `status()` 就自动替换,因为另一个 wrapper 可能随后重新 lock。只有 identifier 与 protocol major 相同、bundled `CFBundleVersion` 可比较且严格更高、并且 Agent 同时声明 prepared 与 committed-drain capability 时,App 才可自动更新。运行中 Agent build 更高时绝不自动降级。 +- `prepareForReplacement` 必须在 Agent 的串行执行边界安装短期 fail-safe barrier,再按策略解锁并返回独占 ownership ticket。Agent 必须拒绝第二个 prepare,不能覆盖 ticket 或转移 cancellation 权限。 +- legacy Agent、major 不兼容、身份不匹配或缺少 replacement capability 的 Agent 只能通过明确用户动作更新。无法建立 drain 时 UI 必须说明 replacement 窗口中的新 lock 可能被进程退出释放。 +- 替换顺序固定为 prepare/unlock → 幂等 commit 为不可取消、不可过期的 drain → 仅在 commit reply 成功后提交并等待 `SMAppService.unregister()` → 注册 bundled Agent → 失效旧 Client connection → 建立新 connection → 重新读取 descriptor。新 descriptor 必须兼容且 `agentInstanceID` 必须变化;任何失败都停止。每个 App 进程对同一 bundled build 最多自动尝试一次,不得形成 restart loop。 +- prepared drain 可以在 commit 前由 owner cancel 或短期 expire;committed drain 只能随旧 Agent 进程退出清除,绝不能用 heartbeat loss 或固定 timeout 推断在途 unregister 已取消。commit reply 丢失时由 exact ticket 的 `replacementStatus` 恢复真实结果。其他 App 通过 additive `replacementPhase` 解释 UI,只能周期性重新握手,不得接管、cancel 或再次 unregister。commit 后 coordinator crash 的极端窗口选择 fail closed;若长期未恢复,需要重启 macOS,而不能冒险让第二次 unregister 释放后来建立的活动锁。 -## Contract Violations to Watch For +## 需要警惕的违约 -These are the concrete failure modes this contract exists to prevent: +以下是本契约要防范的具体失败模式: -- A wrapper holding its own `KeyboardLockerSettingsStore` / `UserDefaults` → settings drift from the core (the Agent would act on stale or default settings). -- A "session" abstraction implying a client owns the lock → confusing behavior where one surface can't unlock what another locked. -- Duplicated lock/settings logic across App and CLI → the exact maintenance explosion the DRY rule forbids. -- A wrapper importing `Service` to "just call the engine directly" → bypasses the single core. +- wrapper 自己持有 `KeyboardLockerSettingsStore` / `UserDefaults` → 设置与核心漂移(Agent 会基于过期或默认设置行动)。 +- wrapper 在设置 payload 缺失或损坏时回退 `.default` → wrapper 凭空制造第二份“当前设置”,展示的解锁方式可能与 Agent 实际执行不一致。 +- 引入"会话"抽象、暗示客户端拥有锁 → 造成"某个面无法解开另一个面锁上的锁"这种迷惑行为。 +- 锁/设置逻辑在 App 与 CLI 之间重复 → 正是 DRY 规则要禁止的维护爆炸。 +- wrapper 为了"直接调引擎"而 import `Service` → 绕过了单核。 +- 一次性面(CLI/AppleScript/Shortcuts)订阅广播,或在多次调用之间缓存锁状态 → 徒增复杂度并制造新的漂移源;它运行时直接调 `status()` 即可。 +- 把通知载荷当作状态、而不去拉取 `status()` → 过期/乱序/丢失的通知会悄悄让 UI 失去同步。 diff --git a/docs/automation.md b/docs/automation.md new file mode 100644 index 0000000..135cbfd --- /dev/null +++ b/docs/automation.md @@ -0,0 +1,154 @@ +# 自动化 + +KeyboardLocker 的 Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 与 Control 都是同一个 Agent capability 的薄 wrapper;Notifications 由 Agent 进程自己发布(见下文)。首次使用任一入口前,先启动一次 KeyboardLocker App 或运行 `klock register-agent`,让 App 注册后台 Agent。 + +所有入口共享以下语义: + +- `lock` 与 `unlock` 是幂等的 desired-state action。 +- `lock` 会消费 macOS 交付给 Agent event tap 的标准按键和键盘 system control,包括音量、亮度、播放控制、eject 与 power;鼠标和触控板输入继续可用。macOS 没有交付给 event tap 的硬件或系统保留路径不在可保证范围内。 +- `status` 每次都从 Agent 查询权威状态;wrapper 不维护本地副本。 +- lock 是全局状态,不属于发起它的 Shortcut、AppleScript 或 shell process。任意普通入口的显式 `unlock` 都会解开同一个全局锁。Focus Filter 的关闭事件使用更窄的条件释放规则,只解除它自己创建且未被后续显式 `lock` 接管的锁。 +- `toggle` 在 Agent 的串行执行边界内原子翻转全局锁并返回翻转后的状态;它不是 wrapper 先读 `status()` 再决定方向的 client-side 组合,因此不存在并发竞态。自动化需要确定终态时,仍应显式选择 `lock` 或 `unlock`。 +- Agent 不可达、权限不足或结果无法确认时,action wrapper 会明确失败,Widget 会显示 unavailable;任何入口都不会把失败猜成 unlocked。 + +## Shortcuts + +KeyboardLocker 提供四个可组合 action: + +- `Lock Keyboard` +- `Unlock Keyboard` +- `Toggle Keyboard Lock` +- `Get Keyboard Lock Status` + +`Toggle Keyboard Lock` 把翻转交给 Agent 原子执行,并返回翻转后的 Boolean;`Get Keyboard Lock Status` 返回当前权威 Boolean,两者都可直接接入 Shortcuts 的条件分支。 + +macOS 26 及以上,应用声明了 `AppShortcutsProvider`,为 lock / unlock / toggle 注册 promoted App Shortcuts,无需手动配置即可从 Spotlight、Quick Keys 与 Siri 调用。更早的 macOS 版本不生成 promoted App Shortcuts 或 invocation phrase,这些 action 从 Shortcuts 的 action library 中选择。 + +## Focus Filter + +macOS 13 及以上可在 **System Settings > Focus** 中为某个 Focus 添加 KeyboardLocker 的 `Keyboard Lock` filter。启用该 Focus 时,系统把 `Lock Keyboard = true` 发送给独立的 App Intents extension,触发一次 lock acquisition;关闭时会用参数默认值 `false` 再次执行 intent,对该次 activation 创建的 generation 做条件清理。因此主 App 未运行时,系统交付的 Focus 生命周期事件仍能经 XPC 到达 Agent。 + +Focus Filter 是 activation-triggered wrapper,不是“只要 Focus active 就持续保持 locked”的 policy。它采用条件 ownership,不会把全局 lock 误当成自己的资源: + +- 若 Focus 启用时已经由其他入口锁定,它不会认领该锁;Focus 关闭时保持 locked。 +- 若 Focus 从 unlocked 创建锁,它只拥有该次 activation 创建的一个 lock generation;使用 Agent 当前 settings,包括现有 auto-unlock policy。 +- 若随后任一普通 wrapper 再次显式调用 `lock`,该用户意图会接管同一 generation 的持久性,但不会重建 event tap、改变活动设置或延长 auto-unlock deadline;Focus 关闭时保持 locked。 +- 显式 unlock、解锁热键、auto-unlock timeout、event-tap failure 或 Agent 退出 / 重启都可以在 Focus 仍 active 时提前结束原 generation;迟到的 Focus 关闭事件不能解除之后新建的锁。 +- generation 提前结束后,KeyboardLocker 不会因为同一个 Focus 仍 active 而自动 relock;Agent 重启也不会查询或 replay 当前 Focus。需要重新触发时,切换 Focus 或从任一普通 wrapper 显式执行 `lock`。 + +Focus extension 保持 sandbox,只获得访问 KeyboardLocker Agent Mach service 的 lookup 权限;Agent 仍要求同 Team 与精确 extension signing identifier。Agent 或 Accessibility 不可用时,intent 会明确失败,不会伪造 Focus 已经应用。 + +## Services + +KeyboardLocker 在 macOS 的 **Services** 菜单注册三个不依赖当前选中文本的原生 action: + +- `Lock Keyboard` +- `Unlock Keyboard` +- `Show Keyboard Lock Status` + +它们可以从其他 App 的 Services 菜单调用,也可以在 **System Settings > Keyboard > Keyboard Shortcuts > Services** 中绑定全局快捷键。三个入口都交给同一个串行 executor:lock/unlock 直接发送 desired state,status 从 Agent 读取权威 Boolean 后显示提示;并发到达的 action 按接收顺序执行。 + +handler 会在有界超时(默认 15s,覆盖 XPC 应答窗口加一次幂等重试)内同步等待串行 executor 的结果:失败经 AppKit Services 的 error out-pointer 回传调用方 App,超时则报告"请求未完成、可能仍会完成";KeyboardLocker 内的失败 alert 同时保留。error 通道只承载失败文本,status 的权威结果仍只在 KeyboardLocker 内提示;需要 machine-readable status 时使用 AppleScript、Shortcuts action 或 `klock status --json`。 + +## URL Scheme + +Launcher、浏览器或其他本地 App 可以打开三个 canonical deep link: + +```text +keyboardlocker://lock +keyboardlocker://unlock +keyboardlocker://status +``` + +Parser 对 scheme 与 host 使用 URL 规定的大小写不敏感比较,但只接受上述 `scheme://host` 结构。userinfo、port、path(包括尾随 `/`)、query、fragment、percent-encoded action 与未知 host 都会被拒绝;错误提示不会回显原始 URL,避免意外暴露调用方放入 query 的数据。一次收到多个 URL 时按交付顺序串行执行。 + +`lock` / `unlock` 成功时静默完成;`status` 读取 Agent 的权威 Boolean 并显示 alert。custom URL scheme 没有结果返回通道,因此 machine-readable 查询仍使用 `klock status --json`。 + +安全边界必须明确:macOS custom URL scheme 不提供 caller authentication,任何能请求系统打开 URL 的本地 App 或网页都可能触发这些 action。Agent 只会看到经过签名认证的 KeyboardLocker App,无法识别最初调用方;严格 parsing 解决的是输入歧义,不是授权。不要在 query 中加入 token 来伪装鉴权。当前实现把这个 unauthenticated convenience surface 作为公开 contract,包括 `unlock`。 + +## Widget + +`Keyboard Lock Status` Widget 支持 small 和 medium family。每次生成 timeline 时,extension 都通过 XPC 读取 Agent 的 `LockStatusSnapshot`,并显示 locked/unlocked、auto-unlock countdown 或 manual unlock,以及当前解锁热键。它不使用 App Group、`UserDefaults` 或自己的状态缓存;Agent 不可达时会明确显示 `Agent Unavailable`。 + +macOS 14 及以上的 Widget 提供 `Lock` / `Unlock` 按钮。按钮把当前展示的 snapshot 翻译成明确的 desired state:`Lock` 只调用幂等 `lock`,`Unlock` 只调用幂等 `unlock`,不会先重新读取状态再执行 client-side toggle。即使用户点击时 snapshot 已过期,action 也不会被翻译成相反方向。Agent 确认成功后才请求刷新 timeline;调用失败会由 App Intent 明确返回错误,不做 optimistic update。macOS 13 上保持只读。 + +Widget 使用 15 分钟 regular fallback;若 auto-unlock deadline 更早,则请求在 deadline 后立即校准。主 App、Shortcuts、AppleScript、Services、URL、Focus、Widget 与 Control 在 Agent 确认 mutation 后都会请求刷新状态 Widget 和可用的 Control;主 App 运行时,其权威状态订阅还会把 CLI、auto-unlock、解锁热键与 event-tap fail-open 的变化桥接成 presentation reload。主 App 未运行时,CLI、热键或 event-tap failure 只能等待下一次 WidgetKit provider execution——CLI 自身无法请求刷新:实测(macOS 26)`chronod` 会把非 extension 容器进程的 reload 请求按 `Ignoring restricted or unknown extension` 忽略。timeline 与显式 reload 都只是系统调度提示;WidgetKit 可以根据刷新预算合并或延后执行,因此 Widget 是系统级状态概览,不是实时监视器。倒计时文本仍由系统持续渲染。 + +## Control + +macOS 26 及以上提供 `Keyboard Lock` Control。Control 的 value provider 每次经 XPC 查询 Agent 的权威 Boolean;用户操作产生的是明确的 desired state:on 调用 `lock`,off 调用 `unlock`。它不会先读取旧值再做 client-side toggle,因此并发状态变化不会把一次操作翻译成错误方向。 + +Agent 确认 action 成功后,extension 会请求刷新 `Keyboard Lock Status` Widget timeline 和 Control value。调用失败会由 App Intent 明确返回错误,也不会先刷新出未经确认的 UI 状态。Control 没有 extension 自绘的 unavailable 状态:value provider 失败时的呈现由系统决定,与 Widget 显式的 `Agent Unavailable` 布局不同。Control API 在当前 macOS SDK 中从 macOS 26 起可用;macOS 13–15 不注册 Control,其中 macOS 14–15 的状态 Widget 可交互,macOS 13 的状态 Widget 只读。 + +## Notifications + +任一入口使键盘进入 locked 时,**Agent 进程自己**发布一条 `Keyboard Locked` 通知:内容直接来自 Agent 持有的 active settings 与 auto-unlock deadline,展示当前解锁热键,并在 timed auto-unlock policy 下附带截止时间。通知使用固定 identifier,重复锁定只替换内容,不会堆叠;任一入口解开同一个全局锁——显式 unlock、解锁热键、auto-unlock timeout 或 event-tap fail-open——通知都随同一次状态转换移除。因为投递/移除与锁状态转换发生在同一进程,通知不会比锁活得更久,与哪个 wrapper 在运行无关;若 Agent 在 locked 时退出(event tap 随进程释放),残留通知由下一次 Agent 启动时清除。 + +通知携带 `Unlock Now` 操作按钮。键盘被锁时鼠标与触控板仍然可用,点击按钮由 Agent 本地执行幂等 `unlock`,不需要拉起任何 App。 + +通知是 presentation-only 的便利面:首次锁定时向系统请求通知权限,被拒绝或未授予时不发送、不报错;通知内容永远不是状态源。通知以 Agent 的 bundle 身份发布,在系统设置中显示为 KeyboardLocker(display name 与主 App 对齐)。 + +因此权限被拒时,按设计静默的入口(Services、URL、AppleScript 的 `lock` / `unlock`)没有任何用户可见的成功反馈。需要确认结果的调用方应使用 `klock status --json`、Shortcuts 返回值或 AppleScript 返回值。 + +## AppleScript + +应用 scripting dictionary 提供三个 command: + +```applescript +tell application "KeyboardLocker" + lock keyboard + set isLocked to get keyboard lock status + unlock keyboard + return isLocked +end tell +``` + +开发构建可能与已安装版本使用相同 bundle identifier。验证特定 build 时,用绝对 app path 避免 Launch Services 命中另一份应用: + +```applescript +tell application "/absolute/path/to/KeyboardLocker.app" + get keyboard lock status +end tell +``` + +命令会等待异步 Agent 调用完成后再回复 Apple event。只有原始 `status()` 查询(如 `get keyboard lock status`)的 XPC timeout 返回 Apple event timeout (`-1712`);`lock keyboard` / `unlock keyboard` 在 Client 内会先做一次幂等重试,最终超时以 outcome unknown 归入 Apple event failed (`-10000`),其余 Agent 调用失败同样返回 `-10000`,并保留 Client 提供的恢复建议。 + +## CLI + +交互式锁定会等待后续 unlock。只有本次命令真正创建新锁时,Agent 才为该轮锁临时启用 `Ctrl+C` 解锁: + +```bash +klock lock +``` + +等待期间进程被终止(关闭终端窗口的 `SIGHUP`、`kill` 的 `SIGTERM`)时,`klock` 会在退出前尽力释放它创建的这轮锁(有界等待,随后无论如何退出)。`kill -9` 无法被捕获,此时遗留的锁仍由解锁热键、通知的 Unlock Now 或 auto-unlock 解开。该清理只适用于真正完成 `unlocked → locked` 转换的调用;`Already locked` 退出的命令不触碰既有锁。清理是不带 generation 标记的尽力释放:等待期间若本轮锁已被解开、另一入口随后重新锁定,退出清理会解开这个较新的锁——这与全局锁契约一致,但需要严格 ownership 语义时不应依赖它(见文末 lease/session 说明)。 + +自动化应使用 one-shot 模式。它在 Agent 确认 locked 后立即退出,不会启用 `Ctrl+C`,也不参与终止清理: + +```bash +klock lock --no-wait +``` + +其他命令: + +```bash +klock status +klock status --json +klock status --snapshot +klock unlock +klock toggle +klock register-agent +klock request-access +``` + +`toggle` 在 Agent 串行边界内原子翻转并打印翻转后的权威状态(与 Shortcuts 的 `Toggle Keyboard Lock` 同一 wire method);老 Agent 无 toggle capability 时命令明确报错,不做 client-side 方向合成。 + +`request-access` 先报告已授权状态;未授权时经 Agent 触发系统 Accessibility prompt,随后有界轮询(默认 30s)并如实报告终态:授权打印 granted 退出 0,窗口内未授权打印 pending 说明退出 1(stdout 文本,区别于 stderr 的 Error)。prompt 是异步的,请求返回不代表已授权。 + +`status --snapshot` 输出扩展 JSON contract(`locked` / `startedAt` / `autoUnlockTargetDate` / `unlockHotkey`,键按字母序固定,日期为 ISO-8601 GMT,无值日期为 `null`);`--json` 的单行 contract 保持字节稳定。老 Agent 无 snapshot capability 时命令显式报错,不回退用 `status` 伪造字段。 + +`register-agent` 在 Agent 不可达时启动一次 KeyboardLocker App 来完成 `SMAppService` 注册(注册只能由 App bundle 执行),随后短暂轮询确认 Agent 可达;若出现 Login Items 批准或 Accessibility 授权要求,命令会指出对应的系统设置入口。Agent 已经可达时它不启动 App,直接报告。 + +`status` 默认输出供人阅读的 `Locked` 或 `Unlocked`。自动化使用 `status --json`,其稳定 contract 是单行 `{"locked":true}` 或 `{"locked":false}`。两种状态都表示查询成功并返回 exit code `0`;参数或 Agent 调用失败返回 `1`,因此脚本不需要把 unlocked 与 transport failure 混在同一个 exit code 中。 + +不要把 `lock --no-wait` / `unlock` 当成 acquire/release pair:若调用 `lock --no-wait` 时全局状态本来已经 locked,后续无条件执行 `unlock` 会解除其他 wrapper 建立的锁。需要这种 ownership 语义时,应先在 Agent contract 中设计明确的 lease/session capability,而不是在 shell 中模拟。 diff --git a/docs/development.md b/docs/development.md index f203906..15b8c04 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,59 +1,158 @@ -# Development Guide +# 开发指南 + +任务相关的工作流与组件参考。关于不可协商的设计规则,先读 [architecture.md](architecture.md) —— 它始终优先于本文档的任何内容。 + +## XPC 通信流程 + +本节是开发时的速查。若需要理解 `Core` 为什么被两侧引用、library 与进程的区别、Agent 注册、完整调用时序、connection 生命周期和失败语义,先读 [XPC 实现与使用指南](xpc.md)。 + +所有面都通过 `KeyboardLockerServiceProtocol` 与 Agent 通信(Mach 服务 `io.lzhlovesjyq.keyboardlocker.agent`)。 + +1. App 建立 connection 后先调用 `XPCClient.serviceDescriptor()`,验证 protocol version、required capabilities 和 bundled Agent build;只有兼容后才调用新增 selector。descriptor 在 fresh connection 上重试后仍失败、但旧 `status` 成功时,只能按 unverified base contract 处理,不能断言它一定是 legacy Agent。capability-gated Client API 会在同一条具体 connection generation 上重新握手,先验证 major 相同及该 selector 的引入 minor,再检查 capability 并发送 selector;connection 变化后不能复用旧 grant。 +2. wrapper(App/CLI/……)通过异步的 `XPCClient` 发出一次**无状态一次性调用**:`lock` / `unlock` / `status` / `lockStatusSnapshot` / `currentSettings`。App 的首次安全测试使用独立的 `beginSafetyCheck`,由 Agent 强制该轮锁在 10 秒后自动解锁。App 还经同一边界查询 Agent 的 Accessibility 状态,并可由明确用户动作请求 Agent 触发权限 prompt。失败会抛错(Agent 挂掉时表现为抛出的错误,绝不挂起)。 +3. Client 与 Listener 都在 activate 前安装同 Team + 精确 signing identifier 的 XPC requirement;系统完成双向认证后,Agent 的 `ServiceDelegate` 才接受连接并路由到 `AgentService`。 +4. Agent bootstrap 创建一个贯穿进程生命周期的 `AgentService`,它拥有设置真相源(`KeyboardLockerSettingsStore`,位于 `Service`)和唯一的 `LockEngine` 实例,并驱动 `lock(settings:allowsControlCUnlock:)` / `unlock()`;所有 listener connection 都接收同一个 service object。 +5. `LockEngine` 创建 CGEventTap,并在任何状态变化时调用 `LockStateBroadcaster.broadcast()`。 +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` 完成这件事(见下文)。 -Task-specific workflows and component reference. For the non-negotiable design rules, read [architecture.md](architecture.md) first — it always wins over anything here. +## 组件地图 -## XPC Communication Flow +只列出不那么显而易见的职责;签名请读源码。 -All surfaces talk to the Agent over `KeyboardLockerServiceProtocol` (Mach service `io.lzhlovesjyq.keyboardlocker.agent`). +**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。 +- `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` 快照。 +- `KeyCodeConverter.swift`:通过 `UCKeyTranslate` 做布局感知的 `CGKeyCode` → 快捷键字符串(⌃⌥⇧⌘ 顺序)。读取 **ASCII-capable keyboard layout**(`TISCopyCurrentASCIICapableKeyboardLayoutInputSource`)而非当前 input source——拼音等输入法不带 Unicode layout data,会把热键渲染成 `?`。TIS/TSM 查询统一串行到 main thread,避免并发测试或多个 wrapper 同时生成显示文本时触发 HIToolbox abort。 -1. A wrapper (App/CLI/…) sends a **stateless one-off call**: `lockKeyboard` / `unlockKeyboard` / `status` / `accessibilityStatus` / `requestAccessibilityPermission`. -2. The Agent's `ServiceDelegate` accepts the connection (after `XPCAccessControl` validates code signature + bundle ID) and routes to `AgentService`. -3. `AgentService` calls `LockEngine.shared.lock(settings:)` / `unlock()`. -4. `LockEngine` creates the CGEventTap and, on any state change, calls `LockStateBroadcaster.broadcast(isLocked:)`. -5. Wrappers observe state via `LockStateSubscriber.subscribe(_:)` (returns an `ObserverToken` that unsubscribes on deallocation) — never by inferring from "did my call succeed". +**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" 类型。 +- `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。 -> The lock is a single global boolean owned by the Agent. Prefer the stateless `XPCClient.status/unlock` calls and the broadcast subscription. `LockSessionController` still exists in `XPCClient.swift` but implies client-owned "session" semantics that conflict with the architecture contract; treat it as legacy pending cleanup, don't build new features on it. +**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(契约的真相源规则)。 +- `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` 确定性覆盖。 +- `AccessibilityManager.swift`(Agent 身份下的实时权限查询与 prompt 请求)、`XPCAccessControl.swift`(生成 Listener 的受信 Client requirement)、`XPCServerConnection.swift`。 -## Component Map +**SystemSurfaces**(`Core/Sources/SystemSurfaces/`)—— 仅 containing App、Widget 与 Focus extension 使用的 presentation adapter +- `LockStateSurfaceInvalidator.swift`:集中定义稳定的 Widget / Control kind,并把 `WidgetCenter` 与 macOS 26+ `ControlCenter` 包装成 nonthrowing reload request。它不 import `Client` / `Service`,不查询或携带锁状态,也不链接到 Agent / CLI;系统可以延后或合并请求,因此它不是状态广播或第二份真相源。(CLI 不链接还有实证依据:`chronod` 会忽略来自非 extension 容器进程的 reload 请求——`Ignoring restricted or unknown extension io.lzhlovesjyq.keyboardlocker.klock`,macOS 26 实测。) -Only the non-obvious responsibilities are listed; read the source for signatures. +**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 秒安全测试;菜单也始终保留手动入口。 +- `SafetyCheckExperienceStore.swift`:只持久化 App presentation 层的“安全测试已完成”标记,不存储或推导锁状态、settings 或 Accessibility 状态。只有 Agent 已创建测试锁、且 App 后续重新取得权威 unlocked 时才写入完成。 +- `KeyboardLockerDiagnostics.swift`:按用户动作实时收集 App/Agent 版本、protocol/capability、Service Management、Accessibility、权威 lock snapshot 与最近错误,生成可复制的稳定文本报告。报告不采集 keyboard input、username、hostname 或 file path;错误文本还会对 path-like token 做二次脱敏。单项查询失败会成为显式 unavailable/error 行,不阻断其余诊断。 +- `AppIntents/KeyboardLockAppIntents.swift`:可在 Shortcuts 中组合的 `Lock Keyboard`、`Unlock Keyboard`、`Toggle Keyboard Lock` 与返回 `Bool` 的 `Get Keyboard Lock Status` action。它们是 one-shot wrapper,每次执行只经 `AgentLockActionServing` 调用 Agent,不缓存状态、不订阅通知。`Toggle Keyboard Lock` 调用 Agent 串行边界内的原子 `toggle` 并返回翻转后的 `Bool`,不做 client-side 状态合成。`AppIntents/KeyboardLockerShortcuts.swift` 在 macOS 26+ 声明 `AppShortcutsProvider`,为 lock / unlock / toggle 注册 promoted App Shortcuts 与 invocation phrases;更早的 macOS 只从 Shortcuts action library 暴露这些 intent。 +- `AppleScript/KeyboardLockerScriptCommands.swift` / `KeyboardLocker.sdef`:向 Cocoa Scripting 暴露 `lock keyboard`、`unlock keyboard` 与 `get keyboard lock status`。命令先 suspend 当前 Apple event,异步调用同一个 `AgentLockActionServing`,再以结果或显式错误 resume;它们不持有本地状态,也不把 XPC 不可达猜成 unlocked。 +- `Automation/ExternalAutomationController.swift`:Services 与 URL event 共用的串行 one-shot executor。每个 action 只调用 `AgentLockActionServing` 的对应 desired-state/query method;跨多次 submit 也保持接收顺序,并把 authoritative status 或合并后的 failure 交给 presentation boundary。 +- `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 变化。 +- `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、锁或设置逻辑。 -**Common** (`Core/Sources/Common/`) — shared by all targets -- `Shared.swift`: `KeyboardLockerServiceProtocol`, `SharedConstants` (Mach name, bundle-ID allowlist, CLI constants), `NotificationNames.stateChanged`. -- `KeyboardLockerSettings.swift`: `KeyboardLockerSettings` (`autoUnlockPolicy` = `.disabled`/`.timed(seconds:)`, `unlockHotkey`, `showsUnlockNotification`) + `.default`. -- `KeyCodeConverter.swift`: layout-aware `CGKeyCode` → shortcut string (⌃⌥⇧⌘ order) via `UCKeyTranslate`. -- `SystemSettings.swift`: opens the Accessibility pane. +**WidgetKit extension**(`KeyboardLockerWidgets/`)—— sandboxed、按需运行的 Widget/Control wrapper;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces +- `KeyboardLockerControlModel.swift`:Widget/Control 共用的可测试纯协调模型。Control value loader 直接返回 Agent 查询结果;desired-state action 只在 XPC lock/unlock 成功后请求 reload,不做 client-side toggle。 +- `KeyboardLockerControl.swift`:macOS 26+ `Keyboard Lock` Control、XPC live adapters 与 `SetValueIntent`。on/off 分别映射到幂等 `lock`/`unlock`,成功后刷新 Control value 和状态 Widget timeline。 +- `KeyboardLockerWidgetAction.swift`:macOS 14+ 状态 Widget 的内部、不可发现 `AppIntent`;`Lock` / `Unlock` 映射到明确 desired state,成功后请求刷新 timeline,失败则原样传播。 +- `KeyboardLockerWidgetTimeline.swift`:每次 timeline execution 经 `XPCClient.lockStatusSnapshot()` 读取 Agent 的权威原子快照。loader 把 transport failure 建模为显式 unavailable entry,并请求 15 分钟 regular fallback 或更早的 auto-unlock deadline reconciliation;不订阅长命通知、不维护第二份状态。 +- `KeyboardLockerStatusWidget.swift`:small/medium 状态 presentation,显示 locked/unlocked、deadline、解锁热键与 Agent unavailable;macOS 14+ 提供 explicit desired-state action,macOS 13 保持只读。WidgetKit 可以合并 timeline policy,因此该 UI 不承诺实时刷新。 +- `KeyboardLockerWidgets.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent 仍以同 Team + 精确 extension identifier 的 listener requirement 独立认证调用方。 -**Client** (`Core/Sources/Client/`) — App/CLI, imports never `Service` -- `XPCClient.swift`: static `status` / `unlock` / `accessibilityStatus` / `requestAccessibilityPermission`. -- `LockStateSubscriber.swift`: distributed-notification subscription → `ObserverToken`. +**App Intents extension**(`KeyboardLockerFocusIntents/`)—— sandboxed、按需运行的 Focus Filter wrapper;领域操作只调用 Client,presentation refresh 只调用 SystemSurfaces +- `KeyboardLockFocusFilterIntent.swift`:macOS 13+ `SetFocusFilterIntent`;参数默认值为 `false`,使 Focus 关闭时向 Agent 发送该 activation generation 的条件 disable。`true` 是一次 activation-triggered acquisition,不是 while-active keep-alive;`perform()` 只调用 capability-gated `setFocusFilterLockEnabled`,成功后请求 system-surface reload,不以普通 `unlock` 模拟条件释放,也不在 Agent restart 后查询或 replay 当前 Focus。 +- `AppIntentsExtension.swift` / `Info.plist`:独立 `com.apple.appintents-extension` 入口,使主 App 未运行时系统仍可执行 Focus 生命周期事件。 +- `KeyboardLockerFocusIntents.entitlements`:保留 App Sandbox,只增加 Agent Mach service 的 global lookup temporary exception。Agent allowlist 只新增 `io.lzhlovesjyq.keyboardlocker.focus-intents` 精确 signing identifier。 -**Service** (`Core/Sources/Service/`) — Agent only -- `LockEngine.swift`: CGEventTap singleton, auto-unlock timer, hotkey detection, `OSAllocatedUnfairLock` state. -- `LockStateBroadcaster.swift`: posts Darwin (payload-free) + Distributed (with payload) notifications. -- `AccessibilityManager.swift`, `XPCAccessControl.swift` (release = signature + Team ID + allowlist; debug = allowlist only), `XPCServerConnection.swift`. +## 常见任务 -## Common Tasks +### Post-MVP:新增用户可编辑设置 +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(见架构契约)。 -### Add a setting -1. Add a `Codable`/`Sendable` property to `KeyboardLockerSettings` and update `.default`. -2. If the engine consumes it, read it in `LockEngine.lock(settings:)`. -3. SwiftUI binds to settings, but wrappers must **not** own a `KeyboardLockerSettingsStore` — the Agent is the source of truth (see architecture contract). Read/write through XPC. +### 新增一个 XPC 方法 +1. 先判断它是否 optional/additive。不得在同一 protocol major 内修改或移除既有 selector、参数顺序或 reply 形状;破坏性变化需要新 major,无法保留 selector union 时需要新 Mach service。 +2. 在 `Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 里加签名,同时在 `ServiceCapability` 增加一个从不复用的稳定名字,并按兼容需求更新 protocol minor / required capability。无法用 `@objc` 表达的值(如 `KeyboardLockerSettings`)以有大小上限的 JSON `Data` 跨界。 +3. 在 `Core/Sources/Service/AgentService.swift` 中实现它并在 descriptor 中声明 capability。 +4. 在 `Client/` 的 `XPCClient` 上加一个薄的异步封装;调用前必须完成 descriptor handshake 并检查 capability。 +5. 在 `Core/Tests/ClientTests/` 补充 old/future descriptor fixture、round-trip、build ordering 与兼容性测试;Server 状态机在 `Core/Tests/ServiceTests/` 覆盖 exclusivity、stale ticket/timer、cancel、expiry 与 committed fail-closed 语义。 -### Add an XPC method -1. Add the signature to `KeyboardLockerServiceProtocol` in `Common/Shared.swift`. -2. Implement it in `KeyboardLockerAgent/AgentService.swift`. -3. Add a thin `XPCClient` wrapper in `Client/` if a surface needs it. +### 修改事件过滤 +在 `LockEngine.handleEvent(type:event:)` 中:返回 `nil` 拦截、返回 `Unmanaged.passUnretained(event)` 放行,并在满足解锁条件(热键或超时)时调用 `unlock()`。`Hotkey.matches(keyCode:flags:)` 会通过 `relevantModifierMask` 过滤掉 CapsLock/NumLock。 -### Modify event filtering -In `LockEngine.handleEvent(proxy:type:event:)`: return `nil` to block, `Unmanaged.passUnretained(event)` to allow, and call `unlock()` when unlock conditions (hotkey or timeout) are met. `Hotkey.matches(keyCode:flags:)` filters CapsLock/NumLock via `relevantModifierMask`. +## 测试须知 -### CLI installation -`CLIInstaller` symlinks `/usr/local/bin/klock` → the CLI binary inside the app bundle (version consistency), prompting for admin via AppleScript. `install()`/`uninstall()` return `InstallResult` (`.success` / `.alreadyInstalled` / `.cancelled` / `.failed(Error)`); check `isInstalled` / `isCurrentVersionInstalled`. +`Core/Tests` 与 `KeyboardLockerModelTests` 统一使用 Swift Testing (`import Testing`, `@Suite`, `@Test`, `#expect` / `#require`);不要在新测试中重新引入 XCTest。现有 `swift test`、`xcodebuild test` 与 CI entrypoint 不变。 -## Testing Notes +### 在 Terminal 中使用开发版 `klock` -- `LockEngine` throws `.eventTapCreationFailed` without Accessibility permission. -- The Agent must be running/registered (`SMAppService`) for XPC calls to succeed. -- Engine operations dispatch to the main thread for CFRunLoop access. -- The system may disable the event tap (timeout/user input); `LockEngine` attempts re-enable. +开发阶段可用脚本安装、检查或移除指向 Debug App 内已签名 `klock` 的 symbolic link: + +```bash +./scripts/install-klock-dev.sh install +./scripts/install-klock-dev.sh status +./scripts/install-klock-dev.sh uninstall +``` + +默认目标是 `~/.local/bin/klock`,也可通过 `--bin-dir PATH` 或 `KLOCK_BIN_DIR` 指定其他用户可写目录。脚本会先验证 bundled executable 的 code signature、精确 signing identifier,以及 App、Agent、CLI 三者的 Team identifier 一致性;遇到已有文件或其他 link 时会拒绝覆盖。它不会复制 binary、修改 shell profile 或调用 `sudo`。若目标目录不在 `PATH`,脚本只打印需要添加的 `export PATH=...`,由用户决定写入哪个 shell 配置。 + +已构建的 App 也在 status menu 中提供 **Command Line Tool…**。该入口遵循同一所有权边界:只创建或移除指向当前 App bundle 的 link;需要配置 `PATH` 时仅提供可复制命令,不会静默修改 dotfile。 + +`klock` 自身不注册后台 Agent——`SMAppService` 注册只能由 App bundle 执行。首次使用前运行 `klock register-agent` 或至少启动一次 KeyboardLocker App;Agent 未注册时,CLI 各命令也会给出对应恢复提示。 + +`klock lock` 只有在本次请求原子创建全局锁时才进入等待,并提示 `Ctrl+C`。这个按键由 Agent event tap 识别后直接解锁,不依赖 Terminal 先收到被锁定输入并生成 `SIGINT`。等待期间进程收到 SIGTERM/SIGHUP/SIGINT 时,会在退出前尽力释放它创建的锁(GCD 信号源 + 有界等待;`KlockTerminationGuard` 为可注入缝),退出码保持 `128 + signal` 惯例;SIGKILL/SIGSTOP 不可捕获,遗留锁由全局解锁途径兜底。若 Agent 已被 App 或其他 CLI 锁定,命令会报告 `Already locked. This command did not create a new lock.` 后成功退出,不改变既有锁。自动化脚本应使用 `klock lock --no-wait`:它确认全局状态为 locked 后立即退出,不启用临时 `Ctrl+C` 手势,也不等待 unlock。 + +Shortcuts、Focus Filter、Services、URL Scheme、AppleScript、CLI、Widget 与 Control 的完整用法和跨 wrapper 语义见 [automation.md](automation.md)。 + +### Homebrew Cask 发布计划(尚未实现) + +正式分发计划采用 Homebrew Cask,而不是把 `klock` 作为独立 Formula 重新编译。Cask 应安装同一个经过 Developer ID 签名和 notarization 的 `KeyboardLocker.app`,并用 `binary` artifact 暴露 App bundle 内的 `Contents/MacOS/klock`;这样 App、CLI 与 Agent 保持同一版本和签名来源。 + +开始实现前需要先具备 versioned release artifact、稳定下载地址、SHA-256、Developer ID 签名及 notarization 验证。首个交付目标为项目自己的 tap,发布流程稳定且满足上游接收要求后,再评估提交到官方 Homebrew Cask 仓库。当前仓库尚未新增 Cask definition 或 release automation。 + +### 重置本地 Agent 注册 + +需要复现首次启动或清理调试注册状态时,运行: + +```bash +./scripts/reset-keyboardlocker.sh +``` + +脚本会构建 Debug App、退出正在运行的 KeyboardLocker、请求 Agent 解锁,然后由 App 自身通过 `SMAppService.unregister()` 移除自己的 LaunchAgent 注册,并确认对应的 `launchd` service 与 Agent 进程都已消失。下次启动 App 时会按首次启动路径重新注册 Agent。 + +该操作是应用范围的开发重置:不会调用影响其他应用的 `sfltool resetbtm`,也不会重置 Accessibility/TCC 权限或删除 Agent 持有的用户设置。 + +- 缺少 Accessibility 权限时,`LockEngine.lock` 抛出 `.accessibilityPermissionDenied`(在创建 event tap 之前检查)。 +- `requestAccessibilityPermission()` 只表示 Agent 已请求系统显示异步 prompt;用户操作完成后必须重新调用 `hasAccessibilityPermission()`。 +- XPC 调用要成功,Agent 必须正在运行 / 已注册(`SMAppService`),且 App、CLI 与 Agent 都必须使用同一 Apple Team 的项目签名。Debug 与 Release 都执行双向 XPC code-signing requirement;unsigned/ad-hoc 可执行文件即使复制 signing identifier 也会被拒绝。 +- `klock` target 必须保留 generated embedded Info.plist section;这是让实际 code-signing identifier 等于 `io.lzhlovesjyq.keyboardlocker.klock` 的载体。只设置 `PRODUCT_BUNDLE_IDENTIFIER` 而不嵌入 Info.plist 时,`codesign` 会退回裸名称 `klock`,Agent 会拒绝它。 +- `AgentService` 是 nonisolated XPC adapter;它把所有 Agent 可变状态与引擎操作切到 `MainActor`,以便在同一隔离域维护 CFRunLoop、timer、settings 和 replacement transaction。 +- 系统可能禁用 event tap(超时 / 用户输入);`LockEngine` 会尝试重新启用。若重新启用仍失败,会清理 tap/timer、切换为 unlocked 并广播,绝不继续报告虚假的 locked 状态。 +- App 协调器通过 protocol injection 与 presentation layer 解耦。`KeyboardLockerModelTests` 是 non-hosted test target,通过 fake Client/lifecycle/state observer 覆盖确定性协调流程,不触碰 live `SMAppService`/XPC。 +- 首次安全测试的 ModelTests 覆盖 Agent 成功创建后等待权威 unlocked 才完成、并发既有锁拒绝与 presentation invalidation;ServiceTests 覆盖固定 10 秒 override、沿用持久化热键、不写回 settings store 及 replacement drain 拒绝。真实 Quartz event tap、TCC prompt 和用户按键解锁仍属于真机手动验证边界。 + +### 覆盖边界与已知限制 + +测试覆盖有意停止在需要真实系统边界的层面;以下是当前接受的残余风险与实现特性,排查问题时先对照,不要重新"发现"它们: + +- `LockEngine` 的纯策略(event policy、schedule 计算、runtime state、tap 安装事务)与引擎语义(Accessibility 预检、tap 安装/拆除、auto-unlock 排程/触发/陈旧代际拒绝、热键与 Control-C 分发、tap-disable re-enable/fail-open、Focus 代际所有权)均已单测 —— 后者由 `LockEngineTests` 经注入缝(`InstalledEventTap`、`MainActorTimerScheduler`、权限/广播/时钟闭包)确定性驱动;剩余不可单测的只有 Quartz 对真实硬件事件的拦截本身(平台集成事实,靠真机验证)。 +- App 侧 `AgentReadinessCoordinator` / `AgentReplacementCoordinator` 已由 `KeyboardLockerModelTests` 经 fake client/lifecycle 覆盖(handshake 重试、unverified fallback、safe/forced 计划、prepare→commit→restart 顺序、cancel/redetection、Task 取消路径);forced 且无锁态可读的计划结构上不可能产生 `.failed`(该路径不存在任何可抛错调用)。`klock` 的参数矩阵与命令执行路径(未知命令、多余参数、already-locked 退出、stdout/stderr 路由与退出码、`register-agent` 的探测/启动/轮询路径、termination guard 的安装/取消矩阵与信号后解锁逻辑)已由 `KlockCommandLineTests` 覆盖;`version` 的执行路径依赖 `Bundle.main` 未测(其解析已覆盖),`KlockAppOpener` 的 bundle 定位与 `/usr/bin/open` 调用、live termination guard 的真实信号投递依赖真实进程环境,经手动验证(kill/kill -HUP 实测解锁与退出码)。 +- auto-unlock 定时器使用单调时钟(系统休眠期间暂停),而 snapshot 携带的 `autoUnlockTargetDate` 是墙钟时间:休眠跨过 deadline 时,锁会多保持约等于休眠时长的时间,直到 timer 触发并广播;wrapper 不得把 deadline 当作保证的解锁时刻,Widget 的 deadline reconciliation 只是提前校准提示。 +- `KeyboardLockerSettingsStore` 在本地持久化数据损坏时回退 `.default` 并记录 `os.Logger` 错误(仅 Agent 写该 key,风险有界;该路径已由 `ServiceTests` 覆盖);跨进程的 wire codec 保持严格显式失败,不受影响。 +- Client 任意调用的超时或 `waitUntilUnlocked` 取消会失效进程内**共享**的缓存 connection:并发的无关在途调用会收到一次 `serviceUnavailable`,下一次调用自愈,这不是 Agent 故障。 +- unsigned/ad-hoc fail-closed:requirement 对异类签名的拒绝已由 `XPCCodeSigningRequirementEvaluationTests` 经 `SecStaticCodeCheckValidity` 静态评估确定性覆盖(Apple 系统签名、ad-hoc、完全未签名三类 fixture 均被拒;`anchor apple` 对照证明评估管线本身正确),`XPCAccessControlTests` 按名验证 listener requirement 的 fail-closed 构造。端到端由 `scripts/verify-unsigned-client-refusal.sh` 承载:临时 control 使用本地 bundled `klock` 的 Team 与精确 identifier 签名,在 ad-hoc probe 前后读取同一个 `agentInstanceID`;只有 control 两次都可达同一 Agent generation 且 ad-hoc client 被拒绝才 PASS。Agent 不可达、发生 replacement 或无法取得本地签名 identity 都返回 exit 2 (`INDETERMINATE`),不会把 service unavailability 当作拒绝证据。该 runbook 需 locally team-signed build 和 GUI session,手动执行,不进 CI。 +- CI 由 `.github/workflows/ci.yml` 承载(macOS runner,依次跑 Core 测试、全量 build、`KeyboardLockerModelTests`)。runner 没有签名身份,app build 与 ModelTests 以 `CODE_SIGNING_ALLOWED=NO` 运行;因此签名认证的**运行时**行为不属于 CI 门禁,发布前仍需本地签名构建验证(签名拒绝的端到端验证用上一条的 runbook 脚本)。 diff --git a/docs/xpc.md b/docs/xpc.md new file mode 100644 index 0000000..19745bb --- /dev/null +++ b/docs/xpc.md @@ -0,0 +1,731 @@ +# XPC 实现与使用指南 + +本文解释 KeyboardLocker 当前的 XPC 实现:`Core` 为什么同时出现在 wrapper 和 Agent 的依赖图里、一次调用如何跨进程执行、锁状态如何同步,以及连接或 Agent 异常时应该如何理解系统状态。 + +三份文档各自只负责一个层次,避免形成重复的事实源: + +- [architecture.md](architecture.md):解释为什么必须是单 Agent、哪些架构边界不可违反。 +- 本文:解释 XPC 在当前实现里如何工作。 +- [development.md](development.md):提供组件索引和常见修改步骤。 + +## 先记住四个结论 + +1. **`Core` 不是进程,也不是后台服务。** 它只是一个本地 Swift Package,构建时向不同 executable 提供代码模块。 +2. **两侧共享的是协议和数据定义,不是内存或单例。** `Common` 会编译进 wrapper 和 Agent,但唯一的锁状态只存在于 Agent bootstrap 创建的进程生命周期 `AgentService` 所私有持有的那一个 `LockEngine` 实例中;所有 listener connection 共用这个 service object。 +3. **wrapper 与 Agent 没有互相直接调用 Swift 对象。** wrapper 取得的是 `KeyboardLockerServiceProtocol` 的远程 proxy;macOS 把方法参数和 reply 序列化后跨进程传输。 +4. **XPC connection 不拥有锁。** connection、App 或 CLI 退出后,锁仍由 Agent 持有;只有显式/自动 unlock、解锁热键或 Agent 退出才会释放 event tap。 + +源码中的几个名字处在不同抽象层,阅读时可以这样理解: + +| 名字 | 实际含义 | +|---|---| +| `Core` | 容纳四种 library product 的 Swift Package;不是“正在运行的核心” | +| `Common` | Client/Agent 两侧共同理解的 wire contract 与 value definitions | +| `Client` | wrapper 侧的 outgoing XPC adapter | +| `Service` | Agent 侧的 domain/runtime library;它本身不是进程 | +| `AgentService` | 把 wire method 转成 domain call 的 exported object | +| `KeyboardLockerAgent` | 真正运行 listener、持有 event tap 和全局状态的 executable/process | + +## 构建依赖和运行进程是两张不同的图 + +### 构建时:`Core` 提供四个 library product + +`Core/Package.swift` 定义四个独立 product: + +```text +Core +├── Common +├── Client -> Common +├── Service -> Common +└── SystemSurfaces + +KeyboardLocker -> Client -> Common +KeyboardLocker -> SystemSurfaces +klock -> Client -> Common +KeyboardLockerWidgets -> Client -> Common +KeyboardLockerWidgets -> SystemSurfaces +KeyboardLockerFocusIntents -> Client -> Common +KeyboardLockerFocusIntents -> SystemSurfaces +KeyboardLockerAgent -> Service -> Common +``` + +Xcode 会按 package 名把这些 dependency 都显示在 `Core` 下面,因此看起来像 App 和 Agent “都引用了 Core”。但 target 实际链接的是不同 product:主 App、Widget 与 Focus extension 链接 `Client` 和 `SystemSurfaces`,`klock` 只链接 `Client`,Agent 只链接 `Service`;没有 target 把整个 package 的全部代码一起引入。 + +它们的职责是: + +| Product | 被谁链接 | 内容 | 不包含什么 | +|---|---|---|---| +| `Common` | `Client`、`Service` | XPC 协议、Mach service 名、通知名、跨进程值类型 | connection、listener、锁状态、event tap | +| `Client` | App、CLI、Widget、Focus extension | `NSXPCConnection`、async 封装、状态订阅 | `LockEngine`、设置存储、Accessibility 执行 | +| `Service` | Agent | listener connection 配置、访问控制、锁引擎、设置真相源、状态广播 | App UI、CLI 命令、客户端 connection | +| `SystemSurfaces` | App、Widget、Focus extension | Widget / Control kind 与 nonthrowing presentation reload adapter | XPC、锁状态、状态广播、Agent 领域逻辑 | + +`Client/Exports.swift` 和 `Service/Exports.swift` 使用 `@_exported import Common`,所以 wrapper 的 XPC 代码只写 `import Client`、Agent 只写 `import Service`,也能看到 `Common` 中的类型。这只是 import 便利,不会让 wrapper 获得 `Service`,也不会让两边共享内存。 + +### 运行时:各 wrapper 与 Agent 是独立进程 + +```mermaid +flowchart LR + subgraph AppProcess["KeyboardLocker process"] + AppCoordinator["AppCoordinator"] --> AppClient["XPCClient"] + end + + subgraph CLIProcess["klock process"] + CLI["Command handler"] --> CLIClient["XPCClient"] + end + + subgraph WidgetProcess["WidgetKit extension process"] + Timeline["Timeline provider"] --> WidgetClient["XPCClient"] + end + + subgraph FocusProcess["App Intents extension process"] + Focus["Focus Filter intent"] --> FocusClient["XPCClient"] + end + + AppClient -->|"NSXPCConnection"| Mach["Mach service"] + CLIClient -->|"NSXPCConnection"| Mach + WidgetClient -->|"NSXPCConnection"| Mach + FocusClient -->|"NSXPCConnection"| Mach + subgraph AgentProcess["KeyboardLockerAgent process"] + Listener["NSXPCListener"] --> AgentService["AgentService"] + AgentService --> Engine["one LockEngine instance"] + AgentService --> Settings["Settings store"] + AgentService --> Accessibility["Accessibility APIs"] + end + + Launchd["launchd"] -->|"starts on demand"| Listener + Mach --> Listener +``` + +`Core` 不会作为额外进程出现。它的代码分别被链接进上图中的 executable: + +- App、按需启动的 WidgetKit extension 与 Focus App Intents extension 各自包含一份 `Client + Common + SystemSurfaces` 代码;CLI 只包含 `Client + Common`。 +- Agent 包含一份 `Service + Common` 代码。 +- 只有 Agent 的 executable 包含 `LockEngine`,因此 App/CLI 不可能绕过 XPC 直接操作 event tap。 + +## `Common` 为什么必须同时存在于两侧 + +XPC 两侧必须对“线上消息长什么样”达成一致。当前契约定义在 `Common/Shared.swift`: + +```swift +@objc(KeyboardLockerServiceProtocol) +public protocol KeyboardLockerServiceProtocol { + func serviceDescriptor(reply: @escaping (Data?, Error?) -> Void) + func lockKeyboard(reply: @escaping (Error?) -> Void) + func lockKeyboardInteractively( + reply: @escaping (_ didAcquireLock: Bool, _ error: Error?) -> Void + ) + func setFocusFilterLockEnabled( + _ enabled: Bool, + reply: @escaping (Error?) -> Void + ) + func unlockKeyboard(reply: @escaping (Error?) -> Void) + func status(reply: @escaping (Bool, Error?) -> Void) + func toggleKeyboard(reply: @escaping (_ isLocked: Bool, _ error: Error?) -> Void) + func lockStatusSnapshot(reply: @escaping (Data?, Error?) -> Void) + func prepareForReplacement( + unlockIfNeeded: Bool, + expectedAgentInstanceID: UUID, + reply: @escaping (Data?, Error?) -> Void + ) + func commitReplacement( + ticket: Data, + reply: @escaping (Error?) -> Void + ) + func replacementStatus( + ticket: Data, + reply: @escaping (Data?, Error?) -> Void + ) + func cancelReplacementPreparation( + ticket: Data, + reply: @escaping (Error?) -> Void + ) + func hasAccessibilityPermission(reply: @escaping (Bool) -> Void) + func requestAccessibilityPermission(reply: @escaping (Error?) -> Void) + func currentSettings(reply: @escaping (Data?) -> Void) + func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void) +} +``` + +同一个 protocol 在两侧承担不同角色: + +- Client 侧把它交给 `NSXPCInterface`,说明远端 proxy 允许调用哪些 selector。 +- Agent 侧由 `AgentService` conform,提供 selector 的真实实现。 + +这和 HTTP 的 client/server 共同依赖同一份 OpenAPI schema 类似:共享的是消息契约,不是业务实例。 + +协议使用 `@objc`,因为 `NSXPCInterface` 基于 Objective-C runtime 描述方法。跨边界参数需要是 XPC 可序列化类型;`ServiceDescriptor`、`LockStatusSnapshot` 和设置模型都是 Swift `Codable` struct,所以先编码成有大小上限的 JSON `Data`,到另一侧再解码,而不是把 Swift struct 直接放进 protocol。 + +## Agent 如何被系统找到 + +Release App 的关键 bundle layout 是: + +```text +KeyboardLocker.app +└── Contents + ├── Library + │ ├── LaunchAgents + │ │ └── io.lzhlovesjyq.keyboardlocker.agent.plist + │ └── LoginItems + │ └── KeyboardLockerAgent.app + └── MacOS + ├── KeyboardLocker + └── klock +``` + +启动链路如下: + +1. App 的 `AgentRegistrar` 使用 `SMAppService.agent(plistName:)` 注册 bundled Agent。 +2. plist 的 `MachServices` 声明 `io.lzhlovesjyq.keyboardlocker.agent`。 +3. 注册成功后,`launchd` 管理 Agent 生命周期。 +4. App 或 CLI 向这个 Mach service 发送第一条 XPC message 时,如果 Agent 尚未运行,`launchd` 可以按需启动它。仅创建或 activate connection 不等于 Agent 已经启动。 +5. Agent 在 `main.swift` 中创建同名 `NSXPCListener` 并开始接收连接。 + +这里有两个不同的 readiness 条件: + +- `SMAppService.Status.enabled`:系统允许这个 Agent 运行。 +- XPC 调用成功:当前 Agent executable 确实启动、接受连接并理解当前 protocol。 + +前者不是后者的充分条件。例如 App 更新后,系统中可能仍运行旧 Agent;它虽然是 enabled,却不认识新加入的 selector。App 必须通过下方的 descriptor handshake 判断运行中进程,不能从 `SMAppService.Status` 推断版本。 + +`klock` 自己不注册 Agent。它依赖完整 App 已经完成注册;一旦注册,App 是否仍在运行不影响 CLI 连接 Agent。 + +## Bootstrap handshake、兼容性和 Agent 更新 + +`serviceDescriptor` 是 feature method 之前的稳定 bootstrap selector。Agent 每次进程启动生成一次 descriptor: + +| 字段 | 用途 | +|---|---| +| `protocolVersion.major/minor` | major 必须相等;App 完整兼容性检查要求 running minor 不低于 Client contract,每个 feature call 另要求 running minor 不低于对应 selector 的引入版本 | +| `capabilities` | Client 只调用 Agent 明确声明支持的 optional selector;未知字符串由旧 Client 保留并忽略 | +| `agentBundleIdentifier` | 与 bundled Agent metadata 比较,发现错误/旧 executable | +| `agentVersion` / `agentBuild` | 判断运行中 Agent 是否就是 App 当前携带的构建 | +| `agentInstanceID` | 每个 Agent process 唯一;重启后必须变化,用于区分进程代际 | +| `replacementPending` | 当前是否存在独占的 prepared 或 committed replacement drain | +| `replacementPhase` | additive 的 `inactive` / `prepared` / `committed`;未知 future string 由旧 Client 保留并忽略 | + +`agentBundleIdentifier`、version 和 build 是远端自报的兼容性信息,不是安全凭证。它们只有在 connection 已完成 peer authentication 后才有意义;不能用 descriptor 替代代码签名 requirement。 + +descriptor/capability grant 还必须绑定到读取它的 **同一条 client-side connection generation**。`XPCClient` 的 capability-gated API 会先在一条具体 connection 上读取 descriptor,按顺序验证 protocol major、selector-specific minimum minor 与 capability,再用同一个 `NSXPCConnection` 发送 feature selector;major 不兼容时 capability string 没有可复用的语义,不能授权 selector。若这条 connection interruption/invalidation,Client 会 invalidate 该 object,后续调用以新 connection 重新协商,不能把 Agent A 的 capability grant 用于 Agent B。replacement request 还把 expected `agentInstanceID` 放进 wire message:Agent 在安装 barrier 前原子比较自己的 instance ID,Client 再校验返回 ticket,形成两侧 generation fence。 + +App 的 readiness 顺序是: + +```text +SMAppService enabled + -> serviceDescriptor() + -> protocol/capability/bundled-build comparison + -> status() + hasAccessibilityPermission() + -> ready application state +``` + +legacy Agent 没有 `serviceDescriptor`,但 descriptor 调用失败也可能只是 transport 或实现故障,不能仅凭一次失败断言远端版本: + +1. Client 失效旧 connection,并在新 connection 上重试一次 `serviceDescriptor`。 +2. 两次 descriptor 都失败后,只调用最早就存在的 `status`。 +3. `status` 成功只能证明 base contract 可达;App 将远端标记为 unverified,显示 `Agent Update Required`,保留旧 `unlock` 作为安全迁移能力,不调用 Accessibility 等新 selector。 +4. `status` 也失败才归类为 unavailable。 + +运行中 Agent 与 bundled Agent 不一致时,兼容性与更新方向必须分开判断: + +- protocol major 不同、identifier 不同或缺少 `lock-control` 时,App 不调用当前 `status`/`prepare` selector,锁状态显示 unknown;只有明确用户动作可以强制 replacement。 +- running build 高于 bundled build 时绝不自动降级;UI 可以让用户明确选择是否使用当前 App 携带的版本。 +- 只有 identifier 和 protocol major 相同、running `CFBundleVersion` 使用 dotted numeric 规则严格低于 bundled build、并支持 `lock-control + prepare-for-replacement + committed-replacement-drain` 时,才进入自动 upgrade 候选。 +- locked 候选绝不自动终止;用户必须明确选择 `Unlock and Replace Agent`。 +- unlocked 自动候选调用 `prepareForReplacement(unlockIfNeeded: false)`。Agent 在自己的串行执行边界再次确认 unlocked、安装 barrier,然后返回 `ServiceReplacementTicket`。 +- 显式 locked replacement 使用 `unlockIfNeeded: true`;Agent 先安装 barrier 再 unlock,因此不存在 `unlock → prepare` 的跨客户端空窗。 +- legacy 或没有 replacement capability 时无法建立 barrier,只能执行带风险说明的用户授权 replacement。 + +受控 replacement 的顺序是: + +```text +prepareForReplacement(unlockIfNeeded:, expectedAgentInstanceID:) -> ownership ticket + -> short-lived prepared drain rejects every new lock + -> commitReplacement(ticket) + -> non-expiring committed drain + -> await SMAppService.unregister() + -> register bundled Agent + -> reset/create Client connection + -> serviceDescriptor() again + -> require compatible build and a new agentInstanceID +``` + +replacement 是两阶段 transaction: + +1. `prepareForReplacement` 创建短期、可 cancel/expire 的独占 preparation。它覆盖“已安装 barrier 但尚未向 Service Management 提交 unregister”的窗口;owner 在这里退出不会永久占用 Agent。 +2. App 紧接着调用幂等的 `commitReplacement(ticket:)`。commit 只接受同一 Agent instance 的活跃 ticket,并把 drain 切换为不可取消、不可过期的 fail-closed 状态。若 commit reply 丢失,Client 用 `replacementStatus(ticket:)` 查询这个 ticket 的真实 phase;确认 committed 后继续,不凭 transport error猜测结果。 +3. 只有收到 commit 成功 reply 后,App 才允许调用 `SMAppService.unregister()`。因此一旦系统中可能存在在途 unregister,Agent 就不会因为 client heartbeat 丢失或任意固定 timeout 而重新接受 lock;barrier 只随旧 Agent 进程退出消失。 + +Agent 拒绝第二个 prepare,绝不允许新 coordinator 覆盖 ticket。另一份 App 从 `replacementPhase` 区分“等待短期 preparation 到期”和“已 committed、等待旧进程退出”,并周期性重新握手,但不能接管、cancel 或再次 unregister。`SMAppService` 没有暴露可用于 fencing 的 operation token;用固定 grace 让第二 coordinator 再次 unregister,会重新制造“迟到的第一次 unregister 杀死新 Agent”的风险。commit 到 unregister 提交之间仍存在极小的 coordinator crash 窗口;此时设计明确选择 fail closed,旧 Agent 持续拒绝新 lock。若 committed 状态长期不消失,用户需要重启 macOS 完成系统级恢复,而不是由 App 猜测在途操作已经取消。 + +同一 App 进程对同一个 bundled build 最多发起一次自动 replacement。自动失败、post-restart descriptor 校验失败或新 Agent 仍不兼容时只允许显式重试,避免 App activation/reconciliation 形成 restart loop。post-restart verification 完成前 `activity` 始终保持 updating,其他 reconciliation 只会等待。 + +## Listener 和 Connection 到底是什么关系 + +“Agent 是 Listener、wrapper 是 Connection”作为第一层理解是对的,但完整模型是: + +- 每个 wrapper 进程主动创建一条 **client-side `NSXPCConnection`**。 +- Agent 创建一个 **`NSXPCListener`**,只负责等待、审查并接受新连接。 +- Listener 每接受一个 wrapper,Foundation 都会在 Agent 侧创建一条对应的 **server-side `NSXPCConnection`**。 +- 业务 method 不经过 Listener delegate;它们在这对 client/server connection 之间传输。 + +```mermaid +flowchart LR + AppConnection["App-side NSXPCConnection"] <-->|"peer channel A"| AgentConnectionA["Agent-side NSXPCConnection A"] + CLIConnection["CLI-side NSXPCConnection"] <-->|"peer channel B"| AgentConnectionB["Agent-side NSXPCConnection B"] + + Listener["One NSXPCListener"] -->|"accepts"| AgentConnectionA + Listener -->|"accepts"| AgentConnectionB + + AgentConnectionA --> SharedService["Shared AgentService"] + AgentConnectionB --> SharedService + SharedService --> Engine["one LockEngine instance"] +``` + +| 对象 | 所在进程 | 当前数量 | 作用 | +|---|---|---|---| +| wrapper-side `NSXPCConnection` | App 或 CLI | `XPCClient.shared` 每个进程通常缓存一条 | 主动找到 Mach service、创建 remote proxy、发送调用、接收 reply/error | +| `NSXPCListener` | Agent | 一个 Mach service 对应一个 | 接收新 connection request,并把每个新 peer 交给 delegate 审查 | +| Agent-side `NSXPCConnection` | Agent | 每个已接受 wrapper 一条 | 暴露 `AgentService`、接收该 wrapper 的 method call、把 reply 发回去 | +| `AgentService` | Agent | 当前整个进程共享一个 | 所有 connection 共用的 wire adapter,保证最终指向同一个全局引擎 | + +在当前代码中: + +1. `KeyboardLockerAgent/main.swift` 创建一个 `NSXPCListener(machServiceName:)`。 +2. Listener 在 activate 前安装 `XPCAccessControl` 生成的 code-signing requirement;不满足 requirement 的 peer 会由 XPC 在调用 delegate 前拒绝。 +3. 新客户端通过身份检查后,Listener 才调用 `listener(_:shouldAcceptNewConnection:)`。 +4. delegate 通过 `XPCServerConnection.configure` 为 **这条 Agent-side connection** 设置 `exportedInterface` 和 `exportedObject`,然后 activate。 +5. 下一位客户端会得到另一条 Agent-side connection,但仍指向同一个 `sharedService`。 + +因此 Listener 更像 socket server 的 acceptor;接受之后的 peer connection 才像一条已经建立的 socket。不过 XPC 是消息/RPC 系统,不应把它理解成可直接读取任意 bytes 的 TCP stream。 + +### Client connection 和 Agent connection 不是同一个对象 + +两侧的 `NSXPCConnection` 是两个不同的 Foundation object: + +```text +Wrapper process Agent process + +clientConnection <--- XPC/Mach ---> serverConnection +NSXPCConnection NSXPCConnection +different object different object +different address space different address space +``` + +Client 侧的对象由 wrapper 主动创建: + +```swift +let clientConnection = NSXPCConnection( + machServiceName: SharedConstants.machServiceName +) +``` + +Agent 侧的对象由 Listener 创建,并作为参数交给 delegate: + +```swift +func listener( + _: NSXPCListener, + shouldAcceptNewConnection serverConnection: NSXPCConnection +) -> Bool +``` + +它们不能共享 object identity,也不能直接访问彼此的内存。准确说法是: + +- `clientConnection` 和 `serverConnection` 是两个不同的 object。 +- 它们分别包装当前进程这一侧的 endpoint 与 peer 状态。 +- XPC runtime 把两个 endpoint 关联成同一条双向逻辑 channel。 +- 任意一侧退出或 invalidate 会影响另一侧观察到的 connection lifecycle,但这不表示它们是同一个对象。 + +业务调用的对象映射是: + +```text +clientConnection.remoteObjectProxy + │ + │ encoded XPC message + ▼ +serverConnection.exportedObject + │ + └─ shared AgentService + │ + └─ one LockEngine instance +``` + +Client 的 `remoteObjectProxy` 也不是 Agent 的 `AgentService`。前者是 wrapper 进程中的本地动态代理;对它调用 method 时,Foundation 才把 invocation 编码并传到 Agent,由后者真正执行。 + +如果 App 和 CLI 同时连接,会形成两对不同的 connection object: + +```text +App clientConnection <--> Agent serverConnection A ─┐ + ├─> shared AgentService +CLI clientConnection <--> Agent serverConnection B ─┘ +``` + +所以文档中出现“同一条 connection”时,指的是同一条**逻辑 peer channel**,不是两个进程共享同一个 `NSXPCConnection` 实例。 + +### `remote` 和 `exported` 是相对于当前进程命名的 + +`NSXPCConnection` 是双向 channel,两侧拥有同一组配置概念: + +| 属性/对象 | 含义 | +|---|---| +| `remoteObjectInterface` | 当前进程准备调用的“对端对象”允许有哪些 method | +| `remoteObjectProxy` | 当前进程中的本地替身;对它调用 method 会产生跨进程 message | +| `exportedInterface` | 当前进程允许对端调用自己的哪些 method | +| `exportedObject` | 当前进程实际接收并执行这些 method 的本地对象 | + +当前项目只使用单向业务接口: + +```text +Wrapper side + remoteObjectInterface = KeyboardLockerServiceProtocol + remoteObjectProxy = proxy for AgentService + exportedInterface = nil + exportedObject = nil + +Agent side + remoteObjectInterface = nil + remoteObjectProxy = unused + exportedInterface = KeyboardLockerServiceProtocol + exportedObject = shared AgentService +``` + +所以 wrapper 侧这句: + +```swift +connection.remoteObjectInterface = NSXPCInterface( + with: KeyboardLockerServiceProtocol.self +) +``` + +和 Agent 侧这两句: + +```swift +connection.exportedInterface = NSXPCInterface( + with: KeyboardLockerServiceProtocol.self +) +connection.exportedObject = exportedService +``` + +是在描述同一条 channel 的两端。两端不是共享一个 Swift object;只是对“远端能调用什么”和“本地由谁执行”达成一致。 + +XPC 本身允许 Agent 反过来调用 wrapper:wrapper 可以设置自己的 `exportedInterface/object`,Agent 再取得 remote proxy。但 KeyboardLocker 没有这样做。全局状态变化使用 payload-free notification 作为刷新信号,再由 wrapper 调 `status()`,避免把状态绑定到某条 client connection。 + +## 从 Foundation RPC 到 Mach IPC 的分层 + +当前项目使用的是 Foundation 的 `NSXPCConnection` API。它建立在较低层的 libxpc、Mach IPC 与 `launchd` 服务发现之上: + +```text +Project API + XPCClient.lock() + ↓ +Foundation RPC layer + NSXPCConnection + NSXPCInterface + remote proxy + NSXPCCoder + ↓ +libxpc layer + peer connection + opaque XPC message + reply correlation + ↓ +Mach IPC layer + Mach ports and Mach messages + ↓ +launchd / bootstrap namespace + service lookup, on-demand process launch, endpoint handoff + ↓ +Agent peer connection + decode invocation -> AgentService -> LockEngine +``` + +其中可以依赖的公开语义是: + +- XPC connection 是双向 peer channel。 +- `NSXPCInterface` 定义允许的 selector、参数/reply 类型和额外 proxy。 +- Foundation 自动编码调用参数并在对端解码,再把调用派发给 `exportedObject`。 +- 低层 libxpc 发送的是 XPC message,并使用 reply handler 关联响应。 +- Mach service name 必须存在于当前进程可访问的 bootstrap namespace,并由 `launchd.plist` 声明。 +- `launchd` 可以在第一条消息到达时按需启动 Agent,再把请求交给 Agent listener。 + +不应依赖的部分是 Foundation/libxpc 的具体 wire bytes、内部 dictionary key、Mach message layout 或私有握手过程。Apple 明确把底层 encoding 和 communication channel 视为 opaque implementation detail;它们不是稳定 ABI,也不应该被持久化或自行解析。 + +### 一次 proxy method call 在系统里发生什么 + +以 `XPCClient.status()` 为例: + +1. `NSXPCConnection(machServiceName:)` 只创建本地 connection object;此时不验证服务名,也不保证 Agent 已运行。 +2. Client 设置 `remoteObjectInterface`、双向 code-signing requirement 和 interruption/invalidation handler,然后调用 `activate()`。 +3. Agent-side connection 同样先设置 exported interface/object,再调用一次 `activate()`。 +4. `remoteObjectProxyWithErrorHandler` 创建一个本地动态 proxy。它不是 `AgentService` 的引用,也不能直接访问 Agent 地址空间。 +5. 对 proxy 调用 `status(reply:)` 时,Foundation 根据 `NSXPCInterface` 检查 selector 与参数形状,并把 invocation 编码成 opaque XPC message。 +6. 第一条实际 message 触发 service lookup;如果需要,`launchd` 启动 Agent。 +7. Agent Listener 收到 connection request 后,XPC runtime 先执行 Listener 的 code-signing requirement;只有通过的 peer 才会创建 Agent-side `NSXPCConnection` 并进入 delegate。 +8. delegate 设置 `exportedInterface/object`、activate connection 并返回 `true`;返回 `false` 会拒绝并 invalidate 它。 +9. Foundation 在 Agent-side connection 上解码 invocation,并调用 `AgentService.status(reply:)`。 +10. 进程生命周期的 `AgentService` 读取自己持有的 `LockEngine.isLocked`,再调用 `reply(isLocked, nil)`。 +11. 这里的 reply closure 不是把 Client 的 Swift closure 复制到 Agent 执行;它是 Foundation 提供的跨进程 reply capability。调用它会生成响应 message。 +12. Client connection 收到响应后,在自己的 connection queue 上执行 reply handler;`XPCClient` 再恢复 Swift continuation。 + +当前所有 wire method 都是 `Void + reply block` 形式,因此 proxy method 本身是异步发送;真正的业务完成点是 reply 到达,而不是 proxy call 返回。项目没有使用 synchronous proxy,因为同步 IPC 可能无限阻塞调用线程,尤其不适合 UI/main thread。 + +### Queue 与并发边界 + +Foundation 当前公开的调度保证包括: + +- 每个 `NSXPCConnection` 有自己的 private serial queue,用于 reply、interruption 和 invalidation handler。 +- 每个 `NSXPCListener` 有自己的 private serial queue,用于 delegate callback。 +- 发给 `exportedObject` 的调用会被串行投递到 non-main queue;业务代码不能假设自己运行在 main thread。 +- 不同 wrapper 对应不同 Agent-side connection,因此不要把“单 connection 串行”误解成“整个 Agent 全局串行”。 + +这解释了 `AgentService.executeOnMainActor` 的必要性:nonisolated XPC adapter 把多个 client connection 的调用切到 `MainActor`;Agent 的 settings、replacement transaction 与 `LockEngine` 状态都受编译器检查的同一隔离域约束,event tap、CFRunLoop 和 timer 的生命周期也收敛到这里。 + +### Interruption、invalidation 与项目自己的 timeout + +三者属于不同层次: + +| 事件 | Foundation/XPC 含义 | 当前项目处理 | +|---|---|---| +| interruption | 远端进程退出或 crash;同一个 named connection object 之后可能透明附着到新进程 | 清除缓存并主动 invalidate 该 object;下次调用创建新 connection 并重新握手 | +| invalidation | connection 无法建立或已永久结束;不能再收发消息 | 清除缓存 connection,后续重建 | +| proxy error | 当前 method 无法取得 reply | 恢复对应 continuation 并抛错 | +| 5 秒 timeout | 项目自己加的上层 deadline,不是 Foundation 自动提供 | 让本 connection 失效;mutation 再查询权威状态 | + +Foundation 保证带 reply 的 proxy call 最终调用 reply handler 或 error handler 之一。项目的 `ResumeOnce` 还要处理自定义 timeout 与这两个 callback 的竞争,确保 Swift continuation 仍然只恢复一次。 + +## 一次 `lock` 调用如何跨进程 + +```mermaid +sequenceDiagram + participant UI as App or CLI + participant Client as XPCClient + participant ClientConnection as Client Connection + participant Launchd as launchd + participant Listener as Agent Listener + participant Peer as Agent Connection + participant Adapter as AgentService + participant Engine as LockEngine + + UI->>Client: lock() + Client->>ClientConnection: call remote proxy + ClientConnection->>Launchd: send first message to Mach service + Launchd->>Listener: start Agent / deliver connection request + Listener->>Peer: validate, configure, and accept + ClientConnection->>Peer: lockKeyboard(...) / lockKeyboardInteractively(...) + Peer->>Adapter: dispatch to exported object + Adapter->>Engine: lock(settings:allowsControlCUnlock:) on MainActor + Engine-->>Adapter: success or domain error + Adapter-->>Peer: reply(error) + Peer-->>ClientConnection: reply message + ClientConnection-->>Client: resume continuation + Client-->>UI: return or throw +``` + +对应源码中的实际步骤是: + +1. App 的 `AppCoordinator` 已在 readiness reconciliation 中完成 descriptor handshake,随后 App 调用 legacy `XPCClient.shared.lock()`;CLI 的 `klock lock` 使用 capability-gated `lockInteractively()`,原子取得 acquired/already-locked outcome。 +2. `XPCClient.currentConnection()` 创建或复用 `NSXPCConnection(machServiceName:)`。 +3. Client 把 `KeyboardLockerServiceProtocol` 设置成 `remoteObjectInterface`,再取得 remote proxy。 +4. Client 在 activate 前安装只接受同 Team、精确 Agent signing identifier 的 requirement;第一条实际 message 让 `launchd` 按需启动 Agent。 +5. Agent Listener 的 requirement 在 delegate 暴露任何方法前校验调用方身份;Client requirement 同时拒绝伪造的 Agent。 +6. `XPCServerConnection` 为 Agent-side connection 设置 protocol,并把同一个 `AgentService` 实例设置为 `exportedObject`。 +7. 对 remote proxy 的 legacy `lockKeyboard(reply:)` 或 additive `lockKeyboardInteractively(reply:)` 调用被系统编码,通过两侧 peer connection 送达并派发给 `AgentService`。 +8. `AgentService` 把领域操作切到 `MainActor`,Agent 可变状态、event tap 和 CFRunLoop 生命周期都在这里维护。 +9. 进程生命周期 `AgentService` 持有的 `LockEngine` 调用 `lock(settings:allowsControlCUnlock:)`,检查 Agent 自己的 Accessibility 权限并创建 event tap;physical duplicate 在创建资源前直接返回 already locked,且只可能清除 Focus 的内部 release marker。 +10. reply 通过 XPC 返回,`XPCClient` 把 callback bridge 成 Swift async/throwing API。 + +`AgentService` 位于 `Service` 模块,是把 wire protocol 翻译成领域调用的 adapter;放在 library 里是为了让 barrier、generation fence 与错误码映射可以被 `ServiceTests` 确定性覆盖。`Service` product 本身不会启动进程;真正创建 listener、保持 RunLoop 的是 `KeyboardLockerAgent/main.swift` 这个极薄的 bootstrap。 + +## 当前 XPC 方法分别表达什么 + +| Wire method | Client API | 权威执行方 | 语义 | +|---|---|---|---| +| `serviceDescriptor` | `serviceDescriptor()` | `AgentService` | bootstrap query;返回 protocol、capability、bundled metadata 和 process instance ID | +| `lockKeyboard` | `lock()` | `LockEngine` | 对物理状态幂等地进入全局 locked;已锁时不修改设置、锁定起点或 auto-unlock deadline,但会接管当前 Focus-owned generation 的持久性 | +| `lockKeyboardInteractively` | `lockInteractively()` | `LockEngine` | 原子返回本次请求是否创建全局锁;仅 acquired 的新锁临时接受 `Ctrl+C` 作为额外解锁手势 | +| `setFocusFilterLockEnabled` | `setFocusFilterLockEnabled(_:)` | `LockEngine` | capability-gated desired state;启用只认领自己新建的 lock generation,停用只条件性释放仍属 Focus 的同一代 | +| `unlockKeyboard` | `unlock()` | `LockEngine` | 幂等地解除全局锁 | +| `status` | `status()` | `LockEngine` | 读取 Agent 当前的权威锁状态 | +| `toggleKeyboard` | `toggle()` | `AgentService` / `LockEngine` | capability-gated;在同一 MainActor turn 内原子翻转全局锁并返回翻转后状态;非幂等,丢失 reply 只能归入 outcome unknown | +| `lockStatusSnapshot` | `lockStatusSnapshot()` | `LockEngine` / `AgentService` | capability-gated query;在一个 Agent execution turn 中读取布尔状态、capture time、锁定起点、auto-unlock deadline 与 active settings,并以有大小上限的 format-1 JSON payload 返回 | +| `prepareForReplacement` | `prepareForReplacement(unlockIfNeeded:expectedAgentInstanceID:)` | `AgentService` | Agent 原子校验 expected instance,安装短期 prepared drain,可在同一 execution turn 解锁,返回同 generation ticket | +| `commitReplacement` | `commitReplacement(ticket:)` | `AgentService` | 在提交 unregister 前把 prepared drain 幂等切换为不可过期的 committed drain | +| `replacementStatus` | `replacementStatus(ticket:)` | `AgentService` | 查询 exact ticket 的 inactive/prepared/committed phase,恢复丢失的 commit reply | +| `cancelReplacementPreparation` | `cancelReplacementPreparation(ticket:)` | `AgentService` | 仅在 commit 前由 ticket owner 解除 preparation;committed drain 拒绝 cancel | +| `hasAccessibilityPermission` | `hasAccessibilityPermission()` | `AccessibilityManager` | 查询 **Agent 进程** 当前是否受信任 | +| `requestAccessibilityPermission` | `requestAccessibilityPermission()` | `AccessibilityManager` | 请求系统异步显示 Agent 的授权 prompt;reply 不代表用户已授权 | +| `currentSettings` | protocol 1.1 legacy Client only | `AgentService` | 为既有 selector ABI 保留;编码失败只能返回 `nil` | +| `currentSettingsWithError` | `currentSettings()` | `KeyboardLockerSettingsStore` / `AgentService` | 读取 Agent 启动时取得并持有的设置快照;本地 persisted payload 损坏时仅 Agent store 记录错误并回退 `.default`,而跨进程 payload 的缺失、损坏、过大或编码失败保持严格显式失败,wrapper 不自行回退默认值 | + +MVP 的 settings contract 有意保持只读,不包含 write selector 或设置编辑 UI;用户可编辑设置属于 post-MVP extension。未来新增写入能力时仍必须由 Agent XPC protocol 承载并由 Agent 持久化。 + +Accessibility 调用必须发生在 Agent,因为 TCC 授权绑定到实际使用 Accessibility API 的进程身份。App 获得 Accessibility 权限并不能让 Agent 创建 event tap。 + +## Connection 生命周期不等于锁生命周期 + +`XPCClient` 会缓存一条 connection,这是减少重复建连的实现优化,不是业务 session: + +- App 和 CLI 各有自己的 connection。 +- 某条 connection interruption 后,Client 会将其 invalidate 并清除缓存;invalidation 同样清除缓存。下一次调用创建新 connection 并重新握手。 +- App 退出、CLI 退出或 connection 断开,不会触发 unlock。 +- `klock lock` 只有在 interactive request 返回 acquired 时才继续等待并打印后续的 `Unlocked.`;该轮锁由 Agent 额外识别并消费 `Ctrl+C` 解锁手势。already locked 表示 physical duplicate,CLI 立即退出,不会解除锁、改变 event tap 或修改活动设置;若当前 generation 由 Focus 创建,这次显式 desired-lock 会接管其持久性。直接杀死 CLI 进程仍不会自动解除锁;等待期间 notification subscriber 提供及时更新,周期性 `status()` 提供丢通知与 Agent 重启后的恢复;连续无法取得权威状态时 CLI 报错退出。 +- Agent 退出则不同:event tap 属于 Agent 进程,进程退出会由系统释放它,内存中的 locked 状态也会消失。 + +因此项目刻意没有 `LockSession`、client ownership 或“连接释放时自动 unlock”之类的抽象。领域事实是一个物理键盘对应一个由 Agent 持有的全局状态。 + +## Reply、超时与“结果未知” + +`XPCClient` 用 `withCheckedThrowingContinuation` 把 reply callback 转为 async,并通过 `ResumeOnce` 保证 reply、proxy error、missing proxy 和 timeout 竞争时只恢复 continuation 一次。 + +所有调用都有 5 秒响应上限: + +| 情况 | Client 看到的结果 | +|---|---| +| Agent reply 成功 | async 方法正常返回 | +| Agent reply 领域错误 | 抛出 Agent 返回的错误,例如缺少 Accessibility 权限 | +| proxy 创建或传输失败 | query 归一化为 `serviceUnavailable`(Foundation transport 细节不跨出 Client 边界);但对端进程在连接存活期间死亡(`NSXPCConnectionInterrupted`)时,mutation 把它视为 reply 丢失(见下),因为已派发的请求可能执行过 | +| 5 秒内没有任何结果 | connection 失效并抛出 `timedOut`,或由 mutation API 转换为 outcome unknown | + +对 mutation 而言,reply 丢失(5 秒 timeout 或对端中断)不等于“Agent 没执行”。请求可能已经到达 Agent,只是 reply 没有返回。因此: + +- `lock()` reply 丢失后通过一条新 connection 重发一次幂等 desired-lock,并要求收到 reply;仅查询 `status()` 不能证明 Focus persistence takeover 已经发生。 +- `unlock()` 对称地确认是否已经 unlocked。 +- `setFocusFilterLockEnabled(_:)` reply 丢失后同样在 fresh connection 上重新协商 capability 并重发同一 desired state;布尔状态无法证明当前 generation 是否由 Focus 创建。 +- 无法确认最终状态时抛出 `operationOutcomeUnknown`,而不是谎称操作一定失败。非幂等的 `toggle` / interactive lock / safety check 只能走这条路,不能重发。 +- Accessibility prompt 请求 reply 丢失同样只表示最终结果未知;不能据此断言 prompt 没有发出。 + +App 在动作结束后仍会做完整 reconciliation,因为跨进程系统中应以 Agent 的当前查询结果为准,而不是长期相信某次历史 reply。 + +## 为什么还需要通知 + +XPC request/reply 适合“现在执行”或“现在查询”,不会自动告诉其他 wrapper 状态后来发生了变化。例如 CLI 锁定后,后续恢复的 App UI 需要更新状态;自动解锁发生时,所有长命 UI 也需要刷新。 + +当前状态同步链路是: + +```mermaid +sequenceDiagram + participant Agent as AgentService + LockEngine + participant Broadcast as State Broadcaster + participant Subscriber as State Subscriber + participant Client as XPCClient + participant UI as Long-lived UI + + UI->>Subscriber: subscribe(last known state) + Subscriber->>Client: initial status() after observers installed + Client->>Agent: authoritative XPC query + Agent-->>Subscriber: current state + Subscriber-->>UI: de-duplicated initial state + Agent->>Broadcast: state changed + Broadcast-->>Subscriber: Darwin + Distributed signals + Subscriber->>Client: status() + Client->>Agent: authoritative XPC query + Agent-->>Client: current state + Client-->>Subscriber: current state + Subscriber-->>UI: de-duplicated update +``` + +关键点: + +- Darwin 和 Distributed notification 都不携带锁状态,只表达“可能有变化”。 +- subscriber 在两个 observer 都安装完成后才发起初始 `status()`;setup 期间发生的变化会被初始查询或 follow-up signal 覆盖。 +- 通知可能重复、丢失或乱序,所以 subscriber 收到信号后必须再走 XPC `status()`。 +- 同一 subscription 内最多只有一个权威查询 worker;并发信号被合并为后续查询,取消后尚未进入 handler 的在途结果不再交付。 +- 两个通知通道是为了提高不同进程状态下的交付可靠性,不是两套状态源。 +- 后续 App UI 变为可见或 active 时还必须主动 reconcile,以弥补挂起期间错过的通知。 +- 一次性 `status` / `unlock` 命令直接查询 Agent,不需要为了读取当前状态先等待通知。 + +## Connection 访问控制 + +身份认证是双向且由 XPC runtime 强制执行: + +- Agent 的 `NSXPCListener` 在 activate 前调用 `setConnectionCodeSigningRequirement`,只接受与 Agent 同 Team 且 signing identifier 精确匹配主 App、bundled CLI、WidgetKit extension 或 Focus App Intents extension 的 Client。系统在调用 delegate 前完成检查,因此未认证 peer 看不到任何 exported selector。 +- App、CLI、WidgetKit extension 与 Focus App Intents extension 的 `NSXPCConnection` 在 activate 前调用 `setCodeSigningRequirement`,只接受与 Client 同 Team 且 signing identifier 精确匹配 bundled Agent 的服务进程。 +- requirement 使用 `anchor apple generic`、Apple 签名证书 `subject.OU` 中的 Team ID 和精确 `identifier`;Team ID 从当前进程已经验证的签名动态读取,不在源码中重复硬编码。 +- Debug 和 Release 使用同一条安全边界。Xcode Debug target 已配置 Apple Development 签名;unsigned、ad-hoc、错误 Team 或仅伪造 identifier 的进程都会 fail closed。 + +当前允许的调用方只有主 App、bundled CLI、`io.lzhlovesjyq.keyboardlocker.widgets` WidgetKit extension 与 `io.lzhlovesjyq.keyboardlocker.focus-intents` App Intents extension 的 namespaced signing identifier;不再接受通用的 `klock` identifier。仅仅知道 Mach service 名称,或在自己的签名中复制 bundle identifier,都不能调用 Agent。descriptor 中的 bundle/version/build 仍只用于兼容性判断,不参与身份认证。 + +WidgetKit extension 保持 App Sandbox 与 `APPLICATION_EXTENSION_API_ONLY`;它的 entitlements 只为 `io.lzhlovesjyq.keyboardlocker.agent` 声明 global Mach lookup temporary exception。这个 sandbox permission 只允许发起 lookup,不能代替 Listener 的同 Team + 精确 identifier 检查;Client 仍会反向验证 Agent。Widget 与 macOS 26+ Control 共用这一 extension process 和 signing identity,但每次 timeline/value/action execution 都建立在 Agent 权威 snapshot/action 上,不共享另一份业务状态。 + +Focus App Intents extension 同样保持 App Sandbox 与 `APPLICATION_EXTENSION_API_ONLY`,并只声明同一个 Agent Mach lookup exception。它使用独立 signing identity,不会与 WidgetKit extension 合并;Focus disable 调用专用 selector,由 Agent 在串行隔离域内比较 Focus-owned lock generation 后条件释放,而不是先读 `status()` 再调用普通 `unlock()`。 + +`klock` 是裸 Mach-O command-line tool,因此 target 必须生成并嵌入 `__TEXT,__info_plist`;否则 `PRODUCT_BUNDLE_IDENTIFIER` 不会成为实际 code-signing identifier,`codesign` 会退回可执行文件名 `klock`,并被 Listener requirement 正确拒绝。`CREATE_INFOPLIST_SECTION_IN_BINARY` 和 `GENERATE_INFOPLIST_FILE` 属于 XPC 身份契约,不能当作无关构建设置移除。 + +端到端拒绝证据由 `scripts/verify-unsigned-client-refusal.sh` 手动验证。脚本不会把同名进程存在或一次 connection failure 当作 PASS:它先用本地 bundled `klock` 的 Team signing identity 和精确 identifier 签出临时 authorized control,在 ad-hoc probe 前后立即分别读取 descriptor,并要求两次 `agentInstanceID` 相同。只有同一 Agent generation 在 probe 前后都响应 authorized control、同时 ad-hoc client 被拒绝才 PASS;这两个 bracket sample 不声称证明采样间隔内的连续可用性。authorized control 不可达、Agent 被替换或本地 signing identity 不可用均以 exit 2 报告 `INDETERMINATE`。 + +## `SMAppService` 状态和 XPC 状态不要混为一谈 + +| 观察结果 | 含义 | App 行为 | +|---|---|---| +| `.notRegistered` | Agent 尚未注册 | 尝试 `register()`,然后重新读取状态 | +| `.enabled` | 系统允许 Agent 运行 | 继续通过 XPC 查询真实 readiness | +| `.requiresApproval` | 用户需要在 Login Items 批准 | 显示说明和 System Settings 入口 | +| `.notFound` | Service Management 无法解析该 service;既可能是 bundled Agent/plist 缺失,也可能只是当前 App 尚无 Background Task Management 注册记录 | 先独立校验 bundled assets;完整时尝试 `register()` 并以注册后的状态或错误为准,缺失时才报告 bundle layout 错误 | +| enabled + descriptor compatible | 当前进程理解所需 contract 且匹配 bundled Agent | 继续查询 lock / Accessibility readiness | +| enabled + running build lower + same major + replacement capability | 安全自动 upgrade 候选 | unlocked 时每 bundled build 自动尝试一次;locked 时要求用户确认 | +| enabled + running build higher | 旧 App 面对更新 Agent | 禁止自动降级;只提供明确用户 replacement | +| enabled + incompatible major/identity/capability | 当前 selector 语义不能安全假定 | 不查询不受保证的 lock state,显示 unknown 和 forced replacement 风险 | +| enabled + fresh-connection descriptor retry 仍失败 + base `status` success | base contract 可达,但版本无法确认 | 只保留 `status`/`unlock`,显示显式 unverified update | +| enabled + descriptor retry 和 base `status` 都失败 | Agent 未启动、崩溃或连接被拒绝 | 诚实显示不可达;可提供显式 retry/restart | +| enabled + compatible descriptor + `replacementPending` | 存在 prepared 或 committed replacement | 周期性重新握手并显示 replacement in progress;不得接管、cancel 或再次 unregister | + +locked Agent 不能自动 restart,因为 Agent 退出本身会释放正在工作的 event tap。只有严格更旧、unlocked、same-major 且支持 ticket barrier 的 Agent 才可受控自动 upgrade;其他情况需要明确用户动作。安全路径必须保持 barrier 直到 `unregister()` 结束,然后清除旧 Client connection 并对新进程重新握手。 + +## 新增 XPC 能力时的修改顺序 + +1. 在同一 protocol major 内只允许新增 selector;不得修改/移除既有 selector、参数顺序、类型或 reply 形状。无法长期保留 selector union 的破坏性升级需要新的 Mach service name。 +2. 在 `Core/Sources/Common/Shared.swift` 的 `KeyboardLockerServiceProtocol` 增加最小 wire method,在 `ServiceCapability` 增加一个稳定且永不复用的名字,并按需要提高 protocol minor。 +3. 只使用 Objective-C/XPC 能表达的参数和 reply 类型;复杂 Swift value 编码成有明确 schema 和大小上限的 `Data`。 +4. 在 `Core/Sources/Service/AgentService.swift` 实现 protocol adapter,并在 descriptor 声明 capability。 +5. 把真实领域行为放在 `Service`,不要堆进 App、CLI 或 wire adapter。 +6. 在 `Core/Sources/Client/XPCClient.swift` 增加薄的 async/throwing 封装,在 selector minimum-minor 表登记引入版本;调用方在同一 connection 上先验证 major、minimum minor 和 capability,再发出 feature call。 +7. wrapper 只调用 `XPCClient`;App/CLI 不得 import `Service`。 +8. 明确方法是 query 还是 mutation,并定义 timeout 后能否确认最终结果。 +9. 如果状态可能在调用之外变化,继续用“notification signal + XPC query”,不要把通知 payload 变成第二个状态源。 +10. 在 `Core/Tests/ClientTests` 覆盖 descriptor round-trip、major/minor、unknown capability、missing capability 与 bundled build mismatch。 + +## 常见误解 + +### “Core 被两侧引用,所以是不是有两份锁状态?” + +不是。两侧都能看到的是 `Common` 契约;`LockEngine` 位于只有 Agent 链接的 `Service` product。App/CLI 进程里根本没有 `LockEngine` 实例。 + +### “`Service` product 就是 XPC 后台进程吗?” + +不是。`Service` 是 library product。`KeyboardLockerAgent` 才是 executable;它创建 listener,并把 `Service` 中的能力暴露给 XPC。 + +### “XPC connection 断开时会自动解锁吗?” + +不会。锁属于 Agent,不属于 connection。只有 unlock、自动解锁、解锁热键或 Agent 进程退出会释放锁。 + +### “通知已经告诉我状态,为什么还要调用 `status()`?” + +因为跨进程通知不是可靠、有序的状态存储。这里的通知只是刷新提示,Agent 的 `status()` 才是权威事实。 + +### “主 App 请求 Accessibility 权限不就够了吗?” + +不够。创建 event tap 的是 Agent,必须由 Agent 自己查询和请求对应权限。 + +## 源码导航 + +| 想理解什么 | 入口 | +|---|---| +| Wire protocol、Mach name、allowlist | [`Core/Sources/Common/Shared.swift`](../Core/Sources/Common/Shared.swift) | +| Descriptor、protocol version、capability | [`Core/Sources/Common/ServiceDescriptor.swift`](../Core/Sources/Common/ServiceDescriptor.swift) | +| Client compatibility rules | [`Core/Sources/Client/ServiceCompatibility.swift`](../Core/Sources/Client/ServiceCompatibility.swift) | +| Client connection、async bridge、timeout | [`Core/Sources/Client/XPCClient.swift`](../Core/Sources/Client/XPCClient.swift) | +| 长命 UI 的状态订阅 | [`Core/Sources/Client/LockStateSubscriber.swift`](../Core/Sources/Client/LockStateSubscriber.swift) | +| Agent listener 与进程入口 | [`KeyboardLockerAgent/main.swift`](../KeyboardLockerAgent/main.swift) | +| Wire method 到领域方法的适配 | [`Core/Sources/Service/AgentService.swift`](../Core/Sources/Service/AgentService.swift) | +| 新 connection 的 server 配置 | [`Core/Sources/Service/XPCServerConnection.swift`](../Core/Sources/Service/XPCServerConnection.swift) | +| 双向 code-signing requirement | [`Core/Sources/Common/XPCCodeSigningRequirement.swift`](../Core/Sources/Common/XPCCodeSigningRequirement.swift)、[`Core/Sources/Service/XPCAccessControl.swift`](../Core/Sources/Service/XPCAccessControl.swift) | +| 全局锁与 event tap | [`Core/Sources/Service/LockEngine.swift`](../Core/Sources/Service/LockEngine.swift) | +| 状态变化信号 | [`Core/Sources/Service/LockStateBroadcaster.swift`](../Core/Sources/Service/LockStateBroadcaster.swift) | +| Agent 注册与显式恢复 | [`KeyboardLocker/AgentRegistrar.swift`](../KeyboardLocker/AgentRegistrar.swift) | +| App 的可注入 XPC/lifecycle adapter | [`KeyboardLocker/AgentCoordinationServices.swift`](../KeyboardLocker/AgentCoordinationServices.swift) | +| Registration、handshake 与 readiness 快照 | [`KeyboardLocker/AgentReadinessCoordinator.swift`](../KeyboardLocker/AgentReadinessCoordinator.swift) | +| Safe/forced Agent replacement 顺序 | [`KeyboardLocker/AgentReplacementCoordinator.swift`](../KeyboardLocker/AgentReplacementCoordinator.swift) | +| App 应用状态、任务与订阅协调 | [`KeyboardLocker/AppCoordinator.swift`](../KeyboardLocker/AppCoordinator.swift) | + +## Apple 参考资料 + +- [`NSXPCConnection`](https://developer.apple.com/documentation/foundation/nsxpcconnection) +- [`NSXPCListener`](https://developer.apple.com/documentation/foundation/nsxpclistener) +- [`NSXPCListenerDelegate.listener(_:shouldAcceptNewConnection:)`](https://developer.apple.com/documentation/foundation/nsxpclistenerdelegate/listener%28_%3Ashouldacceptnewconnection%3A%29) +- [`NSXPCInterface`](https://developer.apple.com/documentation/foundation/nsxpcinterface) +- [Creating XPC Services](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html) +- [Creating Launch Daemons and Agents](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html) diff --git a/klock/KlockAppOpener.swift b/klock/KlockAppOpener.swift new file mode 100644 index 0000000..88d5267 --- /dev/null +++ b/klock/KlockAppOpener.swift @@ -0,0 +1,69 @@ +import Client +import Foundation + +/// Launches the containing KeyboardLocker app so it can register the background Agent with +/// `SMAppService`. Only the App bundle can perform that registration: the launchd plist must +/// live in the main bundle, and `klock`'s main bundle is not KeyboardLocker.app. +enum KlockAppOpener { + enum OpenerError: LocalizedError { + case appNotFound + case launchFailed(String) + + var errorDescription: String? { + switch self { + case .appNotFound: + "KeyboardLocker.app could not be located from this klock executable or by Launch Services." + case let .launchFailed(details): + "KeyboardLocker could not be launched. \(details)" + } + } + + var recoverySuggestion: String? { + "Open KeyboardLocker manually once to register its background agent, then retry." + } + } + + /// klock ships at `Contents/MacOS/klock` inside the signed App bundle and PATH entries are + /// symlinks to it, so the resolved executable path identifies the exact App copy to launch. + /// That matters when a dev build and an installed copy share the same bundle identifier. + static func openContainingApp() throws { + if let appURL = containingAppURL() { + try runOpen(arguments: [appURL.path]) + return + } + try runOpen(arguments: ["-b", SharedConstants.appBundleIdentifier]) + } + + private static func containingAppURL() -> URL? { + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + var size = UInt32(buffer.count) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { + return nil + } + + var url = URL(fileURLWithPath: String(cString: buffer)) + .standardizedFileURL + .resolvingSymlinksInPath() + while url.pathExtension != "app", url.pathComponents.count > 1 { + url.deleteLastPathComponent() + } + return url.pathExtension == "app" ? url : nil + } + + private static func runOpen(arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = arguments + do { + try process.run() + } catch { + throw OpenerError.launchFailed(error.localizedDescription) + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw OpenerError.launchFailed( + "`open \(arguments.joined(separator: " "))` exited with status \(process.terminationStatus)." + ) + } + } +} diff --git a/klock/KlockCLI.swift b/klock/KlockCLI.swift new file mode 100644 index 0000000..4af9ead --- /dev/null +++ b/klock/KlockCLI.swift @@ -0,0 +1,435 @@ +import Client +import Foundation + +/// The Agent operations `klock` depends on, mirroring the `XPCClient` signatures one-to-one so +/// command execution can be driven by a fake in tests. +protocol KlockClientServing: Sendable { + func currentSettings() async throws -> KeyboardLockerSettings + func lockInteractively() async throws -> LockRequestOutcome + func lock() async throws + func unlock() async throws + func toggle() async throws -> Bool + func status() async throws -> Bool + func lockStatusSnapshot() async throws -> LockStatusSnapshot + func waitUntilUnlocked() async throws + func hasAccessibilityPermission() async throws -> Bool + func requestAccessibilityPermission() async throws +} + +extension XPCClient: KlockClientServing {} + +// MARK: - CLI + +enum KlockCLI { + /// Production entry point: wires the real process arguments, the live Agent client, and the + /// real stdout/stderr, then terminates the process with the command's exit code. + static func run() async { + let exitCode = await run( + arguments: Array(CommandLine.arguments.dropFirst()), + client: XPCClient.shared, + printOut: { print($0) }, + printError: { FileHandle.standardError.write(Data("\($0)\n".utf8)) } + ) + exit(exitCode) + } + + /// Testable core: runs one invocation and returns its exit code instead of calling `exit`. + static func run( + arguments: [String], + client: any KlockClientServing, + openKeyboardLockerApp: @escaping () throws -> Void = KlockAppOpener.openContainingApp, + agentPoll: (attempts: Int, interval: Duration) = (attempts: 5, interval: .seconds(1)), + accessPoll: (attempts: Int, interval: Duration) = (attempts: 30, interval: .seconds(1)), + terminationGuard: any KlockTerminationGuarding = LiveKlockTerminationGuard(), + printOut: (String) -> Void, + printError: @escaping (String) -> Void + ) async -> Int32 { + let command: KlockCommand + do { + guard let parsedCommand = try KlockCommandLineParser.parse(arguments) else { + printUsage(to: printOut) + return ExitCode.success + } + command = parsedCommand + } catch let KlockCommandLineError.unknownCommand(name) { + reportError("Unknown command: \(name)", printError: printError) + printUsage(to: printOut) + return ExitCode.error + } catch let KlockCommandLineError.unexpectedArguments(unexpected) { + reportError( + "Unexpected argument\(unexpected.count == 1 ? "" : "s"): \(unexpected.joined(separator: " "))", + printError: printError + ) + printUsage(to: printOut) + return ExitCode.error + } catch { + // KlockCommandLineParser only throws KlockCommandLineError. + return ExitCode.error + } + + switch command { + case .help: + printUsage(to: printOut) + return ExitCode.success + + case .version: + return printVersion(printOut: printOut, printError: printError) + + case let .lock(wait): + if wait { + return await executeInteractiveLock( + client: client, + terminationGuard: terminationGuard, + printOut: printOut, + printError: printError + ) + } + return await executeNonInteractiveLock(client: client, printOut: printOut, printError: printError) + + case .unlock: + return await executeUnlock(client: client, printOut: printOut, printError: printError) + + case .toggle: + return await executeToggle(client: client, printOut: printOut, printError: printError) + + case .requestAccess: + return await executeRequestAccess( + client: client, + accessPoll: accessPoll, + printOut: printOut, + printError: printError + ) + + case .registerAgent: + return await executeRegisterAgent( + client: client, + openKeyboardLockerApp: openKeyboardLockerApp, + agentPoll: agentPoll, + printOut: printOut, + printError: printError + ) + + case let .status(output): + return await executeStatus(output: output, client: client, printOut: printOut, printError: printError) + } + } + + // MARK: - Commands + + private static func executeInteractiveLock( + client: any KlockClientServing, + terminationGuard: any KlockTerminationGuarding, + printOut: (String) -> Void, + printError: @escaping (String) -> Void + ) async -> Int32 { + let unlockHotkey: Result + do { + unlockHotkey = try await .success( + client.currentSettings().unlockHotkey.displayString + ) + } catch { + unlockHotkey = .failure(error) + } + + // Observe termination before sending the mutation. If a signal arrives while the XPC reply is + // in flight, the coordinator waits for the atomic acquisition outcome before deciding whether + // cleanup may touch the global lock. + let terminationCoordinator = KlockInteractiveTerminationCoordinator( + cleanup: { + await Self.unlockBeforeTermination(client: client, printError: printError) + } + ) + let cancelTerminationGuard = terminationGuard.install { signal in + terminationCoordinator.receive(signal: signal) + } + defer { + cancelTerminationGuard() + } + + let outcome: LockRequestOutcome + do { + outcome = try await client.lockInteractively() + } catch { + if let terminationExitCode = terminationCoordinator.resolveWithoutAcquisition() { + return terminationExitCode + } + reportFailure(error, printError: printError) + return ExitCode.error + } + + guard outcome == .acquired else { + if let terminationExitCode = terminationCoordinator.resolveWithoutAcquisition() { + return terminationExitCode + } + printOut("Already locked. This command did not create a new lock.") + return ExitCode.success + } + if let terminationExitCode = await terminationCoordinator.resolveAcquired() { + return terminationExitCode + } + + switch unlockHotkey { + case let .success(hotkey): + printOut("Locked. Press \(hotkey) or Ctrl+C to unlock.") + + case let .failure(error): + printOut("Locked. Press Ctrl+C to unlock, or run `klock unlock` from another Terminal.") + reportWarning( + "Could not read the configured unlock shortcut: \(error.localizedDescription)", + printError: printError + ) + } + + switch await terminationCoordinator.waitUntilUnlocked({ + try await client.waitUntilUnlocked() + }) { + case .unlocked: + printOut("Unlocked.") + return ExitCode.success + + case let .failed(error): + reportFailure(error, printError: printError) + return ExitCode.error + + case let .signaled(exitCode): + return exitCode + } + } + + /// Best-effort release of the lock this command created, bounded by the caller so the + /// process still exits when the Agent is unreachable. The wire protocol carries no + /// generation token for interactive locks, so if this command's lock was already released + /// and another wrapper re-locked while this process waited, cleanup releases that newer + /// lock — permitted by the global-lock contract (any entry may unlock), and documented + /// here so the approximation stays explicit. Internal (not private) so tests can drive + /// it directly — production reaches it only via the signal handler. + static func unlockBeforeTermination( + client: any KlockClientServing, + printError: (String) -> Void + ) async { + do { + try await client.unlock() + printError("Terminated; released the keyboard lock created by this command.") + } catch { + printError( + "Terminated; could not release the keyboard lock (\(error.localizedDescription)). " + + "Unlock with the configured hotkey or the notification's Unlock Now button." + ) + } + } + + private static func executeNonInteractiveLock( + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + try await client.lock() + printOut("Locked.") + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + private static func executeUnlock( + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + try await client.unlock() + printOut("Unlocked.") + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + private static func executeToggle( + client: any KlockClientServing, + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + do { + let isLocked = try await client.toggle() + printOut(isLocked ? "Locked." : "Unlocked.") + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + /// Registers the background Agent by launching the containing App — only the App bundle can + /// hand its launchd plist to `SMAppService`. The command then polls briefly so a terminal + /// user learns immediately whether registration produced a reachable Agent. + private static func executeRegisterAgent( + client: any KlockClientServing, + openKeyboardLockerApp: () throws -> Void, + agentPoll: (attempts: Int, interval: Duration), + printOut: (String) -> Void, + printError: (String) -> Void + ) async -> Int32 { + if await (try? client.status()) != nil { + printOut("The KeyboardLocker agent is already registered and reachable.") + return ExitCode.success + } + + do { + try openKeyboardLockerApp() + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + printOut("Launched KeyboardLocker to register its background agent.") + + for _ in 0.. Void, + printError: (String) -> Void + ) async -> Int32 { + do { + if try await client.hasAccessibilityPermission() { + printOut("Accessibility access is already granted.") + return ExitCode.success + } + + try await client.requestAccessibilityPermission() + printOut( + "Requested the system Accessibility prompt for the KeyboardLocker agent; " + + "waiting for the grant…" + ) + + for _ in 0.. Void, + printError: (String) -> Void + ) async -> Int32 { + do { + switch output { + case .humanReadable, .json: + try await printOut(output.render(isLocked: client.status())) + case .snapshot: + try await printOut(KlockStatusOutput.render(snapshot: client.lockStatusSnapshot())) + } + return ExitCode.success + } catch { + reportFailure(error, printError: printError) + return ExitCode.error + } + } + + // MARK: - Helpers + + private static func reportFailure(_ error: Error, printError: (String) -> Void) { + reportError(error.localizedDescription, printError: printError) + if let suggestion = (error as? LocalizedError)?.recoverySuggestion { + printError(" \(suggestion)") + } + // The shared recovery text names the GUI registration path; point CLI users at the + // native equivalent so the hint stays actionable from Terminal. + if let clientError = error as? XPCClientError, case .serviceUnavailable = clientError { + printError(" Or run `klock register-agent` to register it from Terminal.") + } + } + + private static func reportError(_ message: String, printError: (String) -> Void) { + printError("Error: \(message)") + } + + private static func reportWarning(_ message: String, printError: (String) -> Void) { + printError("Warning: \(message)") + } + + private static func printVersion( + printOut: (String) -> Void, + printError: (String) -> Void + ) -> Int32 { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + guard let version, !version.isEmpty, let build, !build.isEmpty else { + reportError("Version metadata is unavailable in this build.", printError: printError) + return ExitCode.error + } + + printOut("klock \(version) (\(build))") + return ExitCode.success + } + + private static func printUsage(to printOut: (String) -> Void) { + printOut( + """ + OVERVIEW: Control KeyboardLocker from Terminal. + + USAGE: klock [options] + + COMMANDS: + lock [--no-wait] Lock the keyboard; by default, wait until it is unlocked. + unlock Unlock the keyboard. + toggle Toggle the lock state and print the new state. + status [--json] Print the current lock state. + register-agent Launch KeyboardLocker once to register its background agent. + request-access Request Accessibility access for the agent. + help Show this help message. + version Show the klock version. + + OPTIONS: + --no-wait Return after lock is confirmed; do not enable Ctrl+C unlock. + --json Emit a stable JSON object for status automation. + --snapshot Emit the full lock snapshot as JSON. + -h, --help Show this help message. + -v, --version Show the klock version. + """ + ) + } + + private enum ExitCode { + static let success: Int32 = 0 + static let error: Int32 = 1 + } +} diff --git a/klock/KlockCommand.swift b/klock/KlockCommand.swift new file mode 100644 index 0000000..41026ca --- /dev/null +++ b/klock/KlockCommand.swift @@ -0,0 +1,90 @@ +import Foundation + +/// One parsed `klock` invocation. A `nil` parse result means no arguments were given at all. +enum KlockCommand: Equatable { + case help + case version + case lock(wait: Bool) + case unlock + case toggle + case registerAgent + case requestAccess + case status(output: KlockStatusOutput) +} + +enum KlockCommandLineError: Error, Equatable { + case unknownCommand(String) + case unexpectedArguments([String]) +} + +/// Pure argument parsing for `klock`, kept free of I/O and process exit so the accepted +/// command/flag matrix can be exhaustively unit-tested. +enum KlockCommandLineParser { + static func parse(_ arguments: [String]) throws -> KlockCommand? { + guard let command = arguments.first else { + return nil + } + + switch command { + case "--help", "-h", "help": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .help + + case "--version", "-v", "version": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .version + + case "lock": + switch Array(arguments.dropFirst()) { + case []: + return .lock(wait: true) + + case ["--no-wait"]: + return .lock(wait: false) + + case let unexpectedArguments: + throw KlockCommandLineError.unexpectedArguments(unexpectedArguments) + } + + case "unlock": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .unlock + + case "toggle": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .toggle + + case "register-agent": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .registerAgent + + case "request-access": + try rejectUnexpectedArguments(Array(arguments.dropFirst())) + return .requestAccess + + case "status": + switch Array(arguments.dropFirst()) { + case []: + return .status(output: .humanReadable) + + case ["--json"]: + return .status(output: .json) + + case ["--snapshot"]: + return .status(output: .snapshot) + + case let unexpectedArguments: + throw KlockCommandLineError.unexpectedArguments(unexpectedArguments) + } + + default: + throw KlockCommandLineError.unknownCommand(command) + } + } + + private static func rejectUnexpectedArguments(_ arguments: [String]) throws { + guard arguments.isEmpty else { + throw KlockCommandLineError.unexpectedArguments(arguments) + } + } +} diff --git a/klock/KlockStatusOutput.swift b/klock/KlockStatusOutput.swift new file mode 100644 index 0000000..5f02168 --- /dev/null +++ b/klock/KlockStatusOutput.swift @@ -0,0 +1,46 @@ +import Client +import Foundation + +enum KlockStatusOutput: Equatable { + case humanReadable + case json + case snapshot + + func render(isLocked: Bool) -> String { + switch self { + case .humanReadable: + isLocked ? "Locked" : "Unlocked" + + case .json: + "{\"locked\":\(isLocked ? "true" : "false")}" + + case .snapshot: + preconditionFailure("snapshot output renders from the full LockStatusSnapshot") + } + } + + /// The `--snapshot` payload is a separate, richer contract: `--json` stays byte-stable. + /// Keys are emitted in alphabetical order and dates as ISO-8601 in GMT (absent dates are + /// `null`) so automation can compare output verbatim. + static func render(snapshot: LockStatusSnapshot) -> String { + let object: [String: Any] = [ + "autoUnlockTargetDate": snapshot.autoUnlockTargetDate.map(iso8601String) ?? NSNull(), + "locked": snapshot.isLocked, + "startedAt": snapshot.startedAt.map(iso8601String) ?? NSNull(), + "unlockHotkey": snapshot.settings.unlockHotkey.displayString, + ] + // Only strings, a Boolean, and NSNull reach this point, so serialization cannot fail. + let data = try! JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + return String(decoding: data, as: UTF8.self) + } + + private static func iso8601String(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(identifier: "GMT") + return formatter.string(from: date) + } +} diff --git a/klock/KlockTerminationGuard.swift b/klock/KlockTerminationGuard.swift new file mode 100644 index 0000000..0a5317a --- /dev/null +++ b/klock/KlockTerminationGuard.swift @@ -0,0 +1,216 @@ +import Foundation + +/// Signal-observer seam for the interactive-lock wait path, injectable so tests can assert +/// installation and cancellation without touching process signal state. +protocol KlockTerminationGuarding { + /// Observes the covered termination signals and returns a cancellation closure for the + /// normal completion path. `onTermination` receives the caught signal number. + func install(onTermination: @escaping (Int32) -> Void) -> () -> Void +} + +/// Reconciles process termination with interactive acquisition and the subsequent unlock wait. +/// The first accepted signal owns the command result, so a concurrent normal wait completion can +/// never return success or failure before bounded signal cleanup finishes. +final class KlockInteractiveTerminationCoordinator: @unchecked Sendable { + enum WaitResult { + case failed(any Error) + case signaled(Int32) + case unlocked + } + + typealias Cleanup = () async -> Void + + private enum Phase { + case acquiring + case acquired + case finished + } + + private struct State { + var phase = Phase.acquiring + var signal: Int32? + var signalReady = false + var waitContinuation: CheckedContinuation? + } + + private static let cleanupTimeout: Duration = .seconds(2) + + private let cleanup: Cleanup + private let lock = NSLock() + private var state = State() + + init(cleanup: @escaping Cleanup) { + self.cleanup = cleanup + } + + /// Records only the first signal. During acquisition it remains disarmed until the Agent reply + /// establishes whether cleanup may touch the global lock. + func receive(signal: Int32) { + let shouldStartCleanup = lock.withLock { + guard state.phase != .finished, state.signal == nil else { + return false + } + state.signal = signal + guard state.phase == .acquired else { + return false + } + return true + } + + if shouldStartCleanup { + Task { [self] in + await finishSignal(signal) + } + } + } + + /// Atomically closes an acquisition that did not create a lock. A signal already accepted in + /// the acquisition window wins; otherwise later signals are ignored by the finished command. + func resolveWithoutAcquisition() -> Int32? { + lock.withLock { + guard state.phase == .acquiring else { + return nil + } + state.phase = .finished + return state.signal.map { 128 + $0 } + } + } + + /// Arms cleanup after confirmed acquisition and handles a signal retained from the request. + func resolveAcquired() async -> Int32? { + let pendingSignal = lock.withLock { () -> Int32? in + guard state.phase == .acquiring else { + return nil + } + state.phase = .acquired + guard let signal = state.signal else { + return nil + } + return signal + } + + guard let pendingSignal else { + return nil + } + await waitForCleanupOrTimeout() + lock.withLock { state.phase = .finished } + return 128 + pendingSignal + } + + /// Races the normal Agent wait against termination without structured-concurrency cancellation + /// holding the command open. Once `receive(signal:)` accepts a signal, normal completion loses. + func waitUntilUnlocked( + _ operation: @escaping @Sendable () async throws -> Void + ) async -> WaitResult { + await withCheckedContinuation { continuation in + let preparation = lock.withLock { () -> (WaitResult?, Bool) in + guard state.phase == .acquired else { + return (.unlocked, false) + } + if state.signalReady, let signal = state.signal { + state.phase = .finished + return (.signaled(128 + signal), false) + } + state.waitContinuation = continuation + return (nil, state.signal == nil) + } + + if let result = preparation.0 { + continuation.resume(returning: result) + } else if preparation.1 { + Task { [self] in + do { + try await operation() + finishWait(.unlocked) + } catch { + finishWait(.failed(error)) + } + } + } + } + } + + private func finishSignal(_ signal: Int32) async { + await waitForCleanupOrTimeout() + let continuation = lock.withLock { () -> CheckedContinuation? in + state.signalReady = true + guard let continuation = state.waitContinuation else { + return nil + } + state.phase = .finished + state.waitContinuation = nil + return continuation + } + continuation?.resume(returning: .signaled(128 + signal)) + } + + private func finishWait(_ result: WaitResult) { + let continuation = lock.withLock { () -> CheckedContinuation? in + guard state.phase == .acquired, state.signal == nil else { + return nil + } + state.phase = .finished + defer { state.waitContinuation = nil } + return state.waitContinuation + } + continuation?.resume(returning: result) + } + + /// Returns as soon as cleanup completes or after the hard deadline. The losing unstructured + /// task may finish later, but `CleanupWaiter` makes continuation resumption one-shot. + private func waitForCleanupOrTimeout() async { + await withCheckedContinuation { continuation in + let waiter = CleanupWaiter(continuation: continuation) + Task { [self] in + await cleanup() + waiter.finish() + } + Task { + try? await Task.sleep(for: Self.cleanupTimeout) + waiter.finish() + } + } + } +} + +private final class CleanupWaiter: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func finish() { + let continuation = lock.withLock { + defer { self.continuation = nil } + return self.continuation + } + continuation?.resume() + } +} + +/// Watches SIGTERM/SIGHUP/SIGINT via GCD signal sources, whose handlers run on a dispatch +/// queue and may therefore perform ordinary work (unlike raw `signal()` handlers). +/// +/// SIGKILL/SIGSTOP cannot be observed by design; a lock orphaned that way is still reachable +/// through the Agent-side affordances (unlock hotkey, notification action, auto-unlock). +struct LiveKlockTerminationGuard: KlockTerminationGuarding { + private static let coveredSignals: [Int32] = [SIGTERM, SIGHUP, SIGINT] + + func install(onTermination: @escaping (Int32) -> Void) -> () -> Void { + let sources = Self.coveredSignals.map { signalNumber -> DispatchSourceSignal in + // GCD signal delivery requires the default action to be ignored first. + signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .global()) + source.setEventHandler { + onTermination(signalNumber) + } + source.resume() + return source + } + return { + sources.forEach { $0.cancel() } + } + } +} diff --git a/klock/main.swift b/klock/main.swift index 0bc8a52..dc71ae1 100644 --- a/klock/main.swift +++ b/klock/main.swift @@ -5,118 +5,4 @@ // Created by Eden on 2025/11/19. // -import Client -import Foundation - -// MARK: - Entry Point - -KlockCLI.run() - -// MARK: - CLI - -enum KlockCLI { - private static var session: LockSessionController? - private static var stateToken: ObserverToken? - - static func run() { - guard let command = CommandLine.arguments.dropFirst().first else { - printUsage() - exit(ExitCode.error) - } - - switch command { - case "lock": - executeLock() - case "unlock": - executeUnlock() - case "status": - executeStatus() - default: - printError("Unknown command: \(command)") - printUsage() - exit(ExitCode.error) - } - } - - // MARK: - Commands - - private static func executeLock() { - print("Locking...") - - session = XPCClient.startLockSession() - session?.lock { error in - if let error { - printError(error.localizedDescription) - if let suggestion = (error as? LocalizedError)?.recoverySuggestion { - print(" \(suggestion)") - } - exit(ExitCode.error) - } - let hotkey = KeyboardLockerSettings.default.unlockHotkey.displayString - print("Locked. Press \(hotkey) to unlock.") - } - - // Listen for external unlock (hotkey pressed) - stateToken = LockStateSubscriber.subscribe { isLocked in - if !isLocked { - print("Unlocked.") - stateToken = nil - session = nil - exit(ExitCode.success) - } - } - - RunLoop.main.run() - } - - private static func executeUnlock() { - var exitCode = ExitCode.success - let sem = DispatchSemaphore(value: 0) - - XPCClient.unlock { error in - if let error { - printError(error.localizedDescription) - exitCode = ExitCode.error - } else { - print("Unlocked.") - } - sem.signal() - } - - sem.wait() - exit(exitCode) - } - - private static func executeStatus() { - var exitCode = ExitCode.success - let sem = DispatchSemaphore(value: 0) - - XPCClient.status { isLocked, error in - if let error { - printError(error.localizedDescription) - exitCode = ExitCode.error - } else { - print(isLocked ? "Locked" : "Unlocked") - } - sem.signal() - } - - sem.wait() - exit(exitCode) - } - - // MARK: - Helpers - - private static func printUsage() { - print("Usage: klock ") - } - - private static func printError(_ message: String) { - FileHandle.standardError.write(Data("Error: \(message)\n".utf8)) - } - - private enum ExitCode { - static let success: Int32 = 0 - static let error: Int32 = 1 - } -} +await KlockCLI.run() diff --git a/scripts/install-klock-dev.sh b/scripts/install-klock-dev.sh new file mode 100755 index 0000000..489b022 --- /dev/null +++ b/scripts/install-klock-dev.sh @@ -0,0 +1,212 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +readonly REPO_ROOT +readonly PROJECT_PATH="$REPO_ROOT/KeyboardLocker.xcodeproj" +readonly SCHEME="KeyboardLocker" +readonly CONFIGURATION="Debug" +readonly EXPECTED_IDENTIFIER="io.lzhlovesjyq.keyboardlocker.klock" + +usage() { + cat <<'EOF' +Usage: ./scripts/install-klock-dev.sh [install|status|uninstall] [--bin-dir PATH] + +Commands: + install Build the Debug App and link its bundled klock command (default). + status Report whether the expected command link is installed. + uninstall Remove only the command link owned by this Debug build. + +Options: + --bin-dir PATH Command directory. Defaults to $KLOCK_BIN_DIR or ~/.local/bin. + -h, --help Show this help message. + +This script never copies klock, edits shell profiles, or uses sudo. +EOF +} + +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +action="install" +bin_dir="${KLOCK_BIN_DIR:-$HOME/.local/bin}" + +if (($# > 0)) && [[ "$1" != -* ]]; then + action="$1" + shift +fi + +while (($# > 0)); do + case "$1" in + --bin-dir) + (($# >= 2)) || fail "--bin-dir requires a path." + bin_dir="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +case "$action" in + install|status|uninstall) + ;; + *) + fail "Unknown command: $action" + ;; +esac + +if [[ "$bin_dir" != /* ]]; then + bin_dir="$PWD/$bin_dir" +fi +readonly BIN_DIR="$bin_dir" +readonly DESTINATION="$BIN_DIR/klock" + +resolve_source() { + local build_settings + local target_build_dir + local full_product_name + + build_settings="$( + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -showBuildSettings 2>/dev/null + )" + target_build_dir="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*TARGET_BUILD_DIR = / { print $2; exit }' + )" + full_product_name="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*FULL_PRODUCT_NAME = / { print $2; exit }' + )" + + [[ -n "$target_build_dir" && -n "$full_product_name" ]] \ + || fail "Could not resolve the Debug App path from Xcode build settings." + + printf '%s/%s/Contents/MacOS/klock\n' "$target_build_dir" "$full_product_name" +} + +SOURCE="$(resolve_source)" +readonly SOURCE +readonly APP_PATH="${SOURCE%/Contents/MacOS/klock}" +readonly AGENT_PATH="$APP_PATH/Contents/Library/LoginItems/KeyboardLockerAgent.app" + +link_points_to_source() { + [[ -L "$DESTINATION" ]] || return 1 + [[ "$(readlink "$DESTINATION")" == "$SOURCE" ]] +} + +print_path_guidance() { + case ":${PATH:-}:" in + *":$BIN_DIR:"*) + printf "Run \`hash -r\` or open a new Terminal, then use \`klock --help\`.\n" + ;; + *) + printf 'The command directory is not currently in PATH. Add it in your shell configuration:\n' + printf " export PATH=\"%s:\$PATH\"\n" "$BIN_DIR" + printf "Then open a new Terminal and run \`klock --help\`.\n" + ;; + esac +} + +show_status() { + if link_points_to_source; then + printf 'Installed: %s -> %s\n' "$DESTINATION" "$SOURCE" + return 0 + fi + + if [[ -L "$DESTINATION" ]]; then + printf 'Conflict: %s points to %s\n' "$DESTINATION" "$(readlink "$DESTINATION")" >&2 + return 2 + fi + + if [[ -e "$DESTINATION" ]]; then + printf 'Conflict: a non-symlink item exists at %s\n' "$DESTINATION" >&2 + return 2 + fi + + printf 'Not installed: %s\n' "$DESTINATION" + return 1 +} + +case "$action" in + status) + show_status + ;; + + uninstall) + if link_points_to_source; then + rm "$DESTINATION" + printf 'Removed: %s\n' "$DESTINATION" + exit 0 + fi + + if [[ -L "$DESTINATION" || -e "$DESTINATION" ]]; then + fail "Refusing to remove an item not owned by this Debug build: $DESTINATION" + fi + + printf 'Already not installed: %s\n' "$DESTINATION" + ;; + + install) + if ! link_points_to_source && [[ -L "$DESTINATION" || -e "$DESTINATION" ]]; then + fail "Refusing to replace an existing item: $DESTINATION" + fi + + printf 'Building KeyboardLocker (%s)...\n' "$CONFIGURATION" + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -quiet \ + build + + [[ -x "$SOURCE" ]] || fail "The bundled klock executable is missing: $SOURCE" + [[ -d "$AGENT_PATH" ]] || fail "The bundled KeyboardLocker Agent is missing: $AGENT_PATH" + codesign --verify --strict "$SOURCE" \ + || fail "The bundled klock executable failed code-signature verification." + + signing_info="$(codesign -dvv "$SOURCE" 2>&1)" + identifier="$(printf '%s\n' "$signing_info" | awk -F= '/^Identifier=/ { print $2; exit }')" + team_identifier="$(printf '%s\n' "$signing_info" | awk -F= '/^TeamIdentifier=/ { print $2; exit }')" + app_team_identifier="$(codesign -dvv "$APP_PATH" 2>&1 | awk -F= '/^TeamIdentifier=/ { print $2; exit }')" + agent_team_identifier="$(codesign -dvv "$AGENT_PATH" 2>&1 | awk -F= '/^TeamIdentifier=/ { print $2; exit }')" + [[ "$identifier" == "$EXPECTED_IDENTIFIER" ]] \ + || fail "Unexpected klock signing identifier: ${identifier:-missing}" + [[ -n "$team_identifier" && "$team_identifier" != "not set" ]] \ + || fail "The bundled klock executable does not have a Team identifier." + [[ "$team_identifier" == "$app_team_identifier" \ + && "$team_identifier" == "$agent_team_identifier" ]] \ + || fail "The App, Agent, and klock executable do not share one Team identifier." + + if link_points_to_source; then + printf 'Already installed: %s -> %s\n' "$DESTINATION" "$SOURCE" + print_path_guidance + exit 0 + fi + + if [[ -L "$DESTINATION" || -e "$DESTINATION" ]]; then + fail "Refusing to replace an existing item: $DESTINATION" + fi + + mkdir -p "$BIN_DIR" + [[ -d "$BIN_DIR" && -w "$BIN_DIR" ]] \ + || fail "The command directory is not writable: $BIN_DIR" + ln -s "$SOURCE" "$DESTINATION" + printf 'Installed: %s -> %s\n' "$DESTINATION" "$SOURCE" + print_path_guidance + ;; +esac diff --git a/scripts/reset-keyboardlocker.sh b/scripts/reset-keyboardlocker.sh new file mode 100755 index 0000000..97e99a2 --- /dev/null +++ b/scripts/reset-keyboardlocker.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +readonly REPO_ROOT +readonly PROJECT_PATH="$REPO_ROOT/KeyboardLocker.xcodeproj" +readonly SCHEME="KeyboardLocker" +readonly CONFIGURATION="Debug" + +echo "Building KeyboardLocker ($CONFIGURATION)..." +xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -quiet \ + build + +build_settings="$( + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -showBuildSettings 2>/dev/null +)" +target_build_dir="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*TARGET_BUILD_DIR = / { print $2; exit }' +)" +full_product_name="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' '/^[[:space:]]*FULL_PRODUCT_NAME = / { print $2; exit }' +)" + +if [[ -z "$target_build_dir" || -z "$full_product_name" ]]; then + echo "Error: Could not resolve the Debug app path from Xcode build settings." >&2 + exit 1 +fi + +readonly APP_PATH="$target_build_dir/$full_product_name" +readonly APP_INFO_PLIST="$APP_PATH/Contents/Info.plist" +readonly AGENT_PLIST="$APP_PATH/Contents/Library/LaunchAgents/io.lzhlovesjyq.keyboardlocker.agent.plist" + +if [[ ! -x "$APP_PATH/Contents/MacOS/KeyboardLocker" || ! -f "$AGENT_PLIST" ]]; then + echo "Error: The built KeyboardLocker app is incomplete: $APP_PATH" >&2 + exit 1 +fi + +APP_BUNDLE_IDENTIFIER="$( + /usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_INFO_PLIST" +)" +readonly APP_BUNDLE_IDENTIFIER +APP_EXECUTABLE="$( + /usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP_INFO_PLIST" +)" +readonly APP_EXECUTABLE +AGENT_LABEL="$( + /usr/libexec/PlistBuddy -c 'Print :Label' "$AGENT_PLIST" +)" +readonly AGENT_LABEL +AGENT_PROGRAM="$( + /usr/libexec/PlistBuddy -c 'Print :BundleProgram' "$AGENT_PLIST" +)" +readonly AGENT_PROGRAM +AGENT_EXECUTABLE="$(basename "$AGENT_PROGRAM")" +readonly AGENT_EXECUTABLE + +if pgrep -x "$APP_EXECUTABLE" >/dev/null; then + echo "Quitting KeyboardLocker..." + osascript -e "tell application id \"$APP_BUNDLE_IDENTIFIER\" to quit" >/dev/null + + for ((attempt = 0; attempt < 50; attempt += 1)); do + if ! pgrep -x "$APP_EXECUTABLE" >/dev/null; then + break + fi + sleep 0.1 + done + + if pgrep -x "$APP_EXECUTABLE" >/dev/null; then + echo "Error: KeyboardLocker did not quit; registration was not changed." >&2 + exit 1 + fi +fi + +echo "Resetting KeyboardLocker agent registration..." +PROFILE_OUTPUT="$(mktemp "${TMPDIR:-/tmp}/keyboardlocker-reset.profraw.XXXXXX")" +readonly PROFILE_OUTPUT +trap 'rm -f "$PROFILE_OUTPUT"' EXIT + +LLVM_PROFILE_FILE="$PROFILE_OUTPUT" \ + "$APP_PATH/Contents/MacOS/$APP_EXECUTABLE" --reset-agent-registration +rm -f "$PROFILE_OUTPUT" +trap - EXIT + +SERVICE_TARGET="gui/$(id -u)/$AGENT_LABEL" +readonly SERVICE_TARGET +for ((attempt = 0; attempt < 50; attempt += 1)); do + if ! launchctl print "$SERVICE_TARGET" >/dev/null 2>&1 \ + && ! pgrep -x "$AGENT_EXECUTABLE" >/dev/null; then + echo "KeyboardLocker is reset to an unregistered, stopped state." + exit 0 + fi + sleep 0.1 +done + +echo "Error: The KeyboardLocker agent is still registered or running." >&2 +exit 1 diff --git a/scripts/signed-client-control/main.swift b/scripts/signed-client-control/main.swift new file mode 100644 index 0000000..aff9be3 --- /dev/null +++ b/scripts/signed-client-control/main.swift @@ -0,0 +1,100 @@ +import Foundation + +// Positive control for verify-unsigned-client-refusal.sh. The script signs this temporary binary +// with the same local identity and exact signing identifier as the bundled klock executable. A +// decoded descriptor proves that an authorized client reached the authenticated live Agent. + +final class Outcome { + private let lock = NSLock() + private var recorded: (code: Int32, line: String)? + + func record(_ code: Int32, _ line: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard recorded == nil else { + return false + } + recorded = (code, line) + return true + } + + func current() -> (code: Int32, line: String)? { + lock.lock() + defer { lock.unlock() } + return recorded + } +} + +let outcome = Outcome() +let finished = DispatchSemaphore(value: 0) + +func finish(_ code: Int32, _ line: String) { + if outcome.record(code, line) { + finished.signal() + } +} + +let agentRequirement: String +do { + agentRequirement = try XPCCodeSigningRequirement.sameTeam( + identifiers: [SharedConstants.agentBundleIdentifier] + ) +} catch { + print("RESULT:INDETERMINATE could not construct the Agent requirement: \(error)") + exit(2) +} + +let connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) +connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) +connection.setCodeSigningRequirement(agentRequirement) +connection.invalidationHandler = { + finish(2, "RESULT:INDETERMINATE authorized connection invalidated before any reply") +} + +connection.interruptionHandler = { + finish(2, "RESULT:INDETERMINATE authorized connection interrupted before any reply") +} + +connection.activate() + +guard let service = connection.remoteObjectProxyWithErrorHandler({ error in + finish(2, "RESULT:INDETERMINATE authorized proxy error: \(error.localizedDescription)") +}) as? KeyboardLockerServiceProtocol +else { + print("RESULT:INDETERMINATE could not create an authorized remote object proxy") + exit(2) +} + +service.serviceDescriptor { data, error in + if let error { + finish(2, "RESULT:INDETERMINATE Agent returned an error: \(error.localizedDescription)") + return + } + + do { + let descriptor = try ServiceDescriptor.decodedFromXPC(data) + guard descriptor.agentBundleIdentifier == SharedConstants.agentBundleIdentifier else { + finish( + 2, + "RESULT:INDETERMINATE unexpected Agent identifier: \(descriptor.agentBundleIdentifier)" + ) + return + } + finish(0, "RESULT:LIVE \(descriptor.agentInstanceID.uuidString)") + } catch { + finish(2, "RESULT:INDETERMINATE invalid Agent descriptor: \(error.localizedDescription)") + } +} + +if finished.wait(timeout: .now() + 10) == .timedOut { + print("RESULT:INDETERMINATE authorized Agent did not respond within 10s") + exit(2) +} + +guard let result = outcome.current() else { + print("RESULT:INDETERMINATE semaphore signalled without an outcome") + exit(2) +} + +print(result.line) +exit(result.code) diff --git a/scripts/unsigned-client-probe/main.swift b/scripts/unsigned-client-probe/main.swift new file mode 100644 index 0000000..e241dc0 --- /dev/null +++ b/scripts/unsigned-client-probe/main.swift @@ -0,0 +1,85 @@ +import Foundation + +// Runbook probe: prove that an ad-hoc-signed (non-team) process cannot call the live Agent's +// Mach service. Compiled together with the Common module by verify-unsigned-client-refusal.sh, +// so it uses the real protocol and service name. swiftc output on arm64 is ad-hoc signed, +// which is exactly the class of client the Agent's code-signing requirement must refuse. +// +// Prints one RESULT line and exits: 0 = REFUSED, 1 = REPLIED, 2 = INDETERMINATE. + +/// First outcome wins: refusal may surface as a proxy error, an interruption, and an +/// invalidation all at once, while a reply must never be overwritten by later teardown. +final class Outcome { + private let lock = NSLock() + private var recorded: (code: Int32, line: String)? + + func record(_ code: Int32, _ line: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard recorded == nil else { + return false + } + recorded = (code, line) + return true + } + + func current() -> (code: Int32, line: String)? { + lock.lock() + defer { lock.unlock() } + return recorded + } +} + +let outcome = Outcome() +let finished = DispatchSemaphore(value: 0) + +func finish(_ code: Int32, _ line: String) { + if outcome.record(code, line) { + finished.signal() + } +} + +let connection = NSXPCConnection(machServiceName: SharedConstants.machServiceName) +connection.remoteObjectInterface = NSXPCInterface(with: KeyboardLockerServiceProtocol.self) +connection.invalidationHandler = { + finish(0, "RESULT:REFUSED connection invalidated before any reply") +} + +connection.interruptionHandler = { + finish(0, "RESULT:REFUSED connection interrupted before any reply") +} + +connection.activate() + +guard let service = connection.remoteObjectProxyWithErrorHandler({ error in + finish(0, "RESULT:REFUSED proxy error: \(error.localizedDescription)") +}) as? KeyboardLockerServiceProtocol +else { + print("RESULT:INDETERMINATE could not create a remote object proxy") + exit(2) +} + +// A descriptor reply proves acceptance and identifies the Agent generation if the listener's +// fail-closed requirement is broken. Refusal still surfaces before any exported selector runs. +service.serviceDescriptor { data, error in + if let descriptor = try? ServiceDescriptor.decodedFromXPC(data) { + finish(1, "RESULT:REPLIED \(descriptor.agentInstanceID.uuidString)") + } else if let error { + finish(1, "RESULT:REPLIED agent returned an error: \(error.localizedDescription)") + } else { + finish(1, "RESULT:REPLIED agent returned an invalid descriptor") + } +} + +if finished.wait(timeout: .now() + 10) == .timedOut { + print("RESULT:INDETERMINATE no reply, error, or invalidation within 10s") + exit(2) +} + +guard let result = outcome.current() else { + print("RESULT:INDETERMINATE semaphore signalled without an outcome") + exit(2) +} + +print(result.line) +exit(result.code) diff --git a/scripts/verify-unsigned-client-refusal.sh b/scripts/verify-unsigned-client-refusal.sh new file mode 100755 index 0000000..74615e1 --- /dev/null +++ b/scripts/verify-unsigned-client-refusal.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# verify-unsigned-client-refusal.sh — prove that the live Agent refuses non-team clients. +# +# The refusal probe is meaningful only when a known authorized client can call the same Agent +# generation immediately before and after it. This script compiles a temporary control, signs it +# with the local bundled klock identity, and reads ServiceDescriptor.agentInstanceID on both sides +# of the ad-hoc probe. An unavailable or replaced Agent is INDETERMINATE, never PASS. +# +# Precondition: a locally team-signed Debug App has been built and its Agent is running. Override +# the bundled klock discovery with KEYBOARDLOCKER_AUTHORIZED_CLIENT=/absolute/path/to/klock. If +# the bundled certificate's exact SHA-1 identity is unavailable to the local keychain, set +# KEYBOARDLOCKER_SIGNING_IDENTITY to a usable identity name or SHA-1 hash for the same Team ID. +# +# This is a manual/runbook verification, not wired into CI. It creates only temporary binaries. +# +# Exit codes: 0 = PASS (client refused), 1 = FAIL (Agent answered), 2 = INDETERMINATE. + +set -euo pipefail + +readonly AGENT_PROCESS="KeyboardLockerAgent" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +readonly REPO_ROOT +readonly PROJECT_PATH="$REPO_ROOT/KeyboardLocker.xcodeproj" +readonly COMMON_SOURCES_DIR="$REPO_ROOT/Core/Sources/Common" +readonly CONTROL_SOURCE="$REPO_ROOT/scripts/signed-client-control/main.swift" +readonly PROBE_SOURCE="$REPO_ROOT/scripts/unsigned-client-probe/main.swift" +readonly EXPECTED_CLIENT_IDENTIFIER="io.lzhlovesjyq.keyboardlocker.klock" + +WORK_DIR="$(mktemp -d)" +readonly WORK_DIR +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +indeterminate() { + printf 'INDETERMINATE: %s\n' "$*" >&2 + exit 2 +} + +signing_field() { + local binary="$1" + local field="$2" + codesign -dvvv "$binary" 2>&1 \ + | awk -F= -v field="$field" '$1 == field && !found { print $2; found = 1 }' +} + +resolve_bundled_klock() { + local build_settings + local target_build_dir + local full_product_name + + build_settings="$( + xcodebuild \ + -project "$PROJECT_PATH" \ + -scheme KeyboardLocker \ + -configuration Debug \ + -showBuildSettings 2>/dev/null + )" + target_build_dir="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' \ + '/^[[:space:]]*TARGET_BUILD_DIR = / && !found { print $2; found = 1 }' + )" + full_product_name="$( + printf '%s\n' "$build_settings" \ + | awk -F ' = ' \ + '/^[[:space:]]*FULL_PRODUCT_NAME = / && !found { print $2; found = 1 }' + )" + [[ -n "$target_build_dir" && -n "$full_product_name" ]] || return 1 + printf '%s/%s/Contents/MacOS/klock\n' "$target_build_dir" "$full_product_name" +} + +run_control() { + local output + local control_exit + + set +e + output="$("$WORK_DIR/signed-client-control" 2>&1)" + control_exit=$? + set -e + printf '%s\n' "$output" | sed 's/^/ /' >&2 + [[ "$control_exit" -eq 0 ]] || return 1 + printf '%s\n' "$output" | awk '/^RESULT:LIVE / && !found { print $2; found = 1 }' +} + +printf "==> Checking precondition: agent process '%s' is running\n" "$AGENT_PROCESS" +agent_pid="$(pgrep -x "$AGENT_PROCESS" | head -1 || true)" +[[ -n "$agent_pid" ]] || indeterminate \ + "$AGENT_PROCESS is not running; an unavailable service cannot prove refusal." +printf ' agent process exists (pid %s); the signed control will prove XPC liveness\n' "$agent_pid" + +authorized_client="${KEYBOARDLOCKER_AUTHORIZED_CLIENT:-}" +if [[ -z "$authorized_client" ]]; then + authorized_client="$(resolve_bundled_klock)" || indeterminate \ + "Could not resolve the Debug App's bundled klock executable." +fi +[[ "$authorized_client" == /* ]] || indeterminate \ + "KEYBOARDLOCKER_AUTHORIZED_CLIENT must be an absolute path." +[[ -x "$authorized_client" ]] || indeterminate \ + "The authorized client is not executable: $authorized_client" +codesign --verify --strict "$authorized_client" >/dev/null 2>&1 || indeterminate \ + "The authorized client has no valid code signature: $authorized_client" + +client_identifier="$(signing_field "$authorized_client" Identifier)" || indeterminate \ + "Could not inspect the authorized client's signing identifier." +client_team="$(signing_field "$authorized_client" TeamIdentifier)" || indeterminate \ + "Could not inspect the authorized client's Team identifier." +[[ "$client_identifier" == "$EXPECTED_CLIENT_IDENTIFIER" ]] || indeterminate \ + "Expected signing identifier $EXPECTED_CLIENT_IDENTIFIER, found ${client_identifier:-missing}." +[[ -n "$client_team" && "$client_team" != "not set" ]] || indeterminate \ + "The authorized client does not carry a Team identifier." + +signing_identity="${KEYBOARDLOCKER_SIGNING_IDENTITY:-}" +if [[ -z "$signing_identity" ]]; then + certificate_prefix="$WORK_DIR/authorized-client-certificate" + certificate_error="" + if ! certificate_error="$( + codesign -d --extract-certificates="$certificate_prefix" "$authorized_client" 2>&1 + )"; then + indeterminate \ + "Could not extract the authorized client's signing certificate: ${certificate_error:-unknown error}" + fi + [[ -f "${certificate_prefix}0" ]] || indeterminate \ + "The authorized client did not expose a leaf signing certificate." + if ! signing_identity="$( + openssl x509 \ + -inform DER \ + -in "${certificate_prefix}0" \ + -noout \ + -fingerprint \ + -sha1 2>/dev/null \ + | awk -F= 'NF == 2 { gsub(":", "", $2); print $2 }' + )"; then + indeterminate "Could not inspect the authorized client's leaf certificate." + fi +fi +[[ "$signing_identity" =~ ^[0-9A-Fa-f]{40}$ || -n "${KEYBOARDLOCKER_SIGNING_IDENTITY:-}" ]] \ + || indeterminate \ + "Could not determine the authorized client's exact signing identity." + +printf '==> Compiling and team-signing the authorized liveness control\n' +xcrun swiftc \ + "$CONTROL_SOURCE" \ + "$COMMON_SOURCES_DIR"/*.swift \ + -o "$WORK_DIR/signed-client-control" || indeterminate \ + "Could not compile the authorized liveness control." +codesign \ + --force \ + --identifier "$EXPECTED_CLIENT_IDENTIFIER" \ + --sign "$signing_identity" \ + --timestamp=none \ + "$WORK_DIR/signed-client-control" >/dev/null 2>&1 || indeterminate \ + "Could not sign the liveness control with '$signing_identity'." +codesign --verify --strict "$WORK_DIR/signed-client-control" >/dev/null 2>&1 || indeterminate \ + "The signed liveness control failed signature verification." +control_identifier="$(signing_field "$WORK_DIR/signed-client-control" Identifier)" \ + || indeterminate "Could not inspect the liveness control's signing identifier." +control_team="$(signing_field "$WORK_DIR/signed-client-control" TeamIdentifier)" \ + || indeterminate "Could not inspect the liveness control's Team identifier." +[[ "$control_identifier" == "$EXPECTED_CLIENT_IDENTIFIER" && "$control_team" == "$client_team" ]] \ + || indeterminate "The liveness control does not match the authorized client's identity." +printf ' control identity: %s / Team %s\n' "$control_identifier" "$control_team" + +printf '==> Positive control before refusal probe\n' +before_instance="$(run_control)" || indeterminate \ + "An authorized client could not prove that the Agent was live before the refusal probe." +[[ "$before_instance" =~ ^[0-9A-Fa-f-]{36}$ ]] || indeterminate \ + "The first positive control did not return a valid Agent instance ID." + +printf '==> Compiling the ad-hoc refusal probe\n' +xcrun swiftc \ + "$PROBE_SOURCE" \ + "$COMMON_SOURCES_DIR"/*.swift \ + -o "$WORK_DIR/unsigned-client-probe" || indeterminate \ + "Could not compile the ad-hoc refusal probe." +probe_team="$(signing_field "$WORK_DIR/unsigned-client-probe" TeamIdentifier)" \ + || indeterminate "Could not inspect the refusal probe's Team identifier." +[[ "$probe_team" == "not set" ]] || indeterminate \ + "The refusal probe unexpectedly carries Team identifier ${probe_team:-missing}." + +printf '==> Running the ad-hoc probe against Agent instance %s\n' "$before_instance" +set +e +probe_output="$("$WORK_DIR/unsigned-client-probe" 2>&1)" +probe_exit=$? +set -e +printf '%s\n' "$probe_output" | sed 's/^/ /' + +printf '==> Positive control after refusal probe\n' +after_instance="$(run_control)" || indeterminate \ + "An authorized client could not prove that the Agent remained live after the refusal probe." +[[ "$after_instance" == "$before_instance" ]] || indeterminate \ + "The Agent changed during the probe ($before_instance -> ${after_instance:-unknown})." + +case "$probe_exit" in + 0) + printf 'PASS: Agent instance %s refused an ad-hoc non-team client; matching authorized controls succeeded immediately before and after.\n' \ + "$before_instance" + ;; + 1) + printf 'FAIL: Agent instance %s replied to an ad-hoc non-team client.\n' "$before_instance" >&2 + exit 1 + ;; + *) + indeterminate "The refusal probe produced no decisive result (exit $probe_exit)." + ;; +esac