From 32e2338615a0ed2783174af09fd7d98fd1908633 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 20 Oct 2026 10:52:51 -0700 Subject: [PATCH] Driverless media keys, part 2: the session reads them as inputs (phase 6) Apple keyboards deliver their media keys as macOS system-defined events, which the worker's tap never asked for, so a rule with a media key as its input could not fire. The bridge's physical-input rule now admits the same ten consumer-page keys it posts; the worker adds NX_SYSDEFINED to its tap mask, decodes subtype-8 events (SessionKeyMap.mediaInput) and hands a mapped key to Kanata as a consumer-page input, consuming it. Unmapped media keys pass through untouched. Held media inputs have their own ledger (usage numbers overlap across pages), a release whose press predates the tap passes through, and hardware repeats of a held mapped key are swallowed. Co-Authored-By: Claude Opus 5.5 --- .../src/session_config.rs | 26 +++++++++++---- .../SessionRuntime/SessionRuntimeWorker.swift | 33 +++++++++++++++++-- .../SessionRuntime/SessionKeyMap.swift | 18 ++++++++++ .../SessionOutputStateTests.swift | 24 ++++++++++++++ .../UI/MediaKeyCaptureToSaveTests.swift | 18 ++++++++++ docs/architecture/driverless-media-keys.md | 4 +-- docs/architecture/rules-admission-review.md | 14 ++++++-- 7 files changed, 124 insertions(+), 13 deletions(-) diff --git a/Rust/KeyPathKanataHostBridge/src/session_config.rs b/Rust/KeyPathKanataHostBridge/src/session_config.rs index 58eaf0b8c..d5860f023 100644 --- a/Rust/KeyPathKanataHostBridge/src/session_config.rs +++ b/Rust/KeyPathKanataHostBridge/src/session_config.rs @@ -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 { @@ -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, @@ -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. @@ -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, @@ -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)) @@ -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), @@ -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)); diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index c6dfbc5d2..5be9d56a9 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -26,6 +26,9 @@ public final class SessionRuntimeWorker { private var signals: [DispatchSourceSignal] = [] private var outputs = SessionOutputState() private var inputs: Set = [] + /// 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 = [] private var capsRecord: SessionCapsMappingPolicy.Record? private var capsOwner: SessionCapsMappingPolicy.Owner? private var capsDirectory: URL? @@ -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 { @@ -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 @@ -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? { + 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 diff --git a/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift b/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift index c3f85868f..e00032509 100644 --- a/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift +++ b/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift @@ -30,6 +30,24 @@ public enum SessionKeyMap { consumerUsageToMediaKeyType[usage] } + private static let mediaKeyTypeToConsumerUsage = Dictionary( + uniqueKeysWithValues: consumerUsageToMediaKeyType.map { ($0.value, $0.key) } + ) + + /// 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, diff --git a/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift index f19da9672..f8fabe8fc 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift @@ -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) + } + } } diff --git a/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift b/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift index 67d61d308..38c72dfaf 100644 --- a/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift +++ b/Tests/KeyPathTests/UI/MediaKeyCaptureToSaveTests.swift @@ -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)") } + } } diff --git a/docs/architecture/driverless-media-keys.md b/docs/architecture/driverless-media-keys.md index d9ec5cb61..6cc1887aa 100644 --- a/docs/architecture/driverless-media-keys.md +++ b/docs/architecture/driverless-media-keys.md @@ -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 diff --git a/docs/architecture/rules-admission-review.md b/docs/architecture/rules-admission-review.md index 1da6b4b06..8c144dacd 100644 --- a/docs/architecture/rules-admission-review.md +++ b/docs/architecture/rules-admission-review.md @@ -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 +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. @@ -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.