bunite-core 0.12.1 → 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 +172 -16
  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 +239 -12
  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 +118 -26
  34. package/src/webview/polyfill.ts +196 -12
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bunite-core",
3
3
  "description": "Uniting UI and Bun",
4
- "version": "0.12.1",
4
+ "version": "0.14.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "setup:cef": "bun ../tools/bunite-dev/scripts/setup-cef.ts",
@@ -24,8 +24,8 @@
24
24
  "msgpackr": "^1.11.9"
25
25
  },
26
26
  "optionalDependencies": {
27
- "bunite-native-win-x64": "0.0.10",
28
- "bunite-native-mac-arm64": "0.0.1",
29
- "bunite-native-linux-x64": "0.0.1"
27
+ "bunite-native-win-x64": "0.0.12",
28
+ "bunite-native-mac-arm64": "0.0.3",
29
+ "bunite-native-linux-x64": "0.0.3"
30
30
  }
31
31
  }
@@ -18,7 +18,7 @@ import { BrowserWindow } from "./BrowserWindow";
18
18
  import { createSurfaceCapImpl } from "./SurfaceManager";
19
19
  import "./SurfaceBrowserIPC";
20
20
  import { log, logLevelToInt } from "../log";
21
- import { RuntimeCap, SurfaceCap, IpcError, type ImplOf } from "../../rpc/index";
21
+ import { RuntimeCap, SurfaceCap, PageReportingCap, IpcError, type ImplOf } from "../../rpc/index";
22
22
 
23
23
  import type { LogLevel } from "../log";
24
24
 
@@ -189,6 +189,22 @@ export class AppRuntime {
189
189
  themeWatch: () => notImpl("themeWatch"),
190
190
  surface: (_: void, ctx: Parameters<ImplOf<typeof RuntimeCap>["surface"]>[1]) =>
191
191
  ctx.exportCap(SurfaceCap, createSurfaceCapImpl(viewId)),
192
+ reporting: (_: void, ctx: Parameters<ImplOf<typeof RuntimeCap>["reporting"]>[1]) =>
193
+ ctx.exportCap(PageReportingCap, {
194
+ reportConsoleBatch: ({ entries }) => {
195
+ // One queueMicrotask per batch (not per entry) — a 1000-log
196
+ // spam still translates to 1 microtask + N synchronous emits,
197
+ // not N microtasks competing for the queue.
198
+ queueMicrotask(() => {
199
+ for (const entry of entries) {
200
+ buniteEventEmitter.emitEvent(
201
+ buniteEventEmitter.events.webview.consoleMessage(entry),
202
+ viewId
203
+ );
204
+ }
205
+ });
206
+ },
207
+ }),
192
208
  } satisfies ImplOf<typeof RuntimeCap>;
193
209
  return impl;
194
210
  }
@@ -8,13 +8,14 @@ import {
8
8
  type Connection,
9
9
  type BytesPipe,
10
10
  } from "../../rpc/index";
11
- import type { EvaluateResult, SurfaceCapabilities } from "../../rpc/framework";
11
+ import type { EvaluateResult, SurfaceCapabilities, ScreenshotResult, Modifier } from "../../rpc/framework";
12
+ import { encodeModifiers, resolveKey } from "./inputDispatch";
12
13
  import { createEncryptedPipe } from "../encryptedPipe";
13
14
  import {
14
15
  ensureNativeRuntime, getNativeLibrary, toCString, waitForViewReady, cancelWaitForViewReady,
15
- setEvaluateResultHandler, type NativeEvaluateResult
16
+ setEvaluateResultHandler, type NativeEvaluateResult,
17
+ setScreenshotResultHandler, type NativeScreenshotResult,
16
18
  } from "../native";
17
- import { getNativeEngineName } from "../native";
18
19
  import { attachBrowserViewRegistry, getRpcPort } from "./Socket";
19
20
  import { getAppRuntimeOrThrow } from "./App";
20
21
  import { randomBytes } from "node:crypto";
@@ -47,6 +48,54 @@ function rejectEvaluatesForView(viewId: number) {
47
48
  }
48
49
  }
49
50
 
