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
@@ -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 } 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,49 @@ 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
+
35
+ private isReachable(): boolean {
36
+ if (!this._iframe) return false;
37
+ try {
38
+ return this._iframe.contentDocument != null;
39
+ } catch { return false; }
40
+ }
41
+
42
+ private modifierBag(mods?: string[]): {
43
+ shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean;
44
+ } {
45
+ return {
46
+ shiftKey: !!mods?.includes("shift"),
47
+ ctrlKey: !!mods?.includes("ctrl"),
48
+ altKey: !!mods?.includes("alt"),
49
+ metaKey: !!mods?.includes("meta"),
50
+ };
51
+ }
52
+
53
+ private emit(event: SurfaceEvent) {
54
+ this.dispatchEvent(new CustomEvent<SurfaceEvent>("surface-event", { detail: event }));
55
+ }
56
+
57
+ private setupTitleObserver() {
58
+ this._titleObserver?.disconnect();
59
+ this._titleObserver = null;
60
+ if (!this.isReachable()) return;
61
+ const doc = this._iframe!.contentDocument!;
62
+ this._lastTitle = doc.title;
63
+ const fire = () => {
64
+ const t = doc.title;
65
+ if (t && t !== this._lastTitle) {
66
+ this._lastTitle = t;
67
+ this.emit({ type: "title-change", title: t });
68
+ }
69
+ };
70
+ const observer = new MutationObserver(fire);
71
+ const headEl = doc.head ?? doc.documentElement;
72
+ if (headEl) observer.observe(headEl, { childList: true, subtree: true, characterData: true });
73
+ this._titleObserver = observer;
74
+ }
30
75
 
31
76
  private applySandbox(iframe: HTMLIFrameElement) {
32
77
  if (this.hasAttribute("unsandboxed")) {
@@ -38,7 +83,7 @@ function definePolyfillClass(): CustomElementConstructor {
38
83
  }
39
84
 
40
85
  private dispatchBlocked(url: string) {
41
- this.dispatchEvent(new CustomEvent("did-fail-load", { detail: { url, reason: "blocked-scheme" } }));
86
+ this.emit({ type: "load-fail", url, reason: "blocked-scheme" });
42
87
  }
43
88
 
44
89
  connectedCallback() {
@@ -55,6 +100,7 @@ function definePolyfillClass(): CustomElementConstructor {
55
100
  this.dispatchBlocked(src);
56
101
  } else {
57
102
  iframe.src = src;
103
+ this.emit({ type: "load-start", url: src });
58
104
  }
59
105
  }
60
106
 
@@ -66,7 +112,9 @@ function definePolyfillClass(): CustomElementConstructor {
66
112
  // Suppress the spurious about:blank load that fires after a blocked
67
113
  // navigation (or before any explicit navigate).
68
114
  if (isBlockedSrc(url)) return;
69
- this.dispatchEvent(new CustomEvent("did-navigate", { detail: { url } }));
115
+ this.emit({ type: "load-finish", url });
116
+ this.emit({ type: "navigate", url });
117
+ this.setupTitleObserver();
70
118
  });
71
119
 
72
120
  this._iframe = iframe;
@@ -74,6 +122,8 @@ function definePolyfillClass(): CustomElementConstructor {
74
122
  }
75
123
 
76
124
  disconnectedCallback() {
125
+ this._titleObserver?.disconnect();
126
+ this._titleObserver = null;
77
127
  this._iframe?.remove();
78
128
  this._iframe = null;
79
129
  }
@@ -86,6 +136,7 @@ function definePolyfillClass(): CustomElementConstructor {
86
136
  return;
87
137
  }
88
138
  this._iframe.src = newValue ?? "";
139
+ if (newValue) this.emit({ type: "load-start", url: newValue });
89
140
  } else if (name === "sandbox" || name === "unsandboxed") {
90
141
  // Sandbox token changes take effect on the next navigation per HTML spec.
91
142
  this.applySandbox(this._iframe);
@@ -118,20 +169,152 @@ function definePolyfillClass(): CustomElementConstructor {
118
169
  }
119
170
  }
