@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.20

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/doctor.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `mirin doctor` — check the project and environment before anything is built,
3
+ * including the previous session's outcome (docs/agent-devtools.md).
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { createServer } from "node:net";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ readCurrentSession,
12
+ readEventsFile,
13
+ readSessionInfo,
14
+ sessionPaths,
15
+ } from "mirinjs/devtools/session";
16
+ import { isInRepo, vendoredCef } from "./artifacts.ts";
17
+ import { createReporter } from "./shared/report.ts";
18
+
19
+ export type CheckStatus = "ok" | "warn" | "fail";
20
+
21
+ export interface DoctorCheck {
22
+ name: string;
23
+ status: CheckStatus;
24
+ detail: string;
25
+ fix?: string;
26
+ }
27
+
28
+ export interface DoctorReport {
29
+ ok: boolean;
30
+ projectDir: string;
31
+ platform: string;
32
+ checks: DoctorCheck[];
33
+ }
34
+
35
+ const SUPPORTED = new Set(["darwin-arm64", "win32-x64", "win32-arm64", "linux-x64", "linux-arm64"]);
36
+
37
+ function ok(name: string, detail: string): DoctorCheck {
38
+ return { name, status: "ok", detail };
39
+ }
40
+ function warn(name: string, detail: string, fix?: string): DoctorCheck {
41
+ return { name, status: "warn", detail, ...(fix !== undefined ? { fix } : {}) };
42
+ }
43
+ function fail(name: string, detail: string, fix?: string): DoctorCheck {
44
+ return { name, status: "fail", detail, ...(fix !== undefined ? { fix } : {}) };
45
+ }
46
+
47
+ function portFree(port: number): Promise<boolean> {
48
+ return new Promise((resolve) => {
49
+ const server = createServer();
50
+ server.once("error", () => resolve(false));
51
+ server.once("listening", () => server.close(() => resolve(true)));
52
+ server.listen(port, "127.0.0.1");
53
+ });
54
+ }
55
+
56
+ function checkPlatform(): DoctorCheck {
57
+ const target = `${process.platform}-${process.arch}`;
58
+ return SUPPORTED.has(target)
59
+ ? ok("platform", `${target}, bun ${Bun.version}`)
60
+ : fail(
61
+ "platform",
62
+ `${target} is not a supported target`,
63
+ `supported: ${[...SUPPORTED].join(", ")}`,
64
+ );
65
+ }
66
+
67
+ /** Load `mirin.config.ts` and check the fields the CLI and host both require. */
68
+ async function checkConfig(projectDir: string): Promise<DoctorCheck[]> {
69
+ const path = join(projectDir, "mirin.config.ts");
70
+ if (!existsSync(path)) {
71
+ return [fail("config", "mirin.config.ts not found", "run `mirin init` or check the directory")];
72
+ }
73
+
74
+ let config: Record<string, unknown>;
75
+ try {
76
+ const loaded: unknown = (await import(path)).default;
77
+ if (typeof loaded !== "object" || loaded === null) {
78
+ return [fail("config", "mirin.config.ts has no default export object")];
79
+ }
80
+ config = loaded as Record<string, unknown>;
81
+ } catch (err) {
82
+ return [
83
+ fail("config", `mirin.config.ts failed to load: ${err instanceof Error ? err.message : err}`),
84
+ ];
85
+ }
86
+
87
+ const checks: DoctorCheck[] = [];
88
+ const missing = ["id", "name", "main", "windows"].filter((key) => config[key] == null);
89
+ checks.push(
90
+ missing.length === 0
91
+ ? ok("config", `${String(config.name)} (${String(config.id)})`)
92
+ : fail("config", `missing required field(s): ${missing.join(", ")}`),
93
+ );
94
+
95
+ const main = typeof config.main === "string" ? config.main : "main/main.ts";
96
+ checks.push(
97
+ existsSync(join(projectDir, main))
98
+ ? ok("main entry", main)
99
+ : fail("main entry", `${main} does not exist`, "point `main` at your main-process entry"),
100
+ );
101
+
102
+ const windows =
103
+ typeof config.windows === "object" && config.windows !== null ? config.windows : {};
104
+ const names = Object.keys(windows);
105
+ checks.push(
106
+ names.length > 0
107
+ ? ok("windows", names.join(", "))
108
+ : warn("windows", "no windows declared", "the app will start with nothing visible"),
109
+ );
110
+
111
+ const devtools = config.devtools;
112
+ if (typeof devtools === "object" && devtools !== null && "production" in devtools) {
113
+ const enabled = (devtools as { production?: unknown }).production === true;
114
+ checks.push(
115
+ enabled
116
+ ? warn(
117
+ "devtools",
118
+ "devtools.production is true — packaged builds will expose the inspector",
119
+ "remove it before shipping: the inspector can evaluate code in your app",
120
+ )
121
+ : ok("devtools", "production access disabled"),
122
+ );
123
+ }
124
+ return checks;
125
+ }
126
+
127
+ function checkUi(projectDir: string): DoctorCheck[] {
128
+ const checks: DoctorCheck[] = [];
129
+ checks.push(
130
+ existsSync(join(projectDir, "index.html"))
131
+ ? ok("ui entry", "index.html")
132
+ : warn("ui entry", "no index.html at the project root", "Vite needs an HTML entry"),
133
+ );
134
+ const viteConfig = ["vite.config.ts", "vite.config.js", "vite.config.mts"].find((name) =>
135
+ existsSync(join(projectDir, name)),
136
+ );
137
+ checks.push(
138
+ viteConfig !== undefined
139
+ ? ok("vite config", viteConfig)
140
+ : warn("vite config", "none found", "Vite will run with defaults"),
141
+ );
142
+ return checks;
143
+ }
144
+
145
+ function checkCef(): DoctorCheck {
146
+ if (isInRepo()) {
147
+ // Resolved from the CLI's location, not the cwd, which is usually an example app.
148
+ const vendored = vendoredCef();
149
+ return vendored.present
150
+ ? ok("cef", "vendor/cef is present")
151
+ : warn("cef", `${vendored.path} is missing or incomplete`, "run `bun scripts/fetch-cef.ts`");
152
+ }
153
+ const cache = join(homedir(), ".mirinjs", "cef");
154
+ return existsSync(cache)
155
+ ? ok("cef", `cached at ${cache}`)
156
+ : warn("cef", "no cached CEF runtime", "the first `mirin dev` downloads it (a few hundred MB)");
157
+ }
158
+
159
+ async function checkPorts(): Promise<DoctorCheck[]> {
160
+ const [vite, cdp] = await Promise.all([portFree(5173), portFree(9222)]);
161
+ return [
162
+ vite
163
+ ? ok("vite port", "5173 is free")
164
+ : warn("vite port", "5173 is in use", "mirin dev probes upward, so this is not fatal"),
165
+ cdp
166
+ ? ok("devtools port", "9222 is free")
167
+ : warn("devtools port", "9222 is in use", "mirin dev probes upward, so this is not fatal"),
168
+ ];
169
+ }
170
+
171
+ function checkLastSession(projectDir: string): DoctorCheck {
172
+ const dir = readCurrentSession(projectDir);
173
+ if (dir === undefined) return ok("last session", "none recorded yet");
174
+
175
+ const paths = sessionPaths(dir);
176
+ const info = readSessionInfo(paths);
177
+ const errors = readEventsFile(paths.events).filter((event) => event.level === "error");
178
+ const lastPhase = info?.phases.at(-1);
179
+ const phase =
180
+ lastPhase !== undefined ? `${lastPhase.name}=${lastPhase.status}` : "no phases recorded";
181
+
182
+ if (errors.length === 0) return ok("last session", `${phase}, no errors — ${dir}`);
183
+ const first = errors[0];
184
+ return warn(
185
+ "last session",
186
+ `${phase}, ${errors.length} error(s) — first: ${first?.msg ?? ""}`,
187
+ `full stream: ${paths.events}`,
188
+ );
189
+ }
190
+
191
+ function renderReport(report: DoctorReport): void {
192
+ const glyph: Record<CheckStatus, string> = { ok: "✓", warn: "!", fail: "✗" };
193
+ console.log(`\nmirin doctor — ${report.projectDir}\n`);
194
+ for (const check of report.checks) {
195
+ console.log(` ${glyph[check.status]} ${check.name.padEnd(14)} ${check.detail}`);
196
+ if (check.fix !== undefined) console.log(`${" ".repeat(19)}→ ${check.fix}`);
197
+ }
198
+ const failures = report.checks.filter((check) => check.status === "fail").length;
199
+ const warnings = report.checks.filter((check) => check.status === "warn").length;
200
+ console.log(
201
+ `\n${report.ok ? "✓ ready" : `✗ ${failures} blocking problem(s)`}${warnings > 0 ? `, ${warnings} warning(s)` : ""}\n`,
202
+ );
203
+ }
204
+
205
+ /** Run the preflight. Exit code 0 unless a check failed outright. */
206
+ export async function doctor(
207
+ projectDir = process.cwd(),
208
+ options: { json?: boolean } = {},
209
+ ): Promise<number> {
210
+ const reporter = createReporter(options.json === true);
211
+ const checks: DoctorCheck[] = [
212
+ checkPlatform(),
213
+ ...(await checkConfig(projectDir)),
214
+ ...checkUi(projectDir),
215
+ checkCef(),
216
+ ...(await checkPorts()),
217
+ checkLastSession(projectDir),
218
+ ];
219
+ const report: DoctorReport = {
220
+ ok: !checks.some((check) => check.status === "fail"),
221
+ projectDir,
222
+ platform: `${process.platform}-${process.arch}`,
223
+ checks,
224
+ };
225
+ reporter.finish({ ...report }, () => renderReport(report));
226
+ return report.ok ? 0 : 1;
227
+ }
package/src/extras.ts CHANGED
@@ -5,10 +5,12 @@
5
5
  * place so the two CLI paths stay in sync.
