Electron Stagewright docs

ADR-020: Launch-time native instrumentation

Status: Accepted (Playwright transport; opt-in; tray read + event invocation and startup-notification capture consumers, ADR-019)

Context

Some native surfaces an agent wants to assert are set up once at app startup and have no registry to read after the fact. The system Tray is the motivating case: Electron exposes no Tray.getAll(), a tray's tooltip / title / context menu are configured in app.whenReady(), and the app holds the only reference. The notification capture seam (ADR-019) works by patching Notification.prototype.show AFTER launch — fine for events that fire over time, but a hook armed after launch would miss a tray that was already created. To observe such t=0 native state, the instrumentation must be in place before the app's own main runs.

There is no clean post-launch hook for this. The repo's security posture also refuses runtime-altering env vars (NODE_OPTIONS), so a Node --require preload is not an option. The remaining path is to wrap the app's main entry.

Decision

1. An opt-in shim main, launched before the app's real main

LaunchOptions (and the electron_launch tool input) gains instrumentNative?: boolean, default off. It requires main/appPath; an executablePath-only launch has no main entry for Stagewright to wrap, so it is rejected. When set on the Playwright launch transport, the transport does not launch the app's real main directly. Instead it writes a generated shim main (a .cjs in a per-launch temp dir) and passes the shim as args[0]. The shim:

  1. synchronously runs the fixed, transport-owned hooks against require('electron'): TRAY_HOOK_BODY (a constructor wrapper plus setToolTip / setTitle / setImage / setContextMenu prototype patches and destroy cleanup) into a registry on globalThis.__stagewright_trayRegistry, and NOTIFICATION_HOOK_BODY (a Notification.prototype.show patch recording every shown notification into a bounded ring buffer on globalThis.__stagewright_notificationCapture), then
  2. import()s the app's real main — a dynamic import so a CommonJS or an ESM main loads identically.

Because (1) runs before (2), the real main's require('electron').Tray resolves to the wrapped Tray class even when the app destructures const { Tray } = require('electron') at import time (the prototype patches also catch a pre-wrap reference), and every Notification instance shares the patched .show() prototype. The notification hook lets the notification seam (ADR-019) catch startup (t=0) notifications an after-launch arm would miss; it is idempotent so a later arm adopts it rather than double-patching. The shim temp dir is removed when the session disposes (idempotent, best-effort).

2. Opt-in, runs no agent code, launch transport only

Rationale

Alternatives considered

Consequences

References