diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afdc3de3..7b549d73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,3 +159,62 @@ jobs: find src-tauri/target/release/bundle \ \( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \ | xargs gh release upload "app-v${VERSION}" --clobber + + # Verifies that `nix build .#psysonic` still works against the current source + # and refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock` + # (nixpkgs pin). Commits the refreshed files back to `main` when they change. + # + # Tarball publishing / Cachix upload are intentionally out of scope here — + # those will live in a dedicated workflow tied to a binary cache setup. + verify-nix: + needs: create-release + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + # Full history so we can push the auto-commit back to the default branch. + fetch-depth: 0 + # Checkout main, not the tag — we want to push lock/hash refreshes to + # the moving branch, not the immutable tag ref. + ref: main + + - name: install Nix + uses: DeterminateSystems/nix-installer-action@v15 + + - name: compute npmDepsHash from package-lock.json + id: npm-hash + run: | + set -euo pipefail + HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)" + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "Computed npmDepsHash: $HASH" + + - name: write npmDepsHash into nix/upstream-sources.json + run: | + set -euo pipefail + HASH='${{ steps.npm-hash.outputs.hash }}' + jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new + mv nix/upstream-sources.json.new nix/upstream-sources.json + cat nix/upstream-sources.json + + - name: refresh flake.lock (nixpkgs pin) + run: nix flake update --accept-flake-config + + - name: verify nix build + run: nix build .#psysonic --accept-flake-config --no-link --print-build-logs + + - name: commit + push refreshed lock and hash (if changed) + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add flake.lock nix/upstream-sources.json + if git diff --cached --quiet; then + echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit." + exit 0 + fi + VERSION="${{ needs.create-release.outputs.package_version }}" + git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}" + git push origin HEAD:main diff --git a/.gitignore b/.gitignore index 9a7ec645..995e7949 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,7 @@ tmp/ # Third-party clones for local research (not committed) research/ + +# Nix build output symlink +result +result-* diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..2f98e43b --- /dev/null +++ b/flake.nix @@ -0,0 +1,121 @@ +{ + description = '' + Psysonic for NixOS / nixpkgs: installable app + dev shell. + + Packages: + nix build .#psysonic # or .#default — desktop app (.desktop + icon) + nix profile install .#psysonic + + Run (after build, or from any clone with flake): + nix run .#psysonic + nix run github:Psychotoxical/psysonic + + Development: + nix develop # mkShell (Rust/Node/WebKit deps + hooks) + nix shell .#devShells.default # same environment without entering subshell semantics + + Release pipeline updates `flake.lock` (nixpkgs pin refresh) and + `nix/upstream-sources.json` (npmDepsHash) on every `v*` tag push — + see `.github/workflows/release.yml` (verify-nix job). Package version + is read from `package.json`; nothing in this file needs manual bumping + per release. + ''; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + inherit (nixpkgs) lib; + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forSystem = f: lib.genAttrs systems f; + + mkShellFor = + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + gstPlugins = with pkgs.gst_all_1; [ + gstreamer + gst-plugins-base + gst-plugins-good + gst-plugins-bad + ]; + gstPluginPath = pkgs.lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins; + in + pkgs.mkShell { + packages = with pkgs; [ + nodejs_22 + rustc + cargo + cmake + pkg-config + openssl + gtk3 + webkitgtk_4_1 + libsoup_3 + glib-networking + atk + cairo + gdk-pixbuf + glib + pango + librsvg + alsa-lib + libayatana-appindicator + ] + ++ gstPlugins; + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.libayatana-appindicator}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export GST_PLUGIN_PATH="${gstPluginPath}''${GST_PLUGIN_PATH:+:$GST_PLUGIN_PATH}" + export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}" + export GDK_BACKEND=x11 + export WEBKIT_DISABLE_COMPOSITING_MODE=1 + export WEBKIT_DISABLE_DMABUF_RENDERER=1 + unset CI + ''; + + OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include"; + }; + + upstreamMeta = lib.importJSON ./nix/upstream-sources.json; + + psysonicFor = + system: + nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix { + src = self; + inherit upstreamMeta; + }; + in + { + devShells = forSystem (system: { default = mkShellFor system; }); + + packages = forSystem (system: { + psysonic = psysonicFor system; + default = psysonicFor system; + }); + + apps = forSystem ( + system: + let + p = psysonicFor system; + in + { + default = { + type = "app"; + program = lib.getExe p; + meta = { + inherit (p.meta) description homepage license; + mainProgram = "psysonic"; + }; + }; + } + ); + }; +} diff --git a/nix/psysonic.nix b/nix/psysonic.nix new file mode 100644 index 00000000..ba0a5c85 --- /dev/null +++ b/nix/psysonic.nix @@ -0,0 +1,176 @@ +# Installable Psysonic (Tauri): npm build → cargo tauri build --no-bundle. +# Source: `self` (this repo). Package version is read from package.json. +# `npmDepsHash` in nix/upstream-sources.json is refreshed by the release +# workflow (see .github/workflows/release.yml, verify-nix job). + +{ + lib, + stdenv, + fetchNpmDeps, + npmHooks, + rustPlatform, + cargo, + rustc, + pkg-config, + cmake, + openssl, + gtk3, + webkitgtk_4_1, + libsoup_3, + glib-networking, + alsa-lib, + libayatana-appindicator, + atk, + cairo, + gdk-pixbuf, + glib, + pango, + librsvg, + cargo-tauri, + nodejs, + makeWrapper, + wrapGAppsHook4, + copyDesktopItems, + makeDesktopItem, + gst_all_1, + src, + upstreamMeta, +}: + +let + version = (lib.importJSON (src + "/package.json")).version; + # WebKit media stack needs discoverable GStreamer plugins (e.g. appsink in gst-plugins-base). + gstPlugins = with gst_all_1; [ + gstreamer + gst-plugins-base + gst-plugins-good + gst-plugins-bad + ]; + gstPluginPath = lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins; + srcClean = lib.cleanSourceWith { + inherit src; + filter = + path: _: + let + f = toString path; + in + !(lib.hasInfix "/node_modules/" f) + && !(lib.hasInfix "/dist/" f) + && !(lib.hasInfix "/target/" f) + && !(lib.hasInfix "/.git/" f) + && !(lib.hasInfix "/result/" f) + && !(lib.hasInfix "/.flatpak-builder/" f); + }; + npmDeps = fetchNpmDeps { + src = srcClean; + hash = upstreamMeta.npmDepsHash; + }; + cargoLockFile = src + "/src-tauri/Cargo.lock"; +in + +stdenv.mkDerivation (finalAttrs: { + pname = "psysonic"; + inherit version; + src = srcClean; + inherit npmDeps; + + strictDeps = true; + + # cmake is only for Rust deps (e.g. libopus); no top-level CMakeLists.txt in repo root + dontUseCmakeConfigure = true; + + nativeBuildInputs = [ + npmHooks.npmConfigHook + cargo + rustc + rustPlatform.cargoSetupHook + pkg-config + cmake + makeWrapper + wrapGAppsHook4 + copyDesktopItems + cargo-tauri + nodejs + ]; + + buildInputs = [ + gtk3 + webkitgtk_4_1 + libsoup_3 + glib-networking + openssl + alsa-lib + libayatana-appindicator + atk + cairo + gdk-pixbuf + glib + pango + librsvg + ] + ++ gstPlugins; + + cargoRoot = "src-tauri"; + cargoDeps = rustPlatform.importCargoLock { + lockFile = cargoLockFile; + # Local path overrides for `[patch.crates-io]` entries in src-tauri/Cargo.toml. + # Keep in sync with that block — importCargoLock needs the source to match + # the lockfile entries for patched crates (otherwise it tries to fetch from + # crates.io and the hash mismatches). + outputHashes = { }; + }; + + dontUseCargoParallelJobs = true; + + env = { + OPENSSL_DIR = "${openssl.dev}"; + OPENSSL_LIB_DIR = "${openssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${openssl.dev}/include"; + VITE_LASTFM_API_KEY = ""; + VITE_LASTFM_API_SECRET = ""; + }; + + # beforeBuildCommand runs npm run build; npmConfigHook supplies offline node_modules + buildPhase = '' + runHook preBuild + export HOME=$(mktemp -d) + (cd src-tauri && cargo tauri build --no-bundle -v) + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + install -Dm755 src-tauri/target/release/psysonic -t $out/bin + install -Dm644 src-tauri/icons/128x128.png $out/share/icons/hicolor/128x128/apps/psysonic.png + runHook postInstall + ''; + + desktopItems = [ + (makeDesktopItem { + name = "psysonic"; + desktopName = "Psysonic"; + comment = "Subsonic-compatible music player"; + icon = "psysonic"; + exec = "psysonic"; + categories = [ "AudioVideo" "Audio" "Player" ]; + }) + ]; + + postFixup = '' + wrapProgram $out/bin/psysonic \ + --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \ + --prefix GST_PLUGIN_PATH : "${gstPluginPath}" \ + --prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \ + --set GDK_BACKEND x11 \ + --set WEBKIT_DISABLE_COMPOSITING_MODE 1 \ + --set WEBKIT_DISABLE_DMABUF_RENDERER 1 + ''; + + meta = { + description = "Desktop music player for Subsonic-compatible servers"; + homepage = "https://github.com/Psychotoxical/psysonic"; + license = lib.licenses.gpl3Only; + mainProgram = "psysonic"; + platforms = lib.platforms.linux; + }; +}) diff --git a/nix/upstream-sources.json b/nix/upstream-sources.json new file mode 100644 index 00000000..62fbec82 --- /dev/null +++ b/nix/upstream-sources.json @@ -0,0 +1,3 @@ +{ + "npmDepsHash": "sha256-0000000000000000000000000000000000000000000=" +}