mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(review): content-free IME traces, guarded onData self-heal, Android-only retap recovery (PR #143)
- BLOCKER (privacy): the CJK diagnostic trace logged typed CONTENT — _esc(e.key) per keystroke, up to 24 chars of textarea value on focus/blur/compstart/ compend/input, and the flushed text — mirrored into _crashDiag, which persists to localStorage and beacons to POST /api/crash-diag. Traces are now content-free: key CLASS via _kdesc (any single code point → 'printable', named keys pass through), value lengths + phantom presence via _vdesc (len=N[+ph]), and 'flush send len=N'. _esc removed. - MAJOR: the onData self-heal refocused the CJK field whenever gated data arrived with focus elsewhere — but onData also fires for xterm's SELF-GENERATED query replies (DA/DSR/CPR/OSC during Ink redraws), so it stole focus from rename/search/settings inputs while output streamed. Now requires document.activeElement === this.terminal.textarea (genuine typed input) and bails when shouldSuppressTerminalQueryResponse(data) matches. - MAJOR: the pointerdown blur→setTimeout(focus,0) wedged-IME recovery ran on ALL platforms; on iOS tapping the focused empty field is normal and the async refocus is outside the user-gesture stack. The listener is now only registered when /Android/i.test(navigator.userAgent). - tests: trace-privacy test (no typed character or textarea value ever appears in the trace; lengths/key classes still recorded), iOS harness asserts the pointerdown recovery never cycles, self-heal source guard asserts both new conditions; vm harness gained a ua option (navigator injected, Android UA default so the existing wedged-IME test still exercises the recovery). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+36
-21
@@ -64,13 +64,23 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
// ── Diagnostic trace (intermittent CJK-loss investigation) ──
|
// ── Diagnostic trace (intermittent CJK-loss investigation) ──
|
||||||
// In-memory ring buffer of every IME event + flush decision. Mirrored into
|
// In-memory ring buffer of every IME event + flush decision. Mirrored into
|
||||||
// the crash-diag breadcrumbs (app.js), which beacon to the server every 2s —
|
// the crash-diag breadcrumbs (app.js), which persist to localStorage and
|
||||||
// after a repro, `GET /api/crash-diag` shows the exact event sequence.
|
// beacon to the server every 2s — after a repro, `GET /api/crash-diag`
|
||||||
|
// shows the exact event sequence.
|
||||||
|
// PRIVACY: because the trace leaves the page, it must stay CONTENT-FREE —
|
||||||
|
// event types, booleans, key classes, and value LENGTHS only. Never log a
|
||||||
|
// typed character or the textarea value (pasted secrets would be captured).
|
||||||
const TRACE_MAX = 200;
|
const TRACE_MAX = 200;
|
||||||
const _trace = [];
|
const _trace = [];
|
||||||
function _esc(v) {
|
/** Content-free value descriptor: real-text length + phantom presence. */
|
||||||
const s = String(v == null ? '' : v).replace(//g, '∅');
|
function _vdesc(v) {
|
||||||
return JSON.stringify(s.length > 24 ? s.slice(0, 24) + '…' + s.length : s);
|
const s = String(v == null ? '' : v);
|
||||||
|
return `len=${_strip(s).length}${s.includes(PHANTOM) ? '+ph' : ''}`;
|
||||||
|
}
|
||||||
|
/** Content-free key descriptor: named keys (Enter, Process…) pass through; any single code point is typed content. */
|
||||||
|
function _kdesc(key) {
|
||||||
|
const k = String(key == null ? '' : key);
|
||||||
|
return [...k].length === 1 ? 'printable' : k;
|
||||||
}
|
}
|
||||||
function _t(msg) {
|
function _t(msg) {
|
||||||
_trace.push(`${Date.now() % 1000000} ${msg}`);
|
_trace.push(`${Date.now() % 1000000} ${msg}`);
|
||||||
@@ -146,7 +156,7 @@ const CjkInput = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const val = _strip(_textarea.value);
|
const val = _strip(_textarea.value);
|
||||||
_t(`flush ${val ? 'send ' + _esc(val) : 'empty'}`);
|
_t(`flush ${val ? 'send len=' + val.length : 'empty'}`);
|
||||||
if (val) {
|
if (val) {
|
||||||
_send(val);
|
_send(val);
|
||||||
}
|
}
|
||||||
@@ -197,27 +207,33 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||||
|
|
||||||
// ── Wedged-IME recovery (Android) ──
|
// ── Wedged-IME recovery (Android ONLY) ──
|
||||||
// Some Android IMEs (esp. 9-key Sogou/Xiaomi/Baidu) can wedge their
|
// Some Android IMEs (esp. 9-key Sogou/Xiaomi/Baidu) can wedge their
|
||||||
// InputConnection: the keyboard composes in its own candidate bar but
|
// InputConnection: the keyboard composes in its own candidate bar but
|
||||||
// delivers ZERO DOM events to the focused textarea. JS cannot detect
|
// delivers ZERO DOM events to the focused textarea. JS cannot detect
|
||||||
// this (nothing fires) — but re-tapping the already-focused empty field
|
// this (nothing fires) — but re-tapping the already-focused empty field
|
||||||
// is the user's natural "it's stuck" gesture. A blur→focus cycle forces
|
// is the user's natural "it's stuck" gesture. A blur→focus cycle forces
|
||||||
// the browser to restart the IME input session, which un-wedges it.
|
// the browser to restart the IME input session, which un-wedges it.
|
||||||
_listeners.pointerdown = () => {
|
// iOS is excluded: tapping the focused empty field there is normal
|
||||||
if (document.activeElement === _textarea && !_composing && _isEffectivelyEmpty()) {
|
// (paste callout, habitual tap), and the setTimeout refocus runs outside
|
||||||
_t('ime-reset (retap)');
|
// the user-gesture stack, so the cycle would just misbehave.
|
||||||
_textarea.blur();
|
if (/Android/i.test(navigator.userAgent)) {
|
||||||
setTimeout(() => _textarea.focus(), 0);
|
_listeners.pointerdown = () => {
|
||||||
}
|
if (document.activeElement === _textarea && !_composing && _isEffectivelyEmpty()) {
|
||||||
};
|
_t('ime-reset (retap)');
|
||||||
|
_textarea.blur();
|
||||||
|
setTimeout(() => _textarea.focus(), 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_textarea.addEventListener('pointerdown', _listeners.pointerdown);
|
||||||
|
}
|
||||||
_listeners.focus = () => {
|
_listeners.focus = () => {
|
||||||
_t(`focus val=${_esc(_textarea.value)}`);
|
_t(`focus ${_vdesc(_textarea.value)}`);
|
||||||
window.cjkActive = true;
|
window.cjkActive = true;
|
||||||
if (!_textarea.value) _resetToPhantom();
|
if (!_textarea.value) _resetToPhantom();
|
||||||
};
|
};
|
||||||
_listeners.blur = () => {
|
_listeners.blur = () => {
|
||||||
_t(`blur composing=${_composing} val=${_esc(_textarea.value)}`);
|
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
|
||||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||||
// UI may steal focus temporarily, and clearing the flag during that
|
// UI may steal focus temporarily, and clearing the flag during that
|
||||||
// window lets xterm's onData process duplicated input.
|
// window lets xterm's onData process duplicated input.
|
||||||
@@ -230,20 +246,19 @@ const CjkInput = (() => {
|
|||||||
_composing = false;
|
_composing = false;
|
||||||
};
|
};
|
||||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||||
_textarea.addEventListener('pointerdown', _listeners.pointerdown);
|
|
||||||
_textarea.addEventListener('focus', _listeners.focus);
|
_textarea.addEventListener('focus', _listeners.focus);
|
||||||
_textarea.addEventListener('blur', _listeners.blur);
|
_textarea.addEventListener('blur', _listeners.blur);
|
||||||
|
|
||||||
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
||||||
_listeners.compositionstart = () => {
|
_listeners.compositionstart = () => {
|
||||||
_t(`compstart val=${_esc(_textarea.value)}`);
|
_t(`compstart ${_vdesc(_textarea.value)}`);
|
||||||
_composing = true;
|
_composing = true;
|
||||||
_cancelDebouncedFlush();
|
_cancelDebouncedFlush();
|
||||||
// Leave textarea.value untouched — programmatic changes during
|
// Leave textarea.value untouched — programmatic changes during
|
||||||
// compositionstart cancel the IME composition on iOS Safari.
|
// compositionstart cancel the IME composition on iOS Safari.
|
||||||
};
|
};
|
||||||
_listeners.compositionend = () => {
|
_listeners.compositionend = () => {
|
||||||
_t(`compend val=${_esc(_textarea.value)}`);
|
_t(`compend ${_vdesc(_textarea.value)}`);
|
||||||
_composing = false;
|
_composing = false;
|
||||||
_cancelDebouncedFlush();
|
_cancelDebouncedFlush();
|
||||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||||
@@ -261,7 +276,7 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||||
_listeners.keydown = (e) => {
|
_listeners.keydown = (e) => {
|
||||||
_t(`keydown ${_esc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_composing = false;
|
_composing = false;
|
||||||
@@ -327,7 +342,7 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||||
_listeners.input = (e) => {
|
_listeners.input = (e) => {
|
||||||
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} val=${_esc(_textarea.value)}`);
|
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
|
||||||
// ── Stuck-composition recovery ──
|
// ── Stuck-composition recovery ──
|
||||||
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
||||||
// matching compositionend. A stale _composing=true blocks every flush
|
// matching compositionend. A stale _composing=true blocks every flush
|
||||||
|
|||||||
@@ -652,8 +652,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// typed lands HERE and is swallowed — keyboard shows the IME composing
|
// typed lands HERE and is swallowed — keyboard shows the IME composing
|
||||||
// while both the CJK field and the terminal stay empty. Route focus
|
// while both the CJK field and the terminal stay empty. Route focus
|
||||||
// back so the very next keystroke lands in the CJK field again.
|
// back so the very next keystroke lands in the CJK field again.
|
||||||
|
// Only GENUINE typed input qualifies: onData also fires for xterm's
|
||||||
|
// self-generated query replies (DA/DSR/CPR/OSC during Ink redraws),
|
||||||
|
// which arrive no matter what has focus — so require focus to be on
|
||||||
|
// xterm's own textarea and bail on query replies, or this would steal
|
||||||
|
// focus from the rename/search/settings inputs while output streams.
|
||||||
const cjkEl = document.getElementById('cjkInput');
|
const cjkEl = document.getElementById('cjkInput');
|
||||||
if (cjkEl?.classList.contains('cjk-input-visible') && document.activeElement !== cjkEl) {
|
if (
|
||||||
|
cjkEl?.classList.contains('cjk-input-visible') &&
|
||||||
|
document.activeElement === this.terminal.textarea &&
|
||||||
|
!window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
|
||||||
|
) {
|
||||||
_crashDiag.log('CJK regain-focus (onData swallowed input)');
|
_crashDiag.log('CJK regain-focus (onData swallowed input)');
|
||||||
cjkEl.focus();
|
cjkEl.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-1
@@ -60,7 +60,10 @@ function makeTextarea(): FakeTextarea {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCjkHarness() {
|
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0';
|
||||||
|
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko)';
|
||||||
|
|
||||||
|
function loadCjkHarness({ ua = ANDROID_UA }: { ua?: string } = {}) {
|
||||||
const textarea = makeTextarea();
|
const textarea = makeTextarea();
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
const windowObj: Record<string, unknown> = {};
|
const windowObj: Record<string, unknown> = {};
|
||||||
@@ -74,6 +77,7 @@ function loadCjkHarness() {
|
|||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
window: windowObj,
|
window: windowObj,
|
||||||
document: documentObj,
|
document: documentObj,
|
||||||
|
navigator: { userAgent: ua },
|
||||||
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
|
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
|
||||||
clearTimeout: (t: ReturnType<typeof setTimeout>) => clearTimeout(t),
|
clearTimeout: (t: ReturnType<typeof setTimeout>) => clearTimeout(t),
|
||||||
performance: { now: () => performance.now() },
|
performance: { now: () => performance.now() },
|
||||||
@@ -203,6 +207,52 @@ describe('CJK input module', () => {
|
|||||||
expect(blurred).toBe(1);
|
expect(blurred).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not run the wedged-IME pointerdown recovery on iOS (Android-only)', () => {
|
||||||
|
// On iOS, tapping the focused empty field is normal (paste callout,
|
||||||
|
// habitual tap), and the async refocus runs outside the user-gesture
|
||||||
|
// stack — the blur→focus cycle must never fire there.
|
||||||
|
const { textarea, documentObj } = loadCjkHarness({ ua: IOS_UA });
|
||||||
|
documentObj.activeElement = textarea;
|
||||||
|
let blurred = 0;
|
||||||
|
let focused = 0;
|
||||||
|
textarea.blur = () => {
|
||||||
|
blurred++;
|
||||||
|
};
|
||||||
|
textarea.focus = () => {
|
||||||
|
focused++;
|
||||||
|
};
|
||||||
|
|
||||||
|
textarea.fire('pointerdown');
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
expect(blurred).toBe(0);
|
||||||
|
expect(focused).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never records typed content in the diagnostic trace (privacy)', () => {
|
||||||
|
// The trace mirrors into crash-diag, which persists to localStorage and
|
||||||
|
// beacons to the server — it must stay content-free: lengths, key
|
||||||
|
// classes, and event names only. Drive every path that formerly embedded
|
||||||
|
// the value or key literal (composition, keydown send, input, blur).
|
||||||
|
const { CjkInput, textarea, sent } = loadCjkHarness();
|
||||||
|
|
||||||
|
textarea.fire('compositionstart');
|
||||||
|
textarea.value = PHANTOM + '秘密口令';
|
||||||
|
textarea.fire('input', { isComposing: true, inputType: 'insertCompositionText' });
|
||||||
|
textarea.fire('compositionend');
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
|
||||||
|
textarea.fire('keydown', { key: '囍', ctrlKey: false, altKey: false, metaKey: false });
|
||||||
|
textarea.value = PHANTOM + '秘密';
|
||||||
|
textarea.fire('blur');
|
||||||
|
expect(sent).toEqual(['秘密口令', '囍']);
|
||||||
|
|
||||||
|
const trace = CjkInput.getTrace().join('\n');
|
||||||
|
for (const ch of '秘密口令囍') expect(trace).not.toContain(ch);
|
||||||
|
// Lengths and key classes are still traced for diagnostics.
|
||||||
|
expect(trace).toContain('flush send len=4');
|
||||||
|
expect(trace).toContain('keydown printable');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips redundant textarea writes when already reset (Android IME desync guard)', () => {
|
it('skips redundant textarea writes when already reset (Android IME desync guard)', () => {
|
||||||
const { CjkInput, textarea } = loadCjkHarness();
|
const { CjkInput, textarea } = loadCjkHarness();
|
||||||
const before = textarea.valueWrites;
|
const before = textarea.valueWrites;
|
||||||
@@ -227,6 +277,15 @@ describe('CJK input module', () => {
|
|||||||
expect(src).toMatch(/this\.terminal\.focus = \(\) => \{/);
|
expect(src).toMatch(/this\.terminal\.focus = \(\) => \{/);
|
||||||
expect(src).toContain("cjkEl?.classList.contains('cjk-input-visible')");
|
expect(src).toContain("cjkEl?.classList.contains('cjk-input-visible')");
|
||||||
expect(src).toContain('CJK regain-focus (onData swallowed input)');
|
expect(src).toContain('CJK regain-focus (onData swallowed input)');
|
||||||
|
|
||||||
|
// The self-heal must only fire for GENUINE typed input: onData also fires
|
||||||
|
// for xterm's self-generated query replies (DA/DSR/CPR/OSC during Ink
|
||||||
|
// redraws), which arrive while e.g. the rename input or search box has
|
||||||
|
// focus — refocusing on those steals focus mid-typing. Guard: focus must
|
||||||
|
// be on xterm's own textarea AND the data must not be a query reply.
|
||||||
|
const selfHeal = src.slice(src.indexOf('Self-heal'), src.indexOf('CJK regain-focus'));
|
||||||
|
expect(selfHeal).toContain('document.activeElement === this.terminal.textarea');
|
||||||
|
expect(selfHeal).toContain('shouldSuppressTerminalQueryResponse(data)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends text plus carriage return on Enter', () => {
|
it('sends text plus carriage return on Enter', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user