@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,16 @@
1
+ // Wire frames. Every transport (WebSocket, the sweetview ring, memory) carries
2
+ // exactly these, as JSON. The service never sees a frame; hosts do the
3
+ // seq <-> promise correlation.
4
+
5
+ /** Page -> backend. `seq: null` means fire and forget: no reply frame comes back. */
6
+ export type CallFrame = { kind: "call"; seq: string | null; method: string; params: unknown };
7
+
8
+ /** Backend -> page, answering one call. `error` is a user-safe message. */
9
+ export type ReplyFrame =
10
+ | { kind: "reply"; seq: string; ok: true; result: unknown }
11
+ | { kind: "reply"; seq: string; ok: false; error: string };
12
+
13
+ /** Backend -> page, unsolicited. */
14
+ export type EventFrame = { kind: "event"; name: string; payload: unknown };
15
+
16
+ export type Frame = CallFrame | ReplyFrame | EventFrame;
@@ -0,0 +1,16 @@
1
+ export type { CallFrame, EventFrame, Frame, ReplyFrame } from "./frame";
2
+ export {
3
+ defineService,
4
+ type AnyEvent,
5
+ type Ctx,
6
+ type Emit,
7
+ type EventMap,
8
+ type Handlers,
9
+ type MethodMap,
10
+ type Service,
11
+ type ServiceDefinition,
12
+ type Session,
13
+ type SessionKind,
14
+ } from "./service";
15
+ export { createBridge, type Bridge, type Transport, type TransportKind } from "./bridge";
16
+ export { memoryTransport } from "./memory";
@@ -0,0 +1,33 @@
1
+ // A Transport straight onto a Service in the same process. For tests, and for
2
+ // a frontend rendered in plain Bun without any host.
3
+
4
+ import type { Transport } from "./bridge";
5
+ import type { EventMap, MethodMap, Service, Session } from "./service";
6
+
7
+ let nextId = 1;
8
+
9
+ export function memoryTransport<M extends MethodMap, E extends EventMap>(
10
+ service: Service<M, E>,
11
+ session: Session = { id: `memory-${nextId++}`, kind: "web" },
12
+ ): Transport & { close(): void } {
13
+ const ctx = { session };
14
+ const listeners = new Set<(name: string, payload: unknown) => void>();
15
+ const off = service.onEvent((name, payload, to) => {
16
+ if (to !== undefined && to !== session.id) return;
17
+ for (const fn of listeners) fn(name, payload);
18
+ });
19
+ const call = (method: string, params: unknown) => service.call(method as keyof M & string, params as never, ctx);
20
+ return {
21
+ kind: session.kind,
22
+ call,
23
+ notify: (method, params) => void call(method, params).catch(() => {}),
24
+ onEvent(fn) {
25
+ listeners.add(fn);
26
+ return () => listeners.delete(fn);
27
+ },
28
+ close() {
29
+ off();
30
+ service.endSession(session.id);
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,75 @@
1
+ // The backend side of the contract. A Service is transport-free: no sockets,
2
+ // no window handles, no seq numbers. Hosts (sweetipc-ws, sweetview) wrap one.
3
+
4
+ /** method name -> [params, result]. `void` params mean "no argument". */
5
+ export type MethodMap = Record<string, [params: unknown, result: unknown]>;
6
+ /** event name -> payload */
7
+ export type EventMap = Record<string, unknown>;
8
+
9
+ export type SessionKind = "webview" | "web";
10
+ export type Session = { id: string; kind: SessionKind };
11
+ export type Ctx = { session: Session };
12
+
13
+ /** One event as a discriminated union, so a listener can switch on `name` and get a typed `payload`. */
14
+ export type AnyEvent<E extends EventMap> = { [N in keyof E & string]: { name: N; payload: E[N] } }[keyof E & string];
15
+
16
+ /** Send an event to every session, or to one when `to` is given. */
17
+ export type Emit<E extends EventMap> = <N extends keyof E & string>(name: N, payload: E[N], to?: string) => void;
18
+
19
+ export interface Service<M extends MethodMap, E extends EventMap> {
20
+ call<K extends keyof M & string>(method: K, params: M[K][0], ctx: Ctx): Promise<M[K][1]>;
21
+ /** Subscribe to events; returns an unsubscribe function. */
22
+ onEvent(fn: (name: keyof E & string, payload: E[keyof E], to?: string) => void): () => void;
23
+ /** A client went away: drop anything held for that session. */
24
+ endSession(sessionId: string): void;
25
+ /** The process is shutting down. */
26
+ dispose(): Promise<void>;
27
+ }
28
+
29
+ export type Handlers<M extends MethodMap> = {
30
+ [K in keyof M & string]: (params: M[K][0], ctx: Ctx) => M[K][1] | Promise<M[K][1]>;
31
+ };
32
+
33
+ export type ServiceDefinition<M extends MethodMap> = {
34
+ methods: Handlers<M>;
35
+ endSession?: (sessionId: string) => void;
36
+ dispose?: () => void | Promise<void>;
37
+ };
38
+
39
+ /**
40
+ * Build a Service from a handler table. `setup` receives `emit` so handlers
41
+ * and background work can push events.
42
+ *
43
+ * export const createService = () => defineService<Methods, Events>((emit) => ({ methods: { ... } }));
44
+ */
45
+ export function defineService<M extends MethodMap, E extends EventMap>(setup: (emit: Emit<E>) => ServiceDefinition<M>): Service<M, E> {
46
+ type Listener = (name: keyof E & string, payload: E[keyof E], to?: string) => void;
47
+ const listeners = new Set<Listener>();
48
+ const emit: Emit<E> = (name, payload, to) => {
49
+ for (const fn of listeners) fn(name, payload, to);
50
+ };
51
+ const def = setup(emit);
52
+
53
+ return {
54
+ async call(method, params, ctx) {
55
+ // hasOwn: a name like "constructor" or "toString" must not resolve through Object.prototype.
56
+ const handler = Object.hasOwn(def.methods, method) ? (def.methods[method] as (p: unknown, c: Ctx) => unknown) : undefined;
57
+ if (!handler) throw new Error(`unknown method ${method}`);
58
+ return (await handler(params, ctx)) as never;
59
+ },
60
+ onEvent(fn) {
61
+ listeners.add(fn);
62
+ return () => listeners.delete(fn);
63
+ },
64
+ endSession(sessionId) {
65
+ def.endSession?.(sessionId);
66
+ },
67
+ async dispose() {
68
+ try {
69
+ await def.dispose?.();
70
+ } finally {
71
+ listeners.clear();
72
+ }
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,111 @@
1
+ // The page side: a sweetipc Transport over one WebSocket. Calls made before
2
+ // the socket opens are queued; when the socket closes, every pending call
3
+ // rejects and the transport stays closed (reload the page to reconnect).
4
+
5
+ import type { Frame, Transport } from "../ipc";
6
+
7
+ export type WsTransport = Transport & {
8
+ /** Resolves once the socket is open. */
9
+ readonly ready: Promise<void>;
10
+ /** Fires once, when the socket closes for any reason. */
11
+ onClose(fn: () => void): () => void;
12
+ close(): void;
13
+ };
14
+
15
+ /** `ws://` or `wss://` to match the page's own origin, with the given path. */
16
+ export function wsUrl(path = "/ws"): string {
17
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
18
+ return `${proto}//${location.host}${path}`;
19
+ }
20
+
21
+ export function wsTransport(url: string = wsUrl()): WsTransport {
22
+ const ws = new WebSocket(url);
23
+ const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
24
+ const eventListeners = new Set<(name: string, payload: unknown) => void>();
25
+ const closeListeners = new Set<() => void>();
26
+ let queue: string[] = [];
27
+ let seq = 0;
28
+
29
+ const ready = new Promise<void>((resolve, reject) => {
30
+ ws.addEventListener("open", () => {
31
+ for (const text of queue) ws.send(text);
32
+ queue = [];
33
+ resolve();
34
+ });
35
+ ws.addEventListener("close", () => {
36
+ reject(new Error("websocket closed"));
37
+ queue = [];
38
+ for (const p of pending.values()) p.reject(new Error("websocket closed"));
39
+ pending.clear();
40
+ for (const fn of closeListeners) fn();
41
+ closeListeners.clear();
42
+ });
43
+ });
44
+ ready.catch(() => {}); // the rejection is reported through pending calls and onClose
45
+
46
+ ws.addEventListener("message", (e: MessageEvent) => {
47
+ let parsed: unknown;
48
+ try {
49
+ parsed = JSON.parse(String(e.data));
50
+ } catch {
51
+ return;
52
+ }
53
+ if (typeof parsed !== "object" || parsed === null) return;
54
+ const frame = parsed as Frame;
55
+ if (frame.kind === "reply") {
56
+ const p = pending.get(frame.seq);
57
+ if (!p) return;
58
+ pending.delete(frame.seq);
59
+ if (frame.ok) p.resolve(frame.result);
60
+ else p.reject(new Error(frame.error));
61
+ } else if (frame.kind === "event") {
62
+ for (const fn of eventListeners) fn(frame.name, frame.payload);
63
+ }
64
+ });
65
+
66
+ const send = (frame: Frame) => {
67
+ const text = JSON.stringify(frame);
68
+ if (ws.readyState === WebSocket.OPEN) ws.send(text);
69
+ else if (ws.readyState === WebSocket.CONNECTING) queue.push(text);
70
+ else throw new Error("websocket closed");
71
+ };
72
+
73
+ return {
74
+ kind: "web",
75
+ ready,
76
+ call(method, params) {
77
+ return new Promise((resolve, reject) => {
78
+ const id = String(++seq);
79
+ pending.set(id, { resolve, reject });
80
+ try {
81
+ send({ kind: "call", seq: id, method, params });
82
+ } catch (err) {
83
+ pending.delete(id);
84
+ reject(err as Error);
85
+ }
86
+ });
87
+ },
88
+ notify(method, params) {
89
+ try {
90
+ send({ kind: "call", seq: null, method, params });
91
+ } catch (err) {
92
+ console.error(`${method} not sent:`, (err as Error).message);
93
+ }
94
+ },
95
+ onEvent(fn) {
96
+ eventListeners.add(fn);
97
+ return () => eventListeners.delete(fn);
98
+ },
99
+ onClose(fn) {
100
+ if (ws.readyState === WebSocket.CLOSED) {
101
+ fn();
102
+ return () => {};
103
+ }
104
+ closeListeners.add(fn);
105
+ return () => closeListeners.delete(fn);
106
+ },
107
+ close() {
108
+ ws.close();
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,5 @@
1
+ // Import "@n1h/sweetview/ipc-ws/server" from Bun code and "@n1h/sweetview/ipc-ws/client" from the
2
+ // page; this root re-exports both for convenience in code that is bundled
3
+ // for one side only.
4
+ export { wsHost, type WsData, type WsHost, type WsHostOptions } from "./server";
5
+ export { wsTransport, wsUrl, type WsTransport } from "./client";
@@ -0,0 +1,133 @@
1
+ // Hosts a Service behind Bun.serve's websocket handler. One session per
2
+ // socket; events are broadcast, or sent to one session when `to` is set.
3
+ //
4
+ // const host = wsHost(createService());
5
+ // Bun.serve({
6
+ // fetch(req, server) {
7
+ // if (new URL(req.url).pathname === "/ws") return host.upgrade(req, server) ? undefined : new Response("upgrade failed", { status: 400 });
8
+ // ...
9
+ // },
10
+ // websocket: host.websocket,
11
+ // });
12
+
13
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
14
+ import type { CallFrame, EventMap, Frame, MethodMap, ReplyFrame, Service, Session } from "../ipc";
15
+
16
+ export type WsData = { session: Session };
17
+
18
+ export type WsHostOptions = {
19
+ /**
20
+ * Which `Origin` values may connect. Browsers do not apply same-origin to
21
+ * WebSockets, so without this any page the user has open could talk to the
22
+ * service. Default: only the page's own origin (Origin host matches the
23
+ * request's Host). Pass a list of origins, or a predicate.
24
+ */
25
+ allowOrigin?: string[] | ((origin: string, req: Request) => boolean);
26
+ };
27
+
28
+ export type WsHost = {
29
+ /** Upgrade a request to a session; returns false when it was not a websocket request or its Origin is not allowed. */
30
+ upgrade(req: Request, server: Server<WsData>): boolean;
31
+ websocket: WebSocketHandler<WsData>;
32
+ /** Number of live sessions. */
33
+ readonly sessions: number;
34
+ /** Close every socket and stop forwarding events. Does not dispose the service. */
35
+ close(): void;
36
+ };
37
+
38
+ export function wsHost<M extends MethodMap, E extends EventMap>(service: Service<M, E>, options: WsHostOptions = {}): WsHost {
39
+ const sockets = new Map<string, ServerWebSocket<WsData>>();
40
+ let nextId = 1;
41
+
42
+ const originAllowed = (req: Request): boolean => {
43
+ const origin = req.headers.get("origin");
44
+ if (origin === null) return true; // not a browser
45
+ const { allowOrigin } = options;
46
+ if (typeof allowOrigin === "function") return allowOrigin(origin, req);
47
+ if (allowOrigin) return allowOrigin.includes(origin);
48
+ try {
49
+ return new URL(origin).host === req.headers.get("host");
50
+ } catch {
51
+ return false;
52
+ }
53
+ };
54
+
55
+ const send = (ws: ServerWebSocket<WsData>, frame: Frame) => {
56
+ let text: string;
57
+ try {
58
+ text = JSON.stringify(frame);
59
+ } catch (err) {
60
+ // a result or payload JSON cannot carry (a BigInt, a cycle): the page gets an error reply instead of silence
61
+ if (frame.kind === "reply") text = JSON.stringify({ kind: "reply", seq: frame.seq, ok: false, error: `${frame.seq}: the result is not serialisable: ${(err as Error).message}` } satisfies ReplyFrame);
62
+ else {
63
+ console.error(`${frame.kind === "event" ? `event ${frame.name}` : "frame"} is not serialisable:`, (err as Error).message);
64
+ return;
65
+ }
66
+ }
67
+ ws.send(text);
68
+ };
69
+
70
+ const off = service.onEvent((name, payload, to) => {
71
+ const frame: Frame = { kind: "event", name, payload };
72
+ if (to !== undefined) {
73
+ const ws = sockets.get(to);
74
+ if (ws) send(ws, frame);
75
+ return;
76
+ }
77
+ for (const ws of sockets.values()) send(ws, frame);
78
+ });
79
+
80
+ const websocket: WebSocketHandler<WsData> = {
81
+ open(ws) {
82
+ sockets.set(ws.data.session.id, ws);
83
+ },
84
+ async message(ws, raw) {
85
+ let frame: unknown;
86
+ try {
87
+ frame = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
88
+ } catch {
89
+ return; // not ours
90
+ }
91
+ // anything that is not a call frame is ignored, including `null` and other non-objects
92
+ if (typeof frame !== "object" || frame === null || (frame as Frame).kind !== "call") return;
93
+ const reply = await handle(frame as CallFrame, ws.data.session);
94
+ if (reply && sockets.get(ws.data.session.id) === ws) send(ws, reply);
95
+ },
96
+ close(ws) {
97
+ const id = ws.data.session.id;
98
+ if (sockets.get(id) === ws) sockets.delete(id);
99
+ service.endSession(id);
100
+ },
101
+ };
102
+
103
+ async function handle(frame: CallFrame, session: Session): Promise<ReplyFrame | null> {
104
+ try {
105
+ const result = await service.call(frame.method as keyof M & string, frame.params as never, { session });
106
+ return frame.seq === null ? null : { kind: "reply", seq: frame.seq, ok: true, result };
107
+ } catch (err) {
108
+ const error = err instanceof Error ? err.message : String(err);
109
+ if (frame.seq === null) {
110
+ console.error(`${frame.method} failed:`, error);
111
+ return null;
112
+ }
113
+ return { kind: "reply", seq: frame.seq, ok: false, error };
114
+ }
115
+ }
116
+
117
+ return {
118
+ upgrade(req, server) {
119
+ if (!originAllowed(req)) return false;
120
+ const session: Session = { id: `ws-${nextId++}`, kind: "web" };
121
+ return server.upgrade(req, { data: { session } });
122
+ },
123
+ websocket,
124
+ get sessions() {
125
+ return sockets.size;
126
+ },
127
+ close() {
128
+ off();
129
+ for (const ws of sockets.values()) ws.close();
130
+ sockets.clear();
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bun
2
+ // The macOS .app bundle around a compiled sweetview binary, from the command
3
+ // line. The same thing as a function is `makeMacApp` in "@n1h/sweetview/build".
4
+ // Runs on any OS: it only writes files.
5
+ //
6
+ // bun src/make-app.ts --name "My App" --bundle-id dev.example.my-app \
7
+ // --binary dist/my-app-macos-arm64 --out dist [--icns build/AppIcon.icns] \
8
+ // [--version 1.0.0] [--min-macos 11.0] [--plist LSUIElement=true]
9
+
10
+ import { parseArgs } from "node:util";
11
+ import { makeMacApp, type MacAppOptions } from "./build";
12
+
13
+ const USAGE = `usage: bun src/make-app.ts --name <name> --bundle-id <id> --binary <path> --out <dir>
14
+ [--icns <path>] [--version <x.y.z>] [--min-macos <x.y>] [--plist <Key=value>]...
15
+
16
+ --name what Finder and the Dock show; the bundle is <name>.app
17
+ --bundle-id reverse-DNS id (dev.example.my-app); macOS notifications need one
18
+ --binary the compiled macOS executable (bun build --compile --target=bun-darwin-arm64)
19
+ --out folder the bundle is written into; an existing <name>.app there is replaced
20
+ --icns the .icns for Finder and the Dock (sweetview/build writeIconFiles); stock icon without it
21
+ --version CFBundleVersion and CFBundleShortVersionString (default 0.1.0)
22
+ --min-macos LSMinimumSystemVersion (default 11.0)
23
+ --plist an extra Info.plist entry; true/false and numbers are typed, anything else is a string. Repeatable
24
+ `;
25
+
26
+ const SPEC = {
27
+ name: { type: "string" },
28
+ "bundle-id": { type: "string" },
29
+ binary: { type: "string" },
30
+ out: { type: "string" },
31
+ icns: { type: "string" },
32
+ version: { type: "string" },
33
+ "min-macos": { type: "string" },
34
+ plist: { type: "string", multiple: true },
35
+ help: { type: "boolean", short: "h" },
36
+ } as const;
37
+
38
+ function parse(argv: string[]) {
39
+ try {
40
+ return parseArgs({ args: argv, options: SPEC, strict: true }).values;
41
+ } catch (err) {
42
+ console.error((err as Error).message);
43
+ console.error(USAGE);
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ function main(argv: string[]): number {
49
+ const args = parse(argv);
50
+ if (args === undefined) return 2;
51
+ if (args.help) {
52
+ console.log(USAGE);
53
+ return 0;
54
+ }
55
+ const missing = (["name", "bundle-id", "binary", "out"] as const).filter((k) => args[k] === undefined || args[k] === "");
56
+ if (missing.length > 0) {
57
+ console.error(`missing: ${missing.map((k) => `--${k}`).join(", ")}`);
58
+ console.error(USAGE);
59
+ return 2;
60
+ }
61
+
62
+ const plist: Record<string, string | number | boolean> = {};
63
+ for (const entry of args.plist ?? []) {
64
+ const eq = entry.indexOf("=");
65
+ if (eq <= 0) {
66
+ console.error(`--plist wants Key=value, got ${JSON.stringify(entry)}`);
67
+ return 2;
68
+ }
69
+ const key = entry.slice(0, eq);
70
+ const raw = entry.slice(eq + 1);
71
+ plist[key] = raw === "true" ? true : raw === "false" ? false : raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw;
72
+ }
73
+
74
+ const options: MacAppOptions = {
75
+ name: args.name!,
76
+ bundleId: args["bundle-id"]!,
77
+ binary: args.binary!,
78
+ outDir: args.out!,
79
+ ...(args.icns !== undefined ? { icns: args.icns } : {}),
80
+ ...(args.version !== undefined ? { version: args.version } : {}),
81
+ ...(args["min-macos"] !== undefined ? { minimumSystemVersion: args["min-macos"] } : {}),
82
+ ...(Object.keys(plist).length > 0 ? { plist } : {}),
83
+ };
84
+ try {
85
+ console.log(`wrote ${makeMacApp(options)}`);
86
+ return 0;
87
+ } catch (err) {
88
+ console.error((err as Error).message);
89
+ return 1;
90
+ }
91
+ }
92
+
93
+ if (import.meta.main) process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,16 @@
1
+ // What crosses worker.postMessage, UI thread -> worker. The other direction
2
+ // (worker -> UI thread) is sweetipc frames in the ring.
3
+
4
+ import type { CallFrame } from "./ipc";
5
+
6
+ export type InitMessage = {
7
+ kind: "init";
8
+ ring: SharedArrayBuffer;
9
+ webviewHandle: number;
10
+ drainCallbackPtr: number;
11
+ libPath: string;
12
+ };
13
+
14
+ export type WorkerMessage = InitMessage | CallFrame;
15
+
16
+ export const WEBVIEW_SESSION = { id: "webview", kind: "webview" } as const;
@@ -0,0 +1,23 @@
1
+ // The application's own icon, as the OS shows the running app.
2
+ //
3
+ // macOS: the Dock tile and the Cmd+Tab switcher (NSApplication's
4
+ // applicationIconImage). Replaces what the bundle's CFBundleIconFile set, or
5
+ // the generic icon for a bare executable.
6
+ //
7
+ // Windows: nothing to set at run time. Explorer, the taskbar and Alt+Tab take
8
+ // the icon from the executable's resources (`bun build --compile
9
+ // --windows-icon=app.ico`) and from each window (`NativeWindow.setIcon`,
10
+ // `OverlayWindowOptions.icon`).
11
+
12
+ import type { Icon } from "./icon";
13
+ import { backend } from "./platform";
14
+
15
+ type Backend = { setAppIcon(icon: Icon): void };
16
+ const impl = backend<Backend>(
17
+ () => require("./win32/app-icon"),
18
+ () => require("./darwin/app-icon"),
19
+ );
20
+
21
+ export function setAppIcon(icon: Icon): void {
22
+ impl().setAppIcon(icon);
23
+ }
@@ -0,0 +1,18 @@
1
+ // The application's menu bar. macOS: an app menu (About, Quit) and an Edit
2
+ // menu, because webview creates NSApplication without one and Cmd+Q / Cmd+C /
3
+ // Cmd+V do nothing otherwise. Windows: nothing; the tray menu is the menu.
4
+
5
+ import { backend } from "./platform";
6
+
7
+ export type AppMenuOptions = { appName: string; aboutText: string };
8
+
9
+ type Backend = { installAppMenu(options: AppMenuOptions): void };
10
+ const impl = backend<Backend>(
11
+ () => require("./win32/app-menu"),
12
+ () => require("./darwin/app-menu"),
13
+ );
14
+
15
+ /** Call on the UI thread before the run loop starts. */
16
+ export function installAppMenu(options: AppMenuOptions): void {
17
+ impl().installAppMenu(options);
18
+ }
@@ -0,0 +1,10 @@
1
+ // Dock tile and Cmd+Tab icon: NSApplication.applicationIconImage. Full colour
2
+ // (not a template) at the PNG's own size; 512px or 1024px looks right in the Dock.
3
+
4
+ import type { Icon } from "../icon";
5
+ import { applyImage, makeImage } from "./image";
6
+ import { sharedApp } from "./objc";
7
+
8
+ export function setAppIcon(icon: Icon): void {
9
+ applyImage(sharedApp(), "setApplicationIconImage:", makeImage(icon.macos, undefined, false));
10
+ }
@@ -0,0 +1,88 @@
1
+ // macOS main menu. webview creates an NSApplication with no mainMenu, so there
2
+ // is no app menu, no Quit, and Cmd+Q / Cmd+C / Cmd+V do nothing. Build the
3
+ // standard minimum: app menu (About…, Quit) and an Edit menu so text editing
4
+ // shortcuts reach the webview. Call on the main thread before webview.run().
5
+
6
+ import type { AppMenuOptions } from "../app-menu";
7
+ import {
8
+ NIL,
9
+ type Id,
10
+ alloc,
11
+ allocRaw,
12
+ asId,
13
+ cls,
14
+ makeTarget,
15
+ nsString,
16
+ release,
17
+ sel,
18
+ send,
19
+ sendInt,
20
+ sendIntRetBool,
21
+ sendPtr,
22
+ sendPtrSelPtr,
23
+ sharedApp,
24
+ } from "./objc";
25
+
26
+ const NSApplicationActivationPolicyRegular = 0n;
27
+
28
+ type Item = [title: string, action: string, key: string, target?: Id] | "separator";
29
+
30
+ function menuItem(title: string, action: string, key: string, target?: Id): Id {
31
+ const it = allocRaw("NSMenuItem");
32
+ const inited = sendPtrSelPtr(it, sel("initWithTitle:action:keyEquivalent:"), nsString(title), sel(action), nsString(key));
33
+ const id = inited === null ? NIL : BigInt(inited);
34
+ if (target !== undefined) sendPtr(id, sel("setTarget:"), target);
35
+ return id;
36
+ }
37
+
38
+ function submenu(parent: Id, title: string, items: Item[]) {
39
+ const holder = alloc("NSMenuItem");
40
+ sendPtr(parent, sel("addItem:"), holder);
41
+ const menu = asId(sendPtr(allocRaw("NSMenu"), sel("initWithTitle:"), nsString(title)));
42
+ for (const it of items) {
43
+ if (it === "separator") {
44
+ sendPtr(menu, sel("addItem:"), asId(send(cls("NSMenuItem"), sel("separatorItem"))));
45
+ continue;
46
+ }
47
+ const item = menuItem(...it);
48
+ sendPtr(menu, sel("addItem:"), item);
49
+ release(item); // the menu retains it
50
+ }
51
+ sendPtr(holder, sel("setSubmenu:"), menu);
52
+ release(menu); // the holder retains it
53
+ release(holder); // the parent retains it
54
+ }
55
+
56
+ function showAbout(appName: string, text: string) {
57
+ const alert = alloc("NSAlert");
58
+ sendPtr(alert, sel("setMessageText:"), nsString(`About ${appName}`));
59
+ sendPtr(alert, sel("setInformativeText:"), nsString(text));
60
+ sendPtr(alert, sel("addButtonWithTitle:"), nsString("OK"));
61
+ sendInt(alert, sel("runModal"));
62
+ release(alert);
63
+ }
64
+
65
+ export function installAppMenu(opts: AppMenuOptions) {
66
+ const app = sharedApp();
67
+ sendIntRetBool(app, sel("setActivationPolicy:"), NSApplicationActivationPolicyRegular);
68
+
69
+ const target = makeTarget("SweetviewAppMenuTarget", {
70
+ "showAbout:": () => showAbout(opts.appName, opts.aboutText),
71
+ });
72
+
73
+ const menubar = alloc("NSMenu");
74
+ submenu(menubar, opts.appName, [
75
+ [`About ${opts.appName}…`, "showAbout:", "", target],
76
+ "separator",
77
+ [`Quit ${opts.appName}`, "terminate:", "q"],
78
+ ]);
79
+ submenu(menubar, "Edit", [
80
+ ["Undo", "undo:", "z"],
81
+ ["Redo", "redo:", "Z"],
82
+ ["Cut", "cut:", "x"],
83
+ ["Copy", "copy:", "c"],
84
+ ["Paste", "paste:", "v"],
85
+ ["Select All", "selectAll:", "a"],
86
+ ]);
87
+ sendPtr(app, sel("setMainMenu:"), menubar);
88
+ }
@@ -0,0 +1,29 @@
1
+ // Native macOS folder picker: NSOpenPanel through the Objective-C runtime via
2
+ // bun:ffi. No child process. Must run on the main thread (AppKit rule), which
3
+ // the webview UI thread is. See objc.ts for the id-as-bigint rule.
4
+
5
+ import type { WindowHandle } from "../window";
6
+ import { NIL, asId, cls, jsString, nsString, sel, send, sendBool, sendInt, sendPtr, sharedApp } from "./objc";
7
+
8
+ const NSModalResponseOK = 1n;
9
+
10
+ /** Opens NSOpenPanel in folder mode (app-modal; `owner` is not used). Returns the POSIX path, or null if cancelled. */
11
+ export function pickFolder(_owner: WindowHandle, title: string): string | null {
12
+ sendBool(sharedApp(), sel("activateIgnoringOtherApps:"), true);
13
+
14
+ const panel = asId(send(cls("NSOpenPanel"), sel("openPanel")));
15
+ if (panel === NIL) throw new Error("NSOpenPanel.openPanel returned nil");
16
+ sendBool(panel, sel("setCanChooseDirectories:"), true);
17
+ sendBool(panel, sel("setCanChooseFiles:"), false);
18
+ sendBool(panel, sel("setAllowsMultipleSelection:"), false);
19
+ sendBool(panel, sel("setCanCreateDirectories:"), true);
20
+ sendPtr(panel, sel("setMessage:"), nsString(title));
21
+ sendPtr(panel, sel("setPrompt:"), nsString("Choose"));
22
+
23
+ if (BigInt(sendInt(panel, sel("runModal")) ?? 0) !== NSModalResponseOK) return null;
24
+
25
+ const url = asId(send(asId(send(panel, sel("URLs"))), sel("firstObject")));
26
+ if (url === NIL) return null;
27
+ const path = asId(send(url, sel("path")));
28
+ return path === NIL ? null : jsString(path);
29
+ }