Skip to content

Electron Main Process & Window Lifecycle

dazeb edited this page Sep 17, 2026 · 2 revisions

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).

Boot ordering

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.

  1. Settings first (L77–L84). app.getPath('userData') is valid at module load, so app settings are read into a mutable box appSettings = { current: loadAppSettings(...) } before anything else. This matters because the agentBrowserControl flag 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, so syncAgentBrowserControl() / syncA2aServer() always read fresh values.

  2. 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.execPath with process.argv.slice(1) plus --ozone-platform=x11, detached and with inherited stdio, then process.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-port is deliberately never opened — an audit (2026-09-06) found it exposed the main window's full preload bridge to any local process.

  3. Browser guest hardening (L115–L118). installBrowserSecurity() runs before any <webview> guest can exist.

  4. 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-gpu from a launcher the app did not set.

  5. Privileged scheme (L132–L145). protocol.registerSchemesAsPrivileged registers FILE_PROTOCOL (shared/file-url) with standard, secure, supportFetchAPI, stream, and corsEnabled. This must happen before app.ready so <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"]
Loading

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.

The CorePlatform seam

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
Loading

Key consequences of this shape:

  • One data path for terminal bytes. Because every terminal byte already flows through broadcast on pty:data:<id>, the relay host bridge (relayPtyHost.onData, L499–L534) subscribes via ptyRelayTaps instead 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.

Service graph built at module scope

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.

Settings-gated endpoints and the epoch guard

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() increments a2aEpoch, awaits startA2aServer(...), 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() increments a2aEpoch first, 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.

Window creation and metrics

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() reserves min(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.

Lifecycle and boundaries

  • index.ts is the sole owner of app lifecycle: app.commandLine, app.getPath, app.isPackaged, and (below the read range) whenReady / window-all-closed / activate wiring and createWindow(). Any module needing one of these should receive it as a value, not import electron itself.
  • Single-instance behavior. The read range shows one concrete duplicate-process path — the Wayland respawn, which deliberately exits the parent. The userDataPath contract ("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 broadcast targets every window's webContents. Custom-scheme file previews depend on the registerSchemesAsPrivileged call 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.

Extension points

  • Adding a new settings-gated endpoint: copy the sync*() pattern (syncAgentBrowserControl, syncA2aServer) — idempotent start/stop driven by appSettings.current, plus an epoch counter if the start is asynchronous. Do not start it unconditionally in whenReady.
  • Adding a new broadcast consumer: prefix matching in platform.broadcast is the hook. If the consumer needs PTY bytes, add a tap to ptyRelayTaps rather than subscribing to the PTY manager directly; if it needs teardown on session end, add a pty:exit: branch.
  • Changing window geometry or zoom: edit window-metrics.ts and extend window-metrics.test.ts; index.ts should only consume the pure functions.
  • New core module needing the renderer: accept CorePlatform in the constructor. Importing electron from src/core is 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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally