Skip to content

Commit eb10bfe

Browse files
ptrinhclaude
andcommitted
fix(boundary): Enter sau gõ tắt bị nuốt (WhatsApp beep, phải Enter 2 lần) — macOS 26 drop bản copy của phím HID thật
postBoundaryCopy re-post event.copy() của Return thật để nó rơi xuống SAU edit gõ tắt — nhưng macOS 26 âm thầm drop bản copy đó trước khi tới app: log bắt tại trận posted → qua tap của mình → không bao giờ tới IMKit. Hệ quả: 'ko'⏎ nở thành 'không' nhưng tin nhắn không gửi, WhatsApp beep, phải Enter lần 2. Fix: dựng cặp down/up MỚI từ source .hidSystemState (giữ keycode + flags gốc nên Shift+Enter vẫn là xuống dòng). Không magic — bản Enter mới quay lại handle() như phím thật, engine lúc đó đã rỗng nên pass thẳng, không loop. Post thêm keyUp để app không thấy phím bị giữ. Không dùng private source vì Electron (Discord/Slate) hạ cấp Return private-source thành 'insert newline' thay vì Enter-to-send. Tách makeBoundaryRepost thuần + BoundaryRepostTests pin 3 tính chất: keycode/flags giữ nguyên, không magic, cặp down/up cân bằng. Kèm 2 diagnostic: boundary-key code/rewrote (gated debugLogging) và boundary-copy posted/skipped để field report lần sau tự kể chuyện. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5e015be commit eb10bfe

3 files changed

Lines changed: 92 additions & 15 deletions

File tree

App/Sources/TelexInputController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ final class TelexInputController: IMKInputController {
436436
boundaryCommitInFlight = true
437437
let rewrote = boundary(client)
438438
boundaryCommitInFlight = false
439+
logDecision("boundary-key code=\(event.keyCode) rewrote=\(rewrote)")
439440
// When the commit REWROTE the word (gõ tắt "ko"→"không", auto-restore
440441
// "thooiiii"), web-view editors (WhatsApp) apply that insertText
441442
// asynchronously — an immediately-delivered Return fires "send" on the

App/Sources/TerminalTap.swift

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,22 +1309,51 @@ enum SyntheticKeyboard {
13091309
postVirtual(key)
13101310
}
13111311

1312-
/// Re-post a COPY of the user's own boundary keyDown (Return/Tab/Esc) so it
1313-
/// lands AFTER a synthesized rewrite. Unlike postKey/postVirtual this keeps
1314-
/// the ORIGINAL event's HID source state: Electron editors (Discord/Slate)
1315-
/// treat a private-source synthetic Return as "insert newline" instead of
1316-
/// firing their Enter-to-send handler, but an event that is byte-identical
1317-
/// to the hardware one triggers the real action. The copy carries no magic
1318-
/// and no in-flight count on purpose — when it re-enters our tap it must be
1319-
/// handled as a REAL key (by then the engine is empty and the edit burst has
1320-
/// drained, so it passes straight through; ordering is by timestamp).
1321-
/// Only the keyDown is copied: the user's physical keyUp was never
1322-
/// intercepted and reaches the app on its own.
1312+
/// Re-post the user's boundary key (Return/Tab/Esc) as a FRESH event so it
1313+
/// lands AFTER a synthesized rewrite. History of this function:
1314+
/// - postVirtual (private source) was rejected: Electron editors (Discord/
1315+
/// Slate) treat a private-source Return as "insert newline" instead of
1316+
/// firing Enter-to-send.
1317+
/// - `event.copy()` of the real HID event was the replacement — but macOS 26
1318+
/// silently DROPS a re-posted copy of a hardware key before app delivery
1319+
/// (repro 2026-08-06: copy posted → passes our tap → never reaches IMKit;
1320+
/// WhatsApp beeped once, message not sent, shortcut "ko"→"không" needed a
1321+
/// second Enter).
1322+
/// So: build a NEW event from the .hidSystemState source (hardware-like for
1323+
/// Electron, deliverable on macOS 26), same keycode + flags as the original.
1324+
/// No magic on purpose — when it re-enters IMKit it is handled as a REAL key;
1325+
/// by then the engine is empty (boundary() just ran) so it passes straight
1326+
/// through (`rewrote=false`), no loop. The keyUp is posted too so the app
1327+
/// never sees a keyDown left logically held (the user's physical keyUp
1328+
/// precedes our down and cannot pair with it).
13231329
static func postBoundaryCopy(of event: CGEvent) {
1324-
guard Accessibility.isTrusted else { return }
1325-
guard let down = event.copy() else { return }
1326-
stamp(down)
1327-
down.post(tap: .cgSessionEventTap)
1330+
guard Accessibility.isTrusted else {
1331+
DebugLog.log("boundary-copy: SKIPPED (untrusted)")
1332+
return
1333+
}
1334+
let key = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode))
1335+
guard let (down, up) = makeBoundaryRepost(key: key, flags: event.flags) else {
1336+
DebugLog.log("boundary-copy: SKIPPED (create failed)")
1337+
return
1338+
}
1339+
notePostedKeyDown()
1340+
stamp(down); down.post(tap: .cgSessionEventTap)
1341+
stamp(up); up.post(tap: .cgSessionEventTap)
1342+
DebugLog.log("boundary-copy: posted fresh key=\(key)")
1343+
}
1344+
1345+
/// Builds the down/up pair postBoundaryCopy sends. Split out so tests can pin
1346+
/// the load-bearing properties without posting: .hidSystemState source (NOT the
1347+
/// private magic source — Electron demotes private-source Return to "newline",
1348+
/// and magic would make IMKit skip it) and NOT a copy of the hardware event
1349+
/// (macOS 26 drops re-posted copies before app delivery).
1350+
static func makeBoundaryRepost(key: CGKeyCode, flags: CGEventFlags) -> (down: CGEvent, up: CGEvent)? {
1351+
let src = CGEventSource(stateID: .hidSystemState)
1352+
guard let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true),
1353+
let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) else { return nil }
1354+
down.flags = flags
1355+
up.flags = flags
1356+
return (down, up)
13281357
}
13291358

