Initial commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dinlo
2026-05-31 18:44:04 +08:00
commit 436a9631fc
8616 changed files with 1389957 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
import { Arch } from "builder-util";
import { Target } from "../../core";
import { LinuxPackager } from "../../linuxPackager";
import { AppImageOptions } from "../../options/linuxOptions";
import { LinuxTargetHelper } from "../LinuxTargetHelper";
export declare const APP_RUN_ENTRYPOINT = "AppRun";
export default class AppImageTarget extends Target {
private readonly packager;
private readonly helper;
readonly outDir: string;
readonly options: AppImageOptions;
private readonly desktopEntry;
constructor(_ignored: string, packager: LinuxPackager, helper: LinuxTargetHelper, outDir: string);
build(appOutDir: string, arch: Arch): Promise<any>;
private buildFuse2AppImage;
}
+134
View File
@@ -0,0 +1,134 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.APP_RUN_ENTRYPOINT = void 0;
const builder_util_1 = require("builder-util");
const fs_extra_1 = require("fs-extra");
const lazy_val_1 = require("lazy-val");
const path = require("path");
const core_1 = require("../../core");
const PublishManager_1 = require("../../publish/PublishManager");
const appBuilder_1 = require("../../util/appBuilder");
const license_1 = require("../../util/license");
const targetUtil_1 = require("../targetUtil");
const appImageUtil_1 = require("./appImageUtil");
// https://unix.stackexchange.com/questions/375191/append-to-sub-directory-inside-squashfs-file
exports.APP_RUN_ENTRYPOINT = "AppRun";
class AppImageTarget extends core_1.Target {
constructor(_ignored, packager, helper, outDir) {
super("appImage");
this.packager = packager;
this.helper = helper;
this.outDir = outDir;
this.options = { ...this.packager.platformSpecificBuildOptions, ...this.packager.config[this.name] };
this.desktopEntry = new lazy_val_1.Lazy(() => {
var _a;
const args = ((_a = this.options.executableArgs) === null || _a === void 0 ? void 0 : _a.join(" ")) || "--no-sandbox";
return helper.computeDesktopEntry(this.options, `${exports.APP_RUN_ENTRYPOINT} ${args} %U`, {
"X-AppImage-Version": `${packager.appInfo.buildVersion}`,
});
});
}
async build(appOutDir, arch) {
var _a;
const packager = this.packager;
const options = this.options;
// https://github.com/electron-userland/electron-builder/issues/775
// https://github.com/electron-userland/electron-builder/issues/1726
const artifactName = packager.expandArtifactNamePattern(options, "AppImage", arch);
const artifactPath = path.join(this.outDir, artifactName);
await packager.info.emitArtifactBuildStarted({
targetPresentableName: "AppImage",
file: artifactPath,
arch,
});
// Parallelize independent async operations
const [publishConfig, stageDir, desktopEntry, icons, license] = await Promise.all([
(0, PublishManager_1.getAppUpdatePublishConfiguration)(packager, options, arch, false),
(0, targetUtil_1.createStageDir)(this, packager, arch),
this.desktopEntry.value,
this.helper.icons,
(0, license_1.getNotLocalizedLicenseFile)(options.license, this.packager, ["txt", "html"]),
]);
if (publishConfig != null) {
await (0, fs_extra_1.outputFile)(path.join(packager.getResourcesDir(appOutDir), "app-update.yml"), (0, builder_util_1.serializeToYaml)(publishConfig));
}
if (this.packager.packagerOptions.effectiveOptionComputed != null && (await this.packager.packagerOptions.effectiveOptionComputed({ desktop: desktopEntry }))) {
await stageDir.cleanup();
return;
}
let updateInfo;
try {
const appimageTool = (_a = this.packager.config.toolsets) === null || _a === void 0 ? void 0 : _a.appimage;
if (appimageTool == null || appimageTool === "0.0.0") {
updateInfo = await this.buildFuse2AppImage({ stageDir, arch, artifactPath, appOutDir, options, packager, desktopEntry, icons, license });
}
else {
updateInfo = await (0, appImageUtil_1.buildAppImage)({
appDir: appOutDir,
stageDir: stageDir.dir,
arch,
output: artifactPath,
options: {
productName: this.packager.appInfo.productName,
productFilename: this.packager.appInfo.productFilename,
executableName: this.packager.executableName,
license,
desktopEntry,
icons,
fileAssociations: this.packager.fileAssociations,
compression: this.packager.compression === "maximum" ? "xz" : undefined,
},
});
}
}
catch (error) {
builder_util_1.log.error({ error: error.message }, "failed to build AppImage");
await stageDir.cleanup().catch(() => { });
throw error;
}
await stageDir.cleanup();
await packager.info.emitArtifactBuildCompleted({
file: artifactPath,
safeArtifactName: packager.computeSafeArtifactName(artifactName, "AppImage", arch, false),
target: this,
arch,
packager,
isWriteUpdateInfo: true,
updateInfo,
});
}
async buildFuse2AppImage(props) {
const { stageDir, arch, artifactPath, appOutDir, options, packager, desktopEntry, icons, license } = props;
const args = [
"appimage",
"--stage",
stageDir.dir,
"--arch",
builder_util_1.Arch[arch],
"--output",
artifactPath,
"--app",
appOutDir,
"--configuration",
JSON.stringify({
productName: this.packager.appInfo.productName,
productFilename: this.packager.appInfo.productFilename,
desktopEntry,
executableName: this.packager.executableName,
icons,
fileAssociations: this.packager.fileAssociations,
...options,
}),
];
(0, appBuilder_1.objectToArgs)(args, {
license,
});
if (packager.compression === "maximum") {
args.push("--compression", "xz");
}
const updateInfo = await (0, appBuilder_1.executeAppBuilderAsJson)(args);
return updateInfo;
}
}
exports.default = AppImageTarget;
//# sourceMappingURL=AppImageTarget.js.map
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
import { Arch } from "builder-util";
import { FileAssociation } from "../../options/FileAssociation";
import { IconInfo } from "../../platformPackager";
import { BlockMapDataHolder } from "builder-util-runtime";
interface Options {
productName: string;
productFilename: string;
executableName: string;
desktopEntry: string;
icons: IconInfo[];
license?: string | null;
fileAssociations: FileAssociation[];
compression?: "xz" | "lzo" | "zstd";
}
export interface AppImageBuilderOptions {
appDir: string;
stageDir: string;
arch: Arch;
output: string;
options: Options;
}
export declare function buildAppImage(opts: AppImageBuilderOptions): Promise<BlockMapDataHolder>;
export {};
+266
View File
@@ -0,0 +1,266 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildAppImage = buildAppImage;
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 buildAppImage(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)(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);
if (options.compression === "xz") {
args.push("-Xdict-size", "100%", "-b", "1048576");
}
}
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 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 executable/filename fields don't contain dangerous characters
* that could break paths or cause security issues even when escaped.
*/
function validateCriticalPathString(str, fieldName) {
// Only reject characters that would break filesystem paths or cause severe issues
// Allow quotes, spaces, etc. since they can be escaped
if (/[`${}|&;<>\n\r\0]/.test(str) || str.includes("/") || str.includes("\\")) {
throw new builder_util_1.InvalidConfigurationError(`${fieldName} contains characters that cannot be safely used in file paths: ${str}. ` +
`Please use only alphanumeric characters, hyphens, underscores, dots, spaces, and quotes.`);
}
}
async function writeAppLauncherAndRelatedFiles(opts) {
const { stageDir, options: { license, executableName, productFilename, productName, desktopEntry }, } = opts;
// Validate only critical path fields for severe path-breaking characters
// productName and productFilename can contain quotes, spaces, etc. - they'll be escaped
validateCriticalPathString(executableName, "executableName");
validateCriticalPathString(productFilename, "productFilename");
// Write desktop file
const desktopFileName = `${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)(opts);
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 `#!/bin/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
export PATH="\${APPDIR}:\${APPDIR}/usr/sbin:\${PATH}"
export XDG_DATA_DIRS="./share/:/usr/share/gnome:/usr/local/share/:/usr/share/:\${XDG_DATA_DIRS}"
export LD_LIBRARY_PATH="\${APPDIR}/usr/lib:\${LD_LIBRARY_PATH}"
export XDG_DATA_DIRS="\${APPDIR}"/usr/share/:"\${XDG_DATA_DIRS}":/usr/share/gnome/:/usr/local/share/:/usr/share/
export GSETTINGS_SCHEMA_DIR="\${APPDIR}/usr/share/glib-2.0/schemas:\${GSETTINGS_SCHEMA_DIR}"
BIN="$APPDIR/${config.ExecutableName}"
if [ -z "$APPIMAGE_EXIT_AFTER_INSTALL" ] ; then
trap atexit EXIT
fi
isEulaAccepted=1
atexit()
{
if [ $isEulaAccepted == 1 ] ; then
if [ $NUMBER_OF_ARGS -eq 0 ] ; then
exec "$BIN"
else
exec "$BIN" "\${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
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
import { AppImageBuilderOptions } from "./appImageUtil";
export declare function copyIcons(options: AppImageBuilderOptions): Promise<void>;
export declare function copyMimeTypes(options: AppImageBuilderOptions): Promise<string | null>;
+91
View File
@@ -0,0 +1,91 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.copyIcons = copyIcons;
exports.copyMimeTypes = copyMimeTypes;
const path = require("path");
const fs = require("fs-extra");
const builder_util_1 = require("builder-util");
const ICON_DIR_RELATIVE_PATH = "usr/share/icons/hicolor";
const MIME_TYPE_DIR_RELATIVE_PATH = "usr/share/mime/packages";
/**
* Escapes special XML characters to prevent injection
*/
function xmlEscape(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
async function copyIcons(options) {
const { stageDir, options: configuration } = options;
const iconCommonDir = path.join(stageDir, ICON_DIR_RELATIVE_PATH);
await fs.ensureDir(iconCommonDir);
const icons = configuration.icons;
if (!icons || icons.length === 0) {
throw new Error("At least one icon is required for AppImage");
}
const iconExtWithDot = path.extname(icons[0].file);
const iconFileName = `${configuration.executableName}${iconExtWithDot}`;
const maxIconIndex = icons.length - 1;
const iconInfoList = icons.map(icon => {
if (path.extname(icon.file) !== iconExtWithDot) {
throw new Error(`All icons must have the same extension: expected ${iconExtWithDot}, but got ${icon.file}`);
}
let iconSizeDir;
if (iconExtWithDot === ".svg") {
// SVG icons go in scalable/apps directory per freedesktop icon theme spec
iconSizeDir = "scalable/apps";
}
else {
iconSizeDir = `${icon.size}x${icon.size}/apps`;
}
const iconRelativeToStageFile = path.join(ICON_DIR_RELATIVE_PATH, iconSizeDir, iconFileName);
const iconDir = path.join(iconCommonDir, iconSizeDir);
const iconFile = path.join(iconDir, iconFileName);
return { icon, iconDir, iconFile, iconRelativeToStageFile };
});
await Promise.all(iconInfoList.map(async ({ icon, iconDir, iconFile }) => {
await fs.ensureDir(iconDir);
await (0, builder_util_1.copyOrLinkFile)(icon.file, iconFile);
}));
// Create symlinks for the last (largest) icon
const { iconRelativeToStageFile } = iconInfoList[maxIconIndex];
await fs.symlink(iconRelativeToStageFile, path.join(stageDir, iconFileName));
await fs.symlink(iconRelativeToStageFile, path.join(stageDir, ".DirIcon"));
}
async function copyMimeTypes(options) {
const { stageDir, options: { fileAssociations, productName, executableName }, } = options;
if (!fileAssociations || fileAssociations.length === 0) {
return null;
}
const mimeTypeParts = [];
for (const fileAssociation of fileAssociations) {
if (!fileAssociation.mimeType) {
continue;
}
// XML-escape to prevent injection
mimeTypeParts.push(`<mime-type type="${xmlEscape(fileAssociation.mimeType)}">`);
mimeTypeParts.push(` <comment>${xmlEscape(productName)} document</comment>`);
// Handle extension(s)
const extensions = Array.isArray(fileAssociation.ext) ? fileAssociation.ext : [fileAssociation.ext];
for (const ext of extensions) {
// Validate extension doesn't contain dangerous characters
if (!/^[a-zA-Z0-9_-]+$/.test(ext)) {
builder_util_1.log.warn({ extension: ext }, `file extension contains unexpected characters and may not be supported`);
}
mimeTypeParts.push(` <glob pattern="*.${xmlEscape(ext)}"/>`);
}
mimeTypeParts.push(' <generic-icon name="x-office-document"/>');
mimeTypeParts.push("</mime-type>");
}
// If no mime-types were generated, return null
if (mimeTypeParts.length === 0) {
return null;
}
const mimeTypeDir = path.join(stageDir, MIME_TYPE_DIR_RELATIVE_PATH);
const fileName = `${executableName}.xml`;
const mimeTypeFile = path.join(mimeTypeDir, fileName);
await fs.ensureDir(mimeTypeDir);
const xmlContent = ['<?xml version="1.0"?>', '<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">', ...mimeTypeParts, "</mime-info>"].join("\n");
// Use 0o644 (rw-r--r--) instead of 0o666 to avoid world-writable permissions
await fs.writeFile(mimeTypeFile, xmlContent, { mode: 0o644 });
return path.join(MIME_TYPE_DIR_RELATIVE_PATH, fileName);
}
//# sourceMappingURL=appLauncher.js.map
File diff suppressed because one or more lines are too long