mirinjs 0.0.1-alpha.10 → 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 +2 -1
- package/src/app.ts +5 -0
- package/src/codec.ts +62 -0
- package/src/config.ts +15 -0
- package/src/host.ts +1 -1
- package/src/index.ts +2 -0
- package/src/runtime.ts +13 -1
- package/src/updater.ts +345 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mirinjs",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
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/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 = {
|
|
@@ -279,6 +281,9 @@ export interface Dock {
|
|
|
279
281
|
class MirinApp extends Emitter<AppEvents> {
|
|
280
282
|
readonly windows = new Windows();
|
|
281
283
|
|
|
284
|
+
/** Auto-updater (macOS). Inert unless the app was built with `release` set. */
|
|
285
|
+
readonly updater: Updater = updater;
|
|
286
|
+
|
|
282
287
|
/** macOS Dock-icon controls. Hiding suits resident, hotkey-summoned apps. */
|
|
283
288
|
readonly dock: Dock = {
|
|
284
289
|
hide: () => this.#setDock(false),
|
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
|
@@ -86,9 +86,24 @@ export interface MirinConfig {
|
|
|
86
86
|
* into an `.icns`. Embedded in the bundle for the Dock and Finder.
|
|
87
87
|
*/
|
|
88
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;
|
|
89
97
|
windows: Record<string, WindowConfig>;
|
|
90
98
|
}
|
|
91
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
|
+
|
|
92
107
|
/** Identity function for typing/intellisense. `const` generic preserves window names as literal keys. */
|
|
93
108
|
export function defineConfig<const T extends MirinConfig>(config: T): T {
|
|
94
109
|
return config;
|
package/src/host.ts
CHANGED
|
@@ -48,7 +48,7 @@ const coreConfig = JSON.parse(
|
|
|
48
48
|
const core = new Core(corePath);
|
|
49
49
|
|
|
50
50
|
const worker = new Worker(workerPath, {
|
|
51
|
-
workerData: { corePath, manifest, devUrl: process.env.MIRIN_DEV_URL },
|
|
51
|
+
workerData: { corePath, manifest, devUrl: process.env.MIRIN_DEV_URL, resourcesDir },
|
|
52
52
|
});
|
|
53
53
|
worker.on("error", (err) => console.error("[mirin worker]", err));
|
|
54
54
|
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export { dialog } from "./dialog.ts";
|
|
|
23
23
|
export { clipboard } from "./clipboard.ts";
|
|
24
24
|
export { globalShortcut } from "./shortcut.ts";
|
|
25
25
|
export { logger, setLogLevel, getLogLevel, Logger } from "./logger.ts";
|
|
26
|
+
export { updater, Updater } from "./updater.ts";
|
|
26
27
|
export { NotAttachedError } from "./runtime.ts";
|
|
27
28
|
|
|
28
29
|
export type {
|
|
@@ -38,6 +39,7 @@ export type {
|
|
|
38
39
|
} from "./app.ts";
|
|
39
40
|
export type { MenuItemTemplate, MenuRole } from "./menu.ts";
|
|
40
41
|
export type { LogLevel } from "./logger.ts";
|
|
42
|
+
export type { UpdaterStatus, UpdateInfo, UpdateProgress, UpdaterEvents } from "./updater.ts";
|
|
41
43
|
export type { TrayOptions } from "./tray.ts";
|
|
42
44
|
export type { OpenDialogOptions, SaveDialogOptions, MessageDialogOptions } from "./dialog.ts";
|
|
43
45
|
export type {
|
package/src/runtime.ts
CHANGED
|
@@ -18,6 +18,10 @@ 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;
|
|
23
|
+
/** Path to libmirin_core (for the updater codec). */
|
|
24
|
+
corePath?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export class NotAttachedError extends Error {
|
|
@@ -79,6 +83,7 @@ export function boot(): void {
|
|
|
79
83
|
corePath?: string;
|
|
80
84
|
manifest?: { windows?: Record<string, WindowConfig> };
|
|
81
85
|
devUrl?: string;
|
|
86
|
+
resourcesDir?: string;
|
|
82
87
|
};
|
|
83
88
|
const corePath = data.corePath ?? process.env.MIRIN_CORE;
|
|
84
89
|
if (!corePath) return; // not under the host; the API stays detached
|
|
@@ -92,6 +97,13 @@ export function boot(): void {
|
|
|
92
97
|
data.manifest?.windows ?? {},
|
|
93
98
|
).map(([name, cfg]) => ({ name, ...cfg }));
|
|
94
99
|
|
|
95
|
-
current = {
|
|
100
|
+
current = {
|
|
101
|
+
core,
|
|
102
|
+
rpc,
|
|
103
|
+
manifestWindows,
|
|
104
|
+
devUrl: data.devUrl,
|
|
105
|
+
resourcesDir: data.resourcesDir,
|
|
106
|
+
corePath,
|
|
107
|
+
};
|
|
96
108
|
core.onEvent(dispatch);
|
|
97
109
|
}
|
package/src/updater.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
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
|
+
export type UpdaterStatus =
|
|
24
|
+
| "idle"
|
|
25
|
+
| "checking"
|
|
26
|
+
| "update-available"
|
|
27
|
+
| "downloading"
|
|
28
|
+
| "applying"
|
|
29
|
+
| "complete"
|
|
30
|
+
| "error";
|
|
31
|
+
|
|
32
|
+
/** The running app's embedded identity (Resources/version.json). */
|
|
33
|
+
interface VersionInfo {
|
|
34
|
+
version: string;
|
|
35
|
+
channel: string;
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
name: string;
|
|
38
|
+
identifier: string;
|
|
39
|
+
}
|
|
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
|
+
|
|
53
|
+
/** A published release the running app can update to. */
|
|
54
|
+
export interface UpdateInfo {
|
|
55
|
+
version: string;
|
|
56
|
+
currentVersion: string;
|
|
57
|
+
channel: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UpdateProgress {
|
|
61
|
+
received: number;
|
|
62
|
+
total: number;
|
|
63
|
+
/** 0..1 (0 when total is unknown). */
|
|
64
|
+
fraction: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type UpdaterEvents = {
|
|
68
|
+
status: { status: UpdaterStatus };
|
|
69
|
+
progress: UpdateProgress;
|
|
70
|
+
"update-available": UpdateInfo;
|
|
71
|
+
error: { message: string };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type Listener<P> = (payload: P) => void;
|
|
75
|
+
|
|
76
|
+
export class Updater {
|
|
77
|
+
#listeners = new Map<keyof UpdaterEvents, Set<Listener<unknown>>>();
|
|
78
|
+
#status: UpdaterStatus = "idle";
|
|
79
|
+
#info: VersionInfo | null | undefined;
|
|
80
|
+
#manifest: Manifest | null = null;
|
|
81
|
+
#staged: string | null = null;
|
|
82
|
+
#autoCheck: ReturnType<typeof setInterval> | undefined;
|
|
83
|
+
|
|
84
|
+
on<K extends keyof UpdaterEvents>(type: K, listener: Listener<UpdaterEvents[K]>): () => void {
|
|
85
|
+
let set = this.#listeners.get(type);
|
|
86
|
+
if (!set) this.#listeners.set(type, (set = new Set()));
|
|
87
|
+
set.add(listener as Listener<unknown>);
|
|
88
|
+
return () => set!.delete(listener as Listener<unknown>);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get status(): UpdaterStatus {
|
|
92
|
+
return this.#status;
|
|
93
|
+
}
|
|
94
|
+
get currentVersion(): string {
|
|
95
|
+
return this.#version()?.version ?? "0.0.0";
|
|
96
|
+
}
|
|
97
|
+
get channel(): string {
|
|
98
|
+
return this.#version()?.channel ?? "stable";
|
|
99
|
+
}
|
|
100
|
+
/** Whether updates are configured (packaged build with `release` set). */
|
|
101
|
+
get enabled(): boolean {
|
|
102
|
+
return this.#version() != null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Fetch the channel manifest; report an available update or `null`. Never throws. */
|
|
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 Manifest;
|
|
115
|
+
if (!m.version || m.version === v.version) {
|
|
116
|
+
this.#manifest = null;
|
|
117
|
+
this.#setStatus("idle");
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
this.#manifest = m;
|
|
121
|
+
const info: UpdateInfo = { version: m.version, currentVersion: v.version, channel: v.channel };
|
|
122
|
+
this.#setStatus("update-available");
|
|
123
|
+
this.#emit("update-available", info);
|
|
124
|
+
return info;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
this.#fail(err);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Download + verify the pending update — a delta patch from the installed
|
|
133
|
+
* version when available, else the full bundle. Emits `progress`.
|
|
134
|
+
*/
|
|
135
|
+
async download(onProgress?: (p: UpdateProgress) => void): Promise<void> {
|
|
136
|
+
const v = this.#version();
|
|
137
|
+
const m = this.#manifest;
|
|
138
|
+
if (!v || !m) throw new Error("no update to download — call checkForUpdate() first");
|
|
139
|
+
this.#setStatus("downloading");
|
|
140
|
+
try {
|
|
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
|
+
}
|
|
167
|
+
}
|
|
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);
|
|
173
|
+
}
|
|
174
|
+
|
|
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`);
|
|
181
|
+
if (!existsSync(staged)) throw new Error(`extracted bundle not found at ${staged}`);
|
|
182
|
+
|
|
183
|
+
const verify = await $`codesign --verify --deep --strict ${staged}`.quiet().nothrow();
|
|
184
|
+
if (verify.exitCode !== 0) throw new Error("codesign verification failed on the downloaded update");
|
|
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
|
+
this.#staged = staged;
|
|
192
|
+
this.#setStatus("update-available");
|
|
193
|
+
} catch (err) {
|
|
194
|
+
this.#fail(err);
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Swap the running `.app` with the downloaded bundle and relaunch. */
|
|
200
|
+
async applyAndRelaunch(): Promise<void> {
|
|
201
|
+
const v = this.#version();
|
|
202
|
+
if (!v || !this.#staged) throw new Error("no staged update — call download() first");
|
|
203
|
+
this.#setStatus("applying");
|
|
204
|
+
try {
|
|
205
|
+
const runningApp = join(this.#resourcesDir()!, "..", "..");
|
|
206
|
+
const script = [
|
|
207
|
+
`APP=${sh(runningApp)}`,
|
|
208
|
+
`NEW=${sh(this.#staged)}`,
|
|
209
|
+
`PID=${process.pid}`,
|
|
210
|
+
`while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done`,
|
|
211
|
+
`sleep 0.3`,
|
|
212
|
+
`rm -rf "$APP"`,
|
|
213
|
+
`mv "$NEW" "$APP"`,
|
|
214
|
+
`xattr -r -d com.apple.quarantine "$APP" 2>/dev/null || true`,
|
|
215
|
+
`open "$APP"`,
|
|
216
|
+
].join("\n");
|
|
217
|
+
|
|
218
|
+
Bun.spawn(["/bin/sh", "-c", script], {
|
|
219
|
+
stdin: "ignore",
|
|
220
|
+
stdout: "ignore",
|
|
221
|
+
stderr: "ignore",
|
|
222
|
+
}).unref();
|
|
223
|
+
|
|
224
|
+
this.#setStatus("complete");
|
|
225
|
+
runtime().core.quit();
|
|
226
|
+
} catch (err) {
|
|
227
|
+
this.#fail(err);
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Poll for updates every `intervalMs`. Returns a stop function. */
|
|
233
|
+
startAutoCheck(intervalMs = 6 * 60 * 60 * 1000): () => void {
|
|
234
|
+
this.stopAutoCheck();
|
|
235
|
+
void this.checkForUpdate();
|
|
236
|
+
this.#autoCheck = setInterval(() => void this.checkForUpdate(), intervalMs);
|
|
237
|
+
return () => this.stopAutoCheck();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
stopAutoCheck(): void {
|
|
241
|
+
if (this.#autoCheck) clearInterval(this.#autoCheck);
|
|
242
|
+
this.#autoCheck = undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- internals ----
|
|
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
|
+
|
|
280
|
+
#version(): VersionInfo | null {
|
|
281
|
+
if (this.#info !== undefined) return this.#info;
|
|
282
|
+
const dir = this.#resourcesDir();
|
|
283
|
+
const path = dir ? join(dir, "version.json") : undefined;
|
|
284
|
+
if (path && existsSync(path)) {
|
|
285
|
+
try {
|
|
286
|
+
this.#info = JSON.parse(readFileSync(path, "utf8")) as VersionInfo;
|
|
287
|
+
} catch {
|
|
288
|
+
this.#info = null;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
this.#info = null;
|
|
292
|
+
}
|
|
293
|
+
return this.#info;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#resourcesDir(): string | undefined {
|
|
297
|
+
try {
|
|
298
|
+
return runtime().resourcesDir;
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#corePath(): string {
|
|
305
|
+
const p = runtime().corePath;
|
|
306
|
+
if (!p) throw new Error("libmirin_core path unavailable");
|
|
307
|
+
return p;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#prefix(): string {
|
|
311
|
+
return `${this.#version()!.channel}-darwin-${process.arch}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#supportDir(v: VersionInfo): string {
|
|
315
|
+
return join(homedir(), "Library", "Application Support", v.identifier, v.channel);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#setStatus(status: UpdaterStatus): void {
|
|
319
|
+
this.#status = status;
|
|
320
|
+
this.#emit("status", { status });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#fail(err: unknown): void {
|
|
324
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
325
|
+
this.#status = "error";
|
|
326
|
+
this.#emit("status", { status: "error" });
|
|
327
|
+
this.#emit("error", { message });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#emit<K extends keyof UpdaterEvents>(type: K, payload: UpdaterEvents[K]): void {
|
|
331
|
+
this.#listeners.get(type)?.forEach((fn) => fn(payload));
|
|
332
|
+
try {
|
|
333
|
+
runtime().rpc.broadcast(`mirin:updater:${type}`, payload);
|
|
334
|
+
} catch {
|
|
335
|
+
// detached / rpc not up — ignore
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Single-quote a path for safe interpolation into a /bin/sh script. */
|
|
341
|
+
function sh(p: string): string {
|
|
342
|
+
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export const updater = new Updater();
|