mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
15 Commits
+6
-5
@@ -8,15 +8,16 @@ WOC_PASSWORD=wechat
|
||||
# 同时作用于面板镜像和新建微信实例所用镜像。
|
||||
WOC_VERSION=latest
|
||||
|
||||
# 镜像源前缀(registry + 命名空间)。本项目镜像同步发布到 GHCR 和 Docker Hub。
|
||||
# 镜像源前缀(registry + 命名空间)。本项目镜像同步发布到 Docker Hub 和 GHCR。
|
||||
#
|
||||
# 默认走 GHCR;如果 ghcr.io 拉不动,按需切到其它源:
|
||||
# WOC_IMAGE_PREFIX=docker.io/gloridust # Docker Hub(国际网络通用、无需 PAT 即可拉公开镜像)
|
||||
# 默认走 Docker Hub:国内/国际通用、无需登录即可拉公开镜像,飞牛 OS(fnOS) 等 NAS 还内置拉取加速。
|
||||
# 拉不动时按需切到备用源:
|
||||
# WOC_IMAGE_PREFIX=ghcr.io/gloridust # GitHub Container Registry(备用)
|
||||
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(国内较稳)
|
||||
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间 # 自己的阿里云 ACR / 腾讯 TCR / 华为 SWR
|
||||
#
|
||||
# 反代会把 ghcr.io/gloridust/xxx 映射为 <反代>/gloridust/xxx。
|
||||
WOC_IMAGE_PREFIX=ghcr.io/gloridust
|
||||
# 反代会把 docker.io/gloridust/xxx(或 ghcr.io/gloridust/xxx)映射为 <反代>/gloridust/xxx。
|
||||
WOC_IMAGE_PREFIX=docker.io/gloridust
|
||||
|
||||
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
|
||||
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
|
||||
|
||||
Binary file not shown.
@@ -52,7 +52,7 @@
|
||||
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
|
||||
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
|
||||
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
|
||||
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
|
||||
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(Docker Hub + GHCR,GitHub Actions 自动发布)。
|
||||
|
||||
---
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。不熟悉 Docker?先读 [运行原理与 Docker 指南](doc/运行原理.md)。
|
||||
|
||||
`docker-compose.yml` 引用的是 GHCR 上的镜像 `ghcr.io/gloridust/{woc-panel,wechat-on-cloud}`。
|
||||
`docker-compose.yml` 默认引用 **Docker Hub** 上的镜像 `docker.io/gloridust/{woc-panel,wechat-on-cloud}`(同时也发布到 GHCR 作为备用源)。
|
||||
**这两个镜像需先存在**——要么官方已发布(你能直接拉取),要么你在本地自行构建。二选一:
|
||||
|
||||
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
|
||||
@@ -83,29 +83,48 @@ git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
|
||||
cd WechatOnCloud
|
||||
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
|
||||
./scripts/build-local.sh # 构建面板 + 微信实例镜像,打成 compose 用的同名标签
|
||||
docker compose up -d # compose 默认优先用本地镜像,不会再去 GHCR
|
||||
docker compose up -d # compose 默认优先用本地镜像,不会再去远端拉
|
||||
```
|
||||
|
||||
**方式 B · 拉取官方镜像(已发布到 GHCR 后)**
|
||||
**方式 B · 拉取官方镜像(推荐,无需 clone 整个仓库)**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
|
||||
cd WechatOnCloud
|
||||
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
|
||||
docker compose up -d # 直接从 GHCR 拉取
|
||||
```
|
||||
部署**只需要 `docker-compose.yml` 这一个文件**——它用 `image:` 直接拉官方镜像,面板数据放在该文件旁自动创建的 `./data-panel` 目录,不依赖仓库里的其它文件。
|
||||
|
||||
> 报错 `error from registry: denied`?说明 GHCR 上还没有该镜像(或包是私有的)。用方式 A 本地构建,或见 [发布到 GHCR](doc/发布到GHCR.md)。
|
||||
- **命令行**:丢进一个空目录拉起即可
|
||||
```bash
|
||||
mkdir woc && cd woc
|
||||
curl -fsSL https://raw.githubusercontent.com/Gloridust/WechatOnCloud/main/docker-compose.yml -o docker-compose.yml
|
||||
docker compose up -d # 默认从 Docker Hub 拉取(公开、amd64/arm64 多架构)
|
||||
```
|
||||
> `raw.githubusercontent.com` 拉不动时,在 GitHub 网页打开根目录的 [docker-compose.yml](docker-compose.yml),复制内容自己建个同名文件即可。
|
||||
|
||||
- **飞牛 OS(fnOS)/ 群晖 等 NAS**:在 **Docker → Compose 一键部署** 界面,把根目录 [docker-compose.yml](docker-compose.yml) 的内容**直接粘贴进去**即可部署,无需命令行、无需 clone。
|
||||
|
||||
> **改配置(强烈建议至少改密码)**:默认管理员 **admin / wechat**。登录后在「修改密码」里改;或部署前在 `docker-compose.yml` 旁放一个 `.env`(从 [.env.example](.env.example) 下载改名),又或在 NAS 的 Compose 环境变量里填 `WOC_PASSWORD`、`WOC_HTTP_PORT`、`WOC_IMAGE_PREFIX` 等(全部可配置项见 [.env.example](.env.example))。
|
||||
|
||||
> **为什么默认 Docker Hub**:国内 / 国际通用、免登录即可拉公开镜像,**飞牛 OS(fnOS)等 NAS 系统还内置了 Docker Hub 拉取加速**,通常比 GHCR 更快更稳。`ghcr.io` 拉不动时设 `WOC_IMAGE_PREFIX` 切源:
|
||||
>
|
||||
> ```bash
|
||||
> WOC_IMAGE_PREFIX=ghcr.io/gloridust # 备用:GitHub Container Registry
|
||||
> WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 备用:南京大学反代 ghcr.io(国内较稳)
|
||||
> ```
|
||||
>
|
||||
> 更多源(自建阿里云 ACR / 腾讯 TCR 等)见 [.env.example](.env.example)。报错 `denied`?说明该源上还没有镜像(或为私有),换个源或用方式 A 本地构建。
|
||||
|
||||
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`:
|
||||
|
||||
1. 用 `.env` 里设置的管理员账号(默认 **admin / wechat**)登录面板;
|
||||
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从 GHCR 拉取);
|
||||
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从镜像源拉取);
|
||||
3. 进入该实例,点「**下载并安装**」微信(约 190~210MB,进度条实时显示,仅管理员可操作);
|
||||
4. 装好后点「进入电脑版微信」→ 浏览器里出现微信窗口,手机扫码登录即可。
|
||||
|
||||
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
|
||||
|
||||
> **🛠️ NAS / 飞牛(fnOS) 用户必看——首次新建实例若卡住报 `创建容器失败:… registry-1.docker.io … timeout`**:
|
||||
> 这是 Docker **守护进程**拉取微信镜像超时。NAS 自带的「Docker Hub 加速」一般只作用于你在 NAS 界面**手动拉镜像**,不覆盖面板(经 docker.sock)触发的拉取,于是直连 `docker.io` 超时。
|
||||
> **最省事的解法**:先在 NAS 的 **Docker → 镜像 → 拉取** 里手动拉一次 `gloridust/wechat-on-cloud:latest`(和你拉 `woc-panel` 同样的方式)。镜像到本地后,面板新建实例会直接复用、不再联网拉取 → 立即成功。
|
||||
> 想一劳永逸:给 Docker 守护进程配「镜像加速器」(`/etc/docker/daemon.json` 的 `registry-mirrors`,改完重启 Docker),或把 `WOC_IMAGE_PREFIX` 换成国内可达源(如 `ghcr.nju.edu.cn/gloridust`)后重建面板。
|
||||
|
||||
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本/账号见 `.env`(可配置项见 [.env.example](.env.example))。镜像会按 CPU 架构自动适配([详见文档](doc/运行原理.md#架构自动适配))。
|
||||
|
||||
### 面板能做什么
|
||||
@@ -175,7 +194,7 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
- [x] 自研面板:cookie 鉴权 + 反代 + 子账号管理 + PWA(KasmVNC 凭据不下发前端)
|
||||
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
|
||||
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
|
||||
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
|
||||
- [x] 预构建多架构镜像发布到 Docker Hub / GHCR + GitHub Actions 自动化
|
||||
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志导出
|
||||
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
|
||||
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
|
||||
|
||||
+4
-2
@@ -81,11 +81,11 @@ docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
|
||||
## Telegram 发布通知(可选,免服务器)
|
||||
|
||||
仓库内置 [.github/workflows/telegram-notify.yml](../.github/workflows/telegram-notify.yml):**新版本发布** / **新 issue** 时,通过 Telegram Bot 推送到群组。跑在 GitHub Actions 上,无需服务器;未配置则自动跳过。
|
||||
仓库内置 [.github/workflows/telegram-notify.yml](../.github/workflows/telegram-notify.yml) + [.github/scripts/tg-notify.mjs](../.github/scripts/tg-notify.mjs):**新版本发布** / **新 issue** 时,把内容(GitHub Markdown 渲染为 Telegram HTML)推送到群组。跑在 GitHub Actions 上,无需服务器;未配置则自动跳过。**发布通知还会自动置顶,并取消上一个 release 的置顶**(群里始终只置顶最新版本)。
|
||||
|
||||
一次性配置:
|
||||
|
||||
1. 把机器人(如 `@WechatOnCloudBot`)拉进目标 Telegram 群组;需要发言权限时设为管理员。
|
||||
1. 把机器人(如 `@WechatOnCloudBot`)拉进目标 Telegram 群组。**要让"自动置顶"生效,需把机器人设为群管理员并开启「置顶消息」权限**(缺权限时通知照发、仅置顶被跳过,不影响主流程)。
|
||||
2. 取群组 chat id:bot 进群后在群里发条消息,浏览器打开 `https://api.telegram.org/bot<BOT_TOKEN>/getUpdates`,找 `result[].message.chat.id`(群组通常是 `-100` 开头的负数)。
|
||||
3. 仓库 **Settings → Secrets and variables → Actions**:
|
||||
- **Variables** 标签 → `TELEGRAM_CHAT_ID` = 上面的 chat id;
|
||||
@@ -93,6 +93,8 @@ docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
|
||||
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
|
||||
|
||||
> **自动置顶原理**:发完新消息后,用 `getChat` 读出群当前置顶消息(即上一个 release),置顶新消息、再取消旧的——无需任何持久化存储。issue 通知不置顶。手动 **Run workflow** 也会置顶(发的是最新 release),可用来测试。
|
||||
|
||||
---
|
||||
|
||||
## Telegram 命令机器人(可选,免服务器,轮询版)
|
||||
|
||||
+6
-6
@@ -1,19 +1,19 @@
|
||||
# WechatOnCloud —— 面板为唯一服务;微信实例由面板按需动态创建(docker run)。
|
||||
# 面板挂载 docker.sock 来创建/启动/删除微信实例容器,并反向代理到它们的 KasmVNC。
|
||||
# 镜像全部从 GHCR 拉取,无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
|
||||
# 镜像默认从 Docker Hub 拉取(GHCR 备用),无需本地构建。要改配置:复制 .env.example 为 .env 后修改。
|
||||
services:
|
||||
panel:
|
||||
# 镜像源前缀默认 GHCR;大陆网络拉不动时改 .env 的 WOC_IMAGE_PREFIX 切到国内反代(见 .env.example)。
|
||||
image: ${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/woc-panel:${WOC_VERSION:-latest}
|
||||
# 镜像源前缀默认 Docker Hub(飞牛等 NAS 有加速);拉不动时改 .env 的 WOC_IMAGE_PREFIX 切到 GHCR/国内反代(见 .env.example)。
|
||||
image: ${WOC_IMAGE_PREFIX:-docker.io/gloridust}/woc-panel:${WOC_VERSION:-latest}
|
||||
container_name: woc-panel
|
||||
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去 GHCR 拉。
|
||||
# pull_policy 用默认(missing):本地已有同名镜像就直接用,没有才去镜像源拉。
|
||||
# 这样「发布前本地自构建」与「线上拉取」都能用同一份 compose。
|
||||
# 想强制更新到 GHCR 最新版:docker compose pull && docker compose up -d
|
||||
# 想强制更新到最新版:docker compose pull && docker compose up -d
|
||||
|
||||
environment:
|
||||
- PORT=8080
|
||||
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配);前缀同样跟随 WOC_IMAGE_PREFIX。
|
||||
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-ghcr.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
|
||||
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-docker.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
|
||||
# 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行)
|
||||
- PUID=${WOC_PUID:-1000}
|
||||
- PGID=${WOC_PGID:-1000}
|
||||
|
||||
+5
-5
@@ -62,24 +62,24 @@ ENV LANG=zh_CN.UTF-8 \
|
||||
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
|
||||
# 注意:实际加载的是 webpack 产物 dist/main.bundle.js(app/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
|
||||
COPY woc-www-patch.sh woc-ime.pl /woc/
|
||||
RUN chmod +x /woc/woc-www-patch.sh && /woc/woc-www-patch.sh
|
||||
RUN chmod 755 /woc/woc-www-patch.sh && chmod 644 /woc/woc-ime.pl && /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
|
||||
RUN chmod 755 /woc/wechat-ctl.sh
|
||||
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
||||
COPY autostart /defaults/autostart
|
||||
RUN chmod +x /defaults/autostart
|
||||
RUN chmod 755 /defaults/autostart
|
||||
|
||||
# 启动钩子(00):给每个实例唯一且持久的 machine-id,避免所有实例共用镜像里烤死的同一个,
|
||||
# 触发腾讯"设备农场"风控导致登录即被强制退出。须在 autostart(拉起微信)之前执行,故用 00 前缀。
|
||||
COPY woc-identity.sh /custom-cont-init.d/00-woc-identity
|
||||
RUN chmod +x /custom-cont-init.d/00-woc-identity
|
||||
RUN chmod 755 /custom-cont-init.d/00-woc-identity
|
||||
|
||||
# 启动钩子(01):每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
|
||||
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||
RUN chmod +x /custom-cont-init.d/01-woc-autostart
|
||||
RUN chmod 755 /custom-cont-init.d/01-woc-autostart
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
EXPOSE 3000 3001
|
||||
|
||||
@@ -576,12 +576,30 @@ export async function typeInInstance(inst: Instance, text: string): Promise<void
|
||||
'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`,
|
||||
// xclip -i 会 daemon 化常驻持有剪贴板选区,并继承 exec 的 stdout/stderr;不重定向的话 docker exec
|
||||
// 要等这俩 fd 关闭,实测每次卡 ~2s。重定向到 /dev/null 后台后,整条链路从 ~2.1s 降到 ~0.08s。
|
||||
`echo '${b64}' | base64 -d | xclip -selection clipboard -i >/dev/null 2>&1`,
|
||||
'xdotool key --clearmodifiers ctrl+v',
|
||||
].join('; ');
|
||||
await execCapture(inst, ['bash', '-c', cmd]);
|
||||
}
|
||||
|
||||
// 通过 xdotool 在实例容器内模拟一次按键(如 Return / BackSpace)。
|
||||
// 用于「无感输入」模式:中文经 xclip 转发期间,把被截下的回车/退格按序送出,保证顺序、避免抢跑。
|
||||
// key 仅允许字母与下划线(xdotool keysym 名),杜绝注入。
|
||||
export async function keyInInstance(inst: Instance, key: string): Promise<void> {
|
||||
if (!/^[A-Za-z_]{1,20}$/.test(key)) throw new Error('按键名不合法');
|
||||
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 xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
|
||||
`xdotool key --clearmodifiers ${key}`,
|
||||
].join('; ');
|
||||
await execCapture(inst, ['bash', '-c', cmd]);
|
||||
}
|
||||
|
||||
// ---------- 数据卷管理(仅管理员;路由层用 requireAdmin 限制) ----------
|
||||
// 数据卷 = 容器内 /config 持久卷,含微信全部数据(登录态、加密聊天库等)。提供浏览/上传/解压/下载/
|
||||
// 改名/移动/删除 + 整卷备份/恢复。主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
deleteInstanceFile,
|
||||
instanceLogs,
|
||||
typeInInstance,
|
||||
keyInInstance,
|
||||
listOrphanVolumes,
|
||||
removeVolume,
|
||||
listOrphanContainers,
|
||||
@@ -632,6 +633,22 @@ app.post('/api/instances/:id/type', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 模拟单个按键(无感输入模式下按序送出被截下的回车/退格,保证与中文转发的顺序)
|
||||
app.post('/api/instances/:id/key', 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 { key } = (req.body as any) ?? {};
|
||||
if (!key || typeof key !== 'string') return reply.code(400).send({ error: '按键名为空' });
|
||||
try {
|
||||
await keyInInstance(findInstance(id)!, key);
|
||||
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;
|
||||
|
||||
@@ -182,4 +182,5 @@ export const api = {
|
||||
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 }) }),
|
||||
keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }),
|
||||
};
|
||||
|
||||
@@ -887,7 +887,17 @@ function InstanceAdminCard({
|
||||
const installed = wx.installed && wx.phase !== 'downloading';
|
||||
const offline = inst.runtime !== 'running';
|
||||
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
|
||||
const [menuOpen, setMenuOpen] = useState(false); // 「管理」折叠菜单是否展开
|
||||
const [menuOpen, setMenuOpen] = useState(false); // 「管理」菜单是否展开(悬浮层,不占文档流)
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
// 悬浮下拉:点击菜单外部时关闭
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocDown);
|
||||
return () => document.removeEventListener('mousedown', onDocDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
@@ -905,7 +915,7 @@ function InstanceAdminCard({
|
||||
else sub = '微信尚未安装';
|
||||
|
||||
return (
|
||||
<div className="inst-card">
|
||||
<div className={'inst-card' + (menuOpen ? ' open-menu' : '')}>
|
||||
<div className="inst-head">
|
||||
<span className="inst-name">{inst.name}</span>
|
||||
<span className={'tag ' + badge.cls}>{badge.text}</span>
|
||||
@@ -939,13 +949,14 @@ function InstanceAdminCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className={'inst-menu-toggle' + (menuOpen ? ' open' : '')} onClick={() => setMenuOpen((v) => !v)}>
|
||||
<span>管理</span>
|
||||
<span className="inst-menu-caret">{CaretIcon}</span>
|
||||
</button>
|
||||
<div className="inst-menu-wrap" ref={menuRef}>
|
||||
<button className={'inst-menu-toggle' + (menuOpen ? ' open' : '')} onClick={() => setMenuOpen((v) => !v)}>
|
||||
<span>管理</span>
|
||||
<span className="inst-menu-caret">{CaretIcon}</span>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="inst-menu">
|
||||
{menuOpen && (
|
||||
<div className="inst-menu" onClick={() => setMenuOpen(false)}>
|
||||
<div className="inst-menu-group">
|
||||
<div className="inst-menu-label">运维</div>
|
||||
<div className="inst-menu-items">
|
||||
@@ -997,7 +1008,8 @@ function InstanceAdminCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+118
-16
@@ -14,6 +14,76 @@ function desktopUrl(id: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// 「无感输入」钩子:装进同源 iframe,让用户直接在微信里打中文。
|
||||
// - compositionend(中文提交)→ 经 xclip+xdotool 转发(绕开 VNC keysym 容量上限)。
|
||||
// - 转发未完成期间(队列活跃),把后续可见字符 + 回车/退格也串进同一队列按序送出 →
|
||||
// 彻底消除"中文走异步、数字走 keysym 抢跑"导致的"你好123→23"丢字。
|
||||
// - 队列空闲时不干预:英文/数字仍走原生 keysym,零延迟。
|
||||
// 返回清理函数(切回转发模式 / 重连 / 卸载时移除监听)。
|
||||
function installSeamlessIme(win: Window, doc: Document, instId: string): () => void {
|
||||
type Job = { kind: 'text'; data: string } | { kind: 'key'; data: string };
|
||||
const queue: Job[] = [];
|
||||
let draining = false;
|
||||
const active = () => draining || queue.length > 0;
|
||||
|
||||
const drain = async () => {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
const job = queue[0];
|
||||
try {
|
||||
if (job.kind === 'text') await api.typeInInstance(instId, job.data);
|
||||
else await api.keyInInstance(instId, job.data);
|
||||
} catch {
|
||||
/* 单条失败丢弃,继续后续,避免卡住队列 */
|
||||
}
|
||||
queue.shift();
|
||||
}
|
||||
draining = false;
|
||||
};
|
||||
|
||||
const onCompositionEnd = (e: Event) => {
|
||||
const txt = (e as CompositionEvent).data;
|
||||
if (!txt) return;
|
||||
queue.push({ kind: 'text', data: txt });
|
||||
drain();
|
||||
};
|
||||
|
||||
// 捕获阶段(iframe window 最外层)抢先拦截,赶在 noVNC 之前 → stopImmediatePropagation 阻止它发 keysym。
|
||||
// 关键:队列活跃(有中文正在转发)时,只接管【数字】和回车/退格——它们不参与拼音合成、且是原"混数字丢字"的祸首;
|
||||
// 字母绝不接管,否则会把下一个词的拼音首字母(如"呀"的 y)当成字面字符抢走,造成"你好y呀"。字母交给输入法合成。
|
||||
const onKeyDownCapture = (ev: Event) => {
|
||||
const e = ev as KeyboardEvent;
|
||||
if (e.isComposing) return; // 拼音合成中,交给输入法(候选数字选词也在此放行)
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return; // 快捷键放行
|
||||
if (!active()) return; // 没有中文在转发 → 不接管(英文/数字走原生 keysym,零延迟)
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
queue.push({ kind: 'text', data: e.key });
|
||||
drain();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
queue.push({ kind: 'key', data: 'Return' });
|
||||
drain();
|
||||
} else if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
queue.push({ kind: 'key', data: 'BackSpace' });
|
||||
drain();
|
||||
}
|
||||
// 其它非可见键(方向键/功能键等)放行
|
||||
};
|
||||
|
||||
doc.addEventListener('compositionend', onCompositionEnd, true);
|
||||
win.addEventListener('keydown', onKeyDownCapture, true);
|
||||
return () => {
|
||||
doc.removeEventListener('compositionend', onCompositionEnd, true);
|
||||
win.removeEventListener('keydown', onKeyDownCapture, true);
|
||||
};
|
||||
}
|
||||
|
||||
interface TFile {
|
||||
name: string;
|
||||
size: number;
|
||||
@@ -46,8 +116,27 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const [files, setFiles] = useState<TFile[]>([]);
|
||||
const [showClip, setShowClip] = useState(false);
|
||||
const [clipText, setClipText] = useState('');
|
||||
// 中文输入条:面板里的真实 textarea(原生客户端输入法 100% 可用),回车经 xclip+xdotool 粘进微信。
|
||||
const [imeBar, setImeBar] = useState(true); // 默认开(直接在 VNC 里打中文不稳,给一个可靠通道)
|
||||
// 中文输入模式:'forward'=底部输入条转发(默认,最稳);'seamless'=无感(直接在微信里打,提交后转发)。
|
||||
const [inputMode, setInputMode] = useState<'forward' | 'seamless'>(() => {
|
||||
try {
|
||||
return window.localStorage.getItem('woc_input_mode') === 'seamless' ? 'seamless' : 'forward';
|
||||
} catch {
|
||||
return 'forward';
|
||||
}
|
||||
});
|
||||
const setMode = (m: 'forward' | 'seamless') => {
|
||||
try {
|
||||
window.localStorage.setItem('woc_input_mode', m);
|
||||
// 同步写好 enable_ime,重载后新页面的 noVNC 连接时即读到
|
||||
window.localStorage.setItem('enable_ime', m === 'seamless' ? 'true' : 'false');
|
||||
} catch {
|
||||
/* 隐私模式禁用 localStorage:忽略 */
|
||||
}
|
||||
// 整页重载切换:先卸载旧页面(彻底关闭旧 VNC ws),再以新 enable_ime 干净重连。
|
||||
// 不能用页内 bump vncNonce 重挂 iframe——那会让新旧两条 ws 短暂并存,概率性把实例的 Xvnc 卡死
|
||||
//(需重启容器才恢复、面板重启无效),且新连接常读不到新模式(仍是英文)。整页重载是实测唯一可靠的方式。
|
||||
window.location.reload();
|
||||
};
|
||||
const [imeText, setImeText] = useState('');
|
||||
const [imeSending, setImeSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -203,18 +292,27 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
};
|
||||
}, [showVnc, id, frameLoaded]);
|
||||
|
||||
// 每次进入/重连桌面前,强制把 KasmVNC 的 enable_ime 设为【关】。
|
||||
// 原因:开启 IME 模式后,noVNC 用隐藏 textarea + 合成事件还原中文,需要前端拦截/差分,环环相扣极脆,
|
||||
// 实测会"中文混数字时丢最后两个汉字+首个数字"等损坏。中文输入改由底部「中文输入条」可靠承担
|
||||
// (面板真实 textarea 原生输入法 → xclip+xdotool 粘贴),故 VNC 直接打字回归纯 keysym:英文/数字/
|
||||
// 标点都正常、不再损坏;中文直接打不进(请用输入条)。iframe 同源共享 localStorage,加载前设好即生效。
|
||||
// 进入/重连桌面前,按输入模式设 KasmVNC 的 enable_ime(iframe 同源共享 localStorage,加载前设好即生效)。
|
||||
// 无感(seamless):enable_ime=true,启用 noVNC 合成 textarea;中文 keysym 已被容器补丁抑制,
|
||||
// 成品由「无感输入」钩子经 xdotool 转发(见 installSeamlessIme)。
|
||||
// 转发(forward):enable_ime=false,VNC 直接打字纯 keysym(英文/数字正常);中文走底部输入条。
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem('enable_ime', 'false');
|
||||
window.localStorage.setItem('enable_ime', inputMode === 'seamless' ? 'true' : 'false');
|
||||
} catch {
|
||||
/* 隐私模式等禁用 localStorage:忽略 */
|
||||
}
|
||||
}, [id, vncNonce]);
|
||||
}, [id, vncNonce, inputMode]);
|
||||
|
||||
// 无感模式:往同源 iframe 装「中文转发 + 有序队列」钩子;切回转发/重连/卸载时自动移除。
|
||||
useEffect(() => {
|
||||
if (inputMode !== 'seamless' || !showVnc || !frameLoaded || !id) return;
|
||||
const win = frameRef.current?.contentWindow;
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!win || !doc) return;
|
||||
const cleanup = installSeamlessIme(win, doc, id);
|
||||
return cleanup;
|
||||
}, [inputMode, showVnc, frameLoaded, id, vncNonce]);
|
||||
|
||||
// 音频/麦克风桥接:实例就绪即自动连接 kclient 的音频流(扬声器恒开,无需手动找工具条);
|
||||
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
|
||||
@@ -449,11 +547,15 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
文件
|
||||
</button>
|
||||
<button
|
||||
className={'ws-action' + (imeBar ? ' on' : '')}
|
||||
title="底部中文输入条:用本机输入法打中文,回车送进微信(最可靠)"
|
||||
onClick={() => setImeBar((v) => !v)}
|
||||
className={'ws-action' + (inputMode === 'seamless' ? ' on' : '')}
|
||||
title={
|
||||
inputMode === 'seamless'
|
||||
? '无感输入:直接在微信输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
|
||||
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在微信里打)'
|
||||
}
|
||||
onClick={() => setMode(inputMode === 'seamless' ? 'forward' : 'seamless')}
|
||||
>
|
||||
中文输入
|
||||
输入:{inputMode === 'seamless' ? '无感' : '转发'}
|
||||
</button>
|
||||
<button
|
||||
className="ws-action"
|
||||
@@ -552,8 +654,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
setTimeout(() => {
|
||||
focusFrame(); // 加载完把键盘焦点交给 VNC
|
||||
injectVncStyle(); // 让原生控制条在深色背景下可见
|
||||
// 注意:不再调用 patchVncIme —— enable_ime 已关,直接打字走纯 keysym(英文/数字正常);
|
||||
// 中文由底部「中文输入条」承担。那套合成拦截既脆弱又会损坏混合输入,已弃用。
|
||||
// 无感输入模式的键盘钩子由单独的 effect(依赖 inputMode/frameLoaded)安装,不在此处;
|
||||
// 转发模式则 enable_ime=false,直接打字走纯 keysym(英文/数字正常),中文用底部输入条。
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
@@ -690,7 +792,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{imeBar && (
|
||||
{inputMode === 'forward' && (
|
||||
<div className="iv-imebar">
|
||||
<textarea
|
||||
className="iv-imebar-input"
|
||||
|
||||
@@ -307,7 +307,6 @@ button {
|
||||
|
||||
/* 实例卡片操作:「管理」分类折叠菜单(默认收起,点开按运维/设置/危险分组展开文字操作) */
|
||||
.inst-menu-toggle {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
@@ -335,11 +334,24 @@ button {
|
||||
.inst-menu-toggle.open .inst-menu-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
/* 「管理」菜单:绝对定位悬浮层,浮在下方内容之上,不把卡片撑高、不顶走后面的内容 */
|
||||
.inst-menu-wrap {
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.inst-menu {
|
||||
margin-top: 8px;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-small);
|
||||
box-shadow: 0 6px 22px rgba(var(--shadow) / 0.26), 0 2px 6px rgba(var(--shadow) / 0.18);
|
||||
animation: inst-menu-in 0.16s ease;
|
||||
}
|
||||
@keyframes inst-menu-in {
|
||||
@@ -928,6 +940,11 @@ button {
|
||||
.inst-card > * {
|
||||
position: relative;
|
||||
}
|
||||
/* 菜单展开时:放开裁剪让悬浮层露出,并抬升层级盖住下方/同列卡片(仍低于弹窗 z-index 20) */
|
||||
.inst-card.open-menu {
|
||||
overflow: visible;
|
||||
z-index: 5;
|
||||
}
|
||||
.inst-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user