diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e3545c3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,49 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1793726008, + "narHash": "sha256-jQp1wKvbwWdLBASTXkSi/VYmDJ5AbBi1WvcMt9HWJC8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1d060086d60cb76a40cf37109f979d88a5bd6396", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1793956102, + "narHash": "sha256-dASUovwB3DUoTm6daga0hsNZZAFCCGlUS4FPBjK0OwA=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "74cebce67ef6671ea48fda353a5be2d229bd2920", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "74cebce67ef6671ea48fda353a5be2d229bd2920", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..edee905 --- /dev/null +++ b/flake.nix @@ -0,0 +1,78 @@ +{ + description = "CeleMod - Tauri 2 + React desktop app"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Pinned by rev rather than to HEAD: a floating ref makes every evaluation + # resolve HEAD through the GitHub API, which rate-limits with 403 even + # though flake.lock already pins this rev. + rust-overlay = { + url = "github:oxalica/rust-overlay/74cebce67ef6671ea48fda353a5be2d229bd2920"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, rust-overlay }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); + + # rust-overlay is needed for the build too, not just the devShell: the + # crate uses #![feature(try_blocks)], so rustPlatform must be nightly. + pkgsFor = system: + import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + config = { }; + }; + + # packages. holds derivations only; `nix flake check` rejects + # anything else it finds there. + packagesFor = system: + let + pkgs = pkgsFor system; + fromOverlay = self.overlays.default pkgs pkgs; + in + { + inherit (fromOverlay) celemod celemod-ui; + default = fromOverlay.celemod; + }; + in + { + overlays.default = final: prev: import ./nix/lib.nix { pkgs = final; }; + + packages = forAllSystems packagesFor; + + devShells = forAllSystems (system: + { + default = import ./nix/devshell.nix { pkgs = pkgsFor system; }; + ci = import ./nix/devshell.nix { pkgs = pkgsFor system; mode = "ci"; }; + }); + + formatter = forAllSystems (system: (pkgsFor system).nixfmt); + + checks = forAllSystems (system: + let + pkgs = pkgsFor system; + packages = packagesFor system; + in + { + celemod-version = pkgs.runCommand "celemod-version-check" { } '' + test "$(cat ${packages.celemod.version})" = "$(cat ${./version.txt})" + touch $out + ''; + + # Guards against the packages silently collapsing to runCommand + # placeholders, which `nix flake check --no-build` cannot tell apart + # from a real derivation. Assert on the build inputs rather than + # passthru.placeholder: a plain runCommand never sets that attribute. + celemod-is-real = pkgs.runCommand "celemod-real-check" { } '' + test "${toString (builtins.any (d: builtins.match ".*(rust|cargo).*" (d.name or "") != null) (packages.celemod.nativeBuildInputs or [ ]))}" = "1" + test "${toString (builtins.length (packages.celemod-ui.nativeBuildInputs or [ ]))}" != "0" + test "${if packages.celemod-ui ? pnpmDeps then "1" else "0"}" = "1" + touch $out + ''; + }); + }; +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 0000000..cca0520 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,190 @@ +# CeleMod Nix build + +A flake providing `packages.celemod`, `packages.celemod-ui`, `overlays.default` +and `devShells.{default,ci}` for `x86_64-linux` and `aarch64-linux`. + +```bash +nix build .#celemod # -> $out/bin/cele-mod +nix develop # rust nightly, node 22, pnpm 11, GTK/WebKit +nix flake check # evaluation + real-package guard +``` + +## Layout + +``` +flake.nix inputs, packages, overlay, devShells, checks +flake.lock pins nixpkgs + rust-overlay; commit this +nix/lib.nix overlay body: toolchain selection, wires tools.nix +nix/tools.nix frontend and Tauri derivations +nix/devshell.nix devShells +nix/versions.nix every pinned hash +nix/hooks/ optional sourced shell helpers +scripts/update-nix-hashes.sh +``` + +> `nix/` must stay git-tracked. A flake in a git repository only sees tracked +> files, so an untracked `nix/` fails with +> `error: getting status of '.../nix/lib.nix': No such file or directory` +> even though the file is on disk. + +## How the build works + +Two derivations, both `x86_64-linux`: + +| attribute | what | output | +|---|---|---| +| `mkCelemodUi` | Vite/React frontend built with pnpm | `$out/dist/` | +| `mkCelemod` | Tauri 2 shell via `rustPlatform.buildRustPackage` | `$out/bin/cele-mod` | + +1. `fetchPnpmDeps` snapshots `src/celemod-ui/pnpm-lock.yaml` into a + fixed-output derivation. +2. `mkCelemodUi` builds the site offline from that store. +3. `mkCelemod` builds the Rust workspace and its `postPatch` copies the + finished `dist/` into `src/celemod-ui/dist/`, so `tauri-build` finds a + populated `frontendDist` without running node or a dev server. + +The Rust toolchain must be nightly: `src-tauri/src/lib.rs` uses +`#![feature(try_blocks)]`. `lib.nix` takes it from rust-overlay's +`rust-bin.nightly.latest.default`, which is a single derivation bundling cargo, +rustc and std. The component attrs do not work -- `.rustc` alone ships no std +sysroot and fails with `E0463: can't find crate for std`. + +## Pinned inputs and hashes + +`flake.lock` pins both inputs. `rust-overlay` is referenced by full rev rather +than a floating branch: a floating ref makes every evaluation hit the GitHub +API to resolve HEAD, which fails with a rate-limit `403` even though the lock +already pins the rev. Keep the URL in `flake.nix` and the locked rev in +`flake.lock` in sync. + +`nix/versions.nix` holds three hashes, all produced by real fetch failures: + +| key | covers | +|---|---| +| `cargoHash` | all 620 locked crates (619 registry + 1 git) | +| `gameScanner.hash` | the pinned `game-scanner` git dependency | +| `pnpm.depsHash` | the pnpm store for `src/celemod-ui` | + +`pnpm.fetcherVersion` is `4`. nixpkgs 26.11 removed versions 1 and 2, and the +locked nixpkgs accepts 3, 4 or 5; 4 is what the hash was produced with. + +## Refreshing hashes + +```bash +./scripts/update-nix-hashes.sh --dry-run # resolve and print, write nothing +./scripts/update-nix-hashes.sh # rewrite nix/versions.nix +``` + +The script sets each hash to a deliberate fake, builds, and reads the +`got: sha256-...` line back. Nothing is guessed. It pins nixpkgs from the +committed `flake.lock`, so it can never hash against unstable HEAD. + +After writing it validates every digest, asserts that each value equals the one +just measured, and restores the backup on failure. A second run against +unchanged inputs is byte-identical. + +To bump the app version, update `version.txt` (and `src-tauri/Cargo.toml`'s +`version` to match), then re-run the script if `Cargo.lock` changed. + +## Build notes + +These are the non-obvious parts of the derivations. Each one is a real +constraint of this nixpkgs or this repository, not a workaround. + +### `cargoHash` is top-level; `cargoLock` must be absent + +`buildRustPackage` builds `cargoDeps` from `cargoLock` whenever that argument +is present at all, silently ignoring a top-level `cargoHash`. That routes +through `importCargoLock`, which needs `outputHashes` for the git dependency: + +```text +error: No hash was found while vendoring the git dependency game-scanner-1.2.0 +``` + +Supplying only `cargoHash` routes through `fetchCargoVendor` instead, which +handles the git dependency and needs no `outputHashes`. So `tools.nix` passes +no `cargoLock` at all. (`cargoHash` inside `cargoLock` is a separate, harder +failure: `unexpected argument 'cargoHash'` at `import-cargo-lock.nix:76`.) + +### No `cargoRoot` + +`cargoRoot` also drives vendor staging, so it would stage only `src-tauri/`, +which has no `Cargo.lock`: + +```text +FileNotFoundError: [Errno 2] No such file or directory: 'Cargo.lock' +``` + +This repository is a cargo workspace whose `Cargo.toml` and `Cargo.lock` live +at the root, so vendoring runs from the root and the build subdirectory is +selected with `buildAndTestSubdir = "src-tauri"`. + +### `CMAKE_POLICY_VERSION_MINIMUM = 3.5` + +`prost-build 0.10.4` vendors protobuf and runs CMake on a pre-3.5 +`CMakeLists.txt`, which modern CMake rejects. Upstream CI sets the same escape +hatch: + +```text +CMake Error at CMakeLists.txt:2 (cmake_minimum_required): + Compatibility with CMake < 3.5 has been removed from CMake. +``` + +### The pnpm deps snapshot needs `src/celemod-ui` + +`mkCelemodUi` takes the repository root as `src`, but the dependency snapshot +must be taken from the subdirectory that holds the lockfile: + +```nix +pnpmDeps ? celemodUiDeps (src + "/src/celemod-ui") +``` + +Passing the root produces a tarball with no celemod-ui packages, and the later +install falls back to the network and dies inside the sandbox. + +### Do not reassign `HOME` in `buildPhase` + +`pnpmConfigHook` writes the store mapping into `HOME`'s pnpm config, and pnpm's +pre-run dependency check reads it back. Moving `HOME` to `$TMPDIR` discards +that and makes pnpm re-run an online install after a successful offline one. +For the same reason `buildPhase` sets +`pnpm_config_verify_deps_before_run=false`. + +### Tauri and `frontendDist` + +`tauri-build` does not fail when `frontendDist` is missing -- it silently +produces a binary with no embedded UI. `dist/` is gitignored, so the derivation +must populate `src/celemod-ui/dist/` before cargo runs. `TAURI_SKIP_DEVSERVER_BUILD=1` +prevents any attempt to start a dev server. + +## The real-package guard + +`checks..celemod-is-real` fails the build if either package stops being +a real derivation. This matters because `nix flake check --no-build` cannot +tell a real derivation from an empty `runCommand` stub -- a stub is a valid +derivation, so the check would pass green. + +The guard reads `nativeBuildInputs` and `pnpmDeps` rather than +`passthru.placeholder`: a plain `runCommand` never sets that attribute, so +testing it would pass on a genuine placeholder. + +## Developer shells + +`devShells.default` provides rust nightly with `rust-src`, `rustfmt`, `clippy` +and `rust-analyzer`; node 22; pnpm 11; and the full GTK/WebKit stack, plus the +`GIO_*`, `GDK_PIXBUF_*`, `XDG_DATA_DIRS` and `LD_LIBRARY_PATH` variables a Tauri +app needs at runtime. `devShells.ci` drops the interactive extras. + +pnpm is pinned to `pnpm_11` because `package.json` declares +`packageManager: pnpm@11.13.1`; `pkgs.pnpm` tracks 12.x. + +Inside a Nix `''` string, `${...}` is interpolation, so shell parameter +expansions in `shellHook` must be written with a doubled apostrophe, as in +`''${VAR:-default}`. Getting this wrong is a syntax error, not a warning. + +### CI parity + +`.github/workflows/Build.yml` installs `webkit2gtk-4.1`, `libappindicator3`, +`librsvg2`, `patchelf` and `zsync` on Linux. The devShell carries the first +four; `zsync` is intentionally left out because it is only needed by the +AppImage bundler, not by compilation or `nix build`. diff --git a/nix/devshell.nix b/nix/devshell.nix new file mode 100644 index 0000000..d2165c4 --- /dev/null +++ b/nix/devshell.nix @@ -0,0 +1,97 @@ +# Developer shells. +# +# nix develop .#default nightly rust + node/pnpm + GTK/WebKit +# nix develop .#ci the same shell without interactive conveniences +# +# `pkgs` must carry rust-overlay, or pkgs.rust-bin does not exist. +{ pkgs, mode ? "default" }: +let + lib = pkgs.lib; + isCi = mode == "ci"; + + # rust-toolchain.toml pins channel = "nightly". Extensions are what cargo fmt, + # cargo clippy and rust-analyzer need in order to work. + rust = pkgs.rust-bin.selectLatestNightlyWith (toolchain: + toolchain.default.override { + extensions = [ "rust-src" "rustfmt" "clippy" "rust-analyzer" ]; + }); + + # package.json says packageManager pnpm@11.13.1 and the lockfile is 9.0, + # so pnpm_11 -- NOT pkgs.pnpm, which tracks 12.x in nixpkgs 26.11. + pnpm = pkgs.pnpm_11; + + nativeBuildInputs = with pkgs; [ + pkg-config + cmake + patchelf + glib + gtk3 + webkitgtk_4_1 + libsoup_3 + librsvg + libayatana-appindicator + desktop-file-utils + xdg-utils + ]; + + buildInputs = with pkgs; [ + openssl + glib-networking + gsettings-desktop-schemas + dconf + libappindicator-gtk3 + webkitgtk_4_1 + gtk3 + libsoup_3 + librsvg + ]; + + # --------------------------------------------------------------------- + # Runtime env for the GTK/WebKit stack. + # + # Escaping: inside a Nix '' string ${...} is Nix interpolation, so shell + # parameter expansions must be written with a doubled apostrophe, as in + # ''${VAR:-default}. Getting this wrong is a hard syntax error. + # --------------------------------------------------------------------- + gioModulePath = + lib.makeSearchPath "lib/gio/modules" [ pkgs.glib.out pkgs.glib-networking pkgs.dconf ]; + gdkPixbufModulePath = + lib.makeSearchPath "lib/gdk-pixbuf-2.0/2.10.0/loaders" [ pkgs.librsvg pkgs.gdk-pixbuf ]; + + shellHook = '' + if [ -n "''${CELEMOD_SHELL_QUIET:-}" ]; then + return 0 2>/dev/null || true + fi + + export GIO_EXTRA_MODULES="${gioModulePath}" + export GIO_MODULE_DIR="${pkgs.glib-networking}/lib/gio/modules" + export GDK_PIXBUF_MODULE_PATH="${gdkPixbufModulePath}" + export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share:${pkgs.gtk3}/share:${pkgs.shared-mime-info}/share:''${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" + export LD_LIBRARY_PATH="${lib.makeLibraryPath buildInputs}:''${LD_LIBRARY_PATH:-}" + # Several vendored CMakeLists predate CMake 4; without this they hard-error. + export CMAKE_POLICY_VERSION_MINIMUM="''${CMAKE_POLICY_VERSION_MINIMUM:-3.5}" + # corepack must never try to download a pnpm shim during activation. + export COREPACK_ENABLE_AUTO_PIN=0 + export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + ''; +in +pkgs.mkShell { + name = if isCi then "celemod-ci" else "celemod-devenv"; + + inherit nativeBuildInputs buildInputs shellHook; + + packages = [ + rust + pkgs.nodejs_22 + pnpm + pkgs.corepack_22 + ] ++ lib.optionals (!isCi) [ + pkgs.cargo-tauri + pkgs.rust-analyzer + ]; + + # Deliberately plain: `nix develop --command bash -c ...` must work, so the + # hook is idempotent and never changes the caller error policy. + RUST_BACKTRACE = "1"; + CARGO_TERM_COLOR = "always"; +} diff --git a/nix/hooks/celemod.sh b/nix/hooks/celemod.sh new file mode 100755 index 0000000..ca8143e --- /dev/null +++ b/nix/hooks/celemod.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# CeleMod devshell helper -- idempotent, safe to source repeatedly. +if [ "${CELEMOD_HOOK_SOURCED:-0}" = "1" ]; then + return 0 2>/dev/null || true +fi +CELEMOD_HOOK_SOURCED=1 +export CELEMOD_HOOK_SOURCED +echo "CeleMod devshell: rust $(rustc --version 2>/dev/null | cut -d' ' -f2), node $(node --version 2>/dev/null), pnpm $(pnpm --version 2>/dev/null)" + diff --git a/nix/hooks/ci.sh b/nix/hooks/ci.sh new file mode 100755 index 0000000..f00df73 --- /dev/null +++ b/nix/hooks/ci.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# CeleMod CI helper -- sourced by devShells.ci. Must stay non-interactive. +# No `set -e`: this is sourced, so it must not change the caller error policy. + +export CI="${CI:-1}" +export CELEMOD_CI=1 +export COREPACK_ENABLE_AUTO_PIN=0 +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + +# Deterministic, colourless output in CI logs. +export CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-never}" +export RUST_BACKTRACE="${RUST_BACKTRACE:-1}" diff --git a/nix/lib.nix b/nix/lib.nix new file mode 100644 index 0000000..5fc2e27 --- /dev/null +++ b/nix/lib.nix @@ -0,0 +1,117 @@ +# lib.nix -- the body of `overlays.default`. +# +# overlays.default = final: prev: import ./nix/lib.nix { pkgs = final; }; +# +# Only derivations are exported: `nix flake check` rejects a non-derivation +# under packages..*, so a bare `version` string must not live here. +{ + # `final` when used as an overlay body, so pkgs.celemod is self-consistent. + pkgs, + lib ? pkgs.lib, + # overridable for tests; defaults to the repository root + root ? ./.., +}: +let + # -------------------------------------------------------------------------- + # Version: version.txt at the repository root is the single source of truth. + # readFile is pure eval (no IFD); surrounding whitespace is trimmed so that + # nix eval --raw .#packages.x86_64-linux.celemod.version + # prints exactly 1.2.2. + # -------------------------------------------------------------------------- + versionFromFile = + let raw = builtins.readFile (root + "/version.txt"); + in lib.strings.trim raw; + + version = + let r = builtins.tryEval versionFromFile; + in if r.success && r.value != "" then r.value else "0.0.0-unreleased"; + + # -------------------------------------------------------------------------- + # Pinned, machine-refreshable data. Never required: every consumer below has + # a fallback, so a missing or empty versions.nix still EVALUATES. A bad hash + # then fails at fetch time, which is the correct place for that failure. + # -------------------------------------------------------------------------- + versions = + let r = builtins.tryEval (import ./versions.nix); + in if r.success && builtins.isAttrs r.value then r.value else { }; + + # -------------------------------------------------------------------------- + # src-tauri/src/lib.rs is #![feature(try_blocks)], so the compiler must be + # nightly; rust-overlay provides it. + # + # Use the merged `.default` toolchain for BOTH makeRustPlatform slots. The + # component attrs do not work: `selectLatestNightlyWith` returns a package + # with no .cargo/.rustc, and the standalone `.rustc` ships no std sysroot + # (E0463: can't find crate for `std`). `.default` bundles cargo + rustc + + # std and already carries the targetPlatforms passthru buildRustPackage + # reads. + rustToolchain = + if pkgs ? rust-bin then + pkgs.rust-bin.nightly.latest.default + else + throw "CeleModBuild: nightly Rust required (src-tauri/src/lib.rs uses #![feature(try_blocks)]); import nixpkgs with rust-overlay or use this flake outputs"; + + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; + + tools = import ./tools.nix { + inherit lib rustPlatform; + inherit (pkgs) + stdenv + fetchPnpmDeps + pnpmConfigHook + pnpm_11 + nodejs_22 + cargo-tauri + wrapGAppsHook4; + }; + + # Linux GUI stack for the Tauri build. Passed in as inputs rather than + # referenced inside tools.nix, so that module stays pkgs-agnostic. + tauriNativeInputs = with pkgs; [ + pkg-config + cmake + patchelf + glib + gtk3 + webkitgtk_4_1 + libsoup_3 + librsvg + libayatana-appindicator + desktop-file-utils + xdg-utils + ]; + + tauriBuildInputs = with pkgs; [ + openssl + glib-networking + gsettings-desktop-schemas + dconf + libappindicator-gtk3 + webkitgtk_4_1 + gtk3 + libsoup_3 + librsvg + ]; +in +{ + celemod-ui = tools.mkCelemodUi { + src = root; + inherit version; + }; + + celemod = tools.mkCelemod ( + { + src = root; + inherit version; + nativeBuildInputs = tauriNativeInputs; + buildInputs = tauriBuildInputs; + } + # Only inject cargoHash when the sidecar actually has one. Passing null + # would fall back to importCargoLock, which needs outputHashes for the + # pinned game-scanner git dependency. + // lib.optionalAttrs (versions ? cargoHash) { inherit (versions) cargoHash; } + ); +} diff --git a/nix/tools.nix b/nix/tools.nix new file mode 100644 index 0000000..e4f0503 --- /dev/null +++ b/nix/tools.nix @@ -0,0 +1,184 @@ +# Package derivations for CeleMod. +# +# mkCelemodUi -> the Vite dist/ tree as its own derivation +# mkCelemod -> rustPlatform.buildRustPackage; $out/bin/cele-mod +# +# lib.nix wires these together; versions.nix holds every hash. +# +# rustPlatform must be a nightly toolchain: src-tauri/src/lib.rs uses +# #![feature(try_blocks)], which a stable rustc rejects with E0554. +{ lib +, stdenv +, rustPlatform +, pnpm_11 +, pnpmConfigHook +, nodejs_22 +, fetchPnpmDeps +, cargo-tauri +, wrapGAppsHook4 +}: + +let + versions = import ./versions.nix; + + # version.txt at the repository root is the single source of truth. + repoVersion = versions.version; +in +rec { + inherit repoVersion; + + # ------------------------------------------------------------------------- + # Frontend: pnpm dependency snapshot (fixed output, network on first build) + # ------------------------------------------------------------------------- + # nixpkgs 26.11 has no pkgs.pnpm.fetchDeps; the fetcher is the top-level + # pkgs.fetchPnpmDeps, which requires fetcherVersion. Versions 1 and 2 were + # removed; the value used here lives in versions.nix. + celemodUiDeps = src: fetchPnpmDeps { + pname = "celemod-ui-deps"; + version = repoVersion; + inherit src; + pnpm = pnpm_11; + fetcherVersion = versions.pnpm.fetcherVersion; + hash = versions.pnpm.depsHash; + }; + + # ------------------------------------------------------------------------- + # Frontend: build the Vite dist/ tree + # ------------------------------------------------------------------------- + mkCelemodUi = + { src +, pname ? "celemod-ui" +, version ? repoVersion + # The pnpm lockfile lives in src/celemod-ui, so the dependency snapshot + # must be taken from that subdirectory; the root has no lockfile. + , pnpmDeps ? celemodUiDeps (src + "/src/celemod-ui") + }: + stdenv.mkDerivation { + inherit pname version src; + + nativeBuildInputs = [ nodejs_22 pnpm_11 pnpmConfigHook ]; + + inherit pnpmDeps; + pnpmRoot = "src/celemod-ui"; + + # A checkout may carry stale artefacts from a local dev run; pnpm aborts + # on node_modules and a stale dist would mask a broken build. + postPatch = '' + rm -rf src/celemod-ui/node_modules src/celemod-ui/dist + ''; + + buildPhase = '' + runHook preBuild + export CI=true + + # Do not reassign HOME: pnpmConfigHook writes the store mapping into + # HOME's pnpm config and pnpm's pre-run check reads it back. Moving HOME + # makes pnpm re-run an online install after a successful offline one. + export pnpm_config_verify_deps_before_run=false + + cd src/celemod-ui + pnpm run build + cd ../.. + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r src/celemod-ui/dist $out/dist + runHook postInstall + ''; + + meta = { + description = "CeleMod frontend (Vite/React) build output"; + inherit version; + platforms = lib.platforms.unix; + }; + }; + + # ------------------------------------------------------------------------- + # Application: Tauri 2 shell built over the whole repository + # ------------------------------------------------------------------------- + # Build through cargo-tauri's hook rather than a bare buildRustPackage: the + # hook is what runs tauri-build with the custom-protocol feature, so the + # frontend is embedded instead of being fetched from build.devUrl at runtime. + # + # Pass buildSubdir, not cargoRoot: cargoRoot also drives fetchCargoVendor + # staging, which would then look for Cargo.lock inside src-tauri/. This repo + # is a workspace whose Cargo.lock sits at the root. + mkCelemod = + { src +, pname ? "cele-mod" +, version ? repoVersion + , buildSubdir ? "src-tauri" + , cargoRoot ? "." +, cargoHash ? versions.cargoHash +, frontend ? mkCelemodUi { inherit src; } +, nativeBuildInputs ? [ ] +, buildInputs ? [ ] +, doCheck ? false + }: + let + # cargo-tauri would otherwise re-run `pnpm build` itself through + # beforeBuildCommand; the UI is already built by the frontend derivation. + tauriOverrides = builtins.toFile "tauri-overrides.json" ( + builtins.toJSON { build.beforeBuildCommand = ""; } + ); + in + rustPlatform.buildRustPackage { + inherit pname version src cargoRoot doCheck; + + # cargoHash must be TOP-LEVEL and cargoLock must be ABSENT. + # + # buildRustPackage prefers cargoLock over cargoHash whenever cargoLock is + # set, even alongside a cargoHash; it then routes through importCargoLock, + # which demands outputHashes for the pinned git dependency: + # No hash was found while vendoring the git dependency game-scanner-1.2.0 + # With cargoHash alone, fetchCargoVendor vendors the git dependency too. + inherit cargoHash; + + # wrapGAppsHook4 wires XDG_DATA_DIRS and the GIO/GDK module paths that a + # GTK app needs at runtime; without it the window renders unstyled. + # + # No pnpmConfigHook here: the UI already arrives as a finished + # derivation via `frontend`, and the hook insists on a pnpmDeps it has + # no use for in this derivation. + nativeBuildInputs = [ cargo-tauri.hook wrapGAppsHook4 ] + ++ nativeBuildInputs; + inherit buildInputs; + + tauriBuildFlags = [ "-c" tauriOverrides ]; + + # The frontend derivation owns the UI build; copy the result in so + # tauri-build finds a populated frontendDist. + postPatch = '' + rm -rf src/celemod-ui/dist + mkdir -p src/celemod-ui/dist + cp -r ${frontend}/dist/. src/celemod-ui/dist/ + chmod -R u+w src/celemod-ui/dist + ''; + + preBuild = '' + export TAURI_SKIP_DEVSERVER_BUILD=1 + ''; + + buildAndTestSubdir = buildSubdir; + cargoBuildFlags = [ "--bin" pname ]; + + # prost-build 0.10.4 vendors protobuf and runs CMake on a pre-3.5 + # CMakeLists.txt, which modern CMake rejects. Upstream CI sets the same + # escape hatch. + env = { + CMAKE_POLICY_VERSION_MINIMUM = "3.5"; + }; + + meta = { + description = "CeleMod - Celeste mod manager (Tauri 2 + React)"; + inherit version; + homepage = "https://github.com/std-microblock/CeleMod"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + mainProgram = pname; + }; + }; +} diff --git a/nix/versions.nix b/nix/versions.nix new file mode 100644 index 0000000..6086090 --- /dev/null +++ b/nix/versions.nix @@ -0,0 +1,37 @@ +# Pinned, machine-refreshable data for the CeleMod build. +# +# Refreshed by scripts/update-nix-hashes.sh; see nix/README.md. Every hash +# came from a real fixed-output build failure, never from a guess. lib.nix +# tolerates missing attributes, so this file may be reduced to `{ }` and the +# flake still evaluates. +{ + # ---------------- source ---------------- + # version.txt is the single source of truth; lib.nix reads it directly with + # builtins.readFile. This value is the fallback for evaluating the file + # standalone. + version = "1.2.2"; + + # ---------------- Rust ---------------- + # Covers all 620 locked crates (619 registry + 1 git). Keep this TOP-LEVEL + # in buildRustPackage: it only takes effect when cargoLock is absent. + cargoHash = "sha256-xbEoLgpx0vQ0MO79KtVaGyxbg+bO7uTedqT6pmX4OGQ="; + + # The one git dependency in Cargo.lock: + # git+https://github.com/EqualGames/game-scanner?branch=dependabot%2Fcargo%2Fsysinfo-0.28.2#b476668896d3f9496c858fa3c9b3f97735b3837c + gameScanner = { + owner = "EqualGames"; + repo = "game-scanner"; + rev = "b476668896d3f9496c858fa3c9b3f97735b3837c"; + hash = "sha256-B4BhhI7yc9YI+11d4KoDZeQoH+WJwysVIm5MozWU9dc="; + }; + + # ---------------- Frontend (pnpm) ---------------- + # src/celemod-ui/pnpm-lock.yaml is lockfileVersion 9.0 and package.json pins + # packageManager pnpm@11.13.1, so the build uses pkgs.pnpm_11 rather than + # pkgs.pnpm. nixpkgs 26.11 removed fetcherVersion 1 and 2; this value is one + # of the versions the locked nixpkgs still accepts. + pnpm = { + fetcherVersion = 4; + depsHash = "sha256-jvYPUmkbi/rnRR7Sxwv+Z25voMUr5LlSsZKNtPB/R1s="; + }; +} \ No newline at end of file diff --git a/scripts/update-nix-hashes.sh b/scripts/update-nix-hashes.sh new file mode 100755 index 0000000..7ce7942 --- /dev/null +++ b/scripts/update-nix-hashes.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# Refresh every hash in nix/versions.nix. +# +# * Hashes are never guessed: each is read back from the got-sha256 line of a +# deliberate fakeHash build failure, i.e. a real derivation of the real input. +# * Idempotent: re-running against unchanged inputs rewrites the file +# byte-identically, so `git diff --exit-code nix/versions.nix` stays clean. +# * nixpkgs is pinned from the committed flake.lock, never resolved from the +# registry or unstable HEAD. +# +# Usage: scripts/update-nix-hashes.sh [--repo-root DIR] [--dry-run] +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${CELEMOD_REPO_ROOT:-$(dirname "$HERE")}" +if [ ! -f "$REPO_ROOT/flake.nix" ]; then + echo "ERROR: $REPO_ROOT does not look like the CeleMod checkout." >&2 + echo " Pass --repo-root DIR or set CELEMOD_REPO_ROOT." >&2 + exit 1 +fi +DRY_RUN=0 +while [ $# -gt 0 ]; do + case "$1" in + --repo-root) REPO_ROOT="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) sed -n "2,16p" "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +OUT_DIR="$REPO_ROOT/nix" +VERSIONS="$OUT_DIR/versions.nix" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +[ -f "$VERSIONS" ] || { echo "missing $VERSIONS" >&2; exit 1; } +mkdir -p "$WORK" + +# Optional: point nix at a throwaway HOME/cache (useful in sandboxes whose +# real HOME is not writable). +if [ -n "${CELEMOD_NIX_HOME:-}" ]; then + mkdir -p "$CELEMOD_NIX_HOME" + export HOME="$CELEMOD_NIX_HOME" + export XDG_CACHE_HOME="$HOME/.cache" XDG_STATE_HOME="$HOME/.state" TMPDIR="$HOME" + mkdir -p "$XDG_CACHE_HOME" "$XDG_STATE_HOME" +fi +NIX=(nix --extra-experimental-features "nix-command flakes") + +echo "== repo root : $REPO_ROOT" +echo "== versions : $VERSIONS" + +# Pin nixpkgs from the committed flake.lock; never from the registry. +NIXPKGS_REV="$(sed -n "s/.*\"rev\": \"\([0-9a-f]\{40\}\)\".*/\1/p" "$REPO_ROOT/flake.lock" | head -1)" +NIXPKGS_NAR="$(sed -n "s/.*\"narHash\": \"\(sha256-[^\"]*\)\".*/\1/p" "$REPO_ROOT/flake.lock" | head -1)" +if [ -z "$NIXPKGS_REV" ] || [ -z "$NIXPKGS_NAR" ]; then + echo "ERROR: cannot read nixpkgs rev/narHash from $REPO_ROOT/flake.lock" >&2; exit 1 +fi +echo "== nixpkgs : $NIXPKGS_REV" +echo "== narHash : $NIXPKGS_NAR" + +cat > "$WORK/nixpkgs-locked.nix" < resolving $label" >&2 + if "${NIX[@]}" build --impure --no-link --no-write-lock-file \ + --print-out-paths --expr "(import $expr)" >"$log" 2>&1; then + echo " $label already builds with the recorded hash; unchanged." >&2 + return 0 + fi + got="$(grep -oE 'got: +sha256-[A-Za-z0-9+/=]+' "$log" | tail -1 | sed 's/.*got: *//')" + if [ -z "$got" ]; then + echo "ERROR: no got-sha256 line for $label. Raw log:" >&2; tail -40 "$log" >&2; return 1 + fi + echo " $label -> $got" >&2 + printf "%s=%s\n" "$varname" "$got" >> "$PINS" +} + +cat > "$WORK/gs.nix" < "$WORK/pnpm.nix" < "$WORK/cargo.nix" <' provenance comments are refreshed to match. +python3 - "$VERSIONS" "$PINS" "$DRY_RUN" "$OUT_DIR/rewrite.py" </dev/null && echo "eval OK" \ No newline at end of file