mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
fix(terminal): forward the orphaned input event instead of replaying a guessed key
The previous shape guessed the character from `event.key` on keydown, re-emitted it, and then tried to suppress a late canonical copy with a 250 ms character-keyed dedupe. Review found three defects in that, all reproducible: the dedupe matched on the character alone with nothing scoping a candidate to the keydown that created it, so the same character typed twice inside the window had its second, real byte swallowed; anything whose committed text differed from `event.key` (Enter, IME punctuation) was delivered twice, because the dedupe could never match it; and the trigger ignored `key === 'Unidentified'`, which is what a soft keyboard reports, so it may never have fired where it was needed. The input event already carries the committed text in `ev.data` — exactly what xterm itself would have forwarded — so nothing has to be guessed. The controller now only decides WHETHER to forward, by asking whether xterm produced canonical data since the keydown that began the keystroke. No character-keyed matching survives, so the first two defects are structurally impossible rather than defended against, and nothing reads `key`/`keyCode`, so the third cannot recur. Three details are load-bearing and each has a test that fails without it: - The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event. `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a snapshot read at input time already contains that emission, reads it as silence, and delivers the character twice. - Our `input` listener is registered with `capture: true`. The target is visited twice in the event path, so a capture listener calling `stopPropagation()` stops later BUBBLE listeners on that same target; xterm's `cancel()` runs exactly in the branch where it handled the input, so on bubble we would never observe handled events, and whether we observed them at all would hang off `options.cancelEvents`. Measured in jsdom and headless chromium; the table is in the module header. - Enter is deliberately no longer special-cased. That mapping is what made the committed text differ from the re-emitted value in the first place. The scope is also narrower than the old name suggests, and the browser test now proves it rather than assuming it. For a keydown that reports keyCode 229 xterm ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()` diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it" there passes while xterm does all the work, so the browser tests assert WHO delivered the byte: zero canonical emissions for the genuinely orphaned case, exactly one delivery for the case xterm rescues itself. Also addresses review notes: the module gains an `@fileoverview` with `@dependency`/`@loadorder` and an entry in the load-order list and module inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its own. The keydown hook deliberately still runs for every key event rather than moving behind the 229 gate: gating it would reinstate exactly the blindness described above, and it is now a single counter assignment.
This commit is contained in:
@@ -3458,7 +3458,7 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Recovers Android/GBoard keydowns that report keyCode 229 without touching xterm's helper textarea. Must precede terminal-ui.js. -->
|
||||
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
/**
|
||||
* Recover explicit keyCode 229 terminal input when a browser reports a key but
|
||||
* never mutates xterm's helper textarea. xterm remains authoritative whenever
|
||||
* it emits canonical data or the browser enters a real composition lifecycle.
|
||||
* @fileoverview Orphaned-input forwarder for xterm's helper textarea.
|
||||
*
|
||||
* xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText`
|
||||
* input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft
|
||||
* keyboard that delivers a `composed: true` input event after a keydown fails
|
||||
* that guard, so xterm returns without emitting and the committed character is
|
||||
* silently dropped.
|
||||
*
|
||||
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
|
||||
* controller that looks useful while doing nothing. For a keydown that really
|
||||
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
|
||||
* .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots
|
||||
* `textarea.value` and diffs it on a 0 ms timer, emitting the difference
|
||||
* itself. Measured in headless chromium against a real terminal: for a 229
|
||||
* keydown xterm emits and this controller correctly stands down. What is left
|
||||
* unrescued is a refused `insertText` where NO 229 diff was scheduled — that is
|
||||
* the case this module exists for, and the case its browser test asserts by
|
||||
* checking WHO delivered the byte rather than merely that one arrived.
|
||||
*
|
||||
* The recovery never guesses the character: the `input` event already carries
|
||||
* the real committed text in `ev.data`, which is exactly what xterm itself
|
||||
* would have forwarded. We only decide WHETHER to forward it, by asking
|
||||
* whether xterm produced any canonical data since the keydown that started the
|
||||
* keystroke. That snapshot must be taken at KEYDOWN, not at the input event:
|
||||
* xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires,
|
||||
* so a snapshot read at input time would already contain that emission and the
|
||||
* character would be delivered twice.
|
||||
*
|
||||
* Listener registration is load-bearing, in BOTH phase and order. xterm
|
||||
* registers its own `input` listener in `terminal.open()` with `capture:
|
||||
* true`, and ours is added afterwards, so at-target it runs second. It must
|
||||
* also be a CAPTURE listener; see the measured table at the addEventListener
|
||||
* call below.
|
||||
*
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
||||
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const LATE_INPUT_WINDOW_MS = 250;
|
||||
const MAX_RECOVERED_RECORDS = 32;
|
||||
|
||||
function explicitTerminalDataForEvent(event) {
|
||||
if (!event || event.type !== 'keydown' || event.isComposing) return null;
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) return null;
|
||||
try {
|
||||
if (event.getModifierState?.('AltGraph')) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
if (key === 'Enter') return '\r';
|
||||
if (key === 'Process' || key === 'Unidentified' || key === 'Dead') return null;
|
||||
if (typeof key !== 'string' || Array.from(key).length !== 1) return null;
|
||||
const codePoint = key.codePointAt(0);
|
||||
if (codePoint === undefined || codePoint < 32 || codePoint === 127) return null;
|
||||
return key;
|
||||
}
|
||||
|
||||
function terminalDataForEvent(event) {
|
||||
if (event?.keyCode !== 229) return null;
|
||||
return explicitTerminalDataForEvent(event);
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options?.textarea;
|
||||
const emitRecovered = options?.emitRecovered;
|
||||
@@ -39,189 +46,142 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
const enqueueMicrotask = options.queueMicrotask || global.queueMicrotask.bind(global);
|
||||
const isScreenReaderMode = options.isScreenReaderMode;
|
||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||
const now = options.now || (() => global.performance?.now?.() ?? Date.now());
|
||||
|
||||
let destroyed = false;
|
||||
let keySequence = 0;
|
||||
let activeKey = null;
|
||||
let beforeInputClaim = null;
|
||||
// Number of canonical data events xterm has emitted, bumped by the caller's
|
||||
// onData hook. Only its ORDER relative to a keydown matters.
|
||||
let canonicalCount = 0;
|
||||
let keydownSnapshot = null;
|
||||
let composing = false;
|
||||
const pending = [];
|
||||
const recovered = [];
|
||||
|
||||
function removePending(candidate) {
|
||||
const index = pending.indexOf(candidate);
|
||||
if (index !== -1) pending.splice(index, 1);
|
||||
if (candidate.timer !== null) {
|
||||
try {
|
||||
clearTimer(candidate.timer);
|
||||
} catch {}
|
||||
candidate.timer = null;
|
||||
}
|
||||
candidate.active = false;
|
||||
}
|
||||
|
||||
function cancelPending(predicate = () => true) {
|
||||
for (const candidate of [...pending]) {
|
||||
if (predicate(candidate)) removePending(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneRecovered() {
|
||||
const current = now();
|
||||
for (let index = recovered.length - 1; index >= 0; index -= 1) {
|
||||
if (recovered[index].expiresAt < current) recovered.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyEvent(event) {
|
||||
if (destroyed || event?.type !== 'keydown') return;
|
||||
const record = {
|
||||
sequence: ++keySequence,
|
||||
data: explicitTerminalDataForEvent(event),
|
||||
candidate: null,
|
||||
};
|
||||
activeKey = record;
|
||||
const data = terminalDataForEvent(event);
|
||||
const candidate = data === null ? null : { sequence: record.sequence, data, active: true, timer: null };
|
||||
if (candidate) {
|
||||
record.candidate = candidate;
|
||||
pending.push(candidate);
|
||||
}
|
||||
try {
|
||||
// The custom key handler runs before xterm's CompositionHelper. Queueing
|
||||
// our timer from a microtask places it after xterm's own zero-delay
|
||||
// textarea diff, while keeping the recovery delay to one browser task.
|
||||
enqueueMicrotask(() => {
|
||||
if (activeKey === record) activeKey = null;
|
||||
if (destroyed || !candidate?.active) return;
|
||||
function cancelPending() {
|
||||
for (const candidate of pending.splice(0)) {
|
||||
candidate.active = false;
|
||||
if (candidate.timer !== null) {
|
||||
try {
|
||||
candidate.timer = setTimer(() => {
|
||||
if (destroyed || !candidate.active) return;
|
||||
removePending(candidate);
|
||||
try {
|
||||
emitRecovered(candidate.data);
|
||||
} catch {
|
||||
// No dedupe record is retained when delivery fails. A later
|
||||
// canonical xterm value must remain free to pass through.
|
||||
return;
|
||||
}
|
||||
pruneRecovered();
|
||||
recovered.push({
|
||||
sequence: candidate.sequence,
|
||||
data: candidate.data,
|
||||
expiresAt: now() + LATE_INPUT_WINDOW_MS,
|
||||
claimedByInput: false,
|
||||
});
|
||||
if (recovered.length > MAX_RECOVERED_RECORDS) {
|
||||
recovered.splice(0, recovered.length - MAX_RECOVERED_RECORDS);
|
||||
}
|
||||
}, 0);
|
||||
clearTimer(candidate.timer);
|
||||
} catch {
|
||||
removePending(candidate);
|
||||
// A broken timer host must not break input handling.
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
if (activeKey === record) activeKey = null;
|
||||
if (candidate) removePending(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function claimCanonicalInput(data) {
|
||||
if (activeKey?.data === data) {
|
||||
if (activeKey.candidate?.active) removePending(activeKey.candidate);
|
||||
return;
|
||||
}
|
||||
const matchingPending = pending.find((candidate) => candidate.active && candidate.data === data);
|
||||
if (matchingPending) {
|
||||
removePending(matchingPending);
|
||||
return;
|
||||
}
|
||||
const matchingRecovery = recovered.find((record) => !record.claimedByInput && record.data === data);
|
||||
if (matchingRecovery) matchingRecovery.claimedByInput = true;
|
||||
}
|
||||
|
||||
function onCanonicalInput(event) {
|
||||
if (destroyed) return;
|
||||
const inputData = typeof event?.data === 'string' ? event.data : null;
|
||||
if (inputData === null) return;
|
||||
if (event.type === 'input' && beforeInputClaim?.data === inputData) {
|
||||
beforeInputClaim = null;
|
||||
return;
|
||||
}
|
||||
if (event.type === 'beforeinput') {
|
||||
const claim = { data: inputData };
|
||||
beforeInputClaim = claim;
|
||||
try {
|
||||
enqueueMicrotask(() => {
|
||||
if (beforeInputClaim === claim) beforeInputClaim = null;
|
||||
});
|
||||
} catch {
|
||||
beforeInputClaim = null;
|
||||
candidate.timer = null;
|
||||
}
|
||||
}
|
||||
claimCanonicalInput(inputData);
|
||||
}
|
||||
|
||||
function resetForCompositionOrFocusLoss() {
|
||||
function resolveCandidate(candidate) {
|
||||
const index = pending.indexOf(candidate);
|
||||
if (index !== -1) pending.splice(index, 1);
|
||||
candidate.timer = null;
|
||||
if (!candidate.active || destroyed) return;
|
||||
candidate.active = false;
|
||||
// xterm (or its keypress path) spoke for this keystroke — it is already
|
||||
// on its way to the PTY, so there is nothing to recover.
|
||||
if (canonicalCount > candidate.snapshot) return;
|
||||
try {
|
||||
emitRecovered(candidate.data);
|
||||
} catch {
|
||||
// Recovery is best effort; a failed delivery must never throw into the
|
||||
// browser's input handling.
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from xterm's onData hook: xterm produced canonical data. */
|
||||
function notifyCanonicalData() {
|
||||
canonicalCount += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the canonical counter at every keydown. This deliberately reads
|
||||
* NOTHING else off the event — not `key`, not `keyCode`. Gating it on
|
||||
* keyCode 229 would make the recovery inert on exactly the devices it
|
||||
* exists for, whose keydowns report `key: 'Unidentified'`. It is a single
|
||||
* assignment, so running it for every keydown costs nothing.
|
||||
*/
|
||||
function handleKeyEvent(event) {
|
||||
if (destroyed || event?.type !== 'keydown') return;
|
||||
keydownSnapshot = canonicalCount;
|
||||
}
|
||||
|
||||
function onInput(event) {
|
||||
if (destroyed || composing || event?.isComposing) return;
|
||||
if (event.inputType !== 'insertText') return;
|
||||
const data = event.data;
|
||||
if (typeof data !== 'string' || data === '') return;
|
||||
try {
|
||||
if (isScreenReaderMode?.()) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
data,
|
||||
snapshot: keydownSnapshot ?? canonicalCount,
|
||||
active: true,
|
||||
timer: null,
|
||||
};
|
||||
pending.push(candidate);
|
||||
try {
|
||||
candidate.timer = setTimer(() => resolveCandidate(candidate), 0);
|
||||
} catch {
|
||||
cancelPending();
|
||||
}
|
||||
}
|
||||
|
||||
function onCompositionStart() {
|
||||
if (destroyed) return;
|
||||
keySequence += 1;
|
||||
activeKey = null;
|
||||
beforeInputClaim = null;
|
||||
composing = true;
|
||||
cancelPending();
|
||||
recovered.splice(0);
|
||||
}
|
||||
|
||||
function consumeTerminalData(data) {
|
||||
if (destroyed) return false;
|
||||
pruneRecovered();
|
||||
|
||||
if (activeKey?.data === data) {
|
||||
if (activeKey.candidate?.active) removePending(activeKey.candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
const canonical = pending.find((candidate) => candidate.active && candidate.data === data);
|
||||
if (canonical) {
|
||||
removePending(canonical);
|
||||
return false;
|
||||
}
|
||||
|
||||
const duplicateIndex = recovered.findIndex((record) => record.data === data && record.claimedByInput);
|
||||
if (duplicateIndex === -1) return false;
|
||||
recovered.splice(duplicateIndex, 1);
|
||||
return true;
|
||||
function onCompositionEnd() {
|
||||
if (destroyed) return;
|
||||
composing = false;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
activeKey = null;
|
||||
beforeInputClaim = null;
|
||||
cancelPending();
|
||||
recovered.splice(0);
|
||||
try {
|
||||
textarea.removeEventListener('beforeinput', onCanonicalInput, true);
|
||||
textarea.removeEventListener('input', onCanonicalInput, true);
|
||||
textarea.removeEventListener('compositionstart', resetForCompositionOrFocusLoss, true);
|
||||
textarea.removeEventListener('blur', resetForCompositionOrFocusLoss, true);
|
||||
} catch {}
|
||||
textarea.removeEventListener('input', onInput, true);
|
||||
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
||||
textarea.removeEventListener('compositionend', onCompositionEnd, true);
|
||||
} catch {
|
||||
// Teardown is best effort; the terminal is being replaced anyway.
|
||||
}
|
||||
}
|
||||
|
||||
// capture: true, not bubble. The target (the textarea) is visited TWICE in
|
||||
// the event path, so a capture-phase listener on it calling
|
||||
// stopPropagation() still stops later BUBBLE-phase listeners on that same
|
||||
// target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault +
|
||||
// stopPropagation) exactly in the branch where it HANDLED the input, so on
|
||||
// bubble we would never see handled events — and whether we saw them at
|
||||
// all would hang off xterm's `options.cancelEvents`, which Codeman does not
|
||||
// set. Measured (jsdom and headless chromium agree):
|
||||
//
|
||||
// capture-then-BUBBLE, no stop: xterm -> ours
|
||||
// capture-then-BUBBLE, stopPropagation: xterm (ours never fires)
|
||||
// capture-then-CAPTURE, no stop: xterm -> ours
|
||||
// capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires)
|
||||
//
|
||||
// On capture we therefore observe EVERY input event uniformly, and the
|
||||
// canonicalCount snapshot alone decides whether to forward.
|
||||
try {
|
||||
textarea.addEventListener('beforeinput', onCanonicalInput, true);
|
||||
textarea.addEventListener('input', onCanonicalInput, true);
|
||||
textarea.addEventListener('compositionstart', resetForCompositionOrFocusLoss, true);
|
||||
textarea.addEventListener('blur', resetForCompositionOrFocusLoss, true);
|
||||
textarea.addEventListener('input', onInput, true);
|
||||
textarea.addEventListener('compositionstart', onCompositionStart, true);
|
||||
textarea.addEventListener('compositionend', onCompositionEnd, true);
|
||||
} catch {
|
||||
destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.freeze({ handleKeyEvent, consumeTerminalData, destroy });
|
||||
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
||||
}
|
||||
|
||||
global.CodemanKeyCode229Recovery = Object.freeze({ create, terminalDataForEvent });
|
||||
global.CodemanKeyCode229Recovery = Object.freeze({ create });
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
@@ -303,6 +303,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the helper textarea and emit the committed Unicode text.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
try {
|
||||
// Deliberately runs for EVERY keydown, not just keyCode 229: the
|
||||
// controller snapshots a counter and reads nothing off the event, and
|
||||
// the devices this exists for report `key: 'Unidentified'` with no
|
||||
// reliable identity to gate on. Gating it would make recovery inert
|
||||
// exactly where it is needed. Cost is one assignment.
|
||||
this._keyCode229Recovery?.handleKeyEvent?.(ev);
|
||||
} catch {
|
||||
// The fallback must never interfere with xterm's canonical handler.
|
||||
@@ -1041,14 +1046,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// mobile connections. The overlay + localStorage persistence ensure input
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
const handleTerminalData = (data, { recovered = false } = {}) => {
|
||||
if (!recovered) {
|
||||
try {
|
||||
if (this._keyCode229Recovery?.consumeTerminalData?.(data)) return;
|
||||
} catch {
|
||||
// A broken dedupe guard must fail open to canonical xterm data.
|
||||
}
|
||||
}
|
||||
const handleTerminalData = (data) => {
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1372,19 +1370,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
};
|
||||
|
||||
// Android/GBoard fires keydown with keyCode 229 and, on some paths, never
|
||||
// mutates xterm's helper textarea, so the character is silently dropped.
|
||||
// The controller re-emits exactly those keys, and only after xterm has had
|
||||
// its own chance to produce the canonical data.
|
||||
// Chrome on Android delivers a `composed: true` input event preceded by a
|
||||
// keydown, which is exactly the shape xterm's _inputEvent refuses to
|
||||
// forward, so the committed character is silently dropped. The controller
|
||||
// forwards the input event's own `data` when xterm produced nothing for
|
||||
// that keystroke. Created AFTER terminal.open() on purpose: for an event
|
||||
// targeting the textarea, at-target listeners run in registration order,
|
||||
// so xterm's listener (added in open()) still runs first. The controller
|
||||
// registers its own listener with `capture: true`; on bubble xterm's
|
||||
// `cancel()` (stopPropagation) would swallow exactly the handled events —
|
||||
// see the measured table in terminal-keycode229-recovery.js.
|
||||
try {
|
||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
emitRecovered: (data) => handleTerminalData(data, { recovered: true }),
|
||||
emitRecovered: (data) => handleTerminalData(data),
|
||||
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||
});
|
||||
} catch {
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
this.terminal.onData((data) => handleTerminalData(data));
|
||||
this.terminal.onData((data) => {
|
||||
// Canonical xterm data. Telling the controller is what lets it know a
|
||||
// keystroke was already delivered and needs no recovery.
|
||||
try {
|
||||
this._keyCode229Recovery?.notifyCanonicalData?.();
|
||||
} catch {
|
||||
// Bookkeeping must never block real input.
|
||||
}
|
||||
handleTerminalData(data);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user