bunite-core 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +4 -4
  2. package/src/host/core/App.ts +17 -1
  3. package/src/host/core/BrowserView.ts +197 -28
  4. package/src/host/core/SurfaceBrowserIPC.ts +44 -3
  5. package/src/host/core/SurfaceManager.ts +260 -28
  6. package/src/host/core/SurfaceRegistry.ts +9 -1
  7. package/src/host/core/inputDispatch.ts +147 -0
  8. package/src/host/events/webviewEvents.ts +8 -1
  9. package/src/host/native.ts +124 -1
  10. package/src/native/linux/bunite_linux_ffi.cpp +223 -6
  11. package/src/native/linux/bunite_linux_internal.h +6 -0
  12. package/src/native/linux/bunite_linux_runtime.cpp +1 -1
  13. package/src/native/linux/bunite_linux_utils.cpp +2 -2
  14. package/src/native/linux/bunite_linux_view.cpp +85 -0
  15. package/src/native/mac/bunite_mac_ffi.mm +356 -8
  16. package/src/native/mac/bunite_mac_internal.h +6 -0
  17. package/src/native/mac/bunite_mac_utils.mm +2 -2
  18. package/src/native/mac/bunite_mac_view.mm +144 -2
  19. package/src/native/shared/ffi_exports.h +135 -0
  20. package/src/native/win/native_host_cef.cpp +86 -3
  21. package/src/native/win/native_host_ffi.cpp +378 -1
  22. package/src/native/win/native_host_internal.h +13 -0
  23. package/src/native/win/native_host_utils.cpp +2 -1
  24. package/src/native/win/process_helper_win.cpp +54 -27
  25. package/src/native/win-webview2/bunite_webview2_ffi.cpp +303 -9
  26. package/src/native/win-webview2/webview2_internal.h +11 -0
  27. package/src/native/win-webview2/webview2_runtime.cpp +128 -12
  28. package/src/native/win-webview2/webview2_utils.cpp +30 -12
  29. package/src/preload/runtime.built.js +1 -1
  30. package/src/preload/runtime.ts +97 -0
  31. package/src/rpc/framework.ts +173 -4
  32. package/src/rpc/index.ts +21 -0
  33. package/src/webview/native.ts +126 -25
  34. package/src/webview/polyfill.ts +196 -12
@@ -70,6 +70,16 @@ export const ShellCap = defineCap("bunite.Shell", {
70
70
  showItemInFolder: call<{ path: string }, void>(),
71
71
  });
72
72
 
73
+ /** page → host event sink. Distinct from RuntimeCap (page → host *request* API)
74
+ * so future arms like `reportError` / `reportPerformance` don't pollute the
75
+ * runtime cap. Mounted as a sub-cap via `RuntimeCap.reporting()`. */
76
+ export const PageReportingCap = defineCap("bunite.PageReporting", {
77
+ /** preload coalesces console calls in a 16ms window and pushes the batch.
78
+ * Fire-and-forget — preload `.catch`es resource_exhausted to avoid throwing
79
+ * inside `console.log` (which would corrupt page semantics). */
80
+ reportConsoleBatch: call<{ entries: ConsoleEntry[] }, void>(),
81
+ });
82
+
73
83
  export type SurfaceMask = { x: number; y: number; w: number; h: number };
74
84
 
75
85
  /** Automation feature flags reported per surface. Append-only — consumers
@@ -78,22 +88,166 @@ export type SurfaceMask = { x: number; y: number; w: number; h: number };
78
88
  export interface SurfaceCapabilities {
79
89
  evaluate: boolean;
80
90
  crossOriginEval: boolean;
81
- titleChanged: boolean;
82
- /** Stage A: always false. Set true when SendInput focus choreography lands. */
91
+ surfaceEvents: boolean;
92
+ /** click/type/press/mouse produce DOM events with `isTrusted=true` on the page.
93
+ * Does NOT cover scroll/screenshot (those may use CDP path with isTrusted=false). */
83
94
  nativeInputTrusted: boolean;
84
95
  click: boolean;
85
96
  type: boolean;
86
97
  press: boolean;
87
98
  scroll: boolean;
