@mirinjs/cli 0.1.0-alpha.1 → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirinjs/cli",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.11",
4
4
  "description": "CLI for mirin apps: dev, build, init.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "bun": ">=1.2.0"
28
28
  },
29
29
  "dependencies": {
30
- "create-mirinjs": "0.1.0-alpha.1",
31
- "mirinjs": "0.1.0-alpha.1"
30
+ "create-mirinjs": "0.1.0-alpha.11",
31
+ "mirinjs": "0.1.0-alpha.11"
32
32
  },
33
33
  "optionalDependencies": {
34
- "@mirinjs/darwin-arm64": "0.1.0-alpha.1",
35
- "@mirinjs/win32-x64": "0.1.0-alpha.1"
34
+ "@mirinjs/darwin-arm64": "0.1.0-alpha.11",
35
+ "@mirinjs/win32-x64": "0.1.0-alpha.11"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
package/src/artifacts.ts CHANGED
@@ -67,7 +67,9 @@ export async function resolveArtifacts(opts: { release: boolean }): Promise<Arti
67
67
 
68
68
  /** The native core library file name for the host platform (MSVC has no `lib` prefix). */
69
69
  function coreFileName(): string {
70
- return process.platform === "win32" ? "mirin_core.dll" : "libmirin_core.dylib";
70
+ if (process.platform === "win32") return "mirin_core.dll";
71
+ if (process.platform === "linux") return "libmirin_core.so";
72
+ return "libmirin_core.dylib";
71
73
  }
72
74
 
73
75
  /** The CEF subprocess binary name for the host platform. */
@@ -78,10 +80,12 @@ function helperFileName(): string {
78
80
  function assertSupportedPlatform(): void {
79
81
  const supported =
80
82
  (process.platform === "darwin" && process.arch === "arm64") ||
81
- (process.platform === "win32" && process.arch === "x64");
83
+ (process.platform === "win32" && process.arch === "x64") ||
84
+ (process.platform === "linux" && process.arch === "x64");
82
85
  if (!supported) {
83
86
  throw new Error(
84
- `mirin alpha supports macOS arm64 and Windows x64 (got ${process.platform}/${process.arch}).`,
87
+ `mirin alpha supports macOS arm64, Windows x64, and Linux x64 ` +
88
+ `(got ${process.platform}/${process.arch}).`,
85
89
  );
86
90
  }
87
91
  }
@@ -113,9 +117,12 @@ function cliVersion(): string {
113
117
  }
114
118
 
115
119
  /** A file/dir that, if present in a CEF dir, means the distribution is unpacked.
116
- * macOS ships the framework bundle; Windows ships a flat dir with libcef.dll. */
120
+ * macOS ships the framework bundle; Windows ships a flat dir with libcef.dll;
121
+ * Linux ships a flat dir with libcef.so. */
117
122
  function cefMarker(): string {
118
- return process.platform === "win32" ? "libcef.dll" : "Chromium Embedded Framework.framework";
123
+ if (process.platform === "win32") return "libcef.dll";
124
+ if (process.platform === "linux") return "libcef.so";
125
+ return "Chromium Embedded Framework.framework";
119
126
  }
120
127
 
121
128
  async function ensureCef(): Promise<string> {
package/src/build.ts CHANGED
@@ -16,15 +16,92 @@
16
16
  */
17
17
 
18
18
  import { $ } from "bun";
19
- import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
19
+ import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { buildAppBundle } from "./bundle.ts";
22
22
  import { buildWindowsBundle } from "./bundle-win.ts";
23
+ import { buildLinuxBundle } from "./bundle-linux.ts";
24
+ import { buildLinuxPackages, resolveLinuxFormats } from "./package-linux.ts";
25
+ import { makeWindowsIcon } from "./icon-win.ts";
23
26
  import { resolveArtifacts } from "./artifacts.ts";
24
27
  import { sweepBuildTemps } from "./temps.ts";
25
28
  import { normalizeSidecars, compileWorkers } from "./extras.ts";
26
29
 
27
30
  const IS_WINDOWS = process.platform === "win32";
31
+ const IS_LINUX = process.platform === "linux";
32
+
33
+ /** A PE FILEVERSION (`x.y.z.w`) from a semver string (pre-release suffix dropped). */
34
+ function winFileVersion(v: string): string {
35
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
36
+ return m ? `${m[1]}.${m[2]}.${m[3]}.0` : "0.0.0.0";
37
+ }
38
+
39
+ /**
40
+ * Flip a PE exe from the CONSOLE subsystem (3) to GUI (2) so Windows allocates
41
+ * **no console window** when it's launched from a shortcut. Bun's
42
+ * `--windows-hide-console` only hides the console at runtime (a brief flash);
43
+ * GUI subsystem prevents it entirely — the equivalent of Electrobun's
44
+ * `exe.subsystem = .Windows`. The optional-header Subsystem field is at
45
+ * `e_lfanew + 24 (PE sig + COFF) + 68`.
46
+ */
47
+ function patchPeToGuiSubsystem(exePath: string): void {
48
+ const buf = readFileSync(exePath);
49
+ const peOff = buf.readUInt32LE(0x3c);
50
+ if (buf.readUInt32LE(peOff) !== 0x0000_4550) return; // not "PE\0\0"
51
+ const subsystemOff = peOff + 92;
52
+ if (buf.readUInt16LE(subsystemOff) === 3 /* CONSOLE */) {
53
+ buf.writeUInt16LE(2 /* GUI */, subsystemOff);
54
+ writeFileSync(exePath, buf);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Stamp a Bun-compiled Windows exe with the app icon + version metadata via
60
+ * rcedit. Bun's `--windows-icon` is a silent no-op on Windows hosts, so without
61
+ * this the exe keeps Bun's icon — which the Start Menu / Desktop shortcut, the
62
+ * taskbar, and Explorer all show. Uses a single largest-size icon: rcedit's
63
+ * multi-size replacement silently fails on Bun's appended-payload exes, while a
64
+ * single 256 entry takes and Windows downscales it cleanly for smaller views.
65
+ * Best-effort — warns (doesn't fail the build) if rcedit isn't installed.
66
+ */
67
+ async function brandWindowsExe(
68
+ exe: string,
69
+ opts: {
70
+ projectDir: string;
71
+ work: string;
72
+ appName: string;
73
+ version: string;
74
+ icon?: string;
75
+ publisher: string;
76
+ },
77
+ ): Promise<void> {
78
+ if (!Bun.which("rcedit")) {
79
+ console.warn(
80
+ "[mirin build] rcedit not found — the Windows exe keeps Bun's default icon. " +
81
+ "Install it: `scoop install rcedit` (or `choco install rcedit`).",
82
+ );
83
+ return;
84
+ }
85
+ const args: string[] = [];
86
+ if (opts.icon) {
87
+ const ico = makeWindowsIcon(join(opts.projectDir, opts.icon), join(opts.work, "exe-icon.ico"), {
88
+ onlyLargest: true,
89
+ });
90
+ if (ico) args.push("--set-icon", ico);
91
+ }
92
+ const fv = winFileVersion(opts.version);
93
+ 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,
99
+ );
100
+ const res = await $`rcedit ${exe} ${args}`.nothrow();
101
+ if (res.exitCode !== 0) {
102
+ console.warn(`[mirin build] rcedit failed (exit ${res.exitCode}) — exe branding skipped.`);
103
+ }
104
+ }
28
105
 
29
106
  export interface BuildResult {
30
107
  /** Path to the assembled .app. */
@@ -45,12 +122,25 @@ export interface BuildResult {
45
122
  dmg: boolean | import("mirinjs").DmgConfig;
46
123
  /** NSIS installer config (Windows) — `true`/object/`false`; default `true`. */
47
124
  nsis: boolean | import("mirinjs").NsisConfig;
125
+ /** Inno Setup installer config (Windows) — preferred over NSIS; default `true`. */
126
+ inno: boolean | import("mirinjs").InnoConfig;
127
+ /** Linux packaging config (`mirin.config.ts` `linux`) — `true`/object/`false`; default `true`. */
128
+ linux: boolean | import("mirinjs").LinuxConfig;
129
+ /** Publisher / company name (config.publisher ?? appName). */
130
+ publisher: string;
131
+ /** Absolute path to the app icon source (config.icon), if set — for Linux packaging. */
132
+ icon?: string;
48
133
  /** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
49
134
  signIdentity?: string;
50
135
  }
51
136
 
52
- /** Read the project's package.json version (the single source of app version). */
137
+ /**
138
+ * The app version: `$MIRIN_APP_VERSION` (release-pipeline override) wins, otherwise
139
+ * the project's package.json `version` (the single source of truth).
140
+ */
53
141
  function appVersion(projectDir: string): string {
142
+ const override = process.env.MIRIN_APP_VERSION;
143
+ if (override) return override;
54
144
  const pkgPath = join(projectDir, "package.json");
55
145
  if (!existsSync(pkgPath)) return "0.0.0";
56
146
  try {
@@ -60,7 +150,19 @@ function appVersion(projectDir: string): string {
60
150
  }
61
151
  }
62
152
 
63
- export async function build(projectDir = process.cwd()): Promise<BuildResult> {
153
+ export interface BuildOptions {
154
+ /** Override the app version (else `$MIRIN_APP_VERSION` / package.json). */
155
+ version?: string;
156
+ /** On Linux, also emit distributable packages (AppImage/deb/rpm) after assembling. */
157
+ packageLinux?: boolean;
158
+ /** Restrict Linux packaging to these formats (else config.linux.formats / all). */
159
+ linuxFormats?: import("mirinjs").LinuxPackageFormat[];
160
+ }
161
+
162
+ export async function build(
163
+ projectDir = process.cwd(),
164
+ opts: BuildOptions = {},
165
+ ): Promise<BuildResult> {
64
166
  const outDir = join(projectDir, "build");
65
167
  const work = join(projectDir, ".mirin");
66
168
  mkdirSync(outDir, { recursive: true });
@@ -71,11 +173,14 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
71
173
  const appName: string = config.name ?? "Mirin App";
72
174
  const bundleId: string = config.id ?? "dev.mirin.app";
73
175
  const mainEntry = join(projectDir, config.main ?? "main/main.ts");
74
- const version = appVersion(projectDir);
176
+ const version = opts.version ?? appVersion(projectDir);
75
177
  const channel: string = config.release?.channel ?? "stable";
76
178
  const baseUrl: string | undefined = config.release?.baseUrl;
77
179
  const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
78
180
  const nsis: boolean | import("mirinjs").NsisConfig = config.nsis ?? true;
181
+ const inno: boolean | import("mirinjs").InnoConfig = config.inno ?? true;
182
+ const linux: boolean | import("mirinjs").LinuxConfig = config.linux ?? true;
183
+ const publisher: string = config.publisher ?? appName;
79
184
 
80
185
  console.log(`[mirin build] ${appName} ${version}`);
81
186
 
@@ -92,6 +197,17 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
92
197
  const hostExe = join(work, IS_WINDOWS ? "host-release.exe" : "host-release");
93
198
  const workerJs = join(work, "worker.release.js");
94
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
+ }
95
211
  await $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(projectDir);
96
212
 
97
213
  // Extra assets: resolve sidecar binaries + compile any extra worker entries.
@@ -99,7 +215,8 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
99
215
  const extraWorkers = await compileWorkers(projectDir, config.workers, join(work, "workers"), true);
100
216
 
101
217
  // 5. assemble (+ sign on macOS)
102
- console.log(`[mirin build] assembling ${IS_WINDOWS ? "app folder" : ".app"}…`);
218
+ const artifactKind = IS_WINDOWS ? "app folder" : IS_LINUX ? "app folder" : ".app";
219
+ console.log(`[mirin build] assembling ${artifactKind}…`);
103
220
  // version.json embeds the running app's update identity (read by app.updater).
104
221
  // Only when `release` is configured — otherwise the app has no updater.
105
222
  const versionJson = baseUrl
@@ -108,7 +225,11 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
108
225
  const resources = {
109
226
  uiDir: join(projectDir, "dist"),
110
227
  workerJs,
111
- manifestJson: JSON.stringify({ windows: config.windows }),
228
+ manifestJson: JSON.stringify({
229
+ id: bundleId,
230
+ windows: config.windows,
231
+ singleInstance: config.singleInstance,
232
+ }),
112
233
  versionJson,
113
234
  workers: extraWorkers,
114
235
  };
@@ -123,6 +244,17 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
123
244
  icon: config.icon ? join(projectDir, config.icon) : undefined,
124
245
  resources: { ...resources, sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })) },
125
246
  })
247
+ : 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
+ })
126
258
  : await buildAppBundle({
127
259
  appName,
128
260
  bundleId,
@@ -139,6 +271,29 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
139
271
  });
140
272
 
141
273
  console.log(`\n[mirin build] done → ${app}`);
274
+
275
+ // Linux: optionally emit distributable packages (AppImage/deb/rpm) straight from
276
+ // `mirin build`. `mirin release` does its own packaging into build/release, so it
277
+ // never sets `packageLinux` — this path only runs for a direct build request.
278
+ if (IS_LINUX && opts.packageLinux && linux !== false) {
279
+ const formats = resolveLinuxFormats(linux, opts.linuxFormats);
280
+ console.log(`[mirin build] packaging Linux artifacts (${formats.join(", ")})…`);
281
+ const pkgs = await buildLinuxPackages({
282
+ appDir: app,
283
+ appName,
284
+ bundleId,
285
+ version,
286
+ publisher,
287
+ outDir,
288
+ projectDir,
289
+ icon: config.icon ? join(projectDir, config.icon) : undefined,
290
+ options: typeof linux === "object" ? linux : {},
291
+ formats,
292
+ });
293
+ const mb = (n: number) => (n / 1e6).toFixed(1);
294
+ for (const p of pkgs) console.log(` ${p.path} (${mb(p.size)} MB)`);
295
+ }
296
+
142
297
  return {
143
298
  app,
144
299
  appName,
@@ -150,6 +305,10 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
150
305
  projectDir,
151
306
  dmg,
152
307
  nsis,
308
+ inno,
309
+ linux,
310
+ publisher,
311
+ icon: config.icon ? join(projectDir, config.icon) : undefined,
153
312
  signIdentity,
154
313
  };
155
314
  }
@@ -0,0 +1,234 @@
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 { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, chmodSync, statSync } from "node:fs";
22
+ import { join, extname, basename } from "node:path";
23
+
24
+ /** Source dir (vendor/cef on Linux) must contain libcef.so. */
25
+ const CEF_MARKER = "libcef.so";
26
+
27
+ /** CEF runtime files copied next to the host binary. Everything else in the Linux
28
+ * distribution (include/, cmake/, CMakeLists.txt, archive.json, libcef_dll/) is
29
+ * build-time only and intentionally left out. `.so` covers libcef/libEGL/
30
+ * libGLESv2/libvk_swiftshader; the extras list catches the non-extension names. */
31
+ 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"];
33
+
34
+ export interface LinuxBundleOptions {
35
+ appName: string; // also the host binary stem → <appName>
36
+ outDir: string;
37
+ hostExe: string; // compiled Bun host (no extension)
38
+ coreDll: string; // libmirin_core.so
39
+ helperExe: string; // mirin-helper
40
+ cefPath: string; // dir containing the Linux CEF runtime (vendor/cef)
41
+ /** App icon source (.png / .iconset); resolved to a PNG at resources/icon.png for
42
+ * the window's `_NET_WM_ICON` (taskbar/dock). */
43
+ icon?: string;
44
+ /** Production-only resources placed under <App>/resources. */
45
+ resources?: {
46
+ uiDir?: string; // Vite dist/ → resources/ui (served via app://ui)
47
+ workerJs?: string; // bundled main-process Worker → resources/worker.js
48
+ manifestJson?: string; // serialized manifest → resources/mirin.manifest.json
49
+ versionJson?: string; // serialized version.json → resources/version.json (updater)
50
+ sidecars?: { name: string; src: string }[]; // → resources/sidecars/<name>
51
+ workers?: Record<string, string>; // name → abs path → resources/workers/<name>.js
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Resolve the app config's `icon` to a concrete square PNG usable on Linux (for
57
+ * the window's `_NET_WM_ICON`). Accepts a `.png` (used as-is) or a `.iconset`
58
+ * directory (pick a good mid/large size). Returns undefined for `.icns` (which the
59
+ * Linux core can't decode) or a missing/empty source.
60
+ *
61
+ * @param icon absolute path to the icon source (`config.icon` joined with the project root).
62
+ */
63
+ export function resolveLinuxIconPng(icon: string): string | undefined {
64
+ if (!existsSync(icon)) return undefined;
65
+ if (statSync(icon).isDirectory()) {
66
+ const pngs = readdirSync(icon).filter((f) => extname(f).toLowerCase() === ".png");
67
+ if (!pngs.length) return undefined;
68
+ // Prefer 128×128: crisp in the dock, and small enough that its `_NET_WM_ICON`
69
+ // (a 128²+2 CARDINAL array ≈ 64 KB) stays under X11's ~256 KB max request size —
70
+ // a 256×256 icon (≈ 256 KB) can exceed it and get dropped. Fall back to the
71
+ // largest available under that ceiling (by pixels parsed from the iconset name).
72
+ const preferred = ["icon_128x128.png", "icon_64x64.png", "icon_256x256.png"];
73
+ const MAX_SIDE = 128;
74
+ const pick =
75
+ preferred.find((p) => pngs.includes(p)) ??
76
+ pngs
77
+ .slice()
78
+ .sort((a, b) => iconsetPixels(b) - iconsetPixels(a))
79
+ .find((p) => iconsetPixels(p) <= MAX_SIDE * MAX_SIDE) ??
80
+ pngs.sort((a, b) => iconsetPixels(a) - iconsetPixels(b))[0];
81
+ return pick ? join(icon, pick) : undefined;
82
+ }
83
+ return extname(icon).toLowerCase() === ".png" ? icon : undefined;
84
+ }
85
+
86
+ /**
87
+ * Resolve the app config's `icon` to the *largest* concrete PNG (for a `.desktop`
88
+ * `Icon=`, which is a file path with no X11 request-size limit — so we want it
89
+ * crisp, unlike the window `_NET_WM_ICON`). `.png` used as-is; `.iconset` → biggest
90
+ * PNG inside. Undefined for `.icns` or a missing source.
91
+ */
92
+ export function resolveLinuxDesktopIconPng(icon: string): string | undefined {
93
+ if (!existsSync(icon)) return undefined;
94
+ if (statSync(icon).isDirectory()) {
95
+ const pngs = readdirSync(icon).filter((f) => extname(f).toLowerCase() === ".png");
96
+ if (!pngs.length) return undefined;
97
+ const biggest = pngs.sort((a, b) => iconsetPixels(b) - iconsetPixels(a))[0];
98
+ return biggest ? join(icon, biggest) : undefined;
99
+ }
100
+ return extname(icon).toLowerCase() === ".png" ? icon : undefined;
101
+ }
102
+
103
+ /**
104
+ * A freedesktop `.desktop` entry body for a mirin app. cosmic's dock resolves an
105
+ * X11 window's icon by matching its `WM_CLASS` (which the core sets to the app id)
106
+ * against a desktop entry's `StartupWMClass`, then uses that entry's `Icon` — the
107
+ * reliable path on cosmic (its `_NET_WM_ICON` fallback is unreliable). `wmClass`
108
+ * MUST equal the core's `res_class` (the bundle id).
109
+ */
110
+ export function desktopEntry(opts: {
111
+ name: string;
112
+ exec: string;
113
+ iconPng: string;
114
+ wmClass: string;
115
+ /** Freedesktop main category (e.g. "Utility", "Development"). A trailing `;` is
116
+ * added if missing (the spec requires list values to end with one). Omitted → no
117
+ * Categories line. Used by the .deb/.rpm/AppImage entries (dev needs none). */
118
+ categories?: string;
119
+ /** Optional Comment= (tooltip / description) line. */
120
+ comment?: string;
121
+ }): string {
122
+ const lines = ["[Desktop Entry]", "Type=Application", `Name=${opts.name}`];
123
+ if (opts.comment) lines.push(`Comment=${opts.comment}`);
124
+ lines.push(
125
+ `Exec=${opts.exec}`,
126
+ `Icon=${opts.iconPng}`,
127
+ `StartupWMClass=${opts.wmClass}`,
128
+ "Terminal=false",
129
+ );
130
+ if (opts.categories) {
131
+ lines.push(`Categories=${opts.categories.endsWith(";") ? opts.categories : `${opts.categories};`}`);
132
+ }
133
+ lines.push("");
134
+ return lines.join("\n");
135
+ }
136
+
137
+ /** Effective pixel count encoded in an iconset filename (`icon_256x256@2x.png` → 512²). */
138
+ function iconsetPixels(name: string): number {
139
+ const m = basename(name).match(/(\d+)x(\d+)(@(\d+)x)?/);
140
+ if (!m) return 0;
141
+ const scale = m[4] ? Number(m[4]) : 1;
142
+ return Number(m[1]) * Number(m[2]) * scale * scale;
143
+ }
144
+
145
+ /** Build the Linux app folder and return its path + the host binary to launch. */
146
+ export async function buildLinuxBundle(
147
+ opts: LinuxBundleOptions,
148
+ ): Promise<{ app: string; exe: string }> {
149
+ const { appName, cefPath } = opts;
150
+ if (!existsSync(join(cefPath, CEF_MARKER))) {
151
+ throw new Error(`CEF runtime not found at ${cefPath} — run: bun scripts/fetch-cef.ts`);
152
+ }
153
+
154
+ const app = join(opts.outDir, appName);
155
+ rmSync(app, { recursive: true, force: true });
156
+ mkdirSync(app, { recursive: true });
157
+
158
+ const exe = join(app, appName);
159
+ cpSync(opts.hostExe, exe);
160
+ chmodSync(exe, 0o755);
161
+ cpSync(opts.coreDll, join(app, "libmirin_core.so"));
162
+ const helper = join(app, "mirin-helper");
163
+ cpSync(opts.helperExe, helper);
164
+ chmodSync(helper, 0o755);
165
+
166
+ copyCefRuntime(cefPath, app);
167
+
168
+ // The window taskbar/dock icon (`_NET_WM_ICON`) is set by the core from a PNG at
169
+ // `resources/icon.png`; host.ts points the init config's `icon_path` there in prod.
170
+ // (Dev serves it directly from the project via MIRIN_CONFIG_JSON, not the bundle.)
171
+ const iconPng = opts.icon ? resolveLinuxIconPng(opts.icon) : undefined;
172
+ if (iconPng) {
173
+ const res = join(app, "resources");
174
+ mkdirSync(res, { recursive: true });
175
+ cpSync(iconPng, join(res, "icon.png"));
176
+ }
177
+
178
+ // Production resources (dev passes none — paths come from env + the Vite URL).
179
+ if (opts.resources) {
180
+ const res = join(app, "resources");
181
+ mkdirSync(res, { recursive: true });
182
+ if (opts.resources.uiDir) cpSync(opts.resources.uiDir, join(res, "ui"), { recursive: true });
183
+ if (opts.resources.workerJs) cpSync(opts.resources.workerJs, join(res, "worker.js"));
184
+ if (opts.resources.manifestJson != null) {
185
+ writeFileSync(join(res, "mirin.manifest.json"), opts.resources.manifestJson);
186
+ }
187
+ if (opts.resources.versionJson != null) {
188
+ writeFileSync(join(res, "version.json"), opts.resources.versionJson);
189
+ }
190
+ if (opts.resources.workers && Object.keys(opts.resources.workers).length) {
191
+ const workersDir = join(res, "workers");
192
+ mkdirSync(workersDir, { recursive: true });
193
+ for (const [name, src] of Object.entries(opts.resources.workers)) {
194
+ cpSync(src, join(workersDir, `${name}.js`));
195
+ }
196
+ }
197
+ if (opts.resources.sidecars?.length) {
198
+ const sidecarsDir = join(res, "sidecars");
199
+ mkdirSync(sidecarsDir, { recursive: true });
200
+ for (const sc of opts.resources.sidecars) {
201
+ if (!existsSync(sc.src)) throw new Error(`sidecar "${sc.name}" not found: ${sc.src}`);
202
+ const dst = join(sidecarsDir, sc.name);
203
+ cpSync(sc.src, dst);
204
+ chmodSync(dst, 0o755);
205
+ }
206
+ }
207
+ }
208
+
209
+ return { app, exe };
210
+ }
211
+
212
+ /** Copy the CEF runtime (shared libs, paks, snapshot, icu data, locales) into `dest`. */
213
+ function copyCefRuntime(cefPath: string, dest: string): void {
214
+ for (const entry of readdirSync(cefPath)) {
215
+ if (CEF_RUNTIME_EXTS.has(extname(entry).toLowerCase())) {
216
+ cpSync(join(cefPath, entry), join(dest, entry));
217
+ }
218
+ }
219
+ for (const extra of CEF_RUNTIME_EXTRAS) {
220
+ const src = join(cefPath, extra);
221
+ if (existsSync(src)) cpSync(src, join(dest, extra), { recursive: true });
222
+ }
223
+ // chrome-sandbox works setuid-root (mode 4755) or, on kernels with unprivileged
224
+ // user namespaces, via the namespace sandbox with no special perms. We can't
225
+ // setuid without root here; leave it executable and let CEF pick the userns path.
226
+ const sandbox = join(dest, "chrome-sandbox");
227
+ if (existsSync(sandbox)) {
228
+ try {
229
+ require("node:fs").chmodSync(sandbox, 0o755);
230
+ } catch {
231
+ /* best-effort */
232
+ }
233
+ }
234
+ }