ADR-022: Renderer surface targeting
- Status: Accepted
- Date: 2026-07-13
- Deciders: johnny4young
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:
- a snapshot can be taken from one renderer and its ref can silently act in another;
- a hidden or embedded renderer cannot be selected deliberately; and
- 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
- The surface feature is additive for operators who only use
electron_windows_list; no current window selection call changes shape or loses a result in the initial migration. - Every renderer-directed code path must take the selected surface from the transport seam. Direct
Pageaccess inside tools is forbidden, just as direct Playwright imports are forbidden by ADR-003. - Snapshot storage, ref freshness, and retagging become surface-aware together. Partial migration is unsafe because it can make a valid ref point to a different renderer.
- Playwright's exposure of page roots makes
WebContentsViewand webview coverage practical, but Electron main-process metadata is still required for honest labels and parentage. - A future transport can add surface support independently by declaring the capability and honoring this descriptor/selection/ref contract; it does not need to imitate Playwright internals.
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
- ADR-003 — transport seam and capability matrix.
- ADR-005 — snapshot/ref invariants.
- ADR-007 — explicit, recoverable agent operations.
- Electron WebContents — renderer
ownership,
hostWebContents, andWebContentsidentities. - Electron web embeds — iframe,
webview, and
WebContentsViewconstraints. - Playwright ElectronApplication and Frame — page and frame discovery surfaces.