diff --git a/docker/Dockerfile b/docker/Dockerfile index fbd4fa5..b4a85e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -15,6 +15,7 @@ RUN set -eux; \ curl ca-certificates locales dpkg \ fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \ libnss3 libgbm1 libasound2 libxss1 \ + xz-utils \ xdotool xclip; \ sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \ locale-gen; \ @@ -68,6 +69,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 +# 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/ +RUN chmod 644 /woc/app-defs.sh && chmod 755 /woc/app-ctl.sh + # openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守 COPY autostart /defaults/autostart RUN chmod 755 /defaults/autostart @@ -81,5 +87,10 @@ RUN chmod 755 /custom-cont-init.d/00-woc-identity COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart RUN chmod 755 /custom-cont-init.d/01-woc-autostart +# 启动钩子(02):把容器环境 WOC_APP_TYPE 写入 /config/.woc-app,供 autostart 选择启动哪个应用。 +# 须在 autostart 之前执行;缺 WOC_APP_TYPE 则不写 → autostart 回退微信(向后兼容)。 +COPY woc-app-init.sh /custom-cont-init.d/02-woc-app +RUN chmod 755 /custom-cont-init.d/02-woc-app + # 3000 = HTTP web 客户端, 3001 = HTTPS EXPOSE 3000 3001 diff --git a/docker/app-ctl.sh b/docker/app-ctl.sh new file mode 100644 index 0000000..a945ca5 --- /dev/null +++ b/docker/app-ctl.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# 多应用安装/状态控制(面板经 docker exec --user abc 调用): +# app-ctl.sh +# 设计:微信完全委托给原 wechat-ctl.sh(逻辑零改动);其它应用各自实现,状态 JSON 复用同一格式与文件, +# 故面板的轮询逻辑无需区分应用类型。状态文件:/config/.woc-state/status.json。 +set -u + +APP="${1:-wechat}" +ACTION="${2:-status}" + +# 微信:原样委托,保持既有行为不变(向后兼容老实例与旧面板调用路径) +if [ "$APP" = "wechat" ]; then exec /woc/wechat-ctl.sh "$ACTION"; fi + +# shellcheck source=/dev/null +. /woc/app-defs.sh +woc_app_def "$APP" + +STATE_DIR="${WOC_STATE_DIR:-/config/.woc-state}" +STATUS_FILE="$STATE_DIR/status.json" + +is_installed() { [ -n "${APP_BIN:-}" ] && [ -x "$APP_BIN" ]; } + +write_status() { + local phase="$1" percent="$2" message="$3" installed=false + is_installed && installed=true + mkdir -p "$STATE_DIR" + cat > "$STATUS_FILE.tmp" </dev/null)" in + amd64) ;; + *) write_status error 0 "Telegram 官方仅提供 x86_64 版本,当前架构($(dpkg --print-architecture 2>/dev/null))不支持"; return ;; + esac + local work=/config/.woc-dl tmp + tmp="$work/tg.tar.xz" + rm -rf "$work"; mkdir -p "$work" + write_status downloading -1 "正在下载 Telegram" + if ! curl -fSL --retry 3 -A "Mozilla/5.0" -o "$tmp" "https://telegram.org/dl/desktop/linux"; then + write_status error 0 "下载失败,请检查网络后重试"; rm -rf "$work"; return + fi + write_status extracting 92 "正在解压安装" + local newdir="$work/x"; mkdir -p "$newdir" + # 官方包内顶层是 Telegram/ 目录,strip 掉一层 → newdir 下直接是 Telegram + Updater + if ! tar -xJf "$tmp" -C "$newdir" --strip-components=1 2>/dev/null; then + write_status error 0 "解压失败,安装包可能损坏"; rm -rf "$work"; return + fi + if [ ! -x "$newdir/Telegram" ]; then + write_status error 0 "解压后未找到 Telegram 可执行文件"; rm -rf "$work"; return + fi + write_status installing 96 "正在安装" + rm -rf /config/telegram.old + [ -e /config/telegram ] && mv /config/telegram /config/telegram.old + mv "$newdir" /config/telegram + rm -rf /config/telegram.old "$work" + write_status done 100 "安装完成" + pkill -f "/config/telegram/Telegram" 2>/dev/null || true +} + +case "$ACTION" in + status) print_status ;; + install | update) + case "$APP" in + telegram) install_telegram ;; + chromium) write_status done 100 "Chromium 随镜像就绪" ;; # 后续:apt 烤进镜像后即就绪 + custom) + if is_installed; then write_status done 100 "就绪"; else write_status error 0 "请先在「数据卷」上传并配置自定义应用"; fi ;; + *) echo "未知应用: $APP" >&2; exit 1 ;; + esac ;; + *) echo "用法: $0 {install|update|status}" >&2; exit 1 ;; +esac diff --git a/docker/app-defs.sh b/docker/app-defs.sh new file mode 100644 index 0000000..0539d7f --- /dev/null +++ b/docker/app-defs.sh @@ -0,0 +1,31 @@ +# 应用定义(被 autostart 与 app-ctl.sh source)。给定应用类型,输出该应用的: +# APP_BIN — 可执行文件路径(autostart 据此判断"是否就绪/已安装") +# APP_LAUNCH — 启动命令(可带参数;autostart 以 word-split 方式执行,参数勿含空格) +# APP_NAME — 显示名(日志用) +# 缺省/未知类型一律回退微信,保证老实例零改动。v1.2.0 多应用平台。 +woc_app_def() { + case "${1:-wechat}" in + telegram) + APP_BIN=/config/telegram/Telegram + APP_LAUNCH="$APP_BIN" + APP_NAME=Telegram + ;; + chromium) + # 容器内无 user namespace / GPU:--no-sandbox + 软件渲染;--password-store=basic 免 keyring 弹窗 + APP_BIN=/usr/bin/chromium + APP_LAUNCH="$APP_BIN --no-sandbox --no-first-run --no-default-browser-check --start-maximized --password-store=basic --disable-gpu --user-data-dir=/config/chromium" + APP_NAME=Chromium + ;; + custom) + # 自定义:启动命令由面板写入 .woc-app 的 WOC_CUSTOM_LAUNCH(用户上传安装包后设定) + APP_LAUNCH="${WOC_CUSTOM_LAUNCH:-}" + APP_BIN="${WOC_CUSTOM_BIN:-}" + APP_NAME="自定义应用" + ;; + *) + APP_BIN=/config/wechat/opt/wechat/wechat + APP_LAUNCH="$APP_BIN" + APP_NAME=微信 + ;; + esac +} diff --git a/docker/autostart b/docker/autostart index dcaab7b..f1a9be8 100644 --- a/docker/autostart +++ b/docker/autostart @@ -1,17 +1,25 @@ #!/bin/bash # 由 KasmVNC base 的 openbox 会话在桌面就绪后执行(以 app 用户身份)。 -# 微信本体由面板经 docker exec 触发下载/解压到数据卷 /config/wechat(见 wechat-ctl.sh), -# 本脚本只负责:等待微信就绪 + 常驻拉起(关窗自动重开;更新后从新版本路径重启)。 +# v1.2.0 多应用:实例承载的应用(微信/Telegram/Chromium/自定义)由面板写入容器环境 WOC_APP_TYPE, +# 再由 02-woc-app 钩子落到数据卷 /config/.woc-app。本脚本据此等待对应应用就绪并常驻拉起 +# (关窗自动重开;更新后从新版本路径重启)。读不到类型 → 回退微信,老实例零改动。 set -u -WECHAT_BIN=/config/wechat/opt/wechat/wechat - # 容器内无 GPU,强制软件渲染 export LIBGL_ALWAYS_SOFTWARE=1 -# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。 -# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。 +# 解析本实例应用类型与启动信息(APP_BIN / APP_LAUNCH / APP_NAME) +APP_TYPE=wechat +# shellcheck source=/dev/null +[ -f /config/.woc-app ] && . /config/.woc-app 2>/dev/null || true +APP_TYPE="${WOC_APP_TYPE:-wechat}" +# shellcheck source=/dev/null +. /woc/app-defs.sh +woc_app_def "$APP_TYPE" + +# 防“最小化后丢失”:本桌面(openbox)无任务栏,窗口被最小化就无处恢复 → 黑屏。 +# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。对任意应用通用。 ( export DISPLAY="${DISPLAY:-:1}" while sleep 2; do @@ -23,25 +31,32 @@ export LIBGL_ALWAYS_SOFTWARE=1 done ) & -# 1) 等待微信安装就绪(首次需在面板点「下载并安装」) +# 自定义应用若未配置启动命令,给出提示并退出(避免空等) +if [ "$APP_TYPE" = "custom" ] && [ -z "${APP_LAUNCH:-}" ]; then + echo "[autostart] 自定义应用尚未配置启动命令,请在面板「数据卷」上传安装包并设定后重启实例" + exit 0 +fi + +# 1) 等待应用安装就绪(首次需在面板点「下载并安装」) notified=0 -while [ ! -x "${WECHAT_BIN}" ]; do +while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do if [ "${notified}" -eq 0 ]; then - echo "[autostart] 微信尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装微信」)" + echo "[autostart] ${APP_NAME} 尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装」)" notified=1 fi sleep 2 done -# 3) 常驻拉起微信 +# 3) 常驻拉起应用 while true; do - if [ ! -x "${WECHAT_BIN}" ]; then + if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then # 更新过程中本体被临时挪走,等就位再继续 sleep 2 continue fi - echo "[autostart] 启动微信: ${WECHAT_BIN}" - "${WECHAT_BIN}" - echo "[autostart] 微信已退出,2 秒后重启" + echo "[autostart] 启动 ${APP_NAME}: ${APP_LAUNCH}" + # APP_LAUNCH 可带参数,按 word-split 执行(各应用参数均不含空格,见 app-defs.sh) + ${APP_LAUNCH} + echo "[autostart] ${APP_NAME} 已退出,2 秒后重启" sleep 2 done diff --git a/docker/woc-app-init.sh b/docker/woc-app-init.sh new file mode 100644 index 0000000..bb55534 --- /dev/null +++ b/docker/woc-app-init.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# /custom-cont-init.d 钩子(02):把容器环境里的应用类型写入数据卷 /config/.woc-app, +# 供 autostart(以 abc 身份的桌面会话)读取。由 s6 在 autostart 之前以 root 运行。 +# 缺 WOC_APP_TYPE(老实例/老面板)则不写文件 → autostart 回退微信,完全向后兼容。 +APP_TYPE="${WOC_APP_TYPE:-}" +[ -z "$APP_TYPE" ] && exit 0 + +# 仅允许已知的简单标识,杜绝写入异常内容 +case "$APP_TYPE" in + wechat | telegram | chromium | custom) ;; + *) exit 0 ;; +esac + +TMP=/config/.woc-app.tmp +{ + echo "WOC_APP_TYPE='${APP_TYPE}'" + # 自定义应用的启动命令由面板经环境传入(admin 设定);用单引号包裹,转义内部单引号 + if [ -n "${WOC_CUSTOM_LAUNCH:-}" ]; then + esc=${WOC_CUSTOM_LAUNCH//\'/\'\\\'\'} + echo "WOC_CUSTOM_LAUNCH='${esc}'" + fi +} > "$TMP" +mv -f "$TMP" /config/.woc-app +chown abc:abc /config/.woc-app 2>/dev/null || true +echo "[woc-app] 实例应用类型 = ${APP_TYPE}" diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index af9c56b..1be97bc 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs'; import http from 'node:http'; import zlib from 'node:zlib'; import Docker from 'dockerode'; -import type { Instance } from './store.js'; +import { instanceAppType, 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'; @@ -114,6 +114,11 @@ function envList(inst: Instance): string[] { if (!ENABLE_GPU) env.push('DISABLE_DRI=1'); // 透传 os 伪装开关给容器内的 00-woc-identity 钩子(决定是否把 /etc/os-release 改成 deepin)。 env.push(`WOC_SPOOF_OS=${SPOOF_OS ? '1' : '0'}`); + // v1.2.0 多应用:透传应用类型给 02-woc-app 钩子(写入 /config/.woc-app,autostart 据此启动)。 + // 老实例无 appType → instanceAppType 回退 wechat;自定义应用额外透传启动命令。 + const appType = instanceAppType(inst); + env.push(`WOC_APP_TYPE=${appType}`); + if (appType === 'custom' && inst.customLaunch) env.push(`WOC_CUSTOM_LAUNCH=${inst.customLaunch}`); return env; } @@ -404,11 +409,12 @@ async function execCapture(inst: Instance, cmd: string[]): Promise { }); } -// 触发下载/安装(detached,立即返回,后台下载)。 +// 触发下载/安装(detached,立即返回,后台下载)。按实例 appType 分发: +// app-ctl.sh wechat → 委托回 wechat-ctl.sh(微信逻辑零改动);telegram 等各自实现。 export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'): Promise { const c = docker.getContainer(inst.containerName); const exec = await c.exec({ - Cmd: ['/woc/wechat-ctl.sh', cmd === 'update' ? 'update' : 'install'], + Cmd: ['/woc/app-ctl.sh', instanceAppType(inst), cmd === 'update' ? 'update' : 'install'], AttachStdout: false, AttachStderr: false, User: 'abc', diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 6fd6045..131e386 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -28,6 +28,8 @@ import { renameInstance, setInstanceUsers, publicInstance, + APP_TYPES, + type AppType, type User, type Instance, } from './store.js'; @@ -270,11 +272,12 @@ app.get('/api/instances', async (req, reply) => { app.post('/api/admin/instances', async (req, reply) => { const admin = requireAdmin(req, reply); if (!admin) return; - const { name, reuseVolume } = (req.body as any) ?? {}; + const { name, reuseVolume, appType } = (req.body as any) ?? {}; const allowedUserIds = Array.isArray((req.body as any)?.allowedUserIds) ? (req.body as any).allowedUserIds : []; if (!name || String(name).trim().length === 0 || String(name).length > 30) { return reply.code(400).send({ error: '实例名称为 1-30 个字符' }); } + const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat'; // 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。 let reuseVolumeName: string | undefined; if (reuseVolume) { @@ -286,7 +289,7 @@ app.post('/api/admin/instances', async (req, reply) => { } reuseVolumeName = reuseVolume; } - const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName); + const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type); try { await runInstance(inst); } catch (e: any) { diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 05e893b..d8b0b3c 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -19,9 +19,17 @@ export interface WechatStatus { } export type RuntimeState = 'running' | 'stopped' | 'missing'; +export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom'; +export const APP_LABELS: Record = { + wechat: '微信', + telegram: 'Telegram', + chromium: 'Chromium', + custom: '自定义应用', +}; export interface PanelInstance { id: string; name: string; + appType?: AppType; // 缺省(老实例)= wechat createdAt: string; createdBy: string; memSoftLimitMB?: number; @@ -106,10 +114,10 @@ export const api = { // 微信实例 listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'), - createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string) => + createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') => req<{ instance: PanelInstance }>('/api/admin/instances', { method: 'POST', - body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }), + body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }), }), regenMachineId: (id: string) => req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }), diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index f59e9fb..f66c4b5 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { api, type PanelUser, type InstanceWithStatus, type VolEntry } from '../api'; +import { api, APP_LABELS, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api'; import { useUI, PasswordInput } from '../ui'; import { useAuth } from '../auth'; @@ -1367,8 +1367,17 @@ function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithSta ); } +// 可创建的应用类型。ready=false 的暂时禁用(即将支持)。 +const APP_OPTIONS: { type: AppType; desc: string; ready: boolean }[] = [ + { type: 'wechat', desc: '默认', ready: true }, + { type: 'telegram', desc: '仅 x86_64', ready: true }, + { type: 'chromium', desc: '即将支持', ready: false }, + { type: 'custom', desc: '即将支持', ready: false }, +]; + function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: () => void; onDone: () => void }) { const [name, setName] = useState(''); + const [appType, setAppType] = useState('wechat'); const [sel, setSel] = useState>(new Set()); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); @@ -1394,7 +1403,7 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: setErr(''); setBusy(true); try { - await api.createInstance(name.trim(), [...sel], reuse || undefined); + await api.createInstance(name.trim(), [...sel], reuse || undefined, appType); onDone(); } catch (e: any) { setErr(e.message || '创建失败'); @@ -1406,8 +1415,27 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: return (
e.stopPropagation()} onSubmit={submit}> -

新建微信实例

- setName(e.target.value)} /> +

新建实例

+
应用类型
+
+ {APP_OPTIONS.map((o) => ( + + ))} +
+ setName(e.target.value)} /> + {appType === 'telegram' && ( +
Telegram 官方仅提供 x86_64 桌面版;arm64(树莓派 / 部分 NAS)暂不支持。
+ )}
允许访问的子账号(管理员默认可访问全部)
({ id: u.id, label: u.username }))} @@ -1433,7 +1461,9 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: )} {err &&
{err}
} -
创建后会拉起一个新的微信容器,进入后扫码登录。
+
+ 创建后拉起一个新的 {APP_LABELS[appType]} 容器;进入实例后点「下载并安装」,再登录即可。 +