bun-webview2 0.0.0-stage → 0.3.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/CHANGELOG.md +40 -0
- package/LICENSE +28 -0
- package/README.md +184 -2
- package/native/webview.dll +0 -0
- package/package.json +41 -4
- package/src/cdp.ts +173 -0
- package/src/host-ffi.ts +244 -0
- package/src/index.ts +22 -0
- package/src/inject.ts +112 -0
- package/src/serve.ts +34 -0
- package/src/window.ts +540 -0
package/src/host-ffi.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child host process (FFI flavor): owns exactly one native window + webview
|
|
3
|
+
* driven directly through the C webview library via bun:ffi — no wry, no
|
|
4
|
+
* tao, no napi addons.
|
|
5
|
+
*
|
|
6
|
+
* Command channel: the parent CDP-evaluates bwv2_host_cmd("<json>") in the
|
|
7
|
+
* page; the C library delivers it to the bind callback re-entrantly on the
|
|
8
|
+
* GUI thread (the only JS that runs here — webview_run blocks the JS loop by
|
|
9
|
+
* design, and this host deliberately has no other JS machinery to lose to a
|
|
10
|
+
* wedged loop). Replies flow back synchronously: webview_return resolves the
|
|
11
|
+
* page-side promise the parent awaits, so a resolved call IS the ack.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle: user close button or the "close" command ends webview_run; we
|
|
14
|
+
* emit "closed" on stdout and exit. Stdout JSON lines are the only
|
|
15
|
+
* host→parent channel needed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { dlopen, FFIType, JSCallback, ptr } from "bun:ffi";
|
|
19
|
+
|
|
20
|
+
interface HostConfig {
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly width: number;
|
|
23
|
+
readonly height: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface HostCommand {
|
|
27
|
+
readonly op: string;
|
|
28
|
+
readonly args?: unknown[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Config is always the last argv element: [bun, host-ffi.ts, config].
|
|
32
|
+
const config = JSON.parse(process.argv[process.argv.length - 1] ?? "{}") as Partial<HostConfig>;
|
|
33
|
+
|
|
34
|
+
const dllPath = Bun.env.BWV2_NATIVE_LIB ?? `${import.meta.dir}/../native/webview.dll`;
|
|
35
|
+
|
|
36
|
+
const webview = dlopen(dllPath, {
|
|
37
|
+
webview_create: { args: [FFIType.int, FFIType.pointer], returns: FFIType.pointer },
|
|
38
|
+
webview_run: { args: [FFIType.pointer], returns: FFIType.int },
|
|
39
|
+
webview_terminate: { args: [FFIType.pointer], returns: FFIType.int },
|
|
40
|
+
webview_navigate: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
|
|
41
|
+
webview_set_title: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
|
|
42
|
+
webview_set_size: { args: [FFIType.pointer, FFIType.int, FFIType.int, FFIType.int], returns: FFIType.int },
|
|
43
|
+
webview_get_window: { args: [FFIType.pointer], returns: FFIType.pointer },
|
|
44
|
+
webview_bind: { args: [FFIType.pointer, FFIType.pointer, FFIType.pointer, FFIType.pointer], returns: FFIType.int },
|
|
45
|
+
webview_return: { args: [FFIType.pointer, FFIType.pointer, FFIType.int, FFIType.pointer], returns: FFIType.int },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Win32 window controls the C API doesn't cover.
|
|
49
|
+
const user32 = dlopen("user32.dll", {
|
|
50
|
+
SetWindowPos: { args: [FFIType.pointer, FFIType.i64, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.u32], returns: FFIType.int },
|
|
51
|
+
GetWindowLongPtrW: { args: [FFIType.pointer, FFIType.i32], returns: FFIType.i64 },
|
|
52
|
+
SetWindowLongPtrW: { args: [FFIType.pointer, FFIType.i32, FFIType.i64], returns: FFIType.i64 },
|
|
53
|
+
GetWindowRect: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
|
|
54
|
+
GetClientRect: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.int },
|
|
55
|
+
ShowWindow: { args: [FFIType.pointer, FFIType.i32], returns: FFIType.int },
|
|
56
|
+
SetForegroundWindow: { args: [FFIType.pointer], returns: FFIType.int },
|
|
57
|
+
CallWindowProcW: { args: [FFIType.pointer, FFIType.pointer, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.i64 },
|
|
58
|
+
TrackMouseEvent: { args: [FFIType.pointer], returns: FFIType.int },
|
|
59
|
+
GetKeyState: { args: [FFIType.i32], returns: FFIType.i16 },
|
|
60
|
+
});
|
|
61
|
+
const advapi32 = dlopen("advapi32.dll", {
|
|
62
|
+
RegGetValueW: { args: [FFIType.pointer, FFIType.pointer, FFIType.pointer, FFIType.u32, FFIType.u32, FFIType.pointer, FFIType.pointer], returns: FFIType.i32 },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const cstr = (s: string) => ptr(Buffer.from(`${s}\0`, "utf8"));
|
|
66
|
+
|
|
67
|
+
const WEBVIEW_HINT_NONE = 0;
|
|
68
|
+
const SWP_NOSIZE = 0x0001;
|
|
69
|
+
const SWP_NOMOVE = 0x0002;
|
|
70
|
+
const SWP_NOZORDER = 0x0004;
|
|
71
|
+
const SWP_NOACTIVATE = 0x0010;
|
|
72
|
+
const SWP_FRAMECHANGED = 0x0020;
|
|
73
|
+
const GWL_STYLE = -16;
|
|
74
|
+
const WS_OVERLAPPEDWINDOW = 0x00cf0000;
|
|
75
|
+
const SW_MINIMIZE = 6;
|
|
76
|
+
const SW_MAXIMIZE = 3;
|
|
77
|
+
const HWND_TOPMOST = -1;
|
|
78
|
+
const HWND_NOTOPMOST = -2;
|
|
79
|
+
|
|
80
|
+
if (Bun.env.BWV2_DEBUG === "1") console.error("[host-ffi] started");
|
|
81
|
+
|
|
82
|
+
const handle = webview.symbols.webview_create(0, null);
|
|
83
|
+
if (handle === 0n || handle === 0) {
|
|
84
|
+
console.error("[host-ffi] webview_create failed");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const hwnd = webview.symbols.webview_get_window(handle);
|
|
88
|
+
|
|
89
|
+
const emit = (payload: Record<string, unknown>): void => {
|
|
90
|
+
console.log(JSON.stringify(payload));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// --- Window events via WndProc subclassing -------------------------------
|
|
94
|
+
// The flat C API has no event subscriptions, so the window's own procedure
|
|
95
|
+
// is replaced: interesting messages become stdout events (the parent's
|
|
96
|
+
// generic routing picks them up); everything chains to the original proc.
|
|
97
|
+
|
|
98
|
+
const GWL_WNDPROC = -4;
|
|
99
|
+
const WM_MOVE = 0x0003;
|
|
100
|
+
const WM_SIZE = 0x0005;
|
|
101
|
+
// Only non-client, window-level messages reach this procedure: input over
|
|
102
|
+
// the page surface goes to the WebView2 child HWND and is delivered to apps
|
|
103
|
+
// as page DOM events instead (src/inject.ts page-event bridge). Keys,
|
|
104
|
+
// mouse, wheel, and file drops therefore have no WndProc cases at all.
|
|
105
|
+
const WM_ACTIVATE = 0x0006;
|
|
106
|
+
const WM_THEMECHANGED = 0x031a;
|
|
107
|
+
|
|
108
|
+
// personalization: 1 = apps light theme, 0 = dark
|
|
109
|
+
const themeText = (): "light" | "dark" => {
|
|
110
|
+
const value = Buffer.alloc(4);
|
|
111
|
+
const size = Buffer.alloc(4);
|
|
112
|
+
size.writeUInt32LE(4, 0);
|
|
113
|
+
const subkey = Buffer.from("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\0", "utf16le");
|
|
114
|
+
const valueName = Buffer.from("AppsUseLightTheme\0", "utf16le");
|
|
115
|
+
const ok = advapi32.symbols.RegGetValueW(0x80000001n /* HKEY_CURRENT_USER */, ptr(subkey), ptr(valueName), 0x0002 /* RRF_RT_REG_DWORD */, 0, ptr(value), ptr(size));
|
|
116
|
+
return ok === 0 && value.readUInt32LE(0) === 1 ? "light" : "dark";
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const originalProc: { handle: bigint | undefined } = { handle: undefined };
|
|
120
|
+
|
|
121
|
+
const wndProc = new JSCallback(
|
|
122
|
+
(hwndArg: number | bigint, msg: number | bigint, wParam: number | bigint, lParam: number | bigint) => {
|
|
123
|
+
// FFI delivers u64/i64 as number or bigint; normalize once at the boundary
|
|
124
|
+
const hwnd = BigInt(hwndArg);
|
|
125
|
+
const msgN = Number(msg);
|
|
126
|
+
const wp = BigInt.asUintN(64, BigInt(wParam));
|
|
127
|
+
const lp = BigInt.asIntN(64, BigInt(lParam));
|
|
128
|
+
switch (msgN) {
|
|
129
|
+
case WM_MOVE: {
|
|
130
|
+
const l = BigInt.asUintN(32, lp);
|
|
131
|
+
emit({ event: "move", x: Number(BigInt.asIntN(16, l & 0xffffn)), y: Number(BigInt.asIntN(16, (l >> 16n) & 0xffffn)) });
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case WM_SIZE: {
|
|
135
|
+
const l = BigInt.asUintN(32, lp);
|
|
136
|
+
emit({ event: "resize", width: Number(l & 0xffffn), height: Number((l >> 16n) & 0xffffn) });
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case WM_ACTIVATE: {
|
|
140
|
+
// window activation: clicks on the page activate the top window too,
|
|
141
|
+
// unlike WM_SETFOCUS which the WebView2 child HWND claims.
|
|
142
|
+
const active = (BigInt.asUintN(16, wp) & 0xffffn) !== 0n;
|
|
143
|
+
emit({ event: active ? "focus" : "blur" });
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case WM_THEMECHANGED:
|
|
147
|
+
emit({ event: "theme-changed", text: themeText() });
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
// chain everything (handled or not) to the original procedure
|
|
151
|
+
if (originalProc.handle !== undefined) {
|
|
152
|
+
return user32.symbols.CallWindowProcW(originalProc.handle, hwnd, msgN, wp, lp);
|
|
153
|
+
}
|
|
154
|
+
return 0n;
|
|
155
|
+
},
|
|
156
|
+
{ args: [FFIType.pointer, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.i64 },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Subclass the window so the parent receives native window events.
|
|
160
|
+
originalProc.handle = user32.symbols.SetWindowLongPtrW(hwnd, GWL_WNDPROC, BigInt(wndProc.ptr ?? 0));
|
|
161
|
+
|
|
162
|
+
function applyCommand(command: HostCommand): string {
|
|
163
|
+
const args = command.args ?? [];
|
|
164
|
+
switch (command.op) {
|
|
165
|
+
case "hello":
|
|
166
|
+
return "true";
|
|
167
|
+
case "setTitle":
|
|
168
|
+
return String(webview.symbols.webview_set_title(handle, cstr(String(args[0]))));
|
|
169
|
+
case "setSize":
|
|
170
|
+
return String(webview.symbols.webview_set_size(handle, Number(args[0]), Number(args[1]), WEBVIEW_HINT_NONE));
|
|
171
|
+
case "setPosition": {
|
|
172
|
+
const result = user32.symbols.SetWindowPos(
|
|
173
|
+
hwnd,
|
|
174
|
+
0n,
|
|
175
|
+
Number(args[0]),
|
|
176
|
+
Number(args[1]),
|
|
177
|
+
0,
|
|
178
|
+
0,
|
|
179
|
+
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE,
|
|
180
|
+
);
|
|
181
|
+
return String(result);
|
|
182
|
+
}
|
|
183
|
+
case "setDecorations": {
|
|
184
|
+
const current = user32.symbols.GetWindowLongPtrW(hwnd, GWL_STYLE);
|
|
185
|
+
const decorationsOn = Boolean(args[0]);
|
|
186
|
+
const masked = BigInt(current) & ~BigInt(WS_OVERLAPPEDWINDOW);
|
|
187
|
+
const updated = decorationsOn ? masked | BigInt(WS_OVERLAPPEDWINDOW) : masked;
|
|
188
|
+
const result = user32.symbols.SetWindowLongPtrW(hwnd, GWL_STYLE, updated);
|
|
189
|
+
user32.symbols.SetWindowPos(hwnd, 0n, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
|
190
|
+
return String(result);
|
|
191
|
+
}
|
|
192
|
+
case "setAlwaysOnTop": {
|
|
193
|
+
const insertAfter = args[0] ? BigInt(HWND_TOPMOST) : BigInt(HWND_NOTOPMOST);
|
|
194
|
+
const result = user32.symbols.SetWindowPos(hwnd, insertAfter, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
|
|
195
|
+
return String(result);
|
|
196
|
+
}
|
|
197
|
+
case "focus":
|
|
198
|
+
return String(user32.symbols.SetForegroundWindow(hwnd));
|
|
199
|
+
case "minimize":
|
|
200
|
+
return String(user32.symbols.ShowWindow(hwnd, SW_MINIMIZE));
|
|
201
|
+
case "maximize":
|
|
202
|
+
return String(user32.symbols.ShowWindow(hwnd, SW_MAXIMIZE));
|
|
203
|
+
|
|
204
|
+
case "close":
|
|
205
|
+
// restore the original procedure before tearing the window down
|
|
206
|
+
if (originalProc.handle !== undefined) {
|
|
207
|
+
user32.symbols.SetWindowLongPtrW(hwnd, GWL_WNDPROC, BigInt(originalProc.handle));
|
|
208
|
+
originalProc.handle = undefined;
|
|
209
|
+
}
|
|
210
|
+
webview.symbols.webview_terminate(handle);
|
|
211
|
+
return "true";
|
|
212
|
+
default:
|
|
213
|
+
return "null";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const bindCallback = new JSCallback(
|
|
218
|
+
(id: string, req: string) => {
|
|
219
|
+
if (Bun.env.BWV2_DEBUG === "1") console.error(`[host-ffi] op: ${req.slice(0, 80)}`);
|
|
220
|
+
let result = "null";
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(req) as unknown[];
|
|
223
|
+
const payload = typeof parsed[0] === "string" ? (JSON.parse(parsed[0]) as HostCommand) : (parsed[0] as HostCommand);
|
|
224
|
+
result = applyCommand(payload);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error("[host-ffi] command failed:", err);
|
|
227
|
+
}
|
|
228
|
+
webview.symbols.webview_return(handle, cstr(id), 0, cstr(result));
|
|
229
|
+
},
|
|
230
|
+
{ args: [FFIType.cstring, FFIType.cstring, FFIType.pointer], returns: FFIType.void },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
webview.symbols.webview_set_title(handle, cstr(config.title ?? "bun-webview2"));
|
|
234
|
+
webview.symbols.webview_set_size(handle, config.width ?? 800, config.height ?? 600, WEBVIEW_HINT_NONE);
|
|
235
|
+
webview.symbols.webview_bind(handle, cstr("bwv2_host_cmd"), bindCallback.ptr, null);
|
|
236
|
+
|
|
237
|
+
// about:blank keeps the initial document inert; the parent drives all real
|
|
238
|
+
// content over CDP.
|
|
239
|
+
webview.symbols.webview_navigate(handle, cstr("about:blank"));
|
|
240
|
+
|
|
241
|
+
webview.symbols.webview_run(handle);
|
|
242
|
+
// run() returning means the window closed (user X or the "close" command).
|
|
243
|
+
emit({ event: "closed" });
|
|
244
|
+
process.exit(0);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bun-webview2 — desktop apps with Bun on Windows.
|
|
3
|
+
*
|
|
4
|
+
* Native WebView2 windows you fully control from Bun: navigation,
|
|
5
|
+
* evaluation, typed IPC, screenshots, and window chrome, driven over the
|
|
6
|
+
* Chrome DevTools Protocol. No browser UI, no tab strip, no first-run
|
|
7
|
+
* nags — and no DevTools escape hatch unless you allow one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { Webview2Window, createWindow } from "./window.ts";
|
|
11
|
+
export type {
|
|
12
|
+
Webview2WindowOptions,
|
|
13
|
+
ExposedFunction,
|
|
14
|
+
ScreenshotFormat,
|
|
15
|
+
WindowEventPayloads,
|
|
16
|
+
WindowEventName,
|
|
17
|
+
} from "./window.ts";
|
|
18
|
+
export type { HardeningOptions } from "./inject.ts";
|
|
19
|
+
export { CdpConnection, pickFreePort, waitForPageTarget } from "./cdp.ts";
|
|
20
|
+
export type { CdpTargetInfo } from "./cdp.ts";
|
|
21
|
+
export { serveApp } from "./serve.ts";
|
|
22
|
+
export type { ServedApp } from "./serve.ts";
|
package/src/inject.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scripts injected into every new document via Page.addScriptToEvaluateOnNewDocument.
|
|
3
|
+
*
|
|
4
|
+
* Two concerns live here:
|
|
5
|
+
*
|
|
6
|
+
* 1. Hardening — bun-webview2 windows are app windows, not browser windows.
|
|
7
|
+
* By default the Chromium right-click menu and the browser accelerators
|
|
8
|
+
* that leak app-ness (F12, Ctrl+Shift+I/J/C, Ctrl+U/S/P) are disabled.
|
|
9
|
+
* Both are gateable per-window via the constructor options.
|
|
10
|
+
*
|
|
11
|
+
* 2. IPC bridge — exposes `window.bwv2.call(name, ...args)` returning a
|
|
12
|
+
* Promise. Calls travel to Bun through a Runtime binding; results travel
|
|
13
|
+
* back through a resolve callback the host evaluates.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface HardeningOptions {
|
|
17
|
+
/** Allow the native Chromium right-click context menu. Default: false (blocked). */
|
|
18
|
+
readonly contextMenu?: boolean;
|
|
19
|
+
/** Allow DevTools/browser accelerators (F12, Ctrl+Shift+I/J/C, Ctrl+U/S/P). Default: false (blocked). */
|
|
20
|
+
readonly devToolsKeys?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildHardeningScript(opts: HardeningOptions): string {
|
|
24
|
+
const blocks: string[] = ["Object.defineProperty(window, '__bwv2Hardened', { value: true });"];
|
|
25
|
+
|
|
26
|
+
if (!opts.contextMenu) {
|
|
27
|
+
blocks.push(
|
|
28
|
+
"document.addEventListener('contextmenu', (e) => e.preventDefault(), true);",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!opts.devToolsKeys) {
|
|
33
|
+
blocks.push(`
|
|
34
|
+
window.addEventListener('keydown', (e) => {
|
|
35
|
+
const reservedCombo =
|
|
36
|
+
e.key === 'F12' ||
|
|
37
|
+
(e.ctrlKey && e.shiftKey && ['KeyI', 'KeyJ', 'KeyC'].includes(e.code)) ||
|
|
38
|
+
(e.ctrlKey && !e.shiftKey && ['KeyU', 'KeyS', 'KeyP'].includes(e.code));
|
|
39
|
+
if (reservedCombo) e.preventDefault();
|
|
40
|
+
}, true);`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return `(() => { ${blocks.join("\n")} })();`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* IPC bridge. Always injected; inert until the app calls `window.expose()`.
|
|
49
|
+
* Payloads are single JSON strings because CDP bindings take one string arg.
|
|
50
|
+
*/
|
|
51
|
+
export function buildIpcBridgeScript(): string {
|
|
52
|
+
return `(() => {
|
|
53
|
+
if (window.bwv2) return;
|
|
54
|
+
const pending = new Map();
|
|
55
|
+
let nextId = 1;
|
|
56
|
+
window.bwv2 = {
|
|
57
|
+
call(name, ...args) {
|
|
58
|
+
const id = nextId++;
|
|
59
|
+
const settled = Promise.withResolvers();
|
|
60
|
+
pending.set(id, settled);
|
|
61
|
+
window.__bwv2Ipc(JSON.stringify({ id, name, args }));
|
|
62
|
+
return settled.promise;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
window.__bwv2Resolve = (id, ok, value) => {
|
|
66
|
+
const entry = pending.get(id);
|
|
67
|
+
if (!entry) return;
|
|
68
|
+
pending.delete(id);
|
|
69
|
+
if (ok) entry.resolve(value);
|
|
70
|
+
else entry.reject(new Error(typeof value === 'string' ? value : value?.message ?? String(value)));
|
|
71
|
+
};
|
|
72
|
+
})();`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Page-surface events: input over the page never reaches the host's window
|
|
77
|
+
* procedure (the WebView2 child HWND consumes it), so keys, mouse, wheel,
|
|
78
|
+
* and drag-and-drop are captured as DOM events and forwarded through the
|
|
79
|
+
* existing __bwv2Ipc binding. DOM events are also richer than the Win32
|
|
80
|
+
* equivalents: KeyboardEvent carries layout/IME-aware key and code, and
|
|
81
|
+
* the drop event exposes File objects (readable content) rather than paths.
|
|
82
|
+
*/
|
|
83
|
+
export function buildPageEventsScript(): string {
|
|
84
|
+
return `(() => {
|
|
85
|
+
if (window.__bwv2PageEvents) return;
|
|
86
|
+
Object.defineProperty(window, '__bwv2PageEvents', { value: true });
|
|
87
|
+
const send = (name, payload) =>
|
|
88
|
+
window.__bwv2Ipc(JSON.stringify({ id: -1, name: '__event', args: [name, payload] }));
|
|
89
|
+
const mods = (e) =>
|
|
90
|
+
(e.shiftKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.altKey ? 4 : 0) | (e.metaKey ? 8 : 0);
|
|
91
|
+
document.addEventListener('keydown', (e) =>
|
|
92
|
+
send('key-down', { key: e.key, code: e.code, modifiers: mods(e), isRepeat: e.repeat }), true);
|
|
93
|
+
document.addEventListener('keyup', (e) =>
|
|
94
|
+
send('key-up', { key: e.key, code: e.code, modifiers: mods(e) }), true);
|
|
95
|
+
document.addEventListener('mousedown', (e) =>
|
|
96
|
+
send('mouse-down', { x: e.clientX, y: e.clientY, button: e.button, modifiers: mods(e) }), true);
|
|
97
|
+
document.addEventListener('mouseup', (e) =>
|
|
98
|
+
send('mouse-up', { x: e.clientX, y: e.clientY, button: e.button, modifiers: mods(e) }), true);
|
|
99
|
+
document.addEventListener('mouseenter', () => send('mouse-enter', {}), true);
|
|
100
|
+
document.addEventListener('mouseleave', () => send('mouse-leave', {}), true);
|
|
101
|
+
document.addEventListener('wheel', (e) =>
|
|
102
|
+
send('scroll', { deltaX: e.deltaX, deltaY: e.deltaY }), { capture: true, passive: true });
|
|
103
|
+
// drag-and-drop: the dragover preventDefault is what allows drops at all;
|
|
104
|
+
// no event is sent while hovering (it would fire dozens of times per second)
|
|
105
|
+
document.addEventListener('dragover', (e) => e.preventDefault(), true);
|
|
106
|
+
document.addEventListener('drop', (e) => {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
const files = [...(e.dataTransfer?.files ?? [])].map((f) => ({ name: f.name, size: f.size, type: f.type }));
|
|
109
|
+
send('file-drop', { files });
|
|
110
|
+
}, true);
|
|
111
|
+
})();`;
|
|
112
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local content serving — bun-webview2's answer to custom protocols.
|
|
3
|
+
*
|
|
4
|
+
* Instead of registering an `app://` scheme handler, serve your frontend from
|
|
5
|
+
* Bun itself on a free loopback port and point a window at it. Same ergonomics
|
|
6
|
+
* (fetch-style `Request → Response`, works with Hono or plain handlers),
|
|
7
|
+
* zero extra machinery: it's just `Bun.serve`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ServedApp {
|
|
11
|
+
/** Navigate a window here: `createWindow({ url: served.url })`. */
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly port: number;
|
|
14
|
+
/** Stop the server. Windows navigating to `url` afterwards will fail. */
|
|
15
|
+
readonly stop: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Serve app content on a free 127.0.0.1 port from a fetch-style handler. */
|
|
19
|
+
export function serveApp(handler: (req: Request) => Response | Promise<Response>): ServedApp {
|
|
20
|
+
const server = Bun.serve({
|
|
21
|
+
hostname: "127.0.0.1",
|
|
22
|
+
port: 0,
|
|
23
|
+
fetch: (req: Request): Response | Promise<Response> => handler(req),
|
|
24
|
+
});
|
|
25
|
+
const { port } = server;
|
|
26
|
+
if (typeof port !== "number") throw new Error("bun-webview2: Bun.serve did not report an assigned port");
|
|
27
|
+
return {
|
|
28
|
+
url: `http://127.0.0.1:${port}`,
|
|
29
|
+
port,
|
|
30
|
+
stop: () => {
|
|
31
|
+
server.stop(true);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|