bunite-core 0.12.1 → 0.16.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 (36) hide show
  1. package/package.json +4 -4
  2. package/src/host/core/App.ts +19 -2
  3. package/src/host/core/BrowserView.ts +515 -38
  4. package/src/host/core/SurfaceBrowserIPC.ts +53 -3
  5. package/src/host/core/SurfaceManager.ts +603 -30
  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 +25 -1
  9. package/src/host/log.ts +6 -1
  10. package/src/host/native.ts +263 -1
  11. package/src/host/preloadBundle.ts +7 -2
  12. package/src/native/linux/bunite_linux_ffi.cpp +427 -6
  13. package/src/native/linux/bunite_linux_internal.h +18 -0
  14. package/src/native/linux/bunite_linux_runtime.cpp +6 -1
  15. package/src/native/linux/bunite_linux_utils.cpp +2 -2
  16. package/src/native/linux/bunite_linux_view.cpp +296 -5
  17. package/src/native/mac/bunite_mac_ffi.mm +630 -8
  18. package/src/native/mac/bunite_mac_internal.h +19 -0
  19. package/src/native/mac/bunite_mac_utils.mm +2 -2
  20. package/src/native/mac/bunite_mac_view.mm +371 -9
  21. package/src/native/shared/ffi_exports.h +200 -2
  22. package/src/native/win/native_host_cef.cpp +186 -11
  23. package/src/native/win/native_host_ffi.cpp +1194 -1
  24. package/src/native/win/native_host_internal.h +35 -0
  25. package/src/native/win/native_host_utils.cpp +2 -1
  26. package/src/native/win/process_helper_win.cpp +54 -27
  27. package/src/native/win-webview2/bunite_webview2_ffi.cpp +1023 -12
  28. package/src/native/win-webview2/webview2_internal.h +25 -0
  29. package/src/native/win-webview2/webview2_runtime.cpp +403 -34
  30. package/src/native/win-webview2/webview2_utils.cpp +30 -12
  31. package/src/preload/runtime.built.js +1 -1
  32. package/src/preload/runtime.ts +97 -0
  33. package/src/rpc/framework.ts +340 -8
  34. package/src/rpc/index.ts +32 -0
  35. package/src/webview/native.ts +253 -51
  36. package/src/webview/polyfill.ts +283 -22
@@ -1,5 +1,7 @@
1
1
  // Iframe fallback for web (no-op if native already registered). HTMLElement deref'd lazily so module is import-safe in Node/Bun.
2
2
 
3
+ import type { SurfaceEvent, SurfaceEventBase } from "../rpc/framework";
4
+
3
5
  // Default sandbox omits allow-same-origin / allow-top-navigation / allow-modals /
4
6
  // allow-popups-to-escape-sandbox — popup escape stays opt-in so a sandboxed page
5
7
  // can't launch unsandboxed auxiliary contexts by default.
