@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.22
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 +8 -5
- package/src/artifacts.ts +165 -26
- package/src/build.ts +236 -43
- package/src/bundle/linux/app.ts +275 -0
- package/src/bundle/linux/index.ts +1 -0
- package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
- package/src/bundle/macos/index.ts +1 -0
- package/src/bundle/shared/cef-locales.ts +91 -0
- package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
- package/src/bundle/windows/index.ts +1 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +214 -35
- package/src/dmg.ts +22 -10
- package/src/doctor.ts +227 -0
- package/src/extras.ts +52 -13
- package/src/icons/macos/appearance.ts +96 -0
- package/src/icons/macos/index.ts +1 -0
- package/src/icons/sources.ts +25 -0
- package/src/icons/windows/icon.ts +255 -0
- package/src/icons/windows/index.ts +1 -0
- package/src/index.ts +109 -7
- package/src/installer-inno.ts +141 -0
- package/src/installer-win.ts +17 -11
- package/src/package/linux/index.ts +2 -0
- package/src/package/linux/lib/defaults.ts +53 -0
- package/src/package/linux/lib/tools.ts +45 -0
- package/src/package/linux/lib/validation.ts +71 -0
- package/src/package/linux/package.ts +293 -0
- package/src/package/linux/types.ts +30 -0
- package/src/release/installers.ts +148 -0
- package/src/release/previous.ts +105 -0
- package/src/release.ts +166 -116
- package/src/shared/driver.ts +240 -0
- package/src/shared/inspector.ts +64 -0
- package/src/shared/report.ts +69 -0
- package/src/shared/scenario.ts +58 -0
- package/src/shared/session.ts +153 -0
- package/src/skill.ts +51 -0
- package/src/icon-win.ts +0 -81
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 {
|
|
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:
|
|
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
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the manifest's `icon` — a path, or per-platform {@link IconSources} —
|
|
3
|
+
* to the absolute path this build's target platform should use.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { IconSources } from "mirinjs/config";
|
|
8
|
+
|
|
9
|
+
export type IconPlatform = "macos" | "windows" | "linux";
|
|
10
|
+
|
|
11
|
+
export function currentIconPlatform(platform: NodeJS.Platform = process.platform): IconPlatform {
|
|
12
|
+
if (platform === "win32") return "windows";
|
|
13
|
+
if (platform === "darwin") return "macos";
|
|
14
|
+
return "linux";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveIconSource(
|
|
18
|
+
icon: string | IconSources | undefined,
|
|
19
|
+
projectDir: string,
|
|
20
|
+
platform: IconPlatform = currentIconPlatform(),
|
|
21
|
+
): string | undefined {
|
|
22
|
+
if (!icon) return undefined;
|
|
23
|
+
const picked = typeof icon === "string" ? icon : (icon[platform] ?? icon.default);
|
|
24
|
+
return picked ? join(projectDir, picked) : undefined;
|
|
25
|
+
}
|
|
@@ -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";
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,133 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* mirin CLI — `dev`, `build`, `init`.
|
|
3
|
+
* mirin CLI — `dev`, `check`, `doctor`, `build`, `release`, `init`, `skill`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { scaffold } from "create-mirinjs";
|
|
8
8
|
import { build } from "./build.ts";
|
|
9
|
+
import { check } from "./check.ts";
|
|
10
|
+
import { dev } from "./dev.ts";
|
|
11
|
+
import { doctor } from "./doctor.ts";
|
|
12
|
+
import { parseLinuxFormats } from "./package/linux/index.ts";
|
|
9
13
|
import { release } from "./release.ts";
|
|
10
|
-
import {
|
|
14
|
+
import { skill } from "./skill.ts";
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
/** Minimal flag parser: `--k=v`, `--k v`, and boolean `--k`; the rest are positionals. */
|
|
17
|
+
function parseArgs(argv: string[]): {
|
|
18
|
+
positionals: string[];
|
|
19
|
+
opts: Record<string, string | boolean>;
|
|
20
|
+
} {
|
|
21
|
+
const opts: Record<string, string | boolean> = {};
|
|
22
|
+
const positionals: string[] = [];
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
if (a === undefined) continue;
|
|
26
|
+
if (a.startsWith("--")) {
|
|
27
|
+
const eq = a.indexOf("=");
|
|
28
|
+
if (eq >= 0) {
|
|
29
|
+
opts[a.slice(2, eq)] = a.slice(eq + 1);
|
|
30
|
+
} else {
|
|
31
|
+
const key = a.slice(2);
|
|
32
|
+
const next = argv[i + 1];
|
|
33
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
34
|
+
opts[key] = next;
|
|
35
|
+
i++;
|
|
36
|
+
} else {
|
|
37
|
+
opts[key] = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
positionals.push(a);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { positionals, opts };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [command, ...rest] = Bun.argv.slice(2);
|
|
48
|
+
const { positionals, opts } = parseArgs(rest);
|
|
49
|
+
const arg = positionals[0];
|
|
13
50
|
|
|
14
51
|
const USAGE = `mirin — build desktop apps with Bun + Chromium
|
|
15
52
|
|
|
16
53
|
Usage:
|
|
17
54
|
mirin dev run the app against the Vite dev server (HMR + typed RPC)
|
|
18
|
-
mirin
|
|
55
|
+
mirin check boot once, capture a screenshot + UI snapshot, report, exit
|
|
56
|
+
mirin doctor check the project and environment without building
|
|
57
|
+
mirin build package a standalone app (output: ./build)
|
|
19
58
|
mirin release build + emit update artifacts (output: ./build/release)
|
|
20
59
|
mirin init [dir] scaffold a new app
|
|
60
|
+
mirin skill install the agent skill into this project (.claude/skills/mirin)
|
|
61
|
+
|
|
62
|
+
Options (build):
|
|
63
|
+
--version <v> override the app version (else package.json)
|
|
64
|
+
--linux also emit Linux packages (AppImage + .deb + .rpm)
|
|
65
|
+
--linux-target <a,b,…> restrict Linux packaging to these formats
|
|
66
|
+
(appimage,deb,rpm) — implies --linux
|
|
67
|
+
|
|
68
|
+
Options (check):
|
|
69
|
+
--timeout <ms> how long to wait for a window (default 45000)
|
|
70
|
+
--settle <ms> quiet time before capturing (default 1000)
|
|
71
|
+
--scenario <file> drive the app with a scenario before capturing;
|
|
72
|
+
the file default-exports defineCheck(async app => …)
|
|
73
|
+
|
|
74
|
+
Options (check, doctor, dev, build):
|
|
75
|
+
--json machine-readable output on stdout
|
|
21
76
|
`;
|
|
22
77
|
|
|
78
|
+
/** `--flag <n>` as a positive integer, or undefined when absent/unusable. */
|
|
79
|
+
function intOpt(key: string): number | undefined {
|
|
80
|
+
const raw = opts[key];
|
|
81
|
+
if (typeof raw !== "string") return undefined;
|
|
82
|
+
const value = Number(raw);
|
|
83
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const asJson = opts.json === true;
|
|
87
|
+
|
|
23
88
|
switch (command) {
|
|
24
89
|
case "dev": {
|
|
25
|
-
process.exit(await dev());
|
|
90
|
+
process.exit(await dev(process.cwd(), asJson ? { json: true } : undefined));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "check": {
|
|
94
|
+
process.exit(
|
|
95
|
+
await check(process.cwd(), {
|
|
96
|
+
json: asJson,
|
|
97
|
+
...(intOpt("timeout") !== undefined ? { timeoutMs: intOpt("timeout") } : {}),
|
|
98
|
+
...(intOpt("settle") !== undefined ? { settleMs: intOpt("settle") } : {}),
|
|
99
|
+
...(typeof opts.scenario === "string" ? { scenario: opts.scenario } : {}),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "doctor": {
|
|
105
|
+
process.exit(await doctor(process.cwd(), { json: asJson }));
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "skill": {
|
|
109
|
+
process.exit(await skill(process.cwd(), { json: asJson }));
|
|
26
110
|
break;
|
|
27
111
|
}
|
|
28
112
|
case "build": {
|
|
29
|
-
|
|
113
|
+
const version = typeof opts.version === "string" ? opts.version : undefined;
|
|
114
|
+
const target = typeof opts["linux-target"] === "string" ? opts["linux-target"] : undefined;
|
|
115
|
+
const linuxFormats = target ? parseLinuxFormats(target) : undefined;
|
|
116
|
+
const packageLinux = opts.linux === true || linuxFormats != null;
|
|
117
|
+
const result = await build(process.cwd(), { version, packageLinux, linuxFormats });
|
|
118
|
+
if (asJson) {
|
|
119
|
+
// Only what a caller needs; the rest of BuildResult is plumbing for `mirin release`.
|
|
120
|
+
console.log(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
phase: "result",
|
|
123
|
+
app: result.app,
|
|
124
|
+
appName: result.appName,
|
|
125
|
+
bundleId: result.bundleId,
|
|
126
|
+
version: result.version,
|
|
127
|
+
channel: result.channel,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
30
131
|
process.exit(0);
|
|
31
132
|
break;
|
|
32
133
|
}
|
|
@@ -39,6 +140,7 @@ switch (command) {
|
|
|
39
140
|
try {
|
|
40
141
|
const name = scaffold(targetDir);
|
|
41
142
|
console.log(`\n✓ Created ${name} in ${arg ?? "my-mirin-app"}`);
|
|
143
|
+
console.log(" .claude/skills/mirin/ — agent skill for verifying changes");
|
|
42
144
|
console.log(" bun install && bun run dev");
|
|
43
145
|
process.exit(0);
|
|
44
146
|
} catch (err) {
|