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
@@ -6,9 +6,11 @@ import {
6
6
  } from "./SurfaceRegistry";
7
7
  import {
8
8
  SurfaceCap, type ImplOf, IpcError,
9
- type SurfaceEvent, type DialogEvent, type ConsoleEntry,
9
+ type SurfaceEvent, type SurfaceEventBase, type DialogEvent, type ConsoleEntry,
10
+ type NavigationState, type DownloadEvent, type WaitForDownloadResult,
10
11
  } from "../../rpc/index";
11
12
  import { Stream } from "../../rpc/stream";
13
+ import { log } from "../log";
12
14
 
13
15
  function applyHostOffset(hostView: BrowserView, x: number, y: number) {
14
16
  return { x: x + hostView.frame.x, y: y + hostView.frame.y };
@@ -24,10 +26,32 @@ export function onSurfaceInit(cb: SurfaceInitCallback) {
24
26
  type SurfaceEventEmit = (event: { surfaceId: number; event: SurfaceEvent }) => void;
25
27
  const surfaceEventSubs = new Map<number, Set<SurfaceEventEmit>>();
26
28
 
27
- export function emitSurfaceEvent(hostViewId: number, surfaceId: number, event: SurfaceEvent) {
29
+ export function emitSurfaceEvent(
30
+ hostViewId: number,
31
+ surfaceId: number,
32
+ event: SurfaceEventBase,
33
+ ) {
34
+ // Drop late events after dispose so dead surfaceIds don't resurrect state.
35
+ if (!getSurfaceRecord(surfaceId)) return;
36
+ // Mutate before the subscriber guard so `getNavigationState` stays correct.
37
+ const state = getOrCreateState(surfaceId);
38
+ if (event.type === "navigate") {
39
+ state.lastLoadEpoch++;
40
+ state.currentUrl = event.url;
41
+ } else if (event.type === "load-start") {
42
+ state.isLoading = true;
43
+ } else if (event.type === "load-finish" || event.type === "load-fail") {
44
+ state.isLoading = false;
45
+ }
46
+ const stamped: SurfaceEvent = { ...event, epoch: state.lastLoadEpoch };
28
47
  const subs = surfaceEventSubs.get(hostViewId);
29
48
  if (!subs) return;
30
- for (const emit of subs) emit({ surfaceId, event });
49
+ for (const emit of subs) emit({ surfaceId, event: stamped });
50
+ }
51
+
52
+ export function seedNavigationState(surfaceId: number, initialUrl: string) {
53
+ const state = getOrCreateState(surfaceId);
54
+ if (state.currentUrl === "") state.currentUrl = initialUrl;
31
55
  }
32
56
 
33
57
  type DialogEmit = (event: { surfaceId: number; event: DialogEvent }) => void;
@@ -36,6 +60,133 @@ const dialogSubs = new Map<number, Set<DialogEmit>>();
36
60
  type ConsoleEmit = (event: { surfaceId: number; entry: ConsoleEntry }) => void;
37
61
  const consoleSubs = new Map<number, Set<ConsoleEmit>>();
38
62
 
63
+ type DownloadEmit = (event: { surfaceId: number; event: DownloadEvent }) => void;
64
+ const downloadSubs = new Map<number, Set<DownloadEmit>>();
65
+
66
+ type PendingPopup = {
67
+ newSurfaceId: number;
68
+ openerHostViewId: number;
69
+ openerSurfaceId: number;
70
+ url: string;
71
+ disposition: "tab" | "window" | "popup";
72
+ timer: ReturnType<typeof setTimeout> | null;
73
+ armTs: number; // arm emit timestamp for the 60s extend cap
74
+ };
75
+ const pendingPopups = new Map<number, PendingPopup>();
76
+ const POPUP_ADOPT_TIMEOUT_MS = 5000;
77
+ const POPUP_EXTEND_CAP_MS = 60_000;
78
+ // Capped log so callers calling extendPopupTimeout after resolution get a
79
+ // distinct error code instead of bare not_found.
80
+ const popupResolutionLog = new Map<number, "adopted" | "dismissed">();
81
+ const POPUP_RESOLUTION_LOG_MAX = 64;
82
+ function recordResolution(id: number, kind: "adopted" | "dismissed") {
83
+ if (popupResolutionLog.size >= POPUP_RESOLUTION_LOG_MAX) {
84
+ const firstKey = popupResolutionLog.keys().next().value;
85
+ if (firstKey !== undefined) popupResolutionLog.delete(firstKey);
86
+ }
87
+ popupResolutionLog.set(id, kind);
88
+ popupCounters[kind] += 1;
89
+ }
90
+
91
+ // Process-lifetime popup lifecycle counters; surfaced via RuntimeCap.popupMetrics.
92
+ const popupCounters = { armed: 0, adopted: 0, dismissed: 0, timeoutFired: 0, extended: 0 };
93
+ export function getPopupMetricsSnapshot() { return { ...popupCounters }; }
94
+
95
+ /** Called when a backend mints a popup view. Stashes the pending adoption +
96
+ * arms a timer that auto-dismisses if the host doesn't respond. */
97
+ export function emitPopupRequested(
98
+ hostViewId: number,
99
+ openerSurfaceId: number,
100
+ args: { newSurfaceId: number; url: string; disposition: "tab" | "window" | "popup" },
101
+ ) {
102
+ const armTs = Date.now();
103
+ popupCounters.armed += 1;
104
+ const entry: PendingPopup = {
105
+ newSurfaceId: args.newSurfaceId,
106
+ openerHostViewId: hostViewId,
107
+ openerSurfaceId,
108
+ url: args.url,
109
+ disposition: args.disposition,
110
+ timer: null,
111
+ armTs,
112
+ };
113
+ entry.timer = setTimeout(() => {
114
+ if (!pendingPopups.delete(args.newSurfaceId)) return;
115
+ popupCounters.timeoutFired += 1;
116
+ recordResolution(args.newSurfaceId, "dismissed");
117
+ BrowserView.dismissPopupById(args.newSurfaceId);
118
+ }, POPUP_ADOPT_TIMEOUT_MS);
119
+ pendingPopups.set(args.newSurfaceId, entry);
120
+ emitSurfaceEvent(hostViewId, openerSurfaceId, {
121
+ type: "popup",
122
+ url: args.url,
123
+ disposition: args.disposition,
124
+ openerSurfaceId,
125
+ newSurfaceId: args.newSurfaceId,
126
+ });
127
+ }
128
+ type DownloadWaiter = {
129
+ /** Resolved on the next `completed` event (or `failed`/`blocked`). */
130
+ resolve: (r: WaitForDownloadResult) => void;
131
+ /** Captured at registration; only events with `id` started after this are eligible. */
132
+ pendingId: string | null;
133
+ };
134
+ const downloadWaiters = new Map<number, DownloadWaiter[]>();
135
+ const downloadStartedMeta = new Map<number, Map<string, { url: string; suggestedFilename: string; mimeType?: string; sizeBytes?: number }>>();
136
+ // Recent started events that no waiter has claimed yet — lets a `waitForDownload`
137
+ // registered *after* the started event still bind. Per-surface, trimmed to 30s.
138
+ const recentUnownedStarts = new Map<number, { id: string; ts: number }[]>();
139
+
140
+ export function emitDownload(hostViewId: number, surfaceId: number, event: DownloadEvent) {
141
+ if (event.kind === "started") {
142
+ let bySurface = downloadStartedMeta.get(surfaceId);
143
+ if (!bySurface) { bySurface = new Map(); downloadStartedMeta.set(surfaceId, bySurface); }
144
+ bySurface.set(event.id, { url: event.url, suggestedFilename: event.suggestedFilename, mimeType: event.mimeType, sizeBytes: event.sizeBytes });
145
+ // Track unowned started events so a waitForDownload registering AFTER the
146
+ // started event can still bind. Trimmed to recent 30s on each insert.
147
+ let recents = recentUnownedStarts.get(surfaceId);
148
+ if (!recents) { recents = []; recentUnownedStarts.set(surfaceId, recents); }
149
+ const now = Date.now();
150
+ recents.push({ id: event.id, ts: now });
151
+ while (recents.length && now - recents[0].ts > 30_000) recents.shift();
152
+ const queue = downloadWaiters.get(surfaceId);
153
+ if (queue) for (const w of queue) if (w.pendingId === null) {
154
+ w.pendingId = event.id;
155
+ // Take the unowned entry out — it's now bound.
156
+ const idx = recents.findIndex((r) => r.id === event.id);
157
+ if (idx >= 0) recents.splice(idx, 1);
158
+ break;
159
+ }
160
+ }
161
+ const subs = downloadSubs.get(hostViewId);
162
+ if (subs) for (const emit of subs) emit({ surfaceId, event });
163
+ if (event.kind === "completed" || event.kind === "failed" || event.kind === "blocked") {
164
+ const queue = downloadWaiters.get(surfaceId);
165
+ if (queue) {
166
+ // First match by id (started → terminal pair). Else for blocked-without-started
167
+ // (policy=block emits only blocked), bind to the first waiting waiter.
168
+ let idx = queue.findIndex((w) => w.pendingId === event.id);
169
+ if (idx < 0 && event.kind === "blocked") idx = queue.findIndex((w) => w.pendingId === null);
170
+ if (idx >= 0) {
171
+ const [waiter] = queue.splice(idx, 1);
172
+ if (event.kind === "completed") {
173
+ const meta = downloadStartedMeta.get(surfaceId)?.get(event.id);
174
+ waiter.resolve({
175
+ ok: true, id: event.id, localPath: event.localPath,
176
+ url: meta?.url ?? "", suggestedFilename: meta?.suggestedFilename ?? "",
177
+ mimeType: meta?.mimeType, sizeBytes: meta?.sizeBytes,
178
+ });
179
+ } else if (event.kind === "failed") {
180
+ waiter.resolve({ ok: false, code: "failed", message: event.reason });
181
+ } else {
182
+ waiter.resolve({ ok: false, code: "blocked", message: event.reason });
183
+ }
184
+ }
185
+ }
186
+ downloadStartedMeta.get(surfaceId)?.delete(event.id);
187
+ }
188
+ }
189
+
39
190
  const CONSOLE_BUFFER_LIMIT = 200;
40
191
  const DEFAULT_DIALOG_TIMEOUT_MS = 5000;
41
192
 
@@ -50,6 +201,9 @@ type SurfaceState = {
50
201
  consoleBuffer: ConsoleEntry[];
51
202
  dialogTimeoutMs: number | null; // null = no auto-dismiss
52
203
  pendingDialogs: Map<number, PendingDialog>;
204
+ lastLoadEpoch: number;
205
+ isLoading: boolean;
206
+ currentUrl: string;
53
207
  };
54
208
 
55
209
  const surfaceState = new Map<number, SurfaceState>();
@@ -61,6 +215,9 @@ function getOrCreateState(surfaceId: number): SurfaceState {
61
215
  consoleBuffer: [],
62
216
  dialogTimeoutMs: DEFAULT_DIALOG_TIMEOUT_MS,
63
217
  pendingDialogs: new Map(),
218
+ lastLoadEpoch: 0,
219
+ isLoading: false,
220
+ currentUrl: "",
64
221
  };
65
222
  surfaceState.set(surfaceId, s);
66
223
  }
@@ -69,11 +226,17 @@ function getOrCreateState(surfaceId: number): SurfaceState {
69
226
 
70
227
  export function disposeSurfaceState(surfaceId: number) {
71
228
  const s = surfaceState.get(surfaceId);
72
- if (!s) return;
73
- for (const p of s.pendingDialogs.values()) {
74
- if (p.timer) clearTimeout(p.timer);
229
+ if (s) {
230
+ for (const p of s.pendingDialogs.values()) if (p.timer) clearTimeout(p.timer);
231
+ surfaceState.delete(surfaceId);
232
+ }
233
+ const waiters = downloadWaiters.get(surfaceId);
234
+ if (waiters) {
235
+ for (const w of waiters) w.resolve({ ok: false, code: "not_supported", message: "surface destroyed" });
236
+ downloadWaiters.delete(surfaceId);
75
237
  }
76
- surfaceState.delete(surfaceId);
238
+ downloadStartedMeta.delete(surfaceId);
239
+ recentUnownedStarts.delete(surfaceId);
77
240
  }
78
241
 
79
242
  // Wire dispose to any untrack path (remove + removeSurfacesForHostView).
@@ -86,6 +249,8 @@ export function clearConsoleBuffer(surfaceId: number) {
86
249
 
87
250
  export function emitDialog(hostViewId: number, surfaceId: number, event: DialogEvent) {
88
251
  const subs = dialogSubs.get(hostViewId);
252
+ log.debug("dialog/emit hostViewId=" + hostViewId + " surfaceId=" + surfaceId +
253
+ " kind=" + (event as { kind?: string }).kind + " subscribers=" + (subs?.size ?? 0));
89
254
  if (!subs) return;
90
255
  for (const emit of subs) emit({ surfaceId, event });
91
256
  }
@@ -108,6 +273,8 @@ export function registerDialogRequest(
108
273
  surfaceId: number,
109
274
  request: { requestId: number; kind: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
110
275
  ) {
276
+ log.debug("dialog/register hostViewId=" + hostViewId + " surfaceId=" + surfaceId +
277
+ " kind=" + request.kind + " rid=" + request.requestId);
111
278
  const state = getOrCreateState(surfaceId);
112
279
  const view = getSurfaceRecord(surfaceId)?.view;
113
280
 
@@ -174,6 +341,7 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
174
341
  autoResize: false,
175
342
  });
176
343
  trackSurface(view.id, { view, hostViewId, hidden });
344
+ seedNavigationState(view.id, src);
177
345
  try {
178
346
  await view.whenReady();
179
347
  } catch {
@@ -258,10 +426,10 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
258
426
  record?.view.reload();
259
427
  },
260
428
 
261
- evaluate: async ({ surfaceId, script }) => {
429
+ evaluate: async ({ surfaceId, script, frameId }) => {
262
430
  const record = ownedSurface(surfaceId);
263
431
  if (!record) return { ok: false, code: "not_supported", message: "surface not found" };
264
- return record.view.evaluate(script);
432
+ return record.view.evaluate(script, frameId);
265
433
  },
266
434
 
267
435
  click: ({ surfaceId, ...args }) => {
@@ -300,17 +468,15 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
300
468
  return record.view.screenshot(format, quality);
301
469
  },
302
470
 
303
- waitForSelector: async ({ surfaceId, selector, timeoutMs = 5000 }) => {
471
+ waitForSelector: async ({ surfaceId, selector, timeoutMs = 5000, frameId }) => {
304
472
  const record = ownedSurface(surfaceId);
305
473
  if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
306
474
  const deadline = Date.now() + timeoutMs;
307
475
  const expr = `!!document.querySelector(${JSON.stringify(selector)})`;
308
476
  while (Date.now() < deadline) {
309
- const res = await record.view.evaluate(expr);
477
+ const res = await record.view.evaluate(expr, frameId);
310
478
  if (res.ok && res.value === true) return { ok: true as const };
311
479
  if (!res.ok && res.code !== "timeout") {
312
- // Propagate cross_origin distinct from runtime_error — consumer
313
- // may want to retry with a same-origin sub-surface vs. fix the script.
314
480
  const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
315
481
  return { ok: false as const, code, message: res.message };
316
482
  }
@@ -319,12 +485,12 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
319
485
  return { ok: false as const, code: "timeout" as const, message: `selector ${JSON.stringify(selector)} not found within ${timeoutMs}ms` };
320
486
  },
321
487
 
322
- waitForFunction: async ({ surfaceId, expression, timeoutMs = 5000, pollIntervalMs = 50 }) => {
488
+ waitForFunction: async ({ surfaceId, expression, timeoutMs = 5000, pollIntervalMs = 50, frameId }) => {
323
489
  const record = ownedSurface(surfaceId);
324
490
  if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
325
491
  const deadline = Date.now() + timeoutMs;
326
492
  while (Date.now() < deadline) {
327
- const res = await record.view.evaluate(expression);
493
+ const res = await record.view.evaluate(expression, frameId);
328
494
  if (res.ok && res.value) return { ok: true as const };
329
495
  if (!res.ok && res.code !== "timeout") {
330
496
  const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
@@ -365,7 +531,8 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
365
531
  evaluate: false, crossOriginEval: false, surfaceEvents: false,
366
532
  nativeInputTrusted: false, click: false, type: false, press: false,
367
533
  scroll: false, mouse: false, dialogs: false, console: false,
368
- screenshot: false,
534
+ screenshot: false, accessibilitySnapshot: false, getBoundingRect: false,
535
+ frames: false, downloads: false, popups: false, resolveAndClick: false,
369
536
  };
370
537
  }
371
538
  return record.view.capabilities();
@@ -399,14 +566,188 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
399
566
  if (surfaceId === filterId) emit(event);
400
567
  };
401
568
  subs.add(wrapped);
569
+ log.debug("dialog/subscribe hostViewId=" + hostViewId + " filterId=" + filterId + " total=" + subs.size);
402
570
  signal.addEventListener("abort", () => {
403
571
  const set = dialogSubs.get(hostViewId);
404
572
  if (!set) return;
405
573
  set.delete(wrapped);
574
+ log.debug("dialog/unsubscribe hostViewId=" + hostViewId + " filterId=" + filterId + " remaining=" + set.size);
406
575
  if (set.size === 0) dialogSubs.delete(hostViewId);
407
576
  });
408
577
  }),
409
578
 
579
+ getNavigationState: ({ surfaceId }): NavigationState => {
580
+ const record = ownedSurface(surfaceId);
581
+ if (!record) return { lastLoadEpoch: 0, isLoading: false, currentUrl: "" };
582
+ const state = getOrCreateState(surfaceId);
583
+ return {
584
+ lastLoadEpoch: state.lastLoadEpoch,
585
+ isLoading: state.isLoading,
586
+ currentUrl: state.currentUrl,
587
+ };
588
+ },
589
+
590
+ accessibilitySnapshot: async ({ surfaceId, interestingOnly = true }) => {
591
+ const record = ownedSurface(surfaceId);
592
+ if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
593
+ return record.view.accessibilitySnapshot(interestingOnly);
594
+ },
595
+
596
+ getBoundingRect: async ({ surfaceId, selector, frameId }) => {
597
+ const record = ownedSurface(surfaceId);
598
+ if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
599
+ const expr = `(function(){var el=document.querySelector(${JSON.stringify(selector)});if(!el)return null;var r=el.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,visible:r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.top<innerHeight&&r.left<innerWidth};})()`;
600
+ const res = await record.view.evaluate(expr, frameId);
601
+ if (!res.ok) {
602
+ const code = res.code === "cross_origin" ? "cross_origin" as const
603
+ : res.code === "not_supported" ? "not_supported" as const
604
+ : "runtime_error" as const;
605
+ return { ok: false as const, code, message: res.message };
606
+ }
607
+ const v = res.value as null | { x: number; y: number; width: number; height: number; visible: boolean };
608
+ if (!v) return { ok: false as const, code: "not_found" as const, message: `selector ${JSON.stringify(selector)} not found` };
609
+ return { ok: true as const, rect: { x: v.x, y: v.y, width: v.width, height: v.height }, visible: v.visible };
610
+ },
611
+
612
+ listFrames: async ({ surfaceId }) => {
613
+ const record = ownedSurface(surfaceId);
614
+ if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
615
+ return record.view.listFrames();
616
+ },
617
+
618
+ resolveAndClick: async (args) => {
619
+ const record = ownedSurface(args.surfaceId);
620
+ if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
621
+ if (!record.view.capabilities().resolveAndClick) {
622
+ return { ok: false as const, code: "not_supported" as const, message: "resolveAndClick not supported on this backend" };
623
+ }
624
+ return record.view.resolveAndClick(args);
625
+ },
626
+
627
+ downloadEvents: ({ surfaceId: filterId }) => Stream.from<DownloadEvent>((emit, signal) => {
628
+ let subs = downloadSubs.get(hostViewId);
629
+ if (!subs) { subs = new Set(); downloadSubs.set(hostViewId, subs); }
630
+ const wrapped: DownloadEmit = ({ surfaceId, event }) => {
631
+ if (surfaceId === filterId) emit(event);
632
+ };
633
+ subs.add(wrapped);
634
+ signal.addEventListener("abort", () => {
635
+ const set = downloadSubs.get(hostViewId);
636
+ if (!set) return;
637
+ set.delete(wrapped);
638
+ if (set.size === 0) downloadSubs.delete(hostViewId);
639
+ });
640
+ }),
641
+
642
+ waitForDownload: async ({ surfaceId, timeoutMs = 30000 }) => {
643
+ const record = ownedSurface(surfaceId);
644
+ if (!record) return { ok: false as const, code: "not_supported" as const, message: "surface not found" };
645
+ if (!record.view.capabilities().downloads) {
646
+ return { ok: false as const, code: "not_supported" as const, message: "downloads not supported on this backend" };
647
+ }
648
+ return new Promise<WaitForDownloadResult>((resolve) => {
649
+ // If a `started` already arrived without a waiter, consume it.
650
+ const recents = recentUnownedStarts.get(surfaceId);
651
+ const claimed = recents?.shift();
652
+ const waiter: DownloadWaiter = {
653
+ resolve: (r) => { clearTimeout(timer); resolve(r); },
654
+ pendingId: claimed?.id ?? null,
655
+ };
656
+ let queue = downloadWaiters.get(surfaceId);
657
+ if (!queue) { queue = []; downloadWaiters.set(surfaceId, queue); }
658
+ queue.push(waiter);
659
+ const timer = setTimeout(() => {
660
+ const q = downloadWaiters.get(surfaceId);
661
+ if (!q) return;
662
+ const idx = q.indexOf(waiter);
663
+ if (idx >= 0) q.splice(idx, 1);
664
+ resolve({ ok: false, code: "timeout", message: `no download started within ${timeoutMs}ms` });
665
+ }, timeoutMs);
666
+ });
667
+ },
668
+
669
+ setDownloadPolicy: ({ surfaceId, policy, downloadDir }) => {
670
+ const record = ownedSurface(surfaceId);
671
+ if (!record) return;
672
+ if (!record.view.capabilities().downloads) return; // mac/linux silent no-op signal — caller should gate on cap.
673
+ record.view.setDownloadPolicy(policy, downloadDir);
674
+ },
675
+
676
+ acceptPopup: async ({ newSurfaceId, hostViewId: targetHostId, bounds }) => {
677
+ const pending = pendingPopups.get(newSurfaceId);
678
+ if (!pending) return { ok: false as const, code: "not_found" as const, message: "popup not pending" };
679
+ // Only the opener's host page can adopt the popup. The target host
680
+ // (where the new pane lands) is a separate decision.
681
+ if (pending.openerHostViewId !== hostViewId) {
682
+ return { ok: false as const, code: "not_found" as const, message: "popup not owned by this host" };
683
+ }
684
+ const targetHost = BrowserView.getById(targetHostId);
685
+ if (!targetHost || !targetHost.windowId) {
686
+ // Don't consume pending state on validation failure — host can retry
687
+ // with a different target until the auto-dismiss timer fires.
688
+ return { ok: false as const, code: "host_view_invalid" as const, message: "host view not found" };
689
+ }
690
+ const existing = getHostSurfaceIds(targetHostId);
691
+ if (existing && existing.size >= MAX_SURFACES_PER_HOST) {
692
+ return { ok: false as const, code: "host_view_invalid" as const, message: `host surface limit reached (${MAX_SURFACES_PER_HOST})` };
693
+ }
694
+ if (pending.timer) clearTimeout(pending.timer);
695
+ pendingPopups.delete(newSurfaceId);
696
+ recordResolution(newSurfaceId, "adopted");
697
+ const offset = applyHostOffset(targetHost, bounds.x, bounds.y);
698
+ const view = BrowserView.adopt({
699
+ nativeViewId: newSurfaceId,
700
+ hostWindowId: targetHost.windowId,
701
+ bounds: { x: offset.x, y: offset.y, width: bounds.width, height: bounds.height },
702
+ appresRoot: targetHost.appresRoot,
703
+ });
704
+ trackSurface(view.id, { view, hostViewId: targetHostId, hidden: false });
705
+ seedNavigationState(view.id, pending.url);
706
+ for (const cb of initCallbacks) cb(view.id, targetHostId, view);
707
+ return { ok: true as const };
708
+ },
709
+
710
+ dismissPopup: ({ newSurfaceId }) => {
711
+ const pending = pendingPopups.get(newSurfaceId);
712
+ if (!pending) return;
713
+ if (pending.openerHostViewId !== hostViewId) return; // not this host's popup
714
+ if (pending.timer) clearTimeout(pending.timer);
715
+ pendingPopups.delete(newSurfaceId);
716
+ recordResolution(newSurfaceId, "dismissed");
717
+ BrowserView.dismissPopupById(newSurfaceId);
718
+ },
719
+
720
+ extendPopupTimeout: ({ newSurfaceId, gracePeriodMs }) => {
721
+ if (!Number.isFinite(gracePeriodMs) || gracePeriodMs <= 0) {
722
+ return { ok: false as const, code: "not_found" as const, message: "gracePeriodMs must be a positive finite number" };
723
+ }
724
+ const pending = pendingPopups.get(newSurfaceId);
725
+ if (!pending) {
726
+ const prior = popupResolutionLog.get(newSurfaceId);
727
+ if (prior === "adopted") return { ok: false as const, code: "already_adopted" as const, message: "popup adopted" };
728
+ if (prior === "dismissed") return { ok: false as const, code: "already_dismissed" as const, message: "popup dismissed" };
729
+ return { ok: false as const, code: "not_found" as const, message: "popup not pending" };
730
+ }
731
+ if (pending.openerHostViewId !== hostViewId) {
732
+ return { ok: false as const, code: "not_found" as const, message: "popup not owned by this host" };
733
+ }
734
+ const now = Date.now();
735
+ const requested = now + gracePeriodMs;
736
+ const cap = pending.armTs + POPUP_EXTEND_CAP_MS;
737
+ if (requested > cap) {
738
+ return { ok: false as const, code: "cap_exceeded" as const, message: `extend exceeds ${POPUP_EXTEND_CAP_MS}ms cap since arm` };
739
+ }
740
+ if (pending.timer) clearTimeout(pending.timer);
741
+ pending.timer = setTimeout(() => {
742
+ if (!pendingPopups.delete(newSurfaceId)) return;
743
+ popupCounters.timeoutFired += 1;
744
+ recordResolution(newSurfaceId, "dismissed");
745
+ BrowserView.dismissPopupById(newSurfaceId);
746
+ }, gracePeriodMs);
747
+ popupCounters.extended += 1;
748
+ return { ok: true as const, deadlineMs: requested };
749
+ },
750
+
410
751
  consoleEvents: ({ surfaceId: filterId }) => Stream.from<ConsoleEntry>((emit, signal) => {
411
752
  let subs = consoleSubs.get(hostViewId);
412
753
  if (!subs) {
@@ -0,0 +1,69 @@
1
+ import { BrowserWindow } from "./BrowserWindow";
2
+ import {
3
+ BrowserWindowCap, WindowCap, IpcError,
4
+ type ImplOf, type WindowState,
5
+ } from "../../rpc/index";
6
+ import { Stream } from "../../rpc/stream";
7
+
8
+ function browserWindowImpl(win: BrowserWindow): ImplOf<typeof BrowserWindowCap> {
9
+ return {
10
+ focus: () => win.focus(),
11
+ close: () => win.close(),
12
+ setBounds: ({ x, y, w, h }) => win.setFrame(x, y, w, h),
13
+ setTitle: ({ title }) => win.setTitle(title),
14
+ id: () => win.id,
15
+ label: () => win.label,
16
+ minimize: () => win.minimize(),
17
+ unminimize: () => win.unminimize(),
18
+ maximize: () => win.maximize(),
19
+ unmaximize: () => win.unmaximize(),
20
+ toggleMaximize: () => win.toggleMaximize(),
21
+ beginMoveDrag: () => win.beginMoveDrag(),
22
+ getState: () => win.getState(),
23
+ stateWatch: () => Stream.from<WindowState>((emit, signal) => {
24
+ let last = "";
25
+ const push = () => {
26
+ const s = win.getState();
27
+ const key = `${s.maximized}|${s.minimized}|${s.focused}`;
28
+ if (key === last) return;
29
+ last = key;
30
+ emit(s);
31
+ };
32
+ push(); // initial snapshot
33
+ const offs = [
34
+ win.on("focus", push), win.on("blur", push),
35
+ win.on("move", push), win.on("resize", push),
36
+ ];
37
+ signal.addEventListener("abort", () => { for (const off of offs) off(); });
38
+ }),
39
+ };
40
+ }
41
+
42
+ function resolve(args: { id?: number; label?: string }): BrowserWindow | undefined {
43
+ if (args.id != null) return BrowserWindow.getById(args.id);
44
+ if (args.label) return BrowserWindow.getAll().find((w) => w.label === args.label);
45
+ return undefined;
46
+ }
47
+
48
+ /** WindowCap impl for the renderer of `viewId`. `current()` resolves the owning
49
+ * window host-side from the session's viewId — never from a page-supplied id. */
50
+ export function createWindowCapImpl(viewId: number): ImplOf<typeof WindowCap> {
51
+ return {
52
+ create: ({ url, title, bounds, label }, ctx) => {
53
+ const win = new BrowserWindow({
54
+ url, title, label,
55
+ frame: { x: bounds?.x ?? 80, y: bounds?.y ?? 80, width: bounds?.w ?? 1280, height: bounds?.h ?? 900 },
56
+ });
57
+ return ctx.exportCap(BrowserWindowCap, browserWindowImpl(win));
58
+ },
59
+ list: (_void, ctx) =>
60
+ BrowserWindow.getAll().map((w) => ctx.exportCap(BrowserWindowCap, browserWindowImpl(w))),
61
+ current: (_void, ctx) => {
62
+ const win = BrowserWindow.getByWebviewId(viewId);
63
+ if (!win) throw new IpcError({ code: "not_found", message: "no window for this view" });
64
+ return ctx.exportCap(BrowserWindowCap, browserWindowImpl(win));
65
+ },
66
+ focus: (args) => { resolve(args)?.focus(); },
67
+ close: (args) => { resolve(args)?.close(); },
68
+ };
69
+ }
@@ -15,5 +15,22 @@ export default {
15
15
  dialog: (data: { requestId: number; kind: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }) =>
16
16
  new BuniteEvent("dialog", data),
17
17
  consoleMessage: (data: { level: "log" | "warn" | "error" | "info" | "debug"; args: string[]; ts: number }) =>
18
- new BuniteEvent("console-message", data)
18
+ new BuniteEvent("console-message", data),
19
+ downloadEvent: (data: {
20
+ kind: "started" | "progress" | "completed" | "failed" | "blocked";
21
+ id: string;
22
+ url?: string;
23
+ suggestedFilename?: string;
24
+ mimeType?: string;
25
+ sizeBytes?: number;
26
+ receivedBytes?: number;
27
+ totalBytes?: number;
28
+ localPath?: string;
29
+ reason?: string;
30
+ }) => new BuniteEvent("download-event", data),
31
+ popupRequested: (data: {
32
+ newSurfaceId: number;
33
+ url: string;
34
+ disposition: "tab" | "window" | "popup";
35
+ }) => new BuniteEvent("popup-requested", data),
19
36
  };
package/src/host/log.ts CHANGED
@@ -8,7 +8,12 @@ const levels: Record<LogLevel, number> = {
8
8
  silent: 4
9
9
  };
10
10
 
11
- let currentLevel: LogLevel = "warn";
11
+ function initialLevel(): LogLevel {
12
+ const v = (typeof process !== "undefined" ? process.env?.BUNITE_LOG_LEVEL : undefined);
13
+ return v === "debug" || v === "info" || v === "warn" || v === "error" || v === "silent" ? v : "warn";
14
+ }
15
+
16
+ let currentLevel: LogLevel = initialLevel();
12
17
 
13
18
  function shouldLog(level: LogLevel): boolean {
14
19
  return levels[level] >= levels[currentLevel];