Files
WechatOnCloud/panel/web/src/pages/Desktop.tsx
Gloridust 335b839e9a feat(dark): 面板/实例统一深色开关(portal 实时切换)+ 修复 KasmVNC 致命崩溃自愈
实例深色不再靠 GTK_THEME(微信 Chromium 内核不吃),改用 freedesktop 桌面 portal:
每个实例常驻 session D-Bus + xdg-desktop-portal(gtk 后端) + dconf(固定总线地址),
面板顶栏主题开关(管理员)统一控制面板 + 实例深色,持久化进 accounts.json,并对运行中实例
docker exec woc-dark.sh 实时切换 color-scheme(微信据 SettingChanged 实时重绘),无需改 env。

- docker/Dockerfile: 装 xdg-desktop-portal(+gtk) / dconf / gsettings / dbus-x11(单独成层)
- docker/autostart: woc_setup_portal 常驻 portal,初始明暗由 WOC_DARK 决定
- docker/woc-dark.sh: 面板经 exec 调用,实时改 gsettings color-scheme
- store.ts: 持久化 desktopDark;docker.ts: envList 读它 + setInstanceDark;index.ts: /api/desktop-theme
- AppShell 主题开关接 setDesktopTheme(含 auto 跟随系统联动);移除 WOC_INSTANCE_DARK env

KasmVNC 客户端 bundle 长跑空闲后偶发 'lastActiveAt' 致命崩溃 → 仅在其致命错误浮层出现时
做一次干净整页重载(安全重连路径,非激进 churn),5min 限 4 次防死循环。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 02:11:47 +08:00

