bunite-core 0.14.0 → 0.17.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 (33) hide show
  1. package/package.json +4 -4
  2. package/src/host/core/App.ts +6 -3
  3. package/src/host/core/BrowserView.ts +345 -24
  4. package/src/host/core/BrowserWindow.ts +52 -6
  5. package/src/host/core/SurfaceBrowserIPC.ts +10 -1
  6. package/src/host/core/SurfaceManager.ts +357 -16
  7. package/src/host/core/windowCap.ts +69 -0
  8. package/src/host/events/webviewEvents.ts +18 -1
  9. package/src/host/log.ts +6 -1
  10. package/src/host/native.ts +145 -1
  11. package/src/host/preloadBundle.ts +7 -2
  12. package/src/native/linux/bunite_linux_ffi.cpp +225 -1
  13. package/src/native/linux/bunite_linux_internal.h +12 -0
  14. package/src/native/linux/bunite_linux_runtime.cpp +6 -1
  15. package/src/native/linux/bunite_linux_view.cpp +211 -5
  16. package/src/native/mac/bunite_mac_ffi.mm +293 -4
  17. package/src/native/mac/bunite_mac_internal.h +13 -0
  18. package/src/native/mac/bunite_mac_view.mm +227 -7
  19. package/src/native/shared/ffi_exports.h +97 -30
  20. package/src/native/win/native_host_cef.cpp +107 -13
  21. package/src/native/win/native_host_ffi.cpp +831 -2
  22. package/src/native/win/native_host_internal.h +22 -0
  23. package/src/native/win/native_host_runtime.cpp +34 -0
  24. package/src/native/win-webview2/bunite_webview2_ffi.cpp +827 -5
  25. package/src/native/win-webview2/webview2_internal.h +19 -0
  26. package/src/native/win-webview2/webview2_runtime.cpp +383 -31
  27. package/src/preload/runtime.built.js +1 -1
  28. package/src/preload/runtime.ts +39 -0
  29. package/src/rpc/framework.ts +194 -12
  30. package/src/rpc/index.ts +12 -0
  31. package/src/rpc/peer.ts +1 -1
  32. package/src/webview/native.ts +142 -32
  33. package/src/webview/polyfill.ts +91 -14
@@ -1,18 +1,37 @@
1
1
  import { call, defineCap, stream, cap } from "./schema";
2
2
  import type { CapDef } from "./schema";
3
3
 
4
+ /** Window state for custom-titlebar rendering (max/restore glyph + focus ring). */
5
+ export interface WindowState {
6
+ maximized: boolean;
7
+ minimized: boolean;
8
+ focused: boolean;
9
+ }
10
+
4
11
  export const BrowserWindowCap = defineCap("bunite.BrowserWindow", {
5
12
  focus: call<void, void>(),
6
- close: call<void, void>(),
13
+ close: call<void, void>(), // vetoable — routes through close-requested
7
14
  setBounds: call<{ x: number; y: number; w: number; h: number }, void>(),
8
15
  setTitle: call<{ title: string }, void>(),
9
16
  id: call<void, number>({ idempotent: true }),
10
17
  label: call<void, string>({ idempotent: true }),
18
+ minimize: call<void, void>(),
19
+ unminimize: call<void, void>(),
20
+ maximize: call<void, void>(),
21
+ unmaximize: call<void, void>(),
22
+ toggleMaximize: call<void, void>(),
23
+ getState: call<void, WindowState>({ idempotent: true }),
24
+ stateWatch: stream<void, WindowState>(), // emits current state on subscribe, then on change
25
+ // Start an OS window move (Tauri startDragging equiv) — call from a custom
26
+ // titlebar mousedown. Preload auto-calls this for `app-region: drag`; manual
27
+ // callers use it for custom hit-testing. Start-only; native follows the cursor.
28
+ beginMoveDrag: call<void, void>(),
11
29
  });
12
30
 
