Merge pull request #12 from huglemon/fix/cjk-ime-paste-fallback

修复中文 IME 输入大量丢字:增加容器内剪贴板粘贴兜底路径
This commit is contained in:
Ethan Zou
2026-06-03 01:16:40 +08:00
committed by GitHub
Unverified
7 changed files with 148 additions and 17 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ RUN set -eux; \
curl ca-certificates locales dpkg \
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
libnss3 libgbm1 libasound2 libxss1 \
xdotool; \
xdotool xclip; \
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
locale-gen; \
apt-get clean; \
+5 -9
View File
@@ -4,14 +4,15 @@
# 背景:noVNC 原实现靠"隐藏 textarea 差分→逐字符重发 keysym"还原 IME 输入,会在合成过程中
# 把中间拼音也发给远端、且永不 reset 导致累积+退格风暴 → 大量丢字 / 卡住 / 跨浏览器不稳。
#
# 改法:彻底不靠 textarea 差分还原中文。
# 改法:彻底不靠 textarea 差分或 VNC keysym 还原中文。
# - 合成进行中(input 事件):只同步 _lastKeyboardInput、不发送(避免中间拼音泄漏 / 丢字)。
# - 提交时(compositionend)用 e.data(最终上屏字符串)逐字发 keysym,并把 _lastKeyboardInput
# 同步成当前 textarea 值。不 reset、不吞键——避免吞掉下一个词的首键、避免打断下一次合成。
# - 提交时(compositionend)只同步 _lastKeyboardInput 并返回,不再逐字发 keysym。
# 成品文本由面板前端捕获后通过 xclip/xdotool 粘贴进远端窗口,绕开 KasmVNC XKB
# keysym 容量限制,也避免和粘贴路径重复上屏。
# - 若个别浏览器在 compositionend 后还补发一次"提交 input":此时 isComposing/_imeHold 均为假,
# 落到非 IME 差分分支,但 newValue 与刚同步的 _lastKeyboardInput 相等 → 差分为空 → 不重复发送。
# (A) _handleCompositionEnd:提交时 e.data 直发 + 同步 _lastKeyboardInput(不 reset、不吞键)
# (A) _handleCompositionEnd:提交时同步 _lastKeyboardInput,文本由面板粘贴路径负责。
s~\Q if (this._enableIME) {
this._imeInProgress = false;
}
@@ -20,11 +21,6 @@ s~\Q if (this._enableIME) {
this._imeHold = false;
}\E~ if (this._enableIME) { // WOC-IME
this._imeInProgress = false;
var _wocStr = (e && typeof e.data === "string") ? e.data : "";
for (var _wocI = 0; _wocI < _wocStr.length; _wocI++) {
this._sendKeyEvent(keysymdef.lookup(_wocStr.charCodeAt(_wocI)), 'Unidentified', true);
this._sendKeyEvent(keysymdef.lookup(_wocStr.charCodeAt(_wocI)), 'Unidentified', false);
}
this._imeHold = false;
this._lastKeyboardInput = e.target.value;
return;
+3 -4
View File
@@ -2,10 +2,9 @@
# 构建期补丁:改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式(本地输入法打中文,成品汉字发进容器,容器内不装 IME)
# (2) 修复 noVNC 的中文 IME 输入:原实现靠"隐藏 textarea 差分→逐字符重发 keysym"
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴
# 在 VNC 上表现为大量丢字、~21 字后卡住、跨浏览器不稳定
# 改为:合成期间(input)一律不发;只在 compositionend 用 e.data(最终上屏串)逐字发 keysym
# 提交后 reset textarea,并吞掉紧随其后的提交 input 事件,避免重复发送/跨分支竞争。
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴
# 改为合成期间和提交时都只同步内部 textarea 状态,不再发送中文 keysym
# 最终成品文本由面板前端捕获后通过 xclip/xdotool 粘贴,绕过 KasmVNC XKB keysym 限制。
# 末尾断言:若 base 镜像换了打包结构、一个文件都没改到,则构建失败而非静默放过。
set -euo pipefail
+30 -2
View File
@@ -170,7 +170,7 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
}
}
// 在实例容器内执行命令,返回 stdoutdemux 后只取标准输出)
// 在实例容器内执行命令,返回 stdout;若命令失败,把 stderr 透出给调用方
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const c = docker.getContainer(inst.containerName);
const exec = await c.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
@@ -181,7 +181,18 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const stdout = { write: (b: Buffer) => { out += b.toString('utf8'); } } as any;
const stderr = { write: (b: Buffer) => { err += b.toString('utf8'); } } as any;
docker.modem.demuxStream(stream, stdout, stderr);
stream.on('end', () => resolve(out || err));
stream.on('end', async () => {
try {
const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) {
reject(new Error((err || out || `命令执行失败,退出码 ${info.ExitCode}`).trim()));
return;
}
resolve(out || err);
} catch (e) {
reject(e);
}
});
stream.on('error', reject);
});
}
@@ -328,6 +339,23 @@ export async function instanceLogs(inst: Instance, tail = 600): Promise<string>
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
}
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
export async function typeInInstance(inst: Instance, text: string): Promise<void> {
const b64 = Buffer.from(text, 'utf8').toString('base64');
const cmd = [
'set -e',
'display="${DISPLAY:-}"',
'if [ -z "$display" ]; then for x in /tmp/.X11-unix/X*; do [ -e "$x" ] || continue; display=":${x##*X}"; break; done; fi',
'export DISPLAY="${display:-:1}"',
'command -v xclip >/dev/null 2>&1 || { echo "xclip not installed in instance image" >&2; exit 127; }',
'command -v xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
`echo '${b64}' | base64 -d | xclip -selection clipboard -i`,
'xdotool key --clearmodifiers ctrl+v',
].join('; ');
await execCapture(inst, ['bash', '-c', cmd]);
}
// 实例容器名(供反代构造 target)。
export function instanceTarget(inst: Instance): string {
return `http://${inst.containerName}:3000`;
+17
View File
@@ -46,6 +46,7 @@ import {
downloadFromInstance,
deleteInstanceFile,
instanceLogs,
typeInInstance,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
@@ -438,6 +439,22 @@ app.post('/api/instances/:id/control/take', async (req, reply) => {
return { mine: true, holder: u.username };
});
// 通过 xdotool 在实例容器内输入文字(绕过 VNC XKB keysym 容量限制,修复中文 IME 吞字)
app.post('/api/instances/:id/type', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const { text } = (req.body as any) ?? {};
if (!text || typeof text !== 'string' || text.length > 500) return reply.code(400).send({ error: '文字为空或过长' });
try {
await typeInInstance(findInstance(id)!, text);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '输入失败' });
}
});
// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。
app.get('/api/admin/instances/:id/logs', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
+1
View File
@@ -114,4 +114,5 @@ export const api = {
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
};
+91 -1
View File
@@ -52,6 +52,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
const lastBeat = useRef(0);
const lastImeError = useRef(0);
const inst = instances.find((i) => i.id === id);
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
@@ -260,13 +261,101 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
st.textContent =
'#noVNC_control_bar_anchor{z-index:2147483647!important;}' +
'#noVNC_control_bar{background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.55)!important;box-shadow:0 0 24px rgba(0,0,0,.55)!important;}' +
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}';
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}' +
// macOS 中文输入法需要目标元素有非零尺寸才能激活;KasmVNC 默认 0x0 导致无法切换输入法
'#noVNC_keyboardinput{width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;}';
(doc.head || doc.documentElement).appendChild(st);
} catch {
/* 同源正常不会到这 */
}
};
// 中文 IME 输入修复:绕过 VNC XKB keysym 容量限制(~21 个 CJK 字符后 keymap 满,输入全废)。
// 根因:KasmVNC 的 Perl 补丁在 compositionend 发 CJK keysym,但紧随其后的 _handleInput
// diff 逻辑会发 Backspace 清拼音,把刚发的字也删了。必须在捕获阶段拦截,阻止 Perl 补丁执行,
// 手动重置内部状态(防止 _handleInput 发 Backspace),然后通过 API 用 xdotool 粘贴文字。
const patchVncIme = () => {
try {
const doc = frameRef.current?.contentDocument;
if (!doc || doc.getElementById('woc-ime-patch')) return;
const ta = doc.getElementById('noVNC_keyboardinput') as HTMLTextAreaElement | null;
if (!ta) return;
const win = frameRef.current?.contentWindow as any;
let imeComposing = false;
let swallowInputUntil = 0;
const keyboard = () => {
const cv = doc.querySelector('canvas') as any;
return win?.UI?.rfb?.keyboard || cv?._rfb?.keyboard || null;
};
const installKeyboardGuard = () => {
const kb = keyboard() as any;
if (!kb || kb._wocImeGuard || typeof kb._sendKeyEvent !== 'function') return;
const original = kb._sendKeyEvent.bind(kb);
kb._wocImeGuard = true;
if (typeof kb._wocImeSuppressUnicode !== 'boolean') kb._wocImeSuppressUnicode = false;
kb._sendKeyEvent = (keysym: number, ...args: any[]) => {
if (kb._wocImeSuppressUnicode && typeof keysym === 'number' && keysym >= 0x01000000) return;
return original(keysym, ...args);
};
};
const syncKeyboardInput = (value: string) => {
try {
installKeyboardGuard();
const kb = keyboard();
if (kb) {
kb._imeInProgress = false;
kb._imeHold = false;
kb._lastKeyboardInput = value;
if (kb._rfbKeyQueue) kb._rfbKeyQueue.length = 0;
}
} catch { /* ignore */ }
};
const swallowNoVncInput = (e: Event) => {
if (!imeComposing && Date.now() > swallowInputUntil) return;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
};
ta.addEventListener('compositionstart', (e) => {
imeComposing = true;
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
}, true);
ta.addEventListener('beforeinput', swallowNoVncInput, true);
ta.addEventListener('input', swallowNoVncInput, true);
ta.addEventListener('compositionend', (e) => {
const text = (e as CompositionEvent).data;
if (!text || !id) return;
imeComposing = false;
swallowInputUntil = Date.now() + 300;
e.stopImmediatePropagation(); // 阻止 KasmVNC 原生 IME 路径再发一遍 keysym
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
window.setTimeout(() => {
ta.value = '';
syncKeyboardInput('');
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = false;
}, 0);
// 通过面板 API → xdotool 在容器内粘贴,完全绕过 VNC keysym
api.typeInInstance(id, text).catch((err) => {
const now = Date.now();
if (now - lastImeError.current > 3000) {
lastImeError.current = now;
toast(err?.message || '中文输入失败,请确认实例镜像包含 xclip/xdotool', 'error');
}
});
}, true); // capture:先于 Perl 补丁的 bubble handler
const mark = doc.createElement('meta');
mark.id = 'woc-ime-patch';
(doc.head || doc.documentElement).appendChild(mark);
} catch {
/* ignore */
}
};
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
@@ -472,6 +561,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
injectVncStyle(); // 让原生控制条在深色背景下可见
patchVncIme(); // 修复中文 IME 吞字(绕过 VNC XKB keysym 限制)
}, 500);
}}
/>