Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Guidance for Claude Code when working in this repository.

KeyboardLocker suppresses keyboard-originated events exposed through CGEventTap on macOS, including standard keys and system-defined controls such as volume, brightness, media playback, eject, and power. Mouse and trackpad input remain available. Hardware or system-reserved input that macOS does not expose to CGEventTap is outside this guarantee. Three-tier architecture over XPC (Mach service `io.lzhlovesjyq.keyboardlocker.agent`):

- **KeyboardLocker** (menu-bar App, Agent lifecycle coordinator, and one-shot system actions) — imports `Client` and `SystemSurfaces`
- **KeyboardLocker** (menu-bar App with a status/settings popover, Agent lifecycle coordinator, and one-shot system actions) — imports `Client` and `SystemSurfaces`
- **KeyboardLockerAgent** (XPC service, runs the lock engine, holds Accessibility permission) — imports `Service`
- **klock** (CLI) — imports `Client`
- **KeyboardLockerWidgets** (WidgetKit extension hosting Widget and macOS Control) — imports `Client` and `SystemSurfaces`
Expand Down
49 changes: 48 additions & 1 deletion Core/Sources/Client/XPCClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,50 @@ public final class XPCClient: @unchecked Sendable {
return try KeyboardLockerSettings.decodedFromXPC(data)
}

/// Validates and persists settings in the Agent, returning the values it actually stored.
///
/// The reply — not the request — is authoritative: the Agent normalizes values, so adopting the
/// locally sent payload would show the user a configuration that is not what is stored.
///
/// A running lock keeps its active configuration; the new values seed the next lock.
public func applySettings(
_ settings: KeyboardLockerSettings
) async throws -> KeyboardLockerSettings {
let connection = try await negotiatedConnection(
requiring: [.applySettings]
)
let payload = try settings.encodedForXPC()

do {
let data: Data? = try await withProxyReturning(
using: connection,
proxyErrorDisposition: .lostReply
) { service, resume in
service.applySettings(payload) { resume($0, $1) }
}
return try KeyboardLockerSettings.decodedFromXPC(data)
} catch XPCClientError.timedOut, is LostReplyError {
// The write is idempotent, but re-sending it would hide whether the first attempt landed.
// Read the authoritative value instead: matching the request proves the write took effect.
return try await confirmAmbiguousSettingsWrite(expecting: settings)
}
}

/// Recovers a lost `applySettings` reply by comparing the Agent's stored settings with the
/// request. Validation normalizes values, so the comparison uses the same normalization rather
/// than requiring byte equality with the raw request.
private func confirmAmbiguousSettingsWrite(
expecting settings: KeyboardLockerSettings
) async throws -> KeyboardLockerSettings {
guard let expected = try? settings.validated(),
let stored = try? await currentSettings(),
stored == expected
else {
throw XPCClientError.operationOutcomeUnknown
}
return stored
}

// MARK: - Connection Management

