@mirinjs/cli 0.1.0-alpha.10 → 0.1.0-alpha.12

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.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "description": "CLI for mirin apps: dev, build, init.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,13 @@
27
27
  "bun": ">=1.2.0"
28
28
  },
29
29
  "dependencies": {
30
- "create-mirinjs": "0.1.0-alpha.10",
31
- "mirinjs": "0.1.0-alpha.10"
30
+ "create-mirinjs": "0.1.0-alpha.12",
31
+ "mirinjs": "0.1.0-alpha.12"
32
32
  },
33
33
  "optionalDependencies": {
34
- "@mirinjs/darwin-arm64": "0.1.0-alpha.10",
35
- "@mirinjs/win32-x64": "0.1.0-alpha.10"
34
+ "@mirinjs/darwin-arm64": "0.1.0-alpha.12",
35
+ "@mirinjs/win32-x64": "0.1.0-alpha.12",
36
+ "@mirinjs/linux-x64": "0.1.0-alpha.12"
36
37
  },
37
38
  "publishConfig": {
38
39
  "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
@@ -20,12 +20,15 @@ import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node
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";
23
25
  import { makeWindowsIcon } from "./icon-win.ts";
24
26
  import { resolveArtifacts } from "./artifacts.ts";
25
27
  import { sweepBuildTemps } from "./temps.ts";
26
28
  import { normalizeSidecars, compileWorkers } from "./extras.ts";
27
29
 
28
30
  const IS_WINDOWS = process.platform === "win32";
31
+ const IS_LINUX = process.platform === "linux";
29
32
 
30
33
  /** A PE FILEVERSION (`x.y.z.w`) from a semver string (pre-release suffix dropped). */
31
34
  function winFileVersion(v: string): string {
@@ -121,14 +124,23 @@ export interface BuildResult {
121
124
  nsis: boolean | import("mirinjs").NsisConfig;
122
125
  /** Inno Setup installer config (Windows) — preferred over NSIS; default `true`. */
123
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;
124
129
  /** Publisher / company name (config.publisher ?? appName). */
125
130
  publisher: string;
131
+ /** Absolute path to the app icon source (config.icon), if set — for Linux packaging. */
132
+ icon?: string;
126
133
  /** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
127
134
  signIdentity?: string;
128
135
  }
129
136
 
130
- /** 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
+ */
131
141
  function appVersion(projectDir: string): string {
142
+ const override = process.env.MIRIN_APP_VERSION;
143
+ if (override) return override;
132
144
  const pkgPath = join(projectDir, "package.json");
133
145
  if (!existsSync(pkgPath)) return "0.0.0";
134
146
  try {
@@ -138,7 +150,19 @@ function appVersion(projectDir: string): string {
138
150
  }
139
151
  }
140
152
 
141
- 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> {
142
166
  const outDir = join(projectDir, "build");
143
167
  const work = join(projectDir, ".mirin");
144
168
  mkdirSync(outDir, { recursive: true });
@@ -149,12 +173,13 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
149
173
  const appName: string = config.name ?? "Mirin App";
150
174
  const bundleId: string = config.id ?? "dev.mirin.app";
151
175
  const mainEntry = join(projectDir, config.main ?? "main/main.ts");
152
- const version = appVersion(projectDir);
176
+ const version = opts.version ?? appVersion(projectDir);
153
177
  const channel: string = config.release?.channel ?? "stable";
154
178
  const baseUrl: string | undefined = config.release?.baseUrl;
155
179
  const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
156
180
  const nsis: boolean | import("mirinjs").NsisConfig = config.nsis ?? true;
157
181
  const inno: boolean | import("mirinjs").InnoConfig = config.inno ?? true;
182
+ const linux: boolean | import("mirinjs").LinuxConfig = config.linux ?? true;
158
183
  const publisher: string = config.publisher ?? appName;
159
184
 
160
185
  console.log(`[mirin build] ${appName} ${version}`);
@@ -190,7 +215,8 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
190
215
  const extraWorkers = await compileWorkers(projectDir, config.workers, join(work, "workers"), true);
191
216
 
192
217
  // 5. assemble (+ sign on macOS)
193
- 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}…`);
194
220
  // version.json embeds the running app's update identity (read by app.updater).
195
221
  // Only when `release` is configured — otherwise the app has no updater.
196
222
  const versionJson = baseUrl
@@ -218,6 +244,17 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
218
244
  icon: config.icon ? join(projectDir, config.icon) : undefined,
219
245
  resources: { ...resources, sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })) },
220
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
+ })
221
258
  : await buildAppBundle({
222
259
  appName,
223
260
  bundleId,
@@ -234,6 +271,29 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
234
271
  });
235
272
 
236
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
+
237
297
  return {
238
298
  app,
239
299
  appName,
@@ -246,7 +306,9 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
246
306
  dmg,
247
307
  nsis,
248
308
  inno,
309
+ linux,
249
310
  publisher,
311
+ icon: config.icon ? join(projectDir, config.icon) : undefined,
250
312
  signIdentity,
251
313
  };
252
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
+ }
package/src/dev.ts CHANGED
@@ -9,14 +9,22 @@
9
9
  */
10
10
 
11
11
  import { $ } from "bun";
12
- import { mkdirSync, rmSync, symlinkSync, existsSync } from "node:fs";
12
+ import { mkdirSync, rmSync, symlinkSync, existsSync, writeFileSync } from "node:fs";
13
13
  import { createServer } from "node:net";
14
14
  import { join } from "node:path";
15
15
  import { buildAppBundle } from "./bundle.ts";
16
16
  import { buildWindowsBundle } from "./bundle-win.ts";
17
+ import {
18
+ buildLinuxBundle,
19
+ resolveLinuxIconPng,
20
+ resolveLinuxDesktopIconPng,
21
+ desktopEntry,
22
+ } from "./bundle-linux.ts";
23
+ import { homedir } from "node:os";
17
24
  import { resolveArtifacts } from "./artifacts.ts";
18
25
 
19
26
  const IS_WINDOWS = process.platform === "win32";
27
+ const IS_LINUX = process.platform === "linux";
20
28
  import { sweepBuildTemps } from "./temps.ts";
21
29
  import { normalizeSidecars, compileWorkers } from "./extras.ts";
22
30
 
@@ -36,6 +44,11 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
36
44
  const appName: string = config.name ?? "Mirin App";
37
45
  const bundleId: string = config.id ?? "dev.mirin.app";
38
46
  const mainEntry = join(projectDir, config.main ?? "main/main.ts");
47
+ const iconSrc = config.icon ? join(projectDir, config.icon) : undefined;
48
+ // Linux: resolve the app icon to a concrete PNG the core stamps onto the window
49
+ // as `_NET_WM_ICON` (cosmic dock/taskbar). Passed straight through in dev — no
50
+ // bundling; the file is read from the project at window-create time.
51
+ const linuxIconPng = IS_LINUX && iconSrc ? resolveLinuxIconPng(iconSrc) : undefined;
39
52
 
40
53
  // --- native artifacts ---
41
54
  const artifacts = await resolveArtifacts({ release: false });
@@ -61,7 +74,7 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
61
74
  else console.warn(`[mirin dev] sidecar "${sc.name}" not found: ${sc.src}`);
62
75
  }
63
76
 
64
- // --- assemble the dev bundle (Windows app folder, or macOS .app) ---
77
+ // --- assemble the dev bundle (Windows/Linux app folder, or macOS .app) ---
65
78
  console.log("[mirin dev] assembling dev bundle…");
66
79
  const { app, exe } = IS_WINDOWS
67
80
  ? await buildWindowsBundle({
@@ -71,7 +84,17 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
71
84
  coreDll: artifacts.coreDylib,
72
85
  helperExe: artifacts.helperBin,
73
86
  cefPath: artifacts.cefPath,
74
- icon: config.icon ? join(projectDir, config.icon) : undefined,
87
+ icon: iconSrc,
88
+ })
89
+ : IS_LINUX
90
+ ? await buildLinuxBundle({
91
+ appName,
92
+ outDir: work,
93
+ hostExe,
94
+ coreDll: artifacts.coreDylib,
95
+ helperExe: artifacts.helperBin,
96
+ cefPath: artifacts.cefPath,
97
+ icon: iconSrc,
75
98
  })
76
99
  : await buildAppBundle({
77
100
  appName,
@@ -81,19 +104,41 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
81
104
  coreDylib: artifacts.coreDylib,
82
105
  helperBin: artifacts.helperBin,
83
106
  cefPath: artifacts.cefPath,
84
- icon: config.icon ? join(projectDir, config.icon) : undefined,
107
+ icon: iconSrc,
85
108
  urlSchemes: config.urlSchemes,
86
109
  });
87
110
 
111
+ // Linux: install a dev `.desktop` entry so cosmic's dock can resolve the app's
112
+ // icon. cosmic matches a running X11 window's `WM_CLASS` (which the core sets to
113
+ // the bundle id) against `StartupWMClass`, then shows that entry's `Icon`. Written
114
+ // to the per-user applications dir; harmless on other DEs.
115
+ if (IS_LINUX && iconSrc) {
116
+ const desktopIcon = resolveLinuxDesktopIconPng(iconSrc);
117
+ if (desktopIcon) {
118
+ const appsDir = join(homedir(), ".local", "share", "applications");
119
+ mkdirSync(appsDir, { recursive: true });
120
+ writeFileSync(
121
+ join(appsDir, `${bundleId}.desktop`),
122
+ desktopEntry({ name: appName, exec: exe, iconPng: desktopIcon, wmClass: bundleId }),
123
+ );
124
+ }
125
+ }
126
+
88
127
  // --- start Vite on a free port so concurrent dev sessions don't collide ---
89
128
  // `--port <free> --strictPort` pins Vite to the port we probed (overriding any
90
129
  // port/strictPort in the app's vite.config) and passes the real URL to the app,
91
130
  // so the host and Vite never disagree about which port is in use.
131
+ //
132
+ // Bind + address on 127.0.0.1 (IPv4), NOT "localhost": on hosts where Vite binds
133
+ // IPv6-only (`[::1]`) while Chromium resolves `localhost` to IPv4 (common on
134
+ // Linux), the webview's page load is refused and the window stays blank. Pinning
135
+ // both sides to 127.0.0.1 avoids the family mismatch (and matches the loopback
136
+ // RPC server). `--host 127.0.0.1` forces Vite's bind.
92
137
  const port = await findFreePort(DEV_PORT_BASE);
93
- const devUrl = `http://localhost:${port}`;
138
+ const devUrl = `http://127.0.0.1:${port}`;
94
139
  console.log(`[mirin dev] starting Vite dev server on ${devUrl}…`);
95
140
  const vite = Bun.spawn(
96
- ["bunx", "vite", "--clearScreen", "false", "--port", String(port), "--strictPort"],
141
+ ["bunx", "vite", "--clearScreen", "false", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
97
142
  {
98
143
  cwd: projectDir,
99
144
  stdio: ["ignore", "inherit", "inherit"],
@@ -109,13 +154,24 @@ export async function dev(projectDir = process.cwd()): Promise<number> {
109
154
  ...process.env,
110
155
  MIRIN_CORE: IS_WINDOWS
111
156
  ? join(app, "mirin_core.dll")
157
+ : IS_LINUX
158
+ ? join(app, "libmirin_core.so")
112
159
  : join(app, "Contents", "MacOS", "libmirin_core.dylib"),
160
+ // Linux: the flat app dir holds libcef.so + libmirin_core.so; put it on the
161
+ // loader path so the host's dlopen and libmirin_core's NEEDED libcef.so (and
162
+ // the spawned mirin-helper) all resolve. (macOS uses the framework loader;
163
+ // Windows the exe-dir search — neither needs this.)
164
+ ...(IS_LINUX ? { LD_LIBRARY_PATH: `${app}${process.env.LD_LIBRARY_PATH ? ":" + process.env.LD_LIBRARY_PATH : ""}` } : {}),
113
165
  MIRIN_WORKER: workerJs,
114
166
  MIRIN_DEV_URL: devUrl,
115
167
  MIRIN_MANIFEST_JSON: JSON.stringify({ id: bundleId, windows: config.windows }),
116
168
  // dev: true → enables inspect-element AND gives this run its own `-dev`
117
169
  // CEF cache dir, so `mirin dev` can run alongside the installed app.
118
- MIRIN_CONFIG_JSON: JSON.stringify({ dev: true }),
170
+ // `icon_path` (Linux) → the window's `_NET_WM_ICON` (cosmic dock/taskbar).
171
+ MIRIN_CONFIG_JSON: JSON.stringify({
172
+ dev: true,
173
+ ...(linuxIconPng ? { icon_path: linuxIconPng } : {}),
174
+ }),
119
175
  MIRIN_SIDECAR_DIR: sidecarsDir,
120
176
  MIRIN_WORKERS_DIR: workersDir,
121
177
  },
package/src/index.ts CHANGED
@@ -8,16 +8,53 @@ import { dev } from "./dev.ts";
8
8
  import { build } from "./build.ts";
9
9
  import { release } from "./release.ts";
10
10
  import { scaffold } from "create-mirinjs";
11
+ import type { LinuxPackageFormat } from "mirinjs";
11
12
 
12
- const [command, arg] = Bun.argv.slice(2);
13
+ /** Minimal flag parser: `--k=v`, `--k v`, and boolean `--k`; the rest are positionals. */
14
+ function parseArgs(argv: string[]): { positionals: string[]; opts: Record<string, string | boolean> } {
15
+ const opts: Record<string, string | boolean> = {};
16
+ const positionals: string[] = [];
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === undefined) continue;
20
+ if (a.startsWith("--")) {
21
+ const eq = a.indexOf("=");
22
+ if (eq >= 0) {
23
+ opts[a.slice(2, eq)] = a.slice(eq + 1);
24
+ } else {
25
+ const key = a.slice(2);
26
+ const next = argv[i + 1];
27
+ if (next !== undefined && !next.startsWith("--")) {
28
+ opts[key] = next;
29
+ i++;
30
+ } else {
31
+ opts[key] = true;
32
+ }
33
+ }
34
+ } else {
35
+ positionals.push(a);
36
+ }
37
+ }
38
+ return { positionals, opts };
39
+ }
40
+
41
+ const [command, ...rest] = Bun.argv.slice(2);
42
+ const { positionals, opts } = parseArgs(rest);
43
+ const arg = positionals[0];
13
44
 
14
45
  const USAGE = `mirin — build desktop apps with Bun + Chromium
15
46
 
16
47
  Usage:
17
48
  mirin dev run the app against the Vite dev server (HMR + typed RPC)
18
- mirin build package a standalone, signed .app (output: ./build)
49
+ mirin build package a standalone app (output: ./build)
19
50
  mirin release build + emit update artifacts (output: ./build/release)
20
51
  mirin init [dir] scaffold a new app
52
+
53
+ Options (build):
54
+ --version <v> override the app version (else package.json)
55
+ --linux also emit Linux packages (AppImage + .deb + .rpm)
56
+ --linux-target <a,b,…> restrict Linux packaging to these formats
57
+ (appimage,deb,rpm) — implies --linux
21
58
  `;
22
59
 
23
60
  switch (command) {
@@ -26,7 +63,13 @@ switch (command) {
26
63
  break;
27
64
  }
28
65
  case "build": {
29
- await build();
66
+ const version = typeof opts.version === "string" ? opts.version : undefined;
67
+ const target = typeof opts["linux-target"] === "string" ? opts["linux-target"] : undefined;
68
+ const linuxFormats = target
69
+ ? (target.split(",").map((s) => s.trim()).filter(Boolean) as LinuxPackageFormat[])
70
+ : undefined;
71
+ const packageLinux = opts.linux === true || linuxFormats != null;
72
+ await build(process.cwd(), { version, packageLinux, linuxFormats });
30
73
  process.exit(0);
31
74
  break;
32
75
  }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Linux distributable packages for a mirin app — **AppImage**, **.deb**, **.rpm** —
3
+ * built from the assembled Linux app folder (buildLinuxBundle's output). This is the
4
+ * Linux analog of dmg.ts (macOS) and installer-inno.ts / installer-win.ts (Windows):
5
+ * `mirin build` (with packaging requested) and `mirin release` call it after the app
6
+ * folder is assembled.
7
+ *
8
+ * The app folder is a flat, relocatable payload — the host binary, libcef.so + the
9
+ * CEF runtime, and `resources/`, all resolved at runtime from an `$ORIGIN` rpath and
10
+ * `dirname(process.execPath)` (host.ts). Every package therefore ships the whole
11
+ * folder verbatim under one prefix and points a launcher at the *real* host binary
12
+ * inside it (never a bare PATH name — the host derives everything from its own dir):
13
+ *
14
+ * - deb / rpm: payload → `/opt/<id>/`, a `/usr/bin/<bin>` wrapper `exec`s
15
+ * `/opt/<id>/<App>`, plus `/usr/share/applications/<id>.desktop` and a
16
+ * `/usr/share/icons/hicolor/256x256/apps/<id>.png`.
17
+ * - AppImage: payload → `AppDir/usr/lib/<id>/`, top-level `AppRun` `exec`s it,
18
+ * `<id>.desktop` + `<id>.png` (+ `.DirIcon`) at the AppDir root, packed by
19
+ * appimagetool.
20
+ *
21
+ * Tooling (resolved via $MIRIN_FPM / $MIRIN_APPIMAGETOOL, then PATH, then a
22
+ * conventional fallback — see resolveTool):
23
+ * - **fpm** builds both deb and rpm in pure Ruby (no dpkg/rpmbuild on the host).
24
+ * - **appimagetool** packs the AppImage (invoked with `--appimage-extract-and-run`
25
+ * so it works on hosts without FUSE).
26
+ */
27
+
28
+ import { $ } from "bun";
29
+ import {
30
+ cpSync,
31
+ mkdirSync,
32
+ rmSync,
33
+ writeFileSync,
34
+ chmodSync,
35
+ existsSync,
36
+ symlinkSync,
37
+ statSync,
38
+ } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { homedir } from "node:os";
41
+ import { desktopEntry, resolveLinuxDesktopIconPng } from "./bundle-linux.ts";
42
+ import type { LinuxConfig, LinuxPackageFormat } from "mirinjs";
43
+
44
+ export interface LinuxPackageInput {
45
+ /** The assembled Linux app folder (build/<App>): the whole relocatable payload. */
46
+ appDir: string;
47
+ /** App display name; also the host-binary stem inside `appDir`. */
48
+ appName: string;
49
+ /** Reverse-DNS app id, e.g. "dev.netko.anko" (install prefix + `.desktop`/icon name). */
50
+ bundleId: string;
51
+ version: string;
52
+ /** Publisher / company (deb/rpm vendor + maintainer fallback). */
53
+ publisher: string;
54
+ /** Directory the artifacts are written into. */
55
+ outDir: string;
56
+ projectDir: string;
57
+ /** Abs path to the app icon source (`.png` / `.iconset`); resolved to a PNG. */
58
+ icon?: string;
59
+ /** Resolved Linux config (maintainer, depends, category, …). */
60
+ options: LinuxConfig;
61
+ /** Which formats to build. */
62
+ formats: LinuxPackageFormat[];
63
+ }
64
+
65
+ export interface LinuxPackageResult {
66
+ format: LinuxPackageFormat;
67
+ path: string;
68
+ size: number;
69
+ }
70
+
71
+ /** CEF/GTK runtime deps on Debian/Ubuntu package naming (best-effort; overridable). */
72
+ const DEFAULT_DEB_DEPENDS = [
73
+ "libgtk-3-0", "libnss3", "libnspr4", "libasound2", "libx11-6", "libxcomposite1",
74
+ "libxdamage1", "libxext6", "libxfixes3", "libxrandr2", "libgbm1", "libxkbcommon0",
75
+ "libpango-1.0-0", "libcairo2", "libatk1.0-0", "libatk-bridge2.0-0", "libcups2",
76
+ "libdrm2", "libdbus-1-3",
77
+ ];
78
+
79
+ /** CEF/GTK runtime deps on RPM (Fedora/RHEL/openSUSE) package naming. */
80
+ const DEFAULT_RPM_DEPENDS = [
81
+ "gtk3", "nss", "nspr", "alsa-lib", "libX11", "libXcomposite", "libXdamage",
82
+ "libXext", "libXfixes", "libXrandr", "mesa-libgbm", "libxkbcommon", "pango",
83
+ "cairo", "atk", "at-spi2-atk", "cups-libs", "libdrm", "dbus-libs",
84
+ ];
85
+
86
+ /**
87
+ * Resolve an external tool path: an explicit `$ENV` override (if it exists), then
88
+ * PATH, then the first existing `fallback`. Throws a clear, actionable error when
89
+ * none resolve (no user-specific absolute path is baked into committed code).
90
+ */
91
+ function resolveTool(bin: string, envVar: string, fallbacks: string[]): string {
92
+ const fromEnv = process.env[envVar];
93
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
94
+ const onPath = Bun.which(bin);
95
+ if (onPath) return onPath;
96
+ for (const f of fallbacks) if (existsSync(f)) return f;
97
+ throw new Error(
98
+ `[mirin] "${bin}" not found — install it and put it on PATH, or set $${envVar} ` +
99
+ `to its absolute path.`,
100
+ );
101
+ }
102
+
103
+ /** `~/.local/share/gem/ruby/<ver>/bin` (RubyGems user install dir), if resolvable. */
104
+ function gemUserBin(): string | undefined {
105
+ const ruby = Bun.which("ruby");
106
+ if (!ruby) return undefined;
107
+ try {
108
+ const dir = Bun.spawnSync([ruby, "-e", "print Gem.user_dir"]).stdout.toString().trim();
109
+ return dir ? join(dir, "bin") : undefined;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ function resolveFpm(): string {
116
+ const fallbacks: string[] = [];
117
+ const gemBin = gemUserBin();
118
+ if (gemBin) fallbacks.push(join(gemBin, "fpm"));
119
+ return resolveTool("fpm", "MIRIN_FPM", fallbacks);
120
+ }
121
+
122
+ function resolveAppimagetool(): string {
123
+ return resolveTool("appimagetool", "MIRIN_APPIMAGETOOL", [
124
+ join(homedir(), ".local", "bin", "appimagetool"),
125
+ ]);
126
+ }
127
+
128
+ /** A Debian/RPM-friendly package name: lowercase, alnum + `.-+`, no leading sigil. */
129
+ function shortName(input: string): string {
130
+ return (
131
+ input.toLowerCase().replace(/[^a-z0-9.\-+]/g, "-").replace(/^[.\-+]+/, "") || "app"
132
+ );
133
+ }
134
+
135
+ const size = (p: string) => statSync(p).size;
136
+
137
+ /** Build every requested Linux package; returns each artifact's path + size. */
138
+ export async function buildLinuxPackages(
139
+ input: LinuxPackageInput,
140
+ ): Promise<LinuxPackageResult[]> {
141
+ mkdirSync(input.outDir, { recursive: true });
142
+ const binName = input.options.binName ?? shortName(input.bundleId.split(".").pop() ?? input.appName);
143
+ const results: LinuxPackageResult[] = [];
144
+
145
+ // deb + rpm share one staged filesystem tree (payload under /opt, launcher,
146
+ // .desktop, icon). Build it once and reuse.
147
+ const needFsTree = input.formats.includes("deb") || input.formats.includes("rpm");
148
+ let fsRoot: string | undefined;
149
+ try {
150
+ if (needFsTree) fsRoot = stageFsTree(input, binName);
151
+ if (input.formats.includes("deb")) results.push(await buildFpm(input, binName, fsRoot!, "deb"));
152
+ if (input.formats.includes("rpm")) results.push(await buildFpm(input, binName, fsRoot!, "rpm"));
153
+ if (input.formats.includes("appimage")) results.push(await buildAppImage(input, binName));
154
+ } finally {
155
+ if (fsRoot) rmSync(fsRoot, { recursive: true, force: true });
156
+ }
157
+ return results;
158
+ }
159
+
160
+ /**
161
+ * Stage the on-disk filesystem layout deb/rpm install: the whole payload under
162
+ * `/opt/<id>/`, a `/usr/bin/<bin>` wrapper `exec`ing the real host binary, the
163
+ * `.desktop` entry, and a hicolor icon. Returns the staging root (fpm's `-C` chdir).
164
+ */
165
+ function stageFsTree(input: LinuxPackageInput, binName: string): string {
166
+ const root = join(input.outDir, `.pkgroot-${process.pid}`);
167
+ rmSync(root, { recursive: true, force: true });
168
+
169
+ // Payload → /opt/<id>/…
170
+ const prefix = join(root, "opt", input.bundleId);
171
+ mkdirSync(prefix, { recursive: true });
172
+ cpSync(input.appDir, prefix, { recursive: true });
173
+
174
+ // /usr/bin/<bin>: a wrapper that exec's the real binary so process.execPath (and
175
+ // thus the host's core/resources/helper resolution) points into /opt/<id>.
176
+ const binDir = join(root, "usr", "bin");
177
+ mkdirSync(binDir, { recursive: true });
178
+ const launcher = join(binDir, binName);
179
+ writeFileSync(launcher, `#!/bin/sh\nexec "/opt/${input.bundleId}/${input.appName}" "$@"\n`);
180
+ chmodSync(launcher, 0o755);
181
+
182
+ // /usr/share/applications/<id>.desktop — Icon= is the theme name (<id>), resolved
183
+ // from the hicolor icon below; Exec= is the /usr/bin launcher on PATH.
184
+ const appsDir = join(root, "usr", "share", "applications");
185
+ mkdirSync(appsDir, { recursive: true });
186
+ writeFileSync(join(appsDir, `${input.bundleId}.desktop`), buildDesktop(input, binName));
187
+
188
+ stageHicolorIcon(root, input);
189
+ return root;
190
+ }
191
+
192
+ /** Install the app icon at `usr/share/icons/hicolor/256x256/apps/<id>.png` under `root`. */
193
+ function stageHicolorIcon(root: string, input: LinuxPackageInput): void {
194
+ const png = input.icon ? resolveLinuxDesktopIconPng(input.icon) : undefined;
195
+ if (!png) return;
196
+ const dir = join(root, "usr", "share", "icons", "hicolor", "256x256", "apps");
197
+ mkdirSync(dir, { recursive: true });
198
+ cpSync(png, join(dir, `${input.bundleId}.png`));
199
+ }
200
+
201
+ /** The `.desktop` body shared by deb/rpm/AppImage (Exec = launcher name, Icon = <id>). */
202
+ function buildDesktop(input: LinuxPackageInput, binName: string): string {
203
+ return desktopEntry({
204
+ name: input.appName,
205
+ exec: binName,
206
+ iconPng: input.bundleId,
207
+ wmClass: input.bundleId,
208
+ categories: input.options.category ?? "Utility",
209
+ comment: input.options.description,
210
+ });
211
+ }
212
+
213
+ /** Build a .deb or .rpm from the staged tree via fpm. */
214
+ async function buildFpm(
215
+ input: LinuxPackageInput,
216
+ binName: string,
217
+ fsRoot: string,
218
+ kind: "deb" | "rpm",
219
+ ): Promise<LinuxPackageResult> {
220
+ const fpm = resolveFpm();
221
+ const arch = kind === "deb" ? "amd64" : "x86_64";
222
+ const fileName =
223
+ kind === "deb"
224
+ ? `${binName}_${input.version}_${arch}.deb`
225
+ : `${binName}-${input.version}-1.${arch}.rpm`;
226
+ const out = join(input.outDir, fileName);
227
+ rmSync(out, { force: true });
228
+
229
+ const depends =
230
+ kind === "deb"
231
+ ? input.options.debDepends ?? DEFAULT_DEB_DEPENDS
232
+ : input.options.rpmDepends ?? DEFAULT_RPM_DEPENDS;
233
+
234
+ const args: string[] = [
235
+ "-s", "dir",
236
+ "-t", kind,
237
+ "-C", fsRoot,
238
+ "--package", out,
239
+ "--name", binName,
240
+ "--version", input.version,
241
+ "--iteration", "1",
242
+ "--architecture", arch,
243
+ "--maintainer", input.options.maintainer ?? input.publisher,
244
+ "--vendor", input.publisher,
245
+ "--description", input.options.description ?? input.appName,
246
+ ];
247
+ if (input.options.homepage) args.push("--url", input.options.homepage);
248
+ if (input.options.license) args.push("--license", input.options.license);
249
+ for (const d of depends) args.push("--depends", d);
250
+ // Package the whole staged tree ('.' relative to the -C chdir).
251
+ args.push(".");
252
+
253
+ console.log(`[mirin] building ${kind} → ${fileName}`);
254
+ const res = await $`${fpm} ${args}`.nothrow().quiet();
255
+ if (res.exitCode !== 0 || !existsSync(out)) {
256
+ throw new Error(`fpm (${kind}) failed (exit ${res.exitCode}):\n${res.stderr.toString()}`);
257
+ }
258
+ return { format: kind, path: out, size: size(out) };
259
+ }
260
+
261
+ /** Build an AppDir and pack it into a single-file AppImage via appimagetool. */
262
+ async function buildAppImage(
263
+ input: LinuxPackageInput,
264
+ binName: string,
265
+ ): Promise<LinuxPackageResult> {
266
+ const appimagetool = resolveAppimagetool();
267
+ const appDirRoot = join(input.outDir, `.AppDir-${process.pid}`);
268
+ rmSync(appDirRoot, { recursive: true, force: true });
269
+
270
+ // Payload → AppDir/usr/lib/<id>/ so $ORIGIN resolves within it.
271
+ const payload = join(appDirRoot, "usr", "lib", input.bundleId);
272
+ mkdirSync(payload, { recursive: true });
273
+ cpSync(input.appDir, payload, { recursive: true });
274
+
275
+ // AppRun: exec the real host binary (resolves core/resources/helper from its dir).
276
+ const appRun = join(appDirRoot, "AppRun");
277
+ writeFileSync(
278
+ appRun,
279
+ [
280
+ "#!/bin/sh",
281
+ 'HERE="$(dirname "$(readlink -f "$0")")"',
282
+ `exec "$HERE/usr/lib/${input.bundleId}/${input.appName}" "$@"`,
283
+ "",
284
+ ].join("\n"),
285
+ );
286
+ chmodSync(appRun, 0o755);
287
+
288
+ // .desktop at the AppDir root (appimagetool reads it) + the standard location.
289
+ const desktop = buildDesktop(input, binName);
290
+ writeFileSync(join(appDirRoot, `${input.bundleId}.desktop`), desktop);
291
+ const appsDir = join(appDirRoot, "usr", "share", "applications");
292
+ mkdirSync(appsDir, { recursive: true });
293
+ writeFileSync(join(appsDir, `${input.bundleId}.desktop`), desktop);
294
+
295
+ // Icon: <id>.png + .DirIcon at the root, plus the hicolor copy.
296
+ const png = input.icon ? resolveLinuxDesktopIconPng(input.icon) : undefined;
297
+ if (png) {
298
+ cpSync(png, join(appDirRoot, `${input.bundleId}.png`));
299
+ try {
300
+ symlinkSync(`${input.bundleId}.png`, join(appDirRoot, ".DirIcon"));
301
+ } catch {
302
+ /* best-effort: some filesystems disallow symlinks */
303
+ }
304
+ stageHicolorIcon(appDirRoot, input);
305
+ }
306
+
307
+ const fileName = `${input.appName}-${input.version}-x86_64.AppImage`;
308
+ const out = join(input.outDir, fileName);
309
+ rmSync(out, { force: true });
310
+
311
+ console.log(`[mirin] building AppImage → ${fileName}`);
312
+ // --appimage-extract-and-run: run appimagetool without FUSE. ARCH is required by
313
+ // appimagetool; NO_STRIP avoids stripping the Bun-compiled host (which would break it).
314
+ const res = await $`${appimagetool} --appimage-extract-and-run ${appDirRoot} ${out}`
315
+ .env({ ...process.env, ARCH: "x86_64", NO_STRIP: "1" })
316
+ .nothrow()
317
+ .quiet();
318
+ rmSync(appDirRoot, { recursive: true, force: true });
319
+ if (res.exitCode !== 0 || !existsSync(out)) {
320
+ throw new Error(`appimagetool failed (exit ${res.exitCode}):\n${res.stderr.toString()}`);
321
+ }
322
+ chmodSync(out, 0o755);
323
+ return { format: "appimage", path: out, size: size(out) };
324
+ }
325
+
326
+ /** Resolve the effective set of formats from config + an optional CLI/caller override. */
327
+ export function resolveLinuxFormats(
328
+ linux: boolean | LinuxConfig | undefined,
329
+ override?: LinuxPackageFormat[],
330
+ ): LinuxPackageFormat[] {
331
+ if (override && override.length) return override;
332
+ const fromConfig = typeof linux === "object" ? linux.formats : undefined;
333
+ return fromConfig && fromConfig.length ? fromConfig : ["appimage", "deb", "rpm"];
334
+ }
package/src/release.ts CHANGED
@@ -18,12 +18,13 @@
18
18
 
19
19
  import { $ } from "bun";
20
20
  import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
21
- import { join } from "node:path";
21
+ import { join, basename } from "node:path";
22
22
  import { tmpdir } from "node:os";
23
23
  import { build } from "./build.ts";
24
24
  import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
25
25
  import { buildNsisInstaller, hasMakensis } from "./installer-win.ts";
26
26
  import { buildInnoInstaller, hasInno } from "./installer-inno.ts";
27
+ import { buildLinuxPackages, resolveLinuxFormats } from "./package-linux.ts";
27
28
  import { loadCodec } from "mirinjs/codec";
28
29
 
29
30
  const sha256File = (path: string) =>
@@ -37,13 +38,14 @@ export async function release(projectDir = process.cwd()): Promise<number> {
37
38
  }
38
39
 
39
40
  const isWindows = process.platform === "win32";
40
- const platform = isWindows ? "win32" : "darwin";
41
+ const isLinux = process.platform === "linux";
42
+ const platform = isWindows ? "win32" : isLinux ? "linux" : "darwin";
41
43
  const arch = process.arch; // "arm64" | "x64"
42
44
  const prefix = `${result.channel}-${platform}-${arch}`;
43
45
  const safeName = result.appName.replace(/[^A-Za-z0-9._-]/g, "");
44
46
  const base = result.baseUrl.replace(/\/$/, "");
45
- // The packaged unit: a flat app folder on Windows, an `.app` bundle on macOS.
46
- const appArtifact = isWindows ? result.appName : `${result.appName}.app`;
47
+ // The packaged unit: a flat app folder on Windows/Linux, an `.app` bundle on macOS.
48
+ const appArtifact = isWindows || isLinux ? result.appName : `${result.appName}.app`;
47
49
 
48
50
  const buildDir = join(projectDir, "build");
49
51
  const outDir = join(buildDir, "release");
@@ -53,7 +55,7 @@ export async function release(projectDir = process.cwd()): Promise<number> {
53
55
  // Notarize + staple the .app (Developer ID, macOS) before packing, when
54
56
  // credentials are present. The .tar.zst / patch updater bundles are made from
55
57
  // this signed, stapled .app, so the updater swaps in an already-notarized app.
56
- if (!isWindows) await notarizeAndStaple(result.app);
58
+ if (!isWindows && !isLinux) await notarizeAndStaple(result.app);
57
59
 
58
60
  // Load the codec (zstd/bsdiff) from the *bundled* core, not the raw build output:
59
61
  // on Windows mirin_core.dll imports libcef.dll. The DLL loader resolves imports
@@ -179,6 +181,42 @@ export async function release(projectDir = process.cwd()): Promise<number> {
179
181
  await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
180
182
  installerSize = readFileSync(zipPath).byteLength;
181
183
  }
184
+ } else if (isLinux) {
185
+ // Linux: the first-install convenience is a set of distributable packages
186
+ // (AppImage + .deb + .rpm), the analog of the macOS .dmg / Windows installer.
187
+ // Never fail the whole release over a packaging step — warn and ship the rest.
188
+ if (result.linux !== false) {
189
+ try {
190
+ const formats = resolveLinuxFormats(result.linux);
191
+ console.log(`[mirin release] building Linux packages (${formats.join(", ")})…`);
192
+ const pkgs = await buildLinuxPackages({
193
+ appDir: result.app,
194
+ appName: result.appName,
195
+ bundleId: result.bundleId,
196
+ version: result.version,
197
+ publisher: result.publisher,
198
+ outDir,
199
+ projectDir: result.projectDir,
200
+ icon: result.icon,
201
+ options: typeof result.linux === "object" ? result.linux : {},
202
+ formats,
203
+ });
204
+ // The AppImage doubles as the primary "installer" line in the summary.
205
+ const appImage = pkgs.find((p) => p.format === "appimage") ?? pkgs[0];
206
+ if (appImage) {
207
+ installerName = basename(appImage.path);
208
+ installerSize = appImage.size;
209
+ }
210
+ for (const p of pkgs) {
211
+ if (p !== appImage) console.log(` ${basename(p.path)} (${(p.size / 1e6).toFixed(1)} MB)`);
212
+ }
213
+ } catch (e) {
214
+ console.warn(
215
+ `\n[mirin release] ⚠️ Linux packaging failed: ${e instanceof Error ? e.message : e}\n` +
216
+ `[mirin release] shipping the updater bundle + manifest WITHOUT packages.\n`,
217
+ );
218
+ }
219
+ }
182
220
  } else if (result.dmg !== false) {
183
221
  // The DMG is the first-install convenience; the updater bundle + manifest (the
184
222
  // critical artifacts) are already built. Never fail the whole release over a