mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
7 Commits
@@ -1,9 +1,54 @@
|
||||
# WechatOnCloud
|
||||
<div align="center">
|
||||
|
||||
在飞牛 NAS(x86_64 / arm64)上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。
|
||||
<img src="doc/img/icon-192.png" width="88" height="88" alt="云微 logo" />
|
||||
|
||||
> 设计与选型详见 [技术方案.md](技术方案.md)。
|
||||
> 部署形态:拉取 GHCR 预构建多架构镜像(或本地自构建),面板按需动态创建微信实例容器。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)。
|
||||
<h1>云微 · WechatOnCloud</h1>
|
||||
|
||||
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一个微信会话</b></p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/stargazers"><img src="https://img.shields.io/github/stars/Gloridust/WechatOnCloud?style=flat-square&logo=github" alt="stars" /></a>
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/releases"><img src="https://img.shields.io/github/v/release/Gloridust/WechatOnCloud?style=flat-square" alt="release" /></a>
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/issues"><img src="https://img.shields.io/github/issues/Gloridust/WechatOnCloud?style=flat-square" alt="issues" /></a>
|
||||
<img src="https://img.shields.io/badge/arch-amd64%20%7C%20arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="arch" />
|
||||
<img src="https://img.shields.io/badge/PWA-ready-5A0FC8?style=flat-square" alt="pwa" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="#核心特性">核心特性</a> ·
|
||||
<a href="#docker-运行模式详解新手向">运行原理</a> ·
|
||||
<a href="#安全须知必读">安全须知</a> ·
|
||||
<a href="doc/技术方案.md">技术方案</a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="doc/img/Screenshot-1.png" alt="云微 · 面板主界面" /></td>
|
||||
<td width="50%"><img src="doc/img/Screenshot-2.png" alt="云微 · 实例桌面" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
在飞牛 NAS(x86_64 / arm64)或任意 Docker 主机上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。**不修改微信客户端。**
|
||||
|
||||
> 设计与选型详见 [技术方案.md](doc/技术方案.md)。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)。
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
- 🗂️ **多实例** — 一个面板管理多个独立微信会话,每个实例独立容器 + 独立数据卷,互不干扰。
|
||||
- 👥 **多端共享 + 权限** — 多浏览器 / 设备共享同一会话;子账号体系,按账号分配可访问的实例(RBAC)。
|
||||
- 🖥️ **微信 PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉。
|
||||
- 📦 **微信本体运行时下载** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条;按 CPU 架构自动取包。
|
||||
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
|
||||
- 📎 **文件传输** — 原生拖拽上传 + 下载 + 删除,直达微信桌面 `~/Desktop`。
|
||||
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
|
||||
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
|
||||
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
|
||||
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
|
||||
|
||||
---
|
||||
|
||||
@@ -248,7 +293,9 @@ docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 安全须知(必读)
|
||||
## 安全须知(必读)
|
||||
|
||||
> ⚠️ **这套系统暴露的是已登录的微信,请务必认真阅读本节。**
|
||||
|
||||
这套系统暴露的是**已登录的微信**——能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
|
||||
|
||||
@@ -257,7 +304,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
- 实例的增删、微信安装/更新等触碰 docker 引擎的操作**仅限管理员**;docker API 绝不暴露给前端;
|
||||
- KasmVNC 凭据由面板服务端注入,**浏览器永远拿不到**;实例容器名由内部随机 ID 派生,避免注入;
|
||||
- 面板与外网之间再套一层 HTTPS 反代(飞牛自带反代 / Caddy / Nginx)获得正经 TLS;
|
||||
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](技术方案.md) 第 5 节。
|
||||
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](doc/技术方案.md) 第 5 节。
|
||||
|
||||
---
|
||||
|
||||
@@ -321,19 +368,22 @@ WechatOnCloud/
|
||||
├── .github/workflows/
|
||||
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
|
||||
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud)
|
||||
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + 默认开 IME(不打包微信本体)
|
||||
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + xdotool + 默认开 IME(不打包微信本体)
|
||||
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state)
|
||||
│ └── autostart # openbox 会话启动:等待微信就绪 + 常驻拉起(含崩溃自重启)
|
||||
│ ├── autostart # openbox 会话启动:常驻拉起微信(崩溃自重启)+ 最小化窗口自动复原看守
|
||||
│ └── woc-update-autostart # 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本
|
||||
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
|
||||
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
|
||||
│ ├── server/ # Fastify:cookie 鉴权 + 账号/实例/权限 API + dockerode 管理实例 + 反代
|
||||
│ └── web/ # React + TS + PWA(牛奶布艺 + 微信绿主题)
|
||||
│ ├── server/ # Fastify:cookie 鉴权 + 账号/实例/权限/生命周期 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、端口、时区)
|
||||
├── 技术方案.md # 完整设计文档
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -352,3 +402,27 @@ WechatOnCloud/
|
||||
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
|
||||
- [ ] 掉登录时 web 端二维码重扫入口
|
||||
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
|
||||
|
||||
## 致谢
|
||||
|
||||
创意启发自懒猫微服(原 Deepin 团队做的硬件产品),推荐有经济实力、追求稳定运营的朋友尝试!
|
||||
|
||||
也感谢每一位 Star / Issue / PR 的朋友——**两天突破 500 ⭐**,是继续打磨的最大动力 🙌
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Gloridust%2FWechatOnCloud&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>如果这个项目帮到了你,欢迎点个 ⭐ Star 支持一下 ·
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/issues">反馈问题</a> ·
|
||||
<a href="https://github.com/Gloridust/WechatOnCloud/pulls">参与贡献</a></sub>
|
||||
</div>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 905 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 916 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,7 +1,6 @@
|
||||
# WechatOnCloud 技术方案
|
||||
|
||||
> 目标:在飞牛 NAS(x86_64)上运行一个服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
|
||||
> 参考形态:懒猫微服「云微信」。
|
||||
> 目标:在飞牛 NAS(x86_64)或任何服务器上运行服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
|
||||
|
||||
---
|
||||
|
||||
@@ -147,7 +146,6 @@
|
||||
|
||||
```
|
||||
WechatOnCloud/
|
||||
├── 技术方案.md # 本文档
|
||||
├── docker/
|
||||
│ ├── Dockerfile # Debian + Xvfb + 微信deb + KasmVNC
|
||||
│ ├── entrypoint.sh # 启动 Xvfb → WM → 微信 → KasmVNC
|
||||
@@ -183,8 +181,7 @@ WechatOnCloud/
|
||||
|
||||
- [微信 Linux 官网](https://linux.weixin.qq.com/en)
|
||||
- [腾讯上线 Linux 微信官网(x86/Arm/LoongArch)](https://finance.sina.com.cn/tech/roll/2024-11-06/doc-incvceuc0172323.shtml)
|
||||
- [懒猫微服 云微信](https://lazycat.cloud/lcmd/)
|
||||
- [开源云微信部署方案(低配版懒猫远程微信)](https://linux.do/t/topic/1306818)
|
||||
- [懒猫微服 云微信](https://lazycat.cloud/)
|
||||
- [Xpra 官方文档](https://xpra.org/manual)
|
||||
- [Xpra HTML5 客户端](https://github.com/Xpra-org/xpra-html5)
|
||||
- [飞牛 fpk 框架文档](https://developer.fnnas.com/docs/core-concepts/framework)
|
||||
+15
-15
@@ -14,7 +14,8 @@ RUN set -eux; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates locales dpkg \
|
||||
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
|
||||
libnss3 libgbm1 libasound2 libxss1; \
|
||||
libnss3 libgbm1 libasound2 libxss1 \
|
||||
xdotool; \
|
||||
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
apt-get clean; \
|
||||
@@ -53,28 +54,27 @@ ENV LANG=zh_CN.UTF-8 \
|
||||
LC_ALL=zh_CN.UTF-8 \
|
||||
LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
# 让 KasmVNC web 客户端默认开启 IME 输入模式:
|
||||
# 用户用本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字发进容器,无需容器内装 IME。
|
||||
# 默认值仅在浏览器未存过该设置时生效,不会覆盖用户手动改过的偏好。
|
||||
# 改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js:
|
||||
# (1) 默认开启 IME 输入模式:本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字
|
||||
# 发进容器,无需容器内装 IME。默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
|
||||
# (2) 修复 noVNC 中文 IME 输入:原实现靠隐藏 textarea 差分逐字符重发 keysym,会泄漏中间拼音、
|
||||
# 累积不 reset、退格风暴,导致大量丢字 / ~21 字卡住 / 跨浏览器不稳。改为只在 compositionend
|
||||
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
|
||||
# 注意:实际加载的是 webpack 产物 dist/main.bundle.js(app/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
|
||||
# 末尾的 grep 作为断言:若 base 镜像换了打包结构、改不到任何文件,则构建直接失败而非静默放过。
|
||||
RUN set -eux; \
|
||||
patched=0; \
|
||||
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do \
|
||||
if [ -f "$f" ] && grep -q "initSetting('enable_ime', false)" "$f"; then \
|
||||
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"; \
|
||||
patched=1; \
|
||||
fi; \
|
||||
done; \
|
||||
[ "$patched" = "1" ]
|
||||
COPY woc-www-patch.sh woc-ime.pl /woc/
|
||||
RUN chmod +x /woc/woc-www-patch.sh && /woc/woc-www-patch.sh
|
||||
|
||||
# 微信下载/解压控制脚本(运行时由面板经 docker exec 触发,状态写入数据卷 /config/.woc-state)
|
||||
COPY wechat-ctl.sh /woc/wechat-ctl.sh
|
||||
RUN chmod +x /woc/wechat-ctl.sh
|
||||
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
||||
COPY autostart /defaults/autostart
|
||||
RUN chmod +x /defaults/autostart
|
||||
|
||||
# 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
|
||||
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||
RUN chmod +x /custom-cont-init.d/01-woc-autostart
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
EXPOSE 3000 3001
|
||||
|
||||
@@ -10,6 +10,19 @@ WECHAT_BIN=/config/wechat/opt/wechat/wechat
|
||||
# 容器内无 GPU,强制软件渲染
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
|
||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
|
||||
(
|
||||
export DISPLAY="${DISPLAY:-:1}"
|
||||
while sleep 2; do
|
||||
all=$(xdotool search --name '.+' 2>/dev/null) || continue
|
||||
vis=$(xdotool search --onlyvisible --name '.+' 2>/dev/null)
|
||||
for w in ${all}; do
|
||||
printf '%s\n' "${vis}" | grep -qx "${w}" || xdotool windowactivate "${w}" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
) &
|
||||
|
||||
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
|
||||
notified=0
|
||||
while [ ! -x "${WECHAT_BIN}" ]; do
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# perl -0777 -pe 补丁脚本(被 woc-www-patch.sh 引用)。
|
||||
# 对 dist/*.bundle.js 里 noVNC 键盘 IME 逻辑做两处替换,全程字面匹配(\Q..\E)。
|
||||
#
|
||||
# 背景:noVNC 原实现靠"隐藏 textarea 差分→逐字符重发 keysym"还原 IME 输入,会在合成过程中
|
||||
# 把中间拼音也发给远端、且永不 reset 导致累积+退格风暴 → 大量丢字 / 卡住 / 跨浏览器不稳。
|
||||
#
|
||||
# 改法:彻底不靠 textarea 差分还原中文。
|
||||
# - 合成进行中(input 事件):只同步 _lastKeyboardInput、不发送(避免中间拼音泄漏 / 丢字)。
|
||||
# - 提交时(compositionend):用 e.data(最终上屏字符串)逐字发 keysym,并把 _lastKeyboardInput
|
||||
# 同步成当前 textarea 值。不 reset、不吞键——避免吞掉下一个词的首键、避免打断下一次合成。
|
||||
# - 若个别浏览器在 compositionend 后还补发一次"提交 input":此时 isComposing/_imeHold 均为假,
|
||||
# 落到非 IME 差分分支,但 newValue 与刚同步的 _lastKeyboardInput 相等 → 差分为空 → 不重复发送。
|
||||
|
||||
# (A) _handleCompositionEnd:提交时 e.data 直发 + 同步 _lastKeyboardInput(不 reset、不吞键)
|
||||
s~\Q if (this._enableIME) {
|
||||
this._imeInProgress = false;
|
||||
}
|
||||
|
||||
if (isChromiumBased()) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (isChromiumBased()) {
|
||||
this._imeHold = false;
|
||||
}~;
|
||||
|
||||
# (B) _handleInput 顶部守卫:合成进行中只同步值、不发送;提交已在 compositionend 完成。
|
||||
s~\Q if (this._enableIME && this._imeHold) {
|
||||
Debug("IME input change, sending differential");\E~ if (this._enableIME && (e.isComposing || this._imeHold || this._imeInProgress)) { // WOC-IME
|
||||
this._lastKeyboardInput = e.target.value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._enableIME && this._imeHold) {
|
||||
Debug("IME input change, sending differential");~;
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# linuxserver 启动钩子(/custom-cont-init.d,root 身份,每次启动执行)。
|
||||
# 作用:始终用镜像内最新的 /defaults/autostart 覆盖数据卷里的副本。
|
||||
# 原因:baseimage 的 init-kasmvnc-config 只在 /config/.config/openbox/autostart "缺失时"才拷贝,
|
||||
# 导致旧实例(卷里已有旧 autostart)升级镜像后仍跑旧逻辑(如缺少"最小化自动复原"看守)。
|
||||
mkdir -p /config/.config/openbox
|
||||
cp /defaults/autostart /config/.config/openbox/autostart
|
||||
chmod +x /config/.config/openbox/autostart
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /config/.config/openbox/autostart 2>/dev/null || true
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建期补丁:改 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 事件,避免重复发送/跨分支竞争。
|
||||
# 末尾断言:若 base 镜像换了打包结构、一个文件都没改到,则构建失败而非静默放过。
|
||||
set -euo pipefail
|
||||
|
||||
PATCH_PL="$(dirname "$0")/woc-ime.pl"
|
||||
patched=0
|
||||
|
||||
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do
|
||||
[ -f "$f" ] || continue
|
||||
changed=0
|
||||
|
||||
# (1) enable_ime 默认开启
|
||||
if grep -q "initSetting('enable_ime', false)" "$f"; then
|
||||
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"
|
||||
changed=1
|
||||
fi
|
||||
|
||||
# (2) IME 差分逻辑修复(仅含 noVNC 键盘逻辑的 bundle)
|
||||
# 幂等:/usr/share/kasmvnc 是 /usr/local/share/kasmvnc 的软链,两个 glob 会命中同一 inode,
|
||||
# 故已含 _imeJustCommitted 的文件直接跳过,避免重复注入守卫块。
|
||||
if grep -q "IME input change, sending differential" "$f" && ! grep -q "WOC-IME" "$f"; then
|
||||
perl -0777 -i -pe "$(cat "$PATCH_PL")" "$f"
|
||||
after="$(grep -c "WOC-IME" "$f" || true)"
|
||||
# 断言两处替换都命中(compositionend 标记 1 + _handleInput 守卫标记 1 = 2 行)
|
||||
if [ "$after" -ne 2 ]; then
|
||||
echo "FATAL: IME patch mismatch on $f (markers=$after, expect 2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
changed=1
|
||||
fi
|
||||
|
||||
[ "$changed" = "1" ] && { echo "woc-www-patch: patched $f"; patched=1; }
|
||||
done
|
||||
|
||||
[ "$patched" = "1" ] || { echo "FATAL: no bundle patched" >&2; exit 1; }
|
||||
echo "woc-www-patch: done"
|
||||
@@ -125,6 +125,26 @@ export async function ensureRunning(inst: Instance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
|
||||
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
|
||||
export async function upgradeInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await pullImage();
|
||||
} catch (e: any) {
|
||||
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
|
||||
}
|
||||
await runInstance(inst);
|
||||
}
|
||||
|
||||
// 停止实例容器(保留容器与数据卷,可再启动)。
|
||||
export async function stopInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
|
||||
} catch {
|
||||
/* 已停止或不存在 */
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeInstance(inst: Instance, purgeVolume: boolean): Promise<void> {
|
||||
try {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
@@ -292,6 +312,22 @@ export async function downloadFromInstance(inst: Instance, name: string): Promis
|
||||
return tar.subarray(512, 512 + size);
|
||||
}
|
||||
|
||||
// 拉取实例容器日志(末尾 N 行),供前端"查看/导出日志"排错。
|
||||
export async function instanceLogs(inst: Instance, tail = 600): Promise<string> {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
const buf = (await c.logs({ stdout: true, stderr: true, tail, timestamps: true })) as unknown as Buffer;
|
||||
// docker 非 TTY 日志为多路复用流:每帧 8 字节头([stream,0,0,0,size BE])+ 负载;解出纯文本。
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i + 8 <= buf.length) {
|
||||
const size = buf.readUInt32BE(i + 4);
|
||||
if (size < 0 || i + 8 + size > buf.length) break;
|
||||
out += buf.subarray(i + 8, i + 8 + size).toString('utf8');
|
||||
i += 8 + size;
|
||||
}
|
||||
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
|
||||
}
|
||||
|
||||
// 实例容器名(供反代构造 target)。
|
||||
export function instanceTarget(inst: Instance): string {
|
||||
return `http://${inst.containerName}:3000`;
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
ensureNetwork,
|
||||
ensureRunning,
|
||||
runInstance,
|
||||
stopInstance,
|
||||
upgradeInstance,
|
||||
removeInstance as removeInstanceContainer,
|
||||
instanceRuntime,
|
||||
triggerWechat,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
listInstanceFiles,
|
||||
downloadFromInstance,
|
||||
deleteInstanceFile,
|
||||
instanceLogs,
|
||||
} from './docker.js';
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
|
||||
@@ -264,7 +267,7 @@ app.post('/api/admin/instances/:id/rename', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 启动/重启实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
|
||||
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
|
||||
app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
@@ -277,6 +280,46 @@ app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 停止实例容器(仅管理员):保留容器与数据卷。
|
||||
app.post('/api/admin/instances/:id/stop', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 重启实例容器(仅管理员):按当前本地镜像重建(保留数据卷 → 登录态不丢;快速,不联网拉取)。
|
||||
app.post('/api/admin/instances/:id/restart', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await runInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。用于把旧实例更新到新版镜像
|
||||
// (如修复"最小化丢失"等),类似「更新微信」但更新的是实例容器镜像本身。
|
||||
app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await upgradeInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 实例侧:设置该实例可被哪些账户访问
|
||||
app.post('/api/admin/instances/:id/users', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
@@ -395,6 +438,21 @@ app.post('/api/instances/:id/control/take', async (req, reply) => {
|
||||
return { mine: true, holder: u.username };
|
||||
});
|
||||
|
||||
// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。
|
||||
app.get('/api/admin/instances/:id/logs', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
const text = await instanceLogs(inst);
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send(text || '(暂无日志)');
|
||||
} catch (e: any) {
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
return reply.send('获取日志失败:' + (e?.message || e));
|
||||
}
|
||||
});
|
||||
|
||||
// 该实例的微信安装状态(有访问权限即可看)
|
||||
app.get('/api/instances/:id/wechat/status', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
|
||||
@@ -90,6 +90,10 @@ export const api = {
|
||||
instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }),
|
||||
instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }),
|
||||
instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }),
|
||||
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
|
||||
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
|
||||
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
|
||||
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
|
||||
|
||||
// 文件中转
|
||||
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
|
||||
|
||||
@@ -27,7 +27,14 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
|
||||
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
|
||||
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
|
||||
const [starting, setStarting] = useState<Set<string>>(new Set());
|
||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||
const setAct = (id: string, label: string | null) =>
|
||||
setActing((a) => {
|
||||
const n = { ...a };
|
||||
if (label) n[id] = label;
|
||||
else delete n[id];
|
||||
return n;
|
||||
});
|
||||
|
||||
const subs = users.filter((u) => u.role !== 'admin');
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
@@ -72,7 +79,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
};
|
||||
|
||||
const start = async (inst: InstanceWithStatus) => {
|
||||
setStarting((s) => new Set(s).add(inst.id));
|
||||
setAct(inst.id, '启动中…');
|
||||
try {
|
||||
await api.instanceStart(inst.id);
|
||||
toast('实例已启动', 'ok');
|
||||
@@ -80,11 +87,22 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
} catch (e: any) {
|
||||
toast(e.message || '启动失败', 'error');
|
||||
} finally {
|
||||
setStarting((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(inst.id);
|
||||
return n;
|
||||
});
|
||||
setAct(inst.id, null);
|
||||
}
|
||||
};
|
||||
|
||||
const lifecycle = async (inst: InstanceWithStatus, kind: 'stop' | 'restart' | 'upgrade') => {
|
||||
const label = kind === 'stop' ? '停止中…' : kind === 'upgrade' ? '升级中…' : '重启中…';
|
||||
setAct(inst.id, label);
|
||||
if (kind === 'upgrade') toast('正在升级实例:拉取最新镜像并重建,可能需要几分钟,请勿离开…', 'info');
|
||||
try {
|
||||
await (kind === 'stop' ? api.instanceStop(inst.id) : kind === 'upgrade' ? api.instanceUpgrade(inst.id) : api.instanceRestart(inst.id));
|
||||
toast(kind === 'stop' ? '已停止' : kind === 'upgrade' ? '已升级到最新镜像并重启' : '已重启', 'ok');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '操作失败', 'error');
|
||||
} finally {
|
||||
setAct(inst.id, null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,10 +161,13 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
key={inst.id}
|
||||
inst={inst}
|
||||
userCount={usersForInstance(inst.id).length}
|
||||
starting={starting.has(inst.id)}
|
||||
acting={acting[inst.id]}
|
||||
onEnter={() => nav(`/i/${inst.id}`)}
|
||||
onTrigger={trigger}
|
||||
onStart={() => start(inst)}
|
||||
onStop={() => lifecycle(inst, 'stop')}
|
||||
onRestart={() => lifecycle(inst, 'restart')}
|
||||
onUpgrade={() => lifecycle(inst, 'upgrade')}
|
||||
onRename={() => setRenameInst(inst)}
|
||||
onAssign={() => setAssignInst(inst)}
|
||||
onDelete={() => setDeleteInst(inst)}
|
||||
@@ -428,20 +449,26 @@ function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
|
||||
function InstanceAdminCard({
|
||||
inst,
|
||||
userCount,
|
||||
starting,
|
||||
acting,
|
||||
onEnter,
|
||||
onTrigger,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
onUpgrade,
|
||||
onRename,
|
||||
onAssign,
|
||||
onDelete,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
userCount: number;
|
||||
starting?: boolean;
|
||||
acting?: string;
|
||||
onEnter: () => void;
|
||||
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onRestart: () => void;
|
||||
onUpgrade: () => void;
|
||||
onRename: () => void;
|
||||
onAssign: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -450,15 +477,18 @@ function InstanceAdminCard({
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
const installed = wx.installed && wx.phase !== 'downloading';
|
||||
const offline = inst.runtime !== 'running';
|
||||
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (installed) badge = { text: '在线', cls: 'tag-on' };
|
||||
else badge = { text: '待安装', cls: 'tag-warn' };
|
||||
|
||||
let sub: string;
|
||||
if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
if (acting) sub = acting;
|
||||
else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
|
||||
else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止';
|
||||
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
|
||||
@@ -471,48 +501,68 @@ function InstanceAdminCard({
|
||||
<span className={'tag ' + badge.cls}>{badge.text}</span>
|
||||
</div>
|
||||
<div className="inst-sub">
|
||||
{sub} · 可访问 {userCount} 人
|
||||
{sub}
|
||||
{!acting && ` · 可访问 ${userCount} 人`}
|
||||
</div>
|
||||
|
||||
{busy && (
|
||||
{working && (
|
||||
<div className="wx-progress">
|
||||
<div
|
||||
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
|
||||
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
|
||||
className={'wx-progress-bar' + (acting || wx.percent < 0 ? ' indeterminate' : '')}
|
||||
style={!acting && wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!busy && (
|
||||
<div className="inst-actions">
|
||||
{offline ? (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={starting} onClick={onStart}>
|
||||
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
|
||||
进入实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 进行中(升级/重启/停止/下载)时隐藏所有操作,避免重复点击 */}
|
||||
{!working && (
|
||||
<>
|
||||
<div className="inst-actions">
|
||||
{offline ? (
|
||||
<button className="btn btn-primary inst-act-wide" onClick={onStart}>
|
||||
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
|
||||
进入实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inst-admin-links">
|
||||
{!busy && !offline && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<div className="inst-admin-links">
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
|
||||
升级实例
|
||||
</button>
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onRestart}>
|
||||
重启
|
||||
</button>
|
||||
)}
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onStop}>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
|
||||
日志
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+163
-18
@@ -42,9 +42,12 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [files, setFiles] = useState<TFile[]>([]);
|
||||
const [showClip, setShowClip] = useState(false);
|
||||
const [clipText, setClipText] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
|
||||
const [vncNonce, setVncNonce] = useState(0);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
@@ -60,8 +63,21 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
setFrameLoaded(false);
|
||||
setShowFiles(false);
|
||||
setFiles([]);
|
||||
setShowClip(false);
|
||||
setClipText('');
|
||||
}, [id]);
|
||||
|
||||
// 实例未就绪(启动中 / 安装中 / 上下文状态未刷新)时,每 3s 拉取最新状态:
|
||||
// 就绪后自动进入桌面,无需手动刷新(修复"安装完进度 100% 仍提示无实例")。
|
||||
useEffect(() => {
|
||||
if (showVnc || !id) return;
|
||||
const t = window.setInterval(() => {
|
||||
if (!document.hidden) reload();
|
||||
}, 3000);
|
||||
return () => window.clearInterval(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showVnc, id]);
|
||||
|
||||
// 文件拖到窗口 → 弹出落区(覆盖 iframe 接住 drop)
|
||||
useEffect(() => {
|
||||
if (!showVnc) return;
|
||||
@@ -217,21 +233,85 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
};
|
||||
|
||||
// 打开/收起 KasmVNC 原生控制条(音频/麦克风/摄像头/剪贴板/全屏/键盘设置都在里面)。
|
||||
// noVNC 的 openControlbar 是给 #noVNC_control_bar 加 .noVNC_open(CSS 据此展开;手柄绑 mousedown,
|
||||
// 合成 click 触发不了),所以直接切换 bar 上的该 class 最可靠。iframe 与面板同源,可直接访问其 DOM。
|
||||
const openVncControls = () => {
|
||||
// 桌面加载后给 noVNC 原生控制条注入"实心可见"样式:原生背景近纯黑半透明,叠在深色/黑屏上看不见。
|
||||
// 注入后,用 KasmVNC 自带的左侧边缘手柄拉出控制条(音频/剪贴板/键盘/全屏等)时即可见。iframe 同源可直接访问。
|
||||
const injectVncStyle = () => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
const bar = doc?.getElementById('noVNC_control_bar');
|
||||
if (bar) {
|
||||
bar.classList.toggle('noVNC_open');
|
||||
focusFrame();
|
||||
if (!doc || doc.getElementById('woc-vnc-style')) return;
|
||||
const st = doc.createElement('style');
|
||||
st.id = 'woc-vnc-style';
|
||||
st.textContent =
|
||||
'#noVNC_control_bar_anchor{z-index:2147483647!important;}' +
|
||||
'#noVNC_control_bar{background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.55)!important;box-shadow:0 0 24px rgba(0,0,0,.55)!important;}' +
|
||||
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}';
|
||||
(doc.head || doc.documentElement).appendChild(st);
|
||||
} catch {
|
||||
/* 同源正常不会到这 */
|
||||
}
|
||||
};
|
||||
|
||||
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
|
||||
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
|
||||
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
|
||||
// 在微信输入框按 Ctrl+V 即可粘贴。
|
||||
const pushClipboardToRemote = (text: string): boolean => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
|
||||
if (!doc || !ta) return false;
|
||||
ta.value = text;
|
||||
ta.dispatchEvent(new (frameRef.current!.contentWindow as any).Event('change', { bubbles: true }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendClip = () => {
|
||||
const t = clipText;
|
||||
if (!t) {
|
||||
toast('请先输入要发送的文本', 'error');
|
||||
return;
|
||||
}
|
||||
if (pushClipboardToRemote(t)) {
|
||||
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
|
||||
} else {
|
||||
toast('发送失败:桌面尚未连接', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 读取容器(微信侧)当前剪贴板内容到本框,便于把容器内复制的文字带回本地
|
||||
const pullClipboardFromRemote = () => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
|
||||
if (ta) {
|
||||
setClipText(ta.value || '');
|
||||
toast('已读取容器剪贴板', 'ok');
|
||||
} else {
|
||||
toast('控制条尚未就绪,请等画面出现后重试', 'error');
|
||||
toast('读取失败:桌面尚未连接', 'error');
|
||||
}
|
||||
} catch {
|
||||
toast('无法打开 VNC 控制条', 'error');
|
||||
toast('读取失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const restartInstance = async () => {
|
||||
const ok = await confirm({
|
||||
title: '重启该实例?',
|
||||
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
|
||||
confirmText: '重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.instanceRestart(id);
|
||||
toast('已重启,正在重连…', 'ok');
|
||||
setFrameLoaded(false);
|
||||
setVncNonce((n) => n + 1); // 强制 iframe 重挂、重连
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '重启失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -270,9 +350,6 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<span className="ws-title">{title}</span>
|
||||
{showVnc && (
|
||||
<>
|
||||
<button className="ws-action" title="VNC 控制:音频 / 麦克风 / 摄像头 / 剪贴板 / 全屏 / 键盘" onClick={openVncControls}>
|
||||
控制
|
||||
</button>
|
||||
<button
|
||||
className="ws-action"
|
||||
title="文件传输"
|
||||
@@ -283,6 +360,18 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
>
|
||||
文件
|
||||
</button>
|
||||
<button
|
||||
className="ws-action"
|
||||
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
|
||||
onClick={() => setShowClip((v) => !v)}
|
||||
>
|
||||
剪贴板
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
|
||||
重启
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
@@ -312,25 +401,51 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员启动该实例</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
|
||||
查看日志
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : ['downloading', 'extracting', 'installing'].includes(inst.wechat.phase) ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="spinner" />
|
||||
<div className="iv-notice-title">微信安装中…</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.message || '请稍候'}
|
||||
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''} ——完成后自动进入,无需刷新
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !installed ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">微信尚未安装</div>
|
||||
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
|
||||
<div className="iv-notice-sub">
|
||||
{inst.wechat.phase === 'error'
|
||||
? inst.wechat.message || '安装失败,可在「管理」重试'
|
||||
: '该实例容器已就绪,但尚未安装微信'}
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
|
||||
去「管理」下载安装
|
||||
去「管理」{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员在「管理」中下载安装微信</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
|
||||
查看日志
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="iv-stage">
|
||||
<iframe
|
||||
key={id}
|
||||
key={`${id}:${vncNonce}`}
|
||||
ref={frameRef}
|
||||
className="iv-frame"
|
||||
src={desktopUrl(id)}
|
||||
@@ -338,7 +453,10 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => {
|
||||
setFrameLoaded(true);
|
||||
setTimeout(focusFrame, 400); // 加载完把键盘焦点交给 VNC(宿主机输入法)
|
||||
setTimeout(() => {
|
||||
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
|
||||
injectVncStyle(); // 让原生控制条在深色背景下可见
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -347,7 +465,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="spinner" />
|
||||
<div className="iv-loading-text">正在连接桌面…</div>
|
||||
<div className="iv-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;语音/视频/剪贴板点右上角「控制」开启</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;音频/剪贴板等在画面左侧边缘的工具条里</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="iv-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
)}
|
||||
@@ -418,6 +536,33 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showClip && (
|
||||
<div className="iv-files">
|
||||
<div className="files-head">
|
||||
<span>文本剪贴板</span>
|
||||
<button className="btn-text" onClick={() => setShowClip(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="clip-area"
|
||||
value={clipText}
|
||||
onChange={(e) => setClipText(e.target.value)}
|
||||
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
|
||||
rows={5}
|
||||
/>
|
||||
<button className="btn btn-primary files-upload" onClick={sendClip}>
|
||||
发送到微信(容器剪贴板)
|
||||
</button>
|
||||
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
|
||||
↓ 读取容器剪贴板到此框
|
||||
</button>
|
||||
<div className="files-hint">
|
||||
局域网 http 访问时浏览器会禁用系统级剪贴板同步,故用此框中转:文本→容器剪贴板,再在微信里 Ctrl+V。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -713,6 +713,25 @@ button {
|
||||
height: 42px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.clip-area {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
background: var(--mf-trough, #edeef1);
|
||||
color: var(--text, #1a1d24);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.files-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
@@ -1382,9 +1401,9 @@ button {
|
||||
border: none;
|
||||
background: var(--surface);
|
||||
color: var(--wx-green-dark);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 7px 14px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--crease);
|
||||
|
||||
@@ -29,6 +29,10 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// 桌面反代与 API 不能被 SW 拦截
|
||||
navigateFallbackDenylist: [/^\/desktop/, /^\/api/],
|
||||
// 新版本立即接管 + 清理旧缓存,避免更新后仍跑旧代码(硬刷新绕不过 SW)
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user