Files
web-terminal/docs/DESKTOP_PLAN.md
Yaojia Wang cf8cfccab4 feat(desktop): Electron all-in-one desktop shell (Mac/Windows) embedding the server
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).
2026-07-02 06:13:17 +02:00

19 KiB
Raw Blame History

DESKTOP_PLAN.md — Electron 桌面客户端Mac + Windows一体化

落地方案文档。目标:把现有 web-terminal 打包成 Mac / Windows 原生桌面 App。 拓扑选型:一体化App 内嵌 Node 服务器 + node-pty。框架:Electron。 状态:desktop/ 代码已实现并验证2026-07-01分支 feat/desktop-electron —— P0P2 的逻辑代码、单测、Electron 加固、esbuild 打包在本环境全部通过(桌面 tsc 0 错、全量 1401 tests 绿、覆盖率达标)。未验证(需真机/CI见 §9 P1 D5D7:实际启动 Electron GUI、node-pty 的 Electron-ABI 重编译、.dmg/.exe 打包。详见 PROGRESS_LOG.md 首条。本文是"怎么做"的蓝图,配合 TECH_DOC.mdwhy+ ARCHITECTURE.mdhow


0. 目标与范围

做什么

一个 Mac / Windows 原生桌面 App双击即用

  • 窗口里是完全复用的现有前端xterm.js 多标签 UI0 改动)。
  • 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-ptynative 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.js0 改动)                       │     │
│  │  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 utilityProcess22+,官方推荐的 Node 服务隔离方式,可加载原生模块)把服务器挪出 mainIPC 转发端口即可。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.tsderiveAllowedOrigins 默认放行 localhost/127.0.0.1)。绝不要public/build 塞进 file:// 加载——那会让所有相对请求失效、Origin 变 file:// 被 401。


2. 目录结构

新增一个独立 desktop/,与现有 src/serverpublic/(前端)并列且解耦——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      # contextBridgerenderer ↔ 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.icnsMac/ icon.icoWin/ tray png
    ├── electron-builder.yml
    └── dist-app/           # electron-builder 产物:.dmg / .exe

