60 Commits

  • fix(watchdog): VNC 响应性探测默认关闭,可用 env 重新开启
    实测健康实例 VNC 探测 ~1ms 响应;但宿主级 CPU/IO 偶发争用(如同机重 docker build)
    会让 8s 探测超时被误判为 stall,从而重启正常实例(用户反馈"浏览器经常重启")。
    
    - 新增 WOC_WATCHDOG_HEALTH_FAILS(默认 0=关闭该探测);正整数 N = 连续 N 次无响应才重启。
    - 内存阈值自愈(soft/hard)不受影响,照常工作。
    - .env.example 补充说明。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • style(panel): 诊断/关于 改为扁平区块,修按钮断行与缺失的 chip 类
    这两个区块各自只有单一内容,卡片外框显得厚重;按钮作为 flex 项被压窄导致
    "导出诊断包(.tar.gz)" / "查看面板日志" 在按钮内断行。
    
    - 去掉 inst-card 卡片外框,改为扁平 settings-block:标题下直接排描述/控件/操作。
    - 主按钮 s-btn 固定内边距 + white-space:nowrap + flex:none,不再被压窄断行;
      次要操作(查看面板日志 / 检查更新 / 发布日志)改 btn-text 文字链接,narrow 下自然换行。
    - 修复:时间范围 chip 漏了 .chip 基类(只有 .chip-toggle 无内边距/圆角),补回 'chip chip-toggle'。
    - 「导出诊断包(.tar.gz)」按钮文案精简为「导出诊断包」,格式说明移到脚注。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • chore(docker): Chromium 关闭后台 phone-home(--disable-background-networking)
    Chromium 在受限网络(NAS/被墙)下后台不断尝试连 Google 推送(GCM)/组件更新/变体下载
    并失败,刷屏 "gcm ConnectionHandler failed net error: -2" 等。加 --disable-background-networking
    关掉这些后台流量。
    
    仅影响后台 phone-home,不影响前台网页加载与真实网络错误提示(已验证 Chromium 正常启动、
    前台浏览不受影响)。属安全的标准容器化 Chromium 用法。
    
    注:日志里的 gcm/dbus 噪声本就无害,从来不是黑屏/重启的原因;此 flag 主要是减少受限网络下
    的无谓后台请求。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • feat(panel): 全局日志系统 + 一键诊断包导出
    单实例「日志」只记录该实例日志,无从排查跨实例/容器层面的问题(首个实例创建卡死、
    打开实例黑屏不可用、升级失败等)。新增面板级全局日志 + 一键诊断包:
    
    - logs.ts(新):统一持久化日志(面板数据卷,跨重建保留)。实例日志原语从 docker.ts
      迁来;新增 appendPanelLog/readPanelLog、按时间裁剪 filterSince、一年保留 pruneOldLogs、
      时间范围 24h/7d/30d/1y。无 docker 依赖避免循环。
    - 仪表化:实例创建(含镜像拉取前后,定位首次拉取卡死)、删除、启停、重启、升级、
      应用安装/更新、看门狗自愈 均写入面板全局日志(同时回显 stdout)。
    - docker.ts buildDiagnostics:打包 system.txt(Docker/镜像/系统)+ panel.log +
      instances/<id>.log(容器状态 inspect + 持久日志 + 实时日志)+ containers.txt(全部
      woc-* 容器清单,含残留),手搓多文件 tar.gz(沿用既有无依赖 tar 风格)。
    - 路由:GET /api/admin/diagnostics?range=(导出 tar.gz)、GET /api/admin/panel-log?range=;
      启动 + 每 24h 跑 pruneOldLogs。
    - 前端:管理页「诊断与日志」区——时间范围(24h默认/7d/30d/1y) + 导出诊断包 + 查看面板日志。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • fix(panel): 开发版构建的版本展示,不再误标「已是最新」
    本地/未发布构建(dev / dev-<sha>)无法与发布版做语义化比较,之前会错误显示
    「· 已是最新」。现在:
    
    - 「关于」卡:当前版本非 vX.Y.Z 时标「开发版」,仅把最新发布版作为信息展示
      (当前版本 dev-xxx · 最新发布 v1.2.0),不显示「已是最新」、不触发红点。
    - build-local.sh 默认烤入 dev-<短SHA>(而非裸 dev),便于辨识本地构建。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • feat(panel): 显示构建版本号 + 自动检测新版(Docker Hub/GHCR 红点)
    管理界面此前看不到面板版本,也无从知道有没有新版可升。现在:
    
    - 构建时把版本号烤进面板镜像:Dockerfile 新增 ARG/ENV WOC_VERSION(放末尾不
      破坏依赖缓存);release.yml 用 git tag 注入(vX.Y.Z,手动触发为 dev-<sha>),
      仅面板镜像消费;build-local.sh 支持 --build-arg(默认 dev)。
    - 后端 version.ts:best-effort 查询 Docker Hub 与 GHCR 上 woc-panel 的语义化
      标签取最大值,与当前版本比对;启动后 4s 首检 + 每 6h 复检 + 接口惰性触发,
      失败静默(离线/被墙/私有源不报错、不显红点)。命名空间从 WOC_WECHAT_IMAGE 推断。
    - 接口:GET /api/version(任意登录用户读缓存)、POST /api/admin/version/check
      (管理员手动重查)。
    - 前端:管理页「关于」卡显示当前版本/最新版/升级提示/检查更新/发布日志链接;
      侧栏「管理」入口在有新版时点红点(仅管理员)。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • docs(readme): 写入浏览器实例支持 + 精简整理
    v1.2.0 起云微是多应用平台(微信 + Chromium 浏览器),README 仍是纯微信
    视角,补齐并整理:
    
    - 新增「浏览器实例(登录网页版社媒)」章节:Chromium 随镜像就绪、登录态
      写入数据卷常驻、多端共享、中文输入/文件/剪贴板共用一套;顶部导航加锚点。
    - 标题/简介/一句话原理/核心特性/快速开始步骤/「面板能做什么」表 全部从
      「微信」泛化为「实例(微信 / 浏览器)」;新建按钮文案对齐为「新建实例」。
    - 精简冗杂:合并过长的「为什么默认 Docker Hub」blockquote 为一行。
    - 资源占用补充浏览器实例量级说明;安全须知覆盖社媒账号;
      路线图标记多应用平台完成、日志改为「持久化(跨容器重建保留)」。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • feat(panel): 持久化实例日志,跨容器重建保留
    实例容器日志随重建(重启/升级/看门狗自愈)即丢失,用户看不到"上次为何
    重启/崩溃"(浏览器实例常因内存触顶被看门狗重启)。现把生命周期事件 + 重启
    原因 + 重建前的容器日志快照,追加到面板数据卷 /data/logs/<id>.log,跨重建保留。
    
    - docker.ts: appendInstanceLog/readInstanceLog/snapshotContainerLog/deleteInstanceLog;
      日志目录与 accounts.json 同卷(宿主 ./data-panel 持久化),单实例上限 ~400KB
      超限截半保留最近;id 十六进制校验防路径注入。
    - runInstance 删旧容器前先快照其最后日志、启动后记"容器已启动";
      stopInstance 记"容器已停止";removeInstance 彻底删除时清理日志文件。
    - 看门狗 recover() 写入自愈原因(hard/soft/health + 内存明细)。
    - 日志接口返回「持久化历史 + 本次容器实时日志」两段。
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • 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>
  • feat(panel): 管理员数据卷管理(整卷备份/恢复 + 文件浏览器)
    管理页 → 实例「管理」→ 数据卷(仅 admin)。解决大量用户"把 PC 微信数据迁移上 docker"的诉求。
    
    - 整卷备份:流式打包 /config 为 .tar.gz 下载(大文件不入内存);恢复:上传覆盖回 /config。
      machine-id 存在卷内随包迁移 → 跨 woc 实例恢复可保留聊天记录。
    - 文件浏览器:浏览/上传/上传并解压(.tar/.tar.gz)/下载/改名/移动/删除;PC 数据打包上传解压后重启实例。
    - 全程在运行中的实例上操作(exec + docker cp,运行容器才可 exec);恢复为全量覆盖,强提示并建议重启。
    - 安全:仅 admin;路径严格限制在 /config、禁止 .. 穿越;上传落地为 abc 属主。
    - docker.ts 抽出 extractSingleFileFromTar 复用(PAX 头跳过),新增 list/mkdir/move/delete/upload/
      extract/download/backup(stream)/restore;index.ts 加 9 个 /volume 管理路由;前端 VolumeManager 弹窗
      + 线性 SVG 图标(替代渲染不一致的 emoji);新增 doc/数据卷管理.md。
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • ci(telegram): render release/issue notes as Telegram HTML (not raw markdown)
    GitHub-Flavored Markdown ≠ Telegram MarkdownV2, so the old plain-text send
    showed literal ## ** | etc. New .github/scripts/tg-notify.mjs converts GFM
    → Telegram-safe HTML (<b>/<i>/<code>/<pre>/<a>; headers→bold, tables→· rows,
    lists→•, quotes→▎), escapes <>&, and falls back to plain text if Telegram
    rejects the HTML. Adds workflow_dispatch to telegram-notify so you can send
    the latest release rendered for testing without cutting a new release.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat(admin/ci): 管理页折叠菜单 + 空状态/主页优化 + Telegram 命令机器人
    管理页 UI/UX
    - 实例卡片操作改为「管理」分类折叠菜单(默认收起,点开按 运维/设置/危险 分组的
      文字操作),替代之前难辨认的图标排;删除单独成组、红色,降低误点
    - 修复展开一张卡片时同行其它卡片被 grid 拉等高(inst-grid align-items:start)
    - 管理页空状态(无实例/无子账号)改为图标+标题+说明+引导按钮
    - 主页实例卡片加副行(状态·微信版本)、悬停上浮高亮
    
    Telegram 命令机器人(轮询版,纯 GitHub Actions,无服务器)
    - .github/workflows/telegram-bot.yml + scripts/telegram-bot.mjs
    - 私聊/群组命令:/help /releases /release <tag> /issues /issue <编号>
    - cron 每 5 分钟 getUpdates,处理后用 offset 向 Telegram 确认,无需持久化存储
    - 受 vars.TELEGRAM_BOT_ENABLED 开关;命令非实时(cron 限制),文档已说明
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • ci: Telegram notify on release / new issue (serverless via Actions)
    New .github/workflows/telegram-notify.yml: on release published or issue
    opened, send a message to a Telegram group via @WechatOnCloudBot. Runs on
    GitHub Actions (no server). Gated on vars.TELEGRAM_CHAT_ID so unconfigured
    forks skip safely. Arbitrary text passed via env (no script injection),
    sent as plain text with --data-urlencode (no markdown parse breakage).
    Setup documented in doc/发布到GHCR.md.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • feat: 中文输入条 + 文件下载/另存修复 + 卡连接自愈 + CF/MAC/音频
    中文输入(彻底改造,弃用脆弱的 VNC IME 拦截)
    - 关闭 KasmVNC enable_ime:VNC 直接打字回归纯 keysym,英文/数字正常、不再损坏
    - 新增底部「中文输入条」:面板真实 textarea 原生输入法 → POST /type → 容器内 xclip+xdotool
      粘贴进微信,可靠且与浏览器/输入法无关。flex 列布局(nav/画面/输入条三者并列不遮挡),
      牛奶布艺主题配色,可一键收起。
    
    稳定性 / 自愈
    - watchdog 新增响应性探测:实例 I/O/服务 stall(进程在、显示在线但读不出 VNC 文件、永远"正在
      连接桌面")时,连续 2 次无响应即自动重启自愈
    - 前端 12s 未加载出来 → 「桌面无响应」+ 重新连接/重启,不再无限转圈
    - PWA 新 SW 接管即自动重载一次,更新一刷即生效(修"改了仍看旧界面")
    
    文件
    - 下载:正确解析 tar、跳过 PAX 扩展头(中文名文件曾因此大小错误/损坏)
    - 另存:每次启动确保 /config/Desktop 归 abc,修微信另存"保存失败"
    
    安全 / 伪装
    - Host 白名单支持 *.example.com 通配 + X-Forwarded-Host(修 CF 反代域名仍被拒)
    - 设备伪装新增真实网卡 MAC(厂商 OUI,替代容器本地管理位 MAC)
    
    音频:扬声器自动连接(首个手势激活)、焦点离开自动断、麦克风(HTTPS)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • docs: add社媒 links (Twitter / Telegram) to README
    Hero badges + a 交流与关注 section: x.com/gloridust1024, t.me/WechatOnCloud.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • fix(P0): unique persistent machine-id per instance + manual reset
    All instances shared the image-baked machine-id (a67bf09f...), so Tencent
    saw every WechatOnCloud account worldwide as one "device" — a textbook
    device-farm signal triggering risk control and the forced-logout loop
    reported across old and new versions.
    
    - docker/woc-identity.sh: new /custom-cont-init.d/00-woc-identity hook —
      generates a unique machine-id on first start, persists it in the data
      volume (survives restart/upgrade/recreate), writes /etc/machine-id +
      /var/lib/dbus/machine-id, removes /.dockerenv. Existing instances get a
      fresh unique id on first upgraded start (volume lacks the file).
    - regenInstanceMachineId + POST /api/admin/instances/:id/regen-machine-id:
      roll a brand-new device id and restart, for accounts re-flagged by risk
      control. Gated on the hook being present (old image → instructs upgrade).
    - Admin 实例卡片「安全」弹窗新增「重置设备 ID 并重启」。
    
    Verified: two fresh containers get distinct machine-ids; id persists across
    restart; regen (rm persisted file + restart) yields a new persistent id.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
  • ci: optional Docker Hub mirror in release workflow
    - release.yml: dual-push to GHCR + Docker Hub when vars.DOCKERHUB_USERNAME
      is set; falls back to GHCR-only when unset (no behavior change for forks).
    - .env.example: surface docker.io as a first-class WOC_IMAGE_PREFIX option.
    - doc/发布到GHCR.md: document the one-time Variable + Secret setup and the
      prerequisite of pre-creating the public repos on hub.docker.com.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • docs(security): clarify multi-host allowlist + echo rejected host in 400
    - .env.example: surface multi-domain syntax (PANEL_ALLOWED_HOSTS=a,b,c),
      IPv6 literal example, and reverse-proxy troubleshooting tip.
    - index.ts: 400 response includes the rejected `host` and a hint pointing
      at PANEL_ALLOWED_HOSTS — drops the diagnostic floor when reverse-proxy
      Host-passthrough is misconfigured.
    
    Follow-up to #13 (DNS-rebinding allowlist).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • Merge pull request #13 from aaronjmars/security/host-allowlist-dns-rebinding
    fix(security): gate panel Host header to block DNS rebinding
  • fix(security): gate panel Host header to block DNS rebinding
    Without Host validation, a malicious page the operator visits can use DNS
    rebinding to point a hostname at the panel's loopback / LAN IP and drive
    every authenticated API from the operator's own browser — including the
    docker.sock-backed admin endpoints. The README's "intranet-only" guidance
    does not cover this: the browser is the trust-boundary crossing.
    
    Add an onRequest hook (plus a Host check on raw WebSocket upgrades) that
    allows loopback + RFC1918 LAN by default and accepts public hostnames via
    PANEL_ALLOWED_HOSTS (documented in .env.example and threaded through
    docker-compose.yml). 35 inject()-driven assertions; tsc --noEmit clean.
    
    Detected by Aeon + manual review (DNS-rebinding-gate axis).
    Severity: high
    CWE-346 (Origin Validation Error)
  • Merge pull request #12 from huglemon/fix/cjk-ime-paste-fallback
    修复中文 IME 输入大量丢字:增加容器内剪贴板粘贴兜底路径
44 changed files with 5600 additions and 437 deletions
+78 -8
View File
@@ -8,13 +8,16 @@ WOC_PASSWORD=wechat
# 同时作用于面板镜像和新建微信实例所用镜像。
WOC_VERSION=latest
# 镜像源前缀(registry + 命名空间)。默认 GHCR 官方
# 中国大陆网络拉 ghcr.io 常 TLS 超时,可改用 GHCR 反代镜像(前提:GHCR 包已设为 Public):
# WOC_IMAGE_PREFIX=ghcr.nju.edu.cn/gloridust # 南京大学镜像站反代 ghcr.io(较稳定)
# 反代会把 ghcr.io/gloridust/xxx 映射为 <反代>/gloridust/xxx。
# 若用自己的国内仓库(阿里云 ACR / 腾讯 TCR / 华为 SWR),填该仓库的完整前缀即可,例如:
# WOC_IMAGE_PREFIX=registry.cn-hangzhou.aliyuncs.com/你的命名空间
WOC_IMAGE_PREFIX=ghcr.io/gloridust
# 镜像源前缀(registry + 命名空间)。本项目镜像同步发布到 Docker Hub 和 GHCR。
#
# 默认走 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
#
# 反代会把 docker.io/gloridust/xxx(或 ghcr.io/gloridust/xxx)映射为 <反代>/gloridust/xxx。
WOC_IMAGE_PREFIX=docker.io/gloridust
# 宿主用户 uid/gid(飞牛上用 `id` 命令查看;单用户 NAS 一般是 1000)。
# 透传给每个微信实例容器,决定面板数据与微信数据卷的属主。
@@ -28,8 +31,31 @@ WOC_TZ=Asia/Shanghai
# 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。
WOC_HTTP_PORT=36080
# 面板允许的 Host 头白名单。用于阻止 DNS rebinding:攻击者控制的域名解析到面板
# LAN/loopback 地址后,浏览器视其为同源、自动带上面板 cookie,能从受害者浏览器内
# 发起任何已鉴权 API 调用(含挂载 docker.sock 的管理端点);sameSite=lax 不挡此攻击。
#
# 默认始终放行:localhost / 127.0.0.1 / ::1 + RFC1918 私网(10/8、172.16-31/12、
# 192.168/16、169.254/16)—— 覆盖直连 NAS / 局域网访问场景,**不需要改任何东西**。
#
# 套 HTTPS 反代部署(Caddy / nginx / 飞牛 内置反代 / Cloudflare Tunnel 等)时,把对外
# 域名加进来。**支持多个域名,用英文逗号分隔**;填免端口主机名,大小写不敏感:
#
# 单域名: PANEL_ALLOWED_HOSTS=woc.example.com
# 多域名: PANEL_ALLOWED_HOSTS=woc.example.com,woc.lan,wechat.mynas.cn
# 通配子域: PANEL_ALLOWED_HOSTS=*.example.com (匹配 a.example.com,不含裸 example.com
# 含 IPv6 字面量:PANEL_ALLOWED_HOSTS=[2001:db8::1]
#
# 改完务必用 **docker compose up -d** 让容器带新环境重建;只 `docker restart` **不会**加载新值!
#
# 排错:若反代/Cloudflare 部署后仍看到 400 "Host header not allowed",错误响应里会回显面板实际
# 收到的 host / forwardedHost —— 把那个值(或其通配)加进本白名单即可。面板也会自动接受
# X-Forwarded-Host(可信反代设置),故多数反代无需额外配置。
PANEL_ALLOWED_HOSTS=
# ── 音频 / 麦克风 / 摄像头 ───────────────────────────────────
# 音频(听)开箱即用,进入桌面后点 KasmVNC 左侧工具条的扬声器开启。
# 音频(听)**自动开启**,进入桌面、点一下画面(浏览器自动播放策略要求一次手势)即可出声,
# 无需手动找工具条。仅当前聚焦的实例出声,切走/切到别的实例会自动断开,避免多端串音。
# 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板
# (或 localhost)。生产环境务必给面板套 HTTPS(反代/证书),否则浏览器会禁用麦克风与摄像头。
#
@@ -43,3 +69,47 @@ WOC_HTTP_PORT=36080
# b) 显式:在下面列出设备(逗号分隔),并可删掉那条 /host-dev 挂载。
# 留空 = 不映射摄像头(音频/麦克风不受影响)。
WOC_VIDEO_DEVICES=
# ── 实例资源 / 稳定性 ───────────────────────────────────────
# GPU 硬件编码:baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3dGPU 加速编码)。
# 在 WSL2 / 虚拟 GPU(如 Docker Desktop on Windows)下,该路径会导致 Xvnc 内存持续膨胀
# (实测 21 小时涨到 ~9GB)。本项目已强制软件渲染(LIBGL_ALWAYS_SOFTWARE=1),hw3d 对微信
# 这类静态界面收益甚微,故默认关闭。仅在你有真实可用的 GPU 且确认无内存问题时再设为 1 启用。
WOC_ENABLE_GPU=
# 每个微信实例容器的内存上限(GiB)。默认空 = 不限制(与旧版一致)。
# 作为兜底:万一某进程异常增长,命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC,
# 避免拖垮整台宿主。常规使用单实例约需 1~1.5GiB,设 3~4 较稳妥。
WOC_INSTANCE_MEM_GB=
# ── 设备伪装(降低被微信判"非真实设备"风控的概率) ───────────
# 每个实例默认已自动做:唯一且持久的 machine-id(避免全网共用同一个,触发"设备农场"风控)、
# 像个人电脑的 hostname(不再是 woc-wx-<hex>)、移除 /.dockerenv 容器标记。这些恒定开启。
#
# WOC_SPOOF_OS 是否把 /etc/os-release 伪装成 deepin(微信官方支持的发行版,且 Deepin 基于
# Debian、与本镜像用户态一致)。默认 1(开);设 0 恢复显示真实 Debian。
# 说明:设备伪装是「尽力而为」,非保证不被封;详见 doc/设备伪装.md。
WOC_SPOOF_OS=1
# ── 自愈 watchdog(应对 KasmVNC/Xvnc 长跑内存泄漏) ───────────
# 实测 Xvnc 长跑 24h 可膨胀到 ~9GiB,原因在 KasmVNC/Xvnc 自身的 framebuffer / 软渲染 cache
# 累积,不归本项目控制。面板内置一个 watchdog:周期性检查每个 running 实例的 working set
# 内存(= docker stats 显示值),按两档阈值(这里设全局默认;每个实例可在「管理 → 实例卡片
# → 安全」按钮里单独覆盖)触发主动 stop + run(聊天数据在卷里、登录态本来也会定期失效,
# 可控时机自愈优于撑到 OOM):
#
# soft 超过且【当前没人在远程会话】才重启 → 柔和自愈,不打扰使用者
# hard 超过即重启(无视会话),防止 OOM 拖垮宿主
#
# WOC_INSTANCE_MEM_SOFT_MB soft 阈值(MiB),默认 1500
# WOC_INSTANCE_MEM_HARD_MB hard 阈值(MiB),默认 2500(兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB
# WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒,默认 300(5 分钟);最小 60;0 = 关闭整个 watchdog
# WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启实例;默认 0 = 关闭该探测(仅保留内存自愈)。
# 健康实例探测约 1ms,但宿主级 CPU/IO 偶发争用会让探测超时被误判为卡死而重启正常实例,故默认关闭;
# 想要"卡在正在连接桌面"时自动兜底,可设为正整数(如 3 = 连续 3 次≈15 分钟无响应才重启)。
#
# 调参建议:日常活跃单实例约 1500 MiB;soft 应略高于此(如 2000);hard 远低于宿主可用内存。
WOC_INSTANCE_MEM_SOFT_MB=1500
WOC_INSTANCE_MEM_HARD_MB=2500
WOC_WATCHDOG_INTERVAL_SEC=300
# WOC_WATCHDOG_HEALTH_FAILS=0
+136
View File
@@ -0,0 +1,136 @@
// Telegram 命令机器人(轮询版)——跑在 GitHub Actions cron 上,无需任何服务器。
//
// 工作方式:每次运行调用 getUpdates 拉取「自上次确认以来」的待处理更新,逐条处理命令并回复,
// 最后用 offset=最后一条+1 再调一次 getUpdates 向 Telegram「确认」(这些更新随即被服务端清掉,
// 下次不再返回)。Telegram 自己保存 offset 状态(未确认的更新保留 24h),因此本脚本无需任何持久化存储。
//
// 支持私聊与群组(群组里以 /命令 形式发送即可,命令不受 bot 隐私模式影响)。
// 命令:/help /releases /release <tag> /issues /issue <编号>
//
// 局限:受 GitHub cron 最小 5 分钟间隔限制,命令有延迟(非实时)。要实时需改用 webhook(需 serverless 端点)。
const TG = process.env.TG_TOKEN;
const GH = process.env.GH_TOKEN || '';
const REPO = process.env.REPO; // owner/repo
if (!TG) {
console.log('TELEGRAM_BOT_TOKEN 未配置,跳过');
process.exit(0);
}
const tgUrl = (method) => `https://api.telegram.org/bot${TG}/${method}`;
const ghHeaders = {
accept: 'application/vnd.github+json',
'user-agent': 'woc-telegram-bot',
...(GH ? { authorization: `Bearer ${GH}` } : {}),
};
async function tg(method, params) {
const r = await fetch(tgUrl(method), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(params),
});
return r.json();
}
async function gh(path) {
const r = await fetch(`https://api.github.com/repos/${REPO}${path}`, { headers: ghHeaders });
if (!r.ok) throw new Error(`GitHub ${path}${r.status}`);
return r.json();
}
const trunc = (s, n) => (s && s.length > n ? s.slice(0, n) + '…' : s || '');
const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text, disable_web_page_preview: true });
const HELP = [
'🤖 云微 WechatOnCloud 机器人命令:',
'',
'/releases — 最近发布列表',
'/release <tag> — 某版本详情(省略 = 最新)',
'/issues — 打开中的 issue 列表',
'/issue <编号> — issue 详情',
'/help — 显示本帮助',
'',
'(轮询版,命令可能有几分钟延迟)',
].join('\n');
async function handle(cmd, arg, chatId) {
switch (cmd) {
case '/start':
case '/help':
return send(chatId, HELP);
case '/releases': {
const rels = await gh('/releases?per_page=8');
if (!rels.length) return send(chatId, '暂无 release');
const lines = rels.map(
(r) =>
`${r.tag_name}${r.name && r.name !== r.tag_name ? ' — ' + r.name : ''} (${(r.published_at || '').slice(0, 10)})`,
);
return send(chatId, '📦 最近发布:\n' + lines.join('\n') + '\n\n用 /release <tag> 看某版详情');
}
case '/release': {
const rel = arg ? await gh(`/releases/tags/${encodeURIComponent(arg)}`) : await gh('/releases/latest');
const title = `${rel.tag_name}${rel.name && rel.name !== rel.tag_name ? ' · ' + rel.name : ''}`;
return send(
chatId,
`📦 ${title}\n发布于 ${(rel.published_at || '').slice(0, 10)}\n\n${trunc(rel.body, 2500)}\n\n🔗 ${rel.html_url}`,
);
}
case '/issues': {
const items = (await gh('/issues?state=open&per_page=10&sort=updated')).filter((i) => !i.pull_request);
if (!items.length) return send(chatId, '🎉 当前没有打开的 issue');
const lines = items.map((i) => `• #${i.number} ${trunc(i.title, 60)}`);
return send(chatId, `🐛 打开中的 issue${items.length}):\n` + lines.join('\n') + '\n\n用 /issue <编号> 看详情');
}
case '/issue': {
if (!arg) return send(chatId, '用法:/issue <编号>');
const i = await gh(`/issues/${encodeURIComponent(arg)}`);
if (i.pull_request) return send(chatId, `#${arg} 是个 PR,不是 issue`);
return send(
chatId,
`🐛 #${i.number} ${i.title}\n状态:${i.state} · by ${i.user?.login}\n\n${trunc(i.body, 2500)}\n\n🔗 ${i.html_url}`,
);
}
default:
return; // 未知命令静默忽略,避免群里刷屏
}
}
(async () => {
// 短轮询拉取待处理更新(只要 message)
const res = await (
await fetch(tgUrl('getUpdates') + '?timeout=0&allowed_updates=' + encodeURIComponent('["message"]'))
).json();
if (!res.ok) {
console.error('getUpdates 失败:', JSON.stringify(res));
process.exit(res.error_code === 409 ? 0 : 1); // 409 = 设了 webhook,与轮询冲突,直接退出
}
const updates = res.result || [];
let maxId = 0;
for (const u of updates) {
maxId = Math.max(maxId, u.update_id);
const m = u.message;
if (!m || !m.text) continue;
const text = m.text.trim();
if (!text.startsWith('/')) continue;
const parts = text.split(/\s+/);
const cmd = parts[0].split('@')[0].toLowerCase(); // 去掉 @botname 后缀
const arg = parts.slice(1).join(' ').trim();
try {
await handle(cmd, arg, m.chat.id);
} catch (e) {
await send(m.chat.id, '⚠️ 出错了:' + (e?.message || e));
}
}
// 向 Telegram 确认已处理(清掉这些更新,下次不再返回)
if (maxId) {
await fetch(tgUrl('getUpdates') + `?offset=${maxId + 1}&timeout=0`);
}
console.log(`processed ${updates.length} update(s)`);
})().catch((e) => {
console.error(e);
process.exit(1);
});
Binary file not shown.
+29 -1
View File
@@ -50,11 +50,37 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 可选:同时推 Docker Hub。仅当仓库设置里加了 vars.DOCKERHUB_USERNAME 时启用,
# 让 fork 用户开箱即可走 GHCR;想加 Docker Hub 双推只需配 1 个 Variable + 1 个 Secret。
# 配置方法:repo Settings → Secrets and variables → Actions
# · Variables → DOCKERHUB_USERNAME = 你的 Docker Hub 用户名
# · Secrets → DOCKERHUB_TOKEN = Docker Hub Access Tokenhub.docker.com → Account Settings → Personal access tokens
- name: Log in to Docker Hub
if: vars.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 解析烤进面板镜像的版本号:打 tag / 发 Release 时用 tag 名(vX.Y.Z),手动触发则用 dev-<短SHA>。
- name: Resolve build version
id: ver
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Docker metadata (tags + labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
# metadata-action 接受多行 images:每行一个目标 registrybuild-push-action 会同时推。
# Docker Hub 那行只在配了 DOCKERHUB_USERNAME 时出现;否则只剩 GHCR 一行(与旧行为一致)。
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
${{ vars.DOCKERHUB_USERNAME != '' && format('docker.io/{0}/{1}', vars.DOCKERHUB_USERNAME, matrix.image) || '' }}
# 语义化标签:vX.Y.Z → X.Y.Z / X.Y / X;默认分支额外打 latest
tags: |
type=semver,pattern={{version}}
@@ -69,6 +95,8 @@ jobs:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
# 仅面板镜像消费 WOC_VERSION(实例镜像不展示版本号),避免「未使用的 build-arg」告警。
build-args: ${{ matrix.name == 'panel' && format('WOC_VERSION={0}', steps.ver.outputs.version) || '' }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+40
View File
@@ -0,0 +1,40 @@
name: telegram-bot
# Telegram 命令机器人(轮询版)——无需服务器,仅靠 GitHub Actions cron。
# 私聊 / 群组都可用命令查询:/help /releases /release <tag> /issues /issue <编号>。
#
# 启用(一次性):
# 1) 已配置 telegram-notify 用到的 TELEGRAM_BOT_TOKENSecret)即可复用;
# 2) 仓库 Settings → Secrets and variables → Actions → Variables 新建
# TELEGRAM_BOT_ENABLED = true (未设为 true 则本工作流不运行,避免空跑)
# 3) 把机器人拉进群组 / 在私聊里 /start。
#
# 局限:cron 最小 5 分钟且可能再延后 → 命令非实时;GitHub 会在仓库 60 天无活动时暂停定时任务。
# 想立即处理一次:Actions → telegram-bot → Run workflowworkflow_dispatch)。
on:
schedule:
- cron: '*/5 * * * *'
workflow_dispatch: {}
permissions:
contents: read
issues: read
# 避免轮询任务并发重叠重复处理;新触发取消正在跑的(残留未确认更新下次重处理,无副作用)
concurrency:
group: telegram-bot
cancel-in-progress: true
jobs:
poll:
runs-on: ubuntu-latest
if: ${{ vars.TELEGRAM_BOT_ENABLED == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Poll Telegram & handle commands
env:
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: node .github/scripts/telegram-bot.mjs
+55
View File
@@ -0,0 +1,55 @@
name: telegram-notify
# 新版本发布 / 新 issue 时,把内容(GitHub Markdown → Telegram HTML 渲染)推送到 Telegram 群组。
# 跑在 GitHub Actions 上,无需服务器;未配置 TELEGRAM_CHAT_ID 则自动跳过。
#
# 一次性配置(仓库 Settings → Secrets and variables → Actions):
# · Variables → TELEGRAM_CHAT_ID = 群组 chat id(形如 -1001234567890;取法见 doc/发布到GHCR.md
# · Secrets → TELEGRAM_BOT_TOKEN = @BotFather 的 token
# 并把机器人拉进群组。
#
# 手动测试渲染效果:Actions → telegram-notify → Run workflow(会把「最新 release」渲染后发到群)。
on:
release:
types: [published]
issues:
types: [opened]
issue_comment:
types: [created]
workflow_dispatch: {}
permissions:
contents: read
jobs:
notify:
runs-on: ubuntu-latest
if: ${{ vars.TELEGRAM_CHAT_ID != '' }}
steps:
- uses: actions/checkout@v4
- name: Render & send to Telegram
env:
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TG_CHAT: ${{ vars.TELEGRAM_CHAT_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EVENT: ${{ github.event_name }}
# 经 env 传任意文本,避免命令注入
R_TAG: ${{ github.event.release.tag_name }}
R_NAME: ${{ github.event.release.name }}
R_URL: ${{ github.event.release.html_url }}
R_BODY: ${{ github.event.release.body }}
I_NUM: ${{ github.event.issue.number }}
I_TITLE: ${{ github.event.issue.title }}
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
+159 -265
View File
@@ -1,118 +1,83 @@
# WechatOnCloud
<div align="center">
在飞牛 NASx86_64 / arm64)上运行服务端微信:可管理**多个**微信实例,每个实例是一个独立的微信会话;多个 web 用户通过浏览器访问被授权的实例,实现跨设备消息同步、多端共享。
<img src="doc/img/icon-192.png" width="88" height="88" alt="云微 logo" />
> 设计与选型详见 [技术方案.md](技术方案.md)。
> 部署形态:拉取 GHCR 预构建多架构镜像(或本地自构建),面板按需动态创建微信实例容器。不熟悉 Docker?直接看 [Docker 运行模式详解](#docker-运行模式详解新手向)。
<h1>云微 · WechatOnCloud</h1>
<p><b>在自己的 NAS / 服务器上运行「服务端微信」,多端浏览器共享同一会话</b></p>
<p>不止微信——还能开 <b>Chromium 浏览器实例</b>,登录 Telegram / X / Instagram 等网页版社媒,常驻云端、多端同步</p>
<p>
<a href="https://github.com/Gloridust/WechatOnCloud/stargazers"><img src="https://img.shields.io/github/stars/Gloridust/WechatOnCloud?style=flat-square&logo=github" alt="stars" /></a>
<a href="https://github.com/Gloridust/WechatOnCloud/releases"><img src="https://img.shields.io/github/v/release/Gloridust/WechatOnCloud?style=flat-square" alt="release" /></a>
<a href="https://github.com/Gloridust/WechatOnCloud/issues"><img src="https://img.shields.io/github/issues/Gloridust/WechatOnCloud?style=flat-square" alt="issues" /></a>
<img src="https://img.shields.io/badge/arch-amd64%20%7C%20arm64-2496ED?style=flat-square&logo=docker&logoColor=white" alt="arch" />
<img src="https://img.shields.io/badge/PWA-ready-5A0FC8?style=flat-square" alt="pwa" />
<a href="https://x.com/gloridust1024"><img src="https://img.shields.io/badge/Twitter-@gloridust1024-1DA1F2?style=flat-square&logo=x&logoColor=white" alt="twitter" /></a>
<a href="https://t.me/WechatOnCloud"><img src="https://img.shields.io/badge/Telegram-WechatOnCloud-26A5E4?style=flat-square&logo=telegram&logoColor=white" alt="telegram" /></a>
</p>
<p>
<a href="#快速开始">快速开始</a> ·
<a href="#核心特性">核心特性</a> ·
<a href="#浏览器实例登录网页版社媒">浏览器实例</a> ·
<a href="doc/运行原理.md">运行原理</a> ·
<a href="#安全须知必读">安全须知</a> ·
<a href="doc/技术方案.md">技术方案</a>
</p>
<table>
<tr>
<td width="50%"><img src="doc/img/Screenshot-1.png" alt="云微 · 面板主界面" /></td>
<td width="50%"><img src="doc/img/Screenshot-2.png" alt="云微 · 实例桌面" /></td>
</tr>
</table>
</div>
在飞牛 NASx86_64 / arm64)或任意 Docker 主机上运行服务端微信:面板可管理**多个**实例,每个实例都是一个独立容器——可以是一个**微信**会话,也可以是一个 **Chromium 浏览器**(用来登录 Telegram / X / Instagram 等网页版应用)。多个 web 用户通过浏览器访问被授权的实例,实现跨设备同步、多端共享。**不修改微信客户端。**
**一句话原理**:每个实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 一个应用(官方原版微信,或 Chromium 浏览器),KasmVNC 把画面串到浏览器;同一实例被多个浏览器连 = 共享同一个会话。前面一层自研**面板**是唯一对外入口,经 docker.sock 按需创建/销毁实例并反向代理。
交流群: [@WechatOnCloud](https://t.me/WechatOnCloud)
---
## 工作原理(一句话)
## 核心特性
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**
前面一层自研 **面板(panel** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端
```
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
实例只在 docker 网络内暴露,不直连宿主
```
- 🗂️ **多实例** — 一个面板管理多个独立实例,每个实例独立容器 + 独立数据卷,互不干扰
- 🌐 **多应用(微信 + 浏览器)** — 新建实例时可选**微信**或 **Chromium 浏览器**;浏览器实例用来登录 Telegram / X / Instagram 等网页版社媒,登录态写进数据卷、常驻云端、多端共享。
- 👥 **多端共享 + 权限** — 多浏览器 / 设备共享同一会话;子账号体系,按账号分配可访问的实例(RBAC)
- 🖥️ **PC 式界面** — 左侧实例栏 + 右侧内嵌桌面,侧栏可折叠,移动端自动转抽屉;实例图标可自定义(内置图标 / 上传裁剪)。
- 📦 **微信按需下载 · 浏览器开箱即用** — 镜像不打包微信,面板一键「下载安装 / 更新」带进度条、按架构取包;Chromium 已烤进镜像,创建即用、无需下载。
- 🔁 **实例生命周期** — 启动 / 停止 / 重启 / 升级(拉新镜像重建、保留聊天记录),均在面板内一键完成。
- 📎 **文件传输 + 文本剪贴板** — 拖拽上传 + 下载 + 删除,直达实例桌面 `~/Desktop`;文本可经剪贴板中转送进实例(局域网 http 下也可用)。
- 🧩 **多端协作软锁** — 同一实例多人操作时自动只读 + 申请接管,避免键鼠打架。
- 🔒 **安全优先** — 面板为唯一入口,KasmVNC 凭据服务端注入、永不下发前端;docker.sock 仅管理员可触达。
- 📱 **PWA** — iOS「添加到主屏幕」、桌面 Chrome「安装」当原生 App。
- 🏗️ **多架构** — amd64 / arm64 预构建镜像(Docker Hub + GHCRGitHub Actions 自动发布)。
---
## Docker 运行模式详解(新手向)
## 文档
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
### 0. 先认识 5 个 Docker 概念
| 概念 | 一句话理解 | 在本项目里是什么 |
|------|-----------|------------------|
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
### 1. 本项目有两类容器(运行角色不同)
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
| | ① 面板容器 | ② 微信实例容器 |
|---|-----------|---------------|
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
### 2. 镜像从哪来:两种「构建/获取」模式
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([快速开始](#快速开始)对应方式 A / B):
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|---|--------------------|----------------------|
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
| 前提 | 本机能拉到基础镜像(node、KasmVNC base | GHCR 上已发布且包为公开(见[发布到 GHCR](#发布到-ghcr) |
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[数据持久化](#数据持久化))。
### 3. 从零到能用,整体发生了什么
```
你: docker compose up -d
└─▶ Docker 读取 docker-compose.yml
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
└─▶ 面板通过 docker.sock 指挥 Docker:
├─ docker run 一个【微信实例容器 woc-wx-xxx】
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
└─ 接到同一个 docker 网络(面板才能反代到它)
你: 进入该实例 → 点「下载并安装」
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
你: 点「进入电脑版微信」→ 手机扫码
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
```
### 4. 常用命令速查
```bash
docker compose up -d # 启动面板(首次会拉/用镜像)
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*
docker logs -f woc-panel # 看面板日志
docker logs -f woc-wx-<id> # 看某个微信实例日志
docker volume ls | grep woc # 看所有微信实例的数据卷
```
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
| 文档 | 内容 |
|------|------|
| [运行原理与 Docker 指南](doc/运行原理.md) | 工作原理 + 架构图;面向 Docker 新手的逐步拆解、常用命令、架构自动适配 |
| [部署与运维](doc/部署与运维.md) | 数据持久化、常见问题排查、忘记超管密码的离线找回、目录结构 |
| [设备伪装与风控应对](doc/设备伪装.md) | 唯一 machine-id / 真实 hostname / os-release 伪装;账号被微信强制退出循环时怎么办 |
| [数据卷管理与迁移](doc/数据卷管理.md) | 管理员在面板里备份/恢复整卷、上传 PC 微信数据、浏览管理实例 /config 文件 |
| [发布到 GHCR](doc/发布到GHCR.md) | 用 GitHub Actions 或本机 buildx 把镜像发布到 GHCR |
| [技术方案](doc/技术方案.md) | 完整设计文档与选型权衡 |
---
## 快速开始
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。
> 需已安装 Docker(含 Compose 插件)。x86_64 / arm64 均可。不熟悉 Docker?先读 [运行原理与 Docker 指南](doc/运行原理.md)。
`docker-compose.yml` 引用的是 GHCR 上的镜像 `ghcr.io/gloridust/{woc-panel,wechat-on-cloud}`
`docker-compose.yml` 默认引用 **Docker Hub** 上的镜像 `docker.io/gloridust/{woc-panel,wechat-on-cloud}`(同时也发布到 GHCR 作为备用源)
**这两个镜像需先存在**——要么官方已发布(你能直接拉取),要么你在本地自行构建。二选一:
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
@@ -122,222 +87,120 @@ 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」](#发布到-ghcr)。
- **命令行**:丢进一个空目录拉起即可
```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(国内外通用、免登录,**飞牛等 NAS 还内置了 Docker Hub 加速**,通常比 GHCR 更稳)。拉不动时设 `WOC_IMAGE_PREFIX` 切到备用源 `ghcr.io/gloridust` 或国内反代 `ghcr.nju.edu.cn/gloridust`(更多源见 [.env.example](.env.example))。报错 `denied` = 该源上还没有镜像,换源或用方式 A 本地构建。
无论哪种方式,都会拉起面板容器 `woc-panel`(唯一对外服务)。浏览器访问 `http://<NAS_IP>:36080`
1. 用 `.env` 里设置的管理员账号(默认 **admin / wechat**)登录面板;
2. 管理员在面板「实例」页点「**新建微信实例**」,命名并选择哪些子账号可访问 → 面板自动 `docker run` 起一个微信实例容器(微信镜像本地没有时才会从 GHCR 拉取);
3. 进入该实例,点「**下载并安装**」微信(约 190~210MB,进度条实时显示,仅管理员可操作);
4. 装好后点「进入电脑版微信」→ 浏览器里出现微信窗口,手机扫码登录即可
2. 在「实例」页点「**新建实例**」,选应用类型(**微信** 或 **Chromium 浏览器**)、命名、勾选可访问的子账号 → 面板自动 `docker run` 起一个实例容器(镜像本地没有时才会从镜像源拉取);
3. **微信实例**:进入后点「**下载并安装**」微信(约 190~210MB进度条,仅管理员);**浏览器实例**:随镜像就绪,跳过这步;
4. 点「**进入实例**」→ 微信扫码登录即可收发消息;浏览器则直接打开网页登录 Telegram / X / Instagram 等
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**微信会话。
之后被授权的用户换任意设备打开同一地址登录面板,看到自己有权访问的实例,进入即是**同一个**会话。
> 宿主只对外暴露面板的 `36080` 一个端口;微信实例容器仅在 docker 网络内、由面板反代,不直连宿主。要改端口/版本见 `.env`。
> **🛠️ 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#架构自动适配))。
### 面板能做什么
| 功能 | 谁可用 | 说明 |
|------|--------|------|
| 新建 / 删除微信实例 | 管理员 | 一键创建独立微信会话容器;新建时勾选可访问的子账号。删除默认保留数据卷(聊天记录),可选彻底清除 |
| 新建 / 删除实例 | 管理员 | 一键创建独立实例容器(微信 / Chromium 浏览器);新建时勾选可访问的子账号、可自定义图标。删除默认保留数据卷,可选彻底清除 |
| 实例权限分配 | 管理员 | 在实例上改「可访问账户」,或在账户上改「可访问实例」,双向管理 |
| 下载并安装 / 更新微信 | 管理员 | 对某实例一键下载官方微信 Linux 版到数据卷、解压安装带进度条后续可一键「更新到最新版」 |
| 进入电脑版微信 | 被授权用户 | 在浏览器里操作对应实例微信扫码登录、收发消息 |
| 下载并安装 / 更新微信 | 管理员 | 微信实例一键下载官方 Linux 版到数据卷、解压安装带进度条后续可「更新到最新版」(浏览器实例无需此步) |
| 进入实例 | 被授权用户 | 在浏览器里操作对应实例微信扫码收发消息,或在 Chromium 里登录网页应用 |
| 文件 / 文本传输 | 被授权用户 | 拖拽上传 / 下载文件;文本经剪贴板中转送入实例 |
| 实例日志 | 管理员 | 查看实例日志,含**持久化历史**(重启原因 + 上一容器日志快照,跨容器重建保留) |
| 修改密码 | 所有人 | 改自己的登录密码 |
| 子账号管理 | 管理员 | 创建 / 禁用 / 重置 / 删除子账号,并分配实例访问权限 |
| 安装为 App | 所有人 | iOS Safari「添加到主屏幕」、桌面 Chrome「安装」当原生 AppPWA |
> 子账号是**访问这套面板的身份**,不是另开一个微信。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
> 微信本体**不打进镜像**,而是新建实例后在面板点「下载并安装」时下载到该实例的数据卷,所以镜像很小、构建快、不依赖腾讯 CDN。
### 架构自动适配
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
| 运行机器 | 架构 | 自动下载 |
|----------|------|----------|
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
到飞牛上(无论 x64 还是 arm`docker compose up -d` 同一条命令,无需改任何架构相关配置。
### 自定义配置(可选)
复制 `.env.example``.env` 后按需修改,可配置项见 [.env.example](.env.example):管理员账号密码、镜像版本(`WOC_VERSION`,建议上线后钉到具体版本)、PUID/PGID、时区、端口。
> 子账号是**访问这套面板的身份**,不是另开一个微信 / 账号。管理员隐式拥有全部实例访问权;子账号只能看到被授权的实例。
> 微信本体**不打进镜像**,新建微信实例后在面板点「下载并安装」时才下载到该实例的数据卷(浏览器实例则已随镜像就绪),所以镜像小、构建快。
---
## 发布到 GHCR
## 浏览器实例(登录网页版社媒)
两种方式任选其一。
云微是个**多应用**平台:除了微信,新建实例时还可以选 **Chromium 浏览器**——相当于一台**常驻云端、多端共享的浏览器**,专门用来登录各种**网页版**应用:
### 方式 A · GitHub Actions(推荐)
- **社媒 / IM**Telegram Web、XTwitter)、Instagram、WhatsApp Web、Discord、Slack、微博、知乎…… 凡是有网页版的都行;
- **邮箱 / 后台 / 工具**:Gmail、各类管理后台、需要长期保持登录的网页应用。
仓库自带 GitHub Actions[.github/workflows/release.yml](.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR
和微信实例同一套体验与好处
```bash
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
gh release create v1.0.0 --title v1.0.0 --notes "..."
```
- **随镜像就绪、免下载** — Chromium 已烤进镜像,创建后点「进入实例」直接用(amd64 / arm64 均可)。
- **登录态常驻、重启不掉** — 浏览器配置与 Cookie 写在实例数据卷 `/config`,容器重启 / 升级都保留登录。
- **多端共享 + 同步** — 多设备打开同一实例看到的是**同一个**浏览器画面,跨设备无缝接力;多人操作有软锁保护。
- **中文输入 / 文件 / 剪贴板** — 与微信实例共用一套:本地输入法直接打字,工具栏拖拽传文件、剪贴板中转文本。
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`
### 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
适合想立刻出包、或不依赖 CI 的场景。需要 Docker BuildxDocker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
```bash
# 1) 登录 GHCRPAT 需 write:packages 权限)
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc
docker buildx create --name woc --use
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
VER=1.0.1
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
--push ./panel
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
--push ./docker
```
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](scripts/build-local.sh) 构建本机架构单架构镜像即可。
### 发布后:把包设为公开
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel``wechat-on-cloud`
2. **Package settings → Change visibility → Public**
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
> 在镜像发布之前,本地用 `./scripts/build-local.sh` 自构建即可,无需等待发布。
> ⚠️ 浏览器实例登录着你的社媒账号,同样受[安全须知](#安全须知必读)约束——**切勿把面板暴露公网**
---
## 数据持久化
## 资源占用
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
实测(8 核 / 8 GiB 宿主,实例均已登录微信、含 Chromium 内核的 WeChatAppEx):
要点:
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
- 备份面板 = 备份 `./data-panel`
| 状态 | CPU | 内存(RSS |
|------|-----|------------|
| 单实例 · 空闲(已登录、无人观看) | ~0.10.2 核 | ~0.6 GiB |
| 单实例 · 活跃(有人在浏览器操作 / 刷消息) | ~0.5–1 核(可突发) | ~11.5 GiB |
| 面板本身 | 可忽略 | ~0.12 GiB |
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷
- 容器**不设硬性 CPU/内存上限**:空闲时省,活跃时按宿主余量突发;每实例另预留 **1 GiB `/dev/shm`**(微信 Chromium 内核所需,tmpfs,按需占用)
- 估算:**面板 ≈ 0.15 GiB 常驻;每个微信实例按 1 vCPU + 1.5 GiB 内存预留**较从容(轻度使用可更低)。
- 参考容量:**2 核 / 2 GiB** 跑 1 个实例(轻度);**4 核 / 8 GiB** 跑 3–4 个实例;视频通话等重负载需再加预留。
> 内存是主要瓶颈,CPU 多为短时突发。实例越多越吃内存,按上表线性叠加即可估算。**Chromium 浏览器实例**的占用与微信实例同量级(取决于开的标签页数),可套用上表。
---
## ⚠️ 安全须知(必读)
## 安全须知(必读)
这套系统暴露的是**已登录的微信**——能登录面板的人就能看聊天记录、以你身份发消息。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
> ⚠️ **这套系统暴露的是已登录的微信 / 社媒账号,请务必认真阅读本节。**
能登录面板的人就能看你的聊天记录、以你身份发消息(浏览器实例则能用你登录的 Telegram / X / Instagram 等账号)。**面板还挂载了宿主的 `docker.sock`**(创建/销毁实例所需),它等同宿主 root 权限。因此:
- **绝不要把面板裸暴露公网**:只在内网访问,或经飞牛远程访问 / VPN / 内网穿透;
- 务必改掉默认密码(默认 admin / wechat):`cp .env.example .env` 后改 `WOC_PASSWORD`,或登录后在「修改密码」里改;
- 实例的增删、微信安装/更新等触碰 docker 引擎的操作**仅限管理员**;docker API 绝不暴露给前端;
- KasmVNC 凭据由面板服务端注入,**浏览器永远拿不到**;实例容器名由内部随机 ID 派生,避免注入;
- 面板与外网之间再套一层 HTTPS 反代(飞牛自带反代 / Caddy / Nginx)获得正经 TLS
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](技术方案.md) 第 5 节。
- 进一步加固(陌生设备验证码、审计日志、并发控制)见 [技术方案.md](doc/技术方案.md) 第 5 节。
---
## 中文输入
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的
「IME Input Mode」:拼音联想在你本机完成,只把成品汉字发进容器。直接在微信输入框打字即可。
**用你本地(客户端)的输入法打中文,容器内无需安装任何 IME。** 镜像默认开启 KasmVNC 的「IME Input Mode」,并对 noVNC 的 IME 合成逻辑做了修复——**只在输入法「上屏」那一刻把成品汉字整串发进容器**,规避了原生实现逐字符差分带来的丢字 / 卡顿。在微信或浏览器的输入框直接打字即可(对所有实例通用)。
- 默认值只对**未存过该设置的浏览器**生效。之前手动开/关过的,浏览器 localStorage 值优先;想验证默认效果用无痕窗口。
- 已知小毛病:超长拼音串未全部转成汉字就回车,偶尔丢字([issue #97](https://github.com/linuxserver/docker-baseimage-kasmvnc/issues/97)),长句分段输入即可
- 兜底:Chrome/Edge 下本地 `⌘C` → 远端 `Ctrl+V` 无缝粘贴;Firefox 用控制面板的 Clipboard 文本框中转
## 常见问题
| 现象 | 排查 |
|------|------|
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined``shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
| 多人同时操作很乱 | 已内置「操作控制权」软锁:当前操作者每数秒心跳续约,其余端自动转为**只读遮罩**(仍可看画面),空闲超时(约 10s)自动释放,他人可点「申请控制」接管。仍建议同一时刻一人操作 |
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。
### 重置超管密码(离线找回)
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
```bash
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
```
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`
```json
{
"id": "...", "username": "admin", "role": "admin",
"passwordHash": "...", "disabled": false,
"resetPassword": true
}
```
```bash
docker compose up -d # 3) 启动,面板初始化时会重置该账号
```
> ⚠️ 重置逻辑只在面板**进程启动**时执行。若你没先 `stop` 而面板仍在运行,直接 `docker compose up -d` 会因「容器无变化」而空操作(输出 `Running` 而非 `Started`),重置不会发生。此时执行 **`docker compose restart panel`**(或 `docker restart woc-panel`)强制重启即可生效。
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env``WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`
---
## 目录结构
```
WechatOnCloud/
├── .github/workflows/
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + 默认开 IME(不打包微信本体)
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state
│ └── autostart # openbox 会话启动:等待微信就绪 + 常驻拉起(含崩溃自重启)
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限 API + dockerode 管理实例 + 反代
│ └── web/ # React + TS + PWA(牛奶布艺 + 微信绿主题)
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
├── scripts/
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
├── 技术方案.md # 完整设计文档
└── README.md
```
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>``./data-panel` 已在 `.gitignore` 中。
- **跨设备文本**:实例工具栏的「剪贴板」可把文本送入容器剪贴板,再在微信 / 网页里 `Ctrl+V` 粘贴——不依赖浏览器异步剪贴板 API,**局域网 http 访问下也可用**
- **文件**:用工具栏「文件」拖拽上传,微信收到的文件另存到桌面即可在此下载
---
@@ -347,8 +210,39 @@ WechatOnCloud/
- [x] 自研面板:cookie 鉴权 + 反代 + 子账号管理 + PWA(KasmVNC 凭据不下发前端)
- [x] 微信本体运行时下载到数据卷:面板一键「下载并安装 / 更新」,带进度条
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
- [x] 多应用平台:微信 + Chromium 浏览器实例(登录 Telegram / X / Instagram 等网页版社媒)+ 自定义实例图标
- [x] 预构建多架构镜像发布到 Docker Hub / GHCR + GitHub Actions 自动化
- [x] 中文输入修复 + 文本剪贴板中转 + 实例日志持久化(跨容器重建保留重启原因与日志快照)
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
- [ ] 掉登录时 web 端二维码重扫入口
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
## 交流与关注
- 🐦 Twitter / X[@gloridust1024](https://x.com/gloridust1024) — 更新与动态
- ✈️ Telegram[@WechatOnCloud](https://t.me/WechatOnCloud) — 交流群 / 问题反馈
## 致谢
创意启发自懒猫微服(原 Deepin 团队做的硬件产品),推荐有经济实力、追求稳定运营的朋友尝试!
也感谢每一位 Star / Issue / PR 的朋友——**两天突破 500 ⭐**,是继续打磨的最大动力 🙌
## Star History
<a href="https://www.star-history.com/?repos=Gloridust%2FWechatOnCloud&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Gloridust/WechatOnCloud&type=date&legend=top-left" />
</picture>
</a>
---
<div align="center">
<sub>如果这个项目帮到了你,欢迎点个 ⭐ Star 支持一下 ·
<a href="https://github.com/Gloridust/WechatOnCloud/issues">反馈问题</a> ·
<a href="https://github.com/Gloridust/WechatOnCloud/pulls">参与贡献</a></sub>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+113
View File
@@ -0,0 +1,113 @@
# 发布镜像
> 返回 [← README](../README.md)
把两个镜像(`woc-panel``wechat-on-cloud`)发布到容器仓库,供他人 `docker compose up -d` 直接拉取。默认走 GHCR;可选同步推到 Docker Hub。
---
## 方式 A · GitHub Actions(推荐)
仓库自带 GitHub Actions[.github/workflows/release.yml](../.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR
```bash
git tag v1.0.0
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
gh release create v1.0.0 --title v1.0.0 --notes "..."
```
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
### 可选:同步推到 Docker Hub
GHCR 拉取在国际网络下偶尔被 TLS / DNS 干扰;Docker Hub 覆盖面更广,且公开镜像 `docker pull` 无需先 `docker login`。workflow 已内置 Docker Hub 双推开关:**配齐两个变量即可启用,没配就保持只推 GHCR**(向后兼容)。
**一次性配置**GitHub repo → Settings → Secrets and variables → Actions):
| 类型 | Name | Value |
|---|---|---|
| Variable | `DOCKERHUB_USERNAME` | 你的 Docker Hub 用户名(如 `gloridust` |
| Secret | `DOCKERHUB_TOKEN` | Docker Hub Access Token[hub.docker.com → Account Settings → Personal access tokens](https://hub.docker.com/settings/personal-access-tokens) → New Access Token,权限选 `Read & Write` |
> Variable 和 Secret 是两个不同的 tab;用户名放 Variable 即可(不敏感),Token 必须放 Secret。
> 在 Docker Hub 上**预先建好两个 public repo**`<用户名>/woc-panel`、`<用户名>/wechat-on-cloud`hub.docker.com → Create Repository),否则首次推送会失败(Docker Hub 不会自动建 repo)。
配齐后,下次发版(或 `workflow_dispatch` 手动触发)就会同时推到:
- `ghcr.io/<github-owner>/woc-panel:X.Y.Z`
- `docker.io/<dockerhub-user>/woc-panel:X.Y.Z`
使用者在 `.env``WOC_IMAGE_PREFIX=docker.io/<dockerhub-user>` 即可从 Docker Hub 拉。
---
## 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
适合想立刻出包、或不依赖 CI 的场景。需要 Docker BuildxDocker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
```bash
# 1) 登录 GHCRPAT 需 write:packages 权限)
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc
docker buildx create --name woc --use
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
VER=1.0.1
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
--push ./panel
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
--push ./docker
```
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 构建本机架构单架构镜像即可。
---
## 发布后:把包设为公开
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel``wechat-on-cloud`
2. **Package settings → Change visibility → Public**
> 若想保持私有,则使用者需先 `docker login ghcr.io`(用具备 `read:packages` 的 PAT)才能拉取。
> 在镜像发布之前,本地用 [`./scripts/build-local.sh`](../scripts/build-local.sh) 自构建即可,无需等待发布。
---
## Telegram 发布通知(可选,免服务器)
仓库内置 [.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 群组。**要让"自动置顶"生效,需把机器人设为群管理员并开启「置顶消息」权限**(缺权限时通知照发、仅置顶被跳过,不影响主流程)。
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
- **Secrets** 标签 → `TELEGRAM_BOT_TOKEN` = [@BotFather](https://t.me/BotFather) 给的 token。
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
> **自动置顶原理**:发完新消息后,用 `getChat` 读出群当前置顶消息(即上一个 release),置顶新消息、再取消旧的——无需任何持久化存储。issue 通知不置顶。手动 **Run workflow** 也会置顶(发的是最新 release),可用来测试。
---
## Telegram 命令机器人(可选,免服务器,轮询版)
除了发布通知,仓库还内置 [.github/workflows/telegram-bot.yml](../.github/workflows/telegram-bot.yml) + [.github/scripts/telegram-bot.mjs](../.github/scripts/telegram-bot.mjs):让机器人在**私聊 / 群组**里响应命令,跑在 GitHub Actions cron 上,**不需要服务器**。
命令:`/help``/releases``/release <tag>``/issues``/issue <编号>`
启用:
1. 复用上面的 `TELEGRAM_BOT_TOKEN`Secret);
2. 仓库 **Settings → Secrets and variables → Actions → Variables** 新建 `TELEGRAM_BOT_ENABLED = true`(不设为 `true` 则该工作流不运行);
3. 把机器人拉进群组,或在私聊里 `/start`
> **原理**:每 5 分钟(cron 最小间隔)调用 `getUpdates` 拉取待处理命令、回复、再用 `offset` 向 Telegram 确认(Telegram 自己保存 offset**无需任何持久化存储**)。
> **局限**:命令**非实时**——受 cron 最小 5 分钟 + GitHub 排队延迟影响;且 GitHub 会在仓库 60 天无活动时暂停定时任务(去 Actions 页手动重启即可)。想立即处理一次:Actions → telegram-bot → **Run workflow**。要真正实时,得改用 webhook(需一个 serverless 端点)。
+2 -5
View File
@@ -1,7 +1,6 @@
# WechatOnCloud 技术方案
> 目标:在飞牛 NASx86_64)上运行一个服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
> 参考形态:懒猫微服「云微信」。
> 目标:在飞牛 NASx86_64或任何服务器上运行服务端微信,多个 web 用户通过浏览器访问同一个微信会话,实现跨设备消息同步、多端共享,解决原生微信"一台电脑一个登录"的痛点。
---
@@ -147,7 +146,6 @@
```
WechatOnCloud/
├── 技术方案.md # 本文档
├── docker/
│ ├── Dockerfile # Debian + Xvfb + 微信deb + KasmVNC
│ ├── entrypoint.sh # 启动 Xvfb → WM → 微信 → KasmVNC
@@ -183,8 +181,7 @@ WechatOnCloud/
- [微信 Linux 官网](https://linux.weixin.qq.com/en)
- [腾讯上线 Linux 微信官网(x86/Arm/LoongArch](https://finance.sina.com.cn/tech/roll/2024-11-06/doc-incvceuc0172323.shtml)
- [懒猫微服 云微信](https://lazycat.cloud/lcmd/)
- [开源云微信部署方案(低配版懒猫远程微信)](https://linux.do/t/topic/1306818)
- [懒猫微服 云微信](https://lazycat.cloud/)
- [Xpra 官方文档](https://xpra.org/manual)
- [Xpra HTML5 客户端](https://github.com/Xpra-org/xpra-html5)
- [飞牛 fpk 框架文档](https://developer.fnnas.com/docs/core-concepts/framework)
+38
View File
@@ -0,0 +1,38 @@
# 数据卷管理与数据迁移
> 返回 [← README](../README.md)
**管理员专用。** 每个实例的「数据卷」= 容器内 `/config` 持久卷,含微信全部数据(登录态、加密聊天库、配置等)。
入口:**管理页 → 实例卡片「管理」→ 数据卷**。子账号不可见(数据卷等同完整微信会话凭据)。
提供两块能力:**整卷备份 / 恢复** 与 **文件浏览器**(浏览 / 上传 / 上传并解压 / 下载 / 改名 / 移动 / 删除)。
---
## 整卷备份 / 恢复(最可靠)
- **下载整卷备份**:把 `/config` 流式打包成 `.tar.gz` 下载(大文件不进内存,边打包边下)。用于离线备份、换宿主、跨实例迁移。
- **恢复备份**:上传 `.tar.gz` 覆盖写回 `/config`;恢复后在卡片上「重启」实例以加载数据。
- **为什么能保留聊天记录**:唯一设备标识存在卷内的 `.woc-machine-id`,会随备份一起迁移 → 目标实例解密环境一致,聊天记录可还原。这也是 woc 实例之间迁移的推荐方式。详见 [设备伪装](设备伪装.md)。
> 备份大小 ≈ 卷大小(微信数据常为数百 MB ~ 数 GB),请按网络情况耐心等待。
---
## 把 PC 微信数据迁移上来
1. 在 PC 上把微信数据文件夹打包成 `.tar.gz`
2. 数据卷 → 进入目标目录 → 「**上传并解压**」(也支持 `.tar`);
3. 在卡片上「重启」实例。
> ⚠️ **能否解密取决于微信版本与设备绑定**:微信本地库通常按设备/账号加密,跨设备直接复制数据库**不保证**能在新实例打开,请自行测试。最可靠的是「本系统导出的整卷备份」在 woc 实例间恢复。
---
## 安全与注意
- **仅管理员**可用;子账号永不可见(数据卷 = 完整微信会话凭据)。管理员本就持有 `docker.sock`(宿主 root),不新增风险。
- 所有路径严格限制在 `/config` 内,禁止 `..` 穿越。
- **全程在「运行中」的实例上操作**(底层用 `docker exec` 浏览/改名/删除、用 `docker cp` 传输)。改动微信正在使用的数据后,需**重启实例**方可生效;大改动前建议先「下载整卷备份」留底。
- 实例**未运行**时:只能做整卷备份 / 恢复;文件浏览需实例运行中。
- 上传的文件落地为容器内 `abc` 属主(微信进程可读)。
+53
View File
@@ -0,0 +1,53 @@
# 设备伪装与风控应对
> 返回 [← README](../README.md)
## 背景
微信 Linux 端会采集**设备指纹**做风控。容器/虚拟化环境若指纹异常(尤其是**大量实例共用同一指纹**),会被判定为"设备农场 / 非真实设备",表现为:**登录后立即被以安全原因强制退出,再次登录仍被踢,循环无法使用**。
> 注意:虚拟化本身不等于风险——高校、企业大量使用云桌面 / 瘦客户端 / 虚拟机,都是虚拟化的真实办公设备。问题出在**指纹不像一台独立的真实设备**(最典型:所有实例共用一个 machine-id)。本项目的目标就是让每个实例看起来像一台普通、独立的 Linux 桌面。
## 本项目默认做了什么
以下措施**默认全部开启**,每个实例自动生效(新建实例 / 升级实例后):
| 措施 | 说明 | 开关 |
|------|------|------|
| **唯一且持久的 machine-id** | 每个实例首启生成专属 machine-id,存入数据卷,重启/升级/重建都不变。解决"全网实例共用镜像里烤死的同一个 machine-id"这一最致命信号。 | 恒开 |
| **真实的 hostname** | 内部主机名伪装成"个人电脑"样式(如 `lenovo-pc-372`),不再是 `woc-wx-<hex>` 这种容器/服务器特征。每实例不同、稳定不变。 | 恒开 |
| **移除 `/.dockerenv`** | 删掉 Docker 注入的容器标记文件。 | 恒开 |
| **真实网卡 MAC** | 用常见网卡厂商 OUIIntel/Realtek 等)+ 由实例 id 稳定派生的后三段,替代容器默认带"本地管理位"的 MAC`02/26/ee…` 开头 = 明显非真实硬件)。每实例不同、稳定不变。 | 恒开 |
| **os-release 伪装成 deepin** | `/etc/os-release` 显示为 deepin 23(微信官方支持的发行版;Deepin 基于 Debian,与本镜像用户态一致,不自相矛盾)。 | `WOC_SPOOF_OS`,默认 1,设 0 恢复 Debian |
实现位置:`docker/woc-identity.sh`(启动钩子 `/custom-cont-init.d/00-woc-identity`,root 身份、在微信启动前执行)+ `panel/server/src/docker.ts`hostname / MAC / 开关透传,建容器时设置)。
## 手动「重置设备 ID」
若某个微信账号**已经被风控标记**、升级后仍登录即被踢,可以给它换一个全新的设备身份(相当于换一台新电脑):
**管理页 → 该实例卡片 →「安全」→「重置设备 ID 并重启」**
会生成一个新的唯一 machine-id 并重启该实例,之后重新扫码登录。仅对已升级到新镜像的实例可用(旧镜像无设备身份模块,会提示先「升级实例」)。
## 已被标记账号的恢复
- 设备指纹是**面向未来**的:换了干净的唯一指纹后,**新的登录**才会以新设备身份示人。
- 已被风控标记的账号可能有**冷却期**,换设备 ID + 重新登录后不一定立刻恢复,需观察一段时间。
- 建议先拿**非主力账号**验证,确认稳定后再迁主号,避免主号反复触发风控。
## 局限与风险(务必知悉)
- **这是"尽力而为",不是保证。** 风控是持续对抗:腾讯会不断增加新的检测维度(如 X 服务器厂商串、无 GPU 软渲染、SMBIOS 缺失、行为特征等),本项目只能覆盖已知的、可控的指纹。
- **有封号风险。** 在非官方环境运行微信本身违反其使用条款;是否使用、用于何种账号,请自行评估。强烈建议**不要用主力/重要账号**承担试验风险。
- 若伪装后仍被频繁踢,可尝试:`WOC_SPOOF_OS=0` 恢复真实 Debian(排除 os 伪装反而被交叉校验的可能),或反馈 issue 一起排查更深层信号。
## 调参
`.env`(复制自 [.env.example](../.env.example)):
```bash
WOC_SPOOF_OS=1 # 1=伪装成 deepin(默认),0=显示真实 Debian
```
machine-id / hostname / dockerenv 三项无开关、恒定开启(它们没有合理的关闭理由)。
+119
View File
@@ -0,0 +1,119 @@
# 运行原理与 Docker 指南
> 返回 [← README](../README.md) · 深入设计见 [技术方案.md](技术方案.md)
本篇把「云微怎么跑起来」讲透:先一句话原理 + 架构图,再面向 Docker 新手的逐步拆解。
---
## 工作原理(一句话)
每个微信实例 = 一个容器,里面跑 Xvfb 虚拟显示 + 官方原版微信,KasmVNC 把画面串到浏览器。同一实例被多个浏览器连 = 共享同一个微信会话。**不修改微信客户端**。
前面一层自研 **面板(panel** 是唯一对外入口:负责账号登录、子账号与**实例权限**管理,经 docker 引擎**按需创建/销毁**微信实例容器,并反向代理到对应实例——浏览器只和面板打交道,KasmVNC 的凭据由面板在服务端注入,不下发前端。
```
浏览器 ──▶ panel(:36080) ──┬─ / 面板 SPA(登录 / 实例网格 / 子账号 / 进入桌面)
cookie 鉴权 ├─ /api/* 账号、实例、权限接口
└─ /desktop/:id/* 反代 → 对应实例 KasmVNC(注入 Basic 鉴权)
panel ──(docker.sock)──▶ docker 引擎 ──▶ 按需创建/销毁微信实例容器 woc-wx-<id>
每个实例 = 独立容器 + 独立数据卷 + 独立微信会话
实例只在 docker 网络内暴露,不直连宿主
```
---
## Docker 运行模式详解(新手向)
如果你对 Docker 不熟,这一节把本项目「怎么跑起来的」讲透。读完你就能看懂上面的图。
### 0. 先认识 5 个 Docker 概念
| 概念 | 一句话理解 | 在本项目里是什么 |
|------|-----------|------------------|
| **镜像 Image** | 只读的「软件安装包」,里面装好了程序和依赖 | `woc-panel`(面板)、`wechat-on-cloud`(微信实例) |
| **容器 Container** | 镜像「运行起来」的实例,相当于「正在跑的程序」。一个镜像能跑出多个容器 | `woc-panel` 容器、多个 `woc-wx-<id>` 容器 |
| **卷 Volume** | 容器之外的持久磁盘。容器删了,卷里的数据还在 | 每个微信实例一个卷 `woc-data-<id>`,存登录态和消息 |
| **网络 Network** | 容器之间互通的「虚拟局域网」,容器之间可用**容器名**当域名互访 | 面板和所有实例在同一网络里,面板用 `http://woc-wx-<id>:3000` 找实例 |
| **docker.sock** | Docker 引擎的「遥控器」(宿主上的一个特殊文件 `/var/run/docker.sock`)。谁拿到它,谁就能指挥 Docker 创建/删除容器 | 挂进面板容器,面板才能「动态造微信实例」 |
> **Compose** 则是「用一个 `docker-compose.yml` 文件描述要跑哪些容器,`docker compose up -d` 一条命令拉起」。本项目的 compose 里**只有面板一个服务**。
### 1. 本项目有两类容器(运行角色不同)
这是本项目最容易迷惑的地方:**不是所有容器都写在 `docker-compose.yml` 里。**
| | ① 面板容器 | ② 微信实例容器 |
|---|-----------|---------------|
| 容器名 | `woc-panel`(固定一个) | `woc-wx-<随机id>`(可有多个) |
| 用哪个镜像 | `woc-panel` | `wechat-on-cloud` |
| 谁来启动 | **你** 执行 `docker compose up -d` | **面板**:你在网页点「新建微信实例」时,面板通过 docker.sock 自动 `docker run` |
| 写在 compose 里吗 | 是 | **否**(运行期动态创建,compose 里看不到) |
| 对外暴露端口 | 是,宿主 `36080` → 容器 `8080` | 否,只在 docker 网络内,由面板反代 |
| 数据存哪 | 宿主目录 `./data-panel` | 各自的命名卷 `woc-data-<id>` |
| 生命周期 | 常驻 | 你在面板「删除实例」时销毁(默认保留卷) |
一句话:**你只手动管面板这一个容器;微信实例是面板帮你按需开关的。** 这就是为什么面板要挂 `docker.sock`——它需要「遥控」Docker 去开关微信实例容器。
### 2. 镜像从哪来:两种「构建/获取」模式
容器要跑,先得有镜像。本项目的两个镜像有两条获取途径,**任选其一**([README 快速开始](../README.md#快速开始)对应方式 A / B):
| | 方式 A · 本地自构建 | 方式 B · 拉取官方镜像 |
|---|--------------------|----------------------|
| 怎么做 | `./scripts/build-local.sh`(用本仓库 Dockerfile 在你机器上造镜像) | `docker compose up -d`(自动从 GHCR 下载现成镜像) |
| 适合谁 | 官方还没发布镜像时 / 想自己改代码 / 内网无法访问 GHCR | 普通用户,开箱即用 |
| 前提 | 本机能拉到基础镜像(node、KasmVNC base | GHCR 上已发布且包为公开(见[发布到 GHCR](发布到GHCR.md) |
| 产物 | 本地镜像,标签和 compose 里写的一模一样 | 同名镜像,来自云端 |
> compose 的拉取策略是默认值(`missing`):**本地已有同名镜像就直接用,没有才去 GHCR 拉**。所以方式 A 构建完,`docker compose up -d` 会直接用你的本地镜像,不会再联网。想升级到 GHCR 最新版:`docker compose pull && docker compose up -d`。
> 第三个「镜像」其实是**微信本体**:它**不打进任何镜像**,而是你在面板点「下载并安装」时,由实例容器实时从腾讯官方 CDN 下到自己的卷里(见[部署与运维 · 数据持久化](部署与运维.md#数据持久化))。
### 3. 从零到能用,整体发生了什么
```
你: docker compose up -d
└─▶ Docker 读取 docker-compose.yml
└─▶ 拉起【面板容器 woc-panel】,挂上 ./data-panel 和 docker.sock,暴露 36080 端口
你: 浏览器开 http://NAS:36080 → 登录 → 点「新建微信实例」
└─▶ 面板通过 docker.sock 指挥 Docker:
├─ docker run 一个【微信实例容器 woc-wx-xxx】
├─ 给它挂一个新卷 woc-data-xxx(存登录态/消息)
└─ 接到同一个 docker 网络(面板才能反代到它)
你: 进入该实例 → 点「下载并安装」
└─▶ 面板 docker exec 进实例容器,触发脚本从腾讯 CDN 下载微信、解压到卷
你: 点「进入电脑版微信」→ 手机扫码
└─▶ 浏览器 ⇄ 面板(反代+注入鉴权) ⇄ 实例容器的 KasmVNC ⇄ 微信窗口
```
### 4. 常用命令速查
```bash
docker compose up -d # 启动面板(首次会拉/用镜像)
docker compose down # 停止并删除面板容器(不动数据卷和微信实例)
docker compose pull # 把面板/微信镜像更新到 GHCR 最新
docker ps # 看正在运行的容器(能看到 woc-panel 和各 woc-wx-*
docker logs -f woc-panel # 看面板日志
docker logs -f woc-wx-<id> # 看某个微信实例日志
docker volume ls | grep woc # 看所有微信实例的数据卷
```
> ⚠️ 微信实例容器请**始终在面板网页里增删**,不要手动 `docker rm` 它们——否则面板的实例登记和真实容器会对不上。
---
## 架构自动适配
镜像本身多架构(amd64/arm64);下载微信时容器内**运行时再自动检测 CPU 架构**(`dpkg --print-architecture`)取对应官方包:
| 运行机器 | 架构 | 自动下载 |
|----------|------|----------|
| Intel/AMD NAS、x86 服务器 | amd64 | `WeChatLinux_x86_64.deb` |
| ARM NAS、Apple Silicon Mac | arm64 | `WeChatLinux_arm64.deb` |
到飞牛上(无论 x64 还是 arm`docker compose up -d` 同一条命令,无需改任何架构相关配置。
+101
View File
@@ -0,0 +1,101 @@
# 部署与运维
> 返回 [← README](../README.md)
数据持久化、常见问题排查、忘记超管密码的离线找回,以及仓库目录结构。
---
## 数据持久化
- **面板数据**(用户、实例元信息、密码哈希):容器内 `/data`,映射到宿主 `./data-panel`
- **每个微信实例**:独立的 docker 命名卷 `woc-data-<id>`,挂到该实例容器的 `/config`(微信本体在 `/config/wechat`,登录态与消息缓存在 `/config` 其余位置)。
要点:
- 删除实例**默认保留**其数据卷,下次同名重建可复用;只有显式勾选「彻底清除」才会删卷。
- 备份某实例 = 备份对应的 `woc-data-<id>` 卷(`docker volume` 系列命令)。
- 卷需支持执行权限(微信本体直接从卷里运行);放在 `noexec` 卷上微信将无法启动。
- 备份面板 = 备份 `./data-panel`
> **从旧版(单微信容器 + `./data` 绑定挂载)迁移**:旧形态把微信数据放在宿主 `./data`。新版用 docker 命名卷,结构不同,无自动迁移。如需保留旧会话,最简单是新建一个实例、重新扫码登录;或手动把旧 `./data` 内容拷进新实例的 `woc-data-<id>` 卷。
---
## 常见问题
| 现象 | 排查 |
|------|------|
| 新建实例失败 | 多为面板拉不到微信镜像或连不上 docker.sock。确认 `docker.sock` 已挂载、宿主能访问 GHCR;看面板日志 `docker logs woc-panel` |
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined``shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
| 多人同时操作很乱 | 已内置「操作控制权」软锁:当前操作者每数秒心跳续约,其余端自动转为**只读遮罩**(仍可看画面),空闲超时(约 10s)自动释放,他人可点「申请控制」接管。仍建议同一时刻一人操作 |
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。也可在面板的「管理」页或实例页点「查看日志」直接导出实例容器日志。
---
## 重置超管密码(离线找回)
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
```bash
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
```
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`
```json
{
"id": "...", "username": "admin", "role": "admin",
"passwordHash": "...", "disabled": false,
"resetPassword": true
}
```
```bash
docker compose up -d # 3) 启动,面板初始化时会重置该账号
```
> ⚠️ 重置逻辑只在面板**进程启动**时执行。若你没先 `stop` 而面板仍在运行,直接 `docker compose up -d` 会因「容器无变化」而空操作(输出 `Running` 而非 `Started`),重置不会发生。此时执行 **`docker compose restart panel`**(或 `docker restart woc-panel`)强制重启即可生效。
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env``WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`
---
## 目录结构
```
WechatOnCloud/
├── .github/workflows/
│ └── release.yml # 打 tag / 发 Release 时构建多架构镜像并推送 GHCR
├── docker/ # 微信实例镜像(ghcr.io/<owner>/wechat-on-cloud
│ ├── Dockerfile # KasmVNC base + 中文字体 + 微信运行时依赖 + xdotool + IME 补丁(不打包微信本体)
│ ├── woc-www-patch.sh # 构建期补丁:开启 KasmVNC IME 模式 + 修复 noVNC 中文输入(配合 woc-ime.pl
│ ├── woc-ime.pl # noVNC 键盘 IME 逻辑的 perl 补丁(合成提交时整串发送,规避丢字)
│ ├── wechat-ctl.sh # 运行时下载/解压/更新微信(面板经 docker exec 触发,状态写 /config/.woc-state
│ ├── autostart # openbox 会话启动:常驻拉起微信(崩溃自重启)+ 最小化窗口自动复原看守
│ └── woc-update-autostart # 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本
├── panel/ # 自研面板(ghcr.io/<owner>/woc-panel,唯一对外入口)
│ ├── Dockerfile # 前端 Vite 打包 + 后端 Fastify 网关(多架构)
│ ├── server/ # Fastifycookie 鉴权 + 账号/实例/权限/生命周期 API + dockerode + 反代
│ └── web/ # React + TS + PWA(微信 PC 式布局,牛奶布艺 + 微信绿主题)
├── fnos/ # 飞牛 fnOS 应用打包(.fpk 工程 + 构建说明)
├── scripts/
│ └── build-local.sh # 本地构建面板+微信镜像(发布前自测 / 自托管自构建)
├── doc/ # 文档与素材
│ ├── 技术方案.md # 完整设计文档
│ ├── 运行原理.md # 工作原理 + Docker 运行模式详解(新手向)
│ ├── 发布到GHCR.md # 镜像发布到 GHCR 的两种方式
│ ├── 部署与运维.md # 本文档:数据持久化 / 常见问题 / 密码找回 / 目录结构
│ └── img/ # logo 与界面截图
├── docker-compose.yml # 单服务:panel(挂 docker.sock,按需创建实例)
├── .env.example # 可选配置(账号密码、镜像版本、PUID/PGID、端口、时区)
└── README.md
```
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>``./data-panel` 已在 `.gitignore` 中。
+18 -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}
@@ -21,10 +21,22 @@ services:
# 摄像头直通:逗号分隔的宿主视频设备(如 /dev/video0)。留空则自动探测(见下方 /host-dev 挂载)或禁用摄像头。
# 启用前需在宿主加载 v4l2loopback 内核模块。详见 .env.example。
- WOC_VIDEO_DEVICES=${WOC_VIDEO_DEVICES:-}
# 实例资源相关(见 .env.example):GPU 硬件编码默认关闭(避免 WSL2/虚拟 GPU 下 Xvnc 内存暴涨);
# 可选给每个实例设内存上限(GiB),0=不限制;watchdog 双阈值(soft 柔和自愈 / hard 强制重启)+ 巡检间隔。
- WOC_ENABLE_GPU=${WOC_ENABLE_GPU:-}
- WOC_INSTANCE_MEM_GB=${WOC_INSTANCE_MEM_GB:-}
- WOC_INSTANCE_MEM_SOFT_MB=${WOC_INSTANCE_MEM_SOFT_MB:-1500}
- WOC_INSTANCE_MEM_HARD_MB=${WOC_INSTANCE_MEM_HARD_MB:-2500}
- WOC_WATCHDOG_INTERVAL_SEC=${WOC_WATCHDOG_INTERVAL_SEC:-300}
# 设备伪装:os-release 伪装成 deepin(默认开,=0 关恢复 Debian)。详见 .env.example / doc/设备伪装.md。
- WOC_SPOOF_OS=${WOC_SPOOF_OS:-1}
# 面板首个管理员账号(仅首次启动、无账号文件时写入;务必改掉默认密码)
- PANEL_ADMIN_USER=${WOC_USER:-admin}
- PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat}
- PANEL_DATA=/data/accounts.json
# DNS-rebinding 防护:套 HTTPS 反代部署时把对外域名加进 .env(详见 .env.example)。
# 默认仅放行 loopback + RFC1918 私网,直连 NAS / 局域网无需改动。
- PANEL_ALLOWED_HOSTS=${PANEL_ALLOWED_HOSTS:-}
volumes:
# 面板账号数据(用户、实例元信息、密码哈希)
+30 -18
View File
@@ -15,7 +15,9 @@ RUN set -eux; \
curl ca-certificates locales dpkg \
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
libnss3 libgbm1 libasound2 libxss1 \
xdotool; \
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; \
apt-get clean; \
@@ -54,32 +56,42 @@ ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \
LIBGL_ALWAYS_SOFTWARE=1
# KasmVNC web 客户端默认开启 IME 输入模式
# 用户用本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字发进容器,无需容器内装 IME。
# 默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
# KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式:本地(客户端)输入法打中文,拼音联想在本地完成、只把成品汉字
# 发进容器,无需容器内装 IME。默认值仅在浏览器未存过该设置时生效,不覆盖用户手动改过的偏好。
# (2) 修复 noVNC 中文 IME 输入:原实现靠隐藏 textarea 差分逐字符重发 keysym,会泄漏中间拼音、
# 累积不 reset、退格风暴,导致大量丢字 / ~21 字卡住 / 跨浏览器不稳。改为只在 compositionend
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
# 注意:实际加载的是 webpack 产物 dist/main.bundle.jsapp/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
# 末尾的 grep 作为断言:若 base 镜像换了打包结构、改不到任何文件,则构建直接失败而非静默放过。
RUN set -eux; \
patched=0; \
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do \
if [ -f "$f" ] && grep -q "initSetting('enable_ime', false)" "$f"; then \
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"; \
patched=1; \
fi; \
done; \
[ "$patched" = "1" ]
COPY woc-www-patch.sh woc-ime.pl /woc/
RUN chmod 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
# 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
# 启动钩子(00):给每个实例唯一且持久的 machine-id,避免所有实例共用镜像里烤死的同一个,
# 触发腾讯"设备农场"风控导致登录即被强制退出。须在 autostart(拉起微信)之前执行,故用 00 前缀。
COPY woc-identity.sh /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
+34
View File
@@ -0,0 +1,34 @@
# 应用定义(被 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 弹窗。
# --disable-background-networking:关掉 Chromium 后台 phone-homeGCM 推送 / 组件更新 / 变体下载),
# 在受限网络(NAS / 被墙)下这些会反复失败刷屏 "gcm ConnectionHandler failed net error: -2"。
# 只影响后台流量,不影响前台网页加载与真实网络错误提示。
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 --disable-background-networking --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}"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/with-contenv bash
# linuxserver 启动钩子(/custom-cont-init.droot 身份,每次启动、在各服务起来之前执行)。
#
# 目的:给每个实例一个【唯一且持久】的设备身份,避免所有实例共用镜像里烤死的同一个 machine-id。
#
# 背景(P0):Debian/基础镜像把 machine-id 固化在镜像层里,于是全世界每个 wechat-on-cloud
# 实例的 /etc/machine-id 都相同。machine-id 是 Linux 上最接近"设备指纹"的标识,微信会读它做
# 风控;成千上万个账号共用同一个 machine-id = 典型"设备农场"特征 → 被腾讯批量判风险 → 登录即
# 被强制退出、反复循环。
#
# 解法:
# 1) 在数据卷(/config,随实例持久)里存一个本实例专属的随机 machine-id;
# 2) 每次启动把它写回 /etc/machine-id 与 /var/lib/dbus/machine-id。
# 这样:实例之间互不相同(破掉设备农场特征),且重启 / 升级 / 重建容器都保持不变("设备老在变"
# 同样可疑)。仅在卷里尚无该文件时才生成,故老实例首启会拿到一个新的唯一 id(之后恒定)。
set -e
ID_FILE=/config/.woc-machine-id
# 生成 32 位小写十六进制(systemd machine-id 格式):优先用内核 uuid 源,去掉连字符。
if [ ! -s "$ID_FILE" ]; then
if [ -r /proc/sys/kernel/random/uuid ]; then
tr -d '-' < /proc/sys/kernel/random/uuid | tr 'A-F' 'a-f' > "$ID_FILE"
else
head -c16 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$ID_FILE"
fi
fi
MID="$(tr -dc 'a-f0-9' < "$ID_FILE" | head -c 32)"
# 兜底:若文件内容异常(长度不足 32),重新生成
if [ "${#MID}" -ne 32 ]; then
MID="$(tr -d '-' < /proc/sys/kernel/random/uuid | tr 'A-F' 'a-f' | head -c 32)"
printf '%s\n' "$MID" > "$ID_FILE"
fi
printf '%s\n' "$MID" > /etc/machine-id 2>/dev/null || true
mkdir -p /var/lib/dbus
printf '%s\n' "$MID" > /var/lib/dbus/machine-id 2>/dev/null || true
# 抹掉最明显的容器标记(微信可能据此判定非真实桌面)。/.dockerenv 由 docker 注入,删掉无副作用。
rm -f /.dockerenv 2>/dev/null || true
# 设备伪装:把 /etc/os-release 改成 deepin(微信官方支持的发行版;Deepin 本就基于 Debian
# 与本镜像的 Debian 用户态一致,不自相矛盾)。面板按 WOC_SPOOF_OS 控制(默认开,=0 关)。
# /etc/os-release 是指向 /usr/lib/os-release 的软链,重定向会写穿到目标,故直接写它即可。
if [ "${WOC_SPOOF_OS:-1}" = "1" ]; then
cat > /etc/os-release <<'OSEOF'
PRETTY_NAME="deepin 23"
NAME="deepin"
VERSION_ID="23"
VERSION="23"
VERSION_CODENAME=beige
ID=deepin
ID_LIKE=debian
HOME_URL="https://www.deepin.org/"
BUG_REPORT_URL="https://bbs.deepin.org/"
OSEOF
fi
echo "[woc-identity] machine-id 已设为本实例专属(持久化于数据卷);os 伪装=${WOC_SPOOF_OS:-1}"
+41
View File
@@ -0,0 +1,41 @@
# perl -0777 -pe 补丁脚本(被 woc-www-patch.sh 引用)。
# 对 dist/*.bundle.js 里 noVNC 键盘 IME 逻辑做两处替换,全程字面匹配(\Q..\E)。
#
# 背景:noVNC 原实现靠"隐藏 textarea 差分→逐字符重发 keysym"还原 IME 输入,会在合成过程中
# 把中间拼音也发给远端、且永不 reset 导致累积+退格风暴 → 大量丢字 / 卡住 / 跨浏览器不稳。
#
# 改法:彻底不靠 textarea 差分或 VNC keysym 还原中文。
# - 合成进行中(input 事件):只同步 _lastKeyboardInput、不发送(避免中间拼音泄漏 / 丢字)。
# - 提交时(compositionend):只同步 _lastKeyboardInput 并返回,不再逐字发 keysym。
# 成品文本由面板前端捕获后通过 xclip/xdotool 粘贴进远端窗口,绕开 KasmVNC XKB
# keysym 容量限制,也避免和粘贴路径重复上屏。
# - 若个别浏览器在 compositionend 后还补发一次"提交 input":此时 isComposing/_imeHold 均为假,
# 落到非 IME 差分分支,但 newValue 与刚同步的 _lastKeyboardInput 相等 → 差分为空 → 不重复发送。
# (A) _handleCompositionEnd:提交时只同步 _lastKeyboardInput,文本由面板粘贴路径负责。
s~\Q if (this._enableIME) {
this._imeInProgress = false;
}
if (isChromiumBased()) {
this._imeHold = false;
}\E~ if (this._enableIME) { // WOC-IME
this._imeInProgress = false;
this._imeHold = false;
this._lastKeyboardInput = e.target.value;
return;
}
if (isChromiumBased()) {
this._imeHold = false;
}~;
# (B) _handleInput 顶部守卫:合成进行中只同步值、不发送;提交已在 compositionend 完成。
s~\Q if (this._enableIME && this._imeHold) {
Debug("IME input change, sending differential");\E~ if (this._enableIME && (e.isComposing || this._imeHold || this._imeInProgress)) { // WOC-IME
this._lastKeyboardInput = e.target.value;
return;
}
if (this._enableIME && this._imeHold) {
Debug("IME input change, sending differential");~;
+5
View File
@@ -7,3 +7,8 @@ mkdir -p /config/.config/openbox
cp /defaults/autostart /config/.config/openbox/autostart
chmod +x /config/.config/openbox/autostart
chown "${PUID:-1000}:${PGID:-1000}" /config/.config/openbox/autostart 2>/dev/null || true
# 文件中转目录(桌面):确保归 abc(PUID) 所有。否则曾被 root 建成 755 root:root 时,微信(abc 身份)
# 另存到此处会"保存失败",面板上传也写不进。每次启动强制纠正属主,兼容历史遗留的 root 属主。
mkdir -p /config/Desktop
chown "${PUID:-1000}:${PGID:-1000}" /config/Desktop 2>/dev/null || true
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# 构建期补丁:改 KasmVNC web 客户端的 webpack 产物 dist/*.bundle.js
# (1) 默认开启 IME 输入模式(本地输入法打中文,成品汉字发进容器,容器内不装 IME)
# (2) 修复 noVNC 的中文 IME 输入:原实现靠"隐藏 textarea 差分→逐字符重发 keysym"
# 会在合成过程中把中间拼音也发给远端、且永不 reset 导致累积+退格风暴;
# 改为合成期间和提交时都只同步内部 textarea 状态,不再发送中文 keysym。
# 最终成品文本由面板前端捕获后通过 xclip/xdotool 粘贴,绕过 KasmVNC XKB keysym 限制。
# 末尾断言:若 base 镜像换了打包结构、一个文件都没改到,则构建失败而非静默放过。
set -euo pipefail
PATCH_PL="$(dirname "$0")/woc-ime.pl"
patched=0
for f in /usr/share/kasmvnc/www/dist/*.bundle.js /usr/local/share/kasmvnc/www/dist/*.bundle.js; do
[ -f "$f" ] || continue
changed=0
# (1) enable_ime 默认开启
if grep -q "initSetting('enable_ime', false)" "$f"; then
sed -i "s/initSetting('enable_ime', false)/initSetting('enable_ime', true)/g" "$f"
changed=1
fi
# (2) IME 差分逻辑修复(仅含 noVNC 键盘逻辑的 bundle
# 幂等:/usr/share/kasmvnc 是 /usr/local/share/kasmvnc 的软链,两个 glob 会命中同一 inode,
# 故已含 _imeJustCommitted 的文件直接跳过,避免重复注入守卫块。
if grep -q "IME input change, sending differential" "$f" && ! grep -q "WOC-IME" "$f"; then
perl -0777 -i -pe "$(cat "$PATCH_PL")" "$f"
after="$(grep -c "WOC-IME" "$f" || true)"
# 断言两处替换都命中(compositionend 标记 1 + _handleInput 守卫标记 1 = 2 行)
if [ "$after" -ne 2 ]; then
echo "FATAL: IME patch mismatch on $f (markers=$after, expect 2)" >&2
exit 1
fi
changed=1
fi
[ "$changed" = "1" ] && { echo "woc-www-patch: patched $f"; patched=1; }
done
[ "$patched" = "1" ] || { echo "FATAL: no bundle patched" >&2; exit 1; }
echo "woc-www-patch: done"
+5 -1
View File
@@ -17,7 +17,11 @@ RUN npm install
COPY server/ ./
COPY --from=web /web/dist ./web-dist
ENV STATIC_DIR=/app/web-dist \
# 构建版本号:CI 用 git tag 注入(vX.Y.Z),本地构建默认 dev。烤进镜像 → 面板运行时显示真实版本并据此检测更新。
# 放在末尾:改版本号不会破坏上面的依赖安装缓存。
ARG WOC_VERSION=dev
ENV WOC_VERSION=${WOC_VERSION} \
STATIC_DIR=/app/web-dist \
PORT=8080
EXPOSE 8080
CMD ["npm", "run", "start"]
+591 -17
View File
@@ -1,7 +1,10 @@
import { hostname } from 'node:os';
import { existsSync, readdirSync } from 'node:fs';
import { appendInstanceLog, deleteInstanceLog, appendPanelLog, readInstanceLog, readPanelLog, filterSince } from './logs.js';
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';
@@ -9,6 +12,49 @@ const PGID = process.env.PGID || '1000';
const TZ = process.env.TZ || 'Asia/Shanghai';
const SHM_SIZE = 1024 * 1024 * 1024; // 1gb
// 默认关闭 KasmVNC 的 GPU 硬件编码(baseimage 检测到 /dev/dri/renderD* 时会给 Xvnc 加 -hw3d):
// 在 WSL2 / 虚拟 GPU 环境下该路径会导致 Xvnc 内存持续膨胀(实测反馈 21h 涨到 ~9GB)。
// 我们已设 LIBGL_ALWAYS_SOFTWARE=1 走软件渲染,hw3d 对微信这类静态界面收益甚微。
// 真实可用 GPU 想启用硬件编码:面板侧设 WOC_ENABLE_GPU=1。
const ENABLE_GPU = process.env.WOC_ENABLE_GPU === '1';
// 可选:给每个实例容器设内存上限(GiB),作为 Xvnc 等异常增长时的兜底,避免拖垮宿主。
// 默认 0 = 不限制(保持原行为)。命中上限时容器内 OOM 杀进程、由 s6 自动重启 VNC。
const INSTANCE_MEM_GB = Number(process.env.WOC_INSTANCE_MEM_GB) || 0;
const INSTANCE_MEM = INSTANCE_MEM_GB > 0 ? Math.floor(INSTANCE_MEM_GB * 1024 * 1024 * 1024) : 0;
// 设备伪装:把 /etc/os-release 伪装成 deepin(微信官方支持的发行版,且 Deepin 本就基于 Debian
// 与本镜像的 Debian 用户态一致,不会自相矛盾)。默认开启;设 WOC_SPOOF_OS=0 关闭恢复 Debian。
// 配合 00-woc-identity 钩子里的 machine-id 唯一化 + 真实 hostname,整体让容器更像一台普通 Linux 桌面,
// 降低被腾讯按"非真实设备/设备农场"判风险的概率。注意:尽力而为,非保证;详见 doc/设备伪装.md。
const SPOOF_OS = process.env.WOC_SPOOF_OS !== '0';
// 给实例容器派生一个"像个人电脑"的内部 hostname(替代 woc-wx-<hex> 这种容器/服务器特征)。
// 从 inst.id 稳定派生:同一实例每次重建得到相同名字、不同实例不同。仅作伪装,不参与寻址
// (反代用容器名 containerName,不用此 hostname)。
function realisticHostname(id: string): string {
const words = ['deepin', 'lenovo', 'thinkpad', 'matebook', 'xiaoxin', 'legion', 'dell', 'asus', 'desktop', 'home'];
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
const w = words[h % words.length];
const n = ((h >>> 8) % 900) + 100; // 100-999,避免前导 0
return `${w}-pc-${n}`;
}
// 给实例容器派生一个"像真实有线网卡"的 MAC:常见网卡厂商 OUI 前缀 + 由 id 稳定派生的后三段。
// 容器默认 MAC 带"本地管理位"(第一字节第 2 位为 1,如 02/26/ee 开头),是"非真实硬件"的明显特征;
// 这里用全局管理、单播的真实厂商 OUI,更像一台插了网卡的真机。同一实例每次重建得到相同 MAC。
function realisticMac(id: string): string {
// 常见消费级网卡厂商 OUI(全局管理 + 单播,首字节低两位为 0)
const ouis = ['001b21', '8c1645', '00e04c', '0021cc', '3c970e', '001422', 'b827eb'];
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 131 + id.charCodeAt(i)) >>> 0;
const oui = ouis[h % ouis.length];
const hex = (n: number) => (n & 0xff).toString(16).padStart(2, '0');
const tail = hex(h >>> 3) + hex(h >>> 11) + hex(h >>> 19);
return (oui + tail).match(/.{2}/g)!.join(':');
}
const docker = new Docker(); // 默认连 /var/run/docker.sock
// 面板自身所在的 docker 网络名;新实例都 attach 到它,便于按容器名互访。
@@ -58,13 +104,23 @@ function videoDevices(): string[] {
}
function envList(inst: Instance): string[] {
return [
const env = [
`PUID=${PUID}`,
`PGID=${PGID}`,
`TZ=${TZ}`,
`CUSTOM_USER=${inst.kasmUser}`,
`PASSWORD=${inst.kasmPassword}`,
];
// baseimage 仅检查该变量是否「已设置」(值无关),设上即不再给 Xvnc 加 -hw3d。
if (!ENABLE_GPU) env.push('DISABLE_DRI=1');
// 透传 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;
}
// 确保微信镜像在本地存在;缺失则从 GHCR 拉取(首次新建实例时镜像通常还没拉过)。
@@ -75,7 +131,17 @@ async function ensureImage(): Promise<void> {
} catch {
/* 本地没有,下面拉取 */
}
await pullImage();
// 首次新建实例常卡在这一步(NAS 直连 docker.io 拉取超时,见 README)。这里前后都打日志:
// 若诊断包里只见"开始拉取"而无"完成/失败",即可定位为拉取卡死。
appendPanelLog('INFO', `本地无实例镜像 ${WECHAT_IMAGE},开始拉取(首次较慢;NAS 直连 docker.io 可能超时)…`);
const t0 = Date.now();
try {
await pullImage();
appendPanelLog('INFO', `实例镜像拉取完成 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s`);
} catch (e: any) {
appendPanelLog('ERROR', `实例镜像拉取失败 ${WECHAT_IMAGE}(耗时 ${Math.round((Date.now() - t0) / 1000)}s):${e?.message || e}`);
throw e;
}
}
// 创建并启动一个微信实例容器。若同名容器已存在则先移除(仅容器,不动卷)。
@@ -85,6 +151,8 @@ export async function runInstance(inst: Instance): Promise<void> {
try {
const existing = docker.getContainer(inst.containerName);
await existing.inspect();
// 删除前先把旧容器最后日志快照进持久日志,否则随容器删除就看不到"上次为何停/崩"。
await snapshotContainerLog(inst, '容器重建(重启/升级/自愈),保留上一容器最后日志');
await existing.remove({ force: true });
} catch {
/* 不存在,正常 */
@@ -98,20 +166,47 @@ export async function runInstance(inst: Instance): Promise<void> {
ShmSize: SHM_SIZE,
RestartPolicy: { Name: 'unless-stopped' },
};
if (INSTANCE_MEM > 0) {
hostConfig.Memory = INSTANCE_MEM;
hostConfig.MemorySwap = INSTANCE_MEM; // 禁止 swap 膨胀:限制即为硬上限
}
if (vids.length) {
hostConfig.Devices = vids.map((d) => ({ PathOnHost: d, PathInContainer: d, CgroupPermissions: 'rwm' }));
hostConfig.GroupAdd = ['video']; // 让容器内 abc 用户能访问 /dev/videoN
console.log(`[docker] 实例 ${inst.id} 挂载摄像头设备: ${vids.join(', ')}`);
}
const container = await docker.createContainer({
// 伪装成真实有线网卡 MAC(厂商 OUI),替代容器默认的本地管理位 MAC。
const mac = realisticMac(inst.id);
const createOpts: Docker.ContainerCreateOptions = {
name: inst.containerName,
Image: WECHAT_IMAGE,
Hostname: inst.containerName,
// 内部 hostname 伪装成"个人电脑"名(不再用 woc-wx-<hex>,那是容器/服务器特征)。
// 反代靠容器名 name 寻址,与此 hostname 无关。
Hostname: realisticHostname(inst.id),
Env: envList(inst),
ExposedPorts: { '3000/tcp': {} },
HostConfig: hostConfig,
});
await container.start();
};
// 自定义网络时,MAC 须写到对应 endpoint 上(新版 docker 弃用顶层 MacAddress);默认网络则用顶层。
if (net) {
createOpts.NetworkingConfig = { EndpointsConfig: { [net]: { MacAddress: mac } as any } };
} else {
(createOpts as any).MacAddress = mac;
}
const container = await docker.createContainer(createOpts);
try {
await container.start();
appendInstanceLog(inst.id, '容器已启动');
} catch (e) {
// 启动失败但容器已被创建出来(Created 状态),不清理的话会成为"幽灵容器"——
// 它仍占着卷名 woc-data-<id>,让后续删卷报 409。修复 #23 时发现 4 个此类残留。
try {
await container.remove({ force: true });
} catch {
/* 容器已被外部移走或正在被清理,忽略 */
}
throw e;
}
}
// 确保实例容器在运行:缺失则按需创建(不会重建已有卷),停止则启动。
@@ -136,10 +231,31 @@ export async function upgradeInstance(inst: Instance): Promise<void> {
await runInstance(inst);
}
// 重置实例的设备 machine-id:删掉持久化的 .woc-machine-id 后重启,由 00-woc-identity 钩子重新生成
// 一个全新的唯一值(相当于"换一台新设备")。用于某账号被腾讯风控标记后手动滚新设备身份。
// 仅对含身份钩子的新镜像有效;旧镜像(升级前)无钩子,先 throw 提示升级,避免做无用功。
export async function regenInstanceMachineId(inst: Instance): Promise<void> {
const hasHook = (
await execCapture(inst, [
'sh',
'-c',
'test -f /custom-cont-init.d/00-woc-identity && echo yes || echo no',
])
).trim();
if (hasHook !== 'yes') {
throw new Error('该实例运行的是旧镜像(无设备身份模块),请先「升级实例」后再重置设备 ID');
}
// 删除持久化文件;重启时钩子检测到缺失 → 生成新的唯一 machine-id 并写回卷
await execCapture(inst, ['sh', '-c', 'rm -f /config/.woc-machine-id']);
await stopInstance(inst);
await runInstance(inst);
}
// 停止实例容器(保留容器与数据卷,可再启动)。
export async function stopInstance(inst: Instance): Promise<void> {
try {
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
appendInstanceLog(inst.id, '容器已停止');
} catch {
/* 已停止或不存在 */
}
@@ -158,9 +274,121 @@ export async function removeInstance(inst: Instance, purgeVolume: boolean): Prom
} catch {
/* 卷可能不存在 */
}
deleteInstanceLog(inst.id); // 彻底删除时一并清掉持久日志
}
}
// 列出"未被任何容器引用的 woc-data-* 数据卷"。判定改为 docker 真实视角(不再仅看 store),
// 否则 Created 状态的"幽灵容器"会让卷被误判为孤儿,删除时撞 409real-world issue
// 早期 runInstance 启动失败漏清残留容器,留下 4 个 Created 容器各占一个卷名)。
export async function listOrphanVolumes(referencedVolumes: Set<string>): Promise<
Array<{ name: string; createdAt?: string; sizeBytes?: number }>
> {
// 容器视角:扫所有容器(含已停止 / Created),收集它们挂载的 woc-data-* 卷名
const allContainers = await docker.listContainers({ all: true });
const containerRefs = new Set<string>();
for (const c of allContainers) {
for (const m of c.Mounts || []) {
if (typeof m.Name === 'string' && m.Name.startsWith('woc-data-')) containerRefs.add(m.Name);
}
}
// 与 store 视角并集:取两者都未引用的卷
const referenced = new Set<string>([...referencedVolumes, ...containerRefs]);
const { Volumes } = (await (docker as any).listVolumes()) || { Volumes: [] };
if (!Array.isArray(Volumes)) return [];
return Volumes
.filter((v: any) => typeof v?.Name === 'string' && v.Name.startsWith('woc-data-') && !referenced.has(v.Name))
.map((v: any) => ({
name: v.Name,
createdAt: v.CreatedAt,
// UsageData 仅在 docker engine 启用 -v size=true 时返回,常见情况下没有;缺失就不展示
sizeBytes: typeof v?.UsageData?.Size === 'number' && v.UsageData.Size >= 0 ? v.UsageData.Size : undefined,
}))
.sort((a, b) => (a.createdAt && b.createdAt ? (a.createdAt < b.createdAt ? 1 : -1) : 0));
}
// 显式删除一个数据卷(管理员清理孤儿卷用)。调用方负责确认它不被现存实例引用。
export async function removeVolume(name: string): Promise<void> {
await docker.getVolume(name).remove({ force: true } as any);
}
// 列出"残留的 woc-wx-* 容器":在 docker 里存在但 store 没登记的(多为 runInstance 失败时
// 留下的 Created 状态容器,或用户手动 docker run 出来的)。给管理员一键清理。
export async function listOrphanContainers(
knownContainerNames: Set<string>,
): Promise<Array<{ id: string; name: string; status: string; volumeName?: string }>> {
const all = await docker.listContainers({ all: true });
const out: Array<{ id: string; name: string; status: string; volumeName?: string }> = [];
for (const c of all) {
const name = (c.Names || []).map((n) => n.replace(/^\//, '')).find((n) => n.startsWith('woc-wx-'));
if (!name) continue;
if (knownContainerNames.has(name)) continue;
const vol = (c.Mounts || []).map((m) => m.Name).find((n) => typeof n === 'string' && n.startsWith('woc-data-'));
out.push({ id: c.Id, name, status: c.Status || c.State || '', volumeName: vol });
}
return out;
}
// 强制删除一个残留容器(按短/全 id 或容器名都行)。
export async function removeContainerById(idOrName: string): Promise<void> {
await docker.getContainer(idOrName).remove({ force: true });
}
// 取实例容器的"working set"内存(MB):等同 docker stats 显示值 = usage - inactive_file。
// 用于 watchdog 检测 KasmVNC/Xvnc 长跑泄漏(21 小时可涨到 ~9 GiB),无法读取时返回 0(视为"暂未知"
// 不触发自愈,避免容器刚启动 stats 不可用就被误杀)。一次性 stats、不订阅 stream。
export async function instanceMemoryMB(inst: Instance): Promise<number> {
try {
const c = docker.getContainer(inst.containerName);
const s: any = await c.stats({ stream: false } as any);
const usage = Number(s?.memory_stats?.usage) || 0;
const inactive = Number(
s?.memory_stats?.stats?.inactive_file ?? s?.memory_stats?.stats?.total_inactive_file,
) || 0;
const bytes = Math.max(0, usage - inactive);
return Math.round(bytes / 1024 / 1024);
} catch {
return 0;
}
}
// 响应性健康探测:实测发现容器跑久了会出现 I/O / 服务 stall —— 进程没死、面板显示"在线",
// 但读不出 VNC 客户端静态文件(nginx 报 upstream timed out),浏览器永远卡在"正在连接桌面"。
// 这里带注入鉴权请求真正会卡的那条路径(/vnc/index.html,经 nginx→kclient 静态serve),
// 超时即判不健康。无鉴权时 nginx 直接 401(很快),故必须注入鉴权让请求真正打到 kclient 静态层。
export async function instanceHttpHealthy(inst: Instance, timeoutMs = 8000): Promise<boolean> {
const auth = 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64');
return await new Promise<boolean>((resolve) => {
let settled = false;
const done = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
const req = http.get(
{
host: inst.containerName,
port: 3000,
path: '/vnc/index.html',
headers: { authorization: auth },
timeout: timeoutMs,
},
(res) => {
// 拿到响应头即说明 nginx+kclient 静态serve 活着(健康时为 200)。读掉 body 释放连接。
const ok = !!res.statusCode && res.statusCode < 500;
res.resume();
done(ok);
},
);
req.on('timeout', () => {
req.destroy();
done(false); // 超时 = stall,判不健康
});
req.on('error', () => done(false));
});
}
export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
try {
const info = await docker.getContainer(inst.containerName).inspect();
@@ -170,7 +398,7 @@ export async function instanceRuntime(inst: Instance): Promise<RuntimeState> {
}
}
// 在实例容器内执行命令,返回 stdoutdemux 后只取标准输出)
// 在实例容器内执行命令,返回 stdout;若命令失败,把 stderr 透出给调用方
async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const c = docker.getContainer(inst.containerName);
const exec = await c.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true, Tty: false, User: 'abc' });
@@ -181,16 +409,31 @@ async function execCapture(inst: Instance, cmd: string[]): Promise<string> {
const stdout = { write: (b: Buffer) => { out += b.toString('utf8'); } } as any;
const stderr = { write: (b: Buffer) => { err += b.toString('utf8'); } } as any;
docker.modem.demuxStream(stream, stdout, stderr);
stream.on('end', () => resolve(out || err));
stream.on('end', async () => {
try {
const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) {
reject(new Error((err || out || `命令执行失败,退出码 ${info.ExitCode}`).trim()));
return;
}
resolve(out || err);
} catch (e) {
reject(e);
}
});
stream.on('error', reject);
});
}
// 触发下载/安装(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',
@@ -211,7 +454,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 {
@@ -258,6 +507,129 @@ function tarSingleFile(name: string, content: Buffer): Buffer {
return Buffer.concat([h, content, Buffer.alloc(pad, 0), Buffer.alloc(1024, 0)]);
}
// ---------- 诊断包 ----------
// 单个 tar entryUSTAR header + 内容 + 512 对齐填充),复用与 tarSingleFile 相同的格式。
function tarEntry(name: string, content: Buffer): Buffer {
const h = Buffer.alloc(512, 0);
h.write(name.slice(0, 100), 0, 'utf8');
h.write('0000644\0', 100);
h.write('0001750\0', 108);
h.write('0001750\0', 116);
h.write(content.length.toString(8).padStart(11, '0') + '\0', 124);
h.write('00000000000\0', 136);
h.write(' ', 148); // checksum 占位
h.write('0', 156); // typeflag 普通文件
h.write('ustar\0', 257);
h.write('00', 263);
let sum = 0;
for (let i = 0; i < 512; i++) sum += h[i];
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
const pad = (512 - (content.length % 512)) % 512;
return Buffer.concat([h, content, Buffer.alloc(pad, 0)]);
}
// 多文件 tar.gz(内存构建;诊断包通常仅数 MB)。文件名用 ASCII 路径避免 utf8 超 100 字节。
function buildTarGz(entries: { name: string; content: string | Buffer }[]): Buffer {
const parts = entries.map((e) => tarEntry(e.name, Buffer.isBuffer(e.content) ? e.content : Buffer.from(e.content, 'utf8')));
parts.push(Buffer.alloc(1024, 0)); // 两个空块标记归档结束
return zlib.gzipSync(Buffer.concat(parts));
}
// 汇总诊断包:系统信息 + 面板全局日志 + 每个实例(容器状态 + 持久日志 + 实时日志)+ 全部 woc-* 容器清单。
// 日志按 sinceMs 时间裁剪。给排查"首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败"等问题用。
export async function buildDiagnostics(instances: Instance[], sinceMs: number, meta: Record<string, string>): Promise<Buffer> {
const entries: { name: string; content: string | Buffer }[] = [];
const stamp = new Date().toISOString();
entries.push({
name: 'README.txt',
content: [
'云微 · WechatOnCloud 诊断包',
`生成时间: ${stamp}`,
`时间范围: 最近 ${meta.range || '24h'}`,
'',
'内容:',
' system.txt 系统/Docker/镜像信息',
' panel.log 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误)',
' containers.txt 所有 woc-* 容器清单(含残留/未登记)',
' instances/<id>.log 每个实例:容器状态 + 持久日志 + 实时容器日志',
'',
'把本压缩包发给维护者即可协助排查(不含密码/密钥等敏感信息)。',
].join('\n'),
});
// 系统信息
let sys = `生成时间: ${stamp}\n时间范围: 最近 ${meta.range || '24h'}\n\n`;
for (const [k, v] of Object.entries(meta)) sys += `${k}: ${v}\n`;
try {
const ver: any = await docker.version();
sys += `\nDocker 版本: ${ver.Version} (API ${ver.ApiVersion}, ${ver.Os}/${ver.Arch})\n`;
} catch (e: any) {
sys += `\nDocker 版本: 获取失败 ${e?.message || e}\n`;
}
try {
const info: any = await docker.info();
sys += `容器: ${info.Containers}(运行 ${info.ContainersRunning} · 镜像: ${info.Images}\n`;
sys += `内核: ${info.KernelVersion} · OS: ${info.OperatingSystem} · 架构: ${info.Architecture}\n`;
sys += `CPU: ${info.NCPU} 核 · 内存: ${(info.MemTotal / 1073741824).toFixed(1)} GiB\n`;
if (Array.isArray(info.Warnings) && info.Warnings.length) sys += `Docker 警告: ${info.Warnings.join('; ')}\n`;
} catch (e: any) {
sys += `Docker info: 获取失败 ${e?.message || e}\n`;
}
try {
const img: any = await docker.getImage(WECHAT_IMAGE).inspect();
sys += `\n实例镜像 ${WECHAT_IMAGE}: ${String(img.Id).slice(0, 19)} · 创建 ${img.Created}\n`;
} catch {
sys += `\n实例镜像 ${WECHAT_IMAGE}: 本地不存在(首次新建实例需联网拉取,可能在此卡住)\n`;
}
sys += `\n实例数: ${instances.length}\n`;
entries.push({ name: 'system.txt', content: sys });
// 面板全局日志(按范围裁剪)
entries.push({ name: 'panel.log', content: filterSince(readPanelLog(), sinceMs) || '(无面板日志)' });
// 每个实例
for (const inst of instances) {
let c = `实例: ${inst.name}\nID: ${inst.id}\n容器: ${inst.containerName}\n类型: ${instanceAppType(inst)}\n数据卷: ${inst.volumeName}\n创建: ${inst.createdAt}\n\n`;
try {
const info: any = await docker.getContainer(inst.containerName).inspect();
const s = info.State || {};
c += `===== 容器状态 =====\n运行: ${s.Running} · 状态: ${s.Status} · 退出码: ${s.ExitCode}\n`;
c += `OOMKilled: ${s.OOMKilled} · 重启次数: ${info.RestartCount} · 启动于: ${s.StartedAt}\n`;
if (s.Error) c += `错误: ${s.Error}\n`;
c += `镜像: ${String(info.Image).slice(0, 19)} · 健康: ${s.Health?.Status ?? 'n/a'}\n\n`;
} catch (e: any) {
c += `===== 容器状态 =====\n无法读取(容器可能未创建/已删除):${e?.message || e}\n\n`;
}
c += `===== 持久化日志(最近 ${meta.range || '24h'} =====\n${filterSince(readInstanceLog(inst.id), sinceMs) || '(无)'}\n\n`;
try {
c += `===== 本次容器日志(实时 tail 300 =====\n${(await instanceLogs(inst, 300)).trimEnd() || '(无)'}\n`;
} catch (e: any) {
c += `===== 本次容器日志 =====\n获取失败:${e?.message || e}\n`;
}
entries.push({ name: `instances/${inst.id}.log`, content: c });
}
// 全部 woc-* 容器清单(含未登记/残留,用于诊断"首次创建失败遗留")
try {
const all = await docker.listContainers({ all: true });
const known = new Set(instances.map((i) => i.containerName));
let txt = '所有 woc-* 容器:\n\n';
for (const ct of all) {
const names = (ct.Names || []).map((n: string) => n.replace(/^\//, ''));
if (!names.some((n) => n.startsWith('woc-'))) continue;
const nm = names.join(',');
const tag = nm.includes('woc-panel') ? '面板' : known.has(nm) ? '已登记实例' : '未登记/残留';
txt += `[${tag}] ${nm} · ${ct.State}/${ct.Status} · ${ct.Image}\n`;
}
entries.push({ name: 'containers.txt', content: txt });
} catch (e: any) {
entries.push({ name: 'containers.txt', content: '获取失败:' + (e?.message || e) });
}
return buildTarGz(entries);
}
// 校验文件名为安全 basename(防路径穿越)。
function safeName(name: string): boolean {
return !!name && name.length <= 200 && !name.includes('/') && !name.includes('\0') && name !== '.' && name !== '..';
@@ -305,11 +677,213 @@ export async function downloadFromInstance(inst: Instance, name: string): Promis
stream.on('end', () => resolve());
stream.on('error', reject);
});
const tar = Buffer.concat(chunks);
if (tar.length < 512) return Buffer.alloc(0);
const sizeStr = tar.toString('ascii', 124, 135).replace(/\0/g, '').trim();
const size = parseInt(sizeStr, 8) || 0;
return tar.subarray(512, 512 + size);
return extractSingleFileFromTar(Buffer.concat(chunks));
}
// 从 docker getArchive 返回的 tar 中取出第一个普通文件的内容。Docker(Go archive/tar) 在 mtime 含纳秒精度等
// 情况下会先写一个 PAX 扩展头块(typeflag 'x'),把它误当文件头会读到扩展记录长度 → 返回错误长度的数据
// "大小不对")。这里跳过 PAX/全局('x'/'g')与 GNU 长名('L'/'K')等扩展头,找到普通文件('0'/NUL)再取内容。
function extractSingleFileFromTar(tar: Buffer): Buffer {
let off = 0;
while (off + 512 <= tar.length) {
const header = tar.subarray(off, off + 512);
let allZero = true;
for (let i = 0; i < 512; i++) if (header[i] !== 0) { allZero = false; break; }
if (allZero) break; // 归档结束(全零块)
const sizeStr = header.toString('ascii', 124, 136).replace(/[^0-7]/g, '');
const size = sizeStr ? parseInt(sizeStr, 8) : 0;
const typeflag = header[156]; // '0'(0x30) 或 NUL(0) = 普通文件
const dataStart = off + 512;
if (typeflag === 0x30 || typeflag === 0) {
return tar.subarray(dataStart, dataStart + size);
}
// 扩展头/目录等:跳过其数据块(向上对齐 512)后继续
off = dataStart + size + ((512 - (size % 512)) % 512);
}
return Buffer.alloc(0);
}
// 拉取实例容器日志(末尾 N 行),供前端"查看/导出日志"排错。
export async function instanceLogs(inst: Instance, tail = 600): Promise<string> {
const c = docker.getContainer(inst.containerName);
const buf = (await c.logs({ stdout: true, stderr: true, tail, timestamps: true })) as unknown as Buffer;
// docker 非 TTY 日志为多路复用流:每帧 8 字节头([stream,0,0,0,size BE]+ 负载;解出纯文本。
let out = '';
let i = 0;
while (i + 8 <= buf.length) {
const size = buf.readUInt32BE(i + 4);
if (size < 0 || i + 8 + size > buf.length) break;
out += buf.subarray(i + 8, i + 8 + size).toString('utf8');
i += 8 + size;
}
return out || buf.toString('utf8'); // 兜底:TTY 模式非多路复用
}
// ---------- 持久化日志 ----------
// 日志原语(appendInstanceLog / readInstanceLog / deleteInstanceLog / appendPanelLog 等)已抽到 logs.ts
// (无 docker 依赖,避免循环)。这里只保留需要 docker 的快照能力。
// 把"即将被删/重建"的容器最后日志快照进持久日志(否则随容器删除丢失)。
export async function snapshotContainerLog(inst: Instance, reason: string): Promise<void> {
try {
const logs = (await instanceLogs(inst, 200)).trimEnd();
appendInstanceLog(inst.id, `──── ${reason} ────\n${logs}\n──── 上一容器日志快照结束 ────`);
} catch {
/* 容器可能已不可读,忽略 */
}
}
// 通过 xdotool 在实例容器内输入文字(绕过 VNC keysym 限制,解决中文 IME 吞字问题)。
// 用 base64 传递文本避免 shell 转义问题,xclip 写入剪贴板后 xdotool 模拟 Ctrl+V 粘贴。
export async function typeInInstance(inst: Instance, text: string): Promise<void> {
const b64 = Buffer.from(text, 'utf8').toString('base64');
const cmd = [
'set -e',
'display="${DISPLAY:-}"',
'if [ -z "$display" ]; then for x in /tmp/.X11-unix/X*; do [ -e "$x" ] || continue; display=":${x##*X}"; break; done; fi',
'export DISPLAY="${display:-:1}"',
'command -v xclip >/dev/null 2>&1 || { echo "xclip not installed in instance image" >&2; exit 127; }',
'command -v xdotool >/dev/null 2>&1 || { echo "xdotool not installed in instance image" >&2; exit 127; }',
// 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 微信数据迁移上来、跨实例迁移、离线备份。
// 路径安全:所有相对路径经 safeVolPath 归一化并严格限制在 /config 内,禁止 .. 穿越。
const VOL_ROOT = '/config';
// 把用户给的相对路径安全解析为 /config 下的绝对路径;禁止 .. 与 NUL;剥离前导 /。
function safeVolPath(rel: string): string {
const raw = (rel ?? '').replace(/\\/g, '/');
if (raw.includes('\0')) throw new Error('路径不合法');
const parts: string[] = [];
for (const seg of raw.split('/')) {
if (seg === '' || seg === '.') continue;
if (seg === '..') throw new Error('路径不合法(禁止 ..');
parts.push(seg);
}
return parts.length ? `${VOL_ROOT}/${parts.join('/')}` : VOL_ROOT;
}
const relOf = (abs: string): string => (abs === VOL_ROOT ? '' : abs.slice(VOL_ROOT.length + 1));
// gzip 魔数自动识别(用户上传可能是 .tar 或 .tar.gz;本系统备份恒为 .gz)。
const maybeGunzip = (buf: Buffer): Buffer =>
buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b ? zlib.gunzipSync(buf) : buf;
export interface VolEntry {
name: string;
type: 'dir' | 'file' | 'link' | 'other';
size: number;
mtime: number; // epoch ms
}
// 列目录(仅一层)。dirs/files 混合返回,前端排序。
export async function listVolume(inst: Instance, rel: string): Promise<{ path: string; entries: VolEntry[] }> {
const abs = safeVolPath(rel);
// GNU find -printf%y 类型(d/f/l) \t %s 大小 \t %T@ mtime(秒.纳秒) \t %f 名字。argv 直传不经 shell,名字含空格/引号也安全。
const out = await execCapture(inst, [
'find', abs, '-maxdepth', '1', '-mindepth', '1', '-printf', '%y\\t%s\\t%T@\\t%f\\n',
]);
const entries: VolEntry[] = [];
for (const line of out.split('\n')) {
if (!line) continue;
const i1 = line.indexOf('\t');
const i2 = line.indexOf('\t', i1 + 1);
const i3 = line.indexOf('\t', i2 + 1);
if (i1 < 0 || i2 < 0 || i3 < 0) continue;
const y = line.slice(0, i1);
entries.push({
type: y === 'd' ? 'dir' : y === 'f' ? 'file' : y === 'l' ? 'link' : 'other',
size: Number(line.slice(i1 + 1, i2)) || 0,
mtime: Math.round(parseFloat(line.slice(i2 + 1, i3)) * 1000) || 0,
name: line.slice(i3 + 1),
});
}
return { path: relOf(abs), entries };
}
export async function volMkdir(inst: Instance, rel: string): Promise<void> {
const abs = safeVolPath(rel);
if (abs === VOL_ROOT) throw new Error('路径不合法');
await execCapture(inst, ['mkdir', '-p', abs]);
}
export async function volMove(inst: Instance, fromRel: string, toRel: string): Promise<void> {
const from = safeVolPath(fromRel);
const to = safeVolPath(toRel);
if (from === VOL_ROOT || to === VOL_ROOT) throw new Error('不能移动数据卷根目录');
if (from === to) return;
await execCapture(inst, ['mv', '-f', from, to]);
}
export async function volDelete(inst: Instance, rel: string): Promise<void> {
const abs = safeVolPath(rel);
if (abs === VOL_ROOT) throw new Error('不能删除数据卷根目录');
await execCapture(inst, ['rm', '-rf', abs]);
}
// 上传单个文件到指定目录(tarSingleFile 写入 uid/gid 1000,落地即 abc 属主,微信可读)。
export async function volUploadFile(inst: Instance, rel: string, name: string, content: Buffer): Promise<void> {
if (!safeName(name)) throw new Error('文件名不合法');
const dir = safeVolPath(rel);
await execCapture(inst, ['mkdir', '-p', dir]);
await docker.getContainer(inst.containerName).putArchive(tarSingleFile(name, content), { path: dir });
}
// 上传压缩包并解压到指定目录(PC 微信数据迁移:用户把文件夹打成 .tar/.tar.gz 上传)。
// putArchive 把 tar 内容解到 dir 下,Docker 解包限制在 dir 内、防 .. 穿越。
export async function volExtractArchive(inst: Instance, rel: string, archive: Buffer): Promise<void> {
const dir = safeVolPath(rel);
await execCapture(inst, ['mkdir', '-p', dir]);
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: dir });
}
export async function volDownloadFile(inst: Instance, rel: string): Promise<Buffer> {
const abs = safeVolPath(rel);
if (abs === VOL_ROOT) throw new Error('不能下载整个根目录,请用整卷备份');
const stream = (await docker.getContainer(inst.containerName).getArchive({ path: abs })) as NodeJS.ReadableStream;
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
stream.on('data', (d: Buffer) => chunks.push(d));
stream.on('end', () => resolve());
stream.on('error', reject);
});
return extractSingleFileFromTar(Buffer.concat(chunks));
}
// 整卷备份:把 /config 打成 tar 流并经 gzip 输出(路由直接 pipe 给响应,避免大文件入内存)。
// getArchive('/config') 的条目前缀为 config/,恢复时解到容器根即可落回 /config。
export async function volBackupStream(inst: Instance): Promise<NodeJS.ReadableStream> {
const tar = (await docker.getContainer(inst.containerName).getArchive({ path: VOL_ROOT })) as NodeJS.ReadableStream;
const gzip = zlib.createGzip();
tar.on('error', (e) => gzip.destroy(e as Error));
return tar.pipe(gzip);
}
// 整卷恢复:仅适用于本系统导出的备份(条目前缀 config/),解到容器根 → 落回 /config。要求实例已停止。
export async function volRestoreArchive(inst: Instance, archive: Buffer): Promise<void> {
await docker.getContainer(inst.containerName).putArchive(maybeGunzip(archive), { path: '/' });
}
// 实例容器名(供反代构造 target)。
+99
View File
@@ -0,0 +1,99 @@
// Host-header allowlist for DNS-rebinding protection.
//
// Background: the panel binds 0.0.0.0:8080 and ships default credentials
// (admin / wechat). Without Host-header validation, a malicious site the
// operator visits can use DNS rebinding to point a hostname at the panel's
// LAN/loopback IP and drive every authenticated API from the operator's own
// browser — including the docker.sock-backed admin endpoints. The
// `sameSite: 'lax'` cookie does not stop this: after rebinding, the browser
// treats the attacker hostname as same-origin with the panel and includes
// any cookie it issues. The fix is host-allowlisting at the request edge.
//
// Default allowlist (covers documented deploys without operator action):
// - loopback: localhost / 127.0.0.1 / ::1
// - RFC1918 private LAN: 10/8, 172.16-31/12, 192.168/16
// - link-local IPv4: 169.254/16
// Public hostnames (the recommended reverse-proxy deployment) must be added
// via PANEL_ALLOWED_HOSTS=<comma-separated>.
export function parseHost(headerHost: string | undefined): string {
if (!headerHost) return '';
const trimmed = headerHost.trim();
if (!trimmed) return '';
if (trimmed.startsWith('[')) {
const close = trimmed.indexOf(']');
if (close <= 0) return '';
return trimmed.slice(0, close + 1).toLowerCase();
}
const colon = trimmed.lastIndexOf(':');
const host = colon > 0 ? trimmed.slice(0, colon) : trimmed;
return host.toLowerCase();
}
export function isLoopbackHost(host: string): boolean {
return (
host === 'localhost' ||
host === '127.0.0.1' ||
host === '[::1]' ||
host === '::1'
);
}
export function isPrivateIpv4(host: string): boolean {
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!m) return false;
const o = [m[1], m[2], m[3], m[4]].map((s) => Number(s));
if (o.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false;
// 10.0.0.0/8
if (o[0] === 10) return true;
// 172.16.0.0/12
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true;
// 192.168.0.0/16
if (o[0] === 192 && o[1] === 168) return true;
// 169.254.0.0/16 (link-local)
if (o[0] === 169 && o[1] === 254) return true;
return false;
}
export function parseAllowedHosts(raw: string | undefined): string[] {
if (!raw) return [];
const out: string[] = [];
for (const part of raw.split(',')) {
const lower = part.trim().toLowerCase();
if (lower) out.push(lower);
}
return [...new Set(out)];
}
export function isAllowedHost(host: string, allowlist: string[]): boolean {
if (!host) return false;
if (isLoopbackHost(host)) return true;
if (isPrivateIpv4(host)) return true;
for (const entry of allowlist) {
if (entry === host) return true;
// 通配子域:*.example.com 匹配任意子域(a.example.com),但不匹配裸 example.com。
if (entry.startsWith('*.')) {
const suffix = entry.slice(1); // ".example.com"
if (host.length > suffix.length && host.endsWith(suffix)) return true;
}
}
return false;
}
// 反代/CDNCloudflare、nginx、Caddy 等)部署时,真实对外域名可能在 X-Forwarded-Host 里,
// 而 Host 被改写成内部地址。综合判定:Host 或 X-Forwarded-Host 任一在白名单即放行。
// 安全性:DNS-rebinding 攻击者直连面板时,浏览器 fetch 无法设置 X-Forwarded-Host(禁止首部),
// 故该首部只会由可信反代设置,不会被攻击者利用。
export function isRequestHostAllowed(
hostHeader: string | undefined,
forwardedHostHeader: string | string[] | undefined,
allowlist: string[],
): boolean {
if (isAllowedHost(parseHost(hostHeader), allowlist)) return true;
let xfh = Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader;
if (xfh) {
xfh = xfh.split(',')[0]; // 多级代理链取第一个(最初的客户端 Host)
if (isAllowedHost(parseHost(xfh), allowlist)) return true;
}
return false;
}
+579 -3
View File
@@ -20,13 +20,17 @@ import {
setUserInstances,
listInstances,
findInstance,
setInstanceMemLimits,
userInstances,
userCanAccess,
createInstance,
removeInstance as removeInstanceRecord,
renameInstance,
setInstanceIcon,
setInstanceUsers,
publicInstance,
APP_TYPES,
type AppType,
type User,
type Instance,
} from './store.js';
@@ -45,8 +49,31 @@ import {
listInstanceFiles,
downloadFromInstance,
deleteInstanceFile,
instanceLogs,
buildDiagnostics,
typeInInstance,
keyInInstance,
listOrphanVolumes,
removeVolume,
listOrphanContainers,
removeContainerById,
instanceMemoryMB,
instanceHttpHealthy,
regenInstanceMachineId,
listVolume,
volMkdir,
volMove,
volDelete,
volUploadFile,
volExtractArchive,
volDownloadFile,
volBackupStream,
volRestoreArchive,
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js';
import { appendInstanceLog, readInstanceLog, appendPanelLog, readPanelLog, pruneOldLogs, filterSince, rangeToMs, DIAG_RANGES } from './logs.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -54,6 +81,11 @@ const PORT = Number(process.env.PORT || 8080);
const HOST = process.env.HOST || '0.0.0.0';
const STATIC_DIR = process.env.STATIC_DIR || join(__dirname, '../../web/dist');
const COOKIE = 'woc_sess';
// Public hostnames the panel will accept Host headers for, in addition to the
// always-on loopback + RFC1918 LAN allowlist. Required for HTTPS reverse-proxy
// deploys (Caddy/nginx/飞牛 内置反代) where the public hostname differs from
// the LAN IP. See .env.example.
const ALLOWED_HOSTS = parseAllowedHosts(process.env.PANEL_ALLOWED_HOSTS);
function basicAuth(inst: Instance) {
return 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64');
@@ -62,6 +94,23 @@ function basicAuth(inst: Instance) {
initStore();
const app = Fastify({ logger: true, trustProxy: true });
// DNS-rebinding gate: reject requests whose Host header is neither a loopback /
// RFC1918 LAN address nor in PANEL_ALLOWED_HOSTS. Runs before every route so
// /api/*, /desktop/* and static-file responses are all covered.
app.addHook('onRequest', async (req, reply) => {
if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) {
// 把被拒的 Host / X-Forwarded-Host 一起回显,反代调试时可一眼看出"后端实际收到的是什么"
// —— 决定是去白名单加这个 host,还是修反代让它透传 Host。不泄露敏感信息。
reply.code(400).send({
error: 'Host header not allowed',
host: parseHost(req.headers.host) || null,
forwardedHost: req.headers['x-forwarded-host'] || null,
hint: '反代部署请把对外域名加入 PANEL_ALLOWED_HOSTS.env 逗号分隔,支持 *.example.com),改完用 docker compose up -d 重建容器(不是 restart)使其生效',
});
}
});
await app.register(cookie);
// 文件上传走原始二进制(前端以 application/octet-stream 直传 File
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
@@ -124,6 +173,19 @@ app.get('/api/auth/me', async (req, reply) => {
return { user: publicUser(u) };
});
// ---------- 版本与更新检测 ----------
// 当前构建版本 + 缓存的「最新版」检测结果(后台每 6h 查一次 Docker Hub/GHCR)。任何登录用户可读。
app.get('/api/version', async (req, reply) => {
if (!requireAuth(req, reply)) return;
ensureChecked(); // 刚启动还没首检时,触发一次后台检查(不阻塞本次响应)
return versionInfo();
});
// 立即重新检查(管理员,用于「检查更新」按钮)。
app.post('/api/admin/version/check', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return await checkForUpdate();
});
// ---------- 自助改密 ----------
app.post('/api/account/password', async (req, reply) => {
const u = requireAuth(req, reply);
@@ -227,21 +289,166 @@ app.get('/api/instances', async (req, reply) => {
app.post('/api/admin/instances', async (req, reply) => {
const admin = requireAdmin(req, reply);
if (!admin) return;
const { name } = (req.body as any) ?? {};
const { name, reuseVolume, 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 inst = createInstance(String(name), admin.id, allowedUserIds);
const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat';
// 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。
let reuseVolumeName: string | undefined;
if (reuseVolume) {
if (typeof reuseVolume !== 'string' || !/^woc-data-[0-9a-zA-Z._-]{1,64}$/.test(reuseVolume)) {
return reply.code(400).send({ error: '复用卷名不合法' });
}
if (listInstances().some((i) => i.volumeName === reuseVolume)) {
return reply.code(409).send({ error: '该数据卷已被另一个实例占用' });
}
reuseVolumeName = reuseVolume;
}
const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type);
appendPanelLog(
'INFO',
`创建实例「${inst.name}」(${type}, id=${inst.id}) by ${admin.username}${reuseVolumeName ? ` · 复用卷 ${reuseVolumeName}` : ''} → 开始创建容器(镜像缺失会自动拉取,首次较慢)`,
);
appendInstanceLog(inst.id, `实例创建(${type}by ${admin.username}`);
try {
await runInstance(inst);
} catch (e: any) {
removeInstanceRecord(inst.id); // 容器起不来则回滚登记
appendPanelLog('ERROR', `创建实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
return reply.code(500).send({ error: '创建容器失败:' + (e?.message || e) });
}
appendPanelLog('INFO', `创建实例「${inst.name}」(id=${inst.id}) 成功`);
return { instance: publicInstance(inst) };
});
// 列出"未被任何实例引用的 woc-data-* 数据卷"。删除实例时默认保留卷(聊天记录),但 panel 里
// 看不到这些孤儿卷;本接口让管理员在新建实例时复用旧卷(同微信号扫码可继承聊天记录),
// 或在不需要时彻底删除。
app.get('/api/admin/orphan-volumes', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const referenced = new Set(listInstances().map((i) => i.volumeName));
try {
const volumes = await listOrphanVolumes(referenced);
return { volumes };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '读取数据卷失败' });
}
});
// 列出"残留的 woc-wx-* 容器"docker 里存在但 store 没登记。多为 runInstance 启动失败遗留
// 的 Created 容器,会占着 woc-data-<id> 卷名让删卷报 409。提供给管理员一键清理。
app.get('/api/admin/orphan-containers', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const known = new Set(listInstances().map((i) => i.containerName));
try {
const containers = await listOrphanContainers(known);
return { containers };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '读取容器失败' });
}
});
// 强制删除一个残留容器。仅当它不在 store 的已知容器集中(防误删正在用的实例)。
app.delete('/api/admin/orphan-containers/:idOrName', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const idOrName = (req.params as any).idOrName;
if (!idOrName || typeof idOrName !== 'string') return reply.code(400).send({ error: '参数不合法' });
if (listInstances().some((i) => i.containerName === idOrName)) {
return reply.code(409).send({ error: '该容器属于现存实例,不能在此删除' });
}
try {
await removeContainerById(idOrName);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '删除容器失败' });
}
});
// 显式删除一个未使用的数据卷。被现存实例占用时拒绝(避免误删聊天记录)。
app.delete('/api/admin/orphan-volumes/:name', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const name = (req.params as any).name;
if (!name || typeof name !== 'string' || !name.startsWith('woc-data-')) {
return reply.code(400).send({ error: '卷名不合法' });
}
if (listInstances().some((i) => i.volumeName === name)) {
return reply.code(409).send({ error: '该数据卷正被某个实例使用,不能删除' });
}
try {
await removeVolume(name);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '删除数据卷失败' });
}
});
// 查/改单实例的内存安全阀(soft / hard)。前端"实例卡片 → 安全"弹窗用。
// GET 返回 per-instance 当前覆盖值 + 全局默认 + 实时内存(用于弹窗里展示)。
// PUT 接受 {soft, hard},每项可为正整数 / nullnull = 恢复默认)。
app.get('/api/admin/instances/:id/mem-limits', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
let currentMB = 0;
try {
if ((await instanceRuntime(inst)) === 'running') currentMB = await instanceMemoryMB(inst);
} catch {
/* ignore:未运行时为 0 */
}
return {
soft: inst.memSoftLimitMB ?? null,
hard: inst.memHardLimitMB ?? null,
defaultSoft: DEFAULT_SOFT_MB,
defaultHard: DEFAULT_HARD_MB,
currentMB,
watchdogEnabled: WATCHDOG_ENABLED,
intervalSec: WATCHDOG_INTERVAL_SEC,
};
});
app.put('/api/admin/instances/:id/mem-limits', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = (req.body as any) ?? {};
// 允许 number / null;其它类型都视为"未提供"(保持原值)
const norm = (v: any): number | null | undefined =>
v === null ? null : typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined;
const s = norm(body.soft);
const h = norm(body.hard);
// 取最终生效值(写入前校验)
const finalSoft = s === undefined ? inst.memSoftLimitMB ?? null : s;
const finalHard = h === undefined ? inst.memHardLimitMB ?? null : h;
try {
const pub = setInstanceMemLimits(
id,
finalSoft,
finalHard,
);
return { instance: pub };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '阈值不合法' });
}
});
// 重置实例的设备 machine-id(仅管理员):滚一个全新的唯一设备身份并重启实例。
// 用于某微信账号被腾讯按"设备风险"标记、登录即被踢时,像"换台新设备"一样恢复。会触发重新扫码登录。
app.post('/api/admin/instances/:id/regen-machine-id', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const id = (req.params as any).id;
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await regenInstanceMachineId(inst);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '重置设备 ID 失败' });
}
});
// 删除实例(仅管理员):默认保留数据卷,?purge=1 才永久删聊天记录
app.delete('/api/admin/instances/:id', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
@@ -249,6 +456,7 @@ app.delete('/api/admin/instances/:id', async (req, reply) => {
const purge = (req.query as any)?.purge === '1' || (req.query as any)?.purge === 'true';
const inst = findInstance(id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
appendPanelLog('INFO', `删除实例「${inst.name}」(id=${id})${purge ? ' · 同时清除数据卷' : ' · 保留数据卷'}`);
await removeInstanceContainer(inst, purge);
removeInstanceRecord(id);
controlHolders.delete(id);
@@ -266,6 +474,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;
@@ -273,8 +492,10 @@ app.post('/api/admin/instances/:id/start', async (req, reply) => {
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await ensureRunning(inst);
appendPanelLog('INFO', `启动实例「${inst.name}」(id=${inst.id})`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `启动实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
return reply.code(500).send({ error: '启动失败:' + (e?.message || e) });
}
});
@@ -286,8 +507,10 @@ app.post('/api/admin/instances/:id/stop', async (req, reply) => {
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await stopInstance(inst);
appendPanelLog('INFO', `停止实例「${inst.name}」(id=${inst.id})`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `停止实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
}
});
@@ -298,9 +521,11 @@ app.post('/api/admin/instances/:id/restart', async (req, reply) => {
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
appendPanelLog('INFO', `重启实例「${inst.name}」(id=${inst.id})`);
await runInstance(inst);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `重启实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
}
});
@@ -312,9 +537,12 @@ app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`);
await upgradeInstance(inst);
appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`);
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
}
});
@@ -437,6 +665,228 @@ app.post('/api/instances/:id/control/take', async (req, reply) => {
return { mine: true, holder: u.username };
});
// 通过 xdotool 在实例容器内输入文字(绕过 VNC XKB keysym 容量限制,修复中文 IME 吞字)
app.post('/api/instances/:id/type', async (req, reply) => {
const u = requireAuth(req, reply);
if (!u) return;
const id = (req.params as any).id;
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
const { text } = (req.body as any) ?? {};
if (!text || typeof text !== 'string' || text.length > 500) return reply.code(400).send({ error: '文字为空或过长' });
try {
await typeInInstance(findInstance(id)!, text);
return { ok: true };
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '输入失败' });
}
});
// 模拟单个按键(无感输入模式下按序送出被截下的回车/退格,保证与中文转发的顺序)
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;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
reply.header('content-type', 'text/plain; charset=utf-8');
// 持久化历史(重启原因 + 上一容器日志快照,跨重建保留)+ 本次容器实时日志。
const history = readInstanceLog(inst.id).trimEnd();
let live = '';
try {
live = (await instanceLogs(inst)).trimEnd();
} catch (e: any) {
live = '获取本次容器日志失败:' + (e?.message || e);
}
if (!history && !live) return reply.send('(暂无日志)');
if (!history) return reply.send(live);
return reply.send(
`═══ 历史日志(持久化 · 跨重启保留)═══\n${history}\n\n═══ 本次容器日志(实时)═══\n${live || '(本次容器暂无日志)'}`,
);
});
// ---------- 全局日志 / 诊断包(仅管理员)----------
// 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误等跨实例事件),可按时间范围裁剪。
app.get('/api/admin/panel-log', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
reply.header('content-type', 'text/plain; charset=utf-8');
const since = Date.now() - rangeToMs((req.query as any)?.range);
const text = filterSince(readPanelLog(), since).trimEnd();
return reply.send(text || '(暂无面板日志)');
});
// 一键导出诊断包(tar.gz):系统信息 + 面板日志 + 各实例容器状态/持久日志/实时日志 + 全部容器清单。
// 单实例日志只记录"实例内单次日志",这里把全局 + 全部实例 + 容器层面的信息打包,便于排查
// 首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败等问题。range24h(默认)/7d/30d/1y。
app.get('/api/admin/diagnostics', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const range = ((req.query as any)?.range as string) || '24h';
if (!DIAG_RANGES[range]) return reply.code(400).send({ error: '时间范围非法(24h/7d/30d/1y' });
const since = Date.now() - rangeToMs(range);
try {
const buf = await buildDiagnostics(listInstances(), since, { range, 面板版本: CURRENT_VERSION });
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
reply.header('content-type', 'application/gzip');
reply.header('content-disposition', `attachment; filename="woc-diag-${range}-${stamp}.tar.gz"`);
appendPanelLog('INFO', `导出诊断包(范围 ${range}${buf.length} 字节)`);
return reply.send(buf);
} catch (e: any) {
appendPanelLog('ERROR', `导出诊断包失败:${e?.message || e}`);
return reply.code(500).send({ error: '生成诊断包失败:' + (e?.message || e) });
}
});
// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ----------
// 数据卷 = 容器 /config,含微信完整会话与加密聊天库 → 仅 admin 可见可用(admin 本就有 docker.sock=宿主 root
// 不新增风险;子账号永不可达)。
// 全程在「运行中」的实例上操作:浏览/改名/移动/删除靠 docker exec(需容器运行),上传/解压/下载/备份靠
// getArchive/putArchive。不强制停止实例(exec 在停止容器无法运行)。整卷恢复会覆盖全部数据,前端强提示
// 并建议恢复后重启实例以加载数据。
// 浏览目录(一层)
app.get('/api/admin/instances/:id/volume', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
return await listVolume(inst, String((req.query as any)?.path || ''));
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '读取目录失败' });
}
});
// 新建文件夹
app.post('/api/admin/instances/:id/volume/mkdir', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await volMkdir(inst, String((req.body as any)?.path || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '新建失败' });
}
});
// 重命名 / 移动
app.post('/api/admin/instances/:id/volume/move', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const { from, to } = (req.body as any) ?? {};
try {
await volMove(inst, String(from || ''), String(to || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '移动失败' });
}
});
// 删除文件 / 目录
app.delete('/api/admin/instances/:id/volume', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await volDelete(inst, String((req.query as any)?.path || ''));
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '删除失败' });
}
});
// 下载单个文件
app.get('/api/admin/instances/:id/volume/download', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const path = String((req.query as any)?.path || '');
const name = path.split('/').filter(Boolean).pop() || 'file';
try {
const buf = await volDownloadFile(inst, path);
reply.header('content-type', 'application/octet-stream');
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`);
return reply.send(buf);
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '下载失败' });
}
});
// 上传单个文件到当前目录(原始二进制;落地为 abc 属主)
app.post('/api/admin/instances/:id/volume/upload', { bodyLimit: 2 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const path = String((req.query as any)?.path || '');
const name = String((req.query as any)?.name || '').trim();
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volUploadFile(inst, path, name, body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '上传失败' });
}
});
// 上传压缩包并解压到当前目录(.tar / .tar.gz;PC 微信数据迁移用)
app.post('/api/admin/instances/:id/volume/extract', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volExtractArchive(inst, String((req.query as any)?.path || ''), body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '解压失败(请确认是 .tar 或 .tar.gz' });
}
});
// 整卷备份:流式下载 /config 为 .tar.gz
app.get('/api/admin/instances/:id/volume/backup', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
const stream = await volBackupStream(inst);
reply.header('content-type', 'application/gzip');
reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`woc-${inst.name}-backup.tar.gz`)}`);
return reply.send(stream);
} catch (e: any) {
return reply.code(500).send({ error: e?.message || '备份失败' });
}
});
// 整卷恢复:上传本系统导出的 .tar.gz 备份(要求实例已停止)
app.post('/api/admin/instances/:id/volume/restore', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const inst = findInstance((req.params as any).id);
if (!inst) return reply.code(404).send({ error: '实例不存在' });
const body = req.body as Buffer;
if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' });
try {
await volRestoreArchive(inst, body);
return { ok: true };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '恢复失败' });
}
});
// 该实例的微信安装状态(有访问权限即可看)
app.get('/api/instances/:id/wechat/status', async (req, reply) => {
const u = requireAuth(req, reply);
@@ -452,8 +902,10 @@ async function triggerInstanceWechat(id: string, cmd: 'install' | 'update', repl
if (!inst) return reply.code(404).send({ error: '实例不存在' });
try {
await triggerWechat(inst, cmd);
appendPanelLog('INFO', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '下载安装' : '更新'}应用`);
return { ok: true };
} catch (e: any) {
appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '安装' : '更新'}失败:${e?.message || e}`);
return reply.code(500).send({ error: '无法触发安装:' + (e?.message || e) });
}
}
@@ -555,6 +1007,12 @@ function parseCookies(header?: string): Record<string, string> {
await app.ready();
app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
// DNS-rebinding gate for WebSocket upgrades (Fastify's onRequest hook does
// not run on raw upgrades). KasmVNC proxying goes through this path.
if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) {
socket.destroy();
return;
}
const parsed = req.url ? parseDesktopUrl(req.url) : null;
if (!parsed) {
socket.destroy();
@@ -583,5 +1041,123 @@ for (const pub of listInstances()) {
}
}
// WatchdogKasmVNC/Xvnc 长跑会泄漏(实测 24h 可达 ~9 GiB),小内存机器会被拖垮。
// 两档阈值,按"是否有人在用"决定时机:
// softmem >= soft 且当前无活跃会话 → 主动重启(柔和自愈,不打扰)
// hardmem >= hard → 无视会话强制重启(防止 OOM)
// 优先级 hard > soft。两档阈值可在面板"管理 → 实例卡片 → 安全"按钮里单实例覆盖;缺省走 env。
//
// env 默认(可被 per-instance 覆盖):
// WOC_INSTANCE_MEM_SOFT_MB soft 阈值;默认 1500
// WOC_INSTANCE_MEM_HARD_MB hard 阈值;默认 2500(也兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB
// WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒;默认 300(5 分钟),最小 60;0 关闭整个 watchdog
// WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启;默认 0=关闭该探测(仅保留内存自愈)
const DEFAULT_SOFT_MB = Math.max(0, Number(process.env.WOC_INSTANCE_MEM_SOFT_MB ?? 1500));
const DEFAULT_HARD_MB = Math.max(
0,
Number(process.env.WOC_INSTANCE_MEM_HARD_MB ?? process.env.WOC_INSTANCE_MEM_LIMIT_MB ?? 2500),
);
const WATCHDOG_INTERVAL_SEC = Math.max(60, Number(process.env.WOC_WATCHDOG_INTERVAL_SEC ?? 300));
// VNC 响应性探测默认关闭(=0)。实测健康实例 ~1ms 响应,但偶发宿主级 CPU/IO 争用(如同机重 docker build
// 会让探测超时被误判为 stall 而重启正常实例,故默认不启用;需要时设为正整数 N(连续 N 次无响应才重启)开启。
const HEALTH_FAIL_LIMIT = Math.max(0, Number(process.env.WOC_WATCHDOG_HEALTH_FAILS ?? 0));
const WATCHDOG_ENABLED = WATCHDOG_INTERVAL_SEC > 0 && (DEFAULT_SOFT_MB > 0 || DEFAULT_HARD_MB > 0);
// 单实例生效阈值:per-instance 覆盖优先;为 undefined 则用 env 默认。
function effectiveLimits(inst: Instance): { soft: number; hard: number } {
return {
soft: inst.memSoftLimitMB ?? DEFAULT_SOFT_MB,
hard: inst.memHardLimitMB ?? DEFAULT_HARD_MB,
};
}
// "当前有人在远程会话" 启发式判定:复用控制权心跳。前端在用户鼠标/键盘/滚轮交互时 2.5s 节流 beat
// 故 holder 在 TTL 内即视为"有人在主动操作"。只看屏(不交互)超过 TTL 后会被判为空闲——这是有意的,
// 软自愈宁愿在"看似空闲"时短暂打扰,也不要拖到 hard 强制重启。
function hasActiveSession(id: string): boolean {
const h = controlHolders.get(id);
return !!h && Date.now() - h.at <= CONTROL_TTL;
}
if (WATCHDOG_ENABLED) {
const recovering = new Set<string>(); // 防重入:自愈期间跳过本实例
const healthFails = new Map<string, number>(); // id → 连续无响应次数(仅 HEALTH_FAIL_LIMIT>0 时启用)
const recover = async (inst: Instance, reason: string, detail: string) => {
recovering.add(inst.id);
app.log.warn(`[watchdog] ${inst.containerName} ${detail}`);
appendInstanceLog(inst.id, `[看门狗] 自愈重启(${reason}):${detail}`);
appendPanelLog('WARN', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈重启(${reason}):${detail}`);
try {
await stopInstance(inst);
await runInstance(inst);
healthFails.delete(inst.id);
app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason}`);
} catch (e: any) {
appendPanelLog('ERROR', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈失败(${reason}):${e?.message || e}`);
app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}: ${e?.message || e}`);
} finally {
recovering.delete(inst.id);
}
};
const tick = async () => {
for (const pub of listInstances()) {
const inst = findInstance(pub.id);
if (!inst || recovering.has(inst.id)) continue;
try {
if ((await instanceRuntime(inst)) !== 'running') {
healthFails.delete(inst.id);
continue;
}
// 1) 内存阈值自愈(既有):hard 强制 / soft 仅在无人会话时
const mb = await instanceMemoryMB(inst);
if (mb > 0) {
const { soft, hard } = effectiveLimits(inst);
const active = hasActiveSession(inst.id);
if (hard > 0 && mb >= hard) {
await recover(inst, 'hard', `mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active}`);
continue;
}
if (soft > 0 && mb >= soft && !active) {
await recover(inst, 'soft', `mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`);
continue;
}
if (soft > 0 && mb >= soft && active) {
app.log.info(`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用,延后`);
}
}
// 2) 响应性自愈:探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启。
// 应对"进程没死、显示在线,但 I/O/服务 stall 读不出 VNC 文件、永远卡在正在连接桌面"。
// 默认关闭(HEALTH_FAIL_LIMIT=0):偶发宿主级争用会误判健康实例为 stall;需要时用 env 开启。
if (HEALTH_FAIL_LIMIT > 0) {
const healthy = await instanceHttpHealthy(inst);
if (healthy) {
healthFails.delete(inst.id);
continue;
}
const fails = (healthFails.get(inst.id) || 0) + 1;
healthFails.set(inst.id, fails);
app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT}`);
if (fails >= HEALTH_FAIL_LIMIT) {
await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`);
}
}
} catch (e: any) {
app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`);
}
}
};
setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref();
console.log(
`[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · VNC响应性探测=${HEALTH_FAIL_LIMIT > 0 ? `连续${HEALTH_FAIL_LIMIT}` : '关闭'}`,
);
}
await app.listen({ port: PORT, host: HOST });
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`);
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`);
appendPanelLog('INFO', `面板启动 · 版本 ${CURRENT_VERSION} · 监听 ${HOST}:${PORT}`);
startUpdateChecker(); // 后台检测新版(best-effort,失败静默)
// 日志保留期清理:启动后跑一次 + 每 24h 一次,删除超过一年的日志行(unref 不阻止退出)。
pruneOldLogs();
setInterval(() => pruneOldLogs(), 24 * 60 * 60 * 1000).unref();
+128
View File
@@ -0,0 +1,128 @@
// 全局持久化日志(存在面板数据卷 /…/logs/,宿主 ./data-panel 持久保留,跨容器/面板重建不丢)。
// 两类日志:
// _panel.log 面板级运维事件(实例创建/删除/升级/启停、镜像拉取、错误等跨实例的全局动作)
// <实例id>.log 单实例生命周期 + 重启原因 + 重建前容器日志快照
// 单文件按大小封顶(超限截掉前半保留最近),并按「一年保留」定期清理过期行。
// 本模块不依赖 docker,避免与 docker.ts 形成循环依赖(docker.ts/index.ts 反过来引用本模块)。
import { appendFileSync, mkdirSync, statSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
import { dirname } from 'node:path';
// 与 store.ts 的 accounts.json 同目录。fallback 须与 store.ts 一致。
export const LOG_DIR = `${dirname(process.env.PANEL_DATA || '/data/panel/accounts.json')}/logs`;
const PER_FILE_CAP = 512 * 1024; // 单文件 ~512KB,超限截掉前半保留最近
const RETENTION_MS = 365 * 24 * 60 * 60 * 1000; // 日志保留一年,更早的自动清理
const PANEL_LOG = `${LOG_DIR}/_panel.log`;
const INSTANCE_ID_RE = /^[0-9a-f]{1,32}$/; // 实例 id 为十六进制;校验防路径注入
export type LogLevel = 'INFO' | 'WARN' | 'ERROR';
function nowIso(): string {
return new Date().toISOString();
}
function appendTo(file: string, line: string): void {
try {
mkdirSync(LOG_DIR, { recursive: true });
appendFileSync(file, line.endsWith('\n') ? line : line + '\n');
const sz = statSync(file).size;
if (sz > PER_FILE_CAP) writeFileSync(file, readFileSync(file).subarray(sz - Math.floor(PER_FILE_CAP / 2)));
} catch {
/* 写日志失败不影响主流程 */
}
}
function instanceLogPath(id: string): string {
return `${LOG_DIR}/${id}.log`;
}
// ---------- 单实例日志 ----------
export function appendInstanceLog(id: string, line: string): void {
if (!INSTANCE_ID_RE.test(id)) return;
appendTo(instanceLogPath(id), `[${nowIso()}] ${line}`);
}
export function readInstanceLog(id: string): string {
if (!INSTANCE_ID_RE.test(id)) return '';
try {
const p = instanceLogPath(id);
return existsSync(p) ? readFileSync(p, 'utf8') : '';
} catch {
return '';
}
}
// 实例彻底删除(连数据卷一并清除)时,顺手删掉它的持久日志文件,避免遗留孤儿。
export function deleteInstanceLog(id: string): void {
if (!INSTANCE_ID_RE.test(id)) return;
try {
rmSync(instanceLogPath(id), { force: true });
} catch {
/* 忽略 */
}
}
// ---------- 面板级全局日志 ----------
// 同时写入持久文件并回显 stdoutdocker logs woc-panel 仍可见)。运维动作统一走这里,便于诊断包汇总。
export function appendPanelLog(level: LogLevel, message: string): void {
appendTo(PANEL_LOG, `[${nowIso()}] [${level}] ${message}`);
const c = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log;
c(`[panel] ${message}`);
}
export function readPanelLog(): string {
try {
return existsSync(PANEL_LOG) ? readFileSync(PANEL_LOG, 'utf8') : '';
} catch {
return '';
}
}
// ---------- 时间裁剪 / 保留期清理 ----------
// 保留行首 [ISO时间] >= sinceMs 的行;无法解析时间戳的行跟随上一条的保留状态(多行块整体保留)。
export function filterSince(text: string, sinceMs: number): string {
if (!text) return '';
const out: string[] = [];
let keeping = false;
for (const ln of text.split('\n')) {
const m = /^\[(\d{4}-\d\d-\d\dT[\d:.]+Z)\]/.exec(ln);
if (m) {
const t = Date.parse(m[1]);
if (Number.isFinite(t)) keeping = t >= sinceMs;
}
if (keeping) out.push(ln);
}
return out.join('\n');
}
// 清理所有日志文件中早于一年的行;整文件均过期则删除。每日定时调用。
export function pruneOldLogs(): void {
const cutoff = Date.now() - RETENTION_MS;
try {
if (!existsSync(LOG_DIR)) return;
for (const f of readdirSync(LOG_DIR)) {
if (!f.endsWith('.log')) continue;
const p = `${LOG_DIR}/${f}`;
try {
const kept = filterSince(readFileSync(p, 'utf8'), cutoff);
if (kept.trim()) writeFileSync(p, kept.endsWith('\n') ? kept : kept + '\n');
else rmSync(p, { force: true });
} catch {
/* 单文件失败不影响其它 */
}
}
} catch {
/* 忽略 */
}
}
// 诊断包可选时间范围(→ 毫秒)。默认 24h。
export const DIAG_RANGES: Record<string, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000,
};
export function rangeToMs(range: string | undefined): number {
return DIAG_RANGES[range ?? '24h'] ?? DIAG_RANGES['24h'];
}
+103 -5
View File
@@ -25,15 +25,38 @@ 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 拖垮宿主)。
memSoftLimitMB?: number;
memHardLimitMB?: number;
}
interface Data {
@@ -190,7 +213,41 @@ function sanitizeInstanceIds(ids: string[]): string[] {
}
export function publicInstance(i: Instance) {
return { id: i.id, name: i.name, createdAt: i.createdAt, createdBy: i.createdBy };
return {
id: i.id,
name: i.name,
appType: instanceAppType(i), // 老实例无字段时回退 wechat
icon: i.icon,
createdAt: i.createdAt,
createdBy: i.createdBy,
memSoftLimitMB: i.memSoftLimitMB,
memHardLimitMB: i.memHardLimitMB,
};
}
// 设置/清除某实例的 mem 安全阀。传 null 表示恢复默认(从对象上删字段)。
// 校验:正整数;soft < hard;上限 20480 MiB20 GiB)。
export function setInstanceMemLimits(
id: string,
softMB: number | null,
hardMB: number | null,
) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
const norm = (v: number | null): number | undefined => {
if (v == null) return undefined;
if (!Number.isFinite(v) || !Number.isInteger(v) || v < 1 || v > 20480) {
throw new Error('阈值需为 1-20480 之间的整数(MiB');
}
return v;
};
const s = norm(softMB);
const h = norm(hardMB);
if (s != null && h != null && s >= h) throw new Error('soft 阈值需小于 hard 阈值');
inst.memSoftLimitMB = s;
inst.memHardLimitMB = h;
persist();
return publicInstance(inst);
}
export function listInstances() {
@@ -213,13 +270,37 @@ export function userCanAccess(u: User, instanceId: string) {
return u.allowedInstances.includes(instanceId) && !!findInstance(instanceId);
}
export function createInstance(name: string, createdBy: string, allowedUserIds: string[] = []) {
const id = randomBytes(5).toString('hex'); // 10 hex chars
// 复用旧卷时:从 woc-data-<id> 解析回 id,让新实例的 containerName / volumeName 都对齐旧卷的
// id(避免出现"卷叫 woc-data-abc,但实例 id 是 def"这种命名错配)。若旧 id 与现存实例冲突或卷名
// 非标准前缀,则退回新生成 id,仅卷名指向旧卷。
function parseIdFromVolume(volumeName: string): string | null {
const m = /^woc-data-([0-9a-f]{10})$/.exec(volumeName);
return m ? m[1] : null;
}
export function createInstance(
name: string,
createdBy: string,
allowedUserIds: string[] = [],
reuseVolumeName?: string,
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) {
const reusedId = parseIdFromVolume(reuseVolumeName);
if (reusedId && !findInstance(reusedId)) {
id = reusedId;
}
volumeName = reuseVolumeName; // 始终指向旧卷(即便 id 是新生成的)
}
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: `woc-data-${id}`,
volumeName,
kasmUser: 'woc',
// 用 hex(仅 0-9a-f):容器内 init 脚本以 `openssl passwd -apr1 ${PASSWORD}` 未加引号方式生成 .htpasswd
// base64url 可能含前导 '-' 而被 openssl 当作命令行选项,导致密码哈希为空、所有鉴权失败。hex 不含任何 shell 特殊字符。
@@ -249,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('实例不存在');
+118
View File
@@ -0,0 +1,118 @@
// 面板版本与更新检测。
// 构建时把版本号烤进镜像(Dockerfile: ARG/ENV WOC_VERSION,由 CI 用 git tag 注入,本地构建为 dev);
// 运行时查询 Docker Hub 与 GHCR 上 woc-panel 的最新语义化标签,比对后给前端「有新版」红点。
// 全程 best-effort:离线 / 被墙 / 私有源拉取失败时不报错、不打扰,仅不显示红点(记 error 供「上次检查」提示)。
export const CURRENT_VERSION = (process.env.WOC_VERSION || 'dev').trim();
export interface VersionInfo {
current: string; // 当前构建版本(如 v1.2.0 / dev
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
hasUpdate: boolean; // 当前能解析为语义化版本且 latest > current 时为 true
checkedAt: number; // 上次检查时间戳(ms);0 = 尚未检查
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
error: string | null; // 检查失败原因(两个源都拉不到时)
}
// 镜像命名空间:从 WOC_WECHAT_IMAGE 推断(面板与实例镜像同账号)。
// 例 docker.io/gloridust/wechat-on-cloud:latest → gloridustghcr.io/gloridust/... 同理。
function imageOwner(): string {
const img = (process.env.WOC_WECHAT_IMAGE || 'docker.io/gloridust/wechat-on-cloud').split('@')[0];
const segs = img.split('/'); // [registry?, owner, image[:tag]]
return segs.length >= 2 ? segs[segs.length - 2] : 'gloridust';
}
const PANEL_REPO = process.env.WOC_PANEL_REPO || 'woc-panel';
function parseSemver(s: string): [number, number, number] | null {
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
function cmpSemver(a: [number, number, number], b: [number, number, number]): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}
// 从一堆标签里挑出最大的 x.y.z(忽略 latest / x / x.y 等非完整语义化标签)。
function maxSemver(tags: string[]): string | null {
let best: [number, number, number] | null = null;
for (const t of tags) {
const v = parseSemver(t);
if (v && (!best || cmpSemver(v, best) > 0)) best = v;
}
return best ? `${best[0]}.${best[1]}.${best[2]}` : null;
}
async function getJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<any> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { headers: { 'user-agent': 'woc-panel-update-check', ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(t);
}
}
// Docker Hub 公共 API:免鉴权列标签。
async function dockerHubTags(owner: string): Promise<string[]> {
const d = await getJson(`https://hub.docker.com/v2/repositories/${owner}/${PANEL_REPO}/tags?page_size=100`, {}, 8000);
return Array.isArray(d?.results) ? d.results.map((r: any) => String(r?.name || '')) : [];
}
// GHCR 公共镜像:先取匿名 pull token,再走 registry v2 tags/list。
async function ghcrTags(owner: string): Promise<string[]> {
const tok = await getJson(`https://ghcr.io/token?scope=repository:${owner}/${PANEL_REPO}:pull&service=ghcr.io`, {}, 8000);
const d = await getJson(`https://ghcr.io/v2/${owner}/${PANEL_REPO}/tags/list`, { authorization: `Bearer ${tok?.token || ''}` }, 8000);
return Array.isArray(d?.tags) ? d.tags.map((t: any) => String(t)) : [];
}
let cache: VersionInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: 0, source: null, error: null };
let inflight: Promise<VersionInfo> | null = null;
export function versionInfo(): VersionInfo {
return cache;
}
// 查询两个仓库(并行、互不阻塞),取全局最大语义化版本与当前版本比对。失败静默写入 error。
export function checkForUpdate(): Promise<VersionInfo> {
if (inflight) return inflight; // 合并并发请求,避免重复外呼
inflight = (async () => {
const owner = imageOwner();
const sources: string[] = [];
const tags: string[] = [];
const [hub, ghcr] = await Promise.allSettled([dockerHubTags(owner), ghcrTags(owner)]);
if (hub.status === 'fulfilled' && hub.value.length) {
tags.push(...hub.value);
sources.push('dockerhub');
}
if (ghcr.status === 'fulfilled' && ghcr.value.length) {
tags.push(...ghcr.value);
sources.push('ghcr');
}
const latestBare = maxSemver(tags);
const cur = parseSemver(CURRENT_VERSION);
const latestV = latestBare ? parseSemver(latestBare) : null;
const hasUpdate = !!(latestV && cur && cmpSemver(latestV, cur) > 0);
cache = {
current: CURRENT_VERSION,
latest: latestBare ? `v${latestBare}` : null,
hasUpdate,
checkedAt: Date.now(),
source: sources.join('+') || null,
error: tags.length ? null : '无法连接镜像仓库(Docker Hub / GHCR',
};
return cache;
})().finally(() => {
inflight = null;
});
return inflight;
}
// 尚未检查过则触发一次后台检查(不等待)。GET /api/version 调用,保证刚启动也能尽快填上缓存。
export function ensureChecked(): void {
if (!cache.checkedAt && !inflight) void checkForUpdate().catch(() => {});
}
// 启动后延迟首检(让监听就绪)+ 每 6 小时复检;定时器 unref,不阻止进程退出。
export function startUpdateChecker(): void {
setTimeout(() => void checkForUpdate().catch(() => {}), 4_000).unref();
setInterval(() => void checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000).unref();
}
+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>
);
}
+47 -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';
@@ -99,6 +100,12 @@ export default function AppShell() {
useEffect(() => setDrawer(false), [loc.pathname]); // 路由变化关抽屉
// 路由切换时刷新共享实例列表:管理页用的是独立列表,新建/安装实例后不会动到这个共享 context,
// 否则进入实例页 / 回主页都读到陈旧列表(实例缺失),需手动刷新整页才出现。导航即拉一次最新即可。
// 不清空旧数据,拉取期间沿用旧列表,无闪烁。
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => void state.reload(), [loc.pathname]);
// 移动端不收成窄栏(改用抽屉);折叠仅桌面生效
const railed = collapsed && isDesktop;
@@ -150,6 +157,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
const isAdmin = user?.role === 'admin';
const go = (p: string) => nav(p);
// 有新版时在「管理」入口点个红点(仅管理员,因为升级面板需管理员在宿主操作)。
// 依赖 loc.pathname:导航时复查一次(服务端有缓存、开销极小),保证刚启动时首检完成后红点能及时出现。
const [hasUpdate, setHasUpdate] = useState(false);
useEffect(() => {
if (!isAdmin) return;
api
.getVersion()
.then((v) => setHasUpdate(!!v.hasUpdate))
.catch(() => {});
}, [isAdmin, loc.pathname]);
return (
<aside className="sidebar">
<div className="sb-top">
@@ -169,7 +187,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) => {
@@ -178,7 +196,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>}
@@ -189,9 +207,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
</div>
<div className="sb-footer">
<button className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')} onClick={() => go('/admin')} title={isAdmin ? '管理' : '设置'}>
<span className="sb-ic">{Icon.gear}</span>
<button
className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')}
onClick={() => go('/admin')}
title={isAdmin && hasUpdate ? '管理 · 有新版本可用' : isAdmin ? '管理' : '设置'}
>
<span className="sb-ic">
{Icon.gear}
{isAdmin && hasUpdate && <span className="sb-updot" />}
</span>
{!collapsed && <span className="sb-label">{isAdmin ? '管理' : '设置'}</span>}
{!collapsed && isAdmin && hasUpdate && <span className="sb-updot-text"></span>}
</button>
<button
className="sb-item"
@@ -246,7 +272,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')}>
@@ -259,19 +285,30 @@ 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
? `${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-st ' + st.cls}> {st.text}</span>
<span className="home-card-meta">
<span className={'home-card-st ' + st.cls}> {st.text}</span>
{meta && <span className="home-card-ver">{meta}</span>}
</span>
</span>
<span className="enter-arrow"></span>
</button>
+117 -2
View File
@@ -19,17 +19,84 @@ 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;
memHardLimitMB?: number;
}
export interface MemLimits {
soft: number | null;
hard: number | null;
defaultSoft: number;
defaultHard: number;
currentMB: number;
watchdogEnabled: boolean;
intervalSec: number;
}
export interface InstanceWithStatus extends PanelInstance {
runtime: RuntimeState;
wechat: WechatStatus;
}
export interface VolEntry {
name: string;
type: 'dir' | 'file' | 'link' | 'other';
size: number;
mtime: number; // epoch ms
}
export interface VersionInfo {
current: string; // 当前构建版本(如 v1.2.0 / dev
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
hasUpdate: boolean; // 有更高的语义化版本可用
checkedAt: number; // 上次检查时间戳(ms);0=尚未检查
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
error: string | null; // 检查失败原因
}
// 原始二进制上传(File 直传 application/octet-stream),用于数据卷上传/解压/恢复
async function rawUpload(url: string, file: File): Promise<any> {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
return data;
}
async function req<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
// 仅在有 body 时声明 JSON content-type:否则 Fastify 对「空 body + application/json」会报 400
const headers = opts.body ? { 'content-type': 'application/json', ...opts.headers } : opts.headers;
@@ -58,6 +125,10 @@ export const api = {
changePassword: (oldPassword: string, newPassword: string) =>
req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }),
// 版本与更新检测
getVersion: () => req<VersionInfo>('/api/version'),
checkUpdate: () => req<VersionInfo>('/api/admin/version/check', { method: 'POST' }),
// 子账号
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
createUser: (username: string, password: string, allowedInstances: string[] = []) =>
@@ -75,11 +146,30 @@ export const api = {
// 微信实例
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
createInstance: (name: string, allowedUserIds: string[] = []) =>
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') =>
req<{ instance: PanelInstance }>('/api/admin/instances', {
method: 'POST',
body: JSON.stringify({ name, allowedUserIds }),
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }),
}),
regenMachineId: (id: string) =>
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
getInstanceMemLimits: (id: string) =>
req<MemLimits>(`/api/admin/instances/${id}/mem-limits`),
setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/mem-limits`, {
method: 'PUT',
body: JSON.stringify({ soft, hard }),
}),
listOrphanVolumes: () =>
req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'),
deleteOrphanVolume: (name: string) =>
req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }),
listOrphanContainers: () =>
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
deleteOrphanContainer: (idOrName: string) =>
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
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) =>
@@ -93,6 +183,10 @@ export const api = {
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
// 全局日志 / 诊断包(范围 24h/7d/30d/1y
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
panelLogUrl: (range: string) => `/api/admin/panel-log?range=${encodeURIComponent(range)}`,
// 文件中转
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
@@ -109,8 +203,29 @@ export const api = {
downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`,
deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }),
// 数据卷管理(仅管理员)
volumeList: (id: string, path = '') =>
req<{ path: string; entries: VolEntry[] }>(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`),
volumeMkdir: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
volumeMove: (id: string, from: string, to: string) =>
req(`/api/admin/instances/${id}/volume/move`, { method: 'POST', body: JSON.stringify({ from, to }) }),
volumeDelete: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`, { method: 'DELETE' }),
volumeDownloadUrl: (id: string, path: string) =>
`/api/admin/instances/${id}/volume/download?path=${encodeURIComponent(path)}`,
volumeBackupUrl: (id: string) => `/api/admin/instances/${id}/volume/backup`,
volumeUpload: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/upload?path=${encodeURIComponent(path)}&name=${encodeURIComponent(file.name)}`, file),
volumeExtract: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/extract?path=${encodeURIComponent(path)}`, file),
volumeRestore: (id: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/restore`, file),
// 多端协作:操作控制权
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }),
};
+13
View File
@@ -4,6 +4,19 @@ import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
// PWA 更新即时生效:vite-plugin-pwa 用 autoUpdate + skipWaiting + clientsClaim,新版本会立即接管,
// 但当前页仍显示已加载的旧资源,需再刷一次才生效——这正是"改了却还看旧界面"的根源。这里监听 SW 接管,
// 在"本来已有 SW 在控制"(即一次更新,而非首次安装)时自动重载一次,让更新一刷即生效。
if ('serviceWorker' in navigator) {
const hadController = !!navigator.serviceWorker.controller;
let reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloaded || !hadController) return;
reloaded = true;
window.location.reload();
});
}
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
File diff suppressed because it is too large Load Diff
+395 -19
View File
@@ -1,9 +1,10 @@
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';
import { VncAudio } from '../vncAudio';
// KasmVNC noVNC 页面;反代按实例隔离:/desktop/<id>/* → 对应容器,注入凭据。
function desktopUrl(id: string) {
@@ -13,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;
@@ -39,9 +110,35 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const isAdmin = user?.role === 'admin';
const [frameLoaded, setFrameLoaded] = useState(false);
const [loadStuck, setLoadStuck] = useState(false); // iframe 久未加载出来(疑似实例无响应)
const [dragging, setDragging] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const [files, setFiles] = useState<TFile[]>([]);
const [showClip, setShowClip] = useState(false);
const [clipText, setClipText] = useState('');
// 中文输入模式:'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);
const [starting, setStarting] = useState(false);
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
@@ -50,8 +147,14 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const frameRef = useRef<HTMLIFrameElement>(null);
const dragDepth = useRef(0);
const lastBeat = useRef(0);
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);
const offline = inst ? inst.runtime !== 'running' : false;
const installed = !!inst && inst.wechat.installed && inst.wechat.phase !== 'downloading';
const showVnc = !!inst && !offline && installed;
@@ -59,10 +162,47 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
// 切换实例时重置内嵌态
useEffect(() => {
setFrameLoaded(false);
setLoadStuck(false);
setShowFiles(false);
setFiles([]);
setShowClip(false);
setClipText('');
setImeText('');
setProbing(true);
}, [id]);
// 桌面久未加载出来 → 判为"无响应",把无限转圈换成可操作的重试/重启,不让用户干等。
// (实测容器跑久了会 I/O/服务 stall,进程没死、显示在线,但读不出 VNC 文件而永远连接中。)
useEffect(() => {
setLoadStuck(false);
if (!showVnc || frameLoaded) return;
const t = window.setTimeout(() => setLoadStuck(true), 12000);
return () => window.clearTimeout(t);
}, [showVnc, frameLoaded, id, vncNonce]);
// 探测态收敛:找到实例即结束;否则给共享列表一点刷新时间(AppShell 已在导航时拉取),超时仍无则判定不存在。
useEffect(() => {
if (inst) {
setProbing(false);
return;
}
if (!probing) return;
const t = window.setTimeout(() => setProbing(false), 2500);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inst, probing, id]);
// 实例未就绪(启动中 / 安装中 / 上下文状态未刷新)时,每 3s 拉取最新状态:
// 就绪后自动进入桌面,无需手动刷新(修复"安装完进度 100% 仍提示无实例")。
useEffect(() => {
if (showVnc || !id) return;
const t = window.setInterval(() => {
if (!document.hidden) reload();
}, 3000);
return () => window.clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id]);
// 文件拖到窗口 → 弹出落区(覆盖 iframe 接住 drop
useEffect(() => {
if (!showVnc) return;
@@ -154,6 +294,51 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
};
}, [showVnc, id, frameLoaded]);
// 进入/重连桌面前,按输入模式设 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', inputMode === 'seamless' ? 'true' : 'false');
} catch {
/* 隐私模式等禁用 localStorage:忽略 */
}
}, [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 的音频流(扬声器恒开,无需手动找工具条);
// 仅当本实例处于焦点(标签页可见且窗口聚焦)时出声/收音,失焦立即断开,避免多实例多端串音。
useEffect(() => {
if (!showVnc || !id) return;
const audio = new VncAudio(id);
audioRef.current = audio;
audio.connect();
const isFocused = () => !document.hidden && document.hasFocus();
const sync = () => audio.setActive(isFocused());
sync(); // 初始:若当前已聚焦则立即开声
document.addEventListener('visibilitychange', sync);
window.addEventListener('focus', sync);
window.addEventListener('blur', sync);
return () => {
document.removeEventListener('visibilitychange', sync);
window.removeEventListener('focus', sync);
window.removeEventListener('blur', sync);
audio.destroy();
audioRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVnc, id]);
if (!id) {
nav('/', { replace: true });
return null;
@@ -183,7 +368,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
setUploading(false);
if (ok) {
toast(`已上传 ${ok} 个文件到桌面,微信里可直接`, 'ok');
toast(`已上传 ${ok} 个文件到桌面,应用里可直接取`, 'ok');
refreshFiles();
}
};
@@ -196,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');
@@ -229,17 +414,81 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
st.textContent =
'#noVNC_control_bar_anchor{z-index:2147483647!important;}' +
'#noVNC_control_bar{background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.55)!important;box-shadow:0 0 24px rgba(0,0,0,.55)!important;}' +
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}';
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}' +
// macOS 中文输入法需要目标元素有非零尺寸才能激活;KasmVNC 默认 0x0 导致无法切换输入法
'#noVNC_keyboardinput{width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;}';
(doc.head || doc.documentElement).appendChild(st);
} catch {
/* 同源正常不会到这 */
}
};
// 跨设备剪贴板(文本):通过同源 iframe 直接喂给 KasmVNC 自带的剪贴板 textarea 并触发其发送逻辑
// (内部走 RFB.clipboardPasteFrom → clientCutText)。不依赖浏览器异步剪贴板 API,故 http/局域网 IP 下也可用,
// 规避了"非安全上下文禁用 navigator.clipboard 导致粘贴失败"的问题。文本会进入容器系统剪贴板,
// 在微信输入框按 Ctrl+V 即可粘贴。
const pushClipboardToRemote = (text: string): boolean => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (!doc || !ta) return false;
ta.value = text;
ta.dispatchEvent(new (frameRef.current!.contentWindow as any).Event('change', { bubbles: true }));
return true;
} catch {
return false;
}
};
const sendClip = () => {
const t = clipText;
if (!t) {
toast('请先输入要发送的文本', 'error');
return;
}
if (pushClipboardToRemote(t)) {
toast('已发送到容器剪贴板,请在应用输入框按 Ctrl+V 粘贴', 'ok');
} else {
toast('发送失败:桌面尚未连接', 'error');
}
};
// 中文输入条发送:把本框文本经 xclip+xdotool 直接粘进微信当前聚焦的输入框(绕开 VNC IME)。
// 在面板的真实 textarea 里用原生输入法打字,100% 可靠,不依赖 VNC 的 enable_ime / 合成事件。
const sendImeText = async () => {
const t = imeText;
if (!t.trim() || !id) return;
setImeSending(true);
try {
await api.typeInInstance(id, t);
setImeText('');
} catch (e: any) {
toast(e?.message || '发送失败:请确认实例已「升级实例」(镜像含 xclip/xdotool', 'error');
} finally {
setImeSending(false);
}
};
// 读取容器(微信侧)当前剪贴板内容到本框,便于把容器内复制的文字带回本地
const pullClipboardFromRemote = () => {
try {
const doc = frameRef.current?.contentDocument;
const ta = doc?.getElementById('noVNC_clipboard_text') as HTMLTextAreaElement | null;
if (ta) {
setClipText(ta.value || '');
toast('已读取容器剪贴板', 'ok');
} else {
toast('读取失败:桌面尚未连接', 'error');
}
} catch {
toast('读取失败', 'error');
}
};
const restartInstance = async () => {
const ok = await confirm({
title: '重启该实例?',
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
body: `会重建容器(数据保留),${appLabel}重新启动,约十几秒;用于修复卡死/最小化丢失等。`,
confirmText: '重启',
});
if (!ok) return;
@@ -278,7 +527,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
const title = inst?.name || '微信实例';
const title = inst?.name || '实例';
return (
<div className="ws-page">
@@ -299,6 +548,24 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
>
</button>
<button
className={'ws-action' + (inputMode === 'seamless' ? ' on' : '')}
title={
inputMode === 'seamless'
? '无感输入:直接在应用输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在应用里打)'
}
onClick={() => setMode(inputMode === 'seamless' ? 'forward' : 'seamless')}
>
{inputMode === 'seamless' ? '无感' : '转发'}
</button>
<button
className="ws-action"
title="把文本发送到容器剪贴板(局域网 http 下也可用)"
onClick={() => setShowClip((v) => !v)}
>
</button>
{isAdmin && (
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
@@ -309,7 +576,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</header>
{/* —— 各种态 —— */}
{!loaded ? (
{!loaded || (probing && !inst) ? (
<div className="iv-stage iv-center">
<div className="spinner" />
</div>
@@ -333,57 +600,113 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
) : (
<div className="iv-notice-sub"></div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
</button>
)}
</div>
</div>
) : ['downloading', 'extracting', 'installing'].includes(inst.wechat.phase) ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="spinner" />
<div className="iv-notice-title">{appLabel}</div>
<div className="iv-notice-sub">
{inst.wechat.message || '请稍候'}
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''}
</div>
</div>
</div>
) : !installed ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title"></div>
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? `${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')}>
</button>
)}
</div>
</div>
) : (
<div className="iv-stage">
<div className="iv-stage iv-stage--vnc">
<div className="iv-canvas">
<iframe
key={`${id}:${vncNonce}`}
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(宿主机输入法)
focusFrame(); // 加载完把键盘焦点交给 VNC
injectVncStyle(); // 让原生控制条在深色背景下可见
// 无感输入模式的键盘钩子由单独的 effect(依赖 inputMode/frameLoaded)安装,不在此处;
// 转发模式则 enable_ime=false,直接打字走纯 keysym(英文/数字正常),中文用底部输入条。
}, 500);
}}
/>
{!frameLoaded && (
{!frameLoaded && !loadStuck && (
<div className="iv-loading">
<div className="spinner" />
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-loading-sub">/</div>
<div className="iv-loading-sub">{profile.enterHint}</div>
<div className="iv-loading-sub"></div>
{!window.isSecureContext && (
<div className="iv-loading-warn"> HTTPS 访</div>
)}
</div>
)}
{!frameLoaded && loadStuck && (
<div className="iv-loading">
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-stuck-actions">
<button
className="btn btn-primary"
onClick={() => {
setLoadStuck(false);
setFrameLoaded(false);
setVncNonce((n) => n + 1); // 强制 iframe 重挂、重新请求
}}
>
</button>
{isAdmin && (
<button className="btn" onClick={restartInstance}>
</button>
)}
</div>
<div className="iv-loading-sub" style={{ marginTop: 8 }}>
</div>
</div>
)}
{dragging && (
<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>
)}
@@ -421,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' }}>
@@ -442,6 +765,59 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</div>
</div>
)}
{showClip && (
<div className="iv-files">
<div className="files-head">
<span></span>
<button className="btn-text" onClick={() => setShowClip(false)}>
</button>
</div>
<textarea
className="clip-area"
value={clipText}
onChange={(e) => setClipText(e.target.value)}
placeholder="在此输入或粘贴文本,点「发送到剪贴板」后到应用输入框按 Ctrl+V 粘贴"
rows={5}
/>
<button className="btn btn-primary files-upload" onClick={sendClip}>
</button>
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
</button>
<div className="files-hint">
http 访 Ctrl+V
</div>
</div>
)}
</div>
{inputMode === 'forward' && (
<div className="iv-imebar">
<textarea
className="iv-imebar-input"
value={imeText}
onChange={(e) => setImeText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendImeText();
}
}}
placeholder="中文输入这里 → 回车送进应用(先点好应用的输入框)。Shift+回车换行。"
rows={1}
/>
<button
className="btn btn-primary iv-imebar-send"
disabled={imeSending || !imeText.trim()}
onClick={sendImeText}
>
{imeSending ? '发送中' : '发送'}
</button>
</div>
)}
</div>
)}
</div>
+605 -18
View File
@@ -305,6 +305,79 @@ button {
background: rgba(var(--danger-rgb) / 0.12);
}
/* 实例卡片操作:「管理」分类折叠菜单(默认收起,点开按运维/设置/危险分组展开文字操作) */
.inst-menu-toggle {
width: 100%;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border: none;
border-radius: 12px;
background: var(--trough);
color: var(--text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
transition: background 0.15s;
}
.inst-menu-toggle:hover {
background: var(--base);
}
.inst-menu-caret {
display: inline-flex;
color: var(--muted);
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.inst-menu-toggle.open .inst-menu-caret {
transform: rotate(180deg);
}
/* 「管理」菜单:绝对定位悬浮层,浮在下方内容之上,不把卡片撑高、不顶走后面的内容 */
.inst-menu-wrap {
position: relative;
margin-top: 10px;
}
.inst-menu {
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 {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.inst-menu-label {
font-size: 11px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
margin: 2px 4px 2px;
}
.inst-menu-items {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.inst-menu-danger {
margin-top: 2px;
padding-top: 8px;
border-top: 1px solid rgba(var(--shadow) / 0.08);
}
.error {
color: var(--danger);
font-size: 14px;
@@ -713,6 +786,46 @@ button {
height: 42px;
font-size: 15px;
}
.clip-area {
width: 100%;
resize: vertical;
min-height: 96px;
padding: 10px 12px;
border-radius: 14px;
border: none;
background: var(--mf-trough, #edeef1);
color: var(--text, #1a1d24);
font-size: 14px;
line-height: 1.5;
font-family: inherit;
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16);
outline: none;
box-sizing: border-box;
}
.clip-area:focus {
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
}
.security-status {
margin-top: 12px;
padding: 10px 14px;
border-radius: 14px;
background: var(--mf-trough, #edeef1);
box-shadow: inset 0 1px 3px rgba(51, 66, 102, 0.12);
display: flex;
flex-direction: column;
gap: 6px;
}
.security-row {
display: flex;
justify-content: space-between;
font-size: 13px;
line-height: 1.4;
}
.iv-stuck-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.files-hint {
font-size: 12px;
color: var(--muted);
@@ -805,6 +918,8 @@ button {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 22px;
/* 各卡片按自身内容高度,避免某张展开「管理」菜单时同行其它卡片被拉等高(显得也展开了) */
align-items: start;
}
.inst-card {
position: relative;
@@ -825,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;
@@ -850,6 +970,31 @@ button {
gap: 8px;
margin-top: 14px;
}
/* 「关于」版本卡:有新版时的升级提示 + 上次检查脚注 */
.ver-hint {
margin-top: 10px;
padding: 9px 11px;
font-size: 12.5px;
line-height: 1.55;
color: var(--text);
background: rgba(245 158 11 / 0.1);
border-radius: 10px;
}
.ver-hint code {
font-size: 12px;
padding: 1px 5px;
border-radius: 5px;
background: rgba(var(--shadow) / 0.08);
white-space: nowrap;
}
.ver-checked {
margin-top: 12px;
}
.inst-actions a.btn {
text-decoration: none;
display: inline-flex;
align-items: center;
}
.inst-enter {
flex: 1;
}
@@ -902,6 +1047,68 @@ button {
gap: 6px;
margin-top: 4px;
}
/* 扁平设置区块(诊断与日志 / 关于):无卡片,直接排在分区标题下,避免单卡片的厚重感 */
.settings-block {
padding: 2px 2px 4px;
}
.settings-block .s-desc {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.65;
color: var(--muted);
max-width: 660px;
}
.s-field {
margin-top: 14px;
}
.s-field .field-label {
display: block;
margin: 0 2px 8px;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
margin-top: 16px;
}
/* 实心主按钮:固定内边距 + 不换行(之前作为 flex 项被压窄导致按钮内文字断行) */
.settings-actions .s-btn {
height: 42px;
padding: 0 22px;
flex: none;
white-space: nowrap;
}
.settings-actions .btn-text {
white-space: nowrap;
}
.s-foot {
margin: 14px 0 0;
font-size: 12px;
line-height: 1.6;
color: var(--muted);
max-width: 660px;
}
.s-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.s-app {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.s-line {
margin: 8px 0 0;
font-size: 14px;
color: var(--muted);
}
.s-line b {
color: var(--text);
font-weight: 600;
}
.chip-row-pick {
display: flex;
}
@@ -938,6 +1145,9 @@ button {
text-align: center;
padding: 30px 16px 36px;
}
.empty-action {
margin-top: 16px;
}
.empty-blob {
width: 96px;
height: 96px;
@@ -1248,6 +1458,7 @@ button {
color: var(--wx-green-dark);
}
.sb-ic {
position: relative;
flex: none;
width: 24px;
display: flex;
@@ -1255,6 +1466,23 @@ button {
justify-content: center;
color: var(--muted);
}
/* 「有新版」红点:贴在「管理」齿轮图标右上角,折叠/展开都可见 */
.sb-updot {
position: absolute;
top: -2px;
right: -1px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #f5483b;
border: 2px solid var(--surface);
}
.sb-updot-text {
flex: none;
font-size: 11px;
font-weight: 700;
color: #f5483b;
}
.sb-label {
flex: 1;
min-width: 0;
@@ -1274,15 +1502,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;
@@ -1392,6 +1611,10 @@ button {
.ws-action:active {
transform: scale(0.96);
}
.ws-action.on {
background: var(--wx-green, #07c160);
color: #fff;
}
.ws-page .content {
flex: 1;
min-height: 0;
@@ -1423,6 +1646,10 @@ button {
background: var(--sheen);
pointer-events: none;
}
.home-card:hover {
transform: translateY(-2px);
box-shadow: var(--crease-accent);
}
.home-card:active {
transform: scale(0.985);
}
@@ -1431,15 +1658,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;
@@ -1456,10 +1674,29 @@ button {
text-overflow: ellipsis;
white-space: nowrap;
}
.home-card-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.home-card-st {
font-size: 12px;
font-weight: 600;
background: none !important;
flex: none;
}
.home-card-ver {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-card-ver::before {
content: '·';
margin-right: 8px;
color: var(--muted);
}
/* —— 内嵌实例视图 —— */
@@ -1475,6 +1712,18 @@ button {
justify-content: center;
background: var(--base);
}
/* VNC 分支:纵向 flex —— 画面区(.iv-canvas, flex:1) 在上,中文输入条在下,互不重叠。
输入条占用布局空间 → iframe 区域变小 → resize=remote 让远端桌面同步缩小,微信画面完整可见、不被遮。 */
.iv-stage--vnc {
display: flex;
flex-direction: column;
}
.iv-canvas {
position: relative;
flex: 1;
min-height: 0;
background: #000;
}
.iv-frame {
position: absolute;
inset: 0;
@@ -1483,6 +1732,43 @@ button {
border: none;
display: block;
}
.iv-imebar {
flex: 0 0 auto;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
/* 与顶部 nav 栏同款牛奶布艺浅色主题 + 顶部细折痕分隔 */
background: var(--base);
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.07);
box-sizing: border-box;
}
.iv-imebar-input {
flex: 1;
height: 38px;
max-height: 38px;
resize: none;
padding: 9px 12px;
border-radius: 10px;
border: none;
outline: none;
font-size: 14px;
font-family: inherit;
line-height: 1.3;
/* 牛奶布艺凹槽输入框:与「剪贴板」文本框一致 */
background: var(--trough);
color: var(--text);
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.16);
box-sizing: border-box;
}
.iv-imebar-input:focus {
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.16), 0 0 0 2px rgba(7, 193, 96, 0.35);
}
.iv-imebar-send {
height: 38px;
flex: 0 0 auto;
padding: 0 18px;
}
.iv-loading,
.iv-drop {
position: absolute;
@@ -1630,3 +1916,304 @@ button {
max-width: 940px;
}
}
/* ── 数据卷管理(管理员)──────────────────────────────────── */
.vol-modal {
width: 640px;
max-width: 92vw;
max-height: 86vh;
}
/* 运行中警示条:柔和琥珀,inset 凹槽质感(不是红色危险) */
.vol-warn {
background: rgba(245, 166, 35, 0.14);
color: #9a6400;
font-size: 13px;
line-height: 1.5;
padding: 9px 12px;
border-radius: var(--r-small);
}
/* 分区容器:内部元素紧凑成组(label 紧贴其内容),分区之间靠 .modal 的 12px gap 拉开 */
.vol-sec {
display: flex;
flex-direction: column;
gap: 8px;
}
.vol-section-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.vol-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.vol-topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
/* 主操作:中号浮起胶囊(比默认 48px 更克制,和弹窗比例协调) */
.vol-topbar .btn {
height: 40px;
padding: 0 18px;
font-size: 14px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.vol-crumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
font-size: 13px;
}
.vol-crumb {
border: none;
background: none;
color: var(--wx-green);
font-weight: 600;
cursor: pointer;
padding: 2px 4px;
border-radius: 8px;
}
.vol-crumb:active {
background: rgba(var(--green-rgb) / 0.12);
}
.vol-crumb:disabled {
color: var(--muted);
cursor: default;
}
.vol-sep {
color: var(--muted);
}
/* 工具栏:浮起小胶囊(绿字),和上面「扁平绿色面包屑」明显区分,读起来是"按钮"而非散落文字 */
.vol-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.vol-tools .btn-text {
background: var(--surface);
box-shadow: var(--crease-press);
font-size: 14px;
padding: 8px 14px;
}
.vol-tools .btn-text:active {
transform: scale(0.96);
}
.vol-tools .btn-text:disabled {
opacity: 0.5;
}
.vol-mkdir {
display: flex;
gap: 8px;
}
.vol-mkdir .input {
flex: 1;
}
.vol-busy {
font-size: 13px;
color: var(--wx-green-dark);
font-weight: 600;
}
/* 列表容器:凹槽(inset),内部条目为浮起 bumphover) */
.vol-list {
background: var(--trough);
border-radius: var(--r-small);
padding: 6px;
max-height: 44vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.22);
}
.vol-row {
display: flex;
align-items: center;
gap: 6px;
border-radius: 12px;
padding: 2px 4px 2px 6px;
}
.vol-row:hover {
background: var(--surface);
box-shadow: var(--crease-press);
}
.vol-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
border: none;
background: none;
text-align: left;
padding: 8px 4px;
color: var(--text);
font-size: 14px;
}
.vol-main:disabled {
opacity: 0.55;
}
.vol-up {
color: var(--muted);
}
.vol-ic {
flex: 0 0 auto;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.vol-ic.dir {
color: var(--wx-green);
}
.vol-ic svg {
display: block;
}
.vol-nm {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vol-meta {
flex: 0 0 auto;
color: var(--muted);
font-size: 12px;
padding-left: 8px;
}
.vol-rename {
flex: 1;
margin: 2px 0;
}
.vol-acts {
flex: 0 0 auto;
display: flex;
gap: 3px;
}
.vol-act {
border: none;
background: var(--trough);
color: var(--muted);
width: 30px;
height: 30px;
border-radius: 9px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: var(--crease-press);
}
.vol-row:hover .vol-act {
background: var(--base);
color: var(--text);
}
.vol-act:active {
transform: scale(0.92);
}
.vol-act.danger:hover {
color: var(--danger);
background: rgba(var(--danger-rgb) / 0.12);
}
.vol-act:disabled {
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);
}
+269
View File
@@ -0,0 +1,269 @@
// VNC 音频/麦克风桥接(扬声器 + 麦克风)。
//
// 背景:linuxserver KasmVNC 的音频不在我们内嵌的原生 noVNC 客户端里,而在它外层的 kclient
// (容器内 nginx :3000 / → kclient :6900)通过 socket.io(路径 audio/socket.io)提供:
// - 扬声器:服务端把 PulseAudio sink 的 PCM 通过 'audio' 事件推下来,前端用 Web Audio 播放;
// - 麦克风:前端采集 Int16 通过 'micdata' 事件上传,服务端灌进 PulseAudio。
// 我们没有内嵌 kclient(会破坏对原生客户端的 IME / 剪贴板 / 控制条定制),故在面板父页面直接
// 复刻它的音频客户端,连到经面板反代的 /desktop/<id>/audio/socket.io。这样还能精确控制:
// - 「强制开启」:实例就绪即自动连接、首个用户手势后开始播放(浏览器自动播放策略所限);
// - 「焦点不在该实例时断开」:标签页隐藏 / 失焦 / 离开页面时关闭,避免多实例多端互相串音。
//
// 麦克风需要「安全上下文」(HTTPS 或 localhost) 才有 getUserMedia;局域网 http 下浏览器禁用,
// 此时自动跳过麦克风、只保留扬声器。
// kclient 服务端用的 socket.io 版本未知,为避免协议不匹配,动态加载它自带的 socket.io.js
// (经反代取 /desktop/<id>/audio/socket.io/socket.io.js),用全局 io,而非打包我们自己的版本。
function loadIo(id: string): Promise<any> {
const w = window as any;
if (w.io) return Promise.resolve(w.io);
const existing = document.getElementById('woc-socketio') as HTMLScriptElement | null;
if (existing && (existing as any)._wocPromise) return (existing as any)._wocPromise;
const p = new Promise<any>((resolve, reject) => {
const s = document.createElement('script');
s.id = 'woc-socketio';
s.src = `/desktop/${encodeURIComponent(id)}/audio/socket.io/socket.io.js`;
s.onload = () => ((window as any).io ? resolve((window as any).io) : reject(new Error('io 未就绪')));
s.onerror = () => reject(new Error('加载 socket.io 失败'));
document.head.appendChild(s);
(s as any)._wocPromise = p;
});
return p;
}
// PCM 播放器:忠实复刻 kclient 的解码/调度(Int16 立体声 @ 44100 → Web Audio),
// 这套参数与服务端音频格式匹配,改动易出杂音,故照搬。
class PcmPlayer {
audioCtx: AudioContext | null = null;
private gain: GainNode | null = null;
private startTime = 0;
private buffer: Float32Array = new Float32Array(0);
private playing = false;
private lock = false;
private resetTimer: number | undefined;
init() {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioCtx = new Ctx({ sampleRate: 44100 });
this.audioCtx!.resume().catch(() => {});
this.gain = this.audioCtx!.createGain();
this.gain.gain.value = 1;
this.gain.connect(this.audioCtx!.destination);
this.startTime = this.audioCtx!.currentTime;
// 与 kclient 一致:100ms 内无新数据则清空缓冲,避免拖尾/堆积
this.resetTimer = window.setInterval(() => {
if (this.playing) {
if (!this.lock) {
this.buffer = new Float32Array(0);
this.playing = false;
}
this.lock = false;
}
}, 100);
}
feed(data: ArrayBuffer) {
if (!this.audioCtx) return;
this.lock = true;
const i16 = new Int16Array(data);
const f32 = Float32Array.from(i16, (x) => x / 32767);
const merged = new Float32Array(this.buffer.length + f32.length);
merged.set(this.buffer);
merged.set(f32, this.buffer.length);
this.buffer = merged;
const frames = this.buffer.length / 2; // 立体声
const duration = frames / 44100 / 2; // 与 kclient 的 buffAudio.duration/2 等价
if (duration > 0.05 || this.playing) {
this.playing = true;
const buffAudio = this.audioCtx.createBuffer(2, this.buffer.length, 44100);
const left = buffAudio.getChannelData(0);
const right = buffAudio.getChannelData(1);
let bc = 0;
let off = 1;
for (let i = 0; i < frames; i++) {
left[i] = this.buffer[bc];
bc += 2;
right[i] = this.buffer[off];
off += 2;
}
this.buffer = new Float32Array(0);
if (this.startTime < this.audioCtx.currentTime) this.startTime = this.audioCtx.currentTime;
const src = this.audioCtx.createBufferSource();
src.buffer = buffAudio;
src.connect(this.gain!);
src.start(this.startTime);
this.startTime += buffAudio.duration / 2;
}
}
destroy() {
if (this.resetTimer) window.clearInterval(this.resetTimer);
this.resetTimer = undefined;
this.buffer = new Float32Array(0);
this.playing = false;
try {
this.audioCtx?.close();
} catch {
/* ignore */
}
this.audioCtx = null;
this.gain = null;
}
}
export class VncAudio {
private id: string;
private socket: any = null;
private player: PcmPlayer | null = null;
private active = false; // 当前实例是否处于"焦点中"(应出声)
private opened = false; // 是否已对服务端 emit('open')
private micStream: MediaStream | null = null;
private micCtx: AudioContext | null = null;
private micNode: ScriptProcessorNode | null = null;
private micSource: MediaStreamAudioSourceNode | null = null;
private gestureBound = false;
private destroyed = false;
constructor(id: string) {
this.id = id;
}
// 建立 socket 连接(不自动出声,由 setActive 控制)。
async connect() {
if (this.socket || this.destroyed) return;
const io = await loadIo(this.id);
if (this.destroyed) return;
this.socket = io(window.location.origin, {
path: `/desktop/${this.id}/audio/socket.io`,
transports: ['websocket', 'polling'],
withCredentials: true,
reconnection: true,
});
this.socket.on('audio', (data: ArrayBuffer) => {
if (this.active && this.player) this.player.feed(data);
});
this.socket.on('connect', () => {
if (this.active) this.open();
});
}
// 焦点变化时调用:true=本实例获得焦点(出声+收音),false=失焦(断开设备)。
setActive(on: boolean) {
if (this.destroyed) return;
this.active = on;
if (on) {
this.open();
this.startMic();
} else {
this.close();
this.stopMic();
}
}
private open() {
if (!this.socket || !this.socket.connected) return;
if (!this.opened) {
this.socket.emit('open', '');
this.opened = true;
}
if (!this.player) {
this.player = new PcmPlayer();
this.player.init();
}
this.ensureResumeOnGesture();
}
private close() {
if (this.socket && this.opened) {
try {
this.socket.emit('close', '');
} catch {
/* ignore */
}
}
this.opened = false;
this.player?.destroy();
this.player = null;
}
// 浏览器自动播放策略:AudioContext 常被挂起,需用户手势恢复。绑定一次性手势监听,
// 用户点进画面/按键时自动 resume,实现"无需手动点工具条即可出声"。
private ensureResumeOnGesture() {
const ctx = this.player?.audioCtx;
if (!ctx) return;
if (ctx.state !== 'suspended' || this.gestureBound) return;
this.gestureBound = true;
const resume = () => {
this.player?.audioCtx?.resume().catch(() => {});
window.removeEventListener('pointerdown', resume, true);
window.removeEventListener('keydown', resume, true);
this.gestureBound = false;
};
window.addEventListener('pointerdown', resume, true);
window.addEventListener('keydown', resume, true);
}
private async startMic() {
// 麦克风需安全上下文(HTTPS / localhost);http 局域网下静默跳过,只保留扬声器。
if (this.micCtx || !this.socket) return;
const md = navigator.mediaDevices;
if (!window.isSecureContext || !md || !md.getUserMedia) return;
try {
const stream = await md.getUserMedia({ audio: true });
if (this.destroyed || !this.active) {
stream.getTracks().forEach((t) => t.stop());
return;
}
this.micStream = stream;
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.micCtx = new Ctx();
this.micSource = this.micCtx!.createMediaStreamSource(stream);
this.micNode = this.micCtx!.createScriptProcessor(512, 1, 1);
this.micSource.connect(this.micNode);
this.micNode.connect(this.micCtx!.destination);
this.micNode.onaudioprocess = (e) => {
if (!this.active || !this.socket) return;
const input = e.inputBuffer.getChannelData(0);
// 简单能量门限:近乎静音不上传,省带宽(替代 kclient 的 JSON.size 启发式)
let peak = 0;
for (let i = 0; i < input.length; i++) {
const a = input[i] < 0 ? -input[i] : input[i];
if (a > peak) peak = a;
}
if (peak < 0.01) return;
const i16 = Int16Array.from(input, (x) => Math.max(-32768, Math.min(32767, x * 32767)));
this.socket.emit('micdata', i16.buffer);
};
} catch {
this.stopMic();
}
}
private stopMic() {
try {
if (this.micNode) this.micNode.onaudioprocess = null as any;
this.micNode?.disconnect();
this.micSource?.disconnect();
this.micStream?.getTracks().forEach((t) => t.stop());
this.micCtx?.close();
} catch {
/* ignore */
}
this.micNode = null;
this.micSource = null;
this.micStream = null;
this.micCtx = null;
}
destroy() {
this.destroyed = true;
this.close();
this.stopMic();
try {
this.socket?.disconnect();
} catch {
/* ignore */
}
this.socket = null;
}
}
+5 -2
View File
@@ -11,12 +11,15 @@ set -euo pipefail
OWNER="${WOC_IMAGE_OWNER:-gloridust}"
TAG="${WOC_VERSION:-latest}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# 烤进面板镜像的版本号:设了 WOC_VERSION 就用它(如 v1.2.0),否则用 dev-<短SHA>(本地构建标识)。
# 开发版不是正式发布版,面板「关于」会标「开发版」、不会触发「有新版」红点。
VER="${WOC_VERSION:-dev-$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo local)}"
PANEL_IMAGE="ghcr.io/${OWNER}/woc-panel:${TAG}"
WECHAT_IMAGE="ghcr.io/${OWNER}/wechat-on-cloud:${TAG}"
echo "==> 构建面板镜像 ${PANEL_IMAGE}"
docker build -t "${PANEL_IMAGE}" "${ROOT}/panel"
echo "==> 构建面板镜像 ${PANEL_IMAGE} (版本号 ${VER}"
docker build --build-arg "WOC_VERSION=${VER}" -t "${PANEL_IMAGE}" "${ROOT}/panel"
echo "==> 构建微信实例镜像 ${WECHAT_IMAGE}"
docker build -t "${WECHAT_IMAGE}" "${ROOT}/docker"