99
+ /** Raw mouse primitives (move/down/up) — required for drag & hover. */
100
+ mouse: boolean;
101
+ /** Page-initiated dialogs (alert/confirm/prompt/beforeunload) routed through
102
+ * `dialogs` stream + `respondToDialog`. `beforeunload` is Win-only (WebKit
103
+ * handles it through navigation-policy delegate, not script-dialog). */
104
+ dialogs: boolean;
105
+ /** Page `console.{log,warn,error,info,debug}` captured via preload proxy.
106
+ * Only effective for surfaces whose origin is in the preload allowlist
107
+ * (default `appres://app.internal/*`); cross-origin pages don't get preload
108
+ * injection, so `consoleEvents` stays empty even though the flag is true. */
109
+ console: boolean;
88
110
  screenshot: boolean;
89
111
  /** Present only when `screenshot` is true. */
90
112
  formats?: ("png" | "jpeg")[];
91
113
  }
92
114
 
115
+ /** Surface lifecycle event arm. Backends emit a subset honestly — SPA
116
+ * `history.pushState` fires only `navigate`; classic navigation fires
117
+ * `load-start` → `navigate` → `load-finish` (order varies per backend hook
118
+ * sequence and isn't a strict invariant). A navigation produces `load-finish`
119
+ * OR `load-fail`, never both. */
120
+ export type SurfaceEvent =
121
+ | { type: "navigate"; url: string }
122
+ | { type: "load-start"; url: string }
123
+ | { type: "load-finish"; url: string }
124
+ | { type: "load-fail"; url: string; reason?: string }
125
+ | { type: "title-change"; title: string };
126
+
93
127
  export type EvaluateResult =
94
128
  | { ok: true; value: unknown }
95
129
  | { ok: false; code: "cross_origin" | "runtime_error" | "not_supported" | "timeout"; message: string };
96
130
 
