/** * Phase 11 C / M-02 — `bun:ffi` fallback for `webview-bun`. * * # Why this exists * * `webview-bun` (tr1ckydev/webview-bun) is the primary optional peer for * `@mandujs/core/desktop`. Phase 9c R0 diagnostics * (`docs/bun/phase-9-diagnostics/webview-bun-ffi.md`) identified one * supply-chain risk: single maintainer, ~400 stars, small contributor * base. If the peer ever goes unmaintained, Mandu's desktop story breaks. * * The upstream C++ library (https://github.com/webview/webview) is * healthy — 14k★, 636 commits, backed by continuous Microsoft + Apple * platform support. The fallback binds Mandu directly to that library * via `bun:ffi`, bypassing `webview-bun` as a JS layer. * * This file is a **prototype** — it declares the minimal FFI surface * (`webview_create` / `webview_navigate` / `webview_set_title` / * `webview_run` / `webview_destroy`) and wires a creator that mirrors * the `webview-bun` constructor contract so `window.ts::createWindow` * can substitute it. * * # Non-goals * * - NOT production-ready. Phase 11 C ships the prototype; real rollout * (signed DLL mirroring + SHA-256 verification + Mandu CDN or LFS * hosting) is tracked in `docs/bun/phase-11-diagnostics/completeness-sprint.md` * §C-1. * - NOT a replacement for `webview-bun` on the happy path. Only used * when `MANDU_DESKTOP_INLINE_FFI=1` is set OR when the `webview-bun` * peer's dynamic `import()` rejects with a module-not-found error. * - NOT an IPC layer. `webview-bun`'s `bind()` is not mirrored — the * fallback exposes `navigate` + `setHTML` + `title` + `run` + `destroy` * only. Apps that use `bind()` must keep the primary peer. * * # Safety * * - `bun:ffi`'s `dlopen` throws synchronously when the library path is * unreachable. All three entrypoints (`_ffiSymbols`, `loadFFILibwebview`, * `createFallbackWebview`) are guarded so that importing this module * never throws — the failure surface is at first use. * - The library search order is: (1) `MANDU_LIBWEBVIEW_PATH` env var * (absolute path), (2) `@mandujs/core` package-relative * `libwebview.{dll|dylib|so}` (if mirrored by a future release * pipeline), (3) system-default via `dlopen` with the bare name. When * all three fail, `loadFFILibwebview()` throws an actionable error * explaining each probe path that was tried. * - Type definitions use `FFIType.cstring` for strings, matching the * upstream C ABI (`const char*`). `destroy()` zeros the pointer so * double-free is a no-op at the FFI level. * * # Runtime behavior * * On import, this module does NOT load `libwebview`. `dlopen` only runs * on the first `createFallbackWebview()` call, at which point a clean * "install and mirror libwebview" error is thrown if the library is * unreachable. Tests exercise the FFI surface definitions without * triggering the load path (see `__tests__/webview-fallback.test.ts`). * * # References * * - `docs/bun/phase-9-diagnostics/webview-bun-ffi.md` §8 (fallback design) * - https://github.com/webview/webview/blob/master/webview.h (C API) * - https://bun.sh/docs/api/ffi (Bun FFI docs) */ import type { WindowHandle, WindowOptions, WindowSizeHint, } from "./types.js"; // ─── FFI symbol contract (upstream webview/webview) ──────────────────────── // // This object is a PURE specification — it's the `dlopen` symbols map we // hand to `bun:ffi`. Exported so tests can introspect the contract // without triggering a real `dlopen`. /** * The minimal C ABI we need from libwebview. Signatures mirror * `webview.h` from the upstream C++ repo. * * @internal */ export const _ffiSymbols = Object.freeze({ /** * `webview_t webview_create(int debug, void* window);` * * Create a native webview. `debug=1` enables DevTools / WebInspector. * `window=null` asks libwebview to create its own shell window. * Returns an opaque handle (`webview_t`) or `null` on failure. */ webview_create: { args: ["i32", "ptr"] as const, returns: "ptr" as const, }, /** * `void webview_navigate(webview_t w, const char* url);` * * Point the webview at a URL. Blocks until the new page's `onload` * fires (or times out per platform rules). * * Note: `bun:ffi` arg type for C strings is `ptr` (pointer to an * encoded buffer), NOT `cstring` (which is return-only in Bun 1.3). * Callers must pre-encode JS strings via `_encodeCString` before * passing to this symbol. See `webview-bun/src/ffi.ts` for the * canonical reference. */ webview_navigate: { args: ["ptr", "ptr"] as const, returns: "void" as const, }, /** * `void webview_set_title(webview_t w, const char* title);` * * Set the native window title. No-op if the underlying OS window * hasn't been realized yet. Second arg is a ptr — pre-encode via * `_encodeCString`. */ webview_set_title: { args: ["ptr", "ptr"] as const, returns: "void" as const, }, /** * `void webview_set_size(webview_t w, int width, int height, int hints);` * * Hints map (from webview.h): * WEBVIEW_HINT_NONE = 0 // freely resizable * WEBVIEW_HINT_MIN = 1 * WEBVIEW_HINT_MAX = 2 * WEBVIEW_HINT_FIXED = 3 */ webview_set_size: { args: ["ptr", "i32", "i32", "i32"] as const, returns: "void" as const, }, /** * `void webview_set_html(webview_t w, const char* html);` * * Second arg is a ptr — pre-encode via `_encodeCString`. */ webview_set_html: { args: ["ptr", "ptr"] as const, returns: "void" as const, }, /** * `void webview_run(webview_t w);` * * BLOCKING. Returns when the user closes the shell window or when * `webview_terminate(w)` is called from another thread. */ webview_run: { args: ["ptr"] as const, returns: "void" as const, }, /** * `void webview_terminate(webview_t w);` * * Request that `webview_run` return ASAP. Safe to call from any * thread (unlike `destroy`, which must follow run). */ webview_terminate: { args: ["ptr"] as const, returns: "void" as const, }, /** * `void webview_destroy(webview_t w);` * * Free the webview and its native resources. Must be called after * `webview_run` returns. */ webview_destroy: { args: ["ptr"] as const, returns: "void" as const, }, }); /** * Encode a JS string as a null-terminated C string pointer, suitable for * passing to `ptr`-arg FFI calls. * * `bun:ffi`'s `cstring` type is return-only in Bun 1.3; for C-string * arguments we must encode the string to a `Buffer` and take a pointer. * This helper mirrors `webview-bun/src/ffi.ts::encodeCString`. * * The returned pointer is valid ONLY as long as the encoding Buffer is * reachable — callers MUST keep the buffer alive (e.g. by holding the * reference in a local variable) until the FFI call returns. * * @internal */ export async function _encodeCString( value: string, ): Promise<{ ptr: unknown; buffer: Uint8Array }> { const { ptr } = await import("bun:ffi"); const buffer = new TextEncoder().encode(value + "\0"); return { ptr: (ptr as (b: Uint8Array) => unknown)(buffer), buffer, }; } /** * Map the string hint to libwebview's numeric enum (matches * `WEBVIEW_HINT_*` in `webview.h`). * * @internal */ export function _mapHintToInt(hint: WindowSizeHint | undefined): number { switch (hint) { case "min": return 1; case "max": return 2; case "fixed": return 3; case "none": case undefined: default: return 0; } } // ─── Library loading ─────────────────────────────────────────────────────── /** * OS-specific native lib filename. Returned as a best-effort default — the * real resolution path adds `MANDU_LIBWEBVIEW_PATH` and a package-relative * probe before falling back to this. * * @internal */ export function _defaultLibName(): string { switch (process.platform) { case "win32": return "libwebview.dll"; case "darwin": return "libwebview.dylib"; default: // Linux + BSDs + etc. return "libwebview.so"; } } /** * Ordered list of candidate paths for the libwebview shared library. * * Exported for the test suite so we can verify the resolution contract * without triggering a real `dlopen`. * * @internal */ export function _getLibraryCandidates(): string[] { const candidates: string[] = []; const envPath = process.env.MANDU_LIBWEBVIEW_PATH; if (envPath && envPath.length > 0) { candidates.push(envPath); } // Future-proof: a release-time mirror step will ship // `packages/core/dist/native/libwebview.{dll,dylib,so}` alongside the // published module. We probe this location (resolved relative to THIS // module at runtime) but it is NOT guaranteed to exist in Phase 11 C. // // We don't fs.existsSync in this pure function — the caller does. try { const url = new URL("../../dist/native/" + _defaultLibName(), import.meta.url); candidates.push(decodeURIComponent(url.pathname).replace(/^\//, "")); } catch { // URL construction must never throw in normal workflows; if it does // we simply skip this candidate rather than poison the fallback. } // Final fallback — let `dlopen` consult the OS's dynamic-linker search // path (PATH on Windows, LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH // on macOS). candidates.push(_defaultLibName()); return candidates; } /** * Cached FFI handle. Populated on first successful load. */ interface LoadedFFI { /** * Bun's FFI symbols map — opaque to Mandu core, but well-defined by * `bun:ffi`'s `dlopen` return type. We type this as `unknown` to keep * the cross-peer contract narrow. */ symbols: Record; libraryPath: string; } let loadedFFICache: LoadedFFI | null = null; /** * Dynamically load the libwebview shared library via `bun:ffi`. Throws an * actionable error when none of the candidate paths resolve. * * Marked async even though `dlopen` is synchronous so the call shape * matches `_loadWebviewBun` in `window.ts`. * * @internal */ export async function loadFFILibwebview(): Promise { if (loadedFFICache) return loadedFFICache; // Lazy-import `bun:ffi` so a `bun test` in an environment where `bun:ffi` // isn't available (e.g. a Deno test runner, or a future isolated // sandbox) still lets the module load. let dlopen: unknown; try { // @ts-ignore -- `bun:ffi` is a Bun built-in; resolution is runtime-only. const mod = await import("bun:ffi"); dlopen = (mod as { dlopen?: unknown }).dlopen; if (typeof dlopen !== "function") { throw new Error("bun:ffi.dlopen is not a function"); } } catch (cause) { throw new Error( [ "[@mandujs/core/desktop/webview-fallback] Could not load `bun:ffi`.", "This fallback requires the Bun runtime (not Node.js).", "", `Cause: ${cause instanceof Error ? cause.message : String(cause)}`, ].join("\n"), { cause }, ); } const candidates = _getLibraryCandidates(); const errors: string[] = []; for (const libPath of candidates) { try { const loaded = (dlopen as (p: string, s: typeof _ffiSymbols) => { symbols: Record; })(libPath, _ffiSymbols); loadedFFICache = { symbols: loaded.symbols, libraryPath: libPath }; return loadedFFICache; } catch (err) { errors.push( ` ${libPath}: ${err instanceof Error ? err.message : String(err)}`, ); } } throw new Error( [ "[@mandujs/core/desktop/webview-fallback] Failed to load libwebview from any candidate path.", "", "Tried (in order):", ...errors, "", "To install libwebview:", " 1. Prebuilt binaries: https://github.com/webview/webview-releases", " 2. Build from source: https://github.com/webview/webview#building", "", "Then either:", " - Set MANDU_LIBWEBVIEW_PATH= before running the app", " - Place the library on the OS dynamic-linker search path", ` - Default name probed: ${_defaultLibName()}`, ].join("\n"), ); } /** * Reset the library-load cache. Tests only. * * @internal */ export function _resetFFICache(): void { loadedFFICache = null; } // ─── Webview factory (FFI-backed) ────────────────────────────────────────── /** * Create a webview using the bun:ffi fallback path. Returns a * {@link WindowHandle} that uses the same public shape as * `createWindow()` — callers should not need to distinguish between * the two backends. * * This factory is `async` to match the `webview-bun` peer loader contract * (which does a dynamic import). Construction itself is synchronous * beyond the initial `loadFFILibwebview()`. * * @example * ```ts * import { createFallbackWebview } from "@mandujs/core/desktop/webview-fallback"; * * const handle = await createFallbackWebview({ * url: "http://127.0.0.1:3333", * title: "My App", * width: 1024, * height: 768, * }); * handle.run(); * ``` */ export async function createFallbackWebview( options: WindowOptions, ): Promise { // Option validation is the caller's responsibility — this path is // reached only from `window.ts::createWindow` after it calls // `_validateOptions`. We still perform minimal defensive checks so // direct callers (tests, experimental apps) don't segfault. if (!options || typeof options !== "object") { throw new TypeError( "[@mandujs/core/desktop/webview-fallback] options must be an object.", ); } if (typeof options.url !== "string" || options.url.length === 0) { throw new TypeError( "[@mandujs/core/desktop/webview-fallback] 'url' must be a non-empty string.", ); } const { symbols } = await loadFFILibwebview(); // `bun:ffi` exposes each symbol as a callable — the return type is // `(...args: any[]) => unknown`. We narrow via cast at each callsite // instead of widening the symbols map's type. type PtrFn = (...args: unknown[]) => unknown; const create = symbols.webview_create as PtrFn; const navigate = symbols.webview_navigate as PtrFn; const setTitle = symbols.webview_set_title as PtrFn; const setSize = symbols.webview_set_size as PtrFn; const run = symbols.webview_run as PtrFn; const terminate = symbols.webview_terminate as PtrFn; const destroy = symbols.webview_destroy as PtrFn; const debug = options.debug ? 1 : 0; const handle = create(debug, null) as unknown; if (!handle) { throw new Error( "[@mandujs/core/desktop/webview-fallback] webview_create returned null (libwebview init failed).", ); } // Size + title BEFORE navigate, matching the `webview-bun` ctor contract. const width = options.width ?? 1024; const height = options.height ?? 768; setSize(handle, width, height, _mapHintToInt(options.hint)); // The libwebview C API takes a `const char*`; `bun:ffi`'s `ptr` arg // type needs a pre-encoded buffer. Keep references live across the // entire sync block so the GC doesn't collect the buffer before // libwebview copies out the string. `_encodeCString` returns both the // ptr and the backing Uint8Array for this reason. const titleEnc = await _encodeCString(options.title ?? "Mandu Desktop"); setTitle(handle, titleEnc.ptr); const urlEnc = await _encodeCString(options.url); navigate(handle, urlEnc.ptr); // Keep encoding buffers reachable past the sync navigate call. // libwebview copies the strings internally, so once we're past the // synchronous FFI return these buffers can be GC'd — we just need // them alive THROUGH the FFI call, which the local const refs ensure. void titleEnc.buffer; void urlEnc.buffer; let closed = false; let resolveClosed: (() => void) | null = null; const closedPromise = new Promise((resolve) => { resolveClosed = resolve; }); const closeCallbacks: Array<() => void> = []; function markClosed(): void { if (closed) return; closed = true; for (const cb of closeCallbacks) { try { cb(); } catch (error) { console.error( "[@mandujs/core/desktop/webview-fallback] onClose callback threw:", error, ); } } if (options.onClose) { try { const result = options.onClose(); if (result instanceof Promise) { result.catch((error) => console.error( "[@mandujs/core/desktop/webview-fallback] onClose (options) threw:", error, ), ); } } catch (error) { console.error( "[@mandujs/core/desktop/webview-fallback] onClose (options) threw:", error, ); } } resolveClosed?.(); } if (options.onReady) { queueMicrotask(() => { try { const result = options.onReady!(); if (result instanceof Promise) { result.catch((error) => console.error( "[@mandujs/core/desktop/webview-fallback] onReady threw:", error, ), ); } } catch (error) { console.error( "[@mandujs/core/desktop/webview-fallback] onReady threw:", error, ); } }); } return { async close(): Promise { if (closed) return; try { terminate(handle); } catch { /* ignore — terminate may fail if run() hasn't started */ } try { destroy(handle); } catch (error) { if (options.debug) { console.warn( "[@mandujs/core/desktop/webview-fallback] destroy() warning:", error, ); } } markClosed(); }, onClose(cb: () => void): void { if (closed) { queueMicrotask(() => { try { cb(); } catch (error) { console.error( "[@mandujs/core/desktop/webview-fallback] onClose callback threw:", error, ); } }); return; } closeCallbacks.push(cb); }, async eval(_js: string): Promise { // eval() needs `webview_eval` which requires an onmessage callback // wired through webview_bind. Phase 11 C ships the fallback // without the bind surface — explicit error so apps that depend // on eval() stay on the primary peer. throw new Error( "[@mandujs/core/desktop/webview-fallback] eval() is not supported. " + "Install webview-bun for full IPC (eval + bind).", ); }, bind(_name: string, _fn: (...args: unknown[]) => unknown): void { throw new Error( "[@mandujs/core/desktop/webview-fallback] bind() is not supported. " + "Install webview-bun for full IPC (eval + bind).", ); }, closed: closedPromise, run(): void { if (closed) return; try { run(handle); } finally { markClosed(); } }, }; }