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,10 +1,13 @@
1
1
  import { BrowserView } from "./BrowserView";
2
2
  import {
3
3
  trackSurface, untrackSurface, getOwnedSurface,
4
- getHostSurfaceIds, getSurfaceRecord,
4
+ getHostSurfaceIds, getSurfaceRecord, onSurfaceDispose,
5
5
  MAX_SURFACES_PER_HOST
6
6
  } from "./SurfaceRegistry";
7
- import { SurfaceCap, type ImplOf, IpcError } from "../../rpc/index";
7
+ import {
8
+ SurfaceCap, type ImplOf, IpcError,
9
+ type SurfaceEvent, type DialogEvent, type ConsoleEntry,
10
+ } from "../../rpc/index";
8
11
  import { Stream } from "../../rpc/stream";
9
12
 
10
13
  function applyHostOffset(hostView: BrowserView, x: number, y: number) {
@@ -18,22 +21,131 @@ export function onSurfaceInit(cb: SurfaceInitCallback) {
18
21
  initCallbacks.push(cb);
19
22
  }
20
23
 
21
- type DidNavigateEmit = (event: { surfaceId: number; url: string }) => void;
22
- const didNavigateSubs = new Map<number, Set<DidNavigateEmit>>();
24
+ type SurfaceEventEmit = (event: { surfaceId: number; event: SurfaceEvent }) => void;
25
+ const surfaceEventSubs = new Map<number, Set<SurfaceEventEmit>>();
23
26
 
24
- export function emitDidNavigate(hostViewId: number, surfaceId: number, url: string) {
25
- const subs = didNavigateSubs.get(hostViewId);
27
+ export function emitSurfaceEvent(hostViewId: number, surfaceId: number, event: SurfaceEvent) {
28
+ const subs = surfaceEventSubs.get(hostViewId);
26
29
  if (!subs) return;
27
- for (const emit of subs) emit({ surfaceId, url });
30
+ for (const emit of subs) emit({ surfaceId, event });
31
+ }
32
+
33
+ type DialogEmit = (event: { surfaceId: number; event: DialogEvent }) => void;
34
+ const dialogSubs = new Map<number, Set<DialogEmit>>();
35
+
36
+ type ConsoleEmit = (event: { surfaceId: number; entry: ConsoleEntry }) => void;
37
+ const consoleSubs = new Map<number, Set<ConsoleEmit>>();
38
+
39
+ const CONSOLE_BUFFER_LIMIT = 200;
40
+ const DEFAULT_DIALOG_TIMEOUT_MS = 5000;
41
+
42
+ type PendingDialog = {
43
+ requestId: number;
44
+ originalKind: "alert" | "confirm" | "prompt" | "beforeunload";
45
+ message: string;
46
+ timer: ReturnType<typeof setTimeout> | null;
47
+ };
48
+
49
+ type SurfaceState = {
50
+ consoleBuffer: ConsoleEntry[];
51
+ dialogTimeoutMs: number | null; // null = no auto-dismiss
52
+ pendingDialogs: Map<number, PendingDialog>;
53
+ };
54
+
55
+ const surfaceState = new Map<number, SurfaceState>();
56
+
57
+ function getOrCreateState(surfaceId: number): SurfaceState {
58
+ let s = surfaceState.get(surfaceId);
59
+ if (!s) {
60
+ s = {
61
+ consoleBuffer: [],
62
+ dialogTimeoutMs: DEFAULT_DIALOG_TIMEOUT_MS,
63
+ pendingDialogs: new Map(),
64
+ };
65
+ surfaceState.set(surfaceId, s);
66
+ }
67
+ return s;
28
68
  }
29
69
 
30
- type TitleChangedEmit = (event: { surfaceId: number; title: string }) => void;
31
- const titleChangedSubs = new Map<number, Set<TitleChangedEmit>>();
70
+ export function disposeSurfaceState(surfaceId: number) {
71
+ const s = surfaceState.get(surfaceId);
72
+ if (!s) return;
73
+ for (const p of s.pendingDialogs.values()) {
74
+ if (p.timer) clearTimeout(p.timer);
75
+ }
76
+ surfaceState.delete(surfaceId);
77
+ }
78
+
79
+ // Wire dispose to any untrack path (remove + removeSurfacesForHostView).
80
+ onSurfaceDispose(disposeSurfaceState);
81
+
82
+ export function clearConsoleBuffer(surfaceId: number) {
83
+ const s = surfaceState.get(surfaceId);
84
+ if (s) s.consoleBuffer.length = 0;
85
+ }
32
86
 
33
- export function emitTitleChanged(hostViewId: number, surfaceId: number, title: string) {
34
- const subs = titleChangedSubs.get(hostViewId);
87
+ export function emitDialog(hostViewId: number, surfaceId: number, event: DialogEvent) {
88
+ const subs = dialogSubs.get(hostViewId);
35
89
  if (!subs) return;
36
- for (const emit of subs) emit({ surfaceId, title });
90
+ for (const emit of subs) emit({ surfaceId, event });
91
+ }
92
+
93
+ export function emitConsole(hostViewId: number, surfaceId: number, entry: ConsoleEntry) {
94
+ const state = getOrCreateState(surfaceId);
95
+ state.consoleBuffer.push(entry);
96
+ if (state.consoleBuffer.length > CONSOLE_BUFFER_LIMIT) {
97
+ state.consoleBuffer.splice(0, state.consoleBuffer.length - CONSOLE_BUFFER_LIMIT);
98
+ }
99
+ const subs = consoleSubs.get(hostViewId);
100
+ if (!subs) return;
101
+ for (const emit of subs) emit({ surfaceId, entry });
102
+ }
103
+
104
+ /** Called by SurfaceBrowserIPC on native `dialog` event. Stashes pending entry
105
+ * + arms the auto-dismiss timer, then broadcasts to subscribers. */
106
+ export function registerDialogRequest(
107
+ hostViewId: number,
108
+ surfaceId: number,
109
+ request: { requestId: number; kind: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
110
+ ) {
111
+ const state = getOrCreateState(surfaceId);
112
+ const view = getSurfaceRecord(surfaceId)?.view;
113
+
114
+ const entry: PendingDialog = {
115
+ requestId: request.requestId,
116
+ originalKind: request.kind,
117
+ message: request.message,
118
+ timer: null,
119
+ };
120
+ if (state.dialogTimeoutMs !== null && view) {
121
+ entry.timer = setTimeout(() => {
122
+ if (!state.pendingDialogs.delete(request.requestId)) return;
123
+ view.respondToDialog(request.requestId, false);
124
+ emitDialog(hostViewId, surfaceId, {
125
+ kind: "auto-dismissed",
126
+ originalKind: entry.originalKind,
127
+ message: entry.message,
128
+ });
129
+ }, state.dialogTimeoutMs);
130
+ }
131
+ state.pendingDialogs.set(request.requestId, entry);
132
+
133
+ emitDialog(hostViewId, surfaceId, {
134
+ kind: request.kind,
135
+ requestId: request.requestId,
136
+ message: request.message,
137
+ defaultPrompt: request.defaultPrompt,
138
+ });
139
+ }
140
+
141
+ function consumePendingDialog(surfaceId: number, requestId: number): boolean {
142
+ const state = surfaceState.get(surfaceId);
143
+ if (!state) return false;
144
+ const entry = state.pendingDialogs.get(requestId);
145
+ if (!entry) return false;
146
+ if (entry.timer) clearTimeout(entry.timer);
147
+ state.pendingDialogs.delete(requestId);
148
+ return true;
37
149
  }
38
150
 
39
151
  export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceCap> {
@@ -87,6 +199,7 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
87
199
  const record = ownedSurface(surfaceId);
88
200
  if (!record) return;
89
201
  untrackSurface(surfaceId);
202
+ disposeSurfaceState(surfaceId);
90
203
  record.view.remove();
91
204
  },
92
205
 
@@ -151,45 +264,164 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
151
264
  return record.view.evaluate(script);
152
265
  },
153
266
 
267
+ click: ({ surfaceId, ...args }) => {
268
+ const record = ownedSurface(surfaceId);
269
+ if (!record) return;
270
+ record.view.click(args);
271
+ },
272
+
273
+ type: ({ surfaceId, text }) => {
274
+ const record = ownedSurface(surfaceId);
275
+ if (!record) return;
276
+ record.view.type(text);
277
+ },
278
+
279
+ press: ({ surfaceId, key, modifiers, action }) => {
280
+ const record = ownedSurface(surfaceId);
281
+ if (!record) return;
282
+ record.view.press(key, modifiers, action);
283
+ },
284
+
285
+ scroll: ({ surfaceId, ...args }) => {
286
+ const record = ownedSurface(surfaceId);
287
+ if (!record) return;
288
+ record.view.scroll(args);
289
+ },
290
+
291
+ mouse: ({ surfaceId, ...args }) => {
292
+ const record = ownedSurface(surfaceId);
293
+ if (!record) return;
294
+ record.view.mouse(args);
295
+ },
296
+
297
+ screenshot: async ({ surfaceId, format = "png", quality = 90 }) => {
298
+ const record = ownedSurface(surfaceId);
299
+ if (!record) return { ok: false as const, code: "not_supported" as const, message: "surface not found" };
300
+ return record.view.screenshot(format, quality);
301
+ },
302
+
303
+ waitForSelector: async ({ surfaceId, selector, timeoutMs = 5000 }) => {
304
+ const record = ownedSurface(surfaceId);
305
+ if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
306
+ const deadline = Date.now() + timeoutMs;
307
+ const expr = `!!document.querySelector(${JSON.stringify(selector)})`;
308
+ while (Date.now() < deadline) {
309
+ const res = await record.view.evaluate(expr);
310
+ if (res.ok && res.value === true) return { ok: true as const };
311
+ if (!res.ok && res.code !== "timeout") {
312
+ // Propagate cross_origin distinct from runtime_error — consumer
313
+ // may want to retry with a same-origin sub-surface vs. fix the script.
314
+ const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
315
+ return { ok: false as const, code, message: res.message };
316
+ }
317
+ await new Promise((r) => setTimeout(r, 50));
318
+ }
319
+ return { ok: false as const, code: "timeout" as const, message: `selector ${JSON.stringify(selector)} not found within ${timeoutMs}ms` };
320
+ },
321
+
322
+ waitForFunction: async ({ surfaceId, expression, timeoutMs = 5000, pollIntervalMs = 50 }) => {
323
+ const record = ownedSurface(surfaceId);
324
+ if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
325
+ const deadline = Date.now() + timeoutMs;
326
+ while (Date.now() < deadline) {
327
+ const res = await record.view.evaluate(expression);
328
+ if (res.ok && res.value) return { ok: true as const };
329
+ if (!res.ok && res.code !== "timeout") {
330
+ const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
331
+ return { ok: false as const, code, message: res.message };
332
+ }
333
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
334
+ }
335
+ return { ok: false as const, code: "timeout" as const, message: `function did not satisfy within ${timeoutMs}ms` };
336
+ },
337
+
338
+ respondToDialog: ({ surfaceId, requestId, accept, text }) => {
339
+ const record = ownedSurface(surfaceId);
340
+ if (!record) return;
341
+ if (!consumePendingDialog(surfaceId, requestId)) return; // stale or already auto-dismissed
342
+ record.view.respondToDialog(requestId, accept, text);
343
+ },
344
+
345
+ setDialogTimeout: ({ surfaceId, ms }) => {
346
+ const record = ownedSurface(surfaceId);
347
+ if (!record) return;
348
+ const state = getOrCreateState(surfaceId);
349
+ state.dialogTimeoutMs = ms;
350
+ },
351
+
352
+ getConsoleBuffer: ({ surfaceId, clear }) => {
353
+ const record = ownedSurface(surfaceId);
354
+ if (!record) return [];
355
+ const state = getOrCreateState(surfaceId);
356
+ const snapshot = state.consoleBuffer.slice();
357
+ if (clear) state.consoleBuffer.length = 0;
358
+ return snapshot;
359
+ },
360
+
154
361
  capabilities: ({ surfaceId }) => {
155
362
  const record = ownedSurface(surfaceId);
156
363
  if (!record) {
157
364
  return {
158
- evaluate: false, crossOriginEval: false, titleChanged: false,
365
+ evaluate: false, crossOriginEval: false, surfaceEvents: false,
159
366
  nativeInputTrusted: false, click: false, type: false, press: false,
160
- scroll: false, screenshot: false,
367
+ scroll: false, mouse: false, dialogs: false, console: false,
368
+ screenshot: false,
161
369
  };
162
370
  }
163
371
  return record.view.capabilities();
164
372
  },
165
373
 
166
- didNavigate: () => Stream.from<{ surfaceId: number; url: string }>((emit, signal) => {
167
- let subs = didNavigateSubs.get(hostViewId);
374
+ surfaceEvents: ({ surfaceId: filterId }) => Stream.from<SurfaceEvent>((emit, signal) => {
375
+ let subs = surfaceEventSubs.get(hostViewId);
376
+ if (!subs) {
377
+ subs = new Set();
378
+ surfaceEventSubs.set(hostViewId, subs);
379
+ }
380
+ const wrapped: SurfaceEventEmit = ({ surfaceId, event }) => {
381
+ if (surfaceId === filterId) emit(event);
382
+ };
383
+ subs.add(wrapped);
384
+ signal.addEventListener("abort", () => {
385
+ const set = surfaceEventSubs.get(hostViewId);
386
+ if (!set) return;
387
+ set.delete(wrapped);
388
+ if (set.size === 0) surfaceEventSubs.delete(hostViewId);
389
+ });
390
+ }),
391
+
392
+ dialogs: ({ surfaceId: filterId }) => Stream.from<DialogEvent>((emit, signal) => {
393
+ let subs = dialogSubs.get(hostViewId);
168
394
  if (!subs) {
169
395
  subs = new Set();
170
- didNavigateSubs.set(hostViewId, subs);
396
+ dialogSubs.set(hostViewId, subs);
171
397
  }
172
- subs.add(emit);
398
+ const wrapped: DialogEmit = ({ surfaceId, event }) => {
399
+ if (surfaceId === filterId) emit(event);
400
+ };
401
+ subs.add(wrapped);
173
402
  signal.addEventListener("abort", () => {
174
- const set = didNavigateSubs.get(hostViewId);
403
+ const set = dialogSubs.get(hostViewId);
175
404
  if (!set) return;
176
- set.delete(emit);
177
- if (set.size === 0) didNavigateSubs.delete(hostViewId);
405
+ set.delete(wrapped);
406
+ if (set.size === 0) dialogSubs.delete(hostViewId);
178
407
  });
179
408
  }),
180
409
 
181
- titleChanged: () => Stream.from<{ surfaceId: number; title: string }>((emit, signal) => {
182
- let subs = titleChangedSubs.get(hostViewId);
410
+ consoleEvents: ({ surfaceId: filterId }) => Stream.from<ConsoleEntry>((emit, signal) => {
411
+ let subs = consoleSubs.get(hostViewId);
183
412
  if (!subs) {
184
413
  subs = new Set();
185
- titleChangedSubs.set(hostViewId, subs);
414
+ consoleSubs.set(hostViewId, subs);
186
415
  }
187
- subs.add(emit);
416
+ const wrapped: ConsoleEmit = ({ surfaceId, entry }) => {
417
+ if (surfaceId === filterId) emit(entry);
418
+ };
419
+ subs.add(wrapped);
188
420
  signal.addEventListener("abort", () => {
189
- const set = titleChangedSubs.get(hostViewId);
421
+ const set = consoleSubs.get(hostViewId);
190
422
  if (!set) return;
191
- set.delete(emit);
192
- if (set.size === 0) titleChangedSubs.delete(hostViewId);
423
+ set.delete(wrapped);
424
+ if (set.size === 0) consoleSubs.delete(hostViewId);
193
425
  });
194
426
  }),
195
427
  };
@@ -21,6 +21,13 @@ export function trackSurface(surfaceId: number, record: SurfaceRecord) {
21
21
  ids.add(surfaceId);
22
22
  }
23
23
 
24
+ type DisposeHook = (surfaceId: number) => void;
25
+ const disposeHooks: DisposeHook[] = [];
26
+
27
+ export function onSurfaceDispose(cb: DisposeHook) {
28
+ disposeHooks.push(cb);
29
+ }
30
+
24
31
  export function untrackSurface(surfaceId: number) {
25
32
  const record = surfaces.get(surfaceId);
26
33
  if (!record) return;
@@ -30,6 +37,7 @@ export function untrackSurface(surfaceId: number) {
30
37
  ids.delete(surfaceId);
31
38
  if (ids.size === 0) hostSurfaceIds.delete(record.hostViewId);
32
39
  }
40
+ for (const cb of disposeHooks) cb(surfaceId);
33
41
  }
34
42
 
35
43
  export function getOwnedSurface(surfaceId: number, ctx: { viewId: number }): SurfaceRecord | null {
@@ -54,7 +62,7 @@ export function removeSurfacesForHostView(hostViewId: number) {
54
62
  for (const surfaceId of Array.from(ids)) {
55
63
  const record = surfaces.get(surfaceId);
56
64
  if (!record) continue;
57
- untrackSurface(surfaceId);
65
+ untrackSurface(surfaceId); // fires disposeHooks
58
66
  record.view.remove();
59
67
  }
60
68
  }
@@ -0,0 +1,147 @@
1
+ // Input dispatch helpers — modifier encoding + DOM `key` → Win32 VK + macOS
2
+ // Quartz key code + DOM `code` + char. Backends translate the FFI bitmask
3
+ // (Alt=1, Ctrl=2, Meta=4, Shift=8) to native form. Stage B keymap covers
4
+ // ASCII + the named keys Playwright-style automation relies on.
5
+
6
+ import type { Modifier } from "../../rpc/framework";
7
+
8
+ export function encodeModifiers(mods: Modifier[] | undefined): number {
9
+ if (!mods) return 0;
10
+ let bits = 0;
11
+ for (const m of mods) {
12
+ if (m === "alt") bits |= 1;
13
+ else if (m === "ctrl") bits |= 2;
14
+ else if (m === "meta") bits |= 4;
15
+ else if (m === "shift") bits |= 8;
16
+ }
17
+ return bits;
18
+ }
19
+
20
+ export interface ResolvedKey {
21
+ windowsVkCode: number;
22
+ macKeyCode: number;
23
+ /** DOM `KeyboardEvent.key` — pass-through; native dispatchers forward to engines. */
24
+ key: string;
25
+ /** DOM `KeyboardEvent.code` — derived from US keyboard mapping. */
26
+ code: string;
27
+ /** Text payload for the CHAR / insertText event; empty = skip char. */
28
+ character: string;
29
+ /** Win scancode 0xE0 prefix: nav cluster (Arrow/Insert/Delete/Home/End/
30
+ * PageUp/PageDown/Meta/ContextMenu) AND Numpad-Enter. Distinct from
31
+ * `location` — most extended keys are NOT numpad (location 0). */
32
+ extended: boolean;
33
+ /** DOM `KeyboardEvent.location`: 0=standard, 1=left mod, 2=right mod,
34
+ * 3=numpad. WV2 CDP uses this; CEF derives from scancode 0xE0 prefix. */
35
+ location: 0 | 1 | 2 | 3;
36
+ }
37
+
38
+ /** Maps a DOM `KeyboardEvent.key` value to backend-neutral identifiers. */
39
+ export function resolveKey(domKey: string): ResolvedKey {
40
+ if (domKey.length === 0) {
41
+ return { windowsVkCode: 0, macKeyCode: 0, key: "", code: "", character: "", extended: false, location: 0 };
42
+ }
43
+
44
+ // Named key (Enter, Tab, ArrowLeft …).
45
+ const named = NAMED_KEYS[domKey];
46
+ if (named) {
47
+ return {
48
+ windowsVkCode: named.win,
49
+ macKeyCode: named.mac,
50
+ key: domKey,
51
+ code: named.code,
52
+ // Space/Tab/Enter generate text in CDP automatically; we pass an explicit
53
+ // character so DOM `keypress` fires consistently across engines.
54
+ character: named.character ?? "",
55
+ extended: named.ext === true,
56
+ location: named.loc ?? 0,
57
+ };
58
+ }
59
+
60
+ // Single Unicode codepoint — letter / digit / printable / extended.
61
+ if ([...domKey].length === 1) {
62
+ const cp = domKey.codePointAt(0)!;
63
+ // ASCII A-Z / a-z → matching Win VK + mac keyCode + DOM code "KeyX".
64
+ if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) {
65
+ const upper = cp & ~0x20; // strip lowercase bit
66
+ return {
67
+ windowsVkCode: upper,
68
+ macKeyCode: MAC_KEY_LETTER[upper - 0x41],
69
+ key: domKey,
70
+ code: `Key${String.fromCharCode(upper)}`,
71
+ character: domKey,
72
+ extended: false,
73
+ location: 0,
74
+ };
75
+ }
76
+ // ASCII 0-9.
77
+ if (cp >= 0x30 && cp <= 0x39) {
78
+ return {
79
+ windowsVkCode: cp,
80
+ macKeyCode: MAC_KEY_DIGIT[cp - 0x30],
81
+ key: domKey,
82
+ code: `Digit${domKey}`,
83
+ character: domKey,
84
+ extended: false,
85
+ location: 0,
86
+ };
87
+ }
88
+ // Other printable codepoint — char event only, no virtual key.
89
+ return { windowsVkCode: 0, macKeyCode: 0, key: domKey, code: "", character: domKey, extended: false, location: 0 };
90
+ }
91
+
92
+ // Multi-codepoint string we don't recognise as a named key — pass-through.
93
+ return { windowsVkCode: 0, macKeyCode: 0, key: domKey, code: "", character: "", extended: false, location: 0 };
94
+ }
95
+
96
+ // Win32 VK_* + Quartz Event Services kVK_* + DOM code + literal character +
97
+ // LPARAM extended-key flag. `ext: true` for nav-cluster keys (separate from
98
+ // numpad equivalents) and Numpad-Enter — Chromium derives `KeyboardEvent.code`
99
+ // from LPARAM scancode + extended bit. Sources:
100
+ // learn.microsoft.com/windows/win32/inputdev/virtual-key-codes
101
+ // chromium/ui/events/keycodes/dom/keycode_converter_data.inc
102
+ type NamedKey = { win: number; mac: number; code: string; character?: string; ext?: true; loc?: 1 | 2 | 3 };
103
+ const NAMED_KEYS: Record<string, NamedKey> = {
104
+ Backspace: { win: 0x08, mac: 0x33, code: "Backspace" },
105
+ Tab: { win: 0x09, mac: 0x30, code: "Tab", character: "\t" },
106
+ Enter: { win: 0x0D, mac: 0x24, code: "Enter", character: "\r" },
107
+ NumpadEnter: { win: 0x0D, mac: 0x4C, code: "NumpadEnter", character: "\r", ext: true, loc: 3 },
108
+ Escape: { win: 0x1B, mac: 0x35, code: "Escape" },
109
+ " ": { win: 0x20, mac: 0x31, code: "Space", character: " " },
110
+ Space: { win: 0x20, mac: 0x31, code: "Space", character: " " },
111
+ PageUp: { win: 0x21, mac: 0x74, code: "PageUp", ext: true },
112
+ PageDown: { win: 0x22, mac: 0x79, code: "PageDown", ext: true },
113
+ End: { win: 0x23, mac: 0x77, code: "End", ext: true },
114
+ Home: { win: 0x24, mac: 0x73, code: "Home", ext: true },
115
+ ArrowLeft: { win: 0x25, mac: 0x7B, code: "ArrowLeft", ext: true },
116
+ ArrowUp: { win: 0x26, mac: 0x7E, code: "ArrowUp", ext: true },
117
+ ArrowRight: { win: 0x27, mac: 0x7C, code: "ArrowRight", ext: true },
118
+ ArrowDown: { win: 0x28, mac: 0x7D, code: "ArrowDown", ext: true },
119
+ Insert: { win: 0x2D, mac: 0x72, code: "Insert", ext: true },
120
+ Delete: { win: 0x2E, mac: 0x75, code: "Delete", ext: true },
121
+ Meta: { win: 0x5B, mac: 0x37, code: "MetaLeft", ext: true },
122
+ ContextMenu: { win: 0x5D, mac: 0x6E, code: "ContextMenu", ext: true },
123
+ F1: { win: 0x70, mac: 0x7A, code: "F1" },
124
+ F2: { win: 0x71, mac: 0x78, code: "F2" },
125
+ F3: { win: 0x72, mac: 0x63, code: "F3" },
126
+ F4: { win: 0x73, mac: 0x76, code: "F4" },
127
+ F5: { win: 0x74, mac: 0x60, code: "F5" },
128
+ F6: { win: 0x75, mac: 0x61, code: "F6" },
129
+ F7: { win: 0x76, mac: 0x62, code: "F7" },
130
+ F8: { win: 0x77, mac: 0x64, code: "F8" },
131
+ F9: { win: 0x78, mac: 0x65, code: "F9" },
132
+ F10: { win: 0x79, mac: 0x6D, code: "F10" },
133
+ F11: { win: 0x7A, mac: 0x67, code: "F11" },
134
+ F12: { win: 0x7B, mac: 0x6F, code: "F12" },
135
+ };
136
+
137
+ // US keyboard layout — Quartz hardware key code per ASCII letter (A → 0x00, …, Z → 0x06).
138
+ const MAC_KEY_LETTER = [
139
+ // A B C D E F G H I J K L M
140
+ 0x00, 0x0B, 0x08, 0x02, 0x0E, 0x03, 0x05, 0x04, 0x22, 0x26, 0x28, 0x25, 0x2E,
141
+ // N O P Q R S T U V W X Y Z
142
+ 0x2D, 0x1F, 0x23, 0x0C, 0x0F, 0x01, 0x11, 0x20, 0x09, 0x0D, 0x07, 0x10, 0x06,
143
+ ];
144
+ // US keyboard layout — Quartz hardware key code per digit (0 → 0x1D, 1 → 0x12 …).
145
+ const MAC_KEY_DIGIT = [
146
+ 0x1D, 0x12, 0x13, 0x14, 0x15, 0x17, 0x16, 0x1A, 0x1C, 0x19,
147
+ ];
@@ -8,5 +8,12 @@ export default {
8
8
  new BuniteEvent("new-window-open", data),
9
9
  permissionRequested: (data: { requestId: number; kind: number; url?: string }) =>
10
10
  new BuniteEvent("permission-requested", data),
11
- titleChanged: (data: { detail: string }) => new BuniteEvent("title-changed", data)
11
+ titleChanged: (data: { detail: string }) => new BuniteEvent("title-changed", data),
12
+ loadStart: (data: { detail: string }) => new BuniteEvent("load-start", data),
13
+ loadFinish: (data: { detail: string }) => new BuniteEvent("load-finish", data),
14
+ loadFail: (data: { url: string; reason?: string }) => new BuniteEvent("load-fail", data),
15
+ dialog: (data: { requestId: number; kind: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }) =>
16
+ new BuniteEvent("dialog", data),
17
+ consoleMessage: (data: { level: "log" | "warn" | "error" | "info" | "debug"; args: string[]; ts: number }) =>
18
+ new BuniteEvent("console-message", data)
12
19
  };