131
+ /** Modifier bitmask for input dispatch. Backends translate to native form. */
132
+ export type Modifier = "alt" | "ctrl" | "meta" | "shift";
133
+
134
+ export interface ClickArgs {
135
+ surfaceId: number;
136
+ x: number;
137
+ y: number;
138
+ button?: "left" | "middle" | "right";
139
+ clickCount?: number;
140
+ modifiers?: Modifier[];
141
+ }
142
+ export interface TypeArgs { surfaceId: number; text: string }
143
+ export interface PressArgs {
144
+ surfaceId: number;
145
+ key: string;
146
+ modifiers?: Modifier[];
147
+ /** "both" (default) emits down→char→up; "down" / "up" emit only that half.
148
+ * For Playwright-style modifier-held wrap: keydown → click → keyup. */
149
+ action?: "down" | "up" | "both";
150
+ }
151
+ export interface MouseArgs {
152
+ surfaceId: number;
153
+ /** "move" produces mouseMove only; "down"/"up" produces a single half of
154
+ * a mouse-button event. Compose drag = down → move(s) → up. modifiers are
155
+ * per-call atomic (no sticky state across calls). */
156
+ action: "move" | "down" | "up";
157
+ x: number;
158
+ y: number;
159
+ /** Required for "down"/"up"; ignored for "move". */
160
+ button?: "left" | "middle" | "right";
161
+ modifiers?: Modifier[];
162
+ }
163
+
164
+ /** Page-initiated modal dialog. Backend holds page execution until consumer
165
+ * calls `respondToDialog` with the matching `requestId`. If no response
166
+ * arrives within `setDialogTimeout` (default 5000ms; null = wait forever),
167
+ * host auto-dismisses and emits `{kind: "auto-dismissed", originalKind, message}`
168
+ * so the consumer learns the page proceeded without explicit answer.
169
+ *
170
+ * `beforeunload` arm support per backend:
171
+ * - CEF (Win) : ✔ — `OnBeforeUnloadDialog`
172
+ * - WV2 (Win) : ✔ — `ScriptDialogOpening` with kind `BEFOREUNLOAD`
173
+ * - WKWebView (mac) : ✘ — handled by WKNavigationDelegate, surfaced as `will-navigate`
174
+ * - WebKitGTK (linux) : ✔ — `script-dialog` signal with `BEFORE_UNLOAD_CONFIRM` */
175
+ export type DialogEvent =
176
+ | {
177
+ kind: "alert" | "confirm" | "prompt" | "beforeunload";
178
+ requestId: number;
179
+ message: string;
180
+ /** Initial text for `prompt` only. */
181
+ defaultPrompt?: string;
182
+ }
183
+ | {
184
+ kind: "auto-dismissed";
185
+ originalKind: "alert" | "confirm" | "prompt" | "beforeunload";
186
+ message: string;
187
+ };
188
+
189
+ export interface RespondToDialogArgs {
190
+ surfaceId: number;
191
+ requestId: number;
192
+ accept: boolean;
193
+ /** For `prompt` dialogs — the text the page receives. Ignored otherwise. */
194
+ text?: string;
195
+ }
196
+
197
+ export interface SetDialogTimeoutArgs {
198
+ surfaceId: number;
199
+ /** Milliseconds before unanswered dialog is auto-dismissed. `null` disables
200
+ * the safety net — page hangs until consumer responds. Default 5000. */
201
+ ms: number | null;
202
+ }
203
+
204
+ export interface WaitForSelectorArgs {
205
+ surfaceId: number;
206
+ selector: string;
207
+ /** Default 5000ms. Polled at 50ms intervals via `evaluate`. */
208
+ timeoutMs?: number;
209
+ }
210
+ export interface WaitForFunctionArgs {
211
+ surfaceId: number;
212
+ /** JS expression returning truthy when satisfied. */
213
+ expression: string;
214
+ /** Default 5000ms. */
215
+ timeoutMs?: number;
216
+ /** Default 50ms. Increase for heavy expressions to reduce IPC load. */
217
+ pollIntervalMs?: number;
218
+ }
219
+ export type WaitResult =
220
+ | { ok: true }
221
+ | { ok: false; code: "timeout" | "runtime_error" | "cross_origin"; message: string };
222
+
223
+ export type ConsoleLevel = "log" | "warn" | "error" | "info" | "debug";
224
+
225
+ export interface ConsoleEntry {
226
+ level: ConsoleLevel;
227
+ /** `.toString()` / JSON.stringify-ed arguments — preload doesn't structured-clone. */
228
+ args: string[];
229
+ /** Page-side `Date.now()` at capture time. */
230
+ ts: number;
231
+ }
232
+ export interface ScrollArgs {
233
+ surfaceId: number;
234
+ dx: number;
235
+ dy: number;
236
+ x?: number;
237
+ y?: number;
238
+ modifiers?: Modifier[];
239
+ }
240
+
241
+ export interface ScreenshotArgs {
242
+ surfaceId: number;
243
+ format?: "png" | "jpeg";
244
+ /** JPEG only; 0–100. Ignored for PNG. */
245
+ quality?: number;
246
+ }
247
+ export type ScreenshotResult =
248
+ | { ok: true; data: Uint8Array; mime: string; format: "png" | "jpeg" }
249
+ | { ok: false; code: "not_supported" | "runtime_error" | "timeout" | "black_frame"; message: string };
250
+
97
251
  export const SurfaceCap = defineCap("bunite.Surface", {
98
252
  init: call<{
99
253
  src: string;
@@ -114,8 +268,20 @@ export const SurfaceCap = defineCap("bunite.Surface", {
114
268
  reload: call<{ surfaceId: number }, void>(),
115
269
  evaluate: call<{ surfaceId: number; script: string }, EvaluateResult>(),
116
270
  capabilities: call<{ surfaceId: number }, SurfaceCapabilities>(),
117
- didNavigate: stream<void, { surfaceId: number; url: string }>(),
118
- titleChanged: stream<void, { surfaceId: number; title: string }>(),
271
+ click: call<ClickArgs, void>(),
272
+ type: call<TypeArgs, void>(),
273
+ press: call<PressArgs, void>(),
274
+ scroll: call<ScrollArgs, void>(),
275
+ mouse: call<MouseArgs, void>(),
276
+ screenshot: call<ScreenshotArgs, ScreenshotResult>(),
277
+ waitForSelector: call<WaitForSelectorArgs, WaitResult>(),
278
+ waitForFunction: call<WaitForFunctionArgs, WaitResult>(),
279
+ respondToDialog: call<RespondToDialogArgs, void>(),
280
+ setDialogTimeout: call<SetDialogTimeoutArgs, void>(),
281
+ getConsoleBuffer: call<{ surfaceId: number; clear?: boolean }, ConsoleEntry[]>({ idempotent: true }),
282
+ surfaceEvents: stream<{ surfaceId: number }, SurfaceEvent>(),
283
+ dialogs: stream<{ surfaceId: number }, DialogEvent>(),
284
+ consoleEvents: stream<{ surfaceId: number }, ConsoleEntry>(),
119
285
  });
120
286
 
121
287
  export const RuntimeCap = defineCap("bunite.Runtime", {
@@ -128,6 +294,7 @@ export const RuntimeCap = defineCap("bunite.Runtime", {
128
294
  theme: call<void, "light" | "dark">({ idempotent: true }),
129
295
  themeWatch: stream<void, "light" | "dark">(),
130
296
  surface: call<void, typeof SurfaceCap>({ returns: cap(SurfaceCap), idempotent: true }),
297
+ reporting: call<void, typeof PageReportingCap>({ returns: cap(PageReportingCap), idempotent: true }),
131
298
  });
132
299
 
133
300
  export const FRAMEWORK_TYPE_IDS = {
@@ -139,6 +306,7 @@ export const FRAMEWORK_TYPE_IDS = {
139
306
  Shell: 6,
140
307
  BrowserWindow: 7,
141
308
  Surface: 8,
309
+ PageReporting: 9,
142
310
  } as const;
143
311
 
144
312
  const FRAMEWORK_CAP_TYPE_IDS = new Map<CapDef<any, any>, number>([
@@ -150,6 +318,7 @@ const FRAMEWORK_CAP_TYPE_IDS = new Map<CapDef<any, any>, number>([
150
318
  [ShellCap, FRAMEWORK_TYPE_IDS.Shell],
151
319
  [BrowserWindowCap, FRAMEWORK_TYPE_IDS.BrowserWindow],
152
320
  [SurfaceCap, FRAMEWORK_TYPE_IDS.Surface],
321
+ [PageReportingCap, FRAMEWORK_TYPE_IDS.PageReporting],
153
322
  ]);
154
323
 
155
324
  export function frameworkTypeIdOf(cap: CapDef<any, any>): number | undefined {
package/src/rpc/index.ts CHANGED
@@ -101,6 +101,7 @@ export {
101
101
  ClipboardCap,
102
102
  ShellCap,
103
103
  SurfaceCap,
104
+ PageReportingCap,
104
105
  FRAMEWORK_TYPE_IDS,
105
106
  } from "./framework";
106
107
 
@@ -109,6 +110,26 @@ export type {
109
110
  DialogOpenFileOpts,
110
111
  DialogSaveFileOpts,
111
112
  DialogMessageOpts,
113
+ SurfaceCapabilities,
114
+ SurfaceEvent,
115
+ SurfaceMask,
116
+ Modifier,
117
+ ClickArgs,
118
+ TypeArgs,
119
+ PressArgs,
120
+ ScrollArgs,
121
+ MouseArgs,
122
+ DialogEvent,
123
+ RespondToDialogArgs,
124
+ SetDialogTimeoutArgs,
125
+ WaitForSelectorArgs,
126
+ WaitForFunctionArgs,
127
+ WaitResult,
128
+ ConsoleLevel,
129
+ ConsoleEntry,
130
+ ScreenshotArgs,
131
+ ScreenshotResult,
132
+ EvaluateResult,
112
133
  } from "./framework";
113
134
 
114
135
  export type {
@@ -1,7 +1,10 @@
1
1
  // <bunite-webview> custom element — registered in every appres:// page via preload.
2
2
 
3
3
  import type { ClientOf } from "../rpc/index";
4
- import type { SurfaceCap, EvaluateResult, SurfaceCapabilities } from "../rpc/framework";
4
+ import type {
5
+ SurfaceCap, EvaluateResult, SurfaceCapabilities, ScreenshotResult,
6
+ SurfaceEvent, ConsoleEntry, WaitResult,
7
+ } from "../rpc/framework";
5
8
 
6
9
  declare const host: {
7
10
  runtime(): Promise<ClientOf<typeof import("../rpc/framework").RuntimeCap>>;
@@ -120,6 +123,7 @@ class BuniteWebviewElement extends HTMLElement {
120
123
  private _userHidden = false;
121
124
  private _layoutObserver: ResizeObserver | null = null;
122
125
  private _unsubNavigate: (() => void) | null = null;
126
+ private _activeStreams: Array<{ cancel?: () => void }> = [];
123
127
 
124
128
  constructor() {
125
129
  super();
@@ -134,39 +138,45 @@ class BuniteWebviewElement extends HTMLElement {
134
138
  this._unsubNavigate = () => ctrl.abort();
135
139
  void (async () => {
136
140
  try {
141
+ // Wait until *this* connection's init resolves, then capture surfaceId
142
+ // atomically before subscribing. Re-entrant connects abort prior loops
143
+ // via `ctrl`, so a stale resolve from a previous cycle can't leak in.
137
144
  const s = await getSurfaceCap();
138
- const stream = s.didNavigate();
139
- for await (const ev of stream) {
145
+ if (ctrl.signal.aborted) return;
146
+ await this._waitForSurfaceId(ctrl.signal);
147
+ const sid = this._surfaceId;
148
+ if (ctrl.signal.aborted || sid == null) return;
149
+ const stream = s.surfaceEvents({ surfaceId: sid });
150
+ this._activeStreams.push(stream as { cancel?: () => void });
151
+ for await (const event of stream) {
140
152
  if (ctrl.signal.aborted) break;
141
- if (ev.surfaceId === this._surfaceId) {
142
- this.dispatchEvent(new CustomEvent("did-navigate", { detail: { url: ev.url } }));
143
- }
153
+ this.dispatchEvent(new CustomEvent<SurfaceEvent>("surface-event", { detail: event }));
144
154
  }
145
155
  } catch (err) {
146
156
  if ((globalThis as { __BUNITE_DEBUG__?: boolean }).__BUNITE_DEBUG__) {
147
- console.warn("[bunite] didNavigate stream failed", err);
148
- }
149
- }
150
- })();
151
- void (async () => {
152
- try {
153
- const s = await getSurfaceCap();
154
- const stream = s.titleChanged();
155
- for await (const ev of stream) {
156
- if (ctrl.signal.aborted) break;
157
- if (ev.surfaceId === this._surfaceId) {
158
- this.dispatchEvent(new CustomEvent("title-changed", { detail: { title: ev.title } }));
159
- }
160
- }
161
- } catch (err) {
162
- if ((globalThis as { __BUNITE_DEBUG__?: boolean }).__BUNITE_DEBUG__) {
163
- console.warn("[bunite] titleChanged stream failed", err);
157
+ console.warn("[bunite] surfaceEvents stream failed", err);
164
158
  }
165
159
  }
166
160
  })();
167
161
  this._waitForLayout();
168
162
  }
169
163
 
164
+ private async _waitForSurfaceId(signal: AbortSignal): Promise<void> {
165
+ while (this._surfaceId == null && !signal.aborted) {
166
+ const pending = this._initPromise;
167
+ if (pending) {
168
+ // Await this exact init attempt; if it rejects, bail (init failed —
169
+ // we'd otherwise spin forever waiting for a surfaceId that never lands).
170
+ try { await pending; } catch { return; }
171
+ // After resolve, _surfaceId may still be null if disconnect raced —
172
+ // the next loop iteration checks signal.aborted to exit cleanly.
173
+ } else {
174
+ // No init in flight yet (waiting for layout). Yield then re-check.
175
+ await new Promise((r) => setTimeout(r, 16));
176
+ }
177
+ }
178
+ }
179
+
170
180
  private _waitForLayout() {
171
181
  if (this._layoutObserver) return; // already waiting
172
182
 
@@ -198,6 +208,12 @@ class BuniteWebviewElement extends HTMLElement {
198
208
  this._aborted = true;
199
209
  this._unsubNavigate?.();
200
210
  this._unsubNavigate = null;
211
+ // Cancel pending stream iterators so the `for await` actually unblocks —
212
+ // AbortController alone only takes effect at the next received chunk.
213
+ for (const stream of this._activeStreams) {
214
+ try { stream.cancel?.(); } catch {}
215
+ }
216
+ this._activeStreams = [];
201
217
  this._layoutObserver?.disconnect();
202
218
  this._layoutObserver = null;
203
219
  this._syncCtrl?.stop();
@@ -258,14 +274,99 @@ class BuniteWebviewElement extends HTMLElement {
258
274
  const sid = this._surfaceId;
259
275
  if (sid == null) {
260
276
  return {
261
- evaluate: false, crossOriginEval: false, titleChanged: false,
277
+ evaluate: false, crossOriginEval: false, surfaceEvents: false,
262
278
  nativeInputTrusted: false, click: false, type: false, press: false,
263
- scroll: false, screenshot: false,
279
+ scroll: false, mouse: false, dialogs: false, console: false,
280
+ screenshot: false,
264
281
  };
265
282
  }
266
283
  return callSurfaceTyped((s) => s.capabilities({ surfaceId: sid }));
267
284
  }
268
285
 
286
+ // Automation input — `send*` prefix avoids clashing with HTMLElement.click() / .scroll().
287
+ async sendClick(args: {
288
+ x: number; y: number;
289
+ button?: "left" | "middle" | "right";
290
+ clickCount?: number;
291
+ modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
292
+ }): Promise<void> {
293
+ const sid = this._surfaceId;
294
+ if (sid == null) return;
295
+ await callSurfaceTyped((s) => s.click({ surfaceId: sid, ...args }));
296
+ }
297
+
298
+ async sendType(text: string): Promise<void> {
299
+ const sid = this._surfaceId;
300
+ if (sid == null) return;
301
+ await callSurfaceTyped((s) => s.type({ surfaceId: sid, text }));
302
+ }
303
+
304
+ async sendPress(
305
+ key: string,
306
+ modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">,
307
+ action?: "down" | "up" | "both"
308
+ ): Promise<void> {
309
+ const sid = this._surfaceId;
310
+ if (sid == null) return;
311
+ await callSurfaceTyped((s) => s.press({ surfaceId: sid, key, modifiers, action }));
312
+ }
313
+
314
+ async sendScroll(args: {
315
+ dx: number; dy: number; x?: number; y?: number;
316
+ modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
317
+ }): Promise<void> {
318
+ const sid = this._surfaceId;
319
+ if (sid == null) return;
320
+ await callSurfaceTyped((s) => s.scroll({ surfaceId: sid, ...args }));
321
+ }
322
+
323
+ async sendMouse(args: {
324
+ action: "move" | "down" | "up";
325
+ x: number; y: number;
326
+ button?: "left" | "middle" | "right";
327
+ modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
328
+ }): Promise<void> {
329
+ const sid = this._surfaceId;
330
+ if (sid == null) return;
331
+ await callSurfaceTyped((s) => s.mouse({ surfaceId: sid, ...args }));
332
+ }
333
+
334
+ async respondToDialog(requestId: number, accept: boolean, text?: string): Promise<void> {
335
+ const sid = this._surfaceId;
336
+ if (sid == null) return;
337
+ await callSurfaceTyped((s) => s.respondToDialog({ surfaceId: sid, requestId, accept, text }));
338
+ }
339
+
340
+ async setDialogTimeout(ms: number | null): Promise<void> {
341
+ const sid = this._surfaceId;
342
+ if (sid == null) return;
343
+ await callSurfaceTyped((s) => s.setDialogTimeout({ surfaceId: sid, ms }));
344
+ }
345
+
346
+ async waitForSelector(selector: string, timeoutMs?: number): Promise<WaitResult> {
347
+ const sid = this._surfaceId;
348
+ if (sid == null) return { ok: false, code: "runtime_error", message: "surface not ready" };
349
+ return callSurfaceTyped((s) => s.waitForSelector({ surfaceId: sid, selector, timeoutMs }));
350
+ }
351
+
352
+ async waitForFunction(expression: string, opts?: { timeoutMs?: number; pollIntervalMs?: number }): Promise<WaitResult> {
353
+ const sid = this._surfaceId;
354
+ if (sid == null) return { ok: false, code: "runtime_error", message: "surface not ready" };
355
+ return callSurfaceTyped((s) => s.waitForFunction({ surfaceId: sid, expression, ...opts }));
356
+ }
357
+
358
+ async getConsoleBuffer(opts?: { clear?: boolean }): Promise<ConsoleEntry[]> {
359
+ const sid = this._surfaceId;
360
+ if (sid == null) return [];
361
+ return (await callSurfaceTyped((s) => s.getConsoleBuffer({ surfaceId: sid, clear: opts?.clear }))) ?? [];
362
+ }
363
+
364
+ async screenshot(args?: { format?: "png" | "jpeg"; quality?: number }): Promise<ScreenshotResult> {
365
+ const sid = this._surfaceId;
366
+ if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
367
+ return callSurfaceTyped((s) => s.screenshot({ surfaceId: sid, ...args }));
368
+ }
369
+
269
370
  private _applySurfaceHidden() {
270
371
  const sid = this._surfaceId;
271
372
  if (sid == null) return;