1009 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api, appProfile } from '../api';
import { useUI } from '../ui';
import { useAuth } from '../auth';
import { useInstances } from '../AppShell';
import { VncAudio } from '../vncAudio';
// KasmVNC noVNC 页面;反代按实例隔离:/desktop/<id>/* → 对应容器,注入凭据。
function desktopUrl(id: string) {
return (
`/desktop/${id}/vnc/index.html?autoconnect=1&path=desktop/${id}/websockify&resize=remote` +
'&reconnect=true&reconnect_delay=2000&clipboard_up=true&clipboard_down=true&clipboard_seamless=true'
);
}
// 「无感输入」钩子:装进同源 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。
// 关键:队列活跃(有中文正在转发)时,只接管【数字】和回车/退格——它们不参与拼音合成、且是原"混数字丢字"的祸首;
// 字母绝不接管,否则会把下一个词的拼音首字母(如"呀"的 y当成字面字符抢走造成"你好y呀"。字母交给输入法合成。
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 (/^[0-9]$/.test(e.key)) {
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;
}
function humanSize(n: number) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
// KasmVNC/noVNC 客户端 bundle 偶发未捕获异常(实测长时间空闲后报 "Cannot read properties of undefined
// (reading 'lastActiveAt')"),会弹出其致命错误浮层(#noVNC_fallback_error 加 .noVNC_open并卡死桌面
// 此时底层 ws 已死、自带重连也救不回。返回错误文案以便记日志;无致命错误则返回 null。
function fatalErrorMsg(doc: Document | null | undefined): string | null {
try {
const el = doc?.getElementById('noVNC_fallback_error');
if (el && el.classList.contains('noVNC_open')) {
return doc?.getElementById('noVNC_fallback_errormsg')?.textContent?.trim() || 'KasmVNC 致命错误';
}
} catch {
/* 同源正常不会到这 */
}
return null;
}
// 致命崩溃自愈限频:同一实例 5 分钟内最多自动重连 4 次,超限改走手动恢复,杜绝"崩溃→重载→又崩"的死循环。
function allowAutoRecover(iid: string): boolean {
const key = `woc_fatal_${iid}`;
let n = 0;
let last = 0;
try {
const o = JSON.parse(sessionStorage.getItem(key) || '{}');
n = Number(o.n) || 0;
last = Number(o.t) || 0;
} catch {
/* ignore */
}
const now = Date.now();
if (now - last > 5 * 60 * 1000) n = 0; // 距上次自愈超 5min → 计数清零(视作长时间稳定后的新一轮崩溃)
if (n >= 4) return false;
try {
sessionStorage.setItem(key, JSON.stringify({ n: n + 1, t: now }));
} catch {
/* ignore */
}
return true;
}
const MenuIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const { user } = useAuth();
const { toast, confirm } = useUI();
const { instances, loaded, reload } = useInstances();
const isAdmin = user?.role === 'admin';
const [frameLoaded, setFrameLoaded] = useState(false);
const [loadStuck, setLoadStuck] = useState(false); // iframe 久未加载出来(疑似实例无响应)
const [dragging, setDragging] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const [files, setFiles] = useState<TFile[]>([]);
const [showClip, setShowClip] = useState(false);
const [clipText, setClipText] = useState('');
// 中文输入模式:'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') => {
try {
window.localStorage.setItem('woc_input_mode', m);
// 同步写好 enable_ime重载后新页面的 noVNC 连接时即读到
window.localStorage.setItem('enable_ime', m === 'seamless' ? 'true' : 'false');
} catch {
/* 隐私模式禁用 localStorage忽略 */
}
// 整页重载切换:先卸载旧页面(彻底关闭旧 VNC ws再以新 enable_ime 干净重连。
// 不能在页内重挂 iframe 重连——新旧两条 ws 短暂并存会概率性把实例的 Xvnc 卡死(需重启容器才恢复、
// 面板重启无效),且新连接常读不到新模式(仍是英文)。整页重载是实测唯一可靠的方式;
// 「重新连接」按钮与「重启实例」后的重连同样走整页重载(见 restartInstance / 桌面无响应面板)。
window.location.reload();
};
// 声音扬声器开关默认关。1.1.7 本就没有音频桥、连接很稳;音频是经一条额外 socket.io 连到实例 kclient
// 为排除它对连接稳定性的影响、并回到 1.1.7 的连接行为,默认不连音频桥;想听声音再开(开关进 effect 依赖,
// 关→断开音频桥,开→建立)。
const [soundOn, setSoundOn] = useState(() => {
try {
return window.localStorage.getItem('woc_sound_on') === '1';
} catch {
return false;
}
});
const toggleSound = () => {
const v = !soundOn;
setSoundOn(v);
try {
window.localStorage.setItem('woc_sound_on', v ? '1' : '0');
} catch {
/* ignore */
}
};
// 麦克风开关(默认关):仅在「声音」开启时有意义;默认不抢占麦克风,避免把 AirPods 切到低质通话模式。
const [micOn, setMicOn] = useState(() => {
try {
return window.localStorage.getItem('woc_mic_enabled') === '1';
} catch {
return false;
}
});
const toggleMic = () => {
const v = !micOn;
setMicOn(v);
try {
window.localStorage.setItem('woc_mic_enabled', v ? '1' : '0');
} catch {
/* ignore */
}
audioRef.current?.setMicEnabled(v);
};
const [imeText, setImeText] = useState('');
const [imeSending, setImeSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [starting, setStarting] = useState(false);
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
const lastBeat = useRef(0);
const audioRef = useRef<VncAudio | null>(null);
const recovering = useRef(false); // 致命崩溃自愈进行中(防错误浮层轮询与 error 事件重复触发重载)
const inst = instances.find((i) => i.id === id);
const profile = appProfile(inst?.appType); // 按应用类型显示正确文案(微信/Chromium…
const appLabel = profile.label;
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
const [probing, setProbing] = useState(true);
const offline = inst ? inst.runtime !== 'running' : false;
const installed = !!inst && inst.wechat.installed && inst.wechat.phase !== 'downloading';
const showVnc = !!inst && !offline && installed;
// 切换实例时重置内嵌态
useEffect(() => {
setFrameLoaded(false);
setLoadStuck(false);
setShowFiles(false);
setFiles([]);
setShowClip(false);
setClipText('');
setImeText('');
setProbing(true);
recovering.current = false;
}, [id]);
// 桌面久未加载出来 → 判为"无响应",把无限转圈换成可操作的重试/重启,不让用户干等。
// (实测容器跑久了会 I/O/服务 stall进程没死、显示在线但读不出 VNC 文件而永远连接中。)
useEffect(() => {
setLoadStuck(false);
if (!showVnc || frameLoaded) return;
const t = window.setTimeout(() => setLoadStuck(true), 12000);
return () => window.clearTimeout(t);
}, [showVnc, frameLoaded, id]);
// 探测态收敛找到实例即结束否则给共享列表一点刷新时间AppShell 已在导航时拉取),超时仍无则判定不存在。
useEffect(() => {
if (inst) {
setProbing(false);
return;
}
if (!probing) return;
const t = window.setTimeout(() => setProbing(false), 2500);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inst, probing, id]);
// 实例未就绪(启动中 / 安装中 / 上下文状态未刷新)时,每 3s 拉取最新状态:
// 就绪后自动进入桌面,无需手动刷新(修复"安装完进度 100% 仍提示无实例")。
useEffect(() => {
if (showVnc || !id) return;
const t = window.setInterval(() => {
if (!document.hidden) reload();
}, 3000);
return () => window.clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id]);
// 文件拖到窗口 → 弹出落区(覆盖 iframe 接住 drop
useEffect(() => {
if (!showVnc) return;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files');
const onEnter = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current++;
setDragging(true);
};
const onOver = (e: DragEvent) => hasFiles(e) && e.preventDefault();
const onLeave = (e: DragEvent) => {
if (!hasFiles(e)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
};
const onDropWin = (e: DragEvent) => {
if (hasFiles(e)) e.preventDefault();
dragDepth.current = 0;
setDragging(false);
};
window.addEventListener('dragenter', onEnter);
window.addEventListener('dragover', onOver);
window.addEventListener('dragleave', onLeave);
window.addEventListener('drop', onDropWin);
return () => {
window.removeEventListener('dragenter', onEnter);
window.removeEventListener('dragover', onOver);
window.removeEventListener('dragleave', onLeave);
window.removeEventListener('drop', onDropWin);
};
}, [showVnc]);
// 控制权(交互驱动的心跳软锁):每 3s 只读轮询当前操作者;超 TTL 自动释放。
useEffect(() => {
if (!showVnc || !id) {
setControl(null);
return;
}
let alive = true;
const poll = async () => {
if (document.hidden) return;
try {
const r = await api.controlStatus(id);
if (!alive) return;
setControl(r);
if (!r.free && !r.mine) frameRef.current?.blur(); // 只读:移开键盘焦点
} catch {
/* ignore */
}
};
poll();
const t = window.setInterval(poll, 3000);
return () => {
alive = false;
window.clearInterval(t);
};
}, [showVnc, id]);
// 用户在 VNC 内真实操作(鼠标/键盘/滚轮)时续约控制权(同源 iframe 可监听)。节流 2.5s。
// 只读用户的操作已被遮罩拦截/失焦,不会误续约;空闲不操作则超时自动释放。
useEffect(() => {
if (!showVnc || !id || !frameLoaded) return;
const win = frameRef.current?.contentWindow;
if (!win) return;
const onInteract = async () => {
const now = Date.now();
if (now - lastBeat.current < 2500) return;
lastBeat.current = now;
try {
const r = await api.controlBeat(id);
setControl({ free: false, mine: r.mine, holder: r.holder });
} catch {
/* ignore */
}
};
const evs = ['mousedown', 'keydown', 'wheel'] as const;
try {
evs.forEach((e) => win.addEventListener(e, onInteract, { capture: true, passive: true }));
} catch {
return;
}
return () => {
try {
evs.forEach((e) => win.removeEventListener(e, onInteract, { capture: true } as any));
} catch {
/* ignore */
}
};
}, [showVnc, id, frameLoaded]);
// 进入/重连桌面前,按输入模式设 KasmVNC 的 enable_imeiframe 同源共享 localStorage加载前设好即生效
// 无感seamlessenable_ime=true启用 noVNC 合成 textarea中文 keysym 已被容器补丁抑制,
// 成品由「无感输入」钩子经 xdotool 转发(见 installSeamlessIme
// 转发forwardenable_ime=falseVNC 直接打字纯 keysym英文/数字正常);中文走底部输入条。
useEffect(() => {
try {
window.localStorage.setItem('enable_ime', inputMode === 'seamless' ? 'true' : 'false');
} catch {
/* 隐私模式等禁用 localStorage忽略 */
}
}, [id, 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]);
// 音频/麦克风桥接:实例就绪即自动连接 kclient 的音频流(扬声器恒开,无需手动找工具条);
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
useEffect(() => {
if (!showVnc || !id || !soundOn) return; // 声音默认关:未开则完全不连音频桥(回到 1.1.7 无音频的连接行为)
const audio = new VncAudio(id, micOn);
audioRef.current = audio;
audio.connect();
const isFocused = () => !document.hidden && document.hasFocus();
const sync = () => audio.setActive(isFocused());
sync(); // 初始:若当前已聚焦则立即开声
document.addEventListener('visibilitychange', sync);
window.addEventListener('focus', sync);
window.addEventListener('blur', sync);
return () => {
document.removeEventListener('visibilitychange', sync);
window.removeEventListener('focus', sync);
window.removeEventListener('blur', sync);
audio.destroy();
audioRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id, soundOn]);
// 致命崩溃自愈:仅在 KasmVNC 真的弹出致命错误浮层时触发——整页重载是干净重连的唯一可靠路径
// (旧 ws 已死,重载后干净重连;与 setMode/restartInstance 同理,不会引发新旧 ws 并存卡死 Xvnc
// 与本会话曾撤掉的"激进自动重连"本质不同:那是连接态一抖就重连导致 churn这里只在【确认致命崩溃】
// 时重载一次,且 5min 内限 4 次、超限转手动恢复浮层,绝不死循环。
const recoverFromFatal = (msg: string) => {
if (recovering.current || !id) return;
recovering.current = true;
if (allowAutoRecover(id)) {
api.clientLog(id, `KasmVNC 致命错误,自动重连:${msg}`);
toast('桌面连接异常,正在自动重连…', 'error');
window.setTimeout(() => window.location.reload(), 800);
} else {
api.clientLog(id, `KasmVNC 反复致命错误,停止自动重连、转手动恢复:${msg}`);
setFrameLoaded(false);
setLoadStuck(true); // 露出"桌面无响应"浮层,由用户「重新连接/重启实例」
recovering.current = false;
}
};
// VNC 连接态监测 + 致命崩溃检测。
// 连接态:把 kasmweb 在 iframe <html> 上的连接态 class 变化回传 [client] 日志,用于排查(仅记录、不因抖动重连)。
// 致命崩溃:每 3s 检查 KasmVNC 致命错误浮层是否弹出(如长时间空闲后的 'lastActiveAt' 崩溃),弹出即自愈重连。
useEffect(() => {
if (!showVnc || !frameLoaded || !id) return;
let lastState = '';
const t = window.setInterval(() => {
const doc = frameRef.current?.contentDocument;
const fatal = fatalErrorMsg(doc);
if (fatal) {
recoverFromFatal(fatal);
return;
}
let state = '';
try {
const c = doc?.documentElement?.classList;
if (!c) return;
state = c.contains('noVNC_connected')
? 'connected'
: c.contains('noVNC_reconnecting')
? 'reconnecting'
: c.contains('noVNC_connecting')
? 'connecting'
: c.contains('noVNC_disconnected')
? 'disconnected'
: 'other';
} catch {
return; // 理论上同源;偶发不可读则跳过本次
}
if (state && state !== lastState) {
lastState = state;
api.clientLog(id, `VNC状态→${state}`);
}
}, 3000);
return () => window.clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, frameLoaded, id]);
// 更快的致命崩溃捕获:直接监听同源 iframe window 的 'error'KasmVNC 报 Uncaught 时同步触发,比 3s 轮询快),
// 但仅在延迟复核确认致命错误浮层真的弹出后才重连——排除良性报错,杜绝误重载。
useEffect(() => {
if (!showVnc || !frameLoaded || !id) return;
const win = frameRef.current?.contentWindow;
if (!win) return;
const onErr = () => {
window.setTimeout(() => {
const msg = fatalErrorMsg(frameRef.current?.contentDocument);
if (msg) recoverFromFatal(msg);
}, 400);
};
try {
win.addEventListener('error', onErr);
} catch {
return;
}
return () => {
try {
win.removeEventListener('error', onErr);
} catch {
/* ignore */
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, frameLoaded, id]);
if (!id) {
nav('/', { replace: true });
return null;
}
const refreshFiles = async () => {
try {
const { files } = await api.listFiles(id);
setFiles(files);
} catch {
/* ignore */
}
};
const uploadFiles = async (list: FileList | File[]) => {
const arr = Array.from(list);
if (!arr.length) return;
setUploading(true);
let ok = 0;
for (const f of arr) {
try {
await api.uploadFile(id, f);
ok++;
} catch (e: any) {
toast(`${f.name}: ${e.message || '上传失败'}`, 'error');
}
}
setUploading(false);
if (ok) {
toast(`已上传 ${ok} 个文件到桌面,应用里可直接取用`, 'ok');
refreshFiles();
}
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
dragDepth.current = 0;
if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files);
};
const delFile = async (name: string) => {
if (!(await confirm({ title: `删除「${name}」?`, body: '将从桌面(~/Desktop移除该文件。', danger: true, confirmText: '删除' }))) return;
try {
await api.deleteFile(id, name);
toast('已删除', 'ok');
refreshFiles();
} catch (e: any) {
toast(e.message || '删除失败', 'error');
}
};
// 同源 iframe把键盘焦点交给 VNC帮助宿主机输入法把合成的字送进去
const focusFrame = () => {
try {
frameRef.current?.focus();
frameRef.current?.contentWindow?.focus();
const ki = frameRef.current?.contentDocument?.getElementById('noVNC_keyboardinput') as HTMLElement | null;
ki?.focus();
} catch {
/* 跨域兜底(正常同源不会到这) */
}
};
// 桌面加载后给 noVNC 原生控制条注入"实心可见"样式:原生背景近纯黑半透明,叠在深色/黑屏上看不见。
// 注入后,用 KasmVNC 自带的左侧边缘手柄拉出控制条(音频/剪贴板/键盘/全屏等时即可见。iframe 同源可直接访问。
const injectVncStyle = () => {
try {
const doc = frameRef.current?.contentDocument;
if (!doc || doc.getElementById('woc-vnc-style')) return;
const st = doc.createElement('style');
st.id = 'woc-vnc-style';
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;}' +
// macOS 中文输入法需要目标元素有非零尺寸才能激活KasmVNC 默认 0x0 导致无法切换输入法
'#noVNC_keyboardinput{width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;}';
(doc.head || doc.documentElement).appendChild(st);
} catch {
/* 同源正常不会到这 */
}
};
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
// (内部走 RFB.clipboardPasteFrom → clientCutText。不依赖浏览器异步剪贴板 API故 http/局域网 IP 下也可用,
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
// 在微信输入框按 Ctrl+V 即可粘贴。
const pushClipboardToRemote = (text: string): boolean => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (!doc || !ta) return false;
ta.value = text;
ta.dispatchEvent(new (frameRef.current!.contentWindow as any).Event('change', { bubbles: true }));
return true;
} catch {
return false;
}
};
const sendClip = () => {
const t = clipText;
if (!t) {
toast('请先输入要发送的文本', 'error');
return;
}
if (pushClipboardToRemote(t)) {
toast('已发送到容器剪贴板,请在应用输入框按 Ctrl+V 粘贴', 'ok');
} else {
toast('发送失败:桌面尚未连接', 'error');
}
};
// 中文输入条发送:把本框文本经 xclip+xdotool 直接粘进微信当前聚焦的输入框(绕开 VNC IME
// 在面板的真实 textarea 里用原生输入法打字100% 可靠,不依赖 VNC 的 enable_ime / 合成事件。
const sendImeText = async () => {
const t = imeText;
if (!t.trim() || !id) return;
setImeSending(true);
try {
await api.typeInInstance(id, t);
setImeText('');
} catch (e: any) {
toast(e?.message || '发送失败:请确认实例已「升级实例」(镜像含 xclip/xdotool', 'error');
} finally {
setImeSending(false);
}
};
// 读取容器(微信侧)当前剪贴板内容到本框,便于把容器内复制的文字带回本地
const pullClipboardFromRemote = () => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (ta) {
setClipText(ta.value || '');
toast('已读取容器剪贴板', 'ok');
} else {
toast('读取失败:桌面尚未连接', 'error');
}
} catch {
toast('读取失败', 'error');
}
};
const restartInstance = async () => {
const ok = await confirm({
title: '重启该实例?',
body: `会重建容器(数据保留),${appLabel}重新启动,约十几秒;用于修复卡死/最小化丢失等。`,
confirmText: '重启',
});
if (!ok) return;
try {
await api.instanceRestart(id);
toast('已重启,正在重连…', 'ok');
// 整页重载干净重连:旧 ws 指向已销毁的旧容器,重载后连到全新容器。
// 不能页内 bump iframe 重挂——新旧 ws 并存会概率性把 Xvnc 卡死(与 setMode 同理,见上方注释)。
// 稍等让新容器的 KasmVNC 起来noVNC autoconnect+reconnect 会在就绪后自动连上。
setTimeout(() => window.location.reload(), 1200);
} catch (e: any) {
toast(e.message || '重启失败', 'error');
}
};
const takeControl = async () => {
try {
const r = await api.controlTake(id);
setControl({ free: false, mine: r.mine, holder: r.holder });
lastBeat.current = Date.now();
focusFrame();
} catch (e: any) {
toast(e.message || '接管失败', 'error');
}
};
const start = async () => {
setStarting(true);
try {
await api.instanceStart(id);
toast('实例已启动', 'ok');
await reload();
} catch (e: any) {
toast(e.message || '启动失败', 'error');
} finally {
setStarting(false);
}
};
const title = inst?.name || '实例';
return (
<div className="ws-page">
<header className="ws-head">
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
{MenuIcon}
</button>
<span className="ws-title">{title}</span>
{showVnc && (
<>
<button
className="ws-action"
title="文件传输"
onClick={() => {
setShowFiles((v) => !v);
if (!showFiles) refreshFiles();
}}
>
</button>
<button
className={'ws-action' + (inputMode === 'seamless' ? ' on' : '')}
title={
inputMode === 'seamless'
? '无感输入:直接在应用输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在应用里打)'
}
onClick={() => setMode(inputMode === 'seamless' ? 'forward' : 'seamless')}
>
{inputMode === 'seamless' ? '无感' : '转发'}
</button>
<button
className="ws-action"
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
onClick={() => setShowClip((v) => !v)}
>
</button>
<button
className={'ws-action' + (soundOn ? ' on' : '')}
title={soundOn ? '声音已开:已连接实例音频。点击关闭(关闭可减少一条到实例的连接,更稳)' : '声音已关:默认不连音频桥(连接更稳)。点此开启以听到实例声音'}
onClick={toggleSound}
>
{soundOn ? '开' : '关'}
</button>
{soundOn && (
<button
className={'ws-action' + (micOn ? ' on' : '')}
title={
micOn
? '麦克风已开占用本机麦克风AirPods 等可能被切到低音质通话模式)。点击关闭'
: '麦克风已关不占用麦克风AirPods 保持高音质输出。需要语音/通话时点此开启'
}
onClick={toggleMic}
>
{micOn ? '开' : '关'}
</button>
)}
{isAdmin && (
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
</button>
)}
</>
)}
</header>
{/* —— 各种态 —— */}
{!loaded || (probing && !inst) ? (
<div className="iv-stage iv-center">
<div className="spinner" />
</div>
) : !inst ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">访</div>
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/')}>
</button>
</div>
</div>
) : offline ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">{inst.runtime === 'missing' ? '容器尚未创建' : '实例已停止'}</div>
{isAdmin ? (
<button className="btn btn-primary iv-notice-btn" disabled={starting} onClick={start}>
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : (
<div className="iv-notice-sub"></div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
</button>
)}
</div>
</div>
) : ['downloading', 'extracting', 'installing'].includes(inst.wechat.phase) ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="spinner" />
<div className="iv-notice-title">{appLabel}</div>
<div className="iv-notice-sub">
{inst.wechat.message || '请稍候'}
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''}
</div>
</div>
</div>
) : !installed ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? `${appLabel}安装出错` : `${appLabel}尚未安装`}</div>
<div className="iv-notice-sub">
{inst.wechat.phase === 'error'
? inst.wechat.message || '安装失败,可在「管理」重试'
: `该实例容器已就绪,但尚未安装${appLabel}`}
</div>
{isAdmin ? (
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
</button>
) : (
<div className="iv-notice-sub">{appLabel}</div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
</button>
)}
</div>
</div>
) : (
<div className="iv-stage iv-stage--vnc">
<div className="iv-canvas">
{/* 切勿给本 iframe 加 key如 key={id}):那会让切换实例时 React 重挂 iframe先删旧元素再建新元素
旧元素被删时 ws 未必干净关闭 → 实例服务端残留半开连接 → 回到该实例再开新 ws 时新旧并存把 Xvnc 卡死
(刷新都救不了、要重启容器)。无 key 时切实例只改 src浏览器会“导航”iframe旧文档 unload 干净关闭旧 ws
再加载新实例。重连仍走整页重载(见 setMode / 重新连接 / restartInstance。 */}
<iframe
ref={frameRef}
className="iv-frame"
src={desktopUrl(id)}
title={`${appLabel} · 实例桌面`}
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
onLoad={() => {
setFrameLoaded(true);
if (id) api.clientLog(id, 'iframe 已加载noVNC 页面就绪,开始连 VNC');
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC
injectVncStyle(); // 让原生控制条在深色背景下可见
// 无感输入模式的键盘钩子由单独的 effect依赖 inputMode/frameLoaded安装不在此处
// 转发模式则 enable_ime=false直接打字走纯 keysym英文/数字正常),中文用底部输入条。
}, 500);
}}
/>
{!frameLoaded && !loadStuck && (
<div className="iv-loading">
<div className="spinner" />
<div className="iv-loading-text"></div>
<div className="iv-loading-sub">{profile.enterHint}</div>
<div className="iv-loading-sub"></div>
{!window.isSecureContext && (
<div className="iv-loading-warn"> HTTPS 访</div>
)}
</div>
)}
{!frameLoaded && loadStuck && (
<div className="iv-loading">
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-stuck-actions">
<button
className="btn btn-primary"
onClick={() => window.location.reload()}
title="整页重载干净重连(避免页内重挂导致 ws 并存把 Xvnc 卡死)"
>
</button>
{isAdmin && (
<button className="btn" onClick={restartInstance}>
</button>
)}
</div>
<div className="iv-loading-sub" style={{ marginTop: 8 }}>
</div>
</div>
)}
{dragging && (
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
<div className="drop-card">
<div className="drop-icon"></div>
<div className="drop-title"></div>
<div className="drop-sub">+ / </div>
</div>
</div>
)}
{control && !control.free && !control.mine && (
<div className="iv-lock">
<div className="iv-lock-card">
<div className="iv-lock-title">{control.holder}</div>
<div className="iv-lock-sub"></div>
<button className="btn btn-primary iv-notice-btn" onClick={takeControl}>
</button>
</div>
</div>
)}
{showFiles && (
<div className="iv-files">
<div className="files-head">
<span></span>
<button className="btn-text" onClick={() => setShowFiles(false)}>
</button>
</div>
<input
ref={fileInput}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => {
if (e.target.files) uploadFiles(e.target.files);
e.target.value = '';
}}
/>
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
{uploading ? '上传中…' : ' 选择文件上传'}
</button>
<div className="files-hint">~/Desktop</div>
<div className="files-list">
{files.length === 0 && (
<div className="muted small" style={{ padding: '10px 2px' }}>
</div>
)}
{files.map((f) => (
<div key={f.name} className="files-item">
<a className="files-dl" href={api.downloadFileUrl(id, f.name)} download={f.name} title="下载">
<span className="files-name">{f.name}</span>
<span className="files-size">{humanSize(f.size)} </span>
</a>
<button className="files-del" title="删除" onClick={() => delFile(f.name)}>
</button>
</div>
))}
</div>
</div>
)}
{showClip && (
<div className="iv-files">
<div className="files-head">
<span></span>
<button className="btn-text" onClick={() => setShowClip(false)}>
</button>
</div>
<textarea
className="clip-area"
value={clipText}
onChange={(e) => setClipText(e.target.value)}
placeholder="在此输入或粘贴文本,点「发送到剪贴板」后到应用输入框按 Ctrl+V 粘贴"
rows={5}
/>
<button className="btn btn-primary files-upload" onClick={sendClip}>
</button>
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
</button>
<div className="files-hint">
http 访 Ctrl+V
</div>
</div>
)}
</div>
{inputMode === 'forward' && (
<div className="iv-imebar">
<textarea
className="iv-imebar-input"
value={imeText}
onChange={(e) => setImeText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendImeText();
}
}}
placeholder="中文输入这里 → 回车送进应用先点好应用的输入框。Shift+回车换行。"
rows={1}
/>
<button
className="btn btn-primary iv-imebar-send"
disabled={imeSending || !imeText.trim()}
onClick={sendImeText}
>
{imeSending ? '发送中' : '发送'}
</button>
</div>
)}
</div>
)}
</div>
);
}