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.