UNPKG

@reforged/maker-appimage

Version:

An AppImage maker implementation for the Electron Forge.

224 lines (208 loc) 8.11 kB
import EventEmitter from "events"; import { existsSync } from "fs"; import { readFile } from "fs/promises"; import { execFileSync, execFile } from "child_process"; import { coerce } from "semver"; import type { MakerOptions } from "@electron-forge/maker-base" import type { SemVer } from "semver" type AppImageArch = "x86_64"|"aarch64"|"armhf"|"i686"; export type ForgeArch = "x64" | "arm64" | "armv7l" | "ia32" | "mips64el" | "universal"; export interface MakerMeta extends MakerOptions { targetArch: ForgeArch; } /** Function argument definitions for {@linkcode mkSqFsEvt}. */ interface mkSqFSListenerArgs { close: [ /** A returned code when process normally exits. */ code: number|null, /** A signal which closed the process. */ signal:NodeJS.Signals|null, /** A message printed to STDERR, if available. */ msg?:string ]; progress: [ /** A number from range 0-100 indicating the current progress made on creating the image. */ percent: number ]; error: [ error: Error ]; }; type mkSqFSEvtListen<T extends keyof mkSqFSListenerArgs> = [ eventName: T, listener: (..._:mkSqFSListenerArgs[T]) => void ]; type mkSqFSEvtEmit<T extends keyof mkSqFSListenerArgs> = [ event: T, ..._:mkSqFSListenerArgs[T] ]; /** An `EventListener` interface with parsed events from mksquashfs child process. */ interface mkSqFsEvt extends EventEmitter { /** * Emitted when `mksquashfs` process has been closed. */ on(..._:mkSqFSEvtListen<"close">): this; /** * Emitted once `mksquashfs` process has been closed. */ once(..._:mkSqFSEvtListen<"close">): this; /** * Emitted when `mksquashfs` process has been closed. */ addListener(..._:mkSqFSEvtListen<"close">): this; /** * Emitted when `mksquashfs` process has been closed. */ removeListener(..._:mkSqFSEvtListen<"close">): this; /** * Emitted whenever a progress has been made on SquashFS image generation. */ on(..._:mkSqFSEvtListen<"progress">): this; /** * Emitted whenever a progress has been made on SquashFS image generation. */ once(..._:mkSqFSEvtListen<"progress">): this; /** * Emitted whenever a progress has been made on SquashFS image generation. */ addListener(..._:mkSqFSEvtListen<"progress">): this; /** * Emitted whenever a progress has been made on SquashFS image generation. */ removeListener(..._:mkSqFSEvtListen<"progress">): this; /** Emitted whenever process has threw an error. */ on(..._:mkSqFSEvtListen<"error">): this; /** Emitted whenever process has threw an error. */ once(..._:mkSqFSEvtListen<"error">): this; /** Emitted whenever process has threw an error. */ addListener(..._:mkSqFSEvtListen<"error">): this; /** Emitted whenever process has threw an error. */ removeListener(..._:mkSqFSEvtListen<"error">): this; /** @internal */ emit(..._:mkSqFSEvtEmit<"close">): boolean; /** @internal */ emit(..._:mkSqFSEvtEmit<"progress">): boolean; /** @internal */ emit(..._:mkSqFSEvtEmit<"error">): boolean; } // FIXME: Library considerations? Should we make for it separate module? export function generateDesktop(desktopEntry: Partial<Record<string,string[]|string|null>>, actions?: Record<string,Partial<Record<string,string|null>>&{ Name: string }>) { function toEscapeSeq<T>(string:T): T extends string ? string : T { if(typeof string === "string") return string .replaceAll(/\\(?!["`trn])/g,"\\\\") .replaceAll("`","\\`") .replaceAll("\t", "\\t") .replaceAll("\r", "\\r") .replaceAll("\n","\\n") as T extends string ? string : T return string as T extends string ? string : T; } const template:Record<"desktop"|"actions",string[]> = { desktop:[], actions:[] }; let actionsKey:string|null = null; template.desktop.push('[Desktop Entry]'); for(const entry of Object.entries(desktopEntry)) if(entry[0] !== "Actions" && entry[1] !== undefined && entry[1] !== null) { if(Array.isArray(entry[1])) entry[1]=entry[1].length?entry[1].join(";")+";":""; template.desktop.push(entry.map(v => toEscapeSeq(v)).join('=')); } if(actions) for(const [name,record] of Object.entries(actions)) if(/[a-zA-Z]/.test(name)) { actionsKey === null ? actionsKey = name : actionsKey += ";"+name; template.actions.push('\n[Desktop Action '+name+']'); for(const entry of Object.entries(record)) if(entry[1] !== undefined && entry[1] !== null) template.actions.push(entry.map(v => toEscapeSeq(v)).join('=')); } if(actionsKey) template.desktop.push("Actions="+actions); return template.desktop.join('\n')+'\n'+template.actions.join('\n'); } /** * A wrapper for `mksquashfs` binary. * * @returns An event used to watch for `mksquashfs` changes, including the job progress (in percent – as float number). */ export function mkSquashFs(...argv:string[]) { let lastProgress = 0, stderrCollector = ""; const event:mkSqFsEvt = new EventEmitter(), {PATH,SOURCE_DATE_EPOCH} = process.env, { stderr,stdout } = execFile("mksquashfs", argv, { encoding: "utf-8", windowsHide: true, env: { PATH, SOURCE_DATE_EPOCH } }).once("close", (...args) => event.emit( "close", ...args, stderrCollector ? undefined : stderrCollector )).on("error", (error) => event.emit("error", error)); stderr?.on("data", (chunk:string|object) => {switch(chunk.constructor){ case String: stderrCollector+=chunk; break; default: throw new TypeError(`Unresolved chunk of type '${chunk?.constructor.name ?? typeof chunk}'.`); }}) stdout?.on("data", (chunk:string|object) => { if(chunk.constructor !== String) return; const progress = chunk.match(/\] [0-9/]+ ([0-9]+)%/)?.[1]; if(progress === undefined) return; const progInt = parseInt(progress,10); if(progInt >= 0 && progInt <= 100 && progInt !== lastProgress && event.emit("progress", progInt/100)) lastProgress = progInt; }); return event; } /** * Returns the version of `mksquashfs` binary, as `SemVer` value. * * Under the hood, it executes `mksquashfs` with `-version`, parses * the `stdout` and tries to coerce it to `SemVer`. */ export function getSquashFsVer() { let output:string|SemVer|undefined|null = execFileSync("mksquashfs",["-version"],{ encoding: "utf8", timeout: 3000, maxBuffer: 768, windowsHide: true, env: { PATH: process.env["PATH"] } }).split('\n')[0]; if(output === undefined) throw new TypeError("Unable to parse '-version': first line read error."); output = /(?<=version )[0-9.]+/.exec(output)?.[0]; if(output === undefined) throw new TypeError("Unable to parse '-version': number not found."); output = coerce(output); if(output === null) throw new Error(`Unable to coerce string '${output}' to SemVer.`); return output; }; /** * Concatenates files and/or buffers into a new buffer. */ export async function joinFiles(...filesAndBuffers:readonly(string|ArrayBufferLike|Uint8Array)[]) { const buffArr = <Promise<Uint8Array>[]>[]; for(const path of filesAndBuffers) // Convert anything to Uint8Array as buffer representation if(path instanceof <Uint8ArrayConstructor>Object.getPrototypeOf(Uint8Array)) buffArr.push(Promise.resolve(new Uint8Array(path.buffer))); else if(path instanceof ArrayBuffer || path instanceof SharedArrayBuffer) buffArr.push(Promise.resolve(new Uint8Array(path))); else if(existsSync(path)) buffArr.push(readFile(path)); else throw new Error(`Unable to concat '${path}': Invalid path.`); const arr = await Promise.all(buffArr); // Concat all buffers into the new ones. const length = arr.reduce((p,c)=>p+c.length,0); const result = new Uint8Array(length); let preBuffLen = 0; for(const buff of arr) result.set(buff,preBuffLen), preBuffLen=buff.length; return result; } /** * Maps Node.js architecture to the AppImage-friendly format. */ export const mapArch:Readonly<Partial<Record<ForgeArch,AppImageArch>>> = Object.freeze({ x64: "x86_64", ia32: "i686", arm64: "aarch64", armv7l: "armhf" });