diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b0959f52..40f4d5fb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -441,6 +441,12 @@ jobs: path: dist/* if-no-files-found: error + # The snap has a workflow of its own so it can be built without running the release; + # on a tag the release calls that same definition, the way it calls announce. + snap: + uses: ./.github/workflows/snap.yml + secrets: inherit + # Build flatpak, see: https://github.com/flatpak/flatpak-github-actions flatpak: name: "Flatpak (${{ matrix.variant.arch }})" @@ -586,7 +592,7 @@ jobs: git push --force "https://x-access-token:$TOKEN@github.com/$GITHUB_REPOSITORY" flatpak-repo release: - needs: [build, bundle, appimage, flatpak, flatpak-repo] + needs: [build, bundle, appimage, snap, flatpak, flatpak-repo] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml new file mode 100644 index 000000000..01cce3f84 --- /dev/null +++ b/.github/workflows/snap.yml @@ -0,0 +1,136 @@ +name: snap + +# The snap is built by its own workflow, not inline in the release, so it can be +# exercised without running anything named release: a pull request that touches the +# manifest builds it, and it can be dispatched by hand. The release calls this same +# definition on a tag, the way it calls announce. +# +# Unlike the AppImage, the snap does not reuse the executable the build job staged: +# snapcraft compiles the workspace itself inside its own core24 container, so the +# manifest a contributor runs locally is the one that cuts the release. +on: + workflow_call: + workflow_dispatch: + pull_request: + paths: + - snap/** + - .github/workflows/snap.yml + +concurrency: + group: snap-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + snap: + name: "Snap (${{ matrix.variant.arch }})" + strategy: + fail-fast: false + matrix: + variant: + - arch: amd64 + runner: ubuntu-22.04 + - arch: arm64 + runner: ubuntu-22.04-arm + + runs-on: ${{ matrix.variant.runner }} + + env: + ARCH: ${{ matrix.variant.arch }} + # Read at step level to decide whether the store upload can run at all: a step's + # own env is not visible to its own `if`, a job's is. + STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + + steps: + - uses: actions/checkout@v5 + + - name: name the artifact + id: meta + shell: bash + run: | + set -euo pipefail + case "$GITHUB_REF" in + refs/tags/*) version="$GITHUB_REF_NAME" ;; + *) version="${GITHUB_SHA:0:7}" ;; + esac + + # A prerelease tag goes to the beta channel, everything else to stable. An + # untagged run publishes nowhere, which is what the empty channel means. + channel="" + case "$GITHUB_REF" in + refs/tags/*-*) channel="beta" ;; + refs/tags/*) channel="stable" ;; + esac + + { + echo "version=$version" + echo "channel=$channel" + } >> "$GITHUB_OUTPUT" + + - name: build the snap + id: build + uses: snapcore/action-build@v1 + + - name: inspect the snap + env: + SNAP: ${{ steps.build.outputs.snap }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends squashfs-tools + + list="$(unsquashfs -l "$SNAP")" + # The rust plugin installs to bin/; override-build moves it where the desktop + # entry and the app command both expect it. + grep -qx 'squashfs-root/usr/bin/sonora' <<<"$list" + grep -qx 'squashfs-root/usr/share/applications/sonora.desktop' <<<"$list" + grep -qx 'squashfs-root/etc/asound.conf' <<<"$list" + # ALSA_PLUGIN_DIR names one triplet-free path, so the PulseAudio plugin the + # asound.conf routes to has to have been moved there. + grep -qx 'squashfs-root/usr/lib/alsa-lib/libasound_module_pcm_pulse.so' <<<"$list" + # webkit comes from gnome-46-2404; a staged copy is 122 MiB of nothing. + if grep -q 'libwebkit2gtk' <<<"$list"; then + echo "libwebkit2gtk was staged; the gnome platform already provides it" >&2 + exit 1 + fi + grep -qx 'squashfs-root/usr/share/doc/sonora/THIRD-PARTY.md' <<<"$list" + # The Vulkan ICD has to come from the gpu-2404 provider, never from the snap. + if grep -q 'squashfs-root/usr/share/vulkan/icd.d' <<<"$list"; then + echo "a Vulkan ICD was staged; it would shadow the gpu-2404 provider's" >&2 + exit 1 + fi + + # The snap's own version is adopted from Cargo.toml, not from the tag, so a + # release built off a stale version line is caught here rather than in the store. + unsquashfs -d snapmeta -q "$SNAP" meta/snap.yaml + cargo_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" + grep -qx "version: $cargo_version" snapmeta/meta/snap.yaml + grep -qx "base: core24" snapmeta/meta/snap.yaml + grep -qx "confinement: strict" snapmeta/meta/snap.yaml + rm -rf snapmeta + + - name: name the snap + env: + SNAP: ${{ steps.build.outputs.snap }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + mv "$SNAP" "dist/sonora-$VERSION-$ARCH.snap" + + - uses: actions/upload-artifact@v4 + with: + name: release-snap-${{ matrix.variant.arch }} + path: dist/* + if-no-files-found: error + + # Publishing is optional: with no store credentials configured the release still + # carries the .snap file as a downloadable asset, the way it carries the Flatpak + # bundles, and only the Snap Store upload is skipped. + - name: publish to the Snap Store + if: steps.meta.outputs.channel != '' && env.STORE_CREDENTIALS != '' + uses: snapcore/action-publish@v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: dist/sonora-${{ steps.meta.outputs.version }}-${{ matrix.variant.arch }}.snap + release: ${{ steps.meta.outputs.channel }} diff --git a/.gitignore b/.gitignore index 40aa13cda..954f3619a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ __pycache__/ /flatpak/*.flatpak /flatpak/flatpak /.flatpak-builder +/*.snap +/parts +/stage +/prime diff --git a/CHANGELOG.md b/CHANGELOG.md index c0effd410..cc840f945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Sonora is packaged as a snap, for x86-64 and ARM64, alongside the AppImage and the Flatpak. + ### Changed - An artist page opens as soon as their profile and popular tracks arrive, and fills its diff --git a/README.md b/README.md index a879d788c..576f77404 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,23 @@ information, so [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUp AppImage manager such as [AppManager](https://github.com/kem-a/AppManager) can fetch a new release for you. +#### Snap + +```sh +sudo snap install sonora +``` + +An `arm64` build is published beside the `amd64` one, and the `.snap` files are attached to every +release for installing without the store (`snap install --dangerous ./sonora-*.snap`). Local music +on an external drive needs one connection the store does not make for you: + +```sh +sudo snap connect sonora:removable-media +``` + +Discord Rich Presence does not work under snap confinement, and neither does the Widevine module +some providers need for playback; the Flatpak has both. + ### Nix The flake packages the latest tagged release binary or builds from source if unavailable for your platform. diff --git a/snap/local/asound.conf b/snap/local/asound.conf new file mode 100644 index 000000000..7ff5afb8d --- /dev/null +++ b/snap/local/asound.conf @@ -0,0 +1,16 @@ +# cpal opens the ALSA default device, which inside the snap has to be the +# PulseAudio plugin: audio-playback hands out a PulseAudio/PipeWire socket and +# no direct access to the sound card. +pcm.!default { + type pulse + fallback "sysdefault" + hint { + show on + description "Default ALSA Output (currently PulseAudio Sound Server)" + } +} + +ctl.!default { + type pulse + fallback "sysdefault" +} diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 000000000..c28b49b37 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,178 @@ +name: sonora +title: Sonora +base: core24 +adopt-info: sonora +summary: A native music streaming client, built with Rust and GPUI +description: | + Sonora is a native music streaming client built with Rust and GPUI, streaming + through librespot and ytmusic-rs. It plays a Spotify or YouTube Music account + and a local music folder from one library. + + Note: playing a Spotify account requires Spotify Premium. +license: GPL-3.0-or-later +website: https://github.com/sonorahq/sonora +source-code: https://github.com/sonorahq/sonora +issues: https://github.com/sonorahq/sonora/issues + +grade: stable +confinement: strict +compression: lzo + +platforms: + amd64: + arm64: + +apps: + sonora: + command: usr/bin/sonora + extensions: [gnome] + common-id: io.github.nolight132.sonora + desktop: usr/share/applications/sonora.desktop + environment: + # cpal reaches the sound server through ALSA, so the snap carries the ALSA + # PulseAudio plugin and a config that routes the default device to it. + # alsa-lib dlopens its plugins out of ALSA_PLUGIN_DIR, not off the linker path, + # and snapd expands only its own $SNAP_* variables here, never a craft one, so + # the directory the plugins are moved to in override-prime carries no triplet. + ALSA_CONFIG_PATH: $SNAP/etc/asound.conf + ALSA_PLUGIN_DIR: $SNAP/usr/lib/alsa-lib + plugs: + - audio-playback + - network + # The OAuth redirect (127.0.0.1:8989) and the scrobble callback (127.0.0.1:8990) + # are listening sockets, so a plain `network` plug is not enough. + - network-bind + - home + - removable-media + - screen-inhibit-control + # The StatusNotifierItem tray and desktop notifications. + - unity7 + slots: + - mpris + +slots: + mpris: + interface: mpris + # souvlaki publishes org.mpris.MediaPlayer2.sonora. + name: sonora + +parts: + sonora: + # Not the rust plugin: it validates that rustup answers in the build environment, + # which /snap/bin is not on, and it would drive cargo for a workspace this part + # already overrides the build and the prime of anyway. Installing the toolchain + # here is the whole of what the plugin would have done. + plugin: nil + source: . + build-packages: + - curl + # opusic-sys and aws-lc-sys build their C libraries with cmake, and the cmake + # crate asks for the Ninja generator. A bare core24 container has neither; the + # runners and the freedesktop SDK do, which is why only the snap needs this said. + - cmake + - ninja-build + # .cargo/config.toml passes -fuse-ld=mold for x86_64-unknown-linux-gnu. + - mold + # crates/widevine compiles its CDM host from shim.cc. + - g++ + - pkg-config + - libasound2-dev + - libdbus-1-dev + - libfontconfig1-dev + - libfreetype-dev + - libsqlite3-dev + - libvulkan-dev + - libwayland-dev + - libx11-dev + - libxcb1-dev + - libxcursor-dev + - libxi-dev + - libxkbcommon-dev + - libxkbcommon-x11-dev + stage-packages: + - libasound2t64 + - libasound2-plugins + - libdbus-1-3 + - libfontconfig1 + - libfreetype6 + - libsqlite3-0 + - libvulkan1 + - libwayland-client0 + # No libwebkit2gtk here on purpose: gnome-46-2404 stages it and the extension + # puts the platform's library directory on the load path, so a copy of our own + # would be 122 MiB shadowing the one already there. Nothing links it either -- + # the cookie sign-in dlopens it -- so there is no build dependency to match. + - libx11-6 + - libxcb1 + - libxcursor1 + - libxi6 + - libxkbcommon0 + - libxkbcommon-x11-0 + override-pull: | + craftctl default + version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" + craftctl set version="$version" + override-build: | + craftctl default + + # rust-toolchain.toml pins the version, but it also asks for rust-analyzer and + # friends that a build has no use for; RUSTUP_TOOLCHAIN overrides the file so + # only the compiler is fetched. Keep the version in step with that file. + export RUSTUP_HOME="$CRAFT_PART_BUILD/.rustup" + export CARGO_HOME="$CRAFT_PART_BUILD/.cargo" + export RUSTUP_TOOLCHAIN=1.98.1 + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --profile minimal --default-toolchain "$RUSTUP_TOOLCHAIN" + export PATH="$CARGO_HOME/bin:$PATH" + + cargo build --release --locked --package sonora + install -Dm755 target/release/sonora "$CRAFT_PART_INSTALL/usr/bin/sonora" + + # Every asset is compiled into the executable, so only the launcher entry + # and the icons are installed. + for icon in assets/linux/icons/hicolor/*/apps/sonora.png; do + size="$(basename "$(dirname "$(dirname "$icon")")")" + install -Dm644 "$icon" \ + "$CRAFT_PART_INSTALL/usr/share/icons/hicolor/$size/apps/sonora.png" + done + install -Dm644 assets/linux/sonora.svg \ + "$CRAFT_PART_INSTALL/usr/share/icons/hicolor/scalable/apps/sonora.svg" + + # snapd rewrites Exec=, but never Icon=, so the icon is given as an absolute + # path inside the snap. + install -d "$CRAFT_PART_INSTALL/usr/share/applications" + sed 's|^Icon=sonora$|Icon=${SNAP}/usr/share/icons/hicolor/512x512/apps/sonora.png|' \ + assets/linux/sonora.desktop \ + > "$CRAFT_PART_INSTALL/usr/share/applications/sonora.desktop" + + install -Dm644 snap/local/asound.conf "$CRAFT_PART_INSTALL/etc/asound.conf" + + install -Dm644 COPYING "$CRAFT_PART_INSTALL/usr/share/doc/sonora/LICENSE" + install -Dm644 THIRD-PARTY.md "$CRAFT_PART_INSTALL/usr/share/doc/sonora/THIRD-PARTY.md" + install -Dm644 assets/fonts/LICENSE.txt \ + "$CRAFT_PART_INSTALL/usr/share/doc/sonora/LICENSE.Inter" + install -Dm644 assets/icons/LICENSE \ + "$CRAFT_PART_INSTALL/usr/share/doc/sonora/icons/LICENSE" + for licence in assets/icons/*/LICENSE; do + pack="$(basename "$(dirname "$licence")")" + install -Dm644 "$licence" \ + "$CRAFT_PART_INSTALL/usr/share/doc/sonora/icons/LICENSE.$pack" + done + prime: + # The Vulkan ICD comes from the gpu-2404 provider, which the gnome extension + # points the loader at; a driver staged here would only shadow it. + - -usr/share/vulkan/icd.d + override-prime: | + craftctl default + + # libasound2-plugins installs under an architecture triplet, which ALSA_PLUGIN_DIR + # cannot name: it is read by snapd, which expands no craft variable. Moving the + # plugins to one path spells the same thing on amd64 and arm64. + triplet="$(dirname "$(find "$CRAFT_PRIME/usr/lib" -maxdepth 3 -type d -name alsa-lib -print -quit)")" + if [ -n "$triplet" ] && [ "$triplet" != "$CRAFT_PRIME/usr/lib" ]; then + mkdir -p "$CRAFT_PRIME/usr/lib/alsa-lib" + mv "$triplet/alsa-lib"/* "$CRAFT_PRIME/usr/lib/alsa-lib/" + rmdir "$triplet/alsa-lib" + fi + test -f "$CRAFT_PRIME/usr/lib/alsa-lib/libasound_module_pcm_pulse.so" + test -f "$CRAFT_PRIME/usr/lib/alsa-lib/libasound_module_conf_pulse.so"