# 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 打包在本环境全部通过（桌面 `tsc` 0 错、全量 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`，用不到。

### 一个已被验证掉的大风险

现有服务器 **早就为内嵌设计好了**，几乎零重构：

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

**构建管线**（三步，前两步复用现有）：
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',
    // ★ 跨平台默认 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 的 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-only，localhost 内嵌完全满足。

---

## 5. node-pty 打包（唯一的原生模块难点）

- **ABI 重编译**：node-pty 是 native `.node` addon，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）

```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`（可 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 特有事项

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 / 验证方式 / 估时。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 |

**待你拍板的开放项**：
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>
