@mirinjs/cli 0.0.1-alpha.9 → 0.1.0-alpha.1

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/src/dev.ts CHANGED
@@ -9,16 +9,27 @@
9
9
  */
10
10
 
11
11
  import { $ } from "bun";
12
- import { mkdirSync } from "node:fs";
12
+ import { mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
13
+ import { createServer } from "node:net";
13
14
  import { join } from "node:path";
14
15
  import { buildAppBundle } from "./bundle.ts";
16
+ import { buildWindowsBundle } from "./bundle-win.ts";
15
17
  import { resolveArtifacts } from "./artifacts.ts";
16
18
 
17
- const DEV_URL = "http://localhost:5173";
19
+ const IS_WINDOWS = process.platform === "win32";
20
+ import { sweepBuildTemps } from "./temps.ts";
21
+ import { normalizeSidecars, compileWorkers } from "./extras.ts";
22
+
23
+ /** Vite's default port. `mirin dev` probes upward from here for a free one, so a
24
+ * second dev session (or anything already on 5173) doesn't collide. */
25
+ const DEV_PORT_BASE = 5173;
18
26
 
19
27
  export async function dev(projectDir = process.cwd()): Promise<number> {
20
28
  const work = join(projectDir, ".mirin");
21
29
  mkdirSync(work, { recursive: true });
30
+ // `bun build --compile` drops temp *.bun-build files in the cwd; clear any left
31
+ // behind by a previously interrupted run so they don't pile up in the project.
32
+ sweepBuildTemps(projectDir);
22
33
 
23
34
  // --- load the manifest ---
24
35
  const config = (await import(join(projectDir, "mirin.config.ts"))).default;
@@ -31,31 +42,64 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
31
42
 
32
43
  // --- compile the Bun host + bundle the Worker ---
33
44
  console.log("[mirin dev] compiling host + bundling main process…");
34
- const hostExe = join(work, "host");
45
+ // `bun build --compile` emits an `.exe` on Windows; name it so explicitly.
46
+ const hostExe = join(work, IS_WINDOWS ? "host.exe" : "host");
35
47
  const workerJs = join(work, "worker.js");
36
48
  await $`bun build --compile ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
37
49
  await $`bun build ${mainEntry} --target=bun --outfile ${workerJs}`.cwd(projectDir);
38
50
 
39
- // --- assemble the dev .app ---
51
+ // Extra assets (dev): compile workers into .mirin/workers and symlink sidecar
52
+ // binaries into .mirin/sidecars (no copy/sign in dev — they run unsigned locally).
53
+ const workersDir = join(work, "workers");
54
+ await compileWorkers(projectDir, config.workers, workersDir, false);
55
+ const sidecarsDir = join(work, "sidecars");
56
+ mkdirSync(sidecarsDir, { recursive: true });
57
+ for (const sc of normalizeSidecars(projectDir, config.sidecars)) {
58
+ const link = join(sidecarsDir, sc.name);
59
+ rmSync(link, { force: true });
60
+ if (existsSync(sc.src)) symlinkSync(sc.src, link);
61
+ else console.warn(`[mirin dev] sidecar "${sc.name}" not found: ${sc.src}`);
62
+ }
63
+
64
+ // --- assemble the dev bundle (Windows app folder, or macOS .app) ---
40
65
  console.log("[mirin dev] assembling dev bundle…");
41
- const { app, exe } = await buildAppBundle({
42
- appName,
43
- bundleId,
44
- outDir: work,
45
- hostExe,
46
- coreDylib: artifacts.coreDylib,
47
- helperBin: artifacts.helperBin,
48
- cefPath: artifacts.cefPath,
49
- icon: config.icon ? join(projectDir, config.icon) : undefined,
50
- });
66
+ const { app, exe } = IS_WINDOWS
67
+ ? await buildWindowsBundle({
68
+ appName,
69
+ outDir: work,
70
+ hostExe,
71
+ coreDll: artifacts.coreDylib,
72
+ helperExe: artifacts.helperBin,
73
+ cefPath: artifacts.cefPath,
74
+ icon: config.icon ? join(projectDir, config.icon) : undefined,
75
+ })
76
+ : await buildAppBundle({
77
+ appName,
78
+ bundleId,
79
+ outDir: work,
80
+ hostExe,
81
+ coreDylib: artifacts.coreDylib,
82
+ helperBin: artifacts.helperBin,
83
+ cefPath: artifacts.cefPath,
84
+ icon: config.icon ? join(projectDir, config.icon) : undefined,
85
+ urlSchemes: config.urlSchemes,
86
+ });
51
87
 
52
- // --- start Vite ---
53
- console.log("[mirin dev] starting Vite dev server…");
54
- const vite = Bun.spawn(["bunx", "vite", "--clearScreen", "false"], {
55
- cwd: projectDir,
56
- stdio: ["ignore", "inherit", "inherit"],
57
- });
58
- await waitForUrl(DEV_URL, 15_000);
88
+ // --- start Vite on a free port so concurrent dev sessions don't collide ---
89
+ // `--port <free> --strictPort` pins Vite to the port we probed (overriding any
90
+ // port/strictPort in the app's vite.config) and passes the real URL to the app,
91
+ // so the host and Vite never disagree about which port is in use.
92
+ const port = await findFreePort(DEV_PORT_BASE);
93
+ const devUrl = `http://localhost:${port}`;
94
+ console.log(`[mirin dev] starting Vite dev server on ${devUrl}…`);
95
+ const vite = Bun.spawn(
96
+ ["bunx", "vite", "--clearScreen", "false", "--port", String(port), "--strictPort"],
97
+ {
98
+ cwd: projectDir,
99
+ stdio: ["ignore", "inherit", "inherit"],
100
+ },
101
+ );
102
+ await waitForUrl(devUrl, 15_000);
59
103
 
60
104
  // --- launch the app ---
61
105
  console.log(`[mirin dev] launching ${appName}…`);
@@ -63,11 +107,17 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
63
107
  cwd: projectDir,
64
108
  env: {
65
109
  ...process.env,
66
- MIRIN_CORE: join(app, "Contents", "MacOS", "libmirin_core.dylib"),
110
+ MIRIN_CORE: IS_WINDOWS
111
+ ? join(app, "mirin_core.dll")
112
+ : join(app, "Contents", "MacOS", "libmirin_core.dylib"),
67
113
  MIRIN_WORKER: workerJs,
68
- MIRIN_DEV_URL: DEV_URL,
114
+ MIRIN_DEV_URL: devUrl,
69
115
  MIRIN_MANIFEST_JSON: JSON.stringify({ windows: config.windows }),
70
- MIRIN_CONFIG_JSON: "{}",
116
+ // dev: true → enables inspect-element AND gives this run its own `-dev`
117
+ // CEF cache dir, so `mirin dev` can run alongside the installed app.
118
+ MIRIN_CONFIG_JSON: JSON.stringify({ dev: true }),
119
+ MIRIN_SIDECAR_DIR: sidecarsDir,
120
+ MIRIN_WORKERS_DIR: workersDir,
71
121
  },
72
122
  stdio: ["ignore", "inherit", "inherit"],
73
123
  });
