mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Merge pull request #12 from huglemon/fix/cjk-ime-paste-fallback
修复中文 IME 输入大量丢字:增加容器内剪贴板粘贴兜底路径
This commit is contained in:
+1
-1
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
|
||||
}
|
||||
}
|
||||
|
||||
// 在实例容器内执行命令,返回 stdout(demux 后只取标准输出)。
|
||||
// 在实例容器内执行命令,返回 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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) }),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user