@mandujs/core 0.22.1 → 0.24.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.
- package/package.json +1 -1
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -0
- package/src/bundler/dev.ts +288 -0
- package/src/bundler/reverse-import-graph.ts +339 -0
- package/src/bundler/safe-build.test.ts +54 -0
- package/src/bundler/safe-build.ts +33 -7
- package/src/client/prefetch-helper.ts +55 -0
- package/src/config/mandu.ts +106 -0
- package/src/config/validate.ts +87 -1
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -0
- package/src/desktop/__tests__/window.test.ts +79 -3
- package/src/desktop/webview-fallback.ts +583 -0
- package/src/desktop/window.ts +527 -492
- package/src/perf/hmr-markers.ts +12 -0
- package/src/runtime/adapter-bun.ts +64 -62
- package/src/runtime/server.ts +243 -12
- package/src/runtime/ssr.ts +146 -7
- package/src/runtime/streaming-ssr.ts +89 -3
- package/src/testing/db.ts +157 -0
- package/src/testing/index.ts +59 -1
- package/src/testing/mocks.ts +203 -0
- package/src/testing/server.ts +196 -0
- package/src/testing/session.ts +190 -0
- package/src/testing/snapshot.ts +444 -0
|
@@ -165,8 +165,84 @@ describe("@mandujs/core/desktop — createWindow peer loading", () => {
|
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
// Ensure the FFI fallback also can't resolve — otherwise `createWindow`
|
|
169
|
+
// silently flips to the FFI path. We force an unreachable library path
|
|
170
|
+
// so both paths deterministically fail, which is the state the
|
|
171
|
+
// actionable error contract guards.
|
|
172
|
+
const originalFFIPath = process.env.MANDU_LIBWEBVIEW_PATH;
|
|
173
|
+
process.env.MANDU_LIBWEBVIEW_PATH =
|
|
174
|
+
"/path/that/does/not/exist/libwebview.so";
|
|
175
|
+
try {
|
|
176
|
+
await expect(
|
|
177
|
+
createWindow({ url: "http://127.0.0.1:1" }),
|
|
178
|
+
).rejects.toThrow(/webview-bun/);
|
|
179
|
+
} finally {
|
|
180
|
+
if (originalFFIPath !== undefined) {
|
|
181
|
+
process.env.MANDU_LIBWEBVIEW_PATH = originalFFIPath;
|
|
182
|
+
} else {
|
|
183
|
+
delete process.env.MANDU_LIBWEBVIEW_PATH;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("MANDU_DESKTOP_INLINE_FFI=1 routes to the FFI fallback path", async () => {
|
|
189
|
+
// With the env flag set, `createWindow` must import
|
|
190
|
+
// `./webview-fallback` instead of `webview-bun`. We don't assert on
|
|
191
|
+
// the exact success/failure of the FFI call (that depends on the
|
|
192
|
+
// machine's libwebview install state) — we only assert that the
|
|
193
|
+
// branch is taken. The simplest observable is: when the library
|
|
194
|
+
// actually loads AND the test machine has a webview backend
|
|
195
|
+
// (WebView2 on Windows etc.), a real window would open. To stay
|
|
196
|
+
// CI-safe we monkey-patch dynamic import of the fallback module to
|
|
197
|
+
// detect invocation.
|
|
198
|
+
//
|
|
199
|
+
// Bun's ESM cache makes this tricky — instead we verify the call
|
|
200
|
+
// selector by checking that `createWindow` does NOT throw the
|
|
201
|
+
// webview-bun "install me" error when the flag is set. That error
|
|
202
|
+
// is only reachable via the primary `_loadWebviewBun` path; if the
|
|
203
|
+
// FFI branch is skipped or the webview-bun import is tried in its
|
|
204
|
+
// place, the test would observe the install-me error.
|
|
205
|
+
const originalFlag = process.env.MANDU_DESKTOP_INLINE_FFI;
|
|
206
|
+
const originalFFIPath = process.env.MANDU_LIBWEBVIEW_PATH;
|
|
207
|
+
process.env.MANDU_DESKTOP_INLINE_FFI = "1";
|
|
208
|
+
// Force an unreachable libwebview so the fallback fails deterministically
|
|
209
|
+
// and the error surfaces the fallback's distinctive language (which
|
|
210
|
+
// is what we assert on).
|
|
211
|
+
process.env.MANDU_LIBWEBVIEW_PATH = "/nonexistent/libwebview-test-stub.so";
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
// Resolve whatever error comes back and assert it's NOT the
|
|
215
|
+
// webview-bun peer-missing error. The exact failure mode of the
|
|
216
|
+
// FFI path varies by CI machine (libwebview missing, dlopen
|
|
217
|
+
// succeeds on a stub, or the FFI cstring call errors), but none
|
|
218
|
+
// of those produce the distinctive "bun add webview-bun" string
|
|
219
|
+
// from the primary path.
|
|
220
|
+
let caught: unknown;
|
|
221
|
+
try {
|
|
222
|
+
const handle = await createWindow({ url: "http://127.0.0.1:1" });
|
|
223
|
+
// Unexpected success — close to clean up, then fail.
|
|
224
|
+
await handle.close();
|
|
225
|
+
throw new Error(
|
|
226
|
+
"unreachable — createWindow should fail with MANDU_DESKTOP_INLINE_FFI=1 + bogus MANDU_LIBWEBVIEW_PATH",
|
|
227
|
+
);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
caught = err;
|
|
230
|
+
}
|
|
231
|
+
const msg = caught instanceof Error ? caught.message : String(caught);
|
|
232
|
+
// The webview-bun install-me message always mentions "bun add webview-bun".
|
|
233
|
+
// The fallback path's failure never does.
|
|
234
|
+
expect(msg).not.toContain("bun add webview-bun");
|
|
235
|
+
} finally {
|
|
236
|
+
if (originalFlag !== undefined) {
|
|
237
|
+
process.env.MANDU_DESKTOP_INLINE_FFI = originalFlag;
|
|
238
|
+
} else {
|
|
239
|
+
delete process.env.MANDU_DESKTOP_INLINE_FFI;
|
|
240
|
+
}
|
|
241
|
+
if (originalFFIPath !== undefined) {
|
|
242
|
+
process.env.MANDU_LIBWEBVIEW_PATH = originalFFIPath;
|
|
243
|
+
} else {
|
|
244
|
+
delete process.env.MANDU_LIBWEBVIEW_PATH;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
171
247
|
});
|
|
172
248
|
});
|
|
@@ -0,0 +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
|
+
}
|