桌面外壳与启动流程
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 配置打成/解开 zip | electron/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 能驱动 UI | electron/ui-control-server.mjs |
| 迁移 | 从旧 Tauri 版一次性接管快照 | electron/migration.mjs |
| 原生菜单 / 媒体权限 | 应用菜单、麦克风权限门 | electron/app-menu.mjs、electron/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)是唯一的命令清单,从 workspaceCreate、engineStart 到 pickDirectory 都在里面各占一行。
关键细节:类型合同。 这张表被一句 @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):
| 选项 | 值 | 为什么 |
|---|---|---|
contextIsolation | true | renderer 与 preload 隔离,安全底线 |
nodeIntegration | false | 网页碰不到 Node,安全底线 |
backgroundThrottling | false | 窗口最小化时不降频——后台会话/事件流不能被打断 |
plugins | true | 开 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-navigation 里 webContents.stop() 取消加载,再把 URL 转给 browserPanel.routeBlockedMainWindowNavigation(§7.2)。
3.5 App 级生命周期
外壳先抢单实例锁——抢不到就退出,把命令行参数(可能含 deep-link)转给已有实例(electron/main.mjs:1622、second-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):spawnopencode serve --hostname 127.0.0.1 --port <free> --cors *(electron/runtime.mjs:1306)。startOrchestratorRuntime(electron/runtime.mjs:1217):spawnopenwork-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:537 | openwork-orchestrator-state.json,记 daemon 的 baseUrl |
| orchestrator auth 文件 | electron/runtime.mjs:541 | openwork-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 进程,SIGTERM 后 SIGKILL 兜底——防止不干净退出留下孤儿进程占端口。
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:check → download → installAndRestart(electron/updater.mjs:291/320/346)。autoDownload=false,由用户在 UI 里点下载,进度通过 download-progress 事件回传 renderer(electron/updater.mjs:258)。
macOS 上的两处硬仗(精华): Squirrel.Mac 的 ShipIt 默认整包搬移 app bundle,重复安装时容易 ENOENT 中止、然后静默重启旧版(在应用内看版本像是更新了,磁盘上却没变)。外壳用两招绕开:
disableDifferentialDownload = true——下整包 zip,不做增量重建(electron/updater.mjs:249)。- 写 NSUserDefaults
SquirrelMacEnableDirectContentsWrite=YES,让 ShipIt 就地写文件内容而非搬整包(enableSquirrelDirectContentsWrite,electron/updater.mjs:190);并在下载前清理上次卡住的 ShipIt 缓存(cleanStaleUpdaterState,electron/updater.mjs:206)。
7. 核心机制五:原生能力面板
外壳还托管几个"网页做不到"的原生能力。本章只讲它们在外壳里如何被接线;具体协议细节属于各自领域。
7.1 电脑操作(computer-use)
让 agent 操控整台电脑的 macOS 助手是一个独立的 .app。computer-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-ptyspawn 真正的 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-119、electron/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。cleanupPackagedSidecars用ps -Ao匹配命令行来找孤儿进程(electron/runtime.mjs:990),是 Unix 形态的启发式。
10. 代码地图(导航索引)
| 主题 | 文件 | 符号 |
|---|---|---|
| 入口配置 / userData 与 Tauri 共享 | apps/desktop/electron/main.mjs | APP_IDENTIFIER、app.setPath("userData", …) |
| app 生命周期 / 单实例 / 启动 | apps/desktop/electron/main.mjs | app.requestSingleInstanceLock、app.whenReady、before-quit |
| 启动本地栈 | apps/desktop/electron/main.mjs | bootRuntimeForSelectedWorkspace、ensureRuntimeBootstrap |
| 窗口创建 / 外观 / 导航防护 | apps/desktop/electron/main.mjs | createMainWindow、did-start-navigation、setWindowOpenHandler |
| IPC 命令注册表 | apps/desktop/electron/main.mjs | desktopCommandHandlers、handleDesktopInvoke |
| 文件夹选择 / 终端 IPC | apps/desktop/electron/main.mjs | pickDirectory、openwork:terminal:create |
| IPC 桥(preload) | apps/desktop/electron/preload.mjs | contextBridge.exposeInMainWorld("__OPENWORK_ELECTRON__")、invokeDesktop |
| 工作区读写 / 双键名 / 恢复 | apps/desktop/electron/workspace-store.mjs | createWorkspaceStore、readWorkspaceState、writeWorkspaceState、createWorkspace、recoverWorkspacesFromKnownState |
| 旧状态迁移 | apps/desktop/electron/workspace-store.mjs | migrateLegacyElectronWorkspaceStateIfNeeded |
| 配置 zip 导入导出 | apps/desktop/electron/workspace-archive.mjs | exportWorkspaceConfig、importWorkspaceConfig、isSecretName、isSafeArchivePath |
| 运行时管理器 / 串行队列 | apps/desktop/electron/runtime.mjs | createRuntimeManager、withRuntimeLifecycle |
| 实际启动路径(进程内服务器) | apps/desktop/electron/runtime.mjs | engineStart、ensureOpenwork、startOpenworkServer、startEmbeddedServer |
| 休眠的两条命名路径 | apps/desktop/electron/runtime.mjs | startDirectRuntime、startOrchestratorRuntime、DIRECT_RUNTIME、ORCHESTRATOR_RUNTIME |
| orchestrator 落盘约定 | apps/desktop/electron/runtime.mjs | orchestratorDataDir、orchestratorStatePath、orchestratorAuthPath、requestOrchestratorShutdown |
| 二进制解析 / 环境 / sidecar 清理 | apps/desktop/electron/runtime.mjs | resolveBinary、buildChildEnv、spawnManagedChild、cleanupPackagedSidecars |
| 自动更新 | apps/desktop/electron/updater.mjs | registerUpdaterIpc、ensureAutoUpdater、enableSquirrelDirectContentsWrite |
| 从 Tauri 接管快照 | apps/desktop/electron/migration.mjs | registerMigrationIpc |
| 电脑操作助手 | apps/desktop/electron/computer-use.mjs | getComputerUseMcpCommand、checkComputerUsePermissions、listRunningApps |
| 内建浏览器面板 | apps/desktop/electron/browser-panel.mjs | createBrowserPanel、routeBlockedMainWindowNavigation、attachActiveBrowserView |
| UI 遥控服务 | apps/desktop/electron/ui-control-server.mjs | createUiControlServer、runOpenworkControlCommand |
| 媒体权限门 | apps/desktop/electron/media-permissions.mjs | installMediaPermissionHandlers |
| 远程工作区(入口) | apps/desktop/electron/remote-workspace.mjs | selectOpenworkWorkspaceForConnection |