@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -5
- package/src/artifacts.ts +165 -26
- package/src/build.ts +234 -43
- package/src/bundle/linux/app.ts +275 -0
- package/src/bundle/linux/index.ts +1 -0
- package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
- package/src/bundle/macos/index.ts +1 -0
- package/src/bundle/shared/cef-locales.ts +91 -0
- package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
- package/src/bundle/windows/index.ts +1 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +213 -35
- package/src/dmg.ts +22 -10
- package/src/doctor.ts +227 -0
- package/src/extras.ts +52 -13
- package/src/icons/macos/appearance.ts +96 -0
- package/src/icons/macos/index.ts +1 -0
- package/src/icons/windows/icon.ts +255 -0
- package/src/icons/windows/index.ts +1 -0
- package/src/index.ts +109 -7
- package/src/installer-inno.ts +141 -0
- package/src/installer-win.ts +17 -11
- package/src/package/linux/index.ts +2 -0
- package/src/package/linux/lib/defaults.ts +53 -0
- package/src/package/linux/lib/tools.ts +45 -0
- package/src/package/linux/lib/validation.ts +71 -0
- package/src/package/linux/package.ts +293 -0
- package/src/package/linux/types.ts +30 -0
- package/src/release/installers.ts +148 -0
- package/src/release/previous.ts +105 -0
- package/src/release.ts +166 -116
- package/src/shared/driver.ts +240 -0
- package/src/shared/inspector.ts +64 -0
- package/src/shared/report.ts +69 -0
- package/src/shared/scenario.ts +58 -0
- package/src/shared/session.ts +153 -0
- package/src/skill.ts +51 -0
- package/src/icon-win.ts +0 -81
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assemble a Linux app folder for a mirin app. Like Windows, Linux has no `.app`
|
|
3
|
+
* bundle — just a flat directory the dynamic loader can resolve libcef.so + the
|
|
4
|
+
* CEF runtime from, next to the host binary:
|
|
5
|
+
*
|
|
6
|
+
* <App>/
|
|
7
|
+
* <App> compiled Bun host (process.execPath)
|
|
8
|
+
* libmirin_core.so loaded by the host via bun:ffi
|
|
9
|
+
* mirin-helper CEF subprocess (browser_subprocess_path)
|
|
10
|
+
* libcef.so, libEGL.so, libGLESv2.so, *.pak, icudtl.dat,
|
|
11
|
+
* v8_context_snapshot.bin, locales/, chrome-sandbox, … (CEF runtime)
|
|
12
|
+
* resources/ (production only — dev serves from the Vite URL)
|
|
13
|
+
* ui/, worker.js, mirin.manifest.json, version.json, sidecars/, workers/
|
|
14
|
+
*
|
|
15
|
+
* Wayland-only (docs/linux-port.md); the CEF subprocess name (`mirin-helper`) and
|
|
16
|
+
* the `resources/` layout match what mirin-core derives at runtime
|
|
17
|
+
* (engine::derive_subprocess_path, host.ts). libcef.so is resolved via
|
|
18
|
+
* LD_LIBRARY_PATH set on the host (dev.ts) or an rpath at build time.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
chmodSync,
|
|
23
|
+
cpSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
statSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { basename, extname, join } from "node:path";
|
|
32
|
+
import { copyFlatCefLocales } from "../shared/cef-locales.ts";
|
|
33
|
+
|
|
34
|
+
/** Source dir (vendor/cef on Linux) must contain libcef.so. */
|
|
35
|
+
const CEF_MARKER = "libcef.so";
|
|
36
|
+
|
|
37
|
+
/** CEF runtime files copied next to the host binary. Everything else in the Linux
|
|
38
|
+
* distribution (include/, cmake/, CMakeLists.txt, archive.json, libcef_dll/) is
|
|
39
|
+
* build-time only and intentionally left out. `.so` covers libcef/libEGL/
|
|
40
|
+
* libGLESv2/libvk_swiftshader; the extras list catches the non-extension names. */
|
|
41
|
+
const CEF_RUNTIME_EXTS = new Set([".so", ".bin", ".pak", ".dat"]);
|
|
42
|
+
const CEF_RUNTIME_EXTRAS = ["vk_swiftshader_icd.json", "libvulkan.so.1", "chrome-sandbox"];
|
|
43
|
+
|
|
44
|
+
export interface LinuxBundleOptions {
|
|
45
|
+
appName: string; // also the host binary stem → <appName>
|
|
46
|
+
outDir: string;
|
|
47
|
+
hostExe: string; // compiled Bun host (no extension)
|
|
48
|
+
coreDll: string; // libmirin_core.so
|
|
49
|
+
helperExe: string; // mirin-helper
|
|
50
|
+
cefPath: string; // dir containing the Linux CEF runtime (vendor/cef)
|
|
51
|
+
/** Production CEF locale allowlist. Undefined keeps every locale. */
|
|
52
|
+
cefLocales?: string[];
|
|
53
|
+
/** App icon source (.png / .iconset); resolved to a PNG at resources/icon.png for
|
|
54
|
+
* the window's `_NET_WM_ICON` (taskbar/dock). */
|
|
55
|
+
icon?: string;
|
|
56
|
+
/** Production-only resources placed under <App>/resources. */
|
|
57
|
+
resources?: {
|
|
58
|
+
uiDir?: string; // Vite dist/ → resources/ui (served via app://ui)
|
|
59
|
+
workerJs?: string; // bundled main-process Worker → resources/worker.js
|
|
60
|
+
manifestJson?: string; // serialized manifest → resources/mirin.manifest.json
|
|
61
|
+
versionJson?: string; // serialized version.json → resources/version.json (updater)
|
|
62
|
+
sidecars?: { name: string; src: string }[]; // → resources/sidecars/<name>
|
|
63
|
+
workers?: Record<string, string>; // name → abs path → resources/workers/<name>.js
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the app config's `icon` to a concrete square PNG usable on Linux (for
|
|
69
|
+
* the window's `_NET_WM_ICON`). Accepts a `.png` (used as-is) or a `.iconset`
|
|
70
|
+
* directory (pick a good mid/large size). Returns undefined for `.icns` (which the
|
|
71
|
+
* Linux core can't decode) or a missing/empty source.
|
|
72
|
+
*
|
|
73
|
+
* @param icon absolute path to the icon source (`config.icon` joined with the project root).
|
|
74
|
+
*/
|
|
75
|
+
export function resolveLinuxIconPng(icon: string): string | undefined {
|
|
76
|
+
if (!existsSync(icon)) return undefined;
|
|
77
|
+
if (statSync(icon).isDirectory()) {
|
|
78
|
+
const pngs = readdirSync(icon).filter((f) => extname(f).toLowerCase() === ".png");
|
|
79
|
+
if (!pngs.length) return undefined;
|
|
80
|
+
// Prefer 128×128: crisp in the dock, and small enough that its `_NET_WM_ICON`
|
|
81
|
+
// (a 128²+2 CARDINAL array ≈ 64 KB) stays under X11's ~256 KB max request size —
|
|
82
|
+
// a 256×256 icon (≈ 256 KB) can exceed it and get dropped. Fall back to the
|
|
83
|
+
// largest available under that ceiling (by pixels parsed from the iconset name).
|
|
84
|
+
const preferred = ["icon_128x128.png", "icon_64x64.png", "icon_256x256.png"];
|
|
85
|
+
const MaxSide = 128;
|
|
86
|
+
const pick =
|
|
87
|
+
preferred.find((p) => pngs.includes(p)) ??
|
|
88
|
+
pngs
|
|
89
|
+
.slice()
|
|
90
|
+
.sort((a, b) => iconsetPixels(b) - iconsetPixels(a))
|
|
91
|
+
.find((p) => iconsetPixels(p) <= MaxSide * MaxSide) ??
|
|
92
|
+
pngs.sort((a, b) => iconsetPixels(a) - iconsetPixels(b))[0];
|
|
93
|
+
return pick ? join(icon, pick) : undefined;
|
|
94
|
+
}
|
|
95
|
+
return extname(icon).toLowerCase() === ".png" ? icon : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the app config's `icon` to the *largest* concrete PNG (for a `.desktop`
|
|
100
|
+
* `Icon=`, which is a file path with no X11 request-size limit — so we want it
|
|
101
|
+
* crisp, unlike the window `_NET_WM_ICON`). `.png` used as-is; `.iconset` → biggest
|
|
102
|
+
* PNG inside. Undefined for `.icns` or a missing source.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveLinuxDesktopIconPng(icon: string): string | undefined {
|
|
105
|
+
if (!existsSync(icon)) return undefined;
|
|
106
|
+
if (statSync(icon).isDirectory()) {
|
|
107
|
+
const pngs = readdirSync(icon).filter((f) => extname(f).toLowerCase() === ".png");
|
|
108
|
+
if (!pngs.length) return undefined;
|
|
109
|
+
const biggest = pngs.sort((a, b) => iconsetPixels(b) - iconsetPixels(a))[0];
|
|
110
|
+
return biggest ? join(icon, biggest) : undefined;
|
|
111
|
+
}
|
|
112
|
+
return extname(icon).toLowerCase() === ".png" ? icon : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A freedesktop `.desktop` entry body for a mirin app. cosmic's dock resolves an
|
|
117
|
+
* X11 window's icon by matching its `WM_CLASS` (which the core sets to the app id)
|
|
118
|
+
* against a desktop entry's `StartupWMClass`, then uses that entry's `Icon` — the
|
|
119
|
+
* reliable path on cosmic (its `_NET_WM_ICON` fallback is unreliable). `wmClass`
|
|
120
|
+
* MUST equal the core's `res_class` (the bundle id).
|
|
121
|
+
*/
|
|
122
|
+
export function desktopEntry(opts: {
|
|
123
|
+
name: string;
|
|
124
|
+
exec: string;
|
|
125
|
+
iconPng: string;
|
|
126
|
+
wmClass: string;
|
|
127
|
+
/** Freedesktop main category (e.g. "Utility", "Development"). A trailing `;` is
|
|
128
|
+
* added if missing (the spec requires list values to end with one). Omitted → no
|
|
129
|
+
* Categories line. Used by the .deb/.rpm/AppImage entries (dev needs none). */
|
|
130
|
+
categories?: string;
|
|
131
|
+
/** Optional Comment= (tooltip / description) line. */
|
|
132
|
+
comment?: string;
|
|
133
|
+
}): string {
|
|
134
|
+
const lines = ["[Desktop Entry]", "Type=Application", `Name=${desktopValue("Name", opts.name)}`];
|
|
135
|
+
if (opts.comment) lines.push(`Comment=${desktopValue("Comment", opts.comment)}`);
|
|
136
|
+
lines.push(
|
|
137
|
+
`Exec=${desktopExec(opts.exec)}`,
|
|
138
|
+
`Icon=${desktopValue("Icon", opts.iconPng)}`,
|
|
139
|
+
`StartupWMClass=${desktopValue("StartupWMClass", opts.wmClass)}`,
|
|
140
|
+
"Terminal=false",
|
|
141
|
+
);
|
|
142
|
+
if (opts.categories) {
|
|
143
|
+
lines.push(`Categories=${desktopCategories(opts.categories)}`);
|
|
144
|
+
}
|
|
145
|
+
lines.push("");
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function desktopValue(field: string, value: string): string {
|
|
150
|
+
assertDesktopScalar(field, value);
|
|
151
|
+
return value.replace(/\\/g, "\\\\").replace(/\t/g, "\\t");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function desktopExec(value: string): string {
|
|
155
|
+
assertDesktopScalar("Exec", value);
|
|
156
|
+
if (/^[A-Za-z0-9._/+:-]+$/.test(value)) return value;
|
|
157
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function desktopCategories(value: string): string {
|
|
161
|
+
const normalized = value.endsWith(";") ? value : `${value};`;
|
|
162
|
+
assertDesktopScalar("Categories", normalized);
|
|
163
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*(;[A-Za-z0-9][A-Za-z0-9-]*)*;$/.test(normalized)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`[mirin] invalid Linux desktop category "${value}" — use freedesktop category names separated by semicolons.`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertDesktopScalar(field: string, value: string): void {
|
|
172
|
+
if (value.length === 0 || /[\0\r\n]/.test(value)) {
|
|
173
|
+
throw new Error(`[mirin] invalid Linux desktop ${field} value.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Effective pixel count encoded in an iconset filename (`icon_256x256@2x.png` → 512²). */
|
|
178
|
+
function iconsetPixels(name: string): number {
|
|
179
|
+
const m = basename(name).match(/(\d+)x(\d+)(@(\d+)x)?/);
|
|
180
|
+
if (!m) return 0;
|
|
181
|
+
const scale = m[4] ? Number(m[4]) : 1;
|
|
182
|
+
return Number(m[1]) * Number(m[2]) * scale * scale;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Build the Linux app folder and return its path + the host binary to launch. */
|
|
186
|
+
export async function buildLinuxBundle(
|
|
187
|
+
opts: LinuxBundleOptions,
|
|
188
|
+
): Promise<{ app: string; exe: string }> {
|
|
189
|
+
const { appName, cefPath } = opts;
|
|
190
|
+
if (!existsSync(join(cefPath, CEF_MARKER))) {
|
|
191
|
+
throw new Error(`CEF runtime not found at ${cefPath} — run: bun scripts/fetch-cef.ts`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const app = join(opts.outDir, appName);
|
|
195
|
+
rmSync(app, { recursive: true, force: true });
|
|
196
|
+
mkdirSync(app, { recursive: true });
|
|
197
|
+
|
|
198
|
+
const exe = join(app, appName);
|
|
199
|
+
cpSync(opts.hostExe, exe);
|
|
200
|
+
chmodSync(exe, 0o755);
|
|
201
|
+
cpSync(opts.coreDll, join(app, "libmirin_core.so"));
|
|
202
|
+
const helper = join(app, "mirin-helper");
|
|
203
|
+
cpSync(opts.helperExe, helper);
|
|
204
|
+
chmodSync(helper, 0o755);
|
|
205
|
+
|
|
206
|
+
copyCefRuntime(cefPath, app, opts.cefLocales);
|
|
207
|
+
|
|
208
|
+
// The window taskbar/dock icon (`_NET_WM_ICON`) is set by the core from a PNG at
|
|
209
|
+
// `resources/icon.png`; host.ts points the init config's `icon_path` there in prod.
|
|
210
|
+
// (Dev serves it directly from the project via MIRIN_CONFIG_JSON, not the bundle.)
|
|
211
|
+
const iconPng = opts.icon ? resolveLinuxIconPng(opts.icon) : undefined;
|
|
212
|
+
if (iconPng) {
|
|
213
|
+
const res = join(app, "resources");
|
|
214
|
+
mkdirSync(res, { recursive: true });
|
|
215
|
+
cpSync(iconPng, join(res, "icon.png"));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Production resources (dev passes none — paths come from env + the Vite URL).
|
|
219
|
+
if (opts.resources) {
|
|
220
|
+
const res = join(app, "resources");
|
|
221
|
+
mkdirSync(res, { recursive: true });
|
|
222
|
+
if (opts.resources.uiDir) cpSync(opts.resources.uiDir, join(res, "ui"), { recursive: true });
|
|
223
|
+
if (opts.resources.workerJs) cpSync(opts.resources.workerJs, join(res, "worker.js"));
|
|
224
|
+
if (opts.resources.manifestJson != null) {
|
|
225
|
+
writeFileSync(join(res, "mirin.manifest.json"), opts.resources.manifestJson);
|
|
226
|
+
}
|
|
227
|
+
if (opts.resources.versionJson != null) {
|
|
228
|
+
writeFileSync(join(res, "version.json"), opts.resources.versionJson);
|
|
229
|
+
}
|
|
230
|
+
if (opts.resources.workers && Object.keys(opts.resources.workers).length) {
|
|
231
|
+
const workersDir = join(res, "workers");
|
|
232
|
+
mkdirSync(workersDir, { recursive: true });
|
|
233
|
+
for (const [name, src] of Object.entries(opts.resources.workers)) {
|
|
234
|
+
cpSync(src, join(workersDir, `${name}.js`));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (opts.resources.sidecars?.length) {
|
|
238
|
+
const sidecarsDir = join(res, "sidecars");
|
|
239
|
+
mkdirSync(sidecarsDir, { recursive: true });
|
|
240
|
+
for (const sc of opts.resources.sidecars) {
|
|
241
|
+
if (!existsSync(sc.src)) throw new Error(`sidecar "${sc.name}" not found: ${sc.src}`);
|
|
242
|
+
const dst = join(sidecarsDir, sc.name);
|
|
243
|
+
cpSync(sc.src, dst);
|
|
244
|
+
chmodSync(dst, 0o755);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { app, exe };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Copy the CEF runtime (shared libs, paks, snapshot, icu data, locales) into `dest`. */
|
|
253
|
+
function copyCefRuntime(cefPath: string, dest: string, locales: string[] | undefined): void {
|
|
254
|
+
for (const entry of readdirSync(cefPath)) {
|
|
255
|
+
if (CEF_RUNTIME_EXTS.has(extname(entry).toLowerCase())) {
|
|
256
|
+
cpSync(join(cefPath, entry), join(dest, entry));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
for (const extra of CEF_RUNTIME_EXTRAS) {
|
|
260
|
+
const src = join(cefPath, extra);
|
|
261
|
+
if (existsSync(src)) cpSync(src, join(dest, extra), { recursive: true });
|
|
262
|
+
}
|
|
263
|
+
copyFlatCefLocales(join(cefPath, "locales"), join(dest, "locales"), locales);
|
|
264
|
+
// chrome-sandbox works setuid-root (mode 4755) or, on kernels with unprivileged
|
|
265
|
+
// user namespaces, via the namespace sandbox with no special perms. We can't
|
|
266
|
+
// setuid without root here; leave it executable and let CEF pick the userns path.
|
|
267
|
+
const sandbox = join(dest, "chrome-sandbox");
|
|
268
|
+
if (existsSync(sandbox)) {
|
|
269
|
+
try {
|
|
270
|
+
require("node:fs").chmodSync(sandbox, 0o755);
|
|
271
|
+
} catch {
|
|
272
|
+
/* best-effort */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./app.ts";
|
|
@@ -11,9 +11,19 @@
|
|
|
11
11
|
* "<exe> Helper (Renderer).app/Contents/MacOS/<exe> Helper (Renderer)".
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
chmodSync,
|
|
16
|
+
cpSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
16
23
|
import { join } from "node:path";
|
|
24
|
+
import { $ } from "bun";
|
|
25
|
+
import { isIconComposerDoc, writeAppearanceCatalog } from "../../icons/macos/index.ts";
|
|
26
|
+
import { pruneMacCefLocales } from "../shared/cef-locales.ts";
|
|
17
27
|
|
|
18
28
|
const FRAMEWORK = "Chromium Embedded Framework.framework";
|
|
19
29
|
|
|
@@ -33,9 +43,14 @@ export interface BundleOptions {
|
|
|
33
43
|
coreDylib: string; // libmirin_core.dylib
|
|
34
44
|
helperBin: string; // compiled mirin-helper binary
|
|
35
45
|
cefPath: string; // dir containing the CEF framework (vendor/cef)
|
|
46
|
+
/** Production CEF locale allowlist. Undefined keeps every locale. */
|
|
47
|
+
cefLocales?: string[];
|
|
36
48
|
/** App version → Info.plist CFBundleShortVersionString/CFBundleVersion. */
|
|
37
49
|
version?: string;
|
|
38
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* App icon source: a .icns, a .iconset dir, a square .png, or an Icon Composer
|
|
52
|
+
* .icon document (the only form carrying appearance variants). Optional.
|
|
53
|
+
*/
|
|
39
54
|
icon?: string;
|
|
40
55
|
/** Codesign identity; "-" (default) is ad-hoc. Set to a Developer ID to ship. */
|
|
41
56
|
signIdentity?: string;
|
|
@@ -98,19 +113,8 @@ const ICONSET_RENDITIONS = [
|
|
|
98
113
|
{ name: "icon_512x512@2x.png", px: 1024 },
|
|
99
114
|
];
|
|
100
115
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
* stem ("icon"), or undefined if there's no usable source. Accepts a `.icns`
|
|
104
|
-
* (copied), a `.iconset` directory (iconutil), or a square `.png` (rendered to
|
|
105
|
-
* a full iconset via sips, then iconutil).
|
|
106
|
-
*/
|
|
107
|
-
async function writeIcon(iconSrc: string, resources: string): Promise<string | undefined> {
|
|
108
|
-
if (!existsSync(iconSrc)) {
|
|
109
|
-
console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
const icns = join(resources, "icon.icns");
|
|
113
|
-
|
|
116
|
+
/** Render one icon source to `Resources/icon.icns`. */
|
|
117
|
+
async function writeIcns(iconSrc: string, icns: string, resources: string): Promise<void> {
|
|
114
118
|
if (iconSrc.endsWith(".icns")) {
|
|
115
119
|
cpSync(iconSrc, icns);
|
|
116
120
|
} else if (iconSrc.endsWith(".iconset")) {
|
|
@@ -125,7 +129,38 @@ async function writeIcon(iconSrc: string, resources: string): Promise<string | u
|
|
|
125
129
|
await $`iconutil -c icns ${iconset} -o ${icns}`.quiet();
|
|
126
130
|
rmSync(iconset, { recursive: true, force: true });
|
|
127
131
|
}
|
|
128
|
-
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The Info.plist keys an icon contributes, empty when there's no usable source. */
|
|
135
|
+
interface IconKeys {
|
|
136
|
+
/** CFBundleIconFile — `Resources/icon.icns`, read by macOS 25 and earlier. */
|
|
137
|
+
iconFile?: string;
|
|
138
|
+
/** CFBundleIconName — `Resources/Assets.car`, preferred on macOS 26+. */
|
|
139
|
+
iconName?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render the app icon into Resources. An `.icon` document compiles to an
|
|
144
|
+
* `Assets.car` (macOS 26+ light/dark/tinted) and contributes the `.icns` actool
|
|
145
|
+
* derives from it; every other source takes the plain iconutil path.
|
|
146
|
+
*/
|
|
147
|
+
async function writeIcon(iconSrc: string, resources: string, work: string): Promise<IconKeys> {
|
|
148
|
+
if (!existsSync(iconSrc)) {
|
|
149
|
+
console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const icns = join(resources, "icon.icns");
|
|
154
|
+
if (isIconComposerDoc(iconSrc)) {
|
|
155
|
+
// An Icon Composer document can't go through iconutil, so the only .icns
|
|
156
|
+
// available for it is the one actool derives.
|
|
157
|
+
const catalog = await writeAppearanceCatalog(iconSrc, resources, work);
|
|
158
|
+
if (catalog?.icns) cpSync(catalog.icns, icns);
|
|
159
|
+
return { iconFile: existsSync(icns) ? "icon" : undefined, iconName: catalog?.name };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await writeIcns(iconSrc, icns, resources);
|
|
163
|
+
return { iconFile: "icon" };
|
|
129
164
|
}
|
|
130
165
|
|
|
131
166
|
type PlistValue = string | boolean | PlistValue[] | { [k: string]: PlistValue };
|
|
@@ -177,8 +212,12 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
|
|
|
177
212
|
cpSync(opts.coreDylib, join(macos, "libmirin_core.dylib"));
|
|
178
213
|
|
|
179
214
|
// Render the icon (if any) into Resources before writing the plist, so we
|
|
180
|
-
// only set
|
|
181
|
-
const
|
|
215
|
+
// only set the icon keys when an icon was actually produced.
|
|
216
|
+
const iconWork = join(opts.outDir, ".mirin-icon");
|
|
217
|
+
const { iconFile, iconName } = opts.icon
|
|
218
|
+
? await writeIcon(opts.icon, join(contents, "Resources"), iconWork)
|
|
219
|
+
: ({} as IconKeys);
|
|
220
|
+
rmSync(iconWork, { recursive: true, force: true });
|
|
182
221
|
const version = opts.version ?? "0.0.1";
|
|
183
222
|
|
|
184
223
|
const info: Record<string, PlistValue> = {
|
|
@@ -198,20 +237,23 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
|
|
|
198
237
|
LSEnvironment: { MallocNanoZone: "0" },
|
|
199
238
|
};
|
|
200
239
|
if (iconFile) info.CFBundleIconFile = iconFile;
|
|
240
|
+
// macOS 26+ resolves the appearance-aware icon out of Assets.car via this key
|
|
241
|
+
// and falls back to CFBundleIconFile when the catalog has no match.
|
|
242
|
+
if (iconName) info.CFBundleIconName = iconName;
|
|
201
243
|
// Deep-link schemes: register this app as the macOS handler for app://-style
|
|
202
244
|
// URLs (e.g. anko://…). Delivered at runtime via app.on("open-url").
|
|
203
245
|
if (opts.urlSchemes?.length) {
|
|
204
|
-
info.CFBundleURLTypes = [
|
|
205
|
-
{ CFBundleURLName: bundleId, CFBundleURLSchemes: opts.urlSchemes },
|
|
206
|
-
];
|
|
246
|
+
info.CFBundleURLTypes = [{ CFBundleURLName: bundleId, CFBundleURLSchemes: opts.urlSchemes }];
|
|
207
247
|
}
|
|
208
248
|
|
|
209
249
|
writeFileSync(join(contents, "Info.plist"), plist(info));
|
|
210
250
|
|
|
211
|
-
|
|
251
|
+
const bundledFramework = join(frameworks, FRAMEWORK);
|
|
252
|
+
cpSync(join(cefPath, FRAMEWORK), bundledFramework, {
|
|
212
253
|
recursive: true,
|
|
213
254
|
verbatimSymlinks: true,
|
|
214
255
|
});
|
|
256
|
+
pruneMacCefLocales(bundledFramework, opts.cefLocales);
|
|
215
257
|
|
|
216
258
|
// Production resources: the served UI, the Worker bundle, and the manifest.
|
|
217
259
|
const resources = join(contents, "Resources");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./app.ts";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const CEF_LOCALE = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/;
|
|
5
|
+
const MAC_VARIANTS = new Set(["FEMININE", "MASCULINE", "NEUTER"]);
|
|
6
|
+
|
|
7
|
+
/** Validate and normalize an optional CEF locale allowlist. */
|
|
8
|
+
export function normalizeCefLocales(locales: unknown): string[] | undefined {
|
|
9
|
+
if (locales == null) return undefined;
|
|
10
|
+
if (!Array.isArray(locales) || locales.length === 0) {
|
|
11
|
+
throw new Error("[mirin] cef.locales must contain at least one locale.");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
const normalized: string[] = [];
|
|
16
|
+
for (const value of locales) {
|
|
17
|
+
if (typeof value !== "string" || !CEF_LOCALE.test(value)) {
|
|
18
|
+
throw new Error(`[mirin] invalid CEF locale ${JSON.stringify(value)}.`);
|
|
19
|
+
}
|
|
20
|
+
const locale = value.replaceAll("_", "-");
|
|
21
|
+
const key = locale.toLowerCase();
|
|
22
|
+
if (!seen.has(key)) {
|
|
23
|
+
seen.add(key);
|
|
24
|
+
normalized.push(locale);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Copy selected Windows/Linux `locales/<locale>.pak` files, or every locale when unset. */
|
|
31
|
+
export function copyFlatCefLocales(
|
|
32
|
+
source: string,
|
|
33
|
+
destination: string,
|
|
34
|
+
locales: string[] | undefined,
|
|
35
|
+
): void {
|
|
36
|
+
if (!locales) {
|
|
37
|
+
cpSync(source, destination, { recursive: true });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const entries = readdirSync(source, { withFileTypes: true }).filter(
|
|
42
|
+
(entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".pak"),
|
|
43
|
+
);
|
|
44
|
+
const byLocale = new Map(
|
|
45
|
+
entries.map((entry) => [localeKey(entry.name.slice(0, -".pak".length)), entry.name]),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
mkdirSync(destination, { recursive: true });
|
|
49
|
+
for (const locale of locales) {
|
|
50
|
+
const file = byLocale.get(localeKey(locale));
|
|
51
|
+
if (!file) throw new Error(`[mirin] CEF locale "${locale}" is not available in ${source}.`);
|
|
52
|
+
cpSync(join(source, file), join(destination, file));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove unselected macOS `.lproj` locale directories from a copied CEF framework. */
|
|
57
|
+
export function pruneMacCefLocales(framework: string, locales: string[] | undefined): void {
|
|
58
|
+
if (!locales) return;
|
|
59
|
+
|
|
60
|
+
const resources = join(framework, "Resources");
|
|
61
|
+
const localeDirs = readdirSync(resources, { withFileTypes: true })
|
|
62
|
+
.filter((entry) => entry.isDirectory() && entry.name.endsWith(".lproj"))
|
|
63
|
+
.map((entry) => entry.name);
|
|
64
|
+
const available = new Set(localeDirs.map((name) => name.slice(0, -".lproj".length)));
|
|
65
|
+
const keep = new Set<string>();
|
|
66
|
+
|
|
67
|
+
for (const locale of locales) {
|
|
68
|
+
const requested = locale.replaceAll("-", "_");
|
|
69
|
+
const language = requested.split("_", 1)[0] ?? requested;
|
|
70
|
+
const root = available.has(requested)
|
|
71
|
+
? requested
|
|
72
|
+
: available.has(language)
|
|
73
|
+
? language
|
|
74
|
+
: undefined;
|
|
75
|
+
if (!root) throw new Error(`[mirin] CEF locale "${locale}" is not available in ${resources}.`);
|
|
76
|
+
|
|
77
|
+
keep.add(`${root}.lproj`);
|
|
78
|
+
for (const variant of MAC_VARIANTS) {
|
|
79
|
+
const name = `${root}_${variant}.lproj`;
|
|
80
|
+
if (localeDirs.includes(name)) keep.add(name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const directory of localeDirs) {
|
|
85
|
+
if (!keep.has(directory)) rmSync(join(resources, directory), { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function localeKey(locale: string): string {
|
|
90
|
+
return locale.replaceAll("_", "-").toLowerCase();
|
|
91
|
+
}
|
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* what mirin-core derives at runtime (engine::derive_subprocess_path, host.ts).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { cpSync,
|
|
19
|
-
import {
|
|
20
|
-
import { makeWindowsIcon } from "
|
|
18
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { extname, join } from "node:path";
|
|
20
|
+
import { makeWindowsIcon } from "../../icons/windows/index.ts";
|
|
21
|
+
import { copyFlatCefLocales } from "../shared/cef-locales.ts";
|
|
21
22
|
|
|
22
23
|
/** Source dir (vendor/cef on Windows) must contain libcef.dll. */
|
|
23
24
|
const CEF_MARKER = "libcef.dll";
|
|
@@ -26,7 +27,7 @@ const CEF_MARKER = "libcef.dll";
|
|
|
26
27
|
* distribution (libcef.lib, include/, cmake/, archive.json, bootstrap*.exe) is
|
|
27
28
|
* build-time only and intentionally left out. */
|
|
28
29
|
const CEF_RUNTIME_EXTS = new Set([".dll", ".bin", ".pak", ".dat"]);
|
|
29
|
-
const CEF_RUNTIME_EXTRAS = ["vk_swiftshader_icd.json"
|
|
30
|
+
const CEF_RUNTIME_EXTRAS = ["vk_swiftshader_icd.json"];
|
|
30
31
|
|
|
31
32
|
export interface WinBundleOptions {
|
|
32
33
|
appName: string; // also the host exe stem → <appName>.exe
|
|
@@ -35,6 +36,8 @@ export interface WinBundleOptions {
|
|
|
35
36
|
coreDll: string; // mirin_core.dll
|
|
36
37
|
helperExe: string; // mirin-helper.exe
|
|
37
38
|
cefPath: string; // dir containing the Windows CEF runtime (vendor/cef)
|
|
39
|
+
/** Production CEF locale allowlist. Undefined keeps every locale. */
|
|
40
|
+
cefLocales?: string[];
|
|
38
41
|
/** App icon source (.ico / .iconset / .png) → <App>/icon.ico, set as the window icon. */
|
|
39
42
|
icon?: string;
|
|
40
43
|
/** Production-only resources placed under <App>/resources. */
|
|
@@ -66,7 +69,7 @@ export async function buildWindowsBundle(
|
|
|
66
69
|
cpSync(opts.coreDll, join(app, "mirin_core.dll"));
|
|
67
70
|
cpSync(opts.helperExe, join(app, "mirin-helper.exe"));
|
|
68
71
|
|
|
69
|
-
copyCefRuntime(cefPath, app);
|
|
72
|
+
copyCefRuntime(cefPath, app, opts.cefLocales);
|
|
70
73
|
|
|
71
74
|
// App icon → <App>/icon.ico, loaded at runtime by the core and set as the window
|
|
72
75
|
// icon (taskbar / Alt-Tab / title). Sits beside the exe so the core finds it.
|
|
@@ -105,7 +108,7 @@ export async function buildWindowsBundle(
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
/** Copy the CEF runtime (dlls, paks, snapshot, icu data, locales) into `dest`. */
|
|
108
|
-
function copyCefRuntime(cefPath: string, dest: string): void {
|
|
111
|
+
function copyCefRuntime(cefPath: string, dest: string, locales: string[] | undefined): void {
|
|
109
112
|
for (const entry of readdirSync(cefPath)) {
|
|
110
113
|
if (CEF_RUNTIME_EXTS.has(extname(entry).toLowerCase())) {
|
|
111
114
|
cpSync(join(cefPath, entry), join(dest, entry));
|
|
@@ -115,4 +118,5 @@ function copyCefRuntime(cefPath: string, dest: string): void {
|
|
|
115
118
|
const src = join(cefPath, extra);
|
|
116
119
|
if (existsSync(src)) cpSync(src, join(dest, extra), { recursive: true });
|
|
117
120
|
}
|
|
121
|
+
copyFlatCefLocales(join(cefPath, "locales"), join(dest, "locales"), locales);
|
|
118
122
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./app.ts";
|