26 Commits

  • feat(ci/telegram): issue 新回复也推送(简短)
    telegram-notify 增订阅 issue_comment(created),tg-notify.mjs 加 issue_comment 分支:
    💬 Issue #N 新回复 · 标题 / 评论人 / 评论摘要(≤400字) / 链接。PR 的评论也走此事件,按 C_PR 跳过;
    回复通知不置顶(置顶仍仅限 release)。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(v1.2.0): 自定义实例图标——内置精选 + 上传裁剪
    - AppIcon:内置精选平台图标(微信/Chromium/Telegram/小红书/抖音/B站/微博/知乎/YouTube/通用)+ ICON_CHOICES。
    - 编辑器(管理菜单「图标」):选内置图标 / 上传图片用 react-easy-crop 方形裁剪→128px PNG / 恢复默认。
    - 后端 setInstanceIcon + /api/admin/instances/:id/icon(仅 admin;icon=builtin:<key>/data:图片/空,限 ~225KB)。
    - 新增依赖 react-easy-crop。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(v1.2.0): 实例头像由首字母改为按应用类型的图标(自定义图标铺底)
    新增 AppIcon.tsx(InstanceIcon + 内置 SVG 图标:微信/Chromium/Telegram/通用),侧栏与主页
    卡片头像改用它(按 appType 出默认图标;data: 图片 / builtin:<key> 优先)。
    新增 Instance.icon 字段 + publicInstance 下发,为「自定义图标(内置选择 + 上传裁剪)」铺底。
    侧栏/主页标题「微信实例」→「实例」,主页副文案按应用名泛化。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(v1.2.0): 旧容器无 app-ctl.sh 时回退 wechat-ctl.sh(修微信实例误报未安装)
    多应用分发对所有实例调 /woc/app-ctl.sh,但升级前的旧容器镜像里没有该脚本 → exec 失败 →
    wechatStatus 兜底成"未安装",已装微信的老实例全变"待安装"。triggerWechat/wechatStatus 改为
    bash -c:有 app-ctl.sh 则按 appType 分发,无则回退老的 wechat-ctl.sh(旧实例皆微信)。
    老实例不升级也能正常显示/操作;升级到多应用镜像后自动走 app-ctl 分发。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(v1.2.0): wechatStatus 改走 app-ctl <appType>(之前被 iCloud 回退)
    b7fd778 提交时这行被本机 iCloud 回退成 wechat-ctl.sh status,导致非微信实例(Chromium)
    状态去查微信二进制 → 永远「尚未安装」,且 Chromium 无安装按钮 → 卡死。改回 app-ctl.sh
    <appType> status:Chromium 即报已就绪、可直接进入。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(v1.2.0): UI 按应用类型泛化(不再到处写死「微信」)
    新增 APP_PROFILES(label/needsInstall/enterHint/updateLabel)+ appProfile()。
    - 实例卡片:状态副文案、"进入实例"提示、安装/更新按钮均按 appType 显示;Chromium 已烤进镜像
      (needsInstall=false)故不显示"下载安装/更新",状态直接"已就绪"。
    - 桌面页:连接提示语用 enterHint(微信=扫码登录,Chromium=直接使用);安装中/未安装提示、iframe
      标题、文件传输/剪贴板/输入条文案改为应用名或通用"应用/桌面"。
    - 列表区"微信实例"→"实例"、空状态泛化。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(v1.2.0): 重新落实选择器为 微信+Chromium(上个提交被 iCloud 回退)
    eabedde 提交时 APP_OPTIONS 被本机 iCloud 冲突副本回退成旧版(telegram 可选、chromium 禁用)。
    此处重新落实:微信(默认) + 浏览器(Chromium) 可用,自定义「即将支持」禁用,去掉 Telegram。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(v1.2.0): 改为「微信 + Chromium 浏览器」,Telegram/自定义暂缓
    Telegram 仅 x86_64,暂不做(代码留休眠)。聚焦多架构通用的 Chromium:
    - Dockerfile apt 装 chromium(Debian Bookworm,amd64/arm64 均有;本地 arm64 实测装成、
      chromium --version 正常,镜像 +~0.5GB)。Chromium 随镜像就绪,autostart 直接以
      --no-sandbox 软件渲染拉起,无需「下载安装」。
    - 新建实例选择器:微信(默认) + 浏览器(Chromium) 可用;自定义标「即将支持」禁用;去掉 Telegram。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(v1.2.0): 多应用平台——创建实例时选 微信/Telegram(+Chromium/自定义占位)
    镜像层(向后兼容,微信路径零改动):
    - app-defs.sh:按 appType 给出 APP_BIN/APP_LAUNCH/APP_NAME(缺省回退微信)。
    - app-ctl.sh:通用安装/状态分发;wechat 委托回 wechat-ctl.sh;telegram 下载官方 portable tar.xz。
    - autostart:读 /config/.woc-app 选择启动哪个应用,读不到回退微信(老实例零改动)。
    - 02-woc-app 钩子:把容器环境 WOC_APP_TYPE 落到 /config/.woc-app(缺则不写→回退微信)。
    - Dockerfile:加 xz-utils(telegram 解压)+ COPY 新脚本。
    
    后端:envList 透传 WOC_APP_TYPE(+自定义启动命令);triggerWechat/wechatStatus 改走
    app-ctl.sh <appType>(微信行为不变);创建实例路由接受 appType。
    
    前端:新建实例对话框加「应用类型」选择器(微信默认 / Telegram;Chromium、自定义标记"即将支持"禁用)。
    
    本轮 Telegram(x86_64) 端到端可用;Chromium(待 apt 烤镜像) 与 自定义(待上传流) 下一轮。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(desktop): 输入模式切换改为整页重载,修卡死 + 切换不生效
    切换模式时原先 bump vncNonce 在页内重挂 iframe,会让新旧两条 VNC ws 短暂并存,
    概率性把实例 Xvnc 卡死(需重启容器恢复、面板重启无效),且新连接常读不到新 enable_ime
    (仍是英文)。改为 window.location.reload():先卸载旧页彻底关旧 ws,再以新模式干净重连,
    正是用户实测唯一可靠的方式。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(docker): typeInInstance 卡 ~2s 根因——xclip 未重定向 fd
    xclip -i 会 daemon 化常驻持有剪贴板选区,并继承 docker exec 的 stdout/stderr,
    导致 exec 要等这俩 fd 关闭、每次中文转发卡 ~2.1s。给 xclip 重定向 >/dev/null 2>&1
    后整条链路降到 ~0.08s(实测 26× 提速)。中文输入条与无感模式都受益。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(desktop): 修无感输入两处 bug(切换不生效 + "你好y呀"丢字)
    1) 切到无感后当前会话仍是旧 enable_ime → 表现"还是打英文,要换页才行"。
       setMode 现同步写 enable_ime 并 bump vncNonce 重挂 iframe,让 noVNC 立即按新模式重连。
    2) "你好[空格]呀"打出"你好y呀":有序队列在中文转发期间会把下一个词的拼音首字母(y)当字面字符抢走。
       改为队列活跃时只接管【数字】(原"混数字丢字"的祸首) + 回车/退格;字母绝不接管,交给输入法合成。
       附带消除了之前"每个键都走 xdotool"导致的卡顿(字母回到原生合成快路径)。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(v1.2.0): 实例 appType 数据模型地基(向后兼容)
    引入 AppType(wechat/telegram/chromium/custom)+ APP_LABELS + instanceAppType() 兜底。
    Instance 增 appType?(可选) 与 customLaunch?;createInstance 接受 appType(默认 wechat)、
    按应用取默认名;publicInstance 下发 appType(老实例无字段→回退 wechat)。
    纯增量、不改现有运行行为;为后续 autostart 分发 / 各应用安装 / 前端选择器铺底。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(desktop): 输入法双模式切换(无感 / 转发)+ 深度修复无感丢字
    nav 栏可切「无感输入」(直接在微信里打中文)/「转发输入」(底部输入条,默认),偏好持久化。
    无感:enable_ime=true,compositionend 捕获中文经 xdotool 转发;并用有序队列把"转发未完成期间"
    的后续可见字符 + 回车/退格串行送出,彻底消除"中文走异步、数字走 keysym 抢跑"的"你好123→23"丢字;
    队列空闲不干预,英文/数字仍走原生 keysym 零延迟。新增 /api/instances/:id/key(xdotool 单键)。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(admin): 实例「管理」菜单改为悬浮图层展开
    绝对定位悬浮层(从按钮下方浮出),不再撑高卡片/顶走下方内容;展开时卡片 overflow:visible
    + z-index:5(盖住下方/同列卡片,仍低于弹窗);加点击外部 / 点击菜单项自动关闭。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • chore(docker): 00-woc-identity 也用显式 chmod 755(补齐 #17)
    PR #17 把脚本权限改为显式 755/644 以摆脱构建机 umask 影响,但它之后才加的
    00-woc-identity 钩子仍是 chmod +x,是唯一漏网。该钩子由 root 运行(711 也能跑、
    非功能性问题),此处仅为与其余几行统一、彻底做到与 umask 无关。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs(readme): 备注 NAS/飞牛首次建实例镜像拉取超时的解法
    NAS 自带的 Docker Hub 加速通常只覆盖界面手动拉取,不覆盖面板经 docker.sock 触发的拉取,
    导致首次新建实例直连 registry-1.docker.io 超时。README 快速开始加提示:先手动拉一次
    gloridust/wechat-on-cloud:latest(本地有了面板就复用不再联网),或配守护进程镜像加速 /
    换 WOC_IMAGE_PREFIX 到国内源。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs(readme): 方式B 改为仅凭 docker-compose.yml 部署,无需 clone 仓库
    compose 用 image: 拉官方镜像、数据落到旁边自动建的 ./data-panel,不依赖仓库其它文件
    (已在空目录验证 docker compose config 通过)。方式B 改为:命令行 curl 单文件 / 飞牛(fnOS)·
    群晖等 NAS 直接粘贴 docker-compose.yml 到 Compose 一键部署界面;并说明改密码/端口/镜像源
    可用 .env 或 NAS 环境变量,无需 clone。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(ci/telegram): 发布通知自动置顶 + 取消上一个 release 的置顶
    tg-notify.mjs:release/手动触发发完消息后,用 getChat 读群当前置顶(=上一个 release),
    pinChatMessage 置顶新消息(静音,避免二次提醒)、unpinChatMessage 取消旧的——无需持久化存储。
    issue 通知不置顶;置顶失败(机器人非管理员/无置顶权限)仅跳过、不影响通知本身。
    文档补充:需把机器人设为群管理员并开启「置顶消息」权限。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • chore: 默认镜像源切到 Docker Hub,GHCR 作为备用
    Docker Hub 国内/国际通用、免登录拉公开镜像,飞牛OS(fnOS) 等 NAS 还内置拉取加速,
    通常比 GHCR 更快更稳。docker-compose.yml 两处 :- 兜底默认与 .env.example 默认值
    由 ghcr.io/gloridust 改为 docker.io/gloridust;GHCR / 南大反代 / 阿里云等列为备用源。
    README 快速开始措辞同步更新。镜像仍同时发布到两个 registry,未改 CI。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs(readme): 增加 Docker Hub 镜像源切换说明(飞牛OS 等 NAS 有加速)
    官方镜像已同步发布到 Docker Hub(公开、多架构)。README 快速开始补充:
    ghcr.io 拉不动时在 .env 设 WOC_IMAGE_PREFIX=docker.io/gloridust 切到 Docker Hub;
    飞牛 OS(fnOS) 等 NAS 内置 Docker Hub 拉取加速,通常更快更稳。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • fix(panel): 修复 VolumeManager 漏闭合的 .vol-sec 标签致构建失败
    a42006e 把第 1259 行的 </div> 误回退成 </>,与 <div className="vol-sec"> 不匹配,
    导致 vite/esbuild 构建报 "Unexpected closing fragment tag" → main 上 panel 镜像构建失败
    (v1.1.10 标签镜像本身是好的,从 1c34777 构建)。改回 </div>。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22 changed files with 982 additions and 143 deletions
+6 -5
View File
@@ -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.
+9
View File
@@ -15,6 +15,8 @@ on:
types: [published]
issues:
types: [opened]
issue_comment:
types: [created]
workflow_dispatch: {}
permissions:
@@ -43,4 +45,11 @@ jobs:
I_URL: ${{ github.event.issue.html_url }}
I_USER: ${{ github.event.issue.user.login }}
I_BODY: ${{ github.event.issue.body }}
# issue_comment 事件:issue 下的新回复(PR 评论也走此事件,脚本里据 C_PR 跳过)
C_NUM: ${{ github.event.issue.number }}
C_TITLE: ${{ github.event.issue.title }}
C_URL: ${{ github.event.comment.html_url }}
C_USER: ${{ github.event.comment.user.login }}
C_BODY: ${{ github.event.comment.body }}
C_PR: ${{ github.event.issue.pull_request.html_url }}
run: node .github/scripts/tg-notify.mjs
+32 -13
View File
@@ -52,7 +52,7 @@
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(GHCR + GitHub Actions 自动发布)。
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(Docker Hub + GHCRGitHub 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),复制内容自己建个同名文件即可。
- **飞牛 OSfnOS/ 群晖 等 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
View File
@@ -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
View File
@@ -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}
+17 -5
View File
@@ -15,6 +15,8 @@ RUN set -eux; \
curl ca-certificates locales dpkg \
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
libnss3 libgbm1 libasound2 libxss1 \
xz-utils \
chromium \
xdotool xclip; \
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
locale-gen; \
@@ -62,24 +64,34 @@ ENV LANG=zh_CN.UTF-8 \
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
# 注意:实际加载的是 webpack 产物 dist/main.bundle.jsapp/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
# v1.2.0 多应用:应用定义(app-defs.sh,被 autostart/app-ctl 引用)+ 通用安装/状态控制(app-ctl.sh
# 微信委托回 wechat-ctl.sh、其它应用各自实现)。app-defs.sh 是被 source 的、不需可执行位。
COPY app-defs.sh app-ctl.sh /woc/
RUN chmod 644 /woc/app-defs.sh && chmod 755 /woc/app-ctl.sh
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
COPY autostart /defaults/autostart
RUN chmod +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
# 启动钩子(02):把容器环境 WOC_APP_TYPE 写入 /config/.woc-app,供 autostart 选择启动哪个应用。
# 须在 autostart 之前执行;缺 WOC_APP_TYPE 则不写 → autostart 回退微信(向后兼容)。
COPY woc-app-init.sh /custom-cont-init.d/02-woc-app
RUN chmod 755 /custom-cont-init.d/02-woc-app
# 3000 = HTTP web 客户端, 3001 = HTTPS
EXPOSE 3000 3001
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# 多应用安装/状态控制(面板经 docker exec --user abc 调用):
# app-ctl.sh <appType> <install|update|status>
# 设计:微信完全委托给原 wechat-ctl.sh(逻辑零改动);其它应用各自实现,状态 JSON 复用同一格式与文件,
# 故面板的轮询逻辑无需区分应用类型。状态文件:/config/.woc-state/status.json。
set -u
APP="${1:-wechat}"
ACTION="${2:-status}"
# 微信:原样委托,保持既有行为不变(向后兼容老实例与旧面板调用路径)
if [ "$APP" = "wechat" ]; then exec /woc/wechat-ctl.sh "$ACTION"; fi
# shellcheck source=/dev/null
. /woc/app-defs.sh
woc_app_def "$APP"
STATE_DIR="${WOC_STATE_DIR:-/config/.woc-state}"
STATUS_FILE="$STATE_DIR/status.json"
is_installed() { [ -n "${APP_BIN:-}" ] && [ -x "$APP_BIN" ]; }
write_status() {
local phase="$1" percent="$2" message="$3" installed=false
is_installed && installed=true
mkdir -p "$STATE_DIR"
cat > "$STATUS_FILE.tmp" <<EOF
{"phase":"$phase","percent":$percent,"installed":$installed,"version":"","message":"$message","updatedAt":$(date +%s)}
EOF
mv -f "$STATUS_FILE.tmp" "$STATUS_FILE"
}
print_status() {
if [ -f "$STATUS_FILE" ]; then
cat "$STATUS_FILE"
elif is_installed; then
echo "{\"phase\":\"done\",\"percent\":100,\"installed\":true,\"version\":\"\",\"message\":\"已就绪\",\"updatedAt\":$(date +%s)}"
else
echo "{\"phase\":\"idle\",\"percent\":0,\"installed\":false,\"version\":\"\",\"message\":\"未安装\",\"updatedAt\":$(date +%s)}"
fi
}
install_telegram() {
case "$(dpkg --print-architecture 2>/dev/null)" in
amd64) ;;
*) write_status error 0 "Telegram 官方仅提供 x86_64 版本,当前架构($(dpkg --print-architecture 2>/dev/null))不支持"; return ;;
esac
local work=/config/.woc-dl tmp
tmp="$work/tg.tar.xz"
rm -rf "$work"; mkdir -p "$work"
write_status downloading -1 "正在下载 Telegram"
if ! curl -fSL --retry 3 -A "Mozilla/5.0" -o "$tmp" "https://telegram.org/dl/desktop/linux"; then
write_status error 0 "下载失败,请检查网络后重试"; rm -rf "$work"; return
fi
write_status extracting 92 "正在解压安装"
local newdir="$work/x"; mkdir -p "$newdir"
# 官方包内顶层是 Telegram/ 目录,strip 掉一层 → newdir 下直接是 Telegram + Updater
if ! tar -xJf "$tmp" -C "$newdir" --strip-components=1 2>/dev/null; then
write_status error 0 "解压失败,安装包可能损坏"; rm -rf "$work"; return
fi
if [ ! -x "$newdir/Telegram" ]; then
write_status error 0 "解压后未找到 Telegram 可执行文件"; rm -rf "$work"; return
fi
write_status installing 96 "正在安装"
rm -rf /config/telegram.old
[ -e /config/telegram ] && mv /config/telegram /config/telegram.old
mv "$newdir" /config/telegram
rm -rf /config/telegram.old "$work"
write_status done 100 "安装完成"
pkill -f "/config/telegram/Telegram" 2>/dev/null || true
}
case "$ACTION" in
status) print_status ;;
install | update)
case "$APP" in
telegram) install_telegram ;;
chromium) write_status done 100 "Chromium 随镜像就绪" ;; # 后续:apt 烤进镜像后即就绪
custom)
if is_installed; then write_status done 100 "就绪"; else write_status error 0 "请先在「数据卷」上传并配置自定义应用"; fi ;;
*) echo "未知应用: $APP" >&2; exit 1 ;;
esac ;;
*) echo "用法: $0 <appType> {install|update|status}" >&2; exit 1 ;;
esac
+31
View File
@@ -0,0 +1,31 @@
# 应用定义(被 autostart 与 app-ctl.sh source)。给定应用类型,输出该应用的:
# APP_BIN — 可执行文件路径(autostart 据此判断"是否就绪/已安装"
# APP_LAUNCH — 启动命令(可带参数;autostart 以 word-split 方式执行,参数勿含空格)
# APP_NAME — 显示名(日志用)
# 缺省/未知类型一律回退微信,保证老实例零改动。v1.2.0 多应用平台。
woc_app_def() {
case "${1:-wechat}" in
telegram)
APP_BIN=/config/telegram/Telegram
APP_LAUNCH="$APP_BIN"
APP_NAME=Telegram
;;
chromium)
# 容器内无 user namespace / GPU--no-sandbox + 软件渲染;--password-store=basic 免 keyring 弹窗
APP_BIN=/usr/bin/chromium
APP_LAUNCH="$APP_BIN --no-sandbox --no-first-run --no-default-browser-check --start-maximized --password-store=basic --disable-gpu --user-data-dir=/config/chromium"
APP_NAME=Chromium
;;
custom)
# 自定义:启动命令由面板写入 .woc-app 的 WOC_CUSTOM_LAUNCH(用户上传安装包后设定)
APP_LAUNCH="${WOC_CUSTOM_LAUNCH:-}"
APP_BIN="${WOC_CUSTOM_BIN:-}"
APP_NAME="自定义应用"
;;
*)
APP_BIN=/config/wechat/opt/wechat/wechat
APP_LAUNCH="$APP_BIN"
APP_NAME=微信
;;
esac
}
+29 -14
View File
@@ -1,17 +1,25 @@
#!/bin/bash
# 由 KasmVNC base 的 openbox 会话在桌面就绪后执行(以 app 用户身份)。
# 微信本体由面板经 docker exec 触发下载/解压到数据卷 /config/wechat(见 wechat-ctl.sh
# 本脚本只负责:等待微信就绪 + 常驻拉起(关窗自动重开;更新后从新版本路径重启)。
# v1.2.0 多应用:实例承载的应用(微信/Telegram/Chromium/自定义)由面板写入容器环境 WOC_APP_TYPE
# 再由 02-woc-app 钩子落到数据卷 /config/.woc-app。本脚本据此等待对应应用就绪并常驻拉起
# (关窗自动重开;更新后从新版本路径重启)。读不到类型 → 回退微信,老实例零改动。
set -u
WECHAT_BIN=/config/wechat/opt/wechat/wechat
# 容器内无 GPU,强制软件渲染
export LIBGL_ALWAYS_SOFTWARE=1
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
# 解析本实例应用类型与启动信息(APP_BIN / APP_LAUNCH / APP_NAME
APP_TYPE=wechat
# shellcheck source=/dev/null
[ -f /config/.woc-app ] && . /config/.woc-app 2>/dev/null || true
APP_TYPE="${WOC_APP_TYPE:-wechat}"
# shellcheck source=/dev/null
. /woc/app-defs.sh
woc_app_def "$APP_TYPE"
# 防“最小化后丢失”:本桌面(openbox)无任务栏,窗口被最小化就无处恢复 → 黑屏。
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。对任意应用通用。
(
export DISPLAY="${DISPLAY:-:1}"
while sleep 2; do
@@ -23,25 +31,32 @@ export LIBGL_ALWAYS_SOFTWARE=1
done
) &
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」
# 自定义应用若未配置启动命令,给出提示并退出(避免空等
if [ "$APP_TYPE" = "custom" ] && [ -z "${APP_LAUNCH:-}" ]; then
echo "[autostart] 自定义应用尚未配置启动命令,请在面板「数据卷」上传安装包并设定后重启实例"
exit 0
fi
# 1) 等待应用安装就绪(首次需在面板点「下载并安装」)
notified=0
while [ ! -x "${WECHAT_BIN}" ]; do
while [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; do
if [ "${notified}" -eq 0 ]; then
echo "[autostart] 微信尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装微信」)"
echo "[autostart] ${APP_NAME} 尚未安装,等待面板触发下载…(首次使用请在面板点「下载并安装」)"
notified=1
fi
sleep 2
done
# 3) 常驻拉起微信
# 3) 常驻拉起应用
while true; do
if [ ! -x "${WECHAT_BIN}" ]; then
if [ -n "${APP_BIN:-}" ] && [ ! -x "${APP_BIN}" ]; then
# 更新过程中本体被临时挪走,等就位再继续
sleep 2
continue
fi
echo "[autostart] 启动微信: ${WECHAT_BIN}"
"${WECHAT_BIN}"
echo "[autostart] 微信已退出,2 秒后重启"
echo "[autostart] 启动 ${APP_NAME}: ${APP_LAUNCH}"
# APP_LAUNCH 可带参数,按 word-split 执行(各应用参数均不含空格,见 app-defs.sh
${APP_LAUNCH}
echo "[autostart] ${APP_NAME} 已退出,2 秒后重启"
sleep 2
done
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# /custom-cont-init.d 钩子(02):把容器环境里的应用类型写入数据卷 /config/.woc-app
# 供 autostart(以 abc 身份的桌面会话)读取。由 s6 在 autostart 之前以 root 运行。
# 缺 WOC_APP_TYPE(老实例/老面板)则不写文件 → autostart 回退微信,完全向后兼容。
APP_TYPE="${WOC_APP_TYPE:-}"
[ -z "$APP_TYPE" ] && exit 0
# 仅允许已知的简单标识,杜绝写入异常内容
case "$APP_TYPE" in
wechat | telegram | chromium | custom) ;;
*) exit 0 ;;
esac
TMP=/config/.woc-app.tmp
{
echo "WOC_APP_TYPE='${APP_TYPE}'"
# 自定义应用的启动命令由面板经环境传入(admin 设定);用单引号包裹,转义内部单引号
if [ -n "${WOC_CUSTOM_LAUNCH:-}" ]; then
esc=${WOC_CUSTOM_LAUNCH//\'/\'\\\'\'}
echo "WOC_CUSTOM_LAUNCH='${esc}'"
fi
} > "$TMP"
mv -f "$TMP" /config/.woc-app
chown abc:abc /config/.woc-app 2>/dev/null || true
echo "[woc-app] 实例应用类型 = ${APP_TYPE}"
+38 -5
View File
@@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs';
import http from 'node:http';
import zlib from 'node:zlib';
import Docker from 'dockerode';
import type { Instance } from './store.js';
import { instanceAppType, type Instance } from './store.js';
const WECHAT_IMAGE = process.env.WOC_WECHAT_IMAGE || 'ghcr.io/gloridust/wechat-on-cloud:latest';
const PUID = process.env.PUID || '1000';
@@ -114,6 +114,11 @@ function envList(inst: Instance): string[] {
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
// 透传 os 伪装开关给容器内的 00-woc-identity 钩子(决定是否把 /etc/os-release 改成 deepin)。
env.push(`WOC_SPOOF_OS=${SPOOF_OS ? '1' : '0'}`);
// v1.2.0 多应用:透传应用类型给 02-woc-app 钩子(写入 /config/.woc-appautostart 据此启动)。
// 老实例无 appType → instanceAppType 回退 wechat;自定义应用额外透传启动命令。
const appType = instanceAppType(inst);
env.push(`WOC_APP_TYPE=${appType}`);
if (appType === 'custom' && inst.customLaunch) env.push(`WOC_CUSTOM_LAUNCH=${inst.customLaunch}`);
return env;
}
@@ -404,11 +409,15 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
});
}
// 触发下载/安装(detached,立即返回,后台下载)。
// 触发下载/安装(detached,立即返回,后台下载)。按实例 appType 分发:app-ctl.sh wechat → 委托回
// wechat-ctl.shtelegram 等各自实现。兼容旧容器(升级前镜像里没有 /woc/app-ctl.sh):有则用之,无则
// 回退老的 wechat-ctl.sh(旧实例都是微信)。appType 取值受 instanceAppType 约束,可安全内插进 shell。
export async function triggerWechat(inst: Instance, cmd: 'install' | 'update'): Promise<void> {
const c = docker.getContainer(inst.containerName);
const at = instanceAppType(inst);
const action = cmd === 'update' ? 'update' : 'install';
const exec = await c.exec({
Cmd: ['/woc/wechat-ctl.sh', cmd === 'update' ? 'update' : 'install'],
Cmd: ['bash', '-c', `if [ -x /woc/app-ctl.sh ]; then /woc/app-ctl.sh ${at} ${action}; else /woc/wechat-ctl.sh ${action}; fi`],
AttachStdout: false,
AttachStderr: false,
User: 'abc',
@@ -429,7 +438,13 @@ const DEFAULT_STATUS: WechatStatus = { phase: 'idle', percent: 0, installed: fal
export async function wechatStatus(inst: Instance): Promise<WechatStatus> {
try {
const raw = await execCapture(inst, ['/woc/wechat-ctl.sh', 'status']);
// 兼容旧容器(无 /woc/app-ctl.sh):有则按 appType 取状态,无则回退老的 wechat-ctl.sh(旧实例皆微信)。
const at = instanceAppType(inst);
const raw = await execCapture(inst, [
'bash',
'-c',
`if [ -x /woc/app-ctl.sh ]; then /woc/app-ctl.sh ${at} status; else /woc/wechat-ctl.sh status; fi`,
]);
const json = JSON.parse(raw.trim());
return { ...DEFAULT_STATUS, ...json };
} catch {
@@ -576,12 +591,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 微信数据迁移上来、跨实例迁移、离线备份。
+34 -2
View File
@@ -26,8 +26,11 @@ import {
createInstance,
removeInstance as removeInstanceRecord,
renameInstance,
setInstanceIcon,
setInstanceUsers,
publicInstance,
APP_TYPES,
type AppType,
type User,
type Instance,
} from './store.js';
@@ -48,6 +51,7 @@ import {
deleteInstanceFile,
instanceLogs,
typeInInstance,
keyInInstance,
listOrphanVolumes,
removeVolume,
listOrphanContainers,
@@ -269,11 +273,12 @@ app.get('/api/instances', async (req, reply) => {
app.post('/api/admin/instances', async (req, reply) => {
const admin = requireAdmin(req, reply);
if (!admin) return;
const { name, reuseVolume } = (req.body as any) ?? {};
const { name, reuseVolume, appType } = (req.body as any) ?? {};
const allowedUserIds = Array.isArray((req.body as any)?.allowedUserIds) ? (req.body as any).allowedUserIds : [];
if (!name || String(name).trim().length === 0 || String(name).length > 30) {
return reply.code(400).send({ error: '实例名称为 1-30 个字符' });
}
const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
let reuseVolumeName: string | undefined;
if (reuseVolume) {
@@ -285,7 +290,7 @@ app.post('/api/admin/instances', async (req, reply) => {
}
reuseVolumeName = reuseVolume;
}
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName);
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type);
try {
await runInstance(inst);
} catch (e: any) {
@@ -445,6 +450,17 @@ app.post('/api/admin/instances/:id/rename', async (req, reply) => {
}
});
// 设置实例自定义图标(仅管理员):icon = builtin:<key> / data:image 图片 / 空串(恢复默认)。
app.post('/api/admin/instances/:id/icon', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { icon } = (req.body as any) ?? {};
try {
return { instance: setInstanceIcon((req.params as any).id, typeof icon === 'string' ? icon : null) };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '设置图标失败' });
}
});
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
app.post('/api/admin/instances/:id/start', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
@@ -632,6 +648,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;
+41 -1
View File
@@ -25,15 +25,33 @@ export interface User {
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
const DEFAULT_ADMIN_PASSWORD = 'wechat';
// v1.2.0:实例可承载多种应用(不止微信)。同一镜像运行时按 appType 安装/启动对应应用。
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
export const APP_TYPES: AppType[] = ['wechat', 'telegram', 'chromium', 'custom'];
export const APP_LABELS: Record<AppType, string> = {
wechat: '微信',
telegram: 'Telegram',
chromium: '浏览器',
custom: '自定义应用',
};
// 向后兼容:v1.2.0 之前创建的实例没有 appType 字段,一律视为微信。
export function instanceAppType(i: Instance): AppType {
return i.appType && APP_TYPES.includes(i.appType) ? i.appType : 'wechat';
}
export interface Instance {
id: string; // 短 id,用于容器/卷命名
name: string; // 显示名
appType?: AppType; // 承载的应用类型;缺省(老实例)= wechat(见 instanceAppType
icon?: string; // 自定义图标:data: 图片(base64) 或 builtin:<key>;缺省按 appType 取默认图标
containerName: string; // woc-wx-<id>
volumeName: string; // woc-data-<id>
kasmUser: string; // 随机生成,服务端注入反代,永不下发前端
kasmPassword: string;
createdAt: string;
createdBy: string; // userId
// 自定义应用(appType=custom):用户上传的安装包信息,autostart 据此启动。
customLaunch?: string; // 启动命令(容器内绝对路径或命令)
// 自愈 watchdog 的"安全阀"per-instance 覆盖全局默认;缺省时使用 env / 内置默认。
// soft:内存超此值时,仅在"当前没有用户在远程会话"才主动重启(柔和自愈);
// hard:内存超此值时,无论是否有人在会话都重启(防止 OOM 拖垮宿主)。
@@ -198,6 +216,8 @@ export function publicInstance(i: Instance) {
return {
id: i.id,
name: i.name,
appType: instanceAppType(i), // 老实例无字段时回退 wechat
icon: i.icon,
createdAt: i.createdAt,
createdBy: i.createdBy,
memSoftLimitMB: i.memSoftLimitMB,
@@ -263,7 +283,9 @@ export function createInstance(
createdBy: string,
allowedUserIds: string[] = [],
reuseVolumeName?: string,
appType: AppType = 'wechat',
) {
const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
let id = randomBytes(5).toString('hex'); // 10 hex chars
let volumeName = `woc-data-${id}`;
if (reuseVolumeName) {
@@ -275,7 +297,8 @@ export function createInstance(
}
const inst: Instance = {
id,
name: name.trim() || `微信-${id.slice(0, 4)}`,
name: name.trim() || `${APP_LABELS[type]}-${id.slice(0, 4)}`,
appType: type,
containerName: `woc-wx-${id}`,
volumeName,
kasmUser: 'woc',
@@ -307,6 +330,23 @@ export function renameInstance(id: string, name: string) {
return publicInstance(inst);
}
// 设置/清除实例自定义图标。传空 → 恢复按 appType 的默认图标。
// 仅允许 builtin:<key> 或 data:image/...(裁剪后约 128px,限 ~225KB,防滥用撑大 accounts.json)。
export function setInstanceIcon(id: string, icon: string | null) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
const v = (icon ?? '').trim();
if (!v) {
delete inst.icon;
} else if (/^builtin:[a-z0-9_-]{1,32}$/.test(v) || (v.startsWith('data:image/') && v.length <= 300000)) {
inst.icon = v;
} else {
throw new Error('图标格式不合法或过大');
}
persist();
return publicInstance(inst);
}
export function removeInstance(id: string) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
+20
View File
@@ -8,6 +8,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
@@ -4515,6 +4516,12 @@
"node": ">=18"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4735,6 +4742,19 @@
"react": "^18.3.1"
}
},
"node_modules/react-easy-crop": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
+101
View File
@@ -0,0 +1,101 @@
import type { AppType } from './api';
// 实例图标。支持三种来源(优先级从高到低):
// 1) 自定义上传/裁剪的图片 → inst.icon = "data:image/...;base64,..."
// 2) 内置图标 → inst.icon = "builtin:<key>"(如 builtin:xiaohongshu
// 3) 缺省:按 appType 给默认图标(微信 / Chromium / Telegram / 通用)
// 内置图标用简洁 SVG(彩色圆角块 + 白色字形),风格统一、无需联网抓取。后续可往 BUILTIN 里加更多平台。
type Glyph = { bg: string; el: JSX.Element };
const G = (bg: string, el: JSX.Element): Glyph => ({ bg, el });
// 白色字形(viewBox 0 0 48 48,置于彩色圆角块上)
const chat = (
<path
fill="#fff"
d="M19 12c-6.6 0-12 4.2-12 9.5 0 3 1.8 5.7 4.6 7.4l-1.1 3.9 4.4-2.3c1.3.3 2.7.5 4.1.5 6.6 0 12-4.2 12-9.5S25.6 12 19 12zm-4 8.2a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2zm8 0a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2z"
/>
);
const globe = (
<g fill="none" stroke="#fff" strokeWidth="2.4">
<circle cx="24" cy="24" r="13" />
<ellipse cx="24" cy="24" rx="5.5" ry="13" />
<path d="M11.5 20h25M11.5 28h25" />
</g>
);
const plane = <path fill="#fff" d="M35 14L13 23.2l6.1 2.2 2.3 7.2 3.3-3.9 5.6 4.1L35 14zm-12.4 12.6l9-7.2-6.7 8.1-.1 3.6-2.2-4.5z" />;
const dots = (
<g fill="#fff">
<circle cx="16" cy="24" r="2.6" />
<circle cx="24" cy="24" r="2.6" />
<circle cx="32" cy="24" r="2.6" />
</g>
);
// 文字字形(品牌色块 + 白字),用于没有简单标志的平台
const txt = (s: string, fs = 22) => (
<text x="24" y="25" fill="#fff" fontSize={fs} fontWeight="700" textAnchor="middle" dominantBaseline="central" fontFamily="-apple-system, system-ui, sans-serif">
{s}
</text>
);
const play = <path fill="#fff" d="M20 17l12 7-12 7z" />;
// key → 字形。default-by-appType 与「内置图标选择器」共用同一张表。
export const BUILTIN_ICONS: Record<string, Glyph> = {
wechat: G('#07c160', chat),
chromium: G('#4285f4', globe),
telegram: G('#2aabee', plane),
xiaohongshu: G('#ff2442', txt('书')),
douyin: G('#111111', txt('抖')),
bilibili: G('#fb7299', txt('B', 26)),
weibo: G('#e6162d', txt('微')),
zhihu: G('#0084ff', txt('知')),
youtube: G('#ff0000', play),
globe: G('#5b8def', globe),
app: G('#8a9099', dots),
};
// 「内置图标」选择器里展示的可选项(顺序即展示顺序)
export const ICON_CHOICES: { key: string; label: string }[] = [
{ key: 'wechat', label: '微信' },
{ key: 'chromium', label: 'Chromium' },
{ key: 'telegram', label: 'Telegram' },
{ key: 'xiaohongshu', label: '小红书' },
{ key: 'douyin', label: '抖音' },
{ key: 'bilibili', label: 'B站' },
{ key: 'weibo', label: '微博' },
{ key: 'zhihu', label: '知乎' },
{ key: 'youtube', label: 'YouTube' },
{ key: 'globe', label: '通用' },
];
const DEFAULT_BY_APP: Record<AppType, string> = {
wechat: 'wechat',
chromium: 'chromium',
telegram: 'telegram',
custom: 'app',
};
export function InstanceIcon({
icon,
appType,
size = 36,
radius = 12,
}: {
icon?: string;
appType?: AppType;
size?: number;
radius?: number;
}) {
// 1) 自定义图片
if (icon && icon.startsWith('data:')) {
return <img src={icon} width={size} height={size} alt="" style={{ borderRadius: radius, objectFit: 'cover', display: 'block' }} />;
}
// 2) 内置 / 3) 默认
const key = icon && icon.startsWith('builtin:') ? icon.slice(8) : DEFAULT_BY_APP[appType ?? 'wechat'] ?? 'app';
const g = BUILTIN_ICONS[key] ?? BUILTIN_ICONS.app;
return (
<svg width={size} height={size} viewBox="0 0 48 48" style={{ display: 'block' }} aria-hidden="true">
<rect width="48" height="48" rx={(radius / size) * 48} fill={g.bg} />
{g.el}
</svg>
);
}
+14 -10
View File
@@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './auth';
import { useUI, PasswordInput } from './ui';
import { api, type InstanceWithStatus } from './api';
import { api, appProfile, type InstanceWithStatus } from './api';
import { InstanceIcon } from './AppIcon';
import InstanceView from './pages/Desktop';
import Admin from './pages/Admin';
@@ -175,7 +176,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
</button>
</nav>
{!collapsed && <div className="sb-section"></div>}
{!collapsed && <div className="sb-section"></div>}
<div className="sb-list">
{instances.length === 0 && !collapsed && <div className="sb-empty"></div>}
{instances.map((inst) => {
@@ -184,7 +185,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
return (
<button key={inst.id} className={'sb-item sb-inst' + (on ? ' on' : '')} onClick={() => go(`/i/${inst.id}`)} title={inst.name}>
<span className="sb-avatar">
{inst.name.slice(0, 1)}
<InstanceIcon icon={inst.icon} appType={inst.appType} size={34} radius={10} />
<span className={'sb-dot ' + st.cls} />
</span>
{!collapsed && <span className="sb-label">{inst.name}</span>}
@@ -252,7 +253,7 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
)}
<div className="section-row">
<span className="section-title"></span>
<span className="section-title"></span>
{isAdmin && (
<button className="btn-text" onClick={() => nav('/admin')}>
@@ -265,21 +266,24 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
<div className="empty-blob">
<img src="/favicon.svg" alt="" />
</div>
<div className="empty-title"></div>
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
<div className="empty-title"></div>
<div className="empty-sub">{isAdmin ? '去「管理」新建一个实例' : '请联系管理员为你分配实例'}</div>
</div>
) : (
<div className="inst-grid">
{instances.map((inst) => {
const st = statusOf(inst);
const prof = appProfile(inst.appType);
const meta = inst.wechat.installed
? `微信 ${inst.wechat.version || ''}`.trim()
: inst.runtime === 'running'
? '待下载安装微信'
? `${prof.label} ${inst.wechat.version || ''}`.trim()
: inst.runtime === 'running' && prof.needsInstall
? `待下载安装${prof.label}`
: '';
return (
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
<span className="home-card-av">
<InstanceIcon icon={inst.icon} appType={inst.appType} size={42} radius={12} />
</span>
<span className="home-card-main">
<span className="home-card-name">{inst.name}</span>
<span className="home-card-meta">
+32 -2
View File
@@ -19,9 +19,36 @@ export interface WechatStatus {
}
export type RuntimeState = 'running' | 'stopped' | 'missing';
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
export const APP_LABELS: Record<AppType, string> = {
wechat: '微信',
telegram: 'Telegram',
chromium: 'Chromium',
custom: '自定义应用',
};
// 各应用的 UI 画像,供卡片/桌面页按类型显示正确文案(避免到处写死「微信」)。
// needsInstall: 是否需要运行时下载安装(微信/Telegram 是;Chromium 已烤进镜像、即创建即就绪)。
// enterHint: 首次进入实例的提示。
// updateLabel: 「管理」菜单里的更新按钮文案(needsInstall=false 时不显示)。
export interface AppProfile {
label: string;
needsInstall: boolean;
enterHint: string;
updateLabel: string;
}
export const APP_PROFILES: Record<AppType, AppProfile> = {
wechat: { label: '微信', needsInstall: true, enterHint: '首次进入请扫码登录微信', updateLabel: '更新微信' },
telegram: { label: 'Telegram', needsInstall: true, enterHint: '首次进入请登录 Telegram', updateLabel: '更新 Telegram' },
chromium: { label: 'Chromium', needsInstall: false, enterHint: '浏览器已就绪,直接使用即可', updateLabel: '' },
custom: { label: '自定义应用', needsInstall: true, enterHint: '', updateLabel: '更新' },
};
export const appProfile = (t?: AppType): AppProfile => APP_PROFILES[t ?? 'wechat'] ?? APP_PROFILES.wechat;
export interface PanelInstance {
id: string;
name: string;
appType?: AppType; // 缺省(老实例)= wechat
icon?: string; // 自定义图标:data: 图片 / builtin:<key>;缺省按 appType 取默认图标
createdAt: string;
createdBy: string;
memSoftLimitMB?: number;
@@ -106,10 +133,10 @@ export const api = {
// 微信实例
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string) =>
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') =>
req<{ instance: PanelInstance }>('/api/admin/instances', {
method: 'POST',
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined }),
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }),
}),
regenMachineId: (id: string) =>
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
@@ -128,6 +155,8 @@ export const api = {
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' }),
setInstanceIcon: (id: string, icon: string | null) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/icon`, { method: 'POST', body: JSON.stringify({ icon }) }),
renameInstance: (id: string, name: string) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
deleteInstance: (id: string, purge = false) =>
@@ -182,4 +211,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 }) }),
};
+211 -23
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, type PanelUser, type InstanceWithStatus, type VolEntry } from '../api';
import Cropper from 'react-easy-crop';
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api';
import { InstanceIcon, ICON_CHOICES } from '../AppIcon';
import { useUI, PasswordInput } from '../ui';
import { useAuth } from '../auth';
@@ -95,6 +97,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
const [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
const [volumeInst, setVolumeInst] = useState<InstanceWithStatus | null>(null); // 数据卷管理弹窗
const [iconInst, setIconInst] = useState<InstanceWithStatus | null>(null); // 图标编辑弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
@@ -271,7 +274,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{isAdmin && (
<>
<div className="section-row">
<span className="section-title"></span>
<span className="section-title"></span>
<button className="btn-text" onClick={() => setCreatingInst(true)}>
+
</button>
@@ -279,11 +282,11 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{instances.length === 0 ? (
<EmptyState
icon="🖥️"
title="还没有微信实例"
sub="新建一个实例,进入后扫码登录即可在浏览器里用微信"
title="还没有实例"
sub="新建一个实例(微信 / Chromium 浏览器),进入后即可在浏览器里使用"
action={
<button className="btn btn-primary" onClick={() => setCreatingInst(true)}>
</button>
}
/>
@@ -306,6 +309,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
onDelete={() => setDeleteInst(inst)}
onSecurity={() => setSecurityInst(inst)}
onVolume={() => setVolumeInst(inst)}
onIcon={() => setIconInst(inst)}
/>
))}
</div>
@@ -531,6 +535,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{volumeInst && (
<VolumeManager inst={volumeInst} onClose={() => setVolumeInst(null)} onChanged={load} />
)}
{iconInst && (
<InstanceIconEditor
inst={iconInst}
onClose={() => setIconInst(null)}
onDone={() => {
toast('已更新图标', 'ok');
load();
}}
/>
)}
</div>
);
}
@@ -866,6 +880,7 @@ function InstanceAdminCard({
onDelete,
onSecurity,
onVolume,
onIcon,
}: {
inst: InstanceWithStatus;
userCount: number;
@@ -881,13 +896,26 @@ function InstanceAdminCard({
onDelete: () => void;
onSecurity: () => void;
onVolume: () => void;
onIcon: () => void;
}) {
const wx = inst.wechat;
const busy = BUSY_PHASES.includes(wx.phase);
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]);
const profile = appProfile(inst.appType);
let badge: { text: string; cls: string };
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
@@ -901,11 +929,11 @@ function InstanceAdminCard({
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}` : '微信已安装';
else sub = '微信尚未安装';
else if (installed) sub = wx.version ? `${profile.label} ${wx.version}` : `${profile.label}已就绪`;
else sub = `${profile.label}尚未安装`;
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>
@@ -933,25 +961,26 @@ function InstanceAdminCard({
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : (
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装' + profile.label}>
</button>
)}
</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">
{!offline && (
{!offline && profile.needsInstall && (
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
{installed ? '更新微信' : '下载安装'}
{installed ? profile.updateLabel : '下载安装'}
</button>
)}
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录)">
@@ -984,6 +1013,9 @@ function InstanceAdminCard({
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
</button>
<button className="btn-text" onClick={onIcon} title="设置实例图标:内置图标 / 上传图片裁剪">
</button>
<button className="btn-text" onClick={onVolume} title="数据卷:备份/恢复、上传 PC 微信数据、文件管理">
</button>
@@ -997,13 +1029,140 @@ function InstanceAdminCard({
</div>
</div>
</div>
)}
)}
</div>
</>
)}
</div>
);
}
// 把裁剪区域画到 128px 画布并导出 PNG dataURL(存进 inst.icon
async function cropToDataUrl(src: string, area: { x: number; y: number; width: number; height: number }): Promise<string> {
const img = await new Promise<HTMLImageElement>((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = rej;
i.src = src;
});
const SIZE = 128;
const c = document.createElement('canvas');
c.width = SIZE;
c.height = SIZE;
c.getContext('2d')!.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, SIZE, SIZE);
return c.toDataURL('image/png');
}
// 实例图标编辑:选内置图标 / 上传图片裁剪 / 恢复默认。
function InstanceIconEditor({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
const { toast } = useUI();
const [sel, setSel] = useState<string>(inst.icon || ''); // '' = 按应用默认
const [busy, setBusy] = useState(false);
const [cropSrc, setCropSrc] = useState(''); // 非空 = 裁剪态
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [area, setArea] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) return;
if (!f.type.startsWith('image/')) return toast('请选择图片文件', 'error');
if (f.size > 8 * 1024 * 1024) return toast('图片过大(>8MB', 'error');
const r = new FileReader();
r.onload = () => {
setCropSrc(String(r.result));
setCrop({ x: 0, y: 0 });
setZoom(1);
};
r.readAsDataURL(f);
};
const confirmCrop = async () => {
if (!cropSrc || !area) return;
try {
setSel(await cropToDataUrl(cropSrc, area));
setCropSrc('');
} catch {
toast('裁剪失败', 'error');
}
};
const save = async () => {
setBusy(true);
try {
await api.setInstanceIcon(inst.id, sel || null);
onDone();
onClose();
} catch (e: any) {
toast(e?.message || '保存失败', 'error');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 460 }}>
<h2> · {inst.name}</h2>
{cropSrc ? (
<>
<div className="icon-crop">
<Cropper
image={cropSrc}
crop={crop}
zoom={zoom}
aspect={1}
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, a) => setArea(a)}
/>
</div>
<input type="range" min={1} max={3} step={0.01} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
<div className="modal-actions">
<button type="button" className="btn" onClick={() => setCropSrc('')}></button>
<button type="button" className="btn btn-primary" onClick={confirmCrop}>使</button>
</div>
</>
) : (
<>
<div className="icon-edit-top">
<InstanceIcon icon={sel || undefined} appType={inst.appType} size={56} radius={14} />
<div className="muted small">{sel.startsWith('data:') ? '自定义图片' : sel.startsWith('builtin:') ? '内置图标' : '按应用默认'}</div>
</div>
<div className="field-label"></div>
<div className="icon-grid">
<button type="button" className={'icon-pick' + (sel === '' ? ' sel' : '')} onClick={() => setSel('')}>
<InstanceIcon appType={inst.appType} size={38} radius={11} />
<span></span>
</button>
{ICON_CHOICES.map((c) => (
<button
type="button"
key={c.key}
className={'icon-pick' + (sel === `builtin:${c.key}` ? ' sel' : '')}
onClick={() => setSel(`builtin:${c.key}`)}
>
<InstanceIcon icon={`builtin:${c.key}`} size={38} radius={11} />
<span>{c.label}</span>
</button>
))}
</div>
<button type="button" className="btn" onClick={() => fileRef.current?.click()}></button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPickFile} />
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={busy}></button>
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}></button>
</div>
</>
)}
</div>
</div>
);
}
// 数据卷管理(仅管理员):整卷备份/恢复 + 文件浏览器(浏览/上传/解压/下载/改名/移动/删除)。
// 主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。全程在「运行中」的实例上操作
// (浏览/改名/删除靠 docker exec,需容器运行)。整卷恢复会覆盖全部数据,强提示并建议恢复后重启实例。
@@ -1355,8 +1514,16 @@ function CreateUser({ instances, onClose, onDone }: { instances: InstanceWithSta
);
}
// 可创建的应用类型。ready=false 的暂时禁用(即将支持)。Telegram(仅 x86_64)与其它应用暂缓。
const APP_OPTIONS: { type: AppType; desc: string; ready: boolean }[] = [
{ type: 'wechat', desc: '默认', ready: true },
{ type: 'chromium', desc: '浏览器', ready: true },
{ type: 'custom', desc: '即将支持', ready: false },
];
function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose: () => void; onDone: () => void }) {
const [name, setName] = useState('');
const [appType, setAppType] = useState<AppType>('wechat');
const [sel, setSel] = useState<Set<string>>(new Set());
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
@@ -1382,7 +1549,7 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
setErr('');
setBusy(true);
try {
await api.createInstance(name.trim(), [...sel], reuse || undefined);
await api.createInstance(name.trim(), [...sel], reuse || undefined, appType);
onDone();
} catch (e: any) {
setErr(e.message || '创建失败');
@@ -1394,8 +1561,27 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
return (
<div className="modal-mask" onClick={onClose}>
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2></h2>
<input className="input" placeholder="实例名称(如:我的微信 / 公司号)" value={name} onChange={(e) => setName(e.target.value)} />
<h2></h2>
<div className="field-label"></div>
<div className="app-picker">
{APP_OPTIONS.map((o) => (
<button
key={o.type}
type="button"
className={'app-pick' + (appType === o.type ? ' sel' : '')}
disabled={!o.ready}
title={o.ready ? '' : '即将支持'}
onClick={() => o.ready && setAppType(o.type)}
>
<span className="app-pick-name">{APP_LABELS[o.type]}</span>
<span className="app-pick-desc">{o.desc}</span>
</button>
))}
</div>
<input className="input" placeholder="实例名称(留空自动命名)" value={name} onChange={(e) => setName(e.target.value)} />
{appType === 'chromium' && (
<div className="muted small">Chromium </div>
)}
<div className="field-label">访访</div>
<ChipMultiSelect
options={subs.map((u) => ({ id: u.id, label: u.username }))}
@@ -1421,7 +1607,9 @@ function CreateInstance({ subs, onClose, onDone }: { subs: PanelUser[]; onClose:
</>
)}
{err && <div className="error">{err}</div>}
<div className="muted small" style={{ marginTop: 4 }}></div>
<div className="muted small" style={{ marginTop: 4 }}>
{APP_LABELS[appType]}
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
+139 -35
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../api';
import { api, appProfile } from '../api';
import { useUI } from '../ui';
import { useAuth } from '../auth';
import { useInstances } from '../AppShell';
@@ -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);
@@ -61,6 +150,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const audioRef = useRef<VncAudio | null>(null);
const inst = instances.find((i) => i.id === id);
const profile = appProfile(inst?.appType); // 按应用类型显示正确文案(微信/Chromium…)
const appLabel = profile.label;
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
const [probing, setProbing] = useState(true);
@@ -203,18 +294,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_imeiframe 同源共享 localStorage,加载前设好即生效)
// 无感(seamless):enable_ime=true,启用 noVNC 合成 textarea;中文 keysym 已被容器补丁抑制
// 成品由「无感输入」钩子经 xdotool 转发(见 installSeamlessIme)。
// 转发(forward):enable_ime=falseVNC 直接打字纯 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 的音频流(扬声器恒开,无需手动找工具条);
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
@@ -268,7 +368,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
setUploading(false);
if (ok) {
toast(`已上传 ${ok} 个文件到桌面,微信里可直接`, 'ok');
toast(`已上传 ${ok} 个文件到桌面,应用里可直接取`, 'ok');
refreshFiles();
}
};
@@ -281,7 +381,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
};
const delFile = async (name: string) => {
if (!(await confirm({ title: `删除「${name}」?`, body: '将从微信桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
if (!(await confirm({ title: `删除「${name}」?`, body: '将从桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
try {
await api.deleteFile(id, name);
toast('已删除', 'ok');
@@ -347,7 +447,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
return;
}
if (pushClipboardToRemote(t)) {
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
toast('已发送到容器剪贴板,请在应用输入框按 Ctrl+V 粘贴', 'ok');
} else {
toast('发送失败:桌面尚未连接', 'error');
}
@@ -388,7 +488,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const restartInstance = async () => {
const ok = await confirm({
title: '重启该实例?',
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
body: `会重建容器(数据保留),${appLabel}重新启动,约十几秒;用于修复卡死/最小化丢失等。`,
confirmText: '重启',
});
if (!ok) return;
@@ -427,7 +527,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
const title = inst?.name || '微信实例';
const title = inst?.name || '实例';
return (
<div className="ws-page">
@@ -449,11 +549,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"
@@ -507,7 +611,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="spinner" />
<div className="iv-notice-title"></div>
<div className="iv-notice-title">{appLabel}</div>
<div className="iv-notice-sub">
{inst.wechat.message || '请稍候'}
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''}
@@ -517,18 +621,18 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
) : !installed ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? `${appLabel}安装出错` : `${appLabel}尚未安装`}</div>
<div className="iv-notice-sub">
{inst.wechat.phase === 'error'
? inst.wechat.message || '安装失败,可在「管理」重试'
: '该实例容器已就绪,但尚未安装微信'}
: `该实例容器已就绪,但尚未安装${appLabel}`}
</div>
{isAdmin ? (
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
</button>
) : (
<div className="iv-notice-sub"></div>
<div className="iv-notice-sub">{appLabel}</div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
@@ -545,15 +649,15 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
ref={frameRef}
className="iv-frame"
src={desktopUrl(id)}
title="电脑版微信"
title={`${appLabel} · 实例桌面`}
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
onLoad={() => {
setFrameLoaded(true);
setTimeout(() => {
focusFrame(); // 加载完把键盘焦点交给 VNC
injectVncStyle(); // 让原生控制条在深色背景下可见
// 注意:不再调用 patchVncIme —— enable_ime 已关,直接打字走纯 keysym(英文/数字正常)
// 中文底部「中文输入条」承担。那套合成拦截既脆弱又会损坏混合输入,已弃用
// 无感输入模式的键盘钩子由单独的 effect(依赖 inputMode/frameLoaded)安装,不在此处
// 转发模式则 enable_ime=false,直接打字走纯 keysym(英文/数字正常),中文底部输入条
}, 500);
}}
/>
@@ -562,7 +666,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<div className="iv-loading">
<div className="spinner" />
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-loading-sub">{profile.enterHint}</div>
<div className="iv-loading-sub"></div>
{!window.isSecureContext && (
<div className="iv-loading-warn"> HTTPS 访</div>
@@ -601,8 +705,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
<div className="drop-card">
<div className="drop-icon"></div>
<div className="drop-title"></div>
<div className="drop-sub">+ / </div>
<div className="drop-title"></div>
<div className="drop-sub">+ / </div>
</div>
</div>
)}
@@ -640,7 +744,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
{uploading ? '上传中…' : ' 选择文件上传'}
</button>
<div className="files-hint">~/Desktop</div>
<div className="files-hint">~/Desktop</div>
<div className="files-list">
{files.length === 0 && (
<div className="muted small" style={{ padding: '10px 2px' }}>
@@ -674,23 +778,23 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
className="clip-area"
value={clipText}
onChange={(e) => setClipText(e.target.value)}
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
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
http 访 Ctrl+V
</div>
</div>
)}
</div>
{imeBar && (
{inputMode === 'forward' && (
<div className="iv-imebar">
<textarea
className="iv-imebar-input"
@@ -702,7 +806,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
sendImeText();
}
}}
placeholder="中文输入这里 → 回车送进微信(先点好微信的输入框)。Shift+回车换行。"
placeholder="中文输入这里 → 回车送进应用(先点好应用的输入框)。Shift+回车换行。"
rows={1}
/>
<button
+108 -20
View File
@@ -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;
@@ -1380,15 +1397,6 @@ button {
flex: none;
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
color: #fff;
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.sb-dot {
position: absolute;
@@ -1545,15 +1553,6 @@ button {
flex: none;
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
color: #fff;
font-weight: 700;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.home-card-main {
position: relative;
@@ -2024,3 +2023,92 @@ button {
opacity: 0.4;
cursor: default;
}
/* ── 新建实例·应用类型选择 ─────────────────────────────── */
.app-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
gap: 8px;
}
.app-pick {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px 6px;
border: none;
border-radius: var(--r-small);
background: var(--trough);
color: var(--text);
cursor: pointer;
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
}
.app-pick:hover:not(:disabled):not(.sel) {
background: var(--base);
}
.app-pick.sel {
background: var(--surface);
box-shadow: var(--crease-accent);
}
.app-pick.sel .app-pick-name {
color: var(--wx-green-dark);
}
.app-pick:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.app-pick-name {
font-size: 14px;
font-weight: 600;
}
.app-pick-desc {
font-size: 11px;
color: var(--muted);
}
/* ── 实例图标编辑 ─────────────────────────────────────── */
.icon-edit-top {
display: flex;
align-items: center;
gap: 12px;
}
.icon-crop {
position: relative;
height: 240px;
border-radius: var(--r-small);
overflow: hidden;
background: #1a1d24;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
gap: 8px;
}
.icon-pick {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: none;
border-radius: var(--r-small);
background: var(--trough);
cursor: pointer;
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
transition: background 0.15s, box-shadow 0.15s, transform 0.12s;
}
.icon-pick span {
font-size: 11px;
color: var(--muted);
}
.icon-pick.sel {
background: var(--surface);
box-shadow: var(--crease-accent);
}
.icon-pick.sel span {
color: var(--wx-green-dark);
}
.icon-pick:active {
transform: scale(0.96);
}