From 8a2e356e8dd270b4e62900c2fd4b70d336d2a4ae Mon Sep 17 00:00:00 2001 From: jetfir3 Date: Tue, 28 Jul 2026 18:55:50 -0400 Subject: [PATCH] Add Snap package support --- .gitignore | 1 + README.md | 12 ++ spotx-snap.sh | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++ spotx.sh | 4 +- 4 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100755 spotx-snap.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/README.md b/README.md index 55d8968..8b669f1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ bash <(curl -sSL https://raw.githubusercontent.com/SpotX-Official/SpotX-Bash/mai - View additional flags/options and examples in the `Options` section below - For more information, see the [FAQ](https://github.com/SpotX-Official/SpotX-Bash/wiki/SpotX%E2%80%90Bash-FAQ) +### Snap / NixOS: + +Spotify installations using Snap require the included `spotx-snap.sh` helper. Download the repo so `spotx-snap.sh` and `spotx.sh` remain in the same directory: +``` +git clone https://github.com/SpotX-Official/SpotX-Bash.git +cd SpotX-Bash +bash spotx-snap.sh +``` +Run `bash spotx-snap.sh --help` for additional Snap options or see the [FAQ](https://github.com/SpotX-Official/SpotX-Bash/wiki/SpotX%E2%80%90Bash-FAQ#is-the-snap-version-of-spotify-supported). + +NixOS users should use [SpotX-Nix](https://github.com/SpotX-Official/SpotX-Nix), which applies SpotX-Bash while building the Spotify package without modifying the Nix store. + ### Options:
Click to expand! diff --git a/spotx-snap.sh b/spotx-snap.sh new file mode 100755 index 0000000..0c20d9a --- /dev/null +++ b/spotx-snap.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash + +set -euo pipefail + +clr='\033[0m' +green='\033[0;32m' +red='\033[0;31m' +yellow='\033[0;33m' + +show_help() { + echo -e \ +"Usage: bash spotx-snap.sh [helper options] [SpotX-Bash options] + +Helper options: +--allow-unverified : allow an unverifiable snap set by '--snap-file' +--build-only : create patched snap without installing +--channel : select stable, candidate, beta, or edge with '--download' +--download : download Spotify snap instead of using a local source +--help : print this help message +--output-dir : set output directory for '--build-only' +--restore : restore official store-managed Spotify snap +--snap-file : use a specific local Spotify snap +--uninstall : same as '--restore' + +All other supported options are passed to SpotX-Bash. +" +} + +error() { + echo -e "${red}Error:${clr} $*\n" >&2 + exit 1 +} + +latest_cached_snap() { + local version="${1:-}" candidate latest='' + for candidate in "${cacheDir}"/spotify_*.snap; do + [[ -f "${candidate}" ]] || continue + [[ -z "${version}" || "${candidate##*/}" == "spotify_${version}_"*.snap ]] || continue + [[ -z "${latest}" || "${candidate}" -nt "${latest}" ]] && latest="${candidate}" + done + [[ -n "${latest}" ]] && printf '%s\n' "${latest}" +} + +has_spotx_marker() { + local spa="${1}" entry markerFile="${workDir}/marker.js" + for entry in xpui.js xpui-snapshot.js; do + "${sudoCmd[@]}" unzip -p "${spa}" "${entry}" > "${markerFile}" 2>/dev/null || true + grep -Fq "//# SpotX was here" "${markerFile}" && return 0 + done + return 1 +} + +verify_snap_source() { + local snap="${1}" digest size assertion assertedDigest snapId assertedSize declaration declaredName candidate remoteAssertion + digest=$("${sudoCmd[@]}" snap info --verbose "${snap}" 2>/dev/null | awk '$1 == "sha3-384:" { print $2; exit }') + size=$("${sudoCmd[@]}" stat -c '%s' "${snap}") + [[ -n "${digest}" ]] || return 1 + assertion=$(LC_ALL=C snap known snap-revision snap-sha3-384="${digest}" 2>/dev/null || true) + [[ -n "${assertion}" ]] || { + for candidate in "$(dirname -- "${sourceSnap}")"/*.assert; do + [[ -f "${candidate}" ]] || continue + "${sudoCmd[@]}" grep -Fqx "snap-sha3-384: ${digest}" "${candidate}" 2>/dev/null || continue + "${sudoCmd[@]}" snap ack "${candidate}" >/dev/null 2>&1 || continue + assertion=$(LC_ALL=C snap known snap-revision snap-sha3-384="${digest}" 2>/dev/null || true) + [[ -n "${assertion}" ]] && break + done + } + [[ -n "${assertion}" ]] || { + remoteAssertion=$(LC_ALL=C timeout 20 snap known --remote snap-revision snap-sha3-384="${digest}" 2>/dev/null || true) + [[ -n "${remoteAssertion}" ]] && { + printf '%s\n' "${remoteAssertion}" > "${workDir}/snap-revision.assert" + "${sudoCmd[@]}" snap ack "${workDir}/snap-revision.assert" >/dev/null 2>&1 || true + assertion="${remoteAssertion}" + } + } + [[ -n "${assertion}" ]] || return 1 + assertedDigest=$(awk '$1 == "snap-sha3-384:" { print $2; exit }' <<< "${assertion}") + snapId=$(awk '$1 == "snap-id:" { print $2; exit }' <<< "${assertion}") + assertedSize=$(awk '$1 == "snap-size:" { print $2; exit }' <<< "${assertion}") + [[ "${assertedDigest}" == "${digest}" && -n "${snapId}" && "${assertedSize}" == "${size}" ]] || return 1 + declaration=$(LC_ALL=C snap known snap-declaration snap-id="${snapId}" 2>/dev/null || true) + [[ -n "${declaration}" ]] || { + remoteAssertion=$(LC_ALL=C timeout 20 snap known --remote snap-declaration snap-id="${snapId}" 2>/dev/null || true) + [[ -n "${remoteAssertion}" ]] && { + printf '%s\n' "${remoteAssertion}" > "${workDir}/snap-declaration.assert" + "${sudoCmd[@]}" snap ack "${workDir}/snap-declaration.assert" >/dev/null 2>&1 || true + declaration="${remoteAssertion}" + } + } + declaredName=$(awk '$1 == "snap-name:" { print $2; exit }' <<< "${declaration}") + [[ "${declaredName}" == "spotify" ]] || return 1 + sourceVerified='true' +} + +cleanup() { + [[ -n "${workDir:-}" && -d "${workDir}" && "${workDir}" == "${tempBase}/spotx-snap."* ]] && "${sudoCmd[@]}" rm -rf -- "${workDir}" +} + +snapChannel='stable' +snapFile='' +allowUnverified='' +downloadSnap='' +buildOnly='' +restoreSnap='' +channelSet='' +outputSet='' +outputDir="${PWD}" +spotxArgs=() +while (($#)); do + case "${1}" in + --allow-unverified) allowUnverified='true' ;; + --build-only) buildOnly='true' ;; + --channel) + (($# > 1)) || error "'--channel' requires an argument." + snapChannel="${2}" + channelSet='true' + shift + ;; + --channel=*) + snapChannel="${1#*=}" + channelSet='true' + ;; + --download) downloadSnap='true' ;; + --help) show_help; exit 0 ;; + --output-dir) + (($# > 1)) || error "'--output-dir' requires an argument." + outputDir="${2}" + outputSet='true' + shift + ;; + --output-dir=*) + outputDir="${1#*=}" + outputSet='true' + ;; + --restore|--uninstall) restoreSnap='true' ;; + --snap-file) + (($# > 1)) || error "'--snap-file' requires an argument." + snapFile="${2}" + shift + ;; + --snap-file=*) snapFile="${1#*=}" ;; + -P*|-F*|--installdeb|--installmac|--rollback|--stable) + error "'${1}' cannot be used with spotx-snap.sh." + ;; + -c|--clearcache) + error "Snap cache clearing is not supported by spotx-snap.sh." + ;; + -v|--version|--logo) + error "'${1}' does not patch a snap and cannot be used with spotx-snap.sh." + ;; + --) error "'--' is not supported by spotx-snap.sh." ;; + *) spotxArgs+=("${1}") ;; + esac + shift +done + +[[ "$(uname -s)" == "Linux" ]] || error "spotx-snap.sh requires Linux." +[[ "$(uname -m)" == "x86_64" || "$(uname -m)" == "amd64" ]] || error "Spotify snap requires an x86_64 Linux system." +[[ -z "${allowUnverified}" || -n "${snapFile}" ]] || error "'--allow-unverified' requires '--snap-file'." +[[ -z "${snapFile}" || -z "${downloadSnap}" ]] || error "'--snap-file' and '--download' cannot be used together." +[[ -z "${channelSet}" || -n "${downloadSnap}" ]] || error "'--channel' requires '--download'." +[[ -z "${outputSet}" || -n "${buildOnly}" ]] || error "'--output-dir' requires '--build-only'." +[[ "${restoreSnap}" ]] && { + [[ -z "${snapFile}${downloadSnap}${buildOnly}${channelSet}${outputSet}" && ${#spotxArgs[@]} -eq 0 ]] || error "'--restore' cannot be combined with other options." +} +[[ "${snapChannel}" =~ ^(stable|candidate|beta|edge)$ ]] || error "Invalid snap channel '${snapChannel}'." + +scriptDir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +spotxScript="${scriptDir}/spotx.sh" +[[ -f "${spotxScript}" ]] || error "spotx.sh not found beside spotx-snap.sh." + +for requirement in snap unsquashfs unzip zip perl awk grep sha256sum mktemp getent install stat timeout; do + command -v "${requirement}" >/dev/null || error "${requirement} command not found." +done + +runUser="${SUDO_USER:-$(id -un)}" +runUid=$(id -u "${runUser}") +runGid=$(id -g "${runUser}") +runHome=$(getent passwd "${runUser}" 2>/dev/null | awk -F: 'NR == 1 { print $6 }') +[[ -n "${runHome}" ]] || runHome="${HOME}" +cacheBase="${runHome}/.cache/spotx-bash" +cacheDir="${cacheBase}/snap" +sudoCmd=() +((EUID == 0)) || { + command -v sudo >/dev/null || error "sudo command not found." + sudo -n true >/dev/null 2>&1 || { + echo -e "This script requires sudo permission to preserve and install snap files.\nPlease enter your sudo password..." + sudo -v || error "Failed to obtain sudo permission." + } + sudoCmd=(sudo) +} + +snap version >/dev/null 2>&1 || error "snapd is not available." +[[ "${restoreSnap}" ]] && { + LC_ALL=C snap list spotify >/dev/null 2>&1 || error "Spotify snap is not installed." + command pkill -9 '[sS]potify' 2>/dev/null || true + echo -e "Restoring official store-managed Spotify snap...\n" + "${sudoCmd[@]}" snap refresh --amend --channel=latest/stable spotify || error "Official Spotify snap restore failed." + echo + echo -e "${green}Finished${clr}\n" + exit 0 +} + +tempBase="${TMPDIR:-/tmp}" +tempBase="${tempBase%/}" +workDir=$(mktemp -d "${tempBase}/spotx-snap.XXXXXXXX") +trap cleanup EXIT +trap 'exit 130' HUP INT TERM +sourceDir="${workDir}/source" +snapRoot="${workDir}/squashfs-root" +packedDir="${workDir}/packed" +sourceCopy="${sourceDir}/spotify.snap" +mkdir -p "${sourceDir}" "${packedDir}" + +sourceSnap='' +sourceOutput='' +[[ -n "${snapFile}" ]] && { + [[ -f "${snapFile}" ]] || error "Snap file not found: ${snapFile}" + sourceSnap=$(cd -- "$(dirname -- "${snapFile}")" && pwd)/$(basename -- "${snapFile}") + sourceOutput="${sourceSnap}" +} +[[ "${downloadSnap}" ]] && { + downloadDir="${workDir}/download" + mkdir -p "${downloadDir}" + echo -e "Downloading Spotify snap from ${snapChannel} channel...\n" + downloadOutput=$(cd "${downloadDir}" && snap download spotify --channel="${snapChannel}" 2>&1) || { + echo -e "${downloadOutput}\n" >&2 + error "Spotify snap download failed." + } + for candidate in "${downloadDir}"/spotify_*.snap; do + [[ -f "${candidate}" ]] || continue + [[ -z "${sourceSnap}" ]] || error "Multiple Spotify snap downloads found." + sourceSnap="${candidate}" + done + [[ -n "${sourceSnap}" ]] || error "Spotify snap download not found." + sourceOutput="downloaded ${snapChannel} snap" +} +[[ -z "${sourceSnap}" ]] && { + installedOutput=$(LC_ALL=C snap list spotify 2>/dev/null || true) + installedVersion=$(awk 'NR == 2 { print $2 }' <<< "${installedOutput}") + installedRev=$(awk 'NR == 2 { print $3 }' <<< "${installedOutput}") + [[ -n "${installedRev}" && "${installedRev}" != x* ]] && { + installedSnap="/var/lib/snapd/snaps/spotify_${installedRev}.snap" + [[ -f "${installedSnap}" ]] && { + sourceSnap="${installedSnap}" + sourceOutput="installed Spotify snap revision ${installedRev}" + } + } +} +[[ -z "${sourceSnap}" ]] && { + installedCacheVersion='' + [[ -n "${installedVersion:-}" ]] && installedCacheVersion=$(printf '%s' "${installedVersion}" | tr -c 'A-Za-z0-9._-' '_') + cachedSnap=$(latest_cached_snap "${installedCacheVersion}" || true) + [[ -n "${cachedSnap}" ]] && { + sourceSnap="${cachedSnap}" + sourceOutput="cached original snap" + } + [[ -z "${cachedSnap}" && -n "${installedVersion:-}" ]] && error "No cached original found for installed Spotify ${installedVersion}.\nUse '--snap-file ' or '--download'." +} +[[ -n "${sourceSnap}" ]] || error "No unmodified Spotify snap source found.\nUse '--snap-file ' or '--download'." + +echo -e "Using ${sourceOutput}\n" +"${sudoCmd[@]}" cp -p -- "${sourceSnap}" "${sourceCopy}" +sourceVerified='' +verify_snap_source "${sourceCopy}" && { + echo -e "${green}Verified official Spotify snap${clr}\n" +} || { + [[ "${allowUnverified}" ]] || error "Selected snap could not be authenticated as an official Spotify snap.\nConnect to the internet, place its matching assertion beside it or use '--allow-unverified'." + echo -e "${yellow}Warning:${clr} Selected snap could not be authenticated and will not be cached.\n" +} +"${sudoCmd[@]}" unsquashfs -d "${snapRoot}" "${sourceCopy}" >/dev/null +spotifyPath="${snapRoot}/usr/share/spotify" +xpuiSpa="${spotifyPath}/Apps/xpui.spa" +snapYaml="${snapRoot}/meta/snap.yaml" +[[ -f "${xpuiSpa}" && -f "${spotifyPath}/spotify" ]] || error "Spotify client not found inside snap." +[[ -f "${snapYaml}" ]] || error "Snap metadata not found." +snapName=$("${sudoCmd[@]}" awk '$1 == "name:" { print $2; exit }' "${snapYaml}") +snapName="${snapName#\"}" +snapName="${snapName%\"}" +snapName="${snapName#\'}" +snapName="${snapName%\'}" +[[ "${snapName}" == "spotify" ]] || error "Selected snap is not the Spotify snap." +has_spotx_marker "${xpuiSpa}" && error "Selected snap source is already patched.\nUse an original snap file or '--download'." + +snapVersion=$("${sudoCmd[@]}" awk '$1 == "version:" { print $2; exit }' "${snapYaml}") +snapVersion="${snapVersion#\"}" +snapVersion="${snapVersion%\"}" +snapVersion="${snapVersion#\'}" +snapVersion="${snapVersion%\'}" +[[ -n "${snapVersion}" ]] || error "Unable to determine Spotify snap version." +forcedVersion="${snapVersion%%.g*}" +safeVersion=$(printf '%s' "${snapVersion}" | tr -c 'A-Za-z0-9._-' '_') +sourceHash=$("${sudoCmd[@]}" sha256sum "${sourceCopy}" | awk '{ print $1 }') +cacheFile="${cacheDir}/spotify_${safeVersion}_${sourceHash:0:12}.snap" +[[ "${sourceVerified}" ]] && { + "${sudoCmd[@]}" install -d -o "${runUid}" -g "${runGid}" -m 0755 "${cacheBase}" "${cacheDir}" + [[ -f "${cacheFile}" ]] || { + "${sudoCmd[@]}" install -o "${runUid}" -g "${runGid}" -m 0644 "${sourceCopy}" "${cacheFile}" + echo -e "Cached original snap: ${cacheFile}\n" + } +} + +echo -e "Patching Spotify ${forcedVersion} with SpotX-Bash...\n" +"${sudoCmd[@]}" env SPOTX_BUILD_MODE=true bash "${spotxScript}" -P "${spotifyPath}" -F "${forcedVersion}" "${spotxArgs[@]}" +"${sudoCmd[@]}" rm -f -- "${spotifyPath}/spotify.bak" "${spotifyPath}/Apps/xpui.bak" +"${sudoCmd[@]}" unzip -tqq "${xpuiSpa}" || error "Patched xpui.spa validation failed." +has_spotx_marker "${xpuiSpa}" || error "SpotX marker not found in patched xpui.spa." + +echo -e "Packing patched Spotify snap...\n" +"${sudoCmd[@]}" snap pack "${snapRoot}" "${packedDir}" >/dev/null +packedSnap='' +for candidate in "${packedDir}"/*.snap; do + [[ -f "${candidate}" ]] || continue + [[ -z "${packedSnap}" ]] || error "Multiple packed snap files found." + packedSnap="${candidate}" +done +[[ -n "${packedSnap}" ]] || error "Patched snap was not created." +"${sudoCmd[@]}" unsquashfs -s "${packedSnap}" >/dev/null || error "Patched snap validation failed." + +outputName="Spotify.v${forcedVersion}.Linux.x64-SPOTX.snap" +[[ "${buildOnly}" ]] && { + mkdir -p "${outputDir}" + outputDir=$(cd -- "${outputDir}" && pwd) + outputFile="${outputDir}/${outputName}" + [[ ! -e "${outputFile}" ]] || error "Output file already exists: ${outputFile}" + "${sudoCmd[@]}" install -o "${runUid}" -g "${runGid}" -m 0644 "${packedSnap}" "${outputFile}" + echo -e "${green}Created:${clr} ${outputFile}\n" + exit 0 +} + +command pkill -9 '[sS]potify' 2>/dev/null || true +echo -e "${yellow}Warning:${clr} This locally installed snap will not receive automatic Spotify updates." +echo -e "Re-run this helper to update it or use '--restore' to return to the official snap.\n" +echo -e "Installing patched Spotify snap...\n" +"${sudoCmd[@]}" snap install --dangerous "${packedSnap}" +installedOutput=$(LC_ALL=C snap list spotify 2>/dev/null || true) +installedVersion=$(awk 'NR == 2 { print $2 }' <<< "${installedOutput}") +[[ -n "${installedVersion}" ]] || error "Spotify snap installation could not be verified." +echo -e "\n${green}Installed:${clr} Spotify ${installedVersion} with SpotX-Bash" +echo -e "Re-run this helper to rebuild from the cached original snap." +echo -e "Restore the official snap with: ${yellow}bash spotx-snap.sh --restore${clr}\n" +exit 0 diff --git a/spotx.sh b/spotx.sh index 6290a7b..16e7b69 100644 --- a/spotx.sh +++ b/spotx.sh @@ -314,7 +314,7 @@ linux_deb_prepare() { linux_no_client() { command -v snap >/dev/null && snap list spotify &>/dev/null && { - echo -e "${red}Error:${clr} Snap client not supported. See FAQ for more info.\nIf another Spotify package is installed, set directory path with '-P' flag.\n" >&2 + echo -e "${red}Error:${clr} Snap client requires spotx-snap.sh. See FAQ for more info.\nIf another Spotify package is installed, set directory path with '-P' flag.\n" >&2 exit 1 } command -v apt >/dev/null && { @@ -391,7 +391,7 @@ linux_set_path() { } requestedPath="${installPath%/}" [[ "${requestedPath}" == *"snapd/snap"* || "${requestedPath}" == *"snap/spotify"* || "${requestedPath}" == *"snap/bin"* ]] && { - echo -e "${red}Error:${clr} Snap client not supported. See FAQ for more info.\n" >&2 + echo -e "${red}Error:${clr} Snap client requires spotx-snap.sh. See FAQ for more info.\n" >&2 exit 1 } linux_resolve_client_path "${requestedPath}" && {