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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@
try await performRuleFileOperation { try RecoverableRuleWrite.commit(pending) }
} catch {
let cause = error
do { try await performRuleFileOperation { try RecoverableRuleWrite.rollback(pending) } }

Check warning on line 399 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { throw RecoverableRuleWrite.WriteFailure(cause: cause, recoveryError: error) }
throw cause
}
Expand All @@ -422,7 +422,7 @@
try await settleRuleWrite(write, commit: true, mutationPermit: permit)
} catch {
let cause = error
do { try await settleRuleWrite(write, commit: false, mutationPermit: permit) }

Check warning on line 425 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { throw RecoverableRuleWrite.WriteFailure(cause: cause, recoveryError: error) }
throw cause
}
Expand Down Expand Up @@ -525,7 +525,7 @@
preferences: sendablePreferences?.value, preferenceChanges: preferenceChanges,
synchronizePreferences: self.synchronizePreferences)
{ data, url in
if let packUpdate, url == packUpdate.fileURL { try packUpdate.writeFile(data, url) }

Check warning on line 528 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { try RecoverableRuleWrite.durableWrite(data, url) }
}
}
Expand All @@ -536,7 +536,7 @@
func settleRuleWrite(_ write: RuleWrite, commit: Bool, mutationPermit: ConfigurationOperationGate.Permit) async throws {
try await operationGate.withOperation(using: mutationPermit) { @MainActor [self] _ in
try await performRuleFileOperation {
if commit { try RecoverableRuleWrite.commit(write.pending) }

Check warning on line 539 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { try RecoverableRuleWrite.rollback(write.pending) }
}
if commit {
Expand All @@ -557,7 +557,7 @@
try await performRuleFileOperation {
var snapshot: [String: Data] = [:]
for (role, url) in files {
do { snapshot[role] = try Data(contentsOf: url) }

Check warning on line 560 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError {}
}
return snapshot
Expand Down Expand Up @@ -642,11 +642,11 @@
{
try await operationGate.withOperation(using: mutationPermit) { @MainActor [self] _ in
try await performRuleFileOperation {
if commit { try RecoverableRuleWrite.commit(write.pending) }

Check warning on line 645 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { try RecoverableRuleWrite.rollback(write.pending) }
}
stateLock.withLock { currentConfiguration = nil }
if commit { needsRecoveredRuntimeRefresh = false }

Check warning on line 649 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else if requireRuntimeRecovery { needsRecoveredRuntimeRefresh = true }
}
}
Expand Down Expand Up @@ -745,7 +745,7 @@
func settleAppKeymapWrite(_ write: AppKeymapWrite, commit: Bool, mutationPermit: ConfigurationOperationGate.Permit) async throws {
try await operationGate.withOperation(using: mutationPermit) { @MainActor [self] _ in
try await performRuleFileOperation {
if commit { try RecoverableRuleWrite.commit(write.pending) }

Check warning on line 748 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
else { try RecoverableRuleWrite.rollback(write.pending) }
}
if commit {
Expand Down Expand Up @@ -833,7 +833,7 @@
private func performRuleFileOperation<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
ioQueue.async {
do { try continuation.resume(returning: operation()) }

Check warning on line 836 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Else and catch should be on the same line, one space after the previous declaration (statement_position)
catch { continuation.resume(throwing: error) }
}
}
Expand Down Expand Up @@ -1589,7 +1589,7 @@
let uppercasedInput = input.uppercased()

for prefix in modifierPrefixes {
if uppercasedInput.hasPrefix(prefix) {

Check warning on line 1592 in Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift

View workflow job for this annotation

GitHub Actions / code-quality

`where` clauses are preferred over a single `if` inside a `for` (for_where)
// Preserve canonical uppercase prefix, convert base key
let baseKey = String(input.dropFirst(prefix.count))
let convertedBase = convertToKanataKey(baseKey)
Expand Down Expand Up @@ -1823,6 +1823,15 @@
}
}

// 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
Comment on lines +1831 to +1832

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Canonicalize KeyPath aliases before querying Kanata

When the live bridge is available, KeyPath aliases that Kanata does not recognize verbatim—such as escape, space, return, and command—produce known == false, so this early return bypasses the fallback alias set and the later convertToKanataKey mappings. Existing mappings then generate (macro e s c a p e), (macro s p a c e), etc. instead of esc, spc, ret, or lmet; canonicalize the token before querying the bridge or preserve the alias check ahead of this return.

Useful? React with 👍 / 👎.

}

// Known key names that shouldn't be split into macros
let keyNames: Set = [
"escape", "esc", "return", "ret", "enter",
Expand Down
23 changes: 23 additions & 0 deletions Sources/KeyPathCore/SessionRuntime/BackendCapabilityProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand All @@ -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
Expand Down
65 changes: 65 additions & 0 deletions Tests/KeyPathTests/Config/GeneratorKeyNameTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
6 changes: 6 additions & 0 deletions docs/architecture/rules-admission-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading