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>
This commit is contained in:
Gloridust
2026-06-19 01:34:49 +08:00
parent 60dd6d018f
commit 335b839e9a
12 changed files with 344 additions and 8 deletions

View File

@@ -91,6 +91,10 @@ WOC_INSTANCE_MEM_GB=
# 说明:设备伪装是「尽力而为」,非保证不被封;详见 doc/设备伪装.md。
WOC_SPOOF_OS=1
# 实例桌面深色:无需在此配置。面板顶栏右上角的主题开关(管理员)统一控制「面板 + 实例桌面」深色,
# 设置持久化在面板数据accounts.json并对运行中的实例实时切换微信内核是 Chromium跟随容器内
# xdg-desktop-portal 的 color-scheme 信号)。
# ── 自愈 watchdog应对 KasmVNC/Xvnc 长跑内存泄漏) ───────────
# 实测 Xvnc 长跑 24h 可膨胀到 ~9GiB原因在 KasmVNC/Xvnc 自身的 framebuffer / 软渲染 cache
# 累积,不归本项目控制。面板内置一个 watchdog周期性检查每个 running 实例的 working set

View File

@@ -30,6 +30,7 @@ services:
- WOC_WATCHDOG_INTERVAL_SEC=${WOC_WATCHDOG_INTERVAL_SEC:-300}
# 设备伪装os-release 伪装成 deepin默认开=0 关恢复 Debian。详见 .env.example / doc/设备伪装.md。
- WOC_SPOOF_OS=${WOC_SPOOF_OS:-1}
# 实例桌面深色:由面板顶栏的主题开关统一控制(管理员),持久化在 accounts.json无需在此配 env。
# 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码)
- PANEL_ADMIN_USER=${WOC_USER:-admin}
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}

View File