13301359
/// Shift+LeftArrow: extend the selection one char left (used by selectionReplace).

AppTests/BoundaryRepostTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import XCTest
2+
import CoreGraphics
3+
@testable import VietTelex
4+
5+
// Shortcut word + Enter (WhatsApp, 2026-08-06): "ko"⏎ expanded to "không" but the
6+
// message wasn't sent — one beep, second Enter needed. Root cause: postBoundaryCopy
7+
// re-posted `event.copy()` of the REAL hardware Return, and macOS 26 silently drops
8+
// a re-posted copy of a HID event before app delivery (it passed our own tap, then
9+
// vanished — never reached IMKit). The fix posts a FRESH down/up pair instead.
10+
// These tests pin the properties of that pair that each fix a distinct failure:
11+
final class BoundaryRepostTests: XCTestCase {
12+
13+
func testRepostCarriesKeycodeAndFlags() {
14+
guard let (down, up) = SyntheticKeyboard.makeBoundaryRepost(key: 36, flags: .maskShift) else {
15+
return XCTFail("pair must be constructible")
16+
}
17+
XCTAssertEqual(down.getIntegerValueField(.keyboardEventKeycode), 36)
18+
XCTAssertEqual(up.getIntegerValueField(.keyboardEventKeycode), 36)
19+
// Shift+Enter in chat apps means "newline, don't send" — the repost must
20+
// preserve that distinction or a shortcut expansion turns it into a send.
21+
XCTAssertTrue(down.flags.contains(.maskShift))
22+
XCTAssertTrue(up.flags.contains(.maskShift))
23+
}
24+
25+
/// NO magic: IMKit's handle() drops magic events without processing; this Enter
26+
/// must re-enter handle() as a REAL key (engine is empty post-boundary, so it
27+
/// passes through once — no loop) so the app's Enter action fires.
28+
func testRepostCarriesNoMagicStamp() {
29+
guard let (down, up) = SyntheticKeyboard.makeBoundaryRepost(key: 36, flags: []) else {
30+
return XCTFail("pair must be constructible")
31+
}
32+
XCTAssertFalse(SyntheticKeyboard.isSyntheticMagic(down),
33+
"magic would make IMKit skip the repost — the Enter action would never fire")
34+
XCTAssertFalse(SyntheticKeyboard.isSyntheticMagic(up))
35+
}
36+
37+
/// A keyUp must exist: the user's physical keyUp precedes our posted keyDown,
38+
/// so without our own up the key stays logically held (key-repeat / stuck-key
39+
/// semantics in apps that track key state).
40+
func testRepostIsABalancedPair() {
41+
guard let (down, up) = SyntheticKeyboard.makeBoundaryRepost(key: 48, flags: []) else {
42+
return XCTFail("pair must be constructible")
43+
}
44+
XCTAssertEqual(down.type, .keyDown)
45+
XCTAssertEqual(up.type, .keyUp)
46+
}
47+
}

0 commit comments

Comments
 (0)