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
26 changes: 20 additions & 6 deletions Rust/KeyPathKanataHostBridge/src/session_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ fn oscode_supported(code: OsCode, usages: &[u32]) -> bool {
})
}

/// The physical inputs the session can capture: keyboard-page keys in the
/// host's map, and the consumer-page media keys the worker reads from macOS
/// system-defined events (phase 6, part 2) — the same table as outputs.
fn physical_input_supported(pc: PageCode, usages: &[u32]) -> bool {
(pc.page == 7 && usages.contains(&pc.code))
|| (pc.page == 0x0C && CONSUMER_OUTPUT_CODES.contains(&pc.code))
}

/// Outputs the session can emit: the host's key map plus native Caps Lock,
/// which SessionCapsLockOutput posts independently of physical Caps capture.
fn session_output_usages(usages: &[u32]) -> Vec<u32> {
Expand Down Expand Up @@ -168,7 +176,7 @@ pub(crate) fn supported(cfg: &Cfg, usages: &[u32]) -> bool {
OsCode::try_from(usize::from(*raw)).ok().is_some_and(|key| {
cfg.mapped_keys.contains(&key)
&& PageCode::try_from(key)
.is_ok_and(|pc| pc.page == 7 && usages.contains(&pc.code))
.is_ok_and(|pc| physical_input_supported(pc, usages))
})
}) || !action_supported(
chord.action,
Expand All @@ -193,7 +201,7 @@ pub(crate) fn supported(cfg: &Cfg, usages: &[u32]) -> bool {
};
if row == 0 && cfg.mapped_keys.contains(&input) {
let physical_supported = PageCode::try_from(input)
.is_ok_and(|pc| pc.page == 7 && usages.contains(&pc.code));
.is_ok_and(|pc| physical_input_supported(pc, usages));
if !physical_supported {
// Unknown physical inputs and Caps Lock can only pass
// through unchanged; session capture cannot remap them.
Expand Down Expand Up @@ -275,7 +283,7 @@ pub(crate) fn supported_with_managed_caps_reserving(
cfg.mapped_keys.contains(&key)
&& (key == OsCode::KEY_CAPSLOCK
|| PageCode::try_from(key)
.is_ok_and(|pc| pc.page == 7 && usages.contains(&pc.code)))
.is_ok_and(|pc| physical_input_supported(pc, &usages)))
})
}) || !action_supported(
chord.action,
Expand Down Expand Up @@ -311,7 +319,7 @@ pub(crate) fn supported_with_managed_caps_reserving(
&& cfg.mapped_keys.contains(&input)
&& input != OsCode::KEY_CAPSLOCK
&& !PageCode::try_from(input)
.is_ok_and(|pc| pc.page == 7 && usages.contains(&pc.code))
.is_ok_and(|pc| physical_input_supported(pc, &usages))
{
return matches!(action, Action::KeyCode(key) if OsCode::from(*key) == input)
|| matches!(action, Action::KeyCode(KeyCode::ErrorUndefined))
Expand Down Expand Up @@ -354,7 +362,7 @@ mod tests {
}

#[test]
fn consumer_output_table_matches_kanata_names_and_inputs_stay_keyboard_only() {
fn consumer_table_matches_kanata_names_for_outputs_and_inputs() {
for (name, code) in [
("volu", 0xE9), ("vold", 0xEA), ("mute", 0xE2), ("brup", 0x6F), ("brdn", 0x70),
("pp", 0xCD), ("next", 0xB5), ("prev", 0xB6),
Expand All @@ -364,9 +372,15 @@ mod tests {
assert_eq!((pc.page, pc.code), (0x0C, code), "{name}");
assert!(CONSUMER_OUTPUT_CODES.contains(&pc.code), "{name}");
}
// Until the tap reads media events, a media key is not an input.
// The tap reads media events, so a media key is an input too...
let cfg = kanata_parser::cfg::new_from_str("(defsrc volu)(deflayer base a)", Default::default()).unwrap();
assert!(supported(&cfg, HOST));
// ...but a consumer key the worker cannot read is not.
let cfg = kanata_parser::cfg::new_from_str("(defsrc calc)(deflayer base a)", Default::default()).unwrap();
assert!(!supported(&cfg, HOST));
// And under managed Caps the same holds.
let cfg = kanata_parser::cfg::new_from_str("(defsrc mute caps)(deflayer base pp a)", Default::default()).unwrap();
assert!(supported_with_managed_caps(&cfg, HOST));
// ...but it is an output anywhere a keyboard key would be.
let cfg = kanata_parser::cfg::new_from_str("(defsrc a)(deflayer base volu)", Default::default()).unwrap();
assert!(supported(&cfg, HOST));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public final class SessionRuntimeWorker {
private var signals: [DispatchSourceSignal] = []
private var outputs = SessionOutputState()
private var inputs: Set<UInt32> = []
/// Consumer-page media keys held into Kanata. Kept apart from `inputs`:
/// usage numbers overlap across pages (0xE2 is Mute here, Left Alt on page 7).
private var mediaInputs: Set<UInt32> = []
private var capsRecord: SessionCapsMappingPolicy.Record?
private var capsOwner: SessionCapsMappingPolicy.Owner?
private var capsDirectory: URL?
Expand Down Expand Up @@ -207,8 +210,11 @@ public final class SessionRuntimeWorker {
finish(.failed, reason: "runtime-start-failed")
}

// 14 = NX_SYSDEFINED: the media keys (volume, brightness, playback) on
// Apple keyboards arrive as system-defined events, not key events.
let mask = (1 << CGEventType.keyDown.rawValue)
| (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue)
| (1 << Self.systemDefinedEventType)
tap = createModifyingTap(eventsOfInterest: CGEventMask(mask))
guard let startupTap = tap else { finish(.failed, reason: "modifying-tap-unavailable") }
if admission.managedCaps {
Expand Down Expand Up @@ -393,6 +399,7 @@ public final class SessionRuntimeWorker {
finish(.failed, reason: "environment-observer-unavailable")
}
if IsSecureEventInputEnabled() { finish(.secureInput) }
if type.rawValue == Self.systemDefinedEventType { return receiveMedia(event) }
physicalPassthroughFlags = physicalModifiers.passthroughFlags(
keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)),
isFlagsChanged: type == .flagsChanged, flags: event.flags.rawValue
Expand Down Expand Up @@ -450,8 +457,30 @@ public final class SessionRuntimeWorker {
return nil
}

private func send(value: UInt64, usage: UInt32) -> Bool {
guard let runtime, case .success = runtime.sendInput(value: value, usagePage: 7, usage: usage) else {
static let systemDefinedEventType: UInt32 = 14

/// A media key from the built-in or an Apple keyboard. Mapped ones go to
/// Kanata and are consumed; everything else passes through untouched.
/// Hardware repeats of a held mapped key are swallowed, since Kanata (and,
/// for posted media outputs, macOS) repeat on their own.
private func receiveMedia(_ event: CGEvent) -> Unmanaged<CGEvent>? {
let original = Unmanaged.passUnretained(event)
guard let nsEvent = NSEvent(cgEvent: event), nsEvent.type == .systemDefined,
let input = SessionKeyMap.mediaInput(subtype: nsEvent.subtype.rawValue, data1: nsEvent.data1),
runtime?.isInputMapped(usagePage: SessionKeyMap.consumerPage, usage: input.usage) == true
else { return original }
switch input.value {
case 2: return mediaInputs.contains(input.usage) ? nil : original
case 0: guard mediaInputs.contains(input.usage) else { return original } // press predates this tap
default: break
}
guard send(value: input.value, usage: input.usage, usagePage: SessionKeyMap.consumerPage) else { return original }
if input.value == 0 { mediaInputs.remove(input.usage) } else { mediaInputs.insert(input.usage) }
return nil
}

private func send(value: UInt64, usage: UInt32, usagePage: UInt32 = SessionKeyMap.keyboardPage) -> Bool {
guard let runtime, case .success = runtime.sendInput(value: value, usagePage: usagePage, usage: usage) else {
finish(.failed, reason: "input-queue-unavailable")
}
inputCount += 1
Expand Down
18 changes: 18 additions & 0 deletions Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ public enum SessionKeyMap {
consumerUsageToMediaKeyType[usage]
}

private static let mediaKeyTypeToConsumerUsage = Dictionary(
uniqueKeysWithValues: consumerUsageToMediaKeyType.map { ($0.value, $0.key) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude media inputs that cannot be named in Kanata

When hardware sends NX_KEYTYPE_FAST or NX_KEYTYPE_REWIND (19/20), reversing the entire output table decodes them as newly supported inputs, but KeyboardCapture.mediaKeyCodeToString produces fastforward/rewind and KeyMappingFormatter explicitly leaves those unchanged because they have no Kanata name. The Mapper therefore cannot generate a valid defsrc token for these two inputs, despite the bridge admitting their usages; either add a valid lowering or omit them from the input table.

Useful? React with 👍 / 👎.

)

/// The consumer-page input a macOS system-defined media event carries, as
/// Kanata's (usage, value): 1 down, 0 up, 2 a hardware repeat. nil for any
/// other subtype or a key outside the table. data1 holds the NX key type in
/// bits 16–23, 0xA (down) or 0xB (up) in bits 8–15, and the repeat flag in
/// bit 0 (docs/architecture/driverless-media-keys.md §1).
public static func mediaInput(subtype: Int16, data1: Int) -> (usage: UInt32, value: UInt64)? {
guard subtype == 8, let usage = mediaKeyTypeToConsumerUsage[(data1 >> 16) & 0xFF] else { return nil }
switch (data1 >> 8) & 0xFF {
case 0xA: return (usage, data1 & 1 == 1 ? 2 : 1)
case 0xB: return (usage, 0)
default: return nil
}
}

public static let keyCodeToUsage: [UInt16: UInt32] = [
0: 4, 1: 22, 2: 7, 3: 9, 4: 11, 5: 10,
6: 29, 7: 27, 8: 6, 9: 25, 10: 100, 11: 5,
Expand Down
24 changes: 24 additions & 0 deletions Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,28 @@ final class SessionOutputStateTests: XCTestCase {
XCTAssertEqual((up.data1 >> 16) & 0xFFFF, 17)
XCTAssertEqual((up.data1 >> 8) & 0xFF, 0xB)
}

// MARK: - Media keys as inputs (phase 6, part 2)

func testSystemDefinedMediaEventsDecodeToConsumerInputs() {
func data1(_ keyType: Int, _ state: Int, isRepeat: Bool = false) -> Int {
(keyType << 16) | (state << 8) | (isRepeat ? 1 : 0)
}
XCTAssertEqual(SessionKeyMap.mediaInput(subtype: 8, data1: data1(7, 0xA))?.usage, 0xE2)
XCTAssertEqual(SessionKeyMap.mediaInput(subtype: 8, data1: data1(7, 0xA))?.value, 1)
XCTAssertEqual(SessionKeyMap.mediaInput(subtype: 8, data1: data1(7, 0xA, isRepeat: true))?.value, 2)
XCTAssertEqual(SessionKeyMap.mediaInput(subtype: 8, data1: data1(7, 0xB))?.value, 0)
XCTAssertEqual(SessionKeyMap.mediaInput(subtype: 8, data1: data1(0, 0xA))?.usage, 0xE9) // volume up
XCTAssertEqual(SessionKeyMap.mediaInput(subtype: 8, data1: data1(3, 0xA))?.usage, 0x70) // brightness down
XCTAssertNil(SessionKeyMap.mediaInput(subtype: 8, data1: data1(21, 0xA)), "keyboard backlight is not in the table")
XCTAssertNil(SessionKeyMap.mediaInput(subtype: 7, data1: data1(7, 0xA)), "only subtype 8 carries media keys")
XCTAssertNil(SessionKeyMap.mediaInput(subtype: 8, data1: data1(7, 0x5)))
}

/// Every media input round-trips to the NX type the output path posts.
func testMediaInputAndOutputTablesAgree() {
for (usage, keyType) in SessionKeyMap.consumerUsageToMediaKeyType {
XCTAssertEqual(SessionKeyMap.mediaInput(subtype: 8, data1: (keyType << 16) | (0xA << 8))?.usage, usage)
}
}
}
18 changes: 18 additions & 0 deletions Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,22 @@ final class MediaKeyCaptureToSaveTests: KeyPathTestCase {
vm.applyCapturedOutput(KeySequence(keys: [mute, chord], captureMode: .sequence))
XCTAssertNotNil(vm.outputSequence)
}

/// Phase 6, part 2: a captured media key works as a rule *input*; the real
/// bridge admits it and the config captures it in defsrc.
func testCapturedMediaKeyIsAnAdmittedInput() async throws {
let mute = try XCTUnwrap(KeyboardCapture.mediaKeyPress(from: mediaEvent(nxKeyType: 7, down: true)))
let vm = MapperViewModel()
vm.configure(kanataManager: coordinator)
vm.inputSequence = KeySequence(keys: [mute], captureMode: .single)
vm.outputSequence = KeySequence(keys: [KeyPress(baseKey: "a", modifiers: [], keyCode: 0)], captureMode: .single)
await vm.save(kanataManager: coordinator)
XCTAssertFalse(vm.statusIsError, vm.statusMessage ?? "")
let config = try String(contentsOfFile: service.configurationPath, encoding: .utf8)
let defsrc = try XCTUnwrap(config.components(separatedBy: "(defsrc").last?.components(separatedBy: ")").first)
XCTAssertTrue(defsrc.contains("mute"), defsrc)
let admission = SessionCapsRuntimeSupport.validate(configPath: service.configurationPath,
runtimeHost: SessionBridgeTestFixture.runtimeHost, requireSelection: false)
guard case .valid = admission.result else { return XCTFail("media input refused: \(admission.result)") }
}
}
4 changes: 2 additions & 2 deletions docs/architecture/driverless-media-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,8 @@ does not fit "one intermediate per media key".

## Recommendation

**Status 2026-10-20:** the output half shipped (rules-admission-review.md §4.5); the
input half (tap path) is next.
**Status 2026-10-20:** both halves shipped as recommended here — outputs posted by the
worker, inputs read by the tap (rules-admission-review.md §4.5).

### Output: post from the worker, not the app

Expand Down
14 changes: 11 additions & 3 deletions docs/architecture/rules-admission-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,8 +555,16 @@ So the session emits media keys as Kanata's ordinary outputs:
(system-defined events carry no source device); keyboard backlight (21–23) is in
the NX table but unmeasured.

Part 2 adds `1 << 14` to the tap mask so system-defined events become consumer
inputs to Kanata (consumed only when mapped, hardware repeats swallowed).
**Part 2 (inputs, 2026-10-20).** The bridge's physical-input rule
(`physical_input_supported`) admits the same ten consumer codes. The worker adds
NX_SYSDEFINED (14) to its tap mask; `SessionKeyMap.mediaInput(subtype:data1:)` decodes a
Comment on lines +558 to +560

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 Update the published guides before declaring media inputs shipped

This is a user-visible feature, but the commit updates only developer architecture documents. The published guides still tell users that media and brightness remapping is unavailable in driverless mode (guides/fun-layer.md and guides/driverless-caps-lock.md) and provide no instructions or limitations for using media keys as inputs, so users receive directly contradictory guidance. Update the relevant guides/ content along with this shipped status.

AGENTS.md reference: AGENTS.md:L382-L383

Useful? React with 👍 / 👎.

subtype-8 event to Kanata's (usage, value), and the worker sends it as a consumer-page
input only when the profile maps it — otherwise the event passes through untouched, so
an unmapped volume key behaves exactly as before. Held media inputs live in their own
ledger (`mediaInputs`; usage numbers overlap across pages), a release whose press
predates the tap passes through, and hardware repeats of a held mapped key are
swallowed because Kanata repeats on its own. Per-device media rules stay impossible:
system-defined events carry no source device.

Lowering of *other* intents (computed fall-through under managed Caps, §4.7's
registry) remains as designed below.
Expand Down Expand Up @@ -615,7 +623,7 @@ until phase 4.
| **3. Claims + engine** — *done (see §4.4a)* | `AdmissionVerdict` (KeyPathRulesCore) and `AdmissionEngine` (AppKit): conflicts from `detectConflicts` attributed to ids, unmet needs, reserved Caps intermediate (moves automatically), another app holding Caps, pack conflicts and requirements. Not called yet. | Golden scenarios match today's detector; attribution to ids; media blocking per backend; intermediate move/block; foreign Caps owner; packs; a fresh profile admits with zero findings against the real bridge. |
| **4. Callers** — *done 2026-10-19 (4a–4e)* | toggle, rule editor, pack install (conditional preview, see §7), catalog update, save and startup call the engine; `conflictInfo`/`dedupe` divergence removed; the dead `ConflictResolutionDialog` deleted; the dialogs render `Verdict`. | UI tests per caller; pack install preview snapshot; "save reaches a new blocking verdict" logs an error (and the existing test guardrail catches it). |
| **5. Attribution** — *done 2026-10-19 as phase 4e* | one refusal renderer with the engine's blocking sentences in front of the validator's reason; CLI `doctor` lists verdicts. Line-level attribution from Rust was not needed. | `SessionConfigurationRefusalTests`; lifecycle and CLI tests. |
| **6. Media keys** — *part 1 done 2026-10-20 (§4.5)* | the bridge's output rule accepts the ten consumer keys the worker posts as system-defined events; the worker posts and releases them. Part 2: the tap reads system-defined events as consumer inputs. | Rust rule tests; `SessionOutputStateTests` media cases; catalog/profile goldens flipped; hardware on the Air. |
| **6. Media keys** — *done 2026-10-20 (§4.5)* | the ten consumer keys the worker can post are session outputs (part 1) and, read from macOS system-defined events, session inputs (part 2). | Rust rule tests; `SessionOutputStateTests` media output and input cases; `MediaKeyCaptureToSaveTests`; hardware on the Air. |

Phases 1–3 are additive and can land before #1304–#1309 merge; 4–6 should
follow them.
Expand Down
Loading