@@ -52,6 +52,22 @@ RUN set -eux; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*
# 深色模式支持(仅在实例环境 WOC_DARK=1 时由 autostart 启用)。微信内核是 Chromium它判断"系统是否深色"
# 读的是 freedesktop 桌面 portal 的 org.freedesktop.appearance→color-schemeD-Bus 信号),而非 GTK_THEME。
# 极简 KasmVNC 镜像默认没有 portal故装上 xdg-desktop-portal + gtk 后端 + dconf/gsettingsautostart 据
# WOC_DARK 跑一条 session 总线、把 color-scheme 设为 prefer-dark 并拉起 portal微信启动即跟随系统深色。
# 单独成层,避免动到上面缓存的安装层;关闭深色时这些服务完全不启动,零影响旧有稳定行为。
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
dbus-x11 \
xdg-desktop-portal \
xdg-desktop-portal-gtk \
dconf-service dconf-gsettings-backend \
gsettings-desktop-schemas; \
apt-get clean; \
rm -rf /var/lib/apt/lists/*
ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \
LIBGL_ALWAYS_SOFTWARE=1
@@ -70,6 +86,11 @@ RUN chmod 755 /woc/woc-www-patch.sh && chmod 644 /woc/woc-ime.pl && /woc/woc-www
COPY wechat-ctl.sh /woc/wechat-ctl.sh
RUN chmod 755 /woc/wechat-ctl.sh
# 深色模式实时切换脚本:面板顶栏主题开关 → 经 docker exec 调用,改 gsettings color-scheme
# portal 通知微信实时重绘(见 woc-dark.sh / autostart 的 portal 段)
COPY woc-dark.sh /woc/woc-dark.sh
RUN chmod 755 /woc/woc-dark.sh
# v1.2.0 多应用应用定义app-defs.sh被 autostart/app-ctl 引用)+ 通用安装/状态控制app-ctl.sh
# 微信委托回 wechat-ctl.sh、其它应用各自实现。app-defs.sh 是被 source 的、不需可执行位。
COPY app-defs.sh app-ctl.sh /woc/

View File

@@ -18,6 +18,57 @@ APP_TYPE="${WOC_APP_TYPE:-wechat}"
. /woc/app-defs.sh
woc_app_def "$APP_TYPE"
# 深色模式freedesktop 桌面 portal 方案)。微信内核是 Chromium它判断"系统是否深色"读的是 portal 的
# org.freedesktop.appearance→color-schemeD-Bus 信号),不是 GTK_THEME/settings.ini那只影响纯 GTK chrome
# 极简 KasmVNC 默认无 portal故每个实例都常驻一套 session D-Bus + xdg-desktop-portal(gtk 后端) + dconf
# - 这样面板顶栏的主题开关能"远程拨开关"实时切换实例深色(面板 docker exec 改 gsettings color-scheme →
# portal 发 SettingChanged → 微信实时重绘),无需重启实例;
# - 初始明暗由 WOC_DARK 决定(面板把开关持久化、经容器环境下发),微信由本脚本拉起、继承这套环境,启动即跟随。
# 这些服务空闲极轻、且与 VNC 连接完全无关(不参与重连/重启),不影响连接稳定性。总线地址固定,便于 exec 复用。
# 整段 best-effort任一步失败都不阻断后续启动应用。
woc_setup_portal() {
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/woc-run-$(id -u)}"
mkdir -p "$XDG_RUNTIME_DIR" 2>/dev/null && chmod 700 "$XDG_RUNTIME_DIR" 2>/dev/null || true
# 让 xdg-desktop-portal 明确选用 gtk 后端来实现 Settings即 color-scheme 来源)
export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-GNOME}"
_xcfg="${HOME:-/config}/.config/xdg-desktop-portal"
mkdir -p "$_xcfg" 2>/dev/null || true
printf '[preferred]\ndefault=gtk\norg.freedesktop.impl.portal.Settings=gtk\n' >"$_xcfg/portals.conf" 2>/dev/null || true
# 固定地址的 session 总线:微信(本脚本拉起,继承此变量)与面板 docker exec 的 woc-dark.sh 用同一条,
# 改的才是同一个 dconf、portal 才读得到。已在跑socket 存在)则复用。
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/woc-bus"
if [ ! -S "${XDG_RUNTIME_DIR}/woc-bus" ] && command -v dbus-daemon >/dev/null 2>&1; then
dbus-daemon --session --address="$DBUS_SESSION_BUS_ADDRESS" --nopidfile --fork 2>/dev/null || true
_i=0; while [ ! -S "${XDG_RUNTIME_DIR}/woc-bus" ] && [ "$_i" -lt 25 ]; do sleep 0.2; _i=$((_i + 1)); done
fi
[ -S "${XDG_RUNTIME_DIR}/woc-bus" ] || { echo "[autostart] 深色session 总线未就绪,跳过 portal"; return 0; }
# 把环境推进总线的"激活环境",使 dbus 激活起来的 portal 能看到 DISPLAY/XDG_CURRENT_DESKTOP 等
command -v dbus-update-activation-environment >/dev/null 2>&1 && \
dbus-update-activation-environment --all >/dev/null 2>&1 || true
# 初始明暗dconf 共享后端portal 据此对外报告;之后面板 woc-dark.sh 改这同一个键即实时切换)
if command -v gsettings >/dev/null 2>&1; then
if [ "${WOC_DARK:-}" = "1" ] || [ "${WOC_DARK:-}" = "true" ]; then
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' 2>/dev/null || true
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita-dark' 2>/dev/null || true
else
gsettings set org.gnome.desktop.interface color-scheme 'default' 2>/dev/null || true
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' 2>/dev/null || true
fi
fi
# 显式拉起 portalgtk 后端提供 Settings比纯 dbus 激活更稳,重复启动会自行退出、无害)
for _b in xdg-desktop-portal-gtk xdg-desktop-portal; do
for _d in /usr/libexec /usr/lib/xdg-desktop-portal /usr/lib; do
if [ -x "$_d/$_b" ]; then ( "$_d/$_b" >/dev/null 2>&1 & ); break; fi
done
done
echo "[autostart] 深色 portal 已就绪(初始 $([ "${WOC_DARK:-}" = "1" ] || [ "${WOC_DARK:-}" = "true" ] && echo prefer-dark || echo light)"
}
woc_setup_portal || true
# Chromium 实例(浏览器)初始深色再加 app 级强制UI + 网页);微信不吃命令行参数,靠 portal 信号实时跟随。
if { [ "${WOC_DARK:-}" = "1" ] || [ "${WOC_DARK:-}" = "true" ]; } && [ "$APP_TYPE" = "chromium" ]; then
APP_LAUNCH="${APP_LAUNCH} --force-dark-mode --enable-features=WebContentsForceDark"
fi
# 防“最小化后丢失”本桌面openbox无任务栏窗口被最小化就无处恢复 → 黑屏。
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。对任意应用通用。
(

33
docker/woc-dark.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# 实时切换本实例桌面深色(由面板经 docker exec 以 app 用户身份调用woc-dark.sh on|off
# 原理:改 GNOME color-schemedconf 共享后端)→ 与 autostart 同一条 session 总线上的 xdg-desktop-portal
# 收到变化、对外发 org.freedesktop.appearance→color-scheme 的 SettingChanged → 微信等 Chromium 系应用实时重绘。
# 须与 autostart 用完全相同的 XDG_RUNTIME_DIR / 总线地址,否则改的不是同一个 dconf、portal 读不到。
set -u
uid="$(id -u)"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/woc-run-${uid}}"
export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=${XDG_RUNTIME_DIR}/woc-bus}"
case "${1:-}" in
on | 1 | dark | true)
scheme='prefer-dark'
gtk='Adwaita-dark'
;;
*)
scheme='default'
gtk='Adwaita'
;;
esac
command -v gsettings >/dev/null 2>&1 || {
echo "[woc-dark] 容器内无 gsettings镜像未升级到含 portal 的版本),跳过;重启实例即用新镜像生效" >&2
exit 1
}
gsettings set org.gnome.desktop.interface color-scheme "$scheme" 2>/dev/null || {
echo "[woc-dark] 设置 color-scheme 失败portal/总线可能未就绪),可重启实例" >&2
exit 1
}
gsettings set org.gnome.desktop.interface gtk-theme "$gtk" 2>/dev/null || true
echo "[woc-dark] color-scheme=${scheme}"

View File

@@ -4,7 +4,7 @@ import { appendInstanceLog, deleteInstanceLog, appendPanelLog, readInstanceLog,
import http from 'node:http';
import zlib from 'node:zlib';
import Docker from 'dockerode';
import { instanceAppType, type Instance } from './store.js';
import { instanceAppType, getDesktopDark, type Instance } from './store.js';
const WECHAT_IMAGE = process.env.WOC_WECHAT_IMAGE || 'ghcr.io/gloridust/wechat-on-cloud:latest';
const PUID = process.env.PUID || '1000';
@@ -120,6 +120,10 @@ function envList(inst: Instance): string[] {
const appType = instanceAppType(inst);
env.push(`WOC_APP_TYPE=${appType}`);
if (appType === 'custom' && inst.customLaunch) env.push(`WOC_CUSTOM_LAUNCH=${inst.customLaunch}`);
// 深色模式:作为新实例启动时的初始明暗下发给 autostartautostart 据此设 portal color-scheme
// 微信等 Chromium 系应用即跟随系统深色)。开关由面板顶栏主题统一控制、持久化在 accounts.json
// 运行中的实例则通过 setInstanceDark 实时切换(见下)。
if (getDesktopDark()) env.push('WOC_DARK=1');
return env;
}
@@ -426,6 +430,13 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
});
}
// 实时切换运行中实例的桌面深色:以 app 用户身份执行 /woc/woc-dark.sh on|off它经同一条 session 总线
// 改 gsettings color-scheme → portal 发 SettingChanged → 微信等实时重绘。容器须为升级后镜像(含该脚本与
// portal旧镜像没有脚本会报错调用方按"best-effort"忽略即可(重启实例后即用新镜像、自动跟随)。
export async function setInstanceDark(inst: Instance, dark: boolean): Promise<void> {
await execCapture(inst, ['/woc/woc-dark.sh', dark ? 'on' : 'off']);
}
// 触发下载/安装detached立即返回后台下载。按实例 appType 分发app-ctl.sh wechat → 委托回
// wechat-ctl.shtelegram 等各自实现。兼容旧容器(升级前镜像里没有 /woc/app-ctl.sh有则用之无则
// 回退老的 wechat-ctl.sh旧实例都是微信。appType 取值受 instanceAppType 约束,可安全内插进 shell。

View File

@@ -29,6 +29,8 @@ import {
setInstanceIcon,
setInstanceUsers,
publicInstance,
getDesktopDark,
setDesktopDark,
APP_TYPES,
type AppType,
type User,
@@ -42,6 +44,7 @@ import {
upgradeInstance,
removeInstance as removeInstanceContainer,
instanceRuntime,
setInstanceDark,
triggerWechat,
wechatStatus,
instanceTarget,
@@ -200,6 +203,37 @@ app.post('/api/admin/version/self-update', async (req, reply) => {
}
});
// ---------- 实例桌面深色(与面板主题统一的那个开关)----------
// 读取当前实例深色状态(任何登录用户可读,用于前端同步主题开关与实例的一致性)。
app.get('/api/desktop-theme', async (req, reply) => {
if (!requireAuth(req, reply)) return;
return { dark: getDesktopDark() };
});
// 设置实例深色(管理员)。面板顶栏主题开关切到 深/浅 时调用:持久化 → 对所有运行中的实例实时切换
// docker exec woc-dark.sh。实时切换为 best-effort个别实例失败如旧镜像无该脚本不影响其余
// 重启该实例后即用新镜像、按持久化的初始明暗启动。
app.post('/api/admin/desktop-theme', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const dark = !!(req.body as any)?.dark;
setDesktopDark(dark);
let applied = 0;
let failed = 0;
const insts = listInstances();
await Promise.all(
insts.map(async (inst) => {
try {
if ((await instanceRuntime(inst)) !== 'running') return;
await setInstanceDark(inst, dark);
applied++;
} catch {
failed++; // 旧镜像/总线未就绪等:忽略,重启实例即生效
}
}),
);
appendPanelLog('INFO', `实例深色设为 ${dark ? '深色' : '浅色'}(实时应用 ${applied} 个,${failed} 个待重启生效)`);
return { ok: true, dark, applied, failed };
});
// ---------- 自助改密 ----------
app.post('/api/account/password', async (req, reply) => {
const u = requireAuth(req, reply);

View File

@@ -59,9 +59,17 @@ export interface Instance {
memHardLimitMB?: number;
}
// 面板级全局设置(持久化进 accounts.json
export interface Settings {
// 实例桌面深色模式由面板顶栏的主题开关统一控制管理员。true=实例内应用走深色。
// 既作为新建/重启实例的初始明暗(经容器环境 WOC_DARK 下发),也用于对运行中实例实时切换。
desktopDark?: boolean;
}
interface Data {
users: User[];
instances: Instance[];
settings?: Settings;
}
const FILE = process.env.PANEL_DATA || '/data/panel/accounts.json';
@@ -130,6 +138,20 @@ export function initStore() {
persist();
}
// ---------- 全局设置 ----------
export function getSettings(): Settings {
return data.settings || (data.settings = {});
}
export function getDesktopDark(): boolean {
return !!getSettings().desktopDark;
}
export function setDesktopDark(v: boolean) {
getSettings().desktopDark = !!v;
persist();
}
// ---------- 用户 ----------
export function publicUser(u: User) {
return {

View File

@@ -4,7 +4,7 @@ import { useAuth } from './auth';
import { useUI, PasswordInput } from './ui';
import { api, appProfile, type InstanceWithStatus } from './api';
import { InstanceIcon } from './AppIcon';
import { getThemeMode, applyThemeMode, nextThemeMode, type ThemeMode } from './theme';
import { getThemeMode, applyThemeMode, nextThemeMode, resolveDark, type ThemeMode } from './theme';
import InstanceView from './pages/Desktop';
import Admin from './pages/Admin';
@@ -261,16 +261,64 @@ const themeIcon: Record<ThemeMode, JSX.Element> = {
</svg>
),
};
// 主题开关:统一控制「面板」+「实例桌面」深色。面板部分立即生效(本地 CSS实例部分仅管理员可改
// (服务端持久化 + 对运行中实例 docker exec 实时切换;非管理员只切自己的面板观感,不动实例)。
function ThemeToggle() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const [mode, setMode] = useState<ThemeMode>(() => getThemeMode());
// 让实例桌面跟随面板主题。dark = 该模式解析后的实际明暗auto 按系统。best-effort失败忽略。
const pushDesktop = (dark: boolean) => {
if (!isAdmin) return;
api.setDesktopTheme(dark).catch(() => {});
};
// 登录/进入主页时对齐一次:仅当实例当前明暗与面板主题不一致才下发,避免无谓的 exec。
useEffect(() => {
if (!isAdmin) return;
let alive = true;
(async () => {
try {
const want = resolveDark(getThemeMode());
const { dark } = await api.getDesktopTheme();
if (alive && dark !== want) await api.setDesktopTheme(want);
} catch {
/* ignore */
}
})();
return () => {
alive = false;
};
}, [isAdmin]);
// 跟随系统时,系统亮暗变化也要带动实例。
useEffect(() => {
if (!isAdmin || mode !== 'auto') return;
let mq: MediaQueryList;
try {
mq = window.matchMedia('(prefers-color-scheme: dark)');
} catch {
return;
}
const on = () => pushDesktop(mq.matches);
mq.addEventListener?.('change', on);
return () => mq.removeEventListener?.('change', on);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, mode]);
const cycle = () => {
const m = nextThemeMode(mode);
applyThemeMode(m);
setMode(m);
pushDesktop(resolveDark(m));
};
const label = mode === 'auto' ? '跟随系统' : mode === 'light' ? '亮色' : '深色';
const hint = isAdmin
? `主题:${label}(同时切换面板与实例桌面深色;点击循环:跟随系统 / 亮色 / 深色)`
: `主题:${label}(点击切换:跟随系统 / 亮色 / 深色)`;
return (
<button className="theme-toggle" onClick={cycle} title={`主题:${label}(点击切换:跟随系统 / 亮色 / 深色)`} aria-label={`主题:${label}`}>
<button className="theme-toggle" onClick={cycle} title={hint} aria-label={`主题:${label}`}>
{themeIcon[mode]}
</button>
);

