Add a `desktop/` Electron app that embeds the existing Node server + node-pty (all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend unchanged; other LAN devices can still connect. The server needs zero changes — startServer(cfg)/loadConfig already support programmatic embedding. - Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy, notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov) - Electron glue: main/window/tray/menu/preload/embedded-server/logger (hardened: contextIsolation, sandbox, deny foreign-origin navigation) - Native value: OS notifications driven by /live-sessions status, tray, deep links - Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules on-disk under Resources so the server resolves its deps from /Applications; node-pty rebuilt for the Electron ABI - Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97); .dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
19 KiB
DESKTOP_PLAN.md — Electron 桌面客户端(Mac + Windows,一体化)
落地方案文档。目标:把现有 web-terminal 打包成 Mac / Windows 原生桌面 App。 拓扑选型:一体化(App 内嵌 Node 服务器 + node-pty)。框架:Electron。 状态:
desktop/代码已实现并验证(2026-07-01,分支feat/desktop-electron) —— P0–P2 的逻辑代码、单测、Electron 加固、esbuild 打包在本环境全部通过(桌面tsc0 错、全量 1401 tests 绿、覆盖率达标)。未验证(需真机/CI,见 §9 P1 D5–D7):实际启动 Electron GUI、node-pty 的 Electron-ABI 重编译、.dmg/.exe打包。详见PROGRESS_LOG.md首条。本文是"怎么做"的蓝图,配合TECH_DOC.md(why)+ARCHITECTURE.md(how)。
0. 目标与范围
做什么
一个 Mac / Windows 原生桌面 App,双击即用:
- 窗口里是完全复用的现有前端(xterm.js 多标签 UI,0 改动)。
- App 自己内嵌 Node 服务器 + node-pty,无需单独启动
npm start。一次安装 = 一个原生终端 同时 又是给手机/平板复用的 LAN 服务器("vibe coding:发个任务走开,手机上重连查看"这个核心场景原样保留)。 - 原生集成:菜单栏/托盘常驻、审批门的原生系统通知(本 App 相对"开浏览器标签"的最大价值)、深链、开机自启。
不做(v1 范围外)
- 鉴权/登录、多用户隔离(沿用现有威胁模型:LAN-only,建议 Tailscale)。
- 自动更新(列为 P3,可选)。
- 把前端重构成"可配置远程 base-url"——一体化模式下窗口连
localhost,用不到。
一个已被验证掉的大风险
现有服务器 早就为内嵌设计好了,几乎零重构:
// src/server.ts:187 —— 可编程启动,返回 close() 句柄
export function startServer(cfg: Config): { close(): Promise<void> }
// src/config.ts:245 —— 从 env-like 对象构造 Config
export function loadConfig(env: EnvLike): Config
// server.ts 底部 import.meta.url === process.argv[1] 守卫
// → 被 import 时【无副作用】,只有作为主脚本直接运行才 listen。
Electron 主进程直接 import { startServer, loadConfig },构造 config → 启动 → 拿 close() 干净退出。服务器代码一行不用改。
1. 整体架构 / 进程模型
┌─────────────────────────── Electron App ───────────────────────────┐
│ │
│ Main process (Node.js 运行时) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ desktop/main.ts │ │
│ │ 1. pickFreePort() → 选一个空闲端口 │ │
│ │ 2. cfg = loadConfig({ ...env, PORT, BIND_HOST, SHELL_PATH})│ │
│ │ 3. server = startServer(cfg) ← 复用 src/server.ts │ │
│ │ └─ Express + ws + node-pty(native addon 在 main 跑) │ │
│ │ 4. BrowserWindow.loadURL(`http://127.0.0.1:${PORT}/`) │ │
│ │ 5. Tray / Notification / deep-link / auto-launch │ │
│ │ 6. app.on('quit') → server.close() + pty.kill(all) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ▲ IPC (preload) │
│ │ │
│ Renderer process (Chromium) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 现有前端 public/build/main.js(0 改动) │ │
│ │ location.host = 127.0.0.1:PORT │ │
│ │ → WS ws://127.0.0.1:PORT/term ✓ Origin 白名单自动通过 │ │
│ │ → REST /sessions /prefs … ✓ 同源 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
▲ ws://<LAN-IP>:PORT/term (手机/平板,若开 LAN 模式)
为什么服务器跑在 main 进程而不是子进程:服务器是纯 I/O 的"字节搬运工"(CLAUDE.md 的核心设计点),事件循环压力小,跑在 main 足够;node-pty 原生模块在 main 加载最简单。升级路:若未来 main 卡顿,改用 Electron utilityProcess(22+,官方推荐的 Node 服务隔离方式,可加载原生模块)把服务器挪出 main,IPC 转发端口即可。P1 先用 in-main。
为什么窗口 loadURL localhost 而不是 file://(关键决策,来自同源约束):前端严格同源——buildWsUrl() 用 location.host,所有 fetch 是相对路径。窗口加载 http://127.0.0.1:PORT/ 时 location.* 解析到内嵌服务器,WS/REST 全部正确,Origin=http://127.0.0.1:PORT 命中白名单(config.ts 的 deriveAllowedOrigins 默认放行 localhost/127.0.0.1)。绝不要把 public/build 塞进 file:// 加载——那会让所有相对请求失效、Origin 变 file:// 被 401。
2. 目录结构
新增一个独立 desktop/,与现有 src/(server)、public/(前端)并列且解耦——server/前端的 package.json 不受 Electron 依赖污染。
web-terminal/
├── src/ # 服务器(不动,复用 startServer/loadConfig)
├── public/ # 前端(不动)
├── dist/ # tsc 输出:dist/server.js …(复用现有 npm run build)
├── public/build/ # esbuild 输出:main.js(复用现有 npm run build:web)
└── desktop/ # ★ 新增,Electron App
├── package.json # electron + electron-builder + electron-store,"main": "build/main.cjs"
├── tsconfig.json
├── src/
│ ├── main.ts # Electron 入口:起服务器 + 建窗口 + 托盘 + 通知 + 生命周期
│ ├── preload.ts # contextBridge:renderer ↔ main(通知/深链/设置)
│ ├── embedded-server.ts # pickFreePort + 构造 cfg + startServer 封装
│ ├── window.ts # BrowserWindow 创建 + splash/loading
│ ├── tray.ts # 托盘图标 + 聚合状态 + 上下文菜单
│ ├── notifications.ts# 审批门/Claude 状态 → 原生 Notification
│ ├── deep-link.ts # terminalapp://join/<id> 协议注册
│ ├── menu.ts # 应用菜单(⌘T 新标签等映射)
│ └── settings.ts # electron-store:端口/LAN 开关/默认 shell/自启
├── build/ # esbuild 打包 main/preload 的输出(.cjs)
├── resources/ # 图标:icon.icns(Mac)/ icon.ico(Win)/ tray png
├── electron-builder.yml
└── dist-app/ # electron-builder 产物:.dmg / .exe
构建管线(三步,前两步复用现有):
npm run build(根)→dist/server.js(服务器 ESM)。npm run build:web(根)→public/build/main.js(前端)。desktop内:esbuild 打包desktop/src/main.ts+preload.ts→desktop/build/*.cjs(native 依赖--external:node-pty),再electron-builder出安装包。
语言细节:Electron main 用 CJS(
.cjs)最省心(--external:node-pty+ esbuild--format=cjs --platform=node);服务器dist/*.js是 ESM,用动态await import('../dist/server.js')从 main 里加载即可(CJS 里import()合法)。若坚持全 ESM,需 Electron ≥28(支持 ESM main)——P1 先走 CJS main + 动态 import server。
3. 内嵌服务器(embedded-server.ts)
// 伪代码 —— 不可变、显式错误处理
import net from 'node:net'
async function pickFreePort(preferred = 3000): Promise<number> {
// 先试 preferred,占用则让 OS 分配(listen(0) 拿到后立即 close,再交给 startServer)
// 注意:不能直接给 startServer 传 PORT=0,因为 allowedOrigins 由 cfg.port 派生,
// 必须先拿到【确定端口】再 loadConfig,才能让 http://127.0.0.1:<port> 进白名单。
}
export async function startEmbeddedServer(prefs: DesktopPrefs) {
const port = await pickFreePort(prefs.port ?? 3000)
const { loadConfig } = await import('../../dist/config.js')
const { startServer } = await import('../../dist/server.js')
const cfg = loadConfig({
...process.env,
PORT: String(port),
// LAN 开关:默认 0.0.0.0(保留"手机重连"场景),可在设置里改 127.0.0.1(私有)
BIND_HOST: prefs.lanSharing ? '0.0.0.0' : '127.0.0.1',
// ★ 跨平台默认 shell:config.ts 默认 $SHELL||/bin/zsh,Windows 上必须覆盖
SHELL_PATH: prefs.shellPath ?? defaultShellForPlatform(),
})
const handle = startServer(cfg) // { close(): Promise<void> }
return { port, handle, allowedOrigins: cfg.allowedOrigins }
}
function defaultShellForPlatform(): string {
if (process.platform === 'win32') return 'powershell.exe' // 或 pwsh.exe / cmd.exe
return process.env.SHELL ?? '/bin/zsh'
}
要点:
- 端口先定后配:
allowedOrigins由cfg.port派生,所以必须先拿到确定端口再loadConfig,否则http://127.0.0.1:<port>进不了白名单。 - LAN 模式是有意识的开关:默认
0.0.0.0保留手机重连场景;提供设置项切127.0.0.1走纯私有。无鉴权的威胁模型不变——设置页里明示"建议配合 Tailscale"。 - 退出即清理:
app.on('before-quit')→await handle.close()(内部pty.kill()所有会话)。
4. 原生集成
4.1 原生通知(本 App 的核心价值)
前端每个会话已经收 status 帧(pending 审批、Claude 状态)。要在窗口失焦/最小化时弹系统通知:
- 快路(P2 起步):preload 暴露一个
notify(),renderer 在某会话pendingApproval转 true 或 Claude 状态变化时经 IPC 调 main 的 ElectronNotification。只覆盖【已开标签】的会话。 - 全路(一体化独有优势):服务器就在 main 进程内,main 可直接订阅 session manager 的状态总线(
/hook/status已有),对所有会话(哪怕没开标签)弹通知。点通知 → 聚焦窗口/切到该会话 → 一键批准(复用现有 approve/reject 协议)。 - 通知去重/节流:同一会话 pending 只弹一次;已聚焦窗口时不弹。
4.2 托盘 / 菜单栏
Tray 图标显示聚合状态(如"2 会话 · 1 待审批"),点击唤起窗口;右键菜单列会话 + 打开/退出。main 有服务器 → 直接从 live session 列表填充。
4.3 深链 / 单实例
app.requestSingleInstanceLock():二次启动聚焦已有窗口(同时接住深链参数)。- 注册
terminalapp://协议 →terminalapp://join/<id>映射到现有/?join=<id>。
4.4 应用菜单 / 快捷键
原生菜单把 ⌘T 新标签、⌘W 关标签、⌘F 搜索等映射到前端已有能力(经 preload IPC 触发页面事件,或直接注入按键)。
4.5 开机自启
app.setLoginItemSettings({ openAtLogin }),设置页开关。
4.6 Claude Code hooks
npm run setup-hooks 现在指向本地服务器——一体化下服务器就在本机,hooks 天然可用;只需把 setup 指向内嵌服务器的实际端口(可在设置页放一个"安装 hooks"按钮,用当前端口生成配置)。POST /hook* 是 loopback-only,localhost 内嵌完全满足。
5. node-pty 打包(唯一的原生模块难点)
- ABI 重编译:node-pty 是 native
.nodeaddon,Electron 的 Node ABI ≠ 系统 Node。desktop/package.json里postinstall: electron-builder install-app-deps(内部走@electron/rebuild)对 Electron ABI 重编译。现有node_modules/node-pty/prebuilds是系统 ABI,不能直接用,但它证明了 node-pty 支持 win32/darwin 两平台,重编译产出 Electron-ABI 版即可。 - asar 解包:
.node不能在 asar 内加载。electron-builder 配置asarUnpack: ["**/node_modules/node-pty/**"]。Windows 上 node-pty 依赖 ConPTY(conpty.dll/conpty/)——一并解包。 - 打包内容:
files需含dist/(服务器)、public/(含public/build)、desktop/build,以及生产依赖node_modules(express/ws/node-pty/qrcode/web-push)。
6. 打包 · 分发 · 签名(electron-builder)
# desktop/electron-builder.yml(要点)
appId: com.<you>.web-terminal
mac: { target: [dmg, zip], category: public.app-category.developer-tools }
win: { target: [nsis] }
asarUnpack: ["**/node_modules/node-pty/**"]
files: ["build/**", "../dist/**", "../public/**", "../node_modules/**"]
- Mac 目标:
.dmg(可 universal:arm64+x64)。分发要 Apple Developer ID 签名 + 公证(afterSign走@electron/notarize,$99/年)。自用可自签或不签(用户首次右键打开绕过 Gatekeeper)。 - Windows 目标:NSIS
.exe。分发要 Authenticode 证书;自用可不签(SmartScreen 会警告一次)。 - 自动更新(P3 可选):
electron-updater+ GitHub Releases 承载latest.yml/zip。
7. Windows 特有事项
- 默认 shell:
config.ts默认$SHELL || /bin/zsh在 Win 无效 → 必须覆盖SHELL_PATH=powershell.exe(或pwsh/cmd)。见 §3。 - ConPTY:node-pty 在 Win 走 ConPTY,需 Win10 1809+;打包解包其依赖(§5)。
- 路径分隔符:服务器涉及 cwd/项目扫描的地方用 Node
path——现有代码应已跨平台,但 Win 首跑要专门测项目发现/worktree 功能(src/http/projects.ts/worktrees.ts)。 USE_TMUX:tmux keepalive 是 *nix 特性,Win 上应默认关(USE_TMUX=0)。
8. 安全考量(沿用现有威胁模型)
- Origin 校验不变:窗口连
127.0.0.1自动过;开 LAN 模式时 NIC IP 也在白名单。default-deny(空 Origin 被拒)继续保护。 - 无鉴权:LAN 模式仍是"谁能连端口谁就有 shell"。设置页明示风险 + 建议 Tailscale;默认可考虑
127.0.0.1(私有)让用户显式开 LAN。 - loopback hooks:
/hook*仅 loopback,一体化天然满足。 - Electron 加固:
contextIsolation: true、nodeIntegration: false、preload 用contextBridge暴露最小 API;webPreferences禁allowRunningInsecureContent;只 loadURL 本地端口。
9. 分期任务拆解 + 工作量估算
风格对齐 PLAN.md:每个任务给 Owns / 验证方式 / 估时。P0–P1 出"能跑的一体化 App",P2 加原生价值,P3 分发。
P0 — 骨架跑通("它启动了")· ~1 天
- D1
desktop/脚手架:package.json(electron/electron-builder/esbuild)、tsconfig、esbuild 打包脚本。验证:electron .能开空窗口。 - D2
embedded-server.ts:pickFreePort + loadConfig(覆盖 PORT/BIND_HOST/SHELL_PATH) + startServer。验证:main 里起服务器,curl 127.0.0.1:<port>/sessions有响应。 - D3
window.ts:BrowserWindow loadURL localhost,contextIsolation 加固。验证:窗口里出现现有终端 UI,能开标签、跑 shell、WS 连通(前端 0 改动)。 - D4 生命周期:
before-quit→server.close();单实例锁。验证:退出无残留 pty 进程。
P1 — 跨平台真安装包 · ~2–4 天
- D5 node-pty 对 Electron ABI 重编译(
install-app-deps)+asarUnpack。验证:打包后的 App(非 dev)里 pty 正常 spawn。 - D6 electron-builder:Mac
.dmg。验证:装到干净 Mac,双击可用。 - D7 electron-builder:Win
.exe+ Windows 默认 shell/ConPTY/USE_TMUX=0。验证:装到 Win10+,PowerShell 会话可用,项目发现功能可用。
P2 — 原生集成(相对浏览器标签的价值) · ~2–3 天
- D8 原生通知:审批门/Claude 状态 → Electron Notification(先快路,再接 main 状态总线覆盖全会话)。验证:窗口最小化时 Claude 待审批弹系统通知,点通知回到会话。
- D9 托盘 + 聚合状态 + 上下文菜单。
- D10 应用菜单/快捷键映射 + 深链
terminalapp://+ 开机自启 + 设置页(端口/LAN 开关/默认 shell/自启,electron-store)。
P3 — 分发打磨(可选) · ~1–3 天
- D11 代码签名 + 公证(Mac)/ Authenticode(Win)。
- D12 electron-updater 自动更新(GitHub Releases)。
合计:能跑的一体化 App(P0+P1)≈ 3–5 天;加满原生价值(P2)再 2–3 天;分发签名(P3)视是否对外。
10. 风险与开放问题
| 风险 / 问题 | 影响 | 缓解 / 待定 |
|---|---|---|
| node-pty Electron ABI 重编译在 CI 上跨平台出包 | 中 | 用 GitHub Actions 的 mac + win runner 分别出包;本地先手动验证 |
| main 进程跑服务器导致 UI 卡顿 | 低 | 服务器是纯 I/O;若真卡,升级 utilityProcess 隔离 |
| Windows 首跑项目发现/worktree/OSC 行为差异 | 中 | P1 D7 专测;必要时给 Win 打补丁(应在 server 侧,routes 归属不变) |
| 无鉴权 + 默认 LAN 暴露 | 中 | 默认 127.0.0.1,LAN 为显式开关 + Tailscale 提示 |
现有 sw.js 以 classic 方式注册却用 ESM import(疑似 bug,main.ts:87 缺 {type:'module'}) |
低 | 桌面壳不依赖 SW;但若在乎浏览器端离线/推送需单独修(与本方案解耦) |
是否复用 root package.json 还是 npm workspaces |
低 | 倾向 desktop/ 独立 package + 相对引用 ../dist;如需统一装依赖再上 workspaces |
待你拍板的开放项:
- 默认
BIND_HOST:0.0.0.0(开箱即可手机重连,风险高)还是127.0.0.1(私有,LAN 需手动开)? appId/ 产品名 / 图标。- 是否现在就要签名分发,还是先自用(不签)。
11. 与现有文档的关系
- 本文只新增
desktop/,不改src/与public/(startServer/loadConfig已够用)。 - 若 P1 在 Windows 上发现 server 侧跨平台缺陷,修复归属对应模块(route 文件),并按 CLAUDE.md 记
PROGRESS_LOG.md——桌面壳不越界改 server。 - 冲突时:ARCHITECTURE 管 how、TECH_DOC 管 why/scope;本文是它们之上的"打包/分发"新层,不与协议/会话模型冲突。