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