跳到主要内容

桌面外壳与启动流程

30 秒导读: OpenWork 桌面版是一个 Electron 应用。它的"外壳"(main process)负责三件事:开一个装着 React UI 的原生窗口、在后台把这个 UI 接到本地的 OpenWork 服务器 + OpenCode 引擎、再顺带托管自动更新和几个原生能力面板(内建浏览器、电脑操作、UI 遥控)。本章讲这层外壳如何从双击图标一路启动到窗口可用。

本章覆盖 apps/desktop/electron/ 下的桌面外壳。不覆盖 orchestrator 内部如何监管三个 sidecar(见 第 2 章)、embedded 服务器内部的鉴权与文件 API(见 第 3 章)、React UI 内部(见 第 5 章)、以及远程/云工作区细节(见 第 6 章)。


1. 这是什么(零基础也能懂)

一句话定义: 桌面外壳 = 一个 Electron 主进程,它是你双击 OpenWork 图标后第一个跑起来的东西。

Electron 应用天生分两半,理解这一点就理解了本章的一切:

半边是什么在 OpenWork 里装的是
主进程(main)一个 Node.js 进程,有完整系统权限(开窗口、读磁盘、起子进程)apps/desktop/electron/ 全部代码
渲染进程(renderer)一个受限的 Chromium 网页,只能画 UI打包好的 React 单页应用(第 5 章)

解决什么问题: React UI 是一个网页,它不能直接 spawn 一个 opencode 进程、不能弹系统文件选择框、不能装更新。这些"脏活"必须由有系统权限的主进程做。外壳就是网页与操作系统之间那道有纪律的门

它负责什么(功能清单):

  • 开主窗口,管它的生命周期(创建、显示、关闭、退出前清理)。
  • 提供一座 IPC 桥:renderer 通过 window.__OPENWORK_ELECTRON__ 调用主进程能力。
  • 启动时把 UI 接到本地进程栈:OpenWork 服务器 + OpenCode 引擎。
  • 记住你打开过哪些工作区(workspace),并在下次启动时自动拉起选中的那个。
  • 托管原生能力:自动更新、内建浏览器面板、电脑操作(computer-use)、UI 遥控服务、终端。

一句话直觉: 把主进程想成一家餐厅的后厨 + 门房:顾客(React UI)只在前厅点单,所有要动火、要出门采购、要开保险柜的事,都由后厨代劳,前厅只通过一个传菜窗口(IPC 桥)下单。


2. 顶层全景(它大概怎么转)

2.1 启动时序

下面是从进程启动到窗口可用的主干。怎么读这张图: 从上到下是时间顺序;app.whenReady() 之后的几步是并行准备,窗口一旦 ready-to-show 就显示给用户。

双击图标 / open-url


main.mjs 顶层同步执行
├─ 设定 App 名 / 标识符 / userData 目录 (与 Tauri 版共享磁盘状态)
├─ 探测空闲 CDP 调试端口 (9223+) (给浏览器面板 / devtools 插件用)
└─ 构造 4 个管理器 (workspaceStore /
runtimeManager / browserPanel /
uiControlServer) —— 还没启动


app.requestSingleInstanceLock() ── 抢不到锁 → 直接退出,把参数转给已有实例
│ 抢到锁

app.whenReady() ──┬─ 装媒体权限处理器 (只放行本地音频)
├─ 装原生菜单
├─ runtimeManager.prepareFreshRuntime() (杀掉上次残留 sidecar)
├─ 迁移旧 Electron 工作区状态文件(如需要)
├─ uiControlServer.start() (起本地 UI 遥控 HTTP 服务)
├─ bootRuntimeForSelectedWorkspace() ★ 启动本地进程栈(见 §5.3)
└─ createMainWindow() → loadFile(React index.html)


window 'ready-to-show' → win.show() + 冲刷待处理的 deep-link


用户看到 UI;renderer 通过 IPC 桥继续和外壳对话

2.2 部件一句话职责

外壳被拆成一个大 main.mjs 加若干专职模块,main.mjs 把它们组装起来:

