mirinjs 0.0.1-alpha.9 → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirinjs",
3
- "version": "0.0.1-alpha.9",
3
+ "version": "0.1.0-alpha.0",
4
4
  "description": "Build desktop apps with Bun, TypeScript, and Chromium (CEF). The Bun-native Electron alternative.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "./config": "./src/config.ts",
27
27
  "./rpc": "./src/rpc.ts",
28
28
  "./client": "./src/client.ts",
29
+ "./codec": "./src/codec.ts",
29
30
  "./host": "./src/host.ts"
30
31
  },
31
32
  "files": [
package/src/app.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  import { runtime, onNativeEvent, resolveUrl, type NativeEvent } from "./runtime.ts";
7
7
  import type { Router, EventProc } from "./rpc.ts";
8
8
  import type { WindowConfig, WindowMaterial, WindowMaterialOptions } from "./config.ts";
9
+ import { updater } from "./updater.ts";
10
+ import type { Updater } from "./updater.ts";
11
+ import { sidecar as spawnSidecar } from "./sidecar.ts";
12
+ import type { SidecarOptions, SidecarProcess } from "./sidecar.ts";
9
13
 
10
14
  /** Which native backend a window's `material` resolved to. */
11
15
  export type WindowMaterialInfo = {
@@ -30,6 +34,9 @@ export type WindowEvents = {
30
34
  export type AppEvents = {
31
35
  ready: void;
32
36
  "window-all-closed": void;
37
+ /** A deep-link URL (a registered `urlSchemes` scheme) opened the app, or
38
+ * arrived while it was running. Includes the launch URL. */
39
+ "open-url": string;
33
40
  };
34
41
 
35
42
  /** A window's frame in screen points (bottom-left origin, like AppKit). */
@@ -279,6 +286,9 @@ export interface Dock {
279
286
  class MirinApp extends Emitter<AppEvents> {
280
287
  readonly windows = new Windows();
281
288
 
289
+ /** Auto-updater (macOS). Inert unless the app was built with `release` set. */
290
+ readonly updater: Updater = updater;
291
+
282
292
  /** macOS Dock-icon controls. Hiding suits resident, hotkey-summoned apps. */
283
293
  readonly dock: Dock = {
284
294
  hide: () => this.#setDock(false),
@@ -308,6 +318,14 @@ class MirinApp extends Emitter<AppEvents> {
308
318
  return rpcEmitters((method, payload) => runtime().rpc.broadcast(method, payload));
309
319
  }
310
320
 
321
+ /**
322
+ * Spawn a bundled sidecar binary (declared in `mirin.config.ts` `sidecars`).
323
+ * Resolves the in-bundle path and tracks the child so it's killed on quit.
324
+ */
325
+ sidecar(name: string, opts?: SidecarOptions): SidecarProcess {
326
+ return spawnSidecar(name, opts);
327
+ }
328
+
311
329
  quit(): void {
312
330
  runtime().core.quit();
313
331
  }
@@ -342,6 +360,11 @@ export function wireAppEvents(): void {
342
360
 
343
361
  onNativeEvent("window.all-closed", () => app._emit("window-all-closed", undefined));
344
362
 
363
+ onNativeEvent("app.open-url", (event: NativeEvent) => {
364
+ const url = event.url;
365
+ if (typeof url === "string") app._emit("open-url", url);
366
+ });
367
+
345
368
  onNativeEvent("window.material", (event: NativeEvent) => {
346
369
  const id = event.id as number | undefined;
347
370
  if (id == null) return;
package/src/client.ts CHANGED
@@ -23,6 +23,8 @@ export type ClientFor<R extends Router<any>> =
23
23
  interface MirinTransport {
24
24
  call(method: string, input: unknown): Promise<unknown>;
25
25
  onEvent(method: string, listener: (payload: unknown) => void): () => void;
26
+ /** Fire-and-forget internal control action (window dragging/controls). */
27
+ control?(action: string, extra?: Record<string, unknown>): void;
26
28
  }
27
29
 
28
30
  declare global {
@@ -65,3 +67,21 @@ export function client<R extends Router<any>>(): ClientFor<R> {
65
67
  },
66
68
  });
67
69
  }
70
+
71
+ /**
72
+ * Window controls for the current webview's window, callable from page code —
73
+ * for custom title bars (`titleBarStyle: hidden | hiddenInset`) where the app
74
+ * draws its own min/max/close buttons. On Windows these are the only controls
75
+ * (no native caption); on macOS the native traffic lights usually serve instead.
76
+ */
77
+ export const windowControls = {
78
+ minimize(): void {
79
+ transport().control?.("window.control", { verb: "minimize" });
80
+ },
81
+ maximize(): void {
82
+ transport().control?.("window.control", { verb: "maximize" });
83
+ },
84
+ close(): void {
85
+ transport().control?.("window.close");
86
+ },
87
+ };
package/src/codec.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * mirin/codec — thin bun:ffi bindings to libmirin_core's updater codec (zstd +
3
+ * bsdiff, file-path API). Used both at `mirin release` time (produce patches)
4
+ * and at runtime (apply them), loading the same dylib so compress/diff are
5
+ * byte-identical on both sides.
6
+ */
7
+
8
+ import { dlopen, FFIType, ptr } from "bun:ffi";
9
+
10
+ const SYMBOLS = {
11
+ mirin_zstd_compress_file: { args: [FFIType.ptr, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
12
+ mirin_zstd_decompress_file: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
13
+ mirin_bsdiff_file: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
14
+ mirin_bspatch_file: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
15
+ } as const;
16
+
17
+ function cstr(s: string): Uint8Array {
18
+ return new TextEncoder().encode(s + "\0");
19
+ }
20
+
21
+ export interface Codec {
22
+ /** zstd-compress `src` → `dst` at `level` (default 19). */
23
+ compress(src: string, dst: string, level?: number): void;
24
+ /** zstd-decompress `src` → `dst`. */
25
+ decompress(src: string, dst: string): void;
26
+ /** bsdiff: write a patch turning `oldPath` into `newPath`. */
27
+ diff(oldPath: string, newPath: string, patchPath: string): void;
28
+ /** bspatch: apply `patchPath` to `oldPath`, producing `newPath`. */
29
+ patch(oldPath: string, patchPath: string, newPath: string): void;
30
+ }
31
+
32
+ /** Load the codec from a libmirin_core dylib path. */
33
+ export function loadCodec(dylibPath: string): Codec {
34
+ const { symbols } = dlopen(dylibPath, SYMBOLS);
35
+ const check = (rc: number, op: string) => {
36
+ if (rc !== 0) throw new Error(`codec ${op} failed (rc=${rc})`);
37
+ };
38
+ return {
39
+ compress(src, dst, level = 19) {
40
+ const s = cstr(src);
41
+ const d = cstr(dst);
42
+ check(symbols.mirin_zstd_compress_file(ptr(s), ptr(d), level), "compress");
43
+ },
44
+ decompress(src, dst) {
45
+ const s = cstr(src);
46
+ const d = cstr(dst);
47
+ check(symbols.mirin_zstd_decompress_file(ptr(s), ptr(d)), "decompress");
48
+ },
49
+ diff(oldPath, newPath, patchPath) {
50
+ const o = cstr(oldPath);
51
+ const n = cstr(newPath);
52
+ const p = cstr(patchPath);
53
+ check(symbols.mirin_bsdiff_file(ptr(o), ptr(n), ptr(p)), "diff");
54
+ },
55
+ patch(oldPath, patchPath, newPath) {
56
+ const o = cstr(oldPath);
57
+ const p = cstr(patchPath);
58
+ const n = cstr(newPath);
59
+ check(symbols.mirin_bspatch_file(ptr(o), ptr(p), ptr(n)), "patch");
60
+ },
61
+ };
62
+ }
package/src/config.ts CHANGED
@@ -51,6 +51,13 @@ export interface WindowConfig {
51
51
  open?: "auto" | "manual";
52
52
  /** Custom title bar: hide it (content fills) or inset the traffic lights. */
53
53
  titleBarStyle?: "hidden" | "hiddenInset";
54
+ /**
55
+ * Reposition the macOS traffic-light buttons for a custom title bar.
56
+ * `x` insets the leftmost button from the left edge; `y` sets the effective
57
+ * title-bar height the buttons are vertically centered in (so `y` ≈ your CSS
58
+ * title-bar height minus the button height). Re-applied automatically on resize.
59
+ */
60
+ trafficLightPosition?: { x: number; y: number };
54
61
  /** Non-opaque window (for transparent/blurred UIs). */
55
62
  transparent?: boolean;
56
63
  /**
@@ -79,9 +86,103 @@ export interface MirinConfig {
79
86
  * into an `.icns`. Embedded in the bundle for the Dock and Finder.
80
87
  */
81
88
  icon?: string;
89
+ /**
90
+ * Auto-update configuration (macOS). Omit to disable updates entirely.
91
+ * `baseUrl` is a flat directory of release artifacts (a GitHub Releases
92
+ * `…/releases/latest/download` URL, an S3/R2 bucket, or any static host).
93
+ * `channel` (default "stable") is baked into artifact filenames + the app's
94
+ * support folder, so multiple channels can coexist at the same `baseUrl`.
95
+ */
96
+ release?: ReleaseConfig;
97
+ /**
98
+ * macOS `.dmg` installer produced by `mirin release` (alongside the updater
99
+ * artifacts). `true`/omitted builds a default drag-to-Applications DMG; an
100
+ * object customizes it; `false` disables it. The DMG is codesigned and (when
101
+ * notary credentials are set) notarized + stapled like the `.app`.
102
+ */
103
+ dmg?: boolean | DmgConfig;
104
+ /**
105
+ * External binaries bundled into the `.app` and spawned at runtime with
106
+ * `app.sidecar(name)`. Maps a logical name to a path (relative to the project
107
+ * root) or a {@link SidecarSpec}. Each binary is copied into
108
+ * `Contents/Resources/sidecars/<name>`, codesigned (hardened runtime), and —
109
+ * when notary credentials are set — notarized with the rest of the app.
110
+ *
111
+ * Prefer a binary already on the user's PATH (`Bun.spawn("git", …)`) or a
112
+ * download-on-first-run when size/licensing matter; bundle only when you need
113
+ * a pinned version offline.
114
+ */
115
+ sidecars?: Record<string, string | SidecarSpec>;
116
+ /**
117
+ * Extra Bun Worker entry files (relative to the project root) bundled next to
118
+ * the main worker. Resolve one at runtime with `resolveWorker(name)` and pass
119
+ * it to `new Worker(...)` from `node:worker_threads`. These run off the main
120
+ * thread for CPU/IO offload and must NOT call window/native APIs — only the
121
+ * app worker owns AppKit/CEF (see docs/architecture.md).
122
+ */
123
+ workers?: Record<string, string>;
124
+ /**
125
+ * Custom URL schemes this app registers as a handler for (macOS
126
+ * `CFBundleURLTypes`), e.g. `["anko"]` so `anko://…` links launch the app (or
127
+ * focus it if already running). Incoming URLs are delivered to
128
+ * `app.on("open-url", (url) => …)` — including the URL the app was launched
129
+ * with. Scheme names should be lowercase letters/digits (no `://`).
130
+ */
131
+ urlSchemes?: string[];
82
132
  windows: Record<string, WindowConfig>;
83
133
  }
84
134
 
135
+ /** A bundled sidecar binary with optional hardened-runtime entitlements. */
136
+ export interface SidecarSpec {
137
+ /** Path to the binary, relative to the project root. */
138
+ bin: string;
139
+ /**
140
+ * Extra codesign entitlements for this binary under the hardened runtime.
141
+ * Most plain CLIs need none. Use `disable-library-validation` for a binary
142
+ * that loads dylibs not signed by your Team ID, or the JIT entitlements for a
143
+ * binary that runs its own JIT (another Bun/V8/Node).
144
+ */
145
+ entitlements?: SidecarEntitlement[];
146
+ }
147
+
148
+ export type SidecarEntitlement =
149
+ | "allow-jit"
150
+ | "allow-unsigned-executable-memory"
151
+ | "disable-library-validation";
152
+
153
+ export interface DmgConfig {
154
+ /** Volume name shown when the DMG is mounted. Default: the app name. */
155
+ volumeName?: string;
156
+ /**
157
+ * Disk-image format. `ULFO` (lzfse, default) compresses large CEF frameworks
158
+ * best on modern macOS; `UDZO` (zlib) is the most broadly compatible; `UDBZ`
159
+ * (bzip2) is smallest but slowest.
160
+ */
161
+ format?: "ULFO" | "UDZO" | "UDBZ";
162
+ /**
163
+ * Background image for the mounted Finder window (path relative to the project
164
+ * root). Setting this — or any position/size below — switches to the laid-out
165
+ * DMG (Finder window styled via AppleScript); best-effort, falling back to a
166
+ * plain DMG if Finder automation is unavailable.
167
+ */
168
+ background?: string;
169
+ /** Mounted Finder window size, in points. Default 640×400. */
170
+ windowSize?: { width: number; height: number };
171
+ /** Icon size in the mounted window, in points. Default 128. */
172
+ iconSize?: number;
173
+ /** App icon position in the window. Default centered-left. */
174
+ appPosition?: { x: number; y: number };
175
+ /** `/Applications` symlink position. Default centered-right. */
176
+ applicationsPosition?: { x: number; y: number };
177
+ }
178
+
179
+ export interface ReleaseConfig {
180
+ /** Flat directory URL hosting `{channel}-{platform}-{arch}-*` update files. */
181
+ baseUrl: string;
182
+ /** Update channel; baked into artifact names + support dir. Default "stable". */
183
+ channel?: string;
184
+ }
185
+
85
186
  /** Identity function for typing/intellisense. `const` generic preserves window names as literal keys. */
86
187
  export function defineConfig<const T extends MirinConfig>(config: T): T {
87
188
  return config;
package/src/host.ts CHANGED
@@ -19,11 +19,21 @@ import { dirname, join } from "node:path";
19
19
  import { existsSync, readFileSync } from "node:fs";
20
20
  import { Core } from "./native.ts";
21
21
 
22
- const exeDir = dirname(process.execPath); // Contents/MacOS
23
- const resourcesDir = join(exeDir, "..", "Resources");
22
+ // Bundle layout differs by platform: macOS `.app` puts the host in
23
+ // `Contents/MacOS` with resources in `../Resources`; Windows is a flat app folder
24
+ // where the host exe sits beside `mirin_core.dll` with resources in `resources/`.
25
+ const isWindows = process.platform === "win32";
26
+ const exeDir = dirname(process.execPath);
27
+ const resourcesDir = isWindows ? join(exeDir, "resources") : join(exeDir, "..", "Resources");
24
28
 
25
- const corePath = process.env.MIRIN_CORE ?? join(exeDir, "libmirin_core.dylib");
29
+ const corePath =
30
+ process.env.MIRIN_CORE ??
31
+ join(exeDir, isWindows ? "mirin_core.dll" : "libmirin_core.dylib");
26
32
  const workerPath = process.env.MIRIN_WORKER ?? join(resourcesDir, "worker.js");
33
+ // Bundled-asset dirs for sidecars (Bun.spawn binaries) and extra workers. Dev
34
+ // overrides via env (dev.ts stages them under .mirin); prod resolves in-bundle.
35
+ const sidecarDir = process.env.MIRIN_SIDECAR_DIR ?? join(resourcesDir, "sidecars");
36
+ const workersDir = process.env.MIRIN_WORKERS_DIR ?? join(resourcesDir, "workers");
27
37
 
28
38
  if (!existsSync(corePath) || !existsSync(workerPath)) {
29
39
  console.error(`[mirin host] missing core (${corePath}) or worker (${workerPath})`);
@@ -48,7 +58,14 @@ const coreConfig = JSON.parse(
48
58
  const core = new Core(corePath);
49
59
 
50
60
  const worker = new Worker(workerPath, {
51
- workerData: { corePath, manifest, devUrl: process.env.MIRIN_DEV_URL },
61
+ workerData: {
62
+ corePath,
63
+ manifest,
64
+ devUrl: process.env.MIRIN_DEV_URL,
65
+ resourcesDir,
66
+ sidecarDir,
67
+ workersDir,
68
+ },
52
69
  });
53
70
  worker.on("error", (err) => console.error("[mirin worker]", err));
54
71
 
package/src/index.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Importing this module boots the runtime (loads libmirin_core, starts the RPC
5
5
  * server, begins draining native events) and exposes the developer-facing API:
6
- * the `app` singleton plus the `menu`, `Tray`, `dialog`, `clipboard`, and
7
- * `globalShortcut` features (docs/api-design.md).
6
+ * the `app` singleton plus the `menu`, `Tray`, `dialog`, `clipboard`,
7
+ * `globalShortcut`, and `logger` features (docs/api-design.md).
8
8
  */
9
9
 
10
10
  import { app, wireAppEvents } from "./app.ts";
@@ -22,7 +22,10 @@ export { Tray } from "./tray.ts";
22
22
  export { dialog } from "./dialog.ts";
23
23
  export { clipboard } from "./clipboard.ts";
24
24
  export { globalShortcut } from "./shortcut.ts";
25
- export { NotAttachedError } from "./runtime.ts";
25
+ export { logger, setLogLevel, getLogLevel, Logger } from "./logger.ts";
26
+ export { updater, Updater } from "./updater.ts";
27
+ export { sidecar } from "./sidecar.ts";
28
+ export { NotAttachedError, resolveWorker } from "./runtime.ts";
26
29
 
27
30
  export type {
28
31
  WindowEvents,
@@ -36,6 +39,9 @@ export type {
36
39
  Dock,
37
40
  } from "./app.ts";
38
41
  export type { MenuItemTemplate, MenuRole } from "./menu.ts";
42
+ export type { LogLevel } from "./logger.ts";
43
+ export type { UpdaterStatus, UpdateInfo, UpdateProgress, UpdaterEvents } from "./updater.ts";
44
+ export type { SidecarOptions, SidecarProcess } from "./sidecar.ts";
39
45
  export type { TrayOptions } from "./tray.ts";
40
46
  export type { OpenDialogOptions, SaveDialogOptions, MessageDialogOptions } from "./dialog.ts";
41
47
  export type {
@@ -43,6 +49,10 @@ export type {
43
49
  WindowConfig,
44
50
  WindowMaterial,
45
51
  WindowMaterialOptions,
52
+ ReleaseConfig,
53
+ DmgConfig,
54
+ SidecarSpec,
55
+ SidecarEntitlement,
46
56
  } from "./config.ts";
47
57
 
48
58
  wireAppEvents();
package/src/logger.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * mirin/logger — a small leveled logger for the Bun main process.
3
+ *
4
+ * Exported from `mirinjs` so app code and mirin itself log consistently. The
5
+ * level defaults from the `MIRIN_LOG` env var (`debug` | `info` | `warn` |
6
+ * `error` | `silent`), falling back to `info`; change it at runtime with
7
+ * `logger.setLevel(...)`. `warn`/`error` go to stderr, `debug`/`info` to stdout.
8
+ *
9
+ * import { logger } from "mirinjs";
10
+ * logger.info("server listening", port);
11
+ * const db = logger.child("db");
12
+ * db.debug("query", sql); // → [mirin:db] debug query …
13
+ */
14
+
15
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
16
+
17
+ const ORDER: Record<LogLevel, number> = {
18
+ debug: 10,
19
+ info: 20,
20
+ warn: 30,
21
+ error: 40,
22
+ silent: 100,
23
+ };
24
+
25
+ const COLOR = {
26
+ debug: "\x1b[2m", // dim
27
+ info: "\x1b[36m", // cyan
28
+ warn: "\x1b[33m", // yellow
29
+ error: "\x1b[31m", // red
30
+ dim: "\x1b[2m",
31
+ reset: "\x1b[0m",
32
+ } as const;
33
+
34
+ const LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "silent"];
35
+
36
+ function envLevel(): LogLevel {
37
+ const v = (process.env.MIRIN_LOG ?? "").toLowerCase() as LogLevel;
38
+ return LEVELS.includes(v) ? v : "info";
39
+ }
40
+
41
+ // Shared across every logger instance so `setLevel` is global.
42
+ let currentLevel: LogLevel = envLevel();
43
+ const useColor = Boolean(process.stderr.isTTY) && process.env.NO_COLOR == null;
44
+
45
+ /** Set the global minimum level. Messages below it are dropped. */
46
+ export function setLogLevel(level: LogLevel): void {
47
+ currentLevel = level;
48
+ }
49
+
50
+ /** The current global log level. */
51
+ export function getLogLevel(): LogLevel {
52
+ return currentLevel;
53
+ }
54
+
55
+ export class Logger {
56
+ /** @param scope optional dotted scope shown as `[mirin:scope]`. */
57
+ constructor(private readonly scope?: string) {}
58
+
59
+ /** Derive a scoped child logger (e.g. `logger.child("db")`). */
60
+ child(scope: string): Logger {
61
+ return new Logger(this.scope ? `${this.scope}:${scope}` : scope);
62
+ }
63
+
64
+ /** Set the global level (same as the exported `setLogLevel`). */
65
+ setLevel(level: LogLevel): void {
66
+ setLogLevel(level);
67
+ }
68
+
69
+ get level(): LogLevel {
70
+ return currentLevel;
71
+ }
72
+
73
+ debug(...args: unknown[]): void {
74
+ this.#emit("debug", args);
75
+ }
76
+ info(...args: unknown[]): void {
77
+ this.#emit("info", args);
78
+ }
79
+ warn(...args: unknown[]): void {
80
+ this.#emit("warn", args);
81
+ }
82
+ error(...args: unknown[]): void {
83
+ this.#emit("error", args);
84
+ }
85
+
86
+ #emit(level: Exclude<LogLevel, "silent">, args: unknown[]): void {
87
+ if (ORDER[level] < ORDER[currentLevel]) return;
88
+ const name = this.scope ? `[mirin:${this.scope}]` : "[mirin]";
89
+ const prefix = useColor ? `${COLOR.dim}${name}${COLOR.reset}` : name;
90
+ const tag = useColor ? `${COLOR[level]}${level}${COLOR.reset}` : level;
91
+ const sink = level === "warn" || level === "error" ? console.error : console.log;
92
+ sink(`${prefix} ${tag}`, ...args);
93
+ }
94
+ }
95
+
96
+ /** The default mirin logger. Import and use directly, or derive children. */
97
+ export const logger = new Logger();
package/src/native.ts CHANGED
@@ -24,6 +24,7 @@ const symbols = {
24
24
  mirin_window_control: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
25
25
  mirin_window_set_material: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
26
26
  mirin_window_set_position: { args: [FFIType.u32, FFIType.f64, FFIType.f64], returns: FFIType.void },
27
+ mirin_window_maybe_start_drag: { args: [FFIType.u32, FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.void },
27
28
  mirin_app_quit: { args: [], returns: FFIType.void },
28
29
  mirin_app_set_dock_visible: { args: [FFIType.i32], returns: FFIType.void },
29
30
  mirin_set_app_menu: { args: [FFIType.ptr], returns: FFIType.void },
@@ -113,6 +114,10 @@ export class Core {
113
114
  this.#lib.symbols.mirin_window_set_position(id, x, y);
114
115
  }
115
116
 
117
+ windowMaybeStartDrag(id: number, x: number, y: number, detail: number): void {
118
+ this.#lib.symbols.mirin_window_maybe_start_drag(id, x, y, detail);
119
+ }
120
+
116
121
  quit(): void {
117
122
  this.#lib.symbols.mirin_app_quit();
118
123
  }
package/src/rpc-server.ts CHANGED
@@ -18,10 +18,29 @@ interface RequestFrame {
18
18
  input: unknown;
19
19
  }
20
20
 
21
+ /** Internal control frame from the preload bootstrap (window dragging, etc.) —
22
+ * a side channel distinct from the app's typed RPC. `x`/`y` are viewport pixels
23
+ * (CSS px, top-left) for pointer-driven actions like drag detection. */
24
+ interface ControlFrame {
25
+ kind: "control";
26
+ action: string;
27
+ x?: number;
28
+ y?: number;
29
+ verb?: string;
30
+ /** Click count for pointer actions (2 = double-click → maximize). */
31
+ detail?: number;
32
+ }
33
+
34
+ type IncomingFrame = RequestFrame | ControlFrame;
35
+
36
+ /** Handles an internal control action for the webview that sent it. */
37
+ export type ControlHandler = (frame: ControlFrame, webview: number) => void;
38
+
21
39
  export class RpcServer {
22
40
  readonly token = crypto.randomUUID();
23
41
  #server?: ReturnType<typeof Bun.serve>;
24
42
  #router?: Router<any>;
43
+ #control?: ControlHandler;
25
44
  #sockets = new Set<ServerWebSocket<SocketData>>();
26
45
 
27
46
  /** Start listening on an ephemeral loopback port; returns the bound port. */
@@ -65,6 +84,11 @@ export class RpcServer {
65
84
  this.#router = router;
66
85
  }
67
86
 
87
+ /** Register the handler for internal `mirin:*` control actions (e.g. window drag). */
88
+ setControlHandler(handler: ControlHandler): void {
89
+ this.#control = handler;
90
+ }
91
+
68
92
  /** Push an event to every connected webview. */
69
93
  broadcast(method: string, payload: unknown): void {
70
94
  const frame = JSON.stringify({ kind: "event", method, payload });
@@ -80,12 +104,17 @@ export class RpcServer {
80
104
  }
81
105
 
82
106
  async #onMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer): Promise<void> {
83
- let frame: RequestFrame;
107
+ let frame: IncomingFrame;
84
108
  try {
85
109
  frame = JSON.parse(typeof raw === "string" ? raw : raw.toString());
86
110
  } catch {
87
111
  return;
88
112
  }
113
+ // Internal control side channel (window drag, …) from the preload bootstrap.
114
+ if (frame.kind === "control") {
115
+ this.#control?.(frame, ws.data.webview);
116
+ return;
117
+ }
89
118
  if (frame.kind !== "request") return;
90
119
 
91
120
  const reply = (ok: boolean, body: { result?: unknown; error?: string }) =>
package/src/runtime.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { workerData } from "node:worker_threads";
8
+ import { join } from "node:path";
8
9
  import { Core } from "./native.ts";
9
10
  import { RpcServer } from "./rpc-server.ts";
10
11
  import type { WindowConfig } from "./config.ts";
@@ -18,6 +19,14 @@ export interface Runtime {
18
19
  rpc: RpcServer;
19
20
  manifestWindows: ManifestWindowConfig[];
20
21
  devUrl?: string;
22
+ /** Contents/Resources of the running .app (for the updater). Absent in dev. */
23
+ resourcesDir?: string;
24
+ /** Path to libmirin_core (for the updater codec). */
25
+ corePath?: string;
26
+ /** Dir holding bundled sidecar binaries (for `app.sidecar`). */
27
+ sidecarDir?: string;
28
+ /** Dir holding bundled extra-worker JS (for `resolveWorker`). */
29
+ workersDir?: string;
21
30
  }
22
31
 
23
32
  export class NotAttachedError extends Error {
@@ -79,6 +88,9 @@ export function boot(): void {
79
88
  corePath?: string;
80
89
  manifest?: { windows?: Record<string, WindowConfig> };
81
90
  devUrl?: string;
91
+ resourcesDir?: string;
92
+ sidecarDir?: string;
93
+ workersDir?: string;
82
94
  };
83
95
  const corePath = data.corePath ?? process.env.MIRIN_CORE;
84
96
  if (!corePath) return; // not under the host; the API stays detached
@@ -88,10 +100,49 @@ export function boot(): void {
88
100
  const port = rpc.start();
89
101
  core.setRpcEndpoint(port, rpc.token);
90
102
 
103
+ // Internal control actions from the preload bootstrap. `window.maybeStartDrag`
104
+ // forwards a left-mousedown's viewport coords; native checks them against the
105
+ // window's drag regions and begins an OS window-move if it's a title-bar drag
106
+ // area (the webview consumes the event, so dragging is driven from the renderer).
107
+ rpc.setControlHandler((frame, webview) => {
108
+ switch (frame.action) {
109
+ case "window.maybeStartDrag":
110
+ core.windowMaybeStartDrag(webview, frame.x ?? 0, frame.y ?? 0, frame.detail ?? 1);
111
+ break;
112
+ case "window.control":
113
+ if (frame.verb) core.windowControl(webview, frame.verb);
114
+ break;
115
+ case "window.close":
116
+ core.windowClose(webview);
117
+ break;
118
+ }
119
+ });
120
+
91
121
  const manifestWindows: ManifestWindowConfig[] = Object.entries(
92
122
  data.manifest?.windows ?? {},
93
123
  ).map(([name, cfg]) => ({ name, ...cfg }));
94
124
 
95
- current = { core, rpc, manifestWindows, devUrl: data.devUrl };
125
+ current = {
126
+ core,
127
+ rpc,
128
+ manifestWindows,
129
+ devUrl: data.devUrl,
130
+ resourcesDir: data.resourcesDir,
131
+ corePath,
132
+ sidecarDir: data.sidecarDir,
133
+ workersDir: data.workersDir,
134
+ };
96
135
  core.onEvent(dispatch);
97
136
  }
137
+
138
+ /**
139
+ * Resolve a bundled extra-worker (declared in `mirin.config.ts` `workers`) to an
140
+ * absolute path, for `new Worker(resolveWorker(name))` from `node:worker_threads`.
141
+ * Works in `mirin dev` and the built `.app`. Extra workers run off the main
142
+ * thread and must not call window/native APIs.
143
+ */
144
+ export function resolveWorker(name: string): string {
145
+ const dir = current?.workersDir;
146
+ if (!dir) throw new NotAttachedError("resolveWorker");
147
+ return join(dir, `${name}.js`);
148
+ }
package/src/sidecar.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Sidecars — spawn a binary bundled into the `.app` (declared in
3
+ * `mirin.config.ts` `sidecars`). A thin wrapper over `Bun.spawn` that resolves
4
+ * the bundled path (dev or prod, via `runtime().sidecarDir`) and tracks the
5
+ * child so it's killed when the app quits — the two things hand-rolled
6
+ * `Bun.spawn` gets wrong (path resolution + orphaned processes).
7
+ *
8
+ * Sidecars run as separate OS processes, so they may do anything a CLI can; they
9
+ * just can't touch the app's AppKit/CEF UI (nothing off the main thread can).
10
+ */
11
+
12
+ import { join } from "node:path";
13
+ import { runtime, onNativeEvent } from "./runtime.ts";
14
+
15
+ export interface SidecarOptions {
16
+ /** Arguments passed after the binary path. */
17
+ args?: string[];
18
+ cwd?: string;
19
+ /** Extra env on top of the inherited environment. */
20
+ env?: Record<string, string>;
21
+ /** Defaults: stdin "pipe", stdout "pipe", stderr "inherit". */
22
+ stdin?: Bun.SpawnOptions.Writable;
23
+ stdout?: Bun.SpawnOptions.Readable;
24
+ stderr?: Bun.SpawnOptions.Readable;
25
+ }
26
+
27
+ /** A running sidecar. Mirrors the subset of `Bun.Subprocess` apps need. */
28
+ export interface SidecarProcess {
29
+ readonly pid: number;
30
+ readonly stdin: Bun.Subprocess["stdin"];
31
+ readonly stdout: Bun.Subprocess["stdout"];
32
+ readonly stderr: Bun.Subprocess["stderr"];
33
+ /** Resolves with the exit code when the process ends. */
34
+ readonly exited: Promise<number>;
35
+ /** Send a signal (default SIGTERM) and stop tracking it. */
36
+ kill(signal?: number | NodeJS.Signals): void;
37
+ }
38
+
39
+ const children = new Set<Bun.Subprocess>();
40
+ let hooked = false;
41
+
42
+ function killAll(): void {
43
+ for (const child of children) {
44
+ try {
45
+ child.kill();
46
+ } catch {
47
+ // already gone
48
+ }
49
+ }
50
+ children.clear();
51
+ }
52
+
53
+ /** Register shutdown hooks once, so sidecars don't outlive the app. */
54
+ function ensureHooks(): void {
55
+ if (hooked) return;
56
+ hooked = true;
57
+ // App fully quit (last window closed / Cmd-Q path surfaces here).
58
+ onNativeEvent("window.all-closed", killAll);
59
+ // Worker-process shutdown backstops (normal exit / signals). `terminate()` is
60
+ // abrupt and won't fire these, hence the native-event hook above as well.
61
+ for (const sig of ["exit", "SIGINT", "SIGTERM"] as const) {
62
+ process.on(sig, killAll);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Spawn a bundled sidecar binary by its config name. Throws if the runtime
68
+ * isn't attached or no `sidecarDir` is known (e.g. run outside the host).
69
+ */
70
+ export function sidecar(name: string, opts: SidecarOptions = {}): SidecarProcess {
71
+ const dir = runtime().sidecarDir;
72
+ if (!dir) {
73
+ throw new Error(`app.sidecar("${name}"): no sidecar dir (is this running under the mirin host?)`);
74
+ }
75
+ ensureHooks();
76
+
77
+ const bin = join(dir, name);
78
+ const child = Bun.spawn([bin, ...(opts.args ?? [])], {
79
+ cwd: opts.cwd,
80
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
81
+ stdin: opts.stdin ?? "pipe",
82
+ stdout: opts.stdout ?? "pipe",
83
+ stderr: opts.stderr ?? "inherit",
84
+ });
85
+ children.add(child);
86
+ void child.exited.then(() => children.delete(child));
87
+
88
+ return {
89
+ get pid() {
90
+ return child.pid;
91
+ },
92
+ get stdin() {
93
+ return child.stdin;
94
+ },
95
+ get stdout() {
96
+ return child.stdout;
97
+ },
98
+ get stderr() {
99
+ return child.stderr;
100
+ },
101
+ exited: child.exited,
102
+ kill(signal) {
103
+ children.delete(child);
104
+ child.kill(signal as number | undefined);
105
+ },
106
+ };
107
+ }
package/src/updater.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * mirin/updater — the `app.updater` API (runs in the Bun Worker).
3
+ *
4
+ * Reads the app's `Resources/version.json` (embedded by `mirin build` when
5
+ * `release` is set), polls `{baseUrl}/{prefix}-update.json`, and when a different
6
+ * version is published downloads it, verifies its SHA-256, then swaps the whole
7
+ * `.app` and relaunches. Updates prefer a small **delta patch** (bsdiff) from the
8
+ * currently-installed version and fall back to the full bundle whenever a patch
9
+ * isn't usable.
10
+ *
11
+ * A signed/notarized `.app` cannot be modified in place without breaking its
12
+ * signature, so the whole bundle is always replaced. Updates run only in a
13
+ * packaged app with `release` set; in `mirin dev` the updater is idle.
14
+ */
15
+
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
19
+ import { $ } from "bun";
20
+ import { runtime } from "./runtime.ts";
21
+ import { loadCodec } from "./codec.ts";
22
+
23
+ const IS_WINDOWS = process.platform === "win32";
24
+
25
+ export type UpdaterStatus =
26
+ | "idle"
27
+ | "checking"
28
+ | "update-available"
29
+ | "downloading"
30
+ | "applying"
31
+ | "complete"
32
+ | "error";
33
+
34
+ /** The running app's embedded identity (Resources/version.json). */
35
+ interface VersionInfo {
36
+ version: string;
37
+ channel: string;
38
+ baseUrl: string;
39
+ name: string;
40
+ identifier: string;
41
+ }
42
+
43
+ /** The published-release manifest ({prefix}-update.json). */
44
+ interface Manifest {
45
+ version: string;
46
+ channel: string;
47
+ platform: string;
48
+ arch: string;
49
+ /** SHA-256 of the uncompressed bundle tar (update identity + integrity). */
50
+ tarHash: string;
51
+ bundle: { url: string; sha256: string; size?: number };
52
+ patches?: Array<{ fromVersion: string; url: string; sha256: string; size?: number }>;
53
+ }
54
+
55
+ /** A published release the running app can update to. */
56
+ export interface UpdateInfo {
57
+ version: string;
58
+ currentVersion: string;
59
+ channel: string;
60
+ }
61
+
62
+ export interface UpdateProgress {
63
+ received: number;
64
+ total: number;
65
+ /** 0..1 (0 when total is unknown). */
66
+ fraction: number;
67
+ }
68
+
69
+ export type UpdaterEvents = {
70
+ status: { status: UpdaterStatus };
71
+ progress: UpdateProgress;
72
+ "update-available": UpdateInfo;
73
+ error: { message: string };
74
+ };
75
+
76
+ type Listener<P> = (payload: P) => void;
77
+
78
+ export class Updater {
79
+ #listeners = new Map<keyof UpdaterEvents, Set<Listener<unknown>>>();
80
+ #status: UpdaterStatus = "idle";
81
+ #info: VersionInfo | null | undefined;
82
+ #manifest: Manifest | null = null;
83
+ #staged: string | null = null;
84
+ #autoCheck: ReturnType<typeof setInterval> | undefined;
85
+
86
+ on<K extends keyof UpdaterEvents>(type: K, listener: Listener<UpdaterEvents[K]>): () => void {
87
+ let set = this.#listeners.get(type);
88
+ if (!set) this.#listeners.set(type, (set = new Set()));
89
+ set.add(listener as Listener<unknown>);
90
+ return () => set!.delete(listener as Listener<unknown>);
91
+ }
92
+
93
+ get status(): UpdaterStatus {
94
+ return this.#status;
95
+ }
96
+ get currentVersion(): string {
97
+ return this.#version()?.version ?? "0.0.0";
98
+ }
99
+ get channel(): string {
100
+ return this.#version()?.channel ?? "stable";
101
+ }
102
+ /** Whether updates are configured (packaged build with `release` set). */
103
+ get enabled(): boolean {
104
+ return this.#version() != null;
105
+ }
106
+
107
+ /** Fetch the channel manifest; report an available update or `null`. Never throws. */
108
+ async checkForUpdate(): Promise<UpdateInfo | null> {
109
+ const v = this.#version();
110
+ if (!v) return null;
111
+ this.#setStatus("checking");
112
+ try {
113
+ const url = `${v.baseUrl.replace(/\/$/, "")}/${this.#prefix()}-update.json?t=${Date.now()}`;
114
+ const res = await fetch(url, { redirect: "follow" });
115
+ if (!res.ok) throw new Error(`manifest ${res.status}`);
116
+ const m = (await res.json()) as Manifest;
117
+ if (!m.version || m.version === v.version) {
118
+ this.#manifest = null;
119
+ this.#setStatus("idle");
120
+ return null;
121
+ }
122
+ this.#manifest = m;
123
+ const info: UpdateInfo = { version: m.version, currentVersion: v.version, channel: v.channel };
124
+ this.#setStatus("update-available");
125
+ this.#emit("update-available", info);
126
+ return info;
127
+ } catch (err) {
128
+ this.#fail(err);
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Download + verify the pending update — a delta patch from the installed
135
+ * version when available, else the full bundle. Emits `progress`.
136
+ */
137
+ async download(onProgress?: (p: UpdateProgress) => void): Promise<void> {
138
+ const v = this.#version();
139
+ const m = this.#manifest;
140
+ if (!v || !m) throw new Error("no update to download — call checkForUpdate() first");
141
+ this.#setStatus("downloading");
142
+ try {
143
+ const codec = loadCodec(this.#corePath());
144
+ const support = this.#supportDir(v);
145
+ const tarsDir = join(support, "tars");
146
+ const work = join(support, "updates");
147
+ mkdirSync(tarsDir, { recursive: true });
148
+ rmSync(work, { recursive: true, force: true });
149
+ mkdirSync(work, { recursive: true });
150
+
151
+ const base = v.baseUrl.replace(/\/$/, "");
152
+ const newTar = join(work, "new.tar");
153
+ const cachedTar = join(tarsDir, `${v.version}.tar`);
154
+ const patch = m.patches?.find((p) => p.fromVersion === v.version);
155
+
156
+ let ok = false;
157
+ if (patch && existsSync(cachedTar)) {
158
+ try {
159
+ const patchZst = join(work, "patch.zst");
160
+ await this.#fetchToFile(`${base}/${patch.url}`, patchZst, patch.sha256, onProgress);
161
+ const patchRaw = join(work, "patch.bin");
162
+ codec.decompress(patchZst, patchRaw);
163
+ codec.patch(cachedTar, patchRaw, newTar); // bspatch
164
+ await this.#verify(newTar, m.tarHash);
165
+ ok = true;
166
+ } catch {
167
+ ok = false; // delta failed → full bundle
168
+ }
169
+ }
170
+ if (!ok) {
171
+ const bundleZst = join(work, "bundle.tar.zst");
172
+ await this.#fetchToFile(`${base}/${m.bundle.url}`, bundleZst, m.bundle.sha256, onProgress);
173
+ codec.decompress(bundleZst, newTar);
174
+ await this.#verify(newTar, m.tarHash);
175
+ }
176
+
177
+ // Cache the new tar (for the next delta) and extract for swapping.
178
+ const keepTar = join(tarsDir, `${m.version}.tar`);
179
+ rmSync(keepTar, { force: true });
180
+ renameSync(newTar, keepTar);
181
+ await $`tar -xf ${keepTar} -C ${work}`.quiet();
182
+ // The packaged unit: a flat app folder on Windows, an `.app` on macOS.
183
+ const staged = join(work, IS_WINDOWS ? v.name : `${v.name}.app`);
184
+ if (!existsSync(staged)) throw new Error(`extracted bundle not found at ${staged}`);
185
+
186
+ if (!IS_WINDOWS) {
187
+ const verify = await $`codesign --verify --deep --strict ${staged}`.quiet().nothrow();
188
+ if (verify.exitCode !== 0)
189
+ throw new Error("codesign verification failed on the downloaded update");
190
+ }
191
+
192
+ // Keep only the newest cached tar.
193
+ for (const f of readdirSync(tarsDir)) {
194
+ if (f !== `${m.version}.tar`) rmSync(join(tarsDir, f), { force: true });
195
+ }
196
+
197
+ this.#staged = staged;
198
+ this.#setStatus("update-available");
199
+ } catch (err) {
200
+ this.#fail(err);
201
+ throw err;
202
+ }
203
+ }
204
+
205
+ /** Swap the running `.app` with the downloaded bundle and relaunch. */
206
+ async applyAndRelaunch(): Promise<void> {
207
+ const v = this.#version();
208
+ if (!v || !this.#staged) throw new Error("no staged update — call download() first");
209
+ this.#setStatus("applying");
210
+ try {
211
+ // The running app folder: macOS `.app` is Contents/Resources/../.. ; Windows
212
+ // is the exe dir, i.e. resources/.. . A detached helper waits for this process
213
+ // to exit (the exe/bundle is locked while running), swaps the folder, relaunches.
214
+ if (IS_WINDOWS) {
215
+ const runningApp = join(this.#resourcesDir()!, "..");
216
+ const exe = join(runningApp, `${this.#version()!.name}.exe`);
217
+ const ps = [
218
+ `$ErrorActionPreference='SilentlyContinue'`,
219
+ `while (Get-Process -Id ${process.pid} -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 200 }`,
220
+ `Start-Sleep -Milliseconds 400`,
221
+ `Remove-Item -Recurse -Force ${psq(runningApp)}`,
222
+ `Move-Item -Force ${psq(this.#staged)} ${psq(runningApp)}`,
223
+ `Start-Process ${psq(exe)}`,
224
+ ].join("; ");
225
+ Bun.spawn(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], {
226
+ stdin: "ignore",
227
+ stdout: "ignore",
228
+ stderr: "ignore",
229
+ }).unref();
230
+ } else {
231
+ const runningApp = join(this.#resourcesDir()!, "..", "..");
232
+ const script = [
233
+ `APP=${sh(runningApp)}`,
234
+ `NEW=${sh(this.#staged)}`,
235
+ `PID=${process.pid}`,
236
+ `while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done`,
237
+ `sleep 0.3`,
238
+ `rm -rf "$APP"`,
239
+ `mv "$NEW" "$APP"`,
240
+ `xattr -r -d com.apple.quarantine "$APP" 2>/dev/null || true`,
241
+ `open "$APP"`,
242
+ ].join("\n");
243
+ Bun.spawn(["/bin/sh", "-c", script], {
244
+ stdin: "ignore",
245
+ stdout: "ignore",
246
+ stderr: "ignore",
247
+ }).unref();
248
+ }
249
+
250
+ this.#setStatus("complete");
251
+ runtime().core.quit();
252
+ } catch (err) {
253
+ this.#fail(err);
254
+ throw err;
255
+ }
256
+ }
257
+
258
+ /** Poll for updates every `intervalMs`. Returns a stop function. */
259
+ startAutoCheck(intervalMs = 6 * 60 * 60 * 1000): () => void {
260
+ this.stopAutoCheck();
261
+ void this.checkForUpdate();
262
+ this.#autoCheck = setInterval(() => void this.checkForUpdate(), intervalMs);
263
+ return () => this.stopAutoCheck();
264
+ }
265
+
266
+ stopAutoCheck(): void {
267
+ if (this.#autoCheck) clearInterval(this.#autoCheck);
268
+ this.#autoCheck = undefined;
269
+ }
270
+
271
+ // ---- internals ----
272
+
273
+ async #fetchToFile(
274
+ url: string,
275
+ dest: string,
276
+ expectedSha: string,
277
+ onProgress?: (p: UpdateProgress) => void,
278
+ ): Promise<void> {
279
+ const res = await fetch(url, { redirect: "follow" });
280
+ if (!res.ok || !res.body) throw new Error(`download ${res.status}`);
281
+ const total = Number(res.headers.get("content-length") ?? 0);
282
+ const reader = res.body.getReader();
283
+ const chunks: Uint8Array[] = [];
284
+ let received = 0;
285
+ for (;;) {
286
+ const { done, value } = await reader.read();
287
+ if (done) break;
288
+ chunks.push(value);
289
+ received += value.byteLength;
290
+ const p: UpdateProgress = { received, total, fraction: total ? received / total : 0 };
291
+ this.#emit("progress", p);
292
+ onProgress?.(p);
293
+ }
294
+ const buf = Buffer.concat(chunks);
295
+ const got = new Bun.CryptoHasher("sha256").update(buf).digest("hex");
296
+ if (got !== expectedSha) throw new Error("download hash mismatch");
297
+ await Bun.write(dest, buf);
298
+ }
299
+
300
+ async #verify(file: string, expectedSha: string): Promise<void> {
301
+ const buf = await Bun.file(file).bytes();
302
+ const got = new Bun.CryptoHasher("sha256").update(buf).digest("hex");
303
+ if (got !== expectedSha) throw new Error("reconstructed bundle hash mismatch");
304
+ }
305
+
306
+ #version(): VersionInfo | null {
307
+ if (this.#info !== undefined) return this.#info;
308
+ const dir = this.#resourcesDir();
309
+ const path = dir ? join(dir, "version.json") : undefined;
310
+ if (path && existsSync(path)) {
311
+ try {
312
+ this.#info = JSON.parse(readFileSync(path, "utf8")) as VersionInfo;
313
+ } catch {
314
+ this.#info = null;
315
+ }
316
+ } else {
317
+ this.#info = null;
318
+ }
319
+ return this.#info;
320
+ }
321
+
322
+ #resourcesDir(): string | undefined {
323
+ try {
324
+ return runtime().resourcesDir;
325
+ } catch {
326
+ return undefined;
327
+ }
328
+ }
329
+
330
+ #corePath(): string {
331
+ const p = runtime().corePath;
332
+ if (!p) throw new Error("libmirin_core path unavailable");
333
+ return p;
334
+ }
335
+
336
+ #prefix(): string {
337
+ const platform = IS_WINDOWS ? "win32" : "darwin";
338
+ return `${this.#version()!.channel}-${platform}-${process.arch}`;
339
+ }
340
+
341
+ #supportDir(v: VersionInfo): string {
342
+ if (IS_WINDOWS) {
343
+ const base = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
344
+ return join(base, v.identifier, v.channel);
345
+ }
346
+ return join(homedir(), "Library", "Application Support", v.identifier, v.channel);
347
+ }
348
+
349
+ #setStatus(status: UpdaterStatus): void {
350
+ this.#status = status;
351
+ this.#emit("status", { status });
352
+ }
353
+
354
+ #fail(err: unknown): void {
355
+ const message = err instanceof Error ? err.message : String(err);
356
+ this.#status = "error";
357
+ this.#emit("status", { status: "error" });
358
+ this.#emit("error", { message });
359
+ }
360
+
361
+ #emit<K extends keyof UpdaterEvents>(type: K, payload: UpdaterEvents[K]): void {
362
+ this.#listeners.get(type)?.forEach((fn) => fn(payload));
363
+ try {
364
+ runtime().rpc.broadcast(`mirin:updater:${type}`, payload);
365
+ } catch {
366
+ // detached / rpc not up — ignore
367
+ }
368
+ }
369
+ }
370
+
371
+ /** Single-quote a path for safe interpolation into a /bin/sh script. */
372
+ function sh(p: string): string {
373
+ return `'${p.replace(/'/g, `'\\''`)}'`;
374
+ }
375
+
376
+ /** Single-quote a path for safe interpolation into a PowerShell command. */
377
+ function psq(p: string): string {
378
+ return `'${p.replace(/'/g, "''")}'`;
379
+ }
380
+
381
+ export const updater = new Updater();