From 7f8be468ebf674ddad9145093c754db52f5e9bd4 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 20 Oct 2026 06:25:52 -0700 Subject: [PATCH] Driverless media keys, part 1: the session posts them (phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge's output rule accepts the ten consumer-page keys the worker can post (volume up/down/mute, brightness up/down, play/pause, next/previous, fast-forward/rewind), a fixed table in session_config.rs. The worker keeps a media ledger beside its key ledger and posts each as an NSEvent system-defined (subtype 8) event tagged like a key event, dropping Kanata's repeats because macOS repeats a held media key itself; cleanup releases held media keys like keys. Because the capability profile asks the bridge, the catalog's Function layer, media mode of macOS Function Keys and the eight media system actions became available with no Swift change; the hard-coded refusals remain only as the no-bridge fallback. Inputs stay keyboard-page until part 2 (tap reads system-defined events). Tests that used a media key as their "unsupported output" fixture now use Fn, which the generator passes through and the bridge refuses. See docs/architecture/rules-admission-review.md §4.5 and docs/architecture/driverless-media-keys.md. Co-Authored-By: Claude Fable 5.1 --- .../src/session_config.rs | 61 ++++++++++++++----- .../Services/Import/QMKKeycodeMapping.swift | 4 +- .../SessionRuntime/SessionRuntimeWorker.swift | 40 +++++++++++- .../SessionRuntime/SessionKeyMap.swift | 28 ++++++++- .../SessionRuntime/SessionOutputState.swift | 43 +++++++++++++ .../AppSpecificSourceRoutingTests.swift | 3 +- .../Managers/AppKeymapSaveTests.swift | 23 ++++--- .../Managers/SaveCoordinatorTests.swift | 5 +- .../RuleCollectionsManagerTests.swift | 7 ++- .../Services/ConfigurationServiceTests.swift | 2 +- .../BackendCapabilityProfileTests.swift | 26 ++++---- ...eratedSessionProfileEligibilityTests.swift | 12 ++-- .../RuleEnvironmentNeedsTests.swift | 5 +- .../SessionCapsGeneratedConfigTests.swift | 31 +++++----- .../SessionOutputStateTests.swift | 56 +++++++++++++++++ .../Support/SessionBridgeTestFixture.swift | 12 ++++ .../UI/SystemActionInfoTests.swift | 26 ++++++-- Tests/KeyPathTests/ViewModelSyncTests.swift | 8 +-- docs/architecture/driverless-media-keys.md | 3 + docs/architecture/rules-admission-review.md | 54 ++++++++++------ 20 files changed, 346 insertions(+), 103 deletions(-) diff --git a/Rust/KeyPathKanataHostBridge/src/session_config.rs b/Rust/KeyPathKanataHostBridge/src/session_config.rs index f963bcea1..58eaf0b8c 100644 --- a/Rust/KeyPathKanataHostBridge/src/session_config.rs +++ b/Rust/KeyPathKanataHostBridge/src/session_config.rs @@ -8,10 +8,22 @@ fn key_supported(key: KeyCode, usages: &[u32]) -> bool { oscode_supported(key.into(), usages) } -/// The one output rule: keyboard page (7) and in the host's key map. Consumer -/// keys (media, brightness) and Fn are on other pages, so they are refused here. +/// Consumer-page (0x0C) usages the session worker posts as macOS +/// system-defined media events (docs/architecture/driverless-media-keys.md +/// §Recommendation): volume up/down/mute, brightness up/down, play/pause, +/// next/previous, fast-forward/rewind. Fixed by the worker's code, not by the +/// host's keyboard map, so it is a constant here rather than a parameter. +pub(crate) const CONSUMER_OUTPUT_CODES: [u32; 10] = + [0xE9, 0xEA, 0xE2, 0x6F, 0x70, 0xCD, 0xB5, 0xB6, 0xB3, 0xB4]; + +/// The one output rule: keyboard page (7) and in the host's key map, or one +/// of the consumer-page media keys the worker can post. Everything else (Fn, +/// other consumer usages) is refused. fn oscode_supported(code: OsCode, usages: &[u32]) -> bool { - PageCode::try_from(code).is_ok_and(|pc| pc.page == 7 && usages.contains(&pc.code)) + PageCode::try_from(code).is_ok_and(|pc| { + (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, @@ -327,18 +339,39 @@ mod tests { assert_eq!(output_key_name_supported("home", HOST), Some(true)); // Native Caps is always an output, even though the host omits it. assert_eq!(output_key_name_supported("caps", HOST), Some(true)); - // Consumer page: media and brightness are refused. + // Consumer page: the media and brightness keys the worker posts. for name in [ "volu", "voldwn", "mute", "pp", "next", "prev", "brup", "brdown", ] { - assert_eq!(output_key_name_supported(name, HOST), Some(false), "{name}"); + assert_eq!(output_key_name_supported(name, HOST), Some(true), "{name}"); } + // Consumer page, but not one the worker posts. + assert_eq!(output_key_name_supported("calc", HOST), Some(false)); // Keyboard page but outside the host map. assert_eq!(output_key_name_supported("f13", HOST), Some(false)); // Not a Kanata key at all. assert_eq!(output_key_name_supported("not-a-key", HOST), None); } + #[test] + fn consumer_output_table_matches_kanata_names_and_inputs_stay_keyboard_only() { + for (name, code) in [ + ("volu", 0xE9), ("vold", 0xEA), ("mute", 0xE2), ("brup", 0x6F), ("brdn", 0x70), + ("pp", 0xCD), ("next", 0xB5), ("prev", 0xB6), + ] { + let os = kanata_parser::keys::str_to_oscode(name).unwrap(); + let pc = PageCode::try_from(os).unwrap(); + 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. + let cfg = kanata_parser::cfg::new_from_str("(defsrc volu)(deflayer base a)", Default::default()).unwrap(); + assert!(!supported(&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)); + } + #[test] fn native_caps_output_does_not_require_capturing_physical_caps() { for action in [ @@ -566,7 +599,7 @@ mod tests { "((input virtual vk_test))" )); assert!(!eligible( - "(switch ((input virtual vk_test)) volu break () b break)", + "(switch ((input virtual vk_test)) calc break () b break)", "((input virtual vk_test))" )); assert!(!eligible("(unicode λ)", "((input virtual vk_test))")); @@ -582,10 +615,10 @@ mod tests { "(multi (release-layer nav) XX (push-msg \"layer:base\"))", true, ), - ("(multi (release-layer nav) volu)", false), + ("(multi (release-layer nav) calc)", false), ("(multi (release-layer nav) (unicode λ))", false), ("(release-key lctl)", false), - ("(release-key volu)", false), + ("(release-key calc)", false), ] { let cfg = kanata_parser::cfg::new_from_str( &format!( @@ -605,14 +638,14 @@ mod tests { for n in 0..index { definitions.push_str(&format!("vk_{n} XX ")); } - definitions.push_str("vk_media volu)(defsrc a)(deflayer base a)"); + definitions.push_str("vk_media calc)(defsrc a)(deflayer base a)"); let cfg = kanata_parser::cfg::new_from_str(&definitions, Default::default()).unwrap(); assert!(!supported(&cfg, &[4, 5])); } #[test] fn session_virtual_switch_rejects_unsupported_virtual_key_action() { - let cfg = kanata_parser::cfg::new_from_str("(defvirtualkeys vk_test volu)(defalias kp-a (switch ((input virtual vk_test)) b break () a break))(defsrc a)(deflayer base @kp-a)", Default::default()).unwrap(); + let cfg = kanata_parser::cfg::new_from_str("(defvirtualkeys vk_test calc)(defalias kp-a (switch ((input virtual vk_test)) b break () a break))(defsrc a)(deflayer base @kp-a)", Default::default()).unwrap(); assert!(!supported(&cfg, &[4, 5])); } @@ -627,7 +660,7 @@ mod tests { #[test] fn legacy_session_startup_unsupported() { let cfg = kanata_parser::cfg::new_from_str( - "(defcfg alias-to-trigger-on-load boot)(defalias boot volu)(defsrc a)(deflayer base a)", + "(defcfg alias-to-trigger-on-load boot)(defalias boot calc)(defsrc a)(deflayer base a)", Default::default(), ) .unwrap(); @@ -647,7 +680,7 @@ mod tests { #[test] fn legacy_session_override_unsupported() { let cfg = kanata_parser::cfg::new_from_str( - "(defoverrides (a) (volu))(defsrc a)(deflayer base a)", + "(defoverrides (a) (calc))(defsrc a)(deflayer base a)", Default::default(), ) .unwrap(); @@ -666,13 +699,13 @@ mod tests { #[test] fn legacy_session_chord_unsupported() { - let cfg = kanata_parser::cfg::new_from_str("(defcfg concurrent-tap-hold yes)(defchordsv2 (a b) volu 100 all-released ())(defsrc a b)(deflayer base a b)", Default::default()).unwrap(); + let cfg = kanata_parser::cfg::new_from_str("(defcfg concurrent-tap-hold yes)(defchordsv2 (a b) calc 100 all-released ())(defsrc a b)(deflayer base a b)", Default::default()).unwrap(); assert_eq!(supported(&cfg, &[4, 5, 41, 224]), false); } #[test] fn legacy_session_chord_nested_unsupported() { - let cfg = kanata_parser::cfg::new_from_str("(defcfg concurrent-tap-hold yes)(defchordsv2 (a b) (macro volu) 100 all-released ())(defsrc a b)(deflayer base a b)", Default::default()).unwrap(); + let cfg = kanata_parser::cfg::new_from_str("(defcfg concurrent-tap-hold yes)(defchordsv2 (a b) (macro calc) 100 all-released ())(defsrc a b)(deflayer base a b)", Default::default()).unwrap(); assert_eq!(supported(&cfg, &[4, 5, 41, 224]), false); } diff --git a/Sources/KeyPathAppKit/Services/Import/QMKKeycodeMapping.swift b/Sources/KeyPathAppKit/Services/Import/QMKKeycodeMapping.swift index 894504d95..118ec3d4d 100644 --- a/Sources/KeyPathAppKit/Services/Import/QMKKeycodeMapping.swift +++ b/Sources/KeyPathAppKit/Services/Import/QMKKeycodeMapping.swift @@ -209,8 +209,8 @@ enum QMKKeycodeMapping { // NX_KEYTYPE_PREVIOUS = 18 // NX_KEYTYPE_FAST = 19 // NX_KEYTYPE_REWIND = 20 - // NX_KEYTYPE_BRIGHTNESS_UP = 21 (also 0xBD in QMK) - // NX_KEYTYPE_BRIGHTNESS_DOWN = 22 (also 0xBE in QMK) + // NX_KEYTYPE_ILLUMINATION_UP = 21 (keyboard backlight; also 0xBD in QMK) + // NX_KEYTYPE_ILLUMINATION_DOWN = 22 (display brightness is 2/3; see driverless-media-keys.md) // NX_KEYTYPE_EJECT = 14 // // These cannot be represented as standard CGKeyCode values. diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 7eebfd26f..c6dfbc5d2 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -510,6 +510,20 @@ public final class SessionRuntimeWorker { } continue } + if event.usagePage == SessionKeyMap.consumerPage { + // Media keys: posted as system-defined events, held in + // their own ledger so a stop releases them too. + var nextOutputs = outputs + guard let media = try nextOutputs.translateMedia(event) else { continue } + if media.isDown { outputs = nextOutputs; writeReport(.running) } + guard environmentObserver?.check() == true else { + finish(.failed, reason: "environment-observer-unavailable") + } + guard postMedia(media) else { finish(.failed, reason: "output-event-unavailable") } + outputCount += 1 + if !media.isDown { outputs = nextOutputs; writeReport(.running) } + continue + } var nextOutputs = outputs let output = try nextOutputs.translate(event) // Publish a press before posting it, but retain a release in @@ -570,6 +584,24 @@ public final class SessionRuntimeWorker { } } + /// `NSEvent.otherEvent(.systemDefined, subtype 8)`: data1 carries the + /// NX key type in the high word and 0xA (down) / 0xB (up) in the next + /// byte. Tagged like a key event so this worker's own tap ignores it. + private func postMedia(_ output: SessionMediaOutput) -> Bool { + guard let event = Self.mediaEvent(keyType: output.keyType, isDown: output.isDown)?.cgEvent else { return false } + event.setIntegerValueField(.eventSourceUserData, value: Self.outputTag) + event.post(tap: .cgSessionEventTap) + return true + } + + nonisolated static func mediaEvent(keyType: Int, isDown: Bool) -> NSEvent? { + NSEvent.otherEvent( + with: .systemDefined, location: .zero, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: 0, context: nil, + subtype: 8, data1: (keyType << 16) | ((isDown ? 0xA : 0xB) << 8), data2: -1 + ) + } + private func post(_ output: SessionKeyOutput) -> Bool { guard let source = CGEventSource(stateID: .privateState), let event = CGEvent(keyboardEventSource: source, virtualKey: output.keyCode, keyDown: output.isDown) @@ -736,7 +768,8 @@ public final class SessionRuntimeWorker { /// The injected posting closure lets tests exercise this production loop /// without creating a CGEvent or terminating a host process. static func releaseOwnedOutputs( - _ outputs: inout SessionOutputState, post: (SessionKeyOutput) -> Bool + _ outputs: inout SessionOutputState, post: (SessionKeyOutput) -> Bool, + postMedia: (SessionMediaOutput) -> Bool = { _ in true } ) { var pending = outputs for output in pending.releaseAll() { @@ -744,6 +777,9 @@ public final class SessionRuntimeWorker { _ = try? outputs.translate(.init(value: 0, usagePage: 7, usage: usage)) } } + for media in pending.releaseAllMedia() where postMedia(media) { + _ = try? outputs.translateMedia(.init(value: 0, usagePage: SessionKeyMap.consumerPage, usage: media.usage)) + } } private func finish( @@ -763,7 +799,7 @@ public final class SessionRuntimeWorker { // Allocation failure during shutdown cannot recursively exit before // the power ACK. Preserve unposted releases in the crash ledger. var remaining = outputs - Self.releaseOwnedOutputs(&remaining, post: post) + Self.releaseOwnedOutputs(&remaining, post: post, postMedia: postMedia) outputs = remaining if let owner = capsOwner, let directory = capsDirectory { do { diff --git a/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift b/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift index 89aaf600c..c3f85868f 100644 --- a/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift +++ b/Sources/KeyPathCore/SessionRuntime/SessionKeyMap.swift @@ -1,10 +1,34 @@ import Foundation /// Physical macOS virtual key codes to USB keyboard usages, independent of the -/// active character layout. Fn and consumer keys deliberately remain unmapped: -/// session events cannot represent every HID key or identify its source device. +/// active character layout. Fn and most consumer keys remain unmapped: session +/// events cannot represent every HID key or identify its source device. The +/// media keys macOS carries as system-defined events are the exception, as +/// outputs (`mediaKeyType(forUsage:)`). public enum SessionKeyMap { public static let keyboardPage: UInt32 = 7 + public static let consumerPage: UInt32 = 0x0C + + /// Consumer-page usages the worker posts as `NSEvent` system-defined + /// (subtype 8) media events, by their `NX_KEYTYPE_*` code. The bridge's + /// output rule accepts exactly these (`CONSUMER_OUTPUT_CODES` in + /// session_config.rs); see docs/architecture/driverless-media-keys.md §2. + public static let consumerUsageToMediaKeyType: [UInt32: Int] = [ + 0xE9: 0, // volume up NX_KEYTYPE_SOUND_UP + 0xEA: 1, // volume down NX_KEYTYPE_SOUND_DOWN + 0x6F: 2, // brightness up NX_KEYTYPE_BRIGHTNESS_UP + 0x70: 3, // brightness down + 0xE2: 7, // mute NX_KEYTYPE_MUTE + 0xCD: 16, // play/pause NX_KEYTYPE_PLAY + 0xB5: 17, // next NX_KEYTYPE_NEXT + 0xB6: 18, // previous NX_KEYTYPE_PREVIOUS + 0xB3: 19, // fast-forward NX_KEYTYPE_FAST + 0xB4: 20 // rewind NX_KEYTYPE_REWIND + ] + + public static func mediaKeyType(forUsage usage: UInt32) -> Int? { + consumerUsageToMediaKeyType[usage] + } public static let keyCodeToUsage: [UInt16: UInt32] = [ 0: 4, 1: 22, 2: 7, 3: 9, 4: 11, 5: 10, diff --git a/Sources/KeyPathCore/SessionRuntime/SessionOutputState.swift b/Sources/KeyPathCore/SessionRuntime/SessionOutputState.swift index d40894769..3f2e9fc9b 100644 --- a/Sources/KeyPathCore/SessionRuntime/SessionOutputState.swift +++ b/Sources/KeyPathCore/SessionRuntime/SessionOutputState.swift @@ -20,6 +20,21 @@ public struct SessionKeyOutput: Equatable, Sendable { public let flags: UInt64 } +/// A media key the worker posts as a system-defined event. No repeat: macOS +/// repeats a held media key itself (driverless-media-keys.md §2c). +public struct SessionMediaOutput: Equatable, Sendable { + public let usage: UInt32 + /// `NX_KEYTYPE_*`, from `SessionKeyMap.mediaKeyType(forUsage:)`. + public let keyType: Int + public let isDown: Bool + + public init(usage: UInt32, keyType: Int, isDown: Bool) { + self.usage = usage + self.keyType = keyType + self.isDown = isDown + } +} + /// Tracks only keys actually emitted by this runtime. Failure cleanup releases /// that set, never a guessed list of every key on the user's keyboard. public struct SessionOutputState: Sendable { @@ -29,9 +44,37 @@ public struct SessionOutputState: Sendable { } public private(set) var heldUsages: Set = [] + /// Consumer-page media keys currently down, released like keys on cleanup. + public private(set) var heldMediaUsages: Set = [] public init() {} + /// A consumer-page event as the media output to post, or nil for a repeat + /// (dropped: the system repeats a held media key on its own). + public mutating func translateMedia(_ event: KanataHostBridgePassthruOutputEvent) throws -> SessionMediaOutput? { + guard event.usagePage == SessionKeyMap.consumerPage, + let keyType = SessionKeyMap.mediaKeyType(forUsage: event.usage), event.value <= 2 + else { + throw Failure.unsupportedEvent(page: event.usagePage, usage: event.usage, value: event.value) + } + if event.value == 2 { + guard heldMediaUsages.contains(event.usage) else { throw Failure.repeatWithoutPress(usage: event.usage) } + return nil + } + if event.value == 0 { + heldMediaUsages.remove(event.usage) + } else { + heldMediaUsages.insert(event.usage) + } + return SessionMediaOutput(usage: event.usage, keyType: keyType, isDown: event.value != 0) + } + + public mutating func releaseAllMedia() -> [SessionMediaOutput] { + heldMediaUsages.sorted().compactMap { usage in + try? translateMedia(.init(value: 0, usagePage: SessionKeyMap.consumerPage, usage: usage)) ?? nil + } + } + public var modifierFlags: UInt64 { heldUsages.reduce(0) { $0 | SessionKeyMap.modifierFlags(for: $1) } } diff --git a/Tests/KeyPathTests/AppContext/AppSpecificSourceRoutingTests.swift b/Tests/KeyPathTests/AppContext/AppSpecificSourceRoutingTests.swift index 401d958b4..2b9fab4fc 100644 --- a/Tests/KeyPathTests/AppContext/AppSpecificSourceRoutingTests.swift +++ b/Tests/KeyPathTests/AppContext/AppSpecificSourceRoutingTests.swift @@ -34,7 +34,8 @@ final class AppSpecificSourceRoutingTests: XCTestCase { for (output, expectedValid) in [ ("d", true), ("(switch ((input virtual vk_test_app)) (unicode ä) break () d break)", false), - ("volu", false) + ("volu", true), // phase 6: media keys are session outputs + ("fn", false) ] { let keymap = AppKeymap(bundleIdentifier: "test.app", displayName: "Test App", overrides: [ AppKeyOverride(inputKey: "c", action: .rawKanata(output)) diff --git a/Tests/KeyPathTests/Managers/AppKeymapSaveTests.swift b/Tests/KeyPathTests/Managers/AppKeymapSaveTests.swift index 6704a8977..4397cc0c5 100644 --- a/Tests/KeyPathTests/Managers/AppKeymapSaveTests.swift +++ b/Tests/KeyPathTests/Managers/AppKeymapSaveTests.swift @@ -392,21 +392,20 @@ final class AppKeymapSaveTests: KeyPathTestCase { } } - // Stored media profiles remain intact when an app edit cannot run them. - // Generate them for inspection, but require the real validator to reject - // before any app source, include, main config, or journal is written. - for selected in [RuleCollectionIdentifier.macFunctionKeys] { + // A stored rule the session cannot run (Fn as an output: the generator + // passes the name through and the bridge refuses it; media keys are + // posted since phase 6) stays intact when an app edit cannot run + // alongside it. Generate for inspection, but require the real validator + // to reject before any app source, include, main config, or journal is + // written. + let unsupportedRule = CustomRule(input: "f9", action: .keystroke(key: "fn")) + for selected in ["custom rule f9 → fn"] { let collections = RuleCollectionCatalog().defaultCollections().map { original in var collection = original - collection.isEnabled = collection.id == selected - if collection.id == RuleCollectionIdentifier.macFunctionKeys { - collection.functionKeyMode = .media - collection.mappings = RuleCollectionCatalog.functionKeyMappings(for: .media) - } + collection.isEnabled = false return collection } - XCTAssertTrue(collections.contains { $0.id == selected && $0.isEnabled }) - try await withFixture(collections: collections, customRules: [custom]) { fixture in + try await withFixture(collections: collections, customRules: [custom, unsupportedRule]) { fixture in var reloads = 0 var sourceBefore: [String: Data] = [:] for name in ["RuleCollections.json", "CustomRules.json"] { @@ -418,7 +417,7 @@ final class AppKeymapSaveTests: KeyPathTestCase { reloads += 1 return Self.reload(.applied) } - XCTAssertFalse(result.success, "\(selected) must reject unsupported media output") + XCTAssertFalse(result.success, "\(selected) must reject an unsupported output") XCTAssertTrue(result.error?.localizedDescription.contains("driverless session") == true) try self.assertOriginalFiles(fixture) for (name, bytes) in sourceBefore { diff --git a/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift b/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift index 1fcb4bc37..808e042c4 100644 --- a/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift +++ b/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift @@ -140,10 +140,11 @@ final class SaveCoordinatorTests: KeyPathTestCase { XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), original) } - /// AI/import/editor text must pass driverless admission, not just Kanata syntax validation. + /// AI/import/editor text must pass driverless admission, not just Kanata syntax + /// validation. Fn is a key the session cannot emit (media keys are posted since phase 6). func testGeneratedConsumerOutputRejectsBeforePersistingOrReloading() async throws { try await assertDriverlessGeneratedConfigRejected( - "(defcfg)\n(defsrc a)\n(deflayer base volu)" + "(defcfg)\n(defsrc a)\n(deflayer base fn)" ) } diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift index 349ac564f..ea895bf73 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift @@ -320,9 +320,10 @@ final class RuleCollectionsManagerTests: XCTestCase { manager.onError = { refusalMessages.append($0) } for unsupported in [ - // Caps to F18 is no longer refused: the reserved key moves instead. - CustomRule(input: "caps", action: .keystroke(key: "pp"), isEnabled: true), - CustomRule(input: "f8", action: .keystroke(key: "pp"), isEnabled: true), + // Caps to F18 is no longer refused: the reserved key moves instead, and + // media keys are posted (phase 6); Fn is a key the session cannot emit. + CustomRule(input: "caps", action: .keystroke(key: "fn"), isEnabled: true), + CustomRule(input: "f8", action: .keystroke(key: "fn"), isEnabled: true), ] { let accepted = await manager.saveCustomRule(unsupported) XCTAssertFalse(accepted, "Unsupported session behavior must be refused") diff --git a/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift b/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift index a736f376d..cd3bb8fa6 100644 --- a/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift +++ b/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift @@ -427,7 +427,7 @@ class ConfigurationServiceTests: KeyPathTestCase { } defer { observer.cancel() } do { - try await configService.saveConfiguration(input: "caps", output: "pp") + try await configService.saveConfiguration(input: "caps", output: "fn") XCTFail("Unsupported Caps output must be rejected before writing") } catch { XCTAssertTrue(error.localizedDescription.contains("driverless session"), error.localizedDescription) diff --git a/Tests/KeyPathTests/SessionRuntime/BackendCapabilityProfileTests.swift b/Tests/KeyPathTests/SessionRuntime/BackendCapabilityProfileTests.swift index af65a7a7f..3c74911fc 100644 --- a/Tests/KeyPathTests/SessionRuntime/BackendCapabilityProfileTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/BackendCapabilityProfileTests.swift @@ -24,32 +24,30 @@ final class BackendCapabilityProfileTests: XCTestCase { func testBridgeAnswersTheSessionOutputRule() throws { let profile = try bridgeProfile() - for key in ["a", "f1", "f12", "home", "del", "kp5", "caps", "C-S-n", "M-spc"] { + for key in ["a", "f1", "f12", "home", "del", "kp5", "caps", "C-S-n", "M-spc", + // Phase 6: the media keys the worker posts as system-defined events. + "volu", "vold", "voldwn", "mute", "pp", "next", "prev", "brup", "brdn", "brdown"] + { XCTAssertTrue(profile.canEmit(kanataKey: key), key) } - for key in ["volu", "vold", "voldwn", "mute", "pp", "next", "prev", "brup", "brdn", "brdown"] { - XCTAssertFalse(profile.canEmit(kanataKey: key), key) - } + // Consumer page, but not a key the worker posts. + XCTAssertFalse(profile.canEmit(kanataKey: "calc")) XCTAssertFalse(profile.supportsDeviceTargeting) } func testFormerHardCodedListsGiveTheSameAnswersThroughTheBridge() throws { BackendCapabilityProfile.testOverride = try bridgeProfile() - // SystemActionInfo: only the eight consumer actions, as before. + // SystemActionInfo: every offered action, media included (phase 6). let unsupported = Set(SystemActionInfo.allActions.filter { !$0.isSupportedInDriverlessSession }.map(\.id)) - XCTAssertEqual(unsupported, ["play-pause", "next-track", "prev-track", "mute", - "volume-up", "volume-down", "brightness-up", "brightness-down"]) - // FunctionKeyMode: media needs consumer output, function does not. - XCTAssertFalse(FunctionKeyMode.media.isSupportedInDriverlessSession) + XCTAssertEqual(unsupported, []) + // FunctionKeyMode: both modes emit what the worker can post. + XCTAssertTrue(FunctionKeyMode.media.isSupportedInDriverlessSession) XCTAssertTrue(FunctionKeyMode.function.isSupportedInDriverlessSession) - // Catalog: the Function layer was the only unavailable collection. + // Catalog: nothing built in is unavailable any more. let unavailable = RuleCollectionCatalog().originalCollections() .filter { RuleCollectionCatalog.driverlessUnavailableReason(for: $0.id) != nil }.map(\.id) - XCTAssertEqual(unavailable, [RuleCollectionIdentifier.funLayer]) - // The sentence is the admission engine's (phase 4d): it names the key. - let reason = RuleCollectionCatalog.driverlessUnavailableReason(for: RuleCollectionIdentifier.funLayer) ?? "" - XCTAssertTrue(reason.contains("which the current backend can't send"), reason) + XCTAssertEqual(unavailable, []) } func testModifierPrefixesAreStrippedBeforeAsking() { diff --git a/Tests/KeyPathTests/SessionRuntime/GeneratedSessionProfileEligibilityTests.swift b/Tests/KeyPathTests/SessionRuntime/GeneratedSessionProfileEligibilityTests.swift index f95c7fa87..2f9066d08 100644 --- a/Tests/KeyPathTests/SessionRuntime/GeneratedSessionProfileEligibilityTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/GeneratedSessionProfileEligibilityTests.swift @@ -113,9 +113,9 @@ final class GeneratedSessionProfileEligibilityTests: KeyPathTestCase { try collectionBytes.write(to: collectionsURL) try ruleBytes.write(to: rulesURL) do { - // Caps to F18 is admitted now (the reservation moves); a media - // output still has no driverless equivalent. - try await service.writeConfigurationContent("(defsrc caps)(deflayer base pp)") + // Caps to F18 is admitted now (the reservation moves) and media keys + // are posted (phase 6); Fn is a key the session cannot emit. + try await service.writeConfigurationContent("(defsrc caps)(deflayer base fn)") XCTFail("Unsupported Caps output must fail before writing") } catch { XCTAssertTrue(error.localizedDescription.contains("driverless session"), error.localizedDescription) @@ -125,11 +125,11 @@ final class GeneratedSessionProfileEligibilityTests: KeyPathTestCase { do { try await service.operationGate.withOperation { @MainActor permit in _ = try await service.stageRawConfiguration( - content: "(defsrc q)(deflayer base volu)", expectedContent: existing, + content: "(defsrc q)(deflayer base fn)", expectedContent: existing, mutationPermit: permit ) } - XCTFail("Unsupported media output must fail before staging") + XCTFail("Unsupported output must fail before staging") } catch { XCTAssertTrue(error.localizedDescription.contains("driverless session"), error.localizedDescription) } @@ -159,7 +159,7 @@ final class GeneratedSessionProfileEligibilityTests: KeyPathTestCase { try await service.requireSessionEligibleConfiguration(candidate) XCTAssertFalse(FileManager.default.fileExists(atPath: service.configurationPath)) XCTAssertEqual(try String(contentsOf: includeURL, encoding: .utf8), validInclude) - try "(deflayer base volu)".write(to: includeURL, atomically: true, encoding: .utf8) + try "(deflayer base fn)".write(to: includeURL, atomically: true, encoding: .utf8) do { try await service.requireSessionEligibleConfiguration(candidate) XCTFail("Unsupported included actions must be rejected") diff --git a/Tests/KeyPathTests/SessionRuntime/RuleEnvironmentNeedsTests.swift b/Tests/KeyPathTests/SessionRuntime/RuleEnvironmentNeedsTests.swift index 75733263b..b5a55063b 100644 --- a/Tests/KeyPathTests/SessionRuntime/RuleEnvironmentNeedsTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/RuleEnvironmentNeedsTests.swift @@ -33,9 +33,8 @@ final class RuleEnvironmentNeedsTests: XCTestCase { let unmet = RuleCollectionNeedsExtractor.unmet(needs, profile: profile) if !unmet.isEmpty { unmetByCollection[collection.id] = unmet } } - XCTAssertEqual(Set(unmetByCollection.keys), [RuleCollectionIdentifier.funLayer]) - let funUnmet = Set(unmetByCollection[RuleCollectionIdentifier.funLayer] ?? []) - XCTAssertEqual(funUnmet, Set(["brdn", "brup", "mute", "next", "pp", "prev", "vold", "volu"].map { .emits(key: $0) })) + // Phase 6: the Function layer's media keys are outputs the worker posts. + XCTAssertEqual(Set(unmetByCollection.keys), []) } func testCapsLockInputIsInferredAndMetByManagedCaps() throws { diff --git a/Tests/KeyPathTests/SessionRuntime/SessionCapsGeneratedConfigTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionCapsGeneratedConfigTests.swift index b73faa44f..c4c8dcb30 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionCapsGeneratedConfigTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionCapsGeneratedConfigTests.swift @@ -5,8 +5,19 @@ import KeyPathRulesCore import XCTest final class SessionCapsGeneratedConfigTests: XCTestCase { - func testUnsupportedMediaFunctionKeyModeIsRejected() throws { + override func setUpWithError() throws { + try super.setUpWithError() try SessionBridgeTestFixture.requireAvailable() + BackendCapabilityProfile.testOverride = SessionBridgeTestFixture.profile + } + + override func tearDown() { + BackendCapabilityProfile.testOverride = nil + super.tearDown() + } + + /// Phase 6: media mode emits the keys the worker posts, so it is admitted. + func testMediaFunctionKeyModeIsAdmitted() throws { var collection = try XCTUnwrap(RuleCollectionCatalog().defaultCollections().first { $0.id == RuleCollectionIdentifier.macFunctionKeys }) @@ -19,12 +30,11 @@ final class SessionCapsGeneratedConfigTests: XCTestCase { let admission = SessionCapsRuntimeSupport.validate( configPath: path.path, runtimeHost: SessionBridgeTestFixture.runtimeHost, requireSelection: false ) - XCTAssertFalse(FunctionKeyMode.media.isSupportedInDriverlessSession) - if case .valid = admission.result { XCTFail("Unsupported media output must remain rejected") } + XCTAssertTrue(FunctionKeyMode.media.isSupportedInDriverlessSession) + guard case .valid = admission.result else { return XCTFail("media mode refused: \(admission.result)") } } func testEveryBuiltInDefaultWithLeaderIsAdmitted() throws { - try SessionBridgeTestFixture.requireAvailable() let catalog = RuleCollectionCatalog().defaultCollections() XCTAssertEqual(catalog.count, 22, "Review new catalog entries for driverless support") for candidate in catalog { @@ -41,20 +51,13 @@ final class SessionCapsGeneratedConfigTests: XCTestCase { let admission = SessionCapsRuntimeSupport.validate( configPath: path.path, runtimeHost: SessionBridgeTestFixture.runtimeHost, requireSelection: false ) - if let reason = RuleCollectionCatalog.driverlessUnavailableReason(for: candidate.id) { - XCTAssertEqual(candidate.id, RuleCollectionIdentifier.funLayer) - XCTAssertTrue(reason.contains("which the current backend can't send"), reason) - if case .valid = admission.result { XCTFail("Review support policy: previously unsupported collection now validates") } - } else if case .valid = admission.result { - // Every offered default must be admitted by the real bridge. - } else { - XCTFail("\(candidate.name) default refused: \(admission.result)") - } + // Phase 6: every offered default, the Function layer included, is admitted. + XCTAssertNil(RuleCollectionCatalog.driverlessUnavailableReason(for: candidate.id), candidate.name) + guard case .valid = admission.result else { return XCTFail("\(candidate.name) default refused: \(admission.result)") } } } func testEveryOfferedSystemActionIsAdmitted() throws { - try SessionBridgeTestFixture.requireAvailable() for action in SystemActionInfo.allActions { let config = "(defsrc a)(deflayer base \(action.kanataOutput))" let path = FileManager.default.temporaryDirectory.appendingPathComponent("action-\(UUID().uuidString).kbd") diff --git a/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift index 5956429bf..f19da9672 100644 --- a/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift +++ b/Tests/KeyPathTests/SessionRuntime/SessionOutputStateTests.swift @@ -1,3 +1,4 @@ +@testable import KeyPathAppKit import KeyPathCore import XCTest @@ -132,4 +133,59 @@ final class SessionOutputStateTests: XCTestCase { XCTAssertTrue(releases.allSatisfy { !$0.isDown }) XCTAssertTrue(outputs.heldUsages.isEmpty) } + + // MARK: - Media keys (phase 6) + + private func media(_ usage: UInt32, _ value: UInt64) -> KanataHostBridgePassthruOutputEvent { + .init(value: value, usagePage: SessionKeyMap.consumerPage, usage: usage) + } + + func testMediaKeysTranslateToSystemDefinedKeyTypesAndDropRepeats() throws { + var state = SessionOutputState() + let down = try XCTUnwrap(try state.translateMedia(media(0xE9, 1))) + XCTAssertEqual(down, SessionMediaOutput(usage: 0xE9, keyType: 0, isDown: true)) + XCTAssertEqual(state.heldMediaUsages, [0xE9]) + // macOS repeats a held media key itself; Kanata's repeat is dropped, not posted. + XCTAssertNil(try state.translateMedia(media(0xE9, 2))) + XCTAssertThrowsError(try state.translateMedia(media(0xEA, 2))) + let up = try XCTUnwrap(try state.translateMedia(media(0xE9, 0))) + XCTAssertFalse(up.isDown) + XCTAssertTrue(state.heldMediaUsages.isEmpty) + XCTAssertEqual(try state.translateMedia(media(0xCD, 1))?.keyType, 16) // play/pause + XCTAssertEqual(try state.translateMedia(media(0x6F, 1))?.keyType, 2) // brightness up + } + + func testUnknownConsumerUsageAndKeyboardPageAreRefusedByTheMediaLedger() { + var state = SessionOutputState() + XCTAssertThrowsError(try state.translateMedia(media(0xB7, 1))) // stop: not posted + XCTAssertThrowsError(try state.translateMedia(.init(value: 1, usagePage: 7, usage: 4))) + XCTAssertThrowsError(try state.translate(media(0xE9, 1)), "the key ledger never takes a media key") + XCTAssertTrue(state.heldMediaUsages.isEmpty) + } + + @MainActor + func testCleanupReleasesHeldMediaKeysAndKeysIndependently() throws { + var state = SessionOutputState() + _ = try state.translate(.init(value: 1, usagePage: 7, usage: 4)) + _ = try state.translateMedia(media(0xE9, 1)) + _ = try state.translateMedia(media(0xCD, 1)) + var keys: [UInt16] = [], mediaUp: [UInt32] = [] + SessionRuntimeWorker.releaseOwnedOutputs(&state, post: { keys.append($0.keyCode); return true }, + postMedia: { mediaUp.append($0.usage); return !$0.isDown && $0.usage != 0xCD }) + XCTAssertEqual(keys, [0]) + XCTAssertEqual(mediaUp, [0xCD, 0xE9]) + // A release that could not be posted stays in the ledger for the next attempt. + XCTAssertEqual(state.heldMediaUsages, [0xCD]) + XCTAssertTrue(state.heldUsages.isEmpty) + } + + func testSystemDefinedEventCarriesKeyTypeAndDirection() throws { + let down = try XCTUnwrap(SessionRuntimeWorker.mediaEvent(keyType: 0, isDown: true)) + XCTAssertEqual(down.subtype.rawValue, 8) + XCTAssertEqual((down.data1 >> 16) & 0xFFFF, 0) + XCTAssertEqual((down.data1 >> 8) & 0xFF, 0xA) + let up = try XCTUnwrap(SessionRuntimeWorker.mediaEvent(keyType: 17, isDown: false)) + XCTAssertEqual((up.data1 >> 16) & 0xFFFF, 17) + XCTAssertEqual((up.data1 >> 8) & 0xFF, 0xB) + } } diff --git a/Tests/KeyPathTests/Support/SessionBridgeTestFixture.swift b/Tests/KeyPathTests/Support/SessionBridgeTestFixture.swift index 4b23a619c..9d9dd3ab3 100644 --- a/Tests/KeyPathTests/Support/SessionBridgeTestFixture.swift +++ b/Tests/KeyPathTests/Support/SessionBridgeTestFixture.swift @@ -21,6 +21,18 @@ enum SessionBridgeTestFixture { ) } + /// The capability profile the fixture's bridge gives. Tests that compare + /// `isSupportedInDriverlessSession` with a fixture admission install this as + /// `BackendCapabilityProfile.testOverride`, since `.shared` asks whatever + /// bridge the host process finds (an installed app's, possibly older). + static var profile: BackendCapabilityProfile { + BackendCapabilityProfile(backend: .session) { key in + KanataHostBridge.sessionOutputKeySupported( + runtimeHost: runtimeHost, keyName: key, supportedUsages: SessionKeyMap.sessionOutputUsages + ) + } + } + static func requireAvailable() throws { guard FileManager.default.fileExists(atPath: libraryPath) else { if ProcessInfo.processInfo.environment["KEYPATH_SESSION_TEST_BRIDGE_PATH"] != nil { diff --git a/Tests/KeyPathTests/UI/SystemActionInfoTests.swift b/Tests/KeyPathTests/UI/SystemActionInfoTests.swift index 1e2f51ff4..ddab37819 100644 --- a/Tests/KeyPathTests/UI/SystemActionInfoTests.swift +++ b/Tests/KeyPathTests/UI/SystemActionInfoTests.swift @@ -1,23 +1,39 @@ @testable import KeyPathAppKit +@testable import KeyPathCore import KeyPathRulesCore import XCTest final class SystemActionInfoTests: XCTestCase { - func testDriverlessSupportExcludesOnlyConsumerMediaOutputs() { + /// Phase 6: with the real bridge every offered action, media included, can + /// be emitted; without a bridge answer the old consumer-key refusal stands. + func testDriverlessSupportCoversEveryActionWithTheBridge() throws { + try SessionBridgeTestFixture.requireAvailable() + BackendCapabilityProfile.testOverride = BackendCapabilityProfile(backend: .session) { key in + KanataHostBridge.sessionOutputKeySupported( + runtimeHost: SessionBridgeTestFixture.runtimeHost, keyName: key, supportedUsages: SessionKeyMap.sessionOutputUsages + ) + } + defer { BackendCapabilityProfile.testOverride = nil } + XCTAssertEqual(SystemActionInfo.allActions.filter { !$0.isSupportedInDriverlessSession }.map(\.id), []) + XCTAssertTrue(FunctionKeyMode.media.isSupportedInDriverlessSession) + XCTAssertTrue(FunctionKeyMode.function.isSupportedInDriverlessSession) + } + + func testWithoutABridgeAnswerOnlyConsumerMediaOutputsAreRefused() { + BackendCapabilityProfile.testOverride = BackendCapabilityProfile(backend: .session) { _ in nil } + defer { BackendCapabilityProfile.testOverride = nil } let unsupported = Set(SystemActionInfo.allActions.filter { !$0.isSupportedInDriverlessSession }.map(\.id)) XCTAssertEqual(unsupported, ["play-pause", "next-track", "prev-track", "mute", "volume-up", "volume-down", "brightness-up", "brightness-down"]) for output in ["home", "end", "del", "spotlight", "C-c"] { XCTAssertEqual(SystemActionInfo.find(byOutput: output)?.isSupportedInDriverlessSession, true, output) } - } - - func testDriverlessFunctionModePolicy() { XCTAssertFalse(FunctionKeyMode.media.isSupportedInDriverlessSession) - XCTAssertTrue(FunctionKeyMode.function.isSupportedInDriverlessSession) } @MainActor func testUnsupportedActionCannotReplaceMapperSelection() throws { + BackendCapabilityProfile.testOverride = BackendCapabilityProfile(backend: .session) { _ in nil } + defer { BackendCapabilityProfile.testOverride = nil } let model = MapperViewModel() let supported = try XCTUnwrap(SystemActionInfo.find(byOutput: "home")) model.selectSystemAction(supported) diff --git a/Tests/KeyPathTests/ViewModelSyncTests.swift b/Tests/KeyPathTests/ViewModelSyncTests.swift index 2784cb4be..e3f5cb616 100644 --- a/Tests/KeyPathTests/ViewModelSyncTests.swift +++ b/Tests/KeyPathTests/ViewModelSyncTests.swift @@ -73,14 +73,14 @@ struct WindowSnappingActivationModeTests { return manager } - @Test("Unsupported media output preserves activation state and sources") - func unsupportedMediaOutputRefusesBeforeJournalOrReload() async throws { + @Test("Unsupported output preserves activation state and sources") + func unsupportedOutputRefusesBeforeJournalOrReload() async throws { let manager = try await createManagerWithWindowSnapping(supportedEntrance: false) // Caps plus generated layers is supported. Retain rollback coverage with // an actually unsupported output, not the old transparent-layer defect. manager.ruleCollections.append(RuleCollection( - name: "Unsupported media", summary: "", category: .custom, - mappings: [KeyMapping(input: "f20", action: .keystroke(key: "volu"))], isEnabled: true + name: "Unsupported output", summary: "", category: .custom, // Fn: a key the session cannot emit + mappings: [KeyMapping(input: "f20", action: .keystroke(key: "fn"))], isEnabled: true )) try await manager.ruleCollectionStore.saveCollections(manager.ruleCollections) let beforeCollections = manager.ruleCollections diff --git a/docs/architecture/driverless-media-keys.md b/docs/architecture/driverless-media-keys.md index bb0f0839f..d9ec5cb61 100644 --- a/docs/architecture/driverless-media-keys.md +++ b/docs/architecture/driverless-media-keys.md @@ -264,6 +264,9 @@ 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. + ### Output: post from the worker, not the app Allow consumer-page output in admission, and have the worker post diff --git a/docs/architecture/rules-admission-review.md b/docs/architecture/rules-admission-review.md index 793f9d036..a6aa40db1 100644 --- a/docs/architecture/rules-admission-review.md +++ b/docs/architecture/rules-admission-review.md @@ -506,22 +506,40 @@ The engine and `PackDependencyChecker` are plain enums with no actor: both only value types and the immutable registry. `driverlessUnavailableReason` lost the main-actor annotation that had followed from the engine's. -### 4.5 Lowering: intent in rules, backend output in the generator - -Rules keep expressing intent (`volu`, `caps` → Hyper, "this keyboard only"). -A new generator stage, `Lowering`, maps each intent to what the active profile -supports: - -| Intent | `session` profile | `driverKit` profile | -|---|---|---| -| consumer key output | `.viaAdapter`: `push-msg "media:volume-up"`, posted by the app as an `NSSystemDefined` event (pending the media-keys spike's measurements of repeat and external-display brightness) | native key | -| Caps Lock input | HID lease to a free intermediate key (#1307/#1309), `caps` rewritten to that key in `defsrc` | native `caps` | -| device targeting | unsupported → blocking verdict, never emitted | `macos-dev-names-*` | -| layer transparency under managed Caps | computed real fall-through value per key, with an advisory verdict naming keys whose behaviour changed | `_` | - -The generator's `validKanataPassthrough` becomes a check against -`profile.outputUsages` plus the adapter table, so it cannot pass a key the -profile lacks. +### 4.5 Media keys: the worker posts them (phase 6, part 1, 2026-10-20) + +The spike (`docs/architecture/driverless-media-keys.md`) overturned the lowering idea +below: no adapter table, no `push-msg`. Measured, a background process posting +`NSEvent` system-defined events moves volume and built-in brightness, reaches the +now-playing app through the media router, and macOS repeats a held key on its own. +So the session emits media keys as Kanata's ordinary outputs: + +- **Bridge.** `session_config.rs` `oscode_supported` accepts page 7 keys in the + host's map *or* the ten consumer-page codes in `CONSUMER_OUTPUT_CODES` (volume + up/down/mute, brightness up/down, play/pause, next/previous, fast-forward/rewind). + The table is a constant, not a host parameter: what can be posted is fixed by the + worker's code, not by the keyboard. Inputs stay keyboard-page until part 2. +- **Worker.** `SessionOutputState` keeps a media ledger beside the key ledger + (`translateMedia`, `heldMediaUsages`, `releaseAllMedia`); value 2 (repeat) is + dropped because the system repeats. `SessionRuntimeWorker.postMedia` builds + `NSEvent.otherEvent(.systemDefined, subtype 8, data1: keyType << 16 | 0xA/0xB << 8)` + from `SessionKeyMap.consumerUsageToMediaKeyType`, tags it with the worker's output + tag, and posts it at the session tap; cleanup releases held media keys like keys. +- **Everything above the bridge changed by itself.** Because phase 1 made + `BackendCapabilityProfile` ask the bridge, the catalog's Function layer, media mode + 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. +- **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 + 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). + +Lowering of *other* intents (computed fall-through under managed Caps, §4.7's +registry) remains as designed below. ### 4.6 Bridge validation stays, with attribution @@ -576,8 +594,8 @@ until phase 4. | **2. Requirements** — *done (see §4.2a)* | `RuleEnvironmentNeed` (emits key, Caps input, device targeting, external app) inferred from effective mappings; `unmet(_:profile:)` checks them; `Pack.requires` for KindaVim; `conflictsWith` read in both directions; the catalog check now uses the same inference. | Whole-catalog test against the real bridge; system-action and tap/hold inference; device targeting per backend; packs declare only non-inferable needs; conflictsWith both ways. | | **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** | config-line → owner side table; Rust returns the offending element; one message renderer. CLI `doctor` lists verdicts. | end-to-end: a rule with `volu` names itself in the refusal; CLI output contract test. | -| **6. Lowering** | adapter table in the generator; media via push-msg if the spike approves; computed fall-through under managed Caps. | generator goldens per profile; fall-through advisory test. | +| **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. | Phases 1–3 are additive and can land before #1304–#1309 merge; 4–6 should follow them.