部件干什么文件
入口 / 组装顶层配置、窗口、IPC 命令注册表、app 生命周期钩子electron/main.mjs
IPC 桥(preload)在 renderer 里安全暴露 window.__OPENWORK_ELECTRON__electron/preload.mjs
工作区存储读写工作区列表、选中项、导入导出electron/workspace-store.mjs
配置压缩包把工作区的 .opencode 配置打成/解开 zipelectron/workspace-archive.mjs
运行时管理器启停本地 OpenWork 服务器 + OpenCode 引擎electron/runtime.mjs
自动更新检查/下载/安装新版本(打包版专属)electron/updater.mjs
浏览器面板内建的 WebContentsView 网页视图 + 标签页electron/browser-panel.mjs
电脑操作定位并调起 macOS "Computer Use" 助手electron/computer-use.mjs
UI 遥控服务本地 HTTP 回环,让 MCP 能驱动 UIelectron/ui-control-server.mjs
迁移从旧 Tauri 版一次性接管快照electron/migration.mjs
原生菜单 / 媒体权限应用菜单、麦克风权限门electron/app-menu.mjselectron/media-permissions.mjs

main.mjs 顶层把这些管理器都实例化好(electron/main.mjs:393-523),但它们此刻只是被构造,并未启动——真正启动发生在 app.whenReady() 里(electron/main.mjs:1649)。


3. 核心机制一:窗口生命周期与 IPC 桥

3.1 它要解决的小问题

一个网页(renderer)出于安全不能直接碰 Node.js。但 OpenWork 的 UI 又必须能"请主进程帮我起服务器 / 弹文件框 / 装更新"。怎么在不打开安全口子的前提下让两者对话?

3.2 思路:contextBridge + 单一 invoke 通道

Electron 的答案是 preload 脚本 + contextBridge。preload 运行在一个既能碰 ipcRenderer、又与网页隔离的特殊上下文里;它只把一小撮白名单函数挂到网页的 window 上。网页能调这些函数,但碰不到函数背后的 ipcRenderer 本体。

OpenWork 的绝大多数能力走一条通道:invokeDesktop(command, ...args)

// electron/preload.mjs:51 —— 只暴露这一个对象,别的一律碰不到
contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__", {
invokeDesktop(command, ...args) {
// 把 (命令名, 参数) 打包成一次 IPC invoke
return ipcRenderer.invoke("openwork:desktop", command, ...args);
},
// ... shell / system / updater / browser / terminal 等分组
});

主进程这端,"openwork:desktop" 只有一个处理器,它把命令名派发到一张命令注册表:

// electron/main.mjs:1422 handleDesktopInvoke —— 一张表决定谁处理什么
async function handleDesktopInvoke(event, command, ...args) {
const handler = desktopCommandHandlers[command];
if (!handler) throw new Error(`... not implemented yet: ${command}`);
return handler(event, ...args);
}

desktopCommandHandlers(electron/main.mjs:945)是唯一的命令清单,从 workspaceCreateengineStartpickDirectory 都在里面各占一行。

关键细节:类型合同。 这张表被一句 @type 注解绑到共享契约 packages/types/src/desktop-ipc.ts 上(electron/main.mjs:944)。少一个、多一个、或改名一个命令,typecheck:electron 就会失败——这样 renderer 和外壳永远不会对不上号。

3.3 窗口的创建与外观

createMainWindow()(electron/main.mjs:1431)是幂等的:已经有窗口就直接返回它(if (mainWindow) return mainWindow)。macOS 上它做了一层"无边框沉浸"处理:

// electron/main.mjs:1436 —— mac 专属外观:隐藏标题栏 + 毛玻璃
if (process.platform === "darwin") {
Object.assign(windowAppearanceOptions, {
titleBarStyle: "hiddenInset", // 只留红绿灯按钮
vibrancy: macosVibrancyForCurrentTheme(),
visualEffectState: "active",
});
}

webPreferences 里几个选择值得记(electron/main.mjs:1452):

选项为什么
contextIsolationtruerenderer 与 preload 隔离,安全底线
nodeIntegrationfalse网页碰不到 Node,安全底线
backgroundThrottlingfalse窗口最小化时降频——后台会话/事件流不能被打断
pluginstrue开 Chromium 内建 PDF 查看器,artifact 面板要用

加载什么页面: 优先读环境变量 OPENWORK_ELECTRON_START_URL(开发时指向 Vite dev server);否则打包版加载 resources/app-dist/index.html,开发版加载 apps/app/dist/index.html(electron/main.mjs:1532-1539)。

3.4 导航防护:别让整个 UI 被网页顶掉

