@mirinjs/cli 0.1.0-alpha.14 → 0.1.0-alpha.16

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirinjs/cli",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "CLI for mirin apps: dev, build, init.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,15 @@
27
27
  "bun": ">=1.2.0"
28
28
  },
29
29
  "dependencies": {
30
- "create-mirinjs": "0.1.0-alpha.14",
31
- "mirinjs": "0.1.0-alpha.14"
30
+ "create-mirinjs": "0.1.0-alpha.16",
31
+ "mirinjs": "0.1.0-alpha.16"
32
32
  },
33
33
  "optionalDependencies": {
34
- "@mirinjs/darwin-arm64": "0.1.0-alpha.14",
35
- "@mirinjs/win32-x64": "0.1.0-alpha.14",
36
- "@mirinjs/linux-x64": "0.1.0-alpha.14"
34
+ "@mirinjs/darwin-arm64": "0.1.0-alpha.16",
35
+ "@mirinjs/win32-x64": "0.1.0-alpha.16",
36
+ "@mirinjs/win32-arm64": "0.1.0-alpha.16",
37
+ "@mirinjs/linux-arm64": "0.1.0-alpha.16",
38
+ "@mirinjs/linux-x64": "0.1.0-alpha.16"
37
39
  },
38
40
  "publishConfig": {
39
41
  "access": "public"
package/src/artifacts.ts CHANGED
@@ -9,13 +9,22 @@
9
9
  * entry from the `mirin` package, and a CEF framework downloaded once from
10
10
  * the matching GitHub Release into `~/.mirinjs/cef/<version>`. No Rust needed.
11
11
  *
12
- * Alpha supports macOS arm64 only.
12
+ * Alpha supports macOS arm64, Windows x64/arm64, and Linux x64/arm64.
13
13
  */
14
14
 
15
- import { $ } from "bun";
16
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ readlinkSync,
22
+ renameSync,
23
+ rmSync,
24
+ } from "node:fs";
17
25
  import { homedir, tmpdir } from "node:os";
18
- import { dirname, join, resolve } from "node:path";
26
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
27
+ import { $ } from "bun";
19
28
 
20
29
  const REPO_SLUG = "Netko-Labs/mirin";
21
30
 
@@ -80,11 +89,11 @@ function helperFileName(): string {
80
89
  function assertSupportedPlatform(): void {
81
90
  const supported =
82
91
  (process.platform === "darwin" && process.arch === "arm64") ||
83
- (process.platform === "win32" && process.arch === "x64") ||
84
- (process.platform === "linux" && process.arch === "x64");
92
+ (process.platform === "win32" && (process.arch === "x64" || process.arch === "arm64")) ||
93
+ (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64"));
85
94
  if (!supported) {
86
95
  throw new Error(
87
- `mirin alpha supports macOS arm64, Windows x64, and Linux x64 ` +
96
+ "mirin alpha supports macOS arm64, Windows x64/arm64, and Linux x64/arm64 " +
88
97
  `(got ${process.platform}/${process.arch}).`,
89
98
  );
90
99
  }
@@ -101,7 +110,7 @@ function resolveNativeDir(): string {
101
110
  } catch {
102
111
  throw new Error(
103
112
  `mirin: prebuilt native package "${pkg}" is not installed. Run \`bun install\` ` +
104
- `(it is an optional dependency of @mirinjs/cli for your platform).`,
113
+ "(it is an optional dependency of @mirinjs/cli for your platform).",
105
114
  );
106
115
  }
107
116
  }
@@ -136,10 +145,11 @@ async function ensureCef(): Promise<string> {
136
145
  }
137
146
 
138
147
  const version = cliVersion();
139
- const cacheDir = join(homedir(), ".mirinjs", "cef", `${version}-${platformTag()}`);
148
+ const cacheRoot = join(homedir(), ".mirinjs", "cef");
149
+ const cacheDir = join(cacheRoot, `${version}-${platformTag()}`);
140
150
  if (existsSync(join(cacheDir, cefMarker()))) return cacheDir;
141
151
 
142
- mkdirSync(cacheDir, { recursive: true });
152
+ mkdirSync(cacheRoot, { recursive: true });
143
153
  const asset = `cef-${platformTag()}.tar.gz`;
144
154
  const url = `https://github.com/${REPO_SLUG}/releases/download/v${version}/${asset}`;
145
155
  console.log(`[mirin] downloading CEF for ${platformTag()} (one-time, ~hundreds of MB)…`);
@@ -147,14 +157,113 @@ async function ensureCef(): Promise<string> {
147
157
  // Download with curl, not `Bun.write(file, await fetch(url))`: the latter
148
158
  // pins a core at 100% CPU and never completes on large gzip responses
149
159
  // (the streaming write path). curl is fast and ubiquitous.
150
- const archive = join(tmpdir(), asset);
160
+ const downloadDir = mkdtempSync(join(tmpdir(), "mirin-cef-download-"));
161
+ const stagingDir = mkdtempSync(join(cacheRoot, ".staging-"));
162
+ const archive = join(downloadDir, asset);
163
+ const checksum = `${archive}.sha256`;
164
+ const checksumUrl = `${url}.sha256`;
165
+ const httpsOnly = "=https";
151
166
  try {
152
- await $`curl -fSL --retry 3 -o ${archive} ${url}`;
153
- await $`tar -xzf ${archive} -C ${cacheDir}`;
167
+ await $`curl --fail --show-error --location --retry 3 --proto ${httpsOnly} --proto-redir ${httpsOnly} -o ${checksum} ${checksumUrl}`;
168
+ await $`curl --fail --show-error --location --retry 3 --proto ${httpsOnly} --proto-redir ${httpsOnly} -o ${archive} ${url}`;
169
+ await verifyFileSha256(archive, readFileSync(checksum, "utf8"));
170
+ const listing = await $`tar -tzf ${archive}`.quiet().text();
171
+ validateArchiveEntries(listing);
172
+ await $`tar -xzf ${archive} -C ${stagingDir}`;
173
+ validateExtractedSymlinks(stagingDir);
174
+ if (!existsSync(join(stagingDir, cefMarker()))) {
175
+ throw new Error(`CEF archive is missing ${cefMarker()}`);
176
+ }
177
+
178
+ // Another process may have populated the cache while this one downloaded.
179
+ if (existsSync(join(cacheDir, cefMarker()))) return cacheDir;
180
+ rmSync(cacheDir, { recursive: true, force: true });
181
+ renameSync(stagingDir, cacheDir);
154
182
  } catch (err) {
155
- rmSync(archive, { force: true });
156
183
  throw new Error(`mirin: failed to download CEF from ${url} (${err}).`);
184
+ } finally {
185
+ rmSync(downloadDir, { recursive: true, force: true });
186
+ rmSync(stagingDir, { recursive: true, force: true });
157
187
  }
158
- rmSync(archive, { force: true });
159
188
  return cacheDir;
160
189
  }
190
+
191
+ /** Reject absolute paths and parent traversal before a downloaded tar is extracted. */
192
+ export function validateArchiveEntries(listing: string): void {
193
+ for (const rawEntry of listing.split(/\r?\n/)) {
194
+ if (rawEntry.length === 0) continue;
195
+ const entry = archivePath(rawEntry);
196
+ if (entry == null) throw new Error(`unsafe CEF archive entry: ${rawEntry}`);
197
+ }
198
+ }
199
+
200
+ /** Verify a downloaded release asset before parsing or extracting it. */
201
+ export async function verifyFileSha256(path: string, checksum: string): Promise<void> {
202
+ const expected = checksum.trim().toLowerCase();
203
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
204
+ throw new Error("invalid CEF SHA-256 checksum");
205
+ }
206
+
207
+ const hasher = new Bun.CryptoHasher("sha256");
208
+ for await (const chunk of Bun.file(path).stream()) {
209
+ hasher.update(chunk);
210
+ }
211
+ const actual = hasher.digest("hex");
212
+ if (actual !== expected) {
213
+ throw new Error(`CEF SHA-256 mismatch (expected ${expected}, got ${actual})`);
214
+ }
215
+ }
216
+
217
+ /** Reject extracted symlinks whose lexical target escapes the staging directory. */
218
+ export function validateExtractedSymlinks(root: string): void {
219
+ const absoluteRoot = resolve(root);
220
+ const pending = [absoluteRoot];
221
+
222
+ while (pending.length > 0) {
223
+ const directory = pending.pop();
224
+ if (!directory) continue;
225
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
226
+ const path = join(directory, entry.name);
227
+ if (entry.isSymbolicLink()) {
228
+ const target = readlinkSync(path);
229
+ const resolvedTarget = resolve(dirname(path), target);
230
+ const fromRoot = relative(absoluteRoot, resolvedTarget);
231
+ if (
232
+ isAbsolute(target) ||
233
+ fromRoot === ".." ||
234
+ fromRoot.startsWith(`..${sep}`) ||
235
+ isAbsolute(fromRoot)
236
+ ) {
237
+ throw new Error(`CEF archive symlink escapes its cache: ${path} -> ${target}`);
238
+ }
239
+ } else if (entry.isDirectory()) {
240
+ pending.push(path);
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ function archivePath(raw: string): string | null {
247
+ let path = raw.replaceAll("\\", "/");
248
+ while (path.startsWith("./")) path = path.slice(2);
249
+ if (path.length === 0 || path === ".") return "";
250
+ if (path.startsWith("/") || /^[A-Za-z]:/.test(path) || hasControlCharacter(path)) {
251
+ return null;
252
+ }
253
+
254
+ const parts: string[] = [];
255
+ for (const part of path.split("/")) {
256
+ if (part.length === 0 || part === ".") continue;
257
+ if (part === "..") return null;
258
+ parts.push(part);
259
+ }
260
+ return parts.length > 0 ? parts.join("/") : "";
261
+ }
262
+
263
+ function hasControlCharacter(value: string): boolean {
264
+ for (let index = 0; index < value.length; index++) {
265
+ const code = value.charCodeAt(index);
266
+ if (code <= 0x1f || code === 0x7f) return true;
267
+ }
268
+ return false;
269
+ }
package/src/build.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * `mirin build` — package a standalone, signed .app (docs/macos-mvp.md).
3
3
  *
4
- * 1. vite build → production UI in dist/
5
- * 2. resolve native artifacts → core + helper (in-repo build or prebuilt)
6
- * 3. compile host (minified) → Contents/MacOS/<exe>
7
- * 4. bundle Worker (minified) → Resources/worker.js
8
- * 5. assemble + sign the .app → dist/ui, manifest, dylib, helpers, CEF
4
+ * 1. vite build + native artifact resolution run in parallel.
5
+ * 2. compile host + bundle Workers run in parallel.
6
+ * 3. assemble + sign the app from the completed artifacts.
9
7
  *
10
8
  * The result runs with no env and no dev server: the host resolves everything
11
9
  * from inside the bundle, and webviews load their `app://` URLs served from
@@ -15,17 +13,18 @@
15
13
  * ID to produce a distributable, notarizable app.
16
14
  */
17
15
 
18
- import { $ } from "bun";
19
- import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
17
  import { join } from "node:path";
21
- import { buildAppBundle } from "./bundle.ts";
22
- import { buildWindowsBundle } from "./bundle-win.ts";
23
- import { buildLinuxBundle } from "./bundle-linux.ts";
24
- import { buildLinuxPackages, resolveLinuxFormats } from "./package-linux.ts";
25
- import { makeWindowsIcon } from "./icon-win.ts";
18
+ import { $ } from "bun";
26
19
  import { resolveArtifacts } from "./artifacts.ts";
20
+ import { buildLinuxBundle } from "./bundle/linux/index.ts";
21
+ import { buildAppBundle } from "./bundle/macos/index.ts";
22
+ import { normalizeCefLocales } from "./bundle/shared/cef-locales.ts";
23
+ import { buildWindowsBundle } from "./bundle/windows/index.ts";
24
+ import { compileWorkers, normalizeSidecars } from "./extras.ts";
25
+ import { makeWindowsIcon } from "./icons/windows/index.ts";
26
+ import { buildLinuxPackages, resolveLinuxFormats } from "./package/linux/index.ts";
27
27
  import { sweepBuildTemps } from "./temps.ts";
28
- import { normalizeSidecars, compileWorkers } from "./extras.ts";
29
28
 
30
29
  const IS_WINDOWS = process.platform === "win32";
31
30
  const IS_LINUX = process.platform === "linux";
@@ -91,11 +90,19 @@ async function brandWindowsExe(
91
90
  }
92
91
  const fv = winFileVersion(opts.version);
93
92
  args.push(
94
- "--set-version-string", "ProductName", opts.appName,
95
- "--set-version-string", "FileDescription", opts.appName,
96
- "--set-version-string", "CompanyName", opts.publisher,
97
- "--set-file-version", fv,
98
- "--set-product-version", fv,
93
+ "--set-version-string",
94
+ "ProductName",
95
+ opts.appName,
96
+ "--set-version-string",
97
+ "FileDescription",
98
+ opts.appName,
99
+ "--set-version-string",
100
+ "CompanyName",
101
+ opts.publisher,
102
+ "--set-file-version",
103
+ fv,
104
+ "--set-product-version",
105
+ fv,
99
106
  );
100
107
  const res = await $`rcedit ${exe} ${args}`.nothrow();
101
108
  if (res.exitCode !== 0) {
@@ -114,6 +121,8 @@ export interface BuildResult {
114
121
  channel: string;
115
122
  /** Update baseUrl, if `release` is configured. */
116
123
  baseUrl?: string;
124
+ /** Markdown release notes embedded in the updater manifest, if configured. */
125
+ releaseNotes?: string;
117
126
  /** libmirin_core path (for the updater codec at release time). */
118
127
  coreDylib: string;
119
128
  /** Project root (so `mirin release` can resolve relative asset paths). */
@@ -176,43 +185,50 @@ export async function build(
176
185
  const version = opts.version ?? appVersion(projectDir);
177
186
  const channel: string = config.release?.channel ?? "stable";
178
187
  const baseUrl: string | undefined = config.release?.baseUrl;
188
+ const releaseNotes: string | undefined = config.release?.notes;
179
189
  const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
180
190
  const nsis: boolean | import("mirinjs").NsisConfig = config.nsis ?? true;
181
191
  const inno: boolean | import("mirinjs").InnoConfig = config.inno ?? true;
182
192
  const linux: boolean | import("mirinjs").LinuxConfig = config.linux ?? true;
183
193
  const publisher: string = config.publisher ?? appName;
194
+ const cefLocales = normalizeCefLocales(config.cef?.locales);
184
195
 
185
196
  console.log(`[mirin build] ${appName} ${version}`);
186
197
 
187
- // 1. production UI
188
- console.log("[mirin build] vite build…");
189
- await $`bunx vite build`.cwd(projectDir);
190
-
191
- // 2. native artifacts (release)
192
- const artifacts = await resolveArtifacts({ release: true });
198
+ // Production UI and native artifacts do not share outputs, so overlap them.
199
+ console.log("[mirin build] building UI + native artifacts…");
200
+ const [, artifacts] = await Promise.all([
201
+ $`bunx vite build`.cwd(projectDir),
202
+ resolveArtifacts({ release: true }),
203
+ ]);
193
204
 
194
- // 3 + 4. host + worker (minified)
195
- console.log("[mirin build] compiling host + bundling main process…");
205
+ // Host compilation, main Worker bundling, and extra Worker bundling are independent.
206
+ console.log("[mirin build] compiling host + bundling workers…");
196
207
  const signIdentity = process.env.MIRIN_SIGN_IDENTITY;
197
208
  const hostExe = join(work, IS_WINDOWS ? "host-release.exe" : "host-release");
198
209
  const workerJs = join(work, "worker.release.js");
199
- await $`bun build --compile --minify ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
200
- if (IS_WINDOWS) {
201
- patchPeToGuiSubsystem(hostExe);
202
- await brandWindowsExe(hostExe, {
203
- projectDir,
204
- work,
205
- appName,
206
- version,
207
- icon: config.icon,
208
- publisher,
209
- });
210
- }
211
- await $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(projectDir);
212
-
213
- // Extra assets: resolve sidecar binaries + compile any extra worker entries.
214
210
  const sidecars = normalizeSidecars(projectDir, config.sidecars);
215
- const extraWorkers = await compileWorkers(projectDir, config.workers, join(work, "workers"), true);
211
+ const hostBuild = (async (): Promise<void> => {
212
+ await $`bun build --compile --minify ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(
213
+ projectDir,
214
+ );
215
+ if (IS_WINDOWS) {
216
+ patchPeToGuiSubsystem(hostExe);
217
+ await brandWindowsExe(hostExe, {
218
+ projectDir,
219
+ work,
220
+ appName,
221
+ version,
222
+ icon: config.icon,
223
+ publisher,
224
+ });
225
+ }
226
+ })();
227
+ const workerBuild = $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(
228
+ projectDir,
229
+ );
230
+ const extraWorkersBuild = compileWorkers(projectDir, config.workers, join(work, "workers"), true);
231
+ const [, , extraWorkers] = await Promise.all([hostBuild, workerBuild, extraWorkersBuild]);
216
232
 
217
233
  // 5. assemble (+ sign on macOS)
218
234
  const artifactKind = IS_WINDOWS ? "app folder" : IS_LINUX ? "app folder" : ".app";
@@ -241,34 +257,40 @@ export async function build(
241
257
  coreDll: artifacts.coreDylib,
242
258
  helperExe: artifacts.helperBin,
243
259
  cefPath: artifacts.cefPath,
260
+ cefLocales,
244
261
  icon: config.icon ? join(projectDir, config.icon) : undefined,
245
262
  resources: { ...resources, sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })) },
246
263
  })
247
264
  : IS_LINUX
248
- ? await buildLinuxBundle({
249
- appName,
250
- outDir,
251
- hostExe,
252
- coreDll: artifacts.coreDylib,
253
- helperExe: artifacts.helperBin,
254
- cefPath: artifacts.cefPath,
255
- icon: config.icon ? join(projectDir, config.icon) : undefined,
256
- resources: { ...resources, sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })) },
257
- })
258
- : await buildAppBundle({
259
- appName,
260
- bundleId,
261
- outDir,
262
- hostExe,
263
- coreDylib: artifacts.coreDylib,
264
- helperBin: artifacts.helperBin,
265
- cefPath: artifacts.cefPath,
266
- version,
267
- icon: config.icon ? join(projectDir, config.icon) : undefined,
268
- signIdentity,
269
- urlSchemes: config.urlSchemes,
270
- resources: { ...resources, sidecars },
271
- });
265
+ ? await buildLinuxBundle({
266
+ appName,
267
+ outDir,
268
+ hostExe,
269
+ coreDll: artifacts.coreDylib,
270
+ helperExe: artifacts.helperBin,
271
+ cefPath: artifacts.cefPath,
272
+ cefLocales,
273
+ icon: config.icon ? join(projectDir, config.icon) : undefined,
274
+ resources: {
275
+ ...resources,
276
+ sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })),
277
+ },
278
+ })
279
+ : await buildAppBundle({
280
+ appName,
281
+ bundleId,
282
+ outDir,
283
+ hostExe,
284
+ coreDylib: artifacts.coreDylib,
285
+ helperBin: artifacts.helperBin,
286
+ cefPath: artifacts.cefPath,
287
+ cefLocales,
288
+ version,
289
+ icon: config.icon ? join(projectDir, config.icon) : undefined,
290
+ signIdentity,
291
+ urlSchemes: config.urlSchemes,
292
+ resources: { ...resources, sidecars },
293
+ });
272
294
 
