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:
286
docs/DESKTOP_PLAN.md
Normal file
286
docs/DESKTOP_PLAN.md
Normal 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`)** —— 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>
|
||||
Reference in New Issue
Block a user