mirinjs 0.0.1-alpha.1 → 0.0.1-alpha.11

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.1",
3
+ "version": "0.0.1-alpha.11",
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",
package/src/app.ts CHANGED
@@ -6,6 +6,8 @@
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";
9
11
 
10
12
  /** Which native backend a window's `material` resolved to. */
11
13
  export type WindowMaterialInfo = {
@@ -32,6 +34,14 @@ export type AppEvents = {
32
34
  "window-all-closed": void;
33
35
  };
34
36
 
37
+ /** A window's frame in screen points (bottom-left origin, like AppKit). */
38
+ export interface WindowFrame {
39
+ x: number;
40
+ y: number;
41
+ width: number;
42
+ height: number;
43
+ }
44
+
35
45
  type Listener<P> = (payload: P) => void;
36
46
 
37
47
  class Emitter<Events extends Record<string, unknown>> {
@@ -127,6 +137,31 @@ export class WindowHandle extends Emitter<WindowEvents> {
127
137
  runtime().core.windowSetMaterial(this.id, JSON.stringify(normalizeMaterial(material)));
128
138
  }
129
139
 
140
+ /** Move the window's bottom-left origin to screen point (x, y), in points. */
141
+ setPosition(x: number, y: number): void {
142
+ runtime().core.windowSetPosition(this.id, x, y);
143
+ }
144
+
145
+ /** The latest known window frame (screen points, bottom-left origin). Tracked
146
+ * from `moved`/`resized` events, so it's current without a round-trip. */
147
+ getFrame(): WindowFrame {
148
+ return this.#frame ?? { x: 0, y: 0, width: 0, height: 0 };
149
+ }
150
+
151
+ /** Whether the window is currently zoomed (maximized). */
152
+ isMaximized(): boolean {
153
+ return this.#maximized;
154
+ }
155
+
156
+ #frame: WindowFrame | null = null;
157
+ #maximized = false;
158
+
159
+ /** @internal — fed from native window frame events. */
160
+ _setFrame(frame: WindowFrame, maximized: boolean): void {
161
+ this.#frame = frame;
162
+ this.#maximized = maximized;
163
+ }
164
+
130
165
  #control(verb: string): void {
131
166
  runtime().core.windowControl(this.id, verb);
132
167
  }
@@ -246,6 +281,9 @@ export interface Dock {
246
281
  class MirinApp extends Emitter<AppEvents> {
247
282
  readonly windows = new Windows();
248
283
 
284
+ /** Auto-updater (macOS). Inert unless the app was built with `release` set. */
285
+ readonly updater: Updater = updater;
286
+
249
287
  /** macOS Dock-icon controls. Hiding suits resident, hotkey-summoned apps. */
250
288
  readonly dock: Dock = {
251
289
  hide: () => this.#setDock(false),
@@ -322,7 +360,15 @@ export function wireAppEvents(): void {
322
360
  for (const kind of WINDOW_EVENTS) {
323
361
  onNativeEvent(`window.${kind}`, (event: NativeEvent) => {
324
362
  const id = event.id as number | undefined;
325
- if (id != null) app.windows._byId(id)?._emit(kind, undefined);
363
+ if (id == null) return;
364
+ const handle = app.windows._byId(id);
365
+ if (!handle) return;
366
+ // moved/resized carry the current frame + maximized state; track them so
367
+ // getFrame()/isMaximized() are answerable without a round-trip.
368
+ if (event.frame) {
369
+ handle._setFrame(event.frame as WindowFrame, Boolean(event.maximized));
370
+ }
371
+ handle._emit(kind, undefined);
326
372
  });
327
373
  }
328
374
  }
package/src/config.ts CHANGED
@@ -42,12 +42,22 @@ export interface WindowConfig {
42
42
  title?: string;
43
43
  width?: number;
44
44
  height?: number;
45
+ /** Screen position (bottom-left origin, points). Centered when absent. */
46
+ x?: number;
47
+ y?: number;
45
48
  /** "ready" (default) shows on first paint to avoid a white flash. */
46
49
  show?: "ready" | "immediately";
47
50
  /** "auto" (default) opens at launch; "manual" windows are templates for app.windows.open(name). */
48
51
  open?: "auto" | "manual";
49
52
  /** Custom title bar: hide it (content fills) or inset the traffic lights. */
50
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 };
51
61
  /** Non-opaque window (for transparent/blurred UIs). */
52
62
  transparent?: boolean;
53
63
  /**
@@ -70,9 +80,30 @@ export interface MirinConfig {
70
80
  name: string;
71
81
  /** Main-process entry, relative to the project root (runs in the Bun Worker). */
72
82
  main: string;
83
+ /**
84
+ * App icon, relative to the project root (macOS). Accepts a `.icns`, a
85
+ * `.iconset` directory, or a single square `.png` (≥512px) that mirin renders
86
+ * into an `.icns`. Embedded in the bundle for the Dock and Finder.
87
+ */
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;
73
97
  windows: Record<string, WindowConfig>;
74
98
  }
75
99
 
100
+ export interface ReleaseConfig {
101
+ /** Flat directory URL hosting `{channel}-{platform}-{arch}-*` update files. */
102
+ baseUrl: string;
103
+ /** Update channel; baked into artifact names + support dir. Default "stable". */
104
+ channel?: string;
105
+ }
106
+
76
107
  /** Identity function for typing/intellisense. `const` generic preserves window names as literal keys. */
77
108
  export function defineConfig<const T extends MirinConfig>(config: T): T {
78
109
  return config;
package/src/host.ts CHANGED
@@ -36,7 +36,9 @@ const manifest = JSON.parse(
36
36
 
37
37
  const coreConfig = JSON.parse(
38
38
  process.env.MIRIN_CONFIG_JSON ??
39
- JSON.stringify(process.env.MIRIN_DEV_URL ? {} : { resources_path: resourcesDir }),
39
+ JSON.stringify(
40
+ process.env.MIRIN_DEV_URL ? { dev: true } : { resources_path: resourcesDir },
41
+ ),
40
42
  );
41
43
 
42
44
  // Load the native core on the main thread FIRST. The Worker also dlopens the
@@ -46,7 +48,7 @@ const coreConfig = JSON.parse(
46
48
  const core = new Core(corePath);
47
49
 
48
50
  const worker = new Worker(workerPath, {
49
- workerData: { corePath, manifest, devUrl: process.env.MIRIN_DEV_URL },
51
+ workerData: { corePath, manifest, devUrl: process.env.MIRIN_DEV_URL, resourcesDir },
50
52
  });
51
53
  worker.on("error", (err) => console.error("[mirin worker]", err));
52
54
 
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,11 +22,14 @@ 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 { logger, setLogLevel, getLogLevel, Logger } from "./logger.ts";
26
+ export { updater, Updater } from "./updater.ts";
25
27
  export { NotAttachedError } from "./runtime.ts";
26
28
 
27
29
  export type {
28
30
  WindowEvents,
29
31
  WindowMaterialInfo,
32
+ WindowFrame,
30
33
  AppEvents,
31
34
  WindowHandle,
32
35
  WindowOpenOptions,
@@ -35,6 +38,8 @@ export type {
35
38
  Dock,
36
39
  } from "./app.ts";
37
40
  export type { MenuItemTemplate, MenuRole } from "./menu.ts";
41
+ export type { LogLevel } from "./logger.ts";
42
+ export type { UpdaterStatus, UpdateInfo, UpdateProgress, UpdaterEvents } from "./updater.ts";
38
43
  export type { TrayOptions } from "./tray.ts";
39
44
  export type { OpenDialogOptions, SaveDialogOptions, MessageDialogOptions } from "./dialog.ts";
40
45
  export type {
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
@@ -23,6 +23,7 @@ const symbols = {
23
23
  mirin_window_set_title: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.void },
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
+ mirin_window_set_position: { args: [FFIType.u32, FFIType.f64, FFIType.f64], returns: FFIType.void },
26
27
  mirin_app_quit: { args: [], returns: FFIType.void },
27
28
  mirin_app_set_dock_visible: { args: [FFIType.i32], returns: FFIType.void },
28
29
  mirin_set_app_menu: { args: [FFIType.ptr], returns: FFIType.void },
@@ -108,6 +109,10 @@ export class Core {
108
109
  this.#lib.symbols.mirin_window_set_material(id, ptr(buf));
109
110
  }
110
111
 
112
+ windowSetPosition(id: number, x: number, y: number): void {
113
+ this.#lib.symbols.mirin_window_set_position(id, x, y);
114
+ }
115
+
111
116
  quit(): void {
112
117
  this.#lib.symbols.mirin_app_quit();
113
118
  }
package/src/runtime.ts CHANGED
@@ -18,6 +18,8 @@ export interface Runtime {
18
18
  rpc: RpcServer;
19
19
  manifestWindows: ManifestWindowConfig[];
20
20
  devUrl?: string;
21
+ /** Contents/Resources of the running .app (for the updater). Absent in dev. */
22
+ resourcesDir?: string;
21
23
  }
22
24
 
23
25
  export class NotAttachedError extends Error {
@@ -35,9 +37,14 @@ export function runtime(): Runtime {
35
37
  return current;
36
38
  }
37
39
 
38
- /** Dev: every window loads the Vite server. Production: its manifest app:// URL. */
40
+ /** Dev: every window loads the Vite server. Production: its manifest app:// URL.
41
+ * Any query/hash on the requested URL (e.g. "#devtools") is preserved so
42
+ * hash-routed sub-windows reach the right view through the dev server too. */
39
43
  export function resolveUrl(url: string): string {
40
- return current?.devUrl ?? url;
44
+ const devUrl = current?.devUrl;
45
+ if (!devUrl) return url;
46
+ const suffix = url.match(/[?#].*$/)?.[0] ?? "";
47
+ return devUrl + suffix;
41
48
  }
42
49
 
43
50
  // ---- native event dispatch ----
@@ -74,6 +81,7 @@ export function boot(): void {
74
81
  corePath?: string;
75
82
  manifest?: { windows?: Record<string, WindowConfig> };
76
83
  devUrl?: string;
84
+ resourcesDir?: string;
77
85
  };
78
86
  const corePath = data.corePath ?? process.env.MIRIN_CORE;
79
87
  if (!corePath) return; // not under the host; the API stays detached
@@ -87,6 +95,6 @@ export function boot(): void {
87
95
  data.manifest?.windows ?? {},
88
96
  ).map(([name, cfg]) => ({ name, ...cfg }));
89
97
 
90
- current = { core, rpc, manifestWindows, devUrl: data.devUrl };
98
+ current = { core, rpc, manifestWindows, devUrl: data.devUrl, resourcesDir: data.resourcesDir };
91
99
  core.onEvent(dispatch);
92
100
  }
package/src/updater.ts ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * mirin/updater — the `app.updater` API (runs in the Bun Worker).
3
+ *
4
+ * Reads the app's own `Resources/version.json` (embedded by `mirin build` when
5
+ * `release` is configured), polls the manifest at `{baseUrl}/{prefix}-update.json`,
6
+ * downloads the full `.app` bundle when a different version is published, verifies
7
+ * its SHA-256, then swaps the whole `.app` and relaunches.
8
+ *
9
+ * A signed/notarized `.app` cannot be modified in place without breaking its
10
+ * signature, so every update replaces the entire bundle. Updates run only in a
11
+ * packaged app with `release` set; in `mirin dev` (no version.json) the updater
12
+ * stays idle and `checkForUpdate()` resolves `null`.
13
+ *
14
+ * import { app } from "mirinjs";
15
+ * const info = await app.updater.checkForUpdate();
16
+ * if (info) { await app.updater.download(p => …); await app.updater.applyAndRelaunch(); }
17
+ */
18
+
19
+ import { homedir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
22
+ import { $ } from "bun";
23
+ import { runtime } from "./runtime.ts";
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
+ /** A published release the running app can update to. */
44
+ export interface UpdateInfo {
45
+ /** Version being offered. */
46
+ version: string;
47
+ /** The currently-running version. */
48
+ currentVersion: string;
49
+ channel: string;
50
+ /** Artifact filename, relative to baseUrl. */
51
+ url: string;
52
+ sha256: string;
53
+ size?: number;
54
+ }
55
+
56
+ export interface UpdateProgress {
57
+ received: number;
58
+ total: number;
59
+ /** 0..1 (0 when total is unknown). */
60
+ fraction: number;
61
+ }
62
+
63
+ export type UpdaterEvents = {
64
+ status: { status: UpdaterStatus };
65
+ progress: UpdateProgress;
66
+ "update-available": UpdateInfo;
67
+ error: { message: string };
68
+ };
69
+
70
+ type Listener<P> = (payload: P) => void;
71
+
72
+ export class Updater {
73
+ #listeners = new Map<keyof UpdaterEvents, Set<Listener<unknown>>>();
74
+ #status: UpdaterStatus = "idle";
75
+ #info: VersionInfo | null | undefined; // undefined = not yet read
76
+ #pending: UpdateInfo | null = null;
77
+ #staged: string | null = null; // path to the extracted, verified .app
78
+ #autoCheck: ReturnType<typeof setInterval> | undefined;
79
+
80
+ /** Subscribe to an updater event. Returns an unsubscribe function. */
81
+ on<K extends keyof UpdaterEvents>(type: K, listener: Listener<UpdaterEvents[K]>): () => void {
82
+ let set = this.#listeners.get(type);
83
+ if (!set) this.#listeners.set(type, (set = new Set()));
84
+ set.add(listener as Listener<unknown>);
85
+ return () => set!.delete(listener as Listener<unknown>);
86
+ }
87
+
88
+ get status(): UpdaterStatus {
89
+ return this.#status;
90
+ }
91
+ get currentVersion(): string {
92
+ return this.#version()?.version ?? "0.0.0";
93
+ }
94
+ get channel(): string {
95
+ return this.#version()?.channel ?? "stable";
96
+ }
97
+ /** Whether this build has updates configured (packaged + `release` set). */
98
+ get enabled(): boolean {
99
+ return this.#version() != null;
100
+ }
101
+
102
+ /**
103
+ * Fetch the channel manifest and report an available update, or `null` if the
104
+ * app is up to date / updates aren't configured. Never throws.
105
+ */
106
+ async checkForUpdate(): Promise<UpdateInfo | null> {
107
+ const v = this.#version();
108
+ if (!v) return null;
109
+ this.#setStatus("checking");
110
+ try {
111
+ const url = `${v.baseUrl.replace(/\/$/, "")}/${this.#prefix()}-update.json?t=${Date.now()}`;
112
+ const res = await fetch(url, { redirect: "follow" });
113
+ if (!res.ok) throw new Error(`manifest ${res.status}`);
114
+ const m = (await res.json()) as {
115
+ version: string;
116
+ url: string;
117
+ sha256: string;
118
+ size?: number;
119
+ };
120
+ if (!m.version || m.version === v.version) {
121
+ this.#setStatus("idle");
122
+ return null;
123
+ }
124
+ const info: UpdateInfo = {
125
+ version: m.version,
126
+ currentVersion: v.version,
127
+ channel: v.channel,
128
+ url: m.url,
129
+ sha256: m.sha256,
130
+ size: m.size,
131
+ };
132
+ this.#pending = info;
133
+ this.#setStatus("update-available");
134
+ this.#emit("update-available", info);
135
+ return info;
136
+ } catch (err) {
137
+ this.#fail(err);
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Download (and verify) the pending update's bundle into the app's support
144
+ * dir. Emits `progress`. Call `checkForUpdate()` first.
145
+ */
146
+ async download(onProgress?: (p: UpdateProgress) => void): Promise<void> {
147
+ const v = this.#version();
148
+ if (!v || !this.#pending) throw new Error("no update to download — call checkForUpdate() first");
149
+ const info = this.#pending;
150
+ this.#setStatus("downloading");
151
+ try {
152
+ const dir = this.#updatesDir(v);
153
+ rmSync(dir, { recursive: true, force: true });
154
+ mkdirSync(dir, { recursive: true });
155
+
156
+ const url = `${v.baseUrl.replace(/\/$/, "")}/${info.url}`;
157
+ const res = await fetch(url, { redirect: "follow" });
158
+ if (!res.ok || !res.body) throw new Error(`download ${res.status}`);
159
+ const total = Number(res.headers.get("content-length") ?? info.size ?? 0);
160
+
161
+ const reader = res.body.getReader();
162
+ const chunks: Uint8Array[] = [];
163
+ let received = 0;
164
+ for (;;) {
165
+ const { done, value } = await reader.read();
166
+ if (done) break;
167
+ chunks.push(value);
168
+ received += value.byteLength;
169
+ const p: UpdateProgress = { received, total, fraction: total ? received / total : 0 };
170
+ this.#emit("progress", p);
171
+ onProgress?.(p);
172
+ }
173
+ const buf = Buffer.concat(chunks);
174
+
175
+ // Integrity: the manifest's sha256 must match the downloaded artifact.
176
+ const got = new Bun.CryptoHasher("sha256").update(buf).digest("hex");
177
+ if (got !== info.sha256) {
178
+ throw new Error(`hash mismatch (expected ${info.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…)`);
179
+ }
180
+
181
+ const tarPath = join(dir, "update.tar.gz");
182
+ await Bun.write(tarPath, buf);
183
+ await $`tar -xzf ${tarPath} -C ${dir}`.quiet();
184
+ const staged = join(dir, `${v.name}.app`);
185
+ if (!existsSync(staged)) throw new Error(`extracted bundle not found at ${staged}`);
186
+
187
+ // Refuse a tampered/corrupt bundle (works for ad-hoc + Developer ID).
188
+ const verify = await $`codesign --verify --deep --strict ${staged}`.quiet().nothrow();
189
+ if (verify.exitCode !== 0) throw new Error("codesign verification failed on the downloaded update");
190
+
191
+ this.#staged = staged;
192
+ this.#setStatus("update-available");
193
+ } catch (err) {
194
+ this.#fail(err);
195
+ throw err;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Swap the running `.app` with the downloaded bundle and relaunch. Spawns a
201
+ * detached helper that waits for this process to exit, replaces the bundle,
202
+ * strips quarantine, and reopens the app — then quits.
203
+ */
204
+ async applyAndRelaunch(): Promise<void> {
205
+ const v = this.#version();
206
+ if (!v || !this.#staged) throw new Error("no staged update — call download() first");
207
+ this.#setStatus("applying");
208
+ try {
209
+ const runningApp = join(this.#resourcesDir()!, "..", "..");
210
+ const script = [
211
+ `APP=${sh(runningApp)}`,
212
+ `NEW=${sh(this.#staged)}`,
213
+ `PID=${process.pid}`,
214
+ // Wait for the running app to fully exit before swapping.
215
+ `while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done`,
216
+ `sleep 0.3`,
217
+ `rm -rf "$APP"`,
218
+ `mv "$NEW" "$APP"`,
219
+ `xattr -r -d com.apple.quarantine "$APP" 2>/dev/null || true`,
220
+ `open "$APP"`,
221
+ ].join("\n");
222
+
223
+ Bun.spawn(["/bin/sh", "-c", script], {
224
+ stdin: "ignore",
225
+ stdout: "ignore",
226
+ stderr: "ignore",
227
+ }).unref();
228
+
229
+ this.#setStatus("complete");
230
+ // Quit; the detached helper does the swap + relaunch after we exit.
231
+ runtime().core.quit();
232
+ } catch (err) {
233
+ this.#fail(err);
234
+ throw err;
235
+ }
236
+ }
237
+
238
+ /** Poll for updates every `intervalMs`. Returns a stop function. */
239
+ startAutoCheck(intervalMs = 6 * 60 * 60 * 1000): () => void {
240
+ this.stopAutoCheck();
241
+ void this.checkForUpdate();
242
+ this.#autoCheck = setInterval(() => void this.checkForUpdate(), intervalMs);
243
+ return () => this.stopAutoCheck();
244
+ }
245
+
246
+ stopAutoCheck(): void {
247
+ if (this.#autoCheck) clearInterval(this.#autoCheck);
248
+ this.#autoCheck = undefined;
249
+ }
250
+
251
+ // ---- internals ----
252
+
253
+ #version(): VersionInfo | null {
254
+ if (this.#info !== undefined) return this.#info;
255
+ const dir = this.#resourcesDir();
256
+ const path = dir ? join(dir, "version.json") : undefined;
257
+ if (path && existsSync(path)) {
258
+ try {
259
+ this.#info = JSON.parse(readFileSync(path, "utf8")) as VersionInfo;
260
+ } catch {
261
+ this.#info = null;
262
+ }
263
+ } else {
264
+ this.#info = null;
265
+ }
266
+ return this.#info;
267
+ }
268
+
269
+ #resourcesDir(): string | undefined {
270
+ try {
271
+ return runtime().resourcesDir;
272
+ } catch {
273
+ return undefined; // detached (not under the host)
274
+ }
275
+ }
276
+
277
+ #prefix(): string {
278
+ const v = this.#version()!;
279
+ return `${v.channel}-darwin-${process.arch}`;
280
+ }
281
+
282
+ #updatesDir(v: VersionInfo): string {
283
+ return join(homedir(), "Library", "Application Support", v.identifier, v.channel, "updates");
284
+ }
285
+
286
+ #setStatus(status: UpdaterStatus): void {
287
+ this.#status = status;
288
+ this.#emit("status", { status });
289
+ }
290
+
291
+ #fail(err: unknown): void {
292
+ const message = err instanceof Error ? err.message : String(err);
293
+ this.#status = "error";
294
+ this.#emit("status", { status: "error" });
295
+ this.#emit("error", { message });
296
+ }
297
+
298
+ #emit<K extends keyof UpdaterEvents>(type: K, payload: UpdaterEvents[K]): void {
299
+ this.#listeners.get(type)?.forEach((fn) => fn(payload));
300
+ // Bridge to webviews so frontends can react without polling.
301
+ try {
302
+ runtime().rpc.broadcast(`mirin:updater:${type}`, payload);
303
+ } catch {
304
+ // detached / rpc not up — ignore
305
+ }
306
+ }
307
+ }
308
+
309
+ /** Single-quote a path for safe interpolation into a /bin/sh script. */
310
+ function sh(p: string): string {
311
+ return `'${p.replace(/'/g, `'\\''`)}'`;
312
+ }
313
+
314
+ export const updater = new Updater();