273
295
  console.log(`\n[mirin build] done → ${app}`);
274
296
 
@@ -301,6 +323,7 @@ export async function build(
301
323
  version,
302
324
  channel,
303
325
  baseUrl,
326
+ releaseNotes,
304
327
  coreDylib: artifacts.coreDylib,
305
328
  projectDir,
306
329
  dmg,
@@ -18,8 +18,18 @@
18
18
  * LD_LIBRARY_PATH set on the host (dev.ts) or an rpath at build time.
19
19
  */
20
20
 
21
- import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, chmodSync, statSync } from "node:fs";
22
- import { join, extname, basename } from "node:path";
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";
23
33
 
24
34
  /** Source dir (vendor/cef on Linux) must contain libcef.so. */
25
35
  const CEF_MARKER = "libcef.so";
@@ -29,7 +39,7 @@ const CEF_MARKER = "libcef.so";
29
39
  * build-time only and intentionally left out. `.so` covers libcef/libEGL/
30
40
  * libGLESv2/libvk_swiftshader; the extras list catches the non-extension names. */
31
41
  const CEF_RUNTIME_EXTS = new Set([".so", ".bin", ".pak", ".dat"]);
32
- const CEF_RUNTIME_EXTRAS = ["locales", "vk_swiftshader_icd.json", "libvulkan.so.1", "chrome-sandbox"];
42
+ const CEF_RUNTIME_EXTRAS = ["vk_swiftshader_icd.json", "libvulkan.so.1", "chrome-sandbox"];
33
43
 
