| 1 | # 桌面宿主协议 |
| 2 | |
| 3 | [English](DESKTOP_HOST_PROTOCOL.md) |
| 4 | |
| 5 | Electron 壳与 Go 桌面服务是两个进程,通过服务进程 stdio 上的一条私有、带版本的 |
| 6 | JSON-RPC 2.0 连接协作。本文是双方共同实现的契约:Go 侧拥有全部桌面业务命令, |
| 7 | Electron 侧拥有全部原生界面。任何一方都不得绕过契约:React 界面不触碰 Electron |
| 8 | 或 Go 全局对象,Go 业务代码不链接任何壳工具包。 |
| 9 | |
| 10 | ```text |
| 11 | React 渲染进程 ──preload 类型化 IPC──▶ Electron 主进程 ──stdio JSON-RPC──▶ Go 桌面服务 |
| 12 | ▲ │ |
| 13 | └────── host/* 反向请求 ────────────┘ |
| 14 | ``` |
| 15 | |
| 16 | ## 传输 |
| 17 | |
| 18 | - 帧格式:按行分隔的 JSON-RPC 2.0(`rpcwire` 严格模式)。一行一帧,UTF-8,不允许批量数组。 |
| 19 | - Go 服务以 `reasonix-desktop --host-rpc` 启动。stdout 只承载协议帧,stderr 承载日志。 |
| 20 | 壳仅在 `desktop/shutdown` 明确返回 `completed` 后关闭 stdin 作为退出兜底; |
| 21 | 没有完成结果的 stdin 关闭按 `connection_lost` 进入有界收尾。 |
| 22 | - 限制:双向单帧 64 MiB,服务端最多 512 个并发入站处理器,30 秒写入停滞看门狗。 |
| 23 | 大体积二进制数据从不进入帧,而是走下文的资源源。 |
| 24 | - 壳发出的每个请求都在独立 goroutine 上执行,与已退役的进程内壳的绑定调用一致。只有 |
| 25 | `desktop/event` 帧保证顺序,它们由服务端的单一队列写出。 |
| 26 | |
| 27 | ## 握手 |
| 28 | |
| 29 | 新连接上的第一个请求必须是 `desktop/hello`,否则以 `-32002 not_ready` 失败。 |
| 30 | |
| 31 | ```jsonc |
| 32 | // 壳 → 服务 |
| 33 | {"method":"desktop/hello","params":{ |
| 34 | "protocolVersion": 11, |
| 35 | "contractDigest": "sha256:…", // 壳包内嵌的契约摘要 |
| 36 | "build": {"version":"v1.30.0","channel":"stable","commit":"abc123"}, |
| 37 | "host": {"name":"electron","version":"44.2.0","chrome":"152.0.0","platform":"darwin","arch":"arm64"}, |
| 38 | "instance": {"home":"/Users/…/.reasonix","dev":false} |
| 39 | }} |
| 40 | // 服务 → 壳 |
| 41 | {"result":{ |
| 42 | "protocolVersion": 11, |
| 43 | "contractDigest": "sha256:…", |
| 44 | "service": {"version":"v1.30.0","channel":"stable","commit":"abc123","pid":4242}, |
| 45 | "runtimeGeneration": "g-01J…", // 每个服务进程唯一 |
| 46 | "instance": {"identityVersion":2,"identityDigest":"sha256:…","legacyId":"com.reasonix.desktop.…"}, |
| 47 | "runId": "…", "incidentId": "…", "diagnosticsEnabled": true, |
| 48 | "resources": {"origin":"http://127.0.0.1:51234","token":"…"}, |
| 49 | "window": {"width":1280,"height":820,"minWidth":760,"minHeight":480,"frameless":false,"zoomFactor":1} |
| 50 | }} |
| 51 | ``` |
| 52 | |
| 53 | `instance` 为跨版本兼容的可选字段。新服务会发布共享文件系统身份解析器生成的 |
| 54 | 版本化摘要以及旧实例 ID;壳只将这些不透明值用于诊断,不会把摘要当作文件路径。 |
| 55 | 旧壳会忽略该对象,新壳也接受对象缺失。 |
| 56 | |
| 57 | `window` 是 Go 根据保存状态和平台规则得到的主窗口初始几何。可选 |
| 58 | `position: {x, y}` 传递保存的原点(零坐标和负坐标均有效),缺省表示居中。 |
| 59 | 壳选择匹配的显示器,按其 DIP 可用区域校正矩形后隐藏创建窗口。Go 随后在 |
| 60 | `domReady` 中最大化并显示,不再覆盖壳校正后的位置。持久化始终采集普通状态 |
| 61 | 矩形,最大化标记单独保存;旧的超大矩形会按可用区域修正,不会将所有最大化 |
| 62 | 记录都重置为默认尺寸。 |
| 63 | 最小化期间,壳保留最后一次非最小化快照,避免原生普通矩形查询返回最大化外框。 |
| 64 | |
| 65 | 持久化 JSON 结构不变。旧壳忽略握手中可选的位置字段,新壳接受字段缺失。 |
| 66 | 壳与服务应配套发布:开发模式混用不同版本不能提供完整的恢复修复。降级可能 |
| 67 | 重新引入旧几何问题,旧版读取器也可能拒绝低于其原有校验下限的负坐标。 |
| 68 | |
| 69 | 失败码都是终止性的:壳显示真实错误,提供“打开日志”和“退出”,绝不回退到浏览器 mock。 |
| 70 | |
| 71 | | 代码 | 名称 | 含义 | |
| 72 | | --- | --- | --- | |
| 73 | | `-32001` | `protocol_mismatch` | `protocolVersion` 不一致 | |
| 74 | | `-32003` | `contract_mismatch` | 命令/事件摘要不一致(混装) | |
| 75 | | `-32004` | `build_mismatch` | 壳与服务版本不同且都不是 `dev` | |
| 76 | | `-32005` | `instance_mismatch` | 壳的规范数据目录与服务的不一致 | |
| 77 | | `-32002` | `not_ready` | hello 成功前的请求 | |
| 78 | |
| 79 | `runtimeGeneration` 标记该服务进程发出的每个事件、每个审批和浏览器授权。服务重启 |
| 80 | 后产生新的世代;壳丢弃任何旧世代标记的内容。 |
| 81 | |
| 82 | `runId` 标识本次 service 运行,`incidentId` 用于关联同一故障链中的 service 与 shell |
| 83 | 生命周期证据。两者都是随机诊断标识,不包含 PID、本地路径或用户内容。诊断关闭时, |
| 84 | `diagnosticsEnabled` 为 false,两个标识可以省略。 |
| 85 | |
| 86 | ## 生命周期请求(壳 → 服务) |
| 87 | |
| 88 | | 方法 | 参数 | 结果 | Go 负责者 | |
| 89 | | --- | --- | --- | --- | |
| 90 | | `desktop/start` | `{}` | `{}` | `App.startup` | |
| 91 | | `desktop/domReady` | `{}` | `{}` | `App.domReady` | |
| 92 | | `desktop/rendererAttached` | `{"rendererGeneration":n}` | `{}` | 前端心跳/就绪 | |
| 93 | | `desktop/beforeClose` | `{"reason":"window"\|"quit"\|"tray"\|"updater"}` | `{"prevent":bool}` | `App.beforeClose` | |
| 94 | | `desktop/shutdown` | `{"requestId":string,"reason":string}` | 退出阶段与结果 | 可重试的统一退出协调器 | |
| 95 | | `desktop/shutdownStatus` | `{"requestId":string}` | 同一退出阶段与结果 | 超时或结果未知后查询 | |
| 96 | | `desktop/hostEvent` | `{"name":string,"payload":any}` | `{}` | 第二实例、托盘打开/退出、菜单动作 | |
| 97 | | `desktop/browserControl` | `{"enabled":bool}` | `{}` | 内置浏览器开关,构建会话时读取 | |
| 98 | |
| 99 | 顺序:`hello` → `start` → 窗口加载 → `domReady` →(每次渲染进程挂载后 `rendererAttached`) |
| 100 | → … → `beforeClose` →(`shutdown` 完成 → 关闭 stdin 兜底 → 退出)。shutdown RPC |
| 101 | 超时只代表结果未知,壳会查询 `shutdownStatus`;可重试失败时保留窗口。stdin EOF |
| 102 | 进入同一个协调器并记录 `connection_lost`,已完成正常退出后不会再启动第二次收尾。 |
| 103 | |
| 104 | 壳在发送 shutdown RPC 前发布服务 `stopping` 阶段。该阶段公开 readiness 为 false,新的 |
| 105 | 业务调用会被拒绝,但 shutdown 与 shutdown-status 继续复用现有服务会话。正常退出会删除 |
| 106 | `diagnostics/lifecycle` 下当前运行对应的临时文件,因此退出后 lifecycle 目录为空属于预期行为; |
| 107 | 退出后的长期证据以轮转的 `logs/shell.log` 为准。详见 |
| 108 | [Windows 关闭与 transcript 诊断验收说明](WINDOWS_CLOSE_TRANSCRIPT_VALIDATION.zh-CN.md)。 |
| 109 | |
| 110 | ## 业务命令 |
| 111 | |
| 112 | ```jsonc |
| 113 | {"method":"desktop/invoke","params":{"method":"OpenProjectTab","args":["/path", true]}} |
| 114 | {"result": {...}} // 方法的 JSON 结果,void 为 null |
| 115 | {"error":{"code":-32000,"message":"<错误文本>","data":{"method":"OpenProjectTab"}}} |
| 116 | ``` |
| 117 | |
| 118 | `method` 必须是契约注册表接受的 Go `App` 导出方法。签名沿用已退役壳的规则:任意可 |
| 119 | JSON 序列化的参数,结果为 `()`、`(T)`、`(error)` 或 `(T, error)`。注册表在构建期拒绝 |
| 120 | 其他形态,因此接口面不可能出现壳无法调用的方法。壳在转发前按内嵌命令表校验 |
| 121 | `method`;未知名称以 `-32601` 失败。 |
| 122 | |
| 123 | 生成的契约(`cd desktop && go run . -emit-contract frontend/src/generated`)是唯一 |
| 124 | 事实来源:它输出 JSON 契约、摘要、TypeScript 命令表和渲染进程使用的 DTO 类型声明。 |
| 125 | 检入的输出漂移时桌面 Go 测试失败。 |
| 126 | |
| 127 | 每个命令还记录源码模块 `domain`、准确的 `owner`(例如 `App.OpenProjectTab`)、 |
| 128 | 仓库相对路径 `sources`、`scope` 和 `cancellation`,这些字段共同参与摘要。 |
| 129 | 生成器扫描所有平台声明并输出 `desktop/host_command_owners.generated.json`;宿主内嵌 |
| 130 | 该文件,要求每个反射命令都有匹配元数据。Scope 记录原方法的命名 wire `inputs` |
| 131 | (遗留无名参数使用 `argN`)和 `resolver`;无参数命令使用 `owner-state`,其余使用 |
| 132 | `owner-inputs`。这些字段描述来源和分派边界;输入校验、标签/会话选择及权限检查仍由 |
| 133 | 原有 App 方法负责。 |
| 134 | |
| 135 | 当前 App 命令声明 `before-dispatch`:宿主在解码前及真正分派前检查取消;同步写入 |
| 136 | 开始后,即使收到取消也保留原方法的结果,不承诺中断已分派的方法。宿主方法可通过 |
| 137 | 首个 Go 参数 `context.Context` 声明 `cooperative-context`;宿主注入请求 context, |
| 138 | 它不属于 JSON 参数或生成的 DTO。方法自身必须配合取消。业务 Stop/Cancel 命令继续 |
| 139 | 沿用已有 owner 和语义。 |
| 140 | |
| 141 | ## 事件(服务 → 壳 → 渲染进程) |
| 142 | |
| 143 | ```jsonc |
| 144 | {"method":"desktop/event","params":{"seq":1093,"generation":"g-01J…","name":"agent:event","args":[{...}]}} |
| 145 | ``` |
| 146 | |
| 147 | `args` 保留原事件桥的可变参数载荷,多数事件只有一个元素。壳把该帧经 |
| 148 | `reasonix:event` 通道转给渲染进程;preload 的 `on(name, cb)` 按 `name` 过滤并调用 |
| 149 | `cb(...args)`。序号在同一世代内严格递增,重新挂载的渲染进程可据此发现缺口并重新 |
| 150 | 快照,而不是信任陈旧状态。 |
| 151 | |
| 152 | 服务监督器和 preload 都拒绝重复、倒序帧;preload 还根据当前服务状态拒绝旧世代帧, |
| 153 | 并在 React 订阅前接入传输。世代变化、序号缺口或订阅期间遗漏会触发壳内事件 |
| 154 | `desktop:resync`(`generation`、`reason`、`expectedSeq`、`actualSeq`),它不属于 Go |
| 155 | 业务事件。运行状态通过 `SyncRuntimeState` 重读;已挂载控制器重读 `ListTabs`,复用 |
| 156 | 现有 `TurnEventsForTab` 日志与待审批提示恢复投影。异步读取受后续恢复请求、会话身份 |
| 157 | 和导航变更约束,不重放业务调用。服务重启后复用仍存活的应用渲染进程,保留未发送草稿。 |
| 158 | |
| 159 | 当前保证范围是核心运行状态、会话元数据、持久化轮次事件和待审批提示。终端虽有有界 |
| 160 | 输出快照,但没有原子输出游标,因此缺口后的终端会明确标记输出可能不完整,不将无法 |
| 161 | 确定边界的快照合并进实时输出。扩展输出、文件监听等独立事件流仍需各自的重新快照契约, |
| 162 | 不在上述核心恢复保证范围内。 |
| 163 | |
| 164 | ## 原生宿主调用(服务 → 壳) |
| 165 | |
| 166 | 这些调用替代 Go 中对壳工具包的直接调用。每一项对应 Go `nativeHost` 接口的一个方法; |
| 167 | Wails 实现已随 Electron 壳落地删除。 |
| 168 | |
| 169 | | 方法 | 参数 | 结果 | |
| 170 | | --- | --- | --- | |
| 171 | | `host/window.show` | `{"reason":string}` | `{}` | |
| 172 | | `host/window.hide` | `{}` | `{}` | |
| 173 | | `host/app.hide` | `{}` | `{}`(macOS 应用级隐藏) | |
| 174 | | `host/window.maximise` `unmaximise` `minimise` `unminimise` `toggleMaximise` `center` | `{}` | `{}` | |
| 175 | | `host/window.isMaximised` `isMinimised` | `{}` | `{"value":bool}` | |
| 176 | | `host/window.setPosition` | `{"x":n,"y":n}` | `{}` | |
| 177 | | `host/window.setTitle` | `{"title":string}` | `{}` | |
| 178 | | `host/screen.list` | `{}` | `{"screens":[{"x","y","width","height","scale","primary"}]}` | |
| 179 | | `host/dialog.openDirectory` | `{"title","defaultDirectory"}` | `{"path":string}`(`""` 表示取消) | |
| 180 | | `host/dialog.openFile` | `{"title","defaultDirectory","filters":[{"displayName","pattern"}],"multiple":bool}` | `{"paths":[]}` | |
| 181 | | `host/dialog.saveFile` | `{"title","defaultDirectory","defaultFilename","filters"}` | `{"path":string}` | |
| 182 | | `host/dialog.message` | `{"type":"info"\|"warning"\|"error"\|"question","title","message","buttons":[],"defaultButton","cancelButton"}` | `{"button":string}` | |
| 183 | | `host/shell.openExternal` | `{"url":string}` | `{}` | |
| 184 | | `host/app.quit` | `{}` | `{}` | |
| 185 | | `host/app.relaunch` | `{"args":[],"execPath"?:string}` | `{}` | |
| 186 | | `host/devtools.toggle` | `{}` | `{}` | |
| 187 | | `host/remoteWindow.open` | `{"hostKey","url","title"}` | `{"windowId":string}` | |
| 188 | | `host/remoteWindow.navigate` | `{"hostKey","url","title"}` | `{}` | |
| 189 | | `host/remoteWindow.focus` `close` | `{"hostKey"}` | `{}` | |
| 190 | | `host/tray.ensure` | `{"openTitle","openTooltip","quitTitle","quitTooltip","tooltip"}` | `{"ready":bool,"reason":string}` | |
| 191 | |
| 192 | `host/shell.openExternal` 只接受 `http:`、`https:` 和 `mailto:` URL。 |
| 193 | 包括 `file:`、`javascript:`、`data:` 在内的其他协议会在 Electron 宿主边界 |
| 194 | 被拒绝,不会交给系统打开器执行。 |
| 195 | | `host/tray.destroy` | `{}` | `{}` | |
| 196 | | `host/browser.grant` `revoke` | `{"grantId","tabId","sessionId"}` / `{"grantId"}` | `{}` | |
| 197 | | `host/browser.tabs.list` | `{"grantId"}` | `{"tabs":[{"id","url","title","loading","temporary"}]}` | |
| 198 | | `host/browser.tabs.open` | `{"grantId","url","temporary"}` | tab | |
| 199 | | `host/browser.tabs.navigate` | `{"grantId","tabId","url","action"}` | tab | |
| 200 | | `host/browser.tabs.close` | `{"grantId","tabId"}` | `{}` | |
| 201 | | `host/browser.snapshot` | `{"grantId","tabId","selector"}` | `{"documentToken","url","title","tree","refs"}` | |
| 202 | | `host/browser.act` | `{"grantId","operationId","tabId","documentToken","action","ref","text","keys","options","files","submit","deltaX","deltaY"}` | `{"executed","reason","documentToken"}` | |
| 203 | | `host/browser.screenshot` | `{"grantId","tabId","ref","fullPage","directory"}` | `{"path","mime","width","height"}` | |
| 204 | | `host/browser.downloads` | `{"grantId","tabId","waitForMs"}` | `{"downloads":[{"id","url","path","state","bytes"}]}` | |
| 205 | |
| 206 | 浏览器调用以 `-32010`(引用过期)、`-32011`(用户已接管该标签)或 `-32012`(没有当前授权) |
| 207 | 失败;Go 执行器把它们映射到内核哨兵错误,并把操作结果记入账本。授权的 `tabId` 是桌面 |
| 208 | 标签(任务);该授权下打开的浏览器标签归属于它。 |
| 209 | |
| 210 | 宿主事件(`desktop/hostEvent`):`tray.open`、`tray.quit`、`secondInstance`(`payload` 携带 |
| 211 | 原始 argv)、`menu.showWindow`、`remoteWindow.closed`(`{"hostKey"}`)、`browser.takeover` |
| 212 | (`{"tabId","epoch","reason"}`)。 |
| 213 | |
| 214 | 对话框结果从不暴露文件内容;它们只返回路径,再由 Go 经现有工作区和媒体检查授权。 |
| 215 | |
| 216 | ## 资源源 |
| 217 | |
| 218 | 服务在回环端口上监听,承载现有的授权资源处理器(`/__reasonix_workspace_media/…`、 |
| 219 | `/__reasonix_theme_asset/…`、远程 markdown 图片代理)。壳从受限的 `reasonix://app/` |
| 220 | scheme 提供打包界面,只把上述前缀转发到资源源,并在主进程中附加 |
| 221 | `Authorization: Bearer <token>`。token 从不到达渲染进程、网站视图、远程窗口或 |
| 222 | MCP App 框架。Go 保留今天的全部文件身份与 TTL 检查。 |
| 223 | |
| 224 | ## 渲染进程 preload 接口 |
| 225 | |
| 226 | 可信 preload 只暴露一个对象 `window.reasonixDesktop`: |
| 227 | |
| 228 | ```ts |
| 229 | interface ReasonixDesktopHost { |
| 230 | readonly kind: "electron"; |
| 231 | readonly contract: { protocolVersion: number; digest: string; commands: readonly string[] }; |
| 232 | readonly platform: { os: "darwin" | "windows" | "linux"; arch: string; versions: Record<string, string> }; |
| 233 | invoke(method: string, args: unknown[]): Promise<unknown>; |
| 234 | on(name: string, cb: (...args: unknown[]) => void): () => void; |
| 235 | native: { |
| 236 | openExternal(url: string): Promise<void>; |
| 237 | clipboard: { writeText(text: string): Promise<boolean>; readText(): Promise<string> }; |
| 238 | window: { |
| 239 | setTheme(theme: "system" | "light" | "dark"): void; |
| 240 | setBackgroundColour(r: number, g: number, b: number, a: number): void; |
| 241 | getBounds(): Promise<{ x: number; y: number; width: number; height: number; maximised: boolean }>; |
| 242 | isMaximised(): Promise<boolean>; |
| 243 | minimise(): void; toggleMaximise(): void; close(): void; |
| 244 | }; |
| 245 | getPathForFile(file: File): string; // 原生拖放路径 |
| 246 | onServiceState(cb: (state: ServiceState) => void): () => void; |
| 247 | browserControl: { // 内置浏览器的设置页 |
| 248 | get(): Promise<BrowserControlState | null>; |
| 249 | setEnabled(enabled: boolean): Promise<BrowserControlState>; |
| 250 | setIgnoreCertificateErrors(enabled: boolean): Promise<BrowserControlState>; |
| 251 | clearCache(): Promise<void>; // 保留 Cookie 与站点数据 |
| 252 | clearAllData(): Promise<void>; // Cookie、站点数据与缓存 |
| 253 | importChromeLogin(): Promise<ChromeImportOutcome>; |
| 254 | }; |
| 255 | }; |
| 256 | browser: { // 用户驱动的浏览器面板;Agent 调用经 Go |
| 257 | list(): Promise<BrowserTabView[]>; |
| 258 | open(url: string, opts?: { temporary?: boolean; taskId?: string }): Promise<BrowserTabView>; |
| 259 | close(tabId: string): Promise<void>; |
| 260 | activate(tabId: string | null): Promise<void>; |
| 261 | navigate(tabId: string, target: { url?: string; action?: "back" | "forward" | "reload" | "stop" }): Promise<void>; |
| 262 | setZoom(tabId: string, factor: number): Promise<void>; |
| 263 | toggleDevTools(tabId: string): Promise<void>; |
| 264 | resume(tabId: string): Promise<void>; // 把接管的标签交还给 Agent |
| 265 | setLayout(rect: { x: number; y: number; width: number; height: number } | null): void; |
| 266 | setOverlay(active: boolean): void; // 应用覆盖层隐藏所有网站视图 |
| 267 | onTabs(cb: (tabs: BrowserTabView[]) => void): () => void; |
| 268 | onDownload(cb: (download: BrowserDownloadView) => void): () => void; |
| 269 | }; |
| 270 | } |
| 271 | ``` |
| 272 | |
| 273 | `BrowserTabView` 为 `{ id, taskId, url, title, loading, canGoBack, canGoForward, |
| 274 | temporary, mode: "agent" | "human", epoch, zoom, error }`,`BrowserDownloadView` 为 |
| 275 | `{ id, tabId, url, filename, path, state, received, total }`。网站视图位于 |
| 276 | `persist:browser`(共享登录)或 `temp:<id>` 分区,永远不会获得应用 preload。 |
| 277 | |
| 278 | `ServiceState` 为 `{ phase: "starting" | "ready" | "restarting" | "failed" | "exited"; generation: string; error?: string }`。 |
| 279 | 业务组件只导入类型化 SDK,从不直接使用该对象;只有桥接适配层读取它。 |
| 280 | |
| 281 | `BrowserControlState` 为 `{ controlEnabled, ignoreCertificateErrors, writable, |
| 282 | warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null }`, |
| 283 | `ChromeImportOutcome` 为 `{ ok: true, profile, cookies, skipped }` 或 |
| 284 | `{ ok: false, reason }`,`reason` 取值 `chrome-missing`、`profile-not-found`、 |
| 285 | `cookies-unreadable`、`safe-storage-denied`、`safe-storage-unavailable`、 |
| 286 | `unsupported-platform`。 |
| 287 | |
| 288 | ## 安全边界 |
| 289 | |
| 290 | - 应用窗口:sandbox 开启,context isolation 开启,Node integration 关闭,只加载 |
| 291 | `reasonix://app`,使用上述 preload。 |
| 292 | - 网站视图、远程 Serve 窗口和 MCP App 框架:独立 session,没有应用 preload,不能访问 |
| 293 | `reasonix://`,不能触达 `host/*`。 |
| 294 | - IPC 处理器只接受来自应用窗口 `webContents` 的请求,其他发送者被拒绝并记录。 |
| 295 | - 内嵌契约之外的 `desktop/invoke` 名称在到达 Go 之前失败。 |
| 296 | |
| 297 | ## 性能诊断补充 |
| 298 | |
| 299 | 以下可选 native 接口仅允许可信应用主框架调用。旧 shell 可缺少这些接口; |
| 300 | 不涉及持久化用户数据格式变更或迁移。 |
| 301 | |
| 302 | - `processDiagnostics()` 返回 `{scope: "electron", samples, growth}`。 |
| 303 | 样本包含年龄、可空 CPU 区间、PID/类型/创建时间、可空 CPU 百分比、 |
| 304 | 工作集及私有内存(MiB)、截断标记。前台最多每 30 秒采集一次,后台每 60 秒一次; |
| 305 | 最多保留五分钟内的 12 条记录,每条最多 128 个进程。不采集标题、URL 或进程名称。 |
| 306 | 范围仅含 Electron 管理的进程,不含 Go 服务。 |
| 307 | - `captureRendererProfile(requestId?)` 通过 CDP 录制当前 renderer 五秒, |
| 308 | 请求的采样间隔为 10ms,返回状态、时长和最多八个应用脚本的自身耗时摘要。 |
| 309 | 普通页面不启用 JS Self-Profiling。最多一个进行中的采样,要求窗口在前台, |
| 310 | 冷却十分钟,每次启动 shell 最多尝试三次。不接管已有 debugger/DevTools。 |
| 311 | 失焦、隐藏、导航、renderer 退出或取消会停止采样。 |
| 312 | - `cancelRendererProfile(requestId)` 仅取消身份匹配的采样;忽略 renderer 不带身份的取消请求, |
| 313 | 防止长时间挂起后迟到的旧请求干扰新采样。每条 CDP 命令最多等待 1.5 秒, |
| 314 | 所有终态均释放自己的 debugger。分析在临时 Worker 中运行,老生代限制 32 MiB, |
| 315 | 超时 1.5 秒,输入最多 20,000 个节点及 100,000 个样本。 |
| 316 | 原始 profile 不进入 UI 报告。 |
| 317 | - `exportHeapSnapshot()` 先显示原生风险提示并要求用户确认,再选择保存路径, |
| 318 | 仅保存本地、不上传,也不接受 renderer 提供的路径。快照可能包含代码、聊天、 |
| 319 | 密钥,会暂停界面并可能占用较多磁盘。Electron 无法中断已开始的快照, |
| 320 | 因而忙碌状态保持到操作实际结束,不用超时伪装取消成功。 |
| 321 | |
| 322 | 内存增长信号要求 PID 与创建时间连续一致,至少五次读数跨越两分钟, |
| 323 | 最近三次读数均超过前两次的较高基线至少 256 MiB 且至少 50%。 |
| 324 | 全程可用时采用私有内存,否则使用工作集。这表示观察到持续增长, |
| 325 | 不代表确认泄漏,也不代表独占的物理内存。 |
| 326 | |
| 327 | 报告立即显示已有证据。进程补充最多等待 750ms;短时 CPU 采样结束后更新同一报告。 |
| 328 | 前端 12 秒后放弃采样补充并请求取消。这些是异步等待期限,不是同步工作可被抢占的保证。 |
| 329 | 迟到结果不会重建已经关闭的报告,采样结果明确标注为触发后的数据。 |
| 330 | 用户主动生成堆快照期间及结束后五秒内,暂停压力报警,避免诊断触发自身报警。 |
| 331 | |
| 332 | 在 `desktop/electron` 运行 `node scripts/performance-smoke.mjs`, |
| 333 | 可用隔离原生测试验证采样 owner、Worker、报告更新及本地堆快照。 |
| 334 | `node scripts/performance-benchmark.mjs` 分别以关闭监测、基础监测、短时采样运行 |
| 335 | 三次独立进程对照,记录可用的 CPU 时间、帧时序、工作集及指标采集耗时, |
| 336 | 各模式使用同一 renderer bundle,通过运行时开关选择;固定活动信号并关闭后台节流, |
| 337 | 用于无人值守比较成本。宿主事件测试独立覆盖生产焦点和导航取消策略, |
| 338 | 原生 smoke 验证实际 CDP 与 ASAR 路径。 |
| 339 | 输出至 `artifacts/performance/overhead.json`。该合成测试不等同于 Windows 用户场景复现; |
| 340 | 仍需对比刚启动、长时间使用、切回窗口和关闭标签等阶段。 |
| 341 |