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 +2 -1
- package/src/app.ts +23 -0
- package/src/client.ts +20 -0
- package/src/codec.ts +62 -0
- package/src/config.ts +101 -0
- package/src/host.ts +21 -4
- package/src/index.ts +13 -3
- package/src/logger.ts +97 -0
- package/src/native.ts +5 -0
- package/src/rpc-server.ts +30 -1
- package/src/runtime.ts +52 -1
- package/src/sidecar.ts +107 -0
- package/src/updater.ts +381 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mirinjs",
|
|
3
|
-
"version": "0.0
|
|
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
|
-
|
|
23
|
-
|
|
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 =
|
|
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: {
|
|
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`,
|
|
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 {
|
|
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:
|
|
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 = {
|
|
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();
|