@mirinjs/cli 0.1.0-alpha.0 → 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 +5 -5
- package/src/build.ts +103 -2
- package/src/dev.ts +1 -1
- package/src/dmg.ts +29 -1
- package/src/icon-win.ts +210 -39
- package/src/installer-inno.ts +130 -0
- package/src/installer-win.ts +166 -0
- package/src/release.ts +74 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirinjs/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
31
|
-
"mirinjs": "0.1.0-alpha.
|
|
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.
|
|
35
|
-
"@mirinjs/win32-x64": "0.1.0-alpha.
|
|
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;
|
|
@@ -43,6 +117,12 @@ export interface BuildResult {
|
|
|
43
117
|
projectDir: string;
|
|
44
118
|
/** DMG config from mirin.config.ts (`true`/object/`false`); default `true`. */
|
|
45
119
|
dmg: boolean | import("mirinjs").DmgConfig;
|
|
120
|
+
/** NSIS installer config (Windows) — `true`/object/`false`; default `true`. */
|
|
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;
|
|
46
126
|
/** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
|
|
47
127
|
signIdentity?: string;
|
|
48
128
|
}
|
|
@@ -73,6 +153,9 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
73
153
|
const channel: string = config.release?.channel ?? "stable";
|
|
74
154
|
const baseUrl: string | undefined = config.release?.baseUrl;
|
|
75
155
|
const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
|
|
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;
|
|
76
159
|
|
|
77
160
|
console.log(`[mirin build] ${appName} ${version}`);
|
|
78
161
|
|
|
@@ -89,6 +172,17 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
89
172
|
const hostExe = join(work, IS_WINDOWS ? "host-release.exe" : "host-release");
|
|
90
173
|
const workerJs = join(work, "worker.release.js");
|
|
91
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
|
+
}
|
|
92
186
|
await $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(projectDir);
|
|
93
187
|
|
|
94
188
|
// Extra assets: resolve sidecar binaries + compile any extra worker entries.
|
|
@@ -105,7 +199,11 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
105
199
|
const resources = {
|
|
106
200
|
uiDir: join(projectDir, "dist"),
|
|
107
201
|
workerJs,
|
|
108
|
-
manifestJson: JSON.stringify({
|
|
202
|
+
manifestJson: JSON.stringify({
|
|
203
|
+
id: bundleId,
|
|
204
|
+
windows: config.windows,
|
|
205
|
+
singleInstance: config.singleInstance,
|
|
206
|
+
}),
|
|
109
207
|
versionJson,
|
|
110
208
|
workers: extraWorkers,
|
|
111
209
|
};
|
|
@@ -146,6 +244,9 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
146
244
|
coreDylib: artifacts.coreDylib,
|
|
147
245
|
projectDir,
|
|
148
246
|
dmg,
|
|
247
|
+
nsis,
|
|
248
|
+
inno,
|
|
249
|
+
publisher,
|
|
149
250
|
signIdentity,
|
|
150
251
|
};
|
|
151
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
|
|
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) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows installer (NSIS) for `mirin release` — the Windows analogue of `dmg.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Generates an NSIS script from the app folder + config and compiles it with
|
|
5
|
+
* `makensis` into a single `…-setup.exe`. The installer copies the app into
|
|
6
|
+
* Program Files (per-machine) or `%LOCALAPPDATA%\Programs` (per-user, the
|
|
7
|
+
* default — no elevation, and the in-app updater can swap the folder), creates
|
|
8
|
+
* Start Menu + Desktop shortcuts, writes an uninstaller, and registers an
|
|
9
|
+
* Add/Remove Programs entry. Customizable via {@link NsisConfig}.
|
|
10
|
+
*
|
|
11
|
+
* Requires `makensis` (NSIS 3+) on PATH; the caller checks `hasMakensis()` and
|
|
12
|
+
* falls back to the portable `.zip` when it's absent.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { $ } from "bun";
|
|
16
|
+
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join, isAbsolute } from "node:path";
|
|
18
|
+
import type { NsisConfig } from "mirinjs";
|
|
19
|
+
import { makeWindowsIcon } from "./icon-win.ts";
|
|
20
|
+
|
|
21
|
+
export interface BuildNsisInput {
|
|
22
|
+
/** The assembled app folder (build/<App>). */
|
|
23
|
+
appDir: string;
|
|
24
|
+
appName: string;
|
|
25
|
+
/** Host exe name inside the folder (<App>.exe). */
|
|
26
|
+
exeName: string;
|
|
27
|
+
version: string;
|
|
28
|
+
/** Reverse-DNS app id → the Add/Remove Programs registry key. */
|
|
29
|
+
bundleId: string;
|
|
30
|
+
/** Output directory (build/release). */
|
|
31
|
+
outDir: string;
|
|
32
|
+
/** Output file name (e.g. stable-win32-x64-Anko-setup.exe). */
|
|
33
|
+
fileName: string;
|
|
34
|
+
options: NsisConfig;
|
|
35
|
+
/** Project root, for resolving a relative license / installerIcon. */
|
|
36
|
+
projectDir: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether `makensis` is on PATH. */
|
|
40
|
+
export async function hasMakensis(): Promise<boolean> {
|
|
41
|
+
return (await $`makensis -VERSION`.quiet().nothrow()).exitCode === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** NSIS double-quoted string escaping (`"` and `$` are special). */
|
|
45
|
+
function nsis(s: string): string {
|
|
46
|
+
return s.replace(/\$/g, "$$$$").replace(/"/g, '$\\"');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const resolve = (projectDir: string, p: string) => (isAbsolute(p) ? p : join(projectDir, p));
|
|
50
|
+
|
|
51
|
+
/** Build the installer with `makensis`, returning the setup.exe path. */
|
|
52
|
+
export async function buildNsisInstaller(input: BuildNsisInput): Promise<string> {
|
|
53
|
+
const { appDir, appName, exeName, version, bundleId, outDir, fileName, options, projectDir } = input;
|
|
54
|
+
const out = join(outDir, fileName);
|
|
55
|
+
rmSync(out, { force: true });
|
|
56
|
+
|
|
57
|
+
const perMachine = options.perMachine === true;
|
|
58
|
+
const assisted = options.oneClick !== true;
|
|
59
|
+
const desktop = options.desktopShortcut !== false;
|
|
60
|
+
const startMenu = options.startMenuShortcut !== false;
|
|
61
|
+
const runAfter = options.runAfterFinish !== false;
|
|
62
|
+
const changeDir = options.allowChangeInstallDir !== false;
|
|
63
|
+
const publisher = options.publisher ?? appName;
|
|
64
|
+
const root = perMachine ? "HKLM" : "HKCU";
|
|
65
|
+
const installDir =
|
|
66
|
+
options.installDir ?? (perMachine ? `$PROGRAMFILES64\\${appName}` : `$LOCALAPPDATA\\Programs\\${appName}`);
|
|
67
|
+
|
|
68
|
+
// Installer/uninstaller icon: an explicit .ico, else the app's bundled icon.ico.
|
|
69
|
+
let icon: string | undefined;
|
|
70
|
+
if (options.installerIcon) {
|
|
71
|
+
icon = makeWindowsIcon(resolve(projectDir, options.installerIcon), join(outDir, "_installer.ico"));
|
|
72
|
+
} else if (existsSync(join(appDir, "icon.ico"))) {
|
|
73
|
+
icon = join(appDir, "icon.ico");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const license = options.license ? resolve(projectDir, options.license) : undefined;
|
|
77
|
+
if (license && !existsSync(license)) throw new Error(`nsis: license not found: ${license}`);
|
|
78
|
+
|
|
79
|
+
const L: string[] = [];
|
|
80
|
+
L.push("Unicode True");
|
|
81
|
+
L.push("SetCompressor /SOLID lzma");
|
|
82
|
+
L.push('!include "MUI2.nsh"');
|
|
83
|
+
L.push('!include "FileFunc.nsh"');
|
|
84
|
+
L.push(`Name "${nsis(appName)}"`);
|
|
85
|
+
L.push(`OutFile "${nsis(out)}"`);
|
|
86
|
+
L.push(`InstallDir "${installDir}"`);
|
|
87
|
+
L.push(`RequestExecutionLevel ${perMachine ? "admin" : "user"}`);
|
|
88
|
+
L.push(`!define MUI_ABORTWARNING`);
|
|
89
|
+
if (icon) {
|
|
90
|
+
L.push(`!define MUI_ICON "${nsis(icon)}"`);
|
|
91
|
+
L.push(`!define MUI_UNICON "${nsis(icon)}"`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Pages. Assisted shows the full wizard; one-click is just the progress page.
|
|
95
|
+
if (assisted) L.push("!insertmacro MUI_PAGE_WELCOME");
|
|
96
|
+
if (license) L.push(`!insertmacro MUI_PAGE_LICENSE "${nsis(license)}"`);
|
|
97
|
+
if (assisted && changeDir) L.push("!insertmacro MUI_PAGE_DIRECTORY");
|
|
98
|
+
if (assisted && runAfter) {
|
|
99
|
+
L.push(`!define MUI_FINISHPAGE_RUN "$INSTDIR\\${nsis(exeName)}"`);
|
|
100
|
+
}
|
|
101
|
+
L.push("!insertmacro MUI_PAGE_INSTFILES");
|
|
102
|
+
if (assisted) L.push("!insertmacro MUI_PAGE_FINISH");
|
|
103
|
+
L.push("!insertmacro MUI_UNPAGE_CONFIRM");
|
|
104
|
+
L.push("!insertmacro MUI_UNPAGE_INSTFILES");
|
|
105
|
+
L.push('!insertmacro MUI_LANGUAGE "English"');
|
|
106
|
+
|
|
107
|
+
if (options.include) L.push("", "; --- user include ---", options.include, "");
|
|
108
|
+
|
|
109
|
+
const key = `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${bundleId}`;
|
|
110
|
+
const ctx = perMachine ? "all" : "current";
|
|
111
|
+
|
|
112
|
+
// Install section.
|
|
113
|
+
L.push("Section");
|
|
114
|
+
L.push(` SetShellVarContext ${ctx}`);
|
|
115
|
+
L.push(` SetOutPath "$INSTDIR"`);
|
|
116
|
+
L.push(` File /r "${nsis(appDir)}\\*"`);
|
|
117
|
+
L.push(` WriteUninstaller "$INSTDIR\\Uninstall.exe"`);
|
|
118
|
+
if (desktop) {
|
|
119
|
+
L.push(` CreateShortcut "$DESKTOP\\${nsis(appName)}.lnk" "$INSTDIR\\${nsis(exeName)}"`);
|
|
120
|
+
}
|
|
121
|
+
if (startMenu) {
|
|
122
|
+
L.push(` CreateDirectory "$SMPROGRAMS\\${nsis(appName)}"`);
|
|
123
|
+
L.push(
|
|
124
|
+
` CreateShortcut "$SMPROGRAMS\\${nsis(appName)}\\${nsis(appName)}.lnk" "$INSTDIR\\${nsis(exeName)}"`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
// Estimated size for Add/Remove Programs.
|
|
128
|
+
L.push(` \${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2`);
|
|
129
|
+
L.push(` IntFmt $0 "0x%08X" $0`);
|
|
130
|
+
const reg = (name: string, val: string) => L.push(` WriteRegStr ${root} "${key}" "${name}" "${val}"`);
|
|
131
|
+
reg("DisplayName", nsis(appName));
|
|
132
|
+
reg("DisplayVersion", nsis(version));
|
|
133
|
+
reg("Publisher", nsis(publisher));
|
|
134
|
+
reg("DisplayIcon", `$INSTDIR\\${nsis(exeName)}`);
|
|
135
|
+
reg("InstallLocation", "$INSTDIR");
|
|
136
|
+
reg("UninstallString", '$INSTDIR\\Uninstall.exe');
|
|
137
|
+
reg("QuietUninstallString", '$INSTDIR\\Uninstall.exe /S');
|
|
138
|
+
L.push(` WriteRegDWORD ${root} "${key}" "NoModify" 1`);
|
|
139
|
+
L.push(` WriteRegDWORD ${root} "${key}" "NoRepair" 1`);
|
|
140
|
+
L.push(` WriteRegDWORD ${root} "${key}" "EstimatedSize" $0`);
|
|
141
|
+
if (!assisted && runAfter) L.push(` Exec '"$INSTDIR\\${nsis(exeName)}"'`);
|
|
142
|
+
L.push("SectionEnd");
|
|
143
|
+
|
|
144
|
+
// Uninstall section.
|
|
145
|
+
L.push("Section \"Uninstall\"");
|
|
146
|
+
L.push(` SetShellVarContext ${ctx}`);
|
|
147
|
+
if (desktop) L.push(` Delete "$DESKTOP\\${nsis(appName)}.lnk"`);
|
|
148
|
+
if (startMenu) {
|
|
149
|
+
L.push(` Delete "$SMPROGRAMS\\${nsis(appName)}\\${nsis(appName)}.lnk"`);
|
|
150
|
+
L.push(` RMDir "$SMPROGRAMS\\${nsis(appName)}"`);
|
|
151
|
+
}
|
|
152
|
+
L.push(` RMDir /r "$INSTDIR"`);
|
|
153
|
+
L.push(` DeleteRegKey ${root} "${key}"`);
|
|
154
|
+
L.push("SectionEnd");
|
|
155
|
+
|
|
156
|
+
const script = join(outDir, "_installer.nsi");
|
|
157
|
+
writeFileSync(script, L.join("\n") + "\n");
|
|
158
|
+
console.log(`[mirin release] compiling NSIS installer → ${fileName}`);
|
|
159
|
+
const res = await $`makensis -V2 ${script}`.nothrow();
|
|
160
|
+
rmSync(script, { force: true });
|
|
161
|
+
rmSync(join(outDir, "_installer.ico"), { force: true });
|
|
162
|
+
if (res.exitCode !== 0 || !existsSync(out)) {
|
|
163
|
+
throw new Error(`makensis failed (exit ${res.exitCode})`);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
package/src/release.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { join } from "node:path";
|
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { build } from "./build.ts";
|
|
24
24
|
import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
|
|
25
|
+
import { buildNsisInstaller, hasMakensis } from "./installer-win.ts";
|
|
26
|
+
import { buildInnoInstaller, hasInno } from "./installer-inno.ts";
|
|
25
27
|
import { loadCodec } from "mirinjs/codec";
|
|
26
28
|
|
|
27
29
|
const sha256File = (path: string) =>
|
|
@@ -129,28 +131,81 @@ export async function release(projectDir = process.cwd()): Promise<number> {
|
|
|
129
131
|
let installerName: string | undefined;
|
|
130
132
|
let installerSize = 0;
|
|
131
133
|
if (isWindows) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
installerSize = readFileSync(zipPath).byteLength;
|
|
139
|
-
} else if (result.dmg !== false) {
|
|
140
|
-
installerName = `${prefix}-${safeName}.dmg`;
|
|
141
|
-
console.log(`[mirin release] building installer → ${installerName}`);
|
|
142
|
-
const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
|
|
143
|
-
const dmgPath = await buildDmg({
|
|
144
|
-
app: result.app,
|
|
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,
|
|
145
140
|
appName: result.appName,
|
|
141
|
+
exeName: `${result.appName}.exe`,
|
|
142
|
+
version: result.version,
|
|
143
|
+
bundleId: result.bundleId,
|
|
146
144
|
outDir,
|
|
147
|
-
fileName:
|
|
148
|
-
options,
|
|
145
|
+
fileName: setupName,
|
|
149
146
|
projectDir: result.projectDir,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 },
|
|
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;
|
|
166
|
+
installerSize = readFileSync(exePath).byteLength;
|
|
167
|
+
} else {
|
|
168
|
+
if (innoWanted || nsisWanted) {
|
|
169
|
+
console.warn(
|
|
170
|
+
"[mirin release] no installer toolchain found — shipping the portable .zip. " +
|
|
171
|
+
"Install Inno Setup (`scoop install inno-setup`) or NSIS (`scoop install nsis`).",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
installerName = `${prefix}-${safeName}.zip`;
|
|
175
|
+
console.log(`[mirin release] zipping → ${installerName}`);
|
|
176
|
+
const zipPath = join(outDir, installerName);
|
|
177
|
+
rmSync(zipPath, { force: true });
|
|
178
|
+
// bsdtar (Windows `tar`) creates a .zip from the extension with `-a`.
|
|
179
|
+
await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
|
|
180
|
+
installerSize = readFileSync(zipPath).byteLength;
|
|
181
|
+
}
|
|
182
|
+
} else if (result.dmg !== false) {
|
|
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
|
+
}
|
|
154
209
|
}
|
|
155
210
|
|
|
156
211
|
const mb = (n: number) => (n / 1e6).toFixed(1);
|