外壳窗口本身是一个 Chromium 页面。如果有东西(尤其是 agent 通过 CDP 自动化)让主窗口导航去了外部网址,整个工作区 UI 就会被替换掉且回不来(见 issue #2000)。外壳用三道拦截防这个:

外部链接想在主窗口打开
├─ setWindowOpenHandler → 本地 127.0.0.1 放行,其余交系统浏览器 (main.mjs:1488)
├─ will-navigate 事件 → 非同源就拦下,改路由进内建浏览器标签页 (main.mjs:1509)
└─ did-start-navigation → 连 CDP 的 Page.navigate 也拦(它不触发上面两个)(main.mjs:1521)

第三道尤其巧:CDP 的 Page.navigate 行为像 loadURL,不触发 will-navigate,所以要在 did-start-navigationwebContents.stop() 取消加载,再把 URL 转给 browserPanel.routeBlockedMainWindowNavigation(§7.2)。

3.5 App 级生命周期

外壳先抢单实例锁——抢不到就退出,把命令行参数(可能含 deep-link)转给已有实例(electron/main.mjs:1622second-instance 于 1633)。抢到锁后注册几个钩子:

  • before-quit:退出前先清干净。阻止默认退出,显示一个"正在停止服务"的过场页(showShutdownScreen,electron/main.mjs:529),并发起 runtimeManager.dispose() + uiControlServer.stop(),全部结束才真正 app.quit()(electron/main.mjs:1625)。
  • open-url / activate / window-all-closed:标准 macOS 行为(点 dock 图标重开窗口;非 mac 关窗即退出)。

4. 核心机制二:工作区选择与持久化

4.1 工作区是什么

一个 workspace 就是一个本地文件夹(或一个远程连接),OpenWork 在其中跑一个 OpenCode 会话。外壳要记住:你有哪些工作区、当前选中哪个、以及每个工作区的配置。

4.2 选文件夹:走系统对话框

新建本地工作区第一步是让用户选个文件夹。React UI 调 pickDirectory 命令,外壳用 Electron 的 dialog.showOpenDialog原生文件夹选择框:

// electron/main.mjs:1127 "pickDirectory"
const properties = options.multiple
? ["openDirectory", "createDirectory", "multiSelections"]
: ["openDirectory", "createDirectory"]; // 允许在对话框里现场建新文件夹
const result = await dialog.showOpenDialog(activeWindowFromEvent(event), { ... });

说明:UI 侧的抽象层历史上兼容过 Tauri 的 dialog API,但在 Electron 外壳里,文件夹选择就是 dialog.showOpenDialog,没有 Tauri 参与。

选完路径后,createWorkspace(electron/workspace-store.mjs:697)会 mkdir 该文件夹、建 .opencode/ 子目录、写一份默认 openwork.json,然后把它加进工作区列表并设为选中项。

4.3 持久化:一个文件、双份键名

工作区列表存在 userData/openwork-workspaces.json(electron/workspace-store.mjs:126)。这个文件的设计目标是和旧 Tauri 版共享,所以写入时故意存两套键名:

// electron/workspace-store.mjs:545 writeWorkspaceState —— 同一份数据,双键名
const output = {
...nextState,
selectedId, // Electron 用
selectedWorkspaceId: selectedId, // Tauri 的 Rust 状态用
watchedId: watchedId || null,
watchedWorkspaceId: watchedId,
activeId: selectedId || null, // 旧版遗留别名
};

读取时(readWorkspaceState,electron/workspace-store.mjs:564)反过来:按 selectedId → selectedWorkspaceId → activeId 的优先级容错解析,这样无论文件是哪个版本写的都能读。

空列表恢复。 如果列表为空(比如状态文件丢了),readWorkspaceState 会尝试从已知的持久状态里"考古"恢复工作区(recoverWorkspacesFromKnownState,electron/workspace-store.mjs:411):翻 token 存储、翻服务器 server.json,把还认识的工作区捞回来。这是为什么删了状态文件往往还能自愈。

4.4 迁移:接管旧 Tauri 版的一次性快照

OpenWork 从 Tauri 迁到 Electron 时,末代 Tauri 版会往 userData 写一份迁移快照 migration-snapshot.v1.json,里面是工作区列表和"每个工作区看哪个会话"的偏好。Electron 首启时读它、渲染出来,然后把它改名成 .done.json,后续启动就不再重复导入:

// electron/migration.mjs:37 —— ack 就是把快照改名成 .done,幂等
await rename(snapshotPath, donePath);

另有一个更早期的 Electron alpha 遗留:workspace-state.json。仅当共享的规范文件缺失时才把它拷成 openwork-workspaces.json(migrateLegacyElectronWorkspaceStateIfNeeded,electron/workspace-store.mjs:148)。

4.5 导入 / 导出:自制的 zip

工作区配置(opencode.json + .opencode/ 下的文件)可以打成 zip 分享。workspace-archive.mjs 手写了一个 zip 编解码器(不引第三方库),核心是两点安全纪律:

  • 导出剔除密钥: .env*credentials.*.key/.pem/.p12/.pfx 一律排除,并在 manifest 里记为 excluded(isSecretName,electron/workspace-archive.mjs:13)。
  • 导入防 zip-slip: 拒绝绝对路径、盘符、.. 组件,只解 opencode.json.opencode/ 下的项(isSafeArchivePath,electron/workspace-archive.mjs:221)。

5. 核心机制三:运行时选择 —— 把 UI 接到本地进程栈 ★

这是外壳最核心、也最容易被文档误读的部分。先说结论,再拆细。

5.1 它要解决的小问题

React UI 本身不会推理、不会改代码。真正干活的是 OpenCode 引擎(一个本地 HTTP 服务)。UI 需要一个 baseUrl + 凭据才能连上它。外壳的职责就是:在启动时把 OpenCode(以及它上面的 OpenWork 服务器)拉起来,拿到 URL 和 token,交给 UI。

5.2 代码里的两条命名路径,与实际跑的那条

runtime.mjs 顶部定义了两个运行时常量(electron/runtime.mjs:13):

常量设计意图
DIRECT_RUNTIME"direct"直接 spawn 一个 opencode serve
ORCHESTRATOR_RUNTIME"openwork-orchestrator"交给 orchestrator 守护进程去监管 OpenCode

文件里确实有两个对应的启动函数:

  • startDirectRuntime(electron/runtime.mjs:1293):spawn opencode serve --hostname 127.0.0.1 --port <free> --cors *(electron/runtime.mjs:1306)。
  • startOrchestratorRuntime(electron/runtime.mjs:1217):spawn openwork-orchestrator daemon run --data-dir … --daemon-port … --opencode-bin … --opencode-workdir … --allow-external --cors *(electron/runtime.mjs:1241),并管理 orchestrator 的数据目录与 auth 文件。

诚实提示(核实过调用图): 在本章锁定的 commit b71c06b,engineStart 既不调用 startDirectRuntime也不调用 startOrchestratorRuntime——用 grep 追这两个符号,只有定义、没有调用点。ORCHESTRATOR_RUNTIME 也只在那个未被调用的函数里被赋值一次(electron/runtime.mjs:1280)。它们是保留但当前休眠的代码路径。实际启动走的是下面 §5.4 的进程内 embedded 服务器。文档如实记录源码,而非其命名所暗示的旧设计。

5.3 启动入口:bootRuntimeForSelectedWorkspace

app.whenReady() 里调 bootRuntimeForSelectedWorkspace(electron/main.mjs:585)。它的逻辑:

读工作区状态 → 取选中(或第一个本地)工作区
├─ 没有本地工作区 → 跳过(remote 工作区连接见第 6 章)
├─ engineStart(workspaceRoot, { runtime: "direct", workspacePaths })
│ └─ 失败 → 换一个本地工作区做 fallback,重试一次,并把选中项改成它
├─ orchestratorWorkspaceActivate(...) (仅记录激活状态)
└─ 断言 OpenWork 服务器已就绪(有 baseUrl + token)

注意这里传的是 runtime: "direct",但这个 runtime 选项在 engineStart 里其实被忽略了——见下。

5.4 engineStart 实际做了什么

engineStart(electron/runtime.mjs:1381)是所有启动请求的收口。它硬编码只走一条路:

// electron/runtime.mjs:1415 —— 无视传入的 runtime,一律 direct 常量
const runtime = DIRECT_RUNTIME;
// ...
await ensureOpenwork({
projectDir: safeProjectDir,
workspacePaths,
remoteAccessEnabled: options.openworkRemoteAccess === true,
manageOpencode: true, // ★ 关键:让 embedded 服务器自己管 OpenCode
opencodeBinPath: options.opencodeBinPath,
});

ensureOpenwork(electron/runtime.mjs:1361)转手调 startOpenworkServer(electron/runtime.mjs:1069)。而 startOpenworkServer 不 spawn 子进程——它是 import() 一个打包好的 embedded 服务器模块,在 Electron 主进程内起一个 HTTP 服务:

// electron/runtime.mjs:1121 —— 进程内(in-process)起服务器,不是 spawn
const { startEmbeddedServer } = await import(pathToFileURL(embeddedPath).href);
const handle = await startEmbeddedServer({
host, port: portSelection.port, corsOrigins: ["*"],
configPath: serverConfigPath, workspaces: workspacePaths,
token: tokens.clientToken, hostToken: tokens.hostToken,
manageOpencode: options.manageOpencode === true, // 由服务器去拉起 OpenCode
opencodeBin: managedOpencode?.path ?? undefined,
});
inProcessServer = handle;

因为 manageOpencode: true,是 embedded 服务器(第 3 章)负责真正的 OpenCode 进程,外壳只是从服务器的 /workspaces 回读 OpenCode 的 baseUrl,填进 engineState(electron/runtime.mjs:1183)。

所以外壳启动本地栈的真实形态是:

Electron 主进程
└─ startEmbeddedServer() ← 进程内 HTTP 服务器 (OpenWork server, 第3章)
└─ 由它 spawn / 管理 ← opencode 子进程 (真正的引擎)

外壳选 OpenCode 二进制的方式:resolveBinary("opencode")(electron/runtime.mjs:763)按顺序找打包 sidecar 目录 → PATH → 已知安装位置(~/.opencode/bin、Homebrew 等)。找不到时可走 engineInstall(electron/runtime.mjs:1547)用 constants.json 里钉死的版本 curl … | bash 装一份。

5.5 生命周期串行化:防止并发把彼此的服务器杀掉

启动期有两条独立路径会同时调 engineStart:main 进程的 bootRuntimeForSelectedWorkspace,以及 renderer 的连接逻辑。若不串行,后一个的 prepareFreshRuntime 会把前一个刚绑好的服务器杀掉,再抢那个"黏性首选端口",撞进 EADDRINUSE。外壳用一个 promise 队列 withRuntimeLifecycle 把所有生命周期操作排成一列(electron/runtime.mjs:506),对外的 engineStart/engineStop/engineRestart/dispose 全部经它包裹(electron/runtime.mjs:1893)。

engineStart 顶部还有一道复用捷径:若已有健康的进程内服务器、且工作区和远程访问设置都没变,就直接返回现有快照,不重启(electron/runtime.mjs:1395)。

5.6 那两条休眠路径涉及的文件(供理解 orchestrator 设计)

即便当前未被调用,runtime.mjs 仍完整实现了 orchestrator 的落盘约定,理解它有助于读第 2 章:

概念位置说明
orchestratorDataDir()electron/runtime.mjs:531默认 ~/.openwork/openwork-orchestrator,可用 OPENWORK_DATA_DIR 覆盖
orchestrator 状态文件electron/runtime.mjs:537openwork-orchestrator-state.json,记 daemon 的 baseUrl
orchestrator auth 文件electron/runtime.mjs:541openwork-orchestrator-auth.json,存受管 OpenCode 的用户名/密码
关停协议electron/runtime.mjs:563读状态文件里的 daemon baseUrl,发关停请求,再清 auth 文件

prepareFreshRuntime(electron/runtime.mjs:1354)在每次启动前调 cleanupPackagedSidecars(electron/runtime.mjs:978):先请求上次记录的 orchestrator 自杀,再用 ps 扫出本 app bundle 遗留的 sidecar 进程,SIGTERMSIGKILL 兜底——防止不干净退出留下孤儿进程占端口。


6. 核心机制四:自动更新

范围: 仅打包版启用;开发版跳过,免得去探一个不存在的发布渠道(electron/updater.mjs:234 if (!app.isPackaged) return null)。

更新走 electron-updater,注册在 registerUpdaterIpc(electron/updater.mjs:218)。两个发布渠道(electron/updater.mjs:32):stable 指向 GitHub latest,alpha 仅 macOS。渠道选择存 electron-updater-channel.v1.json

更新流程被拆成 renderer 可分步触发的几个 IPC:checkdownloadinstallAndRestart(electron/updater.mjs:291/320/346)。autoDownload=false,由用户在 UI 里点下载,进度通过 download-progress 事件回传 renderer(electron/updater.mjs:258)。

macOS 上的两处硬仗(精华): Squirrel.Mac 的 ShipIt 默认整包搬移 app bundle,重复安装时容易 ENOENT 中止、然后静默重启版(在应用内看版本像是更新了,磁盘上却没变)。外壳用两招绕开:

  1. disableDifferentialDownload = true——下整包 zip,不做增量重建(electron/updater.mjs:249)。
  2. 写 NSUserDefaults SquirrelMacEnableDirectContentsWrite=YES,让 ShipIt 就地写文件内容而非搬整包(enableSquirrelDirectContentsWrite,electron/updater.mjs:190);并在下载前清理上次卡住的 ShipIt 缓存(cleanStaleUpdaterState,electron/updater.mjs:206)。

7. 核心机制五:原生能力面板

外壳还托管几个"网页做不到"的原生能力。本章只讲它们在外壳里如何被接线;具体协议细节属于各自领域。

7.1 电脑操作(computer-use)

让 agent 操控整台电脑的 macOS 助手是一个独立的 .appcomputer-use.mjs 负责定位它(打包 helper → 环境变量 → 开发构建产物,electron/computer-use.mjs:58)、查权限(spawn --check 读一次新鲜的 TCC 状态,electron/computer-use.mjs:87)、以及列出正在运行的 app(供 composer 的 @App 提及用,electron/computer-use.mjs:118)。查权限刻意用"spawn 一个新进程读一次就退出"的方式,保证每次都是新鲜读数,不需要常驻服务。

7.2 内建浏览器面板

browser-panel.mjs 用 Electron 的 WebContentsView 在主窗口里嵌一个真正的网页视图,支持多标签(createBrowserPanel,electron/browser-panel.mjs:22)。它把视图作为子视图挂到主窗口的 contentView 上(attachActiveBrowserView,electron/browser-panel.mjs:600)。§3.4 里被拦下的外部导航,就是被 routeBlockedMainWindowNavigation 转到这里打开(electron/browser-panel.mjs:106),从而永远不会顶掉工作区 UI。所有浏览器操作通过 registerIpc 暴露给 renderer(electron/browser-panel.mjs:745)。

7.3 UI 遥控服务

ui-control-server.mjs 起一个回环 HTTP 服务(127.0.0.1,随机端口,electron/ui-control-server.mjs:136),暴露 /snapshot/actions/execute,让外部 MCP(openwork-ui-mcp)能读取并驱动 UI。安全上:

  • 每次启动生成一个随机 Bearer token,除 /health 外所有请求都要带它(electron/ui-control-server.mjs:52)。
  • {baseUrl, token} 写到发现文件 openwork-ui-control.json,并塞进 process.env.OPENWORK_UI_CONTROL_DISCOVERY 让子进程能找到(electron/ui-control-server.mjs:141-148)。
  • 命令最终通过 webContents.executeJavaScript 打到 renderer 暴露的 window.__openworkControl 上(electron/ui-control-server.mjs:61)。

7.4 终端与媒体权限

  • 终端: openwork:terminal:* 一组 IPC 用 node-pty spawn 真正的 shell,数据双向流回 renderer;每个终端记住属于哪个 webContents,renderer 销毁时自动清理(electron/main.mjs:1568)。
  • 媒体权限: installMediaPermissionHandlers 只对主窗口 + 本地源 + 仅音频放行(语音输入),其余一律拒绝(electron/media-permissions.mjs:28)。

7.5 远程工作区(仅入口)

remote-workspace.mjs 提供从服务器返回的工作区列表里挑出要连的那个的纯函数(selectOpenworkWorkspaceForConnection,electron/remote-workspace.mjs:21)。本章只点到入口——消息连接器、托管 worker 等远程/云细节见 第 6 章


8. 巧妙之处(可借鉴的技术)

  • 和被替换的旧壳共享磁盘状态,让迁移几乎是 no-op。 生产版 Electron 用与 Tauri 相同的 userData 目录和双键名状态文件(electron/main.mjs:100-119electron/workspace-store.mjs:551),所以从 Tauri 切到 Electron、甚至回滚,双方看到的是同一份工作区。
  • 进程内服务器 + import() 而非 spawn。 本地栈的核心不是子进程,而是被动态 import 进主进程的 embedded 服务器(electron/runtime.mjs:1121),省掉一层进程管理与端口探活。
  • CDP 导航也拦得住。 认识到 Page.navigate 不触发 will-navigate,补上 did-start-navigation 这道防线(electron/main.mjs:1521),堵死 agent 自动化顶掉 UI 的坑(#2000)。
  • 生命周期串行队列。 用一个 promise 链把所有启停操作排队(electron/runtime.mjs:506),从根上消除并发 start/stop 互相杀进程导致的 EADDRINUSE 抖动。
  • 更新对抗 Squirrel 的两处 default 微调。 下整包 + 就地写内容(electron/updater.mjs:190/249),换来 macOS 更新不再静默失败。

9. 边界与局限

  • 两条命名运行时路径当前休眠。 startDirectRuntime / startOrchestratorRuntime 有完整实现却无调用点(§5.2);实际只跑进程内 embedded 服务器。读代码时别被 runtime: "direct" 这个被忽略的参数误导。
  • Guided install 不支持 Windows。 engineInstall 在 Windows 直接返回失败,要求手动装(electron/runtime.mjs:1548)。
  • 电脑操作是 macOS 专属。 权限检查、列 app 等在非 darwin 上直接返回空(electron/computer-use.mjs:121)。
  • 自动更新只在打包版。 开发版完全跳过。
  • sidecar 清理依赖 ps cleanupPackagedSidecarsps -Ao 匹配命令行来找孤儿进程(electron/runtime.mjs:990),是 Unix 形态的启发式。

10. 代码地图(导航索引)

主题文件符号
入口配置 / userData 与 Tauri 共享apps/desktop/electron/main.mjsAPP_IDENTIFIERapp.setPath("userData", …)
app 生命周期 / 单实例 / 启动apps/desktop/electron/main.mjsapp.requestSingleInstanceLockapp.whenReadybefore-quit
启动本地栈apps/desktop/electron/main.mjsbootRuntimeForSelectedWorkspaceensureRuntimeBootstrap
窗口创建 / 外观 / 导航防护apps/desktop/electron/main.mjscreateMainWindowdid-start-navigationsetWindowOpenHandler
IPC 命令注册表apps/desktop/electron/main.mjsdesktopCommandHandlershandleDesktopInvoke
文件夹选择 / 终端 IPCapps/desktop/electron/main.mjspickDirectoryopenwork:terminal:create
IPC 桥(preload)apps/desktop/electron/preload.mjscontextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__")invokeDesktop
工作区读写 / 双键名 / 恢复apps/desktop/electron/workspace-store.mjscreateWorkspaceStorereadWorkspaceStatewriteWorkspaceStatecreateWorkspacerecoverWorkspacesFromKnownState
旧状态迁移apps/desktop/electron/workspace-store.mjsmigrateLegacyElectronWorkspaceStateIfNeeded
配置 zip 导入导出apps/desktop/electron/workspace-archive.mjsexportWorkspaceConfigimportWorkspaceConfigisSecretNameisSafeArchivePath
运行时管理器 / 串行队列apps/desktop/electron/runtime.mjscreateRuntimeManagerwithRuntimeLifecycle
实际启动路径(进程内服务器)apps/desktop/electron/runtime.mjsengineStartensureOpenworkstartOpenworkServerstartEmbeddedServer
休眠的两条命名路径apps/desktop/electron/runtime.mjsstartDirectRuntimestartOrchestratorRuntimeDIRECT_RUNTIMEORCHESTRATOR_RUNTIME
orchestrator 落盘约定apps/desktop/electron/runtime.mjsorchestratorDataDirorchestratorStatePathorchestratorAuthPathrequestOrchestratorShutdown
二进制解析 / 环境 / sidecar 清理apps/desktop/electron/runtime.mjsresolveBinarybuildChildEnvspawnManagedChildcleanupPackagedSidecars
自动更新apps/desktop/electron/updater.mjsregisterUpdaterIpcensureAutoUpdaterenableSquirrelDirectContentsWrite
从 Tauri 接管快照apps/desktop/electron/migration.mjsregisterMigrationIpc
电脑操作助手apps/desktop/electron/computer-use.mjsgetComputerUseMcpCommandcheckComputerUsePermissionslistRunningApps
内建浏览器面板apps/desktop/electron/browser-panel.mjscreateBrowserPanelrouteBlockedMainWindowNavigationattachActiveBrowserView
UI 遥控服务apps/desktop/electron/ui-control-server.mjscreateUiControlServerrunOpenworkControlCommand
媒体权限门apps/desktop/electron/media-permissions.mjsinstallMediaPermissionHandlers
远程工作区(入口)apps/desktop/electron/remote-workspace.mjsselectOpenworkWorkspaceForConnection