-
Notifications
You must be signed in to change notification settings - Fork 0
Electron Main Process & Window Lifecycle
src/main/index.ts is the desktop entry point. It is a composition root and a boot script at the same time: everything that must happen before Chromium is ready happens at module scope, and everything that depends on Electron being ready is constructed afterwards. Three files define this page's surface:
| File | Responsibility |
|---|---|
src/main/index.ts |
The Electron main entry: process-level startup decisions, privileged scheme registration, the CorePlatform implementation, construction of every main-process service, IPC handler registration, window creation, and app lifecycle wiring. |
src/main/window-metrics.ts |
Pure, Electron-free helpers that turn a display work area into window bounds and a renderer UI zoom factor. Tested by window-metrics.test.ts. |
src/core/platform.ts |
The CorePlatform seam (broadcast, userDataPath) that the core talks through. Electron implements it here; the Server Edition implements it over WebSockets; tests implement a stub. The core is forbidden from importing electron (enforced by no-electron.test.ts). |
Module-scope work in index.ts is order-sensitive, and the ordering constraints are documented in the source itself rather than in Electron's docs.
-
Settings first (L77–L84).
app.getPath('userData')is valid at module load, so app settings are read into a mutable boxappSettings = { current: loadAppSettings(...) }before anything else. This matters because theagentBrowserControlflag gates whether any browser debug surface is opened later — "off" means no debug endpoint exists, not merely an unadvertised one. The same box is what later settings saves mutate, sosyncAgentBrowserControl()/syncA2aServer()always read fresh values. -
Wayland → X11 respawn (L86–L113). Electron picks the browser process's ozone platform during native startup, before this JS runs.
app.commandLine.appendSwitch('ozone-platform', 'x11')would only move child processes, producing the "window created but never visible" mixed state. The only working fix is to put the flag on the process's real argv and re-spawn:const needsX11Respawn = process.platform === 'linux' && process.env.XDG_SESSION_TYPE === 'wayland' && !process.argv.some((a) => a.startsWith('--ozone-platform'))
The parent spawns
process.execPathwithprocess.argv.slice(1)plus--ozone-platform=x11, detached and with inherited stdio, thenprocess.exit(0)s immediately so no window or IPC is set up in the wrong-ozone instance. Recursion terminates because the child's argv already contains the flag.Only the ozone fix is forwarded. Chromium's raw
--remote-debugging-portis deliberately never opened — an audit (2026-09-06) found it exposed the main window's full preload bridge to any local process. -
Browser guest hardening (L115–L118).
installBrowserSecurity()runs before any<webview>guest can exist. -
Linux GPU fallback (L120–L130). On Linux the main process unconditionally appends
disable-gpu, because some hosts SIGSEGV in the GPU process at startup (exit_code=139) and a terminal-canvas UI needs no real GPU. Users can opt back in with--enable-gpufrom a launcher the app did not set. -
Privileged scheme (L132–L145).
protocol.registerSchemesAsPrivilegedregistersFILE_PROTOCOL(shared/file-url) withstandard,secure,supportFetchAPI,stream, andcorsEnabled. This must happen beforeapp.readyso<img src="termsprawl-file://…">is treated as a secure custom scheme instead of being blocked by the CSP.
flowchart TD
A["Module evaluation: src/main/index.ts"] --> B["loadAppSettings(app.getPath('userData'))"]
B --> C{"linux AND Wayland session AND no --ozone-platform in argv?"}
C -- yes --> D["spawn(self, argv + '--ozone-platform=x11', detached)"]
D --> E["process.exit(0)"]
C -- no --> F["installBrowserSecurity()"]
F --> G["linux: app.commandLine.appendSwitch('disable-gpu')"]
G --> H["protocol.registerSchemesAsPrivileged(FILE_PROTOCOL)"]
H --> I["Construct CorePlatform, then service graph at module scope"]
I --> J["Register IPC handlers (e.g. registerChatIpc)"]
J --> K["App ready -> createWindow()"]
K --> L["clampWindowBounds + desiredUiZoom from screen work area"]
L --> M["Renderer loads; platform.broadcast fans out to every BrowserWindow"]
The decision node is the load-bearing part: a single boolean evaluated from process.platform, XDG_SESSION_TYPE, and argv either terminates this process immediately or lets the rest of the boot proceed. Everything after node H relies on the privileged scheme already being registered.
src/core/platform.ts is deliberately tiny — two members, no Electron types:
-
broadcast(channel, payload)— push an event to the renderer (or server clients). -
userDataPath— the stable per-app data directory used for tmux config/sockets, scrollback, etc.
src/main/index.ts builds exactly one implementation at module scope (L155–L176) and passes it into every core service constructor (new PtyManager(platform), new WorkspaceStore(platform), and so on). This is the mechanism that keeps src/core Electron-free: core modules receive capability, they never reach for it.
broadcast is not a plain passthrough; it is a routing choke point with two prefix-based side effects before the renderer fan-out:
flowchart TD
S["Any service or PTY event"] --> B["platform.broadcast(channel, payload)"]
B --> C{"channel starts with 'pty:data:'?"}
C -- yes --> D["linkService.notePtyActivity(id) — debounced link scheduler"]
D --> E["Fan out to ptyRelayTaps[id] — relay host sees the same bytes"]
C -- no --> F{"channel starts with 'pty:exit:'?"}
E --> F
F -- yes --> G["agentToolsRuntime?.revoke(id)"]
F -- no --> H["for each BrowserWindow: webContents.send(channel, payload)"]
G --> H
Key consequences of this shape:
-
One data path for terminal bytes. Because every terminal byte already flows through
broadcastonpty:data:<id>, the relay host bridge (relayPtyHost.onData, L499–L534) subscribes viaptyRelayTapsinstead of adding a second subscription path to the PTY manager. The relay serves exactly what the local renderer sees. -
Lifecycle revocation is centralized. A
pty:exit:<id>broadcast revokes any agent-tool capability bound to that terminal, so teardown cannot be forgotten by an individual feature. -
Fan-out is "all windows".
BrowserWindow.getAllWindows()means any additional window is automatically a broadcast target; nothing has to register.
Construction is dependency-ordered and happens before app.ready. The main items observable in the read range:
| Service | Notes |
|---|---|
ptyManager, workspaceStore
|
First consumers of platform (L178–L179). |
LinkService |
Node-link scheduler and typed-edge persistence; receives closures over workspaceStore, ptyManager, and platform, plus sendToPeer implementing A2A peer delivery with an env-token override (TERMSPRAWL_A2A_PEER_TOKEN_<PEERID>) and reply injection back into the source node (chat node → chat:event:<id>, terminal node → bracketed paste write). |
createUpdateBridge |
Fed app.isPackaged, the persisted autoDownloadUpdates, and platform.broadcast. |
createCloudRuntime |
API base from settings, with a snapshot() accessor into the workspace store. |
HookServer |
Receives agent CLI lifecycle POSTs, broadcasts ${IPC.agentStatus}:<sessionId>, tracks previous status in prevStatus, and (fail-open) fires OS notifications on busy→idle while unfocused. |
ChatRuntime |
Provider resolution reads settings and lets env keys win (TERMSPRAWL_PROVIDER_KEY_<ID>), then stored keys; tools are scoped to the active project cwd via projectChatTools + resolveFileScope. IPC entry points registered by registerChatIpc() (chatSend / chatStop / chatApprove, each validating argument shapes before dispatch). |
| A2A endpoint | Opt-in (agentA2aServer, default off), loopback-only, token-gated. |
| Relay runtime | Opt-in; nothing dials the relay unless asked. |
The browser-control surface and the A2A server both follow the same pattern: a sync*() function reads the current setting and calls an idempotent start*() / stop*(). syncAgentBrowserControl() simply branches on appSettings.current.agentBrowserControl === true. The A2A path additionally guards against a race that a settings toggle can trivially produce:
-
startA2aEndpoint()incrementsa2aEpoch, awaitsstartA2aServer(...), and if the epoch changed while awaiting (i.e. the user toggled it off mid-start) closes the freshly created handle and returns without storing it. -
stopA2aEndpoint()incrementsa2aEpochfirst, then closes and nulls the handle.
This makes rapid off→on→off toggling converge: a start that loses the race never leaves a listening socket behind.
createWindow() lives in index.ts and is the only place a BrowserWindow is created; window-metrics.ts supplies the numbers. The module comment records the bug it was written for (0.13.4): a hardcoded 1440×900 window on a 1280×720 display opened larger than the screen, cutting off everything anchored to the canvas bottom — React Flow's zoom controls and the undo/redo history bar.
| Export | Contract |
|---|---|
FALLBACK_WORK_AREA |
{ x: 0, y: 0, width: 1440, height: 900 } used when no real display metrics are available. |
MIN_WINDOW_WIDTH / MIN_WINDOW_HEIGHT
|
800 / 600 — mirrors createWindow()'s minWidth/minHeight. |
clampWindowBounds(preferred, workArea) |
Shrinks the preferred size to fit inside the work area with an 8px FIT_MARGIN, then floors at the minimums. Returns preferred unchanged if the work area is missing or degenerate. |
desiredUiZoom(workArea) |
1 when the work area is at least 760px tall; otherwise (height − 8) / 760 clamped to [0.8, 1] and rounded to 2 decimals. |
Two subtleties worth keeping if this code is touched:
-
FIT_HEIGHT()reservesmin(FIT_MARGIN + 24, max(FIT_MARGIN, round(workArea.height * 0.02)))rather than a flat margin, on the theory that dock/panel bands scale with how much chrome the display carries. - The clamp is computed against the work area, but the floor is the absolute window minimum. On a display smaller than 800×600 the minimum wins and the window can still overflow — the intent is that short-but-normal displays get a fitting window, not that degenerate displays are handled.
desiredUiZoom is applied as renderer UI zoom rather than window scaling, so the fix is proportional to viewport height and does not change the BrowserWindow's own bounds.
-
index.tsis the sole owner ofapplifecycle:app.commandLine,app.getPath,app.isPackaged, and (below the read range)whenReady/ window-all-closed / activate wiring andcreateWindow(). Any module needing one of these should receive it as a value, not importelectronitself. -
Single-instance behavior. The read range shows one concrete duplicate-process path — the Wayland respawn, which deliberately exits the parent. The
userDataPathcontract ("stable per-app data directory (tmux config/sockets, scrollback, etc.)") is shared mutable on-disk state, so an application-level lock belongs to this module; its exact wiring sits outside the lines read here. -
Renderer loading. The renderer is loaded into the window created here, and
broadcasttargets every window'swebContents. Custom-scheme file previews depend on theregisterSchemesAsPrivilegedcall above; the preload bridge surface itself is owned by the sibling preload/IPC page, not by this module. - Failure policy is fail-open. The hook server comment is explicit that an agent keeps working even if the server never fires; A2A and Cdp start failures are caught and logged rather than aborting boot.
-
Adding a new settings-gated endpoint: copy the
sync*()pattern (syncAgentBrowserControl,syncA2aServer) — idempotent start/stop driven byappSettings.current, plus an epoch counter if the start is asynchronous. Do not start it unconditionally inwhenReady. -
Adding a new broadcast consumer: prefix matching in
platform.broadcastis the hook. If the consumer needs PTY bytes, add a tap toptyRelayTapsrather than subscribing to the PTY manager directly; if it needs teardown on session end, add apty:exit:branch. -
Changing window geometry or zoom: edit
window-metrics.tsand extendwindow-metrics.test.ts;index.tsshould only consume the pure functions. -
New core module needing the renderer: accept
CorePlatformin the constructor. Importingelectronfromsrc/coreis a build-level violation, not a style preference.
Sources: src/main/index.ts, src/main/index.ts, src/main/index.ts, src/main/index.ts, src/main/window-metrics.ts, src/core/platform.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance