app-builder-lib
Version:
electron-builder lib
323 lines (308 loc) • 13.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildStaticRuntimeAppImage = buildStaticRuntimeAppImage;
exports.buildLegacyFuse2AppImage = buildLegacyFuse2AppImage;
exports.validateCriticalPathString = validateCriticalPathString;
exports.generateAppRunScript = generateAppRunScript;
const builder_util_1 = require("builder-util");
const fs = require("fs-extra");
const path = require("path");
const linux_1 = require("../../toolsets/linux");
const appLauncher_1 = require("./appLauncher");
const differentialUpdateInfoBuilder_1 = require("../differentialUpdateInfoBuilder");
const AppImageTarget_1 = require("./AppImageTarget");
async function buildStaticRuntimeAppImage(appimageToolVersion, opts) {
const { stageDir, output, appDir, options, arch } = opts;
try {
await fs.remove(output);
// Write AppRun launcher and related files
await writeAppLauncherAndRelatedFiles(opts);
const { runtimeLibraries: libraries, runtime, mksquashfs } = await (0, linux_1.getAppImageTools)(appimageToolVersion, arch);
await (0, builder_util_1.copyDir)(libraries, path.join(stageDir, "usr", "lib"));
// Copy app directory to stage
// mksquashfs doesn't support merging, so we copy the entire app dir
await (0, builder_util_1.copyDir)(appDir, stageDir);
const runtimeData = await fs.readFile(runtime);
// Create squashfs with offset for runtime
const args = [stageDir, output, "-offset", runtimeData.length.toString(), "-all-root", "-noappend", "-no-progress", "-quiet", "-no-xattrs", "-no-fragments"];
if (options.compression) {
args.push("-comp", options.compression);
}
await (0, builder_util_1.exec)(mksquashfs, args, {
cwd: stageDir,
});
// Write runtime data at the beginning of the file
await writeRuntimeData(output, runtimeData);
// Make executable
await fs.chmod(output, 0o755);
// Append blockmap inside try block to ensure cleanup on failure
const updateInfo = await (0, differentialUpdateInfoBuilder_1.appendBlockmap)(output);
return updateInfo;
}
catch (error) {
// Clean up partial build on failure
await fs.remove(output).catch(() => { });
throw error;
}
}
async function buildLegacyFuse2AppImage(opts) {
const { stageDir, output, appDir, options, arch } = opts;
try {
await fs.remove(output);
await writeAppLauncherAndRelatedFiles(opts);
const { runtime, mksquashfs, runtimeLibraries } = await (0, linux_1.getAppImageTools)("0.0.0", arch);
// Mirror the app-builder-lib Go implementation: bundle lib/<arch> into usr/lib for x64 and ia32.
// arm targets don't have a dedicated lib dir in the FUSE2 toolset.
if (arch === builder_util_1.Arch.x64 || arch === builder_util_1.Arch.ia32) {
await (0, builder_util_1.copyDir)(runtimeLibraries, path.join(stageDir, "usr", "lib"));
}
await (0, builder_util_1.copyDir)(appDir, stageDir);
const runtimeData = await fs.readFile(runtime);
const args = [stageDir, output, "-offset", runtimeData.length.toString(), "-all-root", "-noappend", "-no-progress", "-quiet", "-no-xattrs", "-no-fragments"];
if (options.compression) {
args.push("-comp", options.compression);
if (options.compression === "xz") {
// Match the dictionary/block-size settings used by the original app-builder Go implementation
args.push("-Xdict-size", "100%", "-b", "1048576");
}
}
await (0, builder_util_1.exec)(mksquashfs, args, { cwd: stageDir });
await writeRuntimeData(output, runtimeData);
await fs.chmod(output, 0o755);
const updateInfo = await (0, differentialUpdateInfoBuilder_1.appendBlockmap)(output);
return updateInfo;
}
catch (error) {
await fs.remove(output).catch(() => { });
throw error;
}
}
async function writeRuntimeData(filePath, runtimeData) {
const fd = await fs.open(filePath, "r+");
try {
await fs.write(fd, runtimeData, 0, runtimeData.length, 0);
}
finally {
try {
await fs.close(fd);
}
catch (closeError) {
// Log but don't throw - preserve original error if any
builder_util_1.log.warn({ message: closeError.message, file: filePath }, `failed to close file descriptor`);
}
}
}
/**
* Escapes a string for safe use in shell scripts by wrapping in single quotes
* and escaping any single quotes within the string.
*
* This allows strings with spaces, special characters, etc. to be safely used.
*/
function escapeShellString(str) {
// Escape single quotes by replacing ' with '\''
// Then wrap the whole string in single quotes
return `'${str.replace(/'/g, "'\\''")}'`;
}
/**
* Validates that critical path fields (executable name, product filename, license filename)
* contain only characters that are safe for use in filesystem paths and embedded bash strings.
* Allowed: Unicode letters, digits, dots, underscores, hyphens, and spaces.
*/
function validateCriticalPathString(str, fieldName) {
if (!/^[\p{L}\p{N}._\- ]+$/u.test(str)) {
throw new builder_util_1.InvalidConfigurationError(`${fieldName} contains characters that cannot be safely used in file paths: ${str}. Please use only letters, digits, hyphens, underscores, dots, and spaces.`);
}
}
async function writeAppLauncherAndRelatedFiles(opts) {
const { stageDir, options: { license, executableName, productFilename, productName, desktopEntry, desktopBaseName }, } = opts;
// executableName and productFilename are embedded directly into double-quoted bash strings
// and used as filesystem paths, so they must pass the whitelist.
// productName is not validated here because it is only ever passed through escapeShellString().
validateCriticalPathString(executableName, "executableName");
validateCriticalPathString(productFilename, "productFilename");
// Write desktop file
const desktopFileName = `${desktopBaseName !== null && desktopBaseName !== void 0 ? desktopBaseName : executableName}.desktop`;
await fs.writeFile(path.join(stageDir, desktopFileName), desktopEntry, { mode: 0o644 });
await (0, appLauncher_1.copyIcons)(opts);
const templateConfig = {
DesktopFileName: desktopFileName,
ExecutableName: executableName,
ProductName: productName,
ProductFilename: productFilename,
ResourceName: `appimagekit-${executableName}`,
};
const mimeTypeFile = await (0, appLauncher_1.copyMimeTypes)(stageDir, opts.options);
if (mimeTypeFile) {
templateConfig.MimeTypeFile = mimeTypeFile;
}
let finalConfig = templateConfig;
// Copy license file if provided
if (license) {
// Validate license file exists
if (!(await (0, builder_util_1.exists)(license))) {
throw new builder_util_1.InvalidConfigurationError(`License file not found: ${license}`);
}
const licenseBaseName = path.basename(license);
const ext = path.extname(license).toLowerCase();
// Validate license filename for path safety
validateCriticalPathString(licenseBaseName, "licenseBaseName");
// Validate extension
if (![".txt", ".html"].includes(ext)) {
builder_util_1.log.warn({ license, expected: ".txt or .html" }, `license file has unexpected extension`);
}
await (0, builder_util_1.copyFile)(license, path.join(stageDir, licenseBaseName));
finalConfig = {
...templateConfig,
EulaFile: licenseBaseName,
IsHtmlEula: ext === ".html",
};
}
const appRunContent = generateAppRunScript(finalConfig);
await fs.writeFile(path.join(stageDir, AppImageTarget_1.APP_RUN_ENTRYPOINT), appRunContent, { mode: 0o755 });
}
function hasEula(config) {
return "EulaFile" in config && typeof config.EulaFile === "string";
}
function generateAppRunScript(config) {
const eulaEnabled = hasEula(config);
return `#!/usr/bin/env bash
set -e
THIS="$0"
# http://stackoverflow.com/questions/3190818/
args=("$@")
NUMBER_OF_ARGS="$#"
if [ -z "$APPDIR" ] ; then
# Find the AppDir. It is the directory that contains AppRun.
# This assumes that this script resides inside the AppDir or a subdirectory.
# If this script is run inside an AppImage, then the AppImage runtime likely has already set $APPDIR
path="$(dirname "$(readlink -f "\${THIS}")")"
while [[ "$path" != "" && ! -e "$path/${AppImageTarget_1.APP_RUN_ENTRYPOINT}" ]]; do
path=\${path%/*}
done
APPDIR="$path"
fi
if [ -z "$APPDIR" ] ; then
echo "ERROR: could not locate the AppDir. Ensure this script is run from within a properly structured AppImage." >&2
exit 1
fi
export PATH="\${APPDIR}:\${APPDIR}/usr/sbin\${PATH:+:\${PATH}}"
export XDG_DATA_DIRS="\${APPDIR}/usr/share/\${XDG_DATA_DIRS:+:\${XDG_DATA_DIRS}}:/usr/share/gnome:/usr/local/share/:/usr/share/"
export LD_LIBRARY_PATH="\${APPDIR}/usr/lib\${LD_LIBRARY_PATH:+:\${LD_LIBRARY_PATH}}"
export GSETTINGS_SCHEMA_DIR="\${APPDIR}/usr/share/glib-2.0/schemas\${GSETTINGS_SCHEMA_DIR:+:\${GSETTINGS_SCHEMA_DIR}}"
BIN="$APPDIR/${config.ExecutableName}"
if [ -z "$APPIMAGE_EXIT_AFTER_INSTALL" ] ; then
trap atexit EXIT
fi
isEulaAccepted=1
HAVE_NO_SANDBOX=0
for arg in "\${args[@]}" ; do
if [ "$arg" = --no-sandbox ] ; then
HAVE_NO_SANDBOX=1
break
fi
done
NO_SANDBOX=()
# Use 'unshare -Ur true' as a heuristic to detect whether user namespaces are available.
# Notes:
# - When running as root, this check will always succeed even if the sandbox configuration
# actually relies on unprivileged user namespaces. In practice, Chrome/Electron usually
# disables or adjusts the sandbox separately when running as root, so this probe is mostly
# a no-op in that scenario.
# - On minimal systems (e.g. Alpine or stripped-down containers) 'unshare' may not exist.
# In that case the shell will return exit code 127 ("command not found"), which will cause
# us to add '--no-sandbox'. This is an intentional fail-safe: we prefer the app to start
# without sandboxing rather than crash on startup.
if [ $HAVE_NO_SANDBOX -eq 0 ] && ! unshare -Ur true 2>/dev/null ; then
NO_SANDBOX=(--no-sandbox)
fi
atexit()
{
if [ $isEulaAccepted == 1 ] ; then
if [ $NUMBER_OF_ARGS -eq 0 ] ; then
exec "$BIN" "\${NO_SANDBOX[@]}"
else
exec "$BIN" "\${NO_SANDBOX[@]}" "\${args[@]}"
fi
fi
}
error()
{
if [ -x /usr/bin/zenity ] ; then
LD_LIBRARY_PATH="" zenity --error --text "\${1}" 2>/dev/null
elif [ -x /usr/bin/kdialog ] ; then
LD_LIBRARY_PATH="" kdialog --msgbox "\${1}" 2>/dev/null
elif [ -x /usr/bin/Xdialog ] ; then
LD_LIBRARY_PATH="" Xdialog --msgbox "\${1}" 2>/dev/null
else
echo "\${1}"
fi
exit 1
}
yesno()
{
TITLE=$1
TEXT=$2
if [ -x /usr/bin/zenity ] ; then
LD_LIBRARY_PATH="" zenity --question --title="$TITLE" --text="$TEXT" 2>/dev/null || exit 0
elif [ -x /usr/bin/kdialog ] ; then
LD_LIBRARY_PATH="" kdialog --title "$TITLE" --yesno "$TEXT" || exit 0
elif [ -x /usr/bin/Xdialog ] ; then
LD_LIBRARY_PATH="" Xdialog --title "$TITLE" --clear --yesno "$TEXT" 10 80 || exit 0
else
echo "zenity, kdialog, Xdialog missing. Skipping \${THIS}."
exit 0
fi
}
check_dep()
{
DEP=$1
if ! command -v "$DEP" &>/dev/null ; then
echo "$DEP is missing. Skipping \${THIS}."
exit 0
fi
}
if [ -z "$APPIMAGE" ] ; then
APPIMAGE="$APPDIR/${AppImageTarget_1.APP_RUN_ENTRYPOINT}"
# not running from within an AppImage; hence using the AppRun for Exec=
fi
${eulaEnabled
? `if [ -z "$APPIMAGE_SILENT_INSTALL" ] ; then
EULA_MARK_DIR="\${XDG_CONFIG_HOME:-$HOME/.config}/${config.ProductFilename}"
EULA_MARK_FILE="$EULA_MARK_DIR/eulaAccepted"
# show EULA only if desktop file doesn't exist
if [ ! -e "$EULA_MARK_FILE" ] ; then
if [ -x /usr/bin/zenity ] ; then
# on cancel simply exits and our trap handler launches app, so, $isEulaAccepted is set here to 0 and then to 1 if EULA accepted
isEulaAccepted=0
LD_LIBRARY_PATH="" zenity --text-info --title=${escapeShellString(config.ProductName)} --filename="$APPDIR/${config.EulaFile}" --ok-label=Agree --cancel-label=Disagree ${config.IsHtmlEula ? "--html" : ""}
elif [ -x /usr/bin/Xdialog ] ; then
isEulaAccepted=0
LD_LIBRARY_PATH="" Xdialog --title ${escapeShellString(config.ProductName)} --textbox "$APPDIR/${config.EulaFile}" 30 80 --ok-label Agree --cancel-label Disagree
elif [ -x /usr/bin/kdialog ] ; then
# cannot find any option to force Agree/Disagree buttons for kdialog. And official example exactly with OK button https://techbase.kde.org/Development/Tutorials/Shell_Scripting_with_KDE_Dialogs#Example_21._--textbox_dialog_box
# in any case we pass labels text
isEulaAccepted=0
LD_LIBRARY_PATH="" kdialog --textbox "$APPDIR/${config.EulaFile}" --yes-label Agree --cancel-label "Disagree"
fi
case $? in
0)
isEulaAccepted=1
echo "License accepted"
mkdir -p "$EULA_MARK_DIR"
touch "$EULA_MARK_FILE"
;;
1)
echo "License not accepted"
exit 0
;;
-1)
echo "An unexpected error has occurred."
isEulaAccepted=1
;;
esac
fi
fi`
: ""}
`;
}
//# sourceMappingURL=appImageUtil.js.map