@@ -84,6 +134,32 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
84
134
  return code;
85
135
  }
86
136
 
137
+ /** First free TCP port at or above `start` on loopback. */
138
+ async function findFreePort(start: number): Promise<number> {
139
+ for (let port = start; port < start + 100; port++) {
140
+ if (await isPortFree(port)) return port;
141
+ }
142
+ throw new Error(`[mirin dev] no free port in ${start}–${start + 99}`);
143
+ }
144
+
145
+ /** Free only if neither IPv4 nor IPv6 loopback reports the port in use — Vite
146
+ * binds `localhost` (often `::1`), so a v4-only probe would miss an IPv6 holder. */
147
+ async function isPortFree(port: number): Promise<boolean> {
148
+ const [v4, v6] = await Promise.all([portInUse(port, "127.0.0.1"), portInUse(port, "::1")]);
149
+ return !v4 && !v6;
150
+ }
151
+
152
+ function portInUse(port: number, host: string): Promise<boolean> {
153
+ return new Promise((resolve) => {
154
+ const srv = createServer();
155
+ // Only EADDRINUSE means taken; other errors (e.g. EADDRNOTAVAIL when IPv6 is
156
+ // off) just mean we can't test that family — treat as not-in-use.
157
+ srv.once("error", (err: NodeJS.ErrnoException) => resolve(err.code === "EADDRINUSE"));
158
+ srv.once("listening", () => srv.close(() => resolve(false)));
159
+ srv.listen(port, host);
160
+ });
161
+ }
162
+
87
163
  async function waitForUrl(url: string, timeoutMs: number): Promise<void> {
88
164
  const deadline = Date.now() + timeoutMs;
89
165
  while (Date.now() < deadline) {
package/src/dmg.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * macOS `.dmg` installer for `mirin release`.
3
+ *
4
+ * Two paths:
5
+ * - **plain** (default): stage the `.app` + an `/Applications` symlink, then
6
+ * `hdiutil create … -format ULFO`. Rock-solid in CI — this is what ships when
7
+ * `dmg: true`. ULFO (lzfse) handles large CEF frameworks best on modern macOS.
8
+ * - **laid-out**: when a `background` or any window/icon position is configured,
9
+ * build a read-write image, style the Finder window via AppleScript, then
10
+ * convert to the compressed read-only format. Best-effort — falls back to the
11
+ * plain DMG if Finder automation isn't available (e.g. a headless runner).
12
+ *
13
+ * The DMG is codesigned (a DMG carries no entitlements / hardened runtime — it's
14
+ * not executable code) and, when notary credentials are present, notarized +
15
+ * stapled so a fresh download opens without a Gatekeeper prompt.
16
+ */
17
+
18
+ import { $ } from "bun";
19
+ import { cpSync, mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
20
+ import { join, basename, isAbsolute } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ export interface DmgOptions {
24
+ volumeName?: string;
25
+ format?: "ULFO" | "UDZO" | "UDBZ";
26
+ background?: string;
27
+ windowSize?: { width: number; height: number };
28
+ iconSize?: number;
29
+ appPosition?: { x: number; y: number };
30
+ applicationsPosition?: { x: number; y: number };
31
+ }
32
+
33
+ export interface BuildDmgInput {
34
+ /** Path to the (signed) `.app`. */
35
+ app: string;
36
+ appName: string;
37
+ /** Output directory for the `.dmg`. */
38
+ outDir: string;
39
+ /** Output file name (e.g. `stable-darwin-arm64-Anko.dmg`). */
40
+ fileName: string;
41
+ /** Resolved DMG options. */
42
+ options: DmgOptions;
43
+ /** Project root, for resolving a relative `background` path. */
44
+ projectDir: string;
45
+ /** Codesign identity ("-"/undefined → ad-hoc, no notarization). */
46
+ signIdentity?: string;
47
+ }
48
+
49
+ /** hdiutil volume names choke on some punctuation; keep it tame but readable. */
50
+ function volumeName(name: string): string {
51
+ return name.replace(/[/:\\]/g, " ").trim() || "App";
52
+ }
53
+
54
+ /**
55
+ * Notarize + staple a `.app` or `.dmg` using MIRIN_NOTARY_* env credentials.
56
+ * No-op (returns false) when credentials are absent. Throws on rejection,
57
+ * printing the notary log so the failure is diagnosable.
58
+ */
59
+ export async function notarizeAndStaple(target: string): Promise<boolean> {
60
+ const apple = process.env.MIRIN_NOTARY_APPLE_ID;
61
+ const pw = process.env.MIRIN_NOTARY_PASSWORD;
62
+ const team = process.env.MIRIN_NOTARY_TEAM_ID;
63
+ if (!apple || !pw || !team) return false;
64
+
65
+ const isApp = target.endsWith(".app");
66
+ // notarytool wants a zip for a bundle; a .dmg is submitted as-is.
67
+ const submitPath = isApp ? `${target}.notarize.zip` : target;
68
+ if (isApp) await $`ditto -c -k --keepParent ${target} ${submitPath}`;
69
+
70
+ console.log(`[mirin release] notarizing ${basename(target)} (this can take a few minutes)…`);
71
+ const out =
72
+ await $`xcrun notarytool submit ${submitPath} --apple-id ${apple} --password ${pw} --team-id ${team} --wait --output-format json`.text();
73
+ if (isApp) rmSync(submitPath, { force: true });
74
+
75
+ let sub: { id?: string; status?: string } = {};
76
+ try {
77
+ sub = JSON.parse(out);
78
+ } catch {
79
+ console.error(out);
80
+ }
81
+ if (sub.status !== "Accepted") {
82
+ console.error(`[mirin release] notarization ${sub.status ?? "failed"} (id: ${sub.id ?? "?"})`);
83
+ if (sub.id) {
84
+ const log =
85
+ await $`xcrun notarytool log ${sub.id} --apple-id ${apple} --password ${pw} --team-id ${team}`
86
+ .text()
87
+ .catch(() => "");
88
+ if (log) console.error(log);
89
+ }
90
+ throw new Error(`notarization not accepted: ${sub.status ?? "unknown"}`);
91
+ }
92
+ await $`xcrun stapler staple ${target}`;
93
+ return true;
94
+ }
95
+
96
+ /** Does this config ask for a styled Finder window (vs. a plain DMG)? */
97
+ function wantsLayout(o: DmgOptions): boolean {
98
+ return !!(o.background || o.appPosition || o.applicationsPosition || o.windowSize || o.iconSize);
99
+ }
100
+
101
+ /**
102
+ * Build (and codesign) the `.dmg`, returning its path. Notarization is the
103
+ * caller's responsibility (so it can be sequenced with the other artifacts).
104
+ */
105
+ export async function buildDmg(input: BuildDmgInput): Promise<string> {
106
+ const { app, appName, outDir, fileName, options, projectDir, signIdentity } = input;
107
+ const vol = volumeName(options.volumeName ?? appName);
108
+ const format = options.format ?? "ULFO";
109
+ const dmgPath = join(outDir, fileName);
110
+ rmSync(dmgPath, { force: true });
111
+
112
+ const staging = join(tmpdir(), `mirin-dmg-${process.pid}`);
113
+ rmSync(staging, { recursive: true, force: true });
114
+ mkdirSync(staging, { recursive: true });
115
+ try {
116
+ // BSD cp -R preserves the bundle's symlinks/signatures.
117
+ await $`cp -R ${app} ${join(staging, basename(app))}`;
118
+ symlinkSync("/Applications", join(staging, "Applications"));
119
+
120
+ let built = false;
121
+ if (wantsLayout(options)) {
122
+ try {
123
+ await buildLaidOutDmg({ staging, vol, format, dmgPath, options, projectDir, appFile: basename(app) });
124
+ built = true;
125
+ } catch (e) {
126
+ console.warn(
127
+ `[mirin release] DMG layout failed (${e instanceof Error ? e.message : e}); ` +
128
+ `falling back to a plain DMG.`,
129
+ );
130
+ rmSync(dmgPath, { force: true });
131
+ }
132
+ }
133
+ if (!built) {
134
+ await $`hdiutil create -volname ${vol} -srcfolder ${staging} -ov -format ${format} ${dmgPath}`.quiet();
135
+ }
136
+ } finally {
137
+ rmSync(staging, { recursive: true, force: true });
138
+ }
139
+
140
+ // Sign the image itself (Gatekeeper checks the DMG's signature too).
141
+ if (signIdentity && signIdentity !== "-") {
142
+ await $`codesign --force --timestamp --sign ${signIdentity} ${dmgPath}`.quiet();
143
+ } else if (signIdentity === "-") {
144
+ await $`codesign --force --sign - ${dmgPath}`.quiet();
145
+ }
146
+ return dmgPath;
147
+ }
148
+
149
+ /**
150
+ * Read-write image → style the Finder window via AppleScript → convert to the
151
+ * compressed read-only `format`. Throws if any step fails (caller falls back).
152
+ */
153
+ async function buildLaidOutDmg(args: {
154
+ staging: string;
155
+ vol: string;
156
+ format: string;
157
+ dmgPath: string;
158
+ options: DmgOptions;
159
+ projectDir: string;
160
+ appFile: string;
161
+ }): Promise<void> {
162
+ const { staging, vol, format, dmgPath, options, projectDir, appFile } = args;
163
+ const win = options.windowSize ?? { width: 640, height: 400 };
164
+ const iconSize = options.iconSize ?? 128;
165
+ const appPos = options.appPosition ?? { x: Math.round(win.width * 0.25), y: Math.round(win.height * 0.5) };
166
+ const appsPos =
167
+ options.applicationsPosition ?? { x: Math.round(win.width * 0.75), y: Math.round(win.height * 0.5) };
168
+
169
+ // Stage a background image (if any) under a hidden folder Finder can reference.
170
+ let bgFile: string | undefined;
171
+ if (options.background) {
172
+ const src = isAbsolute(options.background)
173
+ ? options.background
174
+ : join(projectDir, options.background);
175
+ if (!existsSync(src)) throw new Error(`background not found: ${src}`);
176
+ const bgDir = join(staging, ".background");
177
+ mkdirSync(bgDir, { recursive: true });
178
+ const ext = src.slice(src.lastIndexOf("."));
179
+ bgFile = `bg${ext}`;
180
+ cpSync(src, join(bgDir, bgFile));
181
+ }
182
+
183
+ const rw = `${dmgPath}.rw.dmg`;
184
+ rmSync(rw, { force: true });
185
+ // Read-write image sized to the staged contents, with slack for HFS overhead.
186
+ await $`hdiutil create -volname ${vol} -srcfolder ${staging} -fs HFS+ -format UDRW -ov ${rw}`.quiet();
187
+
188
+ // Attach without auto-opening a Finder window; parse the real mountpoint.
189
+ const attach =
190
+ await $`hdiutil attach ${rw} -readwrite -noverify -noautoopen`.text();
191
+ const mount = attach
192
+ .split("\n")
193
+ .map((l) => l.trim())
194
+ .find((l) => l.includes("/Volumes/"))
195
+ ?.split("\t")
196
+ .pop()
197
+ ?.trim();
198
+ if (!mount || !existsSync(mount)) {
199
+ await $`hdiutil detach ${rw}`.quiet().catch(() => {});
200
+ throw new Error("could not resolve DMG mountpoint");
201
+ }
202
+
203
+ try {
204
+ const bgClause = bgFile
205
+ ? `set background picture of viewOptions to file ".background:${bgFile}"`
206
+ : "";
207
+ const script = `
208
+ tell application "Finder"
209
+ tell disk "${vol}"
210
+ open
211
+ set current view of container window to icon view
212
+ set toolbar visible of container window to false
213
+ set statusbar visible of container window to false
214
+ set the bounds of container window to {200, 120, ${200 + win.width}, ${120 + win.height}}
215
+ set viewOptions to the icon view options of container window
216
+ set arrangement of viewOptions to not arranged
217
+ set icon size of viewOptions to ${iconSize}
218
+ ${bgClause}
219
+ set position of item "${appFile}" of container window to {${appPos.x}, ${appPos.y}}
220
+ set position of item "Applications" of container window to {${appsPos.x}, ${appsPos.y}}
221
+ update without registering applications
222
+ delay 1
223
+ close
224
+ end tell
225
+ end tell`;
226
+ await $`osascript -e ${script}`.quiet();
227
+ await $`sync`.quiet().catch(() => {});
228
+ } finally {
229
+ await $`hdiutil detach ${mount}`.quiet().catch(async () => {
230
+ await $`hdiutil detach ${rw} -force`.quiet().catch(() => {});
231
+ });
232
+ }
233
+
234
+ await $`hdiutil convert ${rw} -format ${format} -ov -o ${dmgPath}`.quiet();
235
+ rmSync(rw, { force: true });
236
+ }
package/src/extras.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared helpers for the two "extra asset" config blocks — `sidecars` (bundled
3
+ * binaries) and `workers` (extra Bun Worker entries) — used by both `mirin build`
4
+ * and `mirin dev`. Keeps the config normalization + worker compilation in one
5
+ * place so the two CLI paths stay in sync.
6
+ */
7
+
8
+ import { $ } from "bun";
9
+ import { join } from "node:path";
10
+
11
+ /** Config shapes (mirrors packages/mirin/src/config.ts; CLI can't import the runtime). */
12
+ export type SidecarConfig = Record<string, string | { bin: string; entitlements?: string[] }>;
13
+ export type WorkersConfig = Record<string, string>;
14
+
15
+ /** A sidecar resolved to an absolute source path + its requested entitlements. */
16
+ export interface NormalizedSidecar {
17
+ name: string;
18
+ /** Absolute path to the source binary. */
19
+ src: string;
20
+ /** Short entitlement names (e.g. "allow-jit"); empty for most CLIs. */
21
+ entitlements: string[];
22
+ }
23
+
24
+ /** Resolve `config.sidecars` to absolute source paths. */
25
+ export function normalizeSidecars(
26
+ projectDir: string,
27
+ sidecars: SidecarConfig | undefined,
28
+ ): NormalizedSidecar[] {
29
+ return Object.entries(sidecars ?? {}).map(([name, value]) => {
30
+ const spec = typeof value === "string" ? { bin: value } : value;
31
+ return {
32
+ name,
33
+ src: join(projectDir, spec.bin),
34
+ entitlements: spec.entitlements ?? [],
35
+ };
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Compile each extra-worker entry to `<outDir>/<name>.js`. Returns name → path.
41
+ * `minify` matches the surrounding build (prod minifies; dev doesn't).
42
+ */
43
+ export async function compileWorkers(
44
+ projectDir: string,
45
+ workers: WorkersConfig | undefined,
46
+ outDir: string,
47
+ minify: boolean,
48
+ ): Promise<Record<string, string>> {
49
+ const out: Record<string, string> = {};
50
+ for (const [name, entry] of Object.entries(workers ?? {})) {
51
+ const js = join(outDir, `${name}.js`);
52
+ const src = join(projectDir, entry);
53
+ if (minify) {
54
+ await $`bun build ${src} --target=bun --minify --outfile ${js}`.cwd(projectDir);
55
+ } else {
56
+ await $`bun build ${src} --target=bun --outfile ${js}`.cwd(projectDir);
57
+ }
58
+ out[name] = js;
59
+ }
60
+ return out;
61
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Produce a Windows `.ico` from the app's icon source (mirrors the macOS `.icns`
3
+ * path in `bundle.ts`). mirin loads it at runtime and sets it as the window icon
4
+ * (taskbar / Alt-Tab / title). Accepts a `.ico` (copied), a `.iconset` directory
5
+ * (its `icon_NxN.png` renditions packed as multi-size PNG entries — Vista+ icons
6
+ * embed PNGs directly, so no rescaling is needed), or a single `.png`.
7
+ */
8
+
9
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ /** The iconset renditions worth packing (small sizes render crisper than scaling 256). */
13
+ const ICO_SIZES = [16, 32, 48, 64, 128, 256];
14
+
15
+ interface IconEntry {
16
+ size: number;
17
+ png: Uint8Array;
18
+ }
19
+
20
+ /** Pack PNG renditions into a multi-image `.ico` (each entry is a PNG blob). */
21
+ function packIco(entries: IconEntry[]): Uint8Array {
22
+ const headerSize = 6 + entries.length * 16;
23
+ const header = new Uint8Array(headerSize);
24
+ const dv = new DataView(header.buffer);
25
+ dv.setUint16(0, 0, true); // reserved
26
+ dv.setUint16(2, 1, true); // type 1 = icon
27
+ dv.setUint16(4, entries.length, true);
28
+
29
+ let offset = headerSize;
30
+ entries.forEach((e, i) => {
31
+ const base = 6 + i * 16;
32
+ header[base] = e.size >= 256 ? 0 : e.size; // width (0 = 256)
33
+ header[base + 1] = e.size >= 256 ? 0 : e.size; // height
34
+ header[base + 2] = 0; // palette
35
+ header[base + 3] = 0; // reserved
36
+ dv.setUint16(base + 4, 1, true); // planes
37
+ dv.setUint16(base + 6, 32, true); // bits per pixel
38
+ dv.setUint32(base + 8, e.png.length, true); // bytes in resource
39
+ dv.setUint32(base + 12, offset, true); // image offset
40
+ offset += e.png.length;
41
+ });
42
+
43
+ const out = new Uint8Array(offset);
44
+ out.set(header, 0);
45
+ let pos = headerSize;
46
+ for (const e of entries) {
47
+ out.set(e.png, pos);
48
+ pos += e.png.length;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** Write `outPath` (an `.ico`) from `iconSrc`, returning it, or undefined if no
54
+ * usable source was found. */
55
+ export function makeWindowsIcon(iconSrc: string, outPath: string): string | undefined {
56
+ if (!existsSync(iconSrc)) {
57
+ console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
58
+ return undefined;
59
+ }
60
+ if (iconSrc.endsWith(".ico")) {
61
+ copyFileSync(iconSrc, outPath);
62
+ return outPath;
63
+ }
64
+
65
+ const entries: IconEntry[] = [];
66
+ if (iconSrc.endsWith(".iconset")) {
67
+ for (const size of ICO_SIZES) {
68
+ const png = join(iconSrc, `icon_${size}x${size}.png`);
69
+ if (existsSync(png)) entries.push({ size, png: readFileSync(png) });
70
+ }
71
+ } else if (iconSrc.endsWith(".png")) {
72
+ entries.push({ size: 256, png: readFileSync(iconSrc) });
73
+ }
74
+
75
+ if (!entries.length) {
76
+ console.warn(`[mirin] no usable icon renditions in ${iconSrc}`);
77
+ return undefined;
78
+ }
79
+ writeFileSync(outPath, packIco(entries));
80
+ return outPath;
81
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { resolve } from "node:path";
7
7
  import { dev } from "./dev.ts";
8
8
  import { build } from "./build.ts";
9
+ import { release } from "./release.ts";
9
10
  import { scaffold } from "create-mirinjs";
10
11
 
11
12
  const [command, arg] = Bun.argv.slice(2);
@@ -15,6 +16,7 @@ const USAGE = `mirin — build desktop apps with Bun + Chromium
15
16
  Usage:
16
17
  mirin dev run the app against the Vite dev server (HMR + typed RPC)
17
18
  mirin build package a standalone, signed .app (output: ./build)
19
+ mirin release build + emit update artifacts (output: ./build/release)
18
20
  mirin init [dir] scaffold a new app
19
21
  `;
20
22
 
@@ -24,7 +26,12 @@ switch (command) {
24
26
  break;
25
27
  }
26
28
  case "build": {
27
- process.exit(await build());
29
+ await build();
30
+ process.exit(0);
31
+ break;
32
+ }
33
+ case "release": {
34
+ process.exit(await release());
28
35
  break;
29
36
  }
30
37
  case "init": {