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).
This commit is contained in:
Yaojia Wang
2026-07-02 06:13:17 +02:00
parent 2af57e6686
commit cf8cfccab4
39 changed files with 7781 additions and 0 deletions

286
docs/DESKTOP_PLAN.md Normal file
View File

@@ -0,0 +1,286 @@
# 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.md`why+ `ARCHITECTURE.md`how
---
## 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`,用不到。
### 一个已被验证掉的大风险
现有服务器 **早就为内嵌设计好了**,几乎零重构:
```ts
// 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 `utilityProcess`22+,官方推荐的 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.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 # 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. `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
```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'
}
```
要点:
- **端口先定后配**`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 的 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.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
```yaml
# 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. **默认 shell**`config.ts` 默认 `$SHELL || /bin/zsh` 在 Win 无效 → 必须覆盖 `SHELL_PATH=powershell.exe`(或 `pwsh`/`cmd`)。见 §3。
2. **ConPTY**node-pty 在 Win 走 ConPTY需 Win10 1809+打包解包其依赖§5
3. **路径分隔符**:服务器涉及 cwd/项目扫描的地方用 Node `path`——现有代码应已跨平台,但 Win 首跑要专门测项目发现/worktree 功能(`src/http/projects.ts` / `worktrees.ts`)。
4. **`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 / 验证方式 / 估时。P0P1 出"能跑的一体化 App"P2 加原生价值P3 分发。
### P0 — 骨架跑通("它启动了")· ~1 天
- **D1** `desktop/` 脚手架package.jsonelectron/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 localhostcontextIsolation 加固。*验证*:窗口里出现现有终端 UI能开标签、跑 shell、WS 连通(前端 0 改动)。
- **D4** 生命周期:`before-quit``server.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+P1**35 天**加满原生价值P2**23 天**分发签名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 |
**待你拍板的开放项**
1. 默认 `BIND_HOST``0.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本文是它们之上的"打包/分发"新层,不与协议/会话模型冲突。
</content>
</invoke>