Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
26 changes: 26 additions & 0 deletions docs/bugs/session-synthetic-paste.md
Original file line number Diff line number Diff line change
@@ -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.
Loading