diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift index 671922d2f..a62e667a6 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift @@ -682,7 +682,9 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { /// Persist exact-device Caps consent only after admitted runtime restoration. /// The change stops the runtime to release the Caps lease; if it was running, /// it is started again afterwards, whether the change committed or was refused. - func setCapsSelection(_ selection: SessionCapsSelection?, automaticSetupDisabled: Bool? = nil) async -> Bool { + func setCapsSelection(_ selection: SessionCapsSelection?, automaticSetupDisabled: Bool? = nil, + selectedKeyboardOnly: Bool? = nil) async -> Bool + { let wasRunning = serviceLifecycleCoordinator.sessionWantsRunning let changed: Bool do { @@ -700,6 +702,26 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { if let automaticSetupDisabled { UserDefaults.standard.set(automaticSetupDisabled, forKey: SessionCapsRuntimeSupport.automaticSetupDisabledKey) } + if let selectedKeyboardOnly { + UserDefaults.standard.set(selectedKeyboardOnly, forKey: SessionCapsRuntimeSupport.selectedKeyboardOnlyKey) + } + }) + } + } catch { changed = false } + await serviceLifecycleCoordinator.resumeAfterCapsSelectionChange(wasRunning: wasRunning) + return changed + } + + /// Apply keyboard exceptions only after the current leases were restored. + /// A failed stop leaves both the preference and live mappings unchanged. + func setCapsExcludedKeyboardIDs(_ ids: Set) async -> Bool { + guard ids.count <= 128, ids.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 4096 }) else { return false } + let wasRunning = serviceLifecycleCoordinator.sessionWantsRunning + let changed: Bool + do { + changed = try await configurationService.operationGate.withOperation { [serviceLifecycleCoordinator] _ in + await serviceLifecycleCoordinator.changeCapsSelection(verify: {}, commit: { + UserDefaults.standard.set(ids.sorted(), forKey: SessionCapsRuntimeSupport.excludedKeyboardsKey) }) } } catch { changed = false } diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift index 75e42d34e..24adbcb04 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator+SessionRuntime.swift @@ -131,12 +131,22 @@ extension ServiceLifecycleCoordinator { { // The keyboard went away between the choice and the // worker's own check, so the worker runs paused. - sessionCapsIntermediate = nil + if SessionCapsRuntimeSupport.selectedKeyboardOnly() { + sessionCapsIntermediate = nil + } sessionCapsNotice = nil - sessionCapsPaused = Self.capsPausedMessage( - disconnected: SessionCapsSelectionStore.savedName ?? "The keyboard chosen for Caps Lock", - setupOff: false - ) + sessionCapsPaused = SessionCapsRuntimeSupport.selectedKeyboardOnly() + ? Self.capsPausedMessage( + disconnected: SessionCapsSelectionStore.savedName ?? "The keyboard chosen for Caps Lock", + setupOff: false + ) + : "Caps Lock rules could not start on a connected keyboard. Your other rules keep working. Check keyboard details in Settings > General." + } + if !SessionCapsRuntimeSupport.selectedKeyboardOnly(), + let coverage = report.capsCoverage, + let warning = Self.capsCoverageWarning(coverage) + { + sessionCapsPaused = warning } onError?(nil) onWarning?(sessionCapsNotice ?? sessionCapsPaused) @@ -389,12 +399,25 @@ extension ServiceLifecycleCoordinator { guard sessionStartIsCurrent(generation), sessionNonce == expectedNonce, let application = sessionApplication, application.processIdentifier == pid else { return false } if !application.isTerminated { - if currentSessionReport() == nil { + guard let report = currentSessionReport() else { _ = try? await sessionOperationGate.withOperation { [self] _ in await stopUnresponsiveSession(expectedGeneration: generation, nonce: expectedNonce) } return false } + if !SessionCapsRuntimeSupport.selectedKeyboardOnly(), + sessionCapsIntermediate != nil + { + let coverageWarning = report.capsCoverage.flatMap(Self.capsCoverageWarning) + let warning = report.managedCapsGeneration == expectedNonce ? coverageWarning + : (coverageWarning ?? sessionCapsPaused + ?? "Caps Lock rules are waiting for a compatible keyboard. Your other rules keep working.") + if warning != sessionCapsPaused { + sessionCapsPaused = warning + onWarning?(warning) + onStateChanged?() + } + } return true } let terminalReport = recoverSessionOutputs(for: application) @@ -748,7 +771,14 @@ extension ServiceLifecycleCoordinator { // Always say explicitly; the worker must not fall back to a saved // selection this launch declined. var passed = SessionCapsRuntimeSupport.noSelection - if let selection = try await chooseCapsSelection() { + if !SessionCapsRuntimeSupport.selectedKeyboardOnly(), + let usage = await chooseAllCapsIntermediate() + { + passed = SessionCapsRuntimeSupport.allKeyboardLaunchValue(intermediateUsage: usage) + sessionCapsIntermediate = usage + } else if SessionCapsRuntimeSupport.selectedKeyboardOnly(), + let selection = try await chooseCapsSelection() + { passed = try String(decoding: JSONEncoder().encode(selection), as: UTF8.self) sessionCapsIntermediate = selection.intermediateUsage } @@ -784,6 +814,38 @@ extension ServiceLifecycleCoordinator { /// mapped, a keyboard shortcut, or a new rule since the last start moves /// the reservation to the next free key, saved so Settings shows it. /// nil when the profile does not need managed Caps. + private func chooseAllCapsIntermediate() async -> UInt32? { + let path = KeyPathConstants.Config.mainConfigPath + guard !UserDefaults.standard.bool(forKey: SessionCapsRuntimeSupport.automaticSetupDisabledKey) else { + let admission = SessionCapsRuntimeSupport.admit(configPath: path, runtimeHost: .current()) + if admission.capsPaused { + sessionCapsPaused = Self.capsPausedMessage(disconnected: nil, setupOff: true) + } + return nil + } + let occupied: Set + do { + occupied = try await Task.detached { try SessionCapsRuntimeSupport.occupiedIntermediates() }.value + } catch { + AppLogger.shared.warn("[SessionRuntime] Could not read keyboard mappings for the Caps intermediate: \(error)") + occupied = [] + } + let admission = SessionCapsRuntimeSupport.admit(configPath: path, runtimeHost: .current(), + requireSelection: false, occupied: occupied) + onAdmissionEnvironment?(.init(intermediateUsage: admission.managedCaps ? admission.intermediateUsage : nil, + occupied: occupied, foreignCapsOwner: nil)) + guard admission.managedCaps else { + if case let .invalid(reason) = admission.result { + sessionCapsPaused = "Caps Lock rules are off: \(reason). Your other rules keep working." + } + return nil + } + UserDefaults.standard.set(admission.intermediateUsage, forKey: SessionCapsRuntimeSupport.allIntermediateKey) + return admission.intermediateUsage + } + + /// The advanced single-keyboard path retains exact-device consent and + /// rechecks its intermediate before each worker launch. private func chooseCapsSelection() async throws -> SessionCapsSelection? { let path = KeyPathConstants.Config.mainConfigPath let needed = SessionCapsRuntimeSupport.admit(configPath: path, runtimeHost: .current()) @@ -862,6 +924,19 @@ extension ServiceLifecycleCoordinator { intended && managedCapsGeneration != nonce } + nonisolated static func capsCoverageWarning(_ coverage: SessionRuntimeReport.CapsCoverage) -> String? { + if coverage.inventoryUnavailable == true { + return "KeyPath cannot check newly connected keyboards for Caps Lock rules right now. Existing keyboard rules keep working; check Settings > General." + } + if coverage.covered < coverage.eligible { + return "Caps Lock rules work on \(coverage.covered) of \(coverage.eligible) selected keyboards. Check keyboard details in Settings > General. Your other rules keep working." + } + if coverage.withheld > 0 { + return "Caps Lock rules could not be verified on \(coverage.withheld) connected keyboard\(coverage.withheld == 1 ? "" : "s"). Check Settings > General. Your other rules keep working." + } + return nil + } + /// While a disconnected keyboard's choice is kept (`displaced`), check /// every two seconds for it at the same port and restart onto it. Polls /// hidutil only in that state; any other start or a new boot ends it. diff --git a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift index 77c0ea5c8..ebe2ac9d3 100644 --- a/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ServiceLifecycleCoordinator.swift @@ -430,6 +430,7 @@ final class ServiceLifecycleCoordinator { } #endif guard !TestEnvironment.isTestHostProcess, + SessionCapsRuntimeSupport.selectedKeyboardOnly(), SessionCapsRuntimeSupport.validate(configPath: KeyPathConstants.Config.mainConfigPath, runtimeHost: .current(), requireSelection: false).managedCaps, !UserDefaults.standard.bool(forKey: SessionCapsRuntimeSupport.automaticSetupDisabledKey) else { return } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsMappingFleet.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsMappingFleet.swift new file mode 100644 index 000000000..d9e7959f9 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsMappingFleet.swift @@ -0,0 +1,215 @@ +import Darwin +import Foundation +import KeyPathCore + +/// Owns one existing, crash-recoverable lease per physical keyboard. The root +/// lease lock is also the parent's termination guard: no hidutil writer can be +/// launched after the parent has begun killing the worker. +struct SessionCapsMappingFleet { + typealias Policy = SessionCapsMappingPolicy + + let directory: URL + let backend: SessionCapsMappingLease.Backend + + private var fleetDirectory: URL { + directory.appendingPathComponent("keyboards", isDirectory: true) + } + + private func deviceDirectory(_ id: UInt64) -> URL { + fleetDirectory.appendingPathComponent(String(id), isDirectory: true) + } + + @discardableResult + private func ensureFleetDirectory(create: Bool) throws -> Bool { + var info = stat() + if lstat(fleetDirectory.path, &info) != 0 { + guard errno == ENOENT else { throw SessionCapsMappingLease.Refusal.unsafeDirectory } + guard create else { return false } + if mkdir(fleetDirectory.path, 0o700) != 0, errno != EEXIST { + throw SessionCapsMappingLease.Refusal.unsafeDirectory + } + guard lstat(fleetDirectory.path, &info) == 0 else { + throw SessionCapsMappingLease.Refusal.unsafeDirectory + } + } + guard info.st_mode & S_IFMT == S_IFDIR, info.st_uid == getuid(), + info.st_mode & 0o7777 == 0o700 else { throw SessionCapsMappingLease.Refusal.unsafeDirectory } + return true + } + + private func withRootGuard(owner: Policy.Owner, _ operation: () throws -> T) throws -> T { + let root = SessionCapsMappingLease(directory: directory, backend: backend) + let guardHandle = try root.acquireTerminationGuard(expectedOwner: owner) + defer { guardHandle.release() } + return try operation() + } + + /// A failed device is left uncovered while working keyboards remain live. + /// An uncertain write is restored under its exact owner before continuing. + /// If safe restoration fails, the whole admission fails closed. + func acquire(owner: Policy.Owner, configSHA256: String, devices: [Policy.DeviceIdentity], + intermediate: UInt64) throws -> (records: [Policy.Record], failures: [UInt64: String]) + { + try withRootGuard(owner: owner) { + try ensureFleetDirectory(create: true) + var records: [Policy.Record] = [] + var failures: [UInt64: String] = [:] + for device in devices { + let lease = SessionCapsMappingLease(directory: deviceDirectory(device.registryEntryID), backend: backend) + do { + let record = try lease.acquire(owner: owner, configSHA256: configSHA256, + device: device, intermediate: intermediate) + records.append(record) + } catch { + failures[device.registryEntryID] = String(describing: error) + if let pending = try lease.pendingRecord() { + guard pending.owner == owner else { throw SessionCapsMappingLease.Refusal.wrongOwner } + try lease.restore(expectedOwner: owner, bootSessionUUID: owner.bootSessionUUID) + } + } + } + return (records, failures) + } + } + + func pendingRecords(owner: Policy.Owner) throws -> [Policy.Record] { + try withRootGuard(owner: owner) { try recordsOnDisk() } + } + + /// Read-only status, never used to authorize a write. Only verified live + /// worker records count; an old journal is not evidence of coverage. + func activeDeviceIDs(bootSessionUUID: String, + processAlive: (Int32) -> Bool = SystemStateProvider.isProcessAlive) throws -> Set + { + var active = Set() + for record in try recordsOnDisk() where record.owner.bootSessionUUID == bootSessionUUID + && processAlive(record.owner.workerPID) + { + if (try? SessionCapsRuntimeSupport.verifyActive( + directory: deviceDirectory(record.device.registryEntryID), owner: record.owner, + backend: backend, bootSessionUUID: { bootSessionUUID } + )) != nil { + active.insert(record.device.registryEntryID) + } + } + return active + } + + /// The worker calls this off the main run loop. A removed service is + /// retired; a newly arrived one is acquired under the same root exclusion. + func reconcile(owner: Policy.Owner, configSHA256: String, devices: [Policy.DeviceIdentity], + intermediate: UInt64, bootSessionUUID: String, + allowNewDevices: Bool = true) throws -> (records: [Policy.Record], failures: [UInt64: String]) + { + try withRootGuard(owner: owner) { + var failures: [UInt64: String] = [:] + let present = Set(devices.map(\.registryEntryID)) + for record in try recordsOnDisk() { + guard record.owner == owner else { throw SessionCapsMappingLease.Refusal.wrongOwner } + let lease = SessionCapsMappingLease(directory: deviceDirectory(record.device.registryEntryID), backend: backend) + if !present.contains(record.device.registryEntryID), + try !backend.registryEntryExists(record.device.registryEntryID) + { + try lease.restore(expectedOwner: owner, bootSessionUUID: bootSessionUUID) + } else { + do { + try SessionCapsRuntimeSupport.verifyActive( + directory: deviceDirectory(record.device.registryEntryID), owner: owner, backend: backend, + bootSessionUUID: { bootSessionUUID } + ) + } catch { + try lease.restore(expectedOwner: owner, bootSessionUUID: bootSessionUUID) + failures[record.device.registryEntryID] = String(describing: error) + } + } + } + var records = try recordsOnDisk() + let leased = Set(records.map(\.device.registryEntryID)) + for device in devices where allowNewDevices && !leased.contains(device.registryEntryID) + && failures[device.registryEntryID] == nil + { + let lease = SessionCapsMappingLease(directory: deviceDirectory(device.registryEntryID), backend: backend) + do { + try records.append(lease.acquire(owner: owner, configSHA256: configSHA256, + device: device, intermediate: intermediate)) + } catch { + failures[device.registryEntryID] = String(describing: error) + if let pending = try lease.pendingRecord() { + guard pending.owner == owner else { throw SessionCapsMappingLease.Refusal.wrongOwner } + try lease.restore(expectedOwner: owner, bootSessionUUID: bootSessionUUID) + } + } + } + return (records, failures) + } + } + + /// The old single-keyboard record remains in the root directory. Fleet + /// records are recovered independently, preserving failures for a later + /// exact-owner retry instead of discarding working devices' recovery data. + func recover(expectedOwner: Policy.Owner?, bootSessionUUID: String, + processAlive: (Int32) -> Bool = SystemStateProvider.isProcessAlive) throws + { + var firstFailure: Error? + for id in try deviceIDsOnDisk() { + let lease = SessionCapsMappingLease(directory: deviceDirectory(id), backend: backend) + do { + guard let record = try lease.pendingRecord() else { continue } + guard record.device.registryEntryID == id else { throw SessionCapsMappingLease.Refusal.unsafeFile } + let owner = record.owner + if owner.bootSessionUUID != bootSessionUUID { + try lease.retirePreviousBoot(bootSessionUUID: bootSessionUUID) + continue + } + if let expectedOwner { + guard expectedOwner == owner else { throw SessionCapsMappingLease.Refusal.wrongOwner } + } else { + guard !processAlive(owner.parentPID), !processAlive(owner.workerPID) + else { throw SessionCapsRuntimeSupport.Refusal.liveOwner } + } + try withRootGuard(owner: owner) { + try lease.restore(expectedOwner: owner, bootSessionUUID: bootSessionUUID) + } + } catch { + if firstFailure == nil { firstFailure = error } + } + } + if let firstFailure { throw firstFailure } + } + + /// Restore every record even if one device is foreign or unreadable. The + /// failed record remains on disk; successful records are retired. + func restore(owner: Policy.Owner, bootSessionUUID: String) throws { + try withRootGuard(owner: owner) { + var firstFailure: Error? + for id in try deviceIDsOnDisk() { + let lease = SessionCapsMappingLease(directory: deviceDirectory(id), backend: backend) + do { + guard let record = try lease.pendingRecord() else { continue } + guard record.device.registryEntryID == id else { throw SessionCapsMappingLease.Refusal.unsafeFile } + try lease.restore(expectedOwner: owner, bootSessionUUID: bootSessionUUID) + } catch { + if firstFailure == nil { firstFailure = error } + } + } + if let firstFailure { throw firstFailure } + } + } + + private func recordsOnDisk() throws -> [Policy.Record] { + try deviceIDsOnDisk().compactMap { id in + let lease = SessionCapsMappingLease(directory: deviceDirectory(id), backend: backend) + guard let record = try lease.pendingRecord() else { return nil } + guard record.device.registryEntryID == id else { throw SessionCapsMappingLease.Refusal.unsafeFile } + return record + } + } + + private func deviceIDsOnDisk() throws -> [UInt64] { + guard try ensureFleetDirectory(create: false) else { return [] } + return try FileManager.default.contentsOfDirectory(atPath: fleetDirectory.path).map { name in + guard let id = UInt64(name), String(id) == name else { throw SessionCapsMappingLease.Refusal.unsafeDirectory } + return id + }.sorted() + } +} diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift index a6566b940..e3928e1d8 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionCapsRuntimeSupport.swift @@ -57,12 +57,49 @@ enum SessionCapsRuntimeSupport { /// after it disconnects. Never used to identify a device. static let selectionNameKey = "keypath.session.caps.selection.name.v1" static let automaticSetupDisabledKey = "keypath.session.caps.automatic.disabled" + /// Advanced compatibility mode. The normal rule scope is every keyboard. + static let selectedKeyboardOnlyKey = "keypath.session.caps.selected.only" + + /// Preserve the scope of an existing exact-device selection on upgrade. + /// A fresh installation has no selection and starts in all-keyboards mode. + static func selectedKeyboardOnly(defaults: UserDefaults = .standard) -> Bool { + if let explicit = defaults.object(forKey: selectedKeyboardOnlyKey) as? Bool { return explicit } + return defaults.data(forKey: selectionKey) != nil + } + + static let allIntermediateKey = "keypath.session.caps.all.intermediate" + static let excludedKeyboardsKey = "keypath.session.caps.excluded.keyboards.v1" static let selectionKey = "keypath.session.caps.selection.v1" static let selectionEnvironmentKey = "KEYPATH_SESSION_CAPS_SELECTION" /// What the app passes when it launched the worker without managed Caps. /// The worker then never falls back to the saved selection, which the /// app may have declined (for example when no intermediate was free). static let noSelection = "none" + private static let allPrefix = "all:" + + static func allKeyboardLaunchValue(intermediateUsage: UInt32) -> String { + "\(allPrefix)\(intermediateUsage)" + } + + static func allKeyboardIntermediate(environment: [String: String] = ProcessInfo.processInfo.environment) -> UInt32? { + guard let value = environment[selectionEnvironmentKey], value.hasPrefix(allPrefix), + let usage = UInt32(value.dropFirst(allPrefix.count)), SessionCapsIntermediate.isCandidate(usage) else { return nil } + return usage + } + + /// An exception follows the physical keyboard at its serial or port, not + /// a boot-scoped IORegistry entry. The encoded tuple is unambiguous even + /// when a vendor uses punctuation in its serial number. + static func preferenceID(_ device: Policy.DeviceIdentity) -> String { + let fields = [String(device.vendorID), String(device.productID), + device.serialNumber ?? "", String(device.locationID ?? 0), + device.appleBuiltIn == true ? "1" : "0"] + return fields.map { "\($0.utf8.count):\($0)" }.joined() + } + + static func excludedKeyboardIDs(defaults: UserDefaults = .standard) -> Set { + Set((defaults.stringArray(forKey: excludedKeyboardsKey) ?? []).filter { $0.utf8.count <= 4096 }) + } private static func selectionSource(_ environment: [String: String], _ selectionData: Data?) -> Data? { guard let passed = environment[selectionEnvironmentKey] else { return selectionData } @@ -101,6 +138,7 @@ enum SessionCapsRuntimeSupport { static func preferredIntermediate(environment: [String: String] = ProcessInfo.processInfo.environment, selectionData: Data? = UserDefaults.standard.data(forKey: selectionKey)) -> UInt32 { + if let all = allKeyboardIntermediate(environment: environment) { return all } #if DEBUG if environment["KEYPATH_EXPERIMENTAL_MANAGED_CAPS_DEVICE"] != nil { return SessionCapsIntermediate.defaultUsage } #endif @@ -177,7 +215,9 @@ enum SessionCapsRuntimeSupport { managedCaps: false, intermediateUsage: preferred) } } - let canManage = (!requireSelection && !UserDefaults.standard.bool(forKey: automaticSetupDisabledKey)) + let canManage = (allKeyboardIntermediate(environment: environment) != nil + && !UserDefaults.standard.bool(forKey: automaticSetupDisabledKey)) + || (!requireSelection && !UserDefaults.standard.bool(forKey: automaticSetupDisabledKey)) || (try? selected(environment: environment, selectionData: selectionData)) != nil if canManage { guard case .valid = managed(chosen) else { return Admission(result: legacy, managedCaps: false, intermediateUsage: preferred) } @@ -382,6 +422,28 @@ enum SessionCapsRuntimeSupport { /// Off-main caller constructs the transport here; only scalar ownership /// crosses actor boundaries. A PID reused by a live process refuses recovery. static func recoverPending(directory: URL, expectedOwner: Policy.Owner? = nil) throws { + let boot = try bootSessionUUID() + try recoverIndependent( + legacy: { try recoverLegacyPending(directory: directory, expectedOwner: expectedOwner) }, + fleet: { + try SessionCapsMappingFleet(directory: directory, backend: SessionCapsHIDUtilTransport.backend()) + .recover(expectedOwner: expectedOwner, bootSessionUUID: boot) + } + ) + } + + static func recoverIndependent(legacy: () throws -> Void, fleet: () throws -> Void) throws { + var firstFailure: Error? + do { try legacy() } + catch { firstFailure = error } + do { try fleet() } + catch { + if firstFailure == nil { firstFailure = error } + } + if let firstFailure { throw firstFailure } + } + + private static func recoverLegacyPending(directory: URL, expectedOwner: Policy.Owner?) throws { var metadata = stat() if lstat(directory.appendingPathComponent("caps-mapping-intent.json").path, &metadata) != 0 { guard errno == ENOENT else { throw SessionCapsMappingLease.Refusal.unsafeFile } diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index a9833334c..dec591456 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -12,6 +12,7 @@ import KeyPathPermissions /// not expose a joinable shutdown, so their library must live until process exit. @MainActor public final class SessionRuntimeWorker { + private enum CapsInventoryFailure: Error { case unavailable } private static let outputTag: Int64 = 0x4B50_5345_5353_494F private let reportURL: URL private let nonce: String @@ -30,6 +31,11 @@ public final class SessionRuntimeWorker { /// usage numbers overlap across pages (0xE2 is Mute here, Left Alt on page 7). private var mediaInputs: Set = [] private var capsRecord: SessionCapsMappingPolicy.Record? + private var capsFleetEnabled = false + private var capsFleetRecords: [SessionCapsMappingPolicy.Record] = [] + private var capsFleetCoverage: SessionRuntimeReport.CapsCoverage? + private var capsFleetRetryAfter: [UInt64: Date] = [:] + private var capsFleetReadFailures = 0 private var capsOwner: SessionCapsMappingPolicy.Owner? private var capsDirectory: URL? private var capsInput = SessionCapsInputState() @@ -220,13 +226,17 @@ public final class SessionRuntimeWorker { if admission.managedCaps { CGEvent.tapEnable(tap: startupTap, enable: false) do { + let allUsage = SessionCapsRuntimeSupport.allKeyboardIntermediate() guard runtime?.isInputMapped(usagePage: 7, usage: 57) == true, - let selection = try SessionCapsRuntimeSupport.selected(), let capsDigest, - selection.intermediateUsage == admission.intermediateUsage, + let capsDigest, try SessionCapsRuntimeSupport.configSHA256(configPath) == capsDigest else { throw SessionCapsRuntimeSupport.Refusal.configIdentity } + let selection = allUsage == nil ? try SessionCapsRuntimeSupport.selected() : nil + guard (allUsage ?? selection?.intermediateUsage) == admission.intermediateUsage else { + throw SessionCapsRuntimeSupport.Refusal.configIdentity + } try SessionCapsRuntimeSupport.requirePhysicalAllUp() let directory = SessionCapsRuntimeSupport.journalDirectory(configPath: configPath) let owner = try SessionCapsMappingPolicy.Owner(uid: getuid(), parentPID: ownerPID, workerPID: getpid(), @@ -237,21 +247,44 @@ public final class SessionRuntimeWorker { // The tap is disabled. Keep the main run loop out of startup // acquisition; environment shutdown cannot race this writer. // Ownership is retained even when write/readback throws. - let device = selection.device - let intermediate = SessionCapsMappingPolicy.packed(selection.intermediateUsage) // Before any HID work: the transport's diagnostics go to the log. SessionCapsHIDUtilTransport.warn = { AppLogger.shared.warn($0) } - capsRecord = try Self.performHIDWork { - let backend = SessionCapsHIDUtilTransport.backend() - do { try SessionCapsMappingPolicy.requireUnique(device, among: backend.enumerate()) } catch { - throw SessionCapsRuntimeSupport.Refusal.selection + if let allUsage { + let result = try Self.performHIDWork { + let backend = SessionCapsHIDUtilTransport.backend() + let excluded = SessionCapsRuntimeSupport.excludedKeyboardIDs() + let inventory = try SessionCapsHIDUtilTransport.keyboards() + let devices = inventory.keyboards.map(\.device) + .filter { !excluded.contains(SessionCapsRuntimeSupport.preferenceID($0)) } + let coverage = try SessionCapsMappingFleet(directory: directory, backend: backend) + .acquire(owner: owner, configSHA256: capsDigest, devices: devices, + intermediate: SessionCapsMappingPolicy.packed(allUsage)) + return (coverage, devices.count, inventory.withheld) + } + capsFleetEnabled = true + capsFleetRecords = result.0.records + capsRecord = result.0.records.first + capsFleetCoverage = .init(eligible: result.1, covered: result.0.records.count, withheld: result.2) + for (id, reason) in result.0.failures { + AppLogger.shared.warn("[SessionCaps] Keyboard \(id) left uncovered: \(reason)") + } + } else if let selection { + let device = selection.device + let intermediate = SessionCapsMappingPolicy.packed(selection.intermediateUsage) + capsRecord = try Self.performHIDWork { + let backend = SessionCapsHIDUtilTransport.backend() + do { try SessionCapsMappingPolicy.requireUnique(device, among: backend.enumerate()) } catch { + throw SessionCapsRuntimeSupport.Refusal.selection + } + let lease = SessionCapsMappingLease(directory: directory, backend: backend) + return try lease.acquire(owner: owner, configSHA256: capsDigest, device: device, + intermediate: intermediate) } - let lease = SessionCapsMappingLease(directory: directory, backend: backend) - return try lease.acquire(owner: owner, configSHA256: capsDigest, device: device, - intermediate: intermediate) } try SessionCapsRuntimeSupport.requirePhysicalAllUp() - capsInput.activate(generation: owner.generation, intermediateUsage: selection.intermediateUsage) + if capsRecord != nil { + capsInput.activate(generation: owner.generation, intermediateUsage: admission.intermediateUsage) + } capsConfigPath = configPath capsAdmittedDigest = capsDigest } catch { @@ -586,8 +619,13 @@ public final class SessionRuntimeWorker { } private func checkCapsOwnershipIfNeeded() { - guard let record = capsRecord, !capsCheckInFlight, - Date().timeIntervalSince(lastCapsCheck) >= 0.5 else { return } + guard !capsCheckInFlight, + Date().timeIntervalSince(lastCapsCheck) >= (capsFleetEnabled ? 2 : 0.5) else { return } + if capsFleetEnabled { + checkCapsFleetIfNeeded() + return + } + guard let record = capsRecord else { return } capsCheckInFlight = true lastCapsCheck = Date() guard let directory = capsDirectory else { finish(.failed, reason: "managed-caps-directory-unavailable") } @@ -617,6 +655,100 @@ public final class SessionRuntimeWorker { } } + private func checkCapsFleetIfNeeded() { + guard let owner = capsOwner, let directory = capsDirectory, + let configPath = capsConfigPath, let admittedDigest = capsAdmittedDigest else { return } + capsCheckInFlight = true + lastCapsCheck = Date() + let previous = Set(capsFleetRecords.map(\.device.registryEntryID)) + let retryAfter = capsFleetRetryAfter + let usage = SessionCapsRuntimeSupport.preferredIntermediate() + DispatchQueue.global(qos: .utility).async { [weak self] in + let digest = try? SessionCapsRuntimeSupport.configSHA256(configPath) + let result: Result<(records: [SessionCapsMappingPolicy.Record], failures: [UInt64: String], + eligible: Int, withheld: Int), Error> + do { + guard digest == nil || digest == admittedDigest else { + throw SessionCapsRuntimeSupport.Refusal.configIdentity + } + let excluded = SessionCapsRuntimeSupport.excludedKeyboardIDs() + let inventory: (keyboards: [SessionCapsHIDUtilTransport.Keyboard], withheld: Int) + do { inventory = try SessionCapsHIDUtilTransport.keyboards() } + catch { throw CapsInventoryFailure.unavailable } + let devices = inventory.keyboards.map(\.device) + .filter { !excluded.contains(SessionCapsRuntimeSupport.preferenceID($0)) } + let now = Date() + let retryable = devices.filter { device in + previous.contains(device.registryEntryID) + || now >= (retryAfter[device.registryEntryID] ?? .distantPast) + } + let fleet = SessionCapsMappingFleet(directory: directory, backend: SessionCapsHIDUtilTransport.backend()) + let coverage = try fleet.reconcile( + owner: owner, configSHA256: admittedDigest, devices: retryable, + intermediate: SessionCapsMappingPolicy.packed(usage), + bootSessionUUID: owner.bootSessionUUID, + allowNewDevices: SessionCapsRuntimeSupport.heldKeys().isEmpty + ) + result = .success((coverage.records, coverage.failures, devices.count, inventory.withheld)) + } catch { + result = .failure(error) + } + CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) { [weak self] in + MainActor.assumeIsolated { + guard let self, !self.finished, self.capsOwner == owner else { return } + self.capsCheckInFlight = false + guard digest == nil || digest == admittedDigest else { + self.finish(.failed, reason: SessionCapsRuntimeSupport.profileChangedReason) + } + switch result { + case let .success(coverage): + self.capsFleetReadFailures = 0 + let current = Set(coverage.records.map(\.device.registryEntryID)) + if previous.isEmpty, !current.isEmpty { + self.capsInput.activate(generation: owner.generation, intermediateUsage: usage) + } + self.capsFleetRecords = coverage.records + self.capsRecord = coverage.records.first + if current.isEmpty { self.capsInput.revoke() } + for id in current { + self.capsFleetRetryAfter.removeValue(forKey: id) + } + for id in coverage.failures.keys { + self.capsFleetRetryAfter[id] = Date().addingTimeInterval(30) + } + let nextCoverage = SessionRuntimeReport.CapsCoverage( + eligible: coverage.eligible, covered: coverage.records.count, withheld: coverage.withheld + ) + let coverageChanged = self.capsFleetCoverage != nextCoverage + self.capsFleetCoverage = nextCoverage + if current != previous || coverageChanged { self.writeReport(.running) } + for (id, reason) in coverage.failures where !previous.contains(id) { + AppLogger.shared.warn("[SessionCaps] Keyboard \(id) left uncovered: \(reason)") + } + case let .failure(error): + if case SessionCapsMappingLease.Refusal.busy = error { return } + if error is CapsInventoryFailure { + self.capsFleetReadFailures += 1 + if self.capsFleetReadFailures == 3 { + self.capsFleetCoverage = .init( + eligible: self.capsFleetCoverage?.eligible ?? self.capsFleetRecords.count, + covered: self.capsFleetRecords.count, + withheld: self.capsFleetCoverage?.withheld ?? 0, + inventoryUnavailable: true + ) + self.writeReport(.running) + } + return + } + AppLogger.shared.warn("[SessionCaps] Fleet verification failed: \(error)") + self.finish(.failed, reason: SessionCapsRuntimeSupport.ownershipLostReason) + } + } + } + CFRunLoopWakeUp(CFRunLoopGetMain()) + } + } + /// `NSEvent.otherEvent(.systemDefined, subtype 8)`: data1 carries the /// NX key type in the high word and 0xA (down) / 0xB (up) in the next /// byte. Tagged like a key event so this worker's own tap ignores it. @@ -773,7 +905,8 @@ public final class SessionRuntimeWorker { tcpPort: port, inputCount: inputCount, outputCount: outputCount, failure: failure, heldOutputUsages: outputs.heldUsages.sorted(), inputAccessSource: capabilities.source, experimentalTapDiagnostics: diagnostics, experimentalTapTimeout: timeoutDiagnostic, - managedCapsGeneration: capsRecord?.owner.generation, rawPermissions: rawPermissions, + managedCapsGeneration: capsRecord?.owner.generation, capsCoverage: capsFleetCoverage, + rawPermissions: rawPermissions, retirementReason: retirementReason ) do { diff --git a/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift b/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift index 3553b7479..76898d7f5 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SessionCapsSettingsSection.swift @@ -38,7 +38,12 @@ struct SessionCapsSettingsSection: View { @State private var saved: SessionCapsSelection? /// Another app's Caps mapping on the chosen keyboard, if any. @State private var conflict: SessionCapsConflict? + @State private var fleetUncovered: [String] = [] + @State private var excludedIDs = SessionCapsRuntimeSupport.excludedKeyboardIDs() @State private var automaticDisabled = UserDefaults.standard.bool(forKey: SessionCapsRuntimeSupport.automaticSetupDisabledKey) + @State private var selectedOnly = SessionCapsRuntimeSupport.selectedKeyboardOnly() + @State private var showAdvanced = false + @State private var choosingOneKeyboard = false @State private var busy = false @State private var message: String? /// The saved keyboard when it is no longer connected as that instance. @@ -54,47 +59,80 @@ struct SessionCapsSettingsSection: View { Image(systemName: "capslock").foregroundStyle(.secondary).frame(width: 20) VStack(alignment: .leading, spacing: SettingsDesign.labelGap) { HStack(spacing: 4) { - Text("Caps Lock remapping").font(.body.weight(.medium)) + Text("Caps Lock rules").font(.body.weight(.medium)) InfoTip(reservedKeyTip) } Text(statusText).font(.caption).foregroundStyle(statusIsWarning ? Color.orange : .secondary) .fixedSize(horizontal: false, vertical: true) } Spacer() - if !automaticDisabled, device == nil || showsPicker || conflict != nil, !busy { + if !automaticDisabled, keyboards.isEmpty || withheld > 0 || conflict != nil || !fleetUncovered.isEmpty, !busy { Button("Check Again") { Task { await reviewKeyboard() } } .settingsAction() .accessibilityIdentifier("settings-caps-check-again") } - Toggle("Caps Lock remapping", isOn: Binding( - get: { !automaticDisabled }, - set: { enabled in Task { await setEnabled(enabled) } } - )) - .labelsHidden() - .toggleStyle(.switch) - .disabled(busy) - .accessibilityIdentifier("settings-caps-toggle") } - if !automaticDisabled, showsPicker { - Picker("Keyboard", selection: Binding( - get: { choices.pending?.registryEntryID ?? device?.registryEntryID }, - set: { id in - guard let chosen = keyboards.first(where: { $0.id == id }) else { return } - Task { await choose(chosen.device) } + DisclosureGroup("Caps Lock options", isExpanded: $showAdvanced) { + VStack(alignment: .leading, spacing: SettingsDesign.labelGap) { + if selectedOnly { + Button("Use Caps Lock rules on all keyboards") { + Task { await setSelectedOnly(false) } + } + .disabled(busy) + .accessibilityIdentifier("settings-caps-use-all-keyboards") + } else if choosingOneKeyboard { + Button("Cancel choosing a keyboard") { + choosingOneKeyboard = false + message = nil + } + .accessibilityIdentifier("settings-caps-cancel-keyboard-choice") + } else { + ForEach(keyboards) { keyboard in + let id = SessionCapsRuntimeSupport.preferenceID(keyboard.device) + Toggle("Use on \(keyboard.name)", isOn: Binding( + get: { !excludedIDs.contains(id) }, + set: { enabled in Task { await setKeyboardEnabled(id, enabled: enabled) } } + )) + .disabled(busy || automaticDisabled) + .accessibilityIdentifier("settings-caps-keyboard-\(keyboard.id)") + } + Button("Use on one keyboard only") { + Task { await setSelectedOnly(true) } + } + .disabled(busy || keyboards.isEmpty) + .accessibilityIdentifier("settings-caps-use-one-keyboard") } - )) { - if device == nil { Text("Choose a keyboard").tag(UInt64?.none) } - ForEach(keyboards) { keyboard in - Text(keyboard.name).tag(UInt64?.some(keyboard.id)) + + if selectedOnly || choosingOneKeyboard { + Picker("Keyboard", selection: Binding( + get: { choosingOneKeyboard ? nil : (choices.pending?.registryEntryID ?? device?.registryEntryID) }, + set: { id in + guard let chosen = keyboards.first(where: { $0.id == id }) else { return } + Task { await choose(chosen.device) } + } + )) { + if device == nil || choosingOneKeyboard { Text("Choose a keyboard").tag(UInt64?.none) } + ForEach(keyboards) { keyboard in + Text(keyboard.name).tag(UInt64?.some(keyboard.id)) + } + } + .pickerStyle(.menu) + .fixedSize() + .disabled(busy) + .accessibilityIdentifier("settings-caps-keyboard-picker") + .accessibilityHint("Caps Lock rules apply to the keyboard you choose") } + + Toggle("Enable Caps Lock rules", isOn: Binding( + get: { !automaticDisabled }, + set: { enabled in Task { await setEnabled(enabled) } } + )) + .disabled(busy) + .accessibilityIdentifier("settings-caps-toggle") } - .pickerStyle(.menu) - .fixedSize() - .disabled(busy) .padding(.leading, 20 + SettingsDesign.rowGap) - .accessibilityIdentifier("settings-caps-keyboard-picker") - .accessibilityHint("Caps Lock rules apply to the keyboard you choose") } + .accessibilityIdentifier("settings-caps-advanced") if !automaticDisabled, withheld > 0 { Text(withheld == 1 ? "One connected keyboard can\u{2019}t be used for Caps Lock rules." : "\(withheld) connected keyboards can\u{2019}t be used for Caps Lock rules.") @@ -108,13 +146,26 @@ struct SessionCapsSettingsSection: View { .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("settings-caps-conflict") } + if !automaticDisabled, !selectedOnly { + ForEach(fleetUncovered, id: \.self) { detail in + Text(detail).font(.caption).foregroundStyle(.orange) + .padding(.leading, 20 + SettingsDesign.rowGap) + .fixedSize(horizontal: false, vertical: true) + } + } if let message { Text(message).font(.caption).foregroundStyle(.orange) .padding(.leading, 20 + SettingsDesign.rowGap) .fixedSize(horizontal: false, vertical: true) } } - .task { await reviewKeyboard() } + .task { + await reviewKeyboard() + if rulesUseCaps, !automaticDisabled { + try? await Task.sleep(for: .seconds(3)) + if !Task.isCancelled, !busy { await reviewKeyboard() } + } + } // Keyboards come and go while Settings is open; refresh on return. .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { await reviewKeyboard() } @@ -127,6 +178,9 @@ struct SessionCapsSettingsSection: View { Task { try? await Task.sleep(for: .milliseconds(500)) await reviewKeyboard() + // The worker's next inventory check may follow the HID event. + try? await Task.sleep(for: .seconds(3)) + if !Task.isCancelled, !busy { await reviewKeyboard() } } } } @@ -142,7 +196,9 @@ struct SessionCapsSettingsSection: View { } private var reservedKey: String { - SessionCapsIntermediate.label(saved?.intermediateUsage ?? SessionCapsIntermediate.defaultUsage) + let stored = UInt32(exactly: UserDefaults.standard.integer(forKey: SessionCapsRuntimeSupport.allIntermediateKey)) + let usage = selectedOnly ? saved?.intermediateUsage : stored.flatMap { SessionCapsIntermediate.isCandidate($0) ? $0 : nil } + return SessionCapsIntermediate.label(usage ?? SessionCapsIntermediate.defaultUsage) } private var reservedKeyTip: String { @@ -158,6 +214,20 @@ struct SessionCapsSettingsSection: View { if automaticDisabled { return rulesUseCaps ? "Off \u{00B7} Rules that use Caps Lock won\u{2019}t run" : "Off" } + if !rulesUseCaps { return "Applies automatically when you add a Caps Lock rule" } + if !selectedOnly { + if keyboards.isEmpty { + return "Caps Lock rules will apply when a compatible keyboard connects" + } + let participating = keyboards.count { !excludedIDs.contains(SessionCapsRuntimeSupport.preferenceID($0.device)) } + let covered = max(0, participating - fleetUncovered.count) + if participating == 0 { + return "Caps Lock rules are off on every connected keyboard" + } + return fleetUncovered.isEmpty + ? "On \u{00B7} \(participating == keyboards.count ? "all compatible keyboards" : "\(participating) selected keyboards") \u{00B7} Uses \(reservedKey) behind the scenes" + : "Caps Lock active on \(covered) of \(participating) selected keyboards" + } guard let device else { if let disconnected { return Self.disconnectedStatus(name: disconnected, listed: keyboards.map(\.name), showsPicker: showsPicker) @@ -171,7 +241,11 @@ struct SessionCapsSettingsSection: View { } private var statusIsWarning: Bool { - (automaticDisabled && rulesUseCaps) || (!automaticDisabled && (device == nil || conflict != nil) && !busy) + guard rulesUseCaps else { return false } + if automaticDisabled { return true } + if busy { return false } + if keyboards.isEmpty { return true } + return selectedOnly ? (device == nil || conflict != nil) : !fleetUncovered.isEmpty } /// The saved keyboard is gone as that instance. Back at the same port it is @@ -202,6 +276,19 @@ struct SessionCapsSettingsSection: View { @MainActor private func setEnabled(_ enabled: Bool) async { message = nil + if !selectedOnly { + busy = true + let changed = await runtime.setCapsSelection(nil, automaticSetupDisabled: !enabled, + selectedKeyboardOnly: false) + busy = false + if changed { + automaticDisabled = !enabled + } else { + message = "Caps Lock setup could not be changed safely. Existing settings were kept." + } + await applyDeferredWork() + return + } if enabled, device == nil { UserDefaults.standard.set(false, forKey: SessionCapsRuntimeSupport.automaticSetupDisabledKey) automaticDisabled = false @@ -212,6 +299,48 @@ struct SessionCapsSettingsSection: View { await applyDeferredWork() } + @MainActor + private func setSelectedOnly(_ only: Bool) async { + guard !busy, only != selectedOnly || choosingOneKeyboard else { return } + if only { + choosingOneKeyboard = true + showAdvanced = true + message = "Choose the keyboard whose Caps Lock key should use these rules." + return + } + busy = true + let changed = await runtime.setCapsSelection(nil, automaticSetupDisabled: false, + selectedKeyboardOnly: false) + busy = false + if changed { + selectedOnly = false + choosingOneKeyboard = false + saved = nil + message = nil + } else { + message = "Caps Lock setup could not be changed safely. Existing settings were kept." + } + await applyDeferredWork() + } + + @MainActor + private func setKeyboardEnabled(_ id: String, enabled: Bool) async { + guard !busy else { return } + var next = excludedIDs + if enabled { next.remove(id) } else { next.insert(id) } + busy = true + let changed = await runtime.setCapsExcludedKeyboardIDs(next) + busy = false + if changed { + excludedIDs = next + message = nil + await refreshKeyboards(keepMessage: false) + } else { + message = "This keyboard preference could not be changed safely. Existing settings were kept." + } + await applyDeferredWork() + } + @MainActor private func reviewKeyboard(keepMessage: Bool = false) async { if busy { refreshAgain = true } @@ -239,6 +368,7 @@ struct SessionCapsSettingsSection: View { busy = true defer { busy = false } do { + let needsCaps = rulesUseCaps let storedData = UserDefaults.standard.data(forKey: SessionCapsRuntimeSupport.selectionKey) let displaced = SessionCapsSelectionStore.displaced?.data let found = try await Task.detached { @@ -252,11 +382,27 @@ struct SessionCapsSettingsSection: View { let devices = list.keyboards.map(\.device) let rebound = SessionCapsRuntimeSupport.reboundSelection(selectionData: displaced, devices: devices, boot: boot) ?? (gone ? stored?.rebound(among: devices, currentBoot: boot) : nil) - return (list, boot, rebound ?? stored, gone && rebound == nil) + let excluded = SessionCapsRuntimeSupport.excludedKeyboardIDs() + var uncovered: [String] = [] + if needsCaps, !SessionCapsRuntimeSupport.selectedKeyboardOnly() { + let fleet = SessionCapsMappingFleet(directory: SessionCapsRuntimeSupport.journalDirectory(), + backend: SessionCapsHIDUtilTransport.backend()) + let active = (try? fleet.activeDeviceIDs(bootSessionUUID: boot)) ?? [] + for keyboard in list.keyboards where !excluded.contains(SessionCapsRuntimeSupport.preferenceID(keyboard.device)) + && !active.contains(keyboard.id) + { + let conflict = try? SessionCapsRuntimeSupport.capsConflict(on: keyboard.device) + let explanation = conflict?.message ?? "Caps Lock setup is not active on this keyboard yet." + uncovered.append("\(keyboard.name): \(explanation)") + } + } + return (list, boot, rebound ?? stored, gone && rebound == nil, uncovered, excluded) }.value boot = found.1 keyboards = found.0.keyboards withheld = found.0.withheld + fleetUncovered = found.4 + excludedIDs = found.5 let stored = found.2 device = Self.effectiveDevice(saved: stored?.device, connected: keyboards.map(\.device), withheld: withheld) saved = stored?.device == device ? stored : nil @@ -270,6 +416,7 @@ struct SessionCapsSettingsSection: View { device = nil disconnected = nil conflict = nil + fleetUncovered = [] message = "This keyboard could not be verified for Caps Lock setup. Profiles without Caps Lock rules can still run." } } @@ -301,7 +448,7 @@ struct SessionCapsSettingsSection: View { private func choose(_ picked: SessionCapsMappingPolicy.DeviceIdentity) async { // Busy means a refresh or another change is running; queue the pick. guard let chosen = choices.submit(picked, busy: busy), - chosen != device || saved?.device != chosen else { return } + chosen != device || saved?.device != chosen || choosingOneKeyboard else { return } let previous = (device, saved) device = chosen message = nil @@ -311,6 +458,10 @@ struct SessionCapsSettingsSection: View { message = "That keyboard could not be set up for Caps Lock. It may have been unplugged; check the list and try again. Existing settings were kept." await refreshKeyboards(keepMessage: true) } + if message == nil { + selectedOnly = true + choosingOneKeyboard = false + } await applyDeferredWork() } @@ -330,7 +481,9 @@ struct SessionCapsSettingsSection: View { } else { selection = nil } busy = true defer { busy = false } - guard await runtime.setCapsSelection(selection, automaticSetupDisabled: !enabled) else { + guard await runtime.setCapsSelection(selection, automaticSetupDisabled: !enabled, + selectedKeyboardOnly: enabled ? true : selectedOnly) + else { message = "Caps Lock setup could not be changed safely. Existing settings were kept. If the keyboard was disconnected, restart your Mac and try again." return } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift index 2ebc7dbf3..2ecd9e80b 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView+StatusDetails.swift @@ -52,6 +52,10 @@ extension StatusSettingsTabView { return .warning } + if isDriverless, kanataManager.capsPausedMessage != nil { + return .warning + } + return .success } @@ -106,6 +110,9 @@ extension StatusSettingsTabView { } return "Permissions Required" } + if isDriverless, kanataManager.capsPausedMessage != nil { + return "Caps Lock Needs Attention" + } return overallHealthLevel == .success ? "Everything's Working" : "Setup Needed" } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift index df41bc5f1..0c018c8f4 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView.swift @@ -308,70 +308,78 @@ struct StatusSettingsTabView: View { SettingsSectionHeader(title: "Connected Keyboard") if let activeKeyboard = autoDetectController.activeKeyboard { - HStack(spacing: 10) { - Image(systemName: "keyboard") - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 1) { - Text(activeKeyboard.keyboardName) - .font(.body.weight(.medium)) - Text([activeKeyboard.manufacturer, - activeKeyboard.status == .remembered ? "Remembered" : "Connected"] - .compactMap(\.self).joined(separator: " · ")) - .font(.caption) - .foregroundColor(.secondary) - } + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "keyboard") + .foregroundStyle(.secondary) - Spacer() + VStack(alignment: .leading, spacing: 1) { + Text(activeKeyboard.keyboardName) + .font(.body.weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + Text([activeKeyboard.manufacturer, + activeKeyboard.status == .remembered ? "Remembered" : "Connected"] + .compactMap(\.self).joined(separator: " · ")) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) - Menu { - ForEach(PhysicalLayout.layoutsByCategory(), id: \.category) { group in - if !group.layouts.isEmpty { - Section(group.category.displayName) { - ForEach(group.layouts) { layout in - Button { - autoDetectController.selectLayout(layout.id) - } label: { - if layout.id == activeKeyboard.layoutId { - Label(layout.name, systemImage: "checkmark") - } else { - Text(layout.name) + Button("Forget", role: .destructive) { + autoDetectController.forgetKeyboard(activeKeyboard.id) + } + .buttonStyle(.plain) + .font(.callout) + .foregroundColor(.red) + .help("Forget This Keyboard") + .accessibilityLabel("Forget This Keyboard") + .accessibilityIdentifier("status-forget-keyboard-button") + } + + HStack(spacing: 10) { + Text("Layout") + .font(.callout) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Menu { + ForEach(PhysicalLayout.layoutsByCategory(), id: \.category) { group in + if !group.layouts.isEmpty { + Section(group.category.displayName) { + ForEach(group.layouts) { layout in + Button { + autoDetectController.selectLayout(layout.id) + } label: { + if layout.id == activeKeyboard.layoutId { + Label(layout.name, systemImage: "checkmark") + } else { + Text(layout.name) + } } + .accessibilityIdentifier("status-layout-option-\(layout.id)") } - .accessibilityIdentifier("status-layout-option-\(layout.id)") } } } + Divider() + Button("Search All Keyboards\u{2026}") { + autoDetectController.presentKeyboardSearch(for: activeKeyboard.id) + LiveKeyboardOverlayController.shared.showForQuickLaunch() + } + .accessibilityIdentifier("status-search-keyboard-layouts-button") + } label: { + // One Text keeps the chevron trailing; a menu label lays out + // an HStack's image before its text. + Text("\(activeKeyboard.layoutId.flatMap { PhysicalLayout.find(id: $0)?.name } ?? "Choose layout") ") + + Text(Image(systemName: "chevron.up.chevron.down")) + .font(.caption2.weight(.semibold)) + .foregroundColor(.secondary) } - Divider() - Button("Search All Keyboards\u{2026}") { - autoDetectController.presentKeyboardSearch(for: activeKeyboard.id) - LiveKeyboardOverlayController.shared.showForQuickLaunch() - } - .accessibilityIdentifier("status-search-keyboard-layouts-button") - } label: { - // One Text keeps the chevron trailing; a menu label lays out - // an HStack's image before its text. - Text("\(activeKeyboard.layoutId.flatMap { PhysicalLayout.find(id: $0)?.name } ?? "Choose layout") ") - + Text(Image(systemName: "chevron.up.chevron.down")) - .font(.caption2.weight(.semibold)) - .foregroundColor(.secondary) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .accessibilityLabel("Keyboard layout") - .accessibilityIdentifier("status-keyboard-layout-menu") - - Button("Forget", role: .destructive) { - autoDetectController.forgetKeyboard(activeKeyboard.id) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .lineLimit(1) + .accessibilityLabel("Keyboard layout") + .accessibilityIdentifier("status-keyboard-layout-menu") } - .buttonStyle(.plain) - .font(.callout) - .foregroundColor(.red) - .help("Forget This Keyboard") - .accessibilityLabel("Forget This Keyboard") - .accessibilityIdentifier("status-forget-keyboard-button") } .padding(.horizontal, SettingsDesign.cardInset) .padding(.vertical, 10) diff --git a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift index e370df399..ae6f7dd9b 100644 --- a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift +++ b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift @@ -584,8 +584,15 @@ class KanataViewModel { await manager.restartKanata(reason: reason) } - func setCapsSelection(_ selection: SessionCapsSelection?, automaticSetupDisabled: Bool? = nil) async -> Bool { - await manager.setCapsSelection(selection, automaticSetupDisabled: automaticSetupDisabled) + func setCapsSelection(_ selection: SessionCapsSelection?, automaticSetupDisabled: Bool? = nil, + selectedKeyboardOnly: Bool? = nil) async -> Bool + { + await manager.setCapsSelection(selection, automaticSetupDisabled: automaticSetupDisabled, + selectedKeyboardOnly: selectedKeyboardOnly) + } + + func setCapsExcludedKeyboardIDs(_ ids: Set) async -> Bool { + await manager.setCapsExcludedKeyboardIDs(ids) } func currentRuntimeStatus() async -> RuntimeCoordinator.RuntimeStatus { diff --git a/Sources/KeyPathCore/SessionRuntime/SessionRuntimeReport.swift b/Sources/KeyPathCore/SessionRuntime/SessionRuntimeReport.swift index 7f53bd0f2..5807e9e6e 100644 --- a/Sources/KeyPathCore/SessionRuntime/SessionRuntimeReport.swift +++ b/Sources/KeyPathCore/SessionRuntime/SessionRuntimeReport.swift @@ -3,6 +3,21 @@ import Foundation /// Private, nonce-bound evidence from the independently launched app process. /// Contains capabilities and emitted-key cleanup state, never captured input or text. public struct SessionRuntimeReport: Codable, Sendable, Equatable { + public struct CapsCoverage: Codable, Sendable, Equatable { + public let eligible: Int + public let covered: Int + public let withheld: Int + /// The last inventory refresh failed; previous coverage is not current. + public let inventoryUnavailable: Bool? + + public init(eligible: Int, covered: Int, withheld: Int, inventoryUnavailable: Bool? = nil) { + self.eligible = eligible + self.covered = covered + self.withheld = withheld + self.inventoryUnavailable = inventoryUnavailable + } + } + public enum State: String, Codable, Sendable { case capabilities, starting, running, secureInput, stopped, failed } @@ -315,6 +330,7 @@ public struct SessionRuntimeReport: Codable, Sendable, Equatable { public let heldOutputUsages: [UInt32] public let experimentalTapDiagnostics: ExperimentalTapDiagnostics? public let managedCapsGeneration: String? + public let capsCoverage: CapsCoverage? public let experimentalTapTimeout: ExperimentalTapTimeoutDiagnostic? public init( @@ -324,7 +340,8 @@ public struct SessionRuntimeReport: Codable, Sendable, Equatable { timestamp: Date = Date(), failure: String? = nil, heldOutputUsages: [UInt32] = [], inputAccessSource: String? = nil, experimentalTapDiagnostics: ExperimentalTapDiagnostics? = nil, experimentalTapTimeout: ExperimentalTapTimeoutDiagnostic? = nil, - managedCapsGeneration: String? = nil, rawPermissions: RawPermissions? = nil, + managedCapsGeneration: String? = nil, capsCoverage: CapsCoverage? = nil, + rawPermissions: RawPermissions? = nil, retirementReason: String? = nil ) { self.nonce = nonce @@ -346,6 +363,7 @@ public struct SessionRuntimeReport: Codable, Sendable, Equatable { self.experimentalTapDiagnostics = experimentalTapDiagnostics self.experimentalTapTimeout = experimentalTapTimeout self.managedCapsGeneration = managedCapsGeneration + self.capsCoverage = capsCoverage } public var recoveryReason: String? { diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsMappingFleetTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsMappingFleetTests.swift new file mode 100644 index 000000000..ddd67f61f --- /dev/null +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsMappingFleetTests.swift @@ -0,0 +1,255 @@ +import Darwin +import Foundation +@testable import KeyPathAppKit +import KeyPathCore +import XCTest + +final class SessionCapsMappingFleetTests: XCTestCase { + typealias Policy = SessionCapsMappingPolicy + + private final class Fake { + let devices: [Policy.DeviceIdentity] = (1 ... 3).map { + .init(registryEntryID: UInt64($0 + 100), vendorID: UInt32($0), productID: 1, + serialNumber: nil, locationID: UInt32($0 + 200)) + } + + var connected = Set([101, 102]) + var mappings: [UInt64: [Policy.Mapping]] = [:] + var writes: [UInt64] = [] + var failReadOnce: UInt64? + var backend: SessionCapsMappingLease.Backend { + .init(enumerate: { self.devices.filter { self.connected.contains($0.registryEntryID) } }, + read: { device in + if self.failReadOnce == device.registryEntryID { + self.failReadOnce = nil + throw NSError(domain: "fixture", code: 1) + } + return .init(device: device, mappings: self.mappings[device.registryEntryID] ?? []) + }, + write: { device, rows in + self.writes.append(device.registryEntryID) + self.mappings[device.registryEntryID] = rows + }, + registryEntryExists: { self.connected.contains($0) }) + } + } + + private func fixture(_ body: (SessionCapsMappingFleet, Fake, Policy.Owner) throws -> Void) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: root) } + let fake = Fake() + let owner = Policy.Owner(uid: getuid(), parentPID: 100, workerPID: 101, nonce: "fleet", + generation: "fleet", bootSessionUUID: UUID().uuidString) + try body(.init(directory: root, backend: fake.backend), fake, owner) + } + + func testSeveralKeyboardsAcquireAndRestoreIndependently() throws { + try fixture { fleet, fake, owner in + let digest = String(repeating: "a", count: 64) + let result = try fleet.acquire(owner: owner, configSHA256: digest, devices: Array(fake.devices.prefix(2)), + intermediate: Policy.f18) + XCTAssertEqual(result.records.count, 2) + XCTAssertTrue(result.failures.isEmpty) + XCTAssertEqual(Set(fake.writes), [101, 102]) + XCTAssertEqual(try fleet.activeDeviceIDs(bootSessionUUID: owner.bootSessionUUID, processAlive: { _ in true }), [101, 102]) + + fake.connected.remove(101) + let afterUnplug = try fleet.reconcile(owner: owner, configSHA256: digest, devices: [fake.devices[1]], + intermediate: Policy.f18, bootSessionUUID: owner.bootSessionUUID) + XCTAssertEqual(afterUnplug.records.map(\.device.registryEntryID), [102]) + XCTAssertEqual(fake.mappings[102], [.init(source: Policy.caps, destination: Policy.f18)]) + try fleet.restore(owner: owner, bootSessionUUID: owner.bootSessionUUID) + XCTAssertEqual(fake.mappings[102], []) + XCTAssertTrue(try fleet.pendingRecords(owner: owner).isEmpty) + } + } + + func testHotPlugAddsKeyboardWithoutReplacingExistingMapping() throws { + try fixture { fleet, fake, owner in + let digest = String(repeating: "b", count: 64) + _ = try fleet.acquire(owner: owner, configSHA256: digest, devices: [fake.devices[0]], + intermediate: Policy.f18) + fake.connected.insert(103) + let paused = try fleet.reconcile(owner: owner, configSHA256: digest, + devices: [fake.devices[0], fake.devices[2]], + intermediate: Policy.f18, bootSessionUUID: owner.bootSessionUUID, + allowNewDevices: false) + XCTAssertEqual(paused.records.count, 1) + let active = try fleet.reconcile(owner: owner, configSHA256: digest, + devices: [fake.devices[0], fake.devices[2]], + intermediate: Policy.f18, bootSessionUUID: owner.bootSessionUUID) + XCTAssertEqual(Set(active.records.map(\.device.registryEntryID)), [101, 103]) + XCTAssertEqual(fake.writes.filter { $0 == 101 }.count, 1) + } + } + + func testOneReadFailureRetiresOnlyThatDevice() throws { + try fixture { fleet, fake, owner in + let devices = Array(fake.devices.prefix(2)) + _ = try fleet.acquire(owner: owner, configSHA256: String(repeating: "a", count: 64), + devices: devices, intermediate: Policy.f18) + fake.failReadOnce = devices[0].registryEntryID + let coverage = try fleet.reconcile(owner: owner, configSHA256: String(repeating: "a", count: 64), + devices: devices, intermediate: Policy.f18, + bootSessionUUID: owner.bootSessionUUID, allowNewDevices: false) + XCTAssertEqual(coverage.records.map(\.device.registryEntryID), [devices[1].registryEntryID]) + XCTAssertNotNil(coverage.failures[devices[0].registryEntryID]) + XCTAssertEqual(fake.mappings[devices[0].registryEntryID], []) + XCTAssertEqual(fake.mappings[devices[1].registryEntryID], [.init(source: Policy.caps, destination: Policy.f18)]) + try fleet.restore(owner: owner, bootSessionUUID: owner.bootSessionUUID) + } + } + + func testExistingSelectionKeepsSingleKeyboardScopeUntilExplicitlyChanged() { + let domain = "caps-fleet-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: domain)! + defer { defaults.removePersistentDomain(forName: domain) } + XCTAssertFalse(SessionCapsRuntimeSupport.selectedKeyboardOnly(defaults: defaults)) + defaults.set(Data("legacy".utf8), forKey: SessionCapsRuntimeSupport.selectionKey) + XCTAssertTrue(SessionCapsRuntimeSupport.selectedKeyboardOnly(defaults: defaults)) + defaults.set(false, forKey: SessionCapsRuntimeSupport.selectedKeyboardOnlyKey) + XCTAssertFalse(SessionCapsRuntimeSupport.selectedKeyboardOnly(defaults: defaults)) + } + + func testLegacyRecoveryFailureStillRunsFleetRecovery() { + var fleetRan = false + XCTAssertThrowsError(try SessionCapsRuntimeSupport.recoverIndependent( + legacy: { throw NSError(domain: "legacy", code: 1) }, + fleet: { fleetRan = true } + )) + XCTAssertTrue(fleetRan) + } + + func testConflictingKeyboardDoesNotDisableOtherKeyboard() throws { + try fixture { fleet, fake, owner in + fake.mappings[102] = [.init(source: Policy.caps, destination: 0x7_0000_00E0)] + let result = try fleet.acquire(owner: owner, configSHA256: String(repeating: "c", count: 64), + devices: Array(fake.devices.prefix(2)), intermediate: Policy.f18) + XCTAssertEqual(result.records.map(\.device.registryEntryID), [101]) + XCTAssertNotNil(result.failures[102]) + XCTAssertEqual(fake.mappings[102], [.init(source: Policy.caps, destination: 0x7_0000_00E0)]) + try fleet.restore(owner: owner, bootSessionUUID: owner.bootSessionUUID) + XCTAssertEqual(fake.mappings[101], []) + XCTAssertEqual(fake.mappings[102], [.init(source: Policy.caps, destination: 0x7_0000_00E0)]) + } + } + + func testCrashRecoveryRestoresEveryKeyboard() throws { + try fixture { fleet, fake, owner in + _ = try fleet.acquire(owner: owner, configSHA256: String(repeating: "d", count: 64), + devices: Array(fake.devices.prefix(2)), intermediate: Policy.f18) + try fleet.recover(expectedOwner: owner, bootSessionUUID: owner.bootSessionUUID) + XCTAssertEqual(fake.mappings[101], []) + XCTAssertEqual(fake.mappings[102], []) + XCTAssertTrue(try fleet.pendingRecords(owner: owner).isEmpty) + } + } + + func testUnreadableJournalDoesNotBlockOtherRestores() throws { + try fixture { fleet, fake, owner in + _ = try fleet.acquire(owner: owner, configSHA256: String(repeating: "f", count: 64), + devices: Array(fake.devices.prefix(2)), intermediate: Policy.f18) + let bad = fleet.directory.appendingPathComponent("keyboards/103", isDirectory: true) + try FileManager.default.createDirectory(at: bad, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + let journal = bad.appendingPathComponent("caps-mapping-intent.json") + try Data("invalid".utf8).write(to: journal) + chmod(journal.path, 0o600) + XCTAssertThrowsError(try fleet.recover(expectedOwner: owner, bootSessionUUID: owner.bootSessionUUID)) + XCTAssertEqual(fake.mappings[101], []) + XCTAssertEqual(fake.mappings[102], []) + XCTAssertTrue(FileManager.default.fileExists(atPath: journal.path)) + } + } + + func testParentTerminationGuardExcludesNewDeviceWriter() throws { + try fixture { fleet, fake, owner in + let root = SessionCapsMappingLease(directory: fleet.directory, backend: fake.backend) + let guardHandle = try root.acquireTerminationGuard(expectedOwner: owner) + defer { guardHandle.release() } + XCTAssertThrowsError(try fleet.acquire(owner: owner, configSHA256: String(repeating: "e", count: 64), + devices: [fake.devices[0]], intermediate: Policy.f18)) + { error in + XCTAssertEqual(error as? SessionCapsMappingLease.Refusal, .busy) + } + XCTAssertTrue(fake.writes.isEmpty) + } + } + + func testKeyboardExceptionSurvivesReplugButDistinguishesPorts() { + let original = Policy.DeviceIdentity(registryEntryID: 101, vendorID: 42, productID: 5, + serialNumber: nil, locationID: 100) + let replugged = Policy.DeviceIdentity(registryEntryID: 999, vendorID: 42, productID: 5, + serialNumber: nil, locationID: 100) + let otherPort = Policy.DeviceIdentity(registryEntryID: 1000, vendorID: 42, productID: 5, + serialNumber: nil, locationID: 200) + XCTAssertEqual(SessionCapsRuntimeSupport.preferenceID(original), SessionCapsRuntimeSupport.preferenceID(replugged)) + XCTAssertNotEqual(SessionCapsRuntimeSupport.preferenceID(original), SessionCapsRuntimeSupport.preferenceID(otherPort)) + let serialOne = Policy.DeviceIdentity(registryEntryID: 101, vendorID: 42, productID: 5, + serialNumber: "unique", locationID: 100) + let serialMoved = Policy.DeviceIdentity(registryEntryID: 102, vendorID: 42, productID: 5, + serialNumber: "unique", locationID: 200) + XCTAssertNotEqual(SessionCapsRuntimeSupport.preferenceID(serialOne), SessionCapsRuntimeSupport.preferenceID(serialMoved)) + } + + func testStatusWarnsOnlyForUncoveredKeyboards() { + XCTAssertNil(ServiceLifecycleCoordinator.capsCoverageWarning(.init(eligible: 3, covered: 3, withheld: 0))) + XCTAssertNil(ServiceLifecycleCoordinator.capsCoverageWarning(.init(eligible: 0, covered: 0, withheld: 0))) + XCTAssertTrue(ServiceLifecycleCoordinator.capsCoverageWarning(.init(eligible: 3, covered: 2, withheld: 0))? + .contains("2 of 3") == true) + XCTAssertTrue(ServiceLifecycleCoordinator.capsCoverageWarning(.init(eligible: 2, covered: 2, withheld: 1))? + .contains("1 connected keyboard") == true) + XCTAssertTrue(ServiceLifecycleCoordinator.capsCoverageWarning( + .init(eligible: 2, covered: 2, withheld: 0, inventoryUnavailable: true) + )?.contains("cannot check newly connected keyboards") == true) + } + + /// Opt-in hardware qualification on the lab mini. Each original mapping + /// is read before the write and verified after an exact-owner restore. + func testLiveAttachedKeyboardsAcquireAndRestore() throws { + guard ProcessInfo.processInfo.environment["KEYPATH_LIVE_CAPS_FLEET"] == "1" else { + throw XCTSkip("Set KEYPATH_LIVE_CAPS_FLEET=1 to test attached physical keyboards") + } + let backend = SessionCapsHIDUtilTransport.backend() + let inventory = try SessionCapsHIDUtilTransport.keyboards() + let devices = inventory.keyboards.map(\.device) + guard inventory.withheld == 0, devices.count >= 2, + inventory.keyboards.contains(where: { $0.name.contains("KeyPath Physical HID Fixture") }) + else { + throw XCTSkip("Requires the lab mini's eligible keyboards and physical HID fixture") + } + try SessionCapsRuntimeSupport.requirePhysicalAllUp() + let original = try Dictionary(uniqueKeysWithValues: devices.map { device in + try (device.registryEntryID, backend.read(device).mappings) + }) + guard original.values.allSatisfy({ rows in !rows.contains(where: { $0.source == Policy.caps }) }) else { + throw XCTSkip("Another Caps Lock mapping is already present") + } + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keypath-live-caps-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + let boot = try SessionCapsRuntimeSupport.bootSessionUUID() + let owner = Policy.Owner(uid: getuid(), parentPID: getppid(), workerPID: getpid(), + nonce: UUID().uuidString, generation: UUID().uuidString, bootSessionUUID: boot) + let fleet = SessionCapsMappingFleet(directory: root, backend: backend) + do { + let result = try fleet.acquire(owner: owner, configSHA256: String(repeating: "a", count: 64), + devices: devices, intermediate: Policy.f18) + XCTAssertEqual(result.records.count, devices.count, "Uncovered devices: \(result.failures)") + for device in devices { + let rows = try backend.read(device).mappings + XCTAssertTrue(rows.contains(.init(source: Policy.caps, destination: Policy.f18))) + } + try fleet.restore(owner: owner, bootSessionUUID: boot) + for device in devices { + XCTAssertEqual(try backend.read(device).mappings, original[device.registryEntryID]) + } + try FileManager.default.removeItem(at: root) + } catch { + try? fleet.restore(owner: owner, bootSessionUUID: boot) + throw error + } + } +} diff --git a/docs/architecture/all-keyboards-caps.md b/docs/architecture/all-keyboards-caps.md index 9b75316cb..ba556f343 100644 --- a/docs/architecture/all-keyboards-caps.md +++ b/docs/architecture/all-keyboards-caps.md @@ -1,6 +1,15 @@ # Caps Lock rules on every keyboard -**Status:** design proposal with spike evidence, 2026-10-21. No production code changes. +**Status:** implementation in progress, 2026-10-24. The worker now applies one +crash-recoverable mapping per eligible keyboard and checks for arrivals every two +seconds. Settings reports uncovered keyboards and offers per-keyboard exclusions. +The original spike and open questions below remain the qualification record; +the opt-in `SessionCapsMappingFleetTests/testLiveAttachedKeyboardsAcquireAndRestore` +passed on three attached USB keyboard services, including the physical HID +fixture, with readback and exact restoration on 2026-10-24. This is not a +release-build or hot-plug test. In particular, a press immediately after plugging in a +keyboard can precede the worker's next inventory check. The passive per-press +attribution described in the proposal has not been implemented. **Question:** can driverless KeyPath apply Caps Lock rules without asking the user to choose one keyboard? @@ -45,7 +54,7 @@ frames this as "KeyPath does not guess which one you mean." `Caps keyboard not chosen automatically (selection); starting with Caps Lock rules paused`. Its Caps Lock rule has never run there. -## Proposal +## Original proposal and remaining qualification work Keep the device-scoped mapping. Apply it to every keyboard KeyPath can target. diff --git a/guides/driverless-caps-lock.md b/guides/driverless-caps-lock.md index 4be88117c..e253bd6ed 100644 --- a/guides/driverless-caps-lock.md +++ b/guides/driverless-caps-lock.md @@ -1,199 +1,77 @@ --- layout: default title: "Caps Lock in driverless KeyPath" -description: "Automatic Caps Lock setup, the reserved function key, conflicts with other apps, and keyboard compatibility" +description: "Caps Lock rules across keyboards, keyboard exceptions, and compatibility" permalink: /guides/driverless-caps-lock/ --- # Caps Lock in driverless KeyPath -Choose a Caps Lock rule in **Rules**, such as Escape on tap and Hyper on hold. -KeyPath prepares Caps Lock automatically when exactly one compatible keyboard -is connected and automatic setup is enabled. You do not normally need to -select a keyboard before using the tour. +Add a Caps Lock rule in **Rules**, such as Escape on tap and Hyper on hold. +KeyPath applies the same rule to every compatible connected keyboard by default. +There is no keyboard to choose before the rule works. A keyboard connected later +is set up automatically once all keys are released. -## More than one keyboard +If you previously chose one keyboard for Caps Lock, KeyPath keeps that choice +when you upgrade. Use **Settings → General → Caps Lock options → Use Caps Lock +rules on all keyboards** to switch to the new default. You can also choose +**Use on one keyboard only** there and select a keyboard from the picker. -A MacBook with an external keyboard has two keyboards. KeyPath does not guess -which one you mean, so a Caps Lock rule stays inactive until you choose: +## Make an exception for one keyboard -```text -Settings → General - - ⇪ Caps Lock remapping [ On ] - Choose which keyboard Caps Lock rules apply to - Keyboard: [ Choose a keyboard ▾ ] - Built-in keyboard - cl80 -``` - -Pick the keyboard whose Caps Lock key you want to remap. The status line then -reads **On · cl80 · Uses F18 behind the scenes** (with your keyboard's name, -and whichever key KeyPath reserved; see below). Caps Lock on your other keyboards -keeps working as a normal Caps Lock key. You can change the choice at any time -from the same menu. - -A virtual keyboard in a VM is not a compatible physical keyboard and is not -listed. +Open **Settings → General → Caps Lock rules → Caps Lock options**. Turn +off **Use on [keyboard name]** to leave that keyboard's Caps Lock key alone. +Other keyboards continue using the rule. Turn it back on to resume. The +exception follows a keyboard reconnected at the same port, with the same +serial number when one is available. Moving it to another port makes it a new +keyboard for this preference. -Some keyboards connect to the Mac as more than one device. Boards running QMK, -VIA or Vial firmware often do, especially with N-key rollover turned on: every -key, Caps Lock included, can arrive through a second connection that macOS -labels as a mouse. KeyPath lists such a board once and remaps Caps Lock on -every connection of it that can send keys, so the choice works whichever one -the board uses. +This setting controls whether a keyboard's Caps Lock enters KeyPath's rule +engine. Different Caps actions on different keyboards are not yet available in +driverless mode: macOS does not include a dependable keyboard identity in the +event stream KeyPath uses for ordinary rules. ## Why a function key is involved -Caps Lock changes macOS's lock state before ordinary driverless interception. -KeyPath uses a macOS mapping on the selected keyboard: - -```text -Physical Caps Lock → macOS substitutes F18 → KeyPath applies your Caps Lock rule -``` - -The function key is an internal stand-in. Configure a Caps Lock rule, not a -rule for that key. This does not install a keyboard driver or require Full Disk -Access. Accessibility is used by driverless setup; a separate Input Monitoring -step is not required in the tested flow. - -## Which key is reserved - -KeyPath reserves one key from **F13 to F20** while Caps Lock rules are on. It -starts with F18. Settings shows the current one: +macOS toggles Caps Lock before KeyPath's driverless keyboard event handler can +stop it. KeyPath changes physical Caps Lock to a spare function key **on each +keyboard**, then handles that key as the Caps Lock input to your rule: ```text -Settings → General - - ⇪ Caps Lock remapping ⓘ [ On ] - On · cl80 · Uses F19 behind the scenes +Physical Caps Lock → spare function key → your Caps Lock rule ``` -A real press of the reserved key cannot be told apart from Caps Lock that -macOS changed into it, so KeyPath avoids any key that is already in use. Each -time KeyPath starts its keyboard session it checks, and moves to the next free -key when the current one is taken by: - -- one of your rules (for example a Caps Lock → F18 rule, or a rule triggered by F18), -- another app's macOS key mapping on any keyboard, or -- a shortcut in **System Settings → Keyboard → Keyboard Shortcuts**. - -You do not need to do anything when it moves. If every key from F13 to F20 is -in use, KeyPath says so and Caps Lock rules stay off until one is free. -F21 to F24 cannot be used: macOS gives them no key code, so KeyPath would -never see them. - -## Can I still turn Caps Lock on? - -Yes. Caps Lock output uses the native macOS lock state. Backup Caps Lock and -multi-tap rules can toggle it; holding or repeating that output does not -repeatedly toggle the state. This is distinct from remapping the physical -Caps Lock input, which needs compatible keyboard setup. - -## Restarts and connected keyboards - -The saved selection identifies one keyboard for the current boot. After a -restart of your Mac it is never reused: automatic setup discovers an eligible -keyboard again, which only happens when exactly one compatible keyboard is -connected. With several, KeyPath asks you to choose again. Unplugging and -replugging during the same boot is covered below. - -## Unplugging the chosen keyboard - -macOS removes the Caps Lock mapping together with the keyboard, so nothing is -left behind. KeyPath keeps running: all your other rules keep working, and -Caps Lock rules pause until a keyboard is chosen again. - -```text -Status - - Keyboard remapping - Running normally. cl80 was disconnected. Caps Lock rules are paused; - your other rules keep working. Choose a keyboard in Settings > General - to turn them back on. - -Settings → General - - ⇪ Caps Lock remapping [ On ] - cl80 is disconnected · Caps Lock rules are paused; choose a keyboard - Keyboard: [ Choose a keyboard ▾ ] -``` - -Plug it back into the **same USB port** and KeyPath picks it up again by -itself within a few seconds: Caps Lock rules turn back on, with the same -reserved key, and KeyPath says **cl80 was reconnected. Caps Lock rules are back -on.** KeyPath treats it as the same keyboard only when all of this holds: - -- it is the same boot (a restart of your Mac always asks again), -- the vendor, product and USB port match the keyboard you chose, and so does - the serial number when the keyboard has one, -- exactly one connected keyboard matches. Two identical keyboards that look - the same to macOS are never guessed between. - -Plugged into a **different port**, it counts as a different keyboard. Settings -then reads **cl80 is connected to a different port · Choose it again to turn -Caps Lock rules back on**; pick it from the menu. - -If only one compatible keyboard is left after the unplug, KeyPath uses that one -automatically, the same as at startup, and says so: **cl80 was disconnected. -Caps Lock rules now apply to Built-in keyboard.** Plugging the cl80 back into -the same port moves them back to it. Choosing a keyboard yourself in Settings -in the meantime replaces that claim. - -While Caps Lock rules are paused, Caps Lock on every keyboard works as a normal -Caps Lock key. The same happens when you turn **Caps Lock remapping** off, or -when more than one keyboard is connected and none is chosen yet: rules that use -Caps Lock don't run, and every other rule does. - -## Another app already uses Caps Lock - -Only one app can remap Caps Lock on a keyboard this way. When another app got -there first, Settings says which one when KeyPath can tell: - -```text -Settings → General - - ⇪ Caps Lock remapping [ On ] - On · cl80 · Uses F18 behind the scenes - Raycast's Hyper Key is using Caps Lock. Turn it off in Raycast, - or use another trigger for Hyper Key. -``` - -- **Raycast Hyper Key**: in Raycast's settings, turn Hyper Key off or give it - another key. Raycast's Hyper Key remaps Caps Lock on - every keyboard, so it conflicts whichever keyboard you choose. KeyPath can do - the same job: a Caps Lock rule with Hyper on hold. -- **Something else**: KeyPath names the key Caps Lock was changed to when it - can. Check other keyboard apps, and **System Settings → Keyboard → Keyboard - Shortcuts → Modifier Keys**, which can also change Caps Lock. - -KeyPath never removes another app's mapping. Once it is gone, choose -**Check Again** or reopen Settings. If safe restoration cannot be verified, -the recovery record is retained and restart can be blocked. Follow the displayed -recovery instructions instead of overwriting another mapping. +KeyPath starts with F18 and can choose another key from F13–F20 when a rule, +another mapping, or a macOS shortcut uses it. The spare key is shared across +the participating keyboards. A physical press of that same function key cannot +be distinguished from remapped Caps Lock while Caps rules are running. -Disabling setup preserves your rules. Rules that use physical Caps Lock stay -paused until setup is on again; your other rules keep running. +KeyPath saves each keyboard's original macOS mapping before changing it and +restores each mapping when the keyboard session ends. An unplugged keyboard's +mapping disappears with that device. A recovery record is retained if a +mapping cannot be restored safely. -## Other limitations +## A keyboard could not be used -- Caps Lock rules apply to one chosen keyboard at a time. The reserved key - (F18 unless KeyPath moved it) stays reserved on every keyboard, because - KeyPath cannot tell which keyboard sent it. -- Protected password fields and Secure Input do not gain dynamic remapping. - Do not rely on a Caps remap for password entry. -- Media and brightness remapping remain unavailable in driverless mode. - Your keyboard's normal media controls remain managed by macOS. +Settings names a keyboard whose Caps Lock setup is unavailable; rules on other +keyboards keep working. KeyPath does not replace an existing Caps Lock mapping +from **System Settings → Keyboard → Modifier Keys** or another app such as +Raycast. Remove that conflicting mapping if you want KeyPath to control Caps +Lock on that keyboard, then choose **Check Again**. -## Validation status +Some keyboards expose several interfaces, including one labeled as a mouse for +N-key rollover. KeyPath covers every interface it can verify may send keys. +If it cannot determine the covered interfaces safely, that keyboard is left +uncovered rather than guessing. Virtual keyboards are not physical Caps Lock +targets. -The current implementation includes automatic selection and native Caps Lock -output. These describe code behavior, not a completed hardware certification. -Physical USB qualification of the current candidate, including reconnect and -restart recovery, remains a release gate. Earlier physical tests do not -substitute for testing this candidate. +The first Caps Lock press immediately after plugging in a keyboard can arrive +before macOS finishes presenting the device for setup. If Caps Lock behaves +normally at that moment, release all keys and try again after KeyPath reports +the keyboard as active. Protected password fields and Secure Input still +prevent dynamic remapping. -## Updating KeyPath +## Can a rule turn Caps Lock on? -Choose **Download Update…**, download the new app, then quit KeyPath before -replacing it. Your rules are kept. This driverless version uses manual downloads. +Yes. A rule whose **output** is Caps Lock uses macOS's native lock state. That +is separate from remapping the physical Caps Lock **input** described above.