120
171
 
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" };
172
+ // Automation surface — works when the iframe is same-origin reachable
173
+ // (i.e. `<bunite-webview unsandboxed>` + same-origin src). Default sandbox
174
+ // strips `allow-same-origin`, so reachability is opt-in. `isTrusted` on
175
+ // synthesised DOM events is always false `nativeInputTrusted` stays false.
176
+ async evaluate(script: string) {
177
+ if (!this.isReachable()) {
178
+ return { ok: false as const, code: "cross_origin" as const, message: "iframe content not same-origin" };
179
+ }
180
+ try {
181
+ const win = this._iframe!.contentWindow as Window & { eval(s: string): unknown };
182
+ return { ok: true as const, value: win.eval(script) };
183
+ } catch (e: unknown) {
184
+ const err = e as { name?: string; message?: string };
185
+ if (err?.name === "SecurityError") {
186
+ return { ok: false as const, code: "cross_origin" as const, message: err.message ?? "SecurityError" };
187
+ }
188
+ return { ok: false as const, code: "runtime_error" as const, message: err?.message ?? String(e) };
189
+ }
127
190
  }
128
191
 
129
192
  async capabilities() {
193
+ const reachable = this.isReachable();
130
194
  return {
131
- evaluate: false, crossOriginEval: false, titleChanged: false,
132
- nativeInputTrusted: false, click: false, type: false, press: false,
133
- scroll: false, screenshot: false,
195
+ evaluate: reachable, crossOriginEval: false, surfaceEvents: true,
196
+ nativeInputTrusted: false,
197
+ click: reachable, type: reachable, press: reachable, scroll: reachable,
198
+ mouse: reachable, dialogs: false, console: false,
199
+ screenshot: false,
200
+ };
201
+ }
202
+
203
+ async sendClick(args: {
204
+ x: number; y: number; button?: string; clickCount?: number; modifiers?: string[];
205
+ }) {
206
+ if (!this.isReachable()) return;
207
+ const doc = this._iframe!.contentDocument!;
208
+ const target = doc.elementFromPoint(args.x, args.y) ?? doc.body;
209
+ if (!target) return;
210
+ const init: MouseEventInit = {
211
+ bubbles: true, cancelable: true, view: this._iframe!.contentWindow,
212
+ clientX: args.x, clientY: args.y,
213
+ button: args.button === "right" ? 2 : args.button === "middle" ? 1 : 0,
214
+ detail: args.clickCount ?? 1,
215
+ ...this.modifierBag(args.modifiers),
216
+ };
217
+ target.dispatchEvent(new MouseEvent("mousedown", init));
218
+ target.dispatchEvent(new MouseEvent("mouseup", init));
219
+ target.dispatchEvent(new MouseEvent("click", init));
220
+ }
221
+
222
+ async sendType(text: string) {
223
+ if (!this.isReachable()) return;
224
+ const doc = this._iframe!.contentDocument!;
225
+ const target = doc.activeElement as (HTMLInputElement | HTMLTextAreaElement | null);
226
+ if (!target || !("setRangeText" in target)) return;
227
+ // setRangeText preserves selection + caret; the `data` field on an
228
+ // InputEvent + bubbling lets React-style controllers detect the change.
229
+ const start = target.selectionStart ?? target.value.length;
230
+ const end = target.selectionEnd ?? target.value.length;
231
+ target.setRangeText(text, start, end, "end");
232
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, data: text, inputType: "insertText" }));
233
+ }
234
+
235
+ async sendPress(key: string, modifiers?: string[], action?: "down" | "up" | "both") {
236
+ if (!this.isReachable()) return;
237
+ const doc = this._iframe!.contentDocument!;
238
+ const target = (doc.activeElement ?? doc.body) as Element | null;
239
+ if (!target) return;
240
+ const init: KeyboardEventInit = {
241
+ bubbles: true, cancelable: true, key, ...this.modifierBag(modifiers),
242
+ };
243
+ const a = action ?? "both";
244
+ if (a !== "up") target.dispatchEvent(new KeyboardEvent("keydown", init));
245
+ if (a === "both") target.dispatchEvent(new KeyboardEvent("keypress", init));
246
+ if (a !== "down") target.dispatchEvent(new KeyboardEvent("keyup", init));
247
+ }
248
+
249
+ async sendScroll(args: { dx: number; dy: number; x?: number; y?: number; modifiers?: string[] }) {
250
+ if (!this.isReachable()) return;
251
+ this._iframe!.contentWindow!.scrollBy(args.dx, args.dy);
252
+ }
253
+
254
+ async sendMouse(args: {
255
+ action: "move" | "down" | "up";
256
+ x: number; y: number;
257
+ button?: string;
258
+ modifiers?: string[];
259
+ }) {
260
+ if (!this.isReachable()) return;
261
+ const doc = this._iframe!.contentDocument!;
262
+ const target = doc.elementFromPoint(args.x, args.y) ?? doc.body;
263
+ if (!target) return;
264
+ const type = args.action === "move" ? "mousemove" : args.action === "down" ? "mousedown" : "mouseup";
265
+ const init: MouseEventInit = {
266
+ bubbles: true, cancelable: true, view: this._iframe!.contentWindow,
267
+ clientX: args.x, clientY: args.y,
268
+ button: args.button === "right" ? 2 : args.button === "middle" ? 1 : 0,
269
+ ...this.modifierBag(args.modifiers),
134
270
  };
271
+ target.dispatchEvent(new MouseEvent(type, init));
272
+ }
273
+
274
+ async respondToDialog(_requestId: number, _accept: boolean, _text?: string) {
275
+ // iframe sandbox forbids cross-frame dialog interception; nothing to do.
276
+ }
277
+
278
+ async setDialogTimeout(_ms: number | null) { /* no-op */ }
279
+
280
+ async waitForSelector(selector: string, timeoutMs = 5000) {
281
+ if (!this.isReachable()) {
282
+ return { ok: false as const, code: "runtime_error" as const, message: "iframe content not same-origin" };
283
+ }
284
+ const doc = this._iframe!.contentDocument!;
285
+ const deadline = Date.now() + timeoutMs;
286
+ while (Date.now() < deadline) {
287
+ if (doc.querySelector(selector)) return { ok: true as const };
288
+ await new Promise((r) => setTimeout(r, 50));
289
+ }
290
+ return { ok: false as const, code: "timeout" as const, message: `selector ${JSON.stringify(selector)} not found within ${timeoutMs}ms` };
291
+ }
292
+
293
+ async waitForFunction(expression: string, opts?: { timeoutMs?: number; pollIntervalMs?: number }) {
294
+ if (!this.isReachable()) {
295
+ return { ok: false as const, code: "runtime_error" as const, message: "iframe content not same-origin" };
296
+ }
297
+ const win = this._iframe!.contentWindow as Window & { eval(s: string): unknown };
298
+ const deadline = Date.now() + (opts?.timeoutMs ?? 5000);
299
+ const interval = opts?.pollIntervalMs ?? 50;
300
+ while (Date.now() < deadline) {
301
+ try {
302
+ if (win.eval(expression)) return { ok: true as const };
303
+ } catch (e) {
304
+ return { ok: false as const, code: "runtime_error" as const, message: (e as Error).message };
305
+ }
306
+ await new Promise((r) => setTimeout(r, interval));
307
+ }
308
+ return { ok: false as const, code: "timeout" as const, message: `function did not satisfy within ${opts?.timeoutMs ?? 5000}ms` };
309
+ }
310
+
311
+ async getConsoleBuffer(_opts?: { clear?: boolean }) {
312
+ // iframe polyfill doesn't inject a preload — no console capture available.
313
+ return [] as { level: string; args: string[]; ts: number }[];
314
+ }
315
+
316
+ async screenshot(_args?: { format?: "png" | "jpeg"; quality?: number }) {
317
+ return { ok: false as const, code: "not_supported" as const, message: "iframe polyfill does not support screenshot" };
135
318
  }
136
319
  }
137
320
 
@@ -149,7 +332,8 @@ function definePolyfillClass(): CustomElementConstructor {
149
332
  * - `referrerpolicy="no-referrer"`.
150
333
  * - `javascript:` / `data:` / `vbscript:` / `file:` / `about:` schemes blocked
151
334
  * (with WHATWG URL-style normalization to defeat embedded-control bypass);
152
- * navigation attempt dispatches `did-fail-load` with `detail.reason === "blocked-scheme"`.
335
+ * navigation attempt dispatches `surface-event` with `detail.type === "load-fail"`
336
+ * and `detail.reason === "blocked-scheme"`.
153
337
  *
154
338
  * Opt-out attributes on `<bunite-webview>` (observed — mutations re-apply):
155
339
  * - `sandbox="..."` — override the default sandbox token string verbatim.