@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
@@ -0,0 +1,228 @@
1
+ // Notification-area icon: Shell_NotifyIcon on a hidden message window.
2
+ // Right click pops the menu; left double click fires onDoubleClick; a left
3
+ // click that is not followed by a second one within GetDoubleClickTime fires
4
+ // onClick. The single/double distinction uses a Win32 timer on the message
5
+ // window rather than setTimeout, because Bun's event loop does not tick while
6
+ // the webview's run loop owns the thread.
7
+
8
+ import { resolveMenu } from "../menu";
9
+ import type { MenuSource } from "../menu";
10
+ import type { NotificationOptions } from "../notifications";
11
+ import type { Tray, TrayOptions } from "../tray";
12
+ import {
13
+ SM_CXICON,
14
+ WM_APP,
15
+ WM_LBUTTONDBLCLK,
16
+ WM_LBUTTONUP,
17
+ WM_RBUTTONUP,
18
+ WM_TIMER,
19
+ lastError,
20
+ shell32,
21
+ user32,
22
+ wstr,
23
+ } from "./api";
24
+ import { loadIcon, releaseIcon } from "./icon";
25
+ import { popupMenuAtCursor } from "./menu";
26
+ import { createMessageWindow } from "./message-window";
27
+
28
+ const WM_TRAYICON = WM_APP + 1;
29
+ const CLICK_TIMER_ID = 1n;
30
+
31
+ const NIM_ADD = 0;
32
+ const NIM_MODIFY = 1;
33
+ const NIM_DELETE = 2;
34
+ const NIF_MESSAGE = 0x1;
35
+ const NIF_ICON = 0x2;
36
+ const NIF_TIP = 0x4;
37
+ const NIF_INFO = 0x10;
38
+
39
+ // dwInfoFlags
40
+ const NIIF_INFO = 0x1;
41
+ const NIIF_USER = 0x4;
42
+ const NIIF_NOSOUND = 0x10;
43
+ const NIIF_LARGE_ICON = 0x20;
44
+ const NIIF_RESPECT_QUIET_TIME = 0x80;
45
+
46
+ // Balloon callbacks in the low word of lParam.
47
+ const NIN_BALLOONUSERCLICK = 0x0405;
48
+
49
+ // NOTIFYICONDATAW, x64, current version (sizeof = 976).
50
+ export const NID_SIZE = 976;
51
+ export const NID_HWND = 8;
52
+ export const NID_UID = 16;
53
+ export const NID_FLAGS = 20;
54
+ export const NID_CALLBACK = 24;
55
+ export const NID_HICON = 32;
56
+ export const NID_TIP = 40; // WCHAR[128]
57
+ export const NID_INFO = 304; // WCHAR[256]
58
+ export const NID_INFO_TITLE = 820; // WCHAR[64]
59
+ export const NID_INFO_FLAGS = 948;
60
+ export const NID_BALLOON_ICON = 968;
61
+
62
+ let nextUid = 1;
63
+ let taskbarCreated: number | undefined;
64
+
65
+ /**
66
+ * The Windows tray, as the rest of this folder sees it: the public Tray plus
67
+ * the balloon channel the notifier uses. Not exported from the package.
68
+ */
69
+ export interface Win32Tray extends Tray {
70
+ readonly balloon: {
71
+ /** Shows a balloon (a toast on Windows 10/11). False once the tray is disposed or when the shell refuses. */
72
+ show(n: NotificationOptions): boolean;
73
+ onClick(fn: (() => void) | undefined): void;
74
+ };
75
+ }
76
+
77
+ let active: Win32Tray | undefined;
78
+ /** The most recently created tray that has not been disposed. */
79
+ export const currentTray = (): Win32Tray | undefined => active;
80
+
81
+ /** Copies `value` into a fixed WCHAR[wchars] field, truncated so the NUL still fits. */
82
+ const setWide = (nid: Uint8Array, offset: number, value: string, wchars: number) => {
83
+ const bytes = wstr(value);
84
+ nid.set(bytes.subarray(0, Math.min(bytes.length, (wchars - 1) * 2)), offset);
85
+ };
86
+
87
+ export type Balloon = { title: string; body: string; silent: boolean; hIcon: bigint };
88
+
89
+ /** The NOTIFYICONDATAW for the icon itself, plus a balloon (Windows 10/11 shows it as a toast) when given. */
90
+ export function notifyIconData(hwnd: bigint, uid: number, hIcon: bigint, tip: string, balloon?: Balloon): Uint8Array {
91
+ const nid = new Uint8Array(NID_SIZE);
92
+ const dv = new DataView(nid.buffer);
93
+ dv.setUint32(0, NID_SIZE, true);
94
+ dv.setBigUint64(NID_HWND, hwnd, true);
95
+ dv.setUint32(NID_UID, uid, true);
96
+ dv.setUint32(NID_FLAGS, NIF_MESSAGE | NIF_ICON | NIF_TIP | (balloon ? NIF_INFO : 0), true);
97
+ dv.setUint32(NID_CALLBACK, WM_TRAYICON, true);
98
+ dv.setBigUint64(NID_HICON, hIcon, true);
99
+ setWide(nid, NID_TIP, tip, 128);
100
+ if (balloon) {
101
+ setWide(nid, NID_INFO, balloon.body, 256);
102
+ setWide(nid, NID_INFO_TITLE, balloon.title, 64);
103
+ const ownIcon = balloon.hIcon !== 0n;
104
+ let flags = (ownIcon ? NIIF_USER | NIIF_LARGE_ICON : NIIF_INFO) | NIIF_RESPECT_QUIET_TIME;
105
+ if (balloon.silent) flags |= NIIF_NOSOUND;
106
+ dv.setUint32(NID_INFO_FLAGS, flags, true);
107
+ dv.setBigUint64(NID_BALLOON_ICON, balloon.hIcon, true);
108
+ }
109
+ return nid;
110
+ }
111
+
112
+ export function createTray(options: TrayOptions): Win32Tray {
113
+ const uid = nextUid++;
114
+ let icon = loadIcon(options.icon?.windows?.ico);
115
+ let balloonIcon = loadIcon(options.icon?.windows?.ico, SM_CXICON); // toasts want the 32px image
116
+ let tooltip = options.tooltip ?? "";
117
+ let balloonClick: (() => void) | undefined;
118
+ let menu: MenuSource | undefined = options.menu;
119
+ let disposed = false;
120
+ let menuOpen = false;
121
+ // A double click arrives as DOWN, UP, DBLCLK, UP: the trailing UP must not start the single-click timer.
122
+ let swallowNextUp = false;
123
+
124
+ taskbarCreated ??= user32.RegisterWindowMessageW(wstr("TaskbarCreated"));
125
+
126
+ const window = createMessageWindow((message, wParam, lParam) => {
127
+ if (message === WM_TRAYICON && Number(wParam) === uid) {
128
+ onTrayMessage(Number(lParam) & 0xffff);
129
+ } else if (message === WM_TIMER && wParam === CLICK_TIMER_ID) {
130
+ user32.KillTimer(window.hwnd, CLICK_TIMER_ID);
131
+ options.onClick?.();
132
+ } else if (message === taskbarCreated && !disposed) {
133
+ sync(NIM_ADD); // Explorer restarted and dropped every icon
134
+ }
135
+ });
136
+
137
+ const sync = (op: number, balloon?: Balloon) => {
138
+ const nid = notifyIconData(window.hwnd, uid, icon.handle, tooltip, balloon);
139
+ const ok = shell32.Shell_NotifyIconW(op, nid);
140
+ if (!ok && balloon) console.error(`sweetnative: Shell_NotifyIcon balloon failed (${lastError()})`);
141
+ // NIM_ADD fails with ERROR_TIMEOUT while Explorer is still starting (login autostart);
142
+ // the TaskbarCreated broadcast that follows re-adds the icon, so do not throw.
143
+ if (!ok && op === NIM_ADD) console.error(`sweetnative: Shell_NotifyIcon(NIM_ADD) failed (${lastError()}); waiting for TaskbarCreated`);
144
+ };
145
+
146
+ const onTrayMessage = (mouse: number) => {
147
+ switch (mouse) {
148
+ case NIN_BALLOONUSERCLICK:
149
+ balloonClick?.();
150
+ return;
151
+ case WM_RBUTTONUP: {
152
+ if (menuOpen || menu === undefined) return;
153
+ const template = resolveMenu(menu);
154
+ if (template.length === 0) return;
155
+ menuOpen = true;
156
+ try {
157
+ popupMenuAtCursor(window.hwnd, template);
158
+ } finally {
159
+ menuOpen = false;
160
+ }
161
+ return;
162
+ }
163
+ case WM_LBUTTONUP:
164
+ if (swallowNextUp) {
165
+ swallowNextUp = false;
166
+ return;
167
+ }
168
+ // Wait one double-click interval before calling it a single click.
169
+ if (options.onClick) user32.SetTimer(window.hwnd, CLICK_TIMER_ID, user32.GetDoubleClickTime(), null);
170
+ return;
171
+ case WM_LBUTTONDBLCLK:
172
+ user32.KillTimer(window.hwnd, CLICK_TIMER_ID);
173
+ swallowNextUp = true;
174
+ options.onDoubleClick?.();
175
+ return;
176
+ }
177
+ };
178
+
179
+ sync(NIM_ADD);
180
+
181
+ const tray: Win32Tray = {
182
+ balloon: {
183
+ show(n) {
184
+ if (disposed) return false;
185
+ // Only an icon we created is safe to hand over as hBalloonIcon; the stock one gets the info glyph.
186
+ const hIcon = balloonIcon.owned ? balloonIcon.handle : 0n;
187
+ const nid = notifyIconData(window.hwnd, uid, icon.handle, tooltip, { title: n.title, body: n.body ?? "", silent: n.silent === true, hIcon });
188
+ const ok = shell32.Shell_NotifyIconW(NIM_MODIFY, nid) !== 0;
189
+ if (!ok) console.error(`sweetnative: Shell_NotifyIcon balloon failed (${lastError()})`);
190
+ return ok;
191
+ },
192
+ onClick(fn) {
193
+ balloonClick = fn;
194
+ },
195
+ },
196
+ setTooltip(text) {
197
+ if (disposed) return;
198
+ tooltip = text;
199
+ sync(NIM_MODIFY);
200
+ },
201
+ setIcon(next) {
202
+ if (disposed) return;
203
+ const previous = icon;
204
+ const previousBalloon = balloonIcon;
205
+ icon = loadIcon(next.windows?.ico);
206
+ balloonIcon = loadIcon(next.windows?.ico, SM_CXICON);
207
+ sync(NIM_MODIFY);
208
+ releaseIcon(previous);
209
+ releaseIcon(previousBalloon);
210
+ },
211
+ setMenu(next) {
212
+ menu = next;
213
+ },
214
+ dispose() {
215
+ if (disposed) return;
216
+ disposed = true;
217
+ user32.KillTimer(window.hwnd, CLICK_TIMER_ID);
218
+ sync(NIM_DELETE);
219
+ window.destroy();
220
+ releaseIcon(icon);
221
+ releaseIcon(balloonIcon);
222
+ if (active === tray) active = undefined;
223
+ },
224
+ };
225
+
226
+ active = tray;
227
+ return tray;
228
+ }
@@ -0,0 +1,77 @@
1
+ // Show/hide and close interception for a window someone else created (the
2
+ // webview's). The close hook subclasses the window procedure with comctl32's
3
+ // SetWindowSubclass, so the webview's own procedure stays in the chain for
4
+ // everything but WM_CLOSE.
5
+
6
+ import { FFIType, JSCallback } from "bun:ffi";
7
+ import type { NativeWindow, WindowHandle } from "../window";
8
+ import { SW_HIDE, SW_RESTORE, SW_SHOW, WM_CLOSE, WM_NCDESTROY, comctl32, user32 } from "./api";
9
+ import { setWindowIcon } from "./icon";
10
+
11
+ export const asHwnd = (handle: WindowHandle): bigint => {
12
+ if (handle === null || handle === 0 || handle === 0n) throw new Error("sweetnative: window handle is NULL");
13
+ return BigInt(handle);
14
+ };
15
+
16
+ const SUBCLASS_ID = 0x4e53n; // "NS"
17
+ const closeHandlers = new Map<bigint, () => void>();
18
+
19
+ // One SUBCLASSPROC for every window we hook, retained for the process.
20
+ let subclassProc: JSCallback | undefined;
21
+ const proc = () =>
22
+ (subclassProc ??= new JSCallback(
23
+ (hwnd: bigint, message: number, wParam: bigint, lParam: bigint, _id: bigint, _ref: bigint): bigint => {
24
+ const handler = closeHandlers.get(hwnd);
25
+ if (message === WM_CLOSE && handler !== undefined) {
26
+ try {
27
+ handler();
28
+ } catch (err) {
29
+ console.error("sweetnative: close handler threw:", err);
30
+ }
31
+ return 0n; // handled: the window stays
32
+ }
33
+ if (message === WM_NCDESTROY) {
34
+ // The window is going away for real (quit); leave the chain cleanly.
35
+ closeHandlers.delete(hwnd);
36
+ comctl32.RemoveWindowSubclass(hwnd, proc().ptr, SUBCLASS_ID);
37
+ }
38
+ return comctl32.DefSubclassProc(hwnd, message, wParam, lParam);
39
+ },
40
+ { args: [FFIType.u64, FFIType.u32, FFIType.u64, FFIType.i64, FFIType.u64, FFIType.u64], returns: FFIType.i64 },
41
+ ));
42
+
43
+ export function nativeWindow(handle: WindowHandle): NativeWindow {
44
+ const hwnd = asHwnd(handle);
45
+ const self: NativeWindow = {
46
+ isVisible: () => user32.IsWindowVisible(hwnd) !== 0,
47
+ show() {
48
+ user32.ShowWindow(hwnd, user32.IsIconic(hwnd) ? SW_RESTORE : SW_SHOW);
49
+ user32.SetForegroundWindow(hwnd);
50
+ },
51
+ hide() {
52
+ user32.ShowWindow(hwnd, SW_HIDE);
53
+ },
54
+ toggle() {
55
+ if (self.isVisible()) self.hide();
56
+ else self.show();
57
+ },
58
+ setIcon(icon) {
59
+ setWindowIcon(hwnd, icon?.windows?.ico);
60
+ },
61
+ onCloseRequest(handler) {
62
+ if (!closeHandlers.has(hwnd)) {
63
+ // SetWindowSubclass on an already-subclassed (hwnd, proc, id) just updates refData.
64
+ if (!comctl32.SetWindowSubclass(hwnd, proc().ptr, SUBCLASS_ID, 0n)) {
65
+ throw new Error("sweetnative: SetWindowSubclass failed");
66
+ }
67
+ }
68
+ closeHandlers.set(hwnd, handler);
69
+ return () => {
70
+ if (closeHandlers.get(hwnd) !== handler) return;
71
+ closeHandlers.delete(hwnd);
72
+ comctl32.RemoveWindowSubclass(hwnd, proc().ptr, SUBCLASS_ID);
73
+ };
74
+ },
75
+ };
76
+ return self;
77
+ }
@@ -0,0 +1,40 @@
1
+ // Control of a window someone else created (the webview's): show, hide, and
2
+ // what the close button does.
3
+
4
+ import type { Pointer } from "bun:ffi";
5
+ import type { Icon } from "./icon";
6
+ import { backend } from "./platform";
7
+
8
+ /** A native window handle as the webview hands it out (HWND on Windows, NSWindow* on macOS). */
9
+ export type WindowHandle = Pointer | bigint | number | null;
10
+
11
+ export interface NativeWindow {
12
+ isVisible(): boolean;
13
+ /** Shows, un-minimises and brings to the front. */
14
+ show(): void;
15
+ hide(): void;
16
+ toggle(): void;
17
+ /**
18
+ * Windows: the title bar, taskbar and Alt+Tab icon for this window, from
19
+ * `icon.windows.ico`; undefined restores the stock one. macOS: no-op, a
20
+ * window has no icon of its own there; `setAppIcon` sets the Dock's.
21
+ */
22
+ setIcon(icon: Icon | undefined): void;
23
+ /**
24
+ * Replaces what the window's close button does (destroy the window, which
25
+ * ends the app) with `handler`, typically `hide`. Returns a function that
26
+ * restores the default. Only the close button is intercepted; quitting
27
+ * through a menu still works.
28
+ */
29
+ onCloseRequest(handler: () => void): () => void;
30
+ }
31
+
32
+ type Backend = { nativeWindow(handle: WindowHandle): NativeWindow };
33
+ const impl = backend<Backend>(
34
+ () => require("./win32/window"),
35
+ () => require("./darwin/window"),
36
+ );
37
+
38
+ export function nativeWindow(handle: WindowHandle): NativeWindow {
39
+ return impl().nativeWindow(handle);
40
+ }
package/src/page.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Page side. Safe to import in browser code: no Bun, no FFI.
2
+ //
3
+ // The UI thread binds `__sweetview_rpc(method, params)` into the page with
4
+ // webview_bind, and delivers events by evaluating `__sweetview_emit(name,
5
+ // payload)`, which this module installs.
6
+
7
+ import type { Transport } from "./ipc";
8
+
9
+ declare global {
10
+ interface Window {
11
+ __sweetview_rpc?: (method: string, params: unknown) => Promise<unknown>;
12
+ __sweetview_emit?: (name: string, payload: unknown) => void;
13
+ }
14
+ }
15
+
16
+ /** True when the page is running inside a sweetview window. */
17
+ export const isWebview = (): boolean => typeof window !== "undefined" && typeof window.__sweetview_rpc === "function";
18
+
19
+ // One emit global per page, shared by every transport created on it.
20
+ const listeners = new Set<(name: string, payload: unknown) => void>();
21
+
22
+ export function webviewTransport(): Transport {
23
+ const rpc = typeof window !== "undefined" ? window.__sweetview_rpc : undefined;
24
+ if (!rpc) throw new Error("not running inside sweetview");
25
+ window.__sweetview_emit ??= (name, payload) => {
26
+ for (const fn of listeners) fn(name, payload);
27
+ };
28
+
29
+ const call = (method: string, params: unknown) =>
30
+ rpc(method, params).then(
31
+ (r) => r,
32
+ (e: unknown) => {
33
+ // webview rejects with the JSON passed to webview_return(status=1): { message }
34
+ const message = typeof e === "object" && e && "message" in e ? String((e as { message: unknown }).message) : String(e);
35
+ throw new Error(message);
36
+ },
37
+ );
38
+
39
+ return {
40
+ kind: "webview",
41
+ call,
42
+ notify: (method, params) => void call(method, params).catch((e: Error) => console.error(`${method} failed:`, e.message)),
43
+ onEvent(fn) {
44
+ listeners.add(fn);
45
+ return () => listeners.delete(fn);
46
+ },
47
+ };
48
+ }
package/src/ring.ts ADDED
@@ -0,0 +1,110 @@
1
+ // Single-producer / single-consumer byte ring over a SharedArrayBuffer.
2
+ //
3
+ // Producer: the worker (Bun event loop). Consumer: the UI thread, inside a
4
+ // webview_dispatch callback. Frames are length-prefixed. The producer blocks
5
+ // (Atomics.wait) only when the ring is full; the consumer never blocks.
6
+ //
7
+ // Header (Int32, shared): [0] write cursor, [1] read cursor, [2] wakeup pending.
8
+ // Cursors grow monotonically and wrap in int32; all arithmetic is done with
9
+ // `| 0` so the wrap is harmless.
10
+
11
+ const HEADER_BYTES = 16;
12
+ const WRITE = 0;
13
+ const READ = 1;
14
+ const PENDING = 2;
15
+
16
+ export class Ring {
17
+ readonly #hdr: Int32Array;
18
+ readonly #data: Uint8Array;
19
+ readonly #cap: number;
20
+
21
+ /** `capacity` must be a power of two: the cursors wrap in int32 and `pos % capacity` only stays continuous across that wrap when capacity divides 2^32. */
22
+ static create(capacity: number): SharedArrayBuffer {
23
+ Ring.#checkCapacity(capacity);
24
+ return new SharedArrayBuffer(HEADER_BYTES + capacity);
25
+ }
26
+
27
+ constructor(sab: SharedArrayBuffer) {
28
+ this.#hdr = new Int32Array(sab, 0, HEADER_BYTES / 4);
29
+ this.#data = new Uint8Array(sab, HEADER_BYTES);
30
+ this.#cap = this.#data.length;
31
+ Ring.#checkCapacity(this.#cap);
32
+ }
33
+
34
+ static #checkCapacity(capacity: number) {
35
+ if (!Number.isInteger(capacity) || capacity < 16 || (capacity & (capacity - 1)) !== 0 || capacity > 1 << 30) {
36
+ throw new RangeError(`ring capacity must be a power of two between 16 and 2^30, got ${capacity}`);
37
+ }
38
+ }
39
+
40
+ get capacity() {
41
+ return this.#cap;
42
+ }
43
+
44
+ /** Producer side. Blocks while the ring is full. */
45
+ write(bytes: Uint8Array): void {
46
+ const need = 4 + bytes.length;
47
+ if (need > this.#cap) throw new RangeError(`frame of ${bytes.length} bytes exceeds ring capacity ${this.#cap}`);
48
+ for (;;) {
49
+ const w = Atomics.load(this.#hdr, WRITE);
50
+ const r = Atomics.load(this.#hdr, READ);
51
+ if (((w - r) | 0) + need <= this.#cap) break;
52
+ Atomics.wait(this.#hdr, READ, r);
53
+ }
54
+ const w = Atomics.load(this.#hdr, WRITE);
55
+ const len = new Uint8Array(4);
56
+ new DataView(len.buffer).setUint32(0, bytes.length, true);
57
+ this.#put(w, len);
58
+ this.#put((w + 4) | 0, bytes);
59
+ Atomics.store(this.#hdr, WRITE, (w + need) | 0);
60
+ }
61
+
62
+ /**
63
+ * Producer side. Returns true when the caller should wake the consumer
64
+ * (no wakeup is already in flight), so a burst costs one dispatch.
65
+ */
66
+ claimWakeup(): boolean {
67
+ return Atomics.compareExchange(this.#hdr, PENDING, 0, 1) === 0;
68
+ }
69
+
70
+ /** Consumer side. Call first thing in the wakeup, before draining. */
71
+ acknowledgeWakeup(): void {
72
+ Atomics.store(this.#hdr, PENDING, 0);
73
+ }
74
+
75
+ /** Consumer side. Yields every complete frame currently in the ring. */
76
+ *drain(): Generator<Uint8Array> {
77
+ let r = Atomics.load(this.#hdr, READ);
78
+ const w = Atomics.load(this.#hdr, WRITE);
79
+ while (r !== w) {
80
+ const lenBytes = this.#get(r, 4);
81
+ const len = new DataView(lenBytes.buffer, lenBytes.byteOffset).getUint32(0, true);
82
+ const frame = this.#get((r + 4) | 0, len);
83
+ r = (r + 4 + len) | 0;
84
+ Atomics.store(this.#hdr, READ, r);
85
+ Atomics.notify(this.#hdr, READ);
86
+ yield frame;
87
+ }
88
+ }
89
+
90
+ #offset(pos: number) {
91
+ const m = pos % this.#cap;
92
+ return m < 0 ? m + this.#cap : m;
93
+ }
94
+
95
+ #put(pos: number, bytes: Uint8Array) {
96
+ const off = this.#offset(pos);
97
+ const first = Math.min(bytes.length, this.#cap - off);
98
+ this.#data.set(bytes.subarray(0, first), off);
99
+ if (first < bytes.length) this.#data.set(bytes.subarray(first), 0);
100
+ }
101
+
102
+ #get(pos: number, len: number): Uint8Array {
103
+ const out = new Uint8Array(len);
104
+ const off = this.#offset(pos);
105
+ const first = Math.min(len, this.#cap - off);
106
+ out.set(this.#data.subarray(off, off + first), 0);
107
+ if (first < len) out.set(this.#data.subarray(0, len - first), first);
108
+ return out;
109
+ }
110
+ }
@@ -0,0 +1,101 @@
1
+ // A small app's settings: one JSON document in the user's app-data folder,
2
+ // read once at start and written whole on every change. Synchronous on
3
+ // purpose: the UI thread is blocked in the OS loop after run() and cannot
4
+ // await, and a settings change is a few hundred bytes.
5
+ //
6
+ // const store = createSettingsStore<Settings>({ app: "my-app", defaults: DEFAULTS, sanitize });
7
+ // let settings = store.load(); // before run(); defaults when the file is missing or unreadable
8
+ // store.save(settings = { ...settings, ...patch });
9
+ //
10
+ // No Bun or FFI; usable from any build or test script.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ /**
17
+ * The per-user folder an app keeps its own files in: `%APPDATA%\<app>` on
18
+ * Windows, `~/Library/Application Support/<app>` on macOS,
19
+ * `$XDG_CONFIG_HOME/<app>` (default `~/.config/<app>`) elsewhere. Not created.
20
+ */
21
+ export function appDataDir(app: string): string {
22
+ const base =
23
+ process.platform === "win32"
24
+ ? (process.env.APPDATA || join(homedir(), "AppData", "Roaming"))
25
+ : process.platform === "darwin"
26
+ ? join(homedir(), "Library", "Application Support")
27
+ : (process.env.XDG_CONFIG_HOME || join(homedir(), ".config"));
28
+ return join(base, app);
29
+ }
30
+
31
+ export type SettingsStoreOptions<T> = {
32
+ /** The app's folder name under the app-data folder (`appDataDir`). */
33
+ app: string;
34
+ /** The file inside it. Default `settings.json`. */
35
+ file?: string;
36
+ /** The folder itself, instead of `appDataDir(app)`; for tests, or an app that keeps settings elsewhere. */
37
+ dir?: string;
38
+ /** The baseline: what the default `sanitize` merges the file over, and what `load()` returns when `sanitize` throws. */
39
+ defaults: T;
40
+ /**
41
+ * Turns whatever the file held into a valid T: fill gaps, clamp ranges,
42
+ * drop junk. Gets the parsed JSON, or `undefined` when the file was
43
+ * missing, unreadable or not JSON. Default: the defaults with the file's
44
+ * own keys over them, when the file holds an object. A throw is logged and
45
+ * `defaults` is returned instead.
46
+ */
47
+ sanitize?: (input: unknown) => T;
48
+ };
49
+
50
+ export type SettingsStore<T> = {
51
+ /** Where the file is (or will be). */
52
+ readonly path: string;
53
+ /** The saved settings through `sanitize`; `sanitize(undefined)` when the file is missing, unreadable or not JSON (the cause goes to stderr). Never throws. */
54
+ load(): T;
55
+ /** Writes the whole document, creating the folder; a failure goes to stderr and is otherwise ignored, so a read-only disk never stops the app. */
56
+ save(settings: T): void;
57
+ };
58
+
59
+ export function createSettingsStore<T extends object>(options: SettingsStoreOptions<T>): SettingsStore<T> {
60
+ const path = join(options.dir ?? appDataDir(options.app), options.file ?? "settings.json");
61
+ const isPlainObject = (input: unknown): input is Partial<T> => typeof input === "object" && input !== null && !Array.isArray(input);
62
+ const sanitize = options.sanitize ?? ((input: unknown): T => ({ ...options.defaults, ...(isPlainObject(input) ? input : {}) }));
63
+ // sanitize runs on every path, so a custom one that derives or migrates fields always gets its turn;
64
+ // a throw from it is reported like an unreadable file and the defaults are used.
65
+ const settle = (input: unknown): T => {
66
+ try {
67
+ return sanitize(input);
68
+ } catch (err) {
69
+ console.error(`settings: sanitize failed for ${path}, using defaults:`, (err as Error).message);
70
+ return { ...options.defaults };
71
+ }
72
+ };
73
+ return {
74
+ path,
75
+ load() {
76
+ if (!existsSync(path)) return settle(undefined);
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(readFileSync(path, "utf8"));
80
+ } catch (err) {
81
+ console.error(`settings: ${path} unreadable, using defaults:`, (err as Error).message);
82
+ return settle(undefined);
83
+ }
84
+ return settle(parsed);
85
+ },
86
+ save(settings) {
87
+ // written beside the file and renamed over it, so a crash mid-write leaves the previous document rather than a truncated one
88
+ const tmp = `${path}.${process.pid}.tmp`;
89
+ try {
90
+ mkdirSync(join(path, ".."), { recursive: true });
91
+ writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`);
92
+ renameSync(tmp, path);
93
+ } catch (err) {
94
+ try {
95
+ rmSync(tmp, { force: true });
96
+ } catch {}
97
+ console.error(`settings: could not write ${path}:`, (err as Error).message);
98
+ }
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,60 @@
1
+ // sweetview runs on the webview library that @n1h/sweetview-webview-bun ships:
2
+ // upstream webview/webview plus `webview_set_background_color` and
3
+ // `webview_set_asset` (see ../docs/webview-library.md). webview-bun loads it from
4
+ // its own build/ folder; `bun build --compile` embeds that file. The two extra
5
+ // symbols are probed here at run time rather than assumed: without
6
+ // webview_set_background_color the page stays opaque with a warning; without
7
+ // webview_set_asset createWebviewApp throws, because the page cannot load.
8
+
9
+ import { FFIType, dlopen, ptr, type Pointer } from "bun:ffi";
10
+
11
+ /** The path webview-bun loads its native library from, resolved the same way it does, so the worker can dlopen the same file (it needs webview_dispatch). */
12
+ export async function webviewLibPath(): Promise<string> {
13
+ if (process.env.WEBVIEW_PATH) return process.env.WEBVIEW_PATH;
14
+ switch (process.platform) {
15
+ case "win32":
16
+ //@ts-expect-error file import
17
+ return (await import("@n1h/sweetview-webview-bun/build/libwebview.dll")).default;
18
+ case "darwin":
19
+ //@ts-expect-error file import
20
+ return (await import("@n1h/sweetview-webview-bun/build/libwebview.dylib")).default;
21
+ default:
22
+ return (await import(`@n1h/sweetview-webview-bun/build/libwebview-${process.arch}.so`)).default;
23
+ }
24
+ }
25
+
26
+ export type SetBackgroundColor = (w: Pointer, r: number, g: number, b: number, a: number) => number;
27
+ export type SetAsset = (w: Pointer, path: string, mime: string, data: Uint8Array) => number;
28
+
29
+ /** The library's `webview_set_background_color`, or undefined when this build does not have it. */
30
+ export function backgroundColorCall(libPath: string): SetBackgroundColor | undefined {
31
+ try {
32
+ return dlopen(libPath, {
33
+ webview_set_background_color: { args: [FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
34
+ }).symbols.webview_set_background_color as SetBackgroundColor;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ /** The library's `webview_set_asset`, or undefined when this build does not have it. */
41
+ export function setAssetCall(libPath: string): SetAsset | undefined {
42
+ try {
43
+ const { webview_set_asset } = dlopen(libPath, {
44
+ webview_set_asset: { args: [FFIType.ptr, FFIType.cstring, FFIType.cstring, FFIType.ptr, FFIType.u64], returns: FFIType.i32 },
45
+ }).symbols;
46
+ const cstr = (s: string) => Buffer.from(`${s}\0`, "utf8");
47
+ // The library copies the bytes, so nothing here needs to outlive the call.
48
+ return (w, path, mime, data) => webview_set_asset(w, cstr(path), cstr(mime), data.byteLength ? ptr(data) : null, BigInt(data.byteLength));
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Where assets are served from, per platform. Windows intercepts
56
+ * http://webview.localhost with WebView2's WebResourceRequested; macOS
57
+ * registers a WKURLSchemeHandler for the "webview" scheme. Relative URLs in
58
+ * the page work the same on both.
59
+ */
60
+ export const ASSET_ORIGIN = process.platform === "darwin" ? "webview://localhost" : "http://webview.localhost";