mirinjs 0.0.1-alpha.11 → 0.0.1-alpha.13
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/codec.ts +62 -0
- package/src/runtime.ts +10 -1
- package/src/updater.ts +122 -91
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.13",
|
|
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 = {
|
|
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
|
|
5
|
-
* `release` is
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
* packaged app with `release` set; in `mirin dev`
|
|
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 {
|
|
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;
|
|
76
|
-
#
|
|
77
|
-
#staged: string | null = null;
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
144
|
-
*
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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;
|
|
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
|
-
|
|
279
|
-
return `${v.channel}-darwin-${process.arch}`;
|
|
311
|
+
return `${this.#version()!.channel}-darwin-${process.arch}`;
|
|
280
312
|
}
|
|
281
313
|
|
|
282
|
-
#
|
|
283
|
-
return join(homedir(), "Library", "Application Support", v.identifier, v.channel
|
|
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 {
|