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 @@ -1784,11 +1784,13 @@
return ""
}

// Multiple whitespace-separated tokens (e.g., "cmd space") → chord/sequence
// Multiple whitespace-separated tokens are a sequence to type in order
// (a chord arrives as one token with modifier prefixes, "M-S-C-4"). A
// bare list "(mute M-S-C-4)" is not Kanata; a macro is.
if tokens.count > 1 {
// Use convertToKanataKeyForMacro to preserve uppercase modifier prefixes
let kanataKeys = tokens.map { convertToKanataKeyForMacro($0) }
return "(\(kanataKeys.joined(separator: " ")))"
return "(macro \(kanataKeys.joined(separator: " ")))"
}

// Single token - check if it's a text sequence to type (e.g., "123", "hello")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,7 @@ extension MapperViewModel {
viewModel.shiftedOutputSequence = sequence
viewModel.shiftedOutputLabel = sequence.displayString
} else {
viewModel.outputSequence = sequence
viewModel.outputLabel = sequence.displayString
viewModel.applyCapturedOutput(sequence)
}

// Reset finalize timer - wait for more keys
Expand Down Expand Up @@ -423,6 +422,22 @@ extension MapperViewModel {
}
}

/// A captured output. A lone media key (volume, brightness, play/pause…)
/// is the system action it names, so the tile shows that action's symbol
/// from the first moment and the save goes the system-action way; any
/// other capture is a key sequence.
func applyCapturedOutput(_ sequence: KeySequence) {
if sequence.keys.count == 1, let press = sequence.keys.first, press.keyCode >= 1000,
let action = SystemActionInfo.find(byOutput: KeyMappingFormatter.toKanataFormat(sequence)),
action.isSupportedInDriverlessSession
{
selectSystemAction(action)

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 Defer media-action selection until capture finalizes

When a media key begins a multi-key output, sequence capture first emits the one-key prefix, so this call selects the system action and immediately starts saveSystemActionMapping because the input is already set. The later callback containing the full sequence sets outputSequence but never clears selectedSystemAction, and autosaveCapturedMapping prioritizes that action; consequently an actual Mute-then-chord capture saves only Mute rather than the new macro, while the added macro test bypasses this streaming path by assigning outputSequence directly. Classify the media key only after capture finalization, or otherwise avoid the premature save and clear the action when the sequence grows.

Useful? React with 👍 / 👎.

return
}
outputSequence = sequence
outputLabel = sequence.displayString
}

func finalizeCapture() {
finalizeTimer?.invalidate()
finalizeTimer = nil
Expand Down
3 changes: 3 additions & 0 deletions Tests/KeyPathTests/KeyPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ final class KeyPathTests: KeyPathTestCase {

// Test sequences
XCTAssertEqual(manager.convertToKanataSequence("hello"), "(macro h e l l o)")
// A captured sequence (space-separated tokens) is a macro, never a bare list.
XCTAssertEqual(manager.convertToKanataSequence("mute M-S-C-4"), "(macro mute M-S-C-4)")
XCTAssertEqual(manager.convertToKanataSequence("escape a"), "(macro esc a)")
XCTAssertEqual(manager.convertToKanataSequence("abc"), "(macro a b c)")
}

Expand Down
31 changes: 31 additions & 0 deletions Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,35 @@ final class MediaKeyCaptureToSaveTests: KeyPathTestCase {
XCTAssertFalse(config.contains("(macro"), "a media key must never be typed as letters")
}
}

/// A media key followed by a chord (the 2026-10-20 edge case: Mute then
/// ⌃⇧⌘4) is a sequence, rendered as a macro Kanata accepts.
func testMediaKeyThenChordSavesAsAMacro() async throws {
let mute = try XCTUnwrap(KeyboardCapture.mediaKeyPress(from: mediaEvent(nxKeyType: 7, down: true)))
let chord = KeyPress(baseKey: "4", modifiers: [.control, .shift, .command], keyCode: 21)
let vm = MapperViewModel()
vm.configure(kanataManager: coordinator)
vm.inputSequence = KeySequence(keys: [KeyPress(baseKey: "grave", modifiers: [], keyCode: 50)], captureMode: .single)
vm.outputSequence = KeySequence(keys: [mute, chord], captureMode: .sequence)
await vm.save(kanataManager: coordinator)
XCTAssertFalse(vm.statusIsError, vm.statusMessage ?? "")
let config = try String(contentsOfFile: service.configurationPath, encoding: .utf8)
XCTAssertTrue(config.contains("(macro mute M-S-C-4)"), config)
}

/// A lone captured media key becomes the system action it names, so the
/// Mapper shows one symbol throughout and saves through the action path.
func testLoneMediaKeyCaptureBecomesTheSystemAction() throws {
let vm = MapperViewModel()
vm.configure(kanataManager: coordinator)
let mute = try XCTUnwrap(KeyboardCapture.mediaKeyPress(from: mediaEvent(nxKeyType: 7, down: true)))
vm.applyCapturedOutput(KeySequence(keys: [mute], captureMode: .single))
XCTAssertEqual(vm.selectedSystemAction?.id, "mute")
XCTAssertNil(vm.outputSequence)
XCTAssertEqual(vm.outputLabel, "Mute")
// A sequence stays a sequence.
let chord = KeyPress(baseKey: "4", modifiers: [.control, .shift, .command], keyCode: 21)
vm.applyCapturedOutput(KeySequence(keys: [mute, chord], captureMode: .sequence))
XCTAssertNotNil(vm.outputSequence)
}
}
5 changes: 5 additions & 0 deletions docs/architecture/rules-admission-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,11 @@ So the session emits media keys as Kanata's ordinary outputs:
of macOS Function Keys and the eight media system actions became available with no
Swift change; the former hard-coded refusals survive only as the no-bridge fallback
(`fallbackConsumerKeys`), and the tests now pin both answers.
- **Found on hardware the same day:** a captured sequence (Mute then ⌃⇧⌘4) was rendered
by the generator as a bare list `(mute M-S-C-4)`, which Kanata cannot parse; multi-token
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.
- **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