View File

@@ -131,6 +131,14 @@ export const api = {
// 一键更新面板自身:拉新镜像 + 派生 helper 容器重建 woc-panel带回滚。返回后面板会重启。
selfUpdatePanel: () => req<{ ok: boolean; target: string; message: string }>('/api/admin/version/self-update', { method: 'POST' }),
// 实例桌面深色(与面板主题统一的那个开关):读取当前态 + 设置(管理员,实时切换运行中实例)。
getDesktopTheme: () => req<{ dark: boolean }>('/api/desktop-theme'),
setDesktopTheme: (dark: boolean) =>
req<{ ok: boolean; dark: boolean; applied: number; failed: number }>('/api/admin/desktop-theme', {
method: 'POST',
body: JSON.stringify({ dark }),
}),
// 子账号
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
createUser: (username: string, password: string, allowedInstances: string[] = []) =>

View File

@@ -95,6 +95,44 @@ function humanSize(n: number) {
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" />
@@ -185,6 +223,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
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…
@@ -206,6 +245,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
setClipText('');
setImeText('');
setProbing(true);
recovering.current = false;
}, [id]);
// 桌面久未加载出来 → 判为"无响应",把无限转圈换成可操作的重试/重启,不让用户干等。
@@ -376,17 +416,41 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id, soundOn]);
// VNC 连接态监测——仅记录、不自动重连/重启。
// 1.1.7 本就没有任何"面板侧自动重连/自动重启",连接很稳;本会话加的自动重载/自动重启(heal)/回前台重载
// 反而造成"频繁卡死/重启"的churn故全部撤掉回到 1.1.7 的行为:由 noVNC 自带重连兜底,仍不行用户手动
// 「重新连接/重启」。这里只把 kasmweb 在 iframe <html> 上的连接态 class 变化回传 [client] 日志,用于排查
// 致命崩溃自愈:仅在 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 = frameRef.current?.contentDocument?.documentElement?.classList;
const c = doc?.documentElement?.classList;
if (!c) return;
state = c.contains('noVNC_connected')
? 'connected'
@@ -406,6 +470,34 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
}, 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) {

View File

@@ -26,3 +26,14 @@ export function applyThemeMode(m: ThemeMode): void {
export function nextThemeMode(m: ThemeMode): ThemeMode {
return m === 'auto' ? 'light' : m === 'light' ? 'dark' : 'auto';
}
// 把主题模式解析成"实际是不是深色"auto 按系统媒体查询判定)。实例只有深/浅两态,故用此布尔下发。
export function resolveDark(m: ThemeMode): boolean {
if (m === 'dark') return true;
if (m === 'light') return false;
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
} catch {
return false;
}
}