mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(mobile): CJK input loss — IME state machine, focus routing, and Android InputConnection recovery
Three independent root causes of intermittent Chinese character loss (English was unaffected because it bypasses the composition path): 1. input-cjk.js state machine: stuck _composing when compositionend never fires (WeChat/Sogou IMEs) silently swallowed all input; the deferred compositionend flush could reset the textarea mid-next-composition (cancels the live IME composition on iOS); the 100ms keydown-echo window discarded ANY input regardless of content. 2. Focus stealing: session-select / SSE-reconnect paths call terminal.focus() (15+ call sites), landing focus on xterm's hidden textarea; with the CJK onData gate active, everything typed there was swallowed. Fix: focus router in initTerminal routes ALL terminal.focus() calls to the CJK field while it is visible, plus a self-healing onData gate that reclaims focus when it swallows input. 3. Android InputConnection wedge (9-key IMEs + Chromium): the keyboard composes in its own UI but delivers zero DOM events. Fix: skip redundant textarea value/selection writes (they race IME session setup), and re-tapping the focused empty field forces a blur→focus cycle that restarts the input session. Diagnostics: input-cjk.js now traces every IME event/flush decision into the crash-diag breadcrumbs; /api/crash-diag stores beacons per page-load id (iOS PWA reloads no longer wipe the trail, concurrent clients no longer clobber each other) and flushes on visibilitychange. Tests: test/input-cjk.test.ts (vm-sandbox, 9 cases incl. regression guards for all three root causes).
This commit is contained in:
+32
-8
@@ -61,6 +61,10 @@
|
||||
const _crashDiag = {
|
||||
_entries: [],
|
||||
_maxEntries: 50,
|
||||
// Per-page-load id: the server keys beacons by it, so a reload (fresh id)
|
||||
// archives the previous page's trail instead of overwriting it, and
|
||||
// concurrent clients (desktop + phone) don't clobber each other.
|
||||
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
|
||||
log(msg) {
|
||||
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
|
||||
this._entries.push(entry);
|
||||
@@ -69,22 +73,36 @@ const _crashDiag = {
|
||||
}
|
||||
};
|
||||
|
||||
// Log previous crash breadcrumbs on startup
|
||||
// Log previous crash breadcrumbs on startup, and re-beacon them under a
|
||||
// distinct page id — an iOS PWA reload wipes the in-memory trail, and without
|
||||
// this the fresh page's first beacon would be all the server ever sees.
|
||||
try {
|
||||
const prev = localStorage.getItem('codeman-crash-diag');
|
||||
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
|
||||
if (prev) {
|
||||
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
|
||||
}
|
||||
} catch {}
|
||||
_crashDiag.log('PAGE LOAD');
|
||||
|
||||
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
|
||||
setInterval(() => {
|
||||
function _crashDiagBeacon() {
|
||||
try {
|
||||
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
|
||||
if (_crashDiag._entries.length > 0) {
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setInterval(() => {
|
||||
try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); } catch {}
|
||||
_crashDiagBeacon();
|
||||
}, 2000);
|
||||
// iOS suspends JS the instant the app is backgrounded — entries logged since
|
||||
// the last 2s tick would sit unsent until (if ever) the page resumes. Flush
|
||||
// immediately on hide so a repro right before an app switch is never lost.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') _crashDiagBeacon();
|
||||
});
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
|
||||
@@ -3390,9 +3408,15 @@ class CodemanApp {
|
||||
// Close WebSocket for previous session (new one opens after buffer load)
|
||||
this._disconnectWs();
|
||||
|
||||
// Clear CJK textarea to prevent sending stale text to the wrong session
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl) cjkEl.value = '';
|
||||
// Clear CJK input to prevent sending stale text to the wrong session.
|
||||
// Must go through CjkInput.clear() — a raw value wipe leaves the module's
|
||||
// pending flush timers armed and drops the phantom char it relies on.
|
||||
if (typeof CjkInput !== 'undefined') {
|
||||
CjkInput.clear();
|
||||
} else {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl) cjkEl.value = '';
|
||||
}
|
||||
|
||||
// Clean up flicker filter state when switching sessions
|
||||
this._clearTimer('flickerFilterTimeout');
|
||||
|
||||
Reference in New Issue
Block a user