@mirinjs/cli 0.0.1-alpha.13 → 0.0.1-alpha.15
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 +4 -4
- package/src/build.ts +28 -2
- package/src/bundle.ts +72 -1
- package/src/dev.ts +63 -10
- package/src/dmg.ts +236 -0
- package/src/extras.ts +61 -0
- package/src/release.ts +27 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirinjs/cli",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.15",
|
|
4
4
|
"description": "CLI for mirin apps: dev, build, init.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"bun": ">=1.2.0"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"create-mirinjs": "0.0.1-alpha.
|
|
31
|
-
"mirinjs": "0.0.1-alpha.
|
|
30
|
+
"create-mirinjs": "0.0.1-alpha.15",
|
|
31
|
+
"mirinjs": "0.0.1-alpha.15"
|
|
32
32
|
},
|
|
33
33
|
"optionalDependencies": {
|
|
34
|
-
"@mirinjs/darwin-arm64": "0.0.1-alpha.
|
|
34
|
+
"@mirinjs/darwin-arm64": "0.0.1-alpha.15"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
package/src/build.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { join } from "node:path";
|
|
|
21
21
|
import { buildAppBundle } from "./bundle.ts";
|
|
22
22
|
import { resolveArtifacts } from "./artifacts.ts";
|
|
23
23
|
import { sweepBuildTemps } from "./temps.ts";
|
|
24
|
+
import { normalizeSidecars, compileWorkers } from "./extras.ts";
|
|
24
25
|
|
|
25
26
|
export interface BuildResult {
|
|
26
27
|
/** Path to the assembled .app. */
|
|
@@ -35,6 +36,12 @@ export interface BuildResult {
|
|
|
35
36
|
baseUrl?: string;
|
|
36
37
|
/** libmirin_core path (for the updater codec at release time). */
|
|
37
38
|
coreDylib: string;
|
|
39
|
+
/** Project root (so `mirin release` can resolve relative asset paths). */
|
|
40
|
+
projectDir: string;
|
|
41
|
+
/** DMG config from mirin.config.ts (`true`/object/`false`); default `true`. */
|
|
42
|
+
dmg: boolean | import("mirinjs").DmgConfig;
|
|
43
|
+
/** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
|
|
44
|
+
signIdentity?: string;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/** Read the project's package.json version (the single source of app version). */
|
|
@@ -62,6 +69,7 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
62
69
|
const version = appVersion(projectDir);
|
|
63
70
|
const channel: string = config.release?.channel ?? "stable";
|
|
64
71
|
const baseUrl: string | undefined = config.release?.baseUrl;
|
|
72
|
+
const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
|
|
65
73
|
|
|
66
74
|
console.log(`[mirin build] ${appName} ${version}`);
|
|
67
75
|
|
|
@@ -74,11 +82,16 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
74
82
|
|
|
75
83
|
// 3 + 4. host + worker (minified)
|
|
76
84
|
console.log("[mirin build] compiling host + bundling main process…");
|
|
85
|
+
const signIdentity = process.env.MIRIN_SIGN_IDENTITY;
|
|
77
86
|
const hostExe = join(work, "host-release");
|
|
78
87
|
const workerJs = join(work, "worker.release.js");
|
|
79
88
|
await $`bun build --compile --minify ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
|
|
80
89
|
await $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(projectDir);
|
|
81
90
|
|
|
91
|
+
// Extra assets: resolve sidecar binaries + compile any extra worker entries.
|
|
92
|
+
const sidecars = normalizeSidecars(projectDir, config.sidecars);
|
|
93
|
+
const extraWorkers = await compileWorkers(projectDir, config.workers, join(work, "workers"), true);
|
|
94
|
+
|
|
82
95
|
// 5. assemble + sign
|
|
83
96
|
console.log("[mirin build] assembling .app…");
|
|
84
97
|
rmSync(join(outDir, `${appName}.app`), { recursive: true, force: true });
|
|
@@ -97,16 +110,29 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
97
110
|
cefPath: artifacts.cefPath,
|
|
98
111
|
version,
|
|
99
112
|
icon: config.icon ? join(projectDir, config.icon) : undefined,
|
|
100
|
-
signIdentity
|
|
113
|
+
signIdentity,
|
|
101
114
|
resources: {
|
|
102
115
|
uiDir: join(projectDir, "dist"),
|
|
103
116
|
workerJs,
|
|
104
117
|
manifestJson: JSON.stringify({ windows: config.windows }),
|
|
105
118
|
versionJson,
|
|
119
|
+
sidecars,
|
|
120
|
+
workers: extraWorkers,
|
|
106
121
|
},
|
|
107
122
|
});
|
|
108
123
|
|
|
109
124
|
console.log(`\n[mirin build] done → ${app}`);
|
|
110
125
|
console.log(` open "${app}"`);
|
|
111
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
app,
|
|
128
|
+
appName,
|
|
129
|
+
bundleId,
|
|
130
|
+
version,
|
|
131
|
+
channel,
|
|
132
|
+
baseUrl,
|
|
133
|
+
coreDylib: artifacts.coreDylib,
|
|
134
|
+
projectDir,
|
|
135
|
+
dmg,
|
|
136
|
+
signIdentity,
|
|
137
|
+
};
|
|
112
138
|
}
|
package/src/bundle.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { $ } from "bun";
|
|
15
|
-
import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
15
|
+
import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
|
|
18
18
|
const FRAMEWORK = "Chromium Embedded Framework.framework";
|
|
@@ -45,9 +45,43 @@ export interface BundleOptions {
|
|
|
45
45
|
workerJs?: string; // bundled main-process Worker entry -> Resources/worker.js
|
|
46
46
|
manifestJson?: string; // serialized manifest -> Resources/mirin.manifest.json
|
|
47
47
|
versionJson?: string; // serialized version.json -> Resources/version.json (updater)
|
|
48
|
+
/** Bundled sidecar binaries -> Resources/sidecars/<name> (copied + signed). */
|
|
49
|
+
sidecars?: SidecarBundle[];
|
|
50
|
+
/** Compiled extra-worker JS (name -> abs path) -> Resources/workers/<name>.js. */
|
|
51
|
+
workers?: Record<string, string>;
|
|
48
52
|
};
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
/** A sidecar binary to bundle: logical name, source path, hardened-runtime entitlements. */
|
|
56
|
+
export interface SidecarBundle {
|
|
57
|
+
name: string;
|
|
58
|
+
src: string;
|
|
59
|
+
entitlements: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Short entitlement name -> the full `com.apple.security.cs.*` key. */
|
|
63
|
+
const SIDECAR_ENTITLEMENT_KEYS: Record<string, string> = {
|
|
64
|
+
"allow-jit": "com.apple.security.cs.allow-jit",
|
|
65
|
+
"allow-unsigned-executable-memory": "com.apple.security.cs.allow-unsigned-executable-memory",
|
|
66
|
+
"disable-library-validation": "com.apple.security.cs.disable-library-validation",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function entitlementsPlist(names: string[]): string {
|
|
70
|
+
const keys = names
|
|
71
|
+
.map((n) => SIDECAR_ENTITLEMENT_KEYS[n])
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.map((k) => ` <key>${k}</key><true/>`)
|
|
74
|
+
.join("\n");
|
|
75
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
76
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
77
|
+
<plist version="1.0">
|
|
78
|
+
<dict>
|
|
79
|
+
${keys}
|
|
80
|
+
</dict>
|
|
81
|
+
</plist>
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
/** The 10 standard iconset renditions (point size + @1x/@2x pixel size). */
|
|
52
86
|
const ICONSET_RENDITIONS = [
|
|
53
87
|
{ name: "icon_16x16.png", px: 16 },
|
|
@@ -182,6 +216,28 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
|
|
|
182
216
|
if (opts.resources?.versionJson != null) {
|
|
183
217
|
writeFileSync(join(resources, "version.json"), opts.resources.versionJson);
|
|
184
218
|
}
|
|
219
|
+
// Extra Bun Worker bundles -> Resources/workers/<name>.js (resolveWorker()).
|
|
220
|
+
if (opts.resources?.workers && Object.keys(opts.resources.workers).length) {
|
|
221
|
+
const workersDir = join(resources, "workers");
|
|
222
|
+
mkdirSync(workersDir, { recursive: true });
|
|
223
|
+
for (const [name, src] of Object.entries(opts.resources.workers)) {
|
|
224
|
+
cpSync(src, join(workersDir, `${name}.js`));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Sidecar binaries -> Resources/sidecars/<name> (chmod +x; signed below). Paths
|
|
228
|
+
// collected here so the codesign loop can sign them inside-out with the app.
|
|
229
|
+
const sidecarDests: SidecarBundle[] = [];
|
|
230
|
+
if (opts.resources?.sidecars?.length) {
|
|
231
|
+
const sidecarsDir = join(resources, "sidecars");
|
|
232
|
+
mkdirSync(sidecarsDir, { recursive: true });
|
|
233
|
+
for (const sc of opts.resources.sidecars) {
|
|
234
|
+
if (!existsSync(sc.src)) throw new Error(`sidecar "${sc.name}" not found: ${sc.src}`);
|
|
235
|
+
const dest = join(sidecarsDir, sc.name);
|
|
236
|
+
cpSync(sc.src, dest);
|
|
237
|
+
chmodSync(dest, 0o755);
|
|
238
|
+
sidecarDests.push({ name: sc.name, src: dest, entitlements: sc.entitlements });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
185
241
|
|
|
186
242
|
for (const { suffix, id } of HELPER_TYPES) {
|
|
187
243
|
const name = `${appName} Helper${suffix}`;
|
|
@@ -249,6 +305,18 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
|
|
|
249
305
|
await sign(cef);
|
|
250
306
|
// 3. our FFI core dylib.
|
|
251
307
|
await sign(join(macos, "libmirin_core.dylib"));
|
|
308
|
+
// 3b. sidecars: hardened runtime + timestamp; per-binary entitlements only
|
|
309
|
+
// when the spec asks (most CLIs need none — over-entitling is a smell).
|
|
310
|
+
for (const sc of sidecarDests) {
|
|
311
|
+
if (sc.entitlements.length) {
|
|
312
|
+
const ent = join(opts.outDir, `_sidecar_${sc.name}.plist`);
|
|
313
|
+
writeFileSync(ent, entitlementsPlist(sc.entitlements));
|
|
314
|
+
await $`codesign --force --timestamp --options runtime --entitlements ${ent} --sign ${identity} ${sc.src}`.quiet();
|
|
315
|
+
rmSync(ent, { force: true });
|
|
316
|
+
} else {
|
|
317
|
+
await sign(sc.src);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
252
320
|
// 4. each helper: the inner executable, then the .app wrapper (entitlements
|
|
253
321
|
// on both — the renderer/GPU helpers are what actually JIT).
|
|
254
322
|
for (const { suffix } of HELPER_TYPES) {
|
|
@@ -263,6 +331,9 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
|
|
|
263
331
|
} else {
|
|
264
332
|
// Ad-hoc: enough to launch locally; not distributable or notarizable.
|
|
265
333
|
await $`codesign --force --sign ${identity} ${cef}`.quiet();
|
|
334
|
+
for (const sc of sidecarDests) {
|
|
335
|
+
await $`codesign --force --sign ${identity} ${sc.src}`.quiet();
|
|
336
|
+
}
|
|
266
337
|
for (const { suffix } of HELPER_TYPES) {
|
|
267
338
|
await $`codesign --force --sign ${identity} ${join(frameworks, `${appName} Helper${suffix}.app`)}`.quiet();
|
|
268
339
|
}
|
package/src/dev.ts
CHANGED
|
@@ -9,13 +9,17 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { $ } from "bun";
|
|
12
|
-
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
|
|
13
|
+
import { createServer } from "node:net";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { buildAppBundle } from "./bundle.ts";
|
|
15
16
|
import { resolveArtifacts } from "./artifacts.ts";
|
|
16
17
|
import { sweepBuildTemps } from "./temps.ts";
|
|
18
|
+
import { normalizeSidecars, compileWorkers } from "./extras.ts";
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
/** Vite's default port. `mirin dev` probes upward from here for a free one, so a
|
|
21
|
+
* second dev session (or anything already on 5173) doesn't collide. */
|
|
22
|
+
const DEV_PORT_BASE = 5173;
|
|
19
23
|
|
|
20
24
|
export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
21
25
|
const work = join(projectDir, ".mirin");
|
|
@@ -40,6 +44,19 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
40
44
|
await $`bun build --compile ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
|
|
41
45
|
await $`bun build ${mainEntry} --target=bun --outfile ${workerJs}`.cwd(projectDir);
|
|
42
46
|
|
|
47
|
+
// Extra assets (dev): compile workers into .mirin/workers and symlink sidecar
|
|
48
|
+
// binaries into .mirin/sidecars (no copy/sign in dev — they run unsigned locally).
|
|
49
|
+
const workersDir = join(work, "workers");
|
|
50
|
+
await compileWorkers(projectDir, config.workers, workersDir, false);
|
|
51
|
+
const sidecarsDir = join(work, "sidecars");
|
|
52
|
+
mkdirSync(sidecarsDir, { recursive: true });
|
|
53
|
+
for (const sc of normalizeSidecars(projectDir, config.sidecars)) {
|
|
54
|
+
const link = join(sidecarsDir, sc.name);
|
|
55
|
+
rmSync(link, { force: true });
|
|
56
|
+
if (existsSync(sc.src)) symlinkSync(sc.src, link);
|
|
57
|
+
else console.warn(`[mirin dev] sidecar "${sc.name}" not found: ${sc.src}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
// --- assemble the dev .app ---
|
|
44
61
|
console.log("[mirin dev] assembling dev bundle…");
|
|
45
62
|
const { app, exe } = await buildAppBundle({
|
|
@@ -53,13 +70,21 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
53
70
|
icon: config.icon ? join(projectDir, config.icon) : undefined,
|
|
54
71
|
});
|
|
55
72
|
|
|
56
|
-
// --- start Vite ---
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
73
|
+
// --- start Vite on a free port so concurrent dev sessions don't collide ---
|
|
74
|
+
// `--port <free> --strictPort` pins Vite to the port we probed (overriding any
|
|
75
|
+
// port/strictPort in the app's vite.config) and passes the real URL to the app,
|
|
76
|
+
// so the host and Vite never disagree about which port is in use.
|
|
77
|
+
const port = await findFreePort(DEV_PORT_BASE);
|
|
78
|
+
const devUrl = `http://localhost:${port}`;
|
|
79
|
+
console.log(`[mirin dev] starting Vite dev server on ${devUrl}…`);
|
|
80
|
+
const vite = Bun.spawn(
|
|
81
|
+
["bunx", "vite", "--clearScreen", "false", "--port", String(port), "--strictPort"],
|
|
82
|
+
{
|
|
83
|
+
cwd: projectDir,
|
|
84
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
await waitForUrl(devUrl, 15_000);
|
|
63
88
|
|
|
64
89
|
// --- launch the app ---
|
|
65
90
|
console.log(`[mirin dev] launching ${appName}…`);
|
|
@@ -69,9 +94,11 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
69
94
|
...process.env,
|
|
70
95
|
MIRIN_CORE: join(app, "Contents", "MacOS", "libmirin_core.dylib"),
|
|
71
96
|
MIRIN_WORKER: workerJs,
|
|
72
|
-
MIRIN_DEV_URL:
|
|
97
|
+
MIRIN_DEV_URL: devUrl,
|
|
73
98
|
MIRIN_MANIFEST_JSON: JSON.stringify({ windows: config.windows }),
|
|
74
99
|
MIRIN_CONFIG_JSON: "{}",
|
|
100
|
+
MIRIN_SIDECAR_DIR: sidecarsDir,
|
|
101
|
+
MIRIN_WORKERS_DIR: workersDir,
|
|
75
102
|
},
|
|
76
103
|
stdio: ["ignore", "inherit", "inherit"],
|
|
77
104
|
});
|
|
@@ -88,6 +115,32 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
|
|
|
88
115
|
return code;
|
|
89
116
|
}
|
|
90
117
|
|
|
118
|
+
/** First free TCP port at or above `start` on loopback. */
|
|
119
|
+
async function findFreePort(start: number): Promise<number> {
|
|
120
|
+
for (let port = start; port < start + 100; port++) {
|
|
121
|
+
if (await isPortFree(port)) return port;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`[mirin dev] no free port in ${start}–${start + 99}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Free only if neither IPv4 nor IPv6 loopback reports the port in use — Vite
|
|
127
|
+
* binds `localhost` (often `::1`), so a v4-only probe would miss an IPv6 holder. */
|
|
128
|
+
async function isPortFree(port: number): Promise<boolean> {
|
|
129
|
+
const [v4, v6] = await Promise.all([portInUse(port, "127.0.0.1"), portInUse(port, "::1")]);
|
|
130
|
+
return !v4 && !v6;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function portInUse(port: number, host: string): Promise<boolean> {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const srv = createServer();
|
|
136
|
+
// Only EADDRINUSE means taken; other errors (e.g. EADDRNOTAVAIL when IPv6 is
|
|
137
|
+
// off) just mean we can't test that family — treat as not-in-use.
|
|
138
|
+
srv.once("error", (err: NodeJS.ErrnoException) => resolve(err.code === "EADDRINUSE"));
|
|
139
|
+
srv.once("listening", () => srv.close(() => resolve(false)));
|
|
140
|
+
srv.listen(port, host);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
91
144
|
async function waitForUrl(url: string, timeoutMs: number): Promise<void> {
|
|
92
145
|
const deadline = Date.now() + timeoutMs;
|
|
93
146
|
while (Date.now() < deadline) {
|
package/src/dmg.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS `.dmg` installer for `mirin release`.
|
|
3
|
+
*
|
|
4
|
+
* Two paths:
|
|
5
|
+
* - **plain** (default): stage the `.app` + an `/Applications` symlink, then
|
|
6
|
+
* `hdiutil create … -format ULFO`. Rock-solid in CI — this is what ships when
|
|
7
|
+
* `dmg: true`. ULFO (lzfse) handles large CEF frameworks best on modern macOS.
|
|
8
|
+
* - **laid-out**: when a `background` or any window/icon position is configured,
|
|
9
|
+
* build a read-write image, style the Finder window via AppleScript, then
|
|
10
|
+
* convert to the compressed read-only format. Best-effort — falls back to the
|
|
11
|
+
* plain DMG if Finder automation isn't available (e.g. a headless runner).
|
|
12
|
+
*
|
|
13
|
+
* The DMG is codesigned (a DMG carries no entitlements / hardened runtime — it's
|
|
14
|
+
* not executable code) and, when notary credentials are present, notarized +
|
|
15
|
+
* stapled so a fresh download opens without a Gatekeeper prompt.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { $ } from "bun";
|
|
19
|
+
import { cpSync, mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
|
|
20
|
+
import { join, basename, isAbsolute } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
|
|
23
|
+
export interface DmgOptions {
|
|
24
|
+
volumeName?: string;
|
|
25
|
+
format?: "ULFO" | "UDZO" | "UDBZ";
|
|
26
|
+
background?: string;
|
|
27
|
+
windowSize?: { width: number; height: number };
|
|
28
|
+
iconSize?: number;
|
|
29
|
+
appPosition?: { x: number; y: number };
|
|
30
|
+
applicationsPosition?: { x: number; y: number };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuildDmgInput {
|
|
34
|
+
/** Path to the (signed) `.app`. */
|
|
35
|
+
app: string;
|
|
36
|
+
appName: string;
|
|
37
|
+
/** Output directory for the `.dmg`. */
|
|
38
|
+
outDir: string;
|
|
39
|
+
/** Output file name (e.g. `stable-darwin-arm64-Anko.dmg`). */
|
|
40
|
+
fileName: string;
|
|
41
|
+
/** Resolved DMG options. */
|
|
42
|
+
options: DmgOptions;
|
|
43
|
+
/** Project root, for resolving a relative `background` path. */
|
|
44
|
+
projectDir: string;
|
|
45
|
+
/** Codesign identity ("-"/undefined → ad-hoc, no notarization). */
|
|
46
|
+
signIdentity?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** hdiutil volume names choke on some punctuation; keep it tame but readable. */
|
|
50
|
+
function volumeName(name: string): string {
|
|
51
|
+
return name.replace(/[/:\\]/g, " ").trim() || "App";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Notarize + staple a `.app` or `.dmg` using MIRIN_NOTARY_* env credentials.
|
|
56
|
+
* No-op (returns false) when credentials are absent. Throws on rejection,
|
|
57
|
+
* printing the notary log so the failure is diagnosable.
|
|
58
|
+
*/
|
|
59
|
+
export async function notarizeAndStaple(target: string): Promise<boolean> {
|
|
60
|
+
const apple = process.env.MIRIN_NOTARY_APPLE_ID;
|
|
61
|
+
const pw = process.env.MIRIN_NOTARY_PASSWORD;
|
|
62
|
+
const team = process.env.MIRIN_NOTARY_TEAM_ID;
|
|
63
|
+
if (!apple || !pw || !team) return false;
|
|
64
|
+
|
|
65
|
+
const isApp = target.endsWith(".app");
|
|
66
|
+
// notarytool wants a zip for a bundle; a .dmg is submitted as-is.
|
|
67
|
+
const submitPath = isApp ? `${target}.notarize.zip` : target;
|
|
68
|
+
if (isApp) await $`ditto -c -k --keepParent ${target} ${submitPath}`;
|
|
69
|
+
|
|
70
|
+
console.log(`[mirin release] notarizing ${basename(target)} (this can take a few minutes)…`);
|
|
71
|
+
const out =
|
|
72
|
+
await $`xcrun notarytool submit ${submitPath} --apple-id ${apple} --password ${pw} --team-id ${team} --wait --output-format json`.text();
|
|
73
|
+
if (isApp) rmSync(submitPath, { force: true });
|
|
74
|
+
|
|
75
|
+
let sub: { id?: string; status?: string } = {};
|
|
76
|
+
try {
|
|
77
|
+
sub = JSON.parse(out);
|
|
78
|
+
} catch {
|
|
79
|
+
console.error(out);
|
|
80
|
+
}
|
|
81
|
+
if (sub.status !== "Accepted") {
|
|
82
|
+
console.error(`[mirin release] notarization ${sub.status ?? "failed"} (id: ${sub.id ?? "?"})`);
|
|
83
|
+
if (sub.id) {
|
|
84
|
+
const log =
|
|
85
|
+
await $`xcrun notarytool log ${sub.id} --apple-id ${apple} --password ${pw} --team-id ${team}`
|
|
86
|
+
.text()
|
|
87
|
+
.catch(() => "");
|
|
88
|
+
if (log) console.error(log);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`notarization not accepted: ${sub.status ?? "unknown"}`);
|
|
91
|
+
}
|
|
92
|
+
await $`xcrun stapler staple ${target}`;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Does this config ask for a styled Finder window (vs. a plain DMG)? */
|
|
97
|
+
function wantsLayout(o: DmgOptions): boolean {
|
|
98
|
+
return !!(o.background || o.appPosition || o.applicationsPosition || o.windowSize || o.iconSize);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build (and codesign) the `.dmg`, returning its path. Notarization is the
|
|
103
|
+
* caller's responsibility (so it can be sequenced with the other artifacts).
|
|
104
|
+
*/
|
|
105
|
+
export async function buildDmg(input: BuildDmgInput): Promise<string> {
|
|
106
|
+
const { app, appName, outDir, fileName, options, projectDir, signIdentity } = input;
|
|
107
|
+
const vol = volumeName(options.volumeName ?? appName);
|
|
108
|
+
const format = options.format ?? "ULFO";
|
|
109
|
+
const dmgPath = join(outDir, fileName);
|
|
110
|
+
rmSync(dmgPath, { force: true });
|
|
111
|
+
|
|
112
|
+
const staging = join(tmpdir(), `mirin-dmg-${process.pid}`);
|
|
113
|
+
rmSync(staging, { recursive: true, force: true });
|
|
114
|
+
mkdirSync(staging, { recursive: true });
|
|
115
|
+
try {
|
|
116
|
+
// BSD cp -R preserves the bundle's symlinks/signatures.
|
|
117
|
+
await $`cp -R ${app} ${join(staging, basename(app))}`;
|
|
118
|
+
symlinkSync("/Applications", join(staging, "Applications"));
|
|
119
|
+
|
|
120
|
+
let built = false;
|
|
121
|
+
if (wantsLayout(options)) {
|
|
122
|
+
try {
|
|
123
|
+
await buildLaidOutDmg({ staging, vol, format, dmgPath, options, projectDir, appFile: basename(app) });
|
|
124
|
+
built = true;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`[mirin release] DMG layout failed (${e instanceof Error ? e.message : e}); ` +
|
|
128
|
+
`falling back to a plain DMG.`,
|
|
129
|
+
);
|
|
130
|
+
rmSync(dmgPath, { force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!built) {
|
|
134
|
+
await $`hdiutil create -volname ${vol} -srcfolder ${staging} -ov -format ${format} ${dmgPath}`.quiet();
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
rmSync(staging, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Sign the image itself (Gatekeeper checks the DMG's signature too).
|
|
141
|
+
if (signIdentity && signIdentity !== "-") {
|
|
142
|
+
await $`codesign --force --timestamp --sign ${signIdentity} ${dmgPath}`.quiet();
|
|
143
|
+
} else if (signIdentity === "-") {
|
|
144
|
+
await $`codesign --force --sign - ${dmgPath}`.quiet();
|
|
145
|
+
}
|
|
146
|
+
return dmgPath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Read-write image → style the Finder window via AppleScript → convert to the
|
|
151
|
+
* compressed read-only `format`. Throws if any step fails (caller falls back).
|
|
152
|
+
*/
|
|
153
|
+
async function buildLaidOutDmg(args: {
|
|
154
|
+
staging: string;
|
|
155
|
+
vol: string;
|
|
156
|
+
format: string;
|
|
157
|
+
dmgPath: string;
|
|
158
|
+
options: DmgOptions;
|
|
159
|
+
projectDir: string;
|
|
160
|
+
appFile: string;
|
|
161
|
+
}): Promise<void> {
|
|
162
|
+
const { staging, vol, format, dmgPath, options, projectDir, appFile } = args;
|
|
163
|
+
const win = options.windowSize ?? { width: 640, height: 400 };
|
|
164
|
+
const iconSize = options.iconSize ?? 128;
|
|
165
|
+
const appPos = options.appPosition ?? { x: Math.round(win.width * 0.25), y: Math.round(win.height * 0.5) };
|
|
166
|
+
const appsPos =
|
|
167
|
+
options.applicationsPosition ?? { x: Math.round(win.width * 0.75), y: Math.round(win.height * 0.5) };
|
|
168
|
+
|
|
169
|
+
// Stage a background image (if any) under a hidden folder Finder can reference.
|
|
170
|
+
let bgFile: string | undefined;
|
|
171
|
+
if (options.background) {
|
|
172
|
+
const src = isAbsolute(options.background)
|
|
173
|
+
? options.background
|
|
174
|
+
: join(projectDir, options.background);
|
|
175
|
+
if (!existsSync(src)) throw new Error(`background not found: ${src}`);
|
|
176
|
+
const bgDir = join(staging, ".background");
|
|
177
|
+
mkdirSync(bgDir, { recursive: true });
|
|
178
|
+
const ext = src.slice(src.lastIndexOf("."));
|
|
179
|
+
bgFile = `bg${ext}`;
|
|
180
|
+
cpSync(src, join(bgDir, bgFile));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const rw = `${dmgPath}.rw.dmg`;
|
|
184
|
+
rmSync(rw, { force: true });
|
|
185
|
+
// Read-write image sized to the staged contents, with slack for HFS overhead.
|
|
186
|
+
await $`hdiutil create -volname ${vol} -srcfolder ${staging} -fs HFS+ -format UDRW -ov ${rw}`.quiet();
|
|
187
|
+
|
|
188
|
+
// Attach without auto-opening a Finder window; parse the real mountpoint.
|
|
189
|
+
const attach =
|
|
190
|
+
await $`hdiutil attach ${rw} -readwrite -noverify -noautoopen`.text();
|
|
191
|
+
const mount = attach
|
|
192
|
+
.split("\n")
|
|
193
|
+
.map((l) => l.trim())
|
|
194
|
+
.find((l) => l.includes("/Volumes/"))
|
|
195
|
+
?.split("\t")
|
|
196
|
+
.pop()
|
|
197
|
+
?.trim();
|
|
198
|
+
if (!mount || !existsSync(mount)) {
|
|
199
|
+
await $`hdiutil detach ${rw}`.quiet().catch(() => {});
|
|
200
|
+
throw new Error("could not resolve DMG mountpoint");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const bgClause = bgFile
|
|
205
|
+
? `set background picture of viewOptions to file ".background:${bgFile}"`
|
|
206
|
+
: "";
|
|
207
|
+
const script = `
|
|
208
|
+
tell application "Finder"
|
|
209
|
+
tell disk "${vol}"
|
|
210
|
+
open
|
|
211
|
+
set current view of container window to icon view
|
|
212
|
+
set toolbar visible of container window to false
|
|
213
|
+
set statusbar visible of container window to false
|
|
214
|
+
set the bounds of container window to {200, 120, ${200 + win.width}, ${120 + win.height}}
|
|
215
|
+
set viewOptions to the icon view options of container window
|
|
216
|
+
set arrangement of viewOptions to not arranged
|
|
217
|
+
set icon size of viewOptions to ${iconSize}
|
|
218
|
+
${bgClause}
|
|
219
|
+
set position of item "${appFile}" of container window to {${appPos.x}, ${appPos.y}}
|
|
220
|
+
set position of item "Applications" of container window to {${appsPos.x}, ${appsPos.y}}
|
|
221
|
+
update without registering applications
|
|
222
|
+
delay 1
|
|
223
|
+
close
|
|
224
|
+
end tell
|
|
225
|
+
end tell`;
|
|
226
|
+
await $`osascript -e ${script}`.quiet();
|
|
227
|
+
await $`sync`.quiet().catch(() => {});
|
|
228
|
+
} finally {
|
|
229
|
+
await $`hdiutil detach ${mount}`.quiet().catch(async () => {
|
|
230
|
+
await $`hdiutil detach ${rw} -force`.quiet().catch(() => {});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await $`hdiutil convert ${rw} -format ${format} -ov -o ${dmgPath}`.quiet();
|
|
235
|
+
rmSync(rw, { force: true });
|
|
236
|
+
}
|
package/src/extras.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the two "extra asset" config blocks — `sidecars` (bundled
|
|
3
|
+
* binaries) and `workers` (extra Bun Worker entries) — used by both `mirin build`
|
|
4
|
+
* and `mirin dev`. Keeps the config normalization + worker compilation in one
|
|
5
|
+
* place so the two CLI paths stay in sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { $ } from "bun";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Config shapes (mirrors packages/mirin/src/config.ts; CLI can't import the runtime). */
|
|
12
|
+
export type SidecarConfig = Record<string, string | { bin: string; entitlements?: string[] }>;
|
|
13
|
+
export type WorkersConfig = Record<string, string>;
|
|
14
|
+
|
|
15
|
+
/** A sidecar resolved to an absolute source path + its requested entitlements. */
|
|
16
|
+
export interface NormalizedSidecar {
|
|
17
|
+
name: string;
|
|
18
|
+
/** Absolute path to the source binary. */
|
|
19
|
+
src: string;
|
|
20
|
+
/** Short entitlement names (e.g. "allow-jit"); empty for most CLIs. */
|
|
21
|
+
entitlements: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve `config.sidecars` to absolute source paths. */
|
|
25
|
+
export function normalizeSidecars(
|
|
26
|
+
projectDir: string,
|
|
27
|
+
sidecars: SidecarConfig | undefined,
|
|
28
|
+
): NormalizedSidecar[] {
|
|
29
|
+
return Object.entries(sidecars ?? {}).map(([name, value]) => {
|
|
30
|
+
const spec = typeof value === "string" ? { bin: value } : value;
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
src: join(projectDir, spec.bin),
|
|
34
|
+
entitlements: spec.entitlements ?? [],
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compile each extra-worker entry to `<outDir>/<name>.js`. Returns name → path.
|
|
41
|
+
* `minify` matches the surrounding build (prod minifies; dev doesn't).
|
|
42
|
+
*/
|
|
43
|
+
export async function compileWorkers(
|
|
44
|
+
projectDir: string,
|
|
45
|
+
workers: WorkersConfig | undefined,
|
|
46
|
+
outDir: string,
|
|
47
|
+
minify: boolean,
|
|
48
|
+
): Promise<Record<string, string>> {
|
|
49
|
+
const out: Record<string, string> = {};
|
|
50
|
+
for (const [name, entry] of Object.entries(workers ?? {})) {
|
|
51
|
+
const js = join(outDir, `${name}.js`);
|
|
52
|
+
const src = join(projectDir, entry);
|
|
53
|
+
if (minify) {
|
|
54
|
+
await $`bun build ${src} --target=bun --minify --outfile ${js}`.cwd(projectDir);
|
|
55
|
+
} else {
|
|
56
|
+
await $`bun build ${src} --target=bun --outfile ${js}`.cwd(projectDir);
|
|
57
|
+
}
|
|
58
|
+
out[name] = js;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
package/src/release.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { build } from "./build.ts";
|
|
24
|
+
import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
|
|
24
25
|
import { loadCodec } from "mirinjs/codec";
|
|
25
26
|
|
|
26
27
|
const sha256File = (path: string) =>
|
|
@@ -44,39 +45,10 @@ export async function release(projectDir = process.cwd()): Promise<number> {
|
|
|
44
45
|
rmSync(outDir, { recursive: true, force: true });
|
|
45
46
|
mkdirSync(outDir, { recursive: true });
|
|
46
47
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (apple && pw && team) {
|
|
52
|
-
console.log("[mirin release] notarizing (this can take a few minutes)…");
|
|
53
|
-
const zip = join(buildDir, "_notarize.zip");
|
|
54
|
-
await $`ditto -c -k --keepParent ${result.app} ${zip}`;
|
|
55
|
-
// `notarytool submit --wait` exits 0 even when the result is "Invalid", so
|
|
56
|
-
// parse the JSON status ourselves and surface the notary log on rejection —
|
|
57
|
-
// otherwise the only symptom is a confusing `stapler` failure downstream.
|
|
58
|
-
const out =
|
|
59
|
-
await $`xcrun notarytool submit ${zip} --apple-id ${apple} --password ${pw} --team-id ${team} --wait --output-format json`.text();
|
|
60
|
-
rmSync(zip, { force: true });
|
|
61
|
-
let sub: { id?: string; status?: string } = {};
|
|
62
|
-
try {
|
|
63
|
-
sub = JSON.parse(out);
|
|
64
|
-
} catch {
|
|
65
|
-
console.error(out);
|
|
66
|
-
}
|
|
67
|
-
if (sub.status !== "Accepted") {
|
|
68
|
-
console.error(`[mirin release] notarization ${sub.status ?? "failed"} (id: ${sub.id ?? "?"})`);
|
|
69
|
-
if (sub.id) {
|
|
70
|
-
const log =
|
|
71
|
-
await $`xcrun notarytool log ${sub.id} --apple-id ${apple} --password ${pw} --team-id ${team}`
|
|
72
|
-
.text()
|
|
73
|
-
.catch(() => "");
|
|
74
|
-
if (log) console.error(log);
|
|
75
|
-
}
|
|
76
|
-
throw new Error(`notarization not accepted: ${sub.status ?? "unknown"}`);
|
|
77
|
-
}
|
|
78
|
-
await $`xcrun stapler staple ${result.app}`;
|
|
79
|
-
}
|
|
48
|
+
// Notarize + staple the .app (Developer ID) before packing, when credentials
|
|
49
|
+
// are present. The .tar.zst / patch updater bundles are made from this signed,
|
|
50
|
+
// stapled .app, so the updater swaps in an already-notarized app.
|
|
51
|
+
await notarizeAndStaple(result.app);
|
|
80
52
|
|
|
81
53
|
const codec = loadCodec(result.coreDylib);
|
|
82
54
|
|
|
@@ -141,11 +113,33 @@ export async function release(projectDir = process.cwd()): Promise<number> {
|
|
|
141
113
|
const manifestName = `${prefix}-update.json`;
|
|
142
114
|
await Bun.write(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
143
115
|
|
|
116
|
+
// Distributable installer: a drag-to-Applications .dmg of the same signed,
|
|
117
|
+
// stapled .app — for first-time installs (the updater uses the .tar.zst/patch).
|
|
118
|
+
let dmgName: string | undefined;
|
|
119
|
+
let dmgSize = 0;
|
|
120
|
+
if (result.dmg !== false) {
|
|
121
|
+
dmgName = `${prefix}-${safeName}.dmg`;
|
|
122
|
+
console.log(`[mirin release] building installer → ${dmgName}`);
|
|
123
|
+
const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
|
|
124
|
+
const dmgPath = await buildDmg({
|
|
125
|
+
app: result.app,
|
|
126
|
+
appName: result.appName,
|
|
127
|
+
outDir,
|
|
128
|
+
fileName: dmgName,
|
|
129
|
+
options,
|
|
130
|
+
projectDir: result.projectDir,
|
|
131
|
+
signIdentity: result.signIdentity,
|
|
132
|
+
});
|
|
133
|
+
await notarizeAndStaple(dmgPath); // no-op without notary credentials
|
|
134
|
+
dmgSize = readFileSync(dmgPath).byteLength;
|
|
135
|
+
}
|
|
136
|
+
|
|
144
137
|
const mb = (n: number) => (n / 1e6).toFixed(1);
|
|
145
138
|
console.log(`\n[mirin release] done → build/release/`);
|
|
146
139
|
console.log(` ${manifestName}`);
|
|
147
140
|
console.log(` ${bundleName} (${mb(bundleSize)} MB)`);
|
|
148
141
|
for (const p of patches) console.log(` ${p.url} (${mb(p.size)} MB delta from ${p.fromVersion})`);
|
|
142
|
+
if (dmgName) console.log(` ${dmgName} (${mb(dmgSize)} MB installer)`);
|
|
149
143
|
console.log(`\nUpload all of build/release/ to: ${result.baseUrl}`);
|
|
150
144
|
if (existsSync(join(outDir, "_new.tar"))) rmSync(join(outDir, "_new.tar"), { force: true });
|
|
151
145
|
return 0;
|