UNPKG

app-builder-lib

Version:
346 lines 18.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LinuxTargetHelper = exports.installPrefix = void 0; const builder_util_1 = require("builder-util"); const builder_util_runtime_1 = require("builder-util-runtime"); const fs_extra_1 = require("fs-extra"); const lazy_val_1 = require("lazy-val"); const path_1 = require("path"); const semver = require("semver"); const core24_1 = require("./snap/core24"); const coreCustom_1 = require("./snap/coreCustom"); const coreLegacy_1 = require("./snap/coreLegacy"); /** * Escape a string value for use in a freedesktop .desktop file string field * (Name, Comment, StartupWMClass, etc.). * * The freedesktop Desktop Entry Specification requires that the following * characters be escaped in string / localestring values: * \n → \\n (newline — would inject new key=value lines otherwise) * \r → \\r * \t → \\t * \\ → \\\\ * * Without escaping, a productName or description containing a literal newline * can inject arbitrary key=value pairs into the generated .desktop file, * potentially overriding the Exec key. * * @see https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#value-types */ function desktopStringEscape(value) { return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); } /** * Characters that require an Exec argument to be double-quoted per the * freedesktop Desktop Entry Specification. Plain alphanumeric args and * field codes must NOT be wrapped in quotes. * * @see https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables */ const EXEC_RESERVED_RE = /[\s"'`\\<>~|&;$*?#()]/; /** * Quote a single argument for use in a .desktop file Exec key. * * Field codes (`%f`, `%u`, `%F`, `%U`, etc.) MUST be left unquoted — the * desktop launcher only expands them in unquoted token positions. Wrapping * them in `"…"` causes the launcher to treat them as literal strings, which * breaks file-association / drag-and-drop functionality. * * For all other arguments, double-quoting is used when the argument contains * any character that would be misinterpreted by the launcher without quoting * (spaces, shell metacharacters, etc.). Safe plain-word args are passed * through unchanged to keep the Exec line readable. * * @see https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables */ function desktopExecArgEscape(arg) { // Field codes (%f, %u, %F, %U, %i, %c, %k, …) must never be quoted. if (/^%[a-zA-Z]$/.test(arg)) { return arg; } // Only quote when the arg actually contains characters that need it. if (EXEC_RESERVED_RE.test(arg)) { return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } return arg; } function mapLinuxCompressionToSnap(level) { if (level === "store") { return "lzo"; } if (level === "maximum") { return "xz"; } return undefined; } exports.installPrefix = "/opt"; class LinuxTargetHelper { constructor(packager) { this.packager = packager; this.iconPromise = new lazy_val_1.Lazy(() => this.computeDesktopIcons()); this.mimeTypeFilesPromise = new lazy_val_1.Lazy(() => this.computeMimeTypeFiles()); this.maxIconPath = null; } get icons() { return this.iconPromise.value; } get mimeTypeFiles() { return this.mimeTypeFilesPromise.value; } getSnapCore() { const { snapcraft, snap: legacySnap } = this.packager.config; if (snapcraft != null && legacySnap != null) { builder_util_1.log.warn("Both `snapcraft` and `snap` configurations are present. `snapcraft` takes precedence; please remove the `snap` key to silence this warning."); } // Merge linux-level options (category, description, mimeTypes, etc.) as the base so they // propagate into the generated snapcraft.yaml and .desktop file without requiring users to // duplicate them under core24/core18/etc. Per-core options always win for conflicts. // linux.compression is a CompressionLevel ("store"/"normal"/"maximum"); snap compression is an // algorithm ("xz"/"lzo"). Map the level to the nearest algorithm; per-core options override. const { compression: linuxCompression, ...linuxOptions } = this.packager.platformSpecificBuildOptions; const snapLinuxOptions = { ...linuxOptions, compression: mapLinuxCompressionToSnap(linuxCompression) }; if (snapcraft != null) { const core = snapcraft.base; const options = snapcraft[core] || {}; switch (core) { case "core18": case "core20": case "core22": if (!this.isElectronVersionGreaterOrEqualThan("4.0.0")) { if (!this.isElectronVersionGreaterOrEqualThan("2.0.0-beta.1")) { throw new builder_util_1.InvalidConfigurationError("Electron 2 and higher is required to build Snap with core18/core20/core22"); } builder_util_1.log.warn(null, "electron 4 and higher is highly recommended for Snap with core18/core20/core22"); } return new coreLegacy_1.SnapCoreLegacy(this.packager, this, (0, builder_util_runtime_1.deepAssign)({}, snapLinuxOptions, { base: core, ...options })); case "core24": if (!this.isElectronVersionGreaterOrEqualThan("28.0.0")) { if (!this.isElectronVersionGreaterOrEqualThan("25.0.0")) { throw new builder_util_1.InvalidConfigurationError("Electron 25 and higher is required to build Snap with core24"); } builder_util_1.log.warn(null, "electron 28 and higher is highly recommended for Snap with core24"); } return new core24_1.SnapCore24(this.packager, this, (0, builder_util_runtime_1.deepAssign)({}, snapLinuxOptions, options)); case "custom": // Pass-through: do not inject linux options into user-supplied yaml return new coreCustom_1.SnapCoreCustom(this.packager, this, snapcraft.custom || {}); } } if (legacySnap != null) { builder_util_1.log.warn({ reason: "`snap` configuration is deprecated", docs: "https://www.electron.build/snapcraft", }, "please consider migrating `snap` configuration to `snapcraft.<core>` and remove `snap` configuration"); } return new coreLegacy_1.SnapCoreLegacy(this.packager, this, (0, builder_util_runtime_1.deepAssign)({}, snapLinuxOptions, legacySnap !== null && legacySnap !== void 0 ? legacySnap : {})); } isElectronVersionGreaterOrEqualThan(version, fallback) { const electronVersion = this.packager.config.electronVersion; if (!electronVersion) { return fallback ? semver.gte(fallback, version) : true; } return semver.gte(electronVersion, version); } async computeMimeTypeFiles() { const items = []; for (const fileAssociation of this.packager.fileAssociations) { if (!fileAssociation.mimeType) { continue; } const data = `<mime-type type="${fileAssociation.mimeType}"> <glob pattern="*.${fileAssociation.ext}"/> ${fileAssociation.description ? `<comment>${fileAssociation.description}</comment>` : ""} <icon name="x-office-document" /> </mime-type>`; items.push(data); } if (items.length === 0) { return null; } const file = await this.packager.getTempFile(".xml"); await (0, fs_extra_1.outputFile)(file, '<?xml version="1.0" encoding="utf-8"?>\n<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">\n' + items.join("\n") + "\n</mime-info>"); return file; } // must be name without spaces and other special characters, but not product name used async computeDesktopIcons() { var _a, _b, _c; const packager = this.packager; const { platformSpecificBuildOptions, config } = packager; const sources = [platformSpecificBuildOptions.icon, (_b = (_a = config.mac) === null || _a === void 0 ? void 0 : _a.icon) !== null && _b !== void 0 ? _b : config.icon].filter(str => !!str); // If no explicit sources are defined, fallback to buildResources directory, then default framework icon let fallbackSources = [...(0, builder_util_1.asArray)(packager.getDefaultFrameworkIcon())]; const buildResources = (_c = config.directories) === null || _c === void 0 ? void 0 : _c.buildResources; if (buildResources && (await (0, builder_util_1.exists)((0, path_1.join)(buildResources, "icons")))) { fallbackSources = [buildResources, ...fallbackSources]; } // need to put here and not as default because need to resolve image size const result = await packager.resolveIcon(sources, fallbackSources, "set"); this.maxIconPath = result[result.length - 1].file; // Ignore .icon files for linux (they are exclusive for macOS) return result.filter(icon => !icon.file.endsWith(".icon")); } getDescription(options) { return options.description || this.packager.appInfo.description; } getSanitizedVersion(target) { const { appInfo: { version }, } = this.packager; switch (target) { case "pacman": return version.replace(/-/g, "_"); case "rpm": case "deb": return version.replace(/-/g, "~"); default: return version; } } async writeDesktopEntry(targetSpecificOptions, exec, destination, extra) { const data = await this.computeDesktopEntry(targetSpecificOptions, exec, extra); const file = destination || (await this.packager.getTempFile(`${this.packager.appInfo.productFilename}.desktop`)); await (0, fs_extra_1.outputFile)(file, data); return file; } getDesktopFileName(fallback = this.packager.executableName) { var _a; if (!this.packager.platformSpecificBuildOptions.syncDesktopName) { return fallback; } const trimmedDesktopName = (_a = this.packager.info.metadata.desktopName) === null || _a === void 0 ? void 0 : _a.trim(); if ((0, builder_util_1.isEmptyOrSpaces)(trimmedDesktopName)) { return fallback; } const basename = trimmedDesktopName.replace(/\.desktop$/, ""); // Guard against path traversal: desktopName flows into filesystem paths // (snap/gui/<name>.desktop, /usr/share/applications/<name>.desktop, etc.). if (/[/\\]/.test(basename) || [...basename].some(c => c.charCodeAt(0) === 0)) { throw new builder_util_1.InvalidConfigurationError(`desktopName "${trimmedDesktopName}" produces an invalid .desktop filename — remove any path separators or NUL characters`); } return basename; } computeDesktopEntry(targetSpecificOptions, exec, extra) { var _a, _b, _c, _d, _e, _f, _g; if (exec != null && exec.length === 0) { throw new Error("Specified exec is empty"); } // https://github.com/electron-userland/electron-builder/issues/3418 if ((_b = (_a = targetSpecificOptions.desktop) === null || _a === void 0 ? void 0 : _a.entry) === null || _b === void 0 ? void 0 : _b.Exec) { throw new Error("Please specify executable name as linux.executableName instead of linux.desktop.Exec"); } const packager = this.packager; const appInfo = packager.appInfo; const executableArgs = targetSpecificOptions.executableArgs; if (exec == null) { exec = `${exports.installPrefix}/${appInfo.sanitizedProductName}/${packager.executableName}`; if (!/^[/0-9A-Za-z._-]+$/.test(exec)) { exec = `"${exec}"`; } if (executableArgs) { exec += " "; // Each arg is double-quoted per the freedesktop Exec key spec so that // spaces, $, ;, & and other reserved characters are not misinterpreted. exec += executableArgs.map(desktopExecArgEscape).join(" "); } // https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables const execCodes = ["%f", "%u", "%F", "%U"]; if (executableArgs == null || executableArgs.findIndex(arg => execCodes.includes(arg)) === -1) { exec += " %U"; } } // https://github.com/electron-userland/electron-builder/issues/9103 // Electron derives app_id from desktopName in package.json; StartupWMClass must match. // https://github.com/electron/electron/blob/9a7b73b5334f1d72c08e2d5e94106706ed751186/lib/browser/init.ts#L128-L133 const trimmedDesktopName = (_c = packager.info.metadata.desktopName) === null || _c === void 0 ? void 0 : _c.trim(); if ((0, builder_util_1.isEmptyOrSpaces)(trimmedDesktopName)) { builder_util_1.log.warn({ reason: "desktopName is not set in package.json", docs: "https://www.electron.build/linux#window-association-desktopname--syncdesktopname", }, "electron uses desktopName as app_id / WM_CLASS for window association. Without it desktop environments may not link running windows to this .desktop entry. Set desktopName in package.json and linux.syncDesktopName: true to fix."); } const wmClass = !(0, builder_util_1.isEmptyOrSpaces)(trimmedDesktopName) ? trimmedDesktopName.replace(/\.desktop$/, "") : appInfo.productName; const desktopMeta = (0, builder_util_runtime_1.deepAssign)({ // String values are escaped per the freedesktop spec (\\, \n, \r, \t) // so that a product name containing a newline cannot inject new key=value // pairs into the .desktop file (e.g. overriding the Exec key). Name: desktopStringEscape(appInfo.productName), Exec: exec, Terminal: "false", Type: "Application", Icon: packager.executableName, // https://askubuntu.com/questions/367396/what-represent-the-startupwmclass-field-of-a-desktop-file // Set to desktopName (minus .desktop suffix) when provided, so it matches Electron's // app_id and desktop environments can associate running windows with this entry. // Falls back to productName for apps that don't set desktopName. // to get WM_CLASS of running window: xprop WM_CLASS // StartupWMClass doesn't work for unicode // https://github.com/electron/electron/blob/2-0-x/atom/browser/native_window_views.cc#L226 StartupWMClass: desktopStringEscape(wmClass), }, extra, (_e = (_d = targetSpecificOptions.desktop) === null || _d === void 0 ? void 0 : _d.entry) !== null && _e !== void 0 ? _e : {}); const description = this.getDescription(targetSpecificOptions); if (!(0, builder_util_1.isEmptyOrSpaces)(description)) { desktopMeta.Comment = desktopStringEscape(description); } const mimeTypes = (0, builder_util_1.asArray)(targetSpecificOptions.mimeTypes); for (const fileAssociation of packager.fileAssociations) { if (fileAssociation.mimeType != null) { mimeTypes.push(fileAssociation.mimeType); } } for (const protocol of (0, builder_util_1.asArray)(packager.config.protocols).concat((0, builder_util_1.asArray)(packager.platformSpecificBuildOptions.protocols))) { for (const scheme of (0, builder_util_1.asArray)(protocol.schemes)) { mimeTypes.push(`x-scheme-handler/${scheme}`); } } if (mimeTypes.length !== 0) { desktopMeta.MimeType = mimeTypes.join(";") + ";"; } let category = targetSpecificOptions.category; if ((0, builder_util_1.isEmptyOrSpaces)(category)) { const macCategory = (packager.config.mac || {}).category; if (macCategory != null) { category = macToLinuxCategory[macCategory]; } if (category == null) { // https://github.com/develar/onshape-desktop-shell/issues/48 if (macCategory != null) { builder_util_1.log.warn({ macCategory }, "cannot map macOS category to Linux. If possible mapping is known for you, please file issue to add it."); } builder_util_1.log.warn({ reason: "linux.category is not set and cannot map from macOS", docs: "https://www.electron.build/linux", }, 'application Linux category is set to default "Utility"'); category = "Utility"; } } desktopMeta.Categories = `${category}${category.endsWith(";") ? "" : ";"}`; let data = `[Desktop Entry]`; for (const name of Object.keys(desktopMeta)) { data += `\n${name}=${desktopMeta[name]}`; } data += "\n"; const desktopActions = (_g = (_f = targetSpecificOptions.desktop) === null || _f === void 0 ? void 0 : _f.desktopActions) !== null && _g !== void 0 ? _g : {}; for (const [actionName, config] of Object.entries(desktopActions)) { if (!Object.keys(config !== null && config !== void 0 ? config : {}).length) { continue; } data += `\n[Desktop Action ${actionName}]`; for (const [key, value] of Object.entries(config !== null && config !== void 0 ? config : {})) { data += `\n${key}=${value}`; } data += "\n"; } return Promise.resolve(data); } } exports.LinuxTargetHelper = LinuxTargetHelper; const macToLinuxCategory = { "public.app-category.graphics-design": "Graphics", "public.app-category.developer-tools": "Development", "public.app-category.education": "Education", "public.app-category.games": "Game", "public.app-category.video": "Video;AudioVideo", "public.app-category.utilities": "Utility", "public.app-category.social-networking": "Network;Chat", "public.app-category.finance": "Office;Finance", "public.app-category.music": "Audio;AudioVideo", }; //# sourceMappingURL=LinuxTargetHelper.js.map