mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-09-20 03:23:32 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。对任意应用通用。
|
||||
(
|
||||
|
||||
33
docker/woc-dark.sh
Normal file
33
docker/woc-dark.sh
Normal file
@@ -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}"
|
||||
@@ -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<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.sh;telegram 等各自实现。兼容旧容器(升级前镜像里没有 /woc/app-ctl.sh):有则用之,无则
|
||||
// 回退老的 wechat-ctl.sh(旧实例都是微信)。appType 取值受 instanceAppType 约束,可安全内插进 shell。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[] = []) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user