{ config, lib, pkgs, ... }: let cfg = config.services.radiantwaveUpdater; in { options.services.radiantwaveUpdater = { enable = lib.mkEnableOption "live RadiantWave app updates via a polled manifest"; manifestUrl = lib.mkOption { type = lib.types.str; description = "HTTP(S) URL of the JSON manifest announcing the current app store path."; }; atticUrl = lib.mkOption { type = lib.types.str; description = "Attic cache URL to fetch the app closure from (fetch-only, never builds)."; }; baselinePath = lib.mkOption { type = lib.types.str; description = "Store path of the RadiantWave closure baked into the system closure (from the channel manifest), used to seed the runtime pointer before the first update."; }; }; config = lib.mkIf cfg.enable { # Runtime pointer the login shell launches RadiantWave from. Seeded on # first boot to the exact closure the channel manifest announced (the same # path the updater would fetch), so no spurious first update is triggered. systemd.tmpfiles.rules = [ "L /var/lib/radiantwave/current - - - - ${cfg.baselinePath}" ]; systemd.services.radiantwave-updater = { description = "Poll the RadiantWave manifest and swap the live app pointer"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "oneshot"; # NOTE: no RemainAfterExit here. Keeping the oneshot "active" after # it finishes breaks the timer's OnUnitActiveSec re-arming, so the # 2-min cadence stops after the first run (NEXT goes empty, Trigger # becomes n/a). Let the service return to dead so the timer refires. }; script = '' set -u NEW_PATH=$(${pkgs.curl}/bin/curl -fsSL "${cfg.manifestUrl}" \ | ${pkgs.jq}/bin/jq -er .storePath) || { echo "radiantwave-updater: failed to fetch/parse manifest from ${cfg.manifestUrl}" exit 0 } CURRENT=$(${pkgs.coreutils}/bin/readlink /var/lib/radiantwave/current 2>/dev/null || true) if [ -z "$NEW_PATH" ] || [ "$NEW_PATH" = "$CURRENT" ]; then # Already current; make sure the running closure is GC-rooted. ${pkgs.coreutils}/bin/ln -sfn "$CURRENT" /var/lib/radiantwave/.current-root 2>/dev/null || true echo "radiantwave-updater: already current (''${CURRENT})" exit 0 fi # Realize the closure via the variant's Attic cache plus cache.nixos.org. # nixpkgs deps (e.g. sqlite) are signed by cache.nixos.org and Attic # intentionally skips them to avoid re-uploading the world, so fetch # them from cache.nixos.org and the whole closure resolves without ever # building from source. Trusted public keys come from the system config # (both Attic repos are trusted in configuration.nix). --add-root pins # the fetched closure so GC can't collect it while the swap below makes # it live. echo "radiantwave-updater: acquiring ''${NEW_PATH}" ${pkgs.nix}/bin/nix-store -r "$NEW_PATH" \ --add-root /var/lib/radiantwave/.current-root \ --option extra-substituters "${cfg.atticUrl} https://cache.nixos.org" || { echo "radiantwave-updater: nix copy failed for ''${NEW_PATH}" exit 1 } # Atomic swap: the app never observes a half-updated pointer. ${pkgs.coreutils}/bin/ln -sfn "$NEW_PATH" /var/lib/radiantwave/current.tmp ${pkgs.coreutils}/bin/mv -T /var/lib/radiantwave/current.tmp /var/lib/radiantwave/current echo "radiantwave-updater: swapped ''${CURRENT} -> ''${NEW_PATH}" loginctl terminate-user kiosk ''; }; systemd.timers.radiantwave-updater = { description = "Check for RadiantWave app updates"; wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = "2min"; OnUnitActiveSec = "2min"; Persistent = true; Unit = "radiantwave-updater.service"; }; }; }; }