diff --git a/docker/Dockerfile b/docker/Dockerfile index 68f0e2b..b0a77c4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -14,7 +14,8 @@ RUN set -eux; \ apt-get install -y --no-install-recommends \ curl ca-certificates locales dpkg \ fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \ - libnss3 libgbm1 libasound2 libxss1; \ + libnss3 libgbm1 libasound2 libxss1 \ + xdotool; \ sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \ locale-gen; \ apt-get clean; \ @@ -72,9 +73,13 @@ RUN set -eux; \ COPY wechat-ctl.sh /woc/wechat-ctl.sh RUN chmod +x /woc/wechat-ctl.sh -# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 +# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守 COPY autostart /defaults/autostart RUN chmod +x /defaults/autostart +# 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑) +COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart +RUN chmod +x /custom-cont-init.d/01-woc-autostart + # 3000 = HTTP web 客户端, 3001 = HTTPS EXPOSE 3000 3001 diff --git a/docker/autostart b/docker/autostart index c3e55e8..dcaab7b 100644 --- a/docker/autostart +++ b/docker/autostart @@ -10,6 +10,19 @@ WECHAT_BIN=/config/wechat/opt/wechat/wechat # 容器内无 GPU,强制软件渲染 export LIBGL_ALWAYS_SOFTWARE=1 +# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。 +# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。 +( + export DISPLAY="${DISPLAY:-:1}" + while sleep 2; do + all=$(xdotool search --name '.+' 2>/dev/null) || continue + vis=$(xdotool search --onlyvisible --name '.+' 2>/dev/null) + for w in ${all}; do + printf '%s\n' "${vis}" | grep -qx "${w}" || xdotool windowactivate "${w}" 2>/dev/null || true + done + done +) & + # 1) 等待微信安装就绪(首次需在面板点「下载并安装」) notified=0 while [ ! -x "${WECHAT_BIN}" ]; do diff --git a/docker/woc-update-autostart b/docker/woc-update-autostart new file mode 100644 index 0000000..fe17542 --- /dev/null +++ b/docker/woc-update-autostart @@ -0,0 +1,9 @@ +#!/usr/bin/with-contenv bash +# linuxserver 启动钩子(/custom-cont-init.d,root 身份,每次启动执行)。 +# 作用:始终用镜像内最新的 /defaults/autostart 覆盖数据卷里的副本。 +# 原因:baseimage 的 init-kasmvnc-config 只在 /config/.config/openbox/autostart "缺失时"才拷贝, +# 导致旧实例(卷里已有旧 autostart)升级镜像后仍跑旧逻辑(如缺少"最小化自动复原"看守)。 +mkdir -p /config/.config/openbox +cp /defaults/autostart /config/.config/openbox/autostart +chmod +x /config/.config/openbox/autostart +chown "${PUID:-1000}:${PGID:-1000}" /config/.config/openbox/autostart 2>/dev/null || true diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 1a445fb..9c87135 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -125,6 +125,26 @@ export async function ensureRunning(inst: Instance): Promise { } } +// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。 +// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。 +export async function upgradeInstance(inst: Instance): Promise { + try { + await pullImage(); + } catch (e: any) { + console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e); + } + await runInstance(inst); +} + +// 停止实例容器(保留容器与数据卷,可再启动)。 +export async function stopInstance(inst: Instance): Promise { + try { + await docker.getContainer(inst.containerName).stop({ t: 5 } as any); + } catch { + /* 已停止或不存在 */ + } +} + export async function removeInstance(inst: Instance, purgeVolume: boolean): Promise { try { const c = docker.getContainer(inst.containerName); diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index cb0b078..4fcc09e 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -34,6 +34,8 @@ import { ensureNetwork, ensureRunning, runInstance, + stopInstance, + upgradeInstance, removeInstance as removeInstanceContainer, instanceRuntime, triggerWechat, @@ -264,7 +266,7 @@ app.post('/api/admin/instances/:id/rename', async (req, reply) => { } }); -// 启动/重启实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。 +// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。 app.post('/api/admin/instances/:id/start', async (req, reply) => { if (!requireAdmin(req, reply)) return; const inst = findInstance((req.params as any).id); @@ -277,6 +279,46 @@ app.post('/api/admin/instances/:id/start', async (req, reply) => { } }); +// 停止实例容器(仅管理员):保留容器与数据卷。 +app.post('/api/admin/instances/:id/stop', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await stopInstance(inst); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: '停止失败:' + (e?.message || e) }); + } +}); + +// 重启实例容器(仅管理员):按当前本地镜像重建(保留数据卷 → 登录态不丢;快速,不联网拉取)。 +app.post('/api/admin/instances/:id/restart', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await runInstance(inst); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: '重启失败:' + (e?.message || e) }); + } +}); + +// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。用于把旧实例更新到新版镜像 +// (如修复"最小化丢失"等),类似「更新微信」但更新的是实例容器镜像本身。 +app.post('/api/admin/instances/:id/upgrade', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await upgradeInstance(inst); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: '升级失败:' + (e?.message || e) }); + } +}); + // 实例侧:设置该实例可被哪些账户访问 app.post('/api/admin/instances/:id/users', async (req, reply) => { if (!requireAdmin(req, reply)) return; diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 42b5ebe..2eb108a 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -90,6 +90,9 @@ export const api = { instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }), instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }), instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }), + instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }), + instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }), + instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }), // 文件中转 listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`), diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index cfc3391..cc4e826 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -27,7 +27,14 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () const [resetTarget, setResetTarget] = useState(null); // 重置密码弹窗 const [deleteInst, setDeleteInst] = useState(null); // 删除实例弹窗 const [renameInst, setRenameInst] = useState(null); // 重命名实例弹窗 - const [starting, setStarting] = useState>(new Set()); + const [acting, setActing] = useState>({}); // 实例 id → 进行中的动作文案(启动中/升级中…) + const setAct = (id: string, label: string | null) => + setActing((a) => { + const n = { ...a }; + if (label) n[id] = label; + else delete n[id]; + return n; + }); const subs = users.filter((u) => u.role !== 'admin'); const timer = useRef(undefined); @@ -72,7 +79,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () }; const start = async (inst: InstanceWithStatus) => { - setStarting((s) => new Set(s).add(inst.id)); + setAct(inst.id, '启动中…'); try { await api.instanceStart(inst.id); toast('实例已启动', 'ok'); @@ -80,11 +87,22 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () } catch (e: any) { toast(e.message || '启动失败', 'error'); } finally { - setStarting((s) => { - const n = new Set(s); - n.delete(inst.id); - return n; - }); + setAct(inst.id, null); + } + }; + + const lifecycle = async (inst: InstanceWithStatus, kind: 'stop' | 'restart' | 'upgrade') => { + const label = kind === 'stop' ? '停止中…' : kind === 'upgrade' ? '升级中…' : '重启中…'; + setAct(inst.id, label); + if (kind === 'upgrade') toast('正在升级实例:拉取最新镜像并重建,可能需要几分钟,请勿离开…', 'info'); + try { + await (kind === 'stop' ? api.instanceStop(inst.id) : kind === 'upgrade' ? api.instanceUpgrade(inst.id) : api.instanceRestart(inst.id)); + toast(kind === 'stop' ? '已停止' : kind === 'upgrade' ? '已升级到最新镜像并重启' : '已重启', 'ok'); + await load(); + } catch (e: any) { + toast(e.message || '操作失败', 'error'); + } finally { + setAct(inst.id, null); } }; @@ -143,10 +161,13 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () key={inst.id} inst={inst} userCount={usersForInstance(inst.id).length} - starting={starting.has(inst.id)} + acting={acting[inst.id]} onEnter={() => nav(`/i/${inst.id}`)} onTrigger={trigger} onStart={() => start(inst)} + onStop={() => lifecycle(inst, 'stop')} + onRestart={() => lifecycle(inst, 'restart')} + onUpgrade={() => lifecycle(inst, 'upgrade')} onRename={() => setRenameInst(inst)} onAssign={() => setAssignInst(inst)} onDelete={() => setDeleteInst(inst)} @@ -428,20 +449,26 @@ function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o function InstanceAdminCard({ inst, userCount, - starting, + acting, onEnter, onTrigger, onStart, + onStop, + onRestart, + onUpgrade, onRename, onAssign, onDelete, }: { inst: InstanceWithStatus; userCount: number; - starting?: boolean; + acting?: string; onEnter: () => void; onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void; onStart: () => void; + onStop: () => void; + onRestart: () => void; + onUpgrade: () => void; onRename: () => void; onAssign: () => void; onDelete: () => void; @@ -450,15 +477,18 @@ function InstanceAdminCard({ const busy = BUSY_PHASES.includes(wx.phase); const installed = wx.installed && wx.phase !== 'downloading'; const offline = inst.runtime !== 'running'; + const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片 let badge: { text: string; cls: string }; - if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' }; + if (acting) badge = { text: '处理中', cls: 'tag-busy' }; + else if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' }; else if (busy) badge = { text: '处理中', cls: 'tag-busy' }; else if (installed) badge = { text: '在线', cls: 'tag-on' }; else badge = { text: '待安装', cls: 'tag-warn' }; let sub: string; - if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…'; + if (acting) sub = acting; + else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…'; else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试'; else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止'; else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装'; @@ -471,48 +501,65 @@ function InstanceAdminCard({ {badge.text}
- {sub} · 可访问 {userCount} 人 + {sub} + {!acting && ` · 可访问 ${userCount} 人`}
- {busy && ( + {working && (
= 0 ? { width: `${wx.percent}%` } : undefined} + className={'wx-progress-bar' + (acting || wx.percent < 0 ? ' indeterminate' : '')} + style={!acting && wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined} />
)} - {!busy && ( -
- {offline ? ( - - ) : ( - - )} -
- )} + {/* 进行中(升级/重启/停止/下载)时隐藏所有操作,避免重复点击 */} + {!working && ( + <> +
+ {offline ? ( + + ) : ( + + )} +
-
- {!busy && !offline && ( - - )} - - - -
+
+ {!offline && ( + + )} + + {!offline && ( + + )} + {!offline && ( + + )} + + + +
+ + )}
); } diff --git a/panel/web/src/pages/Desktop.tsx b/panel/web/src/pages/Desktop.tsx index 05046b6..95e3f02 100644 --- a/panel/web/src/pages/Desktop.tsx +++ b/panel/web/src/pages/Desktop.tsx @@ -45,6 +45,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) const [uploading, setUploading] = useState(false); const [starting, setStarting] = useState(false); const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null); + const [vncNonce, setVncNonce] = useState(0); const fileInput = useRef(null); const frameRef = useRef(null); const dragDepth = useRef(0); @@ -217,21 +218,39 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) } }; - // 打开/收起 KasmVNC 原生控制条(音频/麦克风/摄像头/剪贴板/全屏/键盘设置都在里面)。 - // noVNC 的 openControlbar 是给 #noVNC_control_bar 加 .noVNC_open(CSS 据此展开;手柄绑 mousedown, - // 合成 click 触发不了),所以直接切换 bar 上的该 class 最可靠。iframe 与面板同源,可直接访问其 DOM。 - const openVncControls = () => { + // 桌面加载后给 noVNC 原生控制条注入"实心可见"样式:原生背景近纯黑半透明,叠在深色/黑屏上看不见。 + // 注入后,用 KasmVNC 自带的左侧边缘手柄拉出控制条(音频/剪贴板/键盘/全屏等)时即可见。iframe 同源可直接访问。 + const injectVncStyle = () => { try { const doc = frameRef.current?.contentDocument; - const bar = doc?.getElementById('noVNC_control_bar'); - if (bar) { - bar.classList.toggle('noVNC_open'); - focusFrame(); - } else { - toast('控制条尚未就绪,请等画面出现后重试', 'error'); - } + 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;}'; + (doc.head || doc.documentElement).appendChild(st); } catch { - toast('无法打开 VNC 控制条', 'error'); + /* 同源正常不会到这 */ + } + }; + + const restartInstance = async () => { + const ok = await confirm({ + title: '重启该实例?', + body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。', + confirmText: '重启', + }); + if (!ok) return; + try { + await api.instanceRestart(id); + toast('已重启,正在重连…', 'ok'); + setFrameLoaded(false); + setVncNonce((n) => n + 1); // 强制 iframe 重挂、重连 + await reload(); + } catch (e: any) { + toast(e.message || '重启失败', 'error'); } }; @@ -270,9 +289,6 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) {title} {showVnc && ( <> - + {isAdmin && ( + + )} )} @@ -330,7 +351,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) ) : (