@mirinjs/cli 0.1.0-alpha.1 → 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirinjs/cli",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.10",
4
4
  "description": "CLI for mirin apps: dev, build, init.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "bun": ">=1.2.0"
28
28
  },
29
29
  "dependencies": {
30
- "create-mirinjs": "0.1.0-alpha.1",
31
- "mirinjs": "0.1.0-alpha.1"
30
+ "create-mirinjs": "0.1.0-alpha.10",
31
+ "mirinjs": "0.1.0-alpha.10"
32
32
  },
33
33
  "optionalDependencies": {
34
- "@mirinjs/darwin-arm64": "0.1.0-alpha.1",
35
- "@mirinjs/win32-x64": "0.1.0-alpha.1"
34
+ "@mirinjs/darwin-arm64": "0.1.0-alpha.10",
35
+ "@mirinjs/win32-x64": "0.1.0-alpha.10"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
package/src/build.ts CHANGED
@@ -16,16 +16,90 @@
16
16
  */
17
17
 
18
18
  import { $ } from "bun";
19
- import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
19
+ import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { buildAppBundle } from "./bundle.ts";
22
22
  import { buildWindowsBundle } from "./bundle-win.ts";
23
+ import { makeWindowsIcon } from "./icon-win.ts";
23
24
  import { resolveArtifacts } from "./artifacts.ts";
24
25
  import { sweepBuildTemps } from "./temps.ts";
25
26
  import { normalizeSidecars, compileWorkers } from "./extras.ts";
26
27
 
27
28
  const IS_WINDOWS = process.platform === "win32";
28
29
 
30
+ /** A PE FILEVERSION (`x.y.z.w`) from a semver string (pre-release suffix dropped). */
31
+ function winFileVersion(v: string): string {
32
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
33
+ return m ? `${m[1]}.${m[2]}.${m[3]}.0` : "0.0.0.0";
34
+ }
35
+
36
+ /**
37
+ * Flip a PE exe from the CONSOLE subsystem (3) to GUI (2) so Windows allocates
38
+ * **no console window** when it's launched from a shortcut. Bun's
39
+ * `--windows-hide-console` only hides the console at runtime (a brief flash);
40
+ * GUI subsystem prevents it entirely — the equivalent of Electrobun's
41
+ * `exe.subsystem = .Windows`. The optional-header Subsystem field is at
42
+ * `e_lfanew + 24 (PE sig + COFF) + 68`.
43
+ */
44
+ function patchPeToGuiSubsystem(exePath: string): void {
45
+ const buf = readFileSync(exePath);
46
+ const peOff = buf.readUInt32LE(0x3c);
47
+ if (buf.readUInt32LE(peOff) !== 0x0000_4550) return; // not "PE\0\0"
48
+ const subsystemOff = peOff + 92;
49
+ if (buf.readUInt16LE(subsystemOff) === 3 /* CONSOLE */) {
50
+ buf.writeUInt16LE(2 /* GUI */, subsystemOff);
51
+ writeFileSync(exePath, buf);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Stamp a Bun-compiled Windows exe with the app icon + version metadata via
57
+ * rcedit. Bun's `--windows-icon` is a silent no-op on Windows hosts, so without
58
+ * this the exe keeps Bun's icon — which the Start Menu / Desktop shortcut, the
59
+ * taskbar, and Explorer all show. Uses a single largest-size icon: rcedit's
60
+ * multi-size replacement silently fails on Bun's appended-payload exes, while a
61
+ * single 256 entry takes and Windows downscales it cleanly for smaller views.
62
+ * Best-effort — warns (doesn't fail the build) if rcedit isn't installed.
63
+ */
64
+ async function brandWindowsExe(
65
+ exe: string,
66
+ opts: {
67
+ projectDir: string;
68
+ work: string;
69
+ appName: string;
70
+ version: string;
71
+ icon?: string;
72
+ publisher: string;
73
+ },
74
+ ): Promise<void> {
75
+ if (!Bun.which("rcedit")) {
76
+ console.warn(
77
+ "[mirin build] rcedit not found — the Windows exe keeps Bun's default icon. " +
78
+ "Install it: `scoop install rcedit` (or `choco install rcedit`).",
79
+ );
80
+ return;
81
+ }
82
+ const args: string[] = [];
83
+ if (opts.icon) {
84
+ const ico = makeWindowsIcon(join(opts.projectDir, opts.icon), join(opts.work, "exe-icon.ico"), {
85
+ onlyLargest: true,
86
+ });
87
+ if (ico) args.push("--set-icon", ico);
88
+ }
89
+ const fv = winFileVersion(opts.version);
90
+ args.push(
91
+ "--set-version-string", "ProductName", opts.appName,
92
+ "--set-version-string", "FileDescription", opts.appName,
93
+ "--set-version-string", "CompanyName", opts.publisher,
94
+ "--set-file-version", fv,
95
+ "--set-product-version", fv,
96
+ );
97
+ const res = await $`rcedit ${exe} ${args}`.nothrow();
98
+ if (res.exitCode !== 0) {
99
+ console.warn(`[mirin build] rcedit failed (exit ${res.exitCode}) — exe branding skipped.`);
100
+ }
101
+ }
102
+
29
103
  export interface BuildResult {
30
104
  /** Path to the assembled .app. */
31
105
  app: string;
@@ -45,6 +119,10 @@ export interface BuildResult {
45
119
  dmg: boolean | import("mirinjs").DmgConfig;
46
120
  /** NSIS installer config (Windows) — `true`/object/`false`; default `true`. */
47
121
  nsis: boolean | import("mirinjs").NsisConfig;
122
+ /** Inno Setup installer config (Windows) — preferred over NSIS; default `true`. */
123
+ inno: boolean | import("mirinjs").InnoConfig;
124
+ /** Publisher / company name (config.publisher ?? appName). */
125
+ publisher: string;
48
126
  /** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
49
127
  signIdentity?: string;
50
128
  }
@@ -76,6 +154,8 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
76
154
  const baseUrl: string | undefined = config.release?.baseUrl;
77
155
  const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
78
156
  const nsis: boolean | import("mirinjs").NsisConfig = config.nsis ?? true;
157
+ const inno: boolean | import("mirinjs").InnoConfig = config.inno ?? true;
158
+ const publisher: string = config.publisher ?? appName;
79
159
 
80
160
  console.log(`[mirin build] ${appName} ${version}`);
81
161
 
@@ -92,6 +172,17 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
92
172
  const hostExe = join(work, IS_WINDOWS ? "host-release.exe" : "host-release");
93
173
  const workerJs = join(work, "worker.release.js");
94
174
  await $`bun build --compile --minify ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
175
+ if (IS_WINDOWS) {
176
+ patchPeToGuiSubsystem(hostExe);
177
+ await brandWindowsExe(hostExe, {
178
+ projectDir,
179
+ work,
180
+ appName,
181
+ version,
182
+ icon: config.icon,
183
+ publisher,
184
+ });
185
+ }
95
186
  await $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(projectDir);
96
187
 
97
188
  // Extra assets: resolve sidecar binaries + compile any extra worker entries.
@@ -108,7 +199,11 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
108
199
  const resources = {
109
200
  uiDir: join(projectDir, "dist"),
110
201
  workerJs,
111
- manifestJson: JSON.stringify({ windows: config.windows }),
202
+ manifestJson: JSON.stringify({
203
+ id: bundleId,
204
+ windows: config.windows,
205
+ singleInstance: config.singleInstance,
206
+ }),
112
207
  versionJson,
113
208
  workers: extraWorkers,
114
209
  };
@@ -150,6 +245,8 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
150
245
  projectDir,
151
246
  dmg,
152
247
  nsis,
248
+ inno,
249
+ publisher,
153
250
  signIdentity,
154
251
  };
155
252
  }
package/src/dev.ts CHANGED
@@ -112,7 +112,7 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
112
112
  : join(app, "Contents", "MacOS", "libmirin_core.dylib"),
113
113
  MIRIN_WORKER: workerJs,
114
114
  MIRIN_DEV_URL: devUrl,
115
- MIRIN_MANIFEST_JSON: JSON.stringify({ windows: config.windows }),
115
+ MIRIN_MANIFEST_JSON: JSON.stringify({ id: bundleId, windows: config.windows }),
116
116
  // dev: true → enables inspect-element AND gives this run its own `-dev`
117
117
  // CEF cache dir, so `mirin dev` can run alongside the installed app.
118
118
  MIRIN_CONFIG_JSON: JSON.stringify({ dev: true }),
package/src/dmg.ts CHANGED
@@ -93,6 +93,34 @@ export async function notarizeAndStaple(target: string): Promise<boolean> {
93
93
  return true;
94
94
  }
95
95
 
96
+ /**
97
+ * `hdiutil create` with retries. "Resource busy" is a transient CI flake — a
98
+ * Spotlight/antivirus/`fseventsd` process touches the staging folder (or a prior
99
+ * image hasn't fully detached) while hdiutil tries to read it. A short backoff
100
+ * clears it; only a non-busy error fails immediately.
101
+ */
102
+ async function hdiutilCreate(
103
+ vol: string,
104
+ staging: string,
105
+ format: string,
106
+ dmgPath: string,
107
+ ): Promise<void> {
108
+ let stderr = "";
109
+ for (let attempt = 1; attempt <= 4; attempt++) {
110
+ const res =
111
+ await $`hdiutil create -volname ${vol} -srcfolder ${staging} -ov -format ${format} ${dmgPath}`
112
+ .quiet()
113
+ .nothrow();
114
+ if (res.exitCode === 0) return;
115
+ stderr = res.stderr.toString().trim();
116
+ if (!/busy/i.test(stderr)) break; // not the transient flake — fail now
117
+ console.warn(`[mirin release] hdiutil create busy (attempt ${attempt}/4); retrying…`);
118
+ rmSync(dmgPath, { force: true });
119
+ await Bun.sleep(3000 * attempt);
120
+ }
121
+ throw new Error(`hdiutil create failed: ${stderr}`);
122
+ }
123
+
96
124
  /** Does this config ask for a styled Finder window (vs. a plain DMG)? */
97
125
  function wantsLayout(o: DmgOptions): boolean {
98
126
  return !!(o.background || o.appPosition || o.applicationsPosition || o.windowSize || o.iconSize);
@@ -131,7 +159,7 @@ export async function buildDmg(input: BuildDmgInput): Promise<string> {
131
159
  }
132
160
  }
133
161
  if (!built) {
134
- await $`hdiutil create -volname ${vol} -srcfolder ${staging} -ov -format ${format} ${dmgPath}`.quiet();
162
+ await hdiutilCreate(vol, staging, format, dmgPath);
135
163
  }
136
164
  } finally {
137
165
  rmSync(staging, { recursive: true, force: true });
package/src/icon-win.ts CHANGED
@@ -1,58 +1,210 @@
1
1
  /**
2
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`.
3
+ * path in `bundle.ts`). Emits **classic DIB/BMP icon entries** (32bpp BGRA + AND
4
+ * mask), NOT PNG-compressed entries: Bun's `--windows-icon` embedder silently
5
+ * ignores PNG-format ICOs (the exe keeps Bun's default icon), and some shells
6
+ * mis-scale them. Accepts a `.ico` (copied), a `.iconset` directory (its
7
+ * `icon_NxN.png` renditions), or a single square `.png`.
7
8
  */
8
9
 
9
10
  import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { inflateSync } from "node:zlib";
10
12
  import { join } from "node:path";
11
13
 
12
- /** The iconset renditions worth packing (small sizes render crisper than scaling 256). */
14
+ /** Standard Windows icon sizes; intermediate sizes are downscaled from these by
15
+ * the shell, so the 256 entry keeps large views crisp. */
13
16
  const ICO_SIZES = [16, 32, 48, 64, 128, 256];
14
17
 
15
- interface IconEntry {
18
+ interface IcoEntry {
16
19
  size: number;
17
- png: Uint8Array;
20
+ /** DIB image (small sizes) or raw PNG bytes (256) — see {@link rendition}. */
21
+ data: Buffer;
22
+ /** True when `data` is a PNG blob (the 256 convention) rather than a DIB. */
23
+ png: boolean;
18
24
  }
19
25
 
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);
26
+ /**
27
+ * Minimal PNG decoder → top-down RGBA. Handles 8-bit, non-interlaced, color
28
+ * types 0/2/4/6 (the renditions macOS `iconutil`/`sips` and design tools emit).
29
+ * Throws on anything else so the caller skips just that rendition.
30
+ */
31
+ function decodePng(buf: Buffer): { width: number; height: number; rgba: Buffer } {
32
+ if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error("not a PNG");
33
+ let width = 0;
34
+ let height = 0;
35
+ let bitDepth = 0;
36
+ let colorType = 0;
37
+ let interlace = 0;
38
+ const idat: Buffer[] = [];
39
+ let pos = 8;
40
+ while (pos + 8 <= buf.length) {
41
+ const len = buf.readUInt32BE(pos);
42
+ const type = buf.toString("latin1", pos + 4, pos + 8);
43
+ const data = buf.subarray(pos + 8, pos + 8 + len);
44
+ if (type === "IHDR") {
45
+ width = data.readUInt32BE(0);
46
+ height = data.readUInt32BE(4);
47
+ bitDepth = data[8]!;
48
+ colorType = data[9]!;
49
+ interlace = data[12]!;
50
+ } else if (type === "IDAT") {
51
+ idat.push(data);
52
+ } else if (type === "IEND") {
53
+ break;
54
+ }
55
+ pos += 12 + len;
56
+ }
57
+ if (bitDepth !== 8 || interlace !== 0) {
58
+ throw new Error(`unsupported PNG (depth ${bitDepth}, interlace ${interlace})`);
59
+ }
60
+ const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 4 ? 2 : colorType === 0 ? 1 : 0;
61
+ if (!channels) throw new Error(`unsupported color type ${colorType}`);
62
+
63
+ const raw = inflateSync(Buffer.concat(idat));
64
+ const stride = width * channels;
65
+ const rgba = Buffer.alloc(width * height * 4);
66
+ let prev = Buffer.alloc(stride);
67
+ let rp = 0;
68
+ for (let y = 0; y < height; y++) {
69
+ const filter = raw[rp++]!;
70
+ const cur = Buffer.from(raw.subarray(rp, rp + stride));
71
+ rp += stride;
72
+ unfilter(filter, cur, prev, channels);
73
+ for (let x = 0; x < width; x++) {
74
+ const s = x * channels;
75
+ let r: number;
76
+ let g: number;
77
+ let b: number;
78
+ let a: number;
79
+ if (channels === 4) {
80
+ r = cur[s]!;
81
+ g = cur[s + 1]!;
82
+ b = cur[s + 2]!;
83
+ a = cur[s + 3]!;
84
+ } else if (channels === 3) {
85
+ r = cur[s]!;
86
+ g = cur[s + 1]!;
87
+ b = cur[s + 2]!;
88
+ a = 255;
89
+ } else if (channels === 2) {
90
+ r = g = b = cur[s]!;
91
+ a = cur[s + 1]!;
92
+ } else {
93
+ r = g = b = cur[s]!;
94
+ a = 255;
95
+ }
96
+ const d = (y * width + x) * 4;
97
+ rgba[d] = r;
98
+ rgba[d + 1] = g;
99
+ rgba[d + 2] = b;
100
+ rgba[d + 3] = a;
101
+ }
102
+ prev = cur;
103
+ }
104
+ return { width, height, rgba };
105
+ }
106
+
107
+ /** Reverse a PNG scanline filter in place (None/Sub/Up/Average/Paeth). */
108
+ function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void {
109
+ for (let i = 0; i < cur.length; i++) {
110
+ const a = i >= bpp ? cur[i - bpp]! : 0;
111
+ const b = prev[i]!;
112
+ const c = i >= bpp ? prev[i - bpp]! : 0;
113
+ let v = cur[i]!;
114
+ if (filter === 1) v += a;
115
+ else if (filter === 2) v += b;
116
+ else if (filter === 3) v += (a + b) >> 1;
117
+ else if (filter === 4) {
118
+ const p = a + b - c;
119
+ const pa = Math.abs(p - a);
120
+ const pb = Math.abs(p - b);
121
+ const pc = Math.abs(p - c);
122
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
123
+ }
124
+ cur[i] = v & 0xff;
125
+ }
126
+ }
127
+
128
+ /** Encode one icon image as a DIB: BITMAPINFOHEADER + bottom-up 32bpp BGRA + a
129
+ * (zeroed) 1bpp AND mask — the alpha channel drives transparency. */
130
+ function dibEntry(size: number, rgba: Buffer): Buffer {
131
+ const xor = Buffer.alloc(size * size * 4);
132
+ for (let dy = 0; dy < size; dy++) {
133
+ const sy = size - 1 - dy; // DIB rows run bottom-to-top
134
+ for (let x = 0; x < size; x++) {
135
+ const s = (sy * size + x) * 4;
136
+ const d = (dy * size + x) * 4;
137
+ xor[d] = rgba[s + 2]!; // B
138
+ xor[d + 1] = rgba[s + 1]!; // G
139
+ xor[d + 2] = rgba[s]!; // R
140
+ xor[d + 3] = rgba[s + 3]!; // A
141
+ }
142
+ }
143
+ const andStride = Math.ceil(size / 32) * 4; // 1bpp, rows padded to 4 bytes
144
+ const andMask = Buffer.alloc(andStride * size, 0);
145
+ const hdr = Buffer.alloc(40);
146
+ hdr.writeUInt32LE(40, 0); // biSize
147
+ hdr.writeInt32LE(size, 4); // biWidth
148
+ hdr.writeInt32LE(size * 2, 8); // biHeight = XOR rows + AND rows
149
+ hdr.writeUInt16LE(1, 12); // biPlanes
150
+ hdr.writeUInt16LE(32, 14); // biBitCount
151
+ hdr.writeUInt32LE(0, 16); // BI_RGB
152
+ hdr.writeUInt32LE(xor.length + andMask.length, 20);
153
+ return Buffer.concat([hdr, xor, andMask]);
154
+ }
28
155
 
29
- let offset = headerSize;
156
+ /** Assemble the ICONDIR + entries into a `.ico`. */
157
+ function packIco(entries: IcoEntry[]): Buffer {
158
+ const dirSize = 6 + entries.length * 16;
159
+ const dir = Buffer.alloc(dirSize);
160
+ dir.writeUInt16LE(0, 0); // reserved
161
+ dir.writeUInt16LE(1, 2); // type 1 = icon
162
+ dir.writeUInt16LE(entries.length, 4);
163
+ let offset = dirSize;
30
164
  entries.forEach((e, i) => {
31
165
  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;
166
+ dir[base] = e.size >= 256 ? 0 : e.size; // width (0 = 256)
167
+ dir[base + 1] = e.size >= 256 ? 0 : e.size; // height
168
+ dir.writeUInt16LE(1, base + 4); // planes
169
+ dir.writeUInt16LE(32, base + 6); // bits per pixel
170
+ dir.writeUInt32LE(e.data.length, base + 8); // bytes in resource
171
+ dir.writeUInt32LE(offset, base + 12); // image offset
172
+ offset += e.data.length;
41
173
  });
174
+ return Buffer.concat([dir, ...entries.map((e) => e.data)]);
175
+ }
42
176
 
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;
177
+ /**
178
+ * Build one icon entry from a square PNG rendition. Sizes ≥256 are stored as the
179
+ * raw PNG blob (the Windows convention — a 256×256 DIB is non-standard and is
180
+ * rejected by Bun/rcedit/the resource compiler); smaller sizes are decoded to a
181
+ * DIB. Returns undefined if the file isn't the expected square size.
182
+ */
183
+ function rendition(file: string, size: number): IcoEntry | undefined {
184
+ try {
185
+ const buf = readFileSync(file);
186
+ if (size >= 256) return { size, data: buf, png: true };
187
+ const { width, height, rgba } = decodePng(buf);
188
+ if (width !== size || height !== size) return undefined;
189
+ return { size, data: dibEntry(size, rgba), png: false };
190
+ } catch (e) {
191
+ console.warn(`[mirin] icon: skipping ${file} (${e instanceof Error ? e.message : e})`);
192
+ return undefined;
49
193
  }
50
- return out;
51
194
  }
52
195
 
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 {
196
+ /**
197
+ * Write `outPath` (an `.ico`) from `iconSrc`, returning it, or undefined if no
198
+ * usable source was found. With `onlyLargest`, emits a single (largest) entry —
199
+ * needed for the **exe** icon, since rcedit's multi-size icon replacement fails on
200
+ * Bun-compiled exes (single-entry works, and Windows downscales 256 cleanly for
201
+ * small views). The full multi-size form is fine for `icon.ico` (NSIS + WM_SETICON).
202
+ */
203
+ export function makeWindowsIcon(
204
+ iconSrc: string,
205
+ outPath: string,
206
+ opts?: { onlyLargest?: boolean },
207
+ ): string | undefined {
56
208
  if (!existsSync(iconSrc)) {
57
209
  console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
58
210
  return undefined;
@@ -62,14 +214,33 @@ export function makeWindowsIcon(iconSrc: string, outPath: string): string | unde
62
214
  return outPath;
63
215
  }
64
216
 
65
- const entries: IconEntry[] = [];
217
+ const entries: IcoEntry[] = [];
66
218
  if (iconSrc.endsWith(".iconset")) {
67
- for (const size of ICO_SIZES) {
219
+ const sizes = opts?.onlyLargest ? [...ICO_SIZES].reverse() : ICO_SIZES;
220
+ for (const size of sizes) {
68
221
  const png = join(iconSrc, `icon_${size}x${size}.png`);
69
- if (existsSync(png)) entries.push({ size, png: readFileSync(png) });
222
+ if (existsSync(png)) {
223
+ const e = rendition(png, size);
224
+ if (e) {
225
+ entries.push(e);
226
+ if (opts?.onlyLargest) break;
227
+ }
228
+ }
70
229
  }
71
230
  } else if (iconSrc.endsWith(".png")) {
72
- entries.push({ size: 256, png: readFileSync(iconSrc) });
231
+ try {
232
+ const buf = readFileSync(iconSrc);
233
+ const { width, height, rgba } = decodePng(buf);
234
+ if (width !== height || width > 256) {
235
+ console.warn(`[mirin] icon png must be square ≤256px (got ${width}×${height})`);
236
+ } else if (width === 256) {
237
+ entries.push({ size: 256, data: buf, png: true });
238
+ } else {
239
+ entries.push({ size: width, data: dibEntry(width, rgba), png: false });
240
+ }
241
+ } catch (e) {
242
+ console.warn(`[mirin] icon: cannot decode ${iconSrc} (${e instanceof Error ? e.message : e})`);
243
+ }
73
244
  }
74
245
 
75
246
  if (!entries.length) {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Windows installer via **Inno Setup** — a modern, clean wizard (the installer VS
3
+ * Code ships). Generates an `.iss` script from the app folder + config and compiles
4
+ * it with `iscc`. Like the NSIS path it installs per-user (`%LOCALAPPDATA%\Programs`,
5
+ * no elevation) or per-machine (Program Files), creates Start Menu + Desktop
6
+ * shortcuts, and registers an uninstaller / Add-Remove-Programs entry — but with
7
+ * Inno's `WizardStyle=modern` flat UI instead of NSIS's dated one.
8
+ *
9
+ * Requires `iscc` (Inno Setup 6+) on PATH; the caller checks `hasInno()` and falls
10
+ * back to NSIS / the portable `.zip` when it's absent.
11
+ */
12
+
13
+ import { $ } from "bun";
14
+ import { existsSync, rmSync, writeFileSync } from "node:fs";
15
+ import { join, isAbsolute } from "node:path";
16
+ import type { InnoConfig } from "mirinjs";
17
+ import { makeWindowsIcon } from "./icon-win.ts";
18
+
19
+ export interface BuildInnoInput {
20
+ appDir: string; // the assembled app folder (build/<App>)
21
+ appName: string;
22
+ exeName: string; // <App>.exe
23
+ version: string;
24
+ bundleId: string; // reverse-DNS id → AppId / uninstall key
25
+ outDir: string; // build/release
26
+ fileName: string; // e.g. stable-win32-x64-Anko-setup.exe
27
+ options: InnoConfig;
28
+ projectDir: string;
29
+ }
30
+
31
+ /** Whether the Inno Setup compiler `iscc` is on PATH. */
32
+ export function hasInno(): boolean {
33
+ return Bun.which("iscc") != null;
34
+ }
35
+
36
+ const resolve = (projectDir: string, p: string) => (isAbsolute(p) ? p : join(projectDir, p));
37
+ /** Escape an Inno directive value: `{` is the constant sigil. */
38
+ const v = (s: string) => s.replace(/\{/g, "{{");
39
+
40
+ /** Build the installer with `iscc`, returning the setup.exe path. */
41
+ export async function buildInnoInstaller(input: BuildInnoInput): Promise<string> {
42
+ const { appDir, appName, exeName, version, bundleId, outDir, fileName, options, projectDir } = input;
43
+ const out = join(outDir, fileName);
44
+ rmSync(out, { force: true });
45
+ if (!fileName.endsWith(".exe")) throw new Error("inno: fileName must end with .exe");
46
+ const baseName = fileName.slice(0, -".exe".length);
47
+
48
+ const perMachine = options.perMachine === true;
49
+ const minimal = options.oneClick === true;
50
+ const desktop = options.desktopShortcut !== false;
51
+ const startMenu = options.startMenuShortcut !== false;
52
+ const runAfter = options.runAfterFinish !== false;
53
+ const changeDir = options.allowChangeInstallDir !== false && !minimal;
54
+ const publisher = options.publisher ?? appName;
55
+ const installDir =
56
+ options.installDir ?? (perMachine ? "{autopf}\\" + appName : "{localappdata}\\Programs\\" + appName);
57
+
58
+ // Setup icon: an explicit .ico, else the app's bundled icon.ico.
59
+ let icon: string | undefined;
60
+ if (options.installerIcon) {
61
+ icon = makeWindowsIcon(resolve(projectDir, options.installerIcon), join(outDir, "_inno.ico"));
62
+ } else if (existsSync(join(appDir, "icon.ico"))) {
63
+ icon = join(appDir, "icon.ico");
64
+ }
65
+ const license = options.license ? resolve(projectDir, options.license) : undefined;
66
+ if (license && !existsSync(license)) throw new Error(`inno: license not found: ${license}`);
67
+
68
+ const L: string[] = [];
69
+ L.push("[Setup]");
70
+ L.push(`AppId={{${bundleId}}`);
71
+ L.push(`AppName=${v(appName)}`);
72
+ L.push(`AppVersion=${v(version)}`);
73
+ // Without this, Inno's display name defaults to "<name> version <x>" (shown in
74
+ // the wizard title + Add/Remove Programs); pin it to the app name.
75
+ L.push(`AppVerName=${v(appName)}`);
76
+ L.push(`AppPublisher=${v(publisher)}`);
77
+ L.push("WizardStyle=modern");
78
+ L.push(`DefaultDirName=${installDir}`);
79
+ L.push(`DefaultGroupName=${v(appName)}`);
80
+ L.push(`PrivilegesRequired=${perMachine ? "admin" : "lowest"}`);
81
+ L.push(`OutputDir=${outDir}`);
82
+ L.push(`OutputBaseFilename=${baseName}`);
83
+ L.push("Compression=lzma2/max");
84
+ L.push("SolidCompression=yes");
85
+ L.push("ArchitecturesAllowed=x64compatible");
86
+ L.push("ArchitecturesInstallIn64BitMode=x64compatible");
87
+ L.push(`UninstallDisplayIcon={app}\\${exeName}`);
88
+ if (icon) L.push(`SetupIconFile=${icon}`);
89
+ if (license) L.push(`LicenseFile=${license}`);
90
+ if (minimal) {
91
+ L.push("DisableWelcomePage=yes");
92
+ L.push("DisableReadyPage=yes");
93
+ }
94
+ if (!changeDir) L.push("DisableDirPage=yes");
95
+ if (!startMenu) L.push("DisableProgramGroupPage=yes");
96
+ if (options.include) L.push("", "; --- user include ---", options.include, "");
97
+
98
+ L.push("", "[Files]");
99
+ L.push(`Source: "${appDir}\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs`);
100
+
101
+ if (desktop) {
102
+ L.push("", "[Tasks]");
103
+ L.push(`Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional:"`);
104
+ }
105
+
106
+ L.push("", "[Icons]");
107
+ if (startMenu) L.push(`Name: "{group}\\${v(appName)}"; Filename: "{app}\\${exeName}"`);
108
+ if (desktop) {
109
+ L.push(`Name: "{autodesktop}\\${v(appName)}"; Filename: "{app}\\${exeName}"; Tasks: desktopicon`);
110
+ }
111
+
112
+ if (runAfter) {
113
+ L.push("", "[Run]");
114
+ L.push(
115
+ `Filename: "{app}\\${exeName}"; Description: "Launch ${v(appName)}"; ` +
116
+ "Flags: nowait postinstall skipifsilent",
117
+ );
118
+ }
119
+
120
+ const script = join(outDir, "_installer.iss");
121
+ writeFileSync(script, L.join("\n") + "\n");
122
+ console.log(`[mirin release] compiling Inno Setup installer → ${fileName}`);
123
+ const res = await $`iscc /Q ${script}`.nothrow();
124
+ rmSync(script, { force: true });
125
+ rmSync(join(outDir, "_inno.ico"), { force: true });
126
+ if (res.exitCode !== 0 || !existsSync(out)) {
127
+ throw new Error(`iscc failed (exit ${res.exitCode})`);
128
+ }
129
+ return out;
130
+ }
package/src/release.ts CHANGED
@@ -23,6 +23,7 @@ import { tmpdir } from "node:os";
23
23
  import { build } from "./build.ts";
24
24
  import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
25
25
  import { buildNsisInstaller, hasMakensis } from "./installer-win.ts";
26
+ import { buildInnoInstaller, hasInno } from "./installer-inno.ts";
26
27
  import { loadCodec } from "mirinjs/codec";
27
28
 
28
29
  const sha256File = (path: string) =>
@@ -130,29 +131,44 @@ export async function release(projectDir = process.cwd()): Promise<number> {
130
131
  let installerName: string | undefined;
131
132
  let installerSize = 0;
132
133
  if (isWindows) {
133
- // Preferred: a real NSIS installer (Program Files / per-user install, Start
134
- // Menu + Desktop shortcuts, uninstaller, Add/Remove Programs). Falls back to a
135
- // portable .zip when nsis is disabled or makensis isn't installed.
136
- const wantsInstaller = result.nsis !== false;
137
- if (wantsInstaller && (await hasMakensis())) {
138
- installerName = `${prefix}-${safeName}-setup.exe`;
139
- const exePath = await buildNsisInstaller({
140
- appDir: result.app,
141
- appName: result.appName,
142
- exeName: `${result.appName}.exe`,
143
- version: result.version,
144
- bundleId: result.bundleId,
145
- outDir,
146
- fileName: installerName,
147
- options: typeof result.nsis === "object" ? result.nsis : {},
148
- projectDir: result.projectDir,
134
+ // A real installer (Program Files / per-user install, Start Menu + Desktop
135
+ // shortcuts, uninstaller, Add/Remove Programs): Inno Setup (modern wizard)
136
+ // preferred, then NSIS, then a portable .zip if neither toolchain is present.
137
+ const setupName = `${prefix}-${safeName}-setup.exe`;
138
+ const installerArgs = {
139
+ appDir: result.app,
140
+ appName: result.appName,
141
+ exeName: `${result.appName}.exe`,
142
+ version: result.version,
143
+ bundleId: result.bundleId,
144
+ outDir,
145
+ fileName: setupName,
146
+ projectDir: result.projectDir,
147
+ };
148
+ const innoWanted = result.inno !== false;
149
+ const nsisWanted = result.nsis !== false;
150
+ const innoOpts = typeof result.inno === "object" ? result.inno : {};
151
+ const nsisOpts = typeof result.nsis === "object" ? result.nsis : {};
152
+ let exePath: string | undefined;
153
+ if (innoWanted && hasInno()) {
154
+ exePath = await buildInnoInstaller({
155
+ ...installerArgs,
156
+ options: { ...innoOpts, publisher: innoOpts.publisher ?? result.publisher },
149
157
  });
158
+ } else if (nsisWanted && (await hasMakensis())) {
159
+ exePath = await buildNsisInstaller({
160
+ ...installerArgs,
161
+ options: { ...nsisOpts, publisher: nsisOpts.publisher ?? result.publisher },
162
+ });
163
+ }
164
+ if (exePath) {
165
+ installerName = setupName;
150
166
  installerSize = readFileSync(exePath).byteLength;
151
167
  } else {
152
- if (wantsInstaller) {
168
+ if (innoWanted || nsisWanted) {
153
169
  console.warn(
154
- "[mirin release] makensis (NSIS) not found — shipping the portable .zip. " +
155
- "Install NSIS (e.g. `scoop install nsis`) for an installer.",
170
+ "[mirin release] no installer toolchain found — shipping the portable .zip. " +
171
+ "Install Inno Setup (`scoop install inno-setup`) or NSIS (`scoop install nsis`).",
156
172
  );
157
173
  }
158
174
  installerName = `${prefix}-${safeName}.zip`;
@@ -164,20 +180,32 @@ export async function release(projectDir = process.cwd()): Promise<number> {
164
180
  installerSize = readFileSync(zipPath).byteLength;
165
181
  }
166
182
  } else if (result.dmg !== false) {
167
- installerName = `${prefix}-${safeName}.dmg`;
168
- console.log(`[mirin release] building installer → ${installerName}`);
169
- const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
170
- const dmgPath = await buildDmg({
171
- app: result.app,
172
- appName: result.appName,
173
- outDir,
174
- fileName: installerName,
175
- options,
176
- projectDir: result.projectDir,
177
- signIdentity: result.signIdentity,
178
- });
179
- await notarizeAndStaple(dmgPath); // no-op without notary credentials
180
- installerSize = readFileSync(dmgPath).byteLength;
183
+ // The DMG is the first-install convenience; the updater bundle + manifest (the
184
+ // critical artifacts) are already built. Never fail the whole release over a
185
+ // flaky installer step — warn loudly and ship the rest.
186
+ const dmgName = `${prefix}-${safeName}.dmg`;
187
+ console.log(`[mirin release] building installer → ${dmgName}`);
188
+ try {
189
+ const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
190
+ const dmgPath = await buildDmg({
191
+ app: result.app,
192
+ appName: result.appName,
193
+ outDir,
194
+ fileName: dmgName,
195
+ options,
196
+ projectDir: result.projectDir,
197
+ signIdentity: result.signIdentity,
198
+ });
199
+ await notarizeAndStaple(dmgPath); // no-op without notary credentials
200
+ installerName = dmgName;
201
+ installerSize = readFileSync(dmgPath).byteLength;
202
+ } catch (e) {
203
+ console.warn(
204
+ `\n[mirin release] ⚠️ DMG build failed: ${e instanceof Error ? e.message : e}\n` +
205
+ `[mirin release] shipping the updater bundle + manifest WITHOUT a .dmg installer.\n`,
206
+ );
207
+ rmSync(join(outDir, dmgName), { force: true });
208
+ }
181
209
  }
182
210
 
183
211
  const mb = (n: number) => (n / 1e6).toFixed(1);