@n1h/sweetview 0.0.0-stage → 0.1.1

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-0BSD +12 -0
  3. package/README.md +268 -3
  4. package/docs/README.md +26 -0
  5. package/docs/app.md +523 -0
  6. package/docs/build.md +169 -0
  7. package/docs/ipc-ws.md +202 -0
  8. package/docs/ipc.md +323 -0
  9. package/docs/native.md +1066 -0
  10. package/docs/page.md +71 -0
  11. package/docs/settings-store.md +125 -0
  12. package/docs/webview-library.md +104 -0
  13. package/docs/window-chrome.md +221 -0
  14. package/docs/worker.md +72 -0
  15. package/file-imports.d.ts +19 -0
  16. package/package.json +66 -4
  17. package/src/app.ts +350 -0
  18. package/src/build.ts +118 -0
  19. package/src/html.ts +71 -0
  20. package/src/index.ts +8 -0
  21. package/src/ipc/bridge.ts +41 -0
  22. package/src/ipc/frame.ts +16 -0
  23. package/src/ipc/index.ts +16 -0
  24. package/src/ipc/memory.ts +33 -0
  25. package/src/ipc/service.ts +75 -0
  26. package/src/ipc-ws/client.ts +111 -0
  27. package/src/ipc-ws/index.ts +5 -0
  28. package/src/ipc-ws/server.ts +133 -0
  29. package/src/make-app.ts +93 -0
  30. package/src/messages.ts +16 -0
  31. package/src/native/app-icon.ts +23 -0
  32. package/src/native/app-menu.ts +18 -0
  33. package/src/native/darwin/app-icon.ts +10 -0
  34. package/src/native/darwin/app-menu.ts +88 -0
  35. package/src/native/darwin/folder-picker.ts +29 -0
  36. package/src/native/darwin/image.ts +47 -0
  37. package/src/native/darwin/menu.ts +89 -0
  38. package/src/native/darwin/notifier.ts +95 -0
  39. package/src/native/darwin/objc.ts +246 -0
  40. package/src/native/darwin/overlay.ts +352 -0
  41. package/src/native/darwin/tray.ts +129 -0
  42. package/src/native/darwin/window.ts +64 -0
  43. package/src/native/folder-picker.ts +15 -0
  44. package/src/native/hit-regions.ts +162 -0
  45. package/src/native/icon.ts +530 -0
  46. package/src/native/index.ts +38 -0
  47. package/src/native/menu.ts +43 -0
  48. package/src/native/notifications.ts +40 -0
  49. package/src/native/overlay.ts +182 -0
  50. package/src/native/platform.ts +25 -0
  51. package/src/native/tray.ts +41 -0
  52. package/src/native/win32/api.ts +234 -0
  53. package/src/native/win32/app-icon.ts +5 -0
  54. package/src/native/win32/app-menu.ts +4 -0
  55. package/src/native/win32/folder-picker.ts +121 -0
  56. package/src/native/win32/icon.ts +85 -0
  57. package/src/native/win32/menu.ts +107 -0
  58. package/src/native/win32/message-window.ts +72 -0
  59. package/src/native/win32/notifier.ts +28 -0
  60. package/src/native/win32/overlay.ts +499 -0
  61. package/src/native/win32/tray.ts +228 -0
  62. package/src/native/win32/window.ts +77 -0
  63. package/src/native/window.ts +40 -0
  64. package/src/page.ts +48 -0
  65. package/src/ring.ts +110 -0
  66. package/src/settings-store.ts +101 -0
  67. package/src/webview-lib.ts +60 -0
  68. package/src/window-chrome.ts +118 -0
  69. package/src/worker.ts +72 -0
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@n1h/sweetview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Desktop windows for Bun applications: a webview window on the UI thread, the backend in a Worker, a typed IPC contract between page and backend (also over WebSockets), and bun:ffi helpers for tray, notifications, overlay windows and dialogs. One package with a module per part.",
6
+ "license": "MIT OR 0BSD",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/n0t-invented-here/sweetview.git"
10
+ },
11
+ "homepage": "https://github.com/n0t-invented-here/sweetview#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/n0t-invented-here/sweetview/issues"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "!src/**/*.test.ts",
18
+ "!src/**/*.test.worker.ts",
19
+ "file-imports.d.ts",
20
+ "docs",
21
+ "README.md",
22
+ "LICENSE",
23
+ "LICENSE-0BSD"
24
+ ],
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./worker": "./src/worker.ts",
28
+ "./page": "./src/page.ts",
29
+ "./build": "./src/build.ts",
30
+ "./settings-store": "./src/settings-store.ts",
31
+ "./window-chrome": "./src/window-chrome.ts",
32
+ "./ipc": "./src/ipc/index.ts",
33
+ "./ipc-ws": "./src/ipc-ws/index.ts",
34
+ "./ipc-ws/server": "./src/ipc-ws/server.ts",
35
+ "./ipc-ws/client": "./src/ipc-ws/client.ts",
36
+ "./native": "./src/native/index.ts",
37
+ "./native/hit-regions": "./src/native/hit-regions.ts",
38
+ "./file-imports": {
39
+ "types": "./file-imports.d.ts"
40
+ }
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.4.2"
44
+ },
45
+ "peerDependencies": {
46
+ "@n1h/sweetview-webview-bun": "^2.6.1"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@n1h/sweetview-webview-bun": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "workspaces": [
54
+ "demos/*",
55
+ "demos/web-app/packages/*"
56
+ ],
57
+ "scripts": {
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "bun test"
60
+ },
61
+ "devDependencies": {
62
+ "@types/bun": "latest",
63
+ "@types/react": "^19.1.0",
64
+ "@types/react-dom": "^19.1.0",
65
+ "typescript": "^5.9.0",
66
+ "@n1h/sweetview-webview-bun": "^2.6.1"
67
+ }
68
+ }
package/src/app.ts ADDED
@@ -0,0 +1,350 @@
1
+ // UI thread. Owns the native window and the OS event loop.
2
+ //
3
+ // webview.run() blocks this thread, and with it Bun's event loop, so nothing
4
+ // async can live here. The worker ("@n1h/sweetview/worker") owns the Bun event
5
+ // loop and the app's Service. Transport:
6
+ // page -> here : bound `__sweetview_rpc(method, params)`; answered later via webview_return
7
+ // here -> worker : worker.postMessage (synchronous enqueue, works while blocked)
8
+ // worker -> here : sweetipc frames in a shared ring + webview_dispatch as the wakeup,
9
+ // which runs `drain` on this thread, inside the OS loop
10
+ // here -> page : webview_eval of `__sweetview_emit(name, payload)`
11
+ //
12
+ // The page itself is never passed to set_html (Windows caps that at 2 MB): its
13
+ // index, bundle, styles and files are registered as assets the library serves
14
+ // at the page's own origin, and the webview navigates there.
15
+ //
16
+ // Everything in sweetnative (tray, notifications, window, dialogs, app menu)
17
+ // runs on this thread too; create those between createWebviewApp() and run().
18
+
19
+ import { ASSET_ORIGIN, backgroundColorCall, setAssetCall, webviewLibPath } from "./webview-lib";
20
+ import { FFIType, JSCallback, dlopen, type Pointer } from "bun:ffi";
21
+ import type { AnyEvent, EventMap, Frame, MethodMap } from "./ipc";
22
+ import { nativeWindow, type NativeWindow, type WindowHandle } from "./native";
23
+ import { SizeHint, Webview } from "@n1h/sweetview-webview-bun";
24
+ import { assetMime, indexHtml } from "./html";
25
+ import type { HTMLBundle } from "bun";
26
+ import { basename, extname } from "node:path";
27
+ import { pathToFileURL } from "node:url";
28
+ import type { InitMessage } from "./messages";
29
+ import { Ring } from "./ring";
30
+
31
+ /** Methods answered synchronously on the UI thread instead of by the worker (native dialogs, window state). */
32
+ export type UiMethods<M extends MethodMap> = { [K in keyof M & string]?: (params: M[K][0]) => M[K][1] };
33
+
34
+ /**
35
+ * The bytes served at one asset path. A string is a file path, as a
36
+ * `with { type: "file" }` import gives, read with `Bun.file`; a Blob (a
37
+ * `BunFile`, or what `indexHtml` returns) and a `Uint8Array` are served as
38
+ * they are.
39
+ */
40
+ export type Asset = string | Uint8Array | Blob;
41
+
42
+ export type WebviewAppOptions<M extends MethodMap> = {
43
+ /**
44
+ * The page: an HTML import (`import index from "./index.html"`). Bun
45
+ * bundles the scripts and stylesheets it references; under `bun run` the
46
+ * bundling happens here at start-up, inside a `bun build --compile`
47
+ * executable the bundled files are embedded. Every output is served at
48
+ * the page's origin and the webview opens the page.
49
+ */
50
+ page?: HTMLBundle;
51
+ /** The window title. Default: the page's `<title>`, else the entry script's name. */
52
+ title?: string;
53
+ /**
54
+ * More files for the page's origin, by asset path: models, images, data
55
+ * the page fetches by relative URL. Without `page`, the whole page goes
56
+ * here, by convention `main.js` and `main.css`, and an `index.html` that
57
+ * loads them is generated from `title` unless supplied.
58
+ */
59
+ assets?: Record<string, Asset>;
60
+ /** Asset path the webview opens. Default: the page's HTML, or "index.html". */
61
+ index?: string;
62
+ /**
63
+ * The worker entry, which must call hostService(). A relative path
64
+ * ("./worker.ts") is resolved against the entry script (`Bun.main`), which
65
+ * in a compiled executable is where the second entrypoint lives too, so
66
+ * the same string works uncompiled and compiled. Pass the file to `bun
67
+ * build --compile` as a second entrypoint. Leave it out for a window whose
68
+ * page only needs `uiMethods` (a settings window, say); a call to any other
69
+ * method is then answered with an error.
70
+ */
71
+ worker?: URL | string;
72
+ /** Client size. With `window`, this is locked in as fixed (the library would otherwise apply 640x480 later). */
73
+ width?: number;
74
+ height?: number;
75
+ /** Devtools and context menu. */
76
+ debug?: boolean;
77
+ /**
78
+ * The browser paints a transparent background, so a page with
79
+ * `background: transparent` shows whatever is behind the window. Pair with
80
+ * `createOverlayWindow({ transparent: true })` from @n1h/sweetview/native.
81
+ * Needs the library @n1h/sweetview-webview-bun ships (Windows and macOS);
82
+ * with a library that lacks webview_set_background_color, a warning and an
83
+ * opaque page.
84
+ */
85
+ transparent?: boolean;
86
+ /**
87
+ * Host the browser in a window you created (createOverlayWindow in @n1h/sweetview/native)
88
+ * instead of letting the library make one. You then own showing it and
89
+ * ending the run loop when it is destroyed. On Windows the library still
90
+ * applies `width`/`height` to it, as DIPs: for an overlay pass the size you
91
+ * gave the window divided by its scale; for a framed window they are the
92
+ * client area, and the library sizes the frame around it. On macOS they
93
+ * are ignored.
94
+ */
95
+ window?: WindowHandle;
96
+ /**
97
+ * With `window`: leave the window resizable (a framed window; the library
98
+ * keeps its thick frame and maximise box). Default false: the size is
99
+ * locked, which an overlay wants.
100
+ */
101
+ resizable?: boolean;
102
+ uiMethods?: UiMethods<M>;
103
+ /** Ring capacity in bytes for worker -> UI frames. The worker blocks when it is full. */
104
+ ringBytes?: number;
105
+ };
106
+
107
+ export type WebviewApp<M extends MethodMap, E extends EventMap> = {
108
+ readonly webview: Webview;
109
+ /** Show/hide/toggle and close-to-tray, from sweetnative. */
110
+ readonly window: NativeWindow;
111
+ /** The native window (HWND / NSWindow*), for sweetnative's dialogs. */
112
+ readonly windowHandle: WindowHandle;
113
+ /** Push an event to the page. */
114
+ emit<N extends keyof E & string>(name: N, payload: E[N]): void;
115
+ /** Call the worker's service without waiting; whatever it emits comes back as events. */
116
+ call<K extends keyof M & string>(method: K, ...params: M[K][0] extends void ? [] : [M[K][0]]): void;
117
+ /** See every event the worker sends to the page, before the page does. For mirroring state into a tray menu. */
118
+ onEvent(fn: (event: AnyEvent<E>) => void): () => void;
119
+ /**
120
+ * Run the OS loop. Returns when the window is closed or quit() is called;
121
+ * the worker is terminated. One process has one OS loop: with several
122
+ * windows (several createWebviewApp calls on this thread), call run() on
123
+ * one of them and the others live in the same loop.
124
+ */
125
+ run(): void;
126
+ quit(): void;
127
+ };
128
+
129
+ /** The bundled files of an HTML import, by asset path, plus the path of its HTML. */
130
+ async function pageAssets(page: HTMLBundle): Promise<{ assets: Record<string, Asset>; index: string }> {
131
+ const assets: Record<string, Asset> = {};
132
+ let index: string | undefined;
133
+ if (page.files) {
134
+ // built ahead of time (bun build --compile): the outputs are embedded beside the entry
135
+ for (const f of page.files) {
136
+ const path = basename(f.path);
137
+ assets[path] = Bun.file(f.path);
138
+ if (extname(path) === ".html") index = path;
139
+ }
140
+ } else {
141
+ // bun run: bundle now, in memory
142
+ const built = await Bun.build({ entrypoints: [page.index], target: "browser", minify: true });
143
+ for (const o of built.outputs) {
144
+ const path = basename(o.path);
145
+ assets[path] = o;
146
+ if (extname(path) === ".html") index = path;
147
+ }
148
+ }
149
+ if (index === undefined) throw new Error("sweetview: the page bundle has no HTML output");
150
+ return { assets, index };
151
+ }
152
+
153
+ /** The <title> of an HTML document, or undefined. */
154
+ const htmlTitle = (html: string): string | undefined => {
155
+ const m = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
156
+ return m ? m[1]!.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim() || undefined : undefined;
157
+ };
158
+
159
+ const assetBytes = async (source: Asset): Promise<Uint8Array> =>
160
+ typeof source === "string" ? new Uint8Array(await Bun.file(source).arrayBuffer()) : source instanceof Uint8Array ? source : new Uint8Array(await source.arrayBuffer());
161
+
162
+ // JSON is JS source except U+2028/2029 inside strings.
163
+ const LS = String.fromCharCode(0x2028);
164
+ const PS = String.fromCharCode(0x2029);
165
+ const asJsLiteral = (json: string) => json.replaceAll(LS, "\\u2028").replaceAll(PS, "\\u2029");
166
+
167
+ export async function createWebviewApp<M extends MethodMap, E extends EventMap>(options: WebviewAppOptions<M>): Promise<WebviewApp<M, E>> {
168
+ // Everything async happens here; after run() this thread is the OS loop.
169
+ if (process.platform !== "win32" && process.platform !== "darwin") {
170
+ throw new Error(`sweetview: ${process.platform} is not supported (win32 and darwin are): the native module has no backend for it and the webview library does not serve assets there`);
171
+ }
172
+ const libPath = await webviewLibPath();
173
+
174
+ // the page: an HTML import bundled by Bun, or the assets as given
175
+ const assets: Record<string, Asset> = { ...options.assets };
176
+ let index = options.index ?? "index.html";
177
+ let title = options.title;
178
+ if (options.page) {
179
+ const page = await pageAssets(options.page);
180
+ Object.assign(assets, page.assets);
181
+ index = options.index ?? page.index;
182
+ const html = assets[index];
183
+ if (title === undefined && html !== undefined) {
184
+ title = htmlTitle(typeof html === "string" ? await Bun.file(html).text() : html instanceof Uint8Array ? new TextDecoder().decode(html) : await html.text());
185
+ }
186
+ }
187
+ const windowTitle: string = title ?? basename(Bun.main, extname(Bun.main));
188
+ // the convention without a page import: main.js, and main.css when shipped; an index of the app's own wins
189
+ if (!(index in assets)) assets[index] = indexHtml({ title: windowTitle, css: "main.css" in assets ? "main.css" : "" });
190
+
191
+ // With a window of our own, the hint is FIXED unless `resizable`: on Windows the
192
+ // library's set_size would otherwise give an overlay popup a resizable frame (WS_THICKFRAME).
193
+ const host = options.window ?? null;
194
+ const webview =
195
+ host !== null
196
+ ? new Webview(options.debug ?? false, { width: options.width ?? 320, height: options.height ?? 240, hint: options.resizable ? SizeHint.NONE : SizeHint.FIXED }, Number(host) as Pointer)
197
+ : new Webview(options.debug ?? false, { width: options.width ?? 1100, height: options.height ?? 800, hint: SizeHint.NONE });
198
+ if (webview.unsafeHandle === null || Number(webview.unsafeHandle) === 0) {
199
+ throw new Error(
200
+ host !== null
201
+ ? "sweetview: webview_create failed for the given window (is COM initialised as an STA on this thread? createOverlayWindow in @n1h/sweetview/native does that)"
202
+ : process.platform === "win32"
203
+ ? "sweetview: webview_create failed (is the WebView2 runtime installed?)"
204
+ : "sweetview: webview_create failed",
205
+ );
206
+ }
207
+ try {
208
+ return await finishSetup();
209
+ } catch (err) {
210
+ if (webview.unsafeHandle !== null) webview.destroy();
211
+ throw err;
212
+ }
213
+
214
+ async function finishSetup(): Promise<WebviewApp<M, E>> {
215
+ webview.title = windowTitle;
216
+ // webview_get_window reports NULL for a window the library did not create.
217
+ const windowHandle: WindowHandle = host ?? webview.unsafeWindowHandle;
218
+ const window = nativeWindow(windowHandle);
219
+
220
+ // webview-bun exposes terminate only inside destroy(), which run() calls again on exit;
221
+ // take the one symbol we need from the same library file.
222
+ const lib = dlopen(libPath, {
223
+ webview_terminate: { args: [FFIType.ptr], returns: FFIType.i32 },
224
+ webview_run: { args: [FFIType.ptr], returns: FFIType.i32 },
225
+ });
226
+ const handle = () => Number(webview.unsafeHandle) as Pointer;
227
+ const quit = () => void lib.symbols.webview_terminate(handle());
228
+
229
+ if (options.transparent) {
230
+ const set = backgroundColorCall(libPath);
231
+ if (set) {
232
+ const err = set(handle(), 0, 0, 0, 0);
233
+ if (err !== 0) throw new Error(`sweetview: webview_set_background_color failed (${err})`);
234
+ } else {
235
+ console.warn("sweetview: this webview library has no webview_set_background_color; the page will not be transparent (see @n1h/sweetview-webview-bun)");
236
+ }
237
+ }
238
+
239
+ const sab = Ring.create(options.ringBytes ?? 1 << 20);
240
+ const ring = new Ring(sab);
241
+ const taps = new Set<(event: { name: string; payload: unknown }) => void>();
242
+
243
+ const emit = (name: string, payload: unknown) =>
244
+ webview.eval(`__sweetview_emit(${JSON.stringify(name)}, ${asJsLiteral(JSON.stringify(payload ?? null))})`);
245
+
246
+ const drain = new JSCallback(
247
+ () => {
248
+ ring.acknowledgeWakeup();
249
+ const decoder = new TextDecoder();
250
+ for (const bytes of ring.drain()) {
251
+ const frame = JSON.parse(decoder.decode(bytes)) as Frame;
252
+ if (frame.kind === "reply") {
253
+ if (frame.ok) webview.return(frame.seq, 0, JSON.stringify(frame.result ?? null));
254
+ else webview.return(frame.seq, 1, JSON.stringify({ message: frame.error }));
255
+ } else if (frame.kind === "event") {
256
+ for (const fn of taps) fn({ name: frame.name, payload: frame.payload });
257
+ emit(frame.name, frame.payload);
258
+ }
259
+ }
260
+ },
261
+ { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
262
+ );
263
+
264
+ const workerUrl =
265
+ options.worker === undefined
266
+ ? undefined
267
+ : typeof options.worker === "string"
268
+ ? /^[a-z][a-z0-9+.-]+:/i.test(options.worker)
269
+ ? new URL(options.worker)
270
+ : new URL(options.worker, pathToFileURL(Bun.main))
271
+ : options.worker;
272
+ let worker: Worker | undefined;
273
+ try {
274
+ worker = workerUrl === undefined ? undefined : new Worker(workerUrl.href);
275
+ } catch (err) {
276
+ drain.close();
277
+ throw err;
278
+ }
279
+ const init: InitMessage = { kind: "init", ring: sab, webviewHandle: Number(webview.unsafeHandle), drainCallbackPtr: Number(drain.ptr), libPath };
280
+ worker?.postMessage(init);
281
+
282
+ // Worker first, then the handle: the worker calls webview_dispatch on the handle until it is terminated.
283
+ const shutdown = () => {
284
+ worker?.terminate();
285
+ drain.close();
286
+ if (webview.unsafeHandle !== null) webview.destroy();
287
+ };
288
+ if (worker) worker.onerror = (e) => console.error("sweetview: the worker failed:", e.message ?? e);
289
+
290
+ try {
291
+
292
+ const uiMethods = (options.uiMethods ?? {}) as Record<string, ((params: unknown) => unknown) | undefined>;
293
+
294
+ // page -> backend. `req` is the JSON array of call arguments: [method, params].
295
+ webview.bindRaw("__sweetview_rpc", (seq, req) => {
296
+ const [method, params] = JSON.parse(req) as [string, unknown];
297
+ const local = Object.hasOwn(uiMethods, method) ? uiMethods[method] : undefined;
298
+ if (!local) {
299
+ if (worker) worker.postMessage({ kind: "call", seq, method, params });
300
+ else webview.return(seq, 1, JSON.stringify({ message: `${method}: this window has no worker and no UI-thread method of that name` }));
301
+ return;
302
+ }
303
+ try {
304
+ const result = local(params);
305
+ if (result !== null && typeof result === "object" && typeof (result as { then?: unknown }).then === "function") {
306
+ throw new Error(`${method}: a UI-thread method must return synchronously; this one returned a Promise`);
307
+ }
308
+ webview.return(seq, 0, JSON.stringify(result ?? null));
309
+ } catch (err) {
310
+ webview.return(seq, 1, JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
311
+ }
312
+ });
313
+
314
+ const setAsset = setAssetCall(libPath);
315
+ if (!setAsset) {
316
+ throw new Error("sweetview: this webview library has no webview_set_asset; it needs the library @n1h/sweetview-webview-bun ships (is WEBVIEW_PATH pointing at a stock one?)");
317
+ }
318
+ for (const [path, source] of Object.entries(assets)) {
319
+ const err = setAsset(handle(), path, assetMime(path), await assetBytes(source));
320
+ if (err !== 0) throw new Error(`sweetview: webview_set_asset(${path}) failed (${err})`);
321
+ }
322
+ webview.navigate(`${ASSET_ORIGIN}/${index}`);
323
+ } catch (err) {
324
+ worker?.terminate();
325
+ drain.close();
326
+ throw err;
327
+ }
328
+
329
+ return {
330
+ webview,
331
+ window,
332
+ windowHandle,
333
+ emit,
334
+ call: (method, ...params) => {
335
+ if (worker) worker.postMessage({ kind: "call", seq: null, method, params: params[0] ?? null });
336
+ else console.error(`sweetview: ${method}: this window has no worker`);
337
+ },
338
+ onEvent(fn) {
339
+ const tap = fn as (event: { name: string; payload: unknown }) => void;
340
+ taps.add(tap);
341
+ return () => taps.delete(tap);
342
+ },
343
+ run() {
344
+ lib.symbols.webview_run(handle()); // blocks until the window closes or quit()
345
+ shutdown();
346
+ },
347
+ quit,
348
+ };
349
+ }
350
+ }
package/src/build.ts ADDED
@@ -0,0 +1,118 @@
1
+ // Build-time helpers for shipping a sweetview app: the icon files the
2
+ // executables embed, and the macOS .app bundle around the compiled binary.
3
+ // Plain file writing; runs on any OS, imports nothing native, so a build
4
+ // script can use it on a machine without the webview library.
5
+ //
6
+ // import { makeMacApp, writeIconFiles } from "@n1h/sweetview/build";
7
+ //
8
+ // The bundle is also a script to run: src/make-app.ts.
9
+
10
+ import { chmodSync, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { basename, join } from "node:path";
12
+
13
+ export type MacAppOptions = {
14
+ /** What Finder, the Dock and the menu bar show; the bundle is `<name>.app`. */
15
+ name: string;
16
+ /** Reverse-DNS, `dev.example.my-app`. Notifications are silently unavailable without one, so a bare binary cannot send them. */
17
+ bundleId: string;
18
+ /** The compiled macOS executable (`bun build --compile --target=bun-darwin-arm64 …`). Copied in under its own file name. */
19
+ binary: string;
20
+ /** Where `<name>.app` goes; one already there is replaced. */
21
+ outDir: string;
22
+ /** The `.icns` for Finder and the Dock (sweetnative's `encodeIcns` / `icnsFromRgba`, written by `writeIconFiles`). Without it the bundle shows the stock icon. */
23
+ icns?: string;
24
+ /** `CFBundleVersion` and `CFBundleShortVersionString`. Default `0.1.0`. */
25
+ version?: string;
26
+ /** `LSMinimumSystemVersion`. Default `11.0`. */
27
+ minimumSystemVersion?: string;
28
+ /** More `Info.plist` entries, by key: `{ LSUIElement: true }` for a menu-bar-only app with no Dock tile, say. These win over the defaults. */
29
+ plist?: Record<string, string | number | boolean>;
30
+ };
31
+
32
+ const plistValue = (value: string | number | boolean): string =>
33
+ typeof value === "boolean" ? (value ? "<true/>" : "<false/>") : typeof value === "number" ? (Number.isFinite(value) ? (Number.isInteger(value) ? `<integer>${value}</integer>` : `<real>${value}</real>`) : (() => { throw new Error(`Info.plist cannot hold ${value}`); })()) : `<string>${escapeXml(value)}</string>`;
34
+
35
+ const escapeXml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
36
+
37
+ /**
38
+ * Wraps the compiled macOS binary in a `.app` bundle: `Contents/MacOS/<binary>`
39
+ * (made executable), `Contents/Resources/AppIcon.icns`, `Info.plist`,
40
+ * `PkgInfo`. Finder then launches it without a Terminal window and
41
+ * notifications have the bundle id they need. Returns the bundle's path.
42
+ *
43
+ * The bundle is unsigned. If Finder calls it damaged on the Mac it was copied
44
+ * to: `xattr -dr com.apple.quarantine "<name>.app"`, `chmod +x
45
+ * "<name>.app/Contents/MacOS/"*`, `codesign --sign - --force --deep "<name>.app"`.
46
+ */
47
+ export function makeMacApp(options: MacAppOptions): string {
48
+ if (!existsSync(options.binary)) throw new Error(`makeMacApp: ${options.binary} not found (compile the macOS binary first)`);
49
+ if (options.icns !== undefined && !existsSync(options.icns)) throw new Error(`makeMacApp: ${options.icns} not found (write the .icns first)`);
50
+
51
+ const executable = basename(options.binary);
52
+ const app = join(options.outDir, `${options.name}.app`);
53
+ const macos = join(app, "Contents", "MacOS");
54
+ const resources = join(app, "Contents", "Resources");
55
+ rmSync(app, { recursive: true, force: true });
56
+ mkdirSync(macos, { recursive: true });
57
+ mkdirSync(resources, { recursive: true });
58
+
59
+ cpSync(options.binary, join(macos, executable));
60
+ chmodSync(join(macos, executable), 0o755); // kept by tar/zip, lost by some SMB copies
61
+ if (options.icns !== undefined) cpSync(options.icns, join(resources, "AppIcon.icns"));
62
+
63
+ const version = options.version ?? "0.1.0";
64
+ const entries: Record<string, string | number | boolean> = {
65
+ CFBundleName: options.name,
66
+ CFBundleDisplayName: options.name,
67
+ CFBundleIdentifier: options.bundleId,
68
+ CFBundleVersion: version,
69
+ CFBundleShortVersionString: version,
70
+ CFBundleExecutable: executable,
71
+ ...(options.icns !== undefined ? { CFBundleIconFile: "AppIcon" } : {}),
72
+ CFBundlePackageType: "APPL",
73
+ CFBundleInfoDictionaryVersion: "6.0",
74
+ LSMinimumSystemVersion: options.minimumSystemVersion ?? "11.0",
75
+ NSHighResolutionCapable: true,
76
+ NSPrincipalClass: "NSApplication",
77
+ ...options.plist,
78
+ };
79
+ const dict = Object.entries(entries)
80
+ .map(([key, value]) => ` <key>${escapeXml(key)}</key>${plistValue(value)}`)
81
+ .join("\n");
82
+ writeFileSync(
83
+ join(app, "Contents", "Info.plist"),
84
+ `<?xml version="1.0" encoding="UTF-8"?>
85
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
86
+ <plist version="1.0">
87
+ <dict>
88
+ ${dict}
89
+ </dict>
90
+ </plist>
91
+ `,
92
+ );
93
+ writeFileSync(join(app, "Contents", "PkgInfo"), "APPL????");
94
+ return app;
95
+ }
96
+
97
+ /**
98
+ * Writes the icon files a build embeds into `dir` (created if need be):
99
+ * `app.ico` for the Windows executable (`bun build --compile
100
+ * --windows-icon=<dir>/app.ico`, Windows build host only) and `AppIcon.icns`
101
+ * for the `.app` bundle (`makeMacApp({ icns })`). Pass the ones you have;
102
+ * returns the paths written.
103
+ */
104
+ export function writeIconFiles(dir: string, files: { ico?: Uint8Array; icns?: Uint8Array }): string[] {
105
+ mkdirSync(dir, { recursive: true });
106
+ const written: string[] = [];
107
+ if (files.ico) {
108
+ const path = join(dir, "app.ico");
109
+ writeFileSync(path, files.ico);
110
+ written.push(path);
111
+ }
112
+ if (files.icns) {
113
+ const path = join(dir, "AppIcon.icns");
114
+ writeFileSync(path, files.icns);
115
+ written.push(path);
116
+ }
117
+ return written;
118
+ }
package/src/html.ts ADDED
@@ -0,0 +1,71 @@
1
+ // The page's index document, served as an asset like everything else. It only
2
+ // references the bundle and stylesheet by relative URL; the bytes of those are
3
+ // assets too (see createWebviewApp's `assets`). Nothing is inlined, so there
4
+ // is no size cap (webview_set_html on Windows stops at 2 MB).
5
+ //
6
+ // assets: { "index.html": indexHtml({ title: "My App" }), "main.js": js, "main.css": css }
7
+
8
+ export type IndexHtmlOptions = {
9
+ title: string;
10
+ /** Asset path of the page bundle, loaded as a module. Default "main.js". */
11
+ js?: string;
12
+ /** Asset path of the stylesheet. Default "main.css"; "" for none. */
13
+ css?: string;
14
+ /** Extra markup for <head>. */
15
+ head?: string;
16
+ /** Markup for <body> before the script; defaults to a #root div. */
17
+ body?: string;
18
+ };
19
+
20
+ const escapeHtml = (s: string) => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
21
+
22
+ const MIME: Record<string, string> = {
23
+ html: "text/html; charset=utf-8",
24
+ js: "text/javascript; charset=utf-8",
25
+ mjs: "text/javascript; charset=utf-8",
26
+ css: "text/css; charset=utf-8",
27
+ json: "application/json",
28
+ map: "application/json",
29
+ txt: "text/plain; charset=utf-8",
30
+ svg: "image/svg+xml",
31
+ png: "image/png",
32
+ jpg: "image/jpeg",
33
+ jpeg: "image/jpeg",
34
+ gif: "image/gif",
35
+ webp: "image/webp",
36
+ ico: "image/x-icon",
37
+ woff: "font/woff",
38
+ woff2: "font/woff2",
39
+ ttf: "font/ttf",
40
+ wasm: "application/wasm",
41
+ glb: "model/gltf-binary",
42
+ gltf: "model/gltf+json",
43
+ mp3: "audio/mpeg",
44
+ ogg: "audio/ogg",
45
+ wav: "audio/wav",
46
+ };
47
+
48
+ /** Content-Type for an asset path, from its extension. */
49
+ export const assetMime = (path: string): string => MIME[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream";
50
+
51
+ export function indexHtml({ title, js = "main.js", css = "main.css", head = "", body = '<div id="root"></div>' }: IndexHtmlOptions): Blob {
52
+ return new Blob([indexHtmlText({ title, js, css, head, body })], { type: "text/html; charset=utf-8" });
53
+ }
54
+
55
+ /** The same document as a string. */
56
+ export function indexHtmlText({ title, js = "main.js", css = "main.css", head = "", body = '<div id="root"></div>' }: IndexHtmlOptions): string {
57
+ return `<!doctype html>
58
+ <html lang="en">
59
+ <head>
60
+ <meta charset="utf-8" />
61
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
62
+ <title>${escapeHtml(title)}</title>
63
+ ${css ? `<link rel="stylesheet" href="${escapeHtml(css)}" />` : ""}
64
+ ${head}
65
+ </head>
66
+ <body>
67
+ ${body}
68
+ <script type="module" src="${escapeHtml(js)}"></script>
69
+ </body>
70
+ </html>`;
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // UI-thread entry. The worker entry is "@n1h/sweetview/worker", the page side is
2
+ // "@n1h/sweetview/page". The page side pulls in no bun:ffi; the worker dlopens
3
+ // the webview library for webview_dispatch but never imports webview-bun.
4
+ export { createWebviewApp, type Asset, type UiMethods, type WebviewApp, type WebviewAppOptions } from "./app";
5
+ export { assetMime, indexHtml, indexHtmlText, type IndexHtmlOptions } from "./html";
6
+ export { windowChromeMethods, type WindowChromeInfo, type WindowChromeKind, type WindowChromeMethodTable, type WindowChromeMethods, type WindowChromeOptions } from "./window-chrome";
7
+ export { Ring } from "./ring";
8
+ export { SizeHint, WEBVIEW_VERSION, Webview } from "@n1h/sweetview-webview-bun";
@@ -0,0 +1,41 @@
1
+ // The page side of the contract. A Transport is the one thing a host has to
2
+ // provide to the frontend; createBridge puts the typed surface on it.
3
+
4
+ import type { EventMap, MethodMap } from "./service";
5
+
6
+ export type TransportKind = "webview" | "web";
7
+
8
+ export interface Transport {
9
+ readonly kind: TransportKind;
10
+ /** Resolves with the result, rejects with an Error carrying the backend's message. */
11
+ call(method: string, params: unknown): Promise<unknown>;
12
+ /** Fire and forget: no reply. */
13
+ notify(method: string, params: unknown): void;
14
+ /** Receive every event; returns an unsubscribe function. */
15
+ onEvent(fn: (name: string, payload: unknown) => void): () => void;
16
+ }
17
+
18
+ export interface Bridge<M extends MethodMap, E extends EventMap> {
19
+ readonly kind: TransportKind;
20
+ rpc<K extends keyof M & string>(method: K, ...params: M[K][0] extends void ? [] : [M[K][0]]): Promise<M[K][1]>;
21
+ notify<K extends keyof M & string>(method: K, ...params: M[K][0] extends void ? [] : [M[K][0]]): void;
22
+ on<N extends keyof E & string>(name: N, fn: (payload: E[N]) => void): () => void;
23
+ }
24
+
25
+ export function createBridge<M extends MethodMap, E extends EventMap>(transport: Transport): Bridge<M, E> {
26
+ type Listener = (payload: unknown) => void;
27
+ const listeners = new Map<string, Set<Listener>>();
28
+ transport.onEvent((name, payload) => listeners.get(name)?.forEach((fn) => fn(payload)));
29
+
30
+ return {
31
+ kind: transport.kind,
32
+ rpc: (method, ...params) => transport.call(method, params[0] ?? null) as Promise<never>,
33
+ notify: (method, ...params) => transport.notify(method, params[0] ?? null),
34
+ on(name, fn) {
35
+ let set = listeners.get(name);
36
+ if (!set) listeners.set(name, (set = new Set()));
37
+ set.add(fn as Listener);
38
+ return () => set.delete(fn as Listener);
39
+ },
40
+ };
41
+ }