pakstr 0.29.1 → 0.31.0

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.
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LINUX_ELECTRON_EXECUTABLES = exports.APPIMAGE_RUNTIME = exports.LINUX_PACKAGE_FORMATS = exports.LINUX_X64_TARGET = void 0;
7
+ exports.linuxExecutableName = linuxExecutableName;
8
+ exports.assertLinuxBuildHost = assertLinuxBuildHost;
9
+ exports.linuxOutputPaths = linuxOutputPaths;
10
+ exports.linuxPackagerOptions = linuxPackagerOptions;
11
+ exports.packageLinuxApp = packageLinuxApp;
12
+ exports.assertSquashfsTools = assertSquashfsTools;
13
+ exports.pakstrCacheDir = pakstrCacheDir;
14
+ exports.fetchAppImageRuntime = fetchAppImageRuntime;
15
+ exports.linuxAppFiles = linuxAppFiles;
16
+ exports.normalizeLinuxModes = normalizeLinuxModes;
17
+ exports.expectedLinuxMode = expectedLinuxMode;
18
+ exports.linuxDesktopEntry = linuxDesktopEntry;
19
+ exports.verifyLinuxApp = verifyLinuxApp;
20
+ exports.assertElfX64 = assertElfX64;
21
+ exports.appImageOffset = appImageOffset;
22
+ exports.validateLinuxZip = validateLinuxZip;
23
+ exports.parseSquashfsListing = parseSquashfsListing;
24
+ exports.assertSquashfsListing = assertSquashfsListing;
25
+ exports.validateAppImage = validateAppImage;
26
+ const child_process_1 = require("child_process");
27
+ const fs_1 = __importDefault(require("fs"));
28
+ const os_1 = __importDefault(require("os"));
29
+ const path_1 = __importDefault(require("path"));
30
+ const promises_1 = require("stream/promises");
31
+ const sharp_1 = __importDefault(require("sharp"));
32
+ const unzipper_1 = __importDefault(require("unzipper"));
33
+ const iconResolver_1 = require("../core/iconResolver");
34
+ const process_1 = require("../runners/process");
35
+ const artifacts_1 = require("./common/artifacts");
36
+ const asar_1 = require("./common/asar");
37
+ const electron_1 = require("./common/electron");
38
+ const files_1 = require("./common/files");
39
+ const fuses_1 = require("./common/fuses");
40
+ const packager_1 = require("./common/packager");
41
+ const stage_1 = require("./stage");
42
+ const zip_1 = require("./zip");
43
+ exports.LINUX_X64_TARGET = "linux-x64";
44
+ /** Package formats a linux-x64 build writes. DEB and RPM would be added here. */
45
+ exports.LINUX_PACKAGE_FORMATS = ["appimage", "zip"];
46
+ /**
47
+ * The AppImage type 2 runtime: a static x86-64 ELF that mounts the appended
48
+ * squashfs with FUSE 3 and runs its AppRun. Pinned to a tagged release
49
+ * (GPG-signed upstream) by SHA-256.
50
+ */
51
+ exports.APPIMAGE_RUNTIME = {
52
+ release: "20251108",
53
+ url: "https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64",
54
+ sha256: "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d",
55
+ };
56
+ /**
57
+ * Files of the pinned Electron that ship executable (0755); every other file
58
+ * is 0644. The app executable replaces `electron`. chrome-sandbox is never
59
+ * setuid: AppImage mounts and unpacked ZIPs cannot carry a root-owned setuid
60
+ * helper, so Chromium's sandbox relies on unprivileged user namespaces.
61
+ */
62
+ exports.LINUX_ELECTRON_EXECUTABLES = ["chrome-sandbox", "chrome_crashpad_handler", "libffmpeg.so", "libvk_swiftshader.so", "libvulkan.so.1"];
63
+ const SQUASHFS_OPTIONS = {
64
+ timeoutMs: 20 * 60_000,
65
+ timeoutHint: "mksquashfs or unsquashfs stopped responding; try again.",
66
+ };
67
+ // -quiet, -exit-on-error, zstd, and unsquashfs -offset are all in 4.5.
68
+ const MIN_SQUASHFS_TOOLS = [4, 5];
69
+ // 1980-01-01, the fixed time ZIP entries use: squashfs inode times are clamped to it.
70
+ const SOURCE_DATE_EPOCH = "315532800";
71
+ const ICON_SIZE = 256;
72
+ const DEFAULT_ICON_PATH = path_1.default.resolve(__dirname, "../../assets/logo.png");
73
+ /** The executable, desktop entry, and icon are named after the appId: unique, stable, and shell-safe. */
74
+ function linuxExecutableName(app) {
75
+ return app.appId;
76
+ }
77
+ /** Symlinks and Unix modes are written on the build host, so Windows hosts are refused. */
78
+ function assertLinuxBuildHost(platform = process.platform) {
79
+ if (platform === "win32") {
80
+ throw new Error("Linux desktop packages must be built on macOS or Linux (they need Unix file modes and symlinks)");
81
+ }
82
+ }
83
+ /** AppImage and portable ZIP next to build.out, named like the APK. */
84
+ function linuxOutputPaths(buildOut, appId) {
85
+ const dir = (0, artifacts_1.desktopOutputDir)(buildOut);
86
+ return {
87
+ appimage: (0, artifacts_1.desktopArtifactPath)(dir, appId, exports.LINUX_X64_TARGET, ".AppImage"),
88
+ zip: (0, artifacts_1.desktopArtifactPath)(dir, appId, exports.LINUX_X64_TARGET, ".zip"),
89
+ };
90
+ }
91
+ /** @electron/packager options: the shared base (with the allowlist `ignore`) plus the appId-named executable. */
92
+ function linuxPackagerOptions(options) {
93
+ const { stagingDir, outDir, electronZipDir, app } = options;
94
+ return {
95
+ ...(0, packager_1.basePackagerOptions)({ stagingDir, outDir, electronZipDir }),
96
+ platform: "linux",
97
+ arch: "x64",
98
+ name: app.appName,
99
+ executableName: linuxExecutableName(app),
100
+ appVersion: app.versionName,
101
+ };
102
+ }
103
+ /**
104
+ * Package the staged app as `<appId>` plus Electron's files, flip fuses,
105
+ * normalize modes, and validate it. Then write the portable ZIP and the
106
+ * AppImage, each validated from a fresh extraction. Returns paths in `workDir`.
107
+ */
108
+ async function packageLinuxApp(options) {
109
+ const { stagingDir, workDir, app, configDir } = options;
110
+ assertLinuxBuildHost();
111
+ (0, stage_1.assertDesktopAppName)(app.appName);
112
+ for (const tool of ["mksquashfs", "unsquashfs"]) {
113
+ // unsquashfs -version exits with status 1, so only the output is checked.
114
+ const probe = (0, child_process_1.spawnSync)(tool, ["-version"], { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] });
115
+ if (probe.error) {
116
+ throw new Error(`AppImages need squashfs-tools >= ${MIN_SQUASHFS_TOOLS.join(".")} (${tool}) on PATH: ` +
117
+ `brew install squashfs, or apt/dnf install squashfs-tools. ${probe.error.message}`);
118
+ }
119
+ assertSquashfsTools(probe.stdout, tool);
120
+ }
121
+ const runtime = await fetchAppImageRuntime();
122
+ const electronZip = await (0, electron_1.fetchElectronZip)(electron_1.LINUX_ELECTRON_ZIP);
123
+ const expectedFiles = linuxAppFiles((await unzipper_1.default.Open.file(electronZip)).files.map((file) => file.path), app);
124
+ const { appDir, productPath: exePath } = await (0, packager_1.runPackager)(linuxPackagerOptions({ stagingDir, outDir: path_1.default.join(workDir, "out"), electronZipDir: path_1.default.dirname(electronZip), app }), { name: linuxExecutableName(app), type: "file" });
125
+ await (0, fuses_1.flipDesktopFuses)(exePath);
126
+ normalizeLinuxModes(appDir);
127
+ await verifyLinuxApp(appDir, app, expectedFiles);
128
+ const zip = (0, artifacts_1.desktopArtifactPath)(workDir, app.appId, exports.LINUX_X64_TARGET, ".zip");
129
+ await (0, zip_1.zipDirectory)(appDir, zip, app.appName, { unixModes: true });
130
+ await validateLinuxZip(zip, app, expectedFiles, path_1.default.join(workDir, "verify-zip"));
131
+ // The validated folder becomes the AppDir root, with AppRun, the desktop entry, and the icon beside it.
132
+ const appDirRoot = path_1.default.join(workDir, "AppDir");
133
+ fs_1.default.renameSync(appDir, appDirRoot);
134
+ await writeAppDirMetadata(appDirRoot, app, configDir);
135
+ const squashfs = path_1.default.join(workDir, "app.squashfs");
136
+ (0, process_1.runProcess)("mksquashfs", [appDirRoot, squashfs, "-root-owned", "-noappend", "-no-xattrs", "-comp", "zstd", "-quiet", "-no-progress", "-exit-on-error"], { ...SQUASHFS_OPTIONS, env: { ...process.env, SOURCE_DATE_EPOCH } });
137
+ const appImage = (0, artifacts_1.desktopArtifactPath)(workDir, app.appId, exports.LINUX_X64_TARGET, ".AppImage");
138
+ fs_1.default.writeFileSync(appImage, runtime, { flag: "wx", mode: 0o755 });
139
+ await (0, promises_1.pipeline)(fs_1.default.createReadStream(squashfs), fs_1.default.createWriteStream(appImage, { flags: "a" }));
140
+ fs_1.default.chmodSync(appImage, 0o755);
141
+ await validateAppImage(appImage, app, expectedFiles, path_1.default.join(workDir, "verify-appimage"));
142
+ return { appimage: appImage, zip };
143
+ }
144
+ /** `mksquashfs -version` / `unsquashfs -version` print e.g. "mksquashfs version 4.6.1 (2023/03/25)". */
145
+ function assertSquashfsTools(versionOutput, tool) {
146
+ const match = new RegExp(`^${tool} version (\\d+)\\.(\\d+)`).exec(versionOutput.trim());
147
+ const [major, minor] = match ? [Number(match[1]), Number(match[2])] : [0, 0];
148
+ const [minMajor, minMinor] = MIN_SQUASHFS_TOOLS;
149
+ if (major < minMajor || (major === minMajor && minor < minMinor)) {
150
+ throw new Error(`AppImages need squashfs-tools >= ${MIN_SQUASHFS_TOOLS.join(".")}, found ${JSON.stringify(versionOutput.trim().split("\n")[0])}`);
151
+ }
152
+ }
153
+ /** Per-user cache: $XDG_CACHE_HOME/pakstr, ~/Library/Caches/pakstr, or ~/.cache/pakstr. */
154
+ function pakstrCacheDir(env = process.env, platform = process.platform) {
155
+ if (env.XDG_CACHE_HOME && path_1.default.isAbsolute(env.XDG_CACHE_HOME))
156
+ return path_1.default.join(env.XDG_CACHE_HOME, "pakstr");
157
+ return platform === "darwin" ? path_1.default.join(os_1.default.homedir(), "Library", "Caches", "pakstr") : path_1.default.join(os_1.default.homedir(), ".cache", "pakstr");
158
+ }
159
+ /** Download (or reuse the cached) pinned AppImage runtime and verify its SHA-256. */
160
+ async function fetchAppImageRuntime(cacheDir = pakstrCacheDir()) {
161
+ const file = path_1.default.join(cacheDir, "appimage-runtime", exports.APPIMAGE_RUNTIME.release, "runtime-x86_64");
162
+ const cached = fs_1.default.lstatSync(file, { throwIfNoEntry: false })?.isFile() ? fs_1.default.readFileSync(file) : null;
163
+ if (cached && (0, files_1.sha256)(cached) === exports.APPIMAGE_RUNTIME.sha256)
164
+ return cached;
165
+ const response = await fetch(exports.APPIMAGE_RUNTIME.url, { signal: AbortSignal.timeout(120_000) });
166
+ if (!response.ok)
167
+ throw new Error(`Failed to download the AppImage runtime (HTTP ${response.status}): ${exports.APPIMAGE_RUNTIME.url}`);
168
+ const data = Buffer.from(await response.arrayBuffer());
169
+ if ((0, files_1.sha256)(data) !== exports.APPIMAGE_RUNTIME.sha256) {
170
+ throw new Error(`AppImage runtime download does not match the pinned checksum: ${exports.APPIMAGE_RUNTIME.url}`);
171
+ }
172
+ fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
173
+ const tmp = `${file}.${process.pid}.tmp`;
174
+ fs_1.default.writeFileSync(tmp, data);
175
+ fs_1.default.renameSync(tmp, file);
176
+ return data;
177
+ }
178
+ /** The packaged app's files: Electron's, with the renamed executable and app.asar for default_app.asar. */
179
+ function linuxAppFiles(electronZipEntries, app) {
180
+ const files = electronZipEntries
181
+ .filter((entry) => !entry.endsWith("/"))
182
+ .map((entry) => (entry === "electron" ? linuxExecutableName(app) : entry === "resources/default_app.asar" ? "resources/app.asar" : entry));
183
+ for (const required of [linuxExecutableName(app), "resources/app.asar", ...exports.LINUX_ELECTRON_EXECUTABLES]) {
184
+ if (!files.includes(required))
185
+ throw new Error(`The Electron Linux release does not provide ${required}`);
186
+ }
187
+ return files.sort();
188
+ }
189
+ function listTree(root, relative = "") {
190
+ const entries = [];
191
+ for (const name of fs_1.default.readdirSync(path_1.default.join(root, relative)).sort()) {
192
+ const rel = relative ? `${relative}/${name}` : name;
193
+ const full = path_1.default.join(root, rel);
194
+ const stat = fs_1.default.lstatSync(full);
195
+ const mode = stat.mode & 0o7777;
196
+ if (stat.isDirectory()) {
197
+ entries.push({ path: rel, type: "dir", mode }, ...listTree(root, rel));
198
+ }
199
+ else if (stat.isFile()) {
200
+ entries.push({ path: rel, type: "file", mode });
201
+ }
202
+ else if (stat.isSymbolicLink()) {
203
+ entries.push({ path: rel, type: "symlink", mode, target: fs_1.default.readlinkSync(full) });
204
+ }
205
+ else {
206
+ entries.push({ path: rel, type: "other", mode });
207
+ }
208
+ }
209
+ return entries;
210
+ }
211
+ /** Directories (the app folder itself too) 0755; files 0755 if they were executable, else 0644. Nothing else is allowed. */
212
+ function normalizeLinuxModes(appDir) {
213
+ // @electron/packager creates the app folder 0700.
214
+ fs_1.default.chmodSync(appDir, 0o755);
215
+ for (const entry of listTree(appDir)) {
216
+ if (entry.type !== "file" && entry.type !== "dir") {
217
+ throw new Error(`Unexpected ${entry.type} in the packaged app: ${entry.path}`);
218
+ }
219
+ fs_1.default.chmodSync(path_1.default.join(appDir, ...entry.path.split("/")), entry.type === "dir" || entry.mode & 0o111 ? 0o755 : 0o644);
220
+ }
221
+ }
222
+ /** The mode every packaged file must have. */
223
+ function expectedLinuxMode(file, app) {
224
+ const executables = [linuxExecutableName(app), ...exports.LINUX_ELECTRON_EXECUTABLES];
225
+ return executables.includes(file) ? 0o755 : 0o644;
226
+ }
227
+ /**
228
+ * The AppDir root around the app files: AppRun -> the executable, the
229
+ * desktop entry, and the icon (.DirIcon -> it).
230
+ */
231
+ async function writeAppDirMetadata(appDirRoot, app, configDir) {
232
+ const id = linuxExecutableName(app);
233
+ fs_1.default.symlinkSync(id, path_1.default.join(appDirRoot, "AppRun"));
234
+ fs_1.default.writeFileSync(path_1.default.join(appDirRoot, `${id}.desktop`), linuxDesktopEntry(app), { flag: "wx", mode: 0o644 });
235
+ const icon = await (0, iconResolver_1.resolveAppIcon)({ configDir, configuredIcon: app.icon, defaultIcon: DEFAULT_ICON_PATH });
236
+ console.log(`🖼️ Desktop icon source: ${icon.source} (${icon.path})`);
237
+ const iconFile = path_1.default.join(appDirRoot, `${id}.png`);
238
+ await (0, sharp_1.default)(icon.path)
239
+ .resize(ICON_SIZE, ICON_SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
240
+ .png()
241
+ .toFile(iconFile);
242
+ fs_1.default.chmodSync(iconFile, 0o644);
243
+ fs_1.default.symlinkSync(`${id}.png`, path_1.default.join(appDirRoot, ".DirIcon"));
244
+ }
245
+ /** Desktop entry string value: backslashes and a leading space escaped (no control characters reach it). */
246
+ function desktopEntryString(value) {
247
+ return value.replace(/\\/g, "\\\\").replace(/^ /, "\\s");
248
+ }
249
+ /**
250
+ * The AppImage's desktop entry. StartupWMClass is the appId because the
251
+ * runtime names the Electron app after it (appConfig.applyAppIdentity), and
252
+ * Electron uses that name for the window class. No --no-sandbox in Exec:
253
+ * Chromium's sandbox stays on.
254
+ */
255
+ function linuxDesktopEntry(app) {
256
+ const id = linuxExecutableName(app);
257
+ const comment = app.description?.replace(/[\s\u0000-\u001f\u007f]+/g, " ").trim();
258
+ return [
259
+ "[Desktop Entry]",
260
+ "Type=Application",
261
+ `Name=${desktopEntryString(app.appName)}`,
262
+ ...(comment ? [`Comment=${desktopEntryString(comment)}`] : []),
263
+ `Exec=${id}`,
264
+ `Icon=${id}`,
265
+ "Terminal=false",
266
+ "Categories=Utility;",
267
+ `StartupWMClass=${id}`,
268
+ `X-AppImage-Version=${desktopEntryString(app.versionName.replace(/[\s\u0000-\u001f\u007f]+/g, " "))}`,
269
+ "",
270
+ ].join("\n");
271
+ }
272
+ /**
273
+ * Executable, file set, modes, fuses, and packaged files of an app folder.
274
+ * `appImage` also expects the AppDir root entries next to the app files.
275
+ */
276
+ async function verifyLinuxApp(appDir, app, expectedFiles, options = {}) {
277
+ const id = linuxExecutableName(app);
278
+ const tree = listTree(appDir);
279
+ const files = tree.filter((entry) => entry.type === "file").map((entry) => entry.path).sort();
280
+ const expected = [...expectedFiles, ...(options.appImage ? [`${id}.desktop`, `${id}.png`] : [])].sort();
281
+ const unexpected = files.filter((file) => !expected.includes(file));
282
+ const missing = expected.filter((file) => !files.includes(file));
283
+ if (unexpected.length > 0 || missing.length > 0) {
284
+ throw new Error(`Packaged Linux app files differ from Electron's: unexpected ${JSON.stringify(unexpected)}, missing ${JSON.stringify(missing)}`);
285
+ }
286
+ const expectedLinks = options.appImage ? { AppRun: id, ".DirIcon": `${id}.png` } : {};
287
+ const rootMode = fs_1.default.statSync(appDir).mode & 0o7777;
288
+ if (rootMode !== 0o755)
289
+ throw new Error(`The app folder has mode ${rootMode.toString(8)}, expected 755`);
290
+ for (const entry of tree) {
291
+ const actual = entry.mode.toString(8);
292
+ if (entry.type === "dir" && entry.mode !== 0o755)
293
+ throw new Error(`Directory ${entry.path} has mode ${actual}, expected 755`);
294
+ if (entry.type === "file") {
295
+ const mode = expectedLinuxMode(entry.path, app);
296
+ if (entry.mode !== mode)
297
+ throw new Error(`${entry.path} has mode ${actual}, expected ${mode.toString(8)}`);
298
+ }
299
+ if (entry.type === "symlink" && expectedLinks[entry.path] !== entry.target) {
300
+ throw new Error(`Unexpected symlink ${entry.path} -> ${entry.target}`);
301
+ }
302
+ if (entry.type === "other")
303
+ throw new Error(`Unexpected special file ${entry.path}`);
304
+ }
305
+ for (const [link, target] of Object.entries(expectedLinks)) {
306
+ if (!tree.some((entry) => entry.type === "symlink" && entry.path === link && entry.target === target)) {
307
+ throw new Error(`Missing symlink ${link} -> ${target}`);
308
+ }
309
+ }
310
+ const exePath = path_1.default.join(appDir, id);
311
+ assertElfX64((0, files_1.readHead)(exePath, 64), id);
312
+ await (0, fuses_1.verifyDesktopFuses)(exePath);
313
+ const asarPath = path_1.default.join(appDir, "resources", "app.asar");
314
+ await (0, asar_1.verifyAppAsar)(asarPath, app);
315
+ // Electron does not validate asar integrity on Linux; check the archive's own hashes at build time.
316
+ await (0, asar_1.assertAsarFileIntegrity)(asarPath);
317
+ }
318
+ /** A little-endian 64-bit x86-64 ELF. */
319
+ function assertElfX64(head, name) {
320
+ const ok = head.length >= 64 &&
321
+ head.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) &&
322
+ head[4] === 2 &&
323
+ head[5] === 1 &&
324
+ head.readUInt16LE(18) === 0x3e;
325
+ if (!ok)
326
+ throw new Error(`${name} is not an x86-64 ELF executable`);
327
+ }
328
+ /**
329
+ * The squashfs offset of a type 2 AppImage: the runtime ELF ends where its
330
+ * section header table ends (the runtime computes it the same way), and the
331
+ * ELF identification carries the "AI" type-2 magic.
332
+ */
333
+ function appImageOffset(head) {
334
+ assertElfX64(head, "AppImage");
335
+ if (head.toString("latin1", 8, 11) !== "AI\x02")
336
+ throw new Error("Not a type 2 AppImage (missing AI\\x02 magic)");
337
+ const sectionHeaders = Number(head.readBigUInt64LE(0x28));
338
+ return sectionHeaders + head.readUInt16LE(0x3a) * head.readUInt16LE(0x3c);
339
+ }
340
+ /** Extract the portable ZIP and check it holds exactly `<appName>/` with the validated app and its modes. */
341
+ async function validateLinuxZip(zipPath, app, expectedFiles, extractDir) {
342
+ const extracted = await (0, zip_1.verifyPortableZip)(zipPath, extractDir, app.appName, expectedFiles);
343
+ await verifyLinuxApp(extracted, app, expectedFiles);
344
+ }
345
+ /** Parse `unsquashfs -lln` lines: "-rwxr-xr-x 0/0 15216 1980-01-01 00:00 squashfs-root/chrome-sandbox". */
346
+ function parseSquashfsListing(output) {
347
+ const entries = [];
348
+ for (const line of output.split("\n")) {
349
+ const match = /^([-dlbcps][-rwxsStT]{9}) (\d+)\/(\d+) +\d+ \S+ \S+ squashfs-root(\/.*?)?(?: -> (.*))?$/.exec(line);
350
+ if (!match)
351
+ continue;
352
+ entries.push({ mode: match[1], uid: Number(match[2]), gid: Number(match[3]), path: (match[4] ?? "/").slice(1), target: match[5] });
353
+ }
354
+ return entries;
355
+ }
356
+ /** Root-owned, the expected modes, and only the AppDir symlinks: what the AppImage mounts. */
357
+ function assertSquashfsListing(entries, app, expectedFiles) {
358
+ const id = linuxExecutableName(app);
359
+ const links = { AppRun: id, ".DirIcon": `${id}.png` };
360
+ const files = [...expectedFiles, `${id}.desktop`, `${id}.png`];
361
+ if (entries.length === 0)
362
+ throw new Error("The AppImage filesystem listing is empty");
363
+ for (const entry of entries) {
364
+ const where = entry.path || "/";
365
+ if (entry.uid !== 0 || entry.gid !== 0)
366
+ throw new Error(`AppImage entry ${where} is owned by ${entry.uid}:${entry.gid}, expected root`);
367
+ let expected;
368
+ if (entry.path in links) {
369
+ if (!entry.mode.startsWith("l") || entry.target !== links[entry.path]) {
370
+ throw new Error(`AppImage ${entry.path} must be a symlink to ${links[entry.path]}`);
371
+ }
372
+ // Symlink permission bits are host-dependent and ignored by Linux.
373
+ continue;
374
+ }
375
+ else if (files.includes(entry.path)) {
376
+ expected = expectedLinuxMode(entry.path, app) === 0o755 ? "-rwxr-xr-x" : "-rw-r--r--";
377
+ }
378
+ else if (entry.mode.startsWith("d")) {
379
+ expected = "drwxr-xr-x";
380
+ }
381
+ else {
382
+ throw new Error(`Unexpected AppImage entry ${where}`);
383
+ }
384
+ if (entry.mode !== expected)
385
+ throw new Error(`AppImage entry ${where} has mode ${entry.mode}, expected ${expected}`);
386
+ }
387
+ for (const required of [...Object.keys(links), ...files]) {
388
+ if (!entries.some((entry) => entry.path === required))
389
+ throw new Error(`AppImage is missing ${required}`);
390
+ }
391
+ }
392
+ /**
393
+ * The AppImage is the pinned runtime followed by a squashfs whose listing
394
+ * and fresh extraction match the validated app plus the AppDir metadata.
395
+ */
396
+ async function validateAppImage(appImagePath, app, expectedFiles, extractDir) {
397
+ if ((fs_1.default.statSync(appImagePath).mode & 0o777) !== 0o755)
398
+ throw new Error("The AppImage must be mode 755");
399
+ const head = (0, files_1.readHead)(appImagePath, 64);
400
+ const offset = appImageOffset(head);
401
+ const prefix = Buffer.alloc(offset + 4);
402
+ const fd = fs_1.default.openSync(appImagePath, "r");
403
+ try {
404
+ fs_1.default.readSync(fd, prefix, 0, prefix.length, 0);
405
+ }
406
+ finally {
407
+ fs_1.default.closeSync(fd);
408
+ }
409
+ if ((0, files_1.sha256)(prefix.subarray(0, offset)) !== exports.APPIMAGE_RUNTIME.sha256)
410
+ throw new Error("The AppImage runtime is not the pinned type 2 runtime");
411
+ if (prefix.toString("latin1", offset, offset + 4) !== "hsqs")
412
+ throw new Error("No squashfs filesystem after the AppImage runtime");
413
+ const listing = (0, process_1.runProcess)("unsquashfs", ["-lln", "-o", String(offset), appImagePath], SQUASHFS_OPTIONS).stdout;
414
+ assertSquashfsListing(parseSquashfsListing(listing), app, expectedFiles);
415
+ (0, process_1.runProcess)("unsquashfs", ["-o", String(offset), "-d", extractDir, "-no-xattrs", "-quiet", "-no-progress", appImagePath], SQUASHFS_OPTIONS);
416
+ const id = linuxExecutableName(app);
417
+ if (fs_1.default.readFileSync(path_1.default.join(extractDir, `${id}.desktop`), "utf8") !== linuxDesktopEntry(app)) {
418
+ throw new Error("The AppImage desktop entry does not match the app");
419
+ }
420
+ const icon = await (0, sharp_1.default)(path_1.default.join(extractDir, `${id}.png`)).metadata();
421
+ if (icon.format !== "png" || icon.width !== ICON_SIZE || icon.height !== ICON_SIZE) {
422
+ throw new Error(`The AppImage icon must be a ${ICON_SIZE}x${ICON_SIZE} PNG`);
423
+ }
424
+ await verifyLinuxApp(extractDir, app, expectedFiles, { appImage: true });
425
+ }
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MACOS_ARM64_TARGET = void 0;
7
+ exports.macosBundleId = macosBundleId;
8
+ exports.macosPackagerOptions = macosPackagerOptions;
9
+ exports.packageMacosApp = packageMacosApp;
10
+ exports.validateMacosZip = validateMacosZip;
11
+ exports.verifyMacosApp = verifyMacosApp;
12
+ exports.assertAdHocSignature = assertAdHocSignature;
13
+ exports.assertInfoPlist = assertInfoPlist;
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ const process_1 = require("../runners/process");
17
+ const artifacts_1 = require("./common/artifacts");
18
+ const asar_1 = require("./common/asar");
19
+ const electron_1 = require("./common/electron");
20
+ const fuses_1 = require("./common/fuses");
21
+ const packager_1 = require("./common/packager");
22
+ exports.MACOS_ARM64_TARGET = "macos-arm64";
23
+ /**
24
+ * macOS CFBundleIdentifier for an appId. Bundle identifiers allow only
25
+ * letters, digits, hyphens, and periods; appIds cannot contain hyphens, so
26
+ * mapping `_` to `-` is reversible and never collides.
27
+ */
28
+ function macosBundleId(appId) {
29
+ return appId.replace(/_/g, "-");
30
+ }
31
+ const MACOS_TOOL_OPTIONS = {
32
+ timeoutMs: 10 * 60_000,
33
+ timeoutHint: "codesign or ditto stopped responding; try again.",
34
+ };
35
+ /**
36
+ * @electron/packager options: the shared base (with the allowlist `ignore`)
37
+ * plus the bundle identity. No signing here: fuses are flipped first, then
38
+ * the whole bundle is ad-hoc signed.
39
+ */
40
+ function macosPackagerOptions(options) {
41
+ const { stagingDir, outDir, electronZipDir, app } = options;
42
+ return {
43
+ ...(0, packager_1.basePackagerOptions)({ stagingDir, outDir, electronZipDir }),
44
+ platform: "darwin",
45
+ arch: "arm64",
46
+ name: app.appName,
47
+ appBundleId: macosBundleId(app.appId),
48
+ appVersion: app.versionName,
49
+ buildVersion: String(app.versionCode),
50
+ };
51
+ }
52
+ /**
53
+ * Package the staged app as `<appName>.app`, flip fuses, ad-hoc sign, zip
54
+ * with ditto (which keeps the framework symlinks and signatures intact), and
55
+ * validate a fresh extraction of the ZIP. Returns the ZIP path in `workDir`.
56
+ */
57
+ async function packageMacosApp(options) {
58
+ const { stagingDir, workDir, app } = options;
59
+ const electronZip = await (0, electron_1.fetchElectronZip)(electron_1.MACOS_ELECTRON_ZIP);
60
+ const { productPath: appPath } = await (0, packager_1.runPackager)(macosPackagerOptions({ stagingDir, outDir: path_1.default.join(workDir, "out"), electronZipDir: path_1.default.dirname(electronZip), app }), { name: `${app.appName}.app`, type: "directory" });
61
+ await (0, fuses_1.flipDesktopFuses)(appPath);
62
+ (0, process_1.runProcess)("codesign", ["--force", "--deep", "--sign", "-", appPath], MACOS_TOOL_OPTIONS);
63
+ const zipPath = (0, artifacts_1.desktopArtifactPath)(workDir, app.appId, exports.MACOS_ARM64_TARGET, ".zip");
64
+ (0, process_1.runProcess)("ditto", ["-c", "-k", "--sequesterRsrc", "--keepParent", appPath, zipPath], MACOS_TOOL_OPTIONS);
65
+ await validateMacosZip(zipPath, app, path_1.default.join(workDir, "verify"));
66
+ return zipPath;
67
+ }
68
+ /** Extract the ZIP and check it holds exactly the expected, intact app bundle. */
69
+ async function validateMacosZip(zipPath, app, extractDir) {
70
+ fs_1.default.mkdirSync(extractDir);
71
+ (0, process_1.runProcess)("ditto", ["-x", "-k", zipPath, extractDir], MACOS_TOOL_OPTIONS);
72
+ const entries = fs_1.default.readdirSync(extractDir);
73
+ if (entries.length !== 1 || entries[0] !== `${app.appName}.app`) {
74
+ throw new Error(`macOS package must contain only ${app.appName}.app, found: ${entries.join(", ")}`);
75
+ }
76
+ await verifyMacosApp(path_1.default.join(extractDir, entries[0]), app);
77
+ }
78
+ /** Signature, Info.plist, fuses, and packaged files of an app bundle. */
79
+ async function verifyMacosApp(appPath, app) {
80
+ (0, process_1.runProcess)("codesign", ["--verify", "--deep", "--strict", appPath], MACOS_TOOL_OPTIONS);
81
+ const display = (0, process_1.runProcess)("codesign", ["--display", "--verbose=2", appPath], MACOS_TOOL_OPTIONS);
82
+ assertAdHocSignature(`${display.stderr}\n${display.stdout}`, macosBundleId(app.appId));
83
+ const infoPlist = (0, process_1.runProcess)("plutil", ["-convert", "json", "-o", "-", path_1.default.join(appPath, "Contents", "Info.plist")], MACOS_TOOL_OPTIONS);
84
+ assertInfoPlist(JSON.parse(infoPlist.stdout), app);
85
+ await (0, fuses_1.verifyDesktopFuses)(appPath);
86
+ const resources = path_1.default.join(appPath, "Contents", "Resources");
87
+ for (const unpacked of ["app", "app.asar.unpacked"]) {
88
+ if (fs_1.default.existsSync(path_1.default.join(resources, unpacked)))
89
+ throw new Error(`Unexpected ${unpacked} next to app.asar`);
90
+ }
91
+ await (0, asar_1.verifyAppAsar)(path_1.default.join(resources, "app.asar"), app);
92
+ }
93
+ /** Parse `codesign --display --verbose=2`: an ad-hoc, arm64, resource-sealed signature for the bundle id. */
94
+ function assertAdHocSignature(display, bundleId) {
95
+ const lines = display.split("\n").map((line) => line.trim());
96
+ const value = (key) => lines.find((line) => line.startsWith(`${key}=`))?.slice(key.length + 1);
97
+ const problems = [
98
+ value("Identifier") === bundleId ? null : `identifier is ${value("Identifier") ?? "missing"}, expected ${bundleId}`,
99
+ value("Signature") === "adhoc" ? null : "signature is not ad-hoc",
100
+ value("Format")?.includes("arm64") ? null : "binary is not arm64",
101
+ value("TeamIdentifier") === "not set" ? null : "unexpected team identifier",
102
+ // An unsealed bundle prints "Sealed Resources=none".
103
+ lines.some((line) => /^Sealed Resources version=\d+ /.test(line)) ? null : "resources are not sealed",
104
+ ].filter(Boolean);
105
+ if (problems.length > 0)
106
+ throw new Error(`Invalid macOS code signature: ${problems.join("; ")}`);
107
+ }
108
+ function assertInfoPlist(plist, app) {
109
+ const expected = {
110
+ CFBundleIdentifier: macosBundleId(app.appId),
111
+ CFBundleExecutable: app.appName,
112
+ CFBundleName: app.appName,
113
+ CFBundleShortVersionString: app.versionName,
114
+ CFBundleVersion: String(app.versionCode),
115
+ };
116
+ for (const [key, value] of Object.entries(expected)) {
117
+ if (plist[key] !== value)
118
+ throw new Error(`Info.plist ${key} is ${JSON.stringify(plist[key])}, expected ${JSON.stringify(value)}`);
119
+ }
120
+ const integrity = plist.ElectronAsarIntegrity;
121
+ const entry = integrity?.["Resources/app.asar"];
122
+ if (!integrity || Object.keys(integrity).length !== 1 || entry?.algorithm !== "SHA256" || typeof entry.hash !== "string" || !entry.hash) {
123
+ throw new Error("Info.plist lacks ElectronAsarIntegrity for Resources/app.asar");
124
+ }
125
+ }
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DESKTOP_RUNTIME_DIST = exports.DESKTOP_RUNTIME_FILES = exports.DESKTOP_DESCRIPTOR_FILE = void 0;
7
+ exports.isAllowedAppPath = isAllowedAppPath;
8
+ exports.assertDesktopAppName = assertDesktopAppName;
9
+ exports.desktopOutputPath = desktopOutputPath;
10
+ exports.stageDesktopApp = stageDesktopApp;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const androidProject_1 = require("../core/androidProject");
14
+ const artifacts_1 = require("./common/artifacts");
15
+ /**
16
+ * Desktop app layout shared with desktop-runtime/src/appConfig.js:
17
+ *
18
+ * package.json "main" -> runtime/main.js
19
+ * pakstr-desktop.json { appId, appName } (the runtime derives userData from appId)
20
+ * runtime/ the bundled runtime (main.js, preload.js)
21
+ * web/ the built web app plus generated runtime config
22
+ *
23
+ * Nothing else is ever staged or packaged.
24
+ */
25
+ exports.DESKTOP_DESCRIPTOR_FILE = "pakstr-desktop.json";
26
+ exports.DESKTOP_RUNTIME_FILES = ["main.js", "preload.js"];
27
+ const ALLOWED_FILES = new Set([
28
+ "/package.json",
29
+ `/${exports.DESKTOP_DESCRIPTOR_FILE}`,
30
+ "/runtime",
31
+ ...exports.DESKTOP_RUNTIME_FILES.map((file) => `/runtime/${file}`),
32
+ "/web",
33
+ ]);
34
+ /** Runtime bundle built by the CLI's `npm run build` and shipped in the npm package. */
35
+ exports.DESKTOP_RUNTIME_DIST = path_1.default.resolve(__dirname, "../../desktop-runtime/dist");
36
+ /** OS metadata files that are skipped rather than packaged. */
37
+ const JUNK_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
38
+ /** Names that look like credentials or repository data: refuse to package them. */
39
+ const FORBIDDEN_WEB_ENTRY = /^(\.env(\..*)?|\.git|\.npmrc|\.netrc|\.ssh|.*\.(pem|key|p12|pfx|jks|keystore))$/i;
40
+ /**
41
+ * Whether an app-relative POSIX path (leading "/") belongs to the layout. Used
42
+ * as the packager ignore filter and to validate the packaged asar.
43
+ */
44
+ function isAllowedAppPath(appPath) {
45
+ return ALLOWED_FILES.has(appPath) || appPath.startsWith("/web/");
46
+ }
47
+ /** The appName becomes the .app and executable file names. */
48
+ function assertDesktopAppName(appName) {
49
+ if (appName !== appName.trim() ||
50
+ appName.length === 0 ||
51
+ appName.length > 200 ||
52
+ appName.startsWith(".") ||
53
+ /[/\\:\u0000-\u001f\u007f]/.test(appName)) {
54
+ throw new Error(`app.appName "${appName}" cannot be used as a desktop app name: it must not be blank, ` +
55
+ "start with '.', or contain '/', '\\', ':' or control characters");
56
+ }
57
+ }
58
+ /** Output ZIP next to build.out, named like the APK: <appId>-<target>.zip. */
59
+ function desktopOutputPath(buildOut, appId, target) {
60
+ return (0, artifacts_1.desktopArtifactPath)((0, artifacts_1.desktopOutputDir)(buildOut), appId, target, ".zip");
61
+ }
62
+ /** Write the allowlisted app layout into an empty `stagingDir`. */
63
+ function stageDesktopApp(options) {
64
+ const { stagingDir, webDir, app, runtime = {}, runtimeDistDir = exports.DESKTOP_RUNTIME_DIST } = options;
65
+ assertDesktopAppName(app.appName);
66
+ if (fs_1.default.readdirSync(stagingDir).length > 0)
67
+ throw new Error(`Desktop staging directory is not empty: ${stagingDir}`);
68
+ const runtimeDir = path_1.default.join(stagingDir, "runtime");
69
+ fs_1.default.mkdirSync(runtimeDir);
70
+ for (const file of exports.DESKTOP_RUNTIME_FILES) {
71
+ const source = path_1.default.join(runtimeDistDir, file);
72
+ const stat = fs_1.default.lstatSync(source, { throwIfNoEntry: false });
73
+ if (!stat?.isFile()) {
74
+ throw new Error(`Desktop runtime bundle not found: ${source}. ` +
75
+ "This pakstr installation is incomplete; from a source checkout, run `npm --prefix packages/cli run build`.");
76
+ }
77
+ fs_1.default.copyFileSync(source, path_1.default.join(runtimeDir, file));
78
+ }
79
+ const webTarget = path_1.default.join(stagingDir, "web");
80
+ fs_1.default.mkdirSync(webTarget);
81
+ copyWebAssets(path_1.default.resolve(webDir), webTarget, "");
82
+ (0, androidProject_1.generateRuntimeConfig)(webTarget, runtime);
83
+ writeJson(path_1.default.join(stagingDir, exports.DESKTOP_DESCRIPTOR_FILE), { appId: app.appId, appName: app.appName });
84
+ writeJson(path_1.default.join(stagingDir, "package.json"), {
85
+ name: app.appId,
86
+ productName: app.appName,
87
+ version: app.versionName,
88
+ private: true,
89
+ main: "runtime/main.js",
90
+ });
91
+ }
92
+ /** Copy regular files and directories only; never follow or package symlinks. */
93
+ function copyWebAssets(source, target, relative) {
94
+ for (const entry of fs_1.default.readdirSync(source, { withFileTypes: true })) {
95
+ const entryRelative = relative ? `${relative}/${entry.name}` : entry.name;
96
+ if (JUNK_FILES.has(entry.name))
97
+ continue;
98
+ if (FORBIDDEN_WEB_ENTRY.test(entry.name)) {
99
+ throw new Error(`Refusing to package web/${entryRelative}: it looks like a secret or repository file`);
100
+ }
101
+ const from = path_1.default.join(source, entry.name);
102
+ const to = path_1.default.join(target, entry.name);
103
+ if (entry.isDirectory()) {
104
+ fs_1.default.mkdirSync(to);
105
+ copyWebAssets(from, to, entryRelative);
106
+ }
107
+ else if (entry.isFile()) {
108
+ fs_1.default.copyFileSync(from, to, fs_1.default.constants.COPYFILE_EXCL);
109
+ }
110
+ else {
111
+ throw new Error(`Refusing to package web/${entryRelative}: only regular files and directories are allowed`);
112
+ }
113
+ }
114
+ }
115
+ function writeJson(file, value) {
116
+ fs_1.default.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
117
+ }