From 548b6accc977afb4170a1d118da638d09b3860ea Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 20 Oct 2026 08:11:08 -0700 Subject: [PATCH] Generator: Kanata decides what is a key, so key names are never typed as letters The generator decided "key or word to type" from a hand-kept list that missed real Kanata keys, so `.keystroke("f21")` became `(macro f 2 1)` and a rule silently typed three characters instead of being refused. It now asks the bridge (BackendCapabilityProfile.knowsKey): a name Kanata knows is emitted as that key and goes through admission like any output, refused with "sends f21, which the current backend can't send" when the session cannot post it; only a word Kanata does not know ("hello") is typed. The list remains the fallback when no bridge is available. Co-Authored-By: Claude Opus 5.5 --- .../Config/ConfigurationService.swift | 9 +++ .../BackendCapabilityProfile.swift | 23 +++++++ .../Config/GeneratorKeyNameTests.swift | 65 +++++++++++++++++++ docs/architecture/rules-admission-review.md | 6 ++ 4 files changed, 103 insertions(+) create mode 100644 Tests/KeyPathTests/Config/GeneratorKeyNameTests.swift diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index 89a0eadd9..70e96c86e 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -1823,6 +1823,15 @@ public enum KanataKeyConverter { } } + // Kanata decides what is a key. A name it knows (`f21`, `calc`) is + // emitted as a key, so admission can refuse it with a reason if the + // backend cannot send it, rather than typing its letters. Only a word + // Kanata does not know is text to type. The list below is the fallback + // when no bridge is available to ask. + if let known = BackendCapabilityProfile.shared.knowsKey(token) { + return !known + } + // Known key names that shouldn't be split into macros let keyNames: Set = [ "escape", "esc", "return", "ret", "enter", diff --git a/Sources/KeyPathCore/SessionRuntime/BackendCapabilityProfile.swift b/Sources/KeyPathCore/SessionRuntime/BackendCapabilityProfile.swift index e892dabdb..968713ea1 100644 --- a/Sources/KeyPathCore/SessionRuntime/BackendCapabilityProfile.swift +++ b/Sources/KeyPathCore/SessionRuntime/BackendCapabilityProfile.swift @@ -30,6 +30,7 @@ public final class BackendCapabilityProfile: @unchecked Sendable { private let query: @Sendable (String) -> Bool? private let lock = NSLock() private var cache: [String: Bool] = [:] + private var knownCache: [String: Bool] = [:] /// `query` answers for one base key name: true/false, or nil for "no answer". public init(backend: KanataRuntimeBackend, query: @escaping @Sendable (String) -> Bool?) { @@ -54,6 +55,28 @@ public final class BackendCapabilityProfile: @unchecked Sendable { return answer } + /// Whether Kanata knows `name` as a key at all (whatever the backend can + /// emit): true/false from the bridge, nil when there is no bridge to ask. + /// The generator uses this to tell a key name (`f21`, `calc`) from a word + /// to type (`hello`), instead of a hand-kept list that drifts from Kanata. + public func knowsKey(_ name: String) -> Bool? { + let key = name.lowercased() + guard !key.isEmpty else { return nil } + lock.lock() + if let known = knownCache[key] { lock.unlock(); return known } + lock.unlock() + let answer: Bool + if query(key) != nil { + answer = true + } else if query("a") != nil { + answer = false // the bridge answers, so nil meant "not a Kanata key" + } else { + return nil // no bridge: the caller keeps its own fallback + } + lock.lock(); knownCache[key] = answer; lock.unlock() + return answer + } + /// Device-specific rules (`macos-dev-names-*`) are refused by the session backend. public var supportsDeviceTargeting: Bool { backend != .session diff --git a/Tests/KeyPathTests/Config/GeneratorKeyNameTests.swift b/Tests/KeyPathTests/Config/GeneratorKeyNameTests.swift new file mode 100644 index 000000000..4e85f5f40 --- /dev/null +++ b/Tests/KeyPathTests/Config/GeneratorKeyNameTests.swift @@ -0,0 +1,65 @@ +@testable import KeyPathAppKit +@testable import KeyPathCore +import KeyPathRulesCore +@preconcurrency import XCTest + +/// The generator asks Kanata, through the bridge, whether a token is a key. +/// A key it knows is emitted as that key, so admission can refuse it with a +/// reason; only a word Kanata does not know is typed as a macro. +@MainActor +final class GeneratorKeyNameTests: KeyPathTestCase { + private var directory: URL! + + override func setUp() async throws { + try await super.setUp() + try SessionBridgeTestFixture.requireAvailable() + BackendCapabilityProfile.testOverride = SessionBridgeTestFixture.profile + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + BackendCapabilityProfile.testOverride = nil + if let directory { try? FileManager.default.removeItem(at: directory) } + directory = nil + try await super.tearDown() + } + + func testKanataKeysAreEmittedAsKeysAndWordsAreTyped() { + XCTAssertEqual(KanataKeyConverter.convertToKanataSequence("f21"), "f21") + XCTAssertEqual(KanataKeyConverter.convertToKanataSequence("calc"), "calc") + XCTAssertEqual(KanataKeyConverter.convertToKanataSequence("esc"), "esc") + XCTAssertEqual(KanataKeyConverter.convertToKanataSequence("hello"), "(macro h e l l o)") + XCTAssertEqual(KanataKeyConverter.convertToKanataSequence("notakey"), "(macro n o t a k e y)") + } + + func testTheProfileTellsKeysFromWordsAndSaysWhenItCannot() { + let profile = SessionBridgeTestFixture.profile + XCTAssertEqual(profile.knowsKey("f21"), true) + XCTAssertEqual(profile.knowsKey("volu"), true) + XCTAssertEqual(profile.knowsKey("hello"), false) + XCTAssertNil(BackendCapabilityProfile(backend: .session) { _ in nil }.knowsKey("f21"), "no bridge, no answer") + } + + /// `f21` used to become `(macro f 2 1)` and type three characters. Now it + /// is a key the session cannot send, refused before anything is written. + func testAKeyTheSessionCannotSendIsRefusedNotTyped() async throws { + let collections = RuleCollectionStore.testStore(at: directory.appendingPathComponent("RuleCollections.json")) + let rules = CustomRulesStore.testStore(at: directory.appendingPathComponent("CustomRules.json")) + let service = ConfigurationService.sessionTestService(configDirectory: directory.path, ruleCollectionStore: collections, customRulesStore: rules) + let manager = RuleCollectionsManager(ruleCollectionStore: collections, customRulesStore: rules, configurationService: service) + manager.ruleCollections = [] + manager.customRules = [] + let rule = CustomRule(title: "Twenty-one", input: "f9", action: .keystroke(key: "f21")) + XCTAssertEqual(manager.reviewAdmission(forProposed: rule).refusal, + "Twenty-one sends f21, which the current backend can't send.") + do { + try await service.saveConfiguration(ruleCollections: [], customRules: [rule]) + XCTFail("an output the session cannot send must not be saved") + } catch { + XCTAssertTrue(error.localizedDescription.contains("driverless session"), error.localizedDescription) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: service.configurationPath) + && ((try? String(contentsOfFile: service.configurationPath, encoding: .utf8))?.contains("(macro f 2 1)") ?? false)) + } +} diff --git a/docs/architecture/rules-admission-review.md b/docs/architecture/rules-admission-review.md index d1ca13c6f..1da6b4b06 100644 --- a/docs/architecture/rules-admission-review.md +++ b/docs/architecture/rules-admission-review.md @@ -544,6 +544,12 @@ So the session emits media keys as Kanata's ordinary outputs: outputs are now always a `(macro …)`. And a lone captured media key is treated as the system action it names (`MapperViewModel.applyCapturedOutput`), so the Mapper shows one symbol throughout and saves through the action path. +- **Key or word is Kanata's call (2026-10-20).** The generator used a hand-kept list to + decide whether a keystroke token was a key or a word to type, and the list missed real + Kanata keys, so `f21` became `(macro f 2 1)`. It now asks the bridge + (`BackendCapabilityProfile.knowsKey`): a name Kanata knows is emitted as that key and + admission refuses it with a reason if the session cannot send it; only a word Kanata + does not know is typed. The list remains the fallback when there is no bridge. - **Known limits** (from the spike): a posted brightness event drives the built-in panel only (Studio Display unchanged); per-device media rules are impossible (system-defined events carry no source device); keyboard backlight (21–23) is in