6
6
  */
7
7
 
8
+ import { mkdirSync } from "node:fs";
9
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
8
10
  import { $ } from "bun";
9
- import { join } from "node:path";
11
+ import { createReporter, type Reporter } from "./shared/report.ts";
10
12
 
11
- /** Config shapes (mirrors packages/mirin/src/config.ts; CLI can't import the runtime). */
13
+ /** Config shapes (mirrors packages/mirin/src/config/index.ts; CLI can't import the runtime). */
12
14
  export type SidecarConfig = Record<string, string | { bin: string; entitlements?: string[] }>;
13
15
  export type WorkersConfig = Record<string, string>;
14
16
 
@@ -21,16 +23,47 @@ export interface NormalizedSidecar {
21
23
  entitlements: string[];
22
24
  }
23
25
 
26
+ const SAFE_EXTRA_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+
28
+ /**
29
+ * Extra asset names become bundle filenames, so keep them as path segments. This
30
+ * prevents config keys such as "../tool" from escaping resources/sidecars or
31
+ * resources/workers in dev and production bundles.
32
+ */
33
+ export function safeExtraAssetName(name: string, label: string): string {
34
+ if (!SAFE_EXTRA_NAME.test(name) || name === "." || name === "..") {
35
+ throw new Error(
36
+ `[mirin] invalid ${label} "${name}" — use letters, digits, ".", "_" or "-", ` +
37
+ "and do not include path separators.",
38
+ );
39
+ }
40
+ return name;
41
+ }
42
+
43
+ function projectRelativePath(projectDir: string, file: string, label: string): string {
44
+ if (isAbsolute(file)) {
45
+ throw new Error(`[mirin] ${label} must be relative to the project root: ${file}`);
46
+ }
47
+ const root = resolve(projectDir);
48
+ const full = resolve(root, file);
49
+ const rel = relative(root, full);
50
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
51
+ throw new Error(`[mirin] ${label} escapes the project root: ${file}`);
52
+ }
53
+ return full;
54
+ }
55
+
24
56
  /** Resolve `config.sidecars` to absolute source paths. */
