diff --git a/docker/Dockerfile b/docker/Dockerfile index 66f1a89..5b1c356 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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; \ diff --git a/docker/woc-ime.pl b/docker/woc-ime.pl index 02480cf..e3dc666 100644 --- a/docker/woc-ime.pl +++ b/docker/woc-ime.pl @@ -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; diff --git a/docker/woc-www-patch.sh b/docker/woc-www-patch.sh index 750251e..7218aa1 100644 --- a/docker/woc-www-patch.sh +++ b/docker/woc-www-patch.sh @@ -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 diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 6099e84..0ac017d 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -170,7 +170,7 @@ export async function instanceRuntime(inst: Instance): Promise { } } -// 在实例容器内执行命令,返回 stdout(demux 后只取标准输出)。 +// 在实例容器内执行命令,返回 stdout;若命令失败,把 stderr 透出给调用方。 async function execCapture(inst: Instance, cmd: string[]): Promise { 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 { 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 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 { + 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`; diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 29f72d5..0c38c89 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -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; diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 18434d9..6ce7be2 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -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 }) }), }; diff --git a/panel/web/src/pages/Desktop.tsx b/panel/web/src/pages/Desktop.tsx index 4699071..ea84d47 100644 --- a/panel/web/src/pages/Desktop.tsx +++ b/panel/web/src/pages/Desktop.tsx @@ -52,6 +52,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) const frameRef = useRef(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); }} />