UNPKG

@reforged/maker-appimage

Version:

An AppImage maker implementation for the Electron Forge.

234 lines 11.9 kB
process.setSourceMapsEnabled?.(true); import { tmpdir } from "os"; import { resolve, extname, relative } from "path"; import { existsSync, rmSync } from "fs"; import { mkdir, mkdtemp, writeFile, copyFile, readFile, chmod, symlink, rm, cp } from "fs/promises"; import { EventEmitter } from "events"; import { debug, format, styleText } from "util"; import { MakerBase } from "@electron-forge/maker-base"; import sanitizeName from "@spacingbat3/lss"; import { generateDesktop, joinFiles, mkSquashFs, mapArch, getImageMetadata, getSquashFsVer } from "./utils.js"; const d = (() => { const fmt = `@reforged/maker-appimage %o: %s ${styleText(["gray"], "(+%ims)")}`; const timer = function* () { for (let timeNext, time = process.uptime();; time = timeNext) yield (timeNext = process.uptime()) - time; }(); if (debug("reforged:maker-appimage").enabled || /(?:^|,)(?:\*|reforged:(?:\*|maker-appimage))(?:$|,)/ .test(process.env["DEBUG"] ?? "")) // Function that logs similarly to debugLog. return (...args) => console.error(fmt, process.pid, format(...args), timer.next().value * 1000); // NO-OP function return () => { }; })(); /** * An AppImage maker for Electron Forge. * * @remarks * See `Readme.md` file distributed in subproject's root dir for * additional information about this maker. See JSDoc/TSDoc/TypeDoc * documentation (this ones!) for supported configuration options. * * @example * ```js * { * name: "@reforged/maker-appimage" * config: { * options: { * categories: ["Network"], * icon: "path/to/icon.svg" * } * } * } * ``` */ export default class MakerAppImage extends MakerBase { /** @internal */ __VndReForgedAPI = 1; defaultPlatforms = ["linux"]; name = "AppImage"; requiredExternalBinaries = ["mksquashfs"]; isSupportedOnCurrentPlatform = () => true; async make({ appName, dir, makeDir, packageJSON, targetArch }, ...vendorExt) { d("Initializing maker metadata."); const { actions, categories, compressor, genericName, icon } = (this.config.options ?? {}); let { name, bin, productName, runtime } = (this.config.options ?? {}); // FIXME: https://github.com/tc39/proposal-throw-expressions would be nice // here when decision to add it to standard will be made. const appImageArch = mapArch[targetArch] ?? (() => { throw new Error(`Unsupported architecture: '${targetArch}'.`); })(); // Fallbacks: name ??= sanitizeName(this.config.options?.name ?? packageJSON.name); bin ??= name; productName ??= appName; runtime ??= `${"https://github.com/AppImage/type2-runtime/releases/download/" /* RemoteDefaults.Mirror */}${"continuous" /* RemoteDefaults.Tag */}/runtime-${appImageArch}`; /** Resolved path to AppImage output file. */ const outFile = resolve(makeDir, this.name, targetArch, `${productName}-${packageJSON.version}-${targetArch}.AppImage`); const binShell = bin.replaceAll(/(?<!\\)"/g, '\\"'); /** * Detailed information about the source files. * * As of remote content, objects contain the data in form of * ArrayBuffers (which are then allocated to Buffers, * checksum-verified and saved as regular files). The text-based * generated content is however saved in form of the string (UTF-8 * encoded, with LF endings). */ const sources = Object.freeze({ /** Details about the AppImage runtime. */ runtime: existsSync(runtime) ? readFile(runtime) : fetch(runtime) .then(response => { d("Fetched AppImage runtime from mirror."); if (response.status === 200) return response.arrayBuffer(); else throw new Error(`Runtime request failure (${response.status}: ${response.statusText}).`); }), /** Details about the generated `.desktop` file. */ desktop: typeof this.config.options?.desktopFile === "string" ? readFile(this.config.options.desktopFile, "utf-8") : Promise.resolve(generateDesktop({ Version: "1.5", Type: "Application", Name: productName, GenericName: genericName, Exec: `${bin.includes(" ") ? `"${binShell}"` : bin} %U`, Icon: icon ? name : undefined, Categories: categories ? categories.join(';') + ';' : undefined, "X-AppImage-Name": name, "X-AppImage-Version": packageJSON.version, "X-AppImage-Arch": appImageArch }, actions)) }); // Verify if there's a `bin` file in packaged application. if (!existsSync(resolve(dir, bin))) throw new Error([ `Could not find executable '${bin}' in packaged application.`, "Make sure 'packagerConfig.executableName' or 'config.options.bin'", "in Forge config are pointing to valid file." ].join(" ")); /** Icon metadata, ie. detected dimensions and filetype. */ const iconMeta = icon ? readFile(icon).then(icon => getImageMetadata(icon)) : Promise.resolve(undefined); /** A temporary directory used for the packaging. */ const workDir = await mkdtemp(resolve(tmpdir(), `.${productName}-${packageJSON.version}-${targetArch}-`)); d("Setup cleanup hooks for error scenarios."); const [cleanupHook, cleanupSyncHook] = (() => { let cleanup = (rmMethod) => { cleanup = async () => void 0; return rmMethod(workDir, { recursive: true }); }; return [ () => cleanup(rm), () => cleanup(rmSync) ]; })(); process.once("uncaughtExceptionMonitor", cleanupHook); process.once("exit", cleanupSyncHook); process.once("SIGINT", () => { throw new Error("User interrupted the process."); }); const directories = { lib: resolve(workDir, 'usr/lib/'), data: resolve(workDir, 'usr/lib/', name), bin: resolve(workDir, 'usr/bin'), icons: iconMeta.then(meta => meta && meta.width && meta.height ? resolve(workDir, 'usr/share/icons/hicolor', meta.width.toFixed(0) + 'x' + meta.height.toFixed(0)) : null) }; const iconPath = icon ? resolve(workDir, name + extname(icon)) : undefined; const binPath = resolve(directories.bin, bin); d("Queuing asynchronous jobs batches."); /** First-step jobs, which does not depend on any other job. */ const earlyJobs = [ // Create further directory tree (0,1,2) mkdir(directories.lib, { recursive: true, mode: 0o755 }), mkdir(directories.bin, { recursive: true, mode: 0o755 }), directories.icons .then(path => path ? mkdir(path, { recursive: true, mode: 0o755 }).then(() => path) : undefined), // Save `.desktop` to file (3) sources.desktop .then(data => writeFile(resolve(workDir, productName + '.desktop'), data, { mode: 0o755, encoding: "utf-8" })).then(() => d("Wrote '.desktop' file to 'workDir'.")), // Create `AppRun` as a link to bin/ (4) symlink(relative(workDir, binPath), resolve(workDir, 'AppRun'), "file"), // Save icon to file and symlink it as `.DirIcon` (5) icon ? iconPath && existsSync(icon) ? copyFile(icon, iconPath) .then(() => symlink(relative(workDir, iconPath), resolve(workDir, ".DirIcon"), 'file')) : Promise.reject(Error("Invalid icon / icon path.")) : Promise.resolve(), ]; const lateJobs = [ // Write shell script to file or create a symlink earlyJobs[1] .then(() => symlink(relative(directories.bin, resolve(directories.data, bin)), binPath, "file")), // Copy Electron app to AppImage directories earlyJobs[0] .then(() => cp(dir, directories.data, { errorOnExist: true, recursive: true, verbatimSymlinks: true })) .then(() => d("Copied Electron app data.")), // Copy icon to `usr` directory whenever possible Promise.all([earlyJobs[2], earlyJobs[5]]) .then(([path]) => icon && path ? copyFile(icon, resolve(path, name + extname(icon))) : void 0), // Ensure that root folder has proper file mode chmod(workDir, 0o755) ]; d("Waiting for queued jobs to finish."); // Wait for early/late jobs to finish await (Promise.all([...earlyJobs, ...lateJobs])); d("Preparing 'mksquashfs' arguments for data image generation."); // Run `mksquashfs` and wait for it to finish const mkSquashFsArgs = [workDir, outFile]; const mkSquashFsVer = getSquashFsVer(); switch (-1) { // -noappend is supported since 1.2+ case (mkSquashFsVer.compare("1.2.0")): break; //@ts-expect-error falls through case -1: mkSquashFsArgs.push("-noappend"); // -all-root is supported since 2.0+ case mkSquashFsVer.compare("2.0.0"): break; //@ts-expect-error falls through case -1: mkSquashFsArgs.push("-all-root"); // -all-time and -mkfs-time is supported since 4.4+ case mkSquashFsVer.compare("4.4.0"): break; case -(process.env["SOURCE_DATE_EPOCH"] === undefined): mkSquashFsArgs.push("-all-time", "0", "-mkfs-time", "0"); } // Set compressor options if available switch (compressor) { case undefined: break; //@ts-expect-error falls through default: mkSquashFsArgs.push("-comp", compressor); // Defaults for `xz` took from AppImageTool: case "xz": mkSquashFsArgs.push("-Xdict-size", "100%", "-b", "16384"); } d("Queuing 'mksquashfs' task."); await new Promise((resolve, reject) => { this.ensureFile(outFile).then(() => { const evtCh = mkSquashFs(...mkSquashFsArgs) .once("close", (code, _signal, msg) => code !== 0 ? reject(new Error(`mksquashfs returned ${msg ? `'${msg}' in stderr` : "non-zero code"} (${code}).`)) : resolve(d("Crafted SquashFS image file."))) .once("error", (error) => reject(error)); for (let vndHead; vndHead !== undefined && vndHead !== "RF1"; vndHead = vendorExt.pop()) ; const [vndCh] = vendorExt; // Leak current progress to API consumers if supported if (vndCh instanceof EventEmitter) evtCh.on("progress", percent => vndCh.emit("progress", percent)); }).catch(error => reject(error)); }); d("Cleanup workDir & craft final AppImage."); await Promise.all([ cleanupHook() .then(() => void process .off("uncaughtExceptionMonitor", cleanupHook) .off("exit", cleanupSyncHook)), writeFile(outFile, await joinFiles(await sources.runtime, outFile)) ]); chmod(outFile, 0o755); d("Done everything, returning results."); // Finally, return paths to maker artifacts return [outFile]; } } export { MakerAppImage }; //# sourceMappingURL=main.js.map