From 6c724d09a02c4224b6457b98af0cf02c753ce930 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 16:57:39 -0700 Subject: [PATCH 01/15] Refresh rule state at mutation admission --- .../Config/ConfigurationOperationGate.swift | 5 ++ .../Services/Packs/PackInstaller.swift | 2 +- .../RuleCollectionsManager+Mutation.swift | 29 +++++++--- .../RuleCollectionsManager.swift | 3 + .../CustomRuleMutationRecoveryTests.swift | 55 +++++++++++++++++++ .../configuration-save-pipeline.md | 7 ++- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift index c416285a1..158d6fda2 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift @@ -8,6 +8,11 @@ actor ConfigurationOperationGate { struct Permit: Sendable { fileprivate let owner: UUID fileprivate let operation: UUID + + /// Identifies one admitted root operation without exposing the gate owner. + /// Consumers use this only to avoid repeating preparation work for trusted + /// nested calls that carry the same permit. + var operationID: UUID { operation } } enum Failure: LocalizedError { diff --git a/Sources/KeyPathAppKit/Services/Packs/PackInstaller.swift b/Sources/KeyPathAppKit/Services/Packs/PackInstaller.swift index 4d160a594..0be21a58b 100644 --- a/Sources/KeyPathAppKit/Services/Packs/PackInstaller.swift +++ b/Sources/KeyPathAppKit/Services/Packs/PackInstaller.swift @@ -72,7 +72,7 @@ public final class PackInstaller { ) manager.preferencesService.reloadLeaderKeyPreference(from: manager.preferencesService.persistenceDefaults) do { - try await manager.refreshRecoveredRuleStateIfNeeded(recovered, mutationPermit: permit) + try await manager.refreshRuleStateAtMutationAdmission(recovered: recovered, mutationPermit: permit) } catch let error as KeyPathError { if case let .configuration(.loadFailed(reason)) = error { throw InstallError.saveFailed(reason) diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift index 8eb931ae3..24e64918a 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift @@ -22,7 +22,8 @@ extension RuleCollectionsManager { } } - /// Recover interrupted source writes before taking the next editor snapshot. + /// Recover interrupted source writes and refresh normally committed source + /// state before taking the next root-editor snapshot. func recoverAndSnapshotRuleState(mutationPermit: ConfigurationOperationGate.Permit) async -> RuleStateSnapshot? { do { try await recoverRuleState(mutationPermit: mutationPermit) @@ -45,24 +46,36 @@ extension RuleCollectionsManager { if pendingLeaderKeyPreference == nil { preferencesService.reloadLeaderKeyPreference(from: preferencesService.persistenceDefaults) } - try await refreshRecoveredRuleStateIfNeeded(recovered, mutationPermit: mutationPermit) + try await refreshRuleStateAtMutationAdmission( + recovered: recovered, + mutationPermit: mutationPermit + ) } - /// Do not let a retry use stale arrays after journal recovery succeeded but - /// source decoding failed. Clear the requirement only after both stores load. - func refreshRecoveredRuleStateIfNeeded(_ recovered: Bool, mutationPermit: ConfigurationOperationGate.Permit) async throws { - needsRecoveredRuleStateRefresh = needsRecoveredRuleStateRefresh || recovered + /// A cooperating peer can commit a new source revision without leaving a + /// recovery journal. Reload once after this operation acquires its lease so + /// a later rejected edit cannot restore the manager's stale in-memory arrays. + /// Do not reload again for trusted nested calls: they intentionally operate + /// on the candidate staged by their enclosing root operation. + func refreshRuleStateAtMutationAdmission( + recovered: Bool, + mutationPermit: ConfigurationOperationGate.Permit + ) async throws { + let needsRefresh = lastRuleStateRefreshOperationID != mutationPermit.operationID + || needsRecoveredRuleStateRefresh + || recovered || observedRuleRecoveryRevision != configurationService.ruleRecoveryRevision - if needsRecoveredRuleStateRefresh { + if needsRefresh { let collections = await ruleCollectionStore.loadCollectionsDetailed() guard !collections.wasFullReset, collections.failedCollectionNames.isEmpty else { - throw KeyPathError.configuration(.loadFailed(reason: "Recovered rule collections could not be read completely. No edit was made.")) + throw KeyPathError.configuration(.loadFailed(reason: "Rule collections could not be read completely. No edit was made.")) } let rules = try await customRulesStore.loadForMutation() ruleCollections = RuleCollectionDeduplicator.dedupe(collections.collections) customRules = rules needsRecoveredRuleStateRefresh = false observedRuleRecoveryRevision = configurationService.ruleRecoveryRevision + lastRuleStateRefreshOperationID = mutationPermit.operationID refreshLayerIndicatorState() } // The configuration owner retains this requirement across app/rule/raw diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift index cc68b21d0..c46ebc9c8 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift @@ -60,6 +60,9 @@ final class RuleCollectionsManager { /// Remains set when recovered files cannot yet be read completely. var needsRecoveredRuleStateRefresh = false var observedRuleRecoveryRevision: UInt64 = 0 + /// Source state is refreshed once for each newly admitted root operation. + /// Trusted nested calls retain their candidate state instead of reloading it. + var lastRuleStateRefreshOperationID: UUID? var currentLayerName: String = RuleCollectionLayer.base.displayName /// Active keymap layout ID (e.g., "colemak-dh", "dvorak") diff --git a/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift b/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift index b618b28b0..8cd1d9040 100644 --- a/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift +++ b/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift @@ -19,6 +19,7 @@ final class CustomRuleMutationRecoveryTests: KeyPathTestCase { manager.ruleCollections = [] manager.customRules = [CustomRule(input: "f20", action: .keystroke(key: "f19"), createdAt: Date(timeIntervalSince1970: 42))] try await service.saveRuleState(ruleCollections: [], customRules: manager.customRules, collectionStore: collections, customStore: rules) + manager.ruleCollections = await collections.loadCollectionsDetailed().collections } override func tearDown() async throws { @@ -200,6 +201,60 @@ final class CustomRuleMutationRecoveryTests: KeyPathTestCase { XCTAssertEqual(Set(stored.map(\.input)), ["f13", "f20"]) } + func testRejectedMutationRefreshesNormallyCommittedPeerRevisionBeforeSnapshot() async throws { + let peerCollections = RuleCollectionStore.testStore(at: directory.appendingPathComponent("RuleCollections.json")) + let peerRules = CustomRulesStore.testStore(at: directory.appendingPathComponent("CustomRules.json")) + let peerService = ConfigurationService( + configDirectory: directory.path, + ruleCollectionStore: peerCollections, + customRulesStore: peerRules + ) + let peerRule = CustomRule(input: "f18", action: .keystroke(key: "f17")) + let baseline = manager.customRules + try await peerService.saveRuleState( + ruleCollections: [], + customRules: baseline + [peerRule], + collectionStore: peerCollections, + customStore: peerRules + ) + + var reloads = 0 + manager.onRulesChanged = { + reloads += 1 + return Self.reload(reloads == 1 ? .rejected : .applied) + } + let candidate = CustomRule(input: "f13", action: .keystroke(key: "f14")) + let rejected = await manager.saveCustomRule(candidate) + XCTAssertFalse(rejected) + + XCTAssertEqual(Set(manager.customRules.map(\.input)), ["f18", "f20"]) + let afterRejectedSave = try await manager.customRulesStore.loadForMutation() + XCTAssertEqual(Set(afterRejectedSave.map(\.input)), ["f18", "f20"]) + + let applied = await manager.saveCustomRule(candidate) + XCTAssertTrue(applied) + let afterRetry = try await manager.customRulesStore.loadForMutation() + XCTAssertEqual(Set(afterRetry.map(\.input)), ["f13", "f18", "f20"]) + } + + func testNestedMutationRetainsOuterStagedCandidateUnderSamePermit() async throws { + let outerCandidate = CustomRule(input: "f13", action: .keystroke(key: "f14")) + let nestedCandidate = CustomRule(input: "f18", action: .keystroke(key: "f17")) + manager.onRulesChanged = { Self.reload(.applied) } + + try await manager.configurationService.operationGate.withOperation { @MainActor permit in + let snapshot = await self.manager.recoverAndSnapshotRuleState(mutationPermit: permit) + XCTAssertNotNil(snapshot) + self.manager.customRules.append(outerCandidate) + + let nestedSave = await self.manager.saveCustomRule(nestedCandidate, mutationPermit: permit) + XCTAssertTrue(nestedSave) + } + + let stored = try await manager.customRulesStore.loadForMutation() + XCTAssertEqual(Set(stored.map(\.input)), ["f13", "f18", "f20"]) + } + func testUnreadableRecoveredSourcesBlockRetriesUntilRepaired() async throws { let service = manager.configurationService let sourceURL = directory.appendingPathComponent("RuleCollections.json") diff --git a/docs/architecture/configuration-save-pipeline.md b/docs/architecture/configuration-save-pipeline.md index 852cff6ac..81d8b8dea 100644 --- a/docs/architecture/configuration-save-pipeline.md +++ b/docs/architecture/configuration-save-pipeline.md @@ -103,8 +103,11 @@ permits through internal overloads. Missing-file backup reads carry the permit through self-healing creation so the backup retains stored rules. CLI operation ownership is described below. App-specific edits and Simple Modifications now use SaveCoordinator; startup app-include creation uses the same directory gate. -Source/cache freshness and external edits remain separate work. Merely using -this service for validation does not acquire write admission. Pack operations hold admission while staging +`RuleCollectionsManager` refreshes its persisted collection and custom-rule +sources once after each newly admitted root operation, before it snapshots an +edit candidate. Trusted nested calls keep the same permit and therefore retain +that candidate. Other source/cache freshness and external edits remain separate +work. Merely using this service for validation does not acquire write admission. Pack operations hold admission while staging arrays, making nested collection calls, updating metadata and running their existing recovery paths; their multiple writes are still separate durable commits. The collection journal below provides a separate durable file recovery boundary; From 20db584532cc8927884c790c8c75c3d150f57476 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 17:24:53 -0700 Subject: [PATCH 02/15] Retain shortcut and device generation inputs --- .../Config/ConfigurationService.swift | 88 +++++++-- .../Config/KanataConfigurationGenerator.swift | 20 +- .../Config/RecoverableRuleWrite.swift | 34 ++++ .../RuntimeCoordinator+RuleCollections.swift | 13 ++ .../Managers/RuntimeCoordinator.swift | 48 +---- .../Managers/SaveCoordinator.swift | 6 +- .../Configuration/PreferencesService.swift | 65 +++++-- .../Devices/DeviceSelectionStore.swift | 39 +++- .../RuleCollectionsManager+Mutation.swift | 76 ++++++++ .../ContextHUDSettingsSection.swift | 37 +++- .../UI/Overlay/DeviceSelectionView.swift | 34 ++-- .../UI/ViewModels/KanataViewModel.swift | 10 + ...DurableConfigPreferenceRecoveryTests.swift | 172 +++++++++++++++++- .../configuration-save-pipeline.md | 16 +- 14 files changed, 543 insertions(+), 115 deletions(-) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index 745fa4747..c9afcad6e 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -32,9 +32,11 @@ public final class ConfigurationService: FileConfigurationProviding { private let ruleCollectionStore: RuleCollectionStore private let customRulesStore: CustomRulesStore + private let deviceSelectionStore: DeviceSelectionStore private let synchronizePreferences: @Sendable (RecoverableRuleWrite.PreferenceDefaults) -> Bool @MainActor private var needsRecoveredRuntimeRefresh = false + @MainActor private var needsRecoveredDeviceRuntimeRestart = false @MainActor private(set) var ruleRecoveryRevision: UInt64 = 0 let operationGate: ConfigurationOperationGate @@ -50,7 +52,8 @@ public final class ConfigurationService: FileConfigurationProviding { self.init( configDirectory: configDirectory, ruleCollectionStore: .shared, - customRulesStore: .shared + customRulesStore: .shared, + deviceSelectionStore: .shared ) } @@ -60,10 +63,12 @@ public final class ConfigurationService: FileConfigurationProviding { configDirectory: String?, ruleCollectionStore: RuleCollectionStore, customRulesStore: CustomRulesStore, + deviceSelectionStore: DeviceSelectionStore = .shared, synchronizePreferences: @escaping @Sendable (RecoverableRuleWrite.PreferenceDefaults) -> Bool = { $0.value.synchronize() } ) { self.ruleCollectionStore = ruleCollectionStore self.customRulesStore = customRulesStore + self.deviceSelectionStore = deviceSelectionStore self.synchronizePreferences = synchronizePreferences if let customDirectory = configDirectory { self.configDirectory = customDirectory @@ -387,6 +392,7 @@ public final class ConfigurationService: FileConfigurationProviding { let pending: RecoverableRuleWrite.PendingWrite let configuration: KanataConfiguration let packUpdate: InstalledPackTracker.PreparedRecordUpdate? + let deviceSelections: [DeviceSelection]? } /// Stage the generated configuration and both source stores without notifying @@ -398,9 +404,14 @@ public final class ConfigurationService: FileConfigurationProviding { packRecord: InstalledPackTracker.RecordChange? = nil, preferenceDefaults: UserDefaults? = PreferencesService.canonicalDefaults, preferenceChanges: [RecoverableRuleWrite.PreferenceChange] = [], - leaderKeyPreference: LeaderKeyPreference? = nil + leaderKeyPreference: LeaderKeyPreference? = nil, + shortcutListGenerationInput: ShortcutListGenerationInput? = nil, + deviceSelections: [DeviceSelection]? = nil ) async throws -> RuleWrite { try await operationGate.withOperation(using: mutationPermit) { @MainActor [self] permit in + guard packRecord == nil || deviceSelections == nil else { + throw RecoverableRuleWrite.Failure.invalidJournal + } try await recoverPendingRuleWrite( collectionStore: collectionStore, customStore: customStore, @@ -410,6 +421,7 @@ public final class ConfigurationService: FileConfigurationProviding { ) try await recoverPendingAppKeymapWrite(mutationPermit: permit) var targets = await ruleWriteFiles(collectionStore: collectionStore, customStore: customStore) + if deviceSelections != nil { targets["deviceSelection"] = await deviceSelectionStore.persistenceURL } if let packRecord { targets["installedPacks"] = await packRecord.tracker.persistenceURL } let files = targets let before = try await snapshotRuleFiles(files) @@ -418,9 +430,16 @@ public final class ConfigurationService: FileConfigurationProviding { packUpdate = try await packRecord.tracker.prepareUpdate(packRecord) guard packUpdate?.before == before["installedPacks"] else { throw RecoverableRuleWrite.Failure.changedFile("installedPacks") } } else { packUpdate = nil } + let deviceGenerationInput: DeviceGenerationInput? = if let deviceSelections { + await deviceSelectionStore.generationInput(for: deviceSelections) + } else { + nil + } let newConfig = try await preparedConfiguration( ruleCollections: ruleCollections, customRules: customRules, - leaderKeyPreference: leaderKeyPreference ?? persistedLeaderPreference(in: preferenceDefaults) + leaderKeyPreference: leaderKeyPreference ?? persistedLeaderPreference(in: preferenceDefaults), + shortcutListGenerationInput: shortcutListGenerationInput, + deviceGenerationInput: deviceGenerationInput ) var payload = try await [ "config": Data(newConfig.content.utf8), @@ -428,13 +447,14 @@ public final class ConfigurationService: FileConfigurationProviding { "customRules": customStore.encodedRules(customRules) ] if let packUpdate { payload["installedPacks"] = packUpdate.contents } + if let deviceSelections { payload["deviceSelection"] = try await deviceSelectionStore.encodedSelections(deviceSelections) } let contents = payload let sendablePreferences = preferenceDefaults.map(RecoverableRuleWrite.PreferenceDefaults.init) try Task.checkCancellation() let directory = URL(fileURLWithPath: configDirectory) let pending = try await performRuleFileOperation { try RecoverableRuleWrite.stage(files: files, contents: contents, directory: directory, - scope: packUpdate == nil ? .rules : .packRules, expectedBefore: before, + scope: deviceSelections != nil ? .deviceRules : (packUpdate == nil ? .rules : .packRules), expectedBefore: before, preferences: sendablePreferences?.value, preferenceChanges: preferenceChanges, synchronizePreferences: self.synchronizePreferences) { data, url in @@ -442,7 +462,7 @@ public final class ConfigurationService: FileConfigurationProviding { else { try RecoverableRuleWrite.durableWrite(data, url) } } } - return RuleWrite(pending: pending, configuration: newConfig, packUpdate: packUpdate) + return RuleWrite(pending: pending, configuration: newConfig, packUpdate: packUpdate, deviceSelections: deviceSelections) } } @@ -455,9 +475,13 @@ public final class ConfigurationService: FileConfigurationProviding { if commit { await publishSavedConfiguration(write.configuration) if let update = write.packUpdate { await update.tracker.publishCommittedUpdate(update) } + if let deviceSelections = write.deviceSelections { await deviceSelectionStore.publishSelectionsToCache(deviceSelections) } } else { stateLock.withLock { currentConfiguration = nil } if let update = write.packUpdate { await update.tracker.restorePublishedUpdate(update) } + if write.deviceSelections != nil { + await deviceSelectionStore.publishSelectionsToCache(try await deviceSelectionStore.loadForMutation()) + } } } } @@ -494,6 +518,9 @@ public final class ConfigurationService: FileConfigurationProviding { } let packFiles = packTargets let rawFiles = ["config": URL(fileURLWithPath: configurationPath)] + let deviceFiles = files.merging([ + "deviceSelection": await deviceSelectionStore.persistenceURL + ], uniquingKeysWith: { _, deviceSelectionURL in deviceSelectionURL }) let sendablePreferences = preferenceDefaults.map(RecoverableRuleWrite.PreferenceDefaults.init) let recovered = try await performRuleFileOperation { let rawRecovered = try RecoverableRuleWrite.recover( @@ -502,14 +529,19 @@ public final class ConfigurationService: FileConfigurationProviding { ) let packRecovered = try RecoverableRuleWrite.recover(files: packFiles, directory: directory, scope: .packRules, preferences: sendablePreferences?.value) let rulesRecovered = try RecoverableRuleWrite.recover(files: files, directory: directory, preferences: sendablePreferences?.value) - return (raw: rawRecovered, rules: packRecovered || rulesRecovered) + let deviceRecovered = try RecoverableRuleWrite.recover(files: deviceFiles, directory: directory, scope: .deviceRules, preferences: sendablePreferences?.value) + return (raw: rawRecovered, rules: packRecovered || rulesRecovered, device: deviceRecovered) } - if recovered.raw || recovered.rules { + if recovered.raw || recovered.rules || recovered.device { stateLock.withLock { currentConfiguration = nil } needsRecoveredRuntimeRefresh = true } if recovered.rules { ruleRecoveryRevision &+= 1 } - return recovered.rules + if recovered.device { + await deviceSelectionStore.publishSelectionsToCache(try await deviceSelectionStore.loadForMutation()) + needsRecoveredDeviceRuntimeRestart = true + } + return recovered.rules || recovered.device } } @@ -668,7 +700,7 @@ public final class ConfigurationService: FileConfigurationProviding { reloadHandler: (() async -> ReloadResult)? ) async throws -> ReloadResult? { try await operationGate.withOperation(using: mutationPermit) { @MainActor [self] _ in - guard needsRecoveredRuntimeRefresh, let reloadHandler else { return nil } + guard needsRecoveredRuntimeRefresh, !needsRecoveredDeviceRuntimeRestart, let reloadHandler else { return nil } let result = await Task { @MainActor in await reloadHandler() }.value guard result.disposition == .applied || result.disposition == .pending else { throw KeyPathError.configuration(.loadFailed(reason: "Recovered files could not be applied: \(result.errorMessage ?? "keyboard service rejected recovery")")) @@ -678,6 +710,24 @@ public final class ConfigurationService: FileConfigurationProviding { } } + /// Device targeting changes require a daemon restart: a TCP config reload + /// does not replace the active device-grab set. The caller owns restart + /// sequencing, then clears this retained recovery obligation. + func applyRecoveredDeviceRuntimeRestartIfNeeded( + mutationPermit: ConfigurationOperationGate.Permit? = nil, + restartHandler: () async -> Bool + ) async throws -> Bool { + try await operationGate.withOperation(using: mutationPermit) { @MainActor [self] _ in + guard needsRecoveredDeviceRuntimeRestart else { return false } + guard await restartHandler() else { + throw KeyPathError.configuration(.loadFailed(reason: "Recovered device targeting could not be restarted")) + } + needsRecoveredDeviceRuntimeRestart = false + needsRecoveredRuntimeRefresh = false + return true + } + } + private func appKeymapWriteFiles(store: AppKeymapStore) async -> [String: URL] { await ["config": URL(fileURLWithPath: configurationPath), "appKeymaps": store.persistenceURL, @@ -703,11 +753,15 @@ public final class ConfigurationService: FileConfigurationProviding { private func preparedConfiguration( ruleCollections: [RuleCollection], customRules: [CustomRule], - leaderKeyPreference: LeaderKeyPreference? = nil + leaderKeyPreference: LeaderKeyPreference? = nil, + shortcutListGenerationInput: ShortcutListGenerationInput? = nil, + deviceGenerationInput: DeviceGenerationInput? = nil ) async throws -> KanataConfiguration { let newConfig = try await generateConfiguration( ruleCollections: ruleCollections, customRules: customRules, - leaderKeyPreference: leaderKeyPreference + leaderKeyPreference: leaderKeyPreference, + shortcutListGenerationInput: shortcutListGenerationInput, + deviceGenerationInput: deviceGenerationInput ) let validation = await validateConfiguration(newConfig.content) guard validation.isValid else { @@ -738,11 +792,13 @@ public final class ConfigurationService: FileConfigurationProviding { } /// Generate a Kanata configuration from rule stores without writing it. - public func generateConfiguration( + func generateConfiguration( ruleCollections: [RuleCollection], customRules: [CustomRule] = [], appSpecificKeys: Set? = nil, - leaderKeyPreference: LeaderKeyPreference? = nil + leaderKeyPreference: LeaderKeyPreference? = nil, + shortcutListGenerationInput: ShortcutListGenerationInput? = nil, + deviceGenerationInput: DeviceGenerationInput? = nil ) async throws -> KanataConfiguration { // Custom rules come first so they take priority over preset collections let customRuleCollections = customRules.asRuleCollections() @@ -771,6 +827,7 @@ public final class ConfigurationService: FileConfigurationProviding { PreferencesService.shared.contextHUDHoldDelayMs) } let leaderKeyPref = leaderKeyPreference ?? storedLeaderKeyPref + let shortcutInput = shortcutListGenerationInput // DETECT CONFLICTS BEFORE DEDUPLICATION // This catches cases where multiple collections map the same key, and where @@ -795,8 +852,9 @@ public final class ConfigurationService: FileConfigurationProviding { let configContent = KanataConfiguration.generateFromCollections( combinedCollections, leaderKeyPreference: leaderKeyPref, - navActivationMode: triggerMode, - navHoldDelayMs: holdDelayMs, + navActivationMode: shortcutInput?.triggerMode ?? triggerMode, + navHoldDelayMs: shortcutInput?.holdDelayMs ?? holdDelayMs, + deviceGenerationInput: deviceGenerationInput, chordGroups: preservedChordGroups, sequences: preservedSequences, appSpecificKeys: appSpecificKeys diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift index 014229178..8dc7a5dee 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift @@ -39,11 +39,12 @@ public struct KanataConfiguration: Sendable { /// Generate configuration content from rule collections. /// Flattens enabled collections to `defsrc`/`deflayer` for backward compatibility with Kanata config format. - public static func generateFromCollections( + static func generateFromCollections( _ collections: [RuleCollection], leaderKeyPreference: LeaderKeyPreference? = nil, navActivationMode: ContextHUDTriggerMode = .tapToToggle, navHoldDelayMs: Int = 200, + deviceGenerationInput: DeviceGenerationInput? = nil, chordGroups: [ChordGroupConfig] = [], sequences: [KanataDefseqParser.ParsedSequence] = [], appSpecificKeys: Set? = nil @@ -96,7 +97,7 @@ public struct KanataConfiguration: Sendable { let blocks = deduplicateBlocks(rawBlocks) let enabledNames = enabledCollections.map(\.name).joined(separator: ", ") - let macosDeviceTargeting = renderMacOSDeviceTargetingForDefcfg() + let macosDeviceTargeting = renderMacOSDeviceTargetingForDefcfg(deviceGenerationInput) let keyRepeatConfig = enabledCollections .compactMap(\.configuration.keyRepeatControlConfig) .first @@ -225,21 +226,28 @@ public struct KanataConfiguration: Sendable { /// When all non-VirtualHID devices are enabled (the default), we emit only `macos-dev-names-exclude`. /// When any non-VirtualHID device is disabled, we emit `macos-dev-names-include` for enabled devices /// plus `macos-dev-names-exclude` for VirtualHID devices. - private static func renderMacOSDeviceTargetingForDefcfg() -> String { + private static func renderMacOSDeviceTargetingForDefcfg( + _ input: DeviceGenerationInput? = nil + ) -> String { #if os(macOS) let cache = DeviceSelectionCache.shared // Use only the cached device list. CompositionRoot primes selections synchronously // at startup, and device enumeration updates this cache when the Devices tab loads. - let allDevices = cache.getConnectedDevices() + let allDevices = input?.connectedDevices ?? cache.getConnectedDevices() guard !allDevices.isEmpty else { return "" } let virtualHIDDevices = allDevices.filter(\.isVirtualHID) let physicalDevices = allDevices.filter { !$0.isVirtualHID } // Check which physical devices are disabled via user selection - let disabledPhysical = physicalDevices.filter { !cache.isEnabled(hash: $0.hash) } - let enabledPhysical = physicalDevices.filter { cache.isEnabled(hash: $0.hash) } + let enabledByHash = Dictionary( + (input?.selections ?? cache.allSelections()).map { ($0.hash, $0.isEnabled) }, + uniquingKeysWith: { _, latest in latest } + ) + let isEnabled: (String) -> Bool = { enabledByHash[$0] ?? true } + let disabledPhysical = physicalDevices.filter { !isEnabled($0.hash) } + let enabledPhysical = physicalDevices.filter { isEnabled($0.hash) } // VirtualHID exclusion (always needed) let virtualHIDNames = virtualHIDDevices.flatMap { [$0.hash, $0.productKey] }.sorted() diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift b/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift index 22a56fd0a..aedebce2d 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift @@ -11,6 +11,7 @@ enum RecoverableRuleWrite { case appKeymaps case packRules case rawConfig + case deviceRules var roles: Set { switch self { @@ -18,6 +19,7 @@ enum RecoverableRuleWrite { case .appKeymaps: ["config", "appKeymaps", "appInclude"] case .packRules: ["config", "collections", "customRules", "installedPacks"] case .rawConfig: ["config"] + case .deviceRules: ["config", "collections", "customRules", "deviceSelection"] } } } @@ -79,14 +81,44 @@ enum RecoverableRuleWrite { after: requiredPreferenceData(after) ) } + + static func contextHUDTriggerMode(before: Any?, after: String) throws -> Self { + try .init( + role: .contextHUDTriggerMode, + before: preferenceData(before), + after: requiredPreferenceData(after) + ) + } + + static func contextHUDHoldDelayPreset(before: Any?, after: String) throws -> Self { + try .init( + role: .contextHUDHoldDelayPreset, + before: preferenceData(before), + after: requiredPreferenceData(after) + ) + } + + static func contextHUDHoldDelayCustomMs(before: Any?, after: Int) throws -> Self { + try .init( + role: .contextHUDHoldDelayCustomMs, + before: preferenceData(before), + after: requiredPreferenceData(after) + ) + } } enum PreferenceRole: Codable, Hashable, Sendable { case leader + case contextHUDTriggerMode + case contextHUDHoldDelayPreset + case contextHUDHoldDelayCustomMs var key: String { switch self { case .leader: PreferencesService.leaderKeyPreferenceKey + case .contextHUDTriggerMode: "KeyPath.ContextHUD.TriggerMode" + case .contextHUDHoldDelayPreset: "KeyPath.ContextHUD.HoldDelayPreset" + case .contextHUDHoldDelayCustomMs: "KeyPath.ContextHUD.HoldDelayCustomMs" } } } @@ -400,6 +432,7 @@ enum RecoverableRuleWrite { case .appKeymaps: ".keypath-app-write.json" case .packRules: ".keypath-pack-rule-write.json" case .rawConfig: ".keypath-raw-write.json" + case .deviceRules: ".keypath-device-write.json" } return directory.appendingPathComponent(name) } @@ -411,6 +444,7 @@ enum RecoverableRuleWrite { journalURL(directory, scope: .appKeymaps), journalURL(directory, scope: .packRules), journalURL(directory, scope: .rawConfig), + journalURL(directory, scope: .deviceRules), directory.appendingPathComponent(".keypath-rule-write.lock") ] .map(\.standardizedFileURL)) diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift index 64392c0b4..898fde6c3 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift @@ -103,6 +103,19 @@ extension RuntimeCoordinator { await ruleCollectionsCoordinator.updateLeaderKey(newKey) } + @discardableResult + func applyShortcutListGenerationInput(_ input: ShortcutListGenerationInput) async -> Bool { + await ruleCollectionsManager.applyShortcutListGenerationInput(input) + } + + @discardableResult + func applyDeviceSelections(_ selections: [DeviceSelection]) async -> Bool { + await ruleCollectionsManager.applyDeviceSelections(selections) { [weak self] in + guard let self else { return false } + return await self.restartKanata(reason: "Device selection changed") + } + } + @discardableResult func saveCustomRule(_ rule: CustomRule, skipReload: Bool = false, autoResolveConflicts: Bool = false) async -> Bool { await ruleCollectionsCoordinator.saveCustomRule(rule, skipReload: skipReload, autoResolveConflicts: autoResolveConflicts) diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift index a4f24e83b..0b5e9eb8a 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift @@ -477,6 +477,16 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { ) Task { await ruleCollectionsManager.bootstrap() + do { + _ = try await configurationService.applyRecoveredDeviceRuntimeRestartIfNeeded( + mutationPermit: nil + ) { [weak self] in + guard let self else { return false } + return await self.restartKanata(reason: "Recovering device selection") + } + } catch { + self.lastError = error.localizedDescription + } ruleCollectionsManager.startEventMonitoring(port: PreferencesService.shared.tcpServerPort) } HrmObservabilityService.shared.startMonitoring(port: PreferencesService.shared.tcpServerPort) @@ -492,45 +502,7 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { AppLogger.shared.log("๐Ÿงช [RuntimeCoordinator] One-shot probe mode or test host - skipping bootstrap and event monitoring") } - // Observe config-affecting preference changes (e.g., nav trigger mode) to regenerate config if !isOneShotProbeMode { - notificationObserverTokens.append(NotificationCenter.default.addObserver( - forName: .configAffectingPreferenceChanged, - object: nil, - queue: NotificationObserverManager.mainOperationQueue - ) { @Sendable [weak self] _ in - guard let self else { return } - Task { @MainActor in - AppLogger.shared.log("๐Ÿ”„ [RuntimeCoordinator] Config-affecting preference changed, regenerating config...") - await self.ruleCollectionsManager.regenerateConfigFromCollections() - } - }) - - notificationObserverTokens.append(NotificationCenter.default.addObserver( - forName: .deviceSelectionChanged, - object: nil, - queue: NotificationObserverManager.mainOperationQueue - ) { @Sendable [weak self] _ in - guard let self else { return } - Task { @MainActor in - AppLogger.shared.log("๐Ÿ”Œ [RuntimeCoordinator] Device selection changed, regenerating config and restarting Kanata...") - let regenerated = await self.ruleCollectionsManager.regenerateConfigFromCollections(skipReload: true) - let success: Bool = if regenerated { - await self.restartKanata(reason: "Device selection changed") - } else { - false - } - NotificationCenter.default.post( - name: .deviceSelectionApplyCompleted, - object: nil, - userInfo: ["success": success] - ) - if success { - NotificationCenter.default.post(name: .kanataConfigChanged, object: nil) - } - } - }) - // Authoritative kanata grab status (#625): a grab failure means kanata // is up but not remapping. Drive bounded auto-recovery off this signal. notificationObserverTokens.append(NotificationCenter.default.addObserver( diff --git a/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift b/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift index fc2687227..b26422e87 100644 --- a/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift @@ -237,6 +237,8 @@ final class SaveCoordinator { packRecord: InstalledPackTracker.RecordChange? = nil, preferenceChanges: [RecoverableRuleWrite.PreferenceChange] = [], leaderKeyPreference: LeaderKeyPreference? = nil, + shortcutListGenerationInput: ShortcutListGenerationInput? = nil, + deviceSelections: [DeviceSelection]? = nil, reloadHandler: (() async -> ReloadResult)? ) async -> SaveResult { do { @@ -256,7 +258,9 @@ final class SaveCoordinator { mutationPermit: permit, packRecord: packRecord, preferenceDefaults: manager.preferencesService.persistenceDefaults, preferenceChanges: preferenceChanges, - leaderKeyPreference: leaderKeyPreference + leaderKeyPreference: leaderKeyPreference, + shortcutListGenerationInput: shortcutListGenerationInput, + deviceSelections: deviceSelections ) try Task.checkCancellation() playWriteSound() diff --git a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift index 5026cdb55..051ed21f5 100644 --- a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift +++ b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift @@ -1,5 +1,17 @@ import Foundation import KeyPathCore + +/// The three shortcut-list values that affect generated Kanata configuration. +/// A mutation captures this immutable candidate before it writes any defaults. +struct ShortcutListGenerationInput: Sendable, Equatable { + let triggerMode: ContextHUDTriggerMode + let holdDelayPreset: ContextHUDHoldDelayPreset + let customHoldDelayMs: Int + + var holdDelayMs: Int { + holdDelayPreset.milliseconds ?? customHoldDelayMs + } +} import Observation /// Key label display style for modifier and action keys on the keyboard visualization. @@ -176,6 +188,7 @@ final class PreferencesService: @unchecked Sendable { } private var suppressLeaderPersistence = false + private var suppressShortcutListPersistence = false // MARK: - Communication Protocol Configuration @@ -349,10 +362,9 @@ final class PreferencesService: @unchecked Sendable { /// How the HUD/overlay is triggered by the modifier key var contextHUDTriggerMode: ContextHUDTriggerMode { didSet { - UserDefaults.standard.set(contextHUDTriggerMode.rawValue, forKey: Keys.contextHUDTriggerMode) + guard !suppressShortcutListPersistence else { return } + leaderDefaults.set(contextHUDTriggerMode.rawValue, forKey: Keys.contextHUDTriggerMode) AppLogger.shared.log("๐ŸŽฏ [Preferences] contextHUDTriggerMode = \(contextHUDTriggerMode.rawValue)") - // Trigger config regeneration since this affects Kanata layer activation behavior - NotificationCenter.default.post(name: .configAffectingPreferenceChanged, object: nil) } } @@ -372,22 +384,22 @@ final class PreferencesService: @unchecked Sendable { /// Preset hold duration for triggering Shortcut List/navigation layer. var contextHUDHoldDelayPreset: ContextHUDHoldDelayPreset { didSet { - UserDefaults.standard.set(contextHUDHoldDelayPreset.rawValue, forKey: Keys.contextHUDHoldDelayPreset) + guard !suppressShortcutListPersistence else { return } + leaderDefaults.set(contextHUDHoldDelayPreset.rawValue, forKey: Keys.contextHUDHoldDelayPreset) AppLogger.shared.log("๐ŸŽฏ [Preferences] contextHUDHoldDelayPreset = \(contextHUDHoldDelayPreset.rawValue)") - NotificationCenter.default.post(name: .configAffectingPreferenceChanged, object: nil) } } /// Custom hold duration in milliseconds (used when preset is `.custom`). var contextHUDHoldDelayCustomMs: Int { didSet { + guard !suppressShortcutListPersistence else { return } let clamped = Self.clampedContextHUDHoldDelayCustomMs(contextHUDHoldDelayCustomMs) if clamped != contextHUDHoldDelayCustomMs { contextHUDHoldDelayCustomMs = clamped } else { - UserDefaults.standard.set(contextHUDHoldDelayCustomMs, forKey: Keys.contextHUDHoldDelayCustomMs) + leaderDefaults.set(contextHUDHoldDelayCustomMs, forKey: Keys.contextHUDHoldDelayCustomMs) AppLogger.shared.log("๐ŸŽฏ [Preferences] contextHUDHoldDelayCustomMs = \(contextHUDHoldDelayCustomMs)") - NotificationCenter.default.post(name: .configAffectingPreferenceChanged, object: nil) } } } @@ -397,6 +409,37 @@ final class PreferencesService: @unchecked Sendable { contextHUDHoldDelayPreset.milliseconds ?? contextHUDHoldDelayCustomMs } + var shortcutListGenerationInput: ShortcutListGenerationInput { + ShortcutListGenerationInput( + triggerMode: contextHUDTriggerMode, + holdDelayPreset: contextHUDHoldDelayPreset, + customHoldDelayMs: contextHUDHoldDelayCustomMs + ) + } + + /// Reload only the configuration-affecting shortcut-list values after their + /// retained write settles. This intentionally avoids emitting the legacy + /// notification-driven regeneration path. + func reloadShortcutListGenerationInput(from defaults: UserDefaults? = nil) { + let source = defaults ?? leaderDefaults + let trigger = source.string(forKey: Keys.contextHUDTriggerMode) ?? Defaults.contextHUDTriggerMode.rawValue + let preset = source.string(forKey: Keys.contextHUDHoldDelayPreset) ?? Defaults.contextHUDHoldDelayPreset.rawValue + let custom = source.object(forKey: Keys.contextHUDHoldDelayCustomMs) as? Int ?? Defaults.contextHUDHoldDelayCustomMs + stageShortcutListGenerationInput(ShortcutListGenerationInput( + triggerMode: ContextHUDTriggerMode(rawValue: trigger) ?? Defaults.contextHUDTriggerMode, + holdDelayPreset: ContextHUDHoldDelayPreset(rawValue: preset) ?? Defaults.contextHUDHoldDelayPreset, + customHoldDelayMs: Self.clampedContextHUDHoldDelayCustomMs(custom) + )) + } + + func stageShortcutListGenerationInput(_ input: ShortcutListGenerationInput) { + suppressShortcutListPersistence = true + contextHUDTriggerMode = input.triggerMode + contextHUDHoldDelayPreset = input.holdDelayPreset + contextHUDHoldDelayCustomMs = input.customHoldDelayMs + suppressShortcutListPersistence = false + } + /// Presentation mode for KindaVim in leader-hold HUD/key list. var kindaVimLeaderHUDMode: KindaVimLeaderHUDMode { didSet { @@ -576,7 +619,7 @@ final class PreferencesService: @unchecked Sendable { contextHUDDisplayMode = ContextHUDDisplayMode(rawValue: hudModeString) ?? Defaults.contextHUDDisplayMode - let triggerModeString = UserDefaults.standard.string(forKey: Keys.contextHUDTriggerMode) + let triggerModeString = leaderDefaults.string(forKey: Keys.contextHUDTriggerMode) ?? Defaults.contextHUDTriggerMode.rawValue contextHUDTriggerMode = ContextHUDTriggerMode(rawValue: triggerModeString) ?? Defaults.contextHUDTriggerMode @@ -589,17 +632,17 @@ final class PreferencesService: @unchecked Sendable { UserDefaults.standard.set(sanitizedTimeout, forKey: Keys.contextHUDTimeout) } - let holdDelayPresetString = UserDefaults.standard.string(forKey: Keys.contextHUDHoldDelayPreset) + let holdDelayPresetString = leaderDefaults.string(forKey: Keys.contextHUDHoldDelayPreset) ?? Defaults.contextHUDHoldDelayPreset.rawValue contextHUDHoldDelayPreset = ContextHUDHoldDelayPreset(rawValue: holdDelayPresetString) ?? Defaults.contextHUDHoldDelayPreset - let storedCustomDelay = UserDefaults.standard.object(forKey: Keys.contextHUDHoldDelayCustomMs) as? Int + let storedCustomDelay = leaderDefaults.object(forKey: Keys.contextHUDHoldDelayCustomMs) as? Int let rawCustomDelay = storedCustomDelay ?? Defaults.contextHUDHoldDelayCustomMs let sanitizedCustomDelay = Self.clampedContextHUDHoldDelayCustomMs(rawCustomDelay) contextHUDHoldDelayCustomMs = sanitizedCustomDelay if storedCustomDelay != nil, sanitizedCustomDelay != rawCustomDelay { - UserDefaults.standard.set(sanitizedCustomDelay, forKey: Keys.contextHUDHoldDelayCustomMs) + leaderDefaults.set(sanitizedCustomDelay, forKey: Keys.contextHUDHoldDelayCustomMs) } let kindaVimHUDModeString = UserDefaults.standard.string(forKey: Keys.kindaVimLeaderHUDMode) diff --git a/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift b/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift index 3e650a356..ad3b992d3 100644 --- a/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift +++ b/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift @@ -2,7 +2,7 @@ import Foundation import KeyPathCore /// Persisted selection state for a single device. -struct DeviceSelection: Codable, Sendable { +struct DeviceSelection: Codable, Equatable, Sendable { let hash: String let productKey: String var isEnabled: Bool @@ -14,6 +14,14 @@ struct DeviceSelection: Codable, Sendable { } } +/// A point-in-time device view used while generating a candidate configuration. +/// It prevents a retained transaction from reading a cache that has already +/// moved to a different selection. +struct DeviceGenerationInput: Sendable { + let selections: [DeviceSelection] + let connectedDevices: [ConnectedDevice] +} + /// Thread-safe synchronous cache for device selections and connected devices, /// used by the config generator. The generator runs synchronously and cannot /// await actor methods, so it reads from this cache. @@ -102,6 +110,10 @@ actor DeviceSelectionStore { self.decoder = decoder } + /// The transaction owner journals this exact file with the generated + /// configuration. Keeping the URL here prevents a second path convention. + var persistenceURL: URL { fileURL } + func loadSelections() -> [DeviceSelection] { AppLogger.shared.log("๐Ÿ“‚ [DeviceSelectionStore] loadSelections from: \(fileURL.path)") guard fileManager.fileExists(atPath: fileURL.path) else { @@ -119,6 +131,31 @@ actor DeviceSelectionStore { } } + /// Mutation paths must not treat corrupt selection data as an empty choice: + /// doing so could make a later rollback or apply target every keyboard. + func loadForMutation() throws -> [DeviceSelection] { + guard fileManager.fileExists(atPath: fileURL.path) else { return [] } + let data = try Data(contentsOf: fileURL) + return try decoder.decode([DeviceSelection].self, from: data) + } + + func encodedSelections(_ selections: [DeviceSelection]) throws -> Data { + try encoder.encode(selections) + } + + /// Cache publication happens only after the corresponding durable write has + /// settled. Candidate data must never leak into synchronous generation. + func publishSelectionsToCache(_ selections: [DeviceSelection]) { + cache.update(selections) + } + + /// Capture all device-dependent generation inputs from the same injected + /// cache used by this store. A retained transaction must not mix its + /// candidate selection with the process-global device cache. + func generationInput(for selections: [DeviceSelection]) -> DeviceGenerationInput { + DeviceGenerationInput(selections: selections, connectedDevices: cache.getConnectedDevices()) + } + func saveSelections(_ selections: [DeviceSelection]) throws { AppLogger.shared.log("๐Ÿ’พ [DeviceSelectionStore] saveSelections: \(selections.count) device(s) to \(fileURL.path)") let directory = fileURL.deletingLastPathComponent() diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift index 24e64918a..81fd00110 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift @@ -3,6 +3,82 @@ import KeyPathCore import KeyPathRulesCore extension RuleCollectionsManager { + /// Persist shortcut-list generation settings only with the generated + /// configuration that uses them. The preference service keeps its prior + /// in-memory value until the retained write is accepted or deferred. + @discardableResult + func applyShortcutListGenerationInput(_ input: ShortcutListGenerationInput) async -> Bool { + await withRuleMutation(failure: false) { [self] permit in + guard await recoverAndSnapshotRuleState(mutationPermit: permit) != nil else { return false } + let defaults = preferencesService.persistenceDefaults + let changes: [RecoverableRuleWrite.PreferenceChange] + do { + changes = try [ + .contextHUDTriggerMode( + before: defaults.object(forKey: RecoverableRuleWrite.PreferenceRole.contextHUDTriggerMode.key), + after: input.triggerMode.rawValue + ), + .contextHUDHoldDelayPreset( + before: defaults.object(forKey: RecoverableRuleWrite.PreferenceRole.contextHUDHoldDelayPreset.key), + after: input.holdDelayPreset.rawValue + ), + .contextHUDHoldDelayCustomMs( + before: defaults.object(forKey: RecoverableRuleWrite.PreferenceRole.contextHUDHoldDelayCustomMs.key), + after: input.customHoldDelayMs + ) + ] + } catch { + onError?(error.localizedDescription) + return false + } + + let result = await SaveCoordinator(configurationService: configurationService).saveRuleState( + manager: self, + mutationPermit: permit, + preferenceChanges: changes, + shortcutListGenerationInput: input, + reloadHandler: onRulesChanged + ) + guard result.success else { return false } + preferencesService.reloadShortcutListGenerationInput(from: defaults) + return true + } + } + + /// Apply device targeting as a retained configuration write. The candidate + /// selection is deliberately not published to the synchronous cache until + /// the daemon has restarted successfully; a rejected restart restores both + /// the selection file and the previous generated configuration. + @discardableResult + func applyDeviceSelections( + _ selections: [DeviceSelection], + restartHandler: @escaping @MainActor @Sendable () async -> Bool + ) async -> Bool { + await withRuleMutation(failure: false) { [self] permit in + guard await recoverAndSnapshotRuleState(mutationPermit: permit) != nil else { return false } + let reloadHandler: () async -> ReloadResult = { + let restarted = await restartHandler() + return ReloadResult( + success: restarted, + response: nil, + errorMessage: restarted ? nil : "Kanata could not restart with the selected keyboards", + protocol: nil, + disposition: restarted ? .applied : .rejected + ) + } + let result = await SaveCoordinator(configurationService: configurationService).saveRuleState( + manager: self, + mutationPermit: permit, + deviceSelections: selections, + reloadHandler: reloadHandler + ) + if result.success { + NotificationCenter.default.post(name: .kanataConfigChanged, object: nil) + } + return result.success + } + } + /// Admission precedes snapshots and mutation. Nested internal calls carry an /// explicit permit; callbacks without one fail rather than deadlock/reenter. func withRuleMutation( diff --git a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDSettingsSection.swift b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDSettingsSection.swift index e3ef6f31b..232857d8b 100644 --- a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDSettingsSection.swift +++ b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDSettingsSection.swift @@ -4,6 +4,7 @@ import SwiftUI /// Visual settings section for the Shortcut List struct ContextHUDSettingsSection: View { @Environment(\.services) private var services + @Environment(KanataViewModel.self) private var kanataManager @State private var displayMode = PreferencesService.shared.contextHUDDisplayMode @State private var triggerMode = PreferencesService.shared.contextHUDTriggerMode @State private var holdDelayPreset = PreferencesService.shared.contextHUDHoldDelayPreset @@ -60,8 +61,7 @@ struct ContextHUDSettingsSection: View { isSelected: triggerMode == .holdToShow, cardWidth: SettingsOptionCard.settingsRowWidth ) { - triggerMode = .holdToShow - services.preferences.contextHUDTriggerMode = .holdToShow + applyShortcutListInput(triggerMode: .holdToShow) } .accessibilityIdentifier("settings-context-hud-trigger-holdToShow") .accessibilityLabel("Hold trigger mode") @@ -73,8 +73,7 @@ struct ContextHUDSettingsSection: View { isSelected: triggerMode == .tapToToggle, cardWidth: SettingsOptionCard.settingsRowWidth ) { - triggerMode = .tapToToggle - services.preferences.contextHUDTriggerMode = .tapToToggle + applyShortcutListInput(triggerMode: .tapToToggle) } .accessibilityIdentifier("settings-context-hud-trigger-tapToToggle") .accessibilityLabel("Tap trigger mode") @@ -104,8 +103,7 @@ struct ContextHUDSettingsSection: View { .labelsHidden() .frame(width: 140) .onChange(of: holdDelayPreset) { _, newValue in - services.preferences.contextHUDHoldDelayPreset = newValue - customHoldDelayMs = services.preferences.contextHUDHoldDelayCustomMs + applyShortcutListInput(holdDelayPreset: newValue) } .accessibilityIdentifier("settings-context-hud-hold-delay-preset") .accessibilityLabel(Text(TimingCopy.leaderHoldDelay)) @@ -127,8 +125,7 @@ struct ContextHUDSettingsSection: View { .textFieldStyle(.roundedBorder) .frame(width: 100) .onChange(of: customHoldDelayMs) { _, newValue in - services.preferences.contextHUDHoldDelayCustomMs = newValue - customHoldDelayMs = services.preferences.contextHUDHoldDelayCustomMs + applyShortcutListInput(customHoldDelayMs: newValue) } .accessibilityIdentifier("settings-context-hud-hold-delay-custom") .accessibilityLabel(Text(TimingCopy.customLeaderHoldDelayAccessibilityLabel)) @@ -143,6 +140,30 @@ struct ContextHUDSettingsSection: View { // MARK: - Display Mode Card + private func applyShortcutListInput( + triggerMode: ContextHUDTriggerMode? = nil, + holdDelayPreset: ContextHUDHoldDelayPreset? = nil, + customHoldDelayMs: Int? = nil + ) { + let candidate = ShortcutListGenerationInput( + triggerMode: triggerMode ?? self.triggerMode, + holdDelayPreset: holdDelayPreset ?? self.holdDelayPreset, + customHoldDelayMs: customHoldDelayMs ?? self.customHoldDelayMs + ) + Task { + guard await kanataManager.applyShortcutListGenerationInput(candidate) else { + let restored = services.preferences.shortcutListGenerationInput + self.triggerMode = restored.triggerMode + self.holdDelayPreset = restored.holdDelayPreset + self.customHoldDelayMs = restored.customHoldDelayMs + return + } + self.triggerMode = candidate.triggerMode + self.holdDelayPreset = candidate.holdDelayPreset + self.customHoldDelayMs = candidate.customHoldDelayMs + } + } + private func displayModeCard( mode: ContextHUDDisplayMode, icon: String, diff --git a/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift b/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift index 694152f74..4510cd9c9 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift @@ -4,6 +4,7 @@ import SwiftUI /// Displays connected keyboards with toggles to enable/disable remapping per device. /// VirtualHID devices are filtered out (never shown to users). struct DeviceSelectionView: View { + @Environment(KanataViewModel.self) private var kanataManager @State private var connectedDevices: [ConnectedDevice] = [] @State private var selections: [String: DeviceSelection] = [:] @State private var isLoading = true @@ -46,6 +47,7 @@ struct DeviceSelectionView: View { } .padding(.top, 4) } + .disabled(isRestarting) Spacer(minLength: 0) footerView @@ -54,13 +56,6 @@ struct DeviceSelectionView: View { .task { await loadDevices() } - .onReceive(NotificationCenter.default.publisher(for: .deviceSelectionApplyCompleted)) { notification in - isRestarting = false - let success = notification.userInfo?["success"] as? Bool ?? false - if success { - needsRestart = false - } - } } // MARK: - Connected Section @@ -202,12 +197,8 @@ struct DeviceSelectionView: View { } } - // Persist updated lastSeen timestamps - do { - try await DeviceSelectionStore.shared.saveSelections(Array(selections.values)) - } catch { - AppLogger.shared.warn("โš ๏ธ [DeviceSelectionView] Failed to persist lastSeen updates: \(error)") - } + // Device discovery updates the draft only. It joins the durable + // selection/configuration transaction when the user explicitly applies. } // MARK: - Actions @@ -230,20 +221,19 @@ struct DeviceSelectionView: View { } needsRestart = true - // Persist immediately so selections survive tab navigation - Task { - do { - try await DeviceSelectionStore.shared.saveSelections(Array(selections.values)) - } catch { - AppLogger.shared.warn("โš ๏ธ [DeviceSelectionView] Failed to persist toggle: \(error)") - } - } } private func applyChanges() { isRestarting = true Task { - NotificationCenter.default.post(name: .deviceSelectionChanged, object: nil) + let applied = await kanataManager.applyDeviceSelections(Array(selections.values)) + isRestarting = false + if applied { + needsRestart = false + } else { + errorMessage = "KeyPath could not apply the selected keyboards. Your previous selection was restored." + await loadDevices() + } } } } diff --git a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift index 9bbf12d47..7b76d6e02 100644 --- a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift +++ b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift @@ -463,6 +463,16 @@ class KanataViewModel { await manager.updateLeaderKey(newKey) } + @discardableResult + func applyShortcutListGenerationInput(_ input: ShortcutListGenerationInput) async -> Bool { + await manager.applyShortcutListGenerationInput(input) + } + + @discardableResult + func applyDeviceSelections(_ selections: [DeviceSelection]) async -> Bool { + await manager.applyDeviceSelections(selections) + } + func isCompletelyInstalled() -> Bool { manager.isCompletelyInstalled() } diff --git a/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift b/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift index 61581cb47..bec764c9c 100644 --- a/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift +++ b/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift @@ -164,7 +164,7 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { func testManagerCommitsAppliedAndPendingLeaderButRestoresRejectedLeader() async throws { for disposition: ReloadDisposition in [.applied, .pending, .rejected] { let caseDirectory = directory.appendingPathComponent(String(describing: disposition)) - let (manager, _) = try makeManager(at: caseDirectory) + let (manager, _) = try await makeManager(at: caseDirectory) try preferencesReset(to: .default) manager.ruleCollections = try leaderCollections() var reloadCount = 0 @@ -194,10 +194,155 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { } } + @MainActor + func testShortcutListSettingsCommitOnlyWithAcceptedGeneratedConfiguration() async throws { + let baseline = ShortcutListGenerationInput( + triggerMode: .holdToShow, + holdDelayPreset: .long, + customHoldDelayMs: 200 + ) + let candidate = ShortcutListGenerationInput( + triggerMode: .tapToToggle, + holdDelayPreset: .custom, + customHoldDelayMs: 444 + ) + + for disposition: ReloadDisposition in [.applied, .pending, .rejected] { + let caseDirectory = directory.appendingPathComponent("shortcut-\(disposition)") + defaults.set(baseline.triggerMode.rawValue, forKey: "KeyPath.ContextHUD.TriggerMode") + defaults.set(baseline.holdDelayPreset.rawValue, forKey: "KeyPath.ContextHUD.HoldDelayPreset") + defaults.set(baseline.customHoldDelayMs, forKey: "KeyPath.ContextHUD.HoldDelayCustomMs") + let (manager, _) = try await makeManager(at: caseDirectory) + manager.preferencesService.reloadShortcutListGenerationInput(from: defaults) + manager.ruleCollections = try leaderCollections() + var reloadCount = 0 + manager.onRulesChanged = { + reloadCount += 1 + return ReloadResult( + success: disposition == .applied, + response: nil, + errorMessage: disposition == .rejected ? "rejected" : nil, + protocol: nil, + disposition: disposition + ) + } + + let success = await manager.applyShortcutListGenerationInput(candidate) + XCTAssertEqual(success, disposition != .rejected) + let expected = disposition == .rejected ? baseline : candidate + XCTAssertEqual(defaults.string(forKey: "KeyPath.ContextHUD.TriggerMode"), expected.triggerMode.rawValue) + XCTAssertEqual(defaults.string(forKey: "KeyPath.ContextHUD.HoldDelayPreset"), expected.holdDelayPreset.rawValue) + XCTAssertEqual(defaults.object(forKey: "KeyPath.ContextHUD.HoldDelayCustomMs") as? Int, expected.customHoldDelayMs) + XCTAssertEqual(manager.preferencesService.shortcutListGenerationInput, expected) + XCTAssertEqual(reloadCount, disposition == .rejected ? 2 : 1) + } + } + + @MainActor + func testDeviceSelectionsCommitOnlyAfterRestartAndRestoreOnRejection() async throws { + for restartSucceeds in [true, false] { + let caseDirectory = directory.appendingPathComponent("device-\(restartSucceeds)") + let cache = DeviceSelectionCache() + let deviceStore = DeviceSelectionStore.testStore( + at: caseDirectory.appendingPathComponent("DeviceSelection.json"), cache: cache + ) + cache.updateConnectedDevices([ + ConnectedDevice(hash: "a", vendorID: 1, productID: 2, productKey: "Apple Keyboard", isVirtualHID: false) + ]) + let baseline = DeviceSelection(hash: "a", productKey: "Apple Keyboard", isEnabled: true, lastSeen: .distantPast) + let candidate = DeviceSelection(hash: "a", productKey: "Apple Keyboard", isEnabled: false, lastSeen: .now) + try await deviceStore.saveSelections([baseline]) + try FileManager.default.createDirectory(at: caseDirectory, withIntermediateDirectories: true) + let configURL = caseDirectory.appendingPathComponent("keypath.kbd") + let beforeConfig = Data("before-device-config".utf8) + try beforeConfig.write(to: configURL) + let (manager, _) = try await makeManager(at: caseDirectory, deviceSelectionStore: deviceStore) + manager.ruleCollections = try leaderCollections() + var restartCount = 0 + + let success = await manager.applyDeviceSelections([candidate]) { + restartCount += 1 + return restartSucceeds + } + + XCTAssertEqual(success, restartSucceeds) + XCTAssertEqual(restartCount, restartSucceeds ? 1 : 2) + let expected = restartSucceeds ? candidate : baseline + let persistedSelections = try await deviceStore.loadForMutation() + let cachedSelections = cache.allSelections() + XCTAssertEqual(persistedSelections.count, 1) + XCTAssertEqual(cachedSelections.count, 1) + XCTAssertEqual(persistedSelections.first?.hash, expected.hash) + XCTAssertEqual(persistedSelections.first?.productKey, expected.productKey) + XCTAssertEqual(persistedSelections.first?.isEnabled, expected.isEnabled) + XCTAssertEqual(cachedSelections.first?.hash, expected.hash) + XCTAssertEqual(cachedSelections.first?.productKey, expected.productKey) + XCTAssertEqual(cachedSelections.first?.isEnabled, expected.isEnabled) + let persistedConfig = try Data(contentsOf: configURL) + if restartSucceeds { + XCTAssertNotEqual(persistedConfig, beforeConfig) + XCTAssertTrue(String(decoding: persistedConfig, as: UTF8.self).contains("macos-dev-names-include")) + } else { + XCTAssertEqual(persistedConfig, beforeConfig) + } + XCTAssertFalse(FileManager.default.fileExists( + atPath: RecoverableRuleWrite.journalURL(caseDirectory, scope: .deviceRules).path + )) + } + } + + @MainActor + func testInterruptedDeviceWriteRestoresConfigSelectionAndRequiresRestart() async throws { + let caseDirectory = directory.appendingPathComponent("device-crash") + let cache = DeviceSelectionCache() + let deviceStore = DeviceSelectionStore.testStore( + at: caseDirectory.appendingPathComponent("DeviceSelection.json"), cache: cache + ) + cache.updateConnectedDevices([ + ConnectedDevice(hash: "a", vendorID: 1, productID: 2, productKey: "Apple Keyboard", isVirtualHID: false) + ]) + let baseline = DeviceSelection(hash: "a", productKey: "Apple Keyboard", isEnabled: true, lastSeen: .distantPast) + let candidate = DeviceSelection(hash: "a", productKey: "Apple Keyboard", isEnabled: false, lastSeen: .now) + try await deviceStore.saveSelections([baseline]) + try FileManager.default.createDirectory(at: caseDirectory, withIntermediateDirectories: true) + let configURL = caseDirectory.appendingPathComponent("keypath.kbd") + let beforeConfig = Data("before-crash-device-config".utf8) + try beforeConfig.write(to: configURL) + let (manager, service) = try await makeManager(at: caseDirectory, deviceSelectionStore: deviceStore) + manager.ruleCollections = try leaderCollections() + + try await service.operationGate.withOperation { @MainActor permit in + _ = try await service.stageRuleState( + ruleCollections: manager.ruleCollections, customRules: manager.customRules, + collectionStore: manager.ruleCollectionStore, customStore: manager.customRulesStore, + mutationPermit: permit, deviceSelections: [candidate] + ) + } + XCTAssertTrue(FileManager.default.fileExists( + atPath: RecoverableRuleWrite.journalURL(caseDirectory, scope: .deviceRules).path + )) + + _ = try await service.recoverPendingRuleWrite( + collectionStore: manager.ruleCollectionStore, + customStore: manager.customRulesStore + ) + XCTAssertEqual(try Data(contentsOf: configURL), beforeConfig) + let restored = try await deviceStore.loadForMutation() + XCTAssertEqual(restored.first?.isEnabled, baseline.isEnabled) + XCTAssertEqual(cache.allSelections().first?.isEnabled, baseline.isEnabled) + var restartCount = 0 + let handled = try await service.applyRecoveredDeviceRuntimeRestartIfNeeded { + restartCount += 1 + return true + } + XCTAssertTrue(handled) + XCTAssertEqual(restartCount, 1) + } + @MainActor func testManagerRestoresLeaderWhenDurabilityBarrierFails() async throws { let barrierCalls = LockedCounter() - let (manager, _) = try makeManager(at: directory, synchronizePreferences: { _ in + let (manager, _) = try await makeManager(at: directory, synchronizePreferences: { _ in barrierCalls.increment() return false }) @@ -222,7 +367,7 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { @MainActor func testRootMutationRefreshesLeaderCommittedByAnotherServiceBeforeRollback() async throws { - let (manager, _) = try makeManager(at: directory) + let (manager, _) = try await makeManager(at: directory) try preferencesReset(to: .default) manager.ruleCollections = try leaderCollections() let newer = LeaderKeyPreference(key: "f17", targetLayer: .navigation, enabled: true) @@ -238,7 +383,7 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { @MainActor func testManagerRejectsPreferenceChangedAfterSnapshotBeforeJournal() async throws { - let (manager, _) = try makeManager(at: directory) + let (manager, _) = try await makeManager(at: directory) try preferencesReset(to: .default) manager.ruleCollections = try leaderCollections() let beforeFiles = ruleFiles(at: directory) @@ -263,7 +408,7 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { @MainActor func testBootstrapRecoversInterruptedLeaderAndRuleRevision() async throws { - let (stagingManager, stagingService) = try makeManager(at: directory) + let (stagingManager, stagingService) = try await makeManager(at: directory) try preferencesReset(to: .default) stagingManager.ruleCollections = try leaderCollections() let attempted = LeaderKeyPreference(key: "tab", targetLayer: .navigation, enabled: true) @@ -284,7 +429,7 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { } XCTAssertEqual(try storedLeader(defaults), attempted) - let (recoveringManager, _) = try makeManager(at: directory) + let (recoveringManager, _) = try await makeManager(at: directory) recoveringManager.onRulesChanged = { ReloadResult(success: true, response: nil, errorMessage: nil, protocol: nil, disposition: .applied) } @@ -350,8 +495,9 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { @MainActor private func makeManager( at directory: URL, + deviceSelectionStore: DeviceSelectionStore = .shared, synchronizePreferences: @escaping @Sendable (RecoverableRuleWrite.PreferenceDefaults) -> Bool = { $0.value.synchronize() } - ) throws -> (RuleCollectionsManager, ConfigurationService) { + ) async throws -> (RuleCollectionsManager, ConfigurationService) { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let collections = RuleCollectionStore.testStore(at: directory.appendingPathComponent("RuleCollections.json")) let rules = CustomRulesStore.testStore(at: directory.appendingPathComponent("CustomRules.json")) @@ -359,8 +505,20 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { configDirectory: directory.path, ruleCollectionStore: collections, customRulesStore: rules, + deviceSelectionStore: deviceSelectionStore, synchronizePreferences: synchronizePreferences ) + // Root mutations refresh their persisted source state before snapshotting. + // RuleCollectionStore merges omitted catalog entries back in, so persist the + // full catalog with every non-leader collection disabled. That is the same + // effective source model the fixture uses, without relying on a missing-file + // fallback that enables unrelated mappings. + var sourceCollections = RuleCollectionCatalog().defaultCollections() + for index in sourceCollections.indices where sourceCollections[index].id != RuleCollectionIdentifier.leaderKey { + sourceCollections[index].isEnabled = false + } + try await collections.saveCollections(sourceCollections) + try await rules.saveRules([]) let preferences = PreferencesService(leaderDefaults: defaults) let manager = RuleCollectionsManager( ruleCollectionStore: collections, diff --git a/docs/architecture/configuration-save-pipeline.md b/docs/architecture/configuration-save-pipeline.md index 81d8b8dea..ce390757c 100644 --- a/docs/architecture/configuration-save-pipeline.md +++ b/docs/architecture/configuration-save-pipeline.md @@ -58,12 +58,16 @@ commit restores both the files and leader preference. Applied and pending result commit both. A third leader preference revision stops recovery before any file is rolled back and retains the journal for diagnosis. -This does not yet cover logical keymap selection. Its overlay `@AppStorage` -selection is written before the manager receives the mutation, so reconstructing -that preimage would not be a safe transaction. Context HUD trigger/hold settings -and device selection also regenerate configuration through standalone paths. -Those workflows need their own bounded migration. Display-only preferences remain -outside the journal and are preserved. +Context HUD trigger/hold settings now join this retained write with explicit +preimages for all three generation inputs. Device selection uses a dedicated +journal containing its JSON file and the generated configuration; candidate +selections are not published to the generator cache until the daemon restart is +accepted. A rejected or interrupted device apply restores the JSON, cache, and +generated configuration, and startup retains a restart obligation for recovered +device targeting. Logical keymap selection remains outside this transaction: +its overlay `@AppStorage` selection is written before the manager receives the +mutation, so reconstructing that preimage is not yet safe. Display-only +preferences remain outside the journal and are preserved. See the [consolidation baseline](../planning/consolidation-baseline.md) for paths and remaining gaps. UI presentation changes require discussion before implementation. From fb452949199be62b707debc1a75ea34dd9df2eb4 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 17:47:48 -0700 Subject: [PATCH 03/15] Protect managed global configuration files --- .../Config/ConfigurationService.swift | 172 ++++++++++++++++- .../Config/RecoverableRuleWrite.swift | 18 +- .../RuleCollections/RuleCollectionStore.swift | 14 ++ .../Config/ConfigurationRuleWriteTests.swift | 174 ++++++++++++++++++ .../configuration-save-pipeline.md | 9 + 5 files changed, 375 insertions(+), 12 deletions(-) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index c9afcad6e..5df7f243e 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -430,21 +430,29 @@ public final class ConfigurationService: FileConfigurationProviding { packUpdate = try await packRecord.tracker.prepareUpdate(packRecord) guard packUpdate?.before == before["installedPacks"] else { throw RecoverableRuleWrite.Failure.changedFile("installedPacks") } } else { packUpdate = nil } - let deviceGenerationInput: DeviceGenerationInput? = if let deviceSelections { + let persistedInputs = try await persistedGlobalRuleGenerationInputs(preferenceDefaults: preferenceDefaults) + let deviceGenerationInput: DeviceGenerationInput = if let deviceSelections { await deviceSelectionStore.generationInput(for: deviceSelections) } else { - nil + persistedInputs.device } + try await ensureExistingGlobalConfigurationIsReproducible( + collectionStore: collectionStore, + customStore: customStore, + preferenceDefaults: preferenceDefaults, + inputs: persistedInputs + ) let newConfig = try await preparedConfiguration( ruleCollections: ruleCollections, customRules: customRules, leaderKeyPreference: leaderKeyPreference ?? persistedLeaderPreference(in: preferenceDefaults), - shortcutListGenerationInput: shortcutListGenerationInput, + shortcutListGenerationInput: shortcutListGenerationInput ?? persistedInputs.shortcut, deviceGenerationInput: deviceGenerationInput ) var payload = try await [ "config": Data(newConfig.content.utf8), "collections": collectionStore.encodedCollections(ruleCollections), - "customRules": customStore.encodedRules(customRules) + "customRules": customStore.encodedRules(customRules), + "deviceTargetingManifest": Data(deviceTargetingRegion(from: newConfig.content).utf8) ] if let packUpdate { payload["installedPacks"] = packUpdate.contents } if let deviceSelections { payload["deviceSelection"] = try await deviceSelectionStore.encodedSelections(deviceSelections) } @@ -615,25 +623,38 @@ public final class ConfigurationService: FileConfigurationProviding { } let rules = try await customRulesStore.loadForMutation() let leaderKeyPreference = persistedLeaderPreference(in: PreferencesService.canonicalDefaults) + let persistedInputs = try await persistedGlobalRuleGenerationInputs( + preferenceDefaults: PreferencesService.canonicalDefaults + ) // Refuse a lossy regeneration, including manual edits to a generated file. // A generated header alone does not prove that the visual editor owns it. let previousKeys = Set(previous.filter(\.mapping.isEnabled).flatMap { $0.overrides.map { $0.inputKey.lowercased() } }) let expected = try await generateConfiguration( ruleCollections: collections.collections, customRules: rules, - appSpecificKeys: previousKeys, leaderKeyPreference: leaderKeyPreference + appSpecificKeys: previousKeys, leaderKeyPreference: leaderKeyPreference, + shortcutListGenerationInput: persistedInputs.shortcut, + deviceGenerationInput: persistedInputs.device ) for (name, content) in [("keypath.kbd", expected.content), ("keypath-apps.kbd", AppConfigGenerator.generate(from: previous))] { let url = URL(fileURLWithPath: configDirectory).appendingPathComponent(name) - if FileManager.default.fileExists(atPath: url.path), try !AppConfigGenerator.matchesManagedContent(String(contentsOf: url, encoding: .utf8), expected: content) { + if FileManager.default.fileExists(atPath: url.path) { + let existing = try String(contentsOf: url, encoding: .utf8) + let matches = name == "keypath.kbd" + ? matchesGlobalManagedContent(existing, expected: content) + : AppConfigGenerator.matchesManagedContent(existing, expected: content) + guard matches else { throw AppConfigError .validationFailed( errors: ["Your configuration was preserved. The visual editor cannot safely reproduce \(name). App-specific editing requires an explicit conversion with a backup first."] ) + } } } let configuration = try await generateConfiguration( ruleCollections: collections.collections, customRules: rules, - appSpecificKeys: appKeys, leaderKeyPreference: leaderKeyPreference + appSpecificKeys: appKeys, leaderKeyPreference: leaderKeyPreference, + shortcutListGenerationInput: persistedInputs.shortcut, + deviceGenerationInput: persistedInputs.device ) // Validate the exact new include with the new main config before either // replaces its committed file. The engine receives the normal include. @@ -645,7 +666,8 @@ public final class ConfigurationService: FileConfigurationProviding { guard validation.isValid else { throw AppConfigError.validationFailed(errors: validation.errors) } let contents = try await ["config": Data(configuration.content.utf8), "appKeymaps": store.encodedKeymaps(keymaps), - "appInclude": Data(appContent.utf8)] + "appInclude": Data(appContent.utf8), + "deviceTargetingManifest": Data(deviceTargetingRegion(from: configuration.content).utf8)] try Task.checkCancellation() let directory = URL(fileURLWithPath: configDirectory) let pending = try await performRuleFileOperation { @@ -731,14 +753,16 @@ public final class ConfigurationService: FileConfigurationProviding { private func appKeymapWriteFiles(store: AppKeymapStore) async -> [String: URL] { await ["config": URL(fileURLWithPath: configurationPath), "appKeymaps": store.persistenceURL, - "appInclude": URL(fileURLWithPath: configDirectory).appendingPathComponent("keypath-apps.kbd")] + "appInclude": URL(fileURLWithPath: configDirectory).appendingPathComponent("keypath-apps.kbd"), + "deviceTargetingManifest": deviceTargetingManifestURL] } private func ruleWriteFiles(collectionStore: RuleCollectionStore, customStore: CustomRulesStore) async -> [String: URL] { await [ "config": URL(fileURLWithPath: configurationPath), "collections": collectionStore.persistenceURL, - "customRules": customStore.persistenceURL + "customRules": customStore.persistenceURL, + "deviceTargetingManifest": deviceTargetingManifestURL ] } @@ -770,6 +794,134 @@ public final class ConfigurationService: FileConfigurationProviding { return newConfig } + /// Global edits regenerate the main file from collection-backed sources. + /// Refuse before staging any source or configuration file when the committed + /// main file cannot be reproduced from the previously committed inputs. + /// This gives collection, mapper, and standalone regeneration the same + /// handwritten-file preservation contract as app-specific editing. + private func ensureExistingGlobalConfigurationIsReproducible( + collectionStore: RuleCollectionStore, + customStore: CustomRulesStore, + preferenceDefaults: UserDefaults?, + inputs: GlobalRuleGenerationInputs + ) async throws { + let configURL = URL(fileURLWithPath: configurationPath) + guard FileManager.default.fileExists(atPath: configURL.path) else { return } + let existing = try String(contentsOf: configURL, encoding: .utf8) + if let current = withLockedCurrentConfig(), + matchesGlobalManagedContent(existing, expected: current.content) + { + return + } + + // A missing collection store is the first-write/bootstrap migration + // case: there is no committed global input set to reproduce yet. + // Once collections exist, every global writer must preserve a main file + // it cannot reconstruct from that prior revision. + let collectionURL = await collectionStore.persistenceURL + guard FileManager.default.fileExists(atPath: collectionURL.path) else { return } + + let persistedCollections = try await collectionStore.loadForMutation() + let persistedRules = try await customStore.loadForMutation() + let expected = try await generateConfiguration( + ruleCollections: persistedCollections, + customRules: persistedRules, + leaderKeyPreference: persistedLeaderPreference(in: preferenceDefaults), + shortcutListGenerationInput: inputs.shortcut, + deviceGenerationInput: inputs.device + ) + guard matchesGlobalManagedContent(existing, expected: expected.content) else { + throw AppConfigError.validationFailed(errors: ["Your configuration was preserved. The visual editor cannot safely reproduce keypath.kbd. Convert it explicitly with a backup before editing global rules."]) + } + } + + /// Device enumeration is live runtime evidence, not a durable generation + /// input. A connect/disconnect can legitimately change the device-name + /// directives between app launches. We tolerate that only when the exact + /// existing directives equal the sidecar snapshot written with the prior + /// managed transaction; arbitrary handwritten directives are never erased. + private func matchesGlobalManagedContent(_ existing: String, expected: String) -> Bool { + if AppConfigGenerator.matchesManagedContent(existing, expected: expected) { return true } + guard let manifest = try? String(contentsOf: deviceTargetingManifestURL, encoding: .utf8), + manifest == deviceTargetingRegion(from: existing) + else { return false } + return AppConfigGenerator.matchesManagedContent( + removingDynamicDeviceTargeting(from: existing), + expected: removingDynamicDeviceTargeting(from: expected) + ) + } + + private var deviceTargetingManifestURL: URL { + URL(fileURLWithPath: configDirectory).appendingPathComponent("keypath-device-targeting.manifest") + } + + /// Capture exactly the emitted device directive blocks. The durable + /// snapshot is proof that a changed runtime device list is ours to replace. + private func deviceTargetingRegion(from content: String) -> String { + var capturing = false + return content.components(separatedBy: "\n").compactMap { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("macos-dev-names-include (") || trimmed.hasPrefix("macos-dev-names-exclude (") { + capturing = true + return line + } + if capturing { + if trimmed == ")" { capturing = false } + return line + } + return nil + }.joined(separator: "\n") + } + + private func removingDynamicDeviceTargeting(from content: String) -> String { + var skippingDeviceNames = false + return content.components(separatedBy: "\n").compactMap { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("macos-dev-names-include (") || trimmed.hasPrefix("macos-dev-names-exclude (") { + skippingDeviceNames = true + return nil + } + if skippingDeviceNames { + if trimmed == ")" { skippingDeviceNames = false } + return nil + } + if trimmed == ";; Only remap selected keyboards (user device selection)." || + trimmed == ";; All keyboards disabled by user โ€” remap nothing." || + trimmed == ";; Avoid grabbing VirtualHID output keyboard(s); prevents feedback loops." + { + return nil + } + return line + }.joined(separator: "\n") + } + + private struct GlobalRuleGenerationInputs { + let shortcut: ShortcutListGenerationInput + let device: DeviceGenerationInput + } + + private func persistedGlobalRuleGenerationInputs( + preferenceDefaults: UserDefaults? + ) async throws -> GlobalRuleGenerationInputs { + let defaults = preferenceDefaults ?? PreferencesService.canonicalDefaults + let trigger = ContextHUDTriggerMode( + rawValue: defaults.string(forKey: RecoverableRuleWrite.PreferenceRole.contextHUDTriggerMode.key) ?? ContextHUDTriggerMode.holdToShow.rawValue + ) ?? .holdToShow + let preset = ContextHUDHoldDelayPreset( + rawValue: defaults.string(forKey: RecoverableRuleWrite.PreferenceRole.contextHUDHoldDelayPreset.key) ?? ContextHUDHoldDelayPreset.long.rawValue + ) ?? .long + let custom = defaults.object(forKey: RecoverableRuleWrite.PreferenceRole.contextHUDHoldDelayCustomMs.key) as? Int ?? 200 + let selections = try await deviceSelectionStore.loadForMutation() + return GlobalRuleGenerationInputs( + shortcut: ShortcutListGenerationInput( + triggerMode: trigger, + holdDelayPreset: preset, + customHoldDelayMs: custom + ), + device: await deviceSelectionStore.generationInput(for: selections) + ) + } + @MainActor private func persistedLeaderPreference(in defaults: UserDefaults?) -> LeaderKeyPreference? { guard let defaults else { return nil } diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift b/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift index aedebce2d..c00547d3b 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift @@ -14,6 +14,19 @@ enum RecoverableRuleWrite { case deviceRules var roles: Set { + switch self { + case .rules: ["config", "collections", "customRules", "deviceTargetingManifest"] + case .appKeymaps: ["config", "appKeymaps", "appInclude", "deviceTargetingManifest"] + case .packRules: ["config", "collections", "customRules", "installedPacks", "deviceTargetingManifest"] + case .rawConfig: ["config"] + case .deviceRules: ["config", "collections", "customRules", "deviceSelection", "deviceTargetingManifest"] + } + } + + /// Version 1 journals predate the device-targeting provenance sidecar. + /// They remain recoverable: their entries never claimed that sidecar, + /// so recovery must restore the recorded revision and leave it absent. + var legacyRoles: Set { switch self { case .rules: ["config", "collections", "customRules"] case .appKeymaps: ["config", "appKeymaps", "appInclude"] @@ -330,9 +343,10 @@ enum RecoverableRuleWrite { let url = journalURL(directory, scope: scope) guard let data = try read(url) else { return } let journal = try JSONDecoder().decode(Journal.self, from: data) + let journalRoles = Set(journal.entries.map(\.role)) + let usesLegacyRoles = journal.version == 1 && journalRoles == scope.legacyRoles guard journal.version == 1 || journal.version == 2, - journal.entries.count == files.count, - Set(journal.entries.map(\.role)) == Set(files.keys), + (usesLegacyRoles || (journal.entries.count == files.count && journalRoles == Set(files.keys))), journal.entries.allSatisfy({ files[$0.role]?.standardizedFileURL.path == $0.path }), Set(files.values.map(\.standardizedFileURL)).count == files.count else { throw Failure.invalidJournal } diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift index b36e7829e..6a6341f9e 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift @@ -80,6 +80,20 @@ actor RuleCollectionStore { loadCollectionsDetailed().collections } + /// Mutation owners need the persisted revision itself, without catalog + /// defaults merged in. Falling back to defaults here could overwrite a + /// configuration that was generated from an intentionally minimal source + /// revision. + func loadForMutation() throws -> [RuleCollection] { + let data: Data + do { data = try Data(contentsOf: fileURL) } + catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { return [] } + if let versioned = try? decoder.decode(VersionedCollections.self, from: data) { + return versioned.collections + } + return try decoder.decode([RuleCollection].self, from: data) + } + func loadCollectionsDetailed() -> LoadResult { guard fileManager.fileExists(atPath: fileURL.path) else { return LoadResult(collections: catalog.defaultCollections(), failedCollectionNames: [], wasFullReset: false) diff --git a/Tests/KeyPathTests/Config/ConfigurationRuleWriteTests.swift b/Tests/KeyPathTests/Config/ConfigurationRuleWriteTests.swift index a6b9b2db6..7a6c00c00 100644 --- a/Tests/KeyPathTests/Config/ConfigurationRuleWriteTests.swift +++ b/Tests/KeyPathTests/Config/ConfigurationRuleWriteTests.swift @@ -134,6 +134,180 @@ final class ConfigurationRuleWriteTests: KeyPathTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: RecoverableRuleWrite.journalURL(directory).path)) } + func testManualGlobalConfigIsPreservedBeforeSourceWrites() async throws { + let original = collection("Original") + try await service.saveRuleState( + ruleCollections: [original], customRules: [], + collectionStore: collections, customStore: customRules + ) + let configURL = URL(fileURLWithPath: service.configurationPath) + let collectionURL = await collections.persistenceURL + let customURL = await customRules.persistenceURL + let beforeCollections = try Data(contentsOf: collectionURL) + let beforeRules = try Data(contentsOf: customURL) + try ";; handwritten global configuration".write(to: configURL, atomically: true, encoding: .utf8) + + do { + try await service.saveRuleState( + ruleCollections: [collection("Candidate")], customRules: [], + collectionStore: collections, customStore: customRules + ) + XCTFail("A global writer must preserve a configuration it cannot reproduce") + } catch { + XCTAssertTrue(error.localizedDescription.contains("configuration was preserved")) + } + + XCTAssertEqual(try String(contentsOf: configURL, encoding: .utf8), ";; handwritten global configuration") + XCTAssertEqual(try Data(contentsOf: collectionURL), beforeCollections) + XCTAssertEqual(try Data(contentsOf: customURL), beforeRules) + XCTAssertFalse(FileManager.default.fileExists(atPath: RecoverableRuleWrite.journalURL(directory).path)) + } + + func testStandaloneRegenerationPreservesManualGlobalConfig() async throws { + let original = collection("Original") + try await service.saveRuleState( + ruleCollections: [original], customRules: [], + collectionStore: collections, customStore: customRules + ) + let configURL = URL(fileURLWithPath: service.configurationPath) + let collectionURL = await collections.persistenceURL + let beforeCollections = try Data(contentsOf: collectionURL) + try ";; handwritten global configuration".write(to: configURL, atomically: true, encoding: .utf8) + + let manager = RuleCollectionsManager( + ruleCollectionStore: collections, + customRulesStore: customRules, + configurationService: service + ) + manager.ruleCollections = [collection("Candidate")] + let persisted = await manager.regenerateConfigFromCollections(skipReload: true) + + XCTAssertFalse(persisted) + XCTAssertEqual(try String(contentsOf: configURL, encoding: .utf8), ";; handwritten global configuration") + XCTAssertEqual(try Data(contentsOf: collectionURL), beforeCollections) + XCTAssertFalse(FileManager.default.fileExists(atPath: RecoverableRuleWrite.journalURL(directory).path)) + } + + func testFreshServiceReproducesPersistedDeviceAndShortcutInputs() async throws { + let defaultsName = "ConfigurationRuleWriteTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: defaultsName)) + defer { defaults.removePersistentDomain(forName: defaultsName) } + defaults.set(ContextHUDTriggerMode.tapToToggle.rawValue, + forKey: RecoverableRuleWrite.PreferenceRole.contextHUDTriggerMode.key) + defaults.set(ContextHUDHoldDelayPreset.custom.rawValue, + forKey: RecoverableRuleWrite.PreferenceRole.contextHUDHoldDelayPreset.key) + defaults.set(321, forKey: RecoverableRuleWrite.PreferenceRole.contextHUDHoldDelayCustomMs.key) + let shortcut = ShortcutListGenerationInput( + triggerMode: .tapToToggle, + holdDelayPreset: .custom, + customHoldDelayMs: 321 + ) + let connectedCache = DeviceSelectionCache() + connectedCache.updateConnectedDevices([ + ConnectedDevice(hash: "disabled-device", vendorID: 1, productID: 2, + productKey: "Example Keyboard", isVirtualHID: false) + ]) + let deviceStore = DeviceSelectionStore( + fileURL: directory.appendingPathComponent("DeviceSelection.json"), + cache: connectedCache + ) + let service = ConfigurationService( + configDirectory: directory.path, + ruleCollectionStore: collections, + customRulesStore: customRules, + deviceSelectionStore: deviceStore + ) + let selection = DeviceSelection( + hash: "disabled-device", productKey: "Example Keyboard", + isEnabled: false, lastSeen: .distantPast + ) + let original = collection("Original") + try await service.operationGate.withOperation { @MainActor permit in + let write = try await service.stageRuleState( + ruleCollections: [original], customRules: [], + collectionStore: self.collections, customStore: self.customRules, + mutationPermit: permit, preferenceDefaults: defaults, + shortcutListGenerationInput: shortcut, deviceSelections: [selection] + ) + try await service.settleRuleWrite(write, commit: true, mutationPermit: permit) + } + XCTAssertTrue( + try String(contentsOfFile: service.configurationPath, encoding: .utf8) + .contains("macos-dev-names-include") + ) + + let freshDeviceStore = DeviceSelectionStore( + fileURL: directory.appendingPathComponent("DeviceSelection.json"), + cache: DeviceSelectionCache() + ) + let freshService = ConfigurationService( + configDirectory: directory.path, + ruleCollectionStore: collections, + customRulesStore: customRules, + deviceSelectionStore: freshDeviceStore + ) + try await freshService.operationGate.withOperation { @MainActor permit in + let write = try await freshService.stageRuleState( + ruleCollections: [collection("Candidate")], customRules: [], + collectionStore: self.collections, customStore: self.customRules, + mutationPermit: permit, preferenceDefaults: defaults + ) + try await freshService.settleRuleWrite(write, commit: true, mutationPermit: permit) + } + + let content = try String(contentsOfFile: freshService.configurationPath, encoding: .utf8) + let persistedSelections = try await deviceStore.loadForMutation() + XCTAssertEqual(persistedSelections, [selection]) + XCTAssertTrue(content.contains("321")) + } + + func testManualDeviceDirectiveIsPreservedBeforeSourceWrites() async throws { + let cache = DeviceSelectionCache() + cache.updateConnectedDevices([ + ConnectedDevice(hash: "disabled-device", vendorID: 1, productID: 2, + productKey: "Example Keyboard", isVirtualHID: false) + ]) + let deviceStore = DeviceSelectionStore( + fileURL: directory.appendingPathComponent("DeviceSelection.json"), cache: cache + ) + let service = ConfigurationService( + configDirectory: directory.path, ruleCollectionStore: collections, + customRulesStore: customRules, deviceSelectionStore: deviceStore + ) + let selection = DeviceSelection( + hash: "disabled-device", productKey: "Example Keyboard", + isEnabled: false, lastSeen: .distantPast + ) + let original = collection("Original") + try await service.operationGate.withOperation { @MainActor permit in + let write = try await service.stageRuleState( + ruleCollections: [original], customRules: [], + collectionStore: self.collections, customStore: self.customRules, + mutationPermit: permit, deviceSelections: [selection] + ) + try await service.settleRuleWrite(write, commit: true, mutationPermit: permit) + } + let configURL = URL(fileURLWithPath: service.configurationPath) + let handwritten = try String(contentsOf: configURL, encoding: .utf8) + .replacingOccurrences(of: "__keypath_no_devices__", with: "Handwritten Keyboard") + try handwritten.write(to: configURL, atomically: true, encoding: .utf8) + let collectionURL = await collections.persistenceURL + let beforeCollections = try Data(contentsOf: collectionURL) + + do { + try await service.saveRuleState( + ruleCollections: [collection("Candidate")], customRules: [], + collectionStore: collections, customStore: customRules + ) + XCTFail("A hand-edited device directive must be preserved") + } catch { + XCTAssertTrue(error.localizedDescription.contains("configuration was preserved")) + } + + XCTAssertEqual(try String(contentsOf: configURL, encoding: .utf8), handwritten) + XCTAssertEqual(try Data(contentsOf: collectionURL), beforeCollections) + } + func testInterruptedStageIsRecoveredByFreshService() async throws { try await service.saveRuleState(ruleCollections: [collection("Original")], customRules: [], collectionStore: collections, customStore: customRules) let before = try snapshot() diff --git a/docs/architecture/configuration-save-pipeline.md b/docs/architecture/configuration-save-pipeline.md index ce390757c..9124acf2b 100644 --- a/docs/architecture/configuration-save-pipeline.md +++ b/docs/architecture/configuration-save-pipeline.md @@ -181,6 +181,15 @@ reload. Backup is now an async API so lock contention does not block an actor; the CLI command syntax and output are unchanged. A callback attempting another apply, backup or restore is rejected before copying or staging preferences. +CLI apply is an explicit regeneration-and-overwrite command: it replaces +`keypath.kbd` from the supplied collection and rule sources, including when the +existing file is handwritten or otherwise not reproducible by the visual editor. +The global managed-file preservation check is intentionally an app-editor +contract; CLI apply remains the documented conversion/overwrite escape hatch. +Use CLI backup first when preserving a handwritten revision matters. Directory +admission serializes that overwrite, but it does not supply the app save +coordinator's runtime rollback semantics. + This does not yet change reload-result semantics, make directory restore atomic, or include preference restoration in rejected-apply recovery. Feature-specific writers remain separate migration work. Backups remain copies of current disk state; this scope does not recover pending journals From bed269035558403e738c15b8c66b7769c1d4f503 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 17:56:10 -0700 Subject: [PATCH 04/15] Reconcile configuration watcher revisions --- .../Config/ConfigurationService.swift | 5 + .../Managers/RuntimeCoordinator.swift | 9 +- .../Managers/SaveCoordinator.swift | 6 - .../Configuration/ConfigFileWatcher.swift | 115 ++++++++---------- .../Managers/SaveCoordinatorTests.swift | 3 + .../Services/ConfigFileWatcherTests.swift | 45 ++++++- 6 files changed, 106 insertions(+), 77 deletions(-) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index 5df7f243e..79e4e32e8 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -29,6 +29,7 @@ public final class ConfigurationService: FileConfigurationProviding { private var lastContentHash: String? private var fileWatcher: FileWatcher? private var observers: [UUID: @Sendable (Config) async -> Void] = [:] + @MainActor var onWillStageConfigurationWrite: ((String) -> Void)? private let ruleCollectionStore: RuleCollectionStore private let customRulesStore: CustomRulesStore @@ -343,6 +344,7 @@ public final class ConfigurationService: FileConfigurationProviding { let before = try await snapshotRuleFiles(files) let sendablePreferences = RecoverableRuleWrite.PreferenceDefaults(preferenceDefaults) let directory = URL(fileURLWithPath: configDirectory) + onWillStageConfigurationWrite?(newConfig.content) let pending = try await performRuleFileOperation { try RecoverableRuleWrite.stage( files: files, contents: ["config": Data(newConfig.content.utf8)], @@ -460,6 +462,7 @@ public final class ConfigurationService: FileConfigurationProviding { let sendablePreferences = preferenceDefaults.map(RecoverableRuleWrite.PreferenceDefaults.init) try Task.checkCancellation() let directory = URL(fileURLWithPath: configDirectory) + onWillStageConfigurationWrite?(newConfig.content) let pending = try await performRuleFileOperation { try RecoverableRuleWrite.stage(files: files, contents: contents, directory: directory, scope: deviceSelections != nil ? .deviceRules : (packUpdate == nil ? .rules : .packRules), expectedBefore: before, @@ -566,6 +569,7 @@ public final class ConfigurationService: FileConfigurationProviding { let files = ["config": URL(fileURLWithPath: configurationPath)] let directory = URL(fileURLWithPath: configDirectory) try Task.checkCancellation() + onWillStageConfigurationWrite?(content) let pending = try await performRuleFileOperation { try RecoverableRuleWrite.stage(files: files, contents: ["config": Data(content.utf8)], directory: directory, scope: .rawConfig, @@ -670,6 +674,7 @@ public final class ConfigurationService: FileConfigurationProviding { "deviceTargetingManifest": Data(deviceTargetingRegion(from: configuration.content).utf8)] try Task.checkCancellation() let directory = URL(fileURLWithPath: configDirectory) + onWillStageConfigurationWrite?(configuration.content) let pending = try await performRuleFileOperation { try RecoverableRuleWrite.stage(files: files, contents: contents, directory: directory, scope: .appKeymaps, expectedBefore: before) } diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift index 0b5e9eb8a..a316ff4f1 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift @@ -466,10 +466,7 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { } // Note: onActionURI callback not needed - RuleCollectionsManager.handleActionURI() // already dispatches to ActionDispatcher. Setting this would cause double dispatch. - ruleCollectionsManager.onBeforeSave = { [weak self] in - // Suppress file watcher to prevent double-reload when we save internally - self?.configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal rule change") - } + ruleCollectionsManager.onBeforeSave = nil if !isOneShotProbeMode, !TestEnvironment.isTestHostProcess { AppLogger.shared.log( @@ -1350,6 +1347,10 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { return } + configurationService.onWillStageConfigurationWrite = { [weak fileWatcher] content in + fileWatcher?.claimInternalContent(content) + } + // Configure the hot reload service configHotReloadService.configure( configurationService: configurationService, diff --git a/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift b/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift index b26422e87..3dbfc3c86 100644 --- a/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift @@ -122,10 +122,8 @@ final class SaveCoordinator { var staged: ConfigurationService.AppKeymapWrite? var reload: ReloadResult? do { - configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal app-specific save") try await recoverBeforeEditing(appStore: store, mutationPermit: permit, runtimeDidApply: runtimeDidApply, reloadHandler: reloadHandler) - configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal app-specific save after recovery") staged = try await configurationService.stageAppKeymapChange(store: store, mutationPermit: permit, mutate: mutate) try Task.checkCancellation() let result = await reloadHandler() @@ -191,13 +189,11 @@ final class SaveCoordinator { do { return try await configurationService.operationGate.withOperation { @MainActor [self] permit in // Suppress file watcher to prevent double reload - configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal saveConfiguration") saveStatus = .saving do { try await ruleCollectionsManager.recoverRuleState(mutationPermit: permit) _ = try await configurationService.applyRecoveredRuntimeIfNeeded(mutationPermit: permit, reloadHandler: reloadHandler) - configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal mapping save after recovery") let snapshot = ruleCollectionsManager.snapshotRuleState() let (sanitizedInput, sanitizedOutput) = try validateInputOutput(input: input, output: output) let rule = ruleCollectionsManager.makeCustomRule(input: sanitizedInput, output: sanitizedOutput) @@ -382,7 +378,6 @@ final class SaveCoordinator { do { return try await configurationService.operationGate.withOperation(using: mutationPermit) { @MainActor [self] permit in // Suppress file watcher to prevent double reload - configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal saveGeneratedConfiguration") saveStatus = .saving var staged: ConfigurationService.RawConfigurationWrite? @@ -400,7 +395,6 @@ final class SaveCoordinator { } try Task.checkCancellation() backupCurrentConfig(previousContent) - configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal raw save after recovery") staged = try await configurationService.stageRawConfiguration(content: content, expectedContent: previousContent, mutationPermit: permit) try Task.checkCancellation() playWriteSound() diff --git a/Sources/KeyPathAppKit/Services/Configuration/ConfigFileWatcher.swift b/Sources/KeyPathAppKit/Services/Configuration/ConfigFileWatcher.swift index 8c10804d0..43a630aac 100644 --- a/Sources/KeyPathAppKit/Services/Configuration/ConfigFileWatcher.swift +++ b/Sources/KeyPathAppKit/Services/Configuration/ConfigFileWatcher.swift @@ -1,4 +1,5 @@ import AppKit +import CryptoKit import Foundation import KeyPathCore @@ -21,7 +22,7 @@ class ConfigFileWatcher: @unchecked Sendable { private var fileMonitorSource: DispatchSourceFileSystemObject? private var directoryMonitorSource: DispatchSourceFileSystemObject? - private var lastModificationDate: Date? + private var lastContentFingerprint: String? private var debounceTask: Task? private var watchedFilePath: String? private var watchedDirectoryPath: String? @@ -33,8 +34,10 @@ class ConfigFileWatcher: @unchecked Sendable { private var retryCount = 0 private var pendingAtomicWriteEvent = false - // Suppression to prevent self-initiated reload loops - private var suppressUntil: Date? + // Each internal save claims exactly one reconciled filesystem revision. + // Unlike a clock window, this neither hides a later external edit nor + // depends on filesystem timestamp granularity. + private var pendingInternalFingerprint: String? private var inFlightProcessing = false private var rebindTask: Task? @@ -60,20 +63,14 @@ class ConfigFileWatcher: @unchecked Sendable { AppLogger.shared.log("๐Ÿ“ [FileWatcher] ConfigFileWatcher deinitialized") } - // MARK: - Suppression API + // MARK: - Internal revision API - /// Suppress file watcher events for a duration to prevent self-initiated reload loops - func suppressEvents(for duration: TimeInterval, reason: String? = nil) { - suppressUntil = Date().addingTimeInterval(duration) - let reasonText = reason ?? "unspecified" - AppLogger.shared.log("๐Ÿ”‡ [FileWatcher] Suppressing events for \(duration)s - \(reasonText)") - } - - private func isSuppressedNow() -> Bool { - if let until = suppressUntil, Date() < until { - return true - } - return false + /// Claim the next debounced filesystem revision as an app-owned save. + /// Call immediately before the write transaction. The revision is consumed + /// by content reconciliation, not by elapsed time. + func claimInternalContent(_ content: String) { + pendingInternalFingerprint = SHA256.hash(data: Data(content.utf8)).map { String(format: "%02x", $0) }.joined() + AppLogger.shared.log("๐Ÿงพ [FileWatcher] Claimed exact internal content revision") } /// Start watching a file for changes @@ -118,7 +115,7 @@ class ConfigFileWatcher: @unchecked Sendable { } // Get initial modification date - updateLastModificationDate() + updateLastContentFingerprint() // Create file descriptor for monitoring guard let fileDescriptor = openFileDescriptor(at: path) else { @@ -259,7 +256,7 @@ class ConfigFileWatcher: @unchecked Sendable { watchedFilePath = nil watchedDirectoryPath = nil onFileChanged = nil - lastModificationDate = nil + lastContentFingerprint = nil retryCount = 0 AppLogger.shared.log("โœ… [FileWatcher] All monitoring stopped and state cleared") @@ -291,13 +288,6 @@ class ConfigFileWatcher: @unchecked Sendable { rebindFileMonitor(to: path) } - // Check for suppression โ€” skip the callback but descriptor is already rebound above - if isSuppressedNow() { - AppLogger.shared.log("๐Ÿ”‡ [FileWatcher] Event suppressed - skipping processing") - pendingAtomicWriteEvent = false - return - } - if inFlightProcessing { AppLogger.shared.log("๐Ÿ“ [FileWatcher] Event already being processed - skipping duplicate") return @@ -314,6 +304,10 @@ class ConfigFileWatcher: @unchecked Sendable { func simulateFileEventForTesting() async { await handleFileEvent(flags: .write) } + + func reconcileFileChangeForTesting() async { + await processFileChange() + } #endif /// Rebind file monitor to handle atomic writes where the file descriptor becomes stale @@ -477,22 +471,18 @@ class ConfigFileWatcher: @unchecked Sendable { } } - private func updateLastModificationDate() { + private func updateLastContentFingerprint() { guard let path = watchedFilePath else { AppLogger.shared.log("โš ๏ธ [FileWatcher] Cannot update modification date - no watched file path") return } do { - let attributes = try Foundation.FileManager().attributesOfItem(atPath: path) - let modDate = attributes[.modificationDate] as? Date - lastModificationDate = modDate - AppLogger.shared.log( - "๐Ÿ“ [FileWatcher] Updated last modification date: \(modDate?.description ?? "nil")" - ) + lastContentFingerprint = try contentFingerprint(at: path) + AppLogger.shared.log("๐Ÿ“ [FileWatcher] Updated content fingerprint") } catch { - AppLogger.shared.log("โš ๏ธ [FileWatcher] Failed to get modification date for \(path): \(error)") - lastModificationDate = nil + AppLogger.shared.log("โš ๏ธ [FileWatcher] Failed to fingerprint \(path): \(error)") + lastContentFingerprint = nil } } @@ -503,30 +493,18 @@ class ConfigFileWatcher: @unchecked Sendable { } do { - let attributes = try Foundation.FileManager().attributesOfItem(atPath: path) - let currentModDate = attributes[.modificationDate] as? Date - - AppLogger.shared.log( - "๐Ÿ“ [FileWatcher] Checking file modification: current=\(currentModDate?.description ?? "nil"), last=\(lastModificationDate?.description ?? "nil")" - ) - - // If we don't have a previous date, consider it changed - guard let lastDate = lastModificationDate else { - AppLogger.shared.log( - "๐Ÿ“ [FileWatcher] No previous modification date - considering file changed" - ) - lastModificationDate = currentModDate + let fingerprint = try contentFingerprint(at: path) + guard let previous = lastContentFingerprint else { + AppLogger.shared.log("๐Ÿ“ [FileWatcher] No previous content fingerprint - considering file changed") + lastContentFingerprint = fingerprint return true } - // Check if modification date has actually changed - let hasChanged = currentModDate != lastDate + let hasChanged = fingerprint != previous if hasChanged { - lastModificationDate = currentModDate - AppLogger.shared.log( - "โœ… [FileWatcher] File modification confirmed at \(currentModDate?.description ?? "unknown")" - ) + lastContentFingerprint = fingerprint + AppLogger.shared.log("โœ… [FileWatcher] Content fingerprint changed") } else { AppLogger.shared.log("๐Ÿ“ [FileWatcher] File modification date unchanged - no actual change") } @@ -538,6 +516,11 @@ class ConfigFileWatcher: @unchecked Sendable { } } + private func contentFingerprint(at path: String) throws -> String { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + private func processFileChange() async { guard let path = watchedFilePath else { AppLogger.shared.log("โš ๏ธ [FileWatcher] Cannot process file change - no watched file path") @@ -558,16 +541,24 @@ class ConfigFileWatcher: @unchecked Sendable { return } - if pendingAtomicWriteEvent { - pendingAtomicWriteEvent = false - AppLogger.shared.log("๐Ÿ“ [FileWatcher] Atomic write detected - forcing change callback") - } else { - // Check if file actually changed (avoid false positives) - guard hasFileActuallyChanged() else { - AppLogger.shared.log("๐Ÿ“ [FileWatcher] No actual file changes detected - skipping callback") - return - } + // Reconcile the final bytes after debouncing. An atomic replacement, + // recreation, or same-mtime rewrite is judged by contentโ€”not event type + // or timestamp. A claimed internal revision consumes only its own final + // bytes; the following external revision is observed normally. + let changed = hasFileActuallyChanged() + pendingAtomicWriteEvent = false + if let expected = pendingInternalFingerprint, lastContentFingerprint == expected { + pendingInternalFingerprint = nil + AppLogger.shared.log("๐Ÿงพ [FileWatcher] Reconciled exact internal revision") + return + } + guard changed else { + AppLogger.shared.log("๐Ÿ“ [FileWatcher] No content change detected; retaining any internal claim") + return } + // A mismatch is never suppressed: it is an external write (or a failed + // internal write followed by an external one). Retire the stale claim. + pendingInternalFingerprint = nil // Get file size for logging do { diff --git a/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift b/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift index 293c1a64a..9ff0804b9 100644 --- a/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift +++ b/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift @@ -422,6 +422,9 @@ final class SaveCoordinatorTests: KeyPathTestCase { var second = RuleCollection(name: "Second", summary: "Second", category: .custom, mappings: [], isEnabled: true) second.configuration = .tapHoldPicker(TapHoldPickerConfig(inputKey: "a", tapOptions: [], holdOptions: [], selectedTapOutput: "c", selectedHoldOutput: "lalt")) manager.ruleCollections = [first, second] + // Admission reloads the durable revision before resolving conflicts. + // Seed that revision so this test exercises its intended conflict path. + try? await manager.ruleCollectionStore.saveCollections([first, second]) var choices = 0 manager.onMappingConflictResolution = { _ in choices += 1 diff --git a/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift b/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift index 3de9393d6..1439dafd5 100644 --- a/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift +++ b/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift @@ -55,19 +55,54 @@ struct ConfigFileWatcherTests { watcher.stopWatching() } - @Test("suppressEvents prevents callback during suppression window") + @Test("internal revision consumes only its matching write") @MainActor - func suppressionPreventsCallback() async throws { + func internalRevisionConsumesMatchingWrite() async throws { let (_, path) = try makeTempFile() let watcher = ConfigFileWatcher() var callbackCount = 0 watcher.startWatching(path: path) { callbackCount += 1 } - watcher.suppressEvents(for: 5.0, reason: "test") + watcher.claimInternalContent("internal-write") - try "suppressed-write".write(toFile: path, atomically: true, encoding: .utf8) - await watcher.simulateFileEventForTesting() + try "internal-write".write(toFile: path, atomically: true, encoding: .utf8) + await watcher.reconcileFileChangeForTesting() + #expect(callbackCount == 0) + try "external-write".write(toFile: path, atomically: true, encoding: .utf8) + await watcher.reconcileFileChangeForTesting() + #expect(callbackCount == 1) + watcher.stopWatching() + } + + @Test("same-mtime external rewrite is reconciled by content") + @MainActor + func sameMtimeRewriteTriggersCallback() async throws { + let (_, path) = try makeTempFile() + let watcher = ConfigFileWatcher() + var callbackCount = 0 + watcher.startWatching(path: path) { callbackCount += 1 } + let originalDate = try FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date + try "changed".write(toFile: path, atomically: true, encoding: .utf8) + if let originalDate { + try FileManager.default.setAttributes([.modificationDate: originalDate], ofItemAtPath: path) + } + await watcher.reconcileFileChangeForTesting() + #expect(callbackCount == 1) + watcher.stopWatching() + } + + @Test("spurious event retains the pending internal content claim") + @MainActor + func spuriousEventRetainsInternalClaim() async throws { + let (_, path) = try makeTempFile() + let watcher = ConfigFileWatcher() + var callbackCount = 0 + watcher.startWatching(path: path) { callbackCount += 1 } + watcher.claimInternalContent("internal-write") + await watcher.reconcileFileChangeForTesting() + try "internal-write".write(toFile: path, atomically: true, encoding: .utf8) + await watcher.reconcileFileChangeForTesting() #expect(callbackCount == 0) watcher.stopWatching() } From e68523f404b55dd776e1a4be36de3c1c33d4706c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 19:16:19 -0700 Subject: [PATCH 05/15] Make catalog updates explicit and recoverable --- .../Managers/RuleCollectionsCoordinator.swift | 13 ++ .../RuntimeCoordinator+RuleCollections.swift | 8 + .../RuleCollections/RuleCollectionStore.swift | 21 ++- .../RuleCollectionsManager+PublicAPI.swift | 125 ++++++++++++++ .../RuleCollectionsManager.swift | 22 +++ .../UI/Rules/CatalogUpdateReviewSheet.swift | 162 ++++++++++++++++++ .../UI/Rules/RulesSummaryView.swift | 21 +++ .../UI/ViewModels/KanataViewModel.swift | 8 + .../RuleCollectionStoreTests.swift | 26 ++- .../RuleCollectionsManagerTests.swift | 95 ++++++++++ .../UI/CatalogUpdateFeedbackTests.swift | 32 ++++ .../configuration-save-pipeline.md | 12 ++ 12 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift create mode 100644 Tests/KeyPathTests/UI/CatalogUpdateFeedbackTests.swift diff --git a/Sources/KeyPathAppKit/Managers/RuleCollectionsCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuleCollectionsCoordinator.swift index 57da6fede..7b46f7f99 100644 --- a/Sources/KeyPathAppKit/Managers/RuleCollectionsCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuleCollectionsCoordinator.swift @@ -42,6 +42,19 @@ final class RuleCollectionsCoordinator { // MARK: - Rule Collection Operations + func catalogUpdatePreviews() -> [CatalogUpdatePreview] { + ruleCollectionsManager.catalogUpdatePreviews() + } + + func applyCatalogUpdates(ids: Set) async -> CatalogUpdateApplicationResult { + let result = await ruleCollectionsManager.applyCatalogUpdates(ids: ids) + if result.saveResult.success { + applyMappings(ruleCollectionsManager.enabledMappings()) + notifyStateChanged() + } + return result + } + /// Toggle a rule collection's enabled state /// - Returns: `true` if the toggle was applied successfully @discardableResult diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift index 898fde6c3..2ad1755a4 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift @@ -5,6 +5,14 @@ import KeyPathRulesCore extension RuntimeCoordinator { // MARK: - Rule Collections (delegates to RuleCollectionsCoordinator) + func catalogUpdatePreviews() -> [CatalogUpdatePreview] { + ruleCollectionsCoordinator.catalogUpdatePreviews() + } + + func applyCatalogUpdates(ids: Set) async -> CatalogUpdateApplicationResult { + await ruleCollectionsCoordinator.applyCatalogUpdates(ids: ids) + } + func replaceRuleCollections(_ collections: [RuleCollection]) async { await ruleCollectionsCoordinator.replaceRuleCollections(collections) } diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift index 6a6341f9e..f0ad34609 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift @@ -186,9 +186,12 @@ actor RuleCollectionStore { } private func upgradeAndMergeDefaults(_ collections: [RuleCollection]) -> [RuleCollection] { + // Catalog changes are deliberately not applied while loading. Doing so made + // an update appear in memory without an explicit user decision, and the + // next unrelated save could persist it. The Rules screen now previews and + // applies catalog updates through a backed-up mutation instead. var upgraded = collections .filter { $0.id != RuleCollectionIdentifier.typingSounds } - .map { catalog.upgradedCollection(from: $0) } let defaults = catalog.defaultCollections() for collection in defaults where !upgraded.contains(where: { $0.id == collection.id }) { @@ -197,6 +200,22 @@ actor RuleCollectionStore { return upgraded } + /// Make a restorable copy before a user-approved catalog update. This is a + /// separate, explicit backup from corruption recovery: it records a choice, + /// not a failed decode. + func backupForCatalogUpdate() throws -> String? { + guard fileManager.fileExists(atPath: fileURL.path) else { return nil } + let backupDir = fileURL.deletingLastPathComponent().appendingPathComponent(".backups") + try fileManager.createDirectory(at: backupDir, withIntermediateDirectories: true) + + let timestamp = ISO8601DateFormatter().string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + let backupURL = backupDir.appendingPathComponent("RuleCollections-catalog-update-\(timestamp).json") + try fileManager.copyItem(at: fileURL, to: backupURL) + AppLogger.shared.log("๐Ÿ“ฆ [RuleCollectionStore] Backed up collections before catalog update: \(backupURL.lastPathComponent)") + return backupURL.path + } + @discardableResult private func backupBeforeFallback() -> String? { guard fileManager.fileExists(atPath: fileURL.path) else { return nil } diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift index d336c2260..46c721e11 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift @@ -6,6 +6,131 @@ import KeyPathRulesCore extension RuleCollectionsManager { // MARK: - Public API + // MARK: - Catalog updates + + /// Return catalog changes that would alter a persisted collection. The + /// proposed version retains supported per-user selections through + /// `upgradedCollection(from:)`; it does not silently replace the local one. + func catalogUpdatePreviews() -> [CatalogUpdatePreview] { + let catalog = RuleCollectionCatalog() + return ruleCollections.compactMap { existing in + guard catalog.defaultCollections().contains(where: { $0.id == existing.id }) else { return nil } + let proposed = catalog.upgradedCollection(from: existing) + guard proposed != existing else { return nil } + + let existingKeys = normalizedKeys(for: existing) + let proposedKeys = normalizedKeys(for: proposed) + let affectedKeys = existingKeys.symmetricDifference(proposedKeys).sorted() + let affectedLayers = Set([existing.targetLayer.displayName, proposed.targetLayer.displayName]).sorted() + let conflict = conflictInfo(for: proposed) + let conflictDescription = conflict.map { + "Conflicts with \($0.displayName) on \($0.keys.sorted().joined(separator: ", "))" + } + + return CatalogUpdatePreview( + existing: existing, + proposed: proposed, + affectedKeys: affectedKeys, + affectedLayers: affectedLayers, + conflictDescription: conflictDescription, + isPackManaged: existing.owningPackID != nil + ) + } + } + + /// Re-check a selected batch against its final in-memory shape. Per-row + /// previews are useful for review, but two individually safe catalog + /// changes can still collide once both replacements are staged. + func combinedCatalogUpdateConflict(in previews: [CatalogUpdatePreview]) -> RuleConflictInfo? { + let snapshot = ruleCollections + defer { + ruleCollections = snapshot + refreshLayerIndicatorState() + } + + for preview in previews { + guard let index = ruleCollections.firstIndex(where: { $0.id == preview.id }) else { continue } + ruleCollections[index] = preview.proposed + } + + for preview in previews { + guard let candidate = ruleCollections.first(where: { $0.id == preview.id }), + let conflict = conflictInfo(for: candidate) + else { continue } + return conflict + } + return nil + } + + /// Apply explicitly selected, conflict-free catalog updates in one durable + /// mutation. Pack-owned entries remain under their pack's ownership and are + /// never changed here. A backup is created before the mutation so the user + /// can always restore their prior local version. + func applyCatalogUpdates(ids: Set) async -> CatalogUpdateApplicationResult { + guard !ids.isEmpty else { + return CatalogUpdateApplicationResult( + saveResult: .failure(KeyPathError.configuration(.validationFailed(errors: ["No catalog updates were selected."]))), + backupPath: nil, + appliedCollectionIDs: [] + ) + } + + do { + return try await configurationService.operationGate.withOperation { @MainActor [self] permit in + try await recoverRuleState(mutationPermit: permit) + let previews = catalogUpdatePreviews().filter { ids.contains($0.id) } + guard previews.count == ids.count else { + return CatalogUpdateApplicationResult( + saveResult: .failure(KeyPathError.configuration(.validationFailed(errors: ["One or more catalog updates are no longer available."]))), + backupPath: nil, + appliedCollectionIDs: [] + ) + } + guard previews.allSatisfy(\.canApply) else { + return CatalogUpdateApplicationResult( + saveResult: .failure(KeyPathError.configuration(.validationFailed(errors: ["Catalog updates with pack ownership or mapping conflicts must be kept or resolved outside this update flow."]))), + backupPath: nil, + appliedCollectionIDs: [] + ) + } + + if let conflict = combinedCatalogUpdateConflict(in: previews) { + return CatalogUpdateApplicationResult( + saveResult: .failure(KeyPathError.configuration(.validationFailed(errors: [ + "Selected catalog updates conflict with \(conflict.displayName) on \(conflict.keys.sorted().joined(separator: ", ")). Keep Mine or apply a smaller, conflict-free selection." + ]))), + backupPath: nil, + appliedCollectionIDs: [] + ) + } + + let backupPath = try await ruleCollectionStore.backupForCatalogUpdate() + let snapshot = snapshotRuleState() + for preview in previews { + guard let index = ruleCollections.firstIndex(where: { $0.id == preview.id }) else { continue } + ruleCollections[index] = preview.proposed + } + refreshLayerIndicatorState() + let saveResult = await commitRuleMutationResult( + snapshot: snapshot, + failureContext: "catalog updates", + mutationPermit: permit + ) + return CatalogUpdateApplicationResult( + saveResult: saveResult, + backupPath: backupPath, + appliedCollectionIDs: saveResult.success ? ids : [] + ) + } + } catch { + return CatalogUpdateApplicationResult( + saveResult: .failure(error), + backupPath: nil, + appliedCollectionIDs: [] + ) + } + } + /// Get all enabled mappings from collections and custom rules func enabledMappings() -> [KeyMapping] { ruleCollections.enabledMappings() + customRules.enabledMappings() diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift index c46ebc9c8..78aedae49 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift @@ -41,6 +41,28 @@ enum RulePersistenceResult { } } +/// A user-visible catalog update candidate. Catalog changes never apply merely +/// because they were discovered at load time: the user either keeps their local +/// version or explicitly applies this candidate after reviewing its impact. +struct CatalogUpdatePreview: Identifiable, Equatable { + let existing: RuleCollection + let proposed: RuleCollection + let affectedKeys: [String] + let affectedLayers: [String] + let conflictDescription: String? + let isPackManaged: Bool + + var id: UUID { existing.id } + var canApply: Bool { !isPackManaged && conflictDescription == nil } +} + +/// The durable outcome of applying one or more approved catalog updates. +struct CatalogUpdateApplicationResult { + let saveResult: SaveResult + let backupPath: String? + let appliedCollectionIDs: Set +} + // MARK: - RuleCollectionsManager /// Manages rule collections and custom rules with conflict detection. diff --git a/Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift b/Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift new file mode 100644 index 000000000..bbc5dcf71 --- /dev/null +++ b/Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift @@ -0,0 +1,162 @@ +import SwiftUI + +/// Explicit review for catalog changes. Nothing is selected initially: keeping +/// the local rule is the default, and only collision-free candidates can be +/// applied from this sheet. +struct CatalogUpdateReviewSheet: View { + let previews: [CatalogUpdatePreview] + let onApply: (Set) async -> CatalogUpdateApplicationResult + + @Environment(\.dismiss) private var dismiss + @State private var selectedIDs: Set = [] + @State private var isApplying = false + @State private var feedback: String? + + private var selectableIDs: Set { + Set(previews.filter(\.canApply).map(\.id)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Review Catalog Updates") + .font(.title2.weight(.semibold)) + + Text("Your local rules stay in place unless you select an update. Applying an update creates a restorable backup first.") + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(previews) { preview in + updateRow(preview) + } + } + } + .frame(minHeight: 160, maxHeight: 360) + + if let feedback { + Text(feedback) + .font(.callout) + .foregroundColor(feedback.hasPrefix("Could not") ? .red : .secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Keep Mine") { dismiss() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("catalog-update-keep-mine-button") + + Spacer() + + Button("Apply Catalog Update") { + applySelectedUpdates() + } + .buttonStyle(.borderedProminent) + .disabled(selectedIDs.isEmpty || isApplying) + .accessibilityIdentifier("catalog-update-apply-button") + } + } + .padding(24) + .frame(width: 580) + } + + @ViewBuilder + private func updateRow(_ preview: CatalogUpdatePreview) -> some View { + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: selectionBinding(for: preview)) { + Text(preview.existing.name) + .font(.headline) + } + .disabled(!preview.canApply || isApplying) + .accessibilityIdentifier("catalog-update-select-\(preview.id)") + .accessibilityLabel("Apply catalog update for \(preview.existing.name)") + + if !preview.affectedKeys.isEmpty { + Text("Affected keys: \(preview.affectedKeys.joined(separator: ", "))") + .font(.caption) + .foregroundColor(.secondary) + } + Text("Affected layers: \(preview.affectedLayers.joined(separator: ", "))") + .font(.caption) + .foregroundColor(.secondary) + + if preview.isPackManaged { + Label("Managed by its pack; update it through the pack.", systemImage: "lock.fill") + .font(.caption) + .foregroundColor(.orange) + } else if let conflict = preview.conflictDescription { + Label("Keep Mine required: \(conflict)", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("Collision-free merge: your supported settings are retained.") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.secondary.opacity(0.08))) + } + + private func selectionBinding(for preview: CatalogUpdatePreview) -> Binding { + Binding( + get: { selectedIDs.contains(preview.id) }, + set: { selected in + if selected { selectedIDs.insert(preview.id) } + else { selectedIDs.remove(preview.id) } + } + ) + } + + private func applySelectedUpdates() { + let ids = selectedIDs.intersection(selectableIDs) + guard !ids.isEmpty else { return } + isApplying = true + feedback = nil + Task { + let result = await onApply(ids) + await MainActor.run { + isApplying = false + guard result.saveResult.success else { + feedback = CatalogUpdateFeedback.failureMessage(for: result.saveResult) + return + } + let disposition: String + switch result.saveResult.reloadResult?.disposition { + case .applied: disposition = "Applied and running." + case .pending: disposition = "Saved; it will apply when the engine is available." + case .rejected: disposition = "The engine rejected the update; your prior rules were restored." + case .failed: disposition = "The engine could not apply the update; your prior rules were restored." + case nil: disposition = "Saved." + } + let backup = result.backupPath.map { " Backup: \($0)" } ?? "" + feedback = "\(disposition)\(backup)" + selectedIDs = [] + } + } + } +} + +enum CatalogUpdateFeedback { + static func failureMessage(for result: SaveResult) -> String { + switch result.recoveryResult { + case let .restoredPreviousRuleState(reloadResult): + return "The catalog update was not accepted. Your previous rules were restored.\(runtimeRecoveryMessage(reloadResult))" + case .ruleStateRecoveryFailed: + return "The catalog update was not accepted, and restoring your previous rules also failed. Please review your configuration and backup." + default: + return "Could not apply the catalog update: \(result.error?.localizedDescription ?? "Unknown error")" + } + } + + private static func runtimeRecoveryMessage(_ result: ReloadResult?) -> String { + switch result?.disposition { + case .applied: return " The restored rules are running." + case .pending: return " The restored rules are saved and will apply when the engine is available." + case .rejected: return " The engine rejected the restored rules." + case .failed: return " The restored rules could not be applied to the engine." + case nil: return "" + } + } +} diff --git a/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView.swift b/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView.swift index 0357e1b4c..1a8f6a702 100644 --- a/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView.swift +++ b/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView.swift @@ -14,6 +14,7 @@ struct RulesTabView: View { @State private var searchQuery = "" @State var recommendationFocusCollectionId: UUID? @State private var showingResetConfirmation = false + @State private var showingCatalogUpdateReview = false @State private var showingNewRuleSheet = false @State var settingsToastManager = WizardToastManager() @State private var createButtonHovered = false @@ -233,6 +234,18 @@ struct RulesTabView: View { Spacer() + if !kanataManager.catalogUpdatePreviews().isEmpty { + Button { + showingCatalogUpdateReview = true + } label: { + Label("Catalog updates", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityIdentifier("rules-catalog-updates-button") + .accessibilityLabel("Review catalog updates") + } + Button { showingResetConfirmation = true } label: { @@ -563,6 +576,14 @@ struct RulesTabView: View { .sheet(isPresented: $showingHomeRowModsHelp) { MarkdownHelpSheet(resource: "home-row-mods", title: "Home Row Mods") } + .sheet(isPresented: $showingCatalogUpdateReview) { + CatalogUpdateReviewSheet( + previews: kanataManager.catalogUpdatePreviews(), + onApply: { ids in + await kanataManager.applyCatalogUpdates(ids: ids) + } + ) + } .alert("Reset Configuration?", isPresented: $showingResetConfirmation) { Button("Cancel", role: .cancel) {} Button("Open Backups Folder") { diff --git a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift index 7b76d6e02..7849f529b 100644 --- a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift +++ b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift @@ -227,6 +227,14 @@ class KanataViewModel { // Note: Removed manual syncFromManager() calls - AsyncStream automatically updates UI + func catalogUpdatePreviews() -> [CatalogUpdatePreview] { + manager.catalogUpdatePreviews() + } + + func applyCatalogUpdates(ids: Set) async -> CatalogUpdateApplicationResult { + await manager.applyCatalogUpdates(ids: ids) + } + func batchEnableCollections(_ ids: [UUID]) async { await manager.batchEnableCollections(ids: ids) } diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift index 58ed2a5ae..8aa8d2169 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift @@ -54,7 +54,7 @@ final class RuleCollectionStoreTests: XCTestCase { ) } - func testLoadUpgradesBuiltInCollectionsWithLatestMetadata() async throws { + func testLoadKeepsBuiltInCollectionsLocalUntilAnUpdateIsApproved() async throws { let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-collections-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -81,10 +81,11 @@ final class RuleCollectionStoreTests: XCTestCase { let vim = loaded.first { $0.id == RuleCollectionIdentifier.vimNavigation } XCTAssertNotNil(vim) - XCTAssertEqual(vim?.targetLayer, .navigation) - XCTAssertEqual(vim?.momentaryActivator?.input, "space") - XCTAssertEqual(vim?.momentaryActivator?.targetLayer, .navigation) - XCTAssertEqual(vim?.activationHint, "Hold Leader key to enter Navigation layer") + XCTAssertEqual(vim?.summary, "Legacy") + XCTAssertEqual(vim?.targetLayer, .base) + XCTAssertNil(vim?.momentaryActivator) + XCTAssertNil(vim?.activationHint) + XCTAssertEqual(vim?.mappings.first?.input, "h") } func testSaveWritesVersionedFormat() async throws { @@ -311,4 +312,19 @@ final class RuleCollectionStoreTests: XCTestCase { "Loading should merge persisted subset with all catalog defaults (including new ones)" ) } + + func testCatalogUpdateBackupCopiesThePersistedCollections() async throws { + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("rule-collections-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("collections.json") + let store = RuleCollectionStore.testStore(at: fileURL) + let original = RuleCollectionCatalog().defaultCollections() + try await store.saveCollections(original) + + let backupPath = try await store.backupForCatalogUpdate() + + let backupURL = URL(fileURLWithPath: try XCTUnwrap(backupPath)) + XCTAssertEqual(try Data(contentsOf: backupURL), try Data(contentsOf: fileURL)) + } } diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift index 504253cec..3a60ec3ec 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift @@ -33,6 +33,101 @@ final class RuleCollectionsManagerTests: XCTestCase { // MARK: - Existing Tests + @MainActor + func testCatalogUpdatePreviewDefaultsToLocalRuleAndShowsAffectedKeys() async throws { + let (manager, _) = try await createTestManager() + defer { TestEnvironment.forceTestMode = false } + + var local = try XCTUnwrap( + RuleCollectionCatalog().defaultCollections().first { $0.id == RuleCollectionIdentifier.vimNavigation } + ) + local.summary = "My local navigation" + local.mappings = [KeyMapping(input: "q", action: .keystroke(key: "left"))] + manager.ruleCollections = [local] + + let preview = try XCTUnwrap(manager.catalogUpdatePreviews().first { $0.id == local.id }) + + XCTAssertEqual(preview.existing.summary, "My local navigation") + XCTAssertTrue(preview.affectedKeys.contains("q")) + XCTAssertFalse(preview.isPackManaged) + XCTAssertTrue(preview.canApply) + } + + @MainActor + func testCatalogUpdatePreviewDoesNotOfferPackManagedRule() async throws { + let (manager, _) = try await createTestManager() + defer { TestEnvironment.forceTestMode = false } + + var local = try XCTUnwrap( + RuleCollectionCatalog().defaultCollections().first { $0.id == RuleCollectionIdentifier.vimNavigation } + ) + local.summary = "My local navigation" + local.owningPackID = "example-pack" + manager.ruleCollections = [local] + + let preview = try XCTUnwrap(manager.catalogUpdatePreviews().first { $0.id == local.id }) + + XCTAssertTrue(preview.isPackManaged) + XCTAssertFalse(preview.canApply) + } + + @MainActor + func testApplyCatalogUpdateBacksUpLocalRuleAndReportsApplied() async throws { + let (manager, tempDir) = try await createTestManager() + defer { TestEnvironment.forceTestMode = false } + + var collections = RuleCollectionCatalog().defaultCollections() + let index = try XCTUnwrap(collections.firstIndex { $0.id == RuleCollectionIdentifier.vimNavigation }) + collections[index].summary = "My local navigation" + try await manager.ruleCollectionStore.saveCollections(collections) + manager.ruleCollections = collections + manager.onRulesChanged = { + ReloadResult(success: true, response: nil, errorMessage: nil, protocol: nil, disposition: .applied) + } + + let result = await manager.applyCatalogUpdates(ids: [RuleCollectionIdentifier.vimNavigation]) + + XCTAssertTrue(result.saveResult.success) + XCTAssertEqual(result.appliedCollectionIDs, [RuleCollectionIdentifier.vimNavigation]) + XCTAssertTrue(FileManager.default.fileExists(atPath: try XCTUnwrap(result.backupPath))) + XCTAssertNotEqual( + manager.ruleCollections.first { $0.id == RuleCollectionIdentifier.vimNavigation }?.summary, + "My local navigation" + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("RuleCollections.json").path)) + } + + @MainActor + func testCombinedCatalogUpdateConflictRejectsBatchBeforeBackup() async throws { + let (manager, tempDir) = try await createTestManager() + defer { TestEnvironment.forceTestMode = false } + + let firstID = UUID() + let secondID = UUID() + let first = RuleCollection(name: "First", summary: "Local", category: .custom, mappings: [ + KeyMapping(input: "a", action: .keystroke(key: "left")) + ], isEnabled: true, icon: "1.circle") + let second = RuleCollection(name: "Second", summary: "Local", category: .custom, mappings: [ + KeyMapping(input: "b", action: .keystroke(key: "right")) + ], isEnabled: true, icon: "2.circle") + let localFirst = RuleCollection(id: firstID, name: first.name, summary: first.summary, category: first.category, mappings: first.mappings, isEnabled: true, icon: first.icon) + let localSecond = RuleCollection(id: secondID, name: second.name, summary: second.summary, category: second.category, mappings: second.mappings, isEnabled: true, icon: second.icon) + var proposedFirst = localFirst + proposedFirst.mappings = [KeyMapping(input: "x", action: .keystroke(key: "left"))] + var proposedSecond = localSecond + proposedSecond.mappings = [KeyMapping(input: "x", action: .keystroke(key: "right"))] + manager.ruleCollections = [localFirst, localSecond] + + let previews = [ + CatalogUpdatePreview(existing: localFirst, proposed: proposedFirst, affectedKeys: ["a", "x"], affectedLayers: ["Base"], conflictDescription: nil, isPackManaged: false), + CatalogUpdatePreview(existing: localSecond, proposed: proposedSecond, affectedKeys: ["b", "x"], affectedLayers: ["Base"], conflictDescription: nil, isPackManaged: false) + ] + + XCTAssertNotNil(manager.combinedCatalogUpdateConflict(in: previews)) + XCTAssertEqual(manager.ruleCollections, [localFirst, localSecond]) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent(".backups").path)) + } + @MainActor func testToggleRehydratesMissingCatalogCollection() async throws { TestEnvironment.forceTestMode = true diff --git a/Tests/KeyPathTests/UI/CatalogUpdateFeedbackTests.swift b/Tests/KeyPathTests/UI/CatalogUpdateFeedbackTests.swift new file mode 100644 index 000000000..337f197d3 --- /dev/null +++ b/Tests/KeyPathTests/UI/CatalogUpdateFeedbackTests.swift @@ -0,0 +1,32 @@ +@testable import KeyPathAppKit +import KeyPathCore +import XCTest + +final class CatalogUpdateFeedbackTests: XCTestCase { + func testRestoredRuleStateReportsPendingRuntimeRecovery() { + let reload = ReloadResult(success: true, response: nil, errorMessage: nil, protocol: nil, disposition: .pending) + let result = KeyPathAppKit.SaveResult.failure( + KeyPathError.configuration(.validationFailed(errors: ["Rejected"])), + recoveryResult: KeyPathAppKit.SaveRecoveryResult.restoredPreviousRuleState(reloadResult: reload) + ) + + XCTAssertEqual( + CatalogUpdateFeedback.failureMessage(for: result), + "The catalog update was not accepted. Your previous rules were restored. The restored rules are saved and will apply when the engine is available." + ) + } + + func testRuleStateRecoveryFailureIsNotReportedAsAnOrdinarySaveFailure() { + let result = KeyPathAppKit.SaveResult.failure( + KeyPathError.configuration(.validationFailed(errors: ["Rejected"])), + recoveryResult: KeyPathAppKit.SaveRecoveryResult.ruleStateRecoveryFailed(TestFailure()) + ) + + XCTAssertEqual( + CatalogUpdateFeedback.failureMessage(for: result), + "The catalog update was not accepted, and restoring your previous rules also failed. Please review your configuration and backup." + ) + } + + private struct TestFailure: Error {} +} diff --git a/docs/architecture/configuration-save-pipeline.md b/docs/architecture/configuration-save-pipeline.md index 9124acf2b..8c614553e 100644 --- a/docs/architecture/configuration-save-pipeline.md +++ b/docs/architecture/configuration-save-pipeline.md @@ -195,6 +195,18 @@ or include preference restoration in rejected-apply recovery. Feature-specific writers remain separate migration work. Backups remain copies of current disk state; this scope does not recover pending journals or refresh the app's cached state following a CLI restore. +## Catalog updates + +Loading a rule collection never writes a catalog revision back just because the +catalog changed. The Rules screen exposes an explicit review instead: **Keep +Mine** is the default, while an approved catalog update creates a durable +`RuleCollections.json` backup before staging the catalog version. The preview +lists affected keys and layers, excludes pack-managed collections, and offers +an update only if the proposed result has no mapping conflict. Its result makes +the same applied, pending, rejected, or failed runtime distinction as every +other rule write. CLI commands retain their explicit existing policy; they do +not inherit a UI choice. + ## CLI pack ownership `PacksFacade` admits install, uninstall and configure before inspecting installed From 28125d741c22428208ac7d364863d46277e5d5de Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 19:32:39 -0700 Subject: [PATCH 06/15] Make configuration rendering deterministic --- .../Config/ConfigurationService.swift | 34 ++++-- .../KanataConfiguration+BlockBuilders.swift | 11 +- .../Config/KanataConfigurationGenerator.swift | 103 +++++++++++++----- .../Devices/DeviceSelectionStore.swift | 6 +- .../DeviceSwitchConfigTests.swift | 25 ++--- ...aConfigurationGeneratorSnapshotTests.swift | 27 +++++ .../Services/ConfigurationServiceTests.swift | 38 +++++++ .../configuration-save-pipeline.md | 4 + 8 files changed, 190 insertions(+), 58 deletions(-) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index 79e4e32e8..06db05f13 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -831,6 +831,7 @@ public final class ConfigurationService: FileConfigurationProviding { let expected = try await generateConfiguration( ruleCollections: persistedCollections, customRules: persistedRules, + appSpecificKeys: inputs.appSpecificKeys, leaderKeyPreference: persistedLeaderPreference(in: preferenceDefaults), shortcutListGenerationInput: inputs.shortcut, deviceGenerationInput: inputs.device @@ -903,6 +904,7 @@ public final class ConfigurationService: FileConfigurationProviding { private struct GlobalRuleGenerationInputs { let shortcut: ShortcutListGenerationInput let device: DeviceGenerationInput + let appSpecificKeys: Set } private func persistedGlobalRuleGenerationInputs( @@ -917,13 +919,22 @@ public final class ConfigurationService: FileConfigurationProviding { ) ?? .long let custom = defaults.object(forKey: RecoverableRuleWrite.PreferenceRole.contextHUDHoldDelayCustomMs.key) as? Int ?? 200 let selections = try await deviceSelectionStore.loadForMutation() + let appKeymapStore = AppKeymapStore( + fileURL: URL(fileURLWithPath: configDirectory).appendingPathComponent("AppKeymaps.json") + ) + let appKeymaps = try await appKeymapStore.loadForMutation() return GlobalRuleGenerationInputs( shortcut: ShortcutListGenerationInput( triggerMode: trigger, holdDelayPreset: preset, customHoldDelayMs: custom ), - device: await deviceSelectionStore.generationInput(for: selections) + device: await deviceSelectionStore.generationInput(for: selections), + appSpecificKeys: Set( + appKeymaps + .filter { $0.mapping.isEnabled } + .flatMap { $0.overrides.map { $0.inputKey.lowercased() } } + ) ) } @@ -957,6 +968,12 @@ public final class ConfigurationService: FileConfigurationProviding { shortcutListGenerationInput: ShortcutListGenerationInput? = nil, deviceGenerationInput: DeviceGenerationInput? = nil ) async throws -> KanataConfiguration { + let persistedInputs: GlobalRuleGenerationInputs? = if appSpecificKeys == nil || shortcutListGenerationInput == nil || deviceGenerationInput == nil { + try await persistedGlobalRuleGenerationInputs(preferenceDefaults: PreferencesService.canonicalDefaults) + } else { + nil + } + // Custom rules come first so they take priority over preset collections let customRuleCollections = customRules.asRuleCollections() AppLogger.shared.log("๐Ÿ”ง [ConfigService] Converting \(customRules.count) custom rules to \(customRuleCollections.count) collections") @@ -1006,16 +1023,19 @@ public final class ConfigurationService: FileConfigurationProviding { let preservedChordGroups = loadPreservedChordGroups() let preservedSequences = loadPreservedSequences() - let configContent = KanataConfiguration.generateFromCollections( - combinedCollections, + let layoutID = UserDefaults.standard.string(forKey: LayoutPreferences.layoutIdKey) + ?? LayoutPreferences.defaultLayoutId + let inputs = KanataGenerationInputs( leaderKeyPreference: leaderKeyPref, - navActivationMode: shortcutInput?.triggerMode ?? triggerMode, - navHoldDelayMs: shortcutInput?.holdDelayMs ?? holdDelayMs, - deviceGenerationInput: deviceGenerationInput, + navActivationMode: shortcutInput?.triggerMode ?? persistedInputs?.shortcut.triggerMode ?? triggerMode, + navHoldDelayMs: shortcutInput?.holdDelayMs ?? persistedInputs?.shortcut.holdDelayMs ?? holdDelayMs, + deviceGenerationInput: deviceGenerationInput ?? persistedInputs?.device ?? DeviceGenerationInput(selections: [], connectedDevices: []), chordGroups: preservedChordGroups, sequences: preservedSequences, - appSpecificKeys: appSpecificKeys + appSpecificKeys: appSpecificKeys ?? persistedInputs?.appSpecificKeys ?? [], + physicalLayout: PhysicalLayout.find(id: layoutID) ?? .macBookUS ) + let configContent = KanataConfiguration.generateFromCollections(combinedCollections, inputs: inputs) return KanataConfiguration( content: configContent, diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift index b97facca0..34ade7223 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift @@ -48,6 +48,8 @@ extension KanataConfiguration { leaderKeyPreference: LeaderKeyPreference?, navActivationMode: ContextHUDTriggerMode = .tapToToggle, navHoldDelayMs: Int = 200, + connectedDevices: [ConnectedDevice] = [], + physicalLayout: PhysicalLayout = .macBookUS, globalRequirePriorIdleMs: Int = 0 ) -> ([CollectionBlock], [AliasDefinition], [RuleCollectionLayer], [ChordMapping]) { var blocks: [CollectionBlock] = [] @@ -315,9 +317,9 @@ extension KanataConfiguration { // Per-device switch wrapping: if this key has device overrides, // wrap the output in a (switch ((device N)) ...) expression if let overrides = mapping.deviceOverrides, !overrides.isEmpty { - let devices = DeviceSelectionCache.shared.getConnectedDevices() + let devices = connectedDevices if devices.isEmpty { - AppLogger.shared.debug("โš ๏ธ [ConfigGen] Device overrides on key '\(mapping.input)' but no connected devices in cache โ€” using default output only") + AppLogger.shared.debug("โš ๏ธ [ConfigGen] Device overrides on key '\(mapping.input)' but the generation input has no connected devices โ€” using default output only") } let switchExpr = renderDeviceSwitchExpression( defaultOutput: layerOutput, @@ -359,13 +361,10 @@ extension KanataConfiguration { // Skip ALL activator keys that target this layer, not just Vim's own activator // This prevents blocking layer-switch keys like "w" (Nav โ†’ Window) let keysToSkip = activatorKeysBySourceLayer[collection.targetLayer] ?? [] - // Read user's selected physical layout from UserDefaults - let selectedLayoutId = UserDefaults.standard.string(forKey: LayoutPreferences.layoutIdKey) ?? LayoutPreferences.defaultLayoutId - let layout = PhysicalLayout.find(id: selectedLayoutId) ?? .macBookUS let extraKeys = Self.navigationUnmappedKeys( excluding: mappedKeys, skipping: keysToSkip, - layout: layout + layout: physicalLayout ) let blockedEntries = extraKeys.map { key in let layerOutput = wrapWithOneShotExit( diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift index 8dc7a5dee..2e58da347 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift @@ -4,6 +4,42 @@ import KeyPathDaemonLifecycle import KeyPathRulesCore import Network +/// Point-in-time non-rule inputs for synchronous configuration rendering. +/// Capture these in the owning service before rendering; the renderer itself +/// must not mix a candidate with live disk, cache, or preference state. +struct KanataGenerationInputs { + let leaderKeyPreference: LeaderKeyPreference? + let navActivationMode: ContextHUDTriggerMode + let navHoldDelayMs: Int + let deviceGenerationInput: DeviceGenerationInput + let chordGroups: [ChordGroupConfig] + let sequences: [KanataDefseqParser.ParsedSequence] + let appSpecificKeys: Set + let physicalLayout: PhysicalLayout + + init( + leaderKeyPreference: LeaderKeyPreference? = nil, + navActivationMode: ContextHUDTriggerMode = .tapToToggle, + navHoldDelayMs: Int = 200, + deviceGenerationInput: DeviceGenerationInput = DeviceGenerationInput(selections: [], connectedDevices: []), + chordGroups: [ChordGroupConfig] = [], + sequences: [KanataDefseqParser.ParsedSequence] = [], + appSpecificKeys: Set = [], + physicalLayout: PhysicalLayout = .macBookUS + ) { + self.leaderKeyPreference = leaderKeyPreference + self.navActivationMode = navActivationMode + self.navHoldDelayMs = navHoldDelayMs + self.deviceGenerationInput = deviceGenerationInput + self.chordGroups = chordGroups + self.sequences = sequences + self.appSpecificKeys = appSpecificKeys + self.physicalLayout = physicalLayout + } + + static let empty = KanataGenerationInputs() +} + // MARK: - Kanata Configuration Model /// Represents Kanata configuration data and metadata @@ -34,20 +70,14 @@ public struct KanataConfiguration: Sendable { /// Generate configuration content from key mappings (adds default system collections when absent). public static func generateFromMappings(_ mappings: [KeyMapping]) -> String { let collections = [RuleCollection].collection(named: "Custom Mappings", mappings: mappings) - return generateFromCollections(collections) + return generateFromCollections(collections, inputs: .empty) } /// Generate configuration content from rule collections. /// Flattens enabled collections to `defsrc`/`deflayer` for backward compatibility with Kanata config format. static func generateFromCollections( _ collections: [RuleCollection], - leaderKeyPreference: LeaderKeyPreference? = nil, - navActivationMode: ContextHUDTriggerMode = .tapToToggle, - navHoldDelayMs: Int = 200, - deviceGenerationInput: DeviceGenerationInput? = nil, - chordGroups: [ChordGroupConfig] = [], - sequences: [KanataDefseqParser.ParsedSequence] = [], - appSpecificKeys: Set? = nil + inputs: KanataGenerationInputs ) -> String { var resolvedCollections = collections.isEmpty ? defaultSystemCollections : collections if !resolvedCollections.contains(where: { $0.id == RuleCollectionIdentifier.macFunctionKeys }) { @@ -84,9 +114,11 @@ public struct KanataConfiguration: Sendable { let (rawBlocks, aliasDefinitions, extraLayers, chordMappings) = buildCollectionBlocks( from: enabledCollections, - leaderKeyPreference: leaderKeyPreference, - navActivationMode: navActivationMode, - navHoldDelayMs: navHoldDelayMs, + leaderKeyPreference: inputs.leaderKeyPreference, + navActivationMode: inputs.navActivationMode, + navHoldDelayMs: inputs.navHoldDelayMs, + connectedDevices: inputs.deviceGenerationInput.connectedDevices, + physicalLayout: inputs.physicalLayout, globalRequirePriorIdleMs: requirePriorIdleMs ) let mergedAliasDefinitions = deduplicateAliases(aliasDefinitions) @@ -97,14 +129,14 @@ public struct KanataConfiguration: Sendable { let blocks = deduplicateBlocks(rawBlocks) let enabledNames = enabledCollections.map(\.name).joined(separator: ", ") - let macosDeviceTargeting = renderMacOSDeviceTargetingForDefcfg(deviceGenerationInput) + let macosDeviceTargeting = renderMacOSDeviceTargetingForDefcfg(inputs.deviceGenerationInput) let keyRepeatConfig = enabledCollections .compactMap(\.configuration.keyRepeatControlConfig) .first let sequencesConfig = enabledCollections .compactMap(\.configuration.sequencesConfig) .first - let hasSequences = !sequences.isEmpty || !(sequencesConfig?.sequences.isEmpty ?? true) + let hasSequences = !inputs.sequences.isEmpty || !(sequencesConfig?.sequences.isEmpty ?? true) let sequencePauseLimitMs = hasSequences ? sequencesConfig?.clampedPauseLimitMs : nil // All defcfg header construction flows through KanataDefcfg (single source of truth). @@ -149,8 +181,7 @@ public struct KanataConfiguration: Sendable { let sourceBlock = renderDefsrcBlock(blocks) - // Load app-specific keys to use @kp-{key} aliases in base layer - let appSpecificKeys = appSpecificKeys ?? loadAppSpecificKeys() + let appSpecificKeys = inputs.appSpecificKeys let baseLayerBlock = renderLayerBlock(name: RuleCollectionLayer.base.kanataName, blocks: blocks) { entry in // If this key has app-specific overrides, use the alias instead of the plain key @@ -169,8 +200,8 @@ public struct KanataConfiguration: Sendable { let fakeKeysBlock = renderFakeKeysBlock(extraLayers) let aliasBlock = renderAliasBlock(mergedAliasDefinitions) let chordsBlock = renderChordsBlock(chordMappings) - let preservedChordGroupsBlock = renderChordGroupsBlock(chordGroups) - let preservedSequencesBlock = renderSequencesBlock(sequences) + let preservedChordGroupsBlock = renderChordGroupsBlock(inputs.chordGroups) + let preservedSequencesBlock = renderSequencesBlock(inputs.sequences) let uiChordGroupsBlock = renderUIChordGroupsBlock(uiChordGroupsConfig) // Include keypath-apps.kbd if there are app-specific keys @@ -215,7 +246,33 @@ public struct KanataConfiguration: Sendable { .joined(separator: "\n") } - private static let defaultEmptyConfig = generateFromCollections(defaultSystemCollections) + /// Compatibility convenience for callers that do not own all point-in-time inputs yet. + /// It deliberately supplies deterministic empty values instead of consulting live state. + static func generateFromCollections( + _ collections: [RuleCollection], + leaderKeyPreference: LeaderKeyPreference? = nil, + navActivationMode: ContextHUDTriggerMode = .tapToToggle, + navHoldDelayMs: Int = 200, + deviceGenerationInput: DeviceGenerationInput? = nil, + chordGroups: [ChordGroupConfig] = [], + sequences: [KanataDefseqParser.ParsedSequence] = [], + appSpecificKeys: Set = [] + ) -> String { + generateFromCollections( + collections, + inputs: KanataGenerationInputs( + leaderKeyPreference: leaderKeyPreference, + navActivationMode: navActivationMode, + navHoldDelayMs: navHoldDelayMs, + deviceGenerationInput: deviceGenerationInput ?? DeviceGenerationInput(selections: [], connectedDevices: []), + chordGroups: chordGroups, + sequences: sequences, + appSpecificKeys: appSpecificKeys + ) + ) + } + + private static let defaultEmptyConfig = generateFromCollections(defaultSystemCollections, inputs: .empty) // MARK: - macOS Device Targeting (VirtualHID exclusion + per-device selection) @@ -227,14 +284,10 @@ public struct KanataConfiguration: Sendable { /// When any non-VirtualHID device is disabled, we emit `macos-dev-names-include` for enabled devices /// plus `macos-dev-names-exclude` for VirtualHID devices. private static func renderMacOSDeviceTargetingForDefcfg( - _ input: DeviceGenerationInput? = nil + _ input: DeviceGenerationInput ) -> String { #if os(macOS) - let cache = DeviceSelectionCache.shared - - // Use only the cached device list. CompositionRoot primes selections synchronously - // at startup, and device enumeration updates this cache when the Devices tab loads. - let allDevices = input?.connectedDevices ?? cache.getConnectedDevices() + let allDevices = input.connectedDevices guard !allDevices.isEmpty else { return "" } let virtualHIDDevices = allDevices.filter(\.isVirtualHID) @@ -242,7 +295,7 @@ public struct KanataConfiguration: Sendable { // Check which physical devices are disabled via user selection let enabledByHash = Dictionary( - (input?.selections ?? cache.allSelections()).map { ($0.hash, $0.isEnabled) }, + input.selections.map { ($0.hash, $0.isEnabled) }, uniquingKeysWith: { _, latest in latest } ) let isEnabled: (String) -> Bool = { enabledByHash[$0] ?? true } diff --git a/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift b/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift index ad3b992d3..a54de4051 100644 --- a/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift +++ b/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift @@ -22,9 +22,9 @@ struct DeviceGenerationInput: Sendable { let connectedDevices: [ConnectedDevice] } -/// Thread-safe synchronous cache for device selections and connected devices, -/// used by the config generator. The generator runs synchronously and cannot -/// await actor methods, so it reads from this cache. +/// Thread-safe synchronous cache for UI and service-layer device snapshots. +/// Configuration rendering receives a point-in-time `DeviceGenerationInput` +/// instead of consulting this mutable cache directly. final class DeviceSelectionCache: @unchecked Sendable { static let shared = DeviceSelectionCache() diff --git a/Tests/KeyPathTests/Infrastructure/DeviceSwitchConfigTests.swift b/Tests/KeyPathTests/Infrastructure/DeviceSwitchConfigTests.swift index 0e5b60222..371e9178c 100644 --- a/Tests/KeyPathTests/Infrastructure/DeviceSwitchConfigTests.swift +++ b/Tests/KeyPathTests/Infrastructure/DeviceSwitchConfigTests.swift @@ -199,10 +199,6 @@ final class DeviceSwitchConfigTests: XCTestCase { // MARK: - Full Config Snapshot Tests func testFullConfigWithDeviceOverrides_ContainsSwitchBlock() { - let cache = DeviceSelectionCache.shared - cache.updateConnectedDevices([device0, device1]) - defer { cache.reset() } - let mapping = KeyMapping( input: "a", action: .keystroke(key: "b"), @@ -219,7 +215,10 @@ final class DeviceSwitchConfigTests: XCTestCase { mappings: [mapping] ) - let config = KanataConfiguration.generateFromCollections([collection]) + let config = KanataConfiguration.generateFromCollections( + [collection], + deviceGenerationInput: DeviceGenerationInput(selections: [], connectedDevices: [device0, device1]) + ) // The full config should contain the device switch alias definition assertContains(config, "dev_base_a") @@ -232,10 +231,6 @@ final class DeviceSwitchConfigTests: XCTestCase { } func testFullConfigWithDeviceOverrides_OnNavLayer() { - let cache = DeviceSelectionCache.shared - cache.updateConnectedDevices([device0, device1]) - defer { cache.reset() } - let mapping = KeyMapping( input: "h", action: .keystroke(key: "left"), @@ -262,7 +257,8 @@ final class DeviceSwitchConfigTests: XCTestCase { let config = KanataConfiguration.generateFromCollections( [collection], - navActivationMode: .tapToToggle + navActivationMode: .tapToToggle, + deviceGenerationInput: DeviceGenerationInput(selections: [], connectedDevices: [device0, device1]) ) // Device switch should wrap the nav layer output @@ -327,12 +323,6 @@ final class DeviceSwitchConfigTests: XCTestCase { // MARK: - Integration with buildCollectionBlocks func testCollectionWithDeviceOverrides_GeneratesSwitchAlias() { - // Set up connected devices in the cache - let cache = DeviceSelectionCache.shared - cache.updateConnectedDevices([device0, device1]) - - defer { cache.reset() } - let mapping = KeyMapping( input: "a", action: .keystroke(key: "b"), @@ -350,7 +340,8 @@ final class DeviceSwitchConfigTests: XCTestCase { let (_, aliases, _, _) = KanataConfiguration.buildCollectionBlocks( from: [collection], - leaderKeyPreference: nil + leaderKeyPreference: nil, + connectedDevices: [device0, device1] ) let deviceAlias = aliases.first(where: { $0.aliasName.hasPrefix("dev_") }) diff --git a/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift b/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift index 8857148dd..e098772ce 100644 --- a/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift +++ b/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift @@ -3,6 +3,33 @@ import KeyPathRulesCore import XCTest final class KanataConfigurationGeneratorSnapshotTests: XCTestCase { + func testExplicitGenerationInputsDoNotConsultDeviceCache() { + let cachedDevice = ConnectedDevice( + hash: "cache-device", + vendorID: 1, + productID: 1, + productKey: "Cached Keyboard", + isVirtualHID: true + ) + let explicitDevice = ConnectedDevice( + hash: "explicit-device", + vendorID: 2, + productID: 2, + productKey: "Explicit Keyboard", + isVirtualHID: true + ) + DeviceSelectionCache.shared.updateConnectedDevices([cachedDevice]) + defer { DeviceSelectionCache.shared.reset() } + + let inputs = KanataGenerationInputs( + deviceGenerationInput: DeviceGenerationInput(selections: [], connectedDevices: [explicitDevice]) + ) + let config = KanataConfiguration.generateFromCollections([], inputs: inputs) + + assertContains(config, "explicit-device") + XCTAssertFalse(config.contains("cache-device")) + } + func testBaseConfigIncludesDefaultFunctionKeys() { let config = KanataConfiguration.generateFromCollections([]) diff --git a/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift b/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift index 0f6778cec..4106369c9 100644 --- a/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift +++ b/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift @@ -532,6 +532,44 @@ class ConfigurationServiceTests: XCTestCase { ) } + func testCreateInitialConfigPreservesPersistedAppKeysAndDeviceSnapshot() async throws { + let appKeymapStore = AppKeymapStore(fileURL: tempDirectory.appendingPathComponent("AppKeymaps.json")) + try await appKeymapStore.saveKeymaps([ + AppKeymap( + bundleIdentifier: "com.example.KeyPathTests", + displayName: "KeyPath Tests", + overrides: [AppKeyOverride(inputKey: "f1", action: .keystroke(key: "b"))] + ) + ]) + + let deviceCache = DeviceSelectionCache() + deviceCache.updateConnectedDevices([ + ConnectedDevice( + hash: "test-virtual-hid", + vendorID: 1, + productID: 1, + productKey: "KeyPath Test Virtual HID", + isVirtualHID: true + ) + ]) + let service = ConfigurationService( + configDirectory: tempDirectory.path, + ruleCollectionStore: .testStore(at: tempDirectory.appendingPathComponent("RuleCollections.json")), + customRulesStore: .testStore(at: tempDirectory.appendingPathComponent("CustomRules.json")), + deviceSelectionStore: DeviceSelectionStore( + fileURL: tempDirectory.appendingPathComponent("DeviceSelection.json"), + cache: deviceCache + ) + ) + + try await service.createInitialConfigIfNeeded() + + let contents = try String(contentsOf: tempDirectory.appendingPathComponent("keypath.kbd"), encoding: .utf8) + XCTAssertTrue(contents.contains("(include keypath-apps.kbd)")) + XCTAssertTrue(contents.contains("@kp-f1")) + XCTAssertTrue(contents.contains("test-virtual-hid")) + } + /// #929: a pre-existing 0-byte keypath.kbd (left behind by old helper /// scaffolding) must be treated as missing and replaced with the default. func testCreateInitialConfigRewritesEmptyFile() async throws { diff --git a/docs/architecture/configuration-save-pipeline.md b/docs/architecture/configuration-save-pipeline.md index 8c614553e..3bdc4186c 100644 --- a/docs/architecture/configuration-save-pipeline.md +++ b/docs/architecture/configuration-save-pipeline.md @@ -28,6 +28,10 @@ runtime recovery remains incomplete for global collection and pack operations. - Restore the last known-good file after `rejected` or `failed`. - Keep `ConfigurationService` as the only collection-generation writer and `SaveCoordinator` as the only generated/raw-save coordinator. +- Render collection-backed configuration from one immutable point-in-time input + snapshot. `ConfigurationService` captures preferences, device state, app keys, + preserved chords/sequences, and physical layout before calling the renderer; + the renderer does not read disk, mutable caches, or `UserDefaults`. ## Save result boundary From a2ca21d0a88b45d19d90fb059a01cd791505e7d0 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 20:35:49 -0700 Subject: [PATCH 07/15] Extract CLI help discovery boundary --- Package.swift | 37 +++++ .../CLI/CLIActionDescription.swift | 91 ------------ .../Commands/Help/HelpCommand.swift | 5 +- .../Commands/Help/HelpExamplesCommand.swift | 12 +- .../Commands/Help/HelpSchemasCommand.swift | 13 +- Sources/KeyPathCLI/KeyPathTool.swift | 1 + Sources/KeyPathCLI/Utilities/ANSIColor.swift | 24 +--- Sources/KeyPathCLI/Utilities/CLIError.swift | 136 +----------------- .../KeyPathCLI/Utilities/GlobalOptions.swift | 34 +---- Sources/KeyPathCLI/Utilities/Output.swift | 100 +------------ Sources/KeyPathCLICommon/ANSIColor.swift | 9 ++ Sources/KeyPathCLICommon/CLIError.swift | 69 +++++++++ Sources/KeyPathCLICommon/GlobalOptions.swift | 34 +++++ Sources/KeyPathCLICommon/Output.swift | 61 ++++++++ .../CLIActionDescription.swift | 37 +++++ .../HelpOutputContractTests.swift | 67 +++++++++ 16 files changed, 344 insertions(+), 386 deletions(-) delete mode 100644 Sources/KeyPathAppKit/CLI/CLIActionDescription.swift create mode 100644 Sources/KeyPathCLICommon/ANSIColor.swift create mode 100644 Sources/KeyPathCLICommon/CLIError.swift create mode 100644 Sources/KeyPathCLICommon/GlobalOptions.swift create mode 100644 Sources/KeyPathCLICommon/Output.swift create mode 100644 Sources/KeyPathRulesCore/CLIActionDescription.swift create mode 100644 Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift diff --git a/Package.swift b/Package.swift index 7f5a0fa78..26915f133 100644 --- a/Package.swift +++ b/Package.swift @@ -157,6 +157,17 @@ let package = Package( .swiftLanguageMode(.v6) ] ), + // Foundation-only CLI argument and presentation contracts. + .target( + name: "KeyPathCLICommon", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Sources/KeyPathCLICommon", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), // Installation wizard (extracted from KeyPathAppKit for incremental compilation) .target( name: "KeyPathInstallationWizard", @@ -289,15 +300,30 @@ let package = Package( ] ), // CLI library (testable) + .target( + name: "KeyPathCLIHelp", + dependencies: [ + "KeyPathCLICommon", + "KeyPathRulesCore", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Sources/KeyPathCLI/Commands/Help", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), .target( name: "KeyPathCLI", dependencies: [ "KeyPathCLISupport", + "KeyPathCLICommon", + "KeyPathCLIHelp", "KeyPathAppKit", "KeyPathRulesCore", .product(name: "ArgumentParser", package: "swift-argument-parser") ], path: "Sources/KeyPathCLI", + exclude: ["Commands/Help"], swiftSettings: [ .swiftLanguageMode(.v6) ] @@ -389,6 +415,17 @@ let package = Package( .swiftLanguageMode(.v6) ] ), + .testTarget( + name: "KeyPathCLIHelpTests", + dependencies: [ + "KeyPathCLIHelp", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Tests/KeyPathCLIHelpTests", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), // Visual snapshot tests for help documentation screenshots .testTarget( name: "KeyPathSnapshotTests", diff --git a/Sources/KeyPathAppKit/CLI/CLIActionDescription.swift b/Sources/KeyPathAppKit/CLI/CLIActionDescription.swift deleted file mode 100644 index 88913515c..000000000 --- a/Sources/KeyPathAppKit/CLI/CLIActionDescription.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation -import KeyPathRulesCore - -// MARK: - CLIโ€“GUI Parity Guard - -// Exhaustive switches on KeyAction and MappingBehavior ensure the CLI won't -// compile if a new case is added without CLI support. - -public extension KeyAction { - var cliSchemaName: String { - switch self { - case .keystroke: "key" - case .hyper: "hyper" - case .meh: "meh" - case .launchApp: "launch-app" - case .openURL: "open-url" - case .openFolder: "open-folder" - case .runScript: "run-script" - case .systemAction: "system-action" - case .notify: "notify" - case .windowAction: "window" - case .fakeKey: "fake-key" - case .activateLayer: "activate-layer" - case .rawKanata: "raw-kanata" - } - } - - var cliSchemaDescription: String { - switch self { - case .keystroke: "Emit a different key (simple remap)" - case .hyper: "Hyper modifier combo (Cmd+Ctrl+Alt+Shift)" - case .meh: "Meh modifier combo (Ctrl+Alt+Shift)" - case .launchApp: "Launch an application by name or bundle ID" - case .openURL: "Open a URL in the default browser" - case .openFolder: "Open a folder in Finder" - case .runScript: "Run a script file" - case .systemAction: "Trigger a system action (volume, brightness, etc.)" - case .notify: "Show a user notification" - case .windowAction: "Window management action (left half, maximize, etc.)" - case .fakeKey: "Trigger a Kanata virtual/fake key" - case .activateLayer: "Switch to or activate a layer" - case .rawKanata: "Raw kanata expression (power user escape hatch)" - } - } - - static var allSchemaDescriptions: [CLISchemaEntry] { - let representative: [KeyAction] = [ - .keystroke(key: ""), .hyper, .meh, - .launchApp(name: "", bundleId: nil), .openURL(""), .openFolder(path: "", name: nil), - .runScript(path: "", name: nil), .systemAction(id: ""), .notify(title: "", body: nil, sound: false), - .windowAction(position: ""), .fakeKey(name: "", action: .tap), .activateLayer(name: ""), - .rawKanata(""), - ] - return representative.map { CLISchemaEntry(name: $0.cliSchemaName, description: $0.cliSchemaDescription) } - } -} - -public extension MappingBehavior { - var cliSchemaName: String { - switch self { - case .dualRole: "tap-hold" - case .tapOrTapDance: "tap-dance" - case .macro: "macro" - case .chord: "chord" - } - } - - var cliSchemaDescription: String { - switch self { - case .dualRole: "Dual-role key: tap produces one action, hold produces another" - case .tapOrTapDance: "Tap behavior with optional multi-tap (tap-dance)" - case .macro: "Macro: one trigger key produces multiple outputs or text" - case .chord: "Chord: multiple keys pressed together produce a single output" - } - } - - static var allSchemaDescriptions: [CLISchemaEntry] { - let representative: [MappingBehavior] = [ - .dualRole(DualRoleBehavior(tapAction: .empty, holdAction: .empty)), - .tapOrTapDance(.tap), - .macro(MacroBehavior()), - .chord(ChordBehavior(keys: [], output: .empty)), - ] - return representative.map { CLISchemaEntry(name: $0.cliSchemaName, description: $0.cliSchemaDescription) } - } -} - -public struct CLISchemaEntry: Codable, Sendable { - public let name: String - public let description: String -} diff --git a/Sources/KeyPathCLI/Commands/Help/HelpCommand.swift b/Sources/KeyPathCLI/Commands/Help/HelpCommand.swift index d0ff4f89d..087627c51 100644 --- a/Sources/KeyPathCLI/Commands/Help/HelpCommand.swift +++ b/Sources/KeyPathCLI/Commands/Help/HelpCommand.swift @@ -1,7 +1,8 @@ import ArgumentParser -struct Help: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct Help: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "help-topics", abstract: "Extended help and API discovery", subcommands: [ diff --git a/Sources/KeyPathCLI/Commands/Help/HelpExamplesCommand.swift b/Sources/KeyPathCLI/Commands/Help/HelpExamplesCommand.swift index 936b707e1..8128d3ff2 100644 --- a/Sources/KeyPathCLI/Commands/Help/HelpExamplesCommand.swift +++ b/Sources/KeyPathCLI/Commands/Help/HelpExamplesCommand.swift @@ -1,18 +1,20 @@ import ArgumentParser import Foundation +import KeyPathCLICommon -struct HelpExamples: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct HelpExamples: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "examples", abstract: "Curated workflow examples for each command noun" ) - @OptionGroup var globals: GlobalOptions + @OptionGroup public var globals: GlobalOptions @Argument(help: "Noun to show examples for (rule, collection, layer, service, config, system)") - var noun: String? + public var noun: String? - mutating func run() async throws { + public mutating func run() async throws { let ctx = globals.outputContext if let noun { diff --git a/Sources/KeyPathCLI/Commands/Help/HelpSchemasCommand.swift b/Sources/KeyPathCLI/Commands/Help/HelpSchemasCommand.swift index 88f45677b..e53075415 100644 --- a/Sources/KeyPathCLI/Commands/Help/HelpSchemasCommand.swift +++ b/Sources/KeyPathCLI/Commands/Help/HelpSchemasCommand.swift @@ -1,20 +1,21 @@ import ArgumentParser import Foundation -import KeyPathAppKit +import KeyPathCLICommon import KeyPathRulesCore -struct HelpSchemas: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct HelpSchemas: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( commandName: "schemas", abstract: "List available CLI schemas for agent API discovery" ) - @OptionGroup var globals: GlobalOptions + @OptionGroup public var globals: GlobalOptions @Argument(help: "Schema noun to inspect (e.g., action, behavior, rule, collection)") - var noun: String? + public var noun: String? - mutating func run() async throws { + public mutating func run() async throws { let ctx = globals.outputContext if let noun { diff --git a/Sources/KeyPathCLI/KeyPathTool.swift b/Sources/KeyPathCLI/KeyPathTool.swift index 152cb3fcd..a5c0c2f86 100644 --- a/Sources/KeyPathCLI/KeyPathTool.swift +++ b/Sources/KeyPathCLI/KeyPathTool.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import KeyPathCLISupport +@_exported import KeyPathCLIHelp public struct KeyPathCLI: AsyncParsableCommand { public init() {} diff --git a/Sources/KeyPathCLI/Utilities/ANSIColor.swift b/Sources/KeyPathCLI/Utilities/ANSIColor.swift index 3412f0b75..5e16ac3df 100644 --- a/Sources/KeyPathCLI/Utilities/ANSIColor.swift +++ b/Sources/KeyPathCLI/Utilities/ANSIColor.swift @@ -1,23 +1,3 @@ -import Foundation +import KeyPathCLICommon -enum ANSIColor { - static func green(_ s: String, noColor: Bool) -> String { - noColor ? s : "\u{001b}[32m\(s)\u{001b}[0m" - } - - static func red(_ s: String, noColor: Bool) -> String { - noColor ? s : "\u{001b}[31m\(s)\u{001b}[0m" - } - - static func yellow(_ s: String, noColor: Bool) -> String { - noColor ? s : "\u{001b}[33m\(s)\u{001b}[0m" - } - - static func dim(_ s: String, noColor: Bool) -> String { - noColor ? s : "\u{001b}[2m\(s)\u{001b}[0m" - } - - static func bold(_ s: String, noColor: Bool) -> String { - noColor ? s : "\u{001b}[1m\(s)\u{001b}[0m" - } -} +typealias ANSIColor = KeyPathCLICommon.ANSIColor diff --git a/Sources/KeyPathCLI/Utilities/CLIError.swift b/Sources/KeyPathCLI/Utilities/CLIError.swift index 3a3e2d4f9..04d1ea0ce 100644 --- a/Sources/KeyPathCLI/Utilities/CLIError.swift +++ b/Sources/KeyPathCLI/Utilities/CLIError.swift @@ -1,133 +1,5 @@ -import ArgumentParser -import Foundation +import KeyPathCLICommon -public struct CLIError: Error, Codable, Sendable { - public let code: CLIExitCode - public let message: String - public let hint: String? - public let details: [String]? - public let docsUrl: String? - - public init(code: CLIExitCode, message: String, hint: String?, details: [String]?, docsUrl: String?) { - self.code = code - self.message = message - self.hint = hint - self.details = details - self.docsUrl = docsUrl - } -} - -public enum CLIExitCode: Int32, Codable, Sendable, CaseIterable { - case success = 0 - case usage = 2 - case validation = 3 - case conflict = 4 - case notFound = 5 - case serviceUnreachable = 6 - case permissionBlocked = 7 - case kanataInvalid = 8 -} - -extension CLIExitCode { - var exitCode: ExitCode { - ExitCode(rawValue: rawValue) - } -} - -// MARK: - Documentation URLs - -public enum CLIDocsURL { - static let faq = "https://github.com/malpern/KeyPath/blob/master/docs/guides/faq.md" - static let debugging = "https://github.com/malpern/KeyPath/blob/master/docs/troubleshooting/debugging-kanata.md" - static let actionURI = "https://github.com/malpern/KeyPath/blob/master/docs/guides/action-uri-system.md" - static let ruleCollections = "https://github.com/malpern/KeyPath/blob/master/docs/architecture/rules-architecture.html" - static let permissions = "https://github.com/malpern/KeyPath/blob/master/docs/architecture/permissions-architecture.html" -} - -// MARK: - Factory Methods - -public extension CLIError { - static func notFound(_ entity: String, query: String, listCommand: String, suggestions: [String] = []) -> CLIError { - var hint = "Run '\(listCommand)' to see available \(entity.lowercased())s" - if !suggestions.isEmpty { - hint = "Did you mean: \(suggestions.joined(separator: ", "))?\n" + hint - } - return CLIError( - code: .notFound, - message: "\(entity) not found: '\(query)'", - hint: hint, - details: ["query: '\(query)'"], - docsUrl: nil - ) - } - - static func serviceUnreachable(hint: String = "Run 'keypath service status --json' to check if Kanata is running") -> CLIError { - CLIError( - code: .serviceUnreachable, - message: "Could not connect to Kanata TCP server", - hint: hint, - details: nil, - docsUrl: CLIDocsURL.debugging - ) - } - - static func serviceControlFailed(action: String, hint: String, details: [String]? = nil) -> CLIError { - CLIError( - code: .serviceUnreachable, - message: "Could not \(action) Kanata service", - hint: hint, - details: details, - docsUrl: CLIDocsURL.debugging - ) - } - - static func validation(_ message: String, hint: String? = nil, details: [String]? = nil) -> CLIError { - CLIError( - code: .validation, - message: message, - hint: hint, - details: details, - docsUrl: nil - ) - } - - static func conflict(_ message: String, hint: String? = nil) -> CLIError { - CLIError( - code: .conflict, - message: message, - hint: hint, - details: nil, - docsUrl: nil - ) - } - - static func invalidKey(_ key: String, label: String) -> CLIError { - CLIError( - code: .validation, - message: "Invalid \(label) key: '\(key)'", - hint: "Run 'keypath help-topics schemas rule' for valid key names (e.g., caps, lalt, esc, lctl, spc, ret)", - details: nil, - docsUrl: CLIDocsURL.faq - ) - } - - static func ambiguous(_ message: String, matches: [String]) -> CLIError { - CLIError( - code: .conflict, - message: message, - hint: "Use the full name or ID to disambiguate", - details: matches, - docsUrl: nil - ) - } - - static func kanataInvalid(errors: [String]) -> CLIError { - CLIError( - code: .kanataInvalid, - message: "Configuration validation failed", - hint: "Fix the errors above, then run 'keypath config check --json'", - details: errors, - docsUrl: CLIDocsURL.debugging - ) - } -} +public typealias CLIError = KeyPathCLICommon.CLIError +public typealias CLIExitCode = KeyPathCLICommon.CLIExitCode +public typealias CLIDocsURL = KeyPathCLICommon.CLIDocsURL diff --git a/Sources/KeyPathCLI/Utilities/GlobalOptions.swift b/Sources/KeyPathCLI/Utilities/GlobalOptions.swift index a4a413cd1..64809f07c 100644 --- a/Sources/KeyPathCLI/Utilities/GlobalOptions.swift +++ b/Sources/KeyPathCLI/Utilities/GlobalOptions.swift @@ -1,32 +1,4 @@ -import ArgumentParser +import KeyPathCLICommon -struct GlobalOptions: ParsableArguments { - @Flag(help: "Force JSON output") - var json: Bool = false - - @Flag(name: .customLong("no-json"), help: "Force human-readable output") - var noJson: Bool = false - - @Flag(name: .customLong("dry-run"), help: "Preview changes without applying") - var dryRun: Bool = false - - @Flag(name: .customLong("quiet"), help: "Suppress stderr decoration (spinners, progress, hints)") - var quiet: Bool = false - - @Option(name: .customLong("timeout"), help: "Timeout in seconds for IPC and network operations (default: 30)") - var timeout: Int = 30 - - @Option(name: .customLong("on-conflict"), help: "Conflict resolution: fail|replace|skip|merge") - var onConflict: ConflictStrategy = .fail - - var outputContext: OutputContext { - OutputContext.detect(forceJSON: json, forceHuman: noJson, quiet: quiet) - } -} - -enum ConflictStrategy: String, ExpressibleByArgument, Sendable { - case fail - case replace - case skip - case merge -} +typealias GlobalOptions = KeyPathCLICommon.GlobalOptions +typealias ConflictStrategy = KeyPathCLICommon.ConflictStrategy diff --git a/Sources/KeyPathCLI/Utilities/Output.swift b/Sources/KeyPathCLI/Utilities/Output.swift index 8fbf68bc4..449477792 100644 --- a/Sources/KeyPathCLI/Utilities/Output.swift +++ b/Sources/KeyPathCLI/Utilities/Output.swift @@ -1,98 +1,4 @@ -import Foundation -import KeyPathCLISupport +import KeyPathCLICommon -public struct OutputContext: Sendable { - public let isInteractive: Bool - public let forceJSON: Bool - public let forceHuman: Bool - public let noColor: Bool - public let quiet: Bool - - public var shouldOutputJSON: Bool { - forceJSON || (!forceHuman && !isInteractive) - } - - public init(isInteractive: Bool, forceJSON: Bool, forceHuman: Bool, noColor: Bool, quiet: Bool = false) { - self.isInteractive = isInteractive - self.forceJSON = forceJSON - self.forceHuman = forceHuman - self.noColor = noColor - self.quiet = quiet - } - - public static func detect(forceJSON: Bool = false, forceHuman: Bool = false, quiet: Bool = false) -> OutputContext { - OutputContext( - isInteractive: isatty(STDOUT_FILENO) != 0, - forceJSON: forceJSON, - forceHuman: forceHuman, - noColor: ProcessInfo.processInfo.environment["NO_COLOR"] != nil, - quiet: quiet - ) - } -} - -enum CLIOutput { - static func write(_ value: some Encodable, context: OutputContext, humanRender: () -> String) { - if context.shouldOutputJSON { - writeJSON(value) - } else { - let text = humanRender() - print(text) - } - } - - private struct APIEnvelope: Encodable { - let apiVersion: Int = 1 - let data: T - } - - static func writeJSON(_ value: some Encodable) { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - let envelope = APIEnvelope(data: value) - guard let data = try? encoder.encode(envelope), let json = String(data: data, encoding: .utf8) else { - return - } - print(json) - } - - private struct APIErrorEnvelope: Encodable { - let apiVersion: Int = 1 - let error: CLIError - } - - static func writeError(_ error: CLIError, context: OutputContext) { - if context.shouldOutputJSON { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let envelope = APIErrorEnvelope(error: error) - if let data = try? encoder.encode(envelope), let json = String(data: data, encoding: .utf8) { - printErr(json) - } - } else { - let nc = context.noColor - printErr(ANSIColor.red("Error: \(error.message)", noColor: nc)) - if let hint = error.hint { - printErr(ANSIColor.dim("Hint: \(hint)", noColor: nc)) - } - if let details = error.details { - for detail in details { - printErr(ANSIColor.dim(" \(detail)", noColor: nc)) - } - } - if let docsUrl = error.docsUrl { - printErr(ANSIColor.dim("Docs: \(docsUrl)", noColor: nc)) - } - } - } - - static func writeRaw(_ text: String) { - Swift.print(text) - } - - static func progress(_ message: String, context: OutputContext) { - guard context.isInteractive, !context.quiet else { return } - printErr(ANSIColor.yellow(message, noColor: context.noColor)) - } -} +public typealias OutputContext = KeyPathCLICommon.OutputContext +typealias CLIOutput = KeyPathCLICommon.CLIOutput diff --git a/Sources/KeyPathCLICommon/ANSIColor.swift b/Sources/KeyPathCLICommon/ANSIColor.swift new file mode 100644 index 000000000..3df6d8e75 --- /dev/null +++ b/Sources/KeyPathCLICommon/ANSIColor.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum ANSIColor { + public static func green(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[32m\(s)\u{001b}[0m" } + public static func red(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[31m\(s)\u{001b}[0m" } + public static func yellow(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[33m\(s)\u{001b}[0m" } + public static func dim(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[2m\(s)\u{001b}[0m" } + public static func bold(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[1m\(s)\u{001b}[0m" } +} diff --git a/Sources/KeyPathCLICommon/CLIError.swift b/Sources/KeyPathCLICommon/CLIError.swift new file mode 100644 index 000000000..11399d42c --- /dev/null +++ b/Sources/KeyPathCLICommon/CLIError.swift @@ -0,0 +1,69 @@ +import ArgumentParser +import Foundation + +public struct CLIError: Error, Codable, Sendable { + public let code: CLIExitCode + public let message: String + public let hint: String? + public let details: [String]? + public let docsUrl: String? + + public init(code: CLIExitCode, message: String, hint: String?, details: [String]?, docsUrl: String?) { + self.code = code + self.message = message + self.hint = hint + self.details = details + self.docsUrl = docsUrl + } +} + +public enum CLIExitCode: Int32, Codable, Sendable, CaseIterable { + case success = 0, usage = 2, validation = 3, conflict = 4, notFound = 5 + case serviceUnreachable = 6, permissionBlocked = 7, kanataInvalid = 8 + + public var exitCode: ExitCode { ExitCode(rawValue: rawValue) } +} + +public enum CLIDocsURL { + public static let faq = "https://github.com/malpern/KeyPath/blob/master/docs/guides/faq.md" + public static let debugging = "https://github.com/malpern/KeyPath/blob/master/docs/troubleshooting/debugging-kanata.md" + public static let actionURI = "https://github.com/malpern/KeyPath/blob/master/docs/guides/action-uri-system.md" + public static let ruleCollections = "https://github.com/malpern/KeyPath/blob/master/docs/architecture/rules-architecture.html" + public static let permissions = "https://github.com/malpern/KeyPath/blob/master/docs/architecture/permissions-architecture.html" +} + +public extension CLIError { + static func notFound(_ entity: String, query: String, listCommand: String, suggestions: [String] = []) -> CLIError { + var hint = "Run '\(listCommand)' to see available \(entity.lowercased())s" + if !suggestions.isEmpty { hint = "Did you mean: \(suggestions.joined(separator: ", "))?\n" + hint } + return CLIError(code: .notFound, message: "\(entity) not found: '\(query)'", hint: hint, details: ["query: '\(query)'"], docsUrl: nil) + } + + static func serviceUnreachable(hint: String = "Run 'keypath service status --json' to check if Kanata is running") -> CLIError { + CLIError(code: .serviceUnreachable, message: "Could not connect to Kanata TCP server", hint: hint, details: nil, docsUrl: CLIDocsURL.debugging) + } + + static func serviceControlFailed(action: String, hint: String, details: [String]? = nil) -> CLIError { + CLIError(code: .serviceUnreachable, message: "Could not \(action) Kanata service", hint: hint, details: details, docsUrl: CLIDocsURL.debugging) + } + + static func validation(_ message: String, hint: String? = nil, details: [String]? = nil) -> CLIError { + CLIError(code: .validation, message: message, hint: hint, details: details, docsUrl: nil) + } + + static func conflict(_ message: String, hint: String? = nil) -> CLIError { + CLIError(code: .conflict, message: message, hint: hint, details: nil, docsUrl: nil) + } + + static func invalidKey(_ key: String, label: String) -> CLIError { + CLIError(code: .validation, message: "Invalid \(label) key: '\(key)'", hint: "Run 'keypath help-topics schemas rule' for valid key names (e.g., caps, lalt, esc, lctl, spc, ret)", details: nil, docsUrl: CLIDocsURL.faq) + } + + static func ambiguous(_ message: String, matches: [String]) -> CLIError { + CLIError(code: .conflict, message: message, hint: "Use the full name or ID to disambiguate", details: matches, docsUrl: nil) + } + + static func kanataInvalid(errors: [String]) -> CLIError { + CLIError(code: .kanataInvalid, message: "Configuration validation failed", hint: "Fix the errors above, then run 'keypath config check --json'", details: errors, docsUrl: CLIDocsURL.debugging) + } +} diff --git a/Sources/KeyPathCLICommon/GlobalOptions.swift b/Sources/KeyPathCLICommon/GlobalOptions.swift new file mode 100644 index 000000000..358c1ebd0 --- /dev/null +++ b/Sources/KeyPathCLICommon/GlobalOptions.swift @@ -0,0 +1,34 @@ +import ArgumentParser + +public struct GlobalOptions: ParsableArguments { + @Flag(help: "Force JSON output") + public var json: Bool = false + + @Flag(name: .customLong("no-json"), help: "Force human-readable output") + public var noJson: Bool = false + + @Flag(name: .customLong("dry-run"), help: "Preview changes without applying") + public var dryRun: Bool = false + + @Flag(name: .customLong("quiet"), help: "Suppress stderr decoration (spinners, progress, hints)") + public var quiet: Bool = false + + @Option(name: .customLong("timeout"), help: "Timeout in seconds for IPC and network operations (default: 30)") + public var timeout: Int = 30 + + @Option(name: .customLong("on-conflict"), help: "Conflict resolution: fail|replace|skip|merge") + public var onConflict: ConflictStrategy = .fail + + public init() {} + + public var outputContext: OutputContext { + OutputContext.detect(forceJSON: json, forceHuman: noJson, quiet: quiet) + } +} + +public enum ConflictStrategy: String, ExpressibleByArgument, Sendable { + case fail + case replace + case skip + case merge +} diff --git a/Sources/KeyPathCLICommon/Output.swift b/Sources/KeyPathCLICommon/Output.swift new file mode 100644 index 000000000..b79b23900 --- /dev/null +++ b/Sources/KeyPathCLICommon/Output.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct OutputContext: Sendable { + public let isInteractive: Bool + public let forceJSON: Bool + public let forceHuman: Bool + public let noColor: Bool + public let quiet: Bool + + public var shouldOutputJSON: Bool { forceJSON || (!forceHuman && !isInteractive) } + + public init(isInteractive: Bool, forceJSON: Bool, forceHuman: Bool, noColor: Bool, quiet: Bool = false) { + self.isInteractive = isInteractive; self.forceJSON = forceJSON; self.forceHuman = forceHuman; self.noColor = noColor; self.quiet = quiet + } + + public static func detect(forceJSON: Bool = false, forceHuman: Bool = false, quiet: Bool = false) -> OutputContext { + OutputContext(isInteractive: isatty(STDOUT_FILENO) != 0, forceJSON: forceJSON, forceHuman: forceHuman, noColor: ProcessInfo.processInfo.environment["NO_COLOR"] != nil, quiet: quiet) + } +} + +public enum CLIOutput { + public static func write(_ value: some Encodable, context: OutputContext, humanRender: () -> String) { + context.shouldOutputJSON ? writeJSON(value) : print(humanRender()) + } + + private struct APIEnvelope: Encodable { let apiVersion: Int = 1; let data: T } + + public static func writeJSON(_ value: some Encodable) { + let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]; encoder.dateEncodingStrategy = .iso8601 + let envelope = APIEnvelope(data: value) + guard let data = try? encoder.encode(envelope), let json = String(data: data, encoding: .utf8) else { return } + print(json) + } + + private struct APIErrorEnvelope: Encodable { let apiVersion: Int = 1; let error: CLIError } + + public static func writeError(_ error: CLIError, context: OutputContext) { + if context.shouldOutputJSON { + let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let envelope = APIErrorEnvelope(error: error) + if let data = try? encoder.encode(envelope), let json = String(data: data, encoding: .utf8) { writeErrorLine(json) } + } else { + let nc = context.noColor + writeErrorLine(ANSIColor.red("Error: \(error.message)", noColor: nc)) + if let hint = error.hint { writeErrorLine(ANSIColor.dim("Hint: \(hint)", noColor: nc)) } + if let details = error.details { for detail in details { writeErrorLine(ANSIColor.dim(" \(detail)", noColor: nc)) } } + if let docsUrl = error.docsUrl { writeErrorLine(ANSIColor.dim("Docs: \(docsUrl)", noColor: nc)) } + } + } + + public static func writeRaw(_ text: String) { Swift.print(text) } + + public static func progress(_ message: String, context: OutputContext) { + guard context.isInteractive, !context.quiet else { return } + writeErrorLine(ANSIColor.yellow(message, noColor: context.noColor)) + } +} + +private func writeErrorLine(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) +} diff --git a/Sources/KeyPathRulesCore/CLIActionDescription.swift b/Sources/KeyPathRulesCore/CLIActionDescription.swift new file mode 100644 index 000000000..d2fe8bad2 --- /dev/null +++ b/Sources/KeyPathRulesCore/CLIActionDescription.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Stable action and behavior descriptions shared by the CLI help surface. +public struct CLISchemaEntry: Codable, Sendable { + public let name: String + public let description: String +} + +public extension KeyAction { + var cliSchemaName: String { + switch self { + case .keystroke: "key"; case .hyper: "hyper"; case .meh: "meh"; case .launchApp: "launch-app"; case .openURL: "open-url"; case .openFolder: "open-folder"; case .runScript: "run-script"; case .systemAction: "system-action"; case .notify: "notify"; case .windowAction: "window"; case .fakeKey: "fake-key"; case .activateLayer: "activate-layer"; case .rawKanata: "raw-kanata" + } + } + var cliSchemaDescription: String { + switch self { + case .keystroke: "Emit a different key (simple remap)"; case .hyper: "Hyper modifier combo (Cmd+Ctrl+Alt+Shift)"; case .meh: "Meh modifier combo (Ctrl+Alt+Shift)"; case .launchApp: "Launch an application by name or bundle ID"; case .openURL: "Open a URL in the default browser"; case .openFolder: "Open a folder in Finder"; case .runScript: "Run a script file"; case .systemAction: "Trigger a system action (volume, brightness, etc.)"; case .notify: "Show a user notification"; case .windowAction: "Window management action (left half, maximize, etc.)"; case .fakeKey: "Trigger a Kanata virtual/fake key"; case .activateLayer: "Switch to or activate a layer"; case .rawKanata: "Raw kanata expression (power user escape hatch)" + } + } + static var allSchemaDescriptions: [CLISchemaEntry] { + let representative: [KeyAction] = [.keystroke(key: ""), .hyper, .meh, .launchApp(name: "", bundleId: nil), .openURL(""), .openFolder(path: "", name: nil), .runScript(path: "", name: nil), .systemAction(id: ""), .notify(title: "", body: nil, sound: false), .windowAction(position: ""), .fakeKey(name: "", action: .tap), .activateLayer(name: ""), .rawKanata("")] + return representative.map { CLISchemaEntry(name: $0.cliSchemaName, description: $0.cliSchemaDescription) } + } +} + +public extension MappingBehavior { + var cliSchemaName: String { + switch self { case .dualRole: "tap-hold"; case .tapOrTapDance: "tap-dance"; case .macro: "macro"; case .chord: "chord" } + } + var cliSchemaDescription: String { + switch self { case .dualRole: "Dual-role key: tap produces one action, hold produces another"; case .tapOrTapDance: "Tap behavior with optional multi-tap (tap-dance)"; case .macro: "Macro: one trigger key produces multiple outputs or text"; case .chord: "Chord: multiple keys pressed together produce a single output" } + } + static var allSchemaDescriptions: [CLISchemaEntry] { + let representative: [MappingBehavior] = [.dualRole(DualRoleBehavior(tapAction: .empty, holdAction: .empty)), .tapOrTapDance(.tap), .macro(MacroBehavior()), .chord(ChordBehavior(keys: [], output: .empty))] + return representative.map { CLISchemaEntry(name: $0.cliSchemaName, description: $0.cliSchemaDescription) } + } +} diff --git a/Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift b/Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift new file mode 100644 index 000000000..211ba9a49 --- /dev/null +++ b/Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift @@ -0,0 +1,67 @@ +import ArgumentParser +import Darwin +@testable import KeyPathCLIHelp +import XCTest + +final class HelpOutputContractTests: XCTestCase { + func testUnknownSchemaPreservesJSONAndHumanErrorContracts() async throws { + try await assertUnknownHelpOutput( + command: HelpSchemas.self, + noun: "not-a-schema", + entity: "Schema", + listCommand: "keypath help-topics schemas" + ) + } + + func testUnknownExamplePreservesJSONAndHumanErrorContracts() async throws { + try await assertUnknownHelpOutput( + command: HelpExamples.self, + noun: "not-an-example", + entity: "Examples", + listCommand: "keypath help-topics examples" + ) + } + + private func assertUnknownHelpOutput( + command: Command.Type, + noun: String, + entity: String, + listCommand: String + ) async throws { + let jsonError = await captureStandardError { + var parsed = try! command.parse([noun, "--json"]) + _ = try? await parsed.run() + } + let jsonData = try XCTUnwrap(jsonError.data(using: .utf8)) + let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + XCTAssertEqual(json?["apiVersion"] as? Int, 1) + let error = try XCTUnwrap(json?["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? Int, 5) + XCTAssertEqual(error["message"] as? String, "\(entity) not found: '\(noun)'") + XCTAssertEqual(error["hint"] as? String, "Run '\(listCommand)' to see available \(entity.lowercased())s") + XCTAssertEqual(error["details"] as? [String], ["query: '\(noun)'"]) + + let humanError = await captureStandardError { + var parsed = try! command.parse([noun, "--no-json"]) + _ = try? await parsed.run() + } + XCTAssertTrue(humanError.contains("Error: \(entity) not found: '\(noun)'")) + XCTAssertTrue(humanError.contains("Hint: Run '\(listCommand)' to see available \(entity.lowercased())s")) + XCTAssertTrue(humanError.contains(" query: '\(noun)'")) + } + + private func captureStandardError(_ operation: () async -> Void) async -> String { + let pipe = Pipe() + let original = dup(STDERR_FILENO) + XCTAssertNotEqual(original, -1) + XCTAssertNotEqual(dup2(pipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO), -1) + + await operation() + fflush(stderr) + XCTAssertNotEqual(dup2(original, STDERR_FILENO), -1) + close(original) + pipe.fileHandleForWriting.closeFile() + + return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + } +} From 0dd33daadb49b1fadad5be163678f280adb0bd92 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 12 Sep 2026 20:40:28 -0700 Subject: [PATCH 08/15] Fix CLI common lint violations --- Sources/KeyPathCLICommon/CLIError.swift | 8 +++++++- Sources/KeyPathCLICommon/Output.swift | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/KeyPathCLICommon/CLIError.swift b/Sources/KeyPathCLICommon/CLIError.swift index 11399d42c..3b300913e 100644 --- a/Sources/KeyPathCLICommon/CLIError.swift +++ b/Sources/KeyPathCLICommon/CLIError.swift @@ -56,7 +56,13 @@ public extension CLIError { } static func invalidKey(_ key: String, label: String) -> CLIError { - CLIError(code: .validation, message: "Invalid \(label) key: '\(key)'", hint: "Run 'keypath help-topics schemas rule' for valid key names (e.g., caps, lalt, esc, lctl, spc, ret)", details: nil, docsUrl: CLIDocsURL.faq) + CLIError( + code: .validation, + message: "Invalid \(label) key: '\(key)'", + hint: "Run 'keypath help-topics schemas rule' for valid key names (e.g., caps, lalt, esc, lctl, spc, ret)", + details: nil, + docsUrl: CLIDocsURL.faq + ) } static func ambiguous(_ message: String, matches: [String]) -> CLIError { diff --git a/Sources/KeyPathCLICommon/Output.swift b/Sources/KeyPathCLICommon/Output.swift index b79b23900..8489e799b 100644 --- a/Sources/KeyPathCLICommon/Output.swift +++ b/Sources/KeyPathCLICommon/Output.swift @@ -20,7 +20,11 @@ public struct OutputContext: Sendable { public enum CLIOutput { public static func write(_ value: some Encodable, context: OutputContext, humanRender: () -> String) { - context.shouldOutputJSON ? writeJSON(value) : print(humanRender()) + if context.shouldOutputJSON { + writeJSON(value) + } else { + print(humanRender()) + } } private struct APIEnvelope: Encodable { let apiVersion: Int = 1; let data: T } From ba6723ed2fcc8ccba1a39b0a95fd4b66c909c1fb Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 13 Sep 2026 07:14:17 -0700 Subject: [PATCH 09/15] Fix CLI command sendability --- .../Collection/CollectionDisableCommand.swift | 2 +- .../Collection/CollectionEnableCommand.swift | 2 +- .../Commands/Pack/PackConfigureCommand.swift | 2 +- .../Commands/Pack/PackInstallCommand.swift | 2 +- .../Commands/Pack/PackUninstallCommand.swift | 2 +- .../Commands/Rule/RuleAddCommand.swift | 2 +- .../Commands/Rule/RuleDisableCommand.swift | 2 +- .../Commands/Rule/RuleEnableCommand.swift | 2 +- .../Commands/Rule/RuleEnsureCommand.swift | 2 +- .../Commands/Rule/RuleRemoveCommand.swift | 2 +- Sources/KeyPathCLICommon/GlobalOptions.swift | 2 +- .../CLIActionDescription.swift | 56 +++++++++++++++++-- 12 files changed, 61 insertions(+), 17 deletions(-) diff --git a/Sources/KeyPathCLI/Commands/Collection/CollectionDisableCommand.swift b/Sources/KeyPathCLI/Commands/Collection/CollectionDisableCommand.swift index 6ed372bcf..570aa4baf 100644 --- a/Sources/KeyPathCLI/Commands/Collection/CollectionDisableCommand.swift +++ b/Sources/KeyPathCLI/Commands/Collection/CollectionDisableCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct CollectionDisable: AsyncParsableCommand { +struct CollectionDisable: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "disable", abstract: "Disable a rule collection" diff --git a/Sources/KeyPathCLI/Commands/Collection/CollectionEnableCommand.swift b/Sources/KeyPathCLI/Commands/Collection/CollectionEnableCommand.swift index 2b3bccd33..8b7ea2bcc 100644 --- a/Sources/KeyPathCLI/Commands/Collection/CollectionEnableCommand.swift +++ b/Sources/KeyPathCLI/Commands/Collection/CollectionEnableCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct CollectionEnable: AsyncParsableCommand { +struct CollectionEnable: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "enable", abstract: "Enable a rule collection" diff --git a/Sources/KeyPathCLI/Commands/Pack/PackConfigureCommand.swift b/Sources/KeyPathCLI/Commands/Pack/PackConfigureCommand.swift index 8a9086949..ace5e9b30 100644 --- a/Sources/KeyPathCLI/Commands/Pack/PackConfigureCommand.swift +++ b/Sources/KeyPathCLI/Commands/Pack/PackConfigureCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct PackConfigure: AsyncParsableCommand { +struct PackConfigure: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "configure", abstract: "Update quick settings on an installed pack" diff --git a/Sources/KeyPathCLI/Commands/Pack/PackInstallCommand.swift b/Sources/KeyPathCLI/Commands/Pack/PackInstallCommand.swift index f9109ceff..e3f2b66ea 100644 --- a/Sources/KeyPathCLI/Commands/Pack/PackInstallCommand.swift +++ b/Sources/KeyPathCLI/Commands/Pack/PackInstallCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct PackInstall: AsyncParsableCommand { +struct PackInstall: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "install", abstract: "Install a pack" diff --git a/Sources/KeyPathCLI/Commands/Pack/PackUninstallCommand.swift b/Sources/KeyPathCLI/Commands/Pack/PackUninstallCommand.swift index 18efd0606..9f0fcfa80 100644 --- a/Sources/KeyPathCLI/Commands/Pack/PackUninstallCommand.swift +++ b/Sources/KeyPathCLI/Commands/Pack/PackUninstallCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct PackUninstall: AsyncParsableCommand { +struct PackUninstall: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "uninstall", abstract: "Uninstall a pack" diff --git a/Sources/KeyPathCLI/Commands/Rule/RuleAddCommand.swift b/Sources/KeyPathCLI/Commands/Rule/RuleAddCommand.swift index d7b48a9c0..f425d8c0c 100644 --- a/Sources/KeyPathCLI/Commands/Rule/RuleAddCommand.swift +++ b/Sources/KeyPathCLI/Commands/Rule/RuleAddCommand.swift @@ -3,7 +3,7 @@ import Foundation import KeyPathAppKit import KeyPathRulesCore -struct RuleAdd: AsyncParsableCommand { +struct RuleAdd: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "add", abstract: "Create or modify a key remapping" diff --git a/Sources/KeyPathCLI/Commands/Rule/RuleDisableCommand.swift b/Sources/KeyPathCLI/Commands/Rule/RuleDisableCommand.swift index c92ed88c3..706f257e2 100644 --- a/Sources/KeyPathCLI/Commands/Rule/RuleDisableCommand.swift +++ b/Sources/KeyPathCLI/Commands/Rule/RuleDisableCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct RuleDisable: AsyncParsableCommand { +struct RuleDisable: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "disable", abstract: "Disable a custom rule" diff --git a/Sources/KeyPathCLI/Commands/Rule/RuleEnableCommand.swift b/Sources/KeyPathCLI/Commands/Rule/RuleEnableCommand.swift index 80e034a57..6c276f0b1 100644 --- a/Sources/KeyPathCLI/Commands/Rule/RuleEnableCommand.swift +++ b/Sources/KeyPathCLI/Commands/Rule/RuleEnableCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct RuleEnable: AsyncParsableCommand { +struct RuleEnable: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "enable", abstract: "Enable a custom rule" diff --git a/Sources/KeyPathCLI/Commands/Rule/RuleEnsureCommand.swift b/Sources/KeyPathCLI/Commands/Rule/RuleEnsureCommand.swift index 60c508e82..408d86414 100644 --- a/Sources/KeyPathCLI/Commands/Rule/RuleEnsureCommand.swift +++ b/Sources/KeyPathCLI/Commands/Rule/RuleEnsureCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct RuleEnsure: AsyncParsableCommand { +struct RuleEnsure: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "ensure", abstract: "Ensure a rule exists with the given mapping (idempotent)" diff --git a/Sources/KeyPathCLI/Commands/Rule/RuleRemoveCommand.swift b/Sources/KeyPathCLI/Commands/Rule/RuleRemoveCommand.swift index 214b2f3c6..a0536b963 100644 --- a/Sources/KeyPathCLI/Commands/Rule/RuleRemoveCommand.swift +++ b/Sources/KeyPathCLI/Commands/Rule/RuleRemoveCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -struct RuleRemove: AsyncParsableCommand { +struct RuleRemove: AsyncParsableCommand, Sendable { static let configuration = CommandConfiguration( commandName: "remove", abstract: "Remove a key remapping" diff --git a/Sources/KeyPathCLICommon/GlobalOptions.swift b/Sources/KeyPathCLICommon/GlobalOptions.swift index 358c1ebd0..57fe4db69 100644 --- a/Sources/KeyPathCLICommon/GlobalOptions.swift +++ b/Sources/KeyPathCLICommon/GlobalOptions.swift @@ -1,6 +1,6 @@ import ArgumentParser -public struct GlobalOptions: ParsableArguments { +public struct GlobalOptions: ParsableArguments, Sendable { @Flag(help: "Force JSON output") public var json: Bool = false diff --git a/Sources/KeyPathRulesCore/CLIActionDescription.swift b/Sources/KeyPathRulesCore/CLIActionDescription.swift index d2fe8bad2..431b0f2ea 100644 --- a/Sources/KeyPathRulesCore/CLIActionDescription.swift +++ b/Sources/KeyPathRulesCore/CLIActionDescription.swift @@ -9,29 +9,73 @@ public struct CLISchemaEntry: Codable, Sendable { public extension KeyAction { var cliSchemaName: String { switch self { - case .keystroke: "key"; case .hyper: "hyper"; case .meh: "meh"; case .launchApp: "launch-app"; case .openURL: "open-url"; case .openFolder: "open-folder"; case .runScript: "run-script"; case .systemAction: "system-action"; case .notify: "notify"; case .windowAction: "window"; case .fakeKey: "fake-key"; case .activateLayer: "activate-layer"; case .rawKanata: "raw-kanata" + case .keystroke: "key" + case .hyper: "hyper" + case .meh: "meh" + case .launchApp: "launch-app" + case .openURL: "open-url" + case .openFolder: "open-folder" + case .runScript: "run-script" + case .systemAction: "system-action" + case .notify: "notify" + case .windowAction: "window" + case .fakeKey: "fake-key" + case .activateLayer: "activate-layer" + case .rawKanata: "raw-kanata" } } var cliSchemaDescription: String { switch self { - case .keystroke: "Emit a different key (simple remap)"; case .hyper: "Hyper modifier combo (Cmd+Ctrl+Alt+Shift)"; case .meh: "Meh modifier combo (Ctrl+Alt+Shift)"; case .launchApp: "Launch an application by name or bundle ID"; case .openURL: "Open a URL in the default browser"; case .openFolder: "Open a folder in Finder"; case .runScript: "Run a script file"; case .systemAction: "Trigger a system action (volume, brightness, etc.)"; case .notify: "Show a user notification"; case .windowAction: "Window management action (left half, maximize, etc.)"; case .fakeKey: "Trigger a Kanata virtual/fake key"; case .activateLayer: "Switch to or activate a layer"; case .rawKanata: "Raw kanata expression (power user escape hatch)" + case .keystroke: "Emit a different key (simple remap)" + case .hyper: "Hyper modifier combo (Cmd+Ctrl+Alt+Shift)" + case .meh: "Meh modifier combo (Ctrl+Alt+Shift)" + case .launchApp: "Launch an application by name or bundle ID" + case .openURL: "Open a URL in the default browser" + case .openFolder: "Open a folder in Finder" + case .runScript: "Run a script file" + case .systemAction: "Trigger a system action (volume, brightness, etc.)" + case .notify: "Show a user notification" + case .windowAction: "Window management action (left half, maximize, etc.)" + case .fakeKey: "Trigger a Kanata virtual/fake key" + case .activateLayer: "Switch to or activate a layer" + case .rawKanata: "Raw kanata expression (power user escape hatch)" } } static var allSchemaDescriptions: [CLISchemaEntry] { - let representative: [KeyAction] = [.keystroke(key: ""), .hyper, .meh, .launchApp(name: "", bundleId: nil), .openURL(""), .openFolder(path: "", name: nil), .runScript(path: "", name: nil), .systemAction(id: ""), .notify(title: "", body: nil, sound: false), .windowAction(position: ""), .fakeKey(name: "", action: .tap), .activateLayer(name: ""), .rawKanata("")] + let representative: [KeyAction] = [ + .keystroke(key: ""), .hyper, .meh, .launchApp(name: "", bundleId: nil), + .openURL(""), .openFolder(path: "", name: nil), .runScript(path: "", name: nil), + .systemAction(id: ""), .notify(title: "", body: nil, sound: false), + .windowAction(position: ""), .fakeKey(name: "", action: .tap), + .activateLayer(name: ""), .rawKanata("") + ] return representative.map { CLISchemaEntry(name: $0.cliSchemaName, description: $0.cliSchemaDescription) } } } public extension MappingBehavior { var cliSchemaName: String { - switch self { case .dualRole: "tap-hold"; case .tapOrTapDance: "tap-dance"; case .macro: "macro"; case .chord: "chord" } + switch self { + case .dualRole: "tap-hold" + case .tapOrTapDance: "tap-dance" + case .macro: "macro" + case .chord: "chord" + } } var cliSchemaDescription: String { - switch self { case .dualRole: "Dual-role key: tap produces one action, hold produces another"; case .tapOrTapDance: "Tap behavior with optional multi-tap (tap-dance)"; case .macro: "Macro: one trigger key produces multiple outputs or text"; case .chord: "Chord: multiple keys pressed together produce a single output" } + switch self { + case .dualRole: "Dual-role key: tap produces one action, hold produces another" + case .tapOrTapDance: "Tap behavior with optional multi-tap (tap-dance)" + case .macro: "Macro: one trigger key produces multiple outputs or text" + case .chord: "Chord: multiple keys pressed together produce a single output" + } } static var allSchemaDescriptions: [CLISchemaEntry] { - let representative: [MappingBehavior] = [.dualRole(DualRoleBehavior(tapAction: .empty, holdAction: .empty)), .tapOrTapDance(.tap), .macro(MacroBehavior()), .chord(ChordBehavior(keys: [], output: .empty))] + let representative: [MappingBehavior] = [ + .dualRole(DualRoleBehavior(tapAction: .empty, holdAction: .empty)), + .tapOrTapDance(.tap), .macro(MacroBehavior()), + .chord(ChordBehavior(keys: [], output: .empty)) + ] return representative.map { CLISchemaEntry(name: $0.cliSchemaName, description: $0.cliSchemaDescription) } } } From 15225947a0bce90db7809b1581284f0cce1d3231 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 13 Sep 2026 07:59:58 -0700 Subject: [PATCH 10/15] Honor pinned SwiftFormat in CI --- .github/workflows/ci.yml | 7 +++-- .../Config/ConfigurationOperationGate.swift | 4 ++- .../Config/ConfigurationService.swift | 27 +++++++++-------- .../Config/RecoverableRuleWrite.swift | 2 +- .../RuntimeCoordinator+RuleCollections.swift | 2 +- .../Managers/RuntimeCoordinator.swift | 2 +- .../Configuration/PreferencesService.swift | 1 + .../Devices/DeviceSelectionStore.swift | 4 ++- .../RuleCollectionsManager+PublicAPI.swift | 3 +- .../RuleCollectionsManager.swift | 9 ++++-- .../UI/Overlay/DeviceSelectionView.swift | 1 - .../UI/Rules/CatalogUpdateReviewSheet.swift | 30 +++++++++---------- Sources/KeyPathCLI/KeyPathTool.swift | 2 +- Sources/KeyPathCLICommon/ANSIColor.swift | 24 +++++++++++---- Sources/KeyPathCLICommon/CLIError.swift | 4 ++- Sources/KeyPathCLICommon/Output.swift | 12 ++++++-- .../CLIActionDescription.swift | 4 +++ .../HelpOutputContractTests.swift | 4 +-- .../RuleCollectionStoreTests.swift | 2 +- .../RuleCollectionsManagerTests.swift | 2 +- 20 files changed, 93 insertions(+), 53 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e7da5c20..14c680c24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,6 +217,9 @@ jobs: - name: Add Homebrew to PATH run: echo "/opt/homebrew/bin" >> $GITHUB_PATH + - name: Install pinned SwiftFormat + run: mise install swiftformat + - name: Get changed Swift files id: changed run: | @@ -244,7 +247,7 @@ jobs: if: steps.changed.outputs.swift_files != '' run: | PINNED=$(sed -nE 's/^[[:space:]]*swiftformat[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' mise.toml) - ACTUAL=$(swiftformat --version | tr -d '[:space:]') + ACTUAL=$(mise exec swiftformat -- swiftformat --version | tr -d '[:space:]') echo "SwiftFormat โ€” pinned (mise.toml): '$PINNED', installed: '$ACTUAL'" if [ -z "$PINNED" ]; then echo "::error::Could not read the swiftformat pin from mise.toml" @@ -260,7 +263,7 @@ jobs: if: steps.changed.outputs.swift_files != '' run: | echo "Checking formatting of changed Swift files (must match the pinned fixed-point)..." - echo "${{ steps.changed.outputs.swift_files }}" | xargs swiftformat --lint || { + echo "${{ steps.changed.outputs.swift_files }}" | xargs mise exec swiftformat -- swiftformat --lint || { echo "::error::SwiftFormat found formatting issues. Run 'swiftformat Sources Tests' (at the pinned version) and commit." exit 1 } diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift index 158d6fda2..d1a9dc561 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationOperationGate.swift @@ -12,7 +12,9 @@ actor ConfigurationOperationGate { /// Identifies one admitted root operation without exposing the gate owner. /// Consumers use this only to avoid repeating preparation work for trusted /// nested calls that carry the same permit. - var operationID: UUID { operation } + var operationID: UUID { + operation + } } enum Failure: LocalizedError { diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index 06db05f13..34744368c 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -491,7 +491,7 @@ public final class ConfigurationService: FileConfigurationProviding { stateLock.withLock { currentConfiguration = nil } if let update = write.packUpdate { await update.tracker.restorePublishedUpdate(update) } if write.deviceSelections != nil { - await deviceSelectionStore.publishSelectionsToCache(try await deviceSelectionStore.loadForMutation()) + try await deviceSelectionStore.publishSelectionsToCache(deviceSelectionStore.loadForMutation()) } } } @@ -529,8 +529,8 @@ public final class ConfigurationService: FileConfigurationProviding { } let packFiles = packTargets let rawFiles = ["config": URL(fileURLWithPath: configurationPath)] - let deviceFiles = files.merging([ - "deviceSelection": await deviceSelectionStore.persistenceURL + let deviceFiles = await files.merging([ + "deviceSelection": deviceSelectionStore.persistenceURL ], uniquingKeysWith: { _, deviceSelectionURL in deviceSelectionURL }) let sendablePreferences = preferenceDefaults.map(RecoverableRuleWrite.PreferenceDefaults.init) let recovered = try await performRuleFileOperation { @@ -549,7 +549,7 @@ public final class ConfigurationService: FileConfigurationProviding { } if recovered.rules { ruleRecoveryRevision &+= 1 } if recovered.device { - await deviceSelectionStore.publishSelectionsToCache(try await deviceSelectionStore.loadForMutation()) + try await deviceSelectionStore.publishSelectionsToCache(deviceSelectionStore.loadForMutation()) needsRecoveredDeviceRuntimeRestart = true } return recovered.rules || recovered.device @@ -647,10 +647,12 @@ public final class ConfigurationService: FileConfigurationProviding { ? matchesGlobalManagedContent(existing, expected: content) : AppConfigGenerator.matchesManagedContent(existing, expected: content) guard matches else { - throw AppConfigError - .validationFailed( - errors: ["Your configuration was preserved. The visual editor cannot safely reproduce \(name). App-specific editing requires an explicit conversion with a backup first."] - ) + throw AppConfigError + .validationFailed( + errors: [ + "Your configuration was preserved. The visual editor cannot safely reproduce \(name). App-specific editing requires an explicit conversion with a backup first." + ] + ) } } } @@ -837,7 +839,8 @@ public final class ConfigurationService: FileConfigurationProviding { deviceGenerationInput: inputs.device ) guard matchesGlobalManagedContent(existing, expected: expected.content) else { - throw AppConfigError.validationFailed(errors: ["Your configuration was preserved. The visual editor cannot safely reproduce keypath.kbd. Convert it explicitly with a backup before editing global rules."]) + throw AppConfigError + .validationFailed(errors: ["Your configuration was preserved. The visual editor cannot safely reproduce keypath.kbd. Convert it explicitly with a backup before editing global rules."]) } } @@ -923,16 +926,16 @@ public final class ConfigurationService: FileConfigurationProviding { fileURL: URL(fileURLWithPath: configDirectory).appendingPathComponent("AppKeymaps.json") ) let appKeymaps = try await appKeymapStore.loadForMutation() - return GlobalRuleGenerationInputs( + return await GlobalRuleGenerationInputs( shortcut: ShortcutListGenerationInput( triggerMode: trigger, holdDelayPreset: preset, customHoldDelayMs: custom ), - device: await deviceSelectionStore.generationInput(for: selections), + device: deviceSelectionStore.generationInput(for: selections), appSpecificKeys: Set( appKeymaps - .filter { $0.mapping.isEnabled } + .filter(\.mapping.isEnabled) .flatMap { $0.overrides.map { $0.inputKey.lowercased() } } ) ) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift b/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift index c00547d3b..2ea140f49 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift @@ -346,7 +346,7 @@ enum RecoverableRuleWrite { let journalRoles = Set(journal.entries.map(\.role)) let usesLegacyRoles = journal.version == 1 && journalRoles == scope.legacyRoles guard journal.version == 1 || journal.version == 2, - (usesLegacyRoles || (journal.entries.count == files.count && journalRoles == Set(files.keys))), + usesLegacyRoles || (journal.entries.count == files.count && journalRoles == Set(files.keys)), journal.entries.allSatisfy({ files[$0.role]?.standardizedFileURL.path == $0.path }), Set(files.values.map(\.standardizedFileURL)).count == files.count else { throw Failure.invalidJournal } diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift index 2ad1755a4..c6da8deb9 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+RuleCollections.swift @@ -120,7 +120,7 @@ extension RuntimeCoordinator { func applyDeviceSelections(_ selections: [DeviceSelection]) async -> Bool { await ruleCollectionsManager.applyDeviceSelections(selections) { [weak self] in guard let self else { return false } - return await self.restartKanata(reason: "Device selection changed") + return await restartKanata(reason: "Device selection changed") } } diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift index a316ff4f1..26b5da851 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift @@ -479,7 +479,7 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { mutationPermit: nil ) { [weak self] in guard let self else { return false } - return await self.restartKanata(reason: "Recovering device selection") + return await restartKanata(reason: "Recovering device selection") } } catch { self.lastError = error.localizedDescription diff --git a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift index 051ed21f5..0713694ad 100644 --- a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift +++ b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift @@ -12,6 +12,7 @@ struct ShortcutListGenerationInput: Sendable, Equatable { holdDelayPreset.milliseconds ?? customHoldDelayMs } } + import Observation /// Key label display style for modifier and action keys on the keyboard visualization. diff --git a/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift b/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift index a54de4051..cb720ab22 100644 --- a/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift +++ b/Sources/KeyPathAppKit/Services/Devices/DeviceSelectionStore.swift @@ -112,7 +112,9 @@ actor DeviceSelectionStore { /// The transaction owner journals this exact file with the generated /// configuration. Keeping the URL here prevents a second path convention. - var persistenceURL: URL { fileURL } + var persistenceURL: URL { + fileURL + } func loadSelections() -> [DeviceSelection] { AppLogger.shared.log("๐Ÿ“‚ [DeviceSelectionStore] loadSelections from: \(fileURL.path)") diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift index 46c721e11..4337834a4 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+PublicAPI.swift @@ -88,7 +88,8 @@ extension RuleCollectionsManager { } guard previews.allSatisfy(\.canApply) else { return CatalogUpdateApplicationResult( - saveResult: .failure(KeyPathError.configuration(.validationFailed(errors: ["Catalog updates with pack ownership or mapping conflicts must be kept or resolved outside this update flow."]))), + saveResult: .failure(KeyPathError + .configuration(.validationFailed(errors: ["Catalog updates with pack ownership or mapping conflicts must be kept or resolved outside this update flow."]))), backupPath: nil, appliedCollectionIDs: [] ) diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift index 78aedae49..fdc6cfc75 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager.swift @@ -52,8 +52,13 @@ struct CatalogUpdatePreview: Identifiable, Equatable { let conflictDescription: String? let isPackManaged: Bool - var id: UUID { existing.id } - var canApply: Bool { !isPackManaged && conflictDescription == nil } + var id: UUID { + existing.id + } + + var canApply: Bool { + !isPackManaged && conflictDescription == nil + } } /// The durable outcome of applying one or more approved catalog updates. diff --git a/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift b/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift index 4510cd9c9..b15f58607 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/DeviceSelectionView.swift @@ -220,7 +220,6 @@ struct DeviceSelectionView: View { ) } needsRestart = true - } private func applyChanges() { diff --git a/Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift b/Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift index bbc5dcf71..36c0b9dd6 100644 --- a/Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift +++ b/Sources/KeyPathAppKit/UI/Rules/CatalogUpdateReviewSheet.swift @@ -60,7 +60,6 @@ struct CatalogUpdateReviewSheet: View { .frame(width: 580) } - @ViewBuilder private func updateRow(_ preview: CatalogUpdatePreview) -> some View { VStack(alignment: .leading, spacing: 6) { Toggle(isOn: selectionBinding(for: preview)) { @@ -122,13 +121,12 @@ struct CatalogUpdateReviewSheet: View { feedback = CatalogUpdateFeedback.failureMessage(for: result.saveResult) return } - let disposition: String - switch result.saveResult.reloadResult?.disposition { - case .applied: disposition = "Applied and running." - case .pending: disposition = "Saved; it will apply when the engine is available." - case .rejected: disposition = "The engine rejected the update; your prior rules were restored." - case .failed: disposition = "The engine could not apply the update; your prior rules were restored." - case nil: disposition = "Saved." + let disposition = switch result.saveResult.reloadResult?.disposition { + case .applied: "Applied and running." + case .pending: "Saved; it will apply when the engine is available." + case .rejected: "The engine rejected the update; your prior rules were restored." + case .failed: "The engine could not apply the update; your prior rules were restored." + case nil: "Saved." } let backup = result.backupPath.map { " Backup: \($0)" } ?? "" feedback = "\(disposition)\(backup)" @@ -142,21 +140,21 @@ enum CatalogUpdateFeedback { static func failureMessage(for result: SaveResult) -> String { switch result.recoveryResult { case let .restoredPreviousRuleState(reloadResult): - return "The catalog update was not accepted. Your previous rules were restored.\(runtimeRecoveryMessage(reloadResult))" + "The catalog update was not accepted. Your previous rules were restored.\(runtimeRecoveryMessage(reloadResult))" case .ruleStateRecoveryFailed: - return "The catalog update was not accepted, and restoring your previous rules also failed. Please review your configuration and backup." + "The catalog update was not accepted, and restoring your previous rules also failed. Please review your configuration and backup." default: - return "Could not apply the catalog update: \(result.error?.localizedDescription ?? "Unknown error")" + "Could not apply the catalog update: \(result.error?.localizedDescription ?? "Unknown error")" } } private static func runtimeRecoveryMessage(_ result: ReloadResult?) -> String { switch result?.disposition { - case .applied: return " The restored rules are running." - case .pending: return " The restored rules are saved and will apply when the engine is available." - case .rejected: return " The engine rejected the restored rules." - case .failed: return " The restored rules could not be applied to the engine." - case nil: return "" + case .applied: " The restored rules are running." + case .pending: " The restored rules are saved and will apply when the engine is available." + case .rejected: " The engine rejected the restored rules." + case .failed: " The restored rules could not be applied to the engine." + case nil: "" } } } diff --git a/Sources/KeyPathCLI/KeyPathTool.swift b/Sources/KeyPathCLI/KeyPathTool.swift index a5c0c2f86..606fb5c29 100644 --- a/Sources/KeyPathCLI/KeyPathTool.swift +++ b/Sources/KeyPathCLI/KeyPathTool.swift @@ -1,7 +1,7 @@ import ArgumentParser import Foundation -import KeyPathCLISupport @_exported import KeyPathCLIHelp +import KeyPathCLISupport public struct KeyPathCLI: AsyncParsableCommand { public init() {} diff --git a/Sources/KeyPathCLICommon/ANSIColor.swift b/Sources/KeyPathCLICommon/ANSIColor.swift index 3df6d8e75..315e71685 100644 --- a/Sources/KeyPathCLICommon/ANSIColor.swift +++ b/Sources/KeyPathCLICommon/ANSIColor.swift @@ -1,9 +1,23 @@ import Foundation public enum ANSIColor { - public static func green(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[32m\(s)\u{001b}[0m" } - public static func red(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[31m\(s)\u{001b}[0m" } - public static func yellow(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[33m\(s)\u{001b}[0m" } - public static func dim(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[2m\(s)\u{001b}[0m" } - public static func bold(_ s: String, noColor: Bool) -> String { noColor ? s : "\u{001b}[1m\(s)\u{001b}[0m" } + public static func green(_ s: String, noColor: Bool) -> String { + noColor ? s : "\u{001b}[32m\(s)\u{001b}[0m" + } + + public static func red(_ s: String, noColor: Bool) -> String { + noColor ? s : "\u{001b}[31m\(s)\u{001b}[0m" + } + + public static func yellow(_ s: String, noColor: Bool) -> String { + noColor ? s : "\u{001b}[33m\(s)\u{001b}[0m" + } + + public static func dim(_ s: String, noColor: Bool) -> String { + noColor ? s : "\u{001b}[2m\(s)\u{001b}[0m" + } + + public static func bold(_ s: String, noColor: Bool) -> String { + noColor ? s : "\u{001b}[1m\(s)\u{001b}[0m" + } } diff --git a/Sources/KeyPathCLICommon/CLIError.swift b/Sources/KeyPathCLICommon/CLIError.swift index 3b300913e..984d27ec2 100644 --- a/Sources/KeyPathCLICommon/CLIError.swift +++ b/Sources/KeyPathCLICommon/CLIError.swift @@ -21,7 +21,9 @@ public enum CLIExitCode: Int32, Codable, Sendable, CaseIterable { case success = 0, usage = 2, validation = 3, conflict = 4, notFound = 5 case serviceUnreachable = 6, permissionBlocked = 7, kanataInvalid = 8 - public var exitCode: ExitCode { ExitCode(rawValue: rawValue) } + public var exitCode: ExitCode { + ExitCode(rawValue: rawValue) + } } public enum CLIDocsURL { diff --git a/Sources/KeyPathCLICommon/Output.swift b/Sources/KeyPathCLICommon/Output.swift index 8489e799b..47a75e73d 100644 --- a/Sources/KeyPathCLICommon/Output.swift +++ b/Sources/KeyPathCLICommon/Output.swift @@ -7,7 +7,9 @@ public struct OutputContext: Sendable { public let noColor: Bool public let quiet: Bool - public var shouldOutputJSON: Bool { forceJSON || (!forceHuman && !isInteractive) } + public var shouldOutputJSON: Bool { + forceJSON || (!forceHuman && !isInteractive) + } public init(isInteractive: Bool, forceJSON: Bool, forceHuman: Bool, noColor: Bool, quiet: Bool = false) { self.isInteractive = isInteractive; self.forceJSON = forceJSON; self.forceHuman = forceHuman; self.noColor = noColor; self.quiet = quiet @@ -47,12 +49,16 @@ public enum CLIOutput { let nc = context.noColor writeErrorLine(ANSIColor.red("Error: \(error.message)", noColor: nc)) if let hint = error.hint { writeErrorLine(ANSIColor.dim("Hint: \(hint)", noColor: nc)) } - if let details = error.details { for detail in details { writeErrorLine(ANSIColor.dim(" \(detail)", noColor: nc)) } } + if let details = error.details { for detail in details { + writeErrorLine(ANSIColor.dim(" \(detail)", noColor: nc)) + } } if let docsUrl = error.docsUrl { writeErrorLine(ANSIColor.dim("Docs: \(docsUrl)", noColor: nc)) } } } - public static func writeRaw(_ text: String) { Swift.print(text) } + public static func writeRaw(_ text: String) { + Swift.print(text) + } public static func progress(_ message: String, context: OutputContext) { guard context.isInteractive, !context.quiet else { return } diff --git a/Sources/KeyPathRulesCore/CLIActionDescription.swift b/Sources/KeyPathRulesCore/CLIActionDescription.swift index 431b0f2ea..cf2857058 100644 --- a/Sources/KeyPathRulesCore/CLIActionDescription.swift +++ b/Sources/KeyPathRulesCore/CLIActionDescription.swift @@ -24,6 +24,7 @@ public extension KeyAction { case .rawKanata: "raw-kanata" } } + var cliSchemaDescription: String { switch self { case .keystroke: "Emit a different key (simple remap)" @@ -41,6 +42,7 @@ public extension KeyAction { case .rawKanata: "Raw kanata expression (power user escape hatch)" } } + static var allSchemaDescriptions: [CLISchemaEntry] { let representative: [KeyAction] = [ .keystroke(key: ""), .hyper, .meh, .launchApp(name: "", bundleId: nil), @@ -62,6 +64,7 @@ public extension MappingBehavior { case .chord: "chord" } } + var cliSchemaDescription: String { switch self { case .dualRole: "Dual-role key: tap produces one action, hold produces another" @@ -70,6 +73,7 @@ public extension MappingBehavior { case .chord: "Chord: multiple keys pressed together produce a single output" } } + static var allSchemaDescriptions: [CLISchemaEntry] { let representative: [MappingBehavior] = [ .dualRole(DualRoleBehavior(tapAction: .empty, holdAction: .empty)), diff --git a/Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift b/Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift index 211ba9a49..0909bebe8 100644 --- a/Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift +++ b/Tests/KeyPathCLIHelpTests/HelpOutputContractTests.swift @@ -22,8 +22,8 @@ final class HelpOutputContractTests: XCTestCase { ) } - private func assertUnknownHelpOutput( - command: Command.Type, + private func assertUnknownHelpOutput( + command: (some AsyncParsableCommand).Type, noun: String, entity: String, listCommand: String diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift index 8aa8d2169..ce78e9f20 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift @@ -324,7 +324,7 @@ final class RuleCollectionStoreTests: XCTestCase { let backupPath = try await store.backupForCatalogUpdate() - let backupURL = URL(fileURLWithPath: try XCTUnwrap(backupPath)) + let backupURL = try URL(fileURLWithPath: XCTUnwrap(backupPath)) XCTAssertEqual(try Data(contentsOf: backupURL), try Data(contentsOf: fileURL)) } } diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift index 3a60ec3ec..114d11620 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift @@ -89,7 +89,7 @@ final class RuleCollectionsManagerTests: XCTestCase { XCTAssertTrue(result.saveResult.success) XCTAssertEqual(result.appliedCollectionIDs, [RuleCollectionIdentifier.vimNavigation]) - XCTAssertTrue(FileManager.default.fileExists(atPath: try XCTUnwrap(result.backupPath))) + XCTAssertTrue(try FileManager.default.fileExists(atPath: XCTUnwrap(result.backupPath))) XCTAssertNotEqual( manager.ruleCollections.first { $0.id == RuleCollectionIdentifier.vimNavigation }?.summary, "My local navigation" From 2d35729e3559a9ba4511c96ad52787932225f072 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 13 Sep 2026 08:12:32 -0700 Subject: [PATCH 11/15] Preserve bootstrap state without persisted sources --- .../RuleCollections/RuleCollectionStore.swift | 4 ++-- .../RuleCollectionsManager+Mutation.swift | 21 +++++++++++++------ ...nsManagerPrerequisiteResolutionTests.swift | 6 +++++- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift index f0ad34609..d79ff5748 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionStore.swift @@ -71,9 +71,9 @@ actor RuleCollectionStore { decoder = JSONDecoder() } - /// Test-only: the resolved backing file, for asserting sandbox isolation. + /// Test-only compatibility alias for sandbox assertions. var debugFileURL: URL { - fileURL + persistenceURL } func loadCollections() -> [RuleCollection] { diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift index 81fd00110..71e6b49b0 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionsManager+Mutation.swift @@ -142,13 +142,22 @@ extension RuleCollectionsManager { || recovered || observedRuleRecoveryRevision != configurationService.ruleRecoveryRevision if needsRefresh { - let collections = await ruleCollectionStore.loadCollectionsDetailed() - guard !collections.wasFullReset, collections.failedCollectionNames.isEmpty else { - throw KeyPathError.configuration(.loadFailed(reason: "Rule collections could not be read completely. No edit was made.")) - } + let collectionSourceURL = await ruleCollectionStore.persistenceURL + let customRuleSourceURL = await customRulesStore.persistenceURL + let hasPersistedCollections = FileManager.default.fileExists(atPath: collectionSourceURL.path) + let hasPersistedCustomRules = FileManager.default.fileExists(atPath: customRuleSourceURL.path) + let collections = try await ruleCollectionStore.loadForMutation() let rules = try await customRulesStore.loadForMutation() - ruleCollections = RuleCollectionDeduplicator.dedupe(collections.collections) - customRules = rules + // A missing source has no newer revision to recover. Keep the + // manager's bootstrap/test state until its first successful save; + // an existing (including intentionally empty) file remains the + // authoritative source and malformed data still fails closed. + if hasPersistedCollections { + ruleCollections = RuleCollectionDeduplicator.dedupe(collections) + } + if hasPersistedCustomRules { + customRules = rules + } needsRecoveredRuleStateRefresh = false observedRuleRecoveryRevision = configurationService.ruleRecoveryRevision lastRuleStateRefreshOperationID = mutationPermit.operationID diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerPrerequisiteResolutionTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerPrerequisiteResolutionTests.swift index 3acfb8bbf..784b4d1aa 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerPrerequisiteResolutionTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerPrerequisiteResolutionTests.swift @@ -292,7 +292,11 @@ final class RuleCollectionsManagerPrerequisiteResolutionTests: XCTestCase { ) XCTAssertFalse(applied) - XCTAssertEqual(regenerationCount, 1) + XCTAssertEqual( + regenerationCount, + 0, + "Mutation admission must reject an unreadable source before generating a candidate configuration" + ) XCTAssertEqual( reloadCount, 0, From 0c1a8a5bf814645b1cbc9391b9da3b69efc1fd43 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 13 Sep 2026 08:26:25 -0700 Subject: [PATCH 12/15] Update durable recovery test fixtures --- .../Config/RecoverableRuleWriteTests.swift | 2 +- .../CustomRuleMutationRecoveryTests.swift | 7 ++++++- .../DurableConfigPreferenceRecoveryTests.swift | 15 +++++++++------ Tests/KeyPathTests/GenericPackConfigTests.swift | 5 ++++- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Tests/KeyPathTests/Config/RecoverableRuleWriteTests.swift b/Tests/KeyPathTests/Config/RecoverableRuleWriteTests.swift index b4174b465..c6fb82810 100644 --- a/Tests/KeyPathTests/Config/RecoverableRuleWriteTests.swift +++ b/Tests/KeyPathTests/Config/RecoverableRuleWriteTests.swift @@ -9,7 +9,7 @@ final class RecoverableRuleWriteTests: XCTestCase { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } - let files = Dictionary(uniqueKeysWithValues: ["config", "collections", "customRules"].map { + let files = Dictionary(uniqueKeysWithValues: ["config", "collections", "customRules", "deviceTargetingManifest"].map { ($0, directory.appendingPathComponent($0)) }) let old = files.mapValues { Data("old \($0.lastPathComponent)".utf8) } diff --git a/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift b/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift index 8cd1d9040..2d5149dc7 100644 --- a/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift +++ b/Tests/KeyPathTests/CustomRuleMutationRecoveryTests.swift @@ -18,8 +18,13 @@ final class CustomRuleMutationRecoveryTests: KeyPathTestCase { manager = RuleCollectionsManager(ruleCollectionStore: collections, customRulesStore: rules, configurationService: service) manager.ruleCollections = [] manager.customRules = [CustomRule(input: "f20", action: .keystroke(key: "f19"), createdAt: Date(timeIntervalSince1970: 42))] - try await service.saveRuleState(ruleCollections: [], customRules: manager.customRules, collectionStore: collections, customStore: rules) manager.ruleCollections = await collections.loadCollectionsDetailed().collections + try await service.saveRuleState( + ruleCollections: manager.ruleCollections, + customRules: manager.customRules, + collectionStore: collections, + customStore: rules + ) } override func tearDown() async throws { diff --git a/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift b/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift index bec764c9c..a56a1bfe9 100644 --- a/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift +++ b/Tests/KeyPathTests/DurableConfigPreferenceRecoveryTests.swift @@ -253,11 +253,12 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { let candidate = DeviceSelection(hash: "a", productKey: "Apple Keyboard", isEnabled: false, lastSeen: .now) try await deviceStore.saveSelections([baseline]) try FileManager.default.createDirectory(at: caseDirectory, withIntermediateDirectories: true) - let configURL = caseDirectory.appendingPathComponent("keypath.kbd") - let beforeConfig = Data("before-device-config".utf8) - try beforeConfig.write(to: configURL) let (manager, _) = try await makeManager(at: caseDirectory, deviceSelectionStore: deviceStore) manager.ruleCollections = try leaderCollections() + let generated = await manager.regenerateConfigFromCollections(skipReload: true) + XCTAssertTrue(generated) + let configURL = caseDirectory.appendingPathComponent("keypath.kbd") + let beforeConfig = try Data(contentsOf: configURL) var restartCount = 0 let success = await manager.applyDeviceSelections([candidate]) { @@ -305,11 +306,12 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { let candidate = DeviceSelection(hash: "a", productKey: "Apple Keyboard", isEnabled: false, lastSeen: .now) try await deviceStore.saveSelections([baseline]) try FileManager.default.createDirectory(at: caseDirectory, withIntermediateDirectories: true) - let configURL = caseDirectory.appendingPathComponent("keypath.kbd") - let beforeConfig = Data("before-crash-device-config".utf8) - try beforeConfig.write(to: configURL) let (manager, service) = try await makeManager(at: caseDirectory, deviceSelectionStore: deviceStore) manager.ruleCollections = try leaderCollections() + let generated = await manager.regenerateConfigFromCollections(skipReload: true) + XCTAssertTrue(generated) + let configURL = caseDirectory.appendingPathComponent("keypath.kbd") + let beforeConfig = try Data(contentsOf: configURL) try await service.operationGate.withOperation { @MainActor permit in _ = try await service.stageRuleState( @@ -451,6 +453,7 @@ final class DurableConfigPreferenceRecoveryTests: XCTestCase { "config": directory.appendingPathComponent("keypath.kbd"), "collections": directory.appendingPathComponent("RuleCollections.json"), "customRules": directory.appendingPathComponent("CustomRules.json"), + "deviceTargetingManifest": directory.appendingPathComponent("keypath-device-targeting.manifest"), ] for (role, url) in files { try Data("before-\(role)".utf8).write(to: url) diff --git a/Tests/KeyPathTests/GenericPackConfigTests.swift b/Tests/KeyPathTests/GenericPackConfigTests.swift index 0003fc883..3210db0f4 100644 --- a/Tests/KeyPathTests/GenericPackConfigTests.swift +++ b/Tests/KeyPathTests/GenericPackConfigTests.swift @@ -578,11 +578,12 @@ final class GenericPackConfigTests: XCTestCase { action: .keystroke(key: "f19") ) manager.customRules = [existingRule] - let originalRuleState = manager.snapshotRuleState() let didGenerateOriginalConfig = await manager.regenerateConfigFromCollections( skipReload: true ) XCTAssertTrue(didGenerateOriginalConfig) + manager.customRules = try await manager.customRulesStore.loadForMutation() + let originalRuleState = manager.snapshotRuleState() let configURL = URL(fileURLWithPath: manager.configurationService.configurationPath) let collectionStoreURL = tempDir.appendingPathComponent("RuleCollections.json") @@ -1582,6 +1583,8 @@ final class GenericPackConfigTests: XCTestCase { selectedHoldOutput: "lctl" )) } + let regenerated = await manager.regenerateConfigFromCollections(skipReload: true) + XCTAssertTrue(regenerated) // Uninstall with "Keep Current" try await PackInstaller.shared.uninstall(packID: PackRegistry.launcher.id, manager: manager) From edf1662e07d83aaa99f4036d7c29286eb5d3c5e9 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 13 Sep 2026 08:30:17 -0700 Subject: [PATCH 13/15] Serialize CI quality checks on shared runner --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14c680c24..318f3206d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,9 +198,12 @@ jobs: code-quality: runs-on: [self-hosted, macOS, keypath] timeout-minutes: 5 - # No concurrency group: this job never runs `swift test` or touches - # RuleCollectionsManager's config file, so it doesn't share the state - # that caused the race โ€” no need to queue it behind build-and-test. + # This job does not share test state, but it does share the only physical + # runner. Queue it with builds so SwiftFormat gets enough CPU to finish + # before its fixed per-rule safety timeout. + concurrency: + group: keypath-self-hosted-runner + cancel-in-progress: false steps: - name: Clean stale git credentials From 4390aec4d2c0ce06bd1a0ee1957bbbe4de486fef Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 13 Sep 2026 08:41:39 -0700 Subject: [PATCH 14/15] Align transaction tests with durable sources --- .../PackMetadataOperationTests.swift | 10 +++- .../PackRuleTransactionTests.swift | 25 ++++++++- .../PreferencesServicePersistenceTests.swift | 9 ++-- .../KeyPathTests/VallackSystemPackTests.swift | 54 ++++++++++++------- 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/Tests/KeyPathTests/PackMetadataOperationTests.swift b/Tests/KeyPathTests/PackMetadataOperationTests.swift index 5cc0cb72e..7de546353 100644 --- a/Tests/KeyPathTests/PackMetadataOperationTests.swift +++ b/Tests/KeyPathTests/PackMetadataOperationTests.swift @@ -86,8 +86,14 @@ final class PackMetadataOperationTests: KeyPathTestCase { _ = try RecoverableRuleWrite.stage(files: [ "config": fixture.directory.appendingPathComponent("keypath.kbd"), "collections": sourceURL, - "customRules": fixture.directory.appendingPathComponent("CustomRules.json") - ], contents: ["config": Data("(defsrc)\n(deflayer base)".utf8), "collections": proposed, "customRules": Data("[]".utf8)], + "customRules": fixture.directory.appendingPathComponent("CustomRules.json"), + "deviceTargetingManifest": fixture.directory.appendingPathComponent("keypath-device-targeting.manifest") + ], contents: [ + "config": Data("(defsrc)\n(deflayer base)".utf8), + "collections": proposed, + "customRules": Data("[]".utf8), + "deviceTargetingManifest": Data() + ], directory: fixture.directory, scope: .rules) var recoveryReloads = 0 fixture.manager.onRulesChanged = { diff --git a/Tests/KeyPathTests/PackRuleTransactionTests.swift b/Tests/KeyPathTests/PackRuleTransactionTests.swift index 94b40d4b3..6a9a2c846 100644 --- a/Tests/KeyPathTests/PackRuleTransactionTests.swift +++ b/Tests/KeyPathTests/PackRuleTransactionTests.swift @@ -21,6 +21,8 @@ final class PackRuleTransactionTests: KeyPathTestCase { manager.ruleCollections = [] manager.customRules = [CustomRule(input: "f20", action: .keystroke(key: "f19"), createdAt: Date(timeIntervalSince1970: 42))] try await service.saveRuleState(ruleCollections: [], customRules: manager.customRules, collectionStore: collections, customStore: rules) + manager.ruleCollections = try await collections.loadForMutation() + manager.customRules = try await rules.loadForMutation() tracker = InstalledPackTracker(fileURL: directory.appendingPathComponent("installed-packs.json")) try await tracker.upsert(InstalledPackRecord(packID: "unrelated", version: "1", installedAt: Date(timeIntervalSince1970: 42))) pack = makePack(inputs: ["f13", "f15"]) @@ -64,6 +66,13 @@ final class PackRuleTransactionTests: KeyPathTestCase { func testDeclinedCollectionConflictPreservesExistingRuleAndExplainsFailure() async throws { pack = PackRegistry.capsLockToEscape manager.customRules.append(CustomRule(input: "caps", action: .keystroke(key: "f13"))) + try await manager.configurationService.saveRuleState( + ruleCollections: manager.ruleCollections, + customRules: manager.customRules, + collectionStore: manager.ruleCollectionStore, + customStore: manager.customRulesStore + ) + manager.customRules = try await manager.customRulesStore.loadForMutation() let before = try snapshot() let originalRules = manager.customRules manager.onConflictResolution = { _ in .keepExisting } @@ -411,7 +420,7 @@ final class PackRuleTransactionTests: KeyPathTestCase { func testRejectedUninstallRestoresRulesAndInstalledRecord() async throws { _ = try await PackInstaller.shared.install(pack, manager: manager, installedPackTracker: tracker) let before = try snapshot() - let rules = manager.customRules + let rules = try await manager.customRulesStore.loadForMutation() var reloads = 0 manager.onRulesChanged = { reloads += 1 @@ -429,7 +438,7 @@ final class PackRuleTransactionTests: KeyPathTestCase { func testUninstallMetadataWriteFailureRestoresExactRevisionWithoutReload() async throws { _ = try await PackInstaller.shared.install(pack, manager: manager, installedPackTracker: tracker) let before = try snapshot() - let rules = manager.customRules + let rules = try await manager.customRulesStore.loadForMutation() let failingTracker = InstalledPackTracker(fileURL: directory.appendingPathComponent("installed-packs.json")) { _, _ in throw CocoaError(.fileWriteNoPermission) } @@ -468,12 +477,24 @@ final class PackRuleTransactionTests: KeyPathTestCase { func testMissingTimingCollectionDoesNotWriteMetadata() async throws { try await prepareHomeRowSettings() manager.ruleCollections = [] + try await manager.configurationService.saveRuleState( + ruleCollections: manager.ruleCollections, + customRules: manager.customRules, + collectionStore: manager.ruleCollectionStore, + customStore: manager.customRulesStore + ) try await assertInvalidTimingTargetDoesNotWrite() } func testMalformedTimingCollectionDoesNotWriteMetadata() async throws { try await prepareHomeRowSettings() manager.ruleCollections[0].configuration = .list + try await manager.configurationService.saveRuleState( + ruleCollections: manager.ruleCollections, + customRules: manager.customRules, + collectionStore: manager.ruleCollectionStore, + customStore: manager.customRulesStore + ) try await assertInvalidTimingTargetDoesNotWrite() } diff --git a/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift b/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift index 4800d4ff4..e2c681f7e 100644 --- a/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift +++ b/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift @@ -154,7 +154,7 @@ final class PreferencesServicePersistenceTests: XCTestCase { } } - func testRuntimeAffectingPreferenceChangesPostNotifications() { + func testShortcutListPreferenceChangesPersistWithoutLegacyNotification() { withPreservedDefaults { let prefs = PreferencesService() let recorder = NotificationRecorder() @@ -187,10 +187,9 @@ final class PreferencesServicePersistenceTests: XCTestCase { XCTAssertTrue(postedNames.contains(.verboseLoggingChanged)) XCTAssertTrue(postedNames.contains(.accessibilityTestModeChanged)) XCTAssertTrue(postedNames.contains(.overlaySuppressedBundleIDsChanged)) - XCTAssertGreaterThanOrEqual( - postedNames.filter { $0 == .configAffectingPreferenceChanged }.count, - 3 - ) + // Generation now uses a staged, durable shortcut input instead of + // an ambient notification-driven regeneration path. + XCTAssertFalse(postedNames.contains(.configAffectingPreferenceChanged)) } } diff --git a/Tests/KeyPathTests/VallackSystemPackTests.swift b/Tests/KeyPathTests/VallackSystemPackTests.swift index 30c0b46ed..186bcb7ac 100644 --- a/Tests/KeyPathTests/VallackSystemPackTests.swift +++ b/Tests/KeyPathTests/VallackSystemPackTests.swift @@ -397,7 +397,7 @@ final class VallackSystemPackTests: XCTestCase { } @MainActor - func testVallackInstallDoesNotRecordWhenManagedApplyFails() async throws { + func testVallackInstallDoesNotRecordWhenSourceChangesBeforeStaging() async throws { TestEnvironment.forceTestMode = true defer { TestEnvironment.forceTestMode = false } @@ -412,27 +412,43 @@ final class VallackSystemPackTests: XCTestCase { PackCollectionSnapshot.remove(for: PackRegistry.vallackSystem.id) } - // Keep admission/recovery usable; fail the later source-write stage so - // this still exercises managed-default snapshot rollback and cleanup. - let blockedSource = tempDir.appendingPathComponent("CustomRules.json") - try FileManager.default.createDirectory(at: blockedSource, withIntermediateDirectories: true) - + let collections = RuleCollectionStore.testStore(at: tempDir.appendingPathComponent("RuleCollections.json")) + let rules = CustomRulesStore.testStore(at: tempDir.appendingPathComponent("CustomRules.json")) + let service = ConfigurationService(configDirectory: tempDir.path, ruleCollectionStore: collections, customRulesStore: rules) let manager = RuleCollectionsManager( - ruleCollectionStore: RuleCollectionStore( - fileURL: tempDir.appendingPathComponent("RuleCollections.json") - ), - customRulesStore: CustomRulesStore( - fileURL: tempDir.appendingPathComponent("CustomRules.json") - ), - configurationService: ConfigurationService(configDirectory: tempDir.path), + ruleCollectionStore: collections, + customRulesStore: rules, + configurationService: service, eventListener: KanataEventListener() ) manager.ruleCollections = RuleCollectionCatalog().defaultCollections() + try await service.saveRuleState( + ruleCollections: manager.ruleCollections, + customRules: manager.customRules, + collectionStore: collections, + customStore: rules + ) let originalCollections = manager.ruleCollections + let tracker = InstalledPackTracker(fileURL: tempDir.appendingPathComponent("installed-packs.json")) + let customRulesURL = tempDir.appendingPathComponent("CustomRules.json") + var shouldBlockNextSourceWrite = true + service.onWillStageConfigurationWrite = { _ in + guard shouldBlockNextSourceWrite else { return } + shouldBlockNextSourceWrite = false + // Simulate a concurrent source revision after admission captures its + // baseline and before it may stage a managed-pack transaction. + try? FileManager.default.removeItem(at: customRulesURL) + try? FileManager.default.createDirectory(at: customRulesURL, withIntermediateDirectories: true) + } + defer { service.onWillStageConfigurationWrite = nil } do { - _ = try await PackInstaller.shared.install(PackRegistry.vallackSystem, manager: manager) - XCTFail("Install should fail when managed defaults cannot be applied") + _ = try await PackInstaller.shared.install( + PackRegistry.vallackSystem, + manager: manager, + installedPackTracker: tracker + ) + XCTFail("Install should fail when a source changes before staging") } catch let error as PackInstaller.InstallError { guard case .saveFailed = error else { XCTFail("Expected saveFailed, got \(error)") @@ -440,19 +456,19 @@ final class VallackSystemPackTests: XCTestCase { } } - let isInstalled = await InstalledPackTracker.shared.isInstalled(packID: PackRegistry.vallackSystem.id) + let isInstalled = await tracker.isInstalled(packID: PackRegistry.vallackSystem.id) XCTAssertFalse( isInstalled, - "Failed managed install must not record the pack as installed" + "A rejected source revision must not record the pack as installed" ) XCTAssertEqual( manager.ruleCollections, originalCollections, - "Failed managed install should restore in-memory collections" + "A rejected source revision should restore in-memory collections" ) XCTAssertNil( PackCollectionSnapshot.load(for: PackRegistry.vallackSystem.id), - "Failed managed install should not leave an uninstall snapshot behind" + "A rejected source revision should not leave an uninstall snapshot behind" ) } From 11e360ba5623e39d9111faf03a3e101981dbff78 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 13 Sep 2026 08:49:15 -0700 Subject: [PATCH 15/15] Batch SwiftFormat checks per file --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 318f3206d..cc1fd3d8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,7 +266,10 @@ jobs: if: steps.changed.outputs.swift_files != '' run: | echo "Checking formatting of changed Swift files (must match the pinned fixed-point)..." - echo "${{ steps.changed.outputs.swift_files }}" | xargs mise exec swiftformat -- swiftformat --lint || { + # SwiftFormat 0.61.1 can time out while applying rules across a large + # argument set. Each file still uses the same pinned configuration; + # batching one at a time keeps the lint result deterministic. + echo "${{ steps.changed.outputs.swift_files }}" | xargs -n 1 mise exec swiftformat -- swiftformat --lint || { echo "::error::SwiftFormat found formatting issues. Run 'swiftformat Sources Tests' (at the pinned version) and commit." exit 1 }