@@ -27,6 +29,69 @@ function definePolyfillClass(): CustomElementConstructor {
27
29
  static observedAttributes = ["src", "sandbox", "unsandboxed"];
28
30
 
29
31
  private _iframe: HTMLIFrameElement | null = null;
32
+ private _titleObserver: MutationObserver | null = null;
33
+ private _lastTitle: string = "";
34
+ private _epoch: number = 0;
35
+ private _isLoading: boolean = false;
36
+ private _currentUrl: string = "";
37
+ // Local history stack for goBack — cross-origin contentWindow.history is
38
+ // inaccessible, so we track navigations ourselves.
39
+ private _history: string[] = [];
40
+
41
+ private isReachable(): boolean {
42
+ if (!this._iframe) return false;
43
+ try {
44
+ return this._iframe.contentDocument != null;
45
+ } catch { return false; }
46
+ }
47
+
48
+ private modifierBag(mods?: string[]): {
49
+ shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean;
50
+ } {
51
+ return {
52
+ shiftKey: !!mods?.includes("shift"),
53
+ ctrlKey: !!mods?.includes("ctrl"),
54
+ altKey: !!mods?.includes("alt"),
55
+ metaKey: !!mods?.includes("meta"),
56
+ };
57
+ }
58
+
59
+ private emit(event: SurfaceEventBase) {
60
+ if (event.type === "navigate") {
61
+ this._epoch++;
62
+ // Avoid pushing duplicate / same-as-top entries (reload doesn't grow history).
63
+ if (this._currentUrl && this._currentUrl !== event.url &&
64
+ this._history[this._history.length - 1] !== this._currentUrl) {
65
+ this._history.push(this._currentUrl);
66
+ }
67
+ this._currentUrl = event.url;
68
+ } else if (event.type === "load-start") {
69
+ this._isLoading = true;
70
+ } else if (event.type === "load-finish" || event.type === "load-fail") {
71
+ this._isLoading = false;
72
+ }
73
+ const stamped: SurfaceEvent = { ...event, epoch: this._epoch };
74
+ this.dispatchEvent(new CustomEvent<SurfaceEvent>("surface-event", { detail: stamped }));
75
+ }
76
+
77
+ private setupTitleObserver() {
78
+ this._titleObserver?.disconnect();
79
+ this._titleObserver = null;
80
+ if (!this.isReachable()) return;
81
+ const doc = this._iframe!.contentDocument!;
82
+ this._lastTitle = doc.title;
83
+ const fire = () => {
84
+ const t = doc.title;
85
+ if (t && t !== this._lastTitle) {
86
+ this._lastTitle = t;
87
+ this.emit({ type: "title-change", title: t });
88
+ }
89
+ };
90
+ const observer = new MutationObserver(fire);
91
+ const headEl = doc.head ?? doc.documentElement;
92
+ if (headEl) observer.observe(headEl, { childList: true, subtree: true, characterData: true });
93
+ this._titleObserver = observer;
94
+ }
30
95
 
31
96
  private applySandbox(iframe: HTMLIFrameElement) {
32
97
  if (this.hasAttribute("unsandboxed")) {
@@ -38,7 +103,7 @@ function definePolyfillClass(): CustomElementConstructor {
38
103
  }
39
104
 
40
105
  private dispatchBlocked(url: string) {
41
- this.dispatchEvent(new CustomEvent("did-fail-load", { detail: { url, reason: "blocked-scheme" } }));
106
+ this.emit({ type: "load-fail", url, reason: "blocked-scheme" });
42
107
  }
43
108
 
44
109
  connectedCallback() {
@@ -55,6 +120,8 @@ function definePolyfillClass(): CustomElementConstructor {
55
120
  this.dispatchBlocked(src);
56
121
  } else {
57
122
  iframe.src = src;
123
+ this._currentUrl = src;
124
+ this.emit({ type: "load-start", url: src });
58
125
  }
59
126
  }
60
127
 
@@ -66,7 +133,10 @@ function definePolyfillClass(): CustomElementConstructor {
66
133
  // Suppress the spurious about:blank load that fires after a blocked
67
134
  // navigation (or before any explicit navigate).
68
135
  if (isBlockedSrc(url)) return;
69
- this.dispatchEvent(new CustomEvent("did-navigate", { detail: { url } }));
136
+ // navigate first so load-finish carries the bumped epoch.
137
+ this.emit({ type: "navigate", url });
138
+ this.emit({ type: "load-finish", url });
139
+ this.setupTitleObserver();
70
140
  });
71
141
 
72
142
  this._iframe = iframe;
@@ -74,6 +144,8 @@ function definePolyfillClass(): CustomElementConstructor {
74
144
  }
75
145
 
76
146
  disconnectedCallback() {
147
+ this._titleObserver?.disconnect();
148
+ this._titleObserver = null;
77
149
  this._iframe?.remove();
78
150
  this._iframe = null;
79
151
  }
@@ -86,6 +158,7 @@ function definePolyfillClass(): CustomElementConstructor {
86
158
  return;
87
159
  }
88
160
  this._iframe.src = newValue ?? "";
161
+ if (newValue) this.emit({ type: "load-start", url: newValue });
89
162
  } else if (name === "sandbox" || name === "unsandboxed") {
90
163
  // Sandbox token changes take effect on the next navigation per HTML spec.
91
164
  this.applySandbox(this._iframe);
@@ -97,19 +170,22 @@ function definePolyfillClass(): CustomElementConstructor {
97
170
  }
98
171
 
99
172
  goBack() {
100
- try {
101
- this._iframe?.contentWindow?.history.back();
102
- } catch {}
173
+ // Same-origin path uses native history. Cross-origin throws → fall back
174
+ // to our tracked stack (push on every navigate; pop here).
175
+ try { this._iframe?.contentWindow?.history.back(); return; } catch {}
176
+ const prev = this._history.pop();
177
+ if (prev && this._iframe) this._iframe.src = prev;
103
178
  }
104
179
 
105
180
  reload() {
106
- try {
107
- this._iframe?.contentWindow?.location.reload();
108
- } catch {
109
- if (this._iframe) {
110
- this._iframe.src = this._iframe.src;
111
- }
112
- }
181
+ try { this._iframe?.contentWindow?.location.reload(); return; } catch {}
182
+ // Cross-origin fallback: reassigning the same src is a no-op in WHATWG;
183
+ // cycle via about:blank to force a fresh navigation.
184
+ const iframe = this._iframe;
185
+ if (!iframe) return;
186
+ const url = this._currentUrl || iframe.src;
187
+ iframe.src = "about:blank";
188
+ requestAnimationFrame(() => { if (this._iframe) this._iframe.src = url; });
113
189
  }
114
190
 
115
191
  setHidden(hidden: boolean) {
@@ -118,20 +194,204 @@ function definePolyfillClass(): CustomElementConstructor {
118
194
  }
119
195
  }
120
196
 
121
- // Automation surface — web iframe polyfill is intentionally limited.
122
- // Sandbox omits `allow-same-origin`, so `contentWindow.eval` would fail even
123
- // for same-origin URLs. Reporting `evaluate: false` matches reality; callers
124
- // can opt-in with `<bunite-webview unsandboxed>` and extend this method.
125
- async evaluate(_script: string) {
126
- return { ok: false as const, code: "not_supported" as const, message: "iframe polyfill does not support evaluate" };
197
+ // Automation surface — works when the iframe is same-origin reachable
198
+ // (i.e. `<bunite-webview unsandboxed>` + same-origin src). Default sandbox
199
+ // strips `allow-same-origin`, so reachability is opt-in. `isTrusted` on
200
+ // synthesised DOM events is always false `nativeInputTrusted` stays false.
201
+ async evaluate(script: string) {
202
+ if (!this.isReachable()) {
203
+ return { ok: false as const, code: "cross_origin" as const, message: "iframe content not same-origin" };
204
+ }
205
+ try {
206
+ const win = this._iframe!.contentWindow as Window & { eval(s: string): unknown };
207
+ return { ok: true as const, value: win.eval(script) };
208
+ } catch (e: unknown) {
209
+ const err = e as { name?: string; message?: string };
210
+ if (err?.name === "SecurityError") {
211
+ return { ok: false as const, code: "cross_origin" as const, message: err.message ?? "SecurityError" };
212
+ }
213
+ return { ok: false as const, code: "runtime_error" as const, message: err?.message ?? String(e) };
214
+ }
127
215
  }
128
216
 
129
217
  async capabilities() {
218
+ const reachable = this.isReachable();
130
219
  return {
131
- evaluate: false, crossOriginEval: false, titleChanged: false,
132
- nativeInputTrusted: false, click: false, type: false, press: false,
133
- scroll: false, screenshot: false,
220
+ evaluate: reachable, crossOriginEval: false, surfaceEvents: true,
221
+ nativeInputTrusted: false,
222
+ click: reachable, type: reachable, press: reachable, scroll: reachable,
223
+ mouse: reachable, dialogs: false, console: false,
224
+ screenshot: false,
225
+ };
226
+ }
227
+
228
+ async sendClick(args: {
229
+ x: number; y: number; button?: string; clickCount?: number; modifiers?: string[];
230
+ }) {
231
+ if (!this.isReachable()) return;
232
+ const doc = this._iframe!.contentDocument!;
233
+ const target = doc.elementFromPoint(args.x, args.y) ?? doc.body;
234
+ if (!target) return;
235
+ const init: MouseEventInit = {
236
+ bubbles: true, cancelable: true, view: this._iframe!.contentWindow,
237
+ clientX: args.x, clientY: args.y,
238
+ button: args.button === "right" ? 2 : args.button === "middle" ? 1 : 0,
239
+ detail: args.clickCount ?? 1,
240
+ ...this.modifierBag(args.modifiers),
241
+ };
242
+ target.dispatchEvent(new MouseEvent("mousedown", init));
243
+ target.dispatchEvent(new MouseEvent("mouseup", init));
244
+ target.dispatchEvent(new MouseEvent("click", init));
245
+ }
246
+
247
+ async resolveAndClick(_selector: string, _opts?: unknown) {
248
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill iframe: atomic resolveAndClick not supported" };
249
+ }
250
+
251
+ async getBoundingRect(selector: string, _opts?: unknown) {
252
+ if (!this.isReachable()) return { ok: false as const, code: "not_supported" as const, message: "iframe not reachable" };
253
+ try {
254
+ const el = this._iframe!.contentDocument!.querySelector(selector) as Element | null;
255
+ if (!el) return { ok: false as const, code: "not_found" as const, message: `selector ${selector} not found` };
256
+ const r = el.getBoundingClientRect();
257
+ const win = this._iframe!.contentWindow!;
258
+ const visible = r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0
259
+ && r.top < win.innerHeight && r.left < win.innerWidth;
260
+ return { ok: true as const, rect: { x: r.x, y: r.y, width: r.width, height: r.height }, visible };
261
+ } catch (e: any) {
262
+ const code = e?.name === "SecurityError" ? "cross_origin" as const : "runtime_error" as const;
263
+ return { ok: false as const, code, message: e?.message ?? String(e) };
264
+ }
265
+ }
266
+
267
+ async listFrames() {
268
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill: not implemented" };
269
+ }
270
+
271
+ async accessibilitySnapshot(_opts?: unknown) {
272
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill: not implemented" };
273
+ }
274
+
275
+ async setDownloadPolicy(_policy: unknown, _dir?: unknown) {
276
+ // No-op — iframe has no download lifecycle hook.
277
+ }
278
+
279
+ async waitForDownload(_opts?: unknown) {
280
+ return { ok: false as const, code: "not_supported" as const, message: "polyfill: not implemented" };
281
+ }
282
+
283
+ async acceptPopup(_opts?: unknown) {
284
+ return { ok: false as const, code: "not_found" as const, message: "polyfill: no popup orchestration" };
285
+ }
286
+
287
+ async dismissPopup(_id?: unknown) {
288
+ // No-op.
289
+ }
290
+
291
+ async extendAdoptionTimeout(_id?: unknown, _ms?: unknown) {
292
+ return { ok: false as const, code: "not_found" as const, message: "polyfill: no popup orchestration" };
293
+ }
294
+
295
+ async sendType(text: string) {
296
+ if (!this.isReachable()) return;
297
+ const doc = this._iframe!.contentDocument!;
298
+ const target = doc.activeElement as (HTMLInputElement | HTMLTextAreaElement | null);
299
+ if (!target || !("setRangeText" in target)) return;
300
+ // setRangeText preserves selection + caret; the `data` field on an
301
+ // InputEvent + bubbling lets React-style controllers detect the change.
302
+ const start = target.selectionStart ?? target.value.length;
303
+ const end = target.selectionEnd ?? target.value.length;
304
+ target.setRangeText(text, start, end, "end");
305
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, data: text, inputType: "insertText" }));
306
+ }
307
+
308
+ async sendPress(key: string, modifiers?: string[], action?: "down" | "up" | "both") {
309
+ if (!this.isReachable()) return;
310
+ const doc = this._iframe!.contentDocument!;
311
+ const target = (doc.activeElement ?? doc.body) as Element | null;
312
+ if (!target) return;
313
+ const init: KeyboardEventInit = {
314
+ bubbles: true, cancelable: true, key, ...this.modifierBag(modifiers),
315
+ };
316
+ const a = action ?? "both";
317
+ if (a !== "up") target.dispatchEvent(new KeyboardEvent("keydown", init));
318
+ if (a === "both") target.dispatchEvent(new KeyboardEvent("keypress", init));
319
+ if (a !== "down") target.dispatchEvent(new KeyboardEvent("keyup", init));
320
+ }
321
+
322
+ async sendScroll(args: { dx: number; dy: number; x?: number; y?: number; modifiers?: string[] }) {
323
+ if (!this.isReachable()) return;
324
+ this._iframe!.contentWindow!.scrollBy(args.dx, args.dy);
325
+ }
326
+
327
+ async sendMouse(args: {
328
+ action: "move" | "down" | "up";
329
+ x: number; y: number;
330
+ button?: string;
331
+ modifiers?: string[];
332
+ }) {
333
+ if (!this.isReachable()) return;
334
+ const doc = this._iframe!.contentDocument!;
335
+ const target = doc.elementFromPoint(args.x, args.y) ?? doc.body;
336
+ if (!target) return;
337
+ const type = args.action === "move" ? "mousemove" : args.action === "down" ? "mousedown" : "mouseup";
338
+ const init: MouseEventInit = {
339
+ bubbles: true, cancelable: true, view: this._iframe!.contentWindow,
340
+ clientX: args.x, clientY: args.y,
341
+ button: args.button === "right" ? 2 : args.button === "middle" ? 1 : 0,
342
+ ...this.modifierBag(args.modifiers),
134
343
  };
344
+ target.dispatchEvent(new MouseEvent(type, init));
345
+ }
346
+
347
+ async respondToDialog(_requestId: number, _accept: boolean, _text?: string) {
348
+ // iframe sandbox forbids cross-frame dialog interception; nothing to do.
349
+ }
350
+
351
+ async setDialogTimeout(_ms: number | null) { /* no-op */ }
352
+
353
+ async waitForSelector(selector: string, timeoutMs = 5000) {
354
+ if (!this.isReachable()) {
355
+ return { ok: false as const, code: "runtime_error" as const, message: "iframe content not same-origin" };
356
+ }
357
+ const doc = this._iframe!.contentDocument!;
358
+ const deadline = Date.now() + timeoutMs;
359
+ while (Date.now() < deadline) {
360
+ if (doc.querySelector(selector)) return { ok: true as const };
361
+ await new Promise((r) => setTimeout(r, 50));
362
+ }
363
+ return { ok: false as const, code: "timeout" as const, message: `selector ${JSON.stringify(selector)} not found within ${timeoutMs}ms` };
364
+ }
365
+
366
+ async waitForFunction(expression: string, opts?: { timeoutMs?: number; pollIntervalMs?: number }) {
367
+ if (!this.isReachable()) {
368
+ return { ok: false as const, code: "runtime_error" as const, message: "iframe content not same-origin" };
369
+ }
370
+ const win = this._iframe!.contentWindow as Window & { eval(s: string): unknown };
371
+ const deadline = Date.now() + (opts?.timeoutMs ?? 5000);
372
+ const interval = opts?.pollIntervalMs ?? 50;
373
+ while (Date.now() < deadline) {
374
+ try {
375
+ if (win.eval(expression)) return { ok: true as const };
376
+ } catch (e) {
377
+ return { ok: false as const, code: "runtime_error" as const, message: (e as Error).message };
378
+ }
379
+ await new Promise((r) => setTimeout(r, interval));
380
+ }
381
+ return { ok: false as const, code: "timeout" as const, message: `function did not satisfy within ${opts?.timeoutMs ?? 5000}ms` };
382
+ }
383
+
384
+ async getConsoleBuffer(_opts?: { clear?: boolean }) {
385
+ // iframe polyfill doesn't inject a preload — no console capture available.
386
+ return [] as { level: string; args: string[]; ts: number }[];
387
+ }
388
+
389
+ async getNavigationState() {
390
+ return { lastLoadEpoch: this._epoch, isLoading: this._isLoading, currentUrl: this._currentUrl };
391
+ }
392
+
393
+ async screenshot(_args?: { format?: "png" | "jpeg"; quality?: number }) {
394
+ return { ok: false as const, code: "not_supported" as const, message: "iframe polyfill does not support screenshot" };
135
395
  }
136
396
  }
137
397
 
@@ -149,7 +409,8 @@ function definePolyfillClass(): CustomElementConstructor {
149
409
  * - `referrerpolicy="no-referrer"`.
150
410
  * - `javascript:` / `data:` / `vbscript:` / `file:` / `about:` schemes blocked
151
411
  * (with WHATWG URL-style normalization to defeat embedded-control bypass);
152
- * navigation attempt dispatches `did-fail-load` with `detail.reason === "blocked-scheme"`.
412
+ * navigation attempt dispatches `surface-event` with `detail.type === "load-fail"`
413
+ * and `detail.reason === "blocked-scheme"`.
153
414
  *
154
415
  * Opt-out attributes on `<bunite-webview>` (observed — mutations re-apply):
155
416
  * - `sandbox="..."` — override the default sandbox token string verbatim.