51
+ // Screenshot resolvers — parallel to evaluate. Native fires `screenshot-result`
52
+ // keyed by requestId; payload carries base64 data which TS decodes to Uint8Array.
53
+ type ScreenshotPending = { viewId: number; resolve: (result: ScreenshotResult) => void };
54
+ let nextScreenshotRequestId = 1;
55
+ const screenshotResolvers = new Map<number, ScreenshotPending>();
56
+
57
+ function registerScreenshotRequest(viewId: number, resolve: (result: ScreenshotResult) => void): number {
58
+ const id = nextScreenshotRequestId++;
59
+ screenshotResolvers.set(id, { viewId, resolve });
60
+ return id;
61
+ }
62
+
63
+ function rejectScreenshotsForView(viewId: number) {
64
+ for (const [reqId, entry] of screenshotResolvers) {
65
+ if (entry.viewId === viewId) {
66
+ screenshotResolvers.delete(reqId);
67
+ entry.resolve({ ok: false, code: "not_supported", message: "view destroyed" });
68
+ }
69
+ }
70
+ }
71
+
72
+ function decodeBase64(b64: string): Uint8Array {
73
+ const bin = atob(b64);
74
+ const out = new Uint8Array(bin.length);
75
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
76
+ return out;
77
+ }
78
+
79
+ setScreenshotResultHandler((viewId, raw: NativeScreenshotResult) => {
80
+ const entry = screenshotResolvers.get(raw.requestId);
81
+ if (!entry) return;
82
+ if (entry.viewId !== viewId) return;
83
+ screenshotResolvers.delete(raw.requestId);
84
+ if (raw.ok && raw.dataBase64 && raw.format && raw.mime) {
85
+ try {
86
+ entry.resolve({ ok: true, data: decodeBase64(raw.dataBase64), mime: raw.mime, format: raw.format });
87
+ } catch (e) {
88
+ entry.resolve({ ok: false, code: "runtime_error", message: `base64 decode failed: ${(e as Error).message}` });
89
+ }
90
+ } else {
91
+ entry.resolve({
92
+ ok: false,
93
+ code: (raw.code as "not_supported" | "runtime_error" | "timeout") ?? "runtime_error",
94
+ message: raw.message ?? "screenshot failed",
95
+ });
96
+ }
97
+ });
98
+
50
99
  setEvaluateResultHandler((viewId, raw: NativeEvaluateResult) => {
51
100
  const entry = evaluateResolvers.get(raw.requestId);
52
101
  if (!entry) return;
@@ -67,18 +116,40 @@ setEvaluateResultHandler((viewId, raw: NativeEvaluateResult) => {
67
116
  }
68
117
  });
69
118
 
70
- function computeCapabilities(engine: string | null): SurfaceCapabilities {
71
- const supports = engine === "webview2" || engine === "cef";
119
+ // Bit positions match the native enum in `ffi_exports.h` (BuniteCapBit).
120
+ const CAP_EVALUATE = 1 << 0;
121
+ const CAP_CROSS_ORIGIN_EVAL = 1 << 1;
122
+ const CAP_SURFACE_EVENTS = 1 << 2;
123
+ const CAP_NATIVE_INPUT_TRUSTED = 1 << 3;
124
+ const CAP_CLICK = 1 << 4;
125
+ const CAP_TYPE = 1 << 5;
126
+ const CAP_PRESS = 1 << 6;
127
+ const CAP_SCROLL = 1 << 7;
128
+ const CAP_SCREENSHOT = 1 << 8;
129
+ const CAP_FORMAT_PNG = 1 << 9;
130
+ const CAP_FORMAT_JPEG = 1 << 10;
131
+ const CAP_MOUSE = 1 << 11;
132
+ const CAP_DIALOGS = 1 << 12;
133
+ const CAP_CONSOLE = 1 << 13;
134
+
135
+ function decodeCapabilityBits(bits: number): SurfaceCapabilities {
136
+ const formats: ("png" | "jpeg")[] = [];
137
+ if (bits & CAP_FORMAT_PNG) formats.push("png");
138
+ if (bits & CAP_FORMAT_JPEG) formats.push("jpeg");
72
139
  return {
73
- evaluate: supports,
74
- crossOriginEval: false,
75
- titleChanged: supports,
76
- nativeInputTrusted: false,
77
- click: false,
78
- type: false,
79
- press: false,
80
- scroll: false,
81
- screenshot: false,
140
+ evaluate: !!(bits & CAP_EVALUATE),
141
+ crossOriginEval: !!(bits & CAP_CROSS_ORIGIN_EVAL),
142
+ surfaceEvents: !!(bits & CAP_SURFACE_EVENTS),
143
+ nativeInputTrusted: !!(bits & CAP_NATIVE_INPUT_TRUSTED),
144
+ click: !!(bits & CAP_CLICK),
145
+ type: !!(bits & CAP_TYPE),
146
+ press: !!(bits & CAP_PRESS),
147
+ scroll: !!(bits & CAP_SCROLL),
148
+ mouse: !!(bits & CAP_MOUSE),
149
+ dialogs: !!(bits & CAP_DIALOGS),
150
+ console: !!(bits & CAP_CONSOLE),
151
+ screenshot: !!(bits & CAP_SCREENSHOT),
152
+ ...(formats.length > 0 ? { formats } : {}),
82
153
  };
83
154
  }
84
155
 
@@ -282,7 +353,91 @@ export class BrowserView {
282
353
  }
283
354
 
284
355
  capabilities(): SurfaceCapabilities {
285
- return computeCapabilities(getNativeEngineName());
356
+ if (!this.nativeAttached) return decodeCapabilityBits(0);
357
+ const bits = getNativeLibrary()?.symbols.bunite_view_capabilities(this.id) ?? 0;
358
+ return decodeCapabilityBits(bits);
359
+ }
360
+
361
+ // High-level automation API — same shape as `SurfaceCap` RPC + element
362
+ // `send*` methods. Modifier translation + key resolution happen inside;
363
+ // callers never touch the FFI int contract.
364
+ click(args: {
365
+ x: number; y: number;
366
+ button?: "left" | "middle" | "right";
367
+ clickCount?: number;
368
+ modifiers?: Modifier[];
369
+ }) {
370
+ if (!this.nativeAttached) return;
371
+ const button = args.button === "right" ? 2 : args.button === "middle" ? 1 : 0;
372
+ getNativeLibrary()?.symbols.bunite_view_click(
373
+ this.id, args.x, args.y, button, args.clickCount ?? 1, encodeModifiers(args.modifiers)
374
+ );
375
+ }
376
+
377
+ type(text: string) {
378
+ if (!this.nativeAttached) return;
379
+ getNativeLibrary()?.symbols.bunite_view_type(this.id, toCString(text));
380
+ }
381
+
382
+ press(key: string, modifiers?: Modifier[], action?: "down" | "up" | "both") {
383
+ if (!this.nativeAttached) return;
384
+ const r = resolveKey(key);
385
+ const a = action === "down" ? 0 : action === "up" ? 1 : 2;
386
+ getNativeLibrary()?.symbols.bunite_view_press(
387
+ this.id, r.windowsVkCode, r.macKeyCode,
388
+ toCString(r.key), toCString(r.code), toCString(r.character),
389
+ encodeModifiers(modifiers), a, r.extended, r.location
390
+ );
391
+ }
392
+
393
+ scroll(args: {
394
+ dx: number; dy: number; x?: number; y?: number;
395
+ modifiers?: Modifier[];
396
+ }) {
397
+ if (!this.nativeAttached) return;
398
+ getNativeLibrary()?.symbols.bunite_view_scroll(
399
+ this.id, args.dx, args.dy, args.x ?? 0, args.y ?? 0, encodeModifiers(args.modifiers)
400
+ );
401
+ }
402
+
403
+ mouse(args: {
404
+ action: "move" | "down" | "up";
405
+ x: number; y: number;
406
+ button?: "left" | "middle" | "right";
407
+ modifiers?: Modifier[];
408
+ }) {
409
+ if (!this.nativeAttached) return;
410
+ const action = args.action === "move" ? 0 : args.action === "down" ? 1 : 2;
411
+ const button = args.button === "right" ? 2 : args.button === "middle" ? 1 : 0;
412
+ getNativeLibrary()?.symbols.bunite_view_mouse(
413
+ this.id, action, args.x, args.y, button, encodeModifiers(args.modifiers)
414
+ );
415
+ }
416
+
417
+ respondToDialog(requestId: number, accept: boolean, text?: string) {
418
+ if (!this.nativeAttached) return;
419
+ getNativeLibrary()?.symbols.bunite_view_respond_dialog(
420
+ this.id, requestId, accept, toCString(text ?? "")
421
+ );
422
+ }
423
+
424
+ screenshot(format: "png" | "jpeg", quality: number): Promise<ScreenshotResult> {
425
+ if (!this.nativeAttached) {
426
+ return Promise.resolve({ ok: false, code: "not_supported", message: "native runtime unavailable" });
427
+ }
428
+ return new Promise<ScreenshotResult>((resolve) => {
429
+ const requestId = registerScreenshotRequest(this.id, resolve);
430
+ // Timeout — guards against silent hangs (e.g. CEF compositor never delivers).
431
+ const timer = setTimeout(() => {
432
+ if (screenshotResolvers.delete(requestId)) {
433
+ resolve({ ok: false, code: "timeout", message: "screenshot timed out after 30s" });
434
+ }
435
+ }, 30_000);
436
+ const wrappedResolve = (r: ScreenshotResult) => { clearTimeout(timer); resolve(r); };
437
+ // Replace the registered resolver so the timeout-clearing wrapper runs on success.
438
+ screenshotResolvers.set(requestId, { viewId: this.id, resolve: wrappedResolve });
439
+ getNativeLibrary()?.symbols.bunite_view_screenshot(this.id, requestId, toCString(format), quality);
440
+ });
286
441
  }
287
442
 
288
443
  goBack() {
@@ -390,6 +545,7 @@ export class BrowserView {
390
545
  removeSurfacesForHostView(this.id);
391
546
  cancelWaitForViewReady(this.id);
392
547
  rejectEvaluatesForView(this.id);
548
+ rejectScreenshotsForView(this.id);
393
549
  this.nativeAttached = false;
394
550
  for (const eventName of [
395
551
  "will-navigate", "did-navigate", "dom-ready", "new-window-open", "permission-requested", "title-changed"
@@ -400,7 +556,7 @@ export class BrowserView {
400
556
  }
401
557
 
402
558
  on(
403
- name: "will-navigate" | "did-navigate" | "dom-ready" | "new-window-open" | "permission-requested" | "title-changed",
559
+ name: "will-navigate" | "did-navigate" | "dom-ready" | "new-window-open" | "permission-requested" | "title-changed" | "load-start" | "load-finish" | "load-fail" | "dialog" | "console-message",
404
560
  handler: (event: unknown) => void
405
561
  ) {
406
562
  const specificName = `${name}-${this.id}`;
@@ -1,10 +1,51 @@
1
- import { onSurfaceInit, emitDidNavigate, emitTitleChanged } from "./SurfaceManager";
1
+ import {
2
+ onSurfaceInit, emitSurfaceEvent, emitConsole,
3
+ registerDialogRequest, disposeSurfaceState, clearConsoleBuffer,
4
+ } from "./SurfaceManager";
2
5
 
3
6
  onSurfaceInit((surfaceId, hostViewId, view) => {
4
7
  view.on("did-navigate", (event: any) => {
5
- emitDidNavigate(hostViewId, surfaceId, event.data.detail);
8
+ emitSurfaceEvent(hostViewId, surfaceId, { type: "navigate", url: event.data.detail });
6
9
  });
7
10
  view.on("title-changed", (event: any) => {
8
- emitTitleChanged(hostViewId, surfaceId, event.data.detail);
11
+ emitSurfaceEvent(hostViewId, surfaceId, { type: "title-change", title: event.data.detail });
12
+ });
13
+ view.on("load-start", (event: any) => {
14
+ // Reload / fresh nav — clear retained host buffer so consumers don't see
15
+ // stale entries from the prior document.
16
+ clearConsoleBuffer(surfaceId);
17
+ emitSurfaceEvent(hostViewId, surfaceId, { type: "load-start", url: event.data.detail });
18
+ });
19
+ view.on("load-finish", (event: any) => {
20
+ emitSurfaceEvent(hostViewId, surfaceId, { type: "load-finish", url: event.data.detail });
21
+ });
22
+ view.on("load-fail", (event: any) => {
23
+ const d = event.data;
24
+ emitSurfaceEvent(hostViewId, surfaceId, {
25
+ type: "load-fail", url: d.url ?? "", reason: d.reason,
26
+ });
27
+ });
28
+ view.on("dialog", (event: any) => {
29
+ const d = event.data as {
30
+ requestId: number;
31
+ kind: "alert" | "confirm" | "prompt" | "beforeunload";
32
+ message: string;
33
+ defaultPrompt?: string;
34
+ };
35
+ registerDialogRequest(hostViewId, surfaceId, d);
36
+ });
37
+ view.on("console-message", (event: any) => {
38
+ // PageReportingCap impl already wraps the whole batch in a single
39
+ // microtask — no extra deferral needed at the listener level.
40
+ emitConsole(hostViewId, surfaceId, event.data);
9
41
  });
10
42
  });
43
+
44
+ // Surface registry's untrackSurface doesn't fire a teardown event — wire it
45
+ // at the call site if needed. For now state TTL aligns with the view lifetime
46
+ // since surfaceId is reused only after view destruction; the buffer is GC'd
47
+ // once the surface is removed via `remove()` which eventually triggers
48
+ // `disposeSurfaceState` here.
49
+ export function disposeSurface(surfaceId: number) {
50
+ disposeSurfaceState(surfaceId);
51
+ }