@mirinjs/cli 0.1.0-alpha.10 → 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 +5 -5
- package/src/artifacts.ts +12 -5
- package/src/build.ts +66 -4
- package/src/bundle-linux.ts +234 -0
- package/src/dev.ts +63 -7
- package/src/index.ts +46 -3
- package/src/package-linux.ts +334 -0
- package/src/release.ts +43 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirinjs/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
31
|
-
"mirinjs": "0.1.0-alpha.
|
|
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.
|
|
35
|
-
"@mirinjs/win32-x64": "0.1.0-alpha.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|