13
31
  export const WindowCap = defineCap("bunite.Window", {
14
32
  create: call<WindowCreateOpts, typeof BrowserWindowCap>({ returns: cap(BrowserWindowCap) }),
15
33
  list: call<void, typeof BrowserWindowCap>({ returns: cap.array(BrowserWindowCap), idempotent: true }),
34
+ current: call<void, typeof BrowserWindowCap>({ returns: cap(BrowserWindowCap), idempotent: true }),
16
35
  focus: call<{ id?: number; label?: string }, void>(),
17
36
  close: call<{ id?: number; label?: string }, void>(),
18
37
  });
@@ -82,9 +101,9 @@ export const PageReportingCap = defineCap("bunite.PageReporting", {
82
101
 
83
102
  export type SurfaceMask = { x: number; y: number; w: number; h: number };
84
103
 
85
- /** Automation feature flags reported per surface. Append-only consumers
86
- * treat missing fields as `false`. Backend-honest: a method may exist on the
87
- * RPC surface but return `not_supported` when the backend can't fulfil it. */
104
+ /** Automation feature flags reported per surface. Two categories: method
105
+ * gate (false must not call) and property advertise (false method runs,
106
+ * property degrades; per-field JSDoc below). See `.agents/architecture.md`. */
88
107
  export interface SurfaceCapabilities {
89
108
  evaluate: boolean;
90
109
  crossOriginEval: boolean;
@@ -110,19 +129,142 @@ export interface SurfaceCapabilities {
110
129
  screenshot: boolean;
111
130
  /** Present only when `screenshot` is true. */
112
131
  formats?: ("png" | "jpeg")[];
132
+ accessibilitySnapshot: boolean;
133
+ getBoundingRect: boolean;
134
+ /** `listFrames` works + `evaluate({frameId})` reaches the target frame's
135
+ * isolated world. Frame-targeted input dispatch is not yet implemented. */
136
+ frames: boolean;
137
+ /** Backend can intercept downloads and emit lifecycle events. When `false`,
138
+ * every download attempt emits `{kind: "blocked", reason: "not_supported"}`. */
139
+ downloads: boolean;
140
+ /** Backend can intercept popups (window.open / target=_blank / cmd-click) and
141
+ * mint a new surface with the opener relationship preserved. Host adopts via
142
+ * `acceptPopup({newSurfaceId, hostViewId, bounds})`. */
143
+ popups: boolean;
144
+ /** Atomic `resolveAndClick(selector)`. Click trust is per-call (see `ResolveAndClickResult`). */
145
+ resolveAndClick: boolean;
146
+ }
147
+
148
+ export interface AxNode {
149
+ nodeId: string;
150
+ role: string;
151
+ name: string;
152
+ value?: string;
153
+ description?: string;
154
+ level?: number;
155
+ checked?: boolean | "mixed";
156
+ pressed?: boolean | "mixed";
157
+ expanded?: boolean;
158
+ disabled?: boolean;
159
+ focused?: boolean;
160
+ invalid?: boolean;
161
+ required?: boolean;
162
+ selected?: boolean;
163
+ children?: AxNode[];
164
+ }
165
+
166
+ export interface AccessibilitySnapshotArgs {
167
+ surfaceId: number;
168
+ /** Drop nodes with `ignored:true` (and reparent their children). Default true. */
169
+ interestingOnly?: boolean;
170
+ }
171
+ export type AccessibilitySnapshotResult =
172
+ | { ok: true; tree: AxNode }
173
+ | { ok: false; code: "not_supported" | "runtime_error" | "timeout"; message: string };
174
+
175
+ export interface BoundingRectArgs {
176
+ surfaceId: number;
177
+ selector: string;
178
+ /** When set, `rect` is FRAME-LOCAL (the iframe's own viewport). Use frame-local
179
+ * coords for further `evaluate({frameId})` ops; do not pass to `click({x,y})`
180
+ * which expects page-viewport coords. */
181
+ frameId?: string;
182
+ }
183
+ export type BoundingRectResult =
184
+ /** `visible` = rect has size AND intersects the frame's viewport. opacity /
185
+ * visibility:hidden / occlusion are NOT checked — agent must `evaluate` for those. */
186
+ | { ok: true; rect: { x: number; y: number; width: number; height: number }; visible: boolean }
187
+ | { ok: false; code: "not_found" | "runtime_error" | "cross_origin" | "not_supported"; message: string };
188
+
189
+ export interface Frame {
190
+ frameId: string;
191
+ parentFrameId: string | null;
192
+ origin: string;
193
+ url: string;
194
+ name?: string;
195
+ }
196
+
197
+ export type ListFramesResult =
198
+ | { ok: true; frames: Frame[] }
199
+ | { ok: false; code: "not_supported" | "runtime_error"; message: string };
200
+
201
+ export type DownloadPolicy = "auto" | "ask" | "block";
202
+
203
+ export type DownloadEvent =
204
+ | { kind: "started"; id: string; url: string; suggestedFilename: string; mimeType?: string; sizeBytes?: number }
205
+ | { kind: "progress"; id: string; receivedBytes: number; totalBytes?: number }
206
+ | { kind: "completed"; id: string; localPath: string }
207
+ | { kind: "failed"; id: string; reason: string }
208
+ | { kind: "blocked"; id: string; url: string; reason: "host-policy" | "backend-block" | "mime-blocked" | "not_supported" | "ask-not-implemented" };
209
+
210
+ export type WaitForDownloadResult =
211
+ | { ok: true; id: string; suggestedFilename: string; url: string; mimeType?: string; sizeBytes?: number; localPath: string }
212
+ | { ok: false; code: "timeout" | "blocked" | "failed" | "not_supported"; message: string };
213
+
214
+ export interface SetDownloadPolicyArgs {
215
+ surfaceId: number;
216
+ policy: DownloadPolicy;
217
+ /** Absolute path. When omitted, backend keeps current dir (or temp). */
218
+ downloadDir?: string;
219
+ }
220
+
221
+ export interface AcceptPopupArgs {
222
+ newSurfaceId: number;
223
+ /** The host BrowserView that will own the new surface. */
224
+ hostViewId: number;
225
+ bounds: { x: number; y: number; width: number; height: number };
226
+ }
227
+ export type AcceptPopupResult =
228
+ | { ok: true }
229
+ | { ok: false; code: "not_found" | "host_view_invalid"; message: string };
230
+
231
+ export interface ExtendPopupTimeoutArgs {
232
+ newSurfaceId: number;
233
+ /** Reset semantic: new deadline = now + gracePeriodMs. Not cumulative.
234
+ * Capped at 60s since the popup arm was emitted. */
235
+ gracePeriodMs: number;
113
236
  }
237
+ export type ExtendPopupTimeoutResult =
238
+ | { ok: true; deadlineMs: number } // epoch ms of the new deadline
239
+ | { ok: false; code: "not_found" | "already_adopted" | "already_dismissed" | "cap_exceeded"; message: string };
114
240
 
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 =
241
+ /** Surface lifecycle event arm before the surface pipeline stamps `epoch`. */
242
+ export type SurfaceEventBase =
121
243
  | { type: "navigate"; url: string }
122
244
  | { type: "load-start"; url: string }
123
245
  | { type: "load-finish"; url: string }
124
246
  | { type: "load-fail"; url: string; reason?: string }
125
- | { type: "title-change"; title: string };
247
+ | { type: "title-change"; title: string }
248
+ | {
249
+ type: "popup";
250
+ url: string;
251
+ disposition: "tab" | "window" | "popup";
252
+ openerSurfaceId: number;
253
+ /** Native surface ID minted before the arm is emitted. Host must call
254
+ * `acceptPopup` (within `popupAdoptionTimeoutMs`, default 5s) to attach,
255
+ * or `dismissPopup` to close. Auto-dismiss fires on timeout. */
256
+ newSurfaceId: number;
257
+ };
258
+
259
+ /** Wire form of `SurfaceEventBase`. `epoch` bumps on every `navigate` (incl. SPA
260
+ * `pushState`); other arms carry the current epoch. See `docs/browser-automation.md`. */
261
+ export type SurfaceEvent = SurfaceEventBase & { epoch: number };
262
+
263
+ export interface NavigationState {
264
+ lastLoadEpoch: number;
265
+ isLoading: boolean;
266
+ currentUrl: string;
267
+ }
126
268
 
127
269
  export type EvaluateResult =
128
270
  | { ok: true; value: unknown }
@@ -206,6 +348,7 @@ export interface WaitForSelectorArgs {
206
348
  selector: string;
207
349
  /** Default 5000ms. Polled at 50ms intervals via `evaluate`. */
208
350
  timeoutMs?: number;
351
+ frameId?: string;
209
352
  }
210
353
  export interface WaitForFunctionArgs {
211
354
  surfaceId: number;
@@ -215,6 +358,7 @@ export interface WaitForFunctionArgs {
215
358
  timeoutMs?: number;
216
359
  /** Default 50ms. Increase for heavy expressions to reduce IPC load. */
217
360
  pollIntervalMs?: number;
361
+ frameId?: string;
218
362
  }
219
363
  export type WaitResult =
220
364
  | { ok: true }
@@ -248,6 +392,22 @@ export type ScreenshotResult =
248
392
  | { ok: true; data: Uint8Array; mime: string; format: "png" | "jpeg" }
249
393
  | { ok: false; code: "not_supported" | "runtime_error" | "timeout" | "black_frame"; message: string };
250
394
 
395
+ export interface ResolveAndClickArgs {
396
+ surfaceId: number;
397
+ selector: string;
398
+ /** Same-origin iframe (CEF/WV2). OOPIF → `cross_origin`. mac/linux → `not_supported`. */
399
+ frameId?: string;
400
+ button?: "left" | "middle" | "right";
401
+ clickCount?: number;
402
+ modifiers?: Modifier[];
403
+ }
404
+ /** rect is viewport-normalized. `isTrustedEvent` is empirical per backend —
405
+ * CEF/WV2 CDP `Input.dispatchMouseEvent` produces trusted events; mac NSEvent
406
+ * direct dispatch is also trusted. All shipped backends report `true`. */
407
+ export type ResolveAndClickResult =
408
+ | { ok: true; rect: { x: number; y: number; width: number; height: number }; isTrustedEvent: boolean }
409
+ | { ok: false; code: "not_found" | "not_visible" | "runtime_error" | "cross_origin" | "not_supported"; message: string };
410
+
251
411
  export const SurfaceCap = defineCap("bunite.Surface", {
252
412
  init: call<{
253
413
  src: string;
@@ -266,7 +426,7 @@ export const SurfaceCap = defineCap("bunite.Surface", {
266
426
  navigate: call<{ surfaceId: number; url: string }, void>(),
267
427
  goBack: call<{ surfaceId: number }, void>(),
268
428
  reload: call<{ surfaceId: number }, void>(),
269
- evaluate: call<{ surfaceId: number; script: string }, EvaluateResult>(),
429
+ evaluate: call<{ surfaceId: number; script: string; frameId?: string }, EvaluateResult>(),
270
430
  capabilities: call<{ surfaceId: number }, SurfaceCapabilities>(),
271
431
  click: call<ClickArgs, void>(),
272
432
  type: call<TypeArgs, void>(),
@@ -282,8 +442,29 @@ export const SurfaceCap = defineCap("bunite.Surface", {
282
442
  surfaceEvents: stream<{ surfaceId: number }, SurfaceEvent>(),
283
443
  dialogs: stream<{ surfaceId: number }, DialogEvent>(),
284
444
  consoleEvents: stream<{ surfaceId: number }, ConsoleEntry>(),
445
+ getNavigationState: call<{ surfaceId: number }, NavigationState>({ idempotent: true }),
446
+ accessibilitySnapshot: call<AccessibilitySnapshotArgs, AccessibilitySnapshotResult>(),
447
+ getBoundingRect: call<BoundingRectArgs, BoundingRectResult>({ idempotent: true }),
448
+ listFrames: call<{ surfaceId: number }, ListFramesResult>({ idempotent: true }),
449
+ downloadEvents: stream<{ surfaceId: number }, DownloadEvent>(),
450
+ waitForDownload: call<{ surfaceId: number; timeoutMs?: number }, WaitForDownloadResult>(),
451
+ setDownloadPolicy: call<SetDownloadPolicyArgs, void>(),
452
+ acceptPopup: call<AcceptPopupArgs, AcceptPopupResult>(),
453
+ dismissPopup: call<{ newSurfaceId: number }, void>(),
454
+ extendPopupTimeout: call<ExtendPopupTimeoutArgs, ExtendPopupTimeoutResult>(),
455
+ resolveAndClick: call<ResolveAndClickArgs, ResolveAndClickResult>(),
285
456
  });
286
457
 
458
+ /** Process-lifetime cumulative counts of popup lifecycle resolutions. Useful
459
+ * for tuning the default popup adoption timeout. */
460
+ export interface PopupMetrics {
461
+ armed: number;
462
+ adopted: number;
463
+ dismissed: number;
464
+ timeoutFired: number;
465
+ extended: number;
466
+ }
467
+
287
468
  export const RuntimeCap = defineCap("bunite.Runtime", {
288
469
  window: call<void, typeof WindowCap>({ returns: cap(WindowCap), idempotent: true }),
289
470
  dialogs: call<void, typeof DialogsCap>({ returns: cap(DialogsCap), idempotent: true }),
@@ -295,6 +476,7 @@ export const RuntimeCap = defineCap("bunite.Runtime", {
295
476
  themeWatch: stream<void, "light" | "dark">(),
296
477
  surface: call<void, typeof SurfaceCap>({ returns: cap(SurfaceCap), idempotent: true }),
297
478
  reporting: call<void, typeof PageReportingCap>({ returns: cap(PageReportingCap), idempotent: true }),
479
+ popupMetrics: call<void, PopupMetrics>({ idempotent: true }),
298
480
  });
299
481
 
300
482
  export const FRAMEWORK_TYPE_IDS = {
package/src/rpc/index.ts CHANGED
@@ -107,11 +107,17 @@ export {
107
107
 
108
108
  export type {
109
109
  WindowCreateOpts,
110
+ WindowState,
110
111
  DialogOpenFileOpts,
111
112
  DialogSaveFileOpts,
112
113
  DialogMessageOpts,
113
114
  SurfaceCapabilities,
114
115
  SurfaceEvent,
116
+ SurfaceEventBase,
117
+ NavigationState,
118
+ DownloadEvent,
119
+ DownloadPolicy,
120
+ WaitForDownloadResult,
115
121
  SurfaceMask,
116
122
  Modifier,
117
123
  ClickArgs,
@@ -130,6 +136,12 @@ export type {
130
136
  ScreenshotArgs,
131
137
  ScreenshotResult,
132
138
  EvaluateResult,
139
+ AcceptPopupArgs,
140
+ AcceptPopupResult,
141
+ ExtendPopupTimeoutArgs,
142
+ ExtendPopupTimeoutResult,
143
+ ResolveAndClickArgs,
144
+ ResolveAndClickResult,
133
145
  } from "./framework";
134
146
 
135
147
  export type {
package/src/rpc/peer.ts CHANGED
@@ -56,7 +56,7 @@ export const FIRST_USER_TYPE_ID = 128;
56
56
 
57
57
  export const MAX_CAPS_PER_CONNECTION = 1024;
58
58
  export const MAX_IN_FLIGHT_CALLS_PER_CONNECTION = 1024;
59
- /** Client-side LRU cap for revoked cap-ids — prevents unbounded growth on long-lived connections with frequent plugin churn (e.g. flmux). */
59
+ /** Client-side LRU cap for revoked cap-ids — prevents unbounded growth on long-lived connections with frequent plugin churn. */
60
60
  const REVOKED_CACHE_SIZE = MAX_CAPS_PER_CONNECTION * 4;
61
61
 
62
62
  const DEFAULT_DEADLINE_GRACE_MS = 500;
@@ -3,9 +3,14 @@
3
3
  import type { ClientOf } from "../rpc/index";
4
4
  import type {
5
5
  SurfaceCap, EvaluateResult, SurfaceCapabilities, ScreenshotResult,
6
- SurfaceEvent, ConsoleEntry, WaitResult,
6
+ SurfaceEvent, ConsoleEntry, WaitResult, NavigationState,
7
+ AccessibilitySnapshotResult, BoundingRectResult, ListFramesResult,
8
+ DownloadEvent, DownloadPolicy, WaitForDownloadResult,
9
+ ResolveAndClickResult,
7
10
  } from "../rpc/framework";
8
11
 
12
+ declare const __buniteWebviewId: number;
13
+
9
14
  declare const host: {
10
15
  runtime(): Promise<ClientOf<typeof import("../rpc/framework").RuntimeCap>>;
11
16
  };
@@ -138,17 +143,24 @@ class BuniteWebviewElement extends HTMLElement {
138
143
  this._unsubNavigate = () => ctrl.abort();
139
144
  void (async () => {
140
145
  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.
144
146
  const s = await getSurfaceCap();
145
147
  if (ctrl.signal.aborted) return;
146
148
  await this._waitForSurfaceId(ctrl.signal);
147
149
  const sid = this._surfaceId;
148
150
  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) {
151
+ const surfStream = s.surfaceEvents({ surfaceId: sid });
152
+ this._activeStreams.push(surfStream as { cancel?: () => void });
153
+ const dlStream = s.downloadEvents({ surfaceId: sid });
154
+ this._activeStreams.push(dlStream as { cancel?: () => void });
155
+ (async () => {
156
+ try {
157
+ for await (const event of dlStream) {
158
+ if (ctrl.signal.aborted) break;
159
+ this.dispatchEvent(new CustomEvent<DownloadEvent>("download-event", { detail: event }));
160
+ }
161
+ } catch { /* stream torn down */ }
162
+ })();
163
+ for await (const event of surfStream) {
152
164
  if (ctrl.signal.aborted) break;
153
165
  this.dispatchEvent(new CustomEvent<SurfaceEvent>("surface-event", { detail: event }));
154
166
  }
@@ -264,10 +276,10 @@ class BuniteWebviewElement extends HTMLElement {
264
276
  this.setAttribute("src", url);
265
277
  }
266
278
 
267
- async evaluate(script: string): Promise<EvaluateResult> {
279
+ async evaluate(script: string, opts?: { frameId?: string }): Promise<EvaluateResult> {
268
280
  const sid = this._surfaceId;
269
281
  if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
270
- return callSurfaceTyped((s) => s.evaluate({ surfaceId: sid, script }));
282
+ return callSurfaceTyped((s) => s.evaluate({ surfaceId: sid, script, frameId: opts?.frameId }));
271
283
  }
272
284
 
273
285
  async capabilities(): Promise<SurfaceCapabilities> {
@@ -277,7 +289,8 @@ class BuniteWebviewElement extends HTMLElement {
277
289
  evaluate: false, crossOriginEval: false, surfaceEvents: false,
278
290
  nativeInputTrusted: false, click: false, type: false, press: false,
279
291
  scroll: false, mouse: false, dialogs: false, console: false,
280
- screenshot: false,
292
+ screenshot: false, accessibilitySnapshot: false, getBoundingRect: false,
293
+ frames: false, downloads: false, popups: false, resolveAndClick: false,
281
294
  };
282
295
  }
283
296
  return callSurfaceTyped((s) => s.capabilities({ surfaceId: sid }));
@@ -361,6 +374,64 @@ class BuniteWebviewElement extends HTMLElement {
361
374
  return (await callSurfaceTyped((s) => s.getConsoleBuffer({ surfaceId: sid, clear: opts?.clear }))) ?? [];
362
375
  }
363
376
 
377
+ async getNavigationState(): Promise<NavigationState> {
378
+ const sid = this._surfaceId;
379
+ if (sid == null) return { lastLoadEpoch: 0, isLoading: false, currentUrl: "" };
380
+ return callSurfaceTyped((s) => s.getNavigationState({ surfaceId: sid }));
381
+ }
382
+
383
+ async accessibilitySnapshot(opts?: { interestingOnly?: boolean }): Promise<AccessibilitySnapshotResult> {
384
+ const sid = this._surfaceId;
385
+ if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
386
+ return callSurfaceTyped((s) => s.accessibilitySnapshot({ surfaceId: sid, interestingOnly: opts?.interestingOnly }));
387
+ }
388
+
389
+ async getBoundingRect(selector: string, opts?: { frameId?: string }): Promise<BoundingRectResult> {
390
+ const sid = this._surfaceId;
391
+ if (sid == null) return { ok: false, code: "runtime_error", message: "surface not ready" };
392
+ return callSurfaceTyped((s) => s.getBoundingRect({ surfaceId: sid, selector, frameId: opts?.frameId }));
393
+ }
394
+
395
+ async listFrames(): Promise<ListFramesResult> {
396
+ const sid = this._surfaceId;
397
+ if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
398
+ return callSurfaceTyped((s) => s.listFrames({ surfaceId: sid }));
399
+ }
400
+
401
+ async resolveAndClick(
402
+ selector: string,
403
+ opts?: {
404
+ frameId?: string;
405
+ button?: "left" | "middle" | "right";
406
+ clickCount?: number;
407
+ modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
408
+ }
409
+ ): Promise<ResolveAndClickResult> {
410
+ const sid = this._surfaceId;
411
+ if (sid == null) return { ok: false, code: "runtime_error", message: "surface not ready" };
412
+ return callSurfaceTyped((s) => s.resolveAndClick({ surfaceId: sid, selector, ...opts }));
413
+ }
414
+
415
+ async setDownloadPolicy(policy: DownloadPolicy, downloadDir?: string): Promise<void> {
416
+ const sid = this._surfaceId;
417
+ if (sid == null) return;
418
+ await callSurface((s) => s.setDownloadPolicy({ surfaceId: sid, policy, downloadDir }));
419
+ }
420
+
421
+ async waitForDownload(opts?: { timeoutMs?: number }): Promise<WaitForDownloadResult> {
422
+ const sid = this._surfaceId;
423
+ if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
424
+ return callSurfaceTyped((s) => s.waitForDownload({ surfaceId: sid, timeoutMs: opts?.timeoutMs }));
425
+ }
426
+
427
+ async dismissPopup(newSurfaceId: number): Promise<void> {
428
+ await callSurface((s) => s.dismissPopup({ newSurfaceId }));
429
+ }
430
+
431
+ async extendAdoptionTimeout(newSurfaceId: number, gracePeriodMs: number) {
432
+ return callSurfaceTyped((s) => s.extendPopupTimeout({ newSurfaceId, gracePeriodMs }));
433
+ }
434
+
364
435
  async screenshot(args?: { format?: "png" | "jpeg"; quality?: number }): Promise<ScreenshotResult> {
365
436
  const sid = this._surfaceId;
366
437
  if (sid == null) return { ok: false, code: "not_supported", message: "surface not ready" };
@@ -374,11 +445,71 @@ class BuniteWebviewElement extends HTMLElement {
374
445
  void callSurface((s) => s.setHidden({ surfaceId: sid, hidden }));
375
446
  }
376
447
 
448
+ private _setupSyncCtrl() {
449
+ this._syncCtrl = new OverlaySyncController(this, (rect) => {
450
+ const sid = this._surfaceId;
451
+ if (sid == null) return;
452
+ const isZero = rect.width === 0 && rect.height === 0;
453
+ if (isZero) {
454
+ if (!this._syncHidden) {
455
+ this._syncHidden = true;
456
+ this._applySurfaceHidden();
457
+ }
458
+ return;
459
+ }
460
+ if (this._syncHidden) {
461
+ this._syncHidden = false;
462
+ this._applySurfaceHidden();
463
+ }
464
+ void callSurface((s) => s.resize({
465
+ surfaceId: sid, x: rect.x, y: rect.y, w: rect.width, h: rect.height,
466
+ }));
467
+ });
468
+ this._syncCtrl.start();
469
+ }
470
+
377
471
  private initSurface() {
378
472
  if (this._surfaceId != null || this._initPromise != null) return;
379
473
 
380
474
  const dpr = window.devicePixelRatio || 1;
381
475
  const r = this.getBoundingClientRect();
476
+ const adoptAttr = this.getAttribute("adopt-popup-id");
477
+ const adoptId = adoptAttr ? Number(adoptAttr) : NaN;
478
+ if (Number.isFinite(adoptId)) {
479
+ // Popup adoption — bind a backend-minted surface (received via the parent
480
+ // page's `surface-event` `popup` arm) to this element.
481
+ const initPromise = getSurfaceCap().then(async (s) => {
482
+ const res = await s.acceptPopup({
483
+ newSurfaceId: adoptId,
484
+ hostViewId: __buniteWebviewId,
485
+ bounds: {
486
+ x: Math.round(r.x * dpr),
487
+ y: Math.round(r.y * dpr),
488
+ width: Math.round(r.width * dpr),
489
+ height: Math.round(r.height * dpr),
490
+ },
491
+ });
492
+ if (!res.ok) throw new Error(`acceptPopup failed: ${res.code}: ${res.message}`);
493
+ return { surfaceId: adoptId };
494
+ }) as Promise<SurfaceInitResponse>;
495
+ this._initPromise = initPromise;
496
+ initPromise.then((response) => {
497
+ if (this._initPromise !== initPromise) return;
498
+ if (this._aborted) {
499
+ void callSurface((s) => s.remove({ surfaceId: response.surfaceId }));
500
+ return;
501
+ }
502
+ this._surfaceId = response.surfaceId;
503
+ this._setupSyncCtrl();
504
+ }).catch((err) => {
505
+ if ((globalThis as { __BUNITE_DEBUG__?: boolean }).__BUNITE_DEBUG__) {
506
+ console.warn("[bunite] adopt-popup-id init failed", err);
507
+ }
508
+ this._initPromise = null;
509
+ });
510
+ return;
511
+ }
512
+
382
513
  const src = this._pendingSrc || this.getAttribute("src") || "";
383
514
  this._pendingSrc = null;
384
515
 
@@ -415,28 +546,7 @@ class BuniteWebviewElement extends HTMLElement {
415
546
  }
416
547
  }
417
548
 
418
- this._syncCtrl = new OverlaySyncController(this, (rect) => {
419
- const sid = this._surfaceId;
420
- if (sid == null) return;
421
-
422
- const isZero = rect.width === 0 && rect.height === 0;
423
- if (isZero) {
424
- if (!this._syncHidden) {
425
- this._syncHidden = true;
426
- this._applySurfaceHidden();
427
- }
428
- return;
429
- }
430
- if (this._syncHidden) {
431
- this._syncHidden = false;
432
- this._applySurfaceHidden();
433
- }
434
-
435
- void callSurface((s) => s.resize({
436
- surfaceId: sid, x: rect.x, y: rect.y, w: rect.width, h: rect.height,
437
- }));
438
- });
439
- this._syncCtrl.start();
549
+ this._setupSyncCtrl();
440
550
  })
441
551
  .catch(() => {})
442
552
  .finally(() => {