diff --git a/.changeset/dark-moons-love.md b/.changeset/dark-moons-love.md new file mode 100644 index 00000000000..49adfa097d5 --- /dev/null +++ b/.changeset/dark-moons-love.md @@ -0,0 +1,6 @@ +--- +"app-builder-lib": minor +"builder-util": minor +--- + +feat: adding support for core24 snapcraft and refactoring support to a new config property `snapcraft` to maintain backward compatibility diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8b32a0574a5..c1ddb8c8cdb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -322,13 +322,14 @@ jobs: retention-days: 1 if-no-files-found: error - test-linux-native: - name: - Test Linux Native (non-Docker) - # disabled for now - if: false + test-snap: + name: Test Snap (${{ matrix.core }}) runs-on: ubuntu-24.04 timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + core: [core18, core20, core22, core24] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -339,17 +340,39 @@ jobs: cache-key: v-35.7.5-native-electron reset-vitest-smart-cache: ${{ inputs.reset-vitest-smart-cache }} - - name: Install dependencies + - name: Test snap ${{ matrix.core }} (Docker) + run: SNAP_CORE=${{ matrix.core }} ./test/src/linux/test-snap.sh + env: + VITEST_SMART_CACHE_FILE: ${{ github.workspace }}/test/vitest-scripts/_vitest-smart-cache.json + RESET_VITEST_SHARD_CACHE: ${{ inputs.reset-vitest-smart-cache }} + + # Native install+launch test — core24 only; requires snapcraft + unsquashfs on the runner. + - name: Disable background apt services (prevent apt lock contention) + if: matrix.core == 'core24' + run: | + sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service || true + sudo systemctl disable apt-daily.timer apt-daily-upgrade.timer || true + sudo systemctl kill --kill-who=all apt-daily.service apt-daily-upgrade.service || true + while sudo fuser /var/lib/apt/lists/lock /var/lib/dpkg/lock /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do + echo "Waiting for apt lock to be released…" + sleep 2 + done + + - name: Install snapcraft natively + if: matrix.core == 'core24' run: | sudo apt-get update - sudo apt-get install -y rpm flatpak flatpak-builder snapd + sudo apt-get install -y squashfs-tools xvfb sudo snap install snapcraft --classic - - name: Test - run: | - pnpm ci:test + - name: Test snap core24 native (install + launch) + if: matrix.core == 'core24' + run: pnpm ci:test env: - TEST_FILES: flatpakTest,snapHeavyTest + TEST_FILES: snapHeavyTest + SNAP_TEST_CORES: core24 + SNAPCRAFT_BUILD_ENVIRONMENT: host + RUN_SNAP_TESTS: "true" VITEST_SMART_CACHE_FILE: ${{ github.workspace }}/test/vitest-scripts/_vitest-smart-cache.json RESET_VITEST_SHARD_CACHE: ${{ inputs.reset-vitest-smart-cache }} @@ -357,7 +380,7 @@ jobs: uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 if: always() with: - name: vitest-smart-cache-linux-native + name: vitest-smart-cache-snap-${{ matrix.core }} path: ${{ github.workspace }}/test/vitest-scripts/_vitest-smart-cache.json retention-days: 1 if-no-files-found: error @@ -431,7 +454,7 @@ jobs: # ------------------------- merge-smart-cache: - needs: [test-linux, test-windows, test-macos, test-e2e, test-updater, test-linux-native] + needs: [test-linux, test-windows, test-macos, test-e2e, test-updater, test-snap] if: always() runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/packages/app-builder-lib/scheme.json b/packages/app-builder-lib/scheme.json index 6eea4b8e65a..0ef08943e33 100644 --- a/packages/app-builder-lib/scheme.json +++ b/packages/app-builder-lib/scheme.json @@ -5698,6 +5698,63 @@ }, "type": "object" }, + "RemoteBuildOptions": { + "additionalProperties": false, + "description": "Configuration for a remote snap build on [Launchpad](https://launchpad.net/).\nRemote builds run on Canonical's infrastructure and support multiple architectures\n(amd64, arm64, armhf) without requiring native hardware or nested virtualisation.\n\nAuthentication is resolved in this order:\n1. `credentialsFile` — path to a file produced by `snapcraft export-login`\n2. `SNAPCRAFT_STORE_CREDENTIALS` environment variable\n3. An active interactive `snapcraft login` session", + "properties": { + "acceptPublicUpload": { + "description": "Suppress the Launchpad public-upload consent prompt by automatically accepting it.\nYour source code will be uploaded to a **public** Launchpad repository.\nSet to `true` in CI once you understand the implications.", + "type": "boolean" + }, + "buildFor": { + "description": "Target architectures for the remote build.", + "items": { + "type": "string" + }, + "type": "array" + }, + "credentialsFile": { + "description": "Path to a Snapcraft credentials file produced by `snapcraft export-login `.\nRecommended for CI/CD pipelines — avoids interactive login and does not require an\nSSH key on the build agent.", + "type": "string" + }, + "enabled": { + "description": "Whether to enable remote build on Launchpad. Must be set explicitly to `true` to opt in.", + "type": "boolean" + }, + "launchpadUsername": { + "description": "Your Launchpad username. Used to select the correct Launchpad account when more than\none set of credentials is available.", + "type": "string" + }, + "privateProject": { + "description": "Launchpad project name to use for a **private** source upload.\nThe project must already exist and you must have write access.", + "type": "string" + }, + "recover": { + "description": "Resume a previously interrupted remote build rather than starting a new one.", + "type": "boolean" + }, + "sshKeyPath": { + "description": "Path to an SSH private key used for Launchpad authentication.\nDefaults to `~/.ssh/id_rsa`. The matching public key must be registered\non your Launchpad account.", + "type": "string" + }, + "strategy": { + "description": "Controls whether snapcraft may fall back to a different remote build strategy.\n- `\"disable-fallback\"` — always use the primary strategy, fail if unavailable.\n- `\"force-fallback\"` — always use the fallback strategy.", + "enum": [ + "disable-fallback", + "force-fallback" + ], + "type": "string" + }, + "timeout": { + "description": "Maximum time in seconds to wait for the remote build to complete before aborting.", + "type": "number" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "S3Options": { "additionalProperties": false, "description": "[Amazon S3](https://aws.amazon.com/s3/) options.\nAWS credentials are required, please see [getting your credentials](http://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/getting-your-credentials.html).\nTo set credentials define `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` [environment variables](http://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html) directly,\nor use [~/.aws/credentials](http://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html) file,\nor use [~/.aws/config](https://docs.aws.amazon.com/sdkref/latest/guide/file-format.html) file. For the last method to work you will also need to define `AWS_SDK_LOAD_CONFIG=1` environment variable.\n\nExample configuration:\n\n```json\n{\n\"build\":\n \"publish\": {\n \"provider\": \"s3\",\n \"bucket\": \"bucket-name\"\n }\n}\n}\n```", @@ -5734,51 +5791,816 @@ "string" ] }, - "encryption": { + "encryption": { + "anyOf": [ + { + "enum": [ + "AES256", + "aws:kms" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Server-side encryption algorithm to use for the object." + }, + "endpoint": { + "description": "The endpoint URI to send requests to. The default endpoint is built from the configured region.\nThe endpoint should be a string like `https://{service}.{region}.amazonaws.com`.", + "type": [ + "null", + "string" + ] + }, + "forcePathStyle": { + "description": "When true, force a path-style endpoint to be used where the bucket name is part of the path.\n[Path-style Access](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access)", + "type": "boolean" + }, + "path": { + "default": "/", + "description": "The directory path.", + "type": [ + "null", + "string" + ] + }, + "provider": { + "const": "s3", + "description": "The provider. Must be `s3`.", + "type": "string" + }, + "publishAutoUpdate": { + "default": true, + "description": "Whether to publish auto update info files.\n\nAuto update relies only on the first provider in the list (you can specify several publishers).\nThus, probably, there`s no need to upload the metadata files for the other configured providers. But by default will be uploaded.", + "type": "boolean" + }, + "publisherName": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "region": { + "description": "The region. Is determined and set automatically when publishing.", + "type": [ + "null", + "string" + ] + }, + "requestHeaders": { + "$ref": "#/definitions/OutgoingHttpHeaders", + "description": "Any custom request headers" + }, + "storageClass": { + "anyOf": [ + { + "enum": [ + "REDUCED_REDUNDANCY", + "STANDARD", + "STANDARD_IA" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": "STANDARD", + "description": "The type of storage to use for the object." + }, + "timeout": { + "default": 120000, + "description": "Request timeout in milliseconds. (Default is 2 minutes; O is ignored)", + "type": [ + "null", + "number" + ] + }, + "updaterCacheDirName": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "bucket", + "provider" + ], + "type": "object" + }, + "SlotDescriptor": { + "additionalProperties": { + "anyOf": [ + { + "typeof": "function" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + "SnapOptions": { + "additionalProperties": false, + "description": "Flat snap options. Used via the `snap` key in your build config.", + "properties": { + "after": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Names of snapcraft parts that must be built before the app part.\nDefaults to `[\"desktop-gtk2\"]`.\n\nUse `\"default\"` to keep the default and add extras:\n`[\"default\", \"my-helper-part\"]`." + }, + "allowNativeWayland": { + "default": false, + "description": "Allow the snap to run with native Wayland support (`--ozone-platform=wayland`).\nDisabled by default due to compatibility issues in some Electron/snap combinations.", + "type": [ + "null", + "boolean" + ] + }, + "appPartStage": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Filesets controlling which files from the app part are staged into the snap.\nSupports individual files, directories, globs, globstars, and exclusions (prefix `!`).\nSee [Snapcraft filesets](https://snapcraft.io/docs/snapcraft-filesets).\n\nThe built-in defaults are in [snapcraft.ts](https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/src/targets/snap/snapcraft.ts)." + }, + "artifactName": { + "description": "The [artifact file name template](./configuration.md#artifact-file-name-template).", + "type": [ + "null", + "string" + ] + }, + "assumes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "null", + "string" + ] + } + ], + "description": "[Snapd features](https://snapcraft.io/docs/snapcraft-yaml-reference#assumes) that must\nbe present on the host before the snap can be installed." + }, + "autoStart": { + "default": false, + "description": "Whether the snap should automatically start on login.", + "type": "boolean" + }, + "base": { + "description": "The snap base to use as the execution environment.\nExamples: `core18`, `core20`, `core22`.\n\nFor new projects, use the `snapcraft` key with `base: \"core24\"` instead of\nthis legacy interface.", + "type": [ + "null", + "string" + ] + }, + "buildPackages": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Debian packages required at **build** time (installed inside the build environment)." + }, + "category": { + "description": "The [application category](https://specifications.freedesktop.org/menu-spec/latest/apa.html#main-category-registry).", + "type": [ + "null", + "string" + ] + }, + "compression": { + "anyOf": [ + { + "enum": [ + "lzo", + "xz" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compression algorithm for the snap SquashFS image.\n- `xz` — smaller file, slower decompression (good for distribution).\n- `lzo` — larger file, faster decompression (good for development iteration).\nOmit to use snapcraft's default (`xz`)." + }, + "confinement": { + "anyOf": [ + { + "enum": [ + "classic", + "devmode", + "strict" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": "strict", + "description": "The type of [snap confinement](https://snapcraft.io/docs/reference/confinement).\n- `strict` — recommended; the snap runs in a fully isolated sandbox.\n- `devmode` — sandbox violations are logged but not enforced; for development only.\n- `classic` — no confinement; equivalent to a traditionally packaged application.\n Requires Snap Store approval before publishing." + }, + "description": { + "description": "As [description](./configuration.md#description) from application package.json, but allows you to specify different for Linux.", + "type": [ + "null", + "string" + ] + }, + "desktop": { + "anyOf": [ + { + "$ref": "#/definitions/LinuxDesktopFile" + }, + { + "type": "null" + } + ], + "description": "The [Desktop file](https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html#desktop-files)" + }, + "environment": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Environment variables injected into the snap's runtime environment.\nMerged with the electron-builder default `{ TMPDIR: \"$XDG_RUNTIME_DIR\" }`.\nUser-supplied values take precedence." + }, + "executableArgs": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The executable parameters. Pass to executableName" + }, + "grade": { + "anyOf": [ + { + "enum": [ + "devel", + "stable" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": "stable", + "description": "The quality grade of the snap.\n- `stable` — suitable for all channels, including `stable` and `candidate`.\n- `devel` — development snapshot; cannot be promoted to `stable` or `candidate`." + }, + "hooks": { + "default": "build/snap-hooks", + "description": "Directory containing [snap hooks](https://snapcraft.io/docs/snap-hooks), relative to\nthe build resources directory (`build/`).", + "type": [ + "null", + "string" + ] + }, + "layout": { + "anyOf": [ + { + "typeof": "function" + }, + { + "type": "null" + } + ], + "description": "[Snap layouts](https://snapcraft.io/docs/snap-layouts) — bind-mount or symlink host paths\ninto the snap's namespace, making libraries or config at `/usr`, `/var`, `/etc`, etc.\naccessible inside the confined environment." + }, + "mimeTypes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing." + }, + "plugs": { + "anyOf": [ + { + "$ref": "#/definitions/PlugDescriptor" + }, + { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/PlugDescriptor" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "[Plugs](https://snapcraft.io/docs/reference/interfaces) (consumed interfaces) to declare\nfor the app entry point.\nDefaults to `[\"desktop\", \"desktop-legacy\", \"home\", \"x11\", \"wayland\", \"unity7\",\n\"browser-support\", \"network\", \"gsettings\", \"audio-playback\", \"pulseaudio\", \"opengl\"]`.\n\nUse `\"default\"` in the list to keep the defaults and append extras:\n`[\"default\", \"camera\"]` adds `camera` to the standard set.\n\nTo configure plug attributes (e.g. `allow-sandbox` for Chromium's internal sandbox),\nuse a descriptor object:\n```json\n[\n { \"browser-sandbox\": { \"interface\": \"browser-support\", \"allow-sandbox\": true } },\n \"another-simple-plug-name\"\n]\n```" + }, + "publish": { + "anyOf": [ + { + "$ref": "#/definitions/GithubOptions" + }, + { + "$ref": "#/definitions/GitlabOptions" + }, + { + "$ref": "#/definitions/S3Options" + }, + { + "$ref": "#/definitions/SpacesOptions" + }, + { + "$ref": "#/definitions/GenericServerOptions" + }, + { + "$ref": "#/definitions/CustomPublishOptions" + }, + { + "$ref": "#/definitions/KeygenOptions" + }, + { + "$ref": "#/definitions/SnapStoreOptions" + }, + { + "$ref": "#/definitions/BitbucketOptions" + }, + { + "items": { + "$ref": "#/definitions/AllPublishOptions" + }, + "type": "array" + }, + { + "type": [ + "null", + "string" + ] + } + ] + }, + "slots": { + "anyOf": [ + { + "$ref": "#/definitions/SlotDescriptor" + }, + { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/SlotDescriptor" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "[Slots](https://snapcraft.io/docs/reference/interfaces) (provided interfaces) to declare\nfor the app.\n\nTo expose an MPRIS player under the Chromium bus name (required for strict confinement):\n```json\n[{ \"mpris\": { \"name\": \"chromium\" } }]\n```\nChromium [hard-codes](https://source.chromium.org/chromium/chromium/src/+/master:components/system_media_controls/linux/system_media_controls_linux.cc;l=51;bpv=0;bpt=1)\nthe bus name `chromium`, so the slot name must match for snapd to\n[allow it](https://forum.snapcraft.io/t/unable-to-use-mpris-interface/15360/7)." + }, + "stagePackages": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Ubuntu packages to **stage** alongside the app (equivalent to `depends` for deb).\nDefaults to `[\"libnspr4\", \"libnss3\", \"libxss1\", \"libappindicator3-1\", \"libsecret-1-0\"]`.\n\nUse the `\"default\"` keyword to extend the default list:\n`[\"default\", \"my-extra-lib\"]` appends `my-extra-lib` to the defaults." + }, + "summary": { + "description": "A short summary of the snap (max 78 characters).\nDefaults to [productName](./configuration.md#productName).", + "type": [ + "null", + "string" + ] + }, + "synopsis": { + "description": "The [short description](https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Description).", + "type": [ + "null", + "string" + ] + }, + "title": { + "description": "Display title for the snap (may contain uppercase letters and spaces).\nDefaults to `productName`.\nSee [snap format](https://snapcraft.io/docs/snap-format).", + "type": [ + "null", + "string" + ] + }, + "useTemplateApp": { + "description": "Whether to use the pre-built Electron snap template for faster builds.\nWhen `true`, electron-builder delegates snap assembly to the upstream Electron snap\ntemplate rather than running a full snapcraft build, significantly reducing build time.\nDefaults to `true` when `stagePackages` is not customised.\nOnly applicable to x64 and armv7l builds.", + "type": "boolean" + } + }, + "type": "object" + }, + "SnapOptions24": { + "additionalProperties": false, + "description": "Options for building a core24 snap. This is a fresh, forward-looking interface that does\nnot extend the legacy `SnapBaseOptions`. It inherits desktop-entry fields from\n`CommonLinuxOptions` (categories, mimeTypes, executableArgs, etc.) and publish\nconfiguration from `TargetSpecificOptions`.", + "properties": { + "after": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Names of other snapcraft parts that must be built before the app part." + }, + "allowNativeWayland": { + "default": true, + "description": "Allow running the application with native Wayland support (`--ozone-platform=wayland`).\nFor core24 this defaults to `true`. Set to `false` to force X11 mode via XWayland.", + "type": [ + "null", + "boolean" + ] + }, + "appPartStage": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Filesets controlling which files from the app part are staged into the snap.\nSupports glob patterns and exclusions. See [filesets](https://snapcraft.io/docs/snapcraft-filesets)." + }, + "artifactName": { + "description": "The [artifact file name template](./configuration.md#artifact-file-name-template).", + "type": [ + "null", + "string" + ] + }, + "assumes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "null", + "string" + ] + } + ], + "description": "Features that must be supported by the host snapd before the snap can be installed.\nSee [assumes](https://snapcraft.io/docs/snapcraft-yaml-reference#assumes)." + }, + "autoStart": { + "default": false, + "description": "Whether the app should auto-start on login (creates an autostart desktop entry).", + "type": "boolean" + }, + "buildPackages": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Debian packages required at **build** time (installed inside the build environment)." + }, + "category": { + "description": "The [application category](https://specifications.freedesktop.org/menu-spec/latest/apa.html#main-category-registry).", + "type": [ + "null", + "string" + ] + }, + "compression": { + "anyOf": [ + { + "enum": [ + "lzo", + "xz" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compression algorithm for the snap file." + }, + "confinement": { + "anyOf": [ + { + "enum": [ + "classic", + "devmode", + "strict" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": "strict", + "description": "The type of [confinement](https://snapcraft.io/docs/reference/confinement) supported by the snap." + }, + "description": { + "description": "As [description](./configuration.md#description) from application package.json, but allows you to specify different for Linux.", + "type": [ + "null", + "string" + ] + }, + "desktop": { + "anyOf": [ + { + "$ref": "#/definitions/LinuxDesktopFile" + }, + { + "type": "null" + } + ], + "description": "The [Desktop file](https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html#desktop-files)" + }, + "environment": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Additional environment variables injected into the snap's runtime environment.\nMerged with the electron-builder defaults (`TMPDIR=$XDG_RUNTIME_DIR`).\nUser-supplied values take precedence." + }, + "executableArgs": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The executable parameters. Pass to executableName" + }, + "extensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "[Snapcraft extensions](https://snapcraft.io/docs/snapcraft-extensions) to apply to the app.\nDefaults to `[\"gnome\"]` in normal builds (recommended for Electron apps on Ubuntu 24.04+).\nAutomatically set to `[]` in `useDestructiveMode` builds, where the gnome extension is\nincompatible. Explicitly including `\"gnome\"` while `useDestructiveMode` is set will throw.\nSee: https://snapcraft.io/docs/gnome-extension" + }, + "grade": { + "anyOf": [ + { + "enum": [ + "devel", + "stable" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": "stable", + "description": "The quality grade of the snap.\n`devel` — not publishable to stable/candidate channels.\n`stable` — suitable for all channels." + }, + "hooks": { + "default": "build/snap-hooks", + "description": "Directory containing [snap hooks](https://snapcraft.io/docs/snap-hooks), relative to\nthe build resources directory.", + "type": [ + "null", + "string" + ] + }, + "layout": { + "anyOf": [ + { + "typeof": "function" + }, + { + "type": "null" + } + ], + "description": "[Snap layouts](https://snapcraft.io/docs/snap-layouts) — bind-mount or symlink host paths\ninto the snap's namespace. User-provided layouts always override the extension defaults." + }, + "mimeTypes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing." + }, + "plugs": { + "anyOf": [ + { + "$ref": "#/definitions/PlugDescriptor" + }, + { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/PlugDescriptor" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "[Plugs](https://snapcraft.io/docs/reference/interfaces) (consumed interfaces) for the app.\nWhen the `gnome` extension is active, content-snap plugs (themes, GNOME platform, GPU)\nare added automatically — only list custom plugs here.\nWithout any extension, defaults to the standard Electron plug set.\n\nSupports descriptor objects for plugs with attributes:\n```json\n[{ \"browser-sandbox\": { \"interface\": \"browser-support\", \"allow-sandbox\": true } }]\n```" + }, + "publish": { + "anyOf": [ + { + "$ref": "#/definitions/GithubOptions" + }, + { + "$ref": "#/definitions/GitlabOptions" + }, + { + "$ref": "#/definitions/S3Options" + }, + { + "$ref": "#/definitions/SpacesOptions" + }, + { + "$ref": "#/definitions/GenericServerOptions" + }, + { + "$ref": "#/definitions/CustomPublishOptions" + }, + { + "$ref": "#/definitions/KeygenOptions" + }, + { + "$ref": "#/definitions/SnapStoreOptions" + }, + { + "$ref": "#/definitions/BitbucketOptions" + }, + { + "items": { + "$ref": "#/definitions/AllPublishOptions" + }, + "type": "array" + }, + { + "type": [ + "null", + "string" + ] + } + ] + }, + "remoteBuild": { "anyOf": [ { - "enum": [ - "AES256", - "aws:kms" - ], - "type": "string" + "$ref": "#/definitions/RemoteBuildOptions" }, { "type": "null" } ], - "description": "Server-side encryption algorithm to use for the object." - }, - "endpoint": { - "description": "The endpoint URI to send requests to. The default endpoint is built from the configured region.\nThe endpoint should be a string like `https://{service}.{region}.amazonaws.com`.", - "type": [ - "null", - "string" - ] - }, - "forcePathStyle": { - "description": "When true, force a path-style endpoint to be used where the bucket name is part of the path.\n[Path-style Access](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access)", - "type": "boolean" - }, - "path": { - "default": "/", - "description": "The directory path.", - "type": [ - "null", - "string" - ] + "description": "Configuration for a remote build on [Launchpad](https://launchpad.net/).\nEnables multi-architecture builds (amd64, arm64, armhf) in CI without native hardware." }, - "provider": { - "const": "s3", - "description": "The provider. Must be `s3`.", - "type": "string" - }, - "publishAutoUpdate": { - "default": true, - "description": "Whether to publish auto update info files.\n\nAuto update relies only on the first provider in the list (you can specify several publishers).\nThus, probably, there`s no need to upload the metadata files for the other configured providers. But by default will be uploaded.", - "type": "boolean" + "slots": { + "anyOf": [ + { + "$ref": "#/definitions/SlotDescriptor" + }, + { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/SlotDescriptor" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "[Slots](https://snapcraft.io/docs/reference/interfaces) (provided interfaces) for the app.\nUse for MPRIS, D-Bus services, etc.\n\nExample — expose MPRIS under the Chromium bus name:\n```json\n[{ \"mpris\": { \"name\": \"chromium\" } }]\n```" }, - "publisherName": { + "stagePackages": { "anyOf": [ { "items": { @@ -5789,71 +6611,68 @@ { "type": "null" } + ], + "description": "Ubuntu packages to **stage** alongside the app (equivalent to `depends` for deb).\nDefaults to `[\"libnspr4\", \"libnss3\", \"libxss1\", \"libappindicator3-1\", \"libsecret-1-0\"]`.\nSupports the `\"default\"` keyword to reference the default list:\n`[\"default\", \"my-extra-lib\"]` appends `my-extra-lib` to the defaults." + }, + "summary": { + "description": "A short summary of the snap (max 78 characters). Defaults to `productName`.", + "type": [ + "null", + "string" ] }, - "region": { - "description": "The region. Is determined and set automatically when publishing.", + "synopsis": { + "description": "The [short description](https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Description).", "type": [ "null", "string" ] }, - "requestHeaders": { - "$ref": "#/definitions/OutgoingHttpHeaders", - "description": "Any custom request headers" + "title": { + "description": "An optional display title (may contain uppercase letters and spaces). Defaults to `productName`.\nSee [snap format](https://snapcraft.io/docs/snap-format).", + "type": [ + "null", + "string" + ] }, - "storageClass": { - "anyOf": [ - { - "enum": [ - "REDUCED_REDUNDANCY", - "STANDARD", - "STANDARD_IA" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": "STANDARD", - "description": "The type of storage to use for the object." + "useDestructiveMode": { + "description": "Build directly on the host without an isolated VM or container (snapcraft `--destructive-mode`).\nEquivalent to setting `SNAPCRAFT_BUILD_ENVIRONMENT=host`.\n\n**Not recommended for most use cases.** Destructive mode pollutes the host environment\nand produces builds that are difficult to reproduce — any library or tool present on the\nhost at build time can silently end up in the snap. Prefer `useLXD` or `useMultipass`\nfor clean, reproducible builds; use `remoteBuild` for multi-architecture CI.\n\nValid reasons to enable this option:\n- Building inside a Docker container where nested virtualisation (LXD / Multipass) is\n unavailable and a remote Launchpad build is not acceptable.\n- Running test suites in CI where the environment is already fully controlled.\n\nThe `gnome` extension is incompatible with this mode enabled — do not include it in `extensions`.", + "type": [ + "null", + "boolean" + ] }, - "timeout": { - "default": 120000, - "description": "Request timeout in milliseconds. (Default is 2 minutes; O is ignored)", + "useLXD": { + "description": "Use [LXD](https://canonical.com/lxd) as the isolated build environment.\nPreferred over Multipass on most Linux CI systems where nested virtualisation is unavailable.\nMutually exclusive with `useMultipass` and `useDestructiveMode`.", "type": [ "null", - "number" + "boolean" ] }, - "updaterCacheDirName": { + "useMultipass": { + "description": "Use [Multipass](https://multipass.run/) as the isolated build environment.\nMutually exclusive with `useLXD` and `useDestructiveMode`.", "type": [ "null", - "string" + "boolean" ] } }, - "required": [ - "bucket", - "provider" - ], "type": "object" }, - "SlotDescriptor": { - "additionalProperties": { - "anyOf": [ - { - "typeof": "function" - }, - { - "type": "null" - } - ] + "SnapOptionsCustom": { + "additionalProperties": false, + "properties": { + "yamlPath": { + "description": "Path to an existing `snapcraft.yaml` file, relative to `buildResourcesDir`.\nelectron-builder reads the file and passes it through without modification.", + "type": [ + "null", + "string" + ] + } }, "type": "object" }, - "SnapOptions": { + "SnapOptionsLegacy": { "additionalProperties": false, "properties": { "after": { @@ -5868,10 +6687,11 @@ "type": "null" } ], - "description": "Specifies any [parts](https://snapcraft.io/docs/reference/parts) that should be built before this part.\nDefaults to `[\"desktop-gtk2\"\"]`.\n\nIf list contains `default`, it will be replaced to default list, so, `[\"default\", \"foo\"]` can be used to add custom parts `foo` in addition to defaults." + "description": "Names of snapcraft parts that must be built before the app part.\nDefaults to `[\"desktop-gtk2\"]`.\n\nUse `\"default\"` to keep the default and add extras:\n`[\"default\", \"my-helper-part\"]`." }, "allowNativeWayland": { - "description": "Allow running the program with native wayland support with --ozone-platform=wayland.\nDisabled by default because of this issue in older Electron/Snap versions: https://github.com/electron-userland/electron-builder/issues/4007", + "default": false, + "description": "Allow the snap to run with native Wayland support (`--ozone-platform=wayland`).\nDisabled by default due to compatibility issues in some Electron/snap combinations.", "type": [ "null", "boolean" @@ -5889,7 +6709,7 @@ "type": "null" } ], - "description": "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.\n\nThe 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)." + "description": "Filesets controlling which files from the app part are staged into the snap.\nSupports individual files, directories, globs, globstars, and exclusions (prefix `!`).\nSee [Snapcraft filesets](https://snapcraft.io/docs/snapcraft-filesets).\n\nThe built-in defaults are in [snapcraft.ts](https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/src/targets/snap/snapcraft.ts)." }, "artifactName": { "description": "The [artifact file name template](./configuration.md#artifact-file-name-template).", @@ -5913,20 +6733,13 @@ ] } ], - "description": "The list of features that must be supported by the core in order for this snap to install." + "description": "[Snapd features](https://snapcraft.io/docs/snapcraft-yaml-reference#assumes) that must\nbe present on the host before the snap can be installed." }, "autoStart": { "default": false, - "description": "Whether or not the snap should automatically start on login.", + "description": "Whether the snap should automatically start on login.", "type": "boolean" }, - "base": { - "description": "A snap of type base to be used as the execution environment for this snap. Examples: `core`, `core18`, `core20`, `core22`. Defaults to `core20`", - "type": [ - "null", - "string" - ] - }, "buildPackages": { "anyOf": [ { @@ -5939,7 +6752,7 @@ "type": "null" } ], - "description": "The list of debian packages needs to be installed for building this snap." + "description": "Debian packages required at **build** time (installed inside the build environment)." }, "category": { "description": "The [application category](https://specifications.freedesktop.org/menu-spec/latest/apa.html#main-category-registry).", @@ -5961,7 +6774,7 @@ "type": "null" } ], - "description": "Sets the compression type for the snap. Can be xz, lzo, or null." + "description": "Compression algorithm for the snap SquashFS image.\n- `xz` — smaller file, slower decompression (good for distribution).\n- `lzo` — larger file, faster decompression (good for development iteration).\nOmit to use snapcraft's default (`xz`)." }, "confinement": { "anyOf": [ @@ -5978,7 +6791,7 @@ } ], "default": "strict", - "description": "The type of [confinement](https://snapcraft.io/docs/reference/confinement) supported by the snap." + "description": "The type of [snap confinement](https://snapcraft.io/docs/reference/confinement).\n- `strict` — recommended; the snap runs in a fully isolated sandbox.\n- `devmode` — sandbox violations are logged but not enforced; for development only.\n- `classic` — no confinement; equivalent to a traditionally packaged application.\n Requires Snap Store approval before publishing." }, "description": { "description": "As [description](./configuration.md#description) from application package.json, but allows you to specify different for Linux.", @@ -6001,16 +6814,13 @@ "environment": { "anyOf": [ { - "additionalProperties": { - "type": "string" - }, - "type": "object" + "typeof": "function" }, { "type": "null" } ], - "description": "The custom environment. Defaults to `{\"TMPDIR: \"$XDG_RUNTIME_DIR\"}`. If you set custom, it will be merged with default." + "description": "Environment variables injected into the snap's runtime environment.\nMerged with the electron-builder default `{ TMPDIR: \"$XDG_RUNTIME_DIR\" }`.\nUser-supplied values take precedence." }, "executableArgs": { "anyOf": [ @@ -6040,11 +6850,11 @@ } ], "default": "stable", - "description": "The quality grade of the snap. It can be either `devel` (i.e. a development version of the snap, so not to be published to the “stable” or “candidate” channels) or “stable” (i.e. a stable release or release candidate, which can be released to all channels)." + "description": "The quality grade of the snap.\n- `stable` — suitable for all channels, including `stable` and `candidate`.\n- `devel` — development snapshot; cannot be promoted to `stable` or `candidate`." }, "hooks": { "default": "build/snap-hooks", - "description": "The [hooks](https://docs.snapcraft.io/build-snaps/hooks) directory, relative to `build` (build resources directory).", + "description": "Directory containing [snap hooks](https://snapcraft.io/docs/snap-hooks), relative to\nthe build resources directory (`build/`).", "type": [ "null", "string" @@ -6059,7 +6869,7 @@ "type": "null" } ], - "description": "Specifies any files to make accessible from locations such as `/usr`, `/var`, and `/etc`. See [snap layouts](https://snapcraft.io/docs/snap-layouts) to learn more." + "description": "[Snap layouts](https://snapcraft.io/docs/snap-layouts) — bind-mount or symlink host paths\ninto the snap's namespace, making libraries or config at `/usr`, `/var`, `/etc`, etc.\naccessible inside the confined environment." }, "mimeTypes": { "anyOf": [ @@ -6097,7 +6907,7 @@ "type": "null" } ], - "description": "The list of [plugs](https://snapcraft.io/docs/reference/interfaces).\nDefaults to `[\"desktop\", \"desktop-legacy\", \"home\", \"x11\", \"wayland\", \"unity7\", \"browser-support\", \"network\", \"gsettings\", \"audio-playback\", \"pulseaudio\", \"opengl\"]`.\n\nIf list contains `default`, it will be replaced to default list, so, `[\"default\", \"foo\"]` can be used to add custom plug `foo` in addition to defaults.\n\nAdditional attributes can be specified using object instead of just name of plug:\n```\n[\n {\n \"browser-sandbox\": {\n \"interface\": \"browser-support\",\n \"allow-sandbox\": true\n },\n },\n \"another-simple-plug-name\"\n]\n```" + "description": "[Plugs](https://snapcraft.io/docs/reference/interfaces) (consumed interfaces) to declare\nfor the app entry point.\nDefaults to `[\"desktop\", \"desktop-legacy\", \"home\", \"x11\", \"wayland\", \"unity7\",\n\"browser-support\", \"network\", \"gsettings\", \"audio-playback\", \"pulseaudio\", \"opengl\"]`.\n\nUse `\"default\"` in the list to keep the defaults and append extras:\n`[\"default\", \"camera\"]` adds `camera` to the standard set.\n\nTo configure plug attributes (e.g. `allow-sandbox` for Chromium's internal sandbox),\nuse a descriptor object:\n```json\n[\n { \"browser-sandbox\": { \"interface\": \"browser-support\", \"allow-sandbox\": true } },\n \"another-simple-plug-name\"\n]\n```" }, "publish": { "anyOf": [ @@ -6145,7 +6955,7 @@ "slots": { "anyOf": [ { - "$ref": "#/definitions/PlugDescriptor" + "$ref": "#/definitions/SlotDescriptor" }, { "items": { @@ -6164,7 +6974,7 @@ "type": "null" } ], - "description": "The list of [slots](https://snapcraft.io/docs/reference/interfaces).\n\nAdditional attributes can be specified using object instead of just name of slot:\n```\n[\n {\n \"mpris\": {\n \"name\": \"chromium\"\n },\n }\n]\n\nIn case you want your application to be a compliant MPris player, you will need to definie\nThe mpris slot with \"chromium\" name.\nThis electron has it [hardcoded](https://source.chromium.org/chromium/chromium/src/+/master:components/system_media_controls/linux/system_media_controls_linux.cc;l=51;bpv=0;bpt=1),\nand we need to pass this name so snap [will allow it](https://forum.snapcraft.io/t/unable-to-use-mpris-interface/15360/7) in strict confinement." + "description": "[Slots](https://snapcraft.io/docs/reference/interfaces) (provided interfaces) to declare\nfor the app.\n\nTo expose an MPRIS player under the Chromium bus name (required for strict confinement):\n```json\n[{ \"mpris\": { \"name\": \"chromium\" } }]\n```\nChromium [hard-codes](https://source.chromium.org/chromium/chromium/src/+/master:components/system_media_controls/linux/system_media_controls_linux.cc;l=51;bpv=0;bpt=1)\nthe bus name `chromium`, so the slot name must match for snapd to\n[allow it](https://forum.snapcraft.io/t/unable-to-use-mpris-interface/15360/7)." }, "stagePackages": { "anyOf": [ @@ -6178,10 +6988,10 @@ "type": "null" } ], - "description": "The list of Ubuntu packages to use that are needed to support the `app` part creation. Like `depends` for `deb`.\nDefaults to `[\"libnspr4\", \"libnss3\", \"libxss1\", \"libappindicator3-1\", \"libsecret-1-0\"]`.\n\nIf list contains `default`, it will be replaced to default list, so, `[\"default\", \"foo\"]` can be used to add custom package `foo` in addition to defaults." + "description": "Ubuntu packages to **stage** alongside the app (equivalent to `depends` for deb).\nDefaults to `[\"libnspr4\", \"libnss3\", \"libxss1\", \"libappindicator3-1\", \"libsecret-1-0\"]`.\n\nUse the `\"default\"` keyword to extend the default list:\n`[\"default\", \"my-extra-lib\"]` appends `my-extra-lib` to the defaults." }, "summary": { - "description": "The 78 character long summary. Defaults to [productName](./configuration.md#productName).", + "description": "A short summary of the snap (max 78 characters).\nDefaults to [productName](./configuration.md#productName).", "type": [ "null", "string" @@ -6195,14 +7005,14 @@ ] }, "title": { - "description": "An optional title for the snap, may contain uppercase letters and spaces. Defaults to `productName`. See [snap format documentation](https://snapcraft.io/docs/snap-format).", + "description": "Display title for the snap (may contain uppercase letters and spaces).\nDefaults to `productName`.\nSee [snap format](https://snapcraft.io/docs/snap-format).", "type": [ "null", "string" ] }, "useTemplateApp": { - "description": "Whether to use template snap. Defaults to `true` if `stagePackages` not specified.", + "description": "Whether to use the pre-built Electron snap template for faster builds.\nWhen `true`, electron-builder delegates snap assembly to the upstream Electron snap\ntemplate rather than running a full snapcraft build, significantly reducing build time.\nDefaults to `true` when `stagePackages` is not customised.\nOnly applicable to x64 and armv7l builds.", "type": "boolean" } }, @@ -6283,6 +7093,132 @@ ], "type": "object" }, + "SnapcraftOptions": { + "additionalProperties": false, + "description": "New-style snap configuration. Use this via the `snapcraft` key in your build config.\nSelects the snapcraft core version and its per-core options.", + "properties": { + "artifactName": { + "description": "The [artifact file name template](./configuration.md#artifact-file-name-template).", + "type": [ + "null", + "string" + ] + }, + "base": { + "description": "A snap of type base to be used as the execution environment for this snap; can only select one core for target.", + "enum": [ + "core18", + "core20", + "core22", + "core24", + "custom" + ], + "type": "string" + }, + "core18": { + "anyOf": [ + { + "$ref": "#/definitions/SnapOptionsLegacy" + }, + { + "type": "null" + } + ], + "description": "core18; Migrates configuration from the legacy `snap` field for backward compatibility, but only applies if the core is selected in `base`." + }, + "core20": { + "anyOf": [ + { + "$ref": "#/definitions/SnapOptionsLegacy" + }, + { + "type": "null" + } + ], + "description": "core20; Migrates configuration from the legacy `snap` field for backward compatibility, but only applies if the core is selected in `base`." + }, + "core22": { + "anyOf": [ + { + "$ref": "#/definitions/SnapOptionsLegacy" + }, + { + "type": "null" + } + ], + "description": "core22; Migrates configuration from the legacy `snap` field for backward compatibility, but only applies if the core is selected in `base`." + }, + "core24": { + "anyOf": [ + { + "$ref": "#/definitions/SnapOptions24" + }, + { + "type": "null" + } + ], + "description": "[Beta support] Options for building a core24 snap. This is a fresh, forward-looking interface that does not extend the legacy `SnapBaseOptions`.\nInherits desktop-entry fields from `CommonLinuxOptions` (categories, mimeTypes, executableArgs, etc.) and publish configuration from `TargetSpecificOptions`." + }, + "custom": { + "anyOf": [ + { + "$ref": "#/definitions/SnapOptionsCustom" + }, + { + "type": "null" + } + ], + "description": "[Beta support] Pass-through custom snap configuration. electron-builder will read the\nsnapcraft.yaml at `yamlPath` and use it verbatim — no plugs, extensions,\norganize mappings, or desktop files are injected." + }, + "publish": { + "anyOf": [ + { + "$ref": "#/definitions/GithubOptions" + }, + { + "$ref": "#/definitions/GitlabOptions" + }, + { + "$ref": "#/definitions/S3Options" + }, + { + "$ref": "#/definitions/SpacesOptions" + }, + { + "$ref": "#/definitions/GenericServerOptions" + }, + { + "$ref": "#/definitions/CustomPublishOptions" + }, + { + "$ref": "#/definitions/KeygenOptions" + }, + { + "$ref": "#/definitions/SnapStoreOptions" + }, + { + "$ref": "#/definitions/BitbucketOptions" + }, + { + "items": { + "$ref": "#/definitions/AllPublishOptions" + }, + "type": "array" + }, + { + "type": [ + "null", + "string" + ] + } + ] + } + }, + "required": [ + "base" + ], + "type": "object" + }, "SpacesOptions": { "additionalProperties": false, "description": "[DigitalOcean Spaces](https://www.digitalocean.com/community/tutorials/an-introduction-to-digitalocean-spaces) options.\nAccess key is required, define `DO_KEY_ID` and `DO_SECRET_KEY` environment variables.", @@ -7992,8 +8928,18 @@ { "type": "null" } + ] + }, + "snapcraft": { + "anyOf": [ + { + "$ref": "#/definitions/SnapcraftOptions" + }, + { + "type": "null" + } ], - "description": "Snap options." + "description": "Snapcraft options." }, "squirrelWindows": { "anyOf": [ diff --git a/packages/app-builder-lib/src/configuration.ts b/packages/app-builder-lib/src/configuration.ts index 01c79e9b0ee..d3f1bf56da3 100644 --- a/packages/app-builder-lib/src/configuration.ts +++ b/packages/app-builder-lib/src/configuration.ts @@ -9,7 +9,7 @@ import { MsiOptions } from "./options/MsiOptions" import { MsiWrappedOptions } from "./options/MsiWrappedOptions" import { PkgOptions } from "./options/pkgOptions" import { PlatformSpecificBuildOptions } from "./options/PlatformSpecificBuildOptions" -import { SnapOptions } from "./options/SnapOptions" +import { SnapcraftOptions, SnapOptions } from "./options/SnapOptions" import { SquirrelWindowsOptions } from "./options/SquirrelWindowsOptions" import { WindowsConfiguration } from "./options/winOptions" import { BuildResult } from "./packager" @@ -96,9 +96,14 @@ export interface CommonConfiguration { */ readonly deb?: DebOptions | null /** - * Snap options. + * @deprecated Use `snapcraft` instead, which supersedes any `snap` configuration. + * Will be removed in a future major release. */ readonly snap?: SnapOptions | null + /** + * Snapcraft options. + */ + readonly snapcraft?: SnapcraftOptions | null /** * AppImage options. */ diff --git a/packages/app-builder-lib/src/index.ts b/packages/app-builder-lib/src/index.ts index ecf53893d3c..4fb57ec9e9b 100644 --- a/packages/app-builder-lib/src/index.ts +++ b/packages/app-builder-lib/src/index.ts @@ -45,7 +45,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, SnapcraftOptions, 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..403290a8c6d 100644 --- a/packages/app-builder-lib/src/options/SnapOptions.ts +++ b/packages/app-builder-lib/src/options/SnapOptions.ts @@ -1,148 +1,486 @@ import { TargetSpecificOptions } from "../core" import { CommonLinuxOptions } from "./linuxOptions" +/** + * New-style snap configuration. Use this via the `snapcraft` key in your build config. + * Selects the snapcraft core version and its per-core options. + */ +export interface SnapcraftOptions extends TargetSpecificOptions { + /** + * A snap of type base to be used as the execution environment for this snap; can only select one core for target. + */ + readonly base: "core18" | "core20" | "core22" | "core24" | "custom" + /** + * core18; Migrates configuration from the legacy `snap` field for backward compatibility, but only applies if the core is selected in `base`. + */ + readonly core18?: SnapOptionsLegacy | null + /** + * core20; Migrates configuration from the legacy `snap` field for backward compatibility, but only applies if the core is selected in `base`. + */ + readonly core20?: SnapOptionsLegacy | null + /** + * core22; Migrates configuration from the legacy `snap` field for backward compatibility, but only applies if the core is selected in `base`. + */ + readonly core22?: SnapOptionsLegacy | null + + /** + * [Beta support] Options for building a core24 snap. This is a fresh, forward-looking interface that does not extend the legacy `SnapBaseOptions`. + * Inherits desktop-entry fields from `CommonLinuxOptions` (categories, mimeTypes, executableArgs, etc.) and publish configuration from `TargetSpecificOptions`. + * @beta + */ + readonly core24?: SnapOptions24 | null + /** + * [Beta support] Pass-through custom snap configuration. electron-builder will read the + * snapcraft.yaml at `yamlPath` and use it verbatim — no plugs, extensions, + * organize mappings, or desktop files are injected. + * @beta + */ + readonly custom?: SnapOptionsCustom | null +} +// Internal alias used by the core18/20/22 backward-compat fields in SnapcraftOptions. +// Not tagged @deprecated itself to avoid cascading TS6385 hints onto those properties. +export type SnapOptionsLegacy = Omit + +export interface SnapOptionsCustom { + /** + * Path to an existing `snapcraft.yaml` file, relative to `buildResourcesDir`. + * electron-builder reads the file and passes it through without modification. + */ + readonly yamlPath?: string | null +} + +/** + * Flat snap options. Used via the `snap` key in your build config. + * + * @deprecated Prefer the `snapcraft` key with an explicit `base` field (e.g. + * `{ "snapcraft": { "base": "core24", "core24": { ... } } }`). The flat `snap` + * interface is maintained for backward compatibility and targets `core22` and + * older snap bases only. + */ export interface SnapOptions extends CommonLinuxOptions, TargetSpecificOptions { /** - * A snap of type base to be used as the execution environment for this snap. Examples: `core`, `core18`, `core20`, `core22`. Defaults to `core20` + * The snap base to use as the execution environment. + * Examples: `core18`, `core20`, `core22`. + * + * For new projects, use the `snapcraft` key with `base: "core24"` instead of + * this legacy interface. */ readonly base?: string | null /** - * The type of [confinement](https://snapcraft.io/docs/reference/confinement) supported by the snap. + * Whether to use the pre-built Electron snap template for faster builds. + * When `true`, electron-builder delegates snap assembly to the upstream Electron snap + * template rather than running a full snapcraft build, significantly reducing build time. + * Defaults to `true` when `stagePackages` is not customised. + * Only applicable to x64 and armv7l builds. + */ + readonly useTemplateApp?: boolean + + /** + * The type of [snap confinement](https://snapcraft.io/docs/reference/confinement). + * - `strict` — recommended; the snap runs in a fully isolated sandbox. + * - `devmode` — sandbox violations are logged but not enforced; for development only. + * - `classic` — no confinement; equivalent to a traditionally packaged application. + * Requires Snap Store approval before publishing. * @default strict */ readonly confinement?: "devmode" | "strict" | "classic" | null /** - * The custom environment. Defaults to `{"TMPDIR: "$XDG_RUNTIME_DIR"}`. If you set custom, it will be merged with default. + * Environment variables injected into the snap's runtime environment. + * Merged with the electron-builder default `{ TMPDIR: "$XDG_RUNTIME_DIR" }`. + * User-supplied values take precedence. */ readonly environment?: { [key: string]: string } | null /** - * The 78 character long summary. Defaults to [productName](./configuration.md#productName). + * A short summary of the snap (max 78 characters). + * Defaults to [productName](./configuration.md#productName). */ readonly summary?: string | null /** - * The quality grade of the snap. It can be either `devel` (i.e. a development version of the snap, so not to be published to the “stable” or “candidate” channels) or “stable” (i.e. a stable release or release candidate, which can be released to all channels). + * The quality grade of the snap. + * - `stable` — suitable for all channels, including `stable` and `candidate`. + * - `devel` — development snapshot; cannot be promoted to `stable` or `candidate`. * @default stable */ readonly grade?: "devel" | "stable" | null /** - * The list of features that must be supported by the core in order for this snap to install. + * [Snapd features](https://snapcraft.io/docs/snapcraft-yaml-reference#assumes) that must + * be present on the host before the snap can be installed. */ readonly assumes?: Array | string | null /** - * The list of debian packages needs to be installed for building this snap. + * Debian packages required at **build** time (installed inside the build environment). */ readonly buildPackages?: Array | null /** - * The list of Ubuntu packages to use that are needed to support the `app` part creation. Like `depends` for `deb`. + * Ubuntu packages to **stage** alongside the app (equivalent to `depends` for deb). * Defaults to `["libnspr4", "libnss3", "libxss1", "libappindicator3-1", "libsecret-1-0"]`. * - * If list contains `default`, it will be replaced to default list, so, `["default", "foo"]` can be used to add custom package `foo` in addition to defaults. + * Use the `"default"` keyword to extend the default list: + * `["default", "my-extra-lib"]` appends `my-extra-lib` to the defaults. */ readonly stagePackages?: Array | null /** - * The [hooks](https://docs.snapcraft.io/build-snaps/hooks) directory, relative to `build` (build resources directory). + * Directory containing [snap hooks](https://snapcraft.io/docs/snap-hooks), relative to + * the build resources directory (`build/`). * @default build/snap-hooks */ readonly hooks?: string | null /** - * The list of [plugs](https://snapcraft.io/docs/reference/interfaces). - * Defaults to `["desktop", "desktop-legacy", "home", "x11", "wayland", "unity7", "browser-support", "network", "gsettings", "audio-playback", "pulseaudio", "opengl"]`. + * [Plugs](https://snapcraft.io/docs/reference/interfaces) (consumed interfaces) to declare + * for the app entry point. + * Defaults to `["desktop", "desktop-legacy", "home", "x11", "wayland", "unity7", + * "browser-support", "network", "gsettings", "audio-playback", "pulseaudio", "opengl"]`. * - * If list contains `default`, it will be replaced to default list, so, `["default", "foo"]` can be used to add custom plug `foo` in addition to defaults. + * Use `"default"` in the list to keep the defaults and append extras: + * `["default", "camera"]` adds `camera` to the standard set. * - * Additional attributes can be specified using object instead of just name of plug: - * ``` - *[ - * { - * "browser-sandbox": { - * "interface": "browser-support", - * "allow-sandbox": true - * }, - * }, - * "another-simple-plug-name" - *] + * To configure plug attributes (e.g. `allow-sandbox` for Chromium's internal sandbox), + * use a descriptor object: + * ```json + * [ + * { "browser-sandbox": { "interface": "browser-support", "allow-sandbox": true } }, + * "another-simple-plug-name" + * ] * ``` */ readonly plugs?: Array | PlugDescriptor | null /** - * The list of [slots](https://snapcraft.io/docs/reference/interfaces). + * [Slots](https://snapcraft.io/docs/reference/interfaces) (provided interfaces) to declare + * for the app. * - * Additional attributes can be specified using object instead of just name of slot: + * To expose an MPRIS player under the Chromium bus name (required for strict confinement): + * ```json + * [{ "mpris": { "name": "chromium" } }] * ``` - *[ - * { - * "mpris": { - * "name": "chromium" - * }, - * } - *] - * - * In case you want your application to be a compliant MPris player, you will need to definie - * The mpris slot with "chromium" name. - * This electron has it [hardcoded](https://source.chromium.org/chromium/chromium/src/+/master:components/system_media_controls/linux/system_media_controls_linux.cc;l=51;bpv=0;bpt=1), - * and we need to pass this name so snap [will allow it](https://forum.snapcraft.io/t/unable-to-use-mpris-interface/15360/7) in strict confinement. - * + * Chromium [hard-codes](https://source.chromium.org/chromium/chromium/src/+/master:components/system_media_controls/linux/system_media_controls_linux.cc;l=51;bpv=0;bpt=1) + * the bus name `chromium`, so the slot name must match for snapd to + * [allow it](https://forum.snapcraft.io/t/unable-to-use-mpris-interface/15360/7). */ - readonly slots?: Array | PlugDescriptor | null + readonly slots?: Array | SlotDescriptor | null /** - * Specifies any [parts](https://snapcraft.io/docs/reference/parts) that should be built before this part. - * Defaults to `["desktop-gtk2""]`. + * Names of snapcraft parts that must be built before the app part. + * Defaults to `["desktop-gtk2"]`. * - * If list contains `default`, it will be replaced to default list, so, `["default", "foo"]` can be used to add custom parts `foo` in addition to defaults. + * Use `"default"` to keep the default and add extras: + * `["default", "my-helper-part"]`. */ 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. + * Whether the snap should automatically start on login. * @default false */ readonly autoStart?: boolean /** - * Specifies any files to make accessible from locations such as `/usr`, `/var`, and `/etc`. See [snap layouts](https://snapcraft.io/docs/snap-layouts) to learn more. + * [Snap layouts](https://snapcraft.io/docs/snap-layouts) — bind-mount or symlink host paths + * into the snap's namespace, making libraries or config at `/usr`, `/var`, `/etc`, etc. + * accessible inside the confined environment. */ readonly layout?: { [key: string]: { [key: string]: string } } | null /** - * 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. + * Filesets controlling which files from the app part are staged into the snap. + * Supports individual files, directories, globs, globstars, and exclusions (prefix `!`). + * See [Snapcraft filesets](https://snapcraft.io/docs/snapcraft-filesets). * - * 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 built-in defaults are 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 /** - * An optional title for the snap, may contain uppercase letters and spaces. Defaults to `productName`. See [snap format documentation](https://snapcraft.io/docs/snap-format). + * Display title for the snap (may contain uppercase letters and spaces). + * Defaults to `productName`. + * See [snap format](https://snapcraft.io/docs/snap-format). */ readonly title?: string | null /** - * Sets the compression type for the snap. Can be xz, lzo, or null. + * Compression algorithm for the snap SquashFS image. + * - `xz` — smaller file, slower decompression (good for distribution). + * - `lzo` — larger file, faster decompression (good for development iteration). + * Omit to use snapcraft's default (`xz`). */ 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 the snap to run with native Wayland support (`--ozone-platform=wayland`). + * Disabled by default due to compatibility issues in some Electron/snap combinations. + * @default false */ readonly allowNativeWayland?: boolean | null } +/** + * Configuration for a remote snap build on [Launchpad](https://launchpad.net/). + * Remote builds run on Canonical's infrastructure and support multiple architectures + * (amd64, arm64, armhf) without requiring native hardware or nested virtualisation. + * + * Authentication is resolved in this order: + * 1. `credentialsFile` — path to a file produced by `snapcraft export-login` + * 2. `SNAPCRAFT_STORE_CREDENTIALS` environment variable + * 3. An active interactive `snapcraft login` session + */ +export interface RemoteBuildOptions { + /** + * Whether to enable remote build on Launchpad. Must be set explicitly to `true` to opt in. + */ + enabled: boolean + + /** + * Your Launchpad username. Used to select the correct Launchpad account when more than + * one set of credentials is available. + */ + launchpadUsername?: string + + /** + * Target architectures for the remote build. + * @example ["amd64", "arm64", "armhf"] + */ + buildFor?: string[] + + /** + * Suppress the Launchpad public-upload consent prompt by automatically accepting it. + * Your source code will be uploaded to a **public** Launchpad repository. + * Set to `true` in CI once you understand the implications. + */ + acceptPublicUpload?: boolean + + /** + * Launchpad project name to use for a **private** source upload. + * The project must already exist and you must have write access. + */ + privateProject?: string + + /** + * Path to an SSH private key used for Launchpad authentication. + * Defaults to `~/.ssh/id_rsa`. The matching public key must be registered + * on your Launchpad account. + */ + sshKeyPath?: string + + /** + * Path to a Snapcraft credentials file produced by `snapcraft export-login `. + * Recommended for CI/CD pipelines — avoids interactive login and does not require an + * SSH key on the build agent. + */ + credentialsFile?: string + + /** + * Resume a previously interrupted remote build rather than starting a new one. + */ + recover?: boolean + + /** + * Maximum time in seconds to wait for the remote build to complete before aborting. + */ + timeout?: number + + /** + * Controls whether snapcraft may fall back to a different remote build strategy. + * - `"disable-fallback"` — always use the primary strategy, fail if unavailable. + * - `"force-fallback"` — always use the fallback strategy. + */ + strategy?: "disable-fallback" | "force-fallback" +} + +/** + * Options for building a core24 snap. This is a fresh, forward-looking interface that does + * not extend the legacy `SnapBaseOptions`. It inherits desktop-entry fields from + * `CommonLinuxOptions` (categories, mimeTypes, executableArgs, etc.) and publish + * configuration from `TargetSpecificOptions`. + */ +export interface SnapOptions24 extends CommonLinuxOptions, TargetSpecificOptions { + // ─── Build environment (mutually exclusive) ───────────────────────────────── + + /** + * Use [LXD](https://canonical.com/lxd) as the isolated build environment. + * Preferred over Multipass on most Linux CI systems where nested virtualisation is unavailable. + * Mutually exclusive with `useMultipass` and `useDestructiveMode`. + */ + readonly useLXD?: boolean | null + + /** + * Use [Multipass](https://multipass.run/) as the isolated build environment. + * Mutually exclusive with `useLXD` and `useDestructiveMode`. + */ + readonly useMultipass?: boolean | null + + /** + * Build directly on the host without an isolated VM or container (snapcraft `--destructive-mode`). + * Equivalent to setting `SNAPCRAFT_BUILD_ENVIRONMENT=host`. + * + * **Not recommended for most use cases.** Destructive mode pollutes the host environment + * and produces builds that are difficult to reproduce — any library or tool present on the + * host at build time can silently end up in the snap. Prefer `useLXD` or `useMultipass` + * for clean, reproducible builds; use `remoteBuild` for multi-architecture CI. + * + * Valid reasons to enable this option: + * - Building inside a Docker container where nested virtualisation (LXD / Multipass) is + * unavailable and a remote Launchpad build is not acceptable. + * - Running test suites in CI where the environment is already fully controlled. + * + * The `gnome` extension is incompatible with this mode enabled — do not include it in `extensions`. + * @see https://snapcraft.io/docs/build-options + */ + readonly useDestructiveMode?: boolean | null + + /** + * Configuration for a remote build on [Launchpad](https://launchpad.net/). + * Enables multi-architecture builds (amd64, arm64, armhf) in CI without native hardware. + */ + readonly remoteBuild?: RemoteBuildOptions | null + + // ─── Snapcraft extensions ──────────────────────────────────────────────────── + + /** + * [Snapcraft extensions](https://snapcraft.io/docs/snapcraft-extensions) to apply to the app. + * Defaults to `["gnome"]` in normal builds (recommended for Electron apps on Ubuntu 24.04+). + * Automatically set to `[]` in `useDestructiveMode` builds, where the gnome extension is + * incompatible. Explicitly including `"gnome"` while `useDestructiveMode` is set will throw. + * See: https://snapcraft.io/docs/gnome-extension + */ + readonly extensions?: Array | null + + // ─── Snap metadata ─────────────────────────────────────────────────────────── + + /** + * The type of [confinement](https://snapcraft.io/docs/reference/confinement) supported by the snap. + * @default strict + */ + readonly confinement?: "devmode" | "strict" | "classic" | null + + /** + * The quality grade of the snap. + * `devel` — not publishable to stable/candidate channels. + * `stable` — suitable for all channels. + * @default stable + */ + readonly grade?: "devel" | "stable" | null + + /** + * A short summary of the snap (max 78 characters). Defaults to `productName`. + */ + readonly summary?: string | null + + /** + * An optional display title (may contain uppercase letters and spaces). Defaults to `productName`. + * See [snap format](https://snapcraft.io/docs/snap-format). + */ + readonly title?: string | null + + /** + * Compression algorithm for the snap file. + */ + readonly compression?: "xz" | "lzo" | null + + /** + * Features that must be supported by the host snapd before the snap can be installed. + * See [assumes](https://snapcraft.io/docs/snapcraft-yaml-reference#assumes). + */ + readonly assumes?: Array | string | null + + // ─── Build packages / stage packages ──────────────────────────────────────── + + /** + * Debian packages required at **build** time (installed inside the build environment). + */ + readonly buildPackages?: Array | null + + /** + * Ubuntu packages to **stage** alongside the app (equivalent to `depends` for deb). + * Defaults to `["libnspr4", "libnss3", "libxss1", "libappindicator3-1", "libsecret-1-0"]`. + * Supports the `"default"` keyword to reference the default list: + * `["default", "my-extra-lib"]` appends `my-extra-lib` to the defaults. + */ + readonly stagePackages?: Array | null + + /** + * Filesets controlling which files from the app part are staged into the snap. + * Supports glob patterns and exclusions. See [filesets](https://snapcraft.io/docs/snapcraft-filesets). + */ + readonly appPartStage?: Array | null + + /** + * Names of other snapcraft parts that must be built before the app part. + */ + readonly after?: Array | null + + // ─── Snap interfaces ───────────────────────────────────────────────────────── + + /** + * [Plugs](https://snapcraft.io/docs/reference/interfaces) (consumed interfaces) for the app. + * When the `gnome` extension is active, content-snap plugs (themes, GNOME platform, GPU) + * are added automatically — only list custom plugs here. + * Without any extension, defaults to the standard Electron plug set. + * + * Supports descriptor objects for plugs with attributes: + * ```json + * [{ "browser-sandbox": { "interface": "browser-support", "allow-sandbox": true } }] + * ``` + */ + readonly plugs?: Array | PlugDescriptor | null + + /** + * [Slots](https://snapcraft.io/docs/reference/interfaces) (provided interfaces) for the app. + * Use for MPRIS, D-Bus services, etc. + * + * Example — expose MPRIS under the Chromium bus name: + * ```json + * [{ "mpris": { "name": "chromium" } }] + * ``` + */ + readonly slots?: Array | SlotDescriptor | null + + /** + * [Snap layouts](https://snapcraft.io/docs/snap-layouts) — bind-mount or symlink host paths + * into the snap's namespace. User-provided layouts always override the extension defaults. + */ + readonly layout?: { [key: string]: { [key: string]: string } } | null + + // ─── Runtime environment ───────────────────────────────────────────────────── + + /** + * Additional environment variables injected into the snap's runtime environment. + * Merged with the electron-builder defaults (`TMPDIR=$XDG_RUNTIME_DIR`). + * User-supplied values take precedence. + */ + readonly environment?: { [key: string]: string } | null + + /** + * Whether the app should auto-start on login (creates an autostart desktop entry). + * @default false + */ + readonly autoStart?: boolean + + /** + * Allow running the application with native Wayland support (`--ozone-platform=wayland`). + * For core24 this defaults to `true`. Set to `false` to force X11 mode via XWayland. + * @default true + */ + readonly allowNativeWayland?: boolean | null + + // ─── Hooks ─────────────────────────────────────────────────────────────────── + + /** + * Directory containing [snap hooks](https://snapcraft.io/docs/snap-hooks), relative to + * the build resources directory. + * @default build/snap-hooks + */ + readonly hooks?: string | null +} + export interface PlugDescriptor { [key: string]: { [key: string]: any } | null } diff --git a/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts b/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts index 14f24257c71..c7906dbd5d1 100644 --- a/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts +++ b/packages/app-builder-lib/src/targets/LinuxTargetHelper.ts @@ -1,10 +1,17 @@ -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 * as semver from "semver" +import { Configuration } from "../configuration" import { LinuxPackager } from "../linuxPackager" +import { SnapcraftOptions, SnapOptions } from "../options/SnapOptions" import { LinuxTargetSpecificOptions } from "../options/linuxOptions" import { IconInfo } from "../platformPackager" +import { SnapCore } from "./snap/SnapTarget" +import { SnapCore24 } from "./snap/core24" +import { SnapCoreCustom } from "./snap/coreCustom" +import { SnapCoreLegacy } from "./snap/coreLegacy" export const installPrefix = "/opt" @@ -25,6 +32,47 @@ export class LinuxTargetHelper { return this.mimeTypeFilesPromise.value } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getSnapCore(): SnapCore { + const snapcraft = this.resolveSnapcraftConfig(this.packager.config) + if (snapcraft != null) { + const core = snapcraft.base || "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, { base: core, ...(snapcraft[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, snapcraft.core24 || {}) + case "custom": + return new SnapCoreCustom(this.packager, this, snapcraft.custom || {}) + } + } + // Backward compat: flat `snap` key maps directly to the legacy build path. + const legacySnap = this.resolveLegacySnapConfig(this.packager.config) ?? {} + return new SnapCoreLegacy(this.packager, this, legacySnap) + } + + isElectronVersionGreaterOrEqualThan(version: string): boolean { + const electronVersion = this.packager.config.electronVersion + if (!electronVersion) { + return true + } + return semver.gte(electronVersion, version) + } + private async computeMimeTypeFiles(): Promise { const items: Array = [] for (const fileAssociation of this.packager.fileAssociations) { @@ -209,6 +257,14 @@ export class LinuxTargetHelper { } return Promise.resolve(data) } + + private resolveSnapcraftConfig(config: Configuration): SnapcraftOptions | null { + return config.snapcraft ?? null + } + + private resolveLegacySnapConfig(config: Configuration): SnapOptions | null { + return config.snap ?? null + } } const macToLinuxCategory: any = { 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..400fdfa9554 --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/SnapTarget.ts @@ -0,0 +1,136 @@ +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 { SnapcraftOptions, 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: SnapcraftOptions | SnapOptions + + constructor( + name: string, + protected readonly packager: LinuxPackager, + protected readonly helper: LinuxTargetHelper, + readonly outDir: string + ) { + super(name) + + const { + config: { snapcraft, snap }, + platformSpecificBuildOptions, + } = packager + const { compression: _ignored, ...overlappingOptions } = platformSpecificBuildOptions + + this.options = { + ...overlappingOptions, + ...(snapcraft ?? snap), // support deprecated `snap` config for backward compatibility + } + } + + 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 + } + + const snapConfig = config.snapcraft ?? config.snap + if (snapConfig?.publish) { + return this.findSnapPublishConfigInPublishNode(snapConfig.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..6b2d76cc19b --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/core24.ts @@ -0,0 +1,458 @@ +import { Arch, archFromString, copyDir, InvalidConfigurationError, 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" + +const defaultStagePackages = ["libnspr4", "libnss3", "libxss1", "libappindicator3-1", "libsecret-1-0"] + +export class SnapCore24 extends SnapCore { + defaultPlugs = ["desktop", "desktop-legacy", "home", "x11", "wayland", "unity7", "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) + } + + private isHostMode(): boolean { + return this.options.useDestructiveMode === true + } + + async buildSnap(params: { snap: SnapcraftYAML; appOutDir: string; stageDir: string; snapArch: Arch; artifactPath: string }): Promise { + const { snap, appOutDir, stageDir, artifactPath } = params + + 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") + } + + if (this.packager.packagerOptions.effectiveOptionComputed != null && (await this.packager.packagerOptions.effectiveOptionComputed({ snap }))) { + return + } + + 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 + // Default to ["gnome"] in normal builds; no extensions in host/destructive-mode (where the + // gnome extension is incompatible). Throw if the user explicitly includes "gnome" in host mode. + const hostMode = this.isHostMode() + const extensionsList: string[] = options.extensions != null ? [...options.extensions] : hostMode ? [] : ["gnome"] + if (hostMode && extensionsList.includes("gnome")) { + throw new InvalidConfigurationError( + `The "gnome" snapcraft extension is incompatible with host/destructive-mode builds.\n` + + `In this mode snapcraft cannot resolve the extension's command-chain source ` + + `(/usr/share/snapcraft/extensions/desktop/command-chain) and will fail.\n\n` + + `To resolve this, choose one of:\n` + + ` 1. Remove "gnome" from snapcraft.core24.extensions (or set it to []) and add any\n` + + ` required stage-packages manually.\n` + + ` 2. Switch to an isolated build environment by setting snapcraft.core24.useLXD: true\n` + + ` or snapcraft.core24.useMultipass: true instead of useDestructiveMode.\n\n` + + `See: https://snapcraft.io/docs/gnome-extension` + ) + } + const resolvedExtensions = extensionsList.length > 0 ? extensionsList : undefined + const useGnomeExtension = extensionsList.includes("gnome") + + // 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) + : hostMode + ? { root: undefined, app: this.defaultPlugs } + : { + root: defaultRootPlugs, + app: this.defaultPlugs, + } + rootPlugs = result.root + appPlugs = result.app + } + + // Always add browser-support with allow-sandbox so Chromium's internal sandbox + // can create user namespaces under strict confinement. Without allow-sandbox: true + // the app crashes immediately with "FATAL: Permission denied (13)" in credentials.cc. + // Skip the injection only when the user has explicitly provided their own plugs + // (they are responsible for including browser-support in that case). + if (!options.plugs) { + rootPlugs = { ...rootPlugs, "browser-support": { interface: "browser-support", "allow-sandbox": true } } + if (!appPlugs?.includes("browser-support")) { + appPlugs = [...(appPlugs ?? []), "browser-support"] + } + } + + const { root: rootSlots, app: appSlots } = options.slots ? this.processPlugOrSlots(options.slots) : { root: undefined, app: undefined } + + // Create the app configuration + const app: App = { + command: `app/${this.packager.executableName}`, + "command-chain": undefined, + plugs: appPlugs, + slots: appSlots, + autostart: options.autoStart ? `${appName}.desktop` : undefined, + desktop: `meta/gui/${appName}.desktop`, + extensions: resolvedExtensions, + } + + // Icon path — build-time relative path so snapcraft can find the file in snap/gui/ + await this.helper.icons + const iconPath = this.helper.maxIconPath != null ? `snap/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, + } + + // 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 — only needed for cross-compilation; snapcraft 8 + // defaults to host arch and snapcraft 7 rejects this field entirely. + ...(arch !== archFromString(process.arch) + ? { + 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), + + // User-supplied layout always wins. Without gnome extension and not in host mode, fall back to content-snap defaults. + layout: options.layout ?? (useGnomeExtension || hostMode ? 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/coreCustom.ts b/packages/app-builder-lib/src/targets/snap/coreCustom.ts new file mode 100644 index 00000000000..016b11838b3 --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/coreCustom.ts @@ -0,0 +1,56 @@ +import { Arch, InvalidConfigurationError, log } from "builder-util" +import { readFile } from "fs-extra" +import * as yaml from "js-yaml" +import * as path from "path" +import { SnapOptionsCustom } from "../../options/SnapOptions" +import { SnapCore } from "./SnapTarget" +import { SnapcraftYAML } from "./snapcraft" +import { buildSnap } from "./snapcraftBuilder" + +/** + * Pass-through snap builder for custom snapcraft.yaml files. + * + * electron-builder reads the file at `yamlPath`, writes it into the stage + * directory, and invokes snapcraft — no plugs, extensions, organize mappings, + * or desktop files are injected. + */ +export class SnapCoreCustom extends SnapCore { + readonly defaultPlugs: string[] = [] + + async createDescriptor(_arch: Arch): Promise { + const { yamlPath } = this.options + if (!yamlPath) { + throw new InvalidConfigurationError('snap.core = "custom" requires snap.custom.yamlPath pointing to a snapcraft.yaml file') + } + const resolved = path.resolve(this.packager.buildResourcesDir, yamlPath) + const raw = await readFile(resolved, "utf8") + return yaml.load(raw) as SnapcraftYAML + } + + async buildSnap(params: { snap: SnapcraftYAML; appOutDir: string; stageDir: string; snapArch: Arch; artifactPath: string }): Promise { + const { snap, stageDir, artifactPath } = params + + const snapDirResolved = path.resolve(stageDir, "snap") + const snapcraftYamlPath = path.join(snapDirResolved, "snapcraft.yaml") + + const yamlContent = yaml.dump(snap, { + indent: 2, + lineWidth: -1, + noRefs: true, + }) + + const { outputFile } = await import("fs-extra") + await outputFile(snapcraftYamlPath, yamlContent, "utf8") + log.debug(snap, "using custom snapcraft.yaml (pass-through, no injection)") + + if (this.packager.packagerOptions.effectiveOptionComputed != null && (await this.packager.packagerOptions.effectiveOptionComputed({ snap }))) { + return + } + + await buildSnap({ + snapcraftConfig: snap, + artifactPath, + stageDir, + }) + } +} 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..e83ec67ea69 --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/coreLegacy.ts @@ -0,0 +1,296 @@ +import { getTemplatePath } from "../../util/pathManager" +import { replaceDefault as _replaceDefault, Arch, deepAssign, executeAppBuilder, 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, SnapOptions } from "../../options/SnapOptions" +import { SnapCore } from "./SnapTarget" + +// Leverages legacy implementation through app-builder-bin https://github.com/develar/app-builder/blob/master/pkg/package-format/snap +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) + + const stageSet = new Set(stagePackages) + const stageMatchesDefaults = stagePackages.length === defaultStagePackages.length && defaultStagePackages.every(p => stageSet.has(p)) + + this.isUseTemplateApp = this.options.useTemplateApp !== false && (arch === Arch.x64 || arch === Arch.armv7l) && buildPackages.length === 0 && stageMatchesDefaults + + 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, or snap.yaml inside meta/ for template builds + 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 in snap.yaml (template format) + 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) { + // Map TypeScript Arch enum to the string keys expected by app-builder's ResolveTemplateDir. + // The previous code passed the raw numeric enum value (e.g. Arch.x64 = 1 → "electron4:1") + // which fell through to the default case and was treated as a bare URL, failing with + // "unsupported protocol scheme". The switch in snap.go expects "electron4:amd64" / "electron4:armhf". + const templateArch = snapArch === Arch.x64 ? "amd64" : "armhf" + args.push("--template-url", `electron4:${templateArch}`) + } + + 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..ebc4d16d2fe --- /dev/null +++ b/packages/app-builder-lib/src/targets/snap/snapcraftBuilder.ts @@ -0,0 +1,507 @@ +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) + try { + await validateSnapcraftYamlWithCLI(stageDir) + } catch (validationError: any) { + // Non-fatal: expand-extensions can fail in some environments (e.g. no store + // access, host-mode context). The actual snapcraft pack will surface real errors. + log.warn({ error: validationError.message }, "snapcraft CLI pre-validation failed (non-fatal), continuing build") + } + + // Step 5: Detect platform and determine build strategy + progress.logStage("preparing", "detecting platform and build method", 50) + const platform = process.platform + 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)") + + // Validate build environment selection on non-Linux hosts where snapcraft has no fallback. + if (!remoteBuild?.enabled && !useLXD && !useMultipass && !useDestructiveMode && process.platform !== "linux") { + throw new Error( + `No snap build environment specified for ${process.platform}. ` + + `Set one of: snapcraft.core24.useMultipass, snapcraft.core24.useLXD (Linux only), ` + + `or snapcraft.core24.remoteBuild.enabled` + ) + } + + progress.logStage("building", "running snapcraft build", 70) + const snapFilePath = await executeWithRetry( + () => + executeSnapcraftBuild({ + workDir: stageDir, + remoteBuild, + outputSnap: artifactPath, + useLXD, + useMultipass, + useDestructiveMode, + 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") + } + } + + // Always use a basename-only output so the snap lands in the workDir regardless of + // whether snapcraft is running on the host or inside an LXD/Multipass container. + // When using an isolated build environment (--use-lxd / --use-multipass) the + // workDir is mounted into the VM, but an absolute host path is NOT visible there — + // snapcraft would create the file inside the container's rootfs at that absolute + // path. Using a relative name ensures it ends up in the mounted workDir, which is + // always accessible on the host after the build completes. + const outputBasename = path.basename(outputFileName) + args.push("--output", outputBasename) + 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 snapInWorkDir = path.join(workDir, outputBasename) + if (snapInWorkDir !== outputFileName) { + const { copyFile, ensureDir } = await import("fs-extra") + await ensureDir(path.dirname(outputFileName)) + await copyFile(snapInWorkDir, outputFileName) + log.debug({ from: snapInWorkDir, to: outputFileName }, "copied snap from build dir to artifact path") + } + return outputFileName +} diff --git a/packages/builder-util/src/util.ts b/packages/builder-util/src/util.ts index f9ec3ecb8c8..989326e87b8 100644 --- a/packages/builder-util/src/util.ts +++ b/packages/builder-util/src/util.ts @@ -331,6 +331,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/scripts/fix-schema.js b/scripts/fix-schema.js index ceec5d94235..b90da2a3f35 100644 --- a/scripts/fix-schema.js +++ b/scripts/fix-schema.js @@ -49,10 +49,12 @@ schema.definitions.ElectronGetOptions.properties.mirrorOptions = { }, } -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.SnapOptions.properties.environment.anyOf[0] = record o = schema.properties["$schema"] = { description: "JSON Schema for this document.", diff --git a/test/snapshots/linux/snapHeavyTest.js.snap b/test/snapshots/linux/snapHeavyTest.js.snap index 44c8de642cf..46b41f7c650 100644 --- a/test/snapshots/linux/snapHeavyTest.js.snap +++ b/test/snapshots/linux/snapHeavyTest.js.snap @@ -1,17 +1,61 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`snap core test: core24 > snap full (armhf) 1`] = ` +exports[`snap core24 native > core24 build + install + launch 1`] = ` { "linux": [ { - "arch": "armv7l", - "file": "se-wo-template_1.1.0_armhf.snap", + "arch": "x64", + "file": "se-wo-template_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap core24 native > core24 destructive-mode (no gnome extension) 1`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "se-wo-template_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap core24 native > core24 with custom stagePackages 1`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "se-wo-template_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap heavy > snap full (core24) 1`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "se-wo-template_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap heavy > snap install+launch (core18) 1`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "se-wo-template_1.1.0_amd64.snap", }, ], } `; -exports[`snap core test: core24 > snap full 1`] = ` +exports[`snap heavy > snap install+launch (core20) 1`] = ` { "linux": [ { @@ -22,7 +66,7 @@ exports[`snap core test: core24 > snap full 1`] = ` } `; -exports[`snap full 1`] = ` +exports[`snap heavy > snap install+launch (core22) 1`] = ` { "linux": [ { diff --git a/test/snapshots/linux/snapTest.js.snap b/test/snapshots/linux/snapTest.js.snap index cd75c708822..8972a7f1483 100644 --- a/test/snapshots/linux/snapTest.js.snap +++ b/test/snapshots/linux/snapTest.js.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`arm 1`] = ` +exports[`snap > arm 1`] = ` { "linux": [ { @@ -11,7 +11,7 @@ exports[`arm 1`] = ` } `; -exports[`auto start 1`] = ` +exports[`snap > auto start 1`] = ` { "apps": { "sep": { @@ -39,14 +39,14 @@ exports[`auto start 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "sep", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -75,13 +75,18 @@ exports[`auto start 1`] = ` } `; -exports[`auto start 2`] = ` +exports[`snap > auto start 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`base option 1`] = ` +exports[`snap > base option 1`] = ` { "apps": { "testapp": { @@ -108,14 +113,14 @@ exports[`base option 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "testapp", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -144,17 +149,21 @@ exports[`base option 1`] = ` } `; -exports[`base option 2`] = ` +exports[`snap > base option 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "TestApp_1.1.0_amd64.snap", + }, + ], } `; -exports[`buildPackages 1`] = ` +exports[`snap > buildPackages 1`] = ` { "apps": { "sep": { - "adapter": "none", "command": "command.sh", "environment": { "DISABLE_WAYLAND": "1", @@ -178,10 +187,7 @@ exports[`buildPackages 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", @@ -328,6 +334,9 @@ exports[`buildPackages 1`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -356,13 +365,18 @@ exports[`buildPackages 1`] = ` } `; -exports[`buildPackages 2`] = ` +exports[`snap > buildPackages 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`classic confinement 1`] = ` +exports[`snap > classic confinement 1`] = ` { "linux": [ { @@ -373,11 +387,10 @@ exports[`classic confinement 1`] = ` } `; -exports[`compression option 1`] = ` +exports[`snap > compression option 1`] = ` { "apps": { "sep": { - "adapter": "none", "command": "command.sh", "environment": { "DISABLE_WAYLAND": "1", @@ -401,10 +414,7 @@ exports[`compression option 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "compression": "xz", "confinement": "strict", "description": "Test Application (test quite “ #378)", @@ -547,6 +557,9 @@ exports[`compression option 1`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -575,13 +588,368 @@ exports[`compression option 1`] = ` } `; -exports[`compression option 2`] = ` +exports[`snap > compression option 2`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap > core24 custom plugs with default expansion 1`] = ` +{ + "apps": { + "sep": { + "command": "app/sep", + "desktop": "meta/gui/sep.desktop", + "extensions": [ + "gnome", + ], + "plugs": [ + "desktop", + "desktop-legacy", + "home", + "x11", + "wayland", + "unity7", + "network", + "gsettings", + "audio-playback", + "pulseaudio", + "opengl", + "camera", + ], + }, + }, + "base": "core24", + "confinement": "strict", + "description": "Test Application (test quite “ #378)", + "environment": { + "TMPDIR": "$XDG_RUNTIME_DIR", + }, + "grade": "stable", + "icon": "snap/gui/sep.png", + "name": "sep", + "parts": { + "sep": { + "organize": { + "LICENSE.electron.txt": "app/LICENSE.electron.txt", + "LICENSES.chromium.html": "app/LICENSES.chromium.html", + "chrome-sandbox": "app/chrome-sandbox", + "chrome_100_percent.pak": "app/chrome_100_percent.pak", + "chrome_200_percent.pak": "app/chrome_200_percent.pak", + "chrome_crashpad_handler": "app/chrome_crashpad_handler", + "icudtl.dat": "app/icudtl.dat", + "libEGL.so": "app/libEGL.so", + "libGLESv2.so": "app/libGLESv2.so", + "libffmpeg.so": "app/libffmpeg.so", + "libvk_swiftshader.so": "app/libvk_swiftshader.so", + "libvulkan.so.1": "app/libvulkan.so.1", + "locales": "app/locales", + "resources": "app/resources", + "resources.pak": "app/resources.pak", + "sep": "app/sep", + "snapshot_blob.bin": "app/snapshot_blob.bin", + "v8_context_snapshot.bin": "app/v8_context_snapshot.bin", + "vk_swiftshader_icd.json": "app/vk_swiftshader_icd.json", + }, + "plugin": "dump", + "source": "app", + }, + }, + "summary": "Sep", + "title": "Sep", + "version": "1.1.0", +} +`; + +exports[`snap > core24 custom plugs with default expansion 2`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap > core24 default (gnome extension) 1`] = ` +{ + "apps": { + "sep": { + "command": "app/sep", + "desktop": "meta/gui/sep.desktop", + "extensions": [ + "gnome", + ], + "plugs": [ + "browser-support", + ], + }, + }, + "base": "core24", + "confinement": "strict", + "description": "Test Application (test quite “ #378)", + "environment": { + "TMPDIR": "$XDG_RUNTIME_DIR", + }, + "grade": "stable", + "icon": "snap/gui/sep.png", + "name": "sep", + "parts": { + "sep": { + "organize": { + "LICENSE.electron.txt": "app/LICENSE.electron.txt", + "LICENSES.chromium.html": "app/LICENSES.chromium.html", + "chrome-sandbox": "app/chrome-sandbox", + "chrome_100_percent.pak": "app/chrome_100_percent.pak", + "chrome_200_percent.pak": "app/chrome_200_percent.pak", + "chrome_crashpad_handler": "app/chrome_crashpad_handler", + "icudtl.dat": "app/icudtl.dat", + "libEGL.so": "app/libEGL.so", + "libGLESv2.so": "app/libGLESv2.so", + "libffmpeg.so": "app/libffmpeg.so", + "libvk_swiftshader.so": "app/libvk_swiftshader.so", + "libvulkan.so.1": "app/libvulkan.so.1", + "locales": "app/locales", + "resources": "app/resources", + "resources.pak": "app/resources.pak", + "sep": "app/sep", + "snapshot_blob.bin": "app/snapshot_blob.bin", + "v8_context_snapshot.bin": "app/v8_context_snapshot.bin", + "vk_swiftshader_icd.json": "app/vk_swiftshader_icd.json", + }, + "plugin": "dump", + "source": "app", + }, + }, + "plugs": { + "browser-support": { + "allow-sandbox": true, + "interface": "browser-support", + }, + }, + "summary": "Sep", + "title": "Sep", + "version": "1.1.0", +} +`; + +exports[`snap > core24 default (gnome extension) 2`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap > core24 gnome extension throws in destructive-mode 1`] = `"ERR_ELECTRON_BUILDER_INVALID_CONFIGURATION"`; + +exports[`snap > core24 no gnome extension 1`] = ` +{ + "apps": { + "sep": { + "command": "app/sep", + "desktop": "meta/gui/sep.desktop", + "plugs": [ + "desktop", + "desktop-legacy", + "home", + "x11", + "wayland", + "unity7", + "network", + "gsettings", + "audio-playback", + "pulseaudio", + "opengl", + "browser-support", + ], + }, + }, + "base": "core24", + "confinement": "strict", + "description": "Test Application (test quite “ #378)", + "environment": { + "TMPDIR": "$XDG_RUNTIME_DIR", + }, + "grade": "stable", + "icon": "snap/gui/sep.png", + "layout": { + "/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/drirc.d": { + "symlink": "$SNAP/gpu-2404/drirc.d", + }, + "/usr/share/libdrm": { + "bind": "$SNAP/gpu-2404/libdrm", + }, + "/usr/share/xml/iso-codes": { + "bind": "$SNAP/gnome-platform/usr/share/xml/iso-codes", + }, + }, + "name": "sep", + "parts": { + "sep": { + "organize": { + "LICENSE.electron.txt": "app/LICENSE.electron.txt", + "LICENSES.chromium.html": "app/LICENSES.chromium.html", + "chrome-sandbox": "app/chrome-sandbox", + "chrome_100_percent.pak": "app/chrome_100_percent.pak", + "chrome_200_percent.pak": "app/chrome_200_percent.pak", + "chrome_crashpad_handler": "app/chrome_crashpad_handler", + "icudtl.dat": "app/icudtl.dat", + "libEGL.so": "app/libEGL.so", + "libGLESv2.so": "app/libGLESv2.so", + "libffmpeg.so": "app/libffmpeg.so", + "libvk_swiftshader.so": "app/libvk_swiftshader.so", + "libvulkan.so.1": "app/libvulkan.so.1", + "locales": "app/locales", + "resources": "app/resources", + "resources.pak": "app/resources.pak", + "sep": "app/sep", + "snapshot_blob.bin": "app/snapshot_blob.bin", + "v8_context_snapshot.bin": "app/v8_context_snapshot.bin", + "vk_swiftshader_icd.json": "app/vk_swiftshader_icd.json", + }, + "plugin": "dump", + "source": "app", + }, + }, + "plugs": { + "browser-support": { + "allow-sandbox": true, + "interface": "browser-support", + }, + "gnome-46-2404": { + "default-provider": "gnome-46-2404", + "interface": "content", + "target": "$SNAP/gnome-platform", + }, + "gpu-2404": { + "default-provider": "mesa-2404", + "interface": "content", + "target": "$SNAP/gpu-2404", + }, + "gtk-3-themes": { + "default-provider": "gtk-common-themes", + "interface": "content", + "target": "$SNAP/data-dir/themes", + }, + "icon-themes": { + "default-provider": "gtk-common-themes", + "interface": "content", + "target": "$SNAP/data-dir/icons", + }, + "sound-themes": { + "default-provider": "gtk-common-themes", + "interface": "content", + "target": "$SNAP/data-dir/sounds", + }, + }, + "summary": "Sep", + "title": "Sep", + "version": "1.1.0", +} +`; + +exports[`snap > core24 no gnome extension 2`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap > core24 wayland disabled 1`] = ` +{ + "apps": { + "sep": { + "command": "app/sep", + "desktop": "meta/gui/sep.desktop", + "extensions": [ + "gnome", + ], + "plugs": [ + "browser-support", + ], + }, + }, + "base": "core24", + "confinement": "strict", + "description": "Test Application (test quite “ #378)", + "environment": { + "DISABLE_WAYLAND": "1", + "TMPDIR": "$XDG_RUNTIME_DIR", + }, + "grade": "stable", + "icon": "snap/gui/sep.png", + "name": "sep", + "parts": { + "sep": { + "organize": { + "LICENSE.electron.txt": "app/LICENSE.electron.txt", + "LICENSES.chromium.html": "app/LICENSES.chromium.html", + "chrome-sandbox": "app/chrome-sandbox", + "chrome_100_percent.pak": "app/chrome_100_percent.pak", + "chrome_200_percent.pak": "app/chrome_200_percent.pak", + "chrome_crashpad_handler": "app/chrome_crashpad_handler", + "icudtl.dat": "app/icudtl.dat", + "libEGL.so": "app/libEGL.so", + "libGLESv2.so": "app/libGLESv2.so", + "libffmpeg.so": "app/libffmpeg.so", + "libvk_swiftshader.so": "app/libvk_swiftshader.so", + "libvulkan.so.1": "app/libvulkan.so.1", + "locales": "app/locales", + "resources": "app/resources", + "resources.pak": "app/resources.pak", + "sep": "app/sep", + "snapshot_blob.bin": "app/snapshot_blob.bin", + "v8_context_snapshot.bin": "app/v8_context_snapshot.bin", + "vk_swiftshader_icd.json": "app/vk_swiftshader_icd.json", + }, + "plugin": "dump", + "source": "app", + }, + }, + "plugs": { + "browser-support": { + "allow-sandbox": true, + "interface": "browser-support", + }, + }, + "summary": "Sep", + "title": "Sep", + "version": "1.1.0", +} +`; + +exports[`snap > core24 wayland disabled 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`custom after, no desktop 1`] = ` +exports[`snap > custom after, no desktop 1`] = ` { "apps": { "sep": { @@ -608,14 +976,14 @@ exports[`custom after, no desktop 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "sep", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -644,13 +1012,18 @@ exports[`custom after, no desktop 1`] = ` } `; -exports[`custom after, no desktop 2`] = ` +exports[`snap > custom after, no desktop 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`custom env 1`] = ` +exports[`snap > custom env 1`] = ` { "apps": { "sep": { @@ -678,14 +1051,14 @@ exports[`custom env 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "sep", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -714,13 +1087,53 @@ exports[`custom env 1`] = ` } `; -exports[`custom env 2`] = ` +exports[`snap > custom env 2`] = ` +{ + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], +} +`; + +exports[`snap > custom snap yamlPath pass-through 1`] = ` +{ + "apps": { + "sep": { + "command": "sep", + }, + }, + "base": "core24", + "confinement": "strict", + "description": "Built with a custom snapcraft.yaml via electron-builder. +", + "grade": "stable", + "name": "sep", + "parts": { + "app": { + "plugin": "dump", + "source": ".", + }, + }, + "summary": "Custom snap (pass-through)", + "version": "1.0.0", +} +`; + +exports[`snap > custom snap yamlPath pass-through 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`default base 1`] = ` +exports[`snap > default base 1`] = ` { "apps": { "testapp": { @@ -747,14 +1160,14 @@ exports[`default base 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], "base": "core20", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "testapp", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -783,13 +1196,18 @@ exports[`default base 1`] = ` } `; -exports[`default base 2`] = ` +exports[`snap > default base 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "TestApp_1.1.0_amd64.snap", + }, + ], } `; -exports[`default compression 1`] = ` +exports[`snap > default compression 1`] = ` { "apps": { "sep": { @@ -816,14 +1234,14 @@ exports[`default compression 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], "base": "core20", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "sep", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -852,24 +1270,25 @@ exports[`default compression 1`] = ` } `; -exports[`default compression 2`] = ` +exports[`snap > default compression 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`default stagePackages 1`] = ` +exports[`snap > default stagePackages 1`] = ` { "apps": { "sep": { - "adapter": "none", "command": "command.sh", }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "classic", "description": "Test Application (test quite “ #378)", "grade": "stable", @@ -1011,30 +1430,34 @@ exports[`default stagePackages 1`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "summary": "Sep", "title": "Sep", "version": "1.1.0", } `; -exports[`default stagePackages 2`] = ` +exports[`snap > default stagePackages 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`default stagePackages 3`] = ` +exports[`snap > default stagePackages 3`] = ` { "apps": { "sep": { - "adapter": "none", "command": "command.sh", }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "classic", "description": "Test Application (test quite “ #378)", "grade": "stable", @@ -1177,30 +1600,34 @@ exports[`default stagePackages 3`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "summary": "Sep", "title": "Sep", "version": "1.1.0", } `; -exports[`default stagePackages 4`] = ` +exports[`snap > default stagePackages 4`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`default stagePackages 5`] = ` +exports[`snap > default stagePackages 5`] = ` { "apps": { "sep": { - "adapter": "none", "command": "command.sh", }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "classic", "description": "Test Application (test quite “ #378)", "grade": "stable", @@ -1343,30 +1770,34 @@ exports[`default stagePackages 5`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "summary": "Sep", "title": "Sep", "version": "1.1.0", } `; -exports[`default stagePackages 6`] = ` +exports[`snap > default stagePackages 6`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`default stagePackages 7`] = ` +exports[`snap > default stagePackages 7`] = ` { "apps": { "sep": { - "adapter": "none", "command": "command.sh", }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "classic", "description": "Test Application (test quite “ #378)", "grade": "stable", @@ -1510,19 +1941,27 @@ exports[`default stagePackages 7`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "summary": "Sep", "title": "Sep", "version": "1.1.0", } `; -exports[`default stagePackages 8`] = ` +exports[`snap > default stagePackages 8`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`no desktop plugs 1`] = ` +exports[`snap > no desktop plugs 1`] = ` { "apps": { "sep": { @@ -1539,14 +1978,14 @@ exports[`no desktop plugs 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "sep", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -1575,17 +2014,21 @@ exports[`no desktop plugs 1`] = ` } `; -exports[`no desktop plugs 2`] = ` +exports[`snap > no desktop plugs 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`plugs option 1`] = ` +exports[`snap > plugs option 1`] = ` { "apps": { "testapp": { - "adapter": "none", "command": "command.sh", "environment": { "DISABLE_WAYLAND": "1", @@ -1599,10 +2042,7 @@ exports[`plugs option 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", @@ -1744,6 +2184,9 @@ exports[`plugs option 1`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "plugs": { "browser-sandbox": { "allow-sandbox": true, @@ -1776,17 +2219,21 @@ exports[`plugs option 1`] = ` } `; -exports[`plugs option 2`] = ` +exports[`snap > plugs option 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "TestApp_1.1.0_amd64.snap", + }, + ], } `; -exports[`plugs option 3`] = ` +exports[`snap > plugs option 3`] = ` { "apps": { "testapp": { - "adapter": "none", "command": "command.sh", "environment": { "DISABLE_WAYLAND": "1", @@ -1800,10 +2247,7 @@ exports[`plugs option 3`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", @@ -1945,6 +2389,9 @@ exports[`plugs option 3`] = ` "source": "scripts", }, }, + "platforms": [ + "amd64", + ], "plugs": { "browser-sandbox": { "allow-sandbox": true, @@ -1977,13 +2424,18 @@ exports[`plugs option 3`] = ` } `; -exports[`plugs option 4`] = ` +exports[`snap > plugs option 4`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "TestApp_1.1.0_amd64.snap", + }, + ], } `; -exports[`slots option 1`] = ` +exports[`snap > slots option 1`] = ` { "apps": { "sep": { @@ -2014,14 +2466,14 @@ exports[`slots option 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "sep", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -2050,13 +2502,18 @@ exports[`slots option 1`] = ` } `; -exports[`slots option 2`] = ` +exports[`snap > slots option 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`slots option 3`] = ` +exports[`snap > slots option 3`] = ` { "apps": { "sep": { @@ -2087,14 +2544,14 @@ exports[`slots option 3`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "sep", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -2129,13 +2586,18 @@ exports[`slots option 3`] = ` } `; -exports[`slots option 4`] = ` +exports[`snap > slots option 4`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "sep_1.1.0_amd64.snap", + }, + ], } `; -exports[`snap 1`] = ` +exports[`snap > snap 1`] = ` { "linux": [ { @@ -2146,7 +2608,7 @@ exports[`snap 1`] = ` } `; -exports[`use template app 1`] = ` +exports[`snap > use template app 1`] = ` { "apps": { "testapp": { @@ -2173,14 +2635,14 @@ exports[`use template app 1`] = ` ], }, }, - "architectures": [ - "amd64", - ], - "base": "core20", + "base": "core22", "confinement": "strict", "description": "Test Application (test quite “ #378)", "grade": "stable", "name": "testapp", + "platforms": [ + "amd64", + ], "plugs": { "gnome-3-28-1804": { "default-provider": "gnome-3-28-1804", @@ -2209,8 +2671,13 @@ exports[`use template app 1`] = ` } `; -exports[`use template app 2`] = ` +exports[`snap > use template app 2`] = ` { - "linux": [], + "linux": [ + { + "arch": "x64", + "file": "TestApp_1.1.0_amd64.snap", + }, + ], } `; diff --git a/test/src/helpers/launchAppCrossPlatform.ts b/test/src/helpers/launchAppCrossPlatform.ts index 5ab921979d3..2e8acface7b 100644 --- a/test/src/helpers/launchAppCrossPlatform.ts +++ b/test/src/helpers/launchAppCrossPlatform.ts @@ -1,5 +1,5 @@ import { isEmptyOrSpaces } from "builder-util" -import { ChildProcess, spawn } from "child_process" +import { ChildProcess, spawn, spawnSync } from "child_process" import * as fs from "fs" import * as http from "http" import * as net from "net" @@ -311,3 +311,24 @@ export function startXvfb(): { display: string; stop: () => void } { stop, } } + +/** + * Run a snap binary with --version --no-sandbox under Xvfb. + * Returns combined stdout+stderr output. + */ +export function launchSnapBinary(binaryPath: string, timeoutMs = 15_000): string { + const xvfb = startXvfb() + try { + const result = spawnSync(binaryPath, ["--version", "--no-sandbox"], { + env: { ...process.env, DISPLAY: xvfb.display }, + timeout: timeoutMs, + encoding: "utf8", + }) + if (result.error) { + throw result.error + } + return (result.stdout ?? "") + (result.stderr ?? "") + } finally { + xvfb.stop() + } +} diff --git a/test/src/linux/dockerfile-snapcraft b/test/src/linux/dockerfile-snapcraft new file mode 100644 index 00000000000..7feed22bd8c --- /dev/null +++ b/test/src/linux/dockerfile-snapcraft @@ -0,0 +1,57 @@ +# Snap heavy-test image — core24 +# +# Base: ghcr.io/canonical/snapcraft:8_core24 +# Canonical rock image: Snapcraft 8 on Ubuntu 24.04 (Noble, glibc 2.39). +# Snapcraft 8 is the officially supported builder for core24 and is also +# backward-compatible with core18/core20/core22 if needed. +# Ubuntu 24.04 uses t64-transition package names (libgtk-3-0t64, libasound2t64 …). +# +# Source: https://github.com/canonical/snapcraft-rocks +FROM --platform=linux/amd64 ghcr.io/canonical/snapcraft:8_core24 + +ENV DEBIAN_FRONTEND=noninteractive + +# squashfs-tools: mksquashfs is required by snapcraft --destructive-mode. +# xvfb: virtual framebuffer used when electron-builder invokes Electron. +# git: required by pnpm workspace bootstrap. +RUN apt-get update && apt-get install -y --no-install-recommends \ + squashfs-tools \ + xvfb \ + git \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Node.js 22 LTS via NodeSource. +# NodeSource's setup script uses `apt` internally; the resulting WARNING lines +# ("apt does not have a stable CLI interface") come from that script and are +# harmless. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# pnpm + corepack + bun — matches docker/test-in-docker.sh bootstrap exactly. +RUN npm install -g corepack bun \ + && corepack enable + +# Electron runtime libraries (Ubuntu 24.04 / t64-transition names). +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnspr4 \ + libnss3 \ + libxss1 \ + libxtst6 \ + libdrm2 \ + libgbm1 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libgtk-3-0t64 \ + libasound2t64 \ + libcups2t64 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /project + +# Clear any ENTRYPOINT from the upstream rock so the project's test-linux +# script (docker run … /bin/bash -c "…") works without --entrypoint overrides. +ENTRYPOINT [] +CMD ["/bin/bash"] diff --git a/test/src/linux/dockerfile-snapcraft-legacy b/test/src/linux/dockerfile-snapcraft-legacy new file mode 100644 index 00000000000..7ece4bd2065 --- /dev/null +++ b/test/src/linux/dockerfile-snapcraft-legacy @@ -0,0 +1,56 @@ +# Snap heavy-test image — legacy cores (core18 / core20 / core22) +# +# Base: ghcr.io/canonical/snapcraft:7_core22 +# Canonical rock image: Snapcraft 7 on Ubuntu 22.04 (Jammy, glibc 2.35). +# Snapcraft 7 is the officially supported builder for core18/core20/core22. +# Ubuntu 22.04 uses pre-t64-transition package names (libgtk-3-0, libasound2 …). +# +# Source: https://github.com/canonical/snapcraft-rocks +FROM --platform=linux/amd64 ghcr.io/canonical/snapcraft:7_core22 + +ENV DEBIAN_FRONTEND=noninteractive + +# squashfs-tools: mksquashfs is required by snapcraft --destructive-mode. +# xvfb: virtual framebuffer used when electron-builder invokes Electron. +# git: required by pnpm workspace bootstrap. +RUN apt-get update && apt-get install -y --no-install-recommends \ + squashfs-tools \ + xvfb \ + git \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Node.js 22 LTS via NodeSource. +# NodeSource's setup script uses `apt` internally; the resulting WARNING lines +# ("apt does not have a stable CLI interface") come from that script and are +# harmless. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# pnpm + corepack + bun — matches docker/test-in-docker.sh bootstrap exactly. +RUN npm install -g corepack bun \ + && corepack enable + +# Electron runtime libraries (Ubuntu 22.04 / pre-t64 names). +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnspr4 \ + libnss3 \ + libxss1 \ + libxtst6 \ + libdrm2 \ + libgbm1 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libgtk-3-0 \ + libasound2 \ + libcups2 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /project + +# Clear any ENTRYPOINT from the upstream rock so the project's test-linux +# script (docker run … /bin/bash -c "…") works without --entrypoint overrides. +ENTRYPOINT [] +CMD ["/bin/bash"] diff --git a/test/src/linux/snapHeavyTest.ts b/test/src/linux/snapHeavyTest.ts index 81030e2e7ca..1e6e1f25fa3 100644 --- a/test/src/linux/snapHeavyTest.ts +++ b/test/src/linux/snapHeavyTest.ts @@ -1,48 +1,283 @@ import { Arch, Platform } from "app-builder-lib" -import { app, EXTENDED_TIMEOUT, snapTarget } from "../helpers/packTester" +import { log } from "builder-util" +import { execSync, spawnSync } from "child_process" +import { existsSync, readFileSync } from "fs" +import { readdir } from "fs/promises" +import * as path from "path" import * as which from "which" +import { app, assertPack, EXTENDED_TIMEOUT, snapTarget } from "../helpers/packTester" +import { launchSnapBinary } from "../helpers/launchAppCrossPlatform" // very slow -const hasSnapInstalled = () => which.sync("snap", { nothrow: true }) != null +// Guard: tests run when: +// - RUN_SNAP_TESTS=true (set by test-snap.sh inside the Docker container, where +// "snap" the snapd client is absent but "snapcraft" is present), OR +// - the "snap" snapd client is found in PATH (native Linux install), OR +// - the "snapcraft" CLI is found in PATH (e.g. installed via pip / brew) +export const hasSnapInstalled = () => process.env.RUN_SNAP_TESTS === "true" || which.sync("snap", { nothrow: true }) != null || which.sync("snapcraft", { nothrow: true }) != null -describe.heavy.ifEnv(hasSnapInstalled())("snap heavy", { sequential: true, timeout: EXTENDED_TIMEOUT }, () => { - test("snap full", ({ expect }) => - app(expect, { +// Whether install+launch tests should run. Requires unsquashfs on PATH (squashfs-tools). +// Excluded inside Docker containers: test-snap.sh always sets RUN_SNAP_TESTS=true there, +// and Docker images use snapcraft 7 which cannot build core24 snaps correctly. Install+launch +// tests are only meaningful on the native CI runner where the full environment is available. +const canRunInstallTests = () => process.platform === "linux" && process.env.RUN_SNAP_TESTS !== "true" && which.sync("unsquashfs", { nothrow: true }) != null + +// Optional core filter: SNAP_TEST_CORES=core24 (comma-separated) +// When unset every core is tested. +const requestedCores = process.env.SNAP_TEST_CORES ? process.env.SNAP_TEST_CORES.split(",").map(s => s.trim()) : null +const allCores = ["core24", "core22", "core20", "core18"] +const testCores = requestedCores ? allCores.filter(c => requestedCores.includes(c)) : allCores + +// ─── helpers ───────────────────────────────────────────────────────────────── + +/** Find the single .snap file in outDir, throw if none or multiple. */ +async function findSnapArtifact(outDir: string): Promise { + const entries = await readdir(outDir) + const snaps = entries.filter(f => f.endsWith(".snap")).map(f => path.join(outDir, f)) + if (snaps.length === 0) { + throw new Error(`No .snap artifact found in ${outDir}`) + } + if (snaps.length > 1) { + throw new Error(`Multiple .snap artifacts found in ${outDir}: ${snaps.join(", ")}`) + } + return snaps[0] +} + +/** Extract a .snap file into destDir using unsquashfs. */ +function extractSnap(snapPath: string, destDir: string): void { + const result = spawnSync("unsquashfs", ["-f", "-d", destDir, snapPath], { stdio: "inherit" }) + if (result.status !== 0) { + throw new Error(`unsquashfs failed with exit code ${result.status} for ${snapPath}`) + } +} + +/** + * Resolve the Electron binary path inside an extracted snap. + * + * core24 snaps use an `organize` mapping that places all app files under `app/`, + * so the binary is at `/app/`. + * + * Legacy snaps (core18/20/22) do not use `organize`, so the binary is at the + * root of the snap: `/`. + */ +function resolveSnapBinaryPath(primeDir: string, executableName: string, core: string): string { + if (core === "core24") { + return path.join(primeDir, "app", executableName) + } + return path.join(primeDir, executableName) +} + +/** + * Verify the extracted snap directory contains the expected structure. + * Metadata lives at meta/snap.yaml (compiled by snapcraft, not the build-time snapcraft.yaml). + */ +function assertSnapStructure(primeDir: string, appName: string, binaryPath: string): void { + const required = [path.join(primeDir, "meta", "snap.yaml"), path.join(primeDir, "meta", "gui", `${appName}.desktop`), binaryPath] + for (const p of required) { + if (!existsSync(p)) { + throw new Error(`Expected snap file not found: ${p}`) + } + } +} + +/** + * Full install+launch integration test for a single snap. + * Build → extract → assert structure → run binary --version. + * + * All file operations MUST happen inside the packed callback: assertPack cleans + * up outDir (and everything inside it, including extractDir) after the callback + * returns. Running chmod/launch after assertPack would hit a deleted directory. + */ +async function runInstallLaunchTest(expect: any, core: "core18" | "core20" | "core22" | "core24"): Promise { + await assertPack( + expect, + "test-app-one", + { targets: snapTarget, config: { - extraMetadata: { - name: "se-wo-template", + extraMetadata: { name: "se-wo-template" }, + productName: "Snap Electron App", + snapcraft: { + base: core, + // core24 gnome extension requires LXD/multipass; destructive-mode works on bare runners + ...(core === "core24" ? { core24: { useDestructiveMode: true } } : {}), }, - productName: "Snap Electron App (full build)", - snap: { - useTemplateApp: false, + }, + }, + { + packed: async context => { + const snapPath = await findSnapArtifact(context.outDir) + + // ── 1. artifact sanity ───────────────────────────────────────────── + expect(existsSync(snapPath)).toBe(true) + expect(path.basename(snapPath)).toMatch(/^se-wo-template_[\d.]+_amd64\.snap$/) + log.info({ snapPath }, "snap artifact found") + + // ── 2. extract and inspect structure ────────────────────────────── + const extractDir = path.join(path.dirname(snapPath), "extracted-snap") + extractSnap(snapPath, extractDir) + + const binaryPath = resolveSnapBinaryPath(extractDir, "se-wo-template", core) + assertSnapStructure(extractDir, "se-wo-template", binaryPath) + + const snapYaml = readFileSync(path.join(extractDir, "meta", "snap.yaml"), "utf8") + expect(snapYaml).toContain("name: se-wo-template") + expect(snapYaml).toContain(`base: ${core}`) + expect(snapYaml).toContain("confinement:") + + const desktopContent = readFileSync(path.join(extractDir, "meta", "gui", "se-wo-template.desktop"), "utf8") + expect(desktopContent).toContain("[Desktop Entry]") + expect(desktopContent).toContain("Type=Application") + expect(desktopContent).toContain("Exec=") + log.info({ extractDir }, "snap structure validated") + + // ── 3. launch binary (must stay inside packed callback) ──────────── + execSync(`chmod +x "${binaryPath}"`) + const output = launchSnapBinary(binaryPath) + // Electron responds to --version by printing its version (e.g. "v32.0.0") and exiting 0 + expect(output).toMatch(/v?\d+\.\d+\.\d+/) + log.info({ output: output.trim() }, "snap binary launched successfully") + }, + } + ) +} + +// ─── test suites ───────────────────────────────────────────────────────────── + +describe.heavy.ifEnv(hasSnapInstalled())("snap heavy", { sequential: true, timeout: EXTENDED_TIMEOUT }, () => { + for (const _core of testCores) { + const core = _core as any + + // ── build-only test (always runs when snap tooling is present) ─────────── + test(`snap full (${core})`, ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { name: "se-wo-template" }, + productName: "Snap Electron App (full build)", + snapcraft: { + base: core, + // core24 gnome extension is incompatible with SNAPCRAFT_BUILD_ENVIRONMENT=host (set in Docker) + ...(core === "core24" ? { core24: { useDestructiveMode: true } } : {}), + }, + electronFuses: { + runAsNode: true, + enableCookieEncryption: true, + enableNodeOptionsEnvironmentVariable: true, + enableNodeCliInspectArguments: true, + enableEmbeddedAsarIntegrityValidation: true, + onlyLoadAppFromAsar: true, + loadBrowserProcessSpecificV8Snapshot: true, + grantFileProtocolExtraPrivileges: undefined, + }, }, - 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 + })) + + // ── install+launch integration (requires unsquashfs) ──────────────────── + test.ifEnv(canRunInstallTests())(`snap install+launch (${core})`, async ({ expect }) => { + await runInstallLaunchTest(expect, core as "core18" | "core20" | "core22" | "core24") + }) + + // armhf cross-compilation is not supported for core24 in host/destructive-mode + if (core !== "core24") { + test(`snap full (${core} armhf)`, ({ expect }) => + app(expect, { + targets: Platform.LINUX.createTarget("snap", Arch.armv7l), + config: { + extraMetadata: { name: "se-wo-template" }, + productName: "Snap Electron App (full build)", + }, + })) + } + } +}) + +// ─── core24 native Linux tests ─────────────────────────────────────────────── +// +// These tests run on a native Linux GH runner (no Docker required) where snapcraft +// and unsquashfs are available. They exercise the full build → extract → launch +// pipeline for core24 specifically, including the gnome extension path and the +// destructive-mode (no gnome extension) path. + +describe.heavy.ifLinux.ifEnv(hasSnapInstalled() && canRunInstallTests())("snap core24 native", { sequential: true, timeout: EXTENDED_TIMEOUT }, () => { + test("core24 build + install + launch", async ({ expect }) => { + await runInstallLaunchTest(expect, "core24") + }) + + test("core24 destructive-mode (no gnome extension)", async ({ expect }) => { + await assertPack( + expect, + "test-app-one", + { + targets: snapTarget, + config: { + extraMetadata: { name: "se-wo-template" }, + productName: "Snap Electron App", + snapcraft: { + base: "core24", + core24: { + useDestructiveMode: true, + // gnome extension is incompatible with destructive-mode — must not be set + extensions: [], + }, + }, }, }, - })) + { + packed: async context => { + const snapPath = await findSnapArtifact(context.outDir) + expect(existsSync(snapPath)).toBe(true) - // very slow - test("snap full (armhf)", ({ expect }) => - app(expect, { - targets: Platform.LINUX.createTarget("snap", Arch.armv7l), - config: { - extraMetadata: { - name: "se-wo-template", + const extractDir = path.join(path.dirname(snapPath), "extracted-snap-destructive") + extractSnap(snapPath, extractDir) + + const snapYaml = readFileSync(path.join(extractDir, "meta", "snap.yaml"), "utf8") + expect(snapYaml).toContain("name: se-wo-template") + expect(snapYaml).toContain("base: core24") + // gnome extension must not be present in destructive-mode builds + expect(snapYaml).not.toContain("gnome") + + const binaryPath = resolveSnapBinaryPath(extractDir, "se-wo-template", "core24") + expect(existsSync(binaryPath)).toBe(true) + + execSync(`chmod +x "${binaryPath}"`) + const output = launchSnapBinary(binaryPath) + expect(output).toMatch(/v?\d+\.\d+\.\d+/) }, - productName: "Snap Electron App (full build)", - snap: { - useTemplateApp: false, + } + ) + }) + + test("core24 with custom stagePackages", async ({ expect }) => { + await assertPack( + expect, + "test-app-one", + { + targets: snapTarget, + config: { + extraMetadata: { name: "se-wo-template" }, + productName: "Snap Electron App", + snapcraft: { + base: "core24", + core24: { + useDestructiveMode: true, + stagePackages: ["default", "libdrm2"], + }, + }, }, }, - })) + { + packed: async context => { + const snapPath = await findSnapArtifact(context.outDir) + expect(existsSync(snapPath)).toBe(true) + const extractDir = path.join(path.dirname(snapPath), "extracted-snap-custom-stage") + extractSnap(snapPath, extractDir) + const snapYaml = readFileSync(path.join(extractDir, "meta", "snap.yaml"), "utf8") + expect(snapYaml).toContain("name: se-wo-template") + log.info({ snapPath }, "core24 custom stagePackages snap validated") + }, + } + ) + }) }) diff --git a/test/src/linux/snapTest.ts b/test/src/linux/snapTest.ts index 4d1014b7be6..5fe5980590a 100644 --- a/test/src/linux/snapTest.ts +++ b/test/src/linux/snapTest.ts @@ -1,42 +1,94 @@ import { Arch, Platform } from "electron-builder" -import { app, assertPack, snapTarget } from "../helpers/packTester" +import { outputFile } from "fs-extra" +import * as path from "path" +import { app, appThrows, assertPack, EXTENDED_TIMEOUT, snapTarget } from "../helpers/packTester" +import * as which from "which" -test.ifNotWindows("snap", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - 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 - }, - }, - }) -) +// Inline so snapTest.ts does NOT import from snapHeavyTest.ts — importing that file +// causes all its describe() blocks to execute here, registering heavy tests twice. +const hasSnapInstalled = () => process.env.RUN_SNAP_TESTS === "true" || which.sync("snap", { nothrow: true }) != null || which.sync("snapcraft", { nothrow: true }) != null + +describe.heavy.ifEnv(hasSnapInstalled())("snap", { sequential: true, timeout: EXTENDED_TIMEOUT }, () => { + test("snap", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + 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 + }, + }, + })) -test.ifNotWindows("arm", ({ expect }) => - app(expect, { - targets: Platform.LINUX.createTarget("snap", Arch.armv7l), - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - }, + test("arm", ({ expect }) => + app(expect, { + targets: Platform.LINUX.createTarget("snap", Arch.armv7l), + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + }, + })) + + test("default stagePackages", async ({ expect }) => { + for (const p of [["default"], ["default", "custom"], ["custom", "default"], ["foo1", "default", "foo2"]]) { + await assertPack(expect, "test-app-one", { + targets: snapTarget, + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + snapcraft: { + base: "core22", + core22: { + stagePackages: p, + plugs: p, + confinement: "classic", + // otherwise "parts" will be removed + useTemplateApp: false, + }, + }, + }, + effectiveOptionComputed: async ({ snap, args }) => { + delete snap.parts.app.source + expect(snap).toMatchSnapshot() + expect(args).not.toContain("--exclude") + return Promise.resolve(true) + }, + }) + } }) -) -test.ifNotWindows("default stagePackages", async ({ expect }) => { - for (const p of [["default"], ["default", "custom"], ["custom", "default"], ["foo1", "default", "foo2"]]) { + test("classic confinement", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { + name: "cl-co-app", + }, + productName: "Snap Electron App (classic confinement)", + snapcraft: { + base: "core22", + core22: { + confinement: "classic", + }, + }, + }, + })) + + test("buildPackages", async ({ expect }) => { await assertPack(expect, "test-app-one", { targets: snapTarget, config: { @@ -44,301 +96,418 @@ test.ifNotWindows("default stagePackages", async ({ expect }) => { name: "sep", }, productName: "Sep", - snap: { - stagePackages: p, - plugs: p, - confinement: "classic", - // otherwise "parts" will be removed - useTemplateApp: false, + snapcraft: { + base: "core22", + core22: { + buildPackages: ["foo1", "default", "foo2"], + // otherwise "parts" will be removed + useTemplateApp: false, + }, }, }, - effectiveOptionComputed: async ({ snap, args }) => { + effectiveOptionComputed: async ({ snap }) => { delete snap.parts.app.source expect(snap).toMatchSnapshot() - expect(args).not.toContain("--exclude") return Promise.resolve(true) }, }) - } -}) - -test.ifNotWindows("classic confinement", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "cl-co-app", - }, - productName: "Snap Electron App (classic confinement)", - snap: { - confinement: "classic", - }, - }, - }) -) - -test.ifNotWindows("buildPackages", async ({ expect }) => { - await assertPack(expect, "test-app-one", { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - snap: { - buildPackages: ["foo1", "default", "foo2"], - // otherwise "parts" will be removed - useTemplateApp: false, - }, - }, - effectiveOptionComputed: async ({ snap }) => { - delete snap.parts.app.source - expect(snap).toMatchSnapshot() - return Promise.resolve(true) - }, }) -}) -test.ifNotWindows("plugs option", async ({ expect }) => { - for (const p of [ - [ + test("plugs option", async ({ expect }) => { + for (const p of [ + [ + { + "browser-sandbox": { + interface: "browser-support", + "allow-sandbox": true, + }, + }, + "another-simple-plug-name", + ], { "browser-sandbox": { interface: "browser-support", "allow-sandbox": true, }, + "another-simple-plug-name": null, + }, + ]) { + await assertPack(expect, "test-app-one", { + targets: snapTarget, + config: { + snapcraft: { + base: "core22", + core22: { + plugs: p, + // otherwise "parts" will be removed + useTemplateApp: false, + }, + }, + }, + effectiveOptionComputed: async ({ snap, args }) => { + delete snap.parts.app.source + expect(snap).toMatchSnapshot() + expect(args).not.toContain("--exclude") + return Promise.resolve(true) + }, + }) + } + }) + + test("slots option", async ({ expect }) => { + for (const slots of [ + ["foo", "bar"], + [ + { + mpris: { + interface: "mpris", + name: "chromium", + }, + }, + "another-simple-slot-name", + ], + ]) { + await assertPack(expect, "test-app-one", { + targets: snapTarget, + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + snapcraft: { + base: "core22", + core22: { + slots, + }, + }, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + return Promise.resolve(true) + }, + }) + } + }) + + test("custom env", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + snapcraft: { + base: "core22", + core22: { + environment: { + FOO: "bar", + }, + }, + }, }, - "another-simple-plug-name", - ], - { - "browser-sandbox": { - interface: "browser-support", - "allow-sandbox": true, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + return Promise.resolve(true) }, - "another-simple-plug-name": null, - }, - ]) { - await assertPack(expect, "test-app-one", { + })) + + test("custom after, no desktop", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + snapcraft: { + base: "core22", + core22: { + after: ["bar"], + }, + }, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + return Promise.resolve(true) + }, + })) + + test("no desktop plugs", ({ expect }) => + app(expect, { targets: snapTarget, config: { - snap: { - plugs: p, - // otherwise "parts" will be removed - useTemplateApp: false, + extraMetadata: { + name: "sep", + }, + productName: "Sep", + snapcraft: { + base: "core22", + core22: { + plugs: ["foo", "bar"], + }, }, }, effectiveOptionComputed: async ({ snap, args }) => { - delete snap.parts.app.source expect(snap).toMatchSnapshot() - expect(args).not.toContain("--exclude") + expect(args).toContain("--exclude") return Promise.resolve(true) }, - }) - } -}) + })) -test.ifNotWindows("slots option", async ({ expect }) => { - for (const slots of [ - ["foo", "bar"], - [ - { - mpris: { - interface: "mpris", - name: "chromium", + test("auto start", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + snapcraft: { + base: "core22", + core22: { + autoStart: true, + }, }, }, - "another-simple-slot-name", - ], - ]) { - await assertPack(expect, "test-app-one", { + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + expect(snap.apps.sep.autostart).toEqual("sep.desktop") + return Promise.resolve(true) + }, + })) + + test("default compression", ({ expect }) => + app(expect, { targets: snapTarget, config: { extraMetadata: { name: "sep", }, productName: "Sep", - snap: { - slots, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + return Promise.resolve(true) + }, + })) + + test("compression option", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { + name: "sep", + }, + productName: "Sep", + snapcraft: { + base: "core22", + core22: { + useTemplateApp: false, + compression: "xz", + }, }, }, + effectiveOptionComputed: async ({ snap, args }) => { + expect(snap).toMatchSnapshot() + expect(snap.compression).toBe("xz") + expect(args).toEqual(expect.arrayContaining(["--compression", "xz"])) + return Promise.resolve(true) + }, + })) + + test("default base", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + productName: "Sep", + }, effectiveOptionComputed: async ({ snap }) => { expect(snap).toMatchSnapshot() + expect(snap.base).toBe("core20") return Promise.resolve(true) }, - }) - } -}) + })) -test.ifNotWindows("custom env", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - snap: { - environment: { - FOO: "bar", - }, - }, - }, - effectiveOptionComputed: async ({ snap }) => { - expect(snap).toMatchSnapshot() - return Promise.resolve(true) - }, - }) -) + test("base option", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + productName: "Sep", + snapcraft: { + base: "core22", + }, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + expect(snap.base).toBe("core22") + return Promise.resolve(true) + }, + })) -test.ifNotWindows("custom after, no desktop", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - snap: { - after: ["bar"], - }, - }, - effectiveOptionComputed: async ({ snap }) => { - expect(snap).toMatchSnapshot() - return Promise.resolve(true) - }, - }) -) + test("use template app", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + snapcraft: { + base: "core22", + core22: { + useTemplateApp: true, + compression: "xz", + }, + }, + }, + effectiveOptionComputed: async ({ snap, args }) => { + expect(snap).toMatchSnapshot() + expect(snap.parts).toBeUndefined() + expect(snap.compression).toBeUndefined() + expect(snap.contact).toBeUndefined() + expect(snap.donation).toBeUndefined() + expect(snap.issues).toBeUndefined() + expect(snap.parts).toBeUndefined() + expect(snap["source-code"]).toBeUndefined() + expect(snap.website).toBeUndefined() + expect(args).toEqual(expect.arrayContaining(["--exclude", "chrome-sandbox", "--compression", "xz"])) + return Promise.resolve(true) + }, + })) -test.ifNotWindows("no desktop plugs", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - snap: { - plugs: ["foo", "bar"], - }, - }, - effectiveOptionComputed: async ({ snap, args }) => { - expect(snap).toMatchSnapshot() - expect(args).toContain("--exclude") - return Promise.resolve(true) - }, - }) -) + // ─── core24 tests ──────────────────────────────────────────────────────────── -test.ifNotWindows("auto start", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - snap: { - autoStart: true, - }, - }, - effectiveOptionComputed: async ({ snap }) => { - expect(snap).toMatchSnapshot() - expect(snap.apps.sep.autostart).toEqual("sep.desktop") - return Promise.resolve(true) - }, - }) -) + test("core24 default (gnome extension)", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { name: "sep" }, + productName: "Sep", + snapcraft: { base: "core24" }, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + expect(snap.base).toBe("core24") + expect(snap.apps?.sep?.extensions).toContain("gnome") + return Promise.resolve(true) + }, + })) -test.ifNotWindows("default compression", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - }, - effectiveOptionComputed: async ({ snap }) => { - expect(snap).toMatchSnapshot() - return Promise.resolve(true) - }, - }) -) + test("core24 gnome extension throws in destructive-mode", ({ expect }) => + appThrows(expect, { + targets: snapTarget, + config: { + extraMetadata: { name: "sep" }, + productName: "Sep", + snapcraft: { base: "core24", core24: { useDestructiveMode: true, extensions: ["gnome"] } }, + }, + })) -test.ifNotWindows("compression option", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - extraMetadata: { - name: "sep", - }, - productName: "Sep", - snap: { - useTemplateApp: false, - compression: "xz", - }, - }, - effectiveOptionComputed: async ({ snap, args }) => { - expect(snap).toMatchSnapshot() - expect(snap.compression).toBe("xz") - expect(args).toEqual(expect.arrayContaining(["--compression", "xz"])) - return Promise.resolve(true) - }, - }) -) + test("core24 no gnome extension", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { name: "sep" }, + productName: "Sep", + snapcraft: { + base: "core24", + // extensions: [] opts out of gnome; without it isHostMode()=false adds gnome by default + core24: { extensions: [] }, + }, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + expect(snap.base).toBe("core24") + // Without GNOME extension, manual content snaps must be defined at root level + expect(snap.plugs).toBeDefined() + expect(snap.apps?.sep?.extensions).toBeUndefined() + return Promise.resolve(true) + }, + })) -test.ifNotWindows("default base", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - productName: "Sep", - }, - effectiveOptionComputed: async ({ snap }) => { - expect(snap).toMatchSnapshot() - expect(snap.base).toBe("core20") - return Promise.resolve(true) - }, - }) -) + test("core24 wayland disabled", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { name: "sep" }, + productName: "Sep", + snapcraft: { + base: "core24", + core24: { allowNativeWayland: false }, + }, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + expect(snap.environment?.["DISABLE_WAYLAND"]).toBe("1") + return Promise.resolve(true) + }, + })) -test.ifNotWindows("base option", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - productName: "Sep", - snap: { - base: "core22", - }, - }, - effectiveOptionComputed: async ({ snap }) => { - expect(snap).toMatchSnapshot() - expect(snap.base).toBe("core22") - return Promise.resolve(true) - }, - }) -) + test("core24 custom plugs with default expansion", ({ expect }) => + app(expect, { + targets: snapTarget, + config: { + extraMetadata: { name: "sep" }, + productName: "Sep", + snapcraft: { + base: "core24", + core24: { + plugs: ["default", "camera"], + }, + }, + }, + effectiveOptionComputed: async ({ snap }) => { + expect(snap).toMatchSnapshot() + // "default" should expand to the full default plug list plus "camera" + const appPlugs = snap.apps?.sep?.plugs ?? [] + expect(appPlugs).toContain("camera") + expect(appPlugs).toContain("desktop") + return Promise.resolve(true) + }, + })) -test.ifNotWindows("use template app", ({ expect }) => - app(expect, { - targets: snapTarget, - config: { - snap: { - useTemplateApp: true, - compression: "xz", - }, - }, - effectiveOptionComputed: async ({ snap, args }) => { - expect(snap).toMatchSnapshot() - expect(snap.parts).toBeUndefined() - expect(snap.compression).toBeUndefined() - expect(snap.contact).toBeUndefined() - expect(snap.donation).toBeUndefined() - expect(snap.issues).toBeUndefined() - expect(snap.parts).toBeUndefined() - expect(snap["source-code"]).toBeUndefined() - expect(snap.website).toBeUndefined() - expect(args).toEqual(expect.arrayContaining(["--exclude", "chrome-sandbox", "--compression", "xz"])) - return Promise.resolve(true) - }, + test("custom snap yamlPath pass-through", async ({ expect }) => { + await assertPack( + expect, + "test-app-one", + { + targets: snapTarget, + config: { + extraMetadata: { name: "sep" }, + productName: "Sep", + snapcraft: { + base: "custom", + custom: { yamlPath: "custom-snapcraft.yaml" }, + }, + }, + effectiveOptionComputed: ({ snap }) => { + expect(snap).toMatchSnapshot() + // electron-builder must not inject any extra plugs or extensions + expect(snap.name).toBe("sep") + expect(snap.base).toBe("core24") + return Promise.resolve(true) + }, + }, + { + projectDirCreated: async projectDir => { + // Write a minimal valid snapcraft.yaml that electron-builder should pass through unchanged + const customYaml = [ + "name: sep", + "base: core24", + "version: '1.0.0'", + "summary: Custom snap (pass-through)", + "description: |", + " Built with a custom snapcraft.yaml via electron-builder.", + "confinement: strict", + "grade: stable", + "parts:", + " app:", + " plugin: dump", + " source: .", + "apps:", + " sep:", + " command: sep", + ].join("\n") + await outputFile(path.join(projectDir, "build", "custom-snapcraft.yaml"), customYaml) + }, + } + ) }) -) +}) diff --git a/test/src/linux/test-snap.sh b/test/src/linux/test-snap.sh new file mode 100755 index 00000000000..d08a13e35ec --- /dev/null +++ b/test/src/linux/test-snap.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# Run snap heavy tests using Canonical's purpose-built snapcraft rock images. +# +# Two images are used so each core is tested against the snapcraft version that +# Canonical officially supports for it: +# +# dockerfile-snapcraft-legacy → ghcr.io/canonical/snapcraft:7_core22 +# Tests: core18, core20, core22 +# +# dockerfile-snapcraft → ghcr.io/canonical/snapcraft:8_core24 +# Tests: core24 (snapcraft 8 is also backward-compatible with older bases) +# +# Both containers build with SNAPCRAFT_BUILD_ENVIRONMENT=host (destructive mode) +# so no LXD or Multipass daemon is required. --privileged is needed for +# overlayfs access during the snapcraft prime stage. +# +# Usage +# ───── +# # Build both images and run all cores +# ./test/src/linux/test-snap.sh +# +# # Run a single core (useful for CI matrix jobs) +# SNAP_CORE=core24 ./test/src/linux/test-snap.sh +# SNAP_CORE=core22 ./test/src/linux/test-snap.sh +# SNAP_CORE=core20 ./test/src/linux/test-snap.sh +# SNAP_CORE=core18 ./test/src/linux/test-snap.sh +# +# # Skip one pass while running the other (legacy flags, still supported) +# SKIP_LEGACY=1 ./test/src/linux/test-snap.sh # core24 only +# SKIP_CORE24=1 ./test/src/linux/test-snap.sh # core18/20/22 only +# +# # Pass extra docker flags (e.g. a proxy) +# ADDITIONAL_DOCKER_ARGS="-e http_proxy=http://..." ./test/src/linux/test-snap.sh +# +# Prerequisites +# ───────────── +# docker — daemon must be running and support --privileged +# pnpm — installed in the host environment + +set -e + +# Dump snapcraft logs from a host-mounted directory on failure. +# The volume mount -v SNAPCRAFT_LOG_DIR:/root/.local/state/snapcraft/log is added +# to every docker run so logs survive container removal (--rm). +dump_snapcraft_logs() { + local log_dir="$1" + if compgen -G "${log_dir}/*.log" >/dev/null 2>&1; then + echo "--- snapcraft logs ---" >&2 + for f in "${log_dir}"/*.log; do + echo "=== $f ===" >&2 + cat "$f" >&2 + done + echo "--- end snapcraft logs ---" >&2 + fi +} + +CWD=$(dirname "$0") +# Resolve absolute repo root (three levels up: linux/ → src/ → test/ → .) +REPO_ROOT=$(cd "$CWD/../../.." && pwd) + +export TEST_FILES="snapTest,snapHeavyTest" +export DEBUG="${DEBUG:-electron-builder}" +export SKIPPED_TESTS="none" + +# Common docker flags forwarded to every test run. +# +# RUN_SNAP_TESTS=true +# Activates the test guard in snapHeavyTest.ts even when the snapd client +# ("snap") is absent — these images have snapcraft but not snapd. +# +# SNAPCRAFT_BUILD_ENVIRONMENT=host +# Standard snapcraft env-var that selects destructive / host-build mode +# without needing LXD or Multipass inside the container. +# +# --privileged +# overlayfs / bind-mount access required during snapcraft's prime stage. +# Temp dir on the host for snapcraft logs; mounted into every container so +# logs survive container exit (docker run --rm removes the container but not +# host-mounted volumes). +SNAPCRAFT_LOG_DIR=$(mktemp -d) +trap 'rm -rf "$SNAPCRAFT_LOG_DIR"' EXIT + +COMMON_DOCKER_ARGS="--privileged \ + -e RUN_SNAP_TESTS=true \ + -e SNAPCRAFT_BUILD_ENVIRONMENT=host \ + -e "SKIPPED_TESTS=${SKIPPED_TESTS:-}" \ + -v ${SNAPCRAFT_LOG_DIR}:/root/.local/state/snapcraft/log \ + ${ADDITIONAL_DOCKER_ARGS:-}" + +# ── helpers ─────────────────────────────────────────────────────────────────── + +run_pass() { + local cores="$1" # e.g. "core18,core20,core22" or "core24" + local dockerfile="$2" # e.g. "dockerfile-snapcraft-legacy" + local image_tag="$3" # e.g. "snapcraft-legacy-test" + + docker build \ + --platform=linux/amd64 \ + -f "$CWD/$dockerfile" \ + -t "$image_tag" \ + "$REPO_ROOT" + + TEST_RUNNER_IMAGE_TAG="$image_tag" \ + ADDITIONAL_DOCKER_ARGS="$COMMON_DOCKER_ARGS -e SNAP_TEST_CORES=$cores" \ + pnpm test-linux \ + || { dump_snapcraft_logs "$SNAPCRAFT_LOG_DIR"; exit 1; } +} + +# ── dispatch ────────────────────────────────────────────────────────────────── +# Set SNAP_CORE to test a single core (ideal for CI matrix jobs). +# Leave unset (or "all") to run every pass sequentially. + +case "${SNAP_CORE:-all}" in + core18|core20|core22) + run_pass "${SNAP_CORE}" "dockerfile-snapcraft-legacy" "snapcraft-legacy-test" + ;; + core24) + run_pass "core24" "dockerfile-snapcraft" "snapcraft-core24-test" + ;; + all) + [[ -z "${SKIP_LEGACY:-}" ]] && \ + run_pass "core18,core20,core22" "dockerfile-snapcraft-legacy" "snapcraft-legacy-test" + [[ -z "${SKIP_CORE24:-}" ]] && \ + run_pass "core24" "dockerfile-snapcraft" "snapcraft-core24-test" + ;; + *) + echo "Unknown SNAP_CORE=${SNAP_CORE}. Valid values: core18 core20 core22 core24 (or unset for all)." >&2 + exit 1 + ;; +esac diff --git a/test/vitest-scripts/smart-config.ts b/test/vitest-scripts/smart-config.ts index c5ba6cbabf0..2a87acb5821 100644 --- a/test/vitest-scripts/smart-config.ts +++ b/test/vitest-scripts/smart-config.ts @@ -24,12 +24,20 @@ export const IS_LINUX = PLATFORM === "linux" export const UNSTABLE_FAIL_RATIO = 0.2 // Add here broken tests to exclude from smart sharding // TODO: FIX ALL OF THESE 😅 -export const skippedTests = [ - // General instability - "snapHeavyTest", -] +export const skippedTests = + process.env.SKIPPED_TESTS?.split(",") || + [ + // These tests require running on a native Linux environment with Flatpak support + // "flatpakTest", + // These tests are run separately due to different docker images used for testing, and they are currently unstable in the CI environment + // Test via `./test/src/linux/test-snap.sh` + // "snapHeavyTest", + // "snapTest", + // General instability tests are below + // None currently, but this is where we would add any test that is currently unstable in the CI environment and needs to be excluded from smart sharding until it can be fixed. + ] export const skipPerOSTests: Record = { darwin: ["fpmTest", "macUpdaterTest", "blackboxUpdateTest"], - linux: ["flatpakTest"], + linux: [], win32: [], }