feat(v1.2.0): 多应用平台——创建实例时选 微信/Telegram(+Chromium/自定义占位)

镜像层(向后兼容,微信路径零改动):
- app-defs.sh:按 appType 给出 APP_BIN/APP_LAUNCH/APP_NAME(缺省回退微信)。
- app-ctl.sh:通用安装/状态分发;wechat 委托回 wechat-ctl.sh;telegram 下载官方 portable tar.xz。
- autostart:读 /config/.woc-app 选择启动哪个应用,读不到回退微信(老实例零改动)。
- 02-woc-app 钩子:把容器环境 WOC_APP_TYPE 落到 /config/.woc-app(缺则不写→回退微信)。
- Dockerfile:加 xz-utils(telegram 解压)+ COPY 新脚本。

后端:envList 透传 WOC_APP_TYPE(+自定义启动命令);triggerWechat/wechatStatus 改走
app-ctl.sh <appType>(微信行为不变);创建实例路由接受 appType。

前端:新建实例对话框加「应用类型」选择器(微信默认 / Telegram;Chromium、自定义标记"即将支持"禁用)。

本轮 Telegram(x86_64) 端到端可用;Chromium(待 apt 烤镜像) 与 自定义(待上传流) 下一轮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-14 17:38:15 +08:00
Unverified
parent 11c5406d0f
commit b7fd778ab1
10 changed files with 282 additions and 26 deletions
+11
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# 多应用安装/状态控制(面板经 docker exec --user abc 调用):
# app-ctl.sh <appType> <install|update|status>
# 设计:微信完全委托给原 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" <<EOF
{"phase":"$phase","percent":$percent,"installed":$installed,"version":"","message":"$message","updatedAt":$(date +%s)}
EOF
mv -f "$STATUS_FILE.tmp" "$STATUS_FILE"
}
print_status() {
if [ -f "$STATUS_FILE" ]; then
cat "$STATUS_FILE"
elif is_installed; then
echo "{\"phase\":\"done\",\"percent\":100,\"installed\":true,\"version\":\"\",\"message\":\"已就绪\",\"updatedAt\":$(date +%s)}"
else
echo "{\"phase\":\"idle\",\"percent\":0,\"installed\":false,\"version\":\"\",\"message\":\"未安装\",\"updatedAt\":$(date +%s)}"
fi
}
install_telegram() {
case "$(dpkg --print-architecture 2>/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 <appType> {install|update|status}" >&2; exit 1 ;;
esac
+31
View File
@@ -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
}
+29 -14
View File
@@ -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
+25
View File
@@ -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}"
+9 -3
View File
@@ -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-appautostart 据此启动)。
// 老实例无 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<string> {
});
}
// 触发下载/安装(detached,立即返回,后台下载)。
// 触发下载/安装(detached,立即返回,后台下载)。按实例 appType 分发:
// app-ctl.sh wechat → 委托回 wechat-ctl.sh(微信逻辑零改动);telegram 等各自实现。
export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'): Promise<void> {
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',
+5 -2
View File
@@ -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) {
+10 -2
View File
@@ -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<AppType, string> = {
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' }),
+35 -5
View File
@@ -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<AppType>('wechat');
const [sel, setSel] = useState<Set<string>>(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 (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2></h2>
<input className="input" placeholder="实例名称(如:我的微信 / 公司号)" value={name} onChange={(e) => setName(e.target.value)} />
<h2></h2>
<div className="field-label"></div>
<div className="app-picker">
{APP_OPTIONS.map((o) => (
<button
key={o.type}
type="button"
className={'app-pick' + (appType === o.type ? ' sel' : '')}
disabled={!o.ready}
title={o.ready ? '' : '即将支持'}
onClick={() => o.ready && setAppType(o.type)}
>
<span className="app-pick-name">{APP_LABELS[o.type]}</span>
<span className="app-pick-desc">{o.desc}</span>
</button>
))}
</div>
<input className="input" placeholder="实例名称(留空自动命名)" value={name} onChange={(e) => setName(e.target.value)} />
{appType === 'telegram' && (
<div className="muted small">Telegram x86_64 arm64 / NAS</div>
)}
<div className="field-label">访访</div>
<ChipMultiSelect
options={subs.map((u) => ({ id: u.id, label: u.username }))}
@@ -1433,7 +1461,9 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
</>
)}
{err && <div className="error">{err}</div>}
<div className="muted small" style={{ marginTop: 4 }}></div>
<div className="muted small" style={{ marginTop: 4 }}>
{APP_LABELS[appType]}
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
+43
View File
@@ -2041,3 +2041,46 @@ button {
opacity: 0.4;
cursor: default;
}
/* ── 新建实例·应用类型选择 ─────────────────────────────── */
.app-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
gap: 8px;
}
.app-pick {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px 6px;
border: none;
border-radius: var(--r-small);
background: var(--trough);
color: var(--text);
cursor: pointer;
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
}
.app-pick:hover:not(:disabled):not(.sel) {
background: var(--base);
}
.app-pick.sel {
background: var(--surface);
box-shadow: var(--crease-accent);
}
.app-pick.sel .app-pick-name {
color: var(--wx-green-dark);
}
.app-pick:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.app-pick-name {
font-size: 14px;
font-weight: 600;
}
.app-pick-desc {
font-size: 11px;
color: var(--muted);
}