From e946ed1a1391ca4638ab7c2d63d135d58d61785f Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 20 Oct 2026 09:38:44 -0700 Subject: [PATCH] Preserve software-injected keyboard shortcuts in session runtime --- .../SessionInputEventPolicy.swift | 16 +++++++ .../SessionRuntime/SessionRuntimeWorker.swift | 4 ++ .../SessionInputEventPolicyTests.swift | 46 +++++++++++++++++++ docs/bugs/session-synthetic-paste.md | 26 +++++++++++ 4 files changed, 92 insertions(+) create mode 100644 Sources/KeyPathAppKit/Services/SessionRuntime/SessionInputEventPolicy.swift create mode 100644 Tests/KeyPathTests/SessionRuntime/SessionInputEventPolicyTests.swift create mode 100644 docs/bugs/session-synthetic-paste.md diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionInputEventPolicy.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionInputEventPolicy.swift new file mode 100644 index 000000000..199f9f1f5 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionInputEventPolicy.swift @@ -0,0 +1,16 @@ +import CoreGraphics + +/// Remap hardware input, preserving shortcuts and text injected by other apps. +enum SessionInputEventPolicy { + static func isSoftwareKeyboardEvent(type: CGEventType, event: CGEvent) -> Bool { + switch type { + case .keyDown, .keyUp, .flagsChanged: + // Hardware events have no originating process. Software producers can + // set aggregate modifiers without separate physical modifier events. + event.getIntegerValueField(.eventSourceUnixProcessID) > 0 + default: + // Tap-disabled notifications must still reach the worker's recovery. + false + } + } +} diff --git a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift index 5be9d56a9..a9833334c 100644 --- a/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift +++ b/Sources/KeyPathAppKit/Services/SessionRuntime/SessionRuntimeWorker.swift @@ -389,6 +389,10 @@ public final class SessionRuntimeWorker { #endif let original = Unmanaged.passUnretained(event) if finished || event.getIntegerValueField(.eventSourceUserData) == Self.outputTag { return original } + // Aqua Voice and other software inject complete shortcuts. Sending their + // keys through Kanata loses aggregate-only modifiers (Cmd+V becomes v) + // and can contaminate the physical key/held-output state. + if SessionInputEventPolicy.isSoftwareKeyboardEvent(type: type, event: event) { return original } if type == .tapDisabledByTimeout { finish(.failed, reason: "tap-disabled-by-timeout") } diff --git a/Tests/KeyPathTests/SessionRuntime/SessionInputEventPolicyTests.swift b/Tests/KeyPathTests/SessionRuntime/SessionInputEventPolicyTests.swift new file mode 100644 index 000000000..1e3f8cd9a --- /dev/null +++ b/Tests/KeyPathTests/SessionRuntime/SessionInputEventPolicyTests.swift @@ -0,0 +1,46 @@ +import CoreGraphics +@testable import KeyPathAppKit +import XCTest + +final class SessionInputEventPolicyTests: XCTestCase { + func testSoftwarePastePreservesAggregateCommandOnBothEdges() throws { + let source = try XCTUnwrap(CGEventSource(stateID: .privateState)) + for down in [true, false] { + let event = try XCTUnwrap(CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: down)) + event.flags = .maskCommand + XCTAssertGreaterThan(event.getIntegerValueField(.eventSourceUnixProcessID), 0) + // No left/right Command device bits or preceding flagsChanged event. + XCTAssertEqual(event.flags.rawValue & 0xFFFF, 0) + XCTAssertTrue(SessionInputEventPolicy.isSoftwareKeyboardEvent(type: event.type, event: event)) + XCTAssertEqual(event.flags, .maskCommand) + } + } + + func testSoftwareModifierAndUnicodeEventsBypassPhysicalState() throws { + let event = try XCTUnwrap(CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)) + event.setIntegerValueField(.eventSourceUnixProcessID, value: 1234) + XCTAssertTrue(SessionInputEventPolicy.isSoftwareKeyboardEvent(type: .flagsChanged, event: event)) + event.flags = [] + var text: [UniChar] = [0x0061, 0x0062] + event.keyboardSetUnicodeString(stringLength: text.count, unicodeString: &text) + XCTAssertTrue(SessionInputEventPolicy.isSoftwareKeyboardEvent(type: .keyDown, event: event)) + XCTAssertTrue(SessionInputEventPolicy.isSoftwareKeyboardEvent(type: .keyUp, event: event)) + } + + func testHardwareEventsStillReachRemapper() throws { + let event = try XCTUnwrap(CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: true)) + event.setIntegerValueField(.eventSourceUnixProcessID, value: 0) + event.flags = .maskCommand + for type: CGEventType in [.keyDown, .keyUp, .flagsChanged] { + XCTAssertFalse(SessionInputEventPolicy.isSoftwareKeyboardEvent(type: type, event: event)) + } + } + + func testTapDisabledNotificationsStillReachRecovery() throws { + let event = try XCTUnwrap(CGEvent(source: nil)) + event.setIntegerValueField(.eventSourceUnixProcessID, value: 1234) + for type: CGEventType in [.tapDisabledByTimeout, .tapDisabledByUserInput] { + XCTAssertFalse(SessionInputEventPolicy.isSoftwareKeyboardEvent(type: type, event: event)) + } + } +} diff --git a/docs/bugs/session-synthetic-paste.md b/docs/bugs/session-synthetic-paste.md new file mode 100644 index 000000000..3ada4f546 --- /dev/null +++ b/docs/bugs/session-synthetic-paste.md @@ -0,0 +1,26 @@ +# Synthetic Cmd+V became plain V + +On 2026-10-20, Aqua Voice dictation appeared in clipboard history but ChatGPT +received `v`. Aqua Voice Hook only observes the clipboard and posts Return; +the failing paste shortcut was being rewritten by KeyPath's session worker. + +A standalone AppKit diagnostic on the Air posted key code 9 with only +`maskCommand`, using a private CGEventSource. Its own focused window received +key code 9 with flags zero and source PID 59379, the running KeyPath worker. +No clipboard changes or Aqua Voice Hook callbacks were involved. + +The worker exempted its own tagged output but processed other applications' +synthetic events as physical input. Aggregate Command did not populate the +device-specific modifier bits or Kanata's held modifier state. Re-emitting V +therefore dropped Command. Unmapped input also had its flags rebuilt, so merely +removing V from the mapping would not reliably fix this. + +Software keyboard events (positive source PID) now pass through unchanged, +before physical modifier state, Caps translation, input bookkeeping or Kanata +processing. Hardware events (source PID zero) still take the normal path. +Tap-disabled notifications remain on the existing failure/recovery path. + +Regression coverage exercises both paste edges with aggregate-only Command, +software modifier and Unicode events, hardware routing and tap-disable handling. +The live diagnostic is separate evidence from unit coverage: rerun it against an +installed candidate before claiming the installed bug is fixed.