diff --git a/.env.example b/.env.example index 7e48eb4..a04fb53 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index b4915c4..a3603d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docker/Dockerfile b/docker/Dockerfile index 6d55207..8f6695c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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-scheme(D-Bus 信号),而非 GTK_THEME。 +# 极简 KasmVNC 镜像默认没有 portal,故装上 xdg-desktop-portal + gtk 后端 + dconf/gsettings:autostart 据 +# 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/ diff --git a/docker/autostart b/docker/autostart index f1a9be8..f4415a9 100644 --- a/docker/autostart +++ b/docker/autostart @@ -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-scheme(D-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 + # 显式拉起 portal(gtk 后端提供 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 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。对任意应用通用。 ( diff --git a/docker/woc-dark.sh b/docker/woc-dark.sh new file mode 100644 index 0000000..b26ba09 --- /dev/null +++ b/docker/woc-dark.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# 实时切换本实例桌面深色(由面板经 docker exec 以 app 用户身份调用:woc-dark.sh on|off)。 +# 原理:改 GNOME color-scheme(dconf 共享后端)→ 与 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}" diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 5bf214e..127eb88 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -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}`); + // 深色模式:作为新实例启动时的初始明暗下发给 autostart(autostart 据此设 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 { }); } +// 实时切换运行中实例的桌面深色:以 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 { + await execCapture(inst, ['/woc/woc-dark.sh', dark ? 'on' : 'off']); +} + // 触发下载/安装(detached,立即返回,后台下载)。按实例 appType 分发:app-ctl.sh wechat → 委托回 // wechat-ctl.sh;telegram 等各自实现。兼容旧容器(升级前镜像里没有 /woc/app-ctl.sh):有则用之,无则 // 回退老的 wechat-ctl.sh(旧实例都是微信)。appType 取值受 instanceAppType 约束,可安全内插进 shell。 diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index f297db5..582a1e3 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -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); diff --git a/panel/server/src/store.ts b/panel/server/src/store.ts index 5a5c7a7..84aca67 100644 --- a/panel/server/src/store.ts +++ b/panel/server/src/store.ts @@ -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 { diff --git a/panel/web/src/AppShell.tsx b/panel/web/src/AppShell.tsx index b057f7a..df87a41 100644 --- a/panel/web/src/AppShell.tsx +++ b/panel/web/src/AppShell.tsx @@ -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 = { ), }; +// 主题开关:统一控制「面板」+「实例桌面」深色。面板部分立即生效(本地 CSS);实例部分仅管理员可改 +// (服务端持久化 + 对运行中实例 docker exec 实时切换;非管理员只切自己的面板观感,不动实例)。 function ThemeToggle() { + const { user } = useAuth(); + const isAdmin = user?.role === 'admin'; const [mode, setMode] = useState(() => 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 ( - ); diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index e2924f6..8773d48 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -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[] = []) => diff --git a/panel/web/src/pages/Desktop.tsx b/panel/web/src/pages/Desktop.tsx index f3b3b7d..7c8c57b 100644 --- a/panel/web/src/pages/Desktop.tsx +++ b/panel/web/src/pages/Desktop.tsx @@ -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 = ( @@ -185,6 +223,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) const dragDepth = useRef(0); const lastBeat = useRef(0); const audioRef = useRef(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 上的连接态 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 上的连接态 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) { diff --git a/panel/web/src/theme.ts b/panel/web/src/theme.ts index 6b065eb..23d463a 100644 --- a/panel/web/src/theme.ts +++ b/panel/web/src/theme.ts @@ -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; + } +}