25
57
  export function normalizeSidecars(
26
58
  projectDir: string,
27
59
  sidecars: SidecarConfig | undefined,
28
60
  ): NormalizedSidecar[] {
29
61
  return Object.entries(sidecars ?? {}).map(([name, value]) => {
62
+ const safeName = safeExtraAssetName(name, "sidecar name");
30
63
  const spec = typeof value === "string" ? { bin: value } : value;
31
64
  return {
32
- name,
33
- src: join(projectDir, spec.bin),
65
+ name: safeName,
66
+ src: projectRelativePath(projectDir, spec.bin, `sidecar "${safeName}" path`),
34
67
  entitlements: spec.entitlements ?? [],
35
68
  };
36
69
  });
@@ -45,17 +78,23 @@ export async function compileWorkers(
45
78
  workers: WorkersConfig | undefined,
46
79
  outDir: string,
47
80
  minify: boolean,
81
+ /** Where `bun build`'s output goes. Defaults to human mode, which prints it. */
82
+ reporter: Reporter = createReporter(false),
48
83
  ): Promise<Record<string, string>> {
84
+ mkdirSync(outDir, { recursive: true });
49
85
  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;
86
+ const compiled = Object.entries(workers ?? {}).map(async ([name, entry]) => {
87
+ const safeName = safeExtraAssetName(name, "worker name");
88
+ const js = join(outDir, `${safeName}.js`);
89
+ const src = projectRelativePath(projectDir, entry, `worker "${safeName}" entry`);
90
+ const minifyFlag = minify ? ["--minify"] : [];
91
+ await reporter.build(
92
+ $`bun build ${src} --target=bun ${minifyFlag} --outfile ${js}`.cwd(projectDir),
93
+ );
94
+ return { name: safeName, js };
95
+ });
96
+ for (const worker of await Promise.all(compiled)) {
97
+ out[worker.name] = worker.js;
59
98
  }
60
99
  return out;
61
100
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Compile macOS appearance-variant app icons (light / dark / tinted, macOS 26+).
3
+ *
4
+ * `.icns` has no notion of appearance — the Dock picks a variant out of a
5
+ * compiled asset catalog keyed by `CFBundleIconName`, and the only input that
6
+ * produces one is an Icon Composer `.icon` document. `actool` compiles it to
7
+ * the same `Assets.car` shape Apple's own bundled apps ship.
8
+ *
9
+ * Best-effort throughout: `actool` only ships with a full Xcode, so a machine
10
+ * with just Command Line Tools warns and keeps the plain `.icns`.
11
+ */
12
+
13
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { $ } from "bun";
16
+
17
+ /** True when the source is an Icon Composer document, which actool reads directly. */
18
+ export const isIconComposerDoc = (path: string): boolean => path.endsWith(".icon");
19
+
20
+ /** Asset-catalog icon name; also the `CFBundleIconName` written to Info.plist. */
21
+ const ICON_NAME = "AppIcon";
22
+
23
+ /**
24
+ * Locate `actool`. `xcrun` honours the selected developer dir, which is often
25
+ * Command Line Tools (no actool), so fall back to a full Xcode if one is
26
+ * installed but not selected.
27
+ */
28
+ async function findActool(): Promise<string | undefined> {
29
+ const xcrun = await $`xcrun --find actool`.nothrow().quiet();
30
+ if (xcrun.exitCode === 0) {
31
+ const path = xcrun.stdout.toString().trim();
32
+ if (path && existsSync(path)) return path;
33
+ }
34
+ for (const app of ["/Applications/Xcode.app", "/Applications/Xcode-beta.app"]) {
35
+ const path = join(app, "Contents/Developer/usr/bin/actool");
36
+ if (existsSync(path)) return path;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ export interface AppearanceCatalog {
42
+ /** Goes in Info.plist as `CFBundleIconName`. */
43
+ name: string;
44
+ /** The legacy `.icns` actool derives alongside the catalog, if it emitted one. */
45
+ icns?: string;
46
+ }
47
+
48
+ /**
49
+ * Compile `icon` into `<Resources>/Assets.car`, or return undefined if the
50
+ * catalog could not be built (no actool, unreadable art) — callers then keep
51
+ * the plain `.icns` path.
52
+ */
53
+ export async function writeAppearanceCatalog(
54
+ iconDoc: string,
55
+ resourcesDir: string,
56
+ workDir: string,
57
+ ): Promise<AppearanceCatalog | undefined> {
58
+ // actool resolves its own arguments, so hand it absolute paths regardless of
59
+ // what the caller passed and of the subprocess working directory.
60
+ const resources = resolve(resourcesDir);
61
+ const work = resolve(workDir);
62
+ const actool = await findActool();
63
+ if (!actool) {
64
+ console.warn(
65
+ "[mirin] actool not found — appearance variants need a full Xcode " +
66
+ "(Command Line Tools alone is not enough); shipping the default icon only.",
67
+ );
68
+ return undefined;
69
+ }
70
+
71
+ rmSync(work, { recursive: true, force: true });
72
+ mkdirSync(work, { recursive: true });
73
+
74
+ const doc = join(work, `${ICON_NAME}.icon`);
75
+ cpSync(iconDoc, doc, { recursive: true });
76
+
77
+ const compiled = join(work, "compiled");
78
+ mkdirSync(compiled, { recursive: true });
79
+ const res =
80
+ await $`${actool} --output-format human-readable-text --notices --warnings --app-icon ${ICON_NAME} --output-partial-info-plist ${join(work, "partial.plist")} --enable-on-demand-resources NO --target-device mac --minimum-deployment-target 26.0 --platform macosx --compile ${compiled} ${doc}`
81
+ .nothrow()
82
+ .quiet();
83
+
84
+ const car = join(compiled, "Assets.car");
85
+ if (res.exitCode !== 0 || !existsSync(car)) {
86
+ console.warn(
87
+ `[mirin] actool failed (exit ${res.exitCode}) — shipping the default icon only.\n` +
88
+ res.stdout.toString().trim(),
89
+ );
90
+ return undefined;
91
+ }
92
+
93
+ cpSync(car, join(resources, "Assets.car"));
94
+ const icns = join(compiled, `${ICON_NAME}.icns`);
95
+ return { name: ICON_NAME, icns: existsSync(icns) ? icns : undefined };
96
+ }
@@ -0,0 +1 @@
1
+ export { isIconComposerDoc, writeAppearanceCatalog } from "./appearance.ts";
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Produce a Windows `.ico` from the app's icon source (mirrors the macOS `.icns`
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`.
8
+ */
9
+
10
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { inflateSync } from "node:zlib";
13
+
14
+ /** Standard Windows icon sizes; intermediate sizes are downscaled from these by
15
+ * the shell, so the 256 entry keeps large views crisp. */
16
+ const ICO_SIZES = [16, 32, 48, 64, 128, 256];
17
+
18
+ interface IcoEntry {
19
+ size: number;
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;
24
+ }
25
+
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 =
61
+ colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 4 ? 2 : colorType === 0 ? 1 : 0;
62
+ if (!channels) throw new Error(`unsupported color type ${colorType}`);
63
+
64
+ const raw = inflateSync(Buffer.concat(idat));
65
+ const stride = width * channels;
66
+ const rgba = Buffer.alloc(width * height * 4);
67
+ let prev = Buffer.alloc(stride);
68
+ let rp = 0;
69
+ for (let y = 0; y < height; y++) {
70
+ const filter = raw[rp++]!;
71
+ const cur = Buffer.from(raw.subarray(rp, rp + stride));
72
+ rp += stride;
73
+ unfilter(filter, cur, prev, channels);
74
+ for (let x = 0; x < width; x++) {
75
+ const s = x * channels;
76
+ let r: number;
77
+ let g: number;
78
+ let b: number;
79
+ let a: number;
80
+ if (channels === 4) {
81
+ r = cur[s]!;
82
+ g = cur[s + 1]!;
83
+ b = cur[s + 2]!;
84
+ a = cur[s + 3]!;
85
+ } else if (channels === 3) {
86
+ r = cur[s]!;
87
+ g = cur[s + 1]!;
88
+ b = cur[s + 2]!;
89
+ a = 255;
90
+ } else if (channels === 2) {
91
+ r = g = b = cur[s]!;
92
+ a = cur[s + 1]!;
93
+ } else {
94
+ r = g = b = cur[s]!;
95
+ a = 255;
96
+ }
97
+ const d = (y * width + x) * 4;
98
+ rgba[d] = r;
99
+ rgba[d + 1] = g;
100
+ rgba[d + 2] = b;
101
+ rgba[d + 3] = a;
102
+ }
103
+ prev = cur;
104
+ }
105
+ return { width, height, rgba };
106
+ }
107
+
108
+ /** Reverse a PNG scanline filter in place (None/Sub/Up/Average/Paeth). */
109
+ function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void {
110
+ for (let i = 0; i < cur.length; i++) {
111
+ const a = i >= bpp ? cur[i - bpp]! : 0;
112
+ const b = prev[i]!;
113
+ const c = i >= bpp ? prev[i - bpp]! : 0;
114
+ let v = cur[i]!;
115
+ if (filter === 1) v += a;
116
+ else if (filter === 2) v += b;
117
+ else if (filter === 3) v += (a + b) >> 1;
118
+ else if (filter === 4) {
119
+ const p = a + b - c;
120
+ const pa = Math.abs(p - a);
121
+ const pb = Math.abs(p - b);
122
+ const pc = Math.abs(p - c);
123
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
124
+ }
125
+ cur[i] = v & 0xff;
126
+ }
127
+ }
128
+
129
+ /** Encode one icon image as a DIB: BITMAPINFOHEADER + bottom-up 32bpp BGRA + a
130
+ * (zeroed) 1bpp AND mask — the alpha channel drives transparency. */
131
+ function dibEntry(size: number, rgba: Buffer): Buffer {
132
+ const xor = Buffer.alloc(size * size * 4);
133
+ for (let dy = 0; dy < size; dy++) {
134
+ const sy = size - 1 - dy; // DIB rows run bottom-to-top
135
+ for (let x = 0; x < size; x++) {
136
+ const s = (sy * size + x) * 4;
137
+ const d = (dy * size + x) * 4;
138
+ xor[d] = rgba[s + 2]!; // B
139
+ xor[d + 1] = rgba[s + 1]!; // G
140
+ xor[d + 2] = rgba[s]!; // R
141
+ xor[d + 3] = rgba[s + 3]!; // A
142
+ }
143
+ }
144
+ const andStride = Math.ceil(size / 32) * 4; // 1bpp, rows padded to 4 bytes
145
+ const andMask = Buffer.alloc(andStride * size, 0);
146
+ const hdr = Buffer.alloc(40);
147
+ hdr.writeUInt32LE(40, 0); // biSize
148
+ hdr.writeInt32LE(size, 4); // biWidth
149
+ hdr.writeInt32LE(size * 2, 8); // biHeight = XOR rows + AND rows
150
+ hdr.writeUInt16LE(1, 12); // biPlanes
151
+ hdr.writeUInt16LE(32, 14); // biBitCount
152
+ hdr.writeUInt32LE(0, 16); // BI_RGB
153
+ hdr.writeUInt32LE(xor.length + andMask.length, 20);
154
+ return Buffer.concat([hdr, xor, andMask]);
155
+ }
156
+
157
+ /** Assemble the ICONDIR + entries into a `.ico`. */
158
+ function packIco(entries: IcoEntry[]): Buffer {
159
+ const dirSize = 6 + entries.length * 16;
160
+ const dir = Buffer.alloc(dirSize);
161
+ dir.writeUInt16LE(0, 0); // reserved
162
+ dir.writeUInt16LE(1, 2); // type 1 = icon
163
+ dir.writeUInt16LE(entries.length, 4);
164
+ let offset = dirSize;
165
+ entries.forEach((e, i) => {
166
+ const base = 6 + i * 16;
167
+ dir[base] = e.size >= 256 ? 0 : e.size; // width (0 = 256)
168
+ dir[base + 1] = e.size >= 256 ? 0 : e.size; // height
169
+ dir.writeUInt16LE(1, base + 4); // planes
170
+ dir.writeUInt16LE(32, base + 6); // bits per pixel
171
+ dir.writeUInt32LE(e.data.length, base + 8); // bytes in resource
172
+ dir.writeUInt32LE(offset, base + 12); // image offset
173
+ offset += e.data.length;
174
+ });
175
+ return Buffer.concat([dir, ...entries.map((e) => e.data)]);
176
+ }
177
+
178
+ /**
179
+ * Build one icon entry from a square PNG rendition. Sizes ≥256 are stored as the
180
+ * raw PNG blob (the Windows convention — a 256×256 DIB is non-standard and is
181
+ * rejected by Bun/rcedit/the resource compiler); smaller sizes are decoded to a
182
+ * DIB. Returns undefined if the file isn't the expected square size.
183
+ */
184
+ function rendition(file: string, size: number): IcoEntry | undefined {
185
+ try {
186
+ const buf = readFileSync(file);
187
+ if (size >= 256) return { size, data: buf, png: true };
188
+ const { width, height, rgba } = decodePng(buf);
189
+ if (width !== size || height !== size) return undefined;
190
+ return { size, data: dibEntry(size, rgba), png: false };
191
+ } catch (e) {
192
+ console.warn(`[mirin] icon: skipping ${file} (${e instanceof Error ? e.message : e})`);
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Write `outPath` (an `.ico`) from `iconSrc`, returning it, or undefined if no
199
+ * usable source was found. With `onlyLargest`, emits a single (largest) entry —
200
+ * needed for the **exe** icon, since rcedit's multi-size icon replacement fails on
201
+ * Bun-compiled exes (single-entry works, and Windows downscales 256 cleanly for
202
+ * small views). The full multi-size form is fine for `icon.ico` (NSIS + WM_SETICON).
203
+ */
204
+ export function makeWindowsIcon(
205
+ iconSrc: string,
206
+ outPath: string,
207
+ opts?: { onlyLargest?: boolean },
208
+ ): string | undefined {
209
+ if (!existsSync(iconSrc)) {
210
+ console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
211
+ return undefined;
212
+ }
213
+ if (iconSrc.endsWith(".ico")) {
214
+ copyFileSync(iconSrc, outPath);
215
+ return outPath;
216
+ }
217
+
218
+ const entries: IcoEntry[] = [];
219
+ if (iconSrc.endsWith(".iconset")) {
220
+ const sizes = opts?.onlyLargest ? [...ICO_SIZES].reverse() : ICO_SIZES;
221
+ for (const size of sizes) {
222
+ const png = join(iconSrc, `icon_${size}x${size}.png`);
223
+ if (existsSync(png)) {
224
+ const e = rendition(png, size);
225
+ if (e) {
226
+ entries.push(e);
227
+ if (opts?.onlyLargest) break;
228
+ }
229
+ }
230
+ }
231
+ } else if (iconSrc.endsWith(".png")) {
232
+ try {
233
+ const buf = readFileSync(iconSrc);
234
+ const { width, height, rgba } = decodePng(buf);
235
+ if (width !== height || width > 256) {
236
+ console.warn(`[mirin] icon png must be square ≤256px (got ${width}×${height})`);
237
+ } else if (width === 256) {
238
+ entries.push({ size: 256, data: buf, png: true });
239
+ } else {
240
+ entries.push({ size: width, data: dibEntry(width, rgba), png: false });
241
+ }
242
+ } catch (e) {
243
+ console.warn(
244
+ `[mirin] icon: cannot decode ${iconSrc} (${e instanceof Error ? e.message : e})`,
245
+ );
246
+ }
247
+ }
248
+
249
+ if (!entries.length) {
250
+ console.warn(`[mirin] no usable icon renditions in ${iconSrc}`);
251
+ return undefined;
252
+ }
253
+ writeFileSync(outPath, packIco(entries));
254
+ return outPath;
255
+ }
@@ -0,0 +1 @@
1
+ export * from "./icon.ts";