构建管线(三步,前两步复用现有):

  1. npm run build(根)→ dist/server.js(服务器 ESM
  2. npm run build:web(根)→ public/build/main.js(前端)。
  3. desktopesbuild 打包 desktop/src/main.ts+preload.tsdesktop/build/*.cjsnative 依赖 --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',
    // ★ 跨平台默认 shellconfig.ts 默认 $SHELL||/bin/zshWindows 上必须覆盖
    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'
}

要点:

  • 端口先定后配allowedOriginscfg.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 的 Electron Notification。只覆盖【已开标签】的会话。
  • 全路(一体化独有优势):服务器就在 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-onlylocalhost 内嵌完全满足。


5. node-pty 打包(唯一的原生模块难点)

  • ABI 重编译node-pty 是 native .node addonElectron 的 Node ABI ≠ 系统 Node。desktop/package.jsonpostinstall: 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 依赖 ConPTYconpty.dll / conpty/)——一并解包。
  • 打包内容files 需含 dist/(服务器)、public/(含 public/build)、desktop/build,以及生产依赖 node_modulesexpress/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(可 universalarm64+x64。分发要 Apple Developer ID 签名 + 公证(afterSign@electron/notarize$99/年)。自用可自签或不签(用户首次右键打开绕过 Gatekeeper
  • Windows 目标NSIS .exe。分发要 Authenticode 证书自用可不签SmartScreen 会警告一次)。
  • 自动更新P3 可选)electron-updater + GitHub Releases 承载 latest.yml / zip

7. Windows 特有事项

  1. 默认 shellconfig.ts 默认 $SHELL || /bin/zsh 在 Win 无效 → 必须覆盖 SHELL_PATH=powershell.exe(或 pwsh/cmd)。见 §3。
  2. ConPTYnode-pty 在 Win 走 ConPTY需 Win10 1809+打包解包其依赖§5
  3. 路径分隔符:服务器涉及 cwd/项目扫描的地方用 Node path——现有代码应已跨平台,但 Win 首跑要专门测项目发现/worktree 功能(src/http/projects.ts / worktrees.ts)。
  4. USE_TMUXtmux 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: truenodeIntegration: false、preload 用 contextBridge 暴露最小 APIwebPreferencesallowRunningInsecureContent;只 loadURL 本地端口。

9. 分期任务拆解 + 工作量估算

风格对齐 PLAN.md每个任务给 Owns / 验证方式 / 估时。P0P1 出"能跑的一体化 App"P2 加原生价值P3 分发。

P0 — 骨架跑通("它启动了")· ~1 天

  • D1 desktop/ 脚手架package.jsonelectron/electron-builder/esbuild、tsconfig、esbuild 打包脚本。验证electron . 能开空窗口。
  • D2 embedded-server.tspickFreePort + loadConfig(覆盖 PORT/BIND_HOST/SHELL_PATH) + startServer。验证main 里起服务器,curl 127.0.0.1:<port>/sessions 有响应。
  • D3 window.tsBrowserWindow loadURL localhostcontextIsolation 加固。验证:窗口里出现现有终端 UI能开标签、跑 shell、WS 连通(前端 0 改动)。
  • D4 生命周期:before-quitserver.close();单实例锁。验证:退出无残留 pty 进程。

P1 — 跨平台真安装包 · ~24 天

  • D5 node-pty 对 Electron ABI 重编译(install-app-deps+ asarUnpack验证:打包后的 App非 dev里 pty 正常 spawn。
  • D6 electron-builderMac .dmg验证:装到干净 Mac双击可用。
  • D7 electron-builderWin .exe + Windows 默认 shell/ConPTY/USE_TMUX=0。验证:装到 Win10+PowerShell 会话可用,项目发现功能可用。

P2 — 原生集成(相对浏览器标签的价值) · ~23 天

  • D8 原生通知:审批门/Claude 状态 → Electron Notification先快路再接 main 状态总线覆盖全会话)。验证:窗口最小化时 Claude 待审批弹系统通知,点通知回到会话。
  • D9 托盘 + 聚合状态 + 上下文菜单。
  • D10 应用菜单/快捷键映射 + 深链 terminalapp:// + 开机自启 + 设置页(端口/LAN 开关/默认 shell/自启electron-store

P3 — 分发打磨(可选) · ~13 天

  • D11 代码签名 + 公证Mac/ AuthenticodeWin
  • D12 electron-updater 自动更新GitHub Releases

合计:能跑的一体化 AppP0+P135 天加满原生价值P223 天分发签名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.1LAN 为显式开关 + Tailscale 提示
现有 sw.js 以 classic 方式注册却用 ESM import疑似 bugmain.ts:87{type:'module'} 桌面壳不依赖 SW但若在乎浏览器端离线/推送需单独修(与本方案解耦)
是否复用 root package.json 还是 npm workspaces 倾向 desktop/ 独立 package + 相对引用 ../dist;如需统一装依赖再上 workspaces

待你拍板的开放项

  1. 默认 BIND_HOST0.0.0.0(开箱即可手机重连,风险高)还是 127.0.0.1私有LAN 需手动开)?
  2. appId / 产品名 / 图标。
  3. 是否现在就要签名分发,还是先自用(不签)。

11. 与现有文档的关系

  • 本文只新增 desktop/不改 src/public/startServer/loadConfig 已够用)。
  • 若 P1 在 Windows 上发现 server 侧跨平台缺陷修复归属对应模块route 文件),并按 CLAUDE.md 记 PROGRESS_LOG.md——桌面壳不越界改 server。
  • 冲突时ARCHITECTURE 管 how、TECH_DOC 管 why/scope本文是它们之上的"打包/分发"新层,不与协议/会话模型冲突。