/// Invalidates the cached connection so a subsequent call resolves the currently registered
Expand Down Expand Up @@ -583,9 +627,12 @@ public final class XPCClient: @unchecked Sendable {
/// floor prevents an inconsistent descriptor from granting a selector before its wire shape was
/// introduced, even if it advertises the capability string.
enum XPCFeatureNegotiation {
private static let minimumMinorByCapability: [ServiceCapability: Int] = [
/// Visible to `ClientTests` so the introduction-minor guard can assert against this table
/// directly instead of maintaining a second copy that would silently drift out of date.
static let minimumMinorByCapability: [ServiceCapability: Int] = [
.accessibilityPrompt: 0,
.accessibilityStatus: 0,
.applySettings: 8,
.committedReplacementDrain: 1,
.currentSettings: 1,
.currentSettingsWithError: 2,
Expand Down
106 changes: 104 additions & 2 deletions Core/Sources/Common/KeyboardLockerSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ public struct KeyboardLockerSettings: Equatable, Hashable, Codable, Sendable {
self.modifierFlags = modifierFlags
}

/// Filters CapsLock and other irrelevant modifiers to ensure reliable matching
private static let relevantModifierMask: CGEventFlags = [
/// Filters CapsLock and other irrelevant modifiers to ensure reliable matching.
/// Shared with `hasModifier` so validation and matching normalize against one mask.
static let relevantModifierMask: CGEventFlags = [
.maskCommand,
.maskControl,
.maskAlternate,
Expand Down Expand Up @@ -114,6 +115,107 @@ public extension KeyboardLockerSettings.Hotkey {
}
}

// MARK: - Validation

/// Why a candidate settings value cannot become the Agent's active configuration.
///
/// These are lock-out guardrails, not style preferences: each rejected case describes a
/// configuration from which a locked keyboard could not be recovered by the intended gesture.
public enum KeyboardLockerSettingsValidationError: Error, Equatable, LocalizedError {
case autoUnlockNotFinite
case autoUnlockOutOfRange(ClosedRange<TimeInterval>)
case hotkeyMissingModifier
case hotkeyUnmappable

public var errorDescription: String? {
switch self {
case .autoUnlockNotFinite:
"The auto-unlock timeout is not a finite number of seconds."

case let .autoUnlockOutOfRange(range):
"""
The auto-unlock timeout must be between \(Int(range.lowerBound)) and \
\(Int(range.upperBound)) seconds.
"""

case .hotkeyMissingModifier:
"The unlock hotkey needs at least one modifier key (⌃, ⌥, ⇧, or ⌘)."

case .hotkeyUnmappable:
"The unlock hotkey uses a key this keyboard layout cannot display."
}
}

public var recoverySuggestion: String? {
switch self {
case .hotkeyMissingModifier:
"A hotkey without modifiers can be consumed by the frontmost app or an input method before it reaches KeyboardLocker."

case .hotkeyUnmappable:
"Choose a key that appears on the current keyboard layout so the hotkey can be shown in the app, its notification, and the widget."

case .autoUnlockNotFinite, .autoUnlockOutOfRange:
nil
}
}
}

public extension KeyboardLockerSettings {
/// Bounds for a timed auto-unlock. The lower bound keeps a deliberate lock from expiring before
/// the user has finished what they locked the keyboard for; the upper bound keeps the fail-safe
/// within a window a user would actually wait out rather than force-restarting the machine.
static let allowedAutoUnlockRange: ClosedRange<TimeInterval> = 5...3600

/// Returns the normalized settings the Agent may store, or throws when a value would leave a
/// locked keyboard unrecoverable by its configured gesture.
///
/// Lives in `Common` because the Agent owns enforcement while wrappers need the same rules for
/// immediate feedback — duplicating them in a wrapper would let the two drift apart.
func validated() throws -> Self {
_ = try unlockHotkey.validated()

var normalized = self
if case let .timed(seconds) = autoUnlockPolicy {
guard seconds.isFinite else {
throw KeyboardLockerSettingsValidationError.autoUnlockNotFinite
}
// Round before the range check so a value that only differs sub-second from a bound is
// accepted rather than rejected for a difference the UI cannot even express.
let wholeSeconds = seconds.rounded()
guard Self.allowedAutoUnlockRange.contains(wholeSeconds) else {
throw KeyboardLockerSettingsValidationError.autoUnlockOutOfRange(
Self.allowedAutoUnlockRange
)
}
normalized.autoUnlockPolicy = .timed(seconds: wholeSeconds)
}
return normalized
}
}

public extension KeyboardLockerSettings.Hotkey {
/// Whether the hotkey carries at least one modifier that survives event-tap normalization.
///
/// Deliberately measured against the same mask `matches(keyCode:flags:)` normalizes with, so
/// validation can never accept a modifier the matcher would then discard.
var hasModifier: Bool {
!modifierFlags.intersection(Self.relevantModifierMask).isEmpty
}

/// Returns the hotkey when it can actually unlock a locked keyboard, or throws explaining why
/// it cannot. Split out from `KeyboardLockerSettings.validated()` so a hotkey picker can give
/// per-keystroke feedback against the same rule the Agent enforces.
func validated() throws -> Self {
guard hasModifier else {
throw KeyboardLockerSettingsValidationError.hotkeyMissingModifier
}
guard KeyCodeConverter.stringFromKeyCode(keyCode, modifiers: modifierFlags) != nil else {
throw KeyboardLockerSettingsValidationError.hotkeyUnmappable
}
return self
}
}

// MARK: - XPC Serialization

public enum KeyboardLockerSettingsCodingError: Error, Equatable, LocalizedError {
Expand Down
4 changes: 3 additions & 1 deletion Core/Sources/Common/ServiceDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public struct ServiceCapability: Codable, Equatable, Hashable, RawRepresentable,

public static let accessibilityPrompt = Self(rawValue: "accessibility-prompt")
public static let accessibilityStatus = Self(rawValue: "accessibility-status")
public static let applySettings = Self(rawValue: "apply-settings")
public static let currentSettings = Self(rawValue: "current-settings")
public static let currentSettingsWithError = Self(
rawValue: "current-settings-with-error"
Expand Down Expand Up @@ -79,11 +80,12 @@ public struct ServiceReplacementPhase: Codable, Equatable, RawRepresentable, Sen

/// Current bootstrap contract advertised by the bundled Agent.
public enum ServiceContract {
public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 7)
public static let protocolVersion = ServiceProtocolVersion(major: 1, minor: 8)

public static let requiredCapabilities: Set<ServiceCapability> = [
.accessibilityPrompt,
.accessibilityStatus,
.applySettings,
.committedReplacementDrain,
.currentSettings,
.currentSettingsWithError,
Expand Down
8 changes: 8 additions & 0 deletions Core/Sources/Common/Shared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,12 @@ public protocol KeyboardLockerServiceProtocol {

/// Returns the Agent's current settings and reports serialization failures explicitly.
func currentSettingsWithError(reply: @escaping (Data?, Error?) -> Void)

/// Validates and persists JSON-encoded `KeyboardLockerSettings`, replying with the values the
/// Agent actually stored so callers never assume their own payload became authoritative.
///
/// A running lock deliberately keeps its active settings, unlock gesture, start time, and
/// auto-unlock deadline: re-applying settings mid-lock would restart the fail-safe window, and
/// disabling auto-unlock would cancel it outright. New values seed the next lock instead.
func applySettings(_ data: Data, reply: @escaping (Data?, Error?) -> Void)
}
57 changes: 55 additions & 2 deletions Core/Sources/Service/AgentService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ protocol LockEngineServing {

extension LockEngine: LockEngineServing {}

/// The settings persistence surface `AgentService` writes through. Seamed so `ServiceTests` can
/// assert what actually reached storage instead of driving `UserDefaults.standard`.
protocol SettingsPersisting {
func save(_ settings: KeyboardLockerSettings) throws
}

extension KeyboardLockerSettingsStore: SettingsPersisting {}

/// XPC service implementation. Owns the settings source of truth and drives the single
/// global `LockEngine`. All wrappers reach the lock exclusively through this object.
///
Expand All @@ -27,7 +35,8 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche
private static let replacementPreparationDuration: TimeInterval = 30

@MainActor private let descriptorResult: Result<ServiceDescriptor, Error>
@MainActor private let settings: KeyboardLockerSettings
@MainActor private var settings: KeyboardLockerSettings
@MainActor private let settingsStore: any SettingsPersisting
@MainActor private let engine: any LockEngineServing
@MainActor private var lockStatusNotifier: LockStatusNotifier?
@MainActor private let expirationScheduler: MainActorTimerScheduler
Expand All @@ -38,11 +47,13 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche
init(
descriptorResult: Result<ServiceDescriptor, Error>,
settings: KeyboardLockerSettings,
settingsStore: any SettingsPersisting,
engine: any LockEngineServing,
expirationScheduler: @escaping MainActorTimerScheduler
) {
self.descriptorResult = descriptorResult
self.settings = settings
self.settingsStore = settingsStore
self.engine = engine
self.expirationScheduler = expirationScheduler
super.init()
Expand All @@ -62,9 +73,11 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche
notifier?.lockStateDidChange()
}

let settingsStore = KeyboardLockerSettingsStore()
self.init(
descriptorResult: Result { try Self.makeServiceDescriptor() },
settings: KeyboardLockerSettingsStore().load(),
settings: settingsStore.load(),
settingsStore: settingsStore,
engine: engine,
expirationScheduler: liveMainActorTimerScheduler
)
Expand Down Expand Up @@ -368,6 +381,46 @@ public final class AgentService: NSObject, KeyboardLockerServiceProtocol, @unche
}
}

/// Validates, persists, and adopts new settings, replying with the stored values.
///
/// A running lock keeps its active configuration on purpose. `LockEngine.updateSettings` re-arms
/// the auto-unlock timer from the moment it is called — and cancels it outright for a `.disabled`
/// policy — so applying settings mid-lock could extend or entirely remove the fail-safe that
/// recovers a locked keyboard. New values seed the next lock instead.
public func applySettings(
_ data: Data,
reply: @escaping (Data?, Error?) -> Void
) {
let transferredReply = TransferredXPCReply(reply)
let candidate: KeyboardLockerSettings
do {
candidate = try KeyboardLockerSettings.decodedFromXPC(data)
} catch {
transferredReply.body(nil, error)
return
}

executeOnMainActor(reply: transferredReply) { reply in
do {
// Validate before touching storage so a rejected payload leaves no partial state.
let validated = try candidate.validated()
// A pending replacement refuses configuration changes for the same reason it refuses new
// locks: the agent is about to be torn down and must not adopt state it cannot honor.
try self.ensureAcceptingLockRequests()
let encoded = try validated.encodedForXPC()

try self.settingsStore.save(validated)
self.settings = validated
if !self.engine.isLocked {
self.engine.updateSettings(validated)
}
reply(encoded, nil)
} catch {
reply(nil, error)
}
}
}

// MARK: - Helpers

@MainActor
Expand Down
10 changes: 10 additions & 0 deletions Core/Sources/Service/KeyboardLockerSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ final class KeyboardLockerSettingsStore {
}
}

/// Persists settings as the Agent's new source of truth.
///
/// Unlike `load()`, a failure here must propagate: the read path falls back to defaults so the
/// Agent can still start, but silently discarding a write would leave the caller believing its
/// configuration is active while the Agent keeps using the previous one.
func save(_ settings: KeyboardLockerSettings) throws {
let data = try encoder.encode(settings)
userDefaults.set(data, forKey: storageKey)
}

private func registerDefaultsIfNeeded() {
guard userDefaults.object(forKey: storageKey) == nil else {
return
Expand Down
Loading
Loading