7 Commits

17 changed files with 1268 additions and 288 deletions
+31
View File
@@ -43,3 +43,34 @@ WOC_HTTP_PORT=36080
# b) 显式:在下面列出设备(逗号分隔),并可删掉那条 /host-dev 挂载。
# 留空 = 不映射摄像头(音频/麦克风不受影响)。
WOC_VIDEO_DEVICES=
# ── 实例资源 / 稳定性 ───────────────────────────────────────
# GPU 硬件编码:baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3dGPU 加速编码)。
# 在 WSL2 / 虚拟 GPU(如 Docker Desktop on Windows)下,该路径会导致 Xvnc 内存持续膨胀
# (实测 21 小时涨到 ~9GB)。本项目已强制软件渲染(LIBGL_ALWAYS_SOFTWARE=1),hw3d 对微信
# 这类静态界面收益甚微,故默认关闭。仅在你有真实可用的 GPU 且确认无内存问题时再设为 1 启用。
WOC_ENABLE_GPU=
# 每个微信实例容器的内存上限(GiB)。默认空 = 不限制(与旧版一致)。
# 作为兜底:万一某进程异常增长,命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC,
# 避免拖垮整台宿主。常规使用单实例约需 1~1.5GiB,设 3~4 较稳妥。
WOC_INSTANCE_MEM_GB=
# ── 自愈 watchdog(应对 KasmVNC/Xvnc 长跑内存泄漏) ───────────
# 实测 Xvnc 长跑 24h 可膨胀到 ~9GiB,原因在 KasmVNC/Xvnc 自身的 framebuffer / 软渲染 cache
# 累积,不归本项目控制。面板内置一个 watchdog:周期性检查每个 running 实例的 working set
# 内存(= docker stats 显示值),按两档阈值(这里设全局默认;每个实例可在「管理 → 实例卡片
# → 安全」按钮里单独覆盖)触发主动 stop + run(聊天数据在卷里、登录态本来也会定期失效,
# 可控时机自愈优于撑到 OOM):
#
# soft 超过且【当前没人在远程会话】才重启 → 柔和自愈,不打扰使用者
# hard 超过即重启(无视会话),防止 OOM 拖垮宿主
#
# WOC_INSTANCE_MEM_SOFT_MB soft 阈值(MiB),默认 1500
# WOC_INSTANCE_MEM_HARD_MB hard 阈值(MiB),默认 2500(兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB
# WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒,默认 300(5 分钟);最小 60;0 = 关闭整个 watchdog
#
# 调参建议:日常活跃单实例约 1500 MiB;soft 应略高于此(如 2000);hard 远低于宿主可用内存。
WOC_INSTANCE_MEM_SOFT_MB=1500
WOC_INSTANCE_MEM_HARD_MB=2500
WOC_WATCHDOG_INTERVAL_SEC=300
+32 -259
View File
@@ -17,7 +17,7 @@
<p>
<a href="#快速开始">快速开始</a> ·
<a href="#核心特性">核心特性</a> ·
<a href="#docker-运行模式详解新手向">运行原理</a> ·
<a href="doc/运行原理.md">运行原理</a> ·
<a href="#安全须知必读">安全须知</a> ·
<a href="doc/技术方案.md">技术方案</a>
</p>
@@ -33,7 +33,7 @@
在飞牛 NASx86_64 / arm64)或任意 Docker 主机上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。**不修改微信客户端。**
> 设计与选型详见 [技术方案.md](doc/技术方案.md)。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)
**一句话原理**:每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个微信会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理
---
@@ -44,7 +44,7 @@
- 🖥️ **微信 PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉。
- 📦 **微信本体运行时下载** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条;按 CPU 架构自动取包。
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
- 📎 **文件传输**原生拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`;文本可经剪贴板中转送进微信(局域网 http 下也可用)
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
@@ -52,110 +52,20 @@
---
## 工作原理(一句话)
## 文档
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**。
前面一层自研 **面板(panel** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端。
```
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
实例只在 docker 网络内暴露,不直连宿主
```
---
## Docker 运行模式详解(新手向)
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
### 0. 先认识 5 个 Docker 概念
| 概念 | 一句话理解 | 在本项目里是什么 |
|------|-----------|------------------|
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
### 1. 本项目有两类容器(运行角色不同)
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
| | ① 面板容器 | ② 微信实例容器 |
|---|-----------|---------------|
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
### 2. 镜像从哪来:两种「构建/获取」模式
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([快速开始](#快速开始)对应方式 A / B):
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|---|--------------------|----------------------|
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
| 前提 | 本机能拉到基础镜像(node、KasmVNC base | GHCR 上已发布且包为公开(见[发布到 GHCR](#发布到-ghcr) |
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[数据持久化](#数据持久化))。
### 3. 从零到能用,整体发生了什么
```
你: docker compose up -d
└─▶ Docker 读取 docker-compose.yml
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
└─▶ 面板通过 docker.sock 指挥 Docker:
├─ docker run 一个【微信实例容器 woc-wx-xxx】
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
└─ 接到同一个 docker 网络(面板才能反代到它)
你: 进入该实例 → 点「下载并安装」
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
你: 点「进入电脑版微信」→ 手机扫码
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
```
### 4. 常用命令速查
```bash
docker compose up -d # 启动面板(首次会拉/用镜像)
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*
docker logs -f woc-panel # 看面板日志
docker logs -f woc-wx-<id> # 看某个微信实例日志
docker volume ls | grep woc # 看所有微信实例的数据卷
```
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
| 文档 | 内容 |
|------|------|
| [运行原理与 Docker 指南](doc/运行原理.md) | 工作原理 + 架构图;面向 Docker 新手的逐步拆解、常用命令、架构自动适配 |
| [部署与运维](doc/部署与运维.md) | 数据持久化、常见问题排查、忘记超管密码的离线找回、目录结构 |
| [发布到 GHCR](doc/发布到GHCR.md) | 用 GitHub Actions 或本机 buildx 把镜像发布到 GHCR |
| [技术方案](doc/技术方案.md) | 完整设计文档与选型权衡 |
---
## 快速开始
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。不熟悉 Docker?先读 [运行原理与 Docker 指南](doc/运行原理.md)。
`docker-compose.yml` 引用的是 GHCR 上的镜像 `ghcr.io/gloridust/{woc-panel,wechat-on-cloud}`
**这两个镜像需先存在**——要么官方已发布(你能直接拉取),要么你在本地自行构建。二选一:
@@ -179,7 +89,7 @@ cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
docker compose up -d # 直接从 GHCR 拉取
```
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见下方[「发布到 GHCR](#发布到-ghcr)。
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见 [发布到 GHCR](doc/发布到GHCR.md)。
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`
@@ -190,7 +100,7 @@ docker compose up -d # 直接从 GHCR 拉取
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本见 `.env`。
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))
### 面板能做什么
@@ -200,96 +110,31 @@ docker compose up -d # 直接从 GHCR 拉取
| 实例权限分配 | 管理员 | 在实例上改「可访问账户」,或在账户上改「可访问实例」,双向管理 |
| 下载并安装 / 更新微信 | 管理员 | 对某实例一键下载官方微信 Linux 版到其数据卷、解压安装;带进度条;后续可一键「更新到最新版」 |
| 进入电脑版微信 | 被授权用户 | 在浏览器里操作对应实例的微信,扫码登录、收发消息 |
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送进微信 |
| 修改密码 | 所有人 | 改自己的登录密码 |
| 子账号管理 | 管理员 | 创建 / 禁用 / 重置 / 删除子账号,并分配实例访问权限 |
| 安装为 App | 所有人 | iOS Safari「添加到主屏幕」、桌面 Chrome「安装」当原生 AppPWA |
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快、不依赖腾讯 CDN。
### 架构自动适配
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
| 运行机器 | 架构 | 自动下载 |
|----------|------|----------|
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
到飞牛上(无论 x64 还是 arm`docker compose up -d` 同一条命令,无需改任何架构相关配置。
### 自定义配置(可选)
复制 `.env.example``.env` 后按需修改,可配置项见 [.env.example](.env.example):管理员账号密码、镜像版本(`WOC_VERSION`,建议上线后钉到具体版本)、PUID/PGID、时区、端口。
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快。
---
## 发布到 GHCR
## 资源占用
两种方式任选其一。
实测(8 核 / 8 GiB 宿主,实例均已登录微信、含 Chromium 内核的 WeChatAppEx):
### 方式 A · GitHub Actions(推荐)
| 状态 | CPU | 内存(RSS |
|------|-----|------------|
| 单实例 · 空闲(已登录、无人观看) | ~0.10.2 核 | ~0.6 GiB |
| 单实例 · 活跃(有人在浏览器操作 / 刷消息) | ~0.5–1 核(可突发) | ~11.5 GiB |
| 面板本身 | 可忽略 | ~0.12 GiB |
仓库自带 GitHub Actions[.github/workflows/release.yml](.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR
- 容器**不设硬性 CPU/内存上限**:空闲时省,活跃时按宿主余量突发;每实例另预留 **1 GiB `/dev/shm`**(微信 Chromium 内核所需,tmpfs,按需占用)。
- 估算:**面板 ≈ 0.15 GiB 常驻;每个微信实例按 1 vCPU + 1.5 GiB 内存预留**较从容(轻度使用可更低)。
- 参考容量:**2 核 / 2 GiB** 跑 1 个实例(轻度);**4 核 / 8 GiB** 跑 3–4 个实例;视频通话等重负载需再加预留。
```bash
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
gh release create v1.0.0 --title v1.0.0 --notes "..."
```
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
### 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
适合想立刻出包、或不依赖 CI 的场景。需要 Docker BuildxDocker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
```bash
# 1) 登录 GHCRPAT 需 write:packages 权限)
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc
docker buildx create --name woc --use
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
VER=1.0.1
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
--push ./panel
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
--push ./docker
```
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](scripts/build-local.sh) 构建本机架构单架构镜像即可。
### 发布后:把包设为公开
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel``wechat-on-cloud`
2. **Package settings → Change visibility → Public**
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
> 在镜像发布之前,本地用 `./scripts/build-local.sh` 自构建即可,无需等待发布。
---
## 数据持久化
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
要点:
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
- 备份面板 = 备份 `./data-panel`
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷。
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。
---
@@ -297,7 +142,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \
> ⚠️ **这套系统暴露的是已登录的微信,请务必认真阅读本节。**
这套系统暴露的是**已登录的微信**——能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
- **绝不要把面板裸暴露公网**:只在内网访问,或经飞牛远程访问 / VPN / 内网穿透;
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`,或登录后在「修改密码」里改;
@@ -310,84 +155,11 @@ docker buildx build --platform linux/amd64,linux/arm64 \
## 中文输入
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的
「IME Input Mode」:拼音联想在你本机完成,只把成品汉字发进容器。直接在微信输入框打字即可。
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。直接在微信输入框打字即可。
- 默认值只对**未存过该设置的浏览器**生效。之前手动开/关过的,浏览器 localStorage 值优先;想验证默认效果用无痕窗口。
- 已知小毛病:超长拼音串未全部转成汉字就回车,偶尔丢字([issue #97](https://github.com/linuxserver/docker-baseimage-kasmvnc/issues/97)),长句分段输入即可
- 兜底:Chrome/Edge 下本地 `⌘C` → 远端 `Ctrl+V` 无缝粘贴;Firefox 用控制面板的 Clipboard 文本框中转
## 常见问题
| 现象 | 排查 |
|------|------|
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined``shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
| 多人同时操作很乱 | 已内置「操作控制权」软锁:当前操作者每数秒心跳续约,其余端自动转为**只读遮罩**(仍可看画面),空闲超时(约 10s)自动释放,他人可点「申请控制」接管。仍建议同一时刻一人操作 |
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。
### 重置超管密码(离线找回)
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
```bash
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
```
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`
```json
{
"id": "...", "username": "admin", "role": "admin",
"passwordHash": "...", "disabled": false,
"resetPassword": true
}
```
```bash
docker compose up -d # 3) 启动,面板初始化时会重置该账号
```
> ⚠️ 重置逻辑只在面板**进程启动**时执行。若你没先 `stop` 而面板仍在运行,直接 `docker compose up -d` 会因「容器无变化」而空操作(输出 `Running` 而非 `Started`),重置不会发生。此时执行 **`docker compose restart panel`**(或 `docker restart woc-panel`)强制重启即可生效。
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env``WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`
---
## 目录结构
```
WechatOnCloud/
├── .github/workflows/
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + xdotool + 默认开 IME(不打包微信本体)
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state
│ ├── autostart # openbox 会话启动:常驻拉起微信(崩溃自重启)+ 最小化窗口自动复原看守
│ └── woc-update-autostart # 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限/生命周期 API + dockerode + 反代
│ └── web/ # React + TS + PWA(微信 PC 式布局,牛奶布艺 + 微信绿主题)
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
├── scripts/
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
├── doc/ # 文档与素材
│ ├── 技术方案.md # 完整设计文档
│ └── img/ # logo 与界面截图
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
└── README.md
```
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>``./data-panel` 已在 `.gitignore` 中。
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**
- **文件**:用工具栏「文件」拖拽上传,微信收到的文件另存到桌面即可在此下载
---
@@ -398,6 +170,7 @@ WechatOnCloud/
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志导出
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
- [ ] 掉登录时 web 端二维码重扫入口
@@ -425,4 +198,4 @@ WechatOnCloud/
<sub>如果这个项目帮到了你,欢迎点个 ⭐ Star 支持一下 ·
<a href="https://github.com/Gloridust/WechatOnCloud/issues">反馈问题</a> ·
<a href="https://github.com/Gloridust/WechatOnCloud/pulls">参与贡献</a></sub>
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
# 发布到 GHCR
> 返回 [← README](../README.md)
把两个镜像(`woc-panel``wechat-on-cloud`)发布到 GitHub Container Registry,供他人 `docker compose up -d` 直接拉取。两种方式任选其一。
---
## 方式 A · GitHub Actions(推荐)
仓库自带 GitHub Actions[.github/workflows/release.yml](../.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR
```bash
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
gh release create v1.0.0 --title v1.0.0 --notes "..."
```
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
---
## 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
适合想立刻出包、或不依赖 CI 的场景。需要 Docker BuildxDocker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
```bash
# 1) 登录 GHCRPAT 需 write:packages 权限)
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc
docker buildx create --name woc --use
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
VER=1.0.1
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
--push ./panel
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
--push ./docker
```
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 构建本机架构单架构镜像即可。
---
## 发布后:把包设为公开
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel``wechat-on-cloud`
2. **Package settings → Change visibility → Public**
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
> 在镜像发布之前,本地用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 自构建即可,无需等待发布。
+119
View File
@@ -0,0 +1,119 @@
# 运行原理与 Docker 指南
> 返回 [← README](../README.md) · 深入设计见 [技术方案.md](技术方案.md)
本篇把「云微怎么跑起来」讲透:先一句话原理 + 架构图,再面向 Docker 新手的逐步拆解。
---
## 工作原理(一句话)
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**。
前面一层自研 **面板(panel** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端。
```
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
实例只在 docker 网络内暴露,不直连宿主
```
---
## Docker 运行模式详解(新手向)
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
### 0. 先认识 5 个 Docker 概念
| 概念 | 一句话理解 | 在本项目里是什么 |
|------|-----------|------------------|
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
### 1. 本项目有两类容器(运行角色不同)
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
| | ① 面板容器 | ② 微信实例容器 |
|---|-----------|---------------|
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
### 2. 镜像从哪来:两种「构建/获取」模式
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([README 快速开始](../README.md#快速开始)对应方式 A / B):
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|---|--------------------|----------------------|
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
| 前提 | 本机能拉到基础镜像(node、KasmVNC base | GHCR 上已发布且包为公开(见[发布到 GHCR](发布到GHCR.md) |
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[部署与运维 · 数据持久化](部署与运维.md#数据持久化))。
### 3. 从零到能用,整体发生了什么
```
你: docker compose up -d
└─▶ Docker 读取 docker-compose.yml
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
└─▶ 面板通过 docker.sock 指挥 Docker:
├─ docker run 一个【微信实例容器 woc-wx-xxx】
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
└─ 接到同一个 docker 网络(面板才能反代到它)
你: 进入该实例 → 点「下载并安装」
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
你: 点「进入电脑版微信」→ 手机扫码
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
```
### 4. 常用命令速查
```bash
docker compose up -d # 启动面板(首次会拉/用镜像)
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*
docker logs -f woc-panel # 看面板日志
docker logs -f woc-wx-<id> # 看某个微信实例日志
docker volume ls | grep woc # 看所有微信实例的数据卷
```
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
---
## 架构自动适配
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
| 运行机器 | 架构 | 自动下载 |
|----------|------|----------|
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
到飞牛上(无论 x64 还是 arm`docker compose up -d` 同一条命令,无需改任何架构相关配置。
+101
View File
@@ -0,0 +1,101 @@
# 部署与运维
> 返回 [← README](../README.md)
数据持久化、常见问题排查、忘记超管密码的离线找回,以及仓库目录结构。
---
## 数据持久化
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
要点:
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
- 备份面板 = 备份 `./data-panel`
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷。
---
## 常见问题
| 现象 | 排查 |
|------|------|
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined``shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
| 多人同时操作很乱 | 已内置「操作控制权」软锁:当前操作者每数秒心跳续约,其余端自动转为**只读遮罩**(仍可看画面),空闲超时(约 10s)自动释放,他人可点「申请控制」接管。仍建议同一时刻一人操作 |
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。也可在面板的「管理」页或实例页点「查看日志」直接导出实例容器日志。
---
## 重置超管密码(离线找回)
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
```bash
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
```
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`
```json
{
"id": "...", "username": "admin", "role": "admin",
"passwordHash": "...", "disabled": false,
"resetPassword": true
}
```
```bash
docker compose up -d # 3) 启动,面板初始化时会重置该账号
```
> ⚠️ 重置逻辑只在面板**进程启动**时执行。若你没先 `stop` 而面板仍在运行,直接 `docker compose up -d` 会因「容器无变化」而空操作(输出 `Running` 而非 `Started`),重置不会发生。此时执行 **`docker compose restart panel`**(或 `docker restart woc-panel`)强制重启即可生效。
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env``WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`
---
## 目录结构
```
WechatOnCloud/
├── .github/workflows/
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + xdotool + IME 补丁(不打包微信本体)
│ ├── woc-www-patch.sh # 构建期补丁:开启 KasmVNC IME 模式 + 修复 noVNC 中文输入(配合 woc-ime.pl
│ ├── woc-ime.pl # noVNC 键盘 IME 逻辑的 perl 补丁(合成提交时整串发送,规避丢字)
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state
│ ├── autostart # openbox 会话启动:常驻拉起微信(崩溃自重启)+ 最小化窗口自动复原看守
│ └── woc-update-autostart # 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限/生命周期 API + dockerode + 反代
│ └── web/ # React + TS + PWA(微信 PC 式布局,牛奶布艺 + 微信绿主题)
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
├── scripts/
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
├── doc/ # 文档与素材
│ ├── 技术方案.md # 完整设计文档
│ ├── 运行原理.md # 工作原理 + Docker 运行模式详解(新手向)
│ ├── 发布到GHCR.md # 镜像发布到 GHCR 的两种方式
│ ├── 部署与运维.md # 本文档:数据持久化 / 常见问题 / 密码找回 / 目录结构
│ └── img/ # logo 与界面截图
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
└── README.md
```
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>``./data-panel` 已在 `.gitignore` 中。
+7
View File
@@ -21,6 +21,13 @@ services:
# 摄像头直通:逗号分隔的宿主视频设备(如 /dev/video0)。留空则自动探测(见下方 /host-dev 挂载)或禁用摄像头。
# 启用前需在宿主加载 v4l2loopback 内核模块。详见 .env.example。
- WOC_VIDEO_DEVICES=${WOC_VIDEO_DEVICES:-}
# 实例资源相关(见 .env.example):GPU 硬件编码默认关闭(避免 WSL2/虚拟 GPU 下 Xvnc 内存暴涨);
# 可选给每个实例设内存上限(GiB),0=不限制;watchdog 双阈值(soft 柔和自愈 / hard 强制重启)+ 巡检间隔。
- WOC_ENABLE_GPU=${WOC_ENABLE_GPU:-}
- WOC_INSTANCE_MEM_GB=${WOC_INSTANCE_MEM_GB:-}
- WOC_INSTANCE_MEM_SOFT_MB=${WOC_INSTANCE_MEM_SOFT_MB:-1500}
- WOC_INSTANCE_MEM_HARD_MB=${WOC_INSTANCE_MEM_HARD_MB:-2500}
- WOC_WATCHDOG_INTERVAL_SEC=${WOC_WATCHDOG_INTERVAL_SEC:-300}
# 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码)
- PANEL_ADMIN_USER=${WOC_USER:-admin}
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}
+1 -1
View File
@@ -15,7 +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 \
xdotool; \
xdotool xclip; \
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
locale-gen; \
apt-get clean; \
+5 -9
View File
@@ -4,14 +4,15 @@
# 背景:noVNC 原实现靠"隐藏 textarea 差分→逐字符重发 keysym"还原 IME 输入,会在合成过程中
# 把中间拼音也发给远端、且永不 reset 导致累积+退格风暴 → 大量丢字 / 卡住 / 跨浏览器不稳。
#
# 改法:彻底不靠 textarea 差分还原中文。
# 改法:彻底不靠 textarea 差分或 VNC keysym 还原中文。
# - 合成进行中(input 事件):只同步 _lastKeyboardInput、不发送(避免中间拼音泄漏 / 丢字)。
# - 提交时(compositionend)用 e.data(最终上屏字符串)逐字发 keysym,并把 _lastKeyboardInput
# 同步成当前 textarea 值。不 reset、不吞键——避免吞掉下一个词的首键、避免打断下一次合成。
# - 提交时(compositionend)只同步 _lastKeyboardInput 并返回,不再逐字发 keysym。
# 成品文本由面板前端捕获后通过 xclip/xdotool 粘贴进远端窗口,绕开 KasmVNC XKB
# keysym 容量限制,也避免和粘贴路径重复上屏。
# - 若个别浏览器在 compositionend 后还补发一次"提交 input":此时 isComposing/_imeHold 均为假,
# 落到非 IME 差分分支,但 newValue 与刚同步的 _lastKeyboardInput 相等 → 差分为空 → 不重复发送。
# (A) _handleCompositionEnd:提交时 e.data 直发 + 同步 _lastKeyboardInput(不 reset、不吞键)
# (A) _handleCompositionEnd:提交时同步 _lastKeyboardInput,文本由面板粘贴路径负责。
s~\Q if (this._enableIME) {
this._imeInProgress = false;
}
@@ -20,11 +21,6 @@ s~\Q if (this._enableIME) {
this._imeHold = false;
}\E~ if (this._enableIME) { // WOC-IME
this._imeInProgress = false;
var _wocStr = (e && typeof e.data === "string") ? e.data : "";
for (var _wocI = 0; _wocI < _wocStr.length; _wocI++) {
this._sendKeyEvent(keysymdef.lookup(_wocStr.charCodeAt(_wocI)), 'Unidentified', true);
this._sendKeyEvent(keysymdef.lookup(_wocStr.charCodeAt(_wocI)), 'Unidentified', false);
}
this._imeHold = false;
this._lastKeyboardInput = e.target.value;
return;
+3 -4
View File
@@ -2,10 +2,9 @@
# 构建期补丁:改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式(本地输入法打中文,成品汉字发进容器,容器内不装 IME)
# (2) 修复 noVNC 的中文 IME 输入:原实现靠"隐藏 textarea 差分→逐字符重发 keysym"
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴
# 在 VNC 上表现为大量丢字、~21 字后卡住、跨浏览器不稳定
# 改为:合成期间(input)一律不发;只在 compositionend 用 e.data(最终上屏串)逐字发 keysym
# 提交后 reset textarea,并吞掉紧随其后的提交 input 事件,避免重复发送/跨分支竞争。
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴
# 改为合成期间和提交时都只同步内部 textarea 状态,不再发送中文 keysym
# 最终成品文本由面板前端捕获后通过 xclip/xdotool 粘贴,绕过 KasmVNC XKB keysym 限制。
# 末尾断言:若 base 镜像换了打包结构、一个文件都没改到,则构建失败而非静默放过。
set -euo pipefail
+136 -4
View File
@@ -9,6 +9,17 @@ const PGID = process.env.PGID || '1000';
const TZ = process.env.TZ || 'Asia/Shanghai';
const SHM_SIZE = 1024 * 1024 * 1024; // 1gb
// 默认关闭 KasmVNC 的 GPU 硬件编码(baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3d):
// 在 WSL2 / 虚拟 GPU 环境下该路径会导致 Xvnc 内存持续膨胀(实测反馈 21h 涨到 ~9GB)。
// 我们已设 LIBGL_ALWAYS_SOFTWARE=1 走软件渲染,hw3d 对微信这类静态界面收益甚微。
// 真实可用 GPU 想启用硬件编码:面板侧设 WOC_ENABLE_GPU=1。
const ENABLE_GPU = process.env.WOC_ENABLE_GPU === '1';
// 可选:给每个实例容器设内存上限(GiB),作为 Xvnc 等异常增长时的兜底,避免拖垮宿主。
// 默认 0 = 不限制(保持原行为)。命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC。
const INSTANCE_MEM_GB = Number(process.env.WOC_INSTANCE_MEM_GB) || 0;
const INSTANCE_MEM = INSTANCE_MEM_GB > 0 ? Math.floor(INSTANCE_MEM_GB * 1024 * 1024 * 1024) : 0;
const docker = new Docker(); // 默认连 /var/run/docker.sock
// 面板自身所在的 docker 网络名;新实例都 attach 到它,便于按容器名互访。
@@ -58,13 +69,16 @@ function videoDevices(): string[] {
}
function envList(inst: Instance): string[] {
return [
const env = [
`PUID=${PUID}`,
`PGID=${PGID}`,
`TZ=${TZ}`,
`CUSTOM_USER=${inst.kasmUser}`,
`PASSWORD=${inst.kasmPassword}`,
];
// baseimage 仅检查该变量是否「已设置」(值无关),设上即不再给 Xvnc 加 -hw3d。
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
return env;
}
// 确保微信镜像在本地存在;缺失则从 GHCR 拉取(首次新建实例时镜像通常还没拉过)。
@@ -98,6 +112,10 @@ export async function runInstance(inst: Instance): Promise<void> {
ShmSize: SHM_SIZE,
RestartPolicy: { Name: 'unless-stopped' },
};
if (INSTANCE_MEM > 0) {
hostConfig.Memory = INSTANCE_MEM;
hostConfig.MemorySwap = INSTANCE_MEM; // 禁止 swap 膨胀:限制即为硬上限
}
if (vids.length) {
hostConfig.Devices = vids.map((d) => ({ PathOnHost: d, PathInContainer: d, CgroupPermissions: 'rwm' }));
hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN
@@ -111,7 +129,18 @@ export async function runInstance(inst: Instance): Promise<void> {
ExposedPorts: { '3000/tcp': {} },
HostConfig: hostConfig,
});
await container.start();
try {
await container.start();
} catch (e) {
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
try {
await container.remove({ force: true });
} catch {
/* 容器已被外部移走或正在被清理,忽略 */
}
throw e;
}
}
// 确保实例容器在运行:缺失则按需创建(不会重建已有卷),停止则启动。
@@ -161,6 +190,81 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom
}
}
// 列出"未被任何容器引用的 woc-data-* 数据卷"。判定改为 docker 真实视角(不再仅看 store),
// 否则 Created 状态的"幽灵容器"会让卷被误判为孤儿,删除时撞 409real-world issue
// 早期 runInstance 启动失败漏清残留容器,留下 4 个 Created 容器各占一个卷名)。
export async function listOrphanVolumes(referencedVolumes: Set<string>): Promise<
Array<{ name: string; createdAt?: string; sizeBytes?: number }>
> {
// 容器视角:扫所有容器(含已停止 / Created),收集它们挂载的 woc-data-* 卷名
const allContainers = await docker.listContainers({ all: true });
const containerRefs = new Set<string>();
for (const c of allContainers) {
for (const m of c.Mounts || []) {
if (typeof m.Name === 'string' && m.Name.startsWith('woc-data-')) containerRefs.add(m.Name);
}
}
// 与 store 视角并集:取两者都未引用的卷
const referenced = new Set<string>([...referencedVolumes, ...containerRefs]);
const { Volumes } = (await (docker as any).listVolumes()) || { Volumes: [] };
if (!Array.isArray(Volumes)) return [];
return Volumes
.filter((v: any) => typeof v?.Name === 'string' && v.Name.startsWith('woc-data-') && !referenced.has(v.Name))
.map((v: any) => ({
name: v.Name,
createdAt: v.CreatedAt,
// UsageData 仅在 docker engine 启用 -v size=true 时返回,常见情况下没有;缺失就不展示
sizeBytes: typeof v?.UsageData?.Size === 'number' && v.UsageData.Size >= 0 ? v.UsageData.Size : undefined,
}))
.sort((a, b) => (a.createdAt && b.createdAt ? (a.createdAt < b.createdAt ? 1 : -1) : 0));
}
// 显式删除一个数据卷(管理员清理孤儿卷用)。调用方负责确认它不被现存实例引用。
export async function removeVolume(name: string): Promise<void> {
await docker.getVolume(name).remove({ force: true } as any);
}
// 列出"残留的 woc-wx-* 容器":在 docker 里存在但 store 没登记的(多为 runInstance 失败时
// 留下的 Created 状态容器,或用户手动 docker run 出来的)。给管理员一键清理。
export async function listOrphanContainers(
knownContainerNames: Set<string>,
): Promise<Array<{ id: string; name: string; status: string; volumeName?: string }>> {
const all = await docker.listContainers({ all: true });
const out: Array<{ id: string; name: string; status: string; volumeName?: string }> = [];
for (const c of all) {
const name = (c.Names || []).map((n) => n.replace(/^\//, '')).find((n) => n.startsWith('woc-wx-'));
if (!name) continue;
if (knownContainerNames.has(name)) continue;
const vol = (c.Mounts || []).map((m) => m.Name).find((n) => typeof n === 'string' && n.startsWith('woc-data-'));
out.push({ id: c.Id, name, status: c.Status || c.State || '', volumeName: vol });
}
return out;
}
// 强制删除一个残留容器(按短/全 id 或容器名都行)。
export async function removeContainerById(idOrName: string): Promise<void> {
await docker.getContainer(idOrName).remove({ force: true });
}
// 取实例容器的"working set"内存(MB):等同 docker stats 显示值 = usage - inactive_file。
// 用于 watchdog 检测 KasmVNC/Xvnc 长跑泄漏(21 小时可涨到 ~9 GiB),无法读取时返回 0(视为"暂未知"
// 不触发自愈,避免容器刚启动 stats 不可用就被误杀)。一次性 stats、不订阅 stream。
export async function instanceMemoryMB(inst: Instance): Promise<number> {
try {
const c = docker.getContainer(inst.containerName);
const s: any = await c.stats({ stream: false } as any);
const usage = Number(s?.memory_stats?.usage) || 0;
const inactive = Number(
s?.memory_stats?.stats?.inactive_file ?? s?.memory_stats?.stats?.total_inactive_file,
) || 0;
const bytes = Math.max(0, usage - inactive);
return Math.round(bytes / 1024 / 1024);
} catch {
return 0;
}
}
export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
try {
const info = await docker.getContainer(inst.containerName).inspect();
@@ -170,7 +274,7 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
}
}
// 在实例容器内执行命令,返回 stdoutdemux 后只取标准输出)
// 在实例容器内执行命令,返回 stdout;若命令失败,把 stderr 透出给调用方
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const c = docker.getContainer(inst.containerName);
const exec = await c.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
@@ -181,7 +285,18 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const stdout = { write: (b: Buffer) => { out += b.toString('utf8'); } } as any;
const stderr = { write: (b: Buffer) => { err += b.toString('utf8'); } } as any;
docker.modem.demuxStream(stream, stdout, stderr);
stream.on('end', () => resolve(out || err));
stream.on('end', async () => {
try {
const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) {
reject(new Error((err || out || `命令执行失败,退出码 ${info.ExitCode}`).trim()));
return;
}
resolve(out || err);
} catch (e) {
reject(e);
}
});
stream.on('error', reject);
});
}
@@ -328,6 +443,23 @@ export async function instanceLogs(inst: Instance, tail = 600): Promise<string>
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
}
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
export async function typeInInstance(inst: Instance, text: string): Promise<void> {
const b64 = Buffer.from(text, 'utf8').toString('base64');
const cmd = [
'set -e',
'display="${DISPLAY:-}"',
'if [ -z "$display" ]; then for x in /tmp/.X11-unix/X*; do [ -e "$x" ] || continue; display=":${x##*X}"; break; done; fi',
'export DISPLAY="${display:-:1}"',
'command -v xclip >/dev/null 2>&1 || { echo "xclip not installed in instance image" >&2; exit 127; }',
'command -v xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
`echo '${b64}' | base64 -d | xclip -selection clipboard -i`,
'xdotool key --clearmodifiers ctrl+v',
].join('; ');
await execCapture(inst, ['bash', '-c', cmd]);
}
// 实例容器名(供反代构造 target)。
export function instanceTarget(inst: Instance): string {
return `http://${inst.containerName}:3000`;
+234 -2
View File
@@ -20,6 +20,7 @@ import {
setUserInstances,
listInstances,
findInstance,
setInstanceMemLimits,
userInstances,
userCanAccess,
createInstance,
@@ -46,6 +47,12 @@ import {
downloadFromInstance,
deleteInstanceFile,
instanceLogs,
typeInInstance,
listOrphanVolumes,
removeVolume,
listOrphanContainers,
removeContainerById,
instanceMemoryMB,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
@@ -228,12 +235,23 @@ 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 } = (req.body as any) ?? {};
const { name, reuseVolume } = (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 inst = createInstance(String(name), admin.id, allowedUserIds);
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
let reuseVolumeName: string | undefined;
if (reuseVolume) {
if (typeof reuseVolume !== 'string' || !/^woc-data-[0-9a-zA-Z._-]{1,64}$/.test(reuseVolume)) {
return reply.code(400).send({ error: '复用卷名不合法' });
}
if (listInstances().some((i) => i.volumeName === reuseVolume)) {
return reply.code(409).send({ error: '该数据卷已被另一个实例占用' });
}
reuseVolumeName = reuseVolume;
}
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName);
try {
await runInstance(inst);
} catch (e: any) {
@@ -243,6 +261,117 @@ app.post('/api/admin/instances', async (req, reply) => {
return { instance: publicInstance(inst) };
});
// 列出"未被任何实例引用的 woc-data-* 数据卷"。删除实例时默认保留卷(聊天记录),但 panel 里
// 看不到这些孤儿卷;本接口让管理员在新建实例时复用旧卷(同微信号扫码可继承聊天记录),
// 或在不需要时彻底删除。
app.get('/api/admin/orphan-volumes', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const referenced = new Set(listInstances().map((i) => i.volumeName));
try {
const volumes = await listOrphanVolumes(referenced);
return { volumes };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '读取数据卷失败' });
}
});
// 列出"残留的 woc-wx-* 容器"docker 里存在但 store 没登记。多为 runInstance 启动失败遗留
// 的 Created 容器,会占着 woc-data-<id> 卷名让删卷报 409。提供给管理员一键清理。
app.get('/api/admin/orphan-containers', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const known = new Set(listInstances().map((i) => i.containerName));
try {
const containers = await listOrphanContainers(known);
return { containers };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '读取容器失败' });
}
});
// 强制删除一个残留容器。仅当它不在 store 的已知容器集中(防误删正在用的实例)。
app.delete('/api/admin/orphan-containers/:idOrName', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const idOrName = (req.params as any).idOrName;
if (!idOrName || typeof idOrName !== 'string') return reply.code(400).send({ error: '参数不合法' });
if (listInstances().some((i) => i.containerName === idOrName)) {
return reply.code(409).send({ error: '该容器属于现存实例,不能在此删除' });
}
try {
await removeContainerById(idOrName);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '删除容器失败' });
}
});
// 显式删除一个未使用的数据卷。被现存实例占用时拒绝(避免误删聊天记录)。
app.delete('/api/admin/orphan-volumes/:name', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const name = (req.params as any).name;
if (!name || typeof name !== 'string' || !name.startsWith('woc-data-')) {
return reply.code(400).send({ error: '卷名不合法' });
}
if (listInstances().some((i) => i.volumeName === name)) {
return reply.code(409).send({ error: '该数据卷正被某个实例使用,不能删除' });
}
try {
await removeVolume(name);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '删除数据卷失败' });
}
});
// 查/改单实例的内存安全阀(soft / hard)。前端"实例卡片 → 安全"弹窗用。
// GET 返回 per-instance 当前覆盖值 + 全局默认 + 实时内存(用于弹窗里展示)。
// PUT 接受 {soft, hard},每项可为正整数 / nullnull = 恢复默认)。
app.get('/api/admin/instances/:id/mem-limits', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
let currentMB = 0;
try {
if ((await instanceRuntime(inst)) === 'running') currentMB = await instanceMemoryMB(inst);
} catch {
/* ignore:未运行时为 0 */
}
return {
soft: inst.memSoftLimitMB ?? null,
hard: inst.memHardLimitMB ?? null,
defaultSoft: DEFAULT_SOFT_MB,
defaultHard: DEFAULT_HARD_MB,
currentMB,
watchdogEnabled: WATCHDOG_ENABLED,
intervalSec: WATCHDOG_INTERVAL_SEC,
};
});
app.put('/api/admin/instances/:id/mem-limits', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = (req.body as any) ?? {};
// 允许 number / null;其它类型都视为"未提供"(保持原值)
const norm = (v: any): number | null | undefined =>
v === null ? null : typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined;
const s = norm(body.soft);
const h = norm(body.hard);
// 取最终生效值(写入前校验)
const finalSoft = s === undefined ? inst.memSoftLimitMB ?? null : s;
const finalHard = h === undefined ? inst.memHardLimitMB ?? null : h;
try {
const pub = setInstanceMemLimits(
id,
finalSoft,
finalHard,
);
return { instance: pub };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '阈值不合法' });
}
});
// 删除实例(仅管理员):默认保留数据卷,?purge=1 才永久删聊天记录
app.delete('/api/admin/instances/:id', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
@@ -438,6 +567,22 @@ app.post('/api/instances/:id/control/take', async (req, reply) => {
return { mine: true, holder: u.username };
});
// 通过 xdotool 在实例容器内输入文字(绕过 VNC XKB keysym 容量限制,修复中文 IME 吞字)
app.post('/api/instances/:id/type', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const { text } = (req.body as any) ?? {};
if (!text || typeof text !== 'string' || text.length > 500) return reply.code(400).send({ error: '文字为空或过长' });
try {
await typeInInstance(findInstance(id)!, text);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '输入失败' });
}
});
// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。
app.get('/api/admin/instances/:id/logs', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
@@ -599,5 +744,92 @@ for (const pub of listInstances()) {
}
}
// WatchdogKasmVNC/Xvnc 长跑会泄漏(实测 24h 可达 ~9 GiB),小内存机器会被拖垮。
// 两档阈值,按"是否有人在用"决定时机:
// softmem >= soft 且当前无活跃会话 → 主动重启(柔和自愈,不打扰)
// hardmem >= hard → 无视会话强制重启(防止 OOM)
// 优先级 hard > soft。两档阈值可在面板"管理 → 实例卡片 → 安全"按钮里单实例覆盖;缺省走 env。
//
// env 默认(可被 per-instance 覆盖):
// WOC_INSTANCE_MEM_SOFT_MB soft 阈值;默认 1500
// WOC_INSTANCE_MEM_HARD_MB hard 阈值;默认 2500(也兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB
// WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒;默认 300(5 分钟),最小 60;0 关闭整个 watchdog
const DEFAULT_SOFT_MB = Math.max(0, Number(process.env.WOC_INSTANCE_MEM_SOFT_MB ?? 1500));
const DEFAULT_HARD_MB = Math.max(
0,
Number(process.env.WOC_INSTANCE_MEM_HARD_MB ?? process.env.WOC_INSTANCE_MEM_LIMIT_MB ?? 2500),
);
const WATCHDOG_INTERVAL_SEC = Math.max(60, Number(process.env.WOC_WATCHDOG_INTERVAL_SEC ?? 300));
const WATCHDOG_ENABLED = WATCHDOG_INTERVAL_SEC > 0 && (DEFAULT_SOFT_MB > 0 || DEFAULT_HARD_MB > 0);
// 单实例生效阈值:per-instance 覆盖优先;为 undefined 则用 env 默认。
function effectiveLimits(inst: Instance): { soft: number; hard: number } {
return {
soft: inst.memSoftLimitMB ?? DEFAULT_SOFT_MB,
hard: inst.memHardLimitMB ?? DEFAULT_HARD_MB,
};
}
// "当前有人在远程会话" 启发式判定:复用控制权心跳。前端在用户鼠标/键盘/滚轮交互时 2.5s 节流 beat
// 故 holder 在 TTL 内即视为"有人在主动操作"。只看屏(不交互)超过 TTL 后会被判为空闲——这是有意的,
// 软自愈宁愿在"看似空闲"时短暂打扰,也不要拖到 hard 强制重启。
function hasActiveSession(id: string): boolean {
const h = controlHolders.get(id);
return !!h && Date.now() - h.at <= CONTROL_TTL;
}
if (WATCHDOG_ENABLED) {
const recovering = new Set<string>(); // 防重入:自愈期间跳过本实例
const tick = async () => {
for (const pub of listInstances()) {
const inst = findInstance(pub.id);
if (!inst || recovering.has(inst.id)) continue;
try {
if ((await instanceRuntime(inst)) !== 'running') continue;
const mb = await instanceMemoryMB(inst);
if (mb === 0) continue;
const { soft, hard } = effectiveLimits(inst);
const active = hasActiveSession(inst.id);
let reason: 'hard' | 'soft' | null = null;
if (hard > 0 && mb >= hard) reason = 'hard';
else if (soft > 0 && mb >= soft && !active) reason = 'soft';
if (!reason) {
if (soft > 0 && mb >= soft && active) {
app.log.info(
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用(holder=${controlHolders.get(inst.id)?.username}),延后`,
);
}
continue;
}
recovering.add(inst.id);
if (reason === 'hard') {
app.log.warn(
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active}`,
);
} else {
app.log.warn(
`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`,
);
}
try {
await stopInstance(inst);
await runInstance(inst);
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason}`);
} catch (e: any) {
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}: ${e?.message || e}`);
} finally {
recovering.delete(inst.id);
}
} catch (e: any) {
app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`);
}
}
};
setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref();
console.log(
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s`,
);
}
await app.listen({ port: PORT, host: HOST });
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`);
+62 -4
View File
@@ -34,6 +34,11 @@ export interface Instance {
kasmPassword: string;
createdAt: string;
createdBy: string; // userId
// 自愈 watchdog 的"安全阀"per-instance 覆盖全局默认;缺省时使用 env / 内置默认。
// soft:内存超此值时,仅在"当前没有用户在远程会话"才主动重启(柔和自愈);
// hard:内存超此值时,无论是否有人在会话都重启(防止 OOM 拖垮宿主)。
memSoftLimitMB?: number;
memHardLimitMB?: number;
}
interface Data {
@@ -190,7 +195,39 @@ function sanitizeInstanceIds(ids: string[]): string[] {
}
export function publicInstance(i: Instance) {
return { id: i.id, name: i.name, createdAt: i.createdAt, createdBy: i.createdBy };
return {
id: i.id,
name: i.name,
createdAt: i.createdAt,
createdBy: i.createdBy,
memSoftLimitMB: i.memSoftLimitMB,
memHardLimitMB: i.memHardLimitMB,
};
}
// 设置/清除某实例的 mem 安全阀。传 null 表示恢复默认(从对象上删字段)。
// 校验:正整数;soft < hard;上限 20480 MiB20 GiB)。
export function setInstanceMemLimits(
id: string,
softMB: number | null,
hardMB: number | null,
) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
const norm = (v: number | null): number | undefined => {
if (v == null) return undefined;
if (!Number.isFinite(v) || !Number.isInteger(v) || v < 1 || v > 20480) {
throw new Error('阈值需为 1-20480 之间的整数(MiB');
}
return v;
};
const s = norm(softMB);
const h = norm(hardMB);
if (s != null && h != null && s >= h) throw new Error('soft 阈值需小于 hard 阈值');
inst.memSoftLimitMB = s;
inst.memHardLimitMB = h;
persist();
return publicInstance(inst);
}
export function listInstances() {
@@ -213,13 +250,34 @@ export function userCanAccess(u: User, instanceId: string) {
return u.allowedInstances.includes(instanceId) && !!findInstance(instanceId);
}
export function createInstance(name: string, createdBy: string, allowedUserIds: string[] = []) {
const id = randomBytes(5).toString('hex'); // 10 hex chars
// 复用旧卷时:从 woc-data-<id> 解析回 id,让新实例的 containerName / volumeName 都对齐旧卷的
// id(避免出现"卷叫 woc-data-abc,但实例 id 是 def"这种命名错配)。若旧 id 与现存实例冲突或卷名
// 非标准前缀,则退回新生成 id,仅卷名指向旧卷。
function parseIdFromVolume(volumeName: string): string | null {
const m = /^woc-data-([0-9a-f]{10})$/.exec(volumeName);
return m ? m[1] : null;
}
export function createInstance(
name: string,
createdBy: string,
allowedUserIds: string[] = [],
reuseVolumeName?: string,
) {
let id = randomBytes(5).toString('hex'); // 10 hex chars
let volumeName = `woc-data-${id}`;
if (reuseVolumeName) {
const reusedId = parseIdFromVolume(reuseVolumeName);
if (reusedId && !findInstance(reusedId)) {
id = reusedId;
}
volumeName = reuseVolumeName; // 始终指向旧卷(即便 id 是新生成的)
}
const inst: Instance = {
id,
name: name.trim() || `微信-${id.slice(0, 4)}`,
containerName: `woc-wx-${id}`,
volumeName: `woc-data-${id}`,
volumeName,
kasmUser: 'woc',
// 用 hex(仅 0-9a-f):容器内 init 脚本以 `openssl passwd -apr1 ${PASSWORD}` 未加引号方式生成 .htpasswd
// base64url 可能含前导 '-' 而被 openssl 当作命令行选项,导致密码哈希为空、所有鉴权失败。hex 不含任何 shell 特殊字符。
+6
View File
@@ -99,6 +99,12 @@ export default function AppShell() {
useEffect(() => setDrawer(false), [loc.pathname]); // 路由变化关抽屉
// 路由切换时刷新共享实例列表:管理页用的是独立列表,新建/安装实例后不会动到这个共享 context,
// 否则进入实例页 / 回主页都读到陈旧列表(实例缺失),需手动刷新整页才出现。导航即拉一次最新即可。
// 不清空旧数据,拉取期间沿用旧列表,无闪烁。
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => void state.reload(), [loc.pathname]);
// 移动端不收成窄栏(改用抽屉);折叠仅桌面生效
const railed = collapsed && isDesktop;
+29 -2
View File
@@ -24,6 +24,17 @@ export interface PanelInstance {
name: string;
createdAt: string;
createdBy: string;
memSoftLimitMB?: number;
memHardLimitMB?: number;
}
export interface MemLimits {
soft: number | null;
hard: number | null;
defaultSoft: number;
defaultHard: number;
currentMB: number;
watchdogEnabled: boolean;
intervalSec: number;
}
export interface InstanceWithStatus extends PanelInstance {
runtime: RuntimeState;
@@ -75,11 +86,26 @@ export const api = {
// 微信实例
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
createInstance: (name: string, allowedUserIds: string[] = []) =>
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string) =>
req<{ instance: PanelInstance }>('/api/admin/instances', {
method: 'POST',
body: JSON.stringify({ name, allowedUserIds }),
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }),
}),
getInstanceMemLimits: (id: string) =>
req<MemLimits>(`/api/admin/instances/${id}/mem-limits`),
setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/mem-limits`, {
method: 'PUT',
body: JSON.stringify({ soft, hard }),
}),
listOrphanVolumes: () =>
req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'),
deleteOrphanVolume: (name: string) =>
req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }),
listOrphanContainers: () =>
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
deleteOrphanContainer: (idOrName: string) =>
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
renameInstance: (id: string, name: string) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
deleteInstance: (id: string, purge = false) =>
@@ -114,4 +140,5 @@ export const api = {
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
};
+320 -1
View File
@@ -27,7 +27,12 @@ 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 [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
// 残留 woc-wx-* 容器(runInstance 启动失败遗留的 Created 容器等):占着卷名让删卷报 409。
const [orphanConts, setOrphanConts] = useState<{ id: string; name: string; status: string; volumeName?: string }[]>([]);
const setAct = (id: string, label: string | null) =>
setActing((a) => {
const n = { ...a };
@@ -48,6 +53,60 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
} catch (e: any) {
setErr(e.message);
}
// 孤儿卷 / 残留容器独立 catch:docker 接口失败不应阻塞用户/实例视图
try {
const { volumes } = await api.listOrphanVolumes();
setOrphanVols(volumes);
} catch {
/* ignore */
}
try {
const { containers } = await api.listOrphanContainers();
setOrphanConts(containers);
} catch {
/* ignore */
}
};
const removeOrphanCont = async (c: { id: string; name: string }) => {
const ok = await confirm({
title: `删除残留容器「${c.name}」?`,
body: '此容器不属于任何登记实例(多为创建失败遗留)。删除不会动数据卷,删后才能继续清理同名旧数据卷。',
danger: true,
confirmText: '删除容器',
});
if (!ok) return;
try {
await api.deleteOrphanContainer(c.id);
toast('已删除残留容器,可继续清理数据卷', 'ok');
setOrphanConts((cs) => cs.filter((x) => x.id !== c.id));
// 容器走了之后,原本被它占着的卷可能从"被引用"翻成"孤儿",刷新一次
try {
const { volumes } = await api.listOrphanVolumes();
setOrphanVols(volumes);
} catch {
/* ignore */
}
} catch (e: any) {
toast(e.message || '删除失败', 'error');
}
};
const removeOrphanVol = async (name: string) => {
const ok = await confirm({
title: `彻底删除数据卷「${name}」?`,
body: '该卷里保存的微信本地数据(聊天记录缓存等)将永久消失,无法恢复。',
danger: true,
confirmText: '彻底删除',
});
if (!ok) return;
try {
await api.deleteOrphanVolume(name);
toast('已删除数据卷', 'ok');
setOrphanVols((vs) => vs.filter((v) => v.name !== name));
} catch (e: any) {
toast(e.message || '删除失败', 'error');
}
};
useEffect(() => {
@@ -171,6 +230,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
onRename={() => setRenameInst(inst)}
onAssign={() => setAssignInst(inst)}
onDelete={() => setDeleteInst(inst)}
onSecurity={() => setSecurityInst(inst)}
/>
))}
</div>
@@ -222,6 +282,63 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
))}
</div>
)}
{orphanConts.length > 0 && (
<>
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title"></span>
<span className="muted small"></span>
</div>
<div className="inst-grid">
{orphanConts.map((c) => (
<div key={c.id} className="inst-card">
<div className="inst-head">
<span className="inst-name" style={{ fontFamily: 'monospace', fontSize: 13 }}>{c.name}</span>
<span className="tag tag-off">{c.status || 'unknown'}</span>
</div>
{c.volumeName && (
<div className="inst-sub" style={{ fontFamily: 'monospace', fontSize: 12 }}>
{c.volumeName}
</div>
)}
<div className="inst-admin-links">
<button className="btn-text danger" onClick={() => removeOrphanCont(c)}>
</button>
</div>
</div>
))}
</div>
</>
)}
{orphanVols.length > 0 && (
<>
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title">使</span>
<span className="muted small"></span>
</div>
<div className="inst-grid">
{orphanVols.map((v) => (
<div key={v.name} className="inst-card">
<div className="inst-head">
<span className="inst-name" style={{ fontFamily: 'monospace', fontSize: 13 }}>{v.name}</span>
</div>
<div className="inst-sub">
{v.createdAt ? `创建于 ${v.createdAt.slice(0, 10)}` : '创建时间未知'}
{typeof v.sizeBytes === 'number' ? ` · ${(v.sizeBytes / 1024 / 1024).toFixed(1)} MB` : ''}
</div>
<div className="inst-admin-links">
<button className="btn-text" onClick={() => setCreatingInst(true)} title="去「新建实例」对话框,在「数据卷」下拉里选择复用此卷">
</button>
<button className="btn-text danger" onClick={() => removeOrphanVol(v.name)}>
</button>
</div>
</div>
))}
</div>
</>
)}
</>
)}
@@ -319,6 +436,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
}}
/>
)}
{securityInst && (
<InstanceSecurity
inst={securityInst}
onClose={() => setSecurityInst(null)}
onDone={() => {
toast('已保存安全阈值', 'ok');
load();
}}
/>
)}
</div>
);
}
@@ -402,6 +529,160 @@ function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: ()
);
}
// 「安全」弹窗:编辑某实例的内存安全阀(soft / hard)。
// soft:超过且无人在远程会话时主动重启(柔和自愈,不打扰)
// hard:超过即强制重启(无视会话,防止 OOM)
// 留空 = 使用面板全局默认(来自 env)。
function InstanceSecurity({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
const [data, setData] = useState<import('../api').MemLimits | null>(null);
// 输入字段:空串 = "使用默认"(→ 提交时映射为 null)
const [softStr, setSoftStr] = useState('');
const [hardStr, setHardStr] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
// 首次加载 + 每 5s 刷新 currentMB(运行实例的实时内存)
useEffect(() => {
let alive = true;
const fetchOnce = async (initial: boolean) => {
try {
const d = await api.getInstanceMemLimits(inst.id);
if (!alive) return;
setData(d);
if (initial) {
setSoftStr(d.soft == null ? '' : String(d.soft));
setHardStr(d.hard == null ? '' : String(d.hard));
setLoaded(true);
}
} catch (e: any) {
if (alive && initial) {
setErr(e?.message || '读取失败');
setLoaded(true);
}
}
};
fetchOnce(true);
const t = window.setInterval(() => fetchOnce(false), 5000);
return () => {
alive = false;
window.clearInterval(t);
};
}, [inst.id]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
const parse = (s: string): number | null => {
const t = s.trim();
if (t === '') return null;
const n = Number(t);
if (!Number.isInteger(n)) throw new Error('阈值需为整数(MiB');
return n;
};
let s: number | null;
let h: number | null;
try {
s = parse(softStr);
h = parse(hardStr);
} catch (e: any) {
setErr(e.message);
return;
}
if (s != null && h != null && s >= h) {
setErr('soft 阈值需小于 hard 阈值');
return;
}
setBusy(true);
try {
await api.setInstanceMemLimits(inst.id, s, h);
onDone();
onClose();
} catch (e: any) {
setErr(e.message || '保存失败');
} finally {
setBusy(false);
}
};
const resetToDefault = () => {
setSoftStr('');
setHardStr('');
};
return (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit} style={{ maxWidth: 460 }}>
<h2> · {inst.name}</h2>
{!loaded ? (
<div className="muted small" style={{ padding: '14px 0' }}></div>
) : !data ? (
<div className="error">{err || '读取失败'}</div>
) : (
<>
<div className="muted small" style={{ lineHeight: 1.6 }}>
KasmVNC/Xvnc watchdog MiB
<br />
<b>soft</b><b></b>使
<br />
<b>hard</b><b></b> OOM 宿
</div>
<div className="security-status">
<div className="security-row">
<span></span>
<b>{data.currentMB > 0 ? `${data.currentMB} MiB` : '—'}</b>
</div>
<div className="security-row">
<span></span>
<span className="muted">soft {data.defaultSoft} · hard {data.defaultHard}</span>
</div>
<div className="security-row">
<span></span>
<span className="muted">
{data.watchdogEnabled ? `${data.intervalSec}s` : 'watchdog 已关闭'}
</span>
</div>
</div>
<div className="field-label" style={{ marginTop: 12 }}>soft = {data.defaultSoft}</div>
<input
className="input"
inputMode="numeric"
placeholder={`${data.defaultSoft}`}
value={softStr}
onChange={(e) => setSoftStr(e.target.value.replace(/[^0-9]/g, ''))}
/>
<div className="field-label" style={{ marginTop: 8 }}>hard = {data.defaultHard}</div>
<input
className="input"
inputMode="numeric"
placeholder={`${data.defaultHard}`}
value={hardStr}
onChange={(e) => setHardStr(e.target.value.replace(/[^0-9]/g, ''))}
/>
<div className="muted small" style={{ marginTop: 6 }}>
1500 MiBsoft 2000hard 宿 3000~4000
</div>
{err && <div className="error">{err}</div>}
</>
)}
<div className="modal-actions">
<button type="button" className="btn-text" onClick={resetToDefault} disabled={busy}>
</button>
<button type="button" className="btn" onClick={onClose} disabled={busy}>
</button>
<button className="btn btn-primary" disabled={busy || !loaded || !data}>
</button>
</div>
</form>
</div>
);
}
function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
const [purge, setPurge] = useState(false);
const [err, setErr] = useState('');
@@ -459,6 +740,7 @@ function InstanceAdminCard({
onRename,
onAssign,
onDelete,
onSecurity,
}: {
inst: InstanceWithStatus;
userCount: number;
@@ -472,6 +754,7 @@ function InstanceAdminCard({
onRename: () => void;
onAssign: () => void;
onDelete: () => void;
onSecurity: () => void;
}) {
const wx = inst.wechat;
const busy = BUSY_PHASES.includes(wx.phase);
@@ -557,6 +840,9 @@ function InstanceAdminCard({
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
</button>
<button className="btn-text" onClick={onSecurity} title="设置内存阈值:超过 soft 且无人会话时柔和重启;超过 hard 强制重启">
</button>
<button className="btn-text danger" onClick={onDelete}>
</button>
@@ -656,13 +942,29 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
const [sel, setSel] = useState<Set<string>>(new Set());
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
// 未使用的旧数据卷(之前删除实例但未勾选「彻底清除」时保留下来的),允许在此复用以继承聊天记录。
const [orphans, setOrphans] = useState<{ name: string; createdAt?: string }[]>([]);
const [reuse, setReuse] = useState<string>(''); // '' = 不复用,新建空卷
useEffect(() => {
let alive = true;
api
.listOrphanVolumes()
.then(({ volumes }) => alive && setOrphans(volumes))
.catch(() => {
/* 读取失败时不阻塞创建:列表为空即可,照常新建空卷 */
});
return () => {
alive = false;
};
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setBusy(true);
try {
await api.createInstance(name.trim(), [...sel]);
await api.createInstance(name.trim(), [...sel], reuse || undefined);
onDone();
} catch (e: any) {
setErr(e.message || '创建失败');
@@ -683,6 +985,23 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
onToggle={(id) => setSel((s) => toggleSet(s, id))}
empty="暂无子账号"
/>
{orphans.length > 0 && (
<>
<div className="field-label" style={{ marginTop: 12 }}></div>
<select className="input" value={reuse} onChange={(e) => setReuse(e.target.value)}>
<option value=""></option>
{orphans.map((v) => (
<option key={v.name} value={v.name}>
· {v.name}
{v.createdAt ? `${v.createdAt.slice(0, 10)} 创建)` : ''}
</option>
))}
</select>
<div className="muted small" style={{ marginTop: 4 }}>
****
</div>
</>
)}
{err && <div className="error">{err}</div>}
<div className="muted small" style={{ marginTop: 4 }}></div>
<div className="modal-actions">
+108 -2
View File
@@ -52,8 +52,12 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
const lastBeat = useRef(0);
const lastImeError = useRef(0);
const inst = instances.find((i) => i.id === id);
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
const [probing, setProbing] = useState(true);
const offline = inst ? inst.runtime !== 'running' : false;
const installed = !!inst && inst.wechat.installed && inst.wechat.phase !== 'downloading';
const showVnc = !!inst && !offline && installed;
@@ -65,8 +69,21 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
setFiles([]);
setShowClip(false);
setClipText('');
setProbing(true);
}, [id]);
// 探测态收敛:找到实例即结束;否则给共享列表一点刷新时间(AppShell 已在导航时拉取),超时仍无则判定不存在。
useEffect(() => {
if (inst) {
setProbing(false);
return;
}
if (!probing) return;
const t = window.setTimeout(() => setProbing(false), 2500);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inst, probing, id]);
// 实例未就绪(启动中 / 安装中 / 上下文状态未刷新)时,每 3s 拉取最新状态:
// 就绪后自动进入桌面,无需手动刷新(修复"安装完进度 100% 仍提示无实例")。
useEffect(() => {
@@ -244,13 +261,101 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
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;}';
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}' +
// macOS 中文输入法需要目标元素有非零尺寸才能激活;KasmVNC 默认 0x0 导致无法切换输入法
'#noVNC_keyboardinput{width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;}';
(doc.head || doc.documentElement).appendChild(st);
} catch {
/* 同源正常不会到这 */
}
};
// 中文 IME 输入修复:绕过 VNC XKB keysym 容量限制(~21 个 CJK 字符后 keymap 满,输入全废)。
// 根因:KasmVNC 的 Perl 补丁在 compositionend 发 CJK keysym,但紧随其后的 _handleInput
// diff 逻辑会发 Backspace 清拼音,把刚发的字也删了。必须在捕获阶段拦截,阻止 Perl 补丁执行,
// 手动重置内部状态(防止 _handleInput 发 Backspace),然后通过 API 用 xdotool 粘贴文字。
const patchVncIme = () => {
try {
const doc = frameRef.current?.contentDocument;
if (!doc || doc.getElementById('woc-ime-patch')) return;
const ta = doc.getElementById('noVNC_keyboardinput') as HTMLTextAreaElement | null;
if (!ta) return;
const win = frameRef.current?.contentWindow as any;
let imeComposing = false;
let swallowInputUntil = 0;
const keyboard = () => {
const cv = doc.querySelector('canvas') as any;
return win?.UI?.rfb?.keyboard || cv?._rfb?.keyboard || null;
};
const installKeyboardGuard = () => {
const kb = keyboard() as any;
if (!kb || kb._wocImeGuard || typeof kb._sendKeyEvent !== 'function') return;
const original = kb._sendKeyEvent.bind(kb);
kb._wocImeGuard = true;
if (typeof kb._wocImeSuppressUnicode !== 'boolean') kb._wocImeSuppressUnicode = false;
kb._sendKeyEvent = (keysym: number, ...args: any[]) => {
if (kb._wocImeSuppressUnicode && typeof keysym === 'number' && keysym >= 0x01000000) return;
return original(keysym, ...args);
};
};
const syncKeyboardInput = (value: string) => {
try {
installKeyboardGuard();
const kb = keyboard();
if (kb) {
kb._imeInProgress = false;
kb._imeHold = false;
kb._lastKeyboardInput = value;
if (kb._rfbKeyQueue) kb._rfbKeyQueue.length = 0;
}
} catch { /* ignore */ }
};
const swallowNoVncInput = (e: Event) => {
if (!imeComposing && Date.now() > swallowInputUntil) return;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
};
ta.addEventListener('compositionstart', (e) => {
imeComposing = true;
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
e.stopImmediatePropagation();
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
}, true);
ta.addEventListener('beforeinput', swallowNoVncInput, true);
ta.addEventListener('input', swallowNoVncInput, true);
ta.addEventListener('compositionend', (e) => {
const text = (e as CompositionEvent).data;
if (!text || !id) return;
imeComposing = false;
swallowInputUntil = Date.now() + 300;
e.stopImmediatePropagation(); // 阻止 KasmVNC 原生 IME 路径再发一遍 keysym
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = true;
syncKeyboardInput((e.target as HTMLTextAreaElement).value);
window.setTimeout(() => {
ta.value = '';
syncKeyboardInput('');
const kb = keyboard() as any;
if (kb) kb._wocImeSuppressUnicode = false;
}, 0);
// 通过面板 API → xdotool 在容器内粘贴,完全绕过 VNC keysym
api.typeInInstance(id, text).catch((err) => {
const now = Date.now();
if (now - lastImeError.current > 3000) {
lastImeError.current = now;
toast(err?.message || '中文输入失败,请确认实例镜像包含 xclip/xdotool', 'error');
}
});
}, true); // capture:先于 Perl 补丁的 bubble handler
const mark = doc.createElement('meta');
mark.id = 'woc-ime-patch';
(doc.head || doc.documentElement).appendChild(mark);
} catch {
/* ignore */
}
};
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
@@ -377,7 +482,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</header>
{/* —— 各种态 —— */}
{!loaded ? (
{!loaded || (probing && !inst) ? (
<div className="iv-stage iv-center">
<div className="spinner" />
</div>
@@ -456,6 +561,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
injectVncStyle(); // 让原生控制条在深色背景下可见
patchVncIme(); // 修复中文 IME 吞字(绕过 VNC XKB keysym 限制)
}, 500);
}}
/>
+16
View File
@@ -732,6 +732,22 @@ button {
.clip-area:focus {
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
}
.security-status {
margin-top: 12px;
padding: 10px 14px;
border-radius: 14px;
background: var(--mf-trough, #edeef1);
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.12);
display: flex;
flex-direction: column;
gap: 6px;
}
.security-row {
display: flex;
justify-content: space-between;
font-size: 13px;
line-height: 1.4;
}
.files-hint {
font-size: 12px;
color: var(--muted);