mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
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:
@@ -15,6 +15,7 @@ RUN set -eux; \
|
|||||||
curl ca-certificates locales dpkg \
|
curl ca-certificates locales dpkg \
|
||||||
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
|
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
|
||||||
libnss3 libgbm1 libasound2 libxss1 \
|
libnss3 libgbm1 libasound2 libxss1 \
|
||||||
|
xz-utils \
|
||||||
xdotool xclip; \
|
xdotool xclip; \
|
||||||
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||||
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
|
COPY wechat-ctl.sh /woc/wechat-ctl.sh
|
||||||
RUN chmod 755 /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 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
||||||
COPY autostart /defaults/autostart
|
COPY autostart /defaults/autostart
|
||||||
RUN chmod 755 /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
|
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||||
RUN chmod 755 /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
|
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||||
EXPOSE 3000 3001
|
EXPOSE 3000 3001
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,17 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 由 KasmVNC base 的 openbox 会话在桌面就绪后执行(以 app 用户身份)。
|
# 由 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
|
set -u
|
||||||
|
|
||||||
WECHAT_BIN=/config/wechat/opt/wechat/wechat
|
|
||||||
|
|
||||||
# 容器内无 GPU,强制软件渲染
|
# 容器内无 GPU,强制软件渲染
|
||||||
export LIBGL_ALWAYS_SOFTWARE=1
|
export LIBGL_ALWAYS_SOFTWARE=1
|
||||||
|
|
||||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
|
# 解析本实例应用类型与启动信息(APP_BIN / APP_LAUNCH / APP_NAME)
|
||||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
|
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}"
|
export DISPLAY="${DISPLAY:-:1}"
|
||||||
while sleep 2; do
|
while sleep 2; do
|
||||||
@@ -23,25 +31,32 @@ export LIBGL_ALWAYS_SOFTWARE=1
|
|||||||
done
|
done
|
||||||
) &
|
) &
|
||||||
|
|
||||||
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
|
# 自定义应用若未配置启动命令,给出提示并退出(避免空等)
|
||||||
|
if [ "$APP_TYPE" = "custom" ] && [ -z "${APP_LAUNCH:-}" ]; then
|
||||||
|
echo "[autostart] 自定义应用尚未配置启动命令,请在面板「数据卷」上传安装包并设定后重启实例"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) 等待应用安装就绪(首次需在面板点「下载并安装」)
|
||||||
notified=0
|
notified=0
|
||||||
while [ ! -x "${WECHAT_BIN}" ]; do
|
while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
|
||||||
if [ "${notified}" -eq 0 ]; then
|
if [ "${notified}" -eq 0 ]; then
|
||||||
echo "[autostart] 微信尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装微信」)"
|
echo "[autostart] ${APP_NAME} 尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装」)"
|
||||||
notified=1
|
notified=1
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
# 3) 常驻拉起微信
|
# 3) 常驻拉起应用
|
||||||
while true; do
|
while true; do
|
||||||
if [ ! -x "${WECHAT_BIN}" ]; then
|
if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then
|
||||||
# 更新过程中本体被临时挪走,等就位再继续
|
# 更新过程中本体被临时挪走,等就位再继续
|
||||||
sleep 2
|
sleep 2
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
echo "[autostart] 启动微信: ${WECHAT_BIN}"
|
echo "[autostart] 启动 ${APP_NAME}: ${APP_LAUNCH}"
|
||||||
"${WECHAT_BIN}"
|
# APP_LAUNCH 可带参数,按 word-split 执行(各应用参数均不含空格,见 app-defs.sh)
|
||||||
echo "[autostart] 微信已退出,2 秒后重启"
|
${APP_LAUNCH}
|
||||||
|
echo "[autostart] ${APP_NAME} 已退出,2 秒后重启"
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import zlib from 'node:zlib';
|
import zlib from 'node:zlib';
|
||||||
import Docker from 'dockerode';
|
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 WECHAT_IMAGE = process.env.WOC_WECHAT_IMAGE || 'ghcr.io/gloridust/wechat-on-cloud:latest';
|
||||||
const PUID = process.env.PUID || '1000';
|
const PUID = process.env.PUID || '1000';
|
||||||
@@ -114,6 +114,11 @@ function envList(inst: Instance): string[] {
|
|||||||
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
|
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
|
||||||
// 透传 os 伪装开关给容器内的 00-woc-identity 钩子(决定是否把 /etc/os-release 改成 deepin)。
|
// 透传 os 伪装开关给容器内的 00-woc-identity 钩子(决定是否把 /etc/os-release 改成 deepin)。
|
||||||
env.push(`WOC_SPOOF_OS=${SPOOF_OS ? '1' : '0'}`);
|
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;
|
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> {
|
export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'): Promise<void> {
|
||||||
const c = docker.getContainer(inst.containerName);
|
const c = docker.getContainer(inst.containerName);
|
||||||
const exec = await c.exec({
|
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,
|
AttachStdout: false,
|
||||||
AttachStderr: false,
|
AttachStderr: false,
|
||||||
User: 'abc',
|
User: 'abc',
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
renameInstance,
|
renameInstance,
|
||||||
setInstanceUsers,
|
setInstanceUsers,
|
||||||
publicInstance,
|
publicInstance,
|
||||||
|
APP_TYPES,
|
||||||
|
type AppType,
|
||||||
type User,
|
type User,
|
||||||
type Instance,
|
type Instance,
|
||||||
} from './store.js';
|
} from './store.js';
|
||||||
@@ -270,11 +272,12 @@ app.get('/api/instances', async (req, reply) => {
|
|||||||
app.post('/api/admin/instances', async (req, reply) => {
|
app.post('/api/admin/instances', async (req, reply) => {
|
||||||
const admin = requireAdmin(req, reply);
|
const admin = requireAdmin(req, reply);
|
||||||
if (!admin) return;
|
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 : [];
|
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) {
|
if (!name || String(name).trim().length === 0 || String(name).length > 30) {
|
||||||
return reply.code(400).send({ error: '实例名称为 1-30 个字符' });
|
return reply.code(400).send({ error: '实例名称为 1-30 个字符' });
|
||||||
}
|
}
|
||||||
|
const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
|
||||||
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
|
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
|
||||||
let reuseVolumeName: string | undefined;
|
let reuseVolumeName: string | undefined;
|
||||||
if (reuseVolume) {
|
if (reuseVolume) {
|
||||||
@@ -286,7 +289,7 @@ app.post('/api/admin/instances', async (req, reply) => {
|
|||||||
}
|
}
|
||||||
reuseVolumeName = reuseVolume;
|
reuseVolumeName = reuseVolume;
|
||||||
}
|
}
|
||||||
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName);
|
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type);
|
||||||
try {
|
try {
|
||||||
await runInstance(inst);
|
await runInstance(inst);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
+10
-2
@@ -19,9 +19,17 @@ export interface WechatStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeState = 'running' | 'stopped' | 'missing';
|
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 {
|
export interface PanelInstance {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
appType?: AppType; // 缺省(老实例)= wechat
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
memSoftLimitMB?: number;
|
memSoftLimitMB?: number;
|
||||||
@@ -106,10 +114,10 @@ export const api = {
|
|||||||
|
|
||||||
// 微信实例
|
// 微信实例
|
||||||
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
|
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', {
|
req<{ instance: PanelInstance }>('/api/admin/instances', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }),
|
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }),
|
||||||
}),
|
}),
|
||||||
regenMachineId: (id: string) =>
|
regenMachineId: (id: string) =>
|
||||||
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
|
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { useUI, PasswordInput } from '../ui';
|
||||||
import { useAuth } from '../auth';
|
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 }) {
|
function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: () => void; onDone: () => void }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [appType, setAppType] = useState<AppType>('wechat');
|
||||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -1394,7 +1403,7 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
|||||||
setErr('');
|
setErr('');
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.createInstance(name.trim(), [...sel], reuse || undefined);
|
await api.createInstance(name.trim(), [...sel], reuse || undefined, appType);
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e.message || '创建失败');
|
setErr(e.message || '创建失败');
|
||||||
@@ -1406,8 +1415,27 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
|
|||||||
return (
|
return (
|
||||||
<div className="modal-mask" onClick={onClose}>
|
<div className="modal-mask" onClick={onClose}>
|
||||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||||
<h2>新建微信实例</h2>
|
<h2>新建实例</h2>
|
||||||
<input className="input" placeholder="实例名称(如:我的微信 / 公司号)" value={name} onChange={(e) => setName(e.target.value)} />
|
<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>
|
<div className="field-label">允许访问的子账号(管理员默认可访问全部)</div>
|
||||||
<ChipMultiSelect
|
<ChipMultiSelect
|
||||||
options={subs.map((u) => ({ id: u.id, label: u.username }))}
|
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>}
|
{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">
|
<div className="modal-actions">
|
||||||
<button type="button" className="btn" onClick={onClose}>
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@@ -2041,3 +2041,46 @@ button {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: default;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user