@mandujs/core 0.41.2 → 0.43.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 (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,583 +1,583 @@
1
- /**
2
- * Phase 11 C / M-02 — `bun:ffi` fallback for `webview-bun`.
3
- *
4
- * # Why this exists
5
- *
6
- * `webview-bun` (tr1ckydev/webview-bun) is the primary optional peer for
7
- * `@mandujs/core/desktop`. Phase 9c R0 diagnostics
8
- * (`docs/bun/phase-9-diagnostics/webview-bun-ffi.md`) identified one
9
- * supply-chain risk: single maintainer, ~400 stars, small contributor
10
- * base. If the peer ever goes unmaintained, Mandu's desktop story breaks.
11
- *
12
- * The upstream C++ library (https://github.com/webview/webview) is
13
- * healthy — 14k★, 636 commits, backed by continuous Microsoft + Apple
14
- * platform support. The fallback binds Mandu directly to that library
15
- * via `bun:ffi`, bypassing `webview-bun` as a JS layer.
16
- *
17
- * This file is a **prototype** — it declares the minimal FFI surface
18
- * (`webview_create` / `webview_navigate` / `webview_set_title` /
19
- * `webview_run` / `webview_destroy`) and wires a creator that mirrors
20
- * the `webview-bun` constructor contract so `window.ts::createWindow`
21
- * can substitute it.
22
- *
23
- * # Non-goals
24
- *
25
- * - NOT production-ready. Phase 11 C ships the prototype; real rollout
26
- * (signed DLL mirroring + SHA-256 verification + Mandu CDN or LFS
27
- * hosting) is tracked in `docs/bun/phase-11-diagnostics/completeness-sprint.md`
28
- * §C-1.
29
- * - NOT a replacement for `webview-bun` on the happy path. Only used
30
- * when `MANDU_DESKTOP_INLINE_FFI=1` is set OR when the `webview-bun`
31
- * peer's dynamic `import()` rejects with a module-not-found error.
32
- * - NOT an IPC layer. `webview-bun`'s `bind()` is not mirrored — the
33
- * fallback exposes `navigate` + `setHTML` + `title` + `run` + `destroy`
34
- * only. Apps that use `bind()` must keep the primary peer.
35
- *
36
- * # Safety
37
- *
38
- * - `bun:ffi`'s `dlopen` throws synchronously when the library path is
39
- * unreachable. All three entrypoints (`_ffiSymbols`, `loadFFILibwebview`,
40
- * `createFallbackWebview`) are guarded so that importing this module
41
- * never throws — the failure surface is at first use.
42
- * - The library search order is: (1) `MANDU_LIBWEBVIEW_PATH` env var
43
- * (absolute path), (2) `@mandujs/core` package-relative
44
- * `libwebview.{dll|dylib|so}` (if mirrored by a future release
45
- * pipeline), (3) system-default via `dlopen` with the bare name. When
46
- * all three fail, `loadFFILibwebview()` throws an actionable error
47
- * explaining each probe path that was tried.
48
- * - Type definitions use `FFIType.cstring` for strings, matching the
49
- * upstream C ABI (`const char*`). `destroy()` zeros the pointer so
50
- * double-free is a no-op at the FFI level.
51
- *
52
- * # Runtime behavior
53
- *
54
- * On import, this module does NOT load `libwebview`. `dlopen` only runs
55
- * on the first `createFallbackWebview()` call, at which point a clean
56
- * "install and mirror libwebview" error is thrown if the library is
57
- * unreachable. Tests exercise the FFI surface definitions without
58
- * triggering the load path (see `__tests__/webview-fallback.test.ts`).
59
- *
60
- * # References
61
- *
62
- * - `docs/bun/phase-9-diagnostics/webview-bun-ffi.md` §8 (fallback design)
63
- * - https://github.com/webview/webview/blob/master/webview.h (C API)
64
- * - https://bun.sh/docs/api/ffi (Bun FFI docs)
65
- */
66
-
67
- import type {
68
- WindowHandle,
69
- WindowOptions,
70
- WindowSizeHint,
71
- } from "./types.js";
72
-
73
- // ─── FFI symbol contract (upstream webview/webview) ────────────────────────
74
- //
75
- // This object is a PURE specification — it's the `dlopen` symbols map we
76
- // hand to `bun:ffi`. Exported so tests can introspect the contract
77
- // without triggering a real `dlopen`.
78
-
79
- /**
80
- * The minimal C ABI we need from libwebview. Signatures mirror
81
- * `webview.h` from the upstream C++ repo.
82
- *
83
- * @internal
84
- */
85
- export const _ffiSymbols = Object.freeze({
86
- /**
87
- * `webview_t webview_create(int debug, void* window);`
88
- *
89
- * Create a native webview. `debug=1` enables DevTools / WebInspector.
90
- * `window=null` asks libwebview to create its own shell window.
91
- * Returns an opaque handle (`webview_t`) or `null` on failure.
92
- */
93
- webview_create: {
94
- args: ["i32", "ptr"] as const,
95
- returns: "ptr" as const,
96
- },
97
- /**
98
- * `void webview_navigate(webview_t w, const char* url);`
99
- *
100
- * Point the webview at a URL. Blocks until the new page's `onload`
101
- * fires (or times out per platform rules).
102
- *
103
- * Note: `bun:ffi` arg type for C strings is `ptr` (pointer to an
104
- * encoded buffer), NOT `cstring` (which is return-only in Bun 1.3).
105
- * Callers must pre-encode JS strings via `_encodeCString` before
106
- * passing to this symbol. See `webview-bun/src/ffi.ts` for the
107
- * canonical reference.
108
- */
109
- webview_navigate: {
110
- args: ["ptr", "ptr"] as const,
111
- returns: "void" as const,
112
- },
113
- /**
114
- * `void webview_set_title(webview_t w, const char* title);`
115
- *
116
- * Set the native window title. No-op if the underlying OS window
117
- * hasn't been realized yet. Second arg is a ptr — pre-encode via
118
- * `_encodeCString`.
119
- */
120
- webview_set_title: {
121
- args: ["ptr", "ptr"] as const,
122
- returns: "void" as const,
123
- },
124
- /**
125
- * `void webview_set_size(webview_t w, int width, int height, int hints);`
126
- *
127
- * Hints map (from webview.h):
128
- * WEBVIEW_HINT_NONE = 0 // freely resizable
129
- * WEBVIEW_HINT_MIN = 1
130
- * WEBVIEW_HINT_MAX = 2
131
- * WEBVIEW_HINT_FIXED = 3
132
- */
133
- webview_set_size: {
134
- args: ["ptr", "i32", "i32", "i32"] as const,
135
- returns: "void" as const,
136
- },
137
- /**
138
- * `void webview_set_html(webview_t w, const char* html);`
139
- *
140
- * Second arg is a ptr — pre-encode via `_encodeCString`.
141
- */
142
- webview_set_html: {
143
- args: ["ptr", "ptr"] as const,
144
- returns: "void" as const,
145
- },
146
- /**
147
- * `void webview_run(webview_t w);`
148
- *
149
- * BLOCKING. Returns when the user closes the shell window or when
150
- * `webview_terminate(w)` is called from another thread.
151
- */
152
- webview_run: {
153
- args: ["ptr"] as const,
154
- returns: "void" as const,
155
- },
156
- /**
157
- * `void webview_terminate(webview_t w);`
158
- *
159
- * Request that `webview_run` return ASAP. Safe to call from any
160
- * thread (unlike `destroy`, which must follow run).
161
- */
162
- webview_terminate: {
163
- args: ["ptr"] as const,
164
- returns: "void" as const,
165
- },
166
- /**
167
- * `void webview_destroy(webview_t w);`
168
- *
169
- * Free the webview and its native resources. Must be called after
170
- * `webview_run` returns.
171
- */
172
- webview_destroy: {
173
- args: ["ptr"] as const,
174
- returns: "void" as const,
175
- },
176
- });
177
-
178
- /**
179
- * Encode a JS string as a null-terminated C string pointer, suitable for
180
- * passing to `ptr`-arg FFI calls.
181
- *
182
- * `bun:ffi`'s `cstring` type is return-only in Bun 1.3; for C-string
183
- * arguments we must encode the string to a `Buffer` and take a pointer.
184
- * This helper mirrors `webview-bun/src/ffi.ts::encodeCString`.
185
- *
186
- * The returned pointer is valid ONLY as long as the encoding Buffer is
187
- * reachable — callers MUST keep the buffer alive (e.g. by holding the
188
- * reference in a local variable) until the FFI call returns.
189
- *
190
- * @internal
191
- */
192
- export async function _encodeCString(
193
- value: string,
194
- ): Promise<{ ptr: unknown; buffer: Uint8Array }> {
195
- const { ptr } = await import("bun:ffi");
196
- const buffer = new TextEncoder().encode(value + "\0");
197
- return {
198
- ptr: (ptr as (b: Uint8Array) => unknown)(buffer),
199
- buffer,
200
- };
201
- }
202
-
203
- /**
204
- * Map the string hint to libwebview's numeric enum (matches
205
- * `WEBVIEW_HINT_*` in `webview.h`).
206
- *
207
- * @internal
208
- */
209
- export function _mapHintToInt(hint: WindowSizeHint | undefined): number {
210
- switch (hint) {
211
- case "min":
212
- return 1;
213
- case "max":
214
- return 2;
215
- case "fixed":
216
- return 3;
217
- case "none":
218
- case undefined:
219
- default:
220
- return 0;
221
- }
222
- }
223
-
224
- // ─── Library loading ───────────────────────────────────────────────────────
225
-
226
- /**
227
- * OS-specific native lib filename. Returned as a best-effort default — the
228
- * real resolution path adds `MANDU_LIBWEBVIEW_PATH` and a package-relative
229
- * probe before falling back to this.
230
- *
231
- * @internal
232
- */
233
- export function _defaultLibName(): string {
234
- switch (process.platform) {
235
- case "win32":
236
- return "libwebview.dll";
237
- case "darwin":
238
- return "libwebview.dylib";
239
- default:
240
- // Linux + BSDs + etc.
241
- return "libwebview.so";
242
- }
243
- }
244
-
245
- /**
246
- * Ordered list of candidate paths for the libwebview shared library.
247
- *
248
- * Exported for the test suite so we can verify the resolution contract
249
- * without triggering a real `dlopen`.
250
- *
251
- * @internal
252
- */
253
- export function _getLibraryCandidates(): string[] {
254
- const candidates: string[] = [];
255
- const envPath = process.env.MANDU_LIBWEBVIEW_PATH;
256
- if (envPath && envPath.length > 0) {
257
- candidates.push(envPath);
258
- }
259
- // Future-proof: a release-time mirror step will ship
260
- // `packages/core/dist/native/libwebview.{dll,dylib,so}` alongside the
261
- // published module. We probe this location (resolved relative to THIS
262
- // module at runtime) but it is NOT guaranteed to exist in Phase 11 C.
263
- //
264
- // We don't fs.existsSync in this pure function — the caller does.
265
- try {
266
- const url = new URL("../../dist/native/" + _defaultLibName(), import.meta.url);
267
- candidates.push(decodeURIComponent(url.pathname).replace(/^\//, ""));
268
- } catch {
269
- // URL construction must never throw in normal workflows; if it does
270
- // we simply skip this candidate rather than poison the fallback.
271
- }
272
- // Final fallback — let `dlopen` consult the OS's dynamic-linker search
273
- // path (PATH on Windows, LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH
274
- // on macOS).
275
- candidates.push(_defaultLibName());
276
- return candidates;
277
- }
278
-
279
- /**
280
- * Cached FFI handle. Populated on first successful load.
281
- */
282
- interface LoadedFFI {
283
- /**
284
- * Bun's FFI symbols map — opaque to Mandu core, but well-defined by
285
- * `bun:ffi`'s `dlopen` return type. We type this as `unknown` to keep
286
- * the cross-peer contract narrow.
287
- */
288
- symbols: Record<string, unknown>;
289
- libraryPath: string;
290
- }
291
-
292
- let loadedFFICache: LoadedFFI | null = null;
293
-
294
- /**
295
- * Dynamically load the libwebview shared library via `bun:ffi`. Throws an
296
- * actionable error when none of the candidate paths resolve.
297
- *
298
- * Marked async even though `dlopen` is synchronous so the call shape
299
- * matches `_loadWebviewBun` in `window.ts`.
300
- *
301
- * @internal
302
- */
303
- export async function loadFFILibwebview(): Promise<LoadedFFI> {
304
- if (loadedFFICache) return loadedFFICache;
305
-
306
- // Lazy-import `bun:ffi` so a `bun test` in an environment where `bun:ffi`
307
- // isn't available (e.g. a Deno test runner, or a future isolated
308
- // sandbox) still lets the module load.
309
- let dlopen: unknown;
310
- try {
311
- // @ts-ignore -- `bun:ffi` is a Bun built-in; resolution is runtime-only.
312
- const mod = await import("bun:ffi");
313
- dlopen = (mod as { dlopen?: unknown }).dlopen;
314
- if (typeof dlopen !== "function") {
315
- throw new Error("bun:ffi.dlopen is not a function");
316
- }
317
- } catch (cause) {
318
- throw new Error(
319
- [
320
- "[@mandujs/core/desktop/webview-fallback] Could not load `bun:ffi`.",
321
- "This fallback requires the Bun runtime (not Node.js).",
322
- "",
323
- `Cause: ${cause instanceof Error ? cause.message : String(cause)}`,
324
- ].join("\n"),
325
- { cause: cause as Error },
326
- );
327
- }
328
-
329
- const candidates = _getLibraryCandidates();
330
- const errors: string[] = [];
331
-
332
- for (const libPath of candidates) {
333
- try {
334
- const loaded = (dlopen as (p: string, s: typeof _ffiSymbols) => {
335
- symbols: Record<string, unknown>;
336
- })(libPath, _ffiSymbols);
337
- loadedFFICache = { symbols: loaded.symbols, libraryPath: libPath };
338
- return loadedFFICache;
339
- } catch (err) {
340
- errors.push(
341
- ` ${libPath}: ${err instanceof Error ? err.message : String(err)}`,
342
- );
343
- }
344
- }
345
-
346
- throw new Error(
347
- [
348
- "[@mandujs/core/desktop/webview-fallback] Failed to load libwebview from any candidate path.",
349
- "",
350
- "Tried (in order):",
351
- ...errors,
352
- "",
353
- "To install libwebview:",
354
- " 1. Prebuilt binaries: https://github.com/webview/webview-releases",
355
- " 2. Build from source: https://github.com/webview/webview#building",
356
- "",
357
- "Then either:",
358
- " - Set MANDU_LIBWEBVIEW_PATH=<absolute path> before running the app",
359
- " - Place the library on the OS dynamic-linker search path",
360
- ` - Default name probed: ${_defaultLibName()}`,
361
- ].join("\n"),
362
- );
363
- }
364
-
365
- /**
366
- * Reset the library-load cache. Tests only.
367
- *
368
- * @internal
369
- */
370
- export function _resetFFICache(): void {
371
- loadedFFICache = null;
372
- }
373
-
374
- // ─── Webview factory (FFI-backed) ──────────────────────────────────────────
375
-
376
- /**
377
- * Create a webview using the bun:ffi fallback path. Returns a
378
- * {@link WindowHandle} that uses the same public shape as
379
- * `createWindow()` — callers should not need to distinguish between
380
- * the two backends.
381
- *
382
- * This factory is `async` to match the `webview-bun` peer loader contract
383
- * (which does a dynamic import). Construction itself is synchronous
384
- * beyond the initial `loadFFILibwebview()`.
385
- *
386
- * @example
387
- * ```ts
388
- * import { createFallbackWebview } from "@mandujs/core/desktop/webview-fallback";
389
- *
390
- * const handle = await createFallbackWebview({
391
- * url: "http://127.0.0.1:3333",
392
- * title: "My App",
393
- * width: 1024,
394
- * height: 768,
395
- * });
396
- * handle.run();
397
- * ```
398
- */
399
- export async function createFallbackWebview(
400
- options: WindowOptions,
401
- ): Promise<WindowHandle> {
402
- // Option validation is the caller's responsibility — this path is
403
- // reached only from `window.ts::createWindow` after it calls
404
- // `_validateOptions`. We still perform minimal defensive checks so
405
- // direct callers (tests, experimental apps) don't segfault.
406
- if (!options || typeof options !== "object") {
407
- throw new TypeError(
408
- "[@mandujs/core/desktop/webview-fallback] options must be an object.",
409
- );
410
- }
411
- if (typeof options.url !== "string" || options.url.length === 0) {
412
- throw new TypeError(
413
- "[@mandujs/core/desktop/webview-fallback] 'url' must be a non-empty string.",
414
- );
415
- }
416
-
417
- const { symbols } = await loadFFILibwebview();
418
-
419
- // `bun:ffi` exposes each symbol as a callable — the return type is
420
- // `(...args: any[]) => unknown`. We narrow via cast at each callsite
421
- // instead of widening the symbols map's type.
422
- type PtrFn = (...args: unknown[]) => unknown;
423
- const create = symbols.webview_create as PtrFn;
424
- const navigate = symbols.webview_navigate as PtrFn;
425
- const setTitle = symbols.webview_set_title as PtrFn;
426
- const setSize = symbols.webview_set_size as PtrFn;
427
- const run = symbols.webview_run as PtrFn;
428
- const terminate = symbols.webview_terminate as PtrFn;
429
- const destroy = symbols.webview_destroy as PtrFn;
430
-
431
- const debug = options.debug ? 1 : 0;
432
- const handle = create(debug, null) as unknown;
433
- if (!handle) {
434
- throw new Error(
435
- "[@mandujs/core/desktop/webview-fallback] webview_create returned null (libwebview init failed).",
436
- );
437
- }
438
-
439
- // Size + title BEFORE navigate, matching the `webview-bun` ctor contract.
440
- const width = options.width ?? 1024;
441
- const height = options.height ?? 768;
442
- setSize(handle, width, height, _mapHintToInt(options.hint));
443
- // The libwebview C API takes a `const char*`; `bun:ffi`'s `ptr` arg
444
- // type needs a pre-encoded buffer. Keep references live across the
445
- // entire sync block so the GC doesn't collect the buffer before
446
- // libwebview copies out the string. `_encodeCString` returns both the
447
- // ptr and the backing Uint8Array for this reason.
448
- const titleEnc = await _encodeCString(options.title ?? "Mandu Desktop");
449
- setTitle(handle, titleEnc.ptr);
450
- const urlEnc = await _encodeCString(options.url);
451
- navigate(handle, urlEnc.ptr);
452
- // Keep encoding buffers reachable past the sync navigate call.
453
- // libwebview copies the strings internally, so once we're past the
454
- // synchronous FFI return these buffers can be GC'd — we just need
455
- // them alive THROUGH the FFI call, which the local const refs ensure.
456
- void titleEnc.buffer;
457
- void urlEnc.buffer;
458
-
459
- let closed = false;
460
- let resolveClosed: (() => void) | null = null;
461
- const closedPromise = new Promise<void>((resolve) => {
462
- resolveClosed = resolve;
463
- });
464
- const closeCallbacks: Array<() => void> = [];
465
-
466
- function markClosed(): void {
467
- if (closed) return;
468
- closed = true;
469
- for (const cb of closeCallbacks) {
470
- try {
471
- cb();
472
- } catch (error) {
473
- console.error(
474
- "[@mandujs/core/desktop/webview-fallback] onClose callback threw:",
475
- error,
476
- );
477
- }
478
- }
479
- if (options.onClose) {
480
- try {
481
- const result = options.onClose();
482
- if (result instanceof Promise) {
483
- result.catch((error) =>
484
- console.error(
485
- "[@mandujs/core/desktop/webview-fallback] onClose (options) threw:",
486
- error,
487
- ),
488
- );
489
- }
490
- } catch (error) {
491
- console.error(
492
- "[@mandujs/core/desktop/webview-fallback] onClose (options) threw:",
493
- error,
494
- );
495
- }
496
- }
497
- resolveClosed?.();
498
- }
499
-
500
- if (options.onReady) {
501
- queueMicrotask(() => {
502
- try {
503
- const result = options.onReady!();
504
- if (result instanceof Promise) {
505
- result.catch((error) =>
506
- console.error(
507
- "[@mandujs/core/desktop/webview-fallback] onReady threw:",
508
- error,
509
- ),
510
- );
511
- }
512
- } catch (error) {
513
- console.error(
514
- "[@mandujs/core/desktop/webview-fallback] onReady threw:",
515
- error,
516
- );
517
- }
518
- });
519
- }
520
-
521
- return {
522
- async close(): Promise<void> {
523
- if (closed) return;
524
- try {
525
- terminate(handle);
526
- } catch {
527
- /* ignore — terminate may fail if run() hasn't started */
528
- }
529
- try {
530
- destroy(handle);
531
- } catch (error) {
532
- if (options.debug) {
533
- console.warn(
534
- "[@mandujs/core/desktop/webview-fallback] destroy() warning:",
535
- error,
536
- );
537
- }
538
- }
539
- markClosed();
540
- },
541
- onClose(cb: () => void): void {
542
- if (closed) {
543
- queueMicrotask(() => {
544
- try {
545
- cb();
546
- } catch (error) {
547
- console.error(
548
- "[@mandujs/core/desktop/webview-fallback] onClose callback threw:",
549
- error,
550
- );
551
- }
552
- });
553
- return;
554
- }
555
- closeCallbacks.push(cb);
556
- },
557
- async eval(_js: string): Promise<void> {
558
- // eval() needs `webview_eval` which requires an onmessage callback
559
- // wired through webview_bind. Phase 11 C ships the fallback
560
- // without the bind surface — explicit error so apps that depend
561
- // on eval() stay on the primary peer.
562
- throw new Error(
563
- "[@mandujs/core/desktop/webview-fallback] eval() is not supported. " +
564
- "Install webview-bun for full IPC (eval + bind).",
565
- );
566
- },
567
- bind(_name: string, _fn: (...args: unknown[]) => unknown): void {
568
- throw new Error(
569
- "[@mandujs/core/desktop/webview-fallback] bind() is not supported. " +
570
- "Install webview-bun for full IPC (eval + bind).",
571
- );
572
- },
573
- closed: closedPromise,
574
- run(): void {
575
- if (closed) return;
576
- try {
577
- run(handle);
578
- } finally {
579
- markClosed();
580
- }
581
- },
582
- };
583
- }
1
+ /**
2
+ * Phase 11 C / M-02 — `bun:ffi` fallback for `webview-bun`.
3
+ *
4
+ * # Why this exists
5
+ *
6
+ * `webview-bun` (tr1ckydev/webview-bun) is the primary optional peer for
7
+ * `@mandujs/core/desktop`. Phase 9c R0 diagnostics
8
+ * (`docs/bun/phase-9-diagnostics/webview-bun-ffi.md`) identified one
9
+ * supply-chain risk: single maintainer, ~400 stars, small contributor
10
+ * base. If the peer ever goes unmaintained, Mandu's desktop story breaks.
11
+ *
12
+ * The upstream C++ library (https://github.com/webview/webview) is
13
+ * healthy — 14k★, 636 commits, backed by continuous Microsoft + Apple
14
+ * platform support. The fallback binds Mandu directly to that library
15
+ * via `bun:ffi`, bypassing `webview-bun` as a JS layer.
16
+ *
17
+ * This file is a **prototype** — it declares the minimal FFI surface
18
+ * (`webview_create` / `webview_navigate` / `webview_set_title` /
19
+ * `webview_run` / `webview_destroy`) and wires a creator that mirrors
20
+ * the `webview-bun` constructor contract so `window.ts::createWindow`
21
+ * can substitute it.
22
+ *
23
+ * # Non-goals
24
+ *
25
+ * - NOT production-ready. Phase 11 C ships the prototype; real rollout
26
+ * (signed DLL mirroring + SHA-256 verification + Mandu CDN or LFS
27
+ * hosting) is tracked in `docs/bun/phase-11-diagnostics/completeness-sprint.md`
28
+ * §C-1.
29
+ * - NOT a replacement for `webview-bun` on the happy path. Only used
30
+ * when `MANDU_DESKTOP_INLINE_FFI=1` is set OR when the `webview-bun`
31
+ * peer's dynamic `import()` rejects with a module-not-found error.
32
+ * - NOT an IPC layer. `webview-bun`'s `bind()` is not mirrored — the
33
+ * fallback exposes `navigate` + `setHTML` + `title` + `run` + `destroy`
34
+ * only. Apps that use `bind()` must keep the primary peer.
35
+ *
36
+ * # Safety
37
+ *
38
+ * - `bun:ffi`'s `dlopen` throws synchronously when the library path is
39
+ * unreachable. All three entrypoints (`_ffiSymbols`, `loadFFILibwebview`,
40
+ * `createFallbackWebview`) are guarded so that importing this module
41
+ * never throws — the failure surface is at first use.
42
+ * - The library search order is: (1) `MANDU_LIBWEBVIEW_PATH` env var
43
+ * (absolute path), (2) `@mandujs/core` package-relative
44
+ * `libwebview.{dll|dylib|so}` (if mirrored by a future release
45
+ * pipeline), (3) system-default via `dlopen` with the bare name. When
46
+ * all three fail, `loadFFILibwebview()` throws an actionable error
47
+ * explaining each probe path that was tried.
48
+ * - Type definitions use `FFIType.cstring` for strings, matching the
49
+ * upstream C ABI (`const char*`). `destroy()` zeros the pointer so
50
+ * double-free is a no-op at the FFI level.
51
+ *
52
+ * # Runtime behavior
53
+ *
54
+ * On import, this module does NOT load `libwebview`. `dlopen` only runs
55
+ * on the first `createFallbackWebview()` call, at which point a clean
56
+ * "install and mirror libwebview" error is thrown if the library is
57
+ * unreachable. Tests exercise the FFI surface definitions without
58
+ * triggering the load path (see `__tests__/webview-fallback.test.ts`).
59
+ *
60
+ * # References
61
+ *
62
+ * - `docs/bun/phase-9-diagnostics/webview-bun-ffi.md` §8 (fallback design)
63
+ * - https://github.com/webview/webview/blob/master/webview.h (C API)
64
+ * - https://bun.sh/docs/api/ffi (Bun FFI docs)
65
+ */
66
+
67
+ import type {
68
+ WindowHandle,
69
+ WindowOptions,
70
+ WindowSizeHint,
71
+ } from "./types.js";
72
+
73
+ // ─── FFI symbol contract (upstream webview/webview) ────────────────────────
74
+ //
75
+ // This object is a PURE specification — it's the `dlopen` symbols map we
76
+ // hand to `bun:ffi`. Exported so tests can introspect the contract
77
+ // without triggering a real `dlopen`.
78
+
79
+ /**
80
+ * The minimal C ABI we need from libwebview. Signatures mirror
81
+ * `webview.h` from the upstream C++ repo.
82
+ *
83
+ * @internal
84
+ */
85
+ export const _ffiSymbols = Object.freeze({
86
+ /**
87
+ * `webview_t webview_create(int debug, void* window);`
88
+ *
89
+ * Create a native webview. `debug=1` enables DevTools / WebInspector.
90
+ * `window=null` asks libwebview to create its own shell window.
91
+ * Returns an opaque handle (`webview_t`) or `null` on failure.
92
+ */
93
+ webview_create: {
94
+ args: ["i32", "ptr"] as const,
95
+ returns: "ptr" as const,
96
+ },
97
+ /**
98
+ * `void webview_navigate(webview_t w, const char* url);`
99
+ *
100
+ * Point the webview at a URL. Blocks until the new page's `onload`
101
+ * fires (or times out per platform rules).
102
+ *
103
+ * Note: `bun:ffi` arg type for C strings is `ptr` (pointer to an
104
+ * encoded buffer), NOT `cstring` (which is return-only in Bun 1.3).
105
+ * Callers must pre-encode JS strings via `_encodeCString` before
106
+ * passing to this symbol. See `webview-bun/src/ffi.ts` for the
107
+ * canonical reference.
108
+ */
109
+ webview_navigate: {
110
+ args: ["ptr", "ptr"] as const,
111
+ returns: "void" as const,
112
+ },
113
+ /**
114
+ * `void webview_set_title(webview_t w, const char* title);`
115
+ *
116
+ * Set the native window title. No-op if the underlying OS window
117
+ * hasn't been realized yet. Second arg is a ptr — pre-encode via
118
+ * `_encodeCString`.
119
+ */
120
+ webview_set_title: {
121
+ args: ["ptr", "ptr"] as const,
122
+ returns: "void" as const,
123
+ },
124
+ /**
125
+ * `void webview_set_size(webview_t w, int width, int height, int hints);`
126
+ *
127
+ * Hints map (from webview.h):
128
+ * WEBVIEW_HINT_NONE = 0 // freely resizable
129
+ * WEBVIEW_HINT_MIN = 1
130
+ * WEBVIEW_HINT_MAX = 2
131
+ * WEBVIEW_HINT_FIXED = 3
132
+ */
133
+ webview_set_size: {
134
+ args: ["ptr", "i32", "i32", "i32"] as const,
135
+ returns: "void" as const,
136
+ },
137
+ /**
138
+ * `void webview_set_html(webview_t w, const char* html);`
139
+ *
140
+ * Second arg is a ptr — pre-encode via `_encodeCString`.
141
+ */
142
+ webview_set_html: {
143
+ args: ["ptr", "ptr"] as const,
144
+ returns: "void" as const,
145
+ },
146
+ /**
147
+ * `void webview_run(webview_t w);`
148
+ *
149
+ * BLOCKING. Returns when the user closes the shell window or when
150
+ * `webview_terminate(w)` is called from another thread.
151
+ */
152
+ webview_run: {
153
+ args: ["ptr"] as const,
154
+ returns: "void" as const,
155
+ },
156
+ /**
157
+ * `void webview_terminate(webview_t w);`
158
+ *
159
+ * Request that `webview_run` return ASAP. Safe to call from any
160
+ * thread (unlike `destroy`, which must follow run).
161
+ */
162
+ webview_terminate: {
163
+ args: ["ptr"] as const,
164
+ returns: "void" as const,
165
+ },
166
+ /**
167
+ * `void webview_destroy(webview_t w);`
168
+ *
169
+ * Free the webview and its native resources. Must be called after
170
+ * `webview_run` returns.
171
+ */
172
+ webview_destroy: {
173
+ args: ["ptr"] as const,
174
+ returns: "void" as const,
175
+ },
176
+ });
177
+
178
+ /**
179
+ * Encode a JS string as a null-terminated C string pointer, suitable for
180
+ * passing to `ptr`-arg FFI calls.
181
+ *
182
+ * `bun:ffi`'s `cstring` type is return-only in Bun 1.3; for C-string
183
+ * arguments we must encode the string to a `Buffer` and take a pointer.
184
+ * This helper mirrors `webview-bun/src/ffi.ts::encodeCString`.
185
+ *
186
+ * The returned pointer is valid ONLY as long as the encoding Buffer is
187
+ * reachable — callers MUST keep the buffer alive (e.g. by holding the
188
+ * reference in a local variable) until the FFI call returns.
189
+ *
190
+ * @internal
191
+ */
192
+ export async function _encodeCString(
193
+ value: string,
194
+ ): Promise<{ ptr: unknown; buffer: Uint8Array }> {
195
+ const { ptr } = await import("bun:ffi");
196
+ const buffer = new TextEncoder().encode(value + "\0");
197
+ return {
198
+ ptr: (ptr as (b: Uint8Array) => unknown)(buffer),
199
+ buffer,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Map the string hint to libwebview's numeric enum (matches
205
+ * `WEBVIEW_HINT_*` in `webview.h`).
206
+ *
207
+ * @internal
208
+ */
209
+ export function _mapHintToInt(hint: WindowSizeHint | undefined): number {
210
+ switch (hint) {
211
+ case "min":
212
+ return 1;
213
+ case "max":
214
+ return 2;
215
+ case "fixed":
216
+ return 3;
217
+ case "none":
218
+ case undefined:
219
+ default:
220
+ return 0;
221
+ }
222
+ }
223
+
224
+ // ─── Library loading ───────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * OS-specific native lib filename. Returned as a best-effort default — the
228
+ * real resolution path adds `MANDU_LIBWEBVIEW_PATH` and a package-relative
229
+ * probe before falling back to this.
230
+ *
231
+ * @internal
232
+ */
233
+ export function _defaultLibName(): string {
234
+ switch (process.platform) {
235
+ case "win32":
236
+ return "libwebview.dll";
237
+ case "darwin":
238
+ return "libwebview.dylib";
239
+ default:
240
+ // Linux + BSDs + etc.
241
+ return "libwebview.so";
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Ordered list of candidate paths for the libwebview shared library.
247
+ *
248
+ * Exported for the test suite so we can verify the resolution contract
249
+ * without triggering a real `dlopen`.
250
+ *
251
+ * @internal
252
+ */
253
+ export function _getLibraryCandidates(): string[] {
254
+ const candidates: string[] = [];
255
+ const envPath = process.env.MANDU_LIBWEBVIEW_PATH;
256
+ if (envPath && envPath.length > 0) {
257
+ candidates.push(envPath);
258
+ }
259
+ // Future-proof: a release-time mirror step will ship
260
+ // `packages/core/dist/native/libwebview.{dll,dylib,so}` alongside the
261
+ // published module. We probe this location (resolved relative to THIS
262
+ // module at runtime) but it is NOT guaranteed to exist in Phase 11 C.
263
+ //
264
+ // We don't fs.existsSync in this pure function — the caller does.
265
+ try {
266
+ const url = new URL("../../dist/native/" + _defaultLibName(), import.meta.url);
267
+ candidates.push(decodeURIComponent(url.pathname).replace(/^\//, ""));
268
+ } catch {
269
+ // URL construction must never throw in normal workflows; if it does
270
+ // we simply skip this candidate rather than poison the fallback.
271
+ }
272
+ // Final fallback — let `dlopen` consult the OS's dynamic-linker search
273
+ // path (PATH on Windows, LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH
274
+ // on macOS).
275
+ candidates.push(_defaultLibName());
276
+ return candidates;
277
+ }
278
+
279
+ /**
280
+ * Cached FFI handle. Populated on first successful load.
281
+ */
282
+ interface LoadedFFI {
283
+ /**
284
+ * Bun's FFI symbols map — opaque to Mandu core, but well-defined by
285
+ * `bun:ffi`'s `dlopen` return type. We type this as `unknown` to keep
286
+ * the cross-peer contract narrow.
287
+ */
288
+ symbols: Record<string, unknown>;
289
+ libraryPath: string;
290
+ }
291
+
292
+ let loadedFFICache: LoadedFFI | null = null;
293
+
294
+ /**
295
+ * Dynamically load the libwebview shared library via `bun:ffi`. Throws an
296
+ * actionable error when none of the candidate paths resolve.
297
+ *
298
+ * Marked async even though `dlopen` is synchronous so the call shape
299
+ * matches `_loadWebviewBun` in `window.ts`.
300
+ *
301
+ * @internal
302
+ */
303
+ export async function loadFFILibwebview(): Promise<LoadedFFI> {
304
+ if (loadedFFICache) return loadedFFICache;
305
+
306
+ // Lazy-import `bun:ffi` so a `bun test` in an environment where `bun:ffi`
307
+ // isn't available (e.g. a Deno test runner, or a future isolated
308
+ // sandbox) still lets the module load.
309
+ let dlopen: unknown;
310
+ try {
311
+ // @ts-ignore -- `bun:ffi` is a Bun built-in; resolution is runtime-only.
312
+ const mod = await import("bun:ffi");
313
+ dlopen = (mod as { dlopen?: unknown }).dlopen;
314
+ if (typeof dlopen !== "function") {
315
+ throw new Error("bun:ffi.dlopen is not a function");
316
+ }
317
+ } catch (cause) {
318
+ throw new Error(
319
+ [
320
+ "[@mandujs/core/desktop/webview-fallback] Could not load `bun:ffi`.",
321
+ "This fallback requires the Bun runtime (not Node.js).",
322
+ "",
323
+ `Cause: ${cause instanceof Error ? cause.message : String(cause)}`,
324
+ ].join("\n"),
325
+ { cause },
326
+ );
327
+ }
328
+
329
+ const candidates = _getLibraryCandidates();
330
+ const errors: string[] = [];
331
+
332
+ for (const libPath of candidates) {
333
+ try {
334
+ const loaded = (dlopen as (p: string, s: typeof _ffiSymbols) => {
335
+ symbols: Record<string, unknown>;
336
+ })(libPath, _ffiSymbols);
337
+ loadedFFICache = { symbols: loaded.symbols, libraryPath: libPath };
338
+ return loadedFFICache;
339
+ } catch (err) {
340
+ errors.push(
341
+ ` ${libPath}: ${err instanceof Error ? err.message : String(err)}`,
342
+ );
343
+ }
344
+ }
345
+
346
+ throw new Error(
347
+ [
348
+ "[@mandujs/core/desktop/webview-fallback] Failed to load libwebview from any candidate path.",
349
+ "",
350
+ "Tried (in order):",
351
+ ...errors,
352
+ "",
353
+ "To install libwebview:",
354
+ " 1. Prebuilt binaries: https://github.com/webview/webview-releases",
355
+ " 2. Build from source: https://github.com/webview/webview#building",
356
+ "",
357
+ "Then either:",
358
+ " - Set MANDU_LIBWEBVIEW_PATH=<absolute path> before running the app",
359
+ " - Place the library on the OS dynamic-linker search path",
360
+ ` - Default name probed: ${_defaultLibName()}`,
361
+ ].join("\n"),
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Reset the library-load cache. Tests only.
367
+ *
368
+ * @internal
369
+ */
370
+ export function _resetFFICache(): void {
371
+ loadedFFICache = null;
372
+ }
373
+
374
+ // ─── Webview factory (FFI-backed) ──────────────────────────────────────────
375
+
376
+ /**
377
+ * Create a webview using the bun:ffi fallback path. Returns a
378
+ * {@link WindowHandle} that uses the same public shape as
379
+ * `createWindow()` — callers should not need to distinguish between
380
+ * the two backends.
381
+ *
382
+ * This factory is `async` to match the `webview-bun` peer loader contract
383
+ * (which does a dynamic import). Construction itself is synchronous
384
+ * beyond the initial `loadFFILibwebview()`.
385
+ *
386
+ * @example
387
+ * ```ts
388
+ * import { createFallbackWebview } from "@mandujs/core/desktop/webview-fallback";
389
+ *
390
+ * const handle = await createFallbackWebview({
391
+ * url: "http://127.0.0.1:3333",
392
+ * title: "My App",
393
+ * width: 1024,
394
+ * height: 768,
395
+ * });
396
+ * handle.run();
397
+ * ```
398
+ */
399
+ export async function createFallbackWebview(
400
+ options: WindowOptions,
401
+ ): Promise<WindowHandle> {
402
+ // Option validation is the caller's responsibility — this path is
403
+ // reached only from `window.ts::createWindow` after it calls
404
+ // `_validateOptions`. We still perform minimal defensive checks so
405
+ // direct callers (tests, experimental apps) don't segfault.
406
+ if (!options || typeof options !== "object") {
407
+ throw new TypeError(
408
+ "[@mandujs/core/desktop/webview-fallback] options must be an object.",
409
+ );
410
+ }
411
+ if (typeof options.url !== "string" || options.url.length === 0) {
412
+ throw new TypeError(
413
+ "[@mandujs/core/desktop/webview-fallback] 'url' must be a non-empty string.",
414
+ );
415
+ }
416
+
417
+ const { symbols } = await loadFFILibwebview();
418
+
419
+ // `bun:ffi` exposes each symbol as a callable — the return type is
420
+ // `(...args: any[]) => unknown`. We narrow via cast at each callsite
421
+ // instead of widening the symbols map's type.
422
+ type PtrFn = (...args: unknown[]) => unknown;
423
+ const create = symbols.webview_create as PtrFn;
424
+ const navigate = symbols.webview_navigate as PtrFn;
425
+ const setTitle = symbols.webview_set_title as PtrFn;
426
+ const setSize = symbols.webview_set_size as PtrFn;
427
+ const run = symbols.webview_run as PtrFn;
428
+ const terminate = symbols.webview_terminate as PtrFn;
429
+ const destroy = symbols.webview_destroy as PtrFn;
430
+
431
+ const debug = options.debug ? 1 : 0;
432
+ const handle = create(debug, null) as unknown;
433
+ if (!handle) {
434
+ throw new Error(
435
+ "[@mandujs/core/desktop/webview-fallback] webview_create returned null (libwebview init failed).",
436
+ );
437
+ }
438
+
439
+ // Size + title BEFORE navigate, matching the `webview-bun` ctor contract.
440
+ const width = options.width ?? 1024;
441
+ const height = options.height ?? 768;
442
+ setSize(handle, width, height, _mapHintToInt(options.hint));
443
+ // The libwebview C API takes a `const char*`; `bun:ffi`'s `ptr` arg
444
+ // type needs a pre-encoded buffer. Keep references live across the
445
+ // entire sync block so the GC doesn't collect the buffer before
446
+ // libwebview copies out the string. `_encodeCString` returns both the
447
+ // ptr and the backing Uint8Array for this reason.
448
+ const titleEnc = await _encodeCString(options.title ?? "Mandu Desktop");
449
+ setTitle(handle, titleEnc.ptr);
450
+ const urlEnc = await _encodeCString(options.url);
451
+ navigate(handle, urlEnc.ptr);
452
+ // Keep encoding buffers reachable past the sync navigate call.
453
+ // libwebview copies the strings internally, so once we're past the
454
+ // synchronous FFI return these buffers can be GC'd — we just need
455
+ // them alive THROUGH the FFI call, which the local const refs ensure.
456
+ void titleEnc.buffer;
457
+ void urlEnc.buffer;
458
+
459
+ let closed = false;
460
+ let resolveClosed: (() => void) | null = null;
461
+ const closedPromise = new Promise<void>((resolve) => {
462
+ resolveClosed = resolve;
463
+ });
464
+ const closeCallbacks: Array<() => void> = [];
465
+
466
+ function markClosed(): void {
467
+ if (closed) return;
468
+ closed = true;
469
+ for (const cb of closeCallbacks) {
470
+ try {
471
+ cb();
472
+ } catch (error) {
473
+ console.error(
474
+ "[@mandujs/core/desktop/webview-fallback] onClose callback threw:",
475
+ error,
476
+ );
477
+ }
478
+ }
479
+ if (options.onClose) {
480
+ try {
481
+ const result = options.onClose();
482
+ if (result instanceof Promise) {
483
+ result.catch((error) =>
484
+ console.error(
485
+ "[@mandujs/core/desktop/webview-fallback] onClose (options) threw:",
486
+ error,
487
+ ),
488
+ );
489
+ }
490
+ } catch (error) {
491
+ console.error(
492
+ "[@mandujs/core/desktop/webview-fallback] onClose (options) threw:",
493
+ error,
494
+ );
495
+ }
496
+ }
497
+ resolveClosed?.();
498
+ }
499
+
500
+ if (options.onReady) {
501
+ queueMicrotask(() => {
502
+ try {
503
+ const result = options.onReady!();
504
+ if (result instanceof Promise) {
505
+ result.catch((error) =>
506
+ console.error(
507
+ "[@mandujs/core/desktop/webview-fallback] onReady threw:",
508
+ error,
509
+ ),
510
+ );
511
+ }
512
+ } catch (error) {
513
+ console.error(
514
+ "[@mandujs/core/desktop/webview-fallback] onReady threw:",
515
+ error,
516
+ );
517
+ }
518
+ });
519
+ }
520
+
521
+ return {
522
+ async close(): Promise<void> {
523
+ if (closed) return;
524
+ try {
525
+ terminate(handle);
526
+ } catch {
527
+ /* ignore — terminate may fail if run() hasn't started */
528
+ }
529
+ try {
530
+ destroy(handle);
531
+ } catch (error) {
532
+ if (options.debug) {
533
+ console.warn(
534
+ "[@mandujs/core/desktop/webview-fallback] destroy() warning:",
535
+ error,
536
+ );
537
+ }
538
+ }
539
+ markClosed();
540
+ },
541
+ onClose(cb: () => void): void {
542
+ if (closed) {
543
+ queueMicrotask(() => {
544
+ try {
545
+ cb();
546
+ } catch (error) {
547
+ console.error(
548
+ "[@mandujs/core/desktop/webview-fallback] onClose callback threw:",
549
+ error,
550
+ );
551
+ }
552
+ });
553
+ return;
554
+ }
555
+ closeCallbacks.push(cb);
556
+ },
557
+ async eval(_js: string): Promise<void> {
558
+ // eval() needs `webview_eval` which requires an onmessage callback
559
+ // wired through webview_bind. Phase 11 C ships the fallback
560
+ // without the bind surface — explicit error so apps that depend
561
+ // on eval() stay on the primary peer.
562
+ throw new Error(
563
+ "[@mandujs/core/desktop/webview-fallback] eval() is not supported. " +
564
+ "Install webview-bun for full IPC (eval + bind).",
565
+ );
566
+ },
567
+ bind(_name: string, _fn: (...args: unknown[]) => unknown): void {
568
+ throw new Error(
569
+ "[@mandujs/core/desktop/webview-fallback] bind() is not supported. " +
570
+ "Install webview-bun for full IPC (eval + bind).",
571
+ );
572
+ },
573
+ closed: closedPromise,
574
+ run(): void {
575
+ if (closed) return;
576
+ try {
577
+ run(handle);
578
+ } finally {
579
+ markClosed();
580
+ }
581
+ },
582
+ };
583
+ }