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
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.0",
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.9",
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";
@@ -25,29 +26,89 @@ const BrowserViewMap: Record<number, BrowserView> = {};
25
26
  let nextWebviewId = 1;
26
27
 
27
28
  // Evaluate request plumbing — native fires `evaluate-result` events keyed by
28
- // requestId. Resolvers are sliced by viewId on dispatch (set once at module
29
- // load).
29
+ // requestId. Each pending entry records its viewId so detachFromNative can
30
+ // reject any in-flight Promises for that view (otherwise the resolver leaks
31
+ // when the view is destroyed mid-evaluate).
32
+ type EvaluatePending = { viewId: number; resolve: (result: EvaluateResult) => void };
30
33
  let nextEvaluateRequestId = 1;
31
- const evaluateResolvers = new Map<number, (result: EvaluateResult) => void>();
34
+ const evaluateResolvers = new Map<number, EvaluatePending>();
32
35
 
33
- function registerEvaluateRequest(resolve: (result: EvaluateResult) => void): number {
36
+ function registerEvaluateRequest(viewId: number, resolve: (result: EvaluateResult) => void): number {
34
37
  const id = nextEvaluateRequestId++;
35
- evaluateResolvers.set(id, resolve);
38
+ evaluateResolvers.set(id, { viewId, resolve });
36
39
  return id;
37
40
  }
38
41
 
39
- setEvaluateResultHandler((_viewId, raw: NativeEvaluateResult) => {
40
- const resolve = evaluateResolvers.get(raw.requestId);
41
- if (!resolve) return;
42
+ function rejectEvaluatesForView(viewId: number) {
43
+ for (const [reqId, entry] of evaluateResolvers) {
44
+ if (entry.viewId === viewId) {
45
+ evaluateResolvers.delete(reqId);
46
+ entry.resolve({ ok: false, code: "not_supported", message: "view destroyed" });
47
+ }
48
+ }
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
+
99
+ setEvaluateResultHandler((viewId, raw: NativeEvaluateResult) => {
100
+ const entry = evaluateResolvers.get(raw.requestId);
101
+ if (!entry) return;
102
+ if (entry.viewId !== viewId) return; // foreign event — ignore
42
103
  evaluateResolvers.delete(raw.requestId);
43
104
  if (raw.ok && raw.value !== undefined) {
44
105
  try {
45
- resolve({ ok: true, value: JSON.parse(raw.value) });
106
+ entry.resolve({ ok: true, value: JSON.parse(raw.value) });
46
107
  } catch (e) {
47
- resolve({ ok: false, code: "runtime_error", message: `result JSON parse failed: ${(e as Error).message}` });
108
+ entry.resolve({ ok: false, code: "runtime_error", message: `result JSON parse failed: ${(e as Error).message}` });
48
109
  }
49
110
  } else {
50
- resolve({
111
+ entry.resolve({
51
112
  ok: false,
52
113
  code: (raw.code as EvaluateResult extends { code: infer C } ? C : never) ?? "runtime_error",
53
114
  message: raw.message ?? "evaluate failed",
@@ -55,18 +116,40 @@ setEvaluateResultHandler((_viewId, raw: NativeEvaluateResult) => {
55
116
  }
56
117
  });
57
118
 
58
- function computeCapabilities(engine: string | null): SurfaceCapabilities {
59
- 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");
60
139
  return {
61
- evaluate: supports,
62
- crossOriginEval: false,
63
- titleChanged: supports,
64
- nativeInputTrusted: false,
65
- click: false,
66
- type: false,
67
- press: false,
68
- scroll: false,
69
- 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 } : {}),
70
153
  };
71
154
  }
72
155
 
@@ -264,13 +347,97 @@ export class BrowserView {
264
347
  return Promise.resolve({ ok: false, code: "not_supported", message: "native runtime unavailable" });
265
348
  }
266
349
  return new Promise<EvaluateResult>((resolve) => {
267
- const requestId = registerEvaluateRequest(resolve);
350
+ const requestId = registerEvaluateRequest(this.id, resolve);
268
351
  getNativeLibrary()?.symbols.bunite_view_evaluate(this.id, requestId, toCString(script));
269
352
  });
270
353
  }
271
354
 
272
355
  capabilities(): SurfaceCapabilities {
273
- 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
+ });
274
441
  }
275
442
 
276
443
  goBack() {
@@ -377,6 +544,8 @@ export class BrowserView {
377
544
  detachFromNative() {
378
545
  removeSurfacesForHostView(this.id);
379
546
  cancelWaitForViewReady(this.id);
547
+ rejectEvaluatesForView(this.id);
548
+ rejectScreenshotsForView(this.id);
380
549
  this.nativeAttached = false;
381
550
  for (const eventName of [
382
551
  "will-navigate", "did-navigate", "dom-ready", "new-window-open", "permission-requested", "title-changed"
@@ -387,7 +556,7 @@ export class BrowserView {
387
556
  }
388
557
 
389
558
  on(
390
- 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",
391
560
  handler: (event: unknown) => void
392
561
  ) {
393
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
+ }