@mirinjs/cli 0.1.0-alpha.1 → 0.1.0-alpha.11
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 +5 -5
- package/src/artifacts.ts +12 -5
- package/src/build.ts +165 -6
- package/src/bundle-linux.ts +234 -0
- package/src/dev.ts +64 -8
- package/src/dmg.ts +29 -1
- package/src/icon-win.ts +210 -39
- package/src/index.ts +46 -3
- package/src/installer-inno.ts +130 -0
- package/src/package-linux.ts +334 -0
- package/src/release.ts +104 -38
package/src/dev.ts
CHANGED
|
@@ -9,14 +9,22 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { $ } from "bun";
|
|
12
|
-
import { mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
|
|
12
|
+
import { mkdirSync, rmSync, symlinkSync, existsSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { createServer } from "node:net";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { buildAppBundle } from "./bundle.ts";
|
|
16
16
|
import { buildWindowsBundle } from "./bundle-win.ts";
|
|
17
|
+
import {
|
|
18
|
+
buildLinuxBundle,
|
|
19
|
+
resolveLinuxIconPng,
|
|
20
|
+
resolveLinuxDesktopIconPng,
|
|
21
|
+
desktopEntry,
|
|
22
|
+
} from "./bundle-linux.ts";
|
|
23
|
+
import { homedir } from "node:os";
|
|
17
24
|
import { resolveArtifacts } from "./artifacts.ts";
|
|
18
25
|
|
|
19
26
|
const IS_WINDOWS = process.platform === "win32";
|
|
27
|
+
const IS_LINUX = process.platform === "linux";
|
|
20
28
|
import { sweepBuildTemps } from "./temps.ts";
|
|
21
29
|
import { normalizeSidecars, compileWorkers } from "./extras.ts";
|
|
22
30
|
|
|
@@ -36,6 +44,11 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
36
44
|
const appName: string = config.name ?? "Mirin App";
|
|
37
45
|
const bundleId: string = config.id ?? "dev.mirin.app";
|
|
38
46
|
const mainEntry = join(projectDir, config.main ?? "main/main.ts");
|
|
47
|
+
const iconSrc = config.icon ? join(projectDir, config.icon) : undefined;
|
|
48
|
+
// Linux: resolve the app icon to a concrete PNG the core stamps onto the window
|
|
49
|
+
// as `_NET_WM_ICON` (cosmic dock/taskbar). Passed straight through in dev — no
|
|
50
|
+
// bundling; the file is read from the project at window-create time.
|
|
51
|
+
const linuxIconPng = IS_LINUX && iconSrc ? resolveLinuxIconPng(iconSrc) : undefined;
|
|
39
52
|
|
|
40
53
|
// --- native artifacts ---
|
|
41
54
|
const artifacts = await resolveArtifacts({ release: false });
|
|
@@ -61,7 +74,7 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
61
74
|
else console.warn(`[mirin dev] sidecar "${sc.name}" not found: ${sc.src}`);
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
// --- assemble the dev bundle (Windows app folder, or macOS .app) ---
|
|
77
|
+
// --- assemble the dev bundle (Windows/Linux app folder, or macOS .app) ---
|
|
65
78
|
console.log("[mirin dev] assembling dev bundle…");
|
|
66
79
|
const { app, exe } = IS_WINDOWS
|
|
67
80
|
? await buildWindowsBundle({
|
|
@@ -71,7 +84,17 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
71
84
|
coreDll: artifacts.coreDylib,
|
|
72
85
|
helperExe: artifacts.helperBin,
|
|
73
86
|
cefPath: artifacts.cefPath,
|
|
74
|
-
icon:
|
|
87
|
+
icon: iconSrc,
|
|
88
|
+
})
|
|
89
|
+
: IS_LINUX
|
|
90
|
+
? await buildLinuxBundle({
|
|
91
|
+
appName,
|
|
92
|
+
outDir: work,
|
|
93
|
+
hostExe,
|
|
94
|
+
coreDll: artifacts.coreDylib,
|
|
95
|
+
helperExe: artifacts.helperBin,
|
|
96
|
+
cefPath: artifacts.cefPath,
|
|
97
|
+
icon: iconSrc,
|
|
75
98
|
})
|
|
76
99
|
: await buildAppBundle({
|
|
77
100
|
appName,
|
|
@@ -81,19 +104,41 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
81
104
|
coreDylib: artifacts.coreDylib,
|
|
82
105
|
helperBin: artifacts.helperBin,
|
|
83
106
|
cefPath: artifacts.cefPath,
|
|
84
|
-
icon:
|
|
107
|
+
icon: iconSrc,
|
|
85
108
|
urlSchemes: config.urlSchemes,
|
|
86
109
|
});
|
|
87
110
|
|
|
111
|
+
// Linux: install a dev `.desktop` entry so cosmic's dock can resolve the app's
|
|
112
|
+
// icon. cosmic matches a running X11 window's `WM_CLASS` (which the core sets to
|
|
113
|
+
// the bundle id) against `StartupWMClass`, then shows that entry's `Icon`. Written
|
|
114
|
+
// to the per-user applications dir; harmless on other DEs.
|
|
115
|
+
if (IS_LINUX && iconSrc) {
|
|
116
|
+
const desktopIcon = resolveLinuxDesktopIconPng(iconSrc);
|
|
117
|
+
if (desktopIcon) {
|
|
118
|
+
const appsDir = join(homedir(), ".local", "share", "applications");
|
|
119
|
+
mkdirSync(appsDir, { recursive: true });
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(appsDir, `${bundleId}.desktop`),
|
|
122
|
+
desktopEntry({ name: appName, exec: exe, iconPng: desktopIcon, wmClass: bundleId }),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
88
127
|
// --- start Vite on a free port so concurrent dev sessions don't collide ---
|
|
89
128
|
// `--port <free> --strictPort` pins Vite to the port we probed (overriding any
|
|
90
129
|
// port/strictPort in the app's vite.config) and passes the real URL to the app,
|
|
91
130
|
// so the host and Vite never disagree about which port is in use.
|
|
131
|
+
//
|
|
132
|
+
// Bind + address on 127.0.0.1 (IPv4), NOT "localhost": on hosts where Vite binds
|
|
133
|
+
// IPv6-only (`[::1]`) while Chromium resolves `localhost` to IPv4 (common on
|
|
134
|
+
// Linux), the webview's page load is refused and the window stays blank. Pinning
|
|
135
|
+
// both sides to 127.0.0.1 avoids the family mismatch (and matches the loopback
|
|
136
|
+
// RPC server). `--host 127.0.0.1` forces Vite's bind.
|
|
92
137
|
const port = await findFreePort(DEV_PORT_BASE);
|
|
93
|
-
const devUrl = `http://
|
|
138
|
+
const devUrl = `http://127.0.0.1:${port}`;
|
|
94
139
|
console.log(`[mirin dev] starting Vite dev server on ${devUrl}…`);
|
|
95
140
|
const vite = Bun.spawn(
|
|
96
|
-
["bunx", "vite", "--clearScreen", "false", "--port", String(port), "--strictPort"],
|
|
141
|
+
["bunx", "vite", "--clearScreen", "false", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
|
|
97
142
|
{
|
|
98
143
|
cwd: projectDir,
|
|
99
144
|
stdio: ["ignore", "inherit", "inherit"],
|
|
@@ -109,13 +154,24 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
109
154
|
...process.env,
|
|
110
155
|
MIRIN_CORE: IS_WINDOWS
|
|
111
156
|
? join(app, "mirin_core.dll")
|
|
157
|
+
: IS_LINUX
|
|
158
|
+
? join(app, "libmirin_core.so")
|
|
112
159
|
: join(app, "Contents", "MacOS", "libmirin_core.dylib"),
|
|
160
|
+
// Linux: the flat app dir holds libcef.so + libmirin_core.so; put it on the
|
|
161
|
+
// loader path so the host's dlopen and libmirin_core's NEEDED libcef.so (and
|
|
162
|
+
// the spawned mirin-helper) all resolve. (macOS uses the framework loader;
|
|
163
|
+
// Windows the exe-dir search — neither needs this.)
|
|
164
|
+
...(IS_LINUX ? { LD_LIBRARY_PATH: `${app}${process.env.LD_LIBRARY_PATH ? ":" + process.env.LD_LIBRARY_PATH : ""}` } : {}),
|
|
113
165
|
MIRIN_WORKER: workerJs,
|
|
114
166
|
MIRIN_DEV_URL: devUrl,
|
|
115
|
-
MIRIN_MANIFEST_JSON: JSON.stringify({ windows: config.windows }),
|
|
167
|
+
MIRIN_MANIFEST_JSON: JSON.stringify({ id: bundleId, windows: config.windows }),
|
|
116
168
|
// dev: true → enables inspect-element AND gives this run its own `-dev`
|
|
117
169
|
// CEF cache dir, so `mirin dev` can run alongside the installed app.
|
|
118
|
-
|
|
170
|
+
// `icon_path` (Linux) → the window's `_NET_WM_ICON` (cosmic dock/taskbar).
|
|
171
|
+
MIRIN_CONFIG_JSON: JSON.stringify({
|
|
172
|
+
dev: true,
|
|
173
|
+
...(linuxIconPng ? { icon_path: linuxIconPng } : {}),
|
|
174
|
+
}),
|
|
119
175
|
MIRIN_SIDECAR_DIR: sidecarsDir,
|
|
120
176
|
MIRIN_WORKERS_DIR: workersDir,
|
|
121
177
|
},
|
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
|
|
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`).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
18
|
+
interface IcoEntry {
|
|
16
19
|
size: number;
|
|
17
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
/**
|
|
54
|
-
*
|
|
55
|
-
|
|
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:
|
|
217
|
+
const entries: IcoEntry[] = [];
|
|
66
218
|
if (iconSrc.endsWith(".iconset")) {
|
|
67
|
-
|
|
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))
|
|
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
|
-
|
|
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) {
|
package/src/index.ts
CHANGED
|
@@ -8,16 +8,53 @@ import { dev } from "./dev.ts";
|
|
|
8
8
|
import { build } from "./build.ts";
|
|
9
9
|
import { release } from "./release.ts";
|
|
10
10
|
import { scaffold } from "create-mirinjs";
|
|
11
|
+
import type { LinuxPackageFormat } from "mirinjs";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/** Minimal flag parser: `--k=v`, `--k v`, and boolean `--k`; the rest are positionals. */
|
|
14
|
+
function parseArgs(argv: string[]): { positionals: string[]; opts: Record<string, string | boolean> } {
|
|
15
|
+
const opts: Record<string, string | boolean> = {};
|
|
16
|
+
const positionals: string[] = [];
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
if (a === undefined) continue;
|
|
20
|
+
if (a.startsWith("--")) {
|
|
21
|
+
const eq = a.indexOf("=");
|
|
22
|
+
if (eq >= 0) {
|
|
23
|
+
opts[a.slice(2, eq)] = a.slice(eq + 1);
|
|
24
|
+
} else {
|
|
25
|
+
const key = a.slice(2);
|
|
26
|
+
const next = argv[i + 1];
|
|
27
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
28
|
+
opts[key] = next;
|
|
29
|
+
i++;
|
|
30
|
+
} else {
|
|
31
|
+
opts[key] = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
positionals.push(a);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { positionals, opts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [command, ...rest] = Bun.argv.slice(2);
|
|
42
|
+
const { positionals, opts } = parseArgs(rest);
|
|
43
|
+
const arg = positionals[0];
|
|
13
44
|
|
|
14
45
|
const USAGE = `mirin — build desktop apps with Bun + Chromium
|
|
15
46
|
|
|
16
47
|
Usage:
|
|
17
48
|
mirin dev run the app against the Vite dev server (HMR + typed RPC)
|
|
18
|
-
mirin build package a standalone
|
|
49
|
+
mirin build package a standalone app (output: ./build)
|
|
19
50
|
mirin release build + emit update artifacts (output: ./build/release)
|
|
20
51
|
mirin init [dir] scaffold a new app
|
|
52
|
+
|
|
53
|
+
Options (build):
|
|
54
|
+
--version <v> override the app version (else package.json)
|
|
55
|
+
--linux also emit Linux packages (AppImage + .deb + .rpm)
|
|
56
|
+
--linux-target <a,b,…> restrict Linux packaging to these formats
|
|
57
|
+
(appimage,deb,rpm) — implies --linux
|
|
21
58
|
`;
|
|
22
59
|
|
|
23
60
|
switch (command) {
|
|
@@ -26,7 +63,13 @@ switch (command) {
|
|
|
26
63
|
break;
|
|
27
64
|
}
|
|
28
65
|
case "build": {
|
|
29
|
-
|
|
66
|
+
const version = typeof opts.version === "string" ? opts.version : undefined;
|
|
67
|
+
const target = typeof opts["linux-target"] === "string" ? opts["linux-target"] : undefined;
|
|
68
|
+
const linuxFormats = target
|
|
69
|
+
? (target.split(",").map((s) => s.trim()).filter(Boolean) as LinuxPackageFormat[])
|
|
70
|
+
: undefined;
|
|
71
|
+
const packageLinux = opts.linux === true || linuxFormats != null;
|
|
72
|
+
await build(process.cwd(), { version, packageLinux, linuxFormats });
|
|
30
73
|
process.exit(0);
|
|
31
74
|
break;
|
|
32
75
|
}
|
|
@@ -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
|
+
}
|