UNPKG

@reforged/maker-appimage

Version:

An AppImage maker implementation for the Electron Forge.

322 lines (303 loc) 11.9 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; } interface ImageMetadata { type: "PNG"|"SVG"|"XPM3"|"XPM2"; width: number|null; height: number|null; } /** 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|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) 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.`); return Promise.all(buffArr) .then(buffArr => { // Concat all buffers into the new ones. const length = buffArr.reduce((p,c)=>p+c.length,0); const result = new Uint8Array(length); let preBuffLen = 0; for(const buff of buffArr) 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" }); /** * A function to validate if the type of any value is like the one in * {@link ImageMetadata} interface. * * @param meta Any value to validate the type of. * @returns Whenever `meta` is an {@link ImageMetadata}-like object. */ function validateImageMetadata(meta: unknown):meta is ImageMetadata { if(typeof meta !== "object" || meta === null) return false; if(!("type" in meta) || ((meta as {type:unknown}).type !== "PNG" && (meta as {type:unknown}).type !== "SVG")) return false; if(!("width" in meta) || (typeof (meta as {width:unknown}).width !== "number" && (meta as {width:unknown}).width !== null)) return false; if(!("height" in meta) || (typeof (meta as {height:unknown}).height !== "number" && (meta as {height:unknown}).height !== null)) return false; return true; } const enum FileHeader { PNG = 0x89504e47, XPM2 = 0x58504d32, XPM3 = 0x58504D20 } /** * A function to fetch metadata from buffer in PNG or SVG format. * * @remarks * * For PNGs, it gets required information (like image width or height) * from IHDR header (if it is correct according to spec), otherwise it sets * dimension values to `null`. * * For SVGs, it gets information about the dimensions from `<svg>` tag. If it is * missing, this function will return `null` for `width` and/or `height`. * * This function will also recognize file formats based on *MAGIC* headers – for * SVGs, it looks for existence of `<svg>` tag, for PNGs it looks if file starts * from the specific bytes. * * @param image PNG/SVG/XPM image buffer. */ export function getImageMetadata(image:Buffer):ImageMetadata { const svgMagic = { file: /<svg ?[^>]*>/, width: /<svg (?!width).*.width=["']?(\d+)(?:px)?["']?[^>]*>/, height: /<svg (?!height).*.height=["']?(\d+)(?:px)?["']?[^>]*>/ }; const partialMeta: Partial<ImageMetadata> = {}; if(image.readUInt32BE() === FileHeader.PNG) partialMeta["type"] = "PNG"; else if(image.readUInt32BE(2) === FileHeader.XPM2) partialMeta["type"] = "XPM2"; else if(image.readUInt32BE(3) === FileHeader.XPM3) partialMeta["type"] = "XPM3"; else if(svgMagic.file.test(image.toString("utf8"))) partialMeta["type"] = "SVG"; else throw Error("Unsupported image format (FreeDesktop spec expects images only of following MIME type: PNG, SVG and XPM)."); switch(partialMeta.type) { // Based on specification by W3C: https://www.w3.org/TR/PNG/ case "PNG": { const prefixIHDR = 4+image.indexOf("IHDR") const rawMeta = { width: prefixIHDR === 3 ? null : image.readInt32BE(prefixIHDR), height: prefixIHDR === 3 ? null : image.readInt32BE(prefixIHDR+4) } partialMeta["width"] = (rawMeta.width??0) === 0 ? null : rawMeta.width; partialMeta["height"] = (rawMeta.height??0) === 0 ? null : rawMeta.height; break; } case "SVG": { const svgImage = image.toString("utf8"); const rawMeta = { width: parseInt(svgImage.match(svgMagic.width)?.[1]??""), height: parseInt(svgImage.match(svgMagic.height)?.[1]??""), } partialMeta["width"] = isNaN(rawMeta["width"]) ? null : rawMeta["width"]; partialMeta["height"] = isNaN(rawMeta["height"]) ? null : rawMeta["height"]; break; } default: if(typeof partialMeta["type"] === "string") throw new Error(`Not yet supported image format: '${partialMeta["type"]}'.`); else throw new TypeError(`Invalid type of 'partialMeta.type': '${typeof partialMeta["type"]}' (should be 'string')`); } if(validateImageMetadata(partialMeta)) return partialMeta; throw new TypeError("Malformed function return type! ("+JSON.stringify(partialMeta)+")."); }