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

287 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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>