bun-webview2 0.0.0-stage → 0.3.1
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/CHANGELOG.md +47 -0
- package/LICENSE +28 -0
- package/README.md +185 -2
- package/native/webview.dll +0 -0
- package/package.json +41 -4
- package/src/cdp.ts +173 -0
- package/src/host-ffi.ts +264 -0
- package/src/index.ts +22 -0
- package/src/inject.ts +112 -0
- package/src/serve.ts +34 -0
- package/src/window.ts +551 -0
package/src/window.ts
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webview2Window — a native WebView2 window fully controlled from Bun.
|
|
3
|
+
*
|
|
4
|
+
* Architecture: each window is owned by a dedicated child host process
|
|
5
|
+
* (src/host.ts) running @webviewjs/webview (tao + wry over the system
|
|
6
|
+
* WebView2 runtime); the page inside it is driven by this process over the
|
|
7
|
+
* Chrome DevTools Protocol via a loopback WebSocket.
|
|
8
|
+
*
|
|
9
|
+
* Why one process per window: wry/tao only supports window creation before
|
|
10
|
+
* its native event loop has ever been pumped, so a second window from a
|
|
11
|
+
* live host deadlocks at the native level. Fresh process = the only proven
|
|
12
|
+
* creation context, plus per-window crash isolation.
|
|
13
|
+
*
|
|
14
|
+
* Why commands ride the page: on Windows, webviewjs's "non-blocking" pump
|
|
15
|
+
* is tao's blocking run_return(), which sometimes never returns control to
|
|
16
|
+
* JS (verified in their app.rs and reproduced here). A wedged host has a
|
|
17
|
+
* dead Bun event loop — timers, sockets, stdin are all gone — but tao
|
|
18
|
+
* still processes window messages and re-enters JS through native
|
|
19
|
+
* ThreadsafeFunction callbacks. So parent→host commands are delivered by
|
|
20
|
+
* CDP-evaluating window.ipc.postMessage(...) in the page (wry delivers it
|
|
21
|
+
* natively to the host), and host→parent events arrive on stdout, which
|
|
22
|
+
* works in both wedged and healthy states.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { CdpConnection, pickFreePort, waitForPageTarget, type CdpTargetInfo } from "./cdp.ts";
|
|
26
|
+
import { buildHardeningScript, buildIpcBridgeScript, buildPageEventsScript, type HardeningOptions } from "./inject.ts";
|
|
27
|
+
|
|
28
|
+
export interface Webview2WindowOptions extends HardeningOptions {
|
|
29
|
+
readonly title?: string;
|
|
30
|
+
readonly width?: number;
|
|
31
|
+
readonly height?: number;
|
|
32
|
+
/** Minimum window size. Both must be set together — one-sided limits are rejected. */
|
|
33
|
+
readonly minWidth?: number;
|
|
34
|
+
/** @see minWidth */
|
|
35
|
+
readonly minHeight?: number;
|
|
36
|
+
/** Initial URL to load. */
|
|
37
|
+
readonly url?: string;
|
|
38
|
+
/** Initial inline HTML to load (delivered as a data: URL). */
|
|
39
|
+
readonly html?: string;
|
|
40
|
+
/** Seconds to wait for a navigation's load event before rejecting. Default 30. */
|
|
41
|
+
readonly navigationTimeoutSeconds?: number;
|
|
42
|
+
/** Fixed CDP port. Default: a free port is picked. All windows of an app share one port. */
|
|
43
|
+
readonly port?: number;
|
|
44
|
+
/**
|
|
45
|
+
* WebView2 user-data folder for this app (cookies, storage, cache).
|
|
46
|
+
* Default: `%LOCALAPPDATA%\bun-webview2\<hash of the project directory>`.
|
|
47
|
+
* App-wide — the first window's value wins. One browser process serves one
|
|
48
|
+
* data folder: a second *app process* using the same folder attaches to the
|
|
49
|
+
* first one's WebView2 browser and its own CDP port never appears — treat
|
|
50
|
+
* it as a single-instance guard.
|
|
51
|
+
*/
|
|
52
|
+
readonly dataDir?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ExposedFunction = (...args: unknown[]) => unknown | Promise<unknown>;
|
|
56
|
+
|
|
57
|
+
export interface ScreenshotFormat {
|
|
58
|
+
readonly format?: "png" | "jpeg";
|
|
59
|
+
readonly quality?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Typed payloads for `win.on(...)`, mirroring tao's window events. */
|
|
63
|
+
export interface WindowEventPayloads {
|
|
64
|
+
move: { readonly x: number; readonly y: number };
|
|
65
|
+
resize: { readonly width: number; readonly height: number };
|
|
66
|
+
focus: Record<string, never>;
|
|
67
|
+
blur: Record<string, never>;
|
|
68
|
+
"key-down": { readonly key?: string; readonly code?: string; readonly modifiers?: number; readonly isRepeat?: boolean };
|
|
69
|
+
"key-up": { readonly key?: string; readonly code?: string; readonly modifiers?: number };
|
|
70
|
+
"mouse-enter": { readonly x: number; readonly y: number; readonly button?: number; readonly modifiers?: number };
|
|
71
|
+
"mouse-leave": Record<string, never>;
|
|
72
|
+
"mouse-down": { readonly x: number; readonly y: number; readonly button?: number; readonly modifiers?: number };
|
|
73
|
+
"mouse-up": { readonly x: number; readonly y: number; readonly button?: number; readonly modifiers?: number };
|
|
74
|
+
scroll: { readonly deltaX: number; readonly deltaY: number };
|
|
75
|
+
"file-drop": { readonly files?: Array<{ readonly name: string; readonly size: number; readonly type: string }> };
|
|
76
|
+
"theme-changed": { readonly text: "light" | "dark" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type WindowEventName = keyof WindowEventPayloads;
|
|
80
|
+
|
|
81
|
+
interface NavigationResult {
|
|
82
|
+
readonly errorText?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface EvaluateResult {
|
|
86
|
+
readonly result?: { readonly value?: unknown };
|
|
87
|
+
readonly exceptionDetails?: { readonly text?: string; readonly exception?: { readonly description?: string } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ScreenshotResult {
|
|
91
|
+
readonly data: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface BindingCalledParams {
|
|
95
|
+
readonly name: string;
|
|
96
|
+
readonly payload: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface FrameNavigatedParams {
|
|
100
|
+
readonly frame?: { readonly parentId?: string; readonly url?: string };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Module-level: one WebView2 browser process per app, hence one port and one data folder. */
|
|
104
|
+
let activePort: number | undefined;
|
|
105
|
+
let activeDataDir: string | undefined;
|
|
106
|
+
const claimedTargets = new Set<string>();
|
|
107
|
+
const liveHosts = new Set<WindowHost>();
|
|
108
|
+
|
|
109
|
+
// A dying app must not orphan host processes: a lingering host holds the
|
|
110
|
+
// shared WebView2 browser for the data folder, wedging the next run.
|
|
111
|
+
process.on("exit", () => {
|
|
112
|
+
for (const host of liveHosts) host.kill();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** Per-app WebView2 data folder: without this, every Bun process on the
|
|
116
|
+
* machine would share one browser process keyed to bun.exe. */
|
|
117
|
+
function defaultDataDir(): string {
|
|
118
|
+
const localAppData = Bun.env.LOCALAPPDATA ?? import.meta.dir;
|
|
119
|
+
const projectKey = new Bun.CryptoHasher("sha256").update(process.cwd()).digest("hex").slice(0, 16);
|
|
120
|
+
return `${localAppData}\\bun-webview2\\${projectKey}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A live child host process owning exactly one window. */
|
|
124
|
+
class WindowHost {
|
|
125
|
+
readonly #child: Bun.Subprocess<"ignore", "pipe", "inherit">;
|
|
126
|
+
#closed = false;
|
|
127
|
+
readonly #closedWaiters: Array<() => void> = [];
|
|
128
|
+
readonly #eventWaiters = new Set<(event: string, payload: unknown) => void>();
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
readonly options: { title: string; width: number; height: number; minWidth?: number; minHeight?: number },
|
|
132
|
+
) {
|
|
133
|
+
const config = JSON.stringify(options);
|
|
134
|
+
// Compiled executables re-run themselves instead of acting as a runtime,
|
|
135
|
+
// so shipped apps point BWV2_HOST_EXE at a pre-compiled host binary.
|
|
136
|
+
// host-ffi.ts drives the C webview library directly via bun:ffi.
|
|
137
|
+
const command = Bun.env.BWV2_HOST_EXE
|
|
138
|
+
? [Bun.env.BWV2_HOST_EXE]
|
|
139
|
+
: [process.execPath, `${import.meta.dir}/host-ffi.ts`];
|
|
140
|
+
this.#child = Bun.spawn([...command, config], {
|
|
141
|
+
stdin: "ignore",
|
|
142
|
+
stdout: "pipe",
|
|
143
|
+
stderr: "inherit",
|
|
144
|
+
env: {
|
|
145
|
+
...Bun.env,
|
|
146
|
+
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${activePort}`,
|
|
147
|
+
WEBVIEW2_USER_DATA_FOLDER: activeDataDir,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
this.#watchStdout();
|
|
151
|
+
// host death closes the window, whatever the cause
|
|
152
|
+
void this.#child.exited.then(() => this.#handleClosed());
|
|
153
|
+
liveHosts.add(this);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #watchStdout(): Promise<void> {
|
|
157
|
+
const decoder = new TextDecoder();
|
|
158
|
+
const reader = this.#child.stdout.getReader();
|
|
159
|
+
let buffer = "";
|
|
160
|
+
for (;;) {
|
|
161
|
+
const { done, value } = await reader.read();
|
|
162
|
+
if (done) break;
|
|
163
|
+
buffer += decoder.decode(value, { stream: true });
|
|
164
|
+
let newline = buffer.indexOf("\n");
|
|
165
|
+
while (newline !== -1) {
|
|
166
|
+
const line = buffer.slice(0, newline).trim();
|
|
167
|
+
buffer = buffer.slice(newline + 1);
|
|
168
|
+
if (line.length > 0) {
|
|
169
|
+
// tolerate stray non-JSON lines (inherited handles, renderer
|
|
170
|
+
// warnings): one bad line must never kill the whole reader loop
|
|
171
|
+
let message: unknown;
|
|
172
|
+
try {
|
|
173
|
+
message = JSON.parse(line);
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const event = (message as Record<string, unknown>).event;
|
|
178
|
+
if (typeof event === "string") {
|
|
179
|
+
if (event === "closed") this.#handleClosed();
|
|
180
|
+
for (const waiter of this.#eventWaiters) waiter(event, message);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
newline = buffer.indexOf("\n");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#handleClosed(): void {
|
|
189
|
+
if (this.#closed) return;
|
|
190
|
+
this.#closed = true;
|
|
191
|
+
liveHosts.delete(this);
|
|
192
|
+
for (const notify of this.#closedWaiters) notify();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onClosed(listener: () => void): void {
|
|
196
|
+
if (this.#closed) listener();
|
|
197
|
+
else this.#closedWaiters.push(listener);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Subscribe to host stdout events (window events + lifecycle). */
|
|
201
|
+
onHostEvent(listener: (event: string, payload: unknown) => void): () => void {
|
|
202
|
+
this.#eventWaiters.add(listener);
|
|
203
|
+
return () => this.#eventWaiters.delete(listener);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
get closed(): boolean {
|
|
207
|
+
return this.#closed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
kill(): void {
|
|
211
|
+
if (!this.#closed) this.#child.kill();
|
|
212
|
+
this.#handleClosed();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export class Webview2Window {
|
|
217
|
+
readonly #host: WindowHost;
|
|
218
|
+
readonly #cdp: CdpConnection;
|
|
219
|
+
readonly #exposed = new Map<string, ExposedFunction>();
|
|
220
|
+
#closed = false;
|
|
221
|
+
#url: string | undefined;
|
|
222
|
+
readonly #closedWaiters: Array<() => void> = [];
|
|
223
|
+
readonly #eventHandlers = new Map<string, Set<(payload: unknown) => void>>();
|
|
224
|
+
#navigationTimeoutMs: number;
|
|
225
|
+
|
|
226
|
+
private constructor(host: WindowHost, cdp: CdpConnection, opts: Webview2WindowOptions) {
|
|
227
|
+
this.#host = host;
|
|
228
|
+
this.#cdp = cdp;
|
|
229
|
+
this.#navigationTimeoutMs = (opts.navigationTimeoutSeconds ?? 30) * 1000;
|
|
230
|
+
host.onClosed(() => this.#handleClosed());
|
|
231
|
+
// Events arrive as JSON from our own child process — trusted channel,
|
|
232
|
+
// so one narrow cast from unknown is all the validation needed.
|
|
233
|
+
host.onHostEvent((event, payload) => {
|
|
234
|
+
if (event === "closed") return;
|
|
235
|
+
this.#eventHandlers.get(event)?.forEach((handler) => handler(payload));
|
|
236
|
+
});
|
|
237
|
+
cdp.on("Runtime.bindingCalled", (params) => this.#handleBinding(params));
|
|
238
|
+
cdp.on("Page.frameNavigated", (params) => {
|
|
239
|
+
const frame = (params as Partial<FrameNavigatedParams>)?.frame;
|
|
240
|
+
if (frame && frame.parentId === undefined && typeof frame.url === "string") {
|
|
241
|
+
this.#url = frame.url;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#handleClosed(): void {
|
|
247
|
+
if (this.#closed) return;
|
|
248
|
+
this.#closed = true;
|
|
249
|
+
this.#cdp.close();
|
|
250
|
+
for (const notify of this.#closedWaiters) notify();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create a window (in its own host process) and attach to its page target.
|
|
255
|
+
* Resolves once the initial content (if any) has loaded.
|
|
256
|
+
*/
|
|
257
|
+
static async create(opts: Webview2WindowOptions = {}): Promise<Webview2Window> {
|
|
258
|
+
if (activePort === undefined) {
|
|
259
|
+
const port = opts.port ?? (await pickFreePort());
|
|
260
|
+
activeDataDir = opts.dataDir ?? defaultDataDir();
|
|
261
|
+
activePort = port;
|
|
262
|
+
} else if (opts.port !== undefined && opts.port !== activePort) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`bun-webview2: port ${opts.port} requested, but this app already drives its WebView2 browser on ${activePort}. All windows share one port.`,
|
|
265
|
+
);
|
|
266
|
+
} else if (opts.dataDir !== undefined && opts.dataDir !== activeDataDir) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`bun-webview2: dataDir ${opts.dataDir} requested, but this app already uses ${activeDataDir}. All windows share one data folder.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if ((opts.minWidth !== undefined) !== (opts.minHeight !== undefined)) {
|
|
272
|
+
throw new Error("bun-webview2: minWidth and minHeight must be set together — one-sided limits are not supported.");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const host = new WindowHost({
|
|
276
|
+
title: opts.title ?? "bun-webview2",
|
|
277
|
+
width: opts.width ?? 800,
|
|
278
|
+
height: opts.height ?? 600,
|
|
279
|
+
minWidth: opts.minWidth,
|
|
280
|
+
minHeight: opts.minHeight,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const target: CdpTargetInfo = await waitForPageTarget(activePort, claimedTargets, 15_000, activeDataDir);
|
|
284
|
+
claimedTargets.add(target.webSocketDebuggerUrl);
|
|
285
|
+
|
|
286
|
+
const cdp = new CdpConnection(target.webSocketDebuggerUrl);
|
|
287
|
+
await cdp.connect();
|
|
288
|
+
await cdp.call("Page.enable");
|
|
289
|
+
|
|
290
|
+
const win = new Webview2Window(host, cdp, opts);
|
|
291
|
+
|
|
292
|
+
// Prime the target with one committed document before any other CDP
|
|
293
|
+
// setup: on a never-navigated webview, Runtime.enable and even
|
|
294
|
+
// Page.navigate can wedge forever (bisected empirically — commands that
|
|
295
|
+
// need a renderer have none yet). about:blank commits one, fast.
|
|
296
|
+
await win.navigate("about:blank");
|
|
297
|
+
|
|
298
|
+
await cdp.call("Runtime.enable");
|
|
299
|
+
await cdp.call("Page.addScriptToEvaluateOnNewDocument", { source: buildHardeningScript(opts) });
|
|
300
|
+
await cdp.call("Page.addScriptToEvaluateOnNewDocument", { source: buildIpcBridgeScript() });
|
|
301
|
+
await cdp.call("Page.addScriptToEvaluateOnNewDocument", { source: buildPageEventsScript() });
|
|
302
|
+
await cdp.call("Runtime.addBinding", { name: "__bwv2Ipc" });
|
|
303
|
+
|
|
304
|
+
// Confirm the parent→host command channel (CDP -> page -> bind -> host).
|
|
305
|
+
await win.#sendCommand("hello");
|
|
306
|
+
|
|
307
|
+
if (opts.url) await win.navigate(opts.url);
|
|
308
|
+
else if (opts.html) await win.setHTML(opts.html);
|
|
309
|
+
return win;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#handleBinding(params: unknown): void {
|
|
313
|
+
const { name, payload } = (params ?? {}) as Partial<BindingCalledParams>;
|
|
314
|
+
if (name !== "__bwv2Ipc" || typeof payload !== "string") return;
|
|
315
|
+
const request = JSON.parse(payload) as { id: number; name: string; args?: unknown[] };
|
|
316
|
+
if (request.name === "__event") {
|
|
317
|
+
// page-surface event forwarded by the injected bridge; no reply expected
|
|
318
|
+
const [eventName, eventPayload] = request.args ?? [];
|
|
319
|
+
if (typeof eventName === "string") {
|
|
320
|
+
this.#eventHandlers.get(eventName)?.forEach((handler) => handler(eventPayload));
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const fn = this.#exposed.get(request.name);
|
|
325
|
+
if (!fn) {
|
|
326
|
+
void this.#reply(request.id, false, `bun-webview2: nothing exposed under "${request.name}"`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
Promise.resolve()
|
|
330
|
+
.then(() => fn(...(request.args ?? [])))
|
|
331
|
+
.then((result) => void this.#reply(request.id, true, result))
|
|
332
|
+
.catch((err: unknown) => void this.#reply(request.id, false, err instanceof Error ? err.message : String(err)));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async #reply(id: number, ok: boolean, value: unknown): Promise<void> {
|
|
336
|
+
if (this.#closed) return;
|
|
337
|
+
// undefined serializes to the literal `undefined` expression — acceptable to CDP evaluate.
|
|
338
|
+
await this.#cdp
|
|
339
|
+
.call("Runtime.evaluate", {
|
|
340
|
+
expression: `window.__bwv2Resolve(${id}, ${ok}, ${JSON.stringify(value) ?? "undefined"})`,
|
|
341
|
+
})
|
|
342
|
+
.catch(() => {
|
|
343
|
+
// window closed mid-reply; nothing to do
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Deliver a window-control command to the host process and return its
|
|
349
|
+
* reply: CDP-evaluate the bound bwv2_host_cmd in the page; the C webview
|
|
350
|
+
* library hands it to the host natively (all host JS runs re-entrantly
|
|
351
|
+
* inside webview_run, so nothing here depends on a JS event loop), and
|
|
352
|
+
* webview_return resolves the page-side promise the parent awaits — the
|
|
353
|
+
* resolved call IS the delivery confirmation. Commands are resent on
|
|
354
|
+
* failure (mid-navigation); ops are idempotent setters.
|
|
355
|
+
*/
|
|
356
|
+
async #sendCommand(op: string, ...args: unknown[]): Promise<unknown> {
|
|
357
|
+
if (this.#closed) return;
|
|
358
|
+
const expression = `bwv2_host_cmd(${JSON.stringify(JSON.stringify({ op, args }))})`;
|
|
359
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
360
|
+
if (this.#closed) return;
|
|
361
|
+
try {
|
|
362
|
+
const response = (await this.#cdp.call("Runtime.evaluate", {
|
|
363
|
+
expression,
|
|
364
|
+
awaitPromise: true,
|
|
365
|
+
returnByValue: true,
|
|
366
|
+
})) as EvaluateResult;
|
|
367
|
+
return response.result?.value;
|
|
368
|
+
} catch {
|
|
369
|
+
// mid-navigation or window closing — next attempt may land
|
|
370
|
+
}
|
|
371
|
+
await Bun.sleep(100);
|
|
372
|
+
}
|
|
373
|
+
console.error(`[bwv2] host command "${op}" never resolved after 20 attempts`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Navigate to a URL. Resolves when the main frame's load event fires. */
|
|
377
|
+
async navigate(url: string): Promise<void> {
|
|
378
|
+
if (this.#closed) throw new Error("bun-webview2: window is closed");
|
|
379
|
+
|
|
380
|
+
const { promise: loaded, resolve: fireLoaded, reject: failLoaded } = Promise.withResolvers<void>();
|
|
381
|
+
const unsubscribe = this.#cdp.on("Page.loadEventFired", () => fireLoaded());
|
|
382
|
+
const timer = setTimeout(() => failLoaded(new Error(`bun-webview2: navigation to ${url} timed out`)), this.#navigationTimeoutMs);
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
// cdp.call's timeout guards the command itself; the load-wait timer
|
|
386
|
+
// below guards the navigation completing.
|
|
387
|
+
const result = (await this.#cdp.call("Page.navigate", { url }, this.#navigationTimeoutMs)) as NavigationResult;
|
|
388
|
+
if (typeof result?.errorText === "string" && result.errorText !== "") {
|
|
389
|
+
throw new Error(`bun-webview2: navigation to ${url} failed: ${result.errorText}`);
|
|
390
|
+
}
|
|
391
|
+
await loaded;
|
|
392
|
+
} finally {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
unsubscribe();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Replace the page with inline HTML (delivered as a data: URL). */
|
|
399
|
+
async setHTML(html: string): Promise<void> {
|
|
400
|
+
await this.navigate(`data:text/html,${encodeURIComponent(html)}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Evaluate an expression in the page and return its JSON-serializable
|
|
405
|
+
* result. Promises are awaited. Functions, `undefined`, and symbols come
|
|
406
|
+
* back as `undefined`; circular structures throw inside the page.
|
|
407
|
+
*/
|
|
408
|
+
async evaluate<T = unknown>(expression: string): Promise<T | undefined> {
|
|
409
|
+
if (this.#closed) throw new Error("bun-webview2: window is closed");
|
|
410
|
+
const response = (await this.#cdp.call("Runtime.evaluate", {
|
|
411
|
+
expression,
|
|
412
|
+
returnByValue: true,
|
|
413
|
+
awaitPromise: true,
|
|
414
|
+
})) as EvaluateResult;
|
|
415
|
+
if (response?.exceptionDetails) {
|
|
416
|
+
const details = response.exceptionDetails;
|
|
417
|
+
throw new Error(`bun-webview2: page threw: ${details.exception?.description ?? details.text ?? "unknown error"}`);
|
|
418
|
+
}
|
|
419
|
+
return (response?.result?.value ?? undefined) as T | undefined;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Expose a Bun function to the page as `await window.bwv2.call(name, ...args)`.
|
|
424
|
+
* Arguments and return values are JSON round-tripped.
|
|
425
|
+
*/
|
|
426
|
+
async expose(name: string, fn: ExposedFunction): Promise<void> {
|
|
427
|
+
this.#exposed.set(name, fn);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Capture the current viewport as an image. */
|
|
431
|
+
async screenshot(opts: ScreenshotFormat = {}): Promise<Blob> {
|
|
432
|
+
if (this.#closed) throw new Error("bun-webview2: window is closed");
|
|
433
|
+
const result = (await this.#cdp.call("Page.captureScreenshot", {
|
|
434
|
+
format: opts.format ?? "png",
|
|
435
|
+
...(opts.quality !== undefined && opts.format === "jpeg" ? { quality: opts.quality } : {}),
|
|
436
|
+
})) as ScreenshotResult;
|
|
437
|
+
const bytes = Uint8Array.from(atob(result.data), (c) => c.charCodeAt(0));
|
|
438
|
+
return new Blob([bytes], { type: opts.format === "jpeg" ? "image/jpeg" : "image/png" });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The URL the page is currently showing. */
|
|
442
|
+
get url(): string | undefined {
|
|
443
|
+
return this.#url;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The live CDP connection for this window's page — the escape hatch to the
|
|
448
|
+
* full Chrome DevTools Protocol (Network, DOM, Input, Emulation, …) when
|
|
449
|
+
* the typed methods here don't cover a need.
|
|
450
|
+
*/
|
|
451
|
+
get cdp(): CdpConnection {
|
|
452
|
+
return this.#cdp;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** True once the window has been closed by the user or `close()`. */
|
|
456
|
+
get closed(): boolean {
|
|
457
|
+
return this.#closed;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Runs when the window is closed (user close button or `close()`). */
|
|
461
|
+
onClosed(listener: () => void): void {
|
|
462
|
+
if (this.#closed) listener();
|
|
463
|
+
else this.#closedWaiters.push(listener);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Subscribe to a native window event (move, resize, focus/blur, key/mouse,
|
|
468
|
+
* file-drop for drag & drop, scroll, theme-changed). Returns an
|
|
469
|
+
* unsubscribe function.
|
|
470
|
+
*/
|
|
471
|
+
on<K extends WindowEventName>(event: K, handler: (payload: WindowEventPayloads[K]) => void): () => void {
|
|
472
|
+
const handlers = this.#eventHandlers.get(event) ?? new Set<(payload: unknown) => void>();
|
|
473
|
+
// trusted child-protocol payloads; the double cast bridges the generic's
|
|
474
|
+
// contravariance, which a single cast cannot express
|
|
475
|
+
const wrapped = handler as unknown as (payload: unknown) => void;
|
|
476
|
+
handlers.add(wrapped);
|
|
477
|
+
this.#eventHandlers.set(event, handlers);
|
|
478
|
+
return () => handlers.delete(wrapped);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// --- Native window controls (delivered to the host via the page) ---
|
|
482
|
+
|
|
483
|
+
/** Fire-and-forget; commands retry in the background until the call resolves. */
|
|
484
|
+
#command(op: string, ...args: unknown[]): void {
|
|
485
|
+
void this.#sendCommand(op, ...args);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
setTitle(title: string): void {
|
|
489
|
+
this.#command("setTitle", title);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
setSize(width: number, height: number): void {
|
|
493
|
+
this.#command("setSize", width, height);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
setPosition(x: number, y: number): void {
|
|
497
|
+
this.#command("setPosition", x, y);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
setDecorations(decorations: boolean): void {
|
|
501
|
+
this.#command("setDecorations", decorations);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
setAlwaysOnTop(alwaysOnTop: boolean): void {
|
|
505
|
+
this.#command("setAlwaysOnTop", alwaysOnTop);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
focus(): void {
|
|
509
|
+
this.#command("focus");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
minimize(): void {
|
|
513
|
+
this.#command("minimize");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
maximize(): void {
|
|
517
|
+
this.#command("maximize");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Close the window, stop its host process, and detach from CDP. */
|
|
521
|
+
close(): void {
|
|
522
|
+
if (!this.#closed) {
|
|
523
|
+
this.#host.kill();
|
|
524
|
+
this.#handleClosed();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Close via the host's own close op and wait for its "closed" event — the
|
|
530
|
+
* same path a user's close button takes. Rejects if the host doesn't
|
|
531
|
+
* confirm within `timeoutMs` (default 5s), after which the window is
|
|
532
|
+
* force-closed instead.
|
|
533
|
+
*/
|
|
534
|
+
async closeGracefully(timeoutMs = 5_000): Promise<void> {
|
|
535
|
+
if (this.#closed) return;
|
|
536
|
+
const confirmed = Promise.withResolvers<void>();
|
|
537
|
+
this.onClosed(() => confirmed.resolve());
|
|
538
|
+
void this.#sendCommand("close");
|
|
539
|
+
const winner = await Promise.race([confirmed.promise.then(() => "closed" as const), Bun.sleep(timeoutMs).then(() => "timeout" as const)]);
|
|
540
|
+
if (winner === "timeout") {
|
|
541
|
+
this.#host.kill();
|
|
542
|
+
this.#handleClosed();
|
|
543
|
+
throw new Error("bun-webview2: host did not confirm close in time; window force-closed");
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Shorthand for `Webview2Window.create`. */
|
|
549
|
+
export function createWindow(opts: Webview2WindowOptions = {}): Promise<Webview2Window> {
|
|
550
|
+
return Webview2Window.create(opts);
|
|
551
|
+
}
|