@reforged/maker-appimage
Version:
An AppImage maker implementation for the Electron Forge.
224 lines (208 loc) • 8.11 kB
text/typescript
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"
});