mirinjs 0.0.1-alpha.11 → 0.0.1-alpha.12

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.11",
3
+ "version": "0.0.1-alpha.12",
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/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/runtime.ts CHANGED
@@ -20,6 +20,8 @@ export interface Runtime {
20
20
  devUrl?: string;
21
21
  /** Contents/Resources of the running .app (for the updater). Absent in dev. */
22
22
  resourcesDir?: string;
23
+ /** Path to libmirin_core (for the updater codec). */
24
+ corePath?: string;
23
25
  }
24
26
 
25
27
  export class NotAttachedError extends Error {
@@ -95,6 +97,13 @@ export function boot(): void {
95
97
  data.manifest?.windows ?? {},
96
98
  ).map(([name, cfg]) => ({ name, ...cfg }));
97
99
 
98
- current = { core, rpc, manifestWindows, devUrl: data.devUrl, resourcesDir: data.resourcesDir };
100
+ current = {
101
+ core,
102
+ rpc,
103
+ manifestWindows,
104
+ devUrl: data.devUrl,
105
+ resourcesDir: data.resourcesDir,
106
+ corePath,
107
+ };
99
108
  core.onEvent(dispatch);
100
109
  }
package/src/updater.ts CHANGED
@@ -1,26 +1,24 @@
1
1
  /**
2
2
  * mirin/updater — the `app.updater` API (runs in the Bun Worker).
3
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.
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.
8
10
  *
9
11
  * 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(); }
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.
17
14
  */
18
15
 
19
16
  import { homedir } from "node:os";
20
- import { dirname, join } from "node:path";
21
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
22
19
  import { $ } from "bun";
23
20
  import { runtime } from "./runtime.ts";
21
+ import { loadCodec } from "./codec.ts";
24
22
 
25
23
  export type UpdaterStatus =
26
24
  | "idle"
@@ -40,17 +38,23 @@ interface VersionInfo {
40
38
  identifier: string;
41
39
  }
42
40
 
41
+ /** The published-release manifest ({prefix}-update.json). */
42
+ interface Manifest {
43
+ version: string;
44
+ channel: string;
45
+ platform: string;
46
+ arch: string;
47
+ /** SHA-256 of the uncompressed bundle tar (update identity + integrity). */
48
+ tarHash: string;
49
+ bundle: { url: string; sha256: string; size?: number };
50
+ patches?: Array<{ fromVersion: string; url: string; sha256: string; size?: number }>;
51
+ }
52
+
43
53
  /** A published release the running app can update to. */
44
54
  export interface UpdateInfo {
45
- /** Version being offered. */
46
55
  version: string;
47
- /** The currently-running version. */
48
56
  currentVersion: string;
49
57
  channel: string;
50
- /** Artifact filename, relative to baseUrl. */
51
- url: string;
52
- sha256: string;
53
- size?: number;
54
58
  }
55
59
 
