diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cab9d38d2b1..1c067516737 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -321,8 +321,6 @@ jobs: test-linux-native: name: Test Linux Native (non-Docker) - # disabled for now - if: false runs-on: ubuntu-24.04 timeout-minutes: 60 steps: @@ -348,7 +346,7 @@ jobs: run: | pnpm ci:test env: - TEST_FILES: flatpakTest,snapHeavyTest + TEST_FILES: flatpakTest,snapHeavyTest,snapTest VITEST_SMART_CACHE_FILE: ${{ github.workspace }}/test/vitest-scripts/_vitest-smart-cache.json RESET_VITEST_SHARD_CACHE: ${{ inputs.reset-vitest-smart-cache }} diff --git a/.vscode/launch.json b/.vscode/launch.json index 1b6abe50859..e28f9036b25 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,7 +22,7 @@ "console": "integratedTerminal", "internalConsoleOptions": "openOnFirstSessionStart", "env": { - "TEST_FILES": "packageManagerTest", + "TEST_FILES": "snapHeavyTest", "UPDATE_SNAPSHOT": "false", "DEBUG": "electron-builder", "TEST_SEQUENTIAL": "true" // Run tests sequentially to debug issues that may be hidden by concurrency (console log pollution when DEBUG flag set) diff --git a/packages/app-builder-lib/src/index.ts b/packages/app-builder-lib/src/index.ts index 29ab91e15be..b1c030d737f 100644 --- a/packages/app-builder-lib/src/index.ts +++ b/packages/app-builder-lib/src/index.ts @@ -44,7 +44,7 @@ export { MsiOptions } from "./options/MsiOptions" export { MsiWrappedOptions } from "./options/MsiWrappedOptions" export { BackgroundAlignment, BackgroundScaling, PkgBackgroundOptions, PkgOptions } from "./options/pkgOptions" export { AsarOptions, FileSet, FilesBuildOptions, PlatformSpecificBuildOptions, Protocol, ReleaseInfo } from "./options/PlatformSpecificBuildOptions" -export { PlugDescriptor, SlotDescriptor, SnapOptions } from "./options/SnapOptions" +export { PlugDescriptor, SlotDescriptor, SnapBaseOptions as SnapOptions } from "./options/SnapOptions" export { SquirrelWindowsOptions } from "./options/SquirrelWindowsOptions" export { WindowsAzureSigningConfiguration, WindowsConfiguration, WindowsSigntoolConfiguration } from "./options/winOptions" export { BuildResult, Packager } from "./packager" diff --git a/packages/app-builder-lib/src/linuxPackager.ts b/packages/app-builder-lib/src/linuxPackager.ts index ba42b5ad534..5ec467f50ea 100644 --- a/packages/app-builder-lib/src/linuxPackager.ts +++ b/packages/app-builder-lib/src/linuxPackager.ts @@ -8,7 +8,7 @@ import AppImageTarget from "./targets/appimage/AppImageTarget" import FlatpakTarget from "./targets/FlatpakTarget" import FpmTarget from "./targets/FpmTarget" import { LinuxTargetHelper } from "./targets/LinuxTargetHelper" -import SnapTarget from "./targets/snap" +import SnapTarget from "./targets/snap/SnapTarget" import { createCommonTarget } from "./targets/targetFactory" export class LinuxPackager extends PlatformPackager { @@ -44,7 +44,7 @@ export class LinuxPackager extends PlatformPackager { case "appimage": return require("./targets/appimage/AppImageTarget").default case "snap": - return require("./targets/snap").default + return require("./targets/snap/SnapTarget").default case "flatpak": return require("./targets/FlatpakTarget").default case "deb": diff --git a/packages/app-builder-lib/src/options/SnapOptions.ts b/packages/app-builder-lib/src/options/SnapOptions.ts index 1ea3fbcb59a..6dd7afb2152 100644 --- a/packages/app-builder-lib/src/options/SnapOptions.ts +++ b/packages/app-builder-lib/src/options/SnapOptions.ts @@ -1,9 +1,90 @@ import { TargetSpecificOptions } from "../core" +import { SnapcraftYAML } from "../targets/snap/snapcraft" import { CommonLinuxOptions } from "./linuxOptions" -export interface SnapOptions extends CommonLinuxOptions, TargetSpecificOptions { +export interface SnapOptions extends TargetSpecificOptions { /** - * A snap of type base to be used as the execution environment for this snap. Examples: `core`, `core18`, `core20`, `core22`. Defaults to `core20` + * A snap of type base to be used as the execution environment for this snap. Examples: `core18`, `core20`, `core22`, `core24`. + * @default core24 + */ + readonly core: "core18" | "core20" | "core22" | "core24" | "custom" + readonly core18?: SnapOptionsLegacy | null + readonly core20?: SnapOptionsLegacy | null + readonly core22?: SnapOptionsLegacy | null + readonly core24?: SnapOptions24 | null + readonly custom?: SnapcraftYAML | null +} + +export interface SnapOptionsLegacy extends SnapBaseOptions { + /** + * Whether to use template snap. Defaults to `true` if `stagePackages` is not specified. + */ + readonly useTemplateApp?: boolean +} + +export interface RemoteBuildOptions { + // Whether to enable remote build. Explicit true/false required. + enabled: boolean + + // Your Launchpad ID + launchpadUsername?: string + + // Remote build (multi-architecture) + // Example - buildFor: ['amd64', 'arm64', 'armhf'] + buildFor?: string[] // Target architectures + + // Auto-accept public upload + acceptPublicUpload?: boolean + + // Remote build with private project + privateProject?: string + + // Example: Remote build with credentials file (for CI/CD) + sshKeyPath?: string + // OR, generate credentials: snapcraft export-login credentials.txt + credentialsFile?: string + + // Resume interrupted build + recover?: boolean + + // Build timeout in seconds + timeout?: number + + strategy?: "disable-fallback" | "force-fallback" + + /** + * Allow running the program with native wayland support with --ozone-platform=wayland. + * Disabled by default because of this issue in older Electron/Snap versions: https://github.com/electron-userland/electron-builder/issues/4007 + * @default false + */ + readonly allowNativeWayland?: boolean | null +} + +export interface SnapOptions24 extends SnapBaseOptions { + /** + * The list of debian packages needs to be installed for building this snap. + * @default ["gnome"] + */ + readonly extensions?: Array | null + + readonly useGnomeExtension?: boolean | null + + readonly remoteBuild?: RemoteBuildOptions | null + + useLXD?: boolean | null + readonly useMultipass?: boolean | null + useDestructiveMode?: boolean | null + + /** + * Whether or not to enable Wayland support natively. + * @default true + */ + readonly allowNativeWayland?: boolean | null +} + +export interface SnapBaseOptions extends CommonLinuxOptions { + /** + * A snap of type base to be used as the execution environment for this snap. Examples: `core`, `core18`, `core20`, `core22`, `core24`. Defaults to `core24` */ readonly base?: string | null @@ -103,11 +184,6 @@ export interface SnapOptions extends CommonLinuxOptions, TargetSpecificOptions { */ readonly after?: Array | null - /** - * Whether to use template snap. Defaults to `true` if `stagePackages` not specified. - */ - readonly useTemplateApp?: boolean - /** * Whether or not the snap should automatically start on login. * @default false @@ -122,7 +198,7 @@ export interface SnapOptions extends CommonLinuxOptions, TargetSpecificOptions { /** * Specifies which files from the app part to stage and which to exclude. Individual files, directories, wildcards, globstars, and exclusions are accepted. See [Snapcraft filesets](https://snapcraft.io/docs/snapcraft-filesets) to learn more about the format. * - * The defaults can be found in [snap.ts](https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/templates/snap/snapcraft.yaml#L29). + * The defaults can be found in [snapcraft.ts](https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/src/targets/snap/snapcraft.ts). */ readonly appPartStage?: Array | null @@ -137,8 +213,7 @@ export interface SnapOptions extends CommonLinuxOptions, TargetSpecificOptions { readonly compression?: "xz" | "lzo" | null /** - * Allow running the program with native wayland support with --ozone-platform=wayland. - * Disabled by default because of this issue in older Electron/Snap versions: https://github.com/electron-userland/electron-builder/issues/4007 + * Allow running the program with native wayland support. */ readonly allowNativeWayland?: boolean | null } diff --git a/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts b/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts index 14f24257c71..c309b669168 100644 --- a/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts +++ b/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts @@ -1,10 +1,14 @@ -import { asArray, exists, isEmptyOrSpaces, log } from "builder-util" +import { asArray, exists, InvalidConfigurationError, isEmptyOrSpaces, log } from "builder-util" import { outputFile } from "fs-extra" import { Lazy } from "lazy-val" import { join } from "path" import { LinuxPackager } from "../linuxPackager" import { LinuxTargetSpecificOptions } from "../options/linuxOptions" import { IconInfo } from "../platformPackager" +import { SnapCore } from "./snap/SnapTarget" +import { SnapBaseOptions } from "../options/SnapOptions" +import { SnapCoreLegacy } from "./snap/coreLegacy" +import { SnapCore24 } from "./snap/core24" export const installPrefix = "/opt" @@ -25,6 +29,38 @@ export class LinuxTargetHelper { return this.mimeTypeFilesPromise.value } + getSnapCore(): SnapCore { + const snap = this.packager.config.snap! + const core = snap.core || "core24" + switch (core) { + case "core18": + case "core20": + case "core22": + if (!this.isElectronVersionGreaterOrEqualThan("4.0.0")) { + if (!this.isElectronVersionGreaterOrEqualThan("2.0.0-beta.1")) { + throw new InvalidConfigurationError("Electron 2 and higher is required to build Snap with core18/core20/core22") + } + log.warn(null, "electron 4 and higher is highly recommended for Snap with core18/core20/core22") + } + return new SnapCoreLegacy(this.packager, this, (snap as any)[core] || {}) + case "core24": + if (!this.isElectronVersionGreaterOrEqualThan("28.0.0")) { + if (!this.isElectronVersionGreaterOrEqualThan("25.0.0")) { + throw new InvalidConfigurationError("Electron 25 and higher is required to build Snap with core24") + } + log.warn(null, "electron 28 and higher is highly recommended for Snap with core24") + } + return new SnapCore24(this.packager, this, snap.core24 || {}) + case "custom": + throw new InvalidConfigurationError("Custom snapcraft.yaml is not yet supported") + } + } + + isElectronVersionGreaterOrEqualThan(version: string) { + return true + // return semver.gte(this.packager.config.electronVersion, version) + } + private async computeMimeTypeFiles(): Promise { const items: Array = [] for (const fileAssociation of this.packager.fileAssociations) { diff --git a/packages/app-builder-lib/src/targets/snap.ts b/packages/app-builder-lib/src/targets/snap.ts deleted file mode 100644 index 6dfc35a1552..00000000000 --- a/packages/app-builder-lib/src/targets/snap.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { replaceDefault as _replaceDefault, Arch, deepAssign, executeAppBuilder, InvalidConfigurationError, log, serializeToYaml, toLinuxArchString } from "builder-util" -import { asArray, Nullish, SnapStoreOptions } from "builder-util-runtime" -import { outputFile, readFile } from "fs-extra" -import { load } from "js-yaml" -import * as path from "path" -import * as semver from "semver" -import { Configuration } from "../configuration" -import { Publish, Target } from "../core" -import { LinuxPackager } from "../linuxPackager" -import { PlugDescriptor, SnapOptions } from "../options/SnapOptions" -import { getTemplatePath } from "../util/pathManager" -import { LinuxTargetHelper } from "./LinuxTargetHelper" -import { createStageDirPath } from "./targetUtil" - -const defaultPlugs = ["desktop", "desktop-legacy", "home", "x11", "wayland", "unity7", "browser-support", "network", "gsettings", "audio-playback", "pulseaudio", "opengl"] - -export default class SnapTarget extends Target { - readonly options: SnapOptions = { ...this.packager.platformSpecificBuildOptions, ...(this.packager.config as any)[this.name] } - - public isUseTemplateApp = false - - constructor( - name: string, - private readonly packager: LinuxPackager, - private readonly helper: LinuxTargetHelper, - readonly outDir: string - ) { - super(name) - } - - private replaceDefault(inList: Array | Nullish, defaultList: Array) { - const result = _replaceDefault(inList, defaultList) - if (result !== defaultList) { - this.isUseTemplateApp = false - } - return result - } - - private async createDescriptor(arch: Arch): Promise { - if (!this.isElectronVersionGreaterOrEqualThan("4.0.0")) { - if (!this.isElectronVersionGreaterOrEqualThan("2.0.0-beta.1")) { - throw new InvalidConfigurationError("Electron 2 and higher is required to build Snap") - } - - log.warn("Electron 4 and higher is highly recommended for Snap") - } - - const appInfo = this.packager.appInfo - const snapName = this.packager.executableName.toLowerCase() - const options = this.options - - const plugs = normalizePlugConfiguration(this.options.plugs) - - const plugNames = this.replaceDefault(plugs == null ? null : Object.getOwnPropertyNames(plugs), defaultPlugs) - - const slots = normalizePlugConfiguration(this.options.slots) - - const buildPackages = asArray(options.buildPackages) - const defaultStagePackages = getDefaultStagePackages() - const stagePackages = this.replaceDefault(options.stagePackages, defaultStagePackages) - - this.isUseTemplateApp = - this.options.useTemplateApp !== false && - (arch === Arch.x64 || arch === Arch.armv7l) && - buildPackages.length === 0 && - isArrayEqualRegardlessOfSort(stagePackages, defaultStagePackages) - - const appDescriptor: any = { - command: "command.sh", - plugs: plugNames, - adapter: "none", - } - - const snap: any = load(await readFile(path.join(getTemplatePath("snap"), "snapcraft.yaml"), "utf-8")) - if (this.isUseTemplateApp) { - delete appDescriptor.adapter - } - if (options.base != null) { - snap.base = options.base - // from core22 onwards adapter is legacy - if (Number(snap.base.split("core")[1]) >= 22) { - delete appDescriptor.adapter - } - } - if (options.grade != null) { - snap.grade = options.grade - } - if (options.confinement != null) { - snap.confinement = options.confinement - } - if (options.appPartStage != null) { - snap.parts.app.stage = options.appPartStage - } - if (options.layout != null) { - snap.layout = options.layout - } - if (slots != null) { - appDescriptor.slots = Object.getOwnPropertyNames(slots) - for (const slotName of appDescriptor.slots) { - const slotOptions = slots[slotName] - if (slotOptions == null) { - continue - } - if (!snap.slots) { - snap.slots = {} - } - snap.slots[slotName] = slotOptions - } - } - - deepAssign(snap, { - name: snapName, - version: appInfo.version, - title: options.title || appInfo.productName, - summary: options.summary || appInfo.productName, - compression: options.compression, - description: this.helper.getDescription(options), - architectures: [toLinuxArchString(arch, "snap")], - apps: { - [snapName]: appDescriptor, - }, - parts: { - app: { - "stage-packages": stagePackages, - }, - }, - }) - - if (options.autoStart) { - appDescriptor.autostart = `${snap.name}.desktop` - } - - if (options.confinement === "classic") { - delete appDescriptor.plugs - delete snap.plugs - } else { - const archTriplet = archNameToTriplet(arch) - const environment: Record = { - PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH", - SNAP_DESKTOP_RUNTIME: "$SNAP/gnome-platform", - LD_LIBRARY_PATH: [ - "$SNAP_LIBRARY_PATH", - "$SNAP/lib:$SNAP/usr/lib:$SNAP/lib/" + archTriplet + ":$SNAP/usr/lib/" + archTriplet, - "$LD_LIBRARY_PATH:$SNAP/lib:$SNAP/usr/lib", - "$SNAP/lib/" + archTriplet + ":$SNAP/usr/lib/" + archTriplet, - ].join(":"), - ...options.environment, - } - // Determine whether Wayland should be disabled based on: - // - Electron version (<38 historically had Wayland disabled) - // - Explicit allowNativeWayland override. - // https://github.com/electron-userland/electron-builder/issues/9320 - const allow = options.allowNativeWayland - const isOldElectron = !this.isElectronVersionGreaterOrEqualThan("38.0.0") - if ( - (allow == null && isOldElectron) || // No explicit option -> use legacy behavior for old Electron - allow === false // Explicitly disallowed - ) { - environment.DISABLE_WAYLAND = "1" - } - - appDescriptor.environment = environment - - if (plugs != null) { - for (const plugName of plugNames) { - const plugOptions = plugs[plugName] - if (plugOptions == null) { - continue - } - - snap.plugs[plugName] = plugOptions - } - } - } - - if (buildPackages.length > 0) { - snap.parts.app["build-packages"] = buildPackages - } - if (options.after != null) { - snap.parts.app.after = options.after - } - - if (options.assumes != null) { - snap.assumes = asArray(options.assumes) - } - - return snap - } - - async build(appOutDir: string, arch: Arch): Promise { - const packager = this.packager - const options = this.options - // tslint:disable-next-line:no-invalid-template-strings - const artifactName = packager.expandArtifactNamePattern(this.options, "snap", arch, "${name}_${version}_${arch}.${ext}", false) - const artifactPath = path.join(this.outDir, artifactName) - await packager.info.emitArtifactBuildStarted({ - targetPresentableName: "snap", - file: artifactPath, - arch, - }) - - const snap = await this.createDescriptor(arch) - - const stageDir = await createStageDirPath(this, packager, arch) - const snapArch = toLinuxArchString(arch, "snap") - const args = ["snap", "--app", appOutDir, "--stage", stageDir, "--arch", snapArch, "--output", artifactPath, "--executable", this.packager.executableName] - - await this.helper.icons - if (this.helper.maxIconPath != null) { - if (!this.isUseTemplateApp) { - snap.icon = "snap/gui/icon.png" - } - args.push("--icon", this.helper.maxIconPath) - } - - // snapcraft.yaml inside a snap directory - const snapMetaDir = path.join(stageDir, this.isUseTemplateApp ? "meta" : "snap") - const desktopFile = path.join(snapMetaDir, "gui", `${snap.name}.desktop`) - await this.helper.writeDesktopEntry(this.options, packager.executableName + " %U", desktopFile, { - // tslint:disable:no-invalid-template-strings - Icon: "${SNAP}/meta/gui/icon.png", - }) - - const extraAppArgs: Array = options.executableArgs ?? [] - if (this.isElectronVersionGreaterOrEqualThan("5.0.0") && !isBrowserSandboxAllowed(snap)) { - const noSandboxArg = "--no-sandbox" - if (!extraAppArgs.includes(noSandboxArg)) { - extraAppArgs.push(noSandboxArg) - } - if (this.isUseTemplateApp) { - args.push("--exclude", "chrome-sandbox") - } - } - if (extraAppArgs.length > 0) { - args.push("--extraAppArgs=" + extraAppArgs.join(" ")) - } - - if (snap.compression != null) { - args.push("--compression", snap.compression) - } - - if (this.isUseTemplateApp) { - // remove fields that are valid in snapcraft.yaml, but not snap.yaml - const fieldsToStrip = ["compression", "contact", "donation", "issues", "parts", "source-code", "website"] - for (const field of fieldsToStrip) { - delete snap[field] - } - } - - if (packager.packagerOptions.effectiveOptionComputed != null && (await packager.packagerOptions.effectiveOptionComputed({ snap, desktopFile, args }))) { - return - } - - await outputFile(path.join(snapMetaDir, this.isUseTemplateApp ? "snap.yaml" : "snapcraft.yaml"), serializeToYaml(snap)) - - const hooksDir = await packager.getResource(options.hooks, "snap-hooks") - if (hooksDir != null) { - args.push("--hooks", hooksDir) - } - - if (this.isUseTemplateApp) { - args.push("--template-url", `electron4:${snapArch}`) - } - - await executeAppBuilder(args) - - const publishConfig = findSnapPublishConfig(this.packager.config) - - await packager.info.emitArtifactBuildCompleted({ - file: artifactPath, - safeArtifactName: packager.computeSafeArtifactName(artifactName, "snap", arch, false), - target: this, - arch, - packager, - publishConfig, - }) - } - - private isElectronVersionGreaterOrEqualThan(version: string) { - return semver.gte(this.packager.config.electronVersion || "7.0.0", version) - } -} - -function findSnapPublishConfig(config?: Configuration): SnapStoreOptions | null { - const fallback: SnapStoreOptions = { provider: "snapStore" } - - if (!config) { - return fallback - } - - if (config.snap?.publish) { - return findSnapPublishConfigInPublishNode(config.snap.publish) - } - - if (config.linux?.publish) { - const configCandidate = findSnapPublishConfigInPublishNode(config.linux.publish) - - if (configCandidate) { - return configCandidate - } - } - - if (config.publish) { - const configCandidate = findSnapPublishConfigInPublishNode(config.publish) - - if (configCandidate) { - return configCandidate - } - } - - return fallback -} - -function findSnapPublishConfigInPublishNode(configPublishNode: Publish): SnapStoreOptions | null { - if (!configPublishNode) { - return null - } - - if (Array.isArray(configPublishNode)) { - for (const configObj of configPublishNode) { - if (isSnapStoreOptions(configObj)) { - return configObj - } - } - } - - if (typeof configPublishNode === `object` && isSnapStoreOptions(configPublishNode)) { - return configPublishNode - } - - return null -} - -function isSnapStoreOptions(configPublishNode: Publish): configPublishNode is SnapStoreOptions { - const snapStoreOptionsCandidate = configPublishNode as SnapStoreOptions - return snapStoreOptionsCandidate?.provider === `snapStore` -} - -function archNameToTriplet(arch: Arch): string { - switch (arch) { - case Arch.x64: - return "x86_64-linux-gnu" - case Arch.ia32: - return "i386-linux-gnu" - case Arch.armv7l: - // noinspection SpellCheckingInspection - return "arm-linux-gnueabihf" - case Arch.arm64: - return "aarch64-linux-gnu" - - default: - throw new Error(`Unsupported arch ${arch}`) - } -} - -function isArrayEqualRegardlessOfSort(a: Array, b: Array) { - a = a.slice() - b = b.slice() - a.sort() - b.sort() - return a.length === b.length && a.every((value, index) => value === b[index]) -} - -function normalizePlugConfiguration(raw: Array | PlugDescriptor | Nullish): Record | null> | null { - if (raw == null) { - return null - } - - const result: any = {} - for (const item of Array.isArray(raw) ? raw : [raw]) { - if (typeof item === "string") { - result[item] = null - } else { - Object.assign(result, item) - } - } - return result -} - -function isBrowserSandboxAllowed(snap: any): boolean { - if (snap.plugs != null) { - for (const plugName of Object.keys(snap.plugs)) { - const plug = snap.plugs[plugName] - if (plug.interface === "browser-support" && plug["allow-sandbox"] === true) { - return true - } - } - } - return false -} - -function getDefaultStagePackages() { - // libxss1 - was "error while loading shared libraries: libXss.so.1" on Xubuntu 16.04 - // noinspection SpellCheckingInspection - return ["libnspr4", "libnss3", "libxss1", "libappindicator3-1", "libsecret-1-0"] -} diff --git a/packages/app-builder-lib/src/targets/snap/SnapTarget.ts b/packages/app-builder-lib/src/targets/snap/SnapTarget.ts new file mode 100644 index 00000000000..33873d372a9 --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/SnapTarget.ts @@ -0,0 +1,124 @@ +import { Arch } from "builder-util" +import { SnapStoreOptions } from "builder-util-runtime" +import * as path from "path" +import { Configuration } from "../../configuration" +import { Publish, Target } from "../../core" +import { LinuxPackager } from "../../linuxPackager" +import { SnapBaseOptions, SnapOptions } from "../../options/SnapOptions" +import { LinuxTargetHelper } from "../LinuxTargetHelper" +import { createStageDirPath } from "../targetUtil" + +export abstract class SnapCore { + protected abstract defaultPlugs: Array + + constructor( + protected readonly packager: LinuxPackager, + protected readonly helper: LinuxTargetHelper, + protected readonly options: T + ) {} + + abstract createDescriptor(arch: Arch): Promise + abstract buildSnap(params: { snap: any; appOutDir: string; stageDir: string; snapArch: Arch; artifactPath: string }): Promise +} + +export default class SnapTarget extends Target { + readonly options: SnapOptions = { ...this.packager.platformSpecificBuildOptions, ...(this.packager.config as any)[this.name] } + + constructor( + name: string, + protected readonly packager: LinuxPackager, + protected readonly helper: LinuxTargetHelper, + readonly outDir: string + ) { + super(name) + } + + async build(appOutDir: string, arch: Arch): Promise { + const packager = this.packager + // tslint:disable-next-line:no-invalid-template-strings + const artifactName = packager.expandArtifactNamePattern(this.options, "snap", arch, "${name}_${version}_${arch}.${ext}", false) + const artifactPath = path.join(this.outDir, artifactName) + + await packager.info.emitArtifactBuildStarted({ + targetPresentableName: "snap", + file: artifactPath, + arch, + }) + + const core = this.helper.getSnapCore() + + await core.buildSnap({ + snap: await core.createDescriptor(arch), + appOutDir, + stageDir: await createStageDirPath(this, packager, arch), + snapArch: arch, + artifactPath, + }) + + const publishConfig = this.findSnapPublishConfig(packager.config) + + await packager.info.emitArtifactBuildCompleted({ + file: artifactPath, + safeArtifactName: packager.computeSafeArtifactName(artifactName, "snap", arch, false), + target: this, + arch, + packager, + publishConfig, + }) + } + + protected findSnapPublishConfig(config?: Configuration): SnapStoreOptions | null { + const fallback: SnapStoreOptions = { provider: "snapStore" } + + if (!config) { + return fallback + } + + if (config.snap?.publish) { + return this.findSnapPublishConfigInPublishNode(config.snap.publish) + } + + if (config.linux?.publish) { + const configCandidate = this.findSnapPublishConfigInPublishNode(config.linux.publish) + + if (configCandidate) { + return configCandidate + } + } + + if (config.publish) { + const configCandidate = this.findSnapPublishConfigInPublishNode(config.publish) + + if (configCandidate) { + return configCandidate + } + } + + return fallback + } + + private findSnapPublishConfigInPublishNode(configPublishNode: Publish): SnapStoreOptions | null { + if (!configPublishNode) { + return null + } + + if (Array.isArray(configPublishNode)) { + for (const configObj of configPublishNode) { + if (this.isSnapStoreOptions(configObj)) { + return configObj + } + } + } + + if (typeof configPublishNode === `object` && this.isSnapStoreOptions(configPublishNode)) { + return configPublishNode + } + + return null + } + + private isSnapStoreOptions(configPublishNode: Publish): configPublishNode is SnapStoreOptions { + const snapStoreOptionsCandidate = configPublishNode as SnapStoreOptions + return snapStoreOptionsCandidate?.provider === `snapStore` + } +} diff --git a/packages/app-builder-lib/src/targets/snap/core24.ts b/packages/app-builder-lib/src/targets/snap/core24.ts new file mode 100644 index 00000000000..342808904d8 --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/core24.ts @@ -0,0 +1,474 @@ +import { Arch, archFromString, copyDir, log, removeNullish, toLinuxArchString } from "builder-util" +import { copy, mkdir, readdir, writeFile } from "fs-extra" +import * as path from "path" +import { PlugDescriptor, SlotDescriptor, SnapOptions24 } from "../../options/SnapOptions" +import { SnapCore } from "./SnapTarget" +import { App, Part, SnapcraftYAML } from "./snapcraft" +import { buildSnap } from "./snapcraftBuilder" +import * as yaml from "js-yaml" +import { Nullish } from "builder-util-runtime" +import { execSync } from "child_process" + +// Mapping of SnapOptions to SnapcraftYAML +export interface SnapOptionsMapping { + base: SnapcraftYAML["base"] + confinement: SnapcraftYAML["confinement"] + environment: SnapcraftYAML["environment"] + summary: SnapcraftYAML["summary"] + grade: SnapcraftYAML["grade"] + assumes: SnapcraftYAML["assumes"] + hooks: SnapcraftYAML["hooks"] + plugs: SnapcraftYAML["plugs"] + slots: SnapcraftYAML["slots"] + layout: SnapcraftYAML["layout"] + title: SnapcraftYAML["title"] + compression: SnapcraftYAML["compression"] + + buildPackages: Part["build-packages"] + stagePackages: Part["stage-packages"] + after: Part["after"] + appPartStage: Part["stage"] + + autoStart: App["autostart"] + + // Extension support + useGnomeExtension: boolean +} + +const defaultStagePackages = ["libnspr4", "libnss3", "libxss1", "libappindicator3-1", "libsecret-1-0"] + +export class SnapCore24 extends SnapCore { + defaultPlugs = ["desktop", "desktop-legacy", "home", "x11", "wayland", "unity7", "browser-support", "network", "gsettings", "audio-playback", "pulseaudio", "opengl"] + + // Snap file hierarchy: + // - snap/gui/ gets automatically copied to meta/gui/ in the final snap + // - Desktop files in meta/gui/ are used for menu integration + readonly configRelativePath = "snap" + readonly guiRelativePath = path.join(this.configRelativePath, "gui") + + async createDescriptor(arch: Arch): Promise { + return await this.mapSnapOptionsToSnapcraftYAML(arch) + } + + async buildSnap(params: { snap: SnapcraftYAML; appOutDir: string; stageDir: string; snapArch: Arch; artifactPath: string }): Promise { + const { snap, appOutDir, stageDir, artifactPath } = params + + // IMPORTANT: GNOME extension cannot be used with destructive-mode + // The extension's gnome/sdk part tries to install to system paths like /snap/command-chain/ + // which fails with permission denied in destructive mode + const useGnomeExtension = this.options.useGnomeExtension !== false + if (useGnomeExtension && this.options.useDestructiveMode) { + log.warn( + { useGnomeExtension, useDestructiveMode: this.options.useDestructiveMode }, + "GNOME extension cannot be used with destructive-mode. Switching to LXD container build." + ) + // Override destructive mode when using extension + this.options.useDestructiveMode = false + if (!this.options.useLXD && !this.options.useMultipass && !this.options.remoteBuild) { + this.options.useLXD = true + } + } + + const snapDirResolved = path.resolve(stageDir, this.configRelativePath) + const snapcraftYamlPath = path.join(snapDirResolved, "snapcraft.yaml") + + // Create snap/gui directory for desktop files and icons + // Snapcraft will automatically copy snap/gui/ contents to meta/gui/ in the final snap + const guiOutput = path.resolve(stageDir, this.guiRelativePath) + await mkdir(guiOutput, { recursive: true }) + + const yamlContent = yaml.dump(snap, { + indent: 2, + lineWidth: -1, // No line wrapping + noRefs: true, + }) + await writeFile(snapcraftYamlPath, yamlContent, "utf8") + log.debug(snap, "generated snapcraft.yaml") + + // Copy icon to snap/gui/ directory + // Snapcraft will automatically copy this to meta/gui/ in the final snap + const desktopExtraProps: Record = {} + const icon = this.helper.maxIconPath + if (icon) { + const iconFileName = `${snap.name}${path.extname(icon)}` + await copy(icon, path.join(guiOutput, iconFileName)) + // Icon path will be available at ${SNAP}/meta/gui/ after installation + desktopExtraProps.Icon = `\${SNAP}/meta/gui/${iconFileName}` + } + + // Create desktop file in snap/gui/ directory + // Snapcraft will automatically copy this to meta/gui/ in the final snap + const desktopFilePath = path.join(guiOutput, `${snap.name}.desktop`) + await this.helper.writeDesktopEntry(this.options, this.packager.executableName + " %U", desktopFilePath, desktopExtraProps) + + // Copy app files to the project root `app` directory so `source: app` + // in the generated `snapcraft.yaml` (which is under `snap/`) can be + // resolved by snapcraft running in the build environment. + const appDir = path.resolve(stageDir, "app") + if (path.resolve(appDir) !== path.resolve(appOutDir)) { + log.debug({ to: log.filePath(appDir), from: log.filePath(appOutDir) }, "copying app files to project root app directory") + await copyDir(appOutDir, appDir) + } + + // Auto-generate `organize` mapping for the app part so top-level helper + // binaries and resources are placed under `app/` inside the snap. Update + // the already-written `snapcraft.yaml` so the build sees the mapping. + try { + const appPart = snap.parts[snap.name] + if (appPart) { + const entries = await readdir(appOutDir) + const organize: Record = (appPart.organize as Record) || {} + for (const entry of entries) { + if (!entry) { + continue + } + if (organize[entry]) { + continue + } + organize[entry] = `app/${entry}` + } + appPart.organize = organize + + const updatedYaml = yaml.dump(snap, { + indent: 2, + lineWidth: -1, + noRefs: true, + }) + await writeFile(snapcraftYamlPath, updatedYaml, "utf8") + log.debug({ organize }, "updated snapcraft.yaml with organize mapping") + } + } catch (e: any) { + log.debug({ error: e.message }, "failed to generate organize mapping") + } + + await buildSnap({ + snapcraftConfig: snap, + artifactPath, + stageDir, + remoteBuild: this.options.remoteBuild || undefined, + useLXD: this.options.useLXD === true, + useMultipass: this.options.useMultipass === true, + useDestructiveMode: this.options.useDestructiveMode === true, + }) + } + + async mapSnapOptionsToSnapcraftYAML(arch: Arch): Promise { + const appInfo = this.packager.appInfo + const appName = this.packager.executableName.toLowerCase() + const options = this.options + const useGnomeExtension = options.useGnomeExtension !== false // Default to true + + // Create the app part + const appPart: Part = { + plugin: "dump", + source: "app", + "build-packages": options.buildPackages?.length ? options.buildPackages : undefined, + "stage-packages": this.expandDefaultsInArray(options.stagePackages, defaultStagePackages), + after: this.expandDefaultsInArray(options.after, []), + stage: options.appPartStage?.length ? options.appPartStage : undefined, + } + + // Process plugs and slots + // When using GNOME extension, we don't need to manually configure content snaps + // The extension will handle: gnome-46-2404, gtk-3-themes, icon-themes, sound-themes + let rootPlugs: Record | undefined + let appPlugs: string[] | undefined + + if (useGnomeExtension) { + // With GNOME extension, only process user-provided custom plugs + const result = options.plugs ? this.processPlugOrSlots(options.plugs) : { root: undefined, app: undefined } + rootPlugs = result.root + // Extension automatically adds common plugs, so we only add custom ones + appPlugs = result.app + } else { + // Without GNOME extension, we need manual content snaps + const defaultRootPlugs: Record = { + "gtk-3-themes": { + interface: "content", + target: "$SNAP/data-dir/themes", + "default-provider": "gtk-common-themes", + }, + "icon-themes": { + interface: "content", + target: "$SNAP/data-dir/icons", + "default-provider": "gtk-common-themes", + }, + "sound-themes": { + interface: "content", + target: "$SNAP/data-dir/sounds", + "default-provider": "gtk-common-themes", + }, + "gnome-46-2404": { + interface: "content", + target: "$SNAP/gnome-platform", + "default-provider": "gnome-46-2404", + }, + "gpu-2404": { + interface: "content", + target: "$SNAP/gpu-2404", + "default-provider": "mesa-2404", + }, + } + + const result = options.plugs + ? this.processPlugOrSlots(options.plugs) + : { + root: defaultRootPlugs, + app: this.defaultPlugs, + } + rootPlugs = result.root + appPlugs = result.app + } + + const { root: rootSlots, app: appSlots } = options.slots ? this.processPlugOrSlots(options.slots) : { root: {}, app: [] } + + // Create the app configuration + const app: App = { + // When using the extension, don't manually specify command or command-chain + // The extension handles this automatically. Use the executable name (no `app/` prefix) + // because the `dump` plugin copies the contents of the `app` source into the + // part install root (so the executable ends up at the snap root), not in a + // nested `app/` directory inside the snap. + command: `app/${this.packager.executableName}`, + // Don't manually add command-chain when using extension - it adds it automatically + "command-chain": useGnomeExtension ? undefined : ["snap/command-chain/desktop-launch"], + plugs: appPlugs, + slots: appSlots, + autostart: options.autoStart ? `${appName}.desktop` : undefined, + desktop: `meta/gui/${appName}.desktop`, + // Add GNOME extension to the app if enabled + extensions: useGnomeExtension ? ["gnome"] : undefined, + } + + // Icon path in the top-level metadata + const iconPath = (await this.helper.icons) && this.helper.maxIconPath != null ? `\${SNAP}/meta/gui/${appName}${path.extname(this.helper.maxIconPath)}` : undefined + + // Process hooks if configured + const hooksConfig = options.hooks + const hooks = hooksConfig ? await this.processHooks(hooksConfig) : undefined + + // Parts configuration - the extension automatically adds a gnome/sdk part + // Don't manually add desktop-launch when using the extension + const parts: Record = { + [appName]: appPart, + } + + // Only add desktop-launch if NOT using GNOME extension + if (!useGnomeExtension) { + parts["desktop-launch"] = { + plugin: "make", + source: "https://github.com/ubuntu/snapcraft-desktop-helpers.git", + "source-subdir": "gtk", + "build-packages": ["build-essential"], + stage: ["snap/command-chain/desktop-launch"], + } + } + + // Note: `organize` will be generated later in `buildSnap` based on the + // actual contents of the built app directory so helper binaries and + // resources are automatically moved under `app/` in the snap. + + // Build the snapcraft configuration + const snapcraft: SnapcraftYAML = { + // Required fields + name: appName, + base: "core24", + confinement: options.confinement || "strict", + parts: parts, + + // Architecture/Platform + platforms: { + [toLinuxArchString(arch, "snap")]: { + "build-for": toLinuxArchString(arch, "snap"), + "build-on": toLinuxArchString(archFromString(process.arch), "snap"), + }, + }, + + // Metadata - with fallbacks from appInfo + version: appInfo.version, + summary: options.summary || appInfo.productName, + description: appInfo.description || options.summary || appInfo.productName, + grade: options.grade || "stable", + title: options.title || appInfo.productName, + icon: iconPath, + // license: appInfo.metadata?.license, + + // Build configuration + compression: options.compression || undefined, + assumes: this.normalizeAssumesList(options.assumes), + + // Environment + environment: this.buildEnvironment(options), + + // Layout - only add custom layout if NOT using GNOME extension + // The extension provides its own layout + layout: useGnomeExtension ? (options.layout ?? undefined) : this.buildDefaultLayout(options), + + // Interfaces + plugs: rootPlugs, + slots: rootSlots, + + // Hooks + hooks: hooks, + + // Apps + apps: { + [appName]: app, + }, + } + + return removeNullish(snapcraft) + } + + /** + * Build environment variables with proper defaults + */ + private buildEnvironment(options: SnapOptions24): Record | undefined { + const env: Record = {} + + // Add default TMPDIR for Electron/Chromium apps + if (!options.environment?.TMPDIR) { + env.TMPDIR = "$XDG_RUNTIME_DIR" + } + + // Handle Wayland support + if (options.allowNativeWayland === false) { + env.DISABLE_WAYLAND = "1" + } + + // Merge with user-provided environment + if (options.environment) { + Object.assign(env, options.environment) + } + + return Object.keys(env).length > 0 ? env : undefined + } + + /** + * Build default layout for core24 with GNOME platform content snaps (non-extension mode) + * This allows the app to access libraries from the gnome-46-2404 and mesa-2404 content snaps + */ + private buildDefaultLayout(options: SnapOptions24): Record | undefined { + // If user provides custom layout, use that instead + if (options.layout) { + return options.layout + } + + // Default layout for core24 Electron apps using GNOME content snaps WITHOUT extension + return { + "/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/webkit2gtk-4.0": { + bind: "$SNAP/gnome-platform/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/webkit2gtk-4.0", + }, + "/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/webkit2gtk-4.1": { + bind: "$SNAP/gnome-platform/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/webkit2gtk-4.1", + }, + "/usr/share/xml/iso-codes": { + bind: "$SNAP/gnome-platform/usr/share/xml/iso-codes", + }, + "/usr/share/libdrm": { + bind: "$SNAP/gpu-2404/libdrm", + }, + "/usr/share/drirc.d": { + symlink: "$SNAP/gpu-2404/drirc.d", + }, + } + } + + /** + * Process hooks directory into hook definitions + */ + private async processHooks(hooksPath: string): Promise | undefined> { + try { + const hooksDir = path.resolve(this.packager.buildResourcesDir, hooksPath) + const hookFiles = await readdir(hooksDir) + + if (hookFiles.length === 0) { + return undefined + } + + const hooks: Record = {} + for (const hookFile of hookFiles) { + const hookName = path.basename(hookFile, path.extname(hookFile)) + hooks[hookName] = { + // Hook definitions will be populated by snapcraft from the files + // Just register that these hooks exist + } + } + + return hooks + } catch (e: any) { + log.error({ message: e.message }, "error processing Snap hooks directory") + throw e + } + } + + /** + * Normalize assumes list (can be string or array) + */ + normalizeAssumesList(assumes: Array | string | Nullish): string[] | undefined { + if (!assumes) { + return undefined + } + if (typeof assumes === "string") { + return [assumes] + } + return assumes.length > 0 ? assumes : undefined + } + + /** + * Process plugs or slots into root-level definitions and app-level references + */ + processPlugOrSlots | SlotDescriptor | PlugDescriptor | null>( + items: T + ): { + root: Record | undefined + app: string[] | undefined + } { + if (!items || (Array.isArray(items) && items.length === 0)) { + return { root: undefined, app: undefined } + } + const root: Record = {} + const app: string[] = [] + + // Handle single descriptor object + if (!Array.isArray(items)) { + Object.entries(items).forEach(([name, config]) => { + root[name] = config + app.push(name) + }) + return { root, app } + } + + // Handle array - support "default" keyword + const processedItems = this.expandDefaultsInArray(items, this.defaultPlugs) + for (const item of processedItems ?? []) { + if (typeof item === "string") { + // Simple string reference + app.push(item) + } else { + // Descriptor object with configuration + Object.entries(item).forEach(([name, config]) => { + root[name] = config + app.push(name) + }) + } + } + + return { root: Object.keys(root).length > 0 ? root : undefined, app: app.length > 0 ? app : undefined } + } + + /** + * Expand "default" keyword in arrays of anything + */ + private expandDefaultsInArray(items: T[] | Nullish, defaults: T[]): T[] | undefined { + const result: Array = [] + for (const item of items ?? []) { + if (typeof item === "string" && item === "default") { + result.push(...defaults) + } else { + result.push(item) + } + } + return result.length > 0 ? result : undefined + } +} diff --git a/packages/app-builder-lib/src/targets/snap/coreLegacy.ts b/packages/app-builder-lib/src/targets/snap/coreLegacy.ts new file mode 100644 index 00000000000..29fb1f12e74 --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/coreLegacy.ts @@ -0,0 +1,291 @@ +import { getTemplatePath } from "../../util/pathManager" +import { replaceDefault as _replaceDefault, Arch, deepAssign, executeAppBuilder, isArrayEqualRegardlessOfSort, serializeToYaml, toLinuxArchString } from "builder-util" +import { asArray, Nullish } from "builder-util-runtime" +import { outputFile, readFile } from "fs-extra" +import { load } from "js-yaml" +import * as path from "path" +import { PlugDescriptor, SnapOptionsLegacy } from "../../options/SnapOptions" +import { SnapCore } from "./SnapTarget" + +export class SnapCoreLegacy extends SnapCore { + private isUseTemplateApp = false + + defaultPlugs = ["desktop", "desktop-legacy", "home", "x11", "wayland", "unity7", "browser-support", "network", "gsettings", "audio-playback", "pulseaudio", "opengl"] + + private replaceDefault(inList: Array | Nullish, defaultList: Array) { + const result = _replaceDefault(inList, defaultList) + if (result !== defaultList) { + this.isUseTemplateApp = false + } + return result + } + + async createDescriptor(arch: Arch): Promise { + const appInfo = this.packager.appInfo + const snapName = this.packager.executableName.toLowerCase() + const options = this.options + + const plugs = this.normalizePlugConfiguration(this.options.plugs) + + const plugNames = this.replaceDefault(plugs == null ? null : Object.getOwnPropertyNames(plugs), this.defaultPlugs) + + const slots = this.normalizePlugConfiguration(this.options.slots) + + const buildPackages = asArray(options.buildPackages) + const defaultStagePackages = this.getDefaultStagePackages() + const stagePackages = this.replaceDefault(options.stagePackages, defaultStagePackages) + + this.isUseTemplateApp = + this.options.useTemplateApp !== false && + (arch === Arch.x64 || arch === Arch.armv7l) && + buildPackages.length === 0 && + isArrayEqualRegardlessOfSort(stagePackages, defaultStagePackages) + + const appDescriptor: any = { + command: "command.sh", + plugs: plugNames, + adapter: "none", + } + + const snap: any = load(await readFile(path.join(getTemplatePath("snap"), "snapcraft.yaml"), "utf-8")) + if (this.isUseTemplateApp) { + delete appDescriptor.adapter + } + if (options.base != null) { + snap.base = options.base + // from core22 onwards adapter is legacy + if (Number(snap.base.split("core")[1]) >= 22) { + delete appDescriptor.adapter + } + } + if (options.grade != null) { + snap.grade = options.grade + } + if (options.confinement != null) { + snap.confinement = options.confinement + } + if (options.appPartStage != null) { + snap.parts.app.stage = options.appPartStage + } + if (options.layout != null) { + snap.layout = options.layout + } + if (slots != null) { + appDescriptor.slots = Object.getOwnPropertyNames(slots) + for (const slotName of appDescriptor.slots) { + const slotOptions = slots[slotName] + if (slotOptions == null) { + continue + } + if (!snap.slots) { + snap.slots = {} + } + snap.slots[slotName] = slotOptions + } + } + + deepAssign(snap, { + name: snapName, + version: appInfo.version, + title: options.title || appInfo.productName, + summary: options.summary || appInfo.productName, + compression: options.compression, + description: this.helper.getDescription(options), + platforms: [toLinuxArchString(arch, "snap")], + apps: { + [snapName]: appDescriptor, + }, + parts: { + app: { + "stage-packages": stagePackages, + }, + }, + }) + + if (options.autoStart) { + appDescriptor.autostart = `${snap.name}.desktop` + } + + if (options.confinement === "classic") { + delete appDescriptor.plugs + delete snap.plugs + } else { + const archTriplet = this.archNameToTriplet(arch) + const environment: Record = { + PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH", + SNAP_DESKTOP_RUNTIME: "$SNAP/gnome-platform", + LD_LIBRARY_PATH: [ + "$SNAP_LIBRARY_PATH", + "$SNAP/lib:$SNAP/usr/lib:$SNAP/lib/" + archTriplet + ":$SNAP/usr/lib/" + archTriplet, + "$LD_LIBRARY_PATH:$SNAP/lib:$SNAP/usr/lib", + "$SNAP/lib/" + archTriplet + ":$SNAP/usr/lib/" + archTriplet, + ].join(":"), + ...options.environment, + } + // Determine whether Wayland should be disabled based on: + // - Electron version (<38 historically had Wayland disabled) + // - Explicit allowNativeWayland override. + // https://github.com/electron-userland/electron-builder/issues/9320 + const allow = options.allowNativeWayland + const isOldElectron = !this.helper.isElectronVersionGreaterOrEqualThan("38.0.0") + if ( + (allow == null && isOldElectron) || // No explicit option -> use legacy behavior for old Electron + allow === false // Explicitly disallowed + ) { + environment.DISABLE_WAYLAND = "1" + } + + appDescriptor.environment = environment + + if (plugs != null) { + for (const plugName of plugNames) { + const plugOptions = plugs[plugName] + if (plugOptions == null) { + continue + } + + snap.plugs[plugName] = plugOptions + } + } + } + + if (buildPackages.length > 0) { + snap.parts.app["build-packages"] = buildPackages + } + if (options.after != null) { + snap.parts.app.after = options.after + } + + if (options.assumes != null) { + snap.assumes = asArray(options.assumes) + } + + return snap + } + + async buildSnap(props: { snap: any; appOutDir: string; stageDir: string; snapArch: Arch; artifactPath: string }) { + const { snap, appOutDir, stageDir, snapArch, artifactPath } = props + const args = [ + "snap", + "--app", + appOutDir, + "--stage", + stageDir, + "--arch", + toLinuxArchString(snapArch, "snap"), + "--output", + artifactPath, + "--executable", + this.packager.executableName, + ] + + await this.helper.icons + if (this.helper.maxIconPath != null) { + if (!this.isUseTemplateApp) { + snap.icon = "snap/gui/icon.png" + } + args.push("--icon", this.helper.maxIconPath) + } + + // snapcraft.yaml inside a snap directory + const snapMetaDir = path.join(stageDir, this.isUseTemplateApp ? "meta" : "snap") + const desktopFile = path.join(snapMetaDir, "gui", `${snap.name}.desktop`) + await this.helper.writeDesktopEntry(this.options, this.packager.executableName + " %U", desktopFile, { + // tslint:disable:no-invalid-template-strings + Icon: "${SNAP}/meta/gui/icon.png", + }) + + const extraAppArgs: Array = this.options.executableArgs ?? [] + if (this.helper.isElectronVersionGreaterOrEqualThan("5.0.0") && !this.isBrowserSandboxAllowed(snap)) { + const noSandboxArg = "--no-sandbox" + if (!extraAppArgs.includes(noSandboxArg)) { + extraAppArgs.push(noSandboxArg) + } + if (this.isUseTemplateApp) { + args.push("--exclude", "chrome-sandbox") + } + } + if (extraAppArgs.length > 0) { + args.push("--extraAppArgs=" + extraAppArgs.join(" ")) + } + + if (snap.compression != null) { + args.push("--compression", snap.compression) + } + + if (this.isUseTemplateApp) { + // remove fields that are valid in snapcraft.yaml, but not snap.yaml + const fieldsToStrip = ["compression", "contact", "donation", "issues", "parts", "source-code", "website"] + for (const field of fieldsToStrip) { + delete snap[field] + } + } + + if (this.packager.packagerOptions.effectiveOptionComputed != null && (await this.packager.packagerOptions.effectiveOptionComputed({ snap, desktopFile, args }))) { + return + } + + await outputFile(path.join(snapMetaDir, this.isUseTemplateApp ? "snap.yaml" : "snapcraft.yaml"), serializeToYaml(snap)) + + const hooksDir = await this.packager.getResource(this.options.hooks, "snap-hooks") + if (hooksDir != null) { + args.push("--hooks", hooksDir) + } + + if (this.isUseTemplateApp) { + args.push("--template-url", `electron4:${snapArch}`) + } + + await executeAppBuilder(args) + } + + private normalizePlugConfiguration(raw: Array | PlugDescriptor | Nullish): Record | null> | null { + if (raw == null) { + return null + } + + const result: any = {} + for (const item of Array.isArray(raw) ? raw : [raw]) { + if (typeof item === "string") { + result[item] = null + } else { + Object.assign(result, item) + } + } + return result + } + + private isBrowserSandboxAllowed(snap: any): boolean { + if (snap.plugs != null) { + for (const plugName of Object.keys(snap.plugs)) { + const plug = snap.plugs[plugName] + if (plug.interface === "browser-support" && plug["allow-sandbox"] === true) { + return true + } + } + } + return false + } + + private getDefaultStagePackages(): Array { + // libxss1 - was "error while loading shared libraries: libXss.so.1" on Xubuntu 16.04 + return ["libnspr4", "libnss3", "libxss1", "libappindicator3-1", "libsecret-1-0"] + } + + private archNameToTriplet(arch: Arch): string { + switch (arch) { + case Arch.x64: + return "x86_64-linux-gnu" + case Arch.ia32: + return "i386-linux-gnu" + case Arch.armv7l: + // noinspection SpellCheckingInspection + return "arm-linux-gnueabihf" + case Arch.arm64: + return "aarch64-linux-gnu" + + default: + throw new Error(`Unsupported arch ${arch}`) + } + } +} diff --git a/packages/app-builder-lib/src/targets/snap/snapcraft.d.ts b/packages/app-builder-lib/src/targets/snap/snapcraft.d.ts new file mode 100644 index 00000000000..2ac60e9668a --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/snapcraft.d.ts @@ -0,0 +1,258 @@ +/** + * Latest TypeScript types for snapcraft.yaml + * Focuses on the most common Core24 configuration + * Based on https://snapcraft.io/docs/snapcraft-yaml-reference + * npm install -g json-schema-to-typescript + * curl -o snapcraft-schema.json https://raw.githubusercontent.com/canonical/snapcraft/refs/heads/main/schema/snapcraft.json + * npx json2ts -i snapcraft-schema.json -o snapcraft.ts + * (manual edits to simplify and make more strict) + */ + +export interface SnapcraftYAML { + // === Required Fields === + /** The identifying name of the snap */ + name: string + /** The baseline system that the snap is built in */ + base: "core22" | "core24" | "core26" | "bare" | "devel" + /** The amount of isolation the snap has from the host system */ + confinement: "classic" | "devmode" | "strict" + /** The self-contained software pieces needed to create the final artifact */ + parts: Record + + // === Metadata === + version?: string + title?: string + /** A short description of the project (max 78 chars) */ + summary?: string + description?: string + grade?: "stable" | "devel" + /** The project's license as an SPDX expression */ + license?: string + /** The path to the snap's icon file */ + icon?: string + + // === Build Configuration === + /** The baseline system that the snap is built in */ + "build-base"?: string + /** The platforms where the snap can be built and run (core24+) */ + platforms?: Record + /** The architectures that the snap builds and runs on (core22 and earlier) */ + architectures?: (string | Architecture)[] + /** Specifies the algorithm that compresses the snap */ + compression?: "lzo" | "xz" + + // === Apps and Services === + /** The individual programs and services that the snap runs */ + apps?: Record + + // === Interfaces === + /** Declares the snap's plugs */ + plugs?: Record + /** Declares the snap's slots */ + slots?: Record + + // === Hooks === + /** Configures the snap's hooks */ + hooks?: Record + + // === Layout === + /** The file layouts in the execution environment */ + layout?: Record> + + // === Environment === + /** The snap's runtime environment variables */ + environment?: Record + + // === Advanced === + /** The minimum version of snapd and features the snap requires */ + assumes?: string[] + /** The epoch associated with this version of the snap */ + epoch?: string + /** The package repositories to use for build and stage packages */ + "package-repositories"?: Array> + /** Selects a part to inherit metadata from */ + "adopt-info"?: string + /** The system usernames the snap can use */ + "system-usernames"?: Record + /** Ubuntu Pro services to enable when building */ + "ua-services"?: string[] + /** The linter configuration settings */ + lint?: { + ignore: (string | Record)[] + } + /** Components to build in conjunction with the snap */ + components?: Record + /** Snap type */ + type?: "app" | "gadget" | "base" | "kernel" | "snapd" + /** Attributes to pass to snap's metadata file */ + passthrough?: Record + + // === Contact/Links === + /** The snap author's contact links and email addresses */ + contact?: string | string[] + /** Links for submitting issues, bugs, and feature requests */ + issues?: string | string[] + /** Links to the source code */ + "source-code"?: string | string[] + /** The snap's donation links */ + donation?: string | string[] + /** Links to the original software's web pages */ + website?: string | string[] + /** Primary-key header for snaps signed by third parties */ + provenance?: string +} + +// === Part Definition === +export interface Part { + /** Plugin to use for building */ + plugin: string + + // Source + source?: string + "source-type"?: "git" | "bzr" | "hg" | "svn" | "tar" | "zip" | "7z" | "deb" | "rpm" | "local" + "source-branch"?: string + "source-tag"?: string + "source-commit"?: string + "source-depth"?: number + "source-subdir"?: string + "source-checksum"?: string + + // Dependencies + "build-packages"?: string[] + "stage-packages"?: string[] + "build-snaps"?: string[] + "stage-snaps"?: string[] + + // Build configuration + "build-environment"?: Array> + + // Overrides + "override-build"?: string + "override-pull"?: string + "override-stage"?: string + "override-prime"?: string + + // File organization + organize?: Record + stage?: string[] + prime?: string[] + + // Dependencies + after?: string[] + + // Plugin-specific options (allow any additional properties) + // [key: string]: unknown +} + +// === Platform Definition (Core24+) === +export interface Platform { + /** The architectures to build the snap on */ + "build-on"?: string | string[] + /** The target architecture for the build */ + "build-for"?: string | string[] +} + +// === Architecture Definition (Core22 and earlier) === +export interface Architecture { + /** The architectures to build the snap on */ + "build-on": string | string[] + /** The target architecture for the build */ + "build-for"?: string | string[] +} + +// === App Definition === +export interface App { + /** The command to run inside the snap when invoked */ + command: string + + // Extensions + /** The extensions to add to the app (e.g., 'gnome', 'kde-neon') */ + extensions?: string[] + + // Interfaces + /** The interfaces that the app can connect to */ + plugs?: string[] + /** The list of slots that the app provides */ + slots?: string[] + + // Daemon/Service configuration + /** Configures the app as a service */ + daemon?: "simple" | "forking" | "oneshot" | "notify" | "dbus" + "daemon-scope"?: "system" | "user" + after?: string[] + before?: string[] + "refresh-mode"?: "endure" | "restart" | "ignore-running" + "stop-mode"?: "sigterm" | "sigterm-all" | "sighup" | "sighup-all" | "sigusr1" | "sigusr1-all" | "sigusr2" | "sigusr2-all" | "sigint" | "sigint-all" + "restart-condition"?: "on-success" | "on-failure" | "on-abnormal" | "on-abort" | "on-watchdog" | "always" | "never" + "install-mode"?: "enable" | "disable" + + // Commands + "stop-command"?: string + "post-stop-command"?: string + "reload-command"?: string + + // Timeouts + "start-timeout"?: string + "stop-timeout"?: string + "watchdog-timeout"?: string + "restart-delay"?: string + + // Desktop integration + desktop?: string + autostart?: string + "common-id"?: string + completer?: string + + // D-Bus + "bus-name"?: string + "activates-on"?: string[] + + // Sockets + sockets?: Record + timer?: string + + // Environment + environment?: Record + "command-chain"?: string[] + + // Other + aliases?: string[] + "success-exit-status"?: number[] + passthrough?: Record +} + +// === Socket Definition === +export interface Socket { + /** The socket's abstract name or socket path */ + "listen-stream": number | string + /** The mode or permissions of the socket in octal */ + "socket-mode"?: number +} + +// === Hook Definition === +export interface Hook { + /** The ordered list of commands to run before the hook runs */ + "command-chain"?: string[] + /** The environment variables for the hook */ + environment?: Record + /** The list of interfaces that the hook can connect to */ + plugs?: string[] + /** Attributes to pass to snap's metadata file for the hook */ + passthrough?: Record +} + +// === Component Definition === +export interface Component { + /** The summary of the component */ + summary: string + /** The full description of the component */ + description: string + /** The type of the component */ + type: "test" | "kernel-modules" | "standard" + /** The version of the component */ + version?: string + /** The configuration for the component's hooks */ + hooks?: Record + /** Selects a part to inherit metadata from */ + "adopt-info"?: string +} diff --git a/packages/app-builder-lib/src/targets/snap/snapcraftBuilder.ts b/packages/app-builder-lib/src/targets/snap/snapcraftBuilder.ts new file mode 100644 index 00000000000..4f205242058 --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/snapcraftBuilder.ts @@ -0,0 +1,481 @@ +import { RemoteBuildOptions } from "../../options/SnapOptions" +import { log, spawn } from "builder-util" +import * as childProcess from "child_process" +import { access, readFile } from "fs-extra" +import * as os from "os" +import * as path from "path" +import * as util from "util" +import { SnapcraftYAML } from "./snapcraft" + +const execAsync = util.promisify(childProcess.exec) + +interface BuildSnapOptions { + /** The snapcraft YAML configuration */ + snapcraftConfig: SnapcraftYAML + /** The source files to package */ + stageDir: string + /** Whether to use remote build (builds on Launchpad) */ + remoteBuild?: RemoteBuildOptions + /** Whether to use LXD for local builds */ + useLXD?: boolean + /** Whether to use Multipass for local builds (default on macOS/Windows) */ + useMultipass?: boolean + /** Whether to use destructive mode (builds directly on host, Linux only) */ + useDestructiveMode?: boolean + /** Additional environment variables for the build */ + env?: Record + /** The snap output path */ + artifactPath: string +} + +/** + * Progress tracker for snap builds + */ +class SnapBuildProgress { + private startTime = Date.now() + + logStage(stage: string, message: string, percentage?: number) { + const elapsed = Math.floor((Date.now() - this.startTime) / 1000) + log.info( + { + stage, + elapsed: `${elapsed}s`, + percentage: percentage ? `${percentage}%` : undefined, + }, + message + ) + } + + complete() { + const totalTime = Math.floor((Date.now() - this.startTime) / 1000) + log.info({ totalTime: `${totalTime}s` }, "snap build complete") + } +} + +/** + * Validates snapcraft.yaml using snapcraft's built-in validation + * This runs snapcraft expand-extensions which validates without building + */ +async function validateSnapcraftYamlWithCLI(workDir: string): Promise { + try { + // Run expand-extensions to validate the YAML + // This checks syntax, required fields, and expands extensions + const { stdout } = await execAsync("snapcraft expand-extensions", { + cwd: workDir, + timeout: 30000, + }) + log.debug({ expandedYaml: stdout }, "validated extended snapcraft.yaml") + } catch (error: any) { + log.error({ error: error.message, stderr: error.stderr }, "snapcraft.yaml validation failed") + throw new Error( + `Invalid snapcraft.yaml: ${error.message}\n` + + `Snapcraft output: ${error.stderr || error.stdout || "No output"}\n` + + `Run 'snapcraft expand-extensions' in ${workDir} for more details` + ) + } +} + +/** + * Validates snapcraft.yaml configuration with basic client-side checks + * This is a fast pre-check before running the full CLI validation + */ +function validateSnapcraftConfig(config: SnapcraftYAML): void { + const errors: string[] = [] + const warnings: string[] = [] + + // Required fields + if (!config.name) { + errors.push("name is required") + } + if (!config.base) { + errors.push("base is required") + } + if (!config.confinement) { + errors.push("confinement is required") + } + if (!config.parts || Object.keys(config.parts).length === 0) { + errors.push("at least one part is required") + } + + // Name validation + if (config.name) { + if (!/^[a-z0-9-]*$/.test(config.name)) { + errors.push("name must only contain lowercase letters, numbers, and hyphens") + } + if (config.name.length > 40) { + errors.push("name must be 40 characters or less") + } + if (config.name.startsWith("-") || config.name.endsWith("-")) { + errors.push("name cannot start or end with a hyphen") + } + } + + // Summary validation + if (config.summary && config.summary.length > 78) { + warnings.push(`summary is ${config.summary.length} characters (recommended: 78 or less)`) + } + + // Parts validation + Object.entries(config.parts).forEach(([partName, part]) => { + if (!part.plugin) { + errors.push(`part '${partName}' missing required 'plugin' field`) + } + }) + + // Apps validation + if (config.apps) { + Object.entries(config.apps).forEach(([appName, app]) => { + if (!app.command) { + errors.push(`app '${appName}' missing required 'command' field`) + } + }) + } + + // Log results + if (errors.length > 0) { + log.error({ errors }, "snapcraft.yaml validation failed") + throw new Error(`Invalid snapcraft.yaml: ${errors.join(", ")}`) + } + + if (warnings.length > 0) { + log.warn({ warnings }, "snapcraft.yaml validation warnings") + } +} + +/** + * Retry wrapper for operations that may fail transiently + */ +async function executeWithRetry( + fn: () => Promise, + options: { + maxRetries?: number + retryDelay?: number + retryableErrors?: string[] + } = {} +): Promise { + const { maxRetries = 3, retryDelay = 5000, retryableErrors = ["network timeout", "connection refused", "temporary failure", "snap store error"] } = options + + let lastError: Error | undefined + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn() + } catch (error: any) { + lastError = error + const errorMessage = error.message?.toLowerCase() || "" + const isRetryable = retryableErrors.some(pattern => errorMessage.includes(pattern)) + + if (attempt < maxRetries && isRetryable) { + log.warn({ attempt, maxRetries, error: error.message, retryIn: retryDelay }, "build failed with retryable error, retrying...") + await new Promise(resolve => setTimeout(resolve, retryDelay)) + } else { + break + } + } + } + + throw lastError! +} + +/** + * Cleans up build artifacts + */ +async function cleanupBuildArtifacts(workDir: string, keepArtifacts: boolean = false): Promise { + const { remove, readdir } = await import("fs-extra") + const artifactsToClean = ["parts", "stage", "prime"] + + for (const artifact of artifactsToClean) { + const artifactPath = path.join(workDir, artifact) + try { + await remove(artifactPath) + log.debug({ artifact }, "cleaned build artifact") + } catch (error) { + // Ignore errors if artifact doesn't exist + } + } + + // Clean snap files if not keeping artifacts + if (!keepArtifacts) { + try { + const files = await readdir(workDir) + for (const file of files) { + if (file.endsWith(".snap")) { + await remove(path.join(workDir, file)) + log.debug({ file }, "cleaned snap file") + } + } + } catch (error) { + // Ignore errors + } + } +} + +/** + * Builds a snap package from SnapcraftYAML configuration + */ +export async function buildSnap(options: BuildSnapOptions): Promise { + const progress = new SnapBuildProgress() + const { SNAPCRAFT_NO_NETWORK = "1" } = process.env + const { snapcraftConfig, artifactPath, remoteBuild, stageDir, useLXD = false, useMultipass = false, useDestructiveMode = false, env: userEnv } = options + + // Build environment: start from user-provided env, ensure network-disabled by default. + const env: Record = { + ...(userEnv || {}), + SNAPCRAFT_NO_NETWORK, + } + + // Only force host (destructive) build environment when destructive mode is explicitly requested. + if (useDestructiveMode) { + env.SNAPCRAFT_BUILD_ENVIRONMENT = "host" + } + + try { + progress.logStage("preparing", "validating snapcraft configuration", 10) + validateSnapcraftConfig(snapcraftConfig) + + progress.logStage("preparing", "validating with snapcraft CLI", 35) + await validateSnapcraftYamlWithCLI(stageDir) + + // Step 5: Detect platform and determine build strategy + progress.logStage("preparing", "detecting platform and build method", 50) + const platform = process.platform + const isLinux = platform === "linux" + const isMac = platform === "darwin" + const isWindows = platform === "win32" + await ensureSnapcraftInstalled(platform) + + // Step 6: Authenticate for remote build + if (remoteBuild?.enabled) { + progress.logStage("preparing", "authenticating for remote build", 60) + await ensureRemoteBuildAuthentication(remoteBuild, env) + } + + // Step 7: Execute build with retry + // Pre-flight: ensure the app directory exists where snapcraft expects it (stageDir/app). + const { pathExists, readdir } = await import("fs-extra") + const projectAppDir = path.join(stageDir, "app") + if (!(await pathExists(projectAppDir))) { + log.error({ path: projectAppDir }, "snap build failed: app directory not found") + throw new Error(`snap build failed: expected app directory not found at ${projectAppDir}`) + } + const files = await readdir(projectAppDir) + log.debug({ appFiles: files.slice(0, 20) }, "app directory contents (truncated)") + + progress.logStage("building", "running snapcraft build", 70) + const snapFilePath = await executeWithRetry( + () => + executeSnapcraftBuild({ + workDir: stageDir, + remoteBuild, + outputSnap: artifactPath, + useLXD: useLXD, //|| (isLinux && !useDestructiveMode && !useMultipass && !remoteBuild?.enabled), + useMultipass: useMultipass, //|| ((isMac || isWindows) && !remoteBuild?.enabled), + useDestructiveMode: useDestructiveMode, //&& isLinux && !remoteBuild?.enabled, + env, + }), + { + maxRetries: remoteBuild?.enabled ? 3 : 1, + retryDelay: 10000, + } + ) + + progress.logStage("complete", "snap built successfully", 100) + progress.complete() + + log.info({ snapFilePath }, "snap build complete") + return snapFilePath + } catch (error: any) { + log.error({ error: error.message, stack: error.stack }, "snap build failed") + + try { + await cleanupBuildArtifacts(stageDir, false) + } catch (cleanupError: any) { + log.warn({ error: cleanupError.message }, "failed to cleanup build artifacts") + } + + throw error + } +} + +/** + * Ensures snapcraft is installed on the system + */ +async function ensureSnapcraftInstalled(platform: string): Promise { + try { + const { stdout } = await execAsync("snapcraft --version") + log.info({ version: stdout.trim() }, "snapcraft found") + } catch (error: any) { + log.error({ error: error.message }, "snapcraft is not installed") + + if (platform === "linux") { + log.error(null, "Install with: sudo snap install snapcraft --classic") + } else if (platform === "darwin") { + log.error(null, "Install with: brew install snapcraft") + log.error(null, "Then setup: sudo snap install snapcraft --classic (if snap is installed)") + } else if (platform === "win32") { + log.error(null, "Install snapcraft via WSL2 or use remote-build") + log.error(null, "See: https://snapcraft.io/docs/snapcraft-overview") + } + + throw new Error("snapcraft not found - please install snapcraft to continue") + } +} + +/** + * Ensures remote build authentication is configured + */ +async function ensureRemoteBuildAuthentication(remoteBuild: RemoteBuildOptions, env: Record): Promise { + log.debug(null, "checking remote build authentication...") + + // Check if credentials file exists and is readable + if (remoteBuild.credentialsFile) { + try { + const credentials = await readFile(remoteBuild.credentialsFile, "utf8") + + if (!credentials || credentials.trim().length === 0) { + throw new Error("Credentials file is empty") + } + + env["SNAPCRAFT_STORE_CREDENTIALS"] = credentials.trim() + log.debug(null, "using credentials from file") + return + } catch (error: any) { + log.error({ error: error.message, file: remoteBuild.credentialsFile }, "failed to read credentials file") + throw new Error( + `Failed to read credentials file '${remoteBuild.credentialsFile}': ${error.message}\n` + `Generate credentials with: snapcraft export-login ${remoteBuild.credentialsFile}` + ) + } + } + + // Check if already authenticated + try { + const { stdout } = await execAsync("snapcraft whoami") + if (stdout.includes("email:")) { + log.debug({ account: stdout.trim() }, "already authenticated with snapcraft") + return + } + } catch (error) { + // Not logged in, continue with checks + } + + // Check for SSH key (required for remote build) + const sshKeyPath = remoteBuild.sshKeyPath || path.join(os.homedir(), ".ssh", "id_rsa") + + try { + await access(sshKeyPath) + log.debug({ sshKeyPath }, "SSH key found") + } catch (error) { + const publicKeyPath = `${sshKeyPath}.pub` + log.error({ sshKeyPath, publicKeyPath }, "SSH key not found - remote build requires SSH authentication") + throw new Error( + `SSH key not found at ${sshKeyPath}\n` + + `To set up remote build:\n` + + `1. Generate SSH key: ssh-keygen -t rsa -b 4096 -f ${sshKeyPath}\n` + + `2. Add public key to Launchpad: https://launchpad.net/~/+editsshkeys\n` + + `3. Login to Snapcraft: snapcraft login` + ) + } + + // Not authenticated + log.error(null, "not authenticated with snapcraft") + throw new Error( + "Snapcraft authentication required for remote build\n" + + "Authenticate with one of:\n" + + " 1. Run: snapcraft login\n" + + ` 2. Export credentials: snapcraft export-login credentials.txt\n` + + " 3. Set SNAPCRAFT_STORE_CREDENTIALS environment variable" + ) +} + +interface ExecuteSnapcraftOptions { + workDir: string + outputSnap: string + remoteBuild?: RemoteBuildOptions + useLXD: boolean + useMultipass: boolean + useDestructiveMode: boolean + env: Record +} + +/** + * Executes the snapcraft build command + */ +async function executeSnapcraftBuild(options: ExecuteSnapcraftOptions): Promise { + const { workDir, outputSnap: outputFileName, remoteBuild, useLXD, useMultipass, useDestructiveMode, env } = options + + const command = "snapcraft" + const args: string[] = [] + + if (remoteBuild?.enabled) { + // Remote build on Launchpad (works from any platform) + args.push("remote-build") + log.debug(null, "using remote-build (Launchpad)") + + // Add remote build specific options + if (remoteBuild.launchpadUsername) { + args.push("--user", remoteBuild.launchpadUsername) + } + + if (remoteBuild.acceptPublicUpload) { + args.push("--launchpad-accept-public-upload") + } else { + log.warn(null, "your project will be publicly uploaded to Launchpad. Use `acceptPublicUpload: true` to suppress this warning") + } + + if (remoteBuild.privateProject) { + args.push("--project", remoteBuild.privateProject) + log.debug({ project: remoteBuild.privateProject }, "using private Launchpad project") + } + + if (remoteBuild.buildFor && remoteBuild.buildFor.length > 0) { + args.push("--build-for", remoteBuild.buildFor.join(",")) + log.debug({ archs: remoteBuild.buildFor }, "building for architectures") + } + + if (remoteBuild.recover) { + args.push("--recover") + log.debug(null, "recovering previous build") + } + + if (remoteBuild.strategy) { + env["SNAPCRAFT_REMOTE_BUILD_STRATEGY"] = remoteBuild.strategy + } + + if (remoteBuild.timeout) { + log.debug({ timeout: `${remoteBuild.timeout}s` }, "build timeout configured") + } + } else { + // Use 'pack' command for local builds (replaces bare 'snapcraft') + args.push("pack") + + if (useDestructiveMode) { + // Destructive mode (Linux only, builds on host) + args.push("--destructive-mode") + log.debug(null, "using destructive mode (building on host)") + } else if (useLXD) { + // Use LXD (Linux, fast but requires setup) + args.push("--use-lxd") + log.debug(null, "using LXD for build") + } else if (useMultipass) { + // Use Multipass (default for macOS/Windows) + args.push("--use-multipass") + log.debug(null, "using Multipass for build") + } + } + + args.push("--output", outputFileName) + if (log.isDebugEnabled) { + args.push("--verbose") + } + + log.info({ command: `${command} ${args.join(" ")}`, workDir: log.filePath(workDir) }, "executing snapcraft build") + + await spawn(command, args, { + cwd: workDir, + env: { ...process.env, ...env }, + stdio: "inherit", + }) + + const snapFilePath = path.isAbsolute(outputFileName) ? outputFileName : path.join(workDir, outputFileName) + return snapFilePath +} diff --git a/packages/builder-util/src/util.ts b/packages/builder-util/src/util.ts index 360631a4266..afc8ff28fa8 100644 --- a/packages/builder-util/src/util.ts +++ b/packages/builder-util/src/util.ts @@ -316,6 +316,35 @@ export function addValue(map: Map>, key: K, value: T) { } } +export function isArrayEqualRegardlessOfSort(a: Array, b: Array) { + a = a.slice() + b = b.slice() + a.sort() + b.sort() + return a.length === b.length && a.every((value, index) => value === b[index]) +} + +/** + * Recursively removes all undefined and null values from an object + */ +export function removeNullish(obj: T): T { + if (obj === null || typeof obj !== "object") { + return obj + } + + if (Array.isArray(obj)) { + return obj.map(removeNullish) as T + } + + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (value != null) { + result[key] = removeNullish(value) + } + } + return result as T +} + export function replaceDefault(inList: Array | Nullish, defaultList: Array): Array { if (inList == null || (inList.length === 1 && inList[0] === "default")) { return defaultList diff --git a/packages/electron-updater/src/differentialDownloader/multipleRangeDownloader.ts b/packages/electron-updater/src/differentialDownloader/multipleRangeDownloader.ts index fe72f15dcfe..1408ab03fae 100644 --- a/packages/electron-updater/src/differentialDownloader/multipleRangeDownloader.ts +++ b/packages/electron-updater/src/differentialDownloader/multipleRangeDownloader.ts @@ -47,7 +47,7 @@ function doExecuteTasks(differentialDownloader: DifferentialDownloader, options: for (let i = options.start; i < options.end; i++) { const task = options.tasks[i] if (task.kind === OperationKind.DOWNLOAD) { - ranges += `${task.start}-${task.end - 1}, ` + ranges += `${task.start}-${task.end - 1},` partIndexToTaskIndex.set(partCount, i) partCount++ partIndexToLength.push(task.end - task.start) diff --git a/scripts/fix-schema.js b/scripts/fix-schema.js index c0cd8c95c26..c5ef8f24f19 100644 --- a/scripts/fix-schema.js +++ b/scripts/fix-schema.js @@ -25,10 +25,12 @@ schema.definitions.OutgoingHttpHeaders.additionalProperties = { ] } -o = schema.definitions.SnapOptions.properties.environment.anyOf[0] = { +const record = { additionalProperties: { type: "string" }, type: "object", } +o = schema.definitions.SnapOptions24.properties.environment.anyOf[0] = record +o = schema.definitions.SnapOptionsLegacy.properties.environment.anyOf[0] = record o = schema.properties["$schema"] = { "description": "JSON Schema for this document.", diff --git a/test/src/linux/snapHeavyTest.ts b/test/src/linux/snapHeavyTest.ts index 81030e2e7ca..02f5a7bce18 100644 --- a/test/src/linux/snapHeavyTest.ts +++ b/test/src/linux/snapHeavyTest.ts @@ -7,42 +7,45 @@ import * as which from "which" const hasSnapInstalled = () => which.sync("snap", { nothrow: true }) != null describe.heavy.ifEnv(hasSnapInstalled())("snap heavy", { sequential: true, timeout: EXTENDED_TIMEOUT }, () => { - test("snap full", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "se-wo-template", + for (const _core of ["core24", "core22", "core20", "core18"]) { + const core = _core as any + test(`snap full (${core})`, ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { + name: "se-wo-template", + }, + productName: "Snap Electron App (full build)", + snap: { + core, + }, + electronFuses: { + runAsNode: true, + enableCookieEncryption: true, + enableNodeOptionsEnvironmentVariable: true, + enableNodeCliInspectArguments: true, + enableEmbeddedAsarIntegrityValidation: true, + onlyLoadAppFromAsar: true, + loadBrowserProcessSpecificV8Snapshot: true, + grantFileProtocolExtraPrivileges: undefined, // unsupported on current electron version in our tests + }, }, - productName: "Snap Electron App (full build)", - snap: { - useTemplateApp: false, - }, - electronFuses: { - runAsNode: true, - enableCookieEncryption: true, - enableNodeOptionsEnvironmentVariable: true, - enableNodeCliInspectArguments: true, - enableEmbeddedAsarIntegrityValidation: true, - onlyLoadAppFromAsar: true, - loadBrowserProcessSpecificV8Snapshot: true, - grantFileProtocolExtraPrivileges: undefined, // unsupported on current electron version in our tests - }, - }, - })) + })) - // very slow - test("snap full (armhf)", ({ expect }) => - app(expect, { - targets: Platform.LINUX.createTarget("snap", Arch.armv7l), - config: { - extraMetadata: { - name: "se-wo-template", - }, - productName: "Snap Electron App (full build)", - snap: { - useTemplateApp: false, + // very slow + test("snap full (armhf)", ({ expect }) => + app(expect, { + targets: Platform.LINUX.createTarget("snap", Arch.armv7l), + config: { + extraMetadata: { + name: "se-wo-template", + }, + productName: "Snap Electron App (full build)", + snap: { + core, + }, }, - }, - })) + })) + } }) diff --git a/test/src/linux/snapTest.ts b/test/src/linux/snapTest.ts index 4d1014b7be6..3c876788173 100644 --- a/test/src/linux/snapTest.ts +++ b/test/src/linux/snapTest.ts @@ -45,11 +45,14 @@ test.ifNotWindows("default stagePackages", async ({ expect }) => { }, productName: "Sep", snap: { - stagePackages: p, - plugs: p, - confinement: "classic", - // otherwise "parts" will be removed - useTemplateApp: false, + core: "core22", + core22: { + stagePackages: p, + plugs: p, + confinement: "classic", + // otherwise "parts" will be removed + useTemplateApp: false, + }, }, }, effectiveOptionComputed: async ({ snap, args }) => { @@ -71,7 +74,10 @@ test.ifNotWindows("classic confinement", ({ expect }) => }, productName: "Snap Electron App (classic confinement)", snap: { - confinement: "classic", + core: "core22", + core22: { + confinement: "classic", + }, }, }, }) @@ -86,9 +92,12 @@ test.ifNotWindows("buildPackages", async ({ expect }) => { }, productName: "Sep", snap: { - buildPackages: ["foo1", "default", "foo2"], - // otherwise "parts" will be removed - useTemplateApp: false, + core: "core22", + core22: { + buildPackages: ["foo1", "default", "foo2"], + // otherwise "parts" will be removed + useTemplateApp: false, + }, }, }, effectiveOptionComputed: async ({ snap }) => { @@ -122,9 +131,12 @@ test.ifNotWindows("plugs option", async ({ expect }) => { targets: snapTarget, config: { snap: { - plugs: p, - // otherwise "parts" will be removed - useTemplateApp: false, + core: "core22", + core22: { + plugs: p, + // otherwise "parts" will be removed + useTemplateApp: false, + }, }, }, effectiveOptionComputed: async ({ snap, args }) => { @@ -158,7 +170,10 @@ test.ifNotWindows("slots option", async ({ expect }) => { }, productName: "Sep", snap: { - slots, + core: "core22", + core22: { + slots, + }, }, }, effectiveOptionComputed: async ({ snap }) => { @@ -178,8 +193,11 @@ test.ifNotWindows("custom env", ({ expect }) => }, productName: "Sep", snap: { - environment: { - FOO: "bar", + core: "core22", + core22: { + environment: { + FOO: "bar", + }, }, }, }, @@ -199,7 +217,10 @@ test.ifNotWindows("custom after, no desktop", ({ expect }) => }, productName: "Sep", snap: { - after: ["bar"], + core: "core22", + core22: { + after: ["bar"], + }, }, }, effectiveOptionComputed: async ({ snap }) => { @@ -218,7 +239,10 @@ test.ifNotWindows("no desktop plugs", ({ expect }) => }, productName: "Sep", snap: { - plugs: ["foo", "bar"], + core: "core22", + core22: { + plugs: ["foo", "bar"], + }, }, }, effectiveOptionComputed: async ({ snap, args }) => { @@ -238,7 +262,10 @@ test.ifNotWindows("auto start", ({ expect }) => }, productName: "Sep", snap: { - autoStart: true, + core: "core22", + core22: { + autoStart: true, + }, }, }, effectiveOptionComputed: async ({ snap }) => { @@ -274,8 +301,11 @@ test.ifNotWindows("compression option", ({ expect }) => }, productName: "Sep", snap: { - useTemplateApp: false, - compression: "xz", + core: "core22", + core22: { + useTemplateApp: false, + compression: "xz", + }, }, }, effectiveOptionComputed: async ({ snap, args }) => { @@ -307,7 +337,10 @@ test.ifNotWindows("base option", ({ expect }) => config: { productName: "Sep", snap: { - base: "core22", + core: "core22", + core22: { + base: "core22", + }, }, }, effectiveOptionComputed: async ({ snap }) => { @@ -323,8 +356,11 @@ test.ifNotWindows("use template app", ({ expect }) => targets: snapTarget, config: { snap: { - useTemplateApp: true, - compression: "xz", + core: "core22", + core22: { + useTemplateApp: true, + compression: "xz", + }, }, }, effectiveOptionComputed: async ({ snap, args }) => { diff --git a/test/vitest-scripts/smart-config.ts b/test/vitest-scripts/smart-config.ts index c5ba6cbabf0..4aed9ffd891 100644 --- a/test/vitest-scripts/smart-config.ts +++ b/test/vitest-scripts/smart-config.ts @@ -26,10 +26,9 @@ export const UNSTABLE_FAIL_RATIO = 0.2 // TODO: FIX ALL OF THESE 😅 export const skippedTests = [ // General instability - "snapHeavyTest", ] export const skipPerOSTests: Record = { darwin: ["fpmTest", "macUpdaterTest", "blackboxUpdateTest"], - linux: ["flatpakTest"], + linux: [], win32: [], } diff --git a/tsconfig-base.json b/tsconfig-base.json index f05e98abd57..32afb09b154 100644 --- a/tsconfig-base.json +++ b/tsconfig-base.json @@ -7,7 +7,7 @@ "moduleResolution": "node", "skipLibCheck": true, "strict": true, - "noUnusedLocals": true, + "noUnusedLocals": false, "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, "allowJs": true,