34
44
  export interface LinuxBundleOptions {
35
45
  appName: string; // also the host binary stem → <appName>
@@ -38,6 +48,8 @@ export interface LinuxBundleOptions {
38
48
  coreDll: string; // libmirin_core.so
39
49
  helperExe: string; // mirin-helper
40
50
  cefPath: string; // dir containing the Linux CEF runtime (vendor/cef)
51
+ /** Production CEF locale allowlist. Undefined keeps every locale. */
52
+ cefLocales?: string[];
41
53
  /** App icon source (.png / .iconset); resolved to a PNG at resources/icon.png for
42
54
  * the window's `_NET_WM_ICON` (taskbar/dock). */
43
55
  icon?: string;
@@ -70,13 +82,13 @@ export function resolveLinuxIconPng(icon: string): string | undefined {
70
82
  // a 256×256 icon (≈ 256 KB) can exceed it and get dropped. Fall back to the
71
83
  // largest available under that ceiling (by pixels parsed from the iconset name).
72
84
  const preferred = ["icon_128x128.png", "icon_64x64.png", "icon_256x256.png"];
73
- const MAX_SIDE = 128;
85
+ const MaxSide = 128;
74
86
  const pick =
75
87
  preferred.find((p) => pngs.includes(p)) ??
76
88
  pngs
77
89
  .slice()
78
90
  .sort((a, b) => iconsetPixels(b) - iconsetPixels(a))
79
- .find((p) => iconsetPixels(p) <= MAX_SIDE * MAX_SIDE) ??
91
+ .find((p) => iconsetPixels(p) <= MaxSide * MaxSide) ??
80
92
  pngs.sort((a, b) => iconsetPixels(a) - iconsetPixels(b))[0];
81
93
  return pick ? join(icon, pick) : undefined;
82
94
  }
@@ -119,21 +131,49 @@ export function desktopEntry(opts: {
119
131
  /** Optional Comment= (tooltip / description) line. */
120
132
  comment?: string;
121
133
  }): string {
122
- const lines = ["[Desktop Entry]", "Type=Application", `Name=${opts.name}`];
123
- if (opts.comment) lines.push(`Comment=${opts.comment}`);
134
+ const lines = ["[Desktop Entry]", "Type=Application", `Name=${desktopValue("Name", opts.name)}`];
135
+ if (opts.comment) lines.push(`Comment=${desktopValue("Comment", opts.comment)}`);
124
136
  lines.push(
125
- `Exec=${opts.exec}`,
126
- `Icon=${opts.iconPng}`,
127
- `StartupWMClass=${opts.wmClass}`,
137
+ `Exec=${desktopExec(opts.exec)}`,
138
+ `Icon=${desktopValue("Icon", opts.iconPng)}`,
139
+ `StartupWMClass=${desktopValue("StartupWMClass", opts.wmClass)}`,
128
140
  "Terminal=false",
129
141
  );
130
142
  if (opts.categories) {
131
- lines.push(`Categories=${opts.categories.endsWith(";") ? opts.categories : `${opts.categories};`}`);
143
+ lines.push(`Categories=${desktopCategories(opts.categories)}`);
132
144
  }
133
145
  lines.push("");
134
146
  return lines.join("\n");
135
147
  }
136
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
+
137
177
  /** Effective pixel count encoded in an iconset filename (`icon_256x256@2x.png` → 512²). */
138
178
  function iconsetPixels(name: string): number {
139
179
  const m = basename(name).match(/(\d+)x(\d+)(@(\d+)x)?/);
@@ -163,7 +203,7 @@ export async function buildLinuxBundle(
163
203
  cpSync(opts.helperExe, helper);
164
204
  chmodSync(helper, 0o755);
165
205
 
166
- copyCefRuntime(cefPath, app);
206
+ copyCefRuntime(cefPath, app, opts.cefLocales);
167
207
 
168
208
  // The window taskbar/dock icon (`_NET_WM_ICON`) is set by the core from a PNG at
169
209
  // `resources/icon.png`; host.ts points the init config's `icon_path` there in prod.
@@ -210,7 +250,7 @@ export async function buildLinuxBundle(
210
250
  }
211
251
 
212
252
  /** Copy the CEF runtime (shared libs, paks, snapshot, icu data, locales) into `dest`. */
213
- function copyCefRuntime(cefPath: string, dest: string): void {
253
+ function copyCefRuntime(cefPath: string, dest: string, locales: string[] | undefined): void {
214
254
  for (const entry of readdirSync(cefPath)) {
215
255
  if (CEF_RUNTIME_EXTS.has(extname(entry).toLowerCase())) {
216
256
  cpSync(join(cefPath, entry), join(dest, entry));
@@ -220,6 +260,7 @@ function copyCefRuntime(cefPath: string, dest: string): void {
220
260
  const src = join(cefPath, extra);
221
261
  if (existsSync(src)) cpSync(src, join(dest, extra), { recursive: true });
222
262
  }
263
+ copyFlatCefLocales(join(cefPath, "locales"), join(dest, "locales"), locales);
223
264
  // chrome-sandbox works setuid-root (mode 4755) or, on kernels with unprivileged
224
265
  // user namespaces, via the namespace sandbox with no special perms. We can't
225
266
  // setuid without root here; leave it executable and let CEF pick the userns path.
@@ -0,0 +1 @@
1
+ export * from "./app.ts";
@@ -11,9 +11,18 @@
11
11
  * "<exe> Helper (Renderer).app/Contents/MacOS/<exe> Helper (Renderer)".
12
12
  */
13
13
 
14
- import { $ } from "bun";
15
- import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
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 { pruneMacCefLocales } from "../shared/cef-locales.ts";
17
26
 
18
27
  const FRAMEWORK = "Chromium Embedded Framework.framework";
19
28
 
@@ -33,6 +42,8 @@ export interface BundleOptions {
33
42
  coreDylib: string; // libmirin_core.dylib
34
43
  helperBin: string; // compiled mirin-helper binary
35
44
  cefPath: string; // dir containing the CEF framework (vendor/cef)
45
+ /** Production CEF locale allowlist. Undefined keeps every locale. */
46
+ cefLocales?: string[];
36
47
  /** App version → Info.plist CFBundleShortVersionString/CFBundleVersion. */
37
48
  version?: string;
38
49
  /** App icon source: a .icns, a .iconset dir, or a square .png. Optional. */
@@ -201,17 +212,17 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
201
212
  // Deep-link schemes: register this app as the macOS handler for app://-style
202
213
  // URLs (e.g. anko://…). Delivered at runtime via app.on("open-url").
203
214
  if (opts.urlSchemes?.length) {
204
- info.CFBundleURLTypes = [
205
- { CFBundleURLName: bundleId, CFBundleURLSchemes: opts.urlSchemes },
206
- ];
215
+ info.CFBundleURLTypes = [{ CFBundleURLName: bundleId, CFBundleURLSchemes: opts.urlSchemes }];
207
216
  }
208
217
 
209
218
  writeFileSync(join(contents, "Info.plist"), plist(info));
210
219
 
211
- cpSync(join(cefPath, FRAMEWORK), join(frameworks, FRAMEWORK), {
220
+ const bundledFramework = join(frameworks, FRAMEWORK);
221
+ cpSync(join(cefPath, FRAMEWORK), bundledFramework, {
212
222
  recursive: true,
213
223
  verbatimSymlinks: true,
214
224
  });
225
+ pruneMacCefLocales(bundledFramework, opts.cefLocales);
215
226
 
216
227
  // Production resources: the served UI, the Worker bundle, and the manifest.
217
228
  const resources = join(contents, "Resources");
@@ -0,0 +1 @@
1
+ export * from "./app.ts";