56
60
  export interface UpdateProgress {
@@ -72,12 +76,11 @@ type Listener<P> = (payload: P) => void;
72
76
  export class Updater {
73
77
  #listeners = new Map<keyof UpdaterEvents, Set<Listener<unknown>>>();
74
78
  #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
79
+ #info: VersionInfo | null | undefined;
80
+ #manifest: Manifest | null = null;
81
+ #staged: string | null = null;
78
82
  #autoCheck: ReturnType<typeof setInterval> | undefined;
79
83
 
80
- /** Subscribe to an updater event. Returns an unsubscribe function. */
81
84
  on<K extends keyof UpdaterEvents>(type: K, listener: Listener<UpdaterEvents[K]>): () => void {
82
85
  let set = this.#listeners.get(type);
83
86
  if (!set) this.#listeners.set(type, (set = new Set()));
@@ -94,15 +97,12 @@ export class Updater {
94
97
  get channel(): string {
95
98
  return this.#version()?.channel ?? "stable";
96
99
  }
97
- /** Whether this build has updates configured (packaged + `release` set). */
100
+ /** Whether updates are configured (packaged build with `release` set). */
98
101
  get enabled(): boolean {
99
102
  return this.#version() != null;
100
103
  }
101
104
 
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
- */
105
+ /** Fetch the channel manifest; report an available update or `null`. Never throws. */
106
106
  async checkForUpdate(): Promise<UpdateInfo | null> {
107
107
  const v = this.#version();
108
108
  if (!v) return null;
@@ -111,25 +111,14 @@ export class Updater {
111
111
  const url = `${v.baseUrl.replace(/\/$/, "")}/${this.#prefix()}-update.json?t=${Date.now()}`;
112
112
  const res = await fetch(url, { redirect: "follow" });
113
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
- };
114
+ const m = (await res.json()) as Manifest;
120
115
  if (!m.version || m.version === v.version) {
116
+ this.#manifest = null;
121
117
  this.#setStatus("idle");
122
118
  return null;
123
119
  }
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;
120
+ this.#manifest = m;
121
+ const info: UpdateInfo = { version: m.version, currentVersion: v.version, channel: v.channel };
133
122
  this.#setStatus("update-available");
134
123
  this.#emit("update-available", info);
135
124
  return info;
@@ -140,54 +129,65 @@ export class Updater {
140
129
  }
141
130
 
142
131
  /**
143
- * Download (and verify) the pending update's bundle into the app's support
144
- * dir. Emits `progress`. Call `checkForUpdate()` first.
132
+ * Download + verify the pending update — a delta patch from the installed
133
+ * version when available, else the full bundle. Emits `progress`.
145
134
  */
146
135
  async download(onProgress?: (p: UpdateProgress) => void): Promise<void> {
147
136
  const v = this.#version();
148
- if (!v || !this.#pending) throw new Error("no update to download — call checkForUpdate() first");
149
- const info = this.#pending;
137
+ const m = this.#manifest;
138
+ if (!v || !m) throw new Error("no update to download — call checkForUpdate() first");
150
139
  this.#setStatus("downloading");
151
140
  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);
141
+ const codec = loadCodec(this.#corePath());
142
+ const support = this.#supportDir(v);
143
+ const tarsDir = join(support, "tars");
144
+ const work = join(support, "updates");
145
+ mkdirSync(tarsDir, { recursive: true });
146
+ rmSync(work, { recursive: true, force: true });
147
+ mkdirSync(work, { recursive: true });
148
+
149
+ const base = v.baseUrl.replace(/\/$/, "");
150
+ const newTar = join(work, "new.tar");
151
+ const cachedTar = join(tarsDir, `${v.version}.tar`);
152
+ const patch = m.patches?.find((p) => p.fromVersion === v.version);
153
+
154
+ let ok = false;
155
+ if (patch && existsSync(cachedTar)) {
156
+ try {
157
+ const patchZst = join(work, "patch.zst");
158
+ await this.#fetchToFile(`${base}/${patch.url}`, patchZst, patch.sha256, onProgress);
159
+ const patchRaw = join(work, "patch.bin");
160
+ codec.decompress(patchZst, patchRaw);
161
+ codec.patch(cachedTar, patchRaw, newTar); // bspatch
162
+ await this.#verify(newTar, m.tarHash);
163
+ ok = true;
164
+ } catch {
165
+ ok = false; // delta failed → full bundle
166
+ }
172
167
  }
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)}…)`);
168
+ if (!ok) {
169
+ const bundleZst = join(work, "bundle.tar.zst");
170
+ await this.#fetchToFile(`${base}/${m.bundle.url}`, bundleZst, m.bundle.sha256, onProgress);
171
+ codec.decompress(bundleZst, newTar);
172
+ await this.#verify(newTar, m.tarHash);
179
173
  }
180
174
 
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`);
175
+ // Cache the new tar (for the next delta) and extract for swapping.
176
+ const keepTar = join(tarsDir, `${m.version}.tar`);
177
+ rmSync(keepTar, { force: true });
178
+ renameSync(newTar, keepTar);
179
+ await $`tar -xf ${keepTar} -C ${work}`.quiet();
180
+ const staged = join(work, `${v.name}.app`);
185
181
  if (!existsSync(staged)) throw new Error(`extracted bundle not found at ${staged}`);
186
182
 
187
- // Refuse a tampered/corrupt bundle (works for ad-hoc + Developer ID).
188
183
  const verify = await $`codesign --verify --deep --strict ${staged}`.quiet().nothrow();
189
184
  if (verify.exitCode !== 0) throw new Error("codesign verification failed on the downloaded update");
190
185
 
186
+ // Keep only the newest cached tar.
187
+ for (const f of readdirSync(tarsDir)) {
188
+ if (f !== `${m.version}.tar`) rmSync(join(tarsDir, f), { force: true });
189
+ }
190
+
191
191
  this.#staged = staged;
192
192
  this.#setStatus("update-available");
193
193
  } catch (err) {
@@ -196,11 +196,7 @@ export class Updater {
196
196
  }
197
197
  }
198
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
- */
199
+ /** Swap the running `.app` with the downloaded bundle and relaunch. */
204
200
  async applyAndRelaunch(): Promise<void> {
205
201
  const v = this.#version();
206
202
  if (!v || !this.#staged) throw new Error("no staged update — call download() first");
@@ -211,7 +207,6 @@ export class Updater {
211
207
  `APP=${sh(runningApp)}`,
212
208
  `NEW=${sh(this.#staged)}`,
213
209
  `PID=${process.pid}`,
214
- // Wait for the running app to fully exit before swapping.
215
210
  `while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done`,
216
211
  `sleep 0.3`,
217
212
  `rm -rf "$APP"`,
@@ -227,7 +222,6 @@ export class Updater {
227
222
  }).unref();
228
223
 
229
224
  this.#setStatus("complete");
230
- // Quit; the detached helper does the swap + relaunch after we exit.
231
225
  runtime().core.quit();
232
226
  } catch (err) {
233
227
  this.#fail(err);
@@ -250,6 +244,39 @@ export class Updater {
250
244
 
251
245
  // ---- internals ----
252
246
 
247
+ async #fetchToFile(
248
+ url: string,
249
+ dest: string,
250
+ expectedSha: string,
251
+ onProgress?: (p: UpdateProgress) => void,
252
+ ): Promise<void> {
253
+ const res = await fetch(url, { redirect: "follow" });
254
+ if (!res.ok || !res.body) throw new Error(`download ${res.status}`);
255
+ const total = Number(res.headers.get("content-length") ?? 0);
256
+ const reader = res.body.getReader();
257
+ const chunks: Uint8Array[] = [];
258
+ let received = 0;
259
+ for (;;) {
260
+ const { done, value } = await reader.read();
261
+ if (done) break;
262
+ chunks.push(value);
263
+ received += value.byteLength;
264
+ const p: UpdateProgress = { received, total, fraction: total ? received / total : 0 };
265
+ this.#emit("progress", p);
266
+ onProgress?.(p);
267
+ }
268
+ const buf = Buffer.concat(chunks);
269
+ const got = new Bun.CryptoHasher("sha256").update(buf).digest("hex");
270
+ if (got !== expectedSha) throw new Error("download hash mismatch");
271
+ await Bun.write(dest, buf);
272
+ }
273
+
274
+ async #verify(file: string, expectedSha: string): Promise<void> {
275
+ const buf = await Bun.file(file).bytes();
276
+ const got = new Bun.CryptoHasher("sha256").update(buf).digest("hex");
277
+ if (got !== expectedSha) throw new Error("reconstructed bundle hash mismatch");
278
+ }
279
+
253
280
  #version(): VersionInfo | null {
254
281
  if (this.#info !== undefined) return this.#info;
255
282
  const dir = this.#resourcesDir();
@@ -270,17 +297,22 @@ export class Updater {
270
297
  try {
271
298
  return runtime().resourcesDir;
272
299
  } catch {
273
- return undefined; // detached (not under the host)
300
+ return undefined;
274
301
  }
275
302
  }
276
303
 
304
+ #corePath(): string {
305
+ const p = runtime().corePath;
306
+ if (!p) throw new Error("libmirin_core path unavailable");
307
+ return p;
308
+ }
309
+
277
310
  #prefix(): string {
278
- const v = this.#version()!;
279
- return `${v.channel}-darwin-${process.arch}`;
311
+ return `${this.#version()!.channel}-darwin-${process.arch}`;
280
312
  }
281
313
 
282
- #updatesDir(v: VersionInfo): string {
283
- return join(homedir(), "Library", "Application Support", v.identifier, v.channel, "updates");
314
+ #supportDir(v: VersionInfo): string {
315
+ return join(homedir(), "Library", "Application Support", v.identifier, v.channel);
284
316
  }
285
317
 
286
318
  #setStatus(status: UpdaterStatus): void {
@@ -297,7 +329,6 @@ export class Updater {
297
329
 
298
330
  #emit<K extends keyof UpdaterEvents>(type: K, payload: UpdaterEvents[K]): void {
299
331
  this.#listeners.get(type)?.forEach((fn) => fn(payload));
300
- // Bridge to webviews so frontends can react without polling.
301
332
  try {
302
333
  runtime().rpc.broadcast(`mirin:updater:${type}`, payload);
303
334
  } catch {