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