1 Commits

10 changed files with 235 additions and 68 deletions
+7 -2
View File
@@ -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
+13
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/with-contenv bash
# linuxserver 启动钩子(/custom-cont-init.droot 身份,每次启动执行)。
# 作用:始终用镜像内最新的 /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
+20
View File
@@ -125,6 +125,26 @@ export async function ensureRunning(inst: Instance): Promise<void> {
}
}
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
export async function upgradeInstance(inst: Instance): Promise<void> {
try {
await pullImage();
} catch (e: any) {
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
}
await runInstance(inst);
}
// 停止实例容器(保留容器与数据卷,可再启动)。
export async function stopInstance(inst: Instance): Promise<void> {
try {
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
} catch {
/* 已停止或不存在 */
}
}
export async function removeInstance(inst: Instance, purgeVolume: boolean): Promise<void> {
try {
const c = docker.getContainer(inst.containerName);
+43 -1
View File
@@ -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;
+3
View File
@@ -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`),
+92 -45
View File
@@ -27,7 +27,14 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
const [starting, setStarting] = useState<Set<string>>(new Set());
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 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<number | undefined>(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({
<span className={'tag ' + badge.cls}>{badge.text}</span>
</div>
<div className="inst-sub">
{sub} · 访 {userCount}
{sub}
{!acting && ` · 可访问 ${userCount}`}
</div>
{busy && (
{working && (
<div className="wx-progress">
<div
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
className={'wx-progress-bar' + (acting || wx.percent < 0 ? ' indeterminate' : '')}
style={!acting && wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
/>
</div>
)}
{!busy && (
<div className="inst-actions">
{offline ? (
<button className="btn btn-primary inst-act-wide" disabled={starting} onClick={onStart}>
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : (
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
</button>
)}
</div>
)}
{/* 进行中(升级/重启/停止/下载)时隐藏所有操作,避免重复点击 */}
{!working && (
<>
<div className="inst-actions">
{offline ? (
<button className="btn btn-primary inst-act-wide" onClick={onStart}>
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : (
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
</button>
)}
</div>
<div className="inst-admin-links">
{!busy && !offline && (
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
{installed ? '更新微信' : '下载安装'}
</button>
)}
<button className="btn-text" onClick={onRename}>
</button>
<button className="btn-text" onClick={onAssign}>
</button>
<button className="btn-text danger" onClick={onDelete}>
</button>
</div>
<div className="inst-admin-links">
{!offline && (
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
{installed ? '更新微信' : '下载安装'}
</button>
)}
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
</button>
{!offline && (
<button className="btn-text" onClick={onRestart}>
</button>
)}
{!offline && (
<button className="btn-text" onClick={onStop}>
</button>
)}
<button className="btn-text" onClick={onRename}>
</button>
<button className="btn-text" onClick={onAssign}>
</button>
<button className="btn-text danger" onClick={onDelete}>
</button>
</div>
</>
)}
</div>
);
}
+42 -18
View File
@@ -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<HTMLInputElement>(null);
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
@@ -217,21 +218,39 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
// 打开/收起 KasmVNC 原生控制条(音频/麦克风/摄像头/剪贴板/全屏/键盘设置都在里面)
// noVNC 的 openControlbar 是给 #noVNC_control_bar 加 .noVNC_openCSS 据此展开;手柄绑 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 })
<span className="ws-title">{title}</span>
{showVnc && (
<>
<button className="ws-action" title="VNC 控制:音频 / 麦克风 / 摄像头 / 剪贴板 / 全屏 / 键盘" onClick={openVncControls}>
</button>
<button
className="ws-action"
title="文件传输"
@@ -283,6 +299,11 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
>
</button>
{isAdmin && (
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
</button>
)}
</>
)}
</header>
@@ -330,7 +351,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
) : (
<div className="iv-stage">
<iframe
key={id}
key={`${id}:${vncNonce}`}
ref={frameRef}
className="iv-frame"
src={desktopUrl(id)}
@@ -338,7 +359,10 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
onLoad={() => {
setFrameLoaded(true);
setTimeout(focusFrame, 400); // 加载完把键盘焦点交给 VNC(宿主机输入法)
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
injectVncStyle(); // 让原生控制条在深色背景下可见
}, 500);
}}
/>
@@ -347,7 +371,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<div className="spinner" />
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-loading-sub">//</div>
<div className="iv-loading-sub">/</div>
{!window.isSecureContext && (
<div className="iv-loading-warn"> HTTPS 访</div>
)}
+2 -2
View File
@@ -1382,9 +1382,9 @@ button {
border: none;
background: var(--surface);
color: var(--wx-green-dark);
font-size: 14px;
font-size: 13px;
font-weight: 600;
padding: 7px 14px;
padding: 6px 11px;
border-radius: 999px;
cursor: pointer;
box-shadow: var(--crease);
+4
View File
@@ -29,6 +29,10 @@ export default defineConfig({
workbox: {
// 桌面反代与 API 不能被 SW 拦截
navigateFallbackDenylist: [/^\/desktop/, /^\/api/],
// 新版本立即接管 + 清理旧缓存,避免更新后仍跑旧代码(硬刷新绕不过 SW)
clientsClaim: true,
skipWaiting: true,
cleanupOutdatedCaches: true,
},
}),
],