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.
- package/package.json +4 -4
- package/src/host/core/App.ts +17 -1
- package/src/host/core/BrowserView.ts +197 -28
- package/src/host/core/SurfaceBrowserIPC.ts +44 -3
- package/src/host/core/SurfaceManager.ts +260 -28
- package/src/host/core/SurfaceRegistry.ts +9 -1
- package/src/host/core/inputDispatch.ts +147 -0
- package/src/host/events/webviewEvents.ts +8 -1
- package/src/host/native.ts +124 -1
- package/src/native/linux/bunite_linux_ffi.cpp +223 -6
- package/src/native/linux/bunite_linux_internal.h +6 -0
- package/src/native/linux/bunite_linux_runtime.cpp +1 -1
- package/src/native/linux/bunite_linux_utils.cpp +2 -2
- package/src/native/linux/bunite_linux_view.cpp +85 -0
- package/src/native/mac/bunite_mac_ffi.mm +356 -8
- package/src/native/mac/bunite_mac_internal.h +6 -0
- package/src/native/mac/bunite_mac_utils.mm +2 -2
- package/src/native/mac/bunite_mac_view.mm +144 -2
- package/src/native/shared/ffi_exports.h +135 -0
- package/src/native/win/native_host_cef.cpp +86 -3
- package/src/native/win/native_host_ffi.cpp +378 -1
- package/src/native/win/native_host_internal.h +13 -0
- package/src/native/win/native_host_utils.cpp +2 -1
- package/src/native/win/process_helper_win.cpp +54 -27
- package/src/native/win-webview2/bunite_webview2_ffi.cpp +303 -9
- package/src/native/win-webview2/webview2_internal.h +11 -0
- package/src/native/win-webview2/webview2_runtime.cpp +128 -12
- package/src/native/win-webview2/webview2_utils.cpp +30 -12
- package/src/preload/runtime.built.js +1 -1
- package/src/preload/runtime.ts +97 -0
- package/src/rpc/framework.ts +173 -4
- package/src/rpc/index.ts +21 -0
- package/src/webview/native.ts +126 -25
- package/src/webview/polyfill.ts +196 -12
package/src/webview/polyfill.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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 —
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
async evaluate(
|
|
126
|
-
|
|
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:
|
|
132
|
-
nativeInputTrusted: false,
|
|
133
|
-
|
|
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 `
|
|
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.
|