@n1h/sweetview 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/LICENSE-0BSD +12 -0
- package/README.md +268 -3
- package/docs/README.md +26 -0
- package/docs/app.md +523 -0
- package/docs/build.md +169 -0
- package/docs/ipc-ws.md +202 -0
- package/docs/ipc.md +323 -0
- package/docs/native.md +1066 -0
- package/docs/page.md +71 -0
- package/docs/settings-store.md +125 -0
- package/docs/webview-library.md +104 -0
- package/docs/window-chrome.md +221 -0
- package/docs/worker.md +72 -0
- package/file-imports.d.ts +19 -0
- package/package.json +66 -4
- package/src/app.ts +350 -0
- package/src/build.ts +118 -0
- package/src/html.ts +71 -0
- package/src/index.ts +8 -0
- package/src/ipc/bridge.ts +41 -0
- package/src/ipc/frame.ts +16 -0
- package/src/ipc/index.ts +16 -0
- package/src/ipc/memory.ts +33 -0
- package/src/ipc/service.ts +75 -0
- package/src/ipc-ws/client.ts +111 -0
- package/src/ipc-ws/index.ts +5 -0
- package/src/ipc-ws/server.ts +133 -0
- package/src/make-app.ts +93 -0
- package/src/messages.ts +16 -0
- package/src/native/app-icon.ts +23 -0
- package/src/native/app-menu.ts +18 -0
- package/src/native/darwin/app-icon.ts +10 -0
- package/src/native/darwin/app-menu.ts +88 -0
- package/src/native/darwin/folder-picker.ts +29 -0
- package/src/native/darwin/image.ts +47 -0
- package/src/native/darwin/menu.ts +89 -0
- package/src/native/darwin/notifier.ts +95 -0
- package/src/native/darwin/objc.ts +246 -0
- package/src/native/darwin/overlay.ts +352 -0
- package/src/native/darwin/tray.ts +129 -0
- package/src/native/darwin/window.ts +64 -0
- package/src/native/folder-picker.ts +15 -0
- package/src/native/hit-regions.ts +162 -0
- package/src/native/icon.ts +530 -0
- package/src/native/index.ts +38 -0
- package/src/native/menu.ts +43 -0
- package/src/native/notifications.ts +40 -0
- package/src/native/overlay.ts +182 -0
- package/src/native/platform.ts +25 -0
- package/src/native/tray.ts +41 -0
- package/src/native/win32/api.ts +234 -0
- package/src/native/win32/app-icon.ts +5 -0
- package/src/native/win32/app-menu.ts +4 -0
- package/src/native/win32/folder-picker.ts +121 -0
- package/src/native/win32/icon.ts +85 -0
- package/src/native/win32/menu.ts +107 -0
- package/src/native/win32/message-window.ts +72 -0
- package/src/native/win32/notifier.ts +28 -0
- package/src/native/win32/overlay.ts +499 -0
- package/src/native/win32/tray.ts +228 -0
- package/src/native/win32/window.ts +77 -0
- package/src/native/window.ts +40 -0
- package/src/page.ts +48 -0
- package/src/ring.ts +110 -0
- package/src/settings-store.ts +101 -0
- package/src/webview-lib.ts +60 -0
- package/src/window-chrome.ts +118 -0
- package/src/worker.ts +72 -0
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@n1h/sweetview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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.5.0"
|
|
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.5.0"
|
|
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
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
|
+
}
|