Electron Stagewrightdocs

ADR-022: Renderer surface targeting

Context

Electron applications do not have one renderer per native window. A BrowserWindow can contain same-origin or cross-origin frames; a <webview> owns a separate guest WebContents; and a WebContentsView is composed by the main process rather than the DOM. Treating every renderer as the active BrowserWindow causes three agent-facing failures:

  1. a snapshot can be taken from one renderer and its ref can silently act in another;
  2. a hidden or embedded renderer cannot be selected deliberately; and
  3. the tool response cannot say which surface emitted an observable event.

This project already has explicit active-window selection. The next layer must generalize that selection without invalidating the current window tools or claiming that every transport can drive every Electron primitive.

An implementation probe against the supported Electron and Playwright versions established that Playwright exposes WebContentsView and <webview> guests as distinct Page objects through ElectronApplication.windows(), and exposes iframe trees through Page.frames(). Electron main process metadata remains necessary to distinguish a BrowserWindow, a WebContentsView, and a webview guest reliably.

Decision

1. Model renderer targets as a hierarchy of surfaces

The transport contract gains a renderer-surface concept with opaque session-lifetime identifiers. The public descriptor is intentionally compact:

type SurfaceKind = 'window' | 'webcontents_view' | 'webview' | 'frame' | 'other'

interface SurfaceDescriptor {
  readonly id: string
  readonly kind: SurfaceKind
  readonly parentId?: string
  readonly title?: string
  readonly url?: string
  readonly active: boolean
  readonly capabilities: {
    readonly snapshot: boolean
    readonly interaction: boolean
    readonly rendererEval: boolean
  }
  readonly originRelation?: 'same-origin' | 'cross-origin' | 'opaque'
}

The list is parent-first and flat rather than a recursively nested response. That keeps response size bounded, permits direct selection by id, and lets an agent reconstruct the hierarchy when it needs to. IDs are generated by the transport, never derived from a URL, DOM selector, array index, or Electron process id. A live page or frame retains its id across ordinary navigation; an id is never reused during a session.

window and webcontents_view are page roots. A webview guest is a page root with a parent found through Electron's hostWebContents; an iframe is a child of its containing page or frame. A transport must label an unclassifiable renderer as other, not guess its ownership or capability.

2. Add explicit discovery and selection, preserving window compatibility

electron_surfaces_list returns the descriptors and active_surface_id. electron_switch_surface({ surfaceId }) selects one live, supported surface for implicit renderer operations. It returns the selected descriptor.

electron_windows_list and electron_switch_window remain compatible window-oriented operations in this change. They continue to select a page root, while electron_switch_surface is the recommended operation whenever an agent is working with a frame, webview, or WebContentsView. No existing window result is silently removed as part of the migration.

The capability matrix gains supportsSurfaceTargeting. Playwright declares it only after the implementation passes real fixtures. Other transports return TRANSPORT_UNSUPPORTED from the two new tools until their own target discovery and selection are implemented.

3. Scope renderer operations and refs to the active surface

After selection, snapshot, find, renderer eval, reads, expectations, waits, and selector/ref-based interactions act on the active surface. Session-wide facilities (main-process evaluation, native UI, network capture, storage, clock control, and a screenshot addressed by WindowRef) retain their existing scope unless an operation gains an explicit surface contract later.

Snapshot state is keyed by (sessionId, surfaceId), not session alone. Snapshot and find responses include surface_id. A ref is valid only in the active surface that produced it; switching surfaces makes a ref from another surface fail with the normal actionable ref-recovery path rather than acting on a coincidentally numbered DOM node.

4. Report support and lifecycle honestly

Each descriptor carries only the capabilities the current transport can exercise. Cross-origin is metadata, not a presumed failure: a transport that can evaluate and interact with the frame exposes those capabilities; one that cannot marks them false and returns a precise unsupported error.

An observed surface that later detaches or closes is retained as a session-local tombstone until the session ends. Selecting it reports SURFACE_CLOSED; an id never observed in that session reports SURFACE_NOT_FOUND; a live surface whose requested operation is unavailable reports SURFACE_UNSUPPORTED. These codes are added to the central registry with recovery hints during implementation.

Console, network, and dialog records gain surface_id only when the transport reports a source surface unambiguously. The implementation must not infer a frame from a URL, title, or a duplicate console location. Existing windowId data stays available for compatibility.

5. Verify real Electron shapes before declaring support

The Playwright implementation must use fixtures for a multiple-window app, nested same-origin and cross-origin iframes, a webview guest, and a WebContentsView. Each fixture proves discovery, selection, snapshot/ref isolation, interaction, close/detach recovery, and no orphaned process after stop. The transport matrix documents a negative capability instead of approximating unsupported surfaces through main-process eval.

Alternatives considered

Alternative Why rejected
Keep one active BrowserWindow and add frame selectors to individual tools It leaves refs and snapshot baselines ambiguous, duplicates target parsing across tools, and cannot represent webviews or WebContentsView.
Expose every Electron WebContents as a window Electron reports both a BrowserWindow and a WebContentsView as window-type contents in the observed runtime. The label would be false and an agent could not reason about ownership.
Make frame ids DOM paths or array indexes Navigation and insertion can renumber them, producing a stale reference that targets another frame. Transport-owned lifetime ids avoid that class of error.
Treat cross-origin frames as unsupported by default Playwright's frame API can address frames independently. Capability must reflect the actual transport, not browser-origin folklore.
Make screenshots implicitly surface-scoped immediately A screenshot currently has a clear WindowRef contract. Changing its geometry and clipping semantics together with target selection would enlarge the migration and obscure failures.

Consequences

Status Update — 2026-07-27: CDP default root surface

CDP now implements activeSurface() for its selected page target, which makes electron_snapshot, electron_find, renderer reads, waits, expectations, and interactions work against an attached or packaged-launched root page. The descriptor uses kind: "other" rather than guessing window, webcontents_view, or webview: /json/list does not expose enough Electron ownership metadata to distinguish those honestly.

This does not flip supportsSurfaceTargeting. CDP still cannot enumerate and select the complete iframe/webview/WebContentsView hierarchy required by this ADR, so electron_surfaces_list and electron_switch_surface continue to return TRANSPORT_UNSUPPORTED. A default root is the narrow consumer fix; hierarchy targeting remains a separate evidence-backed implementation.

References