@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -5
- package/src/artifacts.ts +165 -26
- package/src/build.ts +236 -43
- package/src/bundle/linux/app.ts +275 -0
- package/src/bundle/linux/index.ts +1 -0
- package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
- package/src/bundle/macos/index.ts +1 -0
- package/src/bundle/shared/cef-locales.ts +91 -0
- package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
- package/src/bundle/windows/index.ts +1 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +214 -35
- package/src/dmg.ts +22 -10
- package/src/doctor.ts +227 -0
- package/src/extras.ts +52 -13
- package/src/icons/macos/appearance.ts +96 -0
- package/src/icons/macos/index.ts +1 -0
- package/src/icons/sources.ts +25 -0
- package/src/icons/windows/icon.ts +255 -0
- package/src/icons/windows/index.ts +1 -0
- package/src/index.ts +109 -7
- package/src/installer-inno.ts +141 -0
- package/src/installer-win.ts +17 -11
- package/src/package/linux/index.ts +2 -0
- package/src/package/linux/lib/defaults.ts +53 -0
- package/src/package/linux/lib/tools.ts +45 -0
- package/src/package/linux/lib/validation.ts +71 -0
- package/src/package/linux/package.ts +293 -0
- package/src/package/linux/types.ts +30 -0
- package/src/release/installers.ts +148 -0
- package/src/release/previous.ts +105 -0
- package/src/release.ts +166 -116
- package/src/shared/driver.ts +240 -0
- package/src/shared/inspector.ts +64 -0
- package/src/shared/report.ts +69 -0
- package/src/shared/scenario.ts +58 -0
- package/src/shared/session.ts +153 -0
- package/src/skill.ts +51 -0
- package/src/icon-win.ts +0 -81
package/src/build.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `mirin build` — package a standalone, signed .app (docs/macos-mvp.md).
|
|
3
3
|
*
|
|
4
|
-
* 1. vite build
|
|
5
|
-
* 2.
|
|
6
|
-
* 3.
|
|
7
|
-
* 4. bundle Worker (minified) → Resources/worker.js
|
|
8
|
-
* 5. assemble + sign the .app → dist/ui, manifest, dylib, helpers, CEF
|
|
4
|
+
* 1. vite build + native artifact resolution run in parallel.
|
|
5
|
+
* 2. compile host + bundle Workers run in parallel.
|
|
6
|
+
* 3. assemble + sign the app from the completed artifacts.
|
|
9
7
|
*
|
|
10
8
|
* The result runs with no env and no dev server: the host resolves everything
|
|
11
9
|
* from inside the bundle, and webviews load their `app://` URLs served from
|
|
@@ -15,16 +13,103 @@
|
|
|
15
13
|
* ID to produce a distributable, notarizable app.
|
|
16
14
|
*/
|
|
17
15
|
|
|
18
|
-
import {
|
|
19
|
-
import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
17
|
import { join } from "node:path";
|
|
21
|
-
import {
|
|
22
|
-
import { buildWindowsBundle } from "./bundle-win.ts";
|
|
18
|
+
import { $ } from "bun";
|
|
23
19
|
import { resolveArtifacts } from "./artifacts.ts";
|
|
20
|
+
import { buildLinuxBundle } from "./bundle/linux/index.ts";
|
|
21
|
+
import { buildAppBundle } from "./bundle/macos/index.ts";
|
|
22
|
+
import { normalizeCefLocales } from "./bundle/shared/cef-locales.ts";
|
|
23
|
+
import { buildWindowsBundle } from "./bundle/windows/index.ts";
|
|
24
|
+
import { compileWorkers, normalizeSidecars } from "./extras.ts";
|
|
25
|
+
import { resolveIconSource } from "./icons/sources.ts";
|
|
26
|
+
import { makeWindowsIcon } from "./icons/windows/index.ts";
|
|
27
|
+
import { buildLinuxPackages, resolveLinuxFormats } from "./package/linux/index.ts";
|
|
24
28
|
import { sweepBuildTemps } from "./temps.ts";
|
|
25
|
-
import { normalizeSidecars, compileWorkers } from "./extras.ts";
|
|
26
29
|
|
|
27
30
|
const IS_WINDOWS = process.platform === "win32";
|
|
31
|
+
const IS_LINUX = process.platform === "linux";
|
|
32
|
+
|
|
33
|
+
/** A PE FILEVERSION (`x.y.z.w`) from a semver string (pre-release suffix dropped). */
|
|
34
|
+
function winFileVersion(v: string): string {
|
|
35
|
+
const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
36
|
+
return m ? `${m[1]}.${m[2]}.${m[3]}.0` : "0.0.0.0";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Flip a PE exe from the CONSOLE subsystem (3) to GUI (2) so Windows allocates
|
|
41
|
+
* **no console window** when it's launched from a shortcut. Bun's
|
|
42
|
+
* `--windows-hide-console` only hides the console at runtime (a brief flash);
|
|
43
|
+
* GUI subsystem prevents it entirely — the equivalent of Electrobun's
|
|
44
|
+
* `exe.subsystem = .Windows`. The optional-header Subsystem field is at
|
|
45
|
+
* `e_lfanew + 24 (PE sig + COFF) + 68`.
|
|
46
|
+
*/
|
|
47
|
+
function patchPeToGuiSubsystem(exePath: string): void {
|
|
48
|
+
const buf = readFileSync(exePath);
|
|
49
|
+
const peOff = buf.readUInt32LE(0x3c);
|
|
50
|
+
if (buf.readUInt32LE(peOff) !== 0x0000_4550) return; // not "PE\0\0"
|
|
51
|
+
const subsystemOff = peOff + 92;
|
|
52
|
+
if (buf.readUInt16LE(subsystemOff) === 3 /* CONSOLE */) {
|
|
53
|
+
buf.writeUInt16LE(2 /* GUI */, subsystemOff);
|
|
54
|
+
writeFileSync(exePath, buf);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Stamp a Bun-compiled Windows exe with the app icon + version metadata via
|
|
60
|
+
* rcedit. Bun's `--windows-icon` is a silent no-op on Windows hosts, so without
|
|
61
|
+
* this the exe keeps Bun's icon — which the Start Menu / Desktop shortcut, the
|
|
62
|
+
* taskbar, and Explorer all show. Uses a single largest-size icon: rcedit's
|
|
63
|
+
* multi-size replacement silently fails on Bun's appended-payload exes, while a
|
|
64
|
+
* single 256 entry takes and Windows downscales it cleanly for smaller views.
|
|
65
|
+
* Best-effort — warns (doesn't fail the build) if rcedit isn't installed.
|
|
66
|
+
*/
|
|
67
|
+
async function brandWindowsExe(
|
|
68
|
+
exe: string,
|
|
69
|
+
opts: {
|
|
70
|
+
work: string;
|
|
71
|
+
appName: string;
|
|
72
|
+
version: string;
|
|
73
|
+
/** Absolute path to the icon source. */
|
|
74
|
+
icon?: string;
|
|
75
|
+
publisher: string;
|
|
76
|
+
},
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
if (!Bun.which("rcedit")) {
|
|
79
|
+
console.warn(
|
|
80
|
+
"[mirin build] rcedit not found — the Windows exe keeps Bun's default icon. " +
|
|
81
|
+
"Install it: `scoop install rcedit` (or `choco install rcedit`).",
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const args: string[] = [];
|
|
86
|
+
if (opts.icon) {
|
|
87
|
+
const ico = makeWindowsIcon(opts.icon, join(opts.work, "exe-icon.ico"), {
|
|
88
|
+
onlyLargest: true,
|
|
89
|
+
});
|
|
90
|
+
if (ico) args.push("--set-icon", ico);
|
|
91
|
+
}
|
|
92
|
+
const fv = winFileVersion(opts.version);
|
|
93
|
+
args.push(
|
|
94
|
+
"--set-version-string",
|
|
95
|
+
"ProductName",
|
|
96
|
+
opts.appName,
|
|
97
|
+
"--set-version-string",
|
|
98
|
+
"FileDescription",
|
|
99
|
+
opts.appName,
|
|
100
|
+
"--set-version-string",
|
|
101
|
+
"CompanyName",
|
|
102
|
+
opts.publisher,
|
|
103
|
+
"--set-file-version",
|
|
104
|
+
fv,
|
|
105
|
+
"--set-product-version",
|
|
106
|
+
fv,
|
|
107
|
+
);
|
|
108
|
+
const res = await $`rcedit ${exe} ${args}`.nothrow();
|
|
109
|
+
if (res.exitCode !== 0) {
|
|
110
|
+
console.warn(`[mirin build] rcedit failed (exit ${res.exitCode}) — exe branding skipped.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
28
113
|
|
|
29
114
|
export interface BuildResult {
|
|
30
115
|
/** Path to the assembled .app. */
|
|
@@ -37,20 +122,37 @@ export interface BuildResult {
|
|
|
37
122
|
channel: string;
|
|
38
123
|
/** Update baseUrl, if `release` is configured. */
|
|
39
124
|
baseUrl?: string;
|
|
125
|
+
/** Markdown release notes embedded in the updater manifest, if configured. */
|
|
126
|
+
releaseNotes?: string;
|
|
40
127
|
/** libmirin_core path (for the updater codec at release time). */
|
|
41
128
|
coreDylib: string;
|
|
129
|
+
/** Standalone updater codec executable used at release time. */
|
|
130
|
+
codecBin: string;
|
|
42
131
|
/** Project root (so `mirin release` can resolve relative asset paths). */
|
|
43
132
|
projectDir: string;
|
|
44
133
|
/** DMG config from mirin.config.ts (`true`/object/`false`); default `true`. */
|
|
45
134
|
dmg: boolean | import("mirinjs").DmgConfig;
|
|
46
135
|
/** NSIS installer config (Windows) — `true`/object/`false`; default `true`. */
|
|
47
136
|
nsis: boolean | import("mirinjs").NsisConfig;
|
|
137
|
+
/** Inno Setup installer config (Windows) — preferred over NSIS; default `true`. */
|
|
138
|
+
inno: boolean | import("mirinjs").InnoConfig;
|
|
139
|
+
/** Linux packaging config (`mirin.config.ts` `linux`) — `true`/object/`false`; default `true`. */
|
|
140
|
+
linux: boolean | import("mirinjs").LinuxConfig;
|
|
141
|
+
/** Publisher / company name (config.publisher ?? appName). */
|
|
142
|
+
publisher: string;
|
|
143
|
+
/** Absolute path to the app icon source (config.icon), if set — for Linux packaging. */
|
|
144
|
+
icon?: string;
|
|
48
145
|
/** Codesign identity used for the bundle, if any (MIRIN_SIGN_IDENTITY). */
|
|
49
146
|
signIdentity?: string;
|
|
50
147
|
}
|
|
51
148
|
|
|
52
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* The app version: `$MIRIN_APP_VERSION` (release-pipeline override) wins, otherwise
|
|
151
|
+
* the project's package.json `version` (the single source of truth).
|
|
152
|
+
*/
|
|
53
153
|
function appVersion(projectDir: string): string {
|
|
154
|
+
const override = process.env.MIRIN_APP_VERSION;
|
|
155
|
+
if (override) return override;
|
|
54
156
|
const pkgPath = join(projectDir, "package.json");
|
|
55
157
|
if (!existsSync(pkgPath)) return "0.0.0";
|
|
56
158
|
try {
|
|
@@ -60,7 +162,19 @@ function appVersion(projectDir: string): string {
|
|
|
60
162
|
}
|
|
61
163
|
}
|
|
62
164
|
|
|
63
|
-
export
|
|
165
|
+
export interface BuildOptions {
|
|
166
|
+
/** Override the app version (else `$MIRIN_APP_VERSION` / package.json). */
|
|
167
|
+
version?: string;
|
|
168
|
+
/** On Linux, also emit distributable packages (AppImage/deb/rpm) after assembling. */
|
|
169
|
+
packageLinux?: boolean;
|
|
170
|
+
/** Restrict Linux packaging to these formats (else config.linux.formats / all). */
|
|
171
|
+
linuxFormats?: import("mirinjs").LinuxPackageFormat[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function build(
|
|
175
|
+
projectDir = process.cwd(),
|
|
176
|
+
opts: BuildOptions = {},
|
|
177
|
+
): Promise<BuildResult> {
|
|
64
178
|
const outDir = join(projectDir, "build");
|
|
65
179
|
const work = join(projectDir, ".mirin");
|
|
66
180
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -71,35 +185,62 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
71
185
|
const appName: string = config.name ?? "Mirin App";
|
|
72
186
|
const bundleId: string = config.id ?? "dev.mirin.app";
|
|
73
187
|
const mainEntry = join(projectDir, config.main ?? "main/main.ts");
|
|
74
|
-
const version = appVersion(projectDir);
|
|
188
|
+
const version = opts.version ?? appVersion(projectDir);
|
|
75
189
|
const channel: string = config.release?.channel ?? "stable";
|
|
76
190
|
const baseUrl: string | undefined = config.release?.baseUrl;
|
|
191
|
+
const releaseNotes: string | undefined = config.release?.notes;
|
|
77
192
|
const dmg: boolean | import("mirinjs").DmgConfig = config.dmg ?? true;
|
|
78
193
|
const nsis: boolean | import("mirinjs").NsisConfig = config.nsis ?? true;
|
|
194
|
+
const inno: boolean | import("mirinjs").InnoConfig = config.inno ?? true;
|
|
195
|
+
const linux: boolean | import("mirinjs").LinuxConfig = config.linux ?? true;
|
|
196
|
+
const publisher: string = config.publisher ?? appName;
|
|
197
|
+
const cefLocales = normalizeCefLocales(config.cef?.locales);
|
|
198
|
+
// `.icon` documents are macOS-only, so the platform picks its own source.
|
|
199
|
+
const appIconPath = resolveIconSource(config.icon, projectDir);
|
|
79
200
|
|
|
80
201
|
console.log(`[mirin build] ${appName} ${version}`);
|
|
81
202
|
|
|
82
|
-
//
|
|
83
|
-
console.log("[mirin build]
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
203
|
+
// Production UI and native artifacts do not share outputs, so overlap them.
|
|
204
|
+
console.log("[mirin build] building UI + native artifacts…");
|
|
205
|
+
const [, artifacts] = await Promise.all([
|
|
206
|
+
$`bunx vite build`.cwd(projectDir),
|
|
207
|
+
resolveArtifacts({ release: true }),
|
|
208
|
+
]);
|
|
88
209
|
|
|
89
|
-
//
|
|
90
|
-
console.log("[mirin build] compiling host + bundling
|
|
210
|
+
// Host compilation, main Worker bundling, and extra Worker bundling are independent.
|
|
211
|
+
console.log("[mirin build] compiling host + bundling workers…");
|
|
91
212
|
const signIdentity = process.env.MIRIN_SIGN_IDENTITY;
|
|
92
213
|
const hostExe = join(work, IS_WINDOWS ? "host-release.exe" : "host-release");
|
|
93
214
|
const workerJs = join(work, "worker.release.js");
|
|
94
|
-
await $`bun build --compile --minify ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(projectDir);
|
|
95
|
-
await $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(projectDir);
|
|
96
|
-
|
|
97
|
-
// Extra assets: resolve sidecar binaries + compile any extra worker entries.
|
|
98
215
|
const sidecars = normalizeSidecars(projectDir, config.sidecars);
|
|
99
|
-
const
|
|
216
|
+
const hostBuild = (async (): Promise<void> => {
|
|
217
|
+
// Bun's Windows arm64 build currently has no bun:ffi/TinyCC runtime. The
|
|
218
|
+
// win32-arm64 Mirin package therefore carries an x64 compatibility payload,
|
|
219
|
+
// which Windows 11 ARM executes through its built-in x64 emulation layer.
|
|
220
|
+
const target = IS_WINDOWS && process.arch === "arm64" ? ["--target=bun-windows-x64"] : [];
|
|
221
|
+
await $`bun build --compile --minify ${target} ${artifacts.hostEntry} --outfile ${hostExe}`.cwd(
|
|
222
|
+
projectDir,
|
|
223
|
+
);
|
|
224
|
+
if (IS_WINDOWS) {
|
|
225
|
+
patchPeToGuiSubsystem(hostExe);
|
|
226
|
+
await brandWindowsExe(hostExe, {
|
|
227
|
+
work,
|
|
228
|
+
appName,
|
|
229
|
+
version,
|
|
230
|
+
icon: appIconPath,
|
|
231
|
+
publisher,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
})();
|
|
235
|
+
const workerBuild = $`bun build ${mainEntry} --target=bun --minify --outfile ${workerJs}`.cwd(
|
|
236
|
+
projectDir,
|
|
237
|
+
);
|
|
238
|
+
const extraWorkersBuild = compileWorkers(projectDir, config.workers, join(work, "workers"), true);
|
|
239
|
+
const [, , extraWorkers] = await Promise.all([hostBuild, workerBuild, extraWorkersBuild]);
|
|
100
240
|
|
|
101
241
|
// 5. assemble (+ sign on macOS)
|
|
102
|
-
|
|
242
|
+
const artifactKind = IS_WINDOWS ? "app folder" : IS_LINUX ? "app folder" : ".app";
|
|
243
|
+
console.log(`[mirin build] assembling ${artifactKind}…`);
|
|
103
244
|
// version.json embeds the running app's update identity (read by app.updater).
|
|
104
245
|
// Only when `release` is configured — otherwise the app has no updater.
|
|
105
246
|
const versionJson = baseUrl
|
|
@@ -108,7 +249,13 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
108
249
|
const resources = {
|
|
109
250
|
uiDir: join(projectDir, "dist"),
|
|
110
251
|
workerJs,
|
|
111
|
-
manifestJson: JSON.stringify({
|
|
252
|
+
manifestJson: JSON.stringify({
|
|
253
|
+
id: bundleId,
|
|
254
|
+
windows: config.windows,
|
|
255
|
+
singleInstance: config.singleInstance,
|
|
256
|
+
// Lets a diagnostic build opt in via `devtools.production`; otherwise fully off.
|
|
257
|
+
devtools: config.devtools,
|
|
258
|
+
}),
|
|
112
259
|
versionJson,
|
|
113
260
|
workers: extraWorkers,
|
|
114
261
|
};
|
|
@@ -120,25 +267,65 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
120
267
|
coreDll: artifacts.coreDylib,
|
|
121
268
|
helperExe: artifacts.helperBin,
|
|
122
269
|
cefPath: artifacts.cefPath,
|
|
123
|
-
|
|
270
|
+
cefLocales,
|
|
271
|
+
icon: appIconPath,
|
|
124
272
|
resources: { ...resources, sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })) },
|
|
125
273
|
})
|
|
126
|
-
:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
274
|
+
: IS_LINUX
|
|
275
|
+
? await buildLinuxBundle({
|
|
276
|
+
appName,
|
|
277
|
+
outDir,
|
|
278
|
+
hostExe,
|
|
279
|
+
coreDll: artifacts.coreDylib,
|
|
280
|
+
helperExe: artifacts.helperBin,
|
|
281
|
+
cefPath: artifacts.cefPath,
|
|
282
|
+
cefLocales,
|
|
283
|
+
icon: appIconPath,
|
|
284
|
+
resources: {
|
|
285
|
+
...resources,
|
|
286
|
+
sidecars: sidecars.map((s) => ({ name: s.name, src: s.src })),
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
: await buildAppBundle({
|
|
290
|
+
appName,
|
|
291
|
+
bundleId,
|
|
292
|
+
outDir,
|
|
293
|
+
hostExe,
|
|
294
|
+
coreDylib: artifacts.coreDylib,
|
|
295
|
+
helperBin: artifacts.helperBin,
|
|
296
|
+
cefPath: artifacts.cefPath,
|
|
297
|
+
cefLocales,
|
|
298
|
+
version,
|
|
299
|
+
icon: appIconPath,
|
|
300
|
+
signIdentity,
|
|
301
|
+
urlSchemes: config.urlSchemes,
|
|
302
|
+
resources: { ...resources, sidecars },
|
|
303
|
+
});
|
|
140
304
|
|
|
141
305
|
console.log(`\n[mirin build] done → ${app}`);
|
|
306
|
+
|
|
307
|
+
// Linux: optionally emit distributable packages (AppImage/deb/rpm) straight from
|
|
308
|
+
// `mirin build`. `mirin release` does its own packaging into build/release, so it
|
|
309
|
+
// never sets `packageLinux` — this path only runs for a direct build request.
|
|
310
|
+
if (IS_LINUX && opts.packageLinux && linux !== false) {
|
|
311
|
+
const formats = resolveLinuxFormats(linux, opts.linuxFormats);
|
|
312
|
+
console.log(`[mirin build] packaging Linux artifacts (${formats.join(", ")})…`);
|
|
313
|
+
const pkgs = await buildLinuxPackages({
|
|
314
|
+
appDir: app,
|
|
315
|
+
appName,
|
|
316
|
+
bundleId,
|
|
317
|
+
version,
|
|
318
|
+
publisher,
|
|
319
|
+
outDir,
|
|
320
|
+
projectDir,
|
|
321
|
+
icon: appIconPath,
|
|
322
|
+
options: typeof linux === "object" ? linux : {},
|
|
323
|
+
formats,
|
|
324
|
+
});
|
|
325
|
+
const mb = (n: number) => (n / 1e6).toFixed(1);
|
|
326
|
+
for (const p of pkgs) console.log(` ${p.path} (${mb(p.size)} MB)`);
|
|
327
|
+
}
|
|
328
|
+
|
|
142
329
|
return {
|
|
143
330
|
app,
|
|
144
331
|
appName,
|
|
@@ -146,10 +333,16 @@ export async function build(projectDir = process.cwd()): Promise<BuildResult> {
|
|
|
146
333
|
version,
|
|
147
334
|
channel,
|
|
148
335
|
baseUrl,
|
|
336
|
+
releaseNotes,
|
|
149
337
|
coreDylib: artifacts.coreDylib,
|
|
338
|
+
codecBin: artifacts.codecBin,
|
|
150
339
|
projectDir,
|
|
151
340
|
dmg,
|
|
152
341
|
nsis,
|
|
342
|
+
inno,
|
|
343
|
+
linux,
|
|
344
|
+
publisher,
|
|
345
|
+
icon: appIconPath,
|
|
153
346
|
signIdentity,
|
|
154
347
|
};
|
|
155
348
|
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assemble a Linux app folder for a mirin app. Like Windows, Linux has no `.app`
|
|
3
|
+
* bundle — just a flat directory the dynamic loader can resolve libcef.so + the
|
|
4
|
+
* CEF runtime from, next to the host binary:
|
|
5
|
+
*
|
|
6
|
+
* <App>/
|
|
7
|
+
* <App> compiled Bun host (process.execPath)
|
|
8
|
+
* libmirin_core.so loaded by the host via bun:ffi
|
|
9
|
+
* mirin-helper CEF subprocess (browser_subprocess_path)
|
|
10
|
+
* libcef.so, libEGL.so, libGLESv2.so, *.pak, icudtl.dat,
|
|
11
|
+
* v8_context_snapshot.bin, locales/, chrome-sandbox, … (CEF runtime)
|
|
12
|
+
* resources/ (production only — dev serves from the Vite URL)
|
|
13
|
+
* ui/, worker.js, mirin.manifest.json, version.json, sidecars/, workers/
|
|
14
|
+
*
|
|
15
|
+
* Wayland-only (docs/linux-port.md); the CEF subprocess name (`mirin-helper`) and
|
|
16
|
+
* the `resources/` layout match what mirin-core derives at runtime
|
|
17
|
+
* (engine::derive_subprocess_path, host.ts). libcef.so is resolved via
|
|
18
|
+
* LD_LIBRARY_PATH set on the host (dev.ts) or an rpath at build time.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
chmodSync,
|
|
23
|
+
cpSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
statSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { basename, extname, join } from "node:path";
|
|
32
|
+
import { copyFlatCefLocales } from "../shared/cef-locales.ts";
|
|
33
|
+
|
|
34
|
+
/** Source dir (vendor/cef on Linux) must contain libcef.so. */
|
|
35
|
+
const CEF_MARKER = "libcef.so";
|
|
36
|
+
|
|
37
|
+
/** CEF runtime files copied next to the host binary. Everything else in the Linux
|
|
38
|
+
* distribution (include/, cmake/, CMakeLists.txt, archive.json, libcef_dll/) is
|
|
39
|
+
* build-time only and intentionally left out. `.so` covers libcef/libEGL/
|
|
40
|
+
* libGLESv2/libvk_swiftshader; the extras list catches the non-extension names. */
|
|
41
|
+
const CEF_RUNTIME_EXTS = new Set([".so", ".bin", ".pak", ".dat"]);
|
|
42
|
+
const CEF_RUNTIME_EXTRAS = ["vk_swiftshader_icd.json", "libvulkan.so.1", "chrome-sandbox"];
|
|
43
|
+
|
|
44
|
+
export interface LinuxBundleOptions {
|
|
45
|
+
appName: string; // also the host binary stem → <appName>
|
|
46
|
+
outDir: string;
|
|
47
|
+
hostExe: string; // compiled Bun host (no extension)
|
|
48
|
+
coreDll: string; // libmirin_core.so
|
|
49
|
+
helperExe: string; // mirin-helper
|
|
50
|
+
cefPath: string; // dir containing the Linux CEF runtime (vendor/cef)
|
|
51
|
+
/** Production CEF locale allowlist. Undefined keeps every locale. */
|
|
52
|
+
cefLocales?: string[];
|
|
53
|
+
/** App icon source (.png / .iconset); resolved to a PNG at resources/icon.png for
|
|
54
|
+
* the window's `_NET_WM_ICON` (taskbar/dock). */
|
|
55
|
+
icon?: string;
|
|
56
|
+
/** Production-only resources placed under <App>/resources. */
|
|
57
|
+
resources?: {
|
|
58
|
+
uiDir?: string; // Vite dist/ → resources/ui (served via app://ui)
|
|
59
|
+
workerJs?: string; // bundled main-process Worker → resources/worker.js
|
|
60
|
+
manifestJson?: string; // serialized manifest → resources/mirin.manifest.json
|
|
61
|
+
versionJson?: string; // serialized version.json → resources/version.json (updater)
|
|
62
|
+
sidecars?: { name: string; src: string }[]; // → resources/sidecars/<name>
|
|
63
|
+
workers?: Record<string, string>; // name → abs path → resources/workers/<name>.js
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the app config's `icon` to a concrete square PNG usable on Linux (for
|
|
69
|
+
* the window's `_NET_WM_ICON`). Accepts a `.png` (used as-is) or a `.iconset`
|
|
70
|
+
* directory (pick a good mid/large size). Returns undefined for `.icns` (which the
|
|
71
|
+
* Linux core can't decode) or a missing/empty source.
|
|
72
|
+
*
|
|
73
|
+
* @param icon absolute path to the icon source (`config.icon` joined with the project root).
|
|
74
|
+
*/
|
|
75
|
+
export function resolveLinuxIconPng(icon: string): string | undefined {
|
|
76
|
+
if (!existsSync(icon)) return undefined;
|
|
77
|
+
if (statSync(icon).isDirectory()) {
|
|
78
|
+
const pngs = readdirSync(icon).filter((f) => extname(f).toLowerCase() === ".png");
|
|
79
|
+
if (!pngs.length) return undefined;
|
|
80
|
+
// Prefer 128×128: crisp in the dock, and small enough that its `_NET_WM_ICON`
|
|
81
|
+
// (a 128²+2 CARDINAL array ≈ 64 KB) stays under X11's ~256 KB max request size —
|
|
82
|
+
// a 256×256 icon (≈ 256 KB) can exceed it and get dropped. Fall back to the
|
|
83
|
+
// largest available under that ceiling (by pixels parsed from the iconset name).
|
|
84
|
+
const preferred = ["icon_128x128.png", "icon_64x64.png", "icon_256x256.png"];
|
|
85
|
+
const MaxSide = 128;
|
|
86
|
+
const pick =
|
|
87
|
+
preferred.find((p) => pngs.includes(p)) ??
|
|
88
|
+
pngs
|
|
89
|
+
.slice()
|
|
90
|
+
.sort((a, b) => iconsetPixels(b) - iconsetPixels(a))
|
|
91
|
+
.find((p) => iconsetPixels(p) <= MaxSide * MaxSide) ??
|
|
92
|
+
pngs.sort((a, b) => iconsetPixels(a) - iconsetPixels(b))[0];
|
|
93
|
+
return pick ? join(icon, pick) : undefined;
|
|
94
|
+
}
|
|
95
|
+
return extname(icon).toLowerCase() === ".png" ? icon : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the app config's `icon` to the *largest* concrete PNG (for a `.desktop`
|
|
100
|
+
* `Icon=`, which is a file path with no X11 request-size limit — so we want it
|
|
101
|
+
* crisp, unlike the window `_NET_WM_ICON`). `.png` used as-is; `.iconset` → biggest
|
|
102
|
+
* PNG inside. Undefined for `.icns` or a missing source.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveLinuxDesktopIconPng(icon: string): string | undefined {
|
|
105
|
+
if (!existsSync(icon)) return undefined;
|
|
106
|
+
if (statSync(icon).isDirectory()) {
|
|
107
|
+
const pngs = readdirSync(icon).filter((f) => extname(f).toLowerCase() === ".png");
|
|
108
|
+
if (!pngs.length) return undefined;
|
|
109
|
+
const biggest = pngs.sort((a, b) => iconsetPixels(b) - iconsetPixels(a))[0];
|
|
110
|
+
return biggest ? join(icon, biggest) : undefined;
|
|
111
|
+
}
|
|
112
|
+
return extname(icon).toLowerCase() === ".png" ? icon : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A freedesktop `.desktop` entry body for a mirin app. cosmic's dock resolves an
|
|
117
|
+
* X11 window's icon by matching its `WM_CLASS` (which the core sets to the app id)
|
|
118
|
+
* against a desktop entry's `StartupWMClass`, then uses that entry's `Icon` — the
|
|
119
|
+
* reliable path on cosmic (its `_NET_WM_ICON` fallback is unreliable). `wmClass`
|
|
120
|
+
* MUST equal the core's `res_class` (the bundle id).
|
|
121
|
+
*/
|
|
122
|
+
export function desktopEntry(opts: {
|
|
123
|
+
name: string;
|
|
124
|
+
exec: string;
|
|
125
|
+
iconPng: string;
|
|
126
|
+
wmClass: string;
|
|
127
|
+
/** Freedesktop main category (e.g. "Utility", "Development"). A trailing `;` is
|
|
128
|
+
* added if missing (the spec requires list values to end with one). Omitted → no
|
|
129
|
+
* Categories line. Used by the .deb/.rpm/AppImage entries (dev needs none). */
|
|
130
|
+
categories?: string;
|
|
131
|
+
/** Optional Comment= (tooltip / description) line. */
|
|
132
|
+
comment?: string;
|
|
133
|
+
}): string {
|
|
134
|
+
const lines = ["[Desktop Entry]", "Type=Application", `Name=${desktopValue("Name", opts.name)}`];
|
|
135
|
+
if (opts.comment) lines.push(`Comment=${desktopValue("Comment", opts.comment)}`);
|
|
136
|
+
lines.push(
|
|
137
|
+
`Exec=${desktopExec(opts.exec)}`,
|
|
138
|
+
`Icon=${desktopValue("Icon", opts.iconPng)}`,
|
|
139
|
+
`StartupWMClass=${desktopValue("StartupWMClass", opts.wmClass)}`,
|
|
140
|
+
"Terminal=false",
|
|
141
|
+
);
|
|
142
|
+
if (opts.categories) {
|
|
143
|
+
lines.push(`Categories=${desktopCategories(opts.categories)}`);
|
|
144
|
+
}
|
|
145
|
+
lines.push("");
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function desktopValue(field: string, value: string): string {
|
|
150
|
+
assertDesktopScalar(field, value);
|
|
151
|
+
return value.replace(/\\/g, "\\\\").replace(/\t/g, "\\t");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function desktopExec(value: string): string {
|
|
155
|
+
assertDesktopScalar("Exec", value);
|
|
156
|
+
if (/^[A-Za-z0-9._/+:-]+$/.test(value)) return value;
|
|
157
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function desktopCategories(value: string): string {
|
|
161
|
+
const normalized = value.endsWith(";") ? value : `${value};`;
|
|
162
|
+
assertDesktopScalar("Categories", normalized);
|
|
163
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*(;[A-Za-z0-9][A-Za-z0-9-]*)*;$/.test(normalized)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`[mirin] invalid Linux desktop category "${value}" — use freedesktop category names separated by semicolons.`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertDesktopScalar(field: string, value: string): void {
|
|
172
|
+
if (value.length === 0 || /[\0\r\n]/.test(value)) {
|
|
173
|
+
throw new Error(`[mirin] invalid Linux desktop ${field} value.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Effective pixel count encoded in an iconset filename (`icon_256x256@2x.png` → 512²). */
|
|
178
|
+
function iconsetPixels(name: string): number {
|
|
179
|
+
const m = basename(name).match(/(\d+)x(\d+)(@(\d+)x)?/);
|
|
180
|
+
if (!m) return 0;
|
|
181
|
+
const scale = m[4] ? Number(m[4]) : 1;
|
|
182
|
+
return Number(m[1]) * Number(m[2]) * scale * scale;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Build the Linux app folder and return its path + the host binary to launch. */
|
|
186
|
+
export async function buildLinuxBundle(
|
|
187
|
+
opts: LinuxBundleOptions,
|
|
188
|
+
): Promise<{ app: string; exe: string }> {
|
|
189
|
+
const { appName, cefPath } = opts;
|
|
190
|
+
if (!existsSync(join(cefPath, CEF_MARKER))) {
|
|
191
|
+
throw new Error(`CEF runtime not found at ${cefPath} — run: bun scripts/fetch-cef.ts`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const app = join(opts.outDir, appName);
|
|
195
|
+
rmSync(app, { recursive: true, force: true });
|
|
196
|
+
mkdirSync(app, { recursive: true });
|
|
197
|
+
|
|
198
|
+
const exe = join(app, appName);
|
|
199
|
+
cpSync(opts.hostExe, exe);
|
|
200
|
+
chmodSync(exe, 0o755);
|
|
201
|
+
cpSync(opts.coreDll, join(app, "libmirin_core.so"));
|
|
202
|
+
const helper = join(app, "mirin-helper");
|
|
203
|
+
cpSync(opts.helperExe, helper);
|
|
204
|
+
chmodSync(helper, 0o755);
|
|
205
|
+
|
|
206
|
+
copyCefRuntime(cefPath, app, opts.cefLocales);
|
|
207
|
+
|
|
208
|
+
// The window taskbar/dock icon (`_NET_WM_ICON`) is set by the core from a PNG at
|
|
209
|
+
// `resources/icon.png`; host.ts points the init config's `icon_path` there in prod.
|
|
210
|
+
// (Dev serves it directly from the project via MIRIN_CONFIG_JSON, not the bundle.)
|
|
211
|
+
const iconPng = opts.icon ? resolveLinuxIconPng(opts.icon) : undefined;
|
|
212
|
+
if (iconPng) {
|
|
213
|
+
const res = join(app, "resources");
|
|
214
|
+
mkdirSync(res, { recursive: true });
|
|
215
|
+
cpSync(iconPng, join(res, "icon.png"));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Production resources (dev passes none — paths come from env + the Vite URL).
|
|
219
|
+
if (opts.resources) {
|
|
220
|
+
const res = join(app, "resources");
|
|
221
|
+
mkdirSync(res, { recursive: true });
|
|
222
|
+
if (opts.resources.uiDir) cpSync(opts.resources.uiDir, join(res, "ui"), { recursive: true });
|
|
223
|
+
if (opts.resources.workerJs) cpSync(opts.resources.workerJs, join(res, "worker.js"));
|
|
224
|
+
if (opts.resources.manifestJson != null) {
|
|
225
|
+
writeFileSync(join(res, "mirin.manifest.json"), opts.resources.manifestJson);
|
|
226
|
+
}
|
|
227
|
+
if (opts.resources.versionJson != null) {
|
|
228
|
+
writeFileSync(join(res, "version.json"), opts.resources.versionJson);
|
|
229
|
+
}
|
|
230
|
+
if (opts.resources.workers && Object.keys(opts.resources.workers).length) {
|
|
231
|
+
const workersDir = join(res, "workers");
|
|
232
|
+
mkdirSync(workersDir, { recursive: true });
|
|
233
|
+
for (const [name, src] of Object.entries(opts.resources.workers)) {
|
|
234
|
+
cpSync(src, join(workersDir, `${name}.js`));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (opts.resources.sidecars?.length) {
|
|
238
|
+
const sidecarsDir = join(res, "sidecars");
|
|
239
|
+
mkdirSync(sidecarsDir, { recursive: true });
|
|
240
|
+
for (const sc of opts.resources.sidecars) {
|
|
241
|
+
if (!existsSync(sc.src)) throw new Error(`sidecar "${sc.name}" not found: ${sc.src}`);
|
|
242
|
+
const dst = join(sidecarsDir, sc.name);
|
|
243
|
+
cpSync(sc.src, dst);
|
|
244
|
+
chmodSync(dst, 0o755);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { app, exe };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Copy the CEF runtime (shared libs, paks, snapshot, icu data, locales) into `dest`. */
|
|
253
|
+
function copyCefRuntime(cefPath: string, dest: string, locales: string[] | undefined): void {
|
|
254
|
+
for (const entry of readdirSync(cefPath)) {
|
|
255
|
+
if (CEF_RUNTIME_EXTS.has(extname(entry).toLowerCase())) {
|
|
256
|
+
cpSync(join(cefPath, entry), join(dest, entry));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
for (const extra of CEF_RUNTIME_EXTRAS) {
|
|
260
|
+
const src = join(cefPath, extra);
|
|
261
|
+
if (existsSync(src)) cpSync(src, join(dest, extra), { recursive: true });
|
|
262
|
+
}
|
|
263
|
+
copyFlatCefLocales(join(cefPath, "locales"), join(dest, "locales"), locales);
|
|
264
|
+
// chrome-sandbox works setuid-root (mode 4755) or, on kernels with unprivileged
|
|
265
|
+
// user namespaces, via the namespace sandbox with no special perms. We can't
|
|
266
|
+
// setuid without root here; leave it executable and let CEF pick the userns path.
|
|
267
|
+
const sandbox = join(dest, "chrome-sandbox");
|
|
268
|
+
if (existsSync(sandbox)) {
|
|
269
|
+
try {
|
|
270
|
+
require("node:fs").chmodSync(sandbox, 0o755);
|
|
271
|
+
} catch {
|
|
272
|
+
/* best-effort */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./app.ts";
|