From 8ab5375ded5973fcca3479e7f15cb6054569a280 Mon Sep 17 00:00:00 2001 From: Gloridust Date: Sun, 14 Jun 2026 15:15:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E8=BE=93=E5=85=A5=E6=B3=95?= =?UTF-8?q?=E5=8F=8C=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2=EF=BC=88=E6=97=A0?= =?UTF-8?q?=E6=84=9F=20/=20=E8=BD=AC=E5=8F=91=EF=BC=89+=20=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=84=9F=E4=B8=A2=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nav 栏可切「无感输入」(直接在微信里打中文)/「转发输入」(底部输入条,默认),偏好持久化。 无感:enable_ime=true,compositionend 捕获中文经 xdotool 转发;并用有序队列把"转发未完成期间" 的后续可见字符 + 回车/退格串行送出,彻底消除"中文走异步、数字走 keysym 抢跑"的"你好123→23"丢字; 队列空闲不干预,英文/数字仍走原生 keysym 零延迟。新增 /api/instances/:id/key(xdotool 单键)。 Co-Authored-By: Claude Opus 4.8 --- panel/server/src/docker.ts | 16 ++++ panel/server/src/index.ts | 17 +++++ panel/web/src/api.ts | 1 + panel/web/src/pages/Desktop.tsx | 127 ++++++++++++++++++++++++++++---- 4 files changed, 145 insertions(+), 16 deletions(-) diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index f9554c1..d32af72 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -582,6 +582,22 @@ export async function typeInInstance(inst: Instance, text: string): Promise { + if (!/^[A-Za-z_]{1,20}$/.test(key)) throw new Error('按键名不合法'); + 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 xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }', + `xdotool key --clearmodifiers ${key}`, + ].join('; '); + await execCapture(inst, ['bash', '-c', cmd]); +} + // ---------- 数据卷管理(仅管理员;路由层用 requireAdmin 限制) ---------- // 数据卷 = 容器内 /config 持久卷,含微信全部数据(登录态、加密聊天库等)。提供浏览/上传/解压/下载/ // 改名/移动/删除 + 整卷备份/恢复。主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。 diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index e36cf5e..6fd6045 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -48,6 +48,7 @@ import { deleteInstanceFile, instanceLogs, typeInInstance, + keyInInstance, listOrphanVolumes, removeVolume, listOrphanContainers, @@ -632,6 +633,22 @@ app.post('/api/instances/:id/type', async (req, reply) => { } }); +// 模拟单个按键(无感输入模式下按序送出被截下的回车/退格,保证与中文转发的顺序) +app.post('/api/instances/:id/key', 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 { key } = (req.body as any) ?? {}; + if (!key || typeof key !== 'string') return reply.code(400).send({ error: '按键名为空' }); + try { + await keyInInstance(findInstance(id)!, key); + 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 80bca9b..05e893b 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -182,4 +182,5 @@ export const api = { 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 }) }), + keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }), }; diff --git a/panel/web/src/pages/Desktop.tsx b/panel/web/src/pages/Desktop.tsx index b4e8bb4..29c6024 100644 --- a/panel/web/src/pages/Desktop.tsx +++ b/panel/web/src/pages/Desktop.tsx @@ -14,6 +14,74 @@ function desktopUrl(id: string) { ); } +// 「无感输入」钩子:装进同源 iframe,让用户直接在微信里打中文。 +// - compositionend(中文提交)→ 经 xclip+xdotool 转发(绕开 VNC keysym 容量上限)。 +// - 转发未完成期间(队列活跃),把后续可见字符 + 回车/退格也串进同一队列按序送出 → +// 彻底消除"中文走异步、数字走 keysym 抢跑"导致的"你好123→23"丢字。 +// - 队列空闲时不干预:英文/数字仍走原生 keysym,零延迟。 +// 返回清理函数(切回转发模式 / 重连 / 卸载时移除监听)。 +function installSeamlessIme(win: Window, doc: Document, instId: string): () => void { + type Job = { kind: 'text'; data: string } | { kind: 'key'; data: string }; + const queue: Job[] = []; + let draining = false; + const active = () => draining || queue.length > 0; + + const drain = async () => { + if (draining) return; + draining = true; + while (queue.length) { + const job = queue[0]; + try { + if (job.kind === 'text') await api.typeInInstance(instId, job.data); + else await api.keyInInstance(instId, job.data); + } catch { + /* 单条失败丢弃,继续后续,避免卡住队列 */ + } + queue.shift(); + } + draining = false; + }; + + const onCompositionEnd = (e: Event) => { + const txt = (e as CompositionEvent).data; + if (!txt) return; + queue.push({ kind: 'text', data: txt }); + drain(); + }; + + // 捕获阶段(iframe window 最外层)抢先拦截,赶在 noVNC 之前 → stopImmediatePropagation 阻止它发 keysym。 + const onKeyDownCapture = (ev: Event) => { + const e = ev as KeyboardEvent; + if (e.isComposing) return; // 拼音合成中,交给输入法 + if (e.ctrlKey || e.altKey || e.metaKey) return; // 快捷键放行 + if (!active()) return; // 没有中文在转发 → 不接管(英文/数字走原生 keysym,零延迟) + if (e.key.length === 1) { + e.preventDefault(); + e.stopImmediatePropagation(); + queue.push({ kind: 'text', data: e.key }); + drain(); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopImmediatePropagation(); + queue.push({ kind: 'key', data: 'Return' }); + drain(); + } else if (e.key === 'Backspace') { + e.preventDefault(); + e.stopImmediatePropagation(); + queue.push({ kind: 'key', data: 'BackSpace' }); + drain(); + } + // 其它非可见键(方向键/功能键等)放行 + }; + + doc.addEventListener('compositionend', onCompositionEnd, true); + win.addEventListener('keydown', onKeyDownCapture, true); + return () => { + doc.removeEventListener('compositionend', onCompositionEnd, true); + win.removeEventListener('keydown', onKeyDownCapture, true); + }; +} + interface TFile { name: string; size: number; @@ -46,8 +114,22 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) const [files, setFiles] = useState([]); const [showClip, setShowClip] = useState(false); const [clipText, setClipText] = useState(''); - // 中文输入条:面板里的真实 textarea(原生客户端输入法 100% 可用),回车经 xclip+xdotool 粘进微信。 - const [imeBar, setImeBar] = useState(true); // 默认开(直接在 VNC 里打中文不稳,给一个可靠通道) + // 中文输入模式:'forward'=底部输入条转发(默认,最稳);'seamless'=无感(直接在微信里打,提交后转发)。 + const [inputMode, setInputMode] = useState<'forward' | 'seamless'>(() => { + try { + return window.localStorage.getItem('woc_input_mode') === 'seamless' ? 'seamless' : 'forward'; + } catch { + return 'forward'; + } + }); + const setMode = (m: 'forward' | 'seamless') => { + setInputMode(m); + try { + window.localStorage.setItem('woc_input_mode', m); + } catch { + /* 隐私模式禁用 localStorage:忽略 */ + } + }; const [imeText, setImeText] = useState(''); const [imeSending, setImeSending] = useState(false); const [uploading, setUploading] = useState(false); @@ -203,18 +285,27 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) }; }, [showVnc, id, frameLoaded]); - // 每次进入/重连桌面前,强制把 KasmVNC 的 enable_ime 设为【关】。 - // 原因:开启 IME 模式后,noVNC 用隐藏 textarea + 合成事件还原中文,需要前端拦截/差分,环环相扣极脆, - // 实测会"中文混数字时丢最后两个汉字+首个数字"等损坏。中文输入改由底部「中文输入条」可靠承担 - // (面板真实 textarea 原生输入法 → xclip+xdotool 粘贴),故 VNC 直接打字回归纯 keysym:英文/数字/ - // 标点都正常、不再损坏;中文直接打不进(请用输入条)。iframe 同源共享 localStorage,加载前设好即生效。 + // 进入/重连桌面前,按输入模式设 KasmVNC 的 enable_ime(iframe 同源共享 localStorage,加载前设好即生效)。 + // 无感(seamless):enable_ime=true,启用 noVNC 合成 textarea;中文 keysym 已被容器补丁抑制, + // 成品由「无感输入」钩子经 xdotool 转发(见 installSeamlessIme)。 + // 转发(forward):enable_ime=false,VNC 直接打字纯 keysym(英文/数字正常);中文走底部输入条。 useEffect(() => { try { - window.localStorage.setItem('enable_ime', 'false'); + window.localStorage.setItem('enable_ime', inputMode === 'seamless' ? 'true' : 'false'); } catch { /* 隐私模式等禁用 localStorage:忽略 */ } - }, [id, vncNonce]); + }, [id, vncNonce, inputMode]); + + // 无感模式:往同源 iframe 装「中文转发 + 有序队列」钩子;切回转发/重连/卸载时自动移除。 + useEffect(() => { + if (inputMode !== 'seamless' || !showVnc || !frameLoaded || !id) return; + const win = frameRef.current?.contentWindow; + const doc = frameRef.current?.contentDocument; + if (!win || !doc) return; + const cleanup = installSeamlessIme(win, doc, id); + return cleanup; + }, [inputMode, showVnc, frameLoaded, id, vncNonce]); // 音频/麦克风桥接:实例就绪即自动连接 kclient 的音频流(扬声器恒开,无需手动找工具条); // 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。 @@ -449,11 +540,15 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) 文件