@reforged/maker-appimage
Version:
An AppImage maker implementation for the Electron Forge.
218 lines • 9.05 kB
JavaScript
import EventEmitter from "events";
import { existsSync } from "fs";
import { readFile } from "fs/promises";
import { execFileSync, execFile } from "child_process";
import { coerce } from "semver";
;
// FIXME: Library considerations? Should we make for it separate module?
export function generateDesktop(desktopEntry, actions) {
function toEscapeSeq(string) {
if (typeof string === "string")
return string
.replaceAll(/\\(?!["`trn])/g, "\\\\")
.replaceAll("`", "\\`")
.replaceAll("\t", "\\t")
.replaceAll("\r", "\\r")
.replaceAll("\n", "\\n");
return string;
}
const template = { desktop: [], actions: [] };
let actionsKey = 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) {
let lastProgress = 0, stderrCollector = "";
const event = 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) => {
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) => {
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 = 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) {
const buffArr = [];
for (const path of filesAndBuffers)
// Convert anything to Uint8Array as buffer representation
if (path instanceof 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 = 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) {
if (typeof meta !== "object" || meta === null)
return false;
if (!("type" in meta) || (meta.type !== "PNG" && meta.type !== "SVG"))
return false;
if (!("width" in meta) || (typeof meta.width !== "number" && meta.width !== null))
return false;
if (!("height" in meta) || (typeof meta.height !== "number" && meta.height !== null))
return false;
return true;
}
/**
* 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) {
const svgMagic = {
file: /<svg ?[^>]*>/,
width: /<svg (?!width).*.width=["']?(\d+)(?:px)?["']?[^>]*>/,
height: /<svg (?!height).*.height=["']?(\d+)(?:px)?["']?[^>]*>/
};
const partialMeta = {};
if (image.readUInt32BE() === 2303741511 /* FileHeader.PNG */)
partialMeta["type"] = "PNG";
else if (image.readUInt32BE(2) === 1481657650 /* FileHeader.XPM2 */)
partialMeta["type"] = "XPM2";
else if (image.readUInt32BE(3) === 1481657632 /* 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) + ").");
}
//# sourceMappingURL=utils.js.map