返回 DeepSeek-Reasonix
DESKTOP_HOST_PROTOCOL.md
根目录 / docs / DESKTOP_HOST_PROTOCOL.md
1 # Desktop host protocol
2
3 [简体中文](DESKTOP_HOST_PROTOCOL.zh-CN.md)
4
5 The Electron shell and the Go desktop service are two processes joined by one
6 private, versioned JSON-RPC 2.0 connection over the service's stdio. This
7 document is the contract both sides implement. The Go side owns every desktop
8 business command; the Electron side owns every native surface. Neither side
9 may reach around the contract: the React UI never touches Electron or Go
10 globals, and Go business code never links a shell toolkit.
11
12 ```text
13 React renderer ──typed IPC (preload)──▶ Electron main ──stdio JSON-RPC──▶ Go desktop service
14 ▲ │
15 └────── host/* reverse requests ────┘
16 ```
17
18 ## Transport
19
20 - Framing: newline-delimited JSON-RPC 2.0 (`rpcwire` strict mode). One frame
21 per line, UTF-8, no batch arrays.
22 - The Go service is started as `reasonix-desktop --host-rpc`. Its stdout carries
23 only protocol frames; stderr carries logs. The shell closes stdin only after
24 `desktop/shutdown` has reported `completed`; closing stdin without that result
25 is treated as `connection_lost` and runs bounded cleanup.
26 - Limits: 64 MiB per inbound frame on both sides, 512 concurrent inbound
27 handlers on the service, 30 s write-stall watchdog. Large binary payloads never
28 travel in frames; they use the resource origin below.
29 - Every request the shell makes runs on its own goroutine, exactly as the
30 retired in-process shell
31 bound calls did. Ordering is only guaranteed for `desktop/event` frames,
32 which the service writes from one queue.
33
34 ## Handshake
35
36 The first request on a fresh connection must be `desktop/hello`. Anything else
37 fails with `-32002 not_ready`.
38
39 ```jsonc
40 // shell → service
41 {"method":"desktop/hello","params":{
42 "protocolVersion": 11,
43 "contractDigest": "sha256:…", // digest embedded in the shell bundle
44 "build": {"version":"v1.30.0","channel":"stable","commit":"abc123"},
45 "host": {"name":"electron","version":"44.2.0","chrome":"152.0.0","platform":"darwin","arch":"arm64"},
46 "instance": {"home":"/Users/…/.reasonix","dev":false}
47 }}
48 // service → shell
49 {"result":{
50 "protocolVersion": 11,
51 "contractDigest": "sha256:…",
52 "service": {"version":"v1.30.0","channel":"stable","commit":"abc123","pid":4242},
53 "runtimeGeneration": "g-01J…", // new for every service process
54 "instance": {"identityVersion":2,"identityDigest":"sha256:…","legacyId":"com.reasonix.desktop.…"},
55 "runId": "…", "incidentId": "…", "diagnosticsEnabled": true,
56 "resources": {"origin":"http://127.0.0.1:51234","token":"…"},
57 "window": {"width":1280,"height":820,"minWidth":760,"minHeight":480,"frameless":false,"zoomFactor":1}
58 }}
59 ```
60
61 `instance` is optional for cross-version compatibility. New services publish a
62 versioned digest from the shared filesystem identity resolver plus the legacy
63 instance ID. The shell consumes these opaque values for diagnostics and never
64 uses the digest as a filesystem path. Older shells ignore the object and newer
65 shells accept its omission.
66
67 `window` is the initial main-window geometry Go derives from the saved state
68 and platform rules. Optional `position: {x, y}` carries the saved origin (zero
69 and negative coordinates are valid); omission requests centering. The shell
70 selects the matching display and fits the rectangle to its DIP work area before
71 creating the hidden window. Go later maximises and shows it from `domReady`,
72 without overriding the shell's corrected position. Persistence always captures
73 the normal-state rectangle, separately from the maximised flag; legacy oversized
74 rectangles are fitted rather than resetting every maximised entry to defaults.
75 While minimized, the shell retains its last non-minimized snapshot because
76 native normal-bounds queries can otherwise expose the maximized frame.
77
78 The persisted JSON shape is unchanged. Older shells ignore the optional hello
79 position; newer shells accept its omission. Ship shell and service together:
80 mixed development builds do not provide the complete restore fix. Downgrading
81 can reintroduce the old geometry bug, and older readers may reject negative
82 origins below their previous validation floor.
83
84 Failure codes are terminal: the shell shows the real error and offers
85 "open logs" and "quit". It never falls back to the browser mock.
86
87 | Code | Name | Meaning |
88 | --- | --- | --- |
89 | `-32001` | `protocol_mismatch` | `protocolVersion` differs |
90 | `-32003` | `contract_mismatch` | command/event digest differs (mixed install) |
91 | `-32004` | `build_mismatch` | shell and service versions differ and neither is `dev` |
92 | `-32005` | `instance_mismatch` | the shell's canonical data home differs from the service's |
93 | `-32002` | `not_ready` | request before a successful hello |
94
95 `runtimeGeneration` tags every event and every approval or browser grant
96 minted by this service process. A restarted service issues a new generation;
97 the shell discards anything tagged with an old one.
98
99 `runId` identifies this service run. `incidentId` links service and shell
100 lifecycle evidence for the same failure chain. Both are random diagnostic
101 identifiers; they do not contain a PID, local path, or user content. When
102 diagnostics are disabled (including a `dev` service build), `diagnosticsEnabled`
103 is false and both identifiers are empty strings; the keys are always present.
104
105 ## Lifecycle requests (shell → service)
106
107 | Method | Params | Result | Go owner |
108 | --- | --- | --- | --- |
109 | `desktop/start` | `{}` | `{}` | `App.startup` |
110 | `desktop/domReady` | `{}` | `{}` | `App.domReady` |
111 | `desktop/rendererAttached` | `{"rendererGeneration":n}` | `{}` | frontend heartbeat/readiness |
112 | `desktop/beforeClose` | `{"reason":"window"\|"quit"\|"tray"\|"updater"}` | `{"prevent":bool}` | `App.beforeClose` |
113 | `desktop/shutdown` | `{"requestId":string,"reason":string}` | shutdown phase/result | coordinated, retryable shutdown |
114 | `desktop/shutdownStatus` | `{"requestId":string}` | same shutdown phase/result | query after timeout/unknown result |
115 | `desktop/hostEvent` | `{"name":string,"payload":any}` | `{}` | second instance, tray open/quit, menu actions |
116 | `desktop/browserControl` | `{"enabled":bool}` | `{}` | built-in browser switch, read when a session is built |
117
118 Order: `hello` → `start` → window load → `domReady` → (`rendererAttached` after
119 each renderer mount) → … → `beforeClose` → (`shutdown` completed → stdin close
120 fallback → exit). A shutdown RPC timeout is an unknown result: the shell queries
121 `shutdownStatus` and keeps the window open on a retryable failure. An abrupt
122 stdin EOF enters the same coordinator with reason `connection_lost`; it does
123 not create a second cleanup flow after a completed shutdown.
124
125 The shell publishes a `stopping` service phase before the shutdown RPC. During
126 that phase readiness is false and new business calls are rejected, while the
127 shutdown and shutdown-status requests retain the existing service session.
128 Clean exit removes the current temporary file under
129 `diagnostics/lifecycle`; an empty lifecycle directory after exit is expected.
130 Rotating `logs/shell.log` is the durable post-exit record. See
131 [Windows close and transcript diagnostics validation](WINDOWS_CLOSE_TRANSCRIPT_VALIDATION.md).
132
133 ## Business commands
134
135 ```jsonc
136 {"method":"desktop/invoke","params":{"method":"OpenProjectTab","args":["/path", true]}}
137 {"result": {...}} // the method's JSON result, null for void
138 {"error":{"code":-32000,"message":"<error text>","data":{"method":"OpenProjectTab"}}}
139 ```
140
141 `method` must name an exported method of the Go `App` value that the contract
142 registry accepted. Signatures follow the rules the retired shell used: any
143 JSON-serialisable
144 parameters and a result of `()`, `(T)`, `(error)` or `(T, error)`. The
145 registry rejects anything else at build time, so the surface can never gain a
146 method the shell cannot call. The shell validates `method` against the
147 embedded command list before forwarding. Unknown names fail with `-32601`.
148
149 The generated contract (`cd desktop && go run . -emit-contract frontend/src/generated`)
150 is the single source of truth: it emits the JSON contract, its digest, the
151 TypeScript command table and the DTO type declarations consumed by the
152 renderer. A desktop Go test fails when the checked-in output drifts.
153
154 Each command also records its source-module `domain`, exact `owner` (for
155 example `App.OpenProjectTab`), repository-relative `sources`, `scope` and
156 `cancellation`; these fields are included in the digest. The generator scans
157 all platform declarations and writes `desktop/host_command_owners.generated.json`,
158 which the host embeds and validates against every reflected command. Scope
159 records the owner's named wire `inputs` (`argN` for unnamed legacy parameters)
160 and `resolver`; zero-input commands use `owner-state`, others `owner-inputs`.
161 These are provenance and dispatch boundaries. Input validation, tab/session
162 selection and access checks remain in the existing App method.
163
164 Current App commands declare `before-dispatch`: the host checks cancellation
165 before decoding and immediately before dispatch, then preserves the method's
166 result even if cancellation arrives during a synchronous write. They do not
167 promise interruption after dispatch. A host method may opt into
168 `cooperative-context` with a leading Go `context.Context`; the host injects
169 the request context and excludes it from JSON arguments and generated DTOs.
170 The method must cooperate with cancellation. Business Stop/Cancel commands
171 continue to use their existing owners and semantics.
172
173 ## Events (service → shell → renderer)
174
175 ```jsonc
176 {"method":"desktop/event","params":{"seq":1093,"generation":"g-01J…","name":"agent:event","args":[{...}]}}
177 ```
178
179 `args` preserves the variadic payload of the previous event bridge; most
180 events carry one element. The shell forwards the frame to the renderer on the
181 `reasonix:event` channel; the preload API `on(name, cb)` filters by `name` and
182 calls `cb(...args)`. Sequence numbers are strictly increasing per generation
183 so a renderer that re-attaches can detect a gap and re-snapshot instead of
184 trusting stale state.
185
186 Both the service supervisor and preload reject duplicate or out-of-order
187 frames; the preload also rejects old generations using the current service
188 state. It binds the transport before React subscribes. A generation change,
189 sequence gap or missed subscription raises the shell-local `desktop:resync`
190 event (`generation`, `reason`, `expectedSeq`, `actualSeq`), which is not a Go
191 business event. Runtime state is re-read through `SyncRuntimeState`; mounted
192 controllers re-read `ListTabs` and use the existing `TurnEventsForTab` ledger
193 and pending-prompt presentation to repair their projection. Reads are fenced
194 against newer recovery requests and session/navigation changes. No business
195 mutation is replayed, and a surviving application renderer is reattached
196 after a service restart without reloading its unsent drafts.
197
198 This recovery currently covers core runtime state, session metadata, durable
199 turn events and pending prompts. Terminal output has a bounded snapshot but
200 no atomic output cursor, so an affected terminal is visibly marked incomplete
201 instead of merging an ambiguous snapshot into live output. Extension output,
202 file-watch and other independent event streams still need capability-specific
203 resnapshot contracts; they are not covered by this core recovery guarantee.
204
205 ## Native host calls (service → shell)
206
207 These replace direct shell-toolkit calls in Go. Each maps to one method of the
208 Go `nativeHost` interface; the Wails implementation was retired when the Electron shell
209 landed.
210
211 | Method | Params | Result |
212 | --- | --- | --- |
213 | `host/window.show` | `{"reason":string}` | `{}` |
214 | `host/window.hide` | `{}` | `{}` |
215 | `host/app.hide` | `{}` | `{}` (macOS application hide) |
216 | `host/window.maximise` `unmaximise` `minimise` `unminimise` `toggleMaximise` `center` | `{}` | `{}` |
217 | `host/window.isMaximised` `isMinimised` | `{}` | `{"value":bool}` |
218 | `host/window.setPosition` | `{"x":n,"y":n}` | `{}` |
219 | `host/window.setTitle` | `{"title":string}` | `{}` |
220 | `host/screen.list` | `{}` | `{"screens":[{"x","y","width","height","scale","primary"}]}` |
221 | `host/dialog.openDirectory` | `{"title","defaultDirectory"}` | `{"path":string}` (`""` = cancelled) |
222 | `host/dialog.openFile` | `{"title","defaultDirectory","filters":[{"displayName","pattern"}],"multiple":bool}` | `{"paths":[]}` |
223 | `host/dialog.saveFile` | `{"title","defaultDirectory","defaultFilename","filters"}` | `{"path":string}` |
224 | `host/dialog.message` | `{"type":"info"\|"warning"\|"error"\|"question","title","message","buttons":[],"defaultButton","cancelButton"}` | `{"button":string}` |
225 | `host/shell.openExternal` | `{"url":string}` | `{}` |
226 | `host/app.quit` | `{}` | `{}` |
227 | `host/app.relaunch` | `{"args":[],"execPath"?:string}` | `{}` |
228 | `host/devtools.toggle` | `{}` | `{}` |
229 | `host/remoteWindow.open` | `{"hostKey","url","title"}` | `{"windowId":string}` |
230 | `host/remoteWindow.navigate` | `{"hostKey","url","title"}` | `{}` |
231 | `host/remoteWindow.focus` `close` | `{"hostKey"}` | `{}` |
232 | `host/tray.ensure` | `{"openTitle","openTooltip","quitTitle","quitTooltip","tooltip"}` | `{"ready":bool,"reason":string}` |
233
234 `host/shell.openExternal` accepts only `http:`, `https:`, and `mailto:` URLs.
235 Other schemes, including `file:`, `javascript:`, and `data:`, are rejected at
236 the Electron host boundary before the system opener is invoked.
237 | `host/tray.destroy` | `{}` | `{}` |
238 | `host/browser.grant` `revoke` | `{"grantId","tabId","sessionId"}` / `{"grantId"}` | `{}` |
239 | `host/browser.tabs.list` | `{"grantId"}` | `{"tabs":[{"id","url","title","loading","temporary"}]}` |
240 | `host/browser.tabs.open` | `{"grantId","url","temporary"}` | tab |
241 | `host/browser.tabs.navigate` | `{"grantId","tabId","url","action"}` | tab |
242 | `host/browser.tabs.close` | `{"grantId","tabId"}` | `{}` |
243 | `host/browser.snapshot` | `{"grantId","tabId","selector"}` | `{"documentToken","url","title","tree","refs"}` |
244 | `host/browser.act` | `{"grantId","operationId","tabId","documentToken","action","ref","text","keys","options","files","submit","deltaX","deltaY"}` | `{"executed","reason","documentToken"}` |
245 | `host/browser.screenshot` | `{"grantId","tabId","ref","fullPage","directory"}` | `{"path","mime","width","height"}` |
246 | `host/browser.downloads` | `{"grantId","tabId","waitForMs"}` | `{"downloads":[{"id","url","path","state","bytes"}]}` |
247
248 Browser calls fail with `-32010` (stale reference), `-32011` (the user took the
249 tab over) or `-32012` (no current grant); the Go executor maps them onto the
250 kernel sentinels and records the operation outcome in its ledger. Grant
251 `tabId` is the desktop tab (the task); browser tabs opened under that grant
252 belong to it.
253
254 Host events (`desktop/hostEvent`): `tray.open`, `tray.quit`, `secondInstance`
255 (raw argv in `payload`), `menu.showWindow`, `remoteWindow.closed`
256 (`{"hostKey"}`), `browser.takeover` (`{"tabId","epoch","reason"}`).
257
258 Dialog results never expose file contents; they return paths that Go then
259 authorises through the existing workspace and media checks.
260
261 ## Resource origin
262
263 The service listens on a loopback port for the existing authorised asset
264 handlers (`/__reasonix_workspace_media/…`, `/__reasonix_theme_asset/…`, the
265 remote markdown image proxy). The shell serves the packaged UI from the
266 privileged `reasonix://app/` scheme and forwards only those prefixes to the
267 resource origin, adding `Authorization: Bearer <token>` in the main process.
268 The token never reaches the renderer, a website view, a remote window or an
269 MCP App frame. Go keeps every file-identity and TTL check it has today.
270
271 ## Renderer preload API
272
273 The trusted preload exposes exactly one object, `window.reasonixDesktop`:
274
275 ```ts
276 interface ReasonixDesktopHost {
277 readonly kind: "electron";
278 readonly contract: { protocolVersion: number; digest: string; commands: readonly string[] };
279 readonly platform: { os: "darwin" | "windows" | "linux"; arch: string; versions: Record<string, string> };
280 invoke(method: string, args: unknown[]): Promise<unknown>;
281 on(name: string, cb: (...args: unknown[]) => void): () => void;
282 native: {
283 openExternal(url: string): Promise<void>;
284 clipboard: { writeText(text: string): Promise<boolean>; readText(): Promise<string> };
285 window: {
286 setTheme(theme: "system" | "light" | "dark"): void;
287 setBackgroundColour(r: number, g: number, b: number, a: number): void;
288 getBounds(): Promise<{ x: number; y: number; width: number; height: number; maximised: boolean }>;
289 isMaximised(): Promise<boolean>;
290 minimise(): void; toggleMaximise(): void; close(): void;
291 };
292 getPathForFile(file: File): string; // native drop paths
293 onServiceState(cb: (state: ServiceState) => void): () => void;
294 browserControl: { // settings page for the built-in browser
295 get(): Promise<BrowserControlState | null>;
296 setEnabled(enabled: boolean): Promise<BrowserControlState>;
297 setIgnoreCertificateErrors(enabled: boolean): Promise<BrowserControlState>;
298 clearCache(): Promise<void>; // keeps cookies and site data
299 clearAllData(): Promise<void>; // cookies, site data and cache
300 importChromeLogin(): Promise<ChromeImportOutcome>;
301 };
302 };
303 browser: { // user-driven browser panel; agent calls go through Go
304 list(): Promise<BrowserTabView[]>;
305 open(url: string, opts?: { temporary?: boolean; taskId?: string }): Promise<BrowserTabView>;
306 close(tabId: string): Promise<void>;
307 activate(tabId: string | null): Promise<void>;
308 navigate(tabId: string, target: { url?: string; action?: "back" | "forward" | "reload" | "stop" }): Promise<void>;
309 setZoom(tabId: string, factor: number): Promise<void>;
310 toggleDevTools(tabId: string): Promise<void>;
311 resume(tabId: string): Promise<void>; // hand a taken-over tab back to the agent
312 setLayout(rect: { x: number; y: number; width: number; height: number } | null): void;
313 setOverlay(active: boolean): void; // app overlays hide every website view
314 onTabs(cb: (tabs: BrowserTabView[]) => void): () => void;
315 onDownload(cb: (download: BrowserDownloadView) => void): () => void;
316 };
317 }
318 ```
319
320 `BrowserTabView` is `{ id, taskId, url, title, loading, canGoBack, canGoForward,
321 temporary, mode: "agent" | "human", epoch, zoom, error }` and
322 `BrowserDownloadView` is `{ id, tabId, url, filename, path, state, received,
323 total }`. Website views live in `persist:browser` (shared logins) or
324 `temp:<id>` partitions and never receive the application preload.
325
326 `ServiceState` is `{ phase: "starting" | "ready" | "restarting" | "failed" | "exited"; generation: string; error?: string }`.
327 Business components import the typed SDK, never this object; only the bridge
328 adapter reads it.
329
330 `BrowserControlState` is `{ controlEnabled, ignoreCertificateErrors, writable,
331 warning: "invalid-config" | "unreadable-config" | "unsupported-version" | null }`
332 and `ChromeImportOutcome` is either `{ ok: true, profile, cookies, skipped }` or
333 `{ ok: false, reason }` with `reason` one of `chrome-missing`,
334 `profile-not-found`, `cookies-unreadable`, `safe-storage-denied`,
335 `safe-storage-unavailable`, `unsupported-platform`.
336
337 ## Performance diagnostics
338
339 The optional native calls below are restricted to the trusted app main frame.
340 Older shells may omit them. No persisted user-data format changes or migrations
341 are required.
342
343 - `processDiagnostics()` returns `{scope: "electron", samples, growth}`.
344 Samples contain age, nullable CPU interval, process PID/type/creation time,
345 nullable CPU percentage, working set and private memory in MiB, and a
346 truncation flag. Sampling is limited to once per 30 seconds in the foreground
347 and once per 60 seconds otherwise. Retention is at most 12 snapshots and five
348 minutes, with at most 128 processes per snapshot. No titles, URLs or process
349 names are collected. Electron-managed processes only; Go is excluded.
350 - `captureRendererProfile(requestId?)` records the current renderer through CDP for
351 five seconds at a requested 10 ms sample interval. It returns a status,
352 duration and at most eight app-script self-time summaries. Normal documents
353 do not enable JS self-profiling. Capture is single-flight, requires the
354 foreground window, observes a ten-minute cooldown, and allows at most three
355 attempts per shell lifetime. Existing debugger/DevTools sessions are not
356 taken over. Blur, hide, navigation, renderer loss or cancellation stops it.
357 - `cancelRendererProfile(requestId)` cancels only the matching capture; unscoped
358 renderer cancellation is ignored. This also fences delayed requests across
359 long suspension/resume gaps. Each CDP command has a
360 1.5 second deadline and the owned debugger is released on every terminal path.
361 Analysis runs in a disposable Worker with a 32 MiB old-generation limit,
362 1.5 second deadline, and input limits of 20,000 nodes / 100,000 samples.
363 Raw profiles never enter the UI report.
364 - `exportHeapSnapshot()` requires a user-confirmed native warning and save
365 dialog. It saves locally without uploading, and accepts no renderer-supplied
366 path. Snapshots may contain code, chats and secrets and can pause the renderer
367 or use substantial disk space. Electron cannot preempt a snapshot: its busy
368 lease remains held until the actual operation settles.
369
370 A memory growth signal requires a continuous PID plus creation-time identity,
371 at least five readings spanning two minutes, and three recent readings exceeding
372 the initial two-reading baseline by both 256 MiB and 50%. Private memory is used
373 when available throughout; otherwise working set is used. This is an observation
374 of sustained growth, not proof of a leak or exclusive physical RAM ownership.
375
376 Reports appear immediately. Process enrichment waits at most 750 ms; a bounded
377 CPU capture can update the same report later. The UI abandons capture enrichment
378 after 12 seconds and requests cancellation. These are asynchronous deadlines,
379 not preemptive limits on synchronous work. Dismissed reports never reappear.
380 The report distinguishes post-trigger samples from the already-ended long task.
381 User-requested heap capture suppresses pressure alerts during capture and for
382 the normal five-second settling grace afterward.
383
384 From `desktop/electron`, run `node scripts/performance-smoke.mjs` to verify the
385 production owner, Worker, report enrichment and local heap snapshot with an
386 isolated native fixture. `node scripts/performance-benchmark.mjs` compares off,
387 lightweight monitoring and short capture in three fresh-process trials each.
388 All modes use the same renderer bundle and runtime mode selection. Activity
389 signals are pinned and background throttling disabled for unattended native
390 measurement. Host event tests separately cover the production focus and
391 navigation cancellation policy; the smoke verifies actual CDP and ASAR paths.
392 It records CPU time where available, frame timings, working sets and metric
393 collection cost in `artifacts/performance/overhead.json`. This synthetic
394 benchmark is not a reproduction of the Windows user workload. Field comparison
395 must still cover startup, extended use, foreground return and closing tabs.
396
397 ## Security boundaries
398
399 - The application window: sandbox on, context isolation on, Node integration
400 off, `reasonix://app` only, preload above.
401 - Website views, remote Serve windows and MCP App frames: separate sessions,
402 no preload from the application, no `reasonix://` access, no `host/*` reach.
403 - IPC handlers accept requests only from the application window's
404 `webContents`. Any other sender is rejected and logged.
405 - `desktop/invoke` names outside the embedded contract fail before reaching Go.
406
406 lines MARKDOWN