From a71c3d3405ed4f4b678e23249bcfd6c191be2ab5 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 03:33:05 -0700 Subject: [PATCH 01/10] feat(nix): add flake with binary package and NixOS module NixOS users could not run Hypercolor at all: the release binaries expect a conventional dynamic loader path, and both installers assume sudo-managed udev rules under /etc. The flake wraps the per-architecture Linux release tarball the same way packaging/aur does, re-linking the ELF binaries against nixpkgs with autoPatchelf, and ships a NixOS module so a single services.hypercolor.enable = true installs the package, the udev rules, the i2c-dev kernel module, and a hardened systemd user service. nix/release.json pins the version and per-architecture checksums so the flake stays usable at every commit; the release pipeline refreshes it. Libraries the daemon opens at runtime (EGL, GL, Vulkan, Wayland, xkbcommon) and the tray's appindicator land on the RUNPATH, and addDriverRunpath makes /run/opengl-driver visible for GPU compositing. A from-source derivation is deliberately out of scope: the daemon's default feature set builds Servo and SpiderMonkey, which is hours of compile behind a build script that expects network access. Verified on an aarch64 Ubuntu VM with nix 2.35: nix flake check passes, the built daemon reports healthy with all 56 bundled effects and serves the UI. --- .gitignore | 4 + flake.lock | 27 +++++++ flake.nix | 94 +++++++++++++++++++++++ nix/module.nix | 151 +++++++++++++++++++++++++++++++++++++ nix/package.nix | 190 +++++++++++++++++++++++++++++++++++++++++++++++ nix/release.json | 7 ++ 6 files changed, 473 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/module.nix create mode 100644 nix/package.nix create mode 100644 nix/release.json diff --git a/.gitignore b/.gitignore index 2692e4356..ee176bf50 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,7 @@ site/out/ .agents/scheduled_tasks.lock .agents/settings.local.json drafts-browser.html + +# Nix build outputs +result +result-* diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..7794f1a5c --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789149629, + "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..d706916f7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,94 @@ +{ + description = "Hypercolor: open-source RGB lighting orchestration engine"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forEachSystem = nixpkgs.lib.genAttrs systems; + pkgsFor = system: nixpkgs.legacyPackages.${system}; + in + { + overlays.default = final: _prev: { + hypercolor = final.callPackage ./nix/package.nix { }; + }; + + packages = forEachSystem ( + system: + let + hypercolor = (pkgsFor system).callPackage ./nix/package.nix { }; + in + { + inherit hypercolor; + default = hypercolor; + } + ); + + nixosModules = { + hypercolor = + { pkgs, ... }: + { + imports = [ ./nix/module.nix ]; + services.hypercolor.package = + nixpkgs.lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + default = self.nixosModules.hypercolor; + }; + + checks = forEachSystem ( + system: + let + # Evaluate the module against a minimal host and build only the + # user unit, so `nix flake check` proves the options and the unit + # text without assembling a whole system closure. + host = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + self.nixosModules.default + { + services.hypercolor = { + enable = true; + input.allDevices = true; + extraArgs = [ + "--log-level" + "debug" + ]; + }; + fileSystems."/" = { + device = "/dev/null"; + fsType = "ext4"; + }; + boot.loader.grub.enable = false; + system.stateVersion = "25.05"; + } + ]; + }; + pkgs = pkgsFor system; + hypercolor = self.packages.${system}.default; + unit = host.config.systemd.user.units."hypercolor.service".unit; + in + { + package = hypercolor; + module = + assert builtins.elem hypercolor host.config.services.udev.packages; + assert builtins.elem "i2c-dev" host.config.boot.kernelModules; + assert builtins.elem "default.target" host.config.systemd.user.services.hypercolor.wantedBy; + pkgs.runCommand "hypercolor-module-check" { } '' + unit=${unit}/hypercolor.service + grep -q -- "--ui-dir ${hypercolor}/share/hypercolor/ui" "$unit" + grep -q -- "--effects-dir ${hypercolor}/share/hypercolor/effects/bundled" "$unit" + grep -q -- "--log-level debug" "$unit" + grep -q "^ProtectSystem=strict" "$unit" + grep -q "^HYPERCOLOR_LOG=info" "$unit" || grep -q 'HYPERCOLOR_LOG=info' "$unit" + touch $out + ''; + } + ); + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 000000000..fcd12530d --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,151 @@ +# NixOS module for Hypercolor. +# +# services.hypercolor.enable = true; +# +# installs the package, the vendor udev rules, the i2c-dev kernel module for +# SMBus discovery, and a hardened systemd user service that starts the daemon +# with every graphical login. The service is a *user* unit on purpose: screen +# capture goes through the XDG desktop portal and host input capture relies +# on logind uaccess ACLs, both of which only exist inside a user session. +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.hypercolor; + inherit (lib) + mkEnableOption + mkOption + mkPackageOption + mkIf + mkDefault + types + escapeShellArgs + ; + + allInputRules = pkgs.writeTextDir "lib/udev/rules.d/70-hypercolor-input-all.rules" ( + builtins.readFile ../udev/70-hypercolor-input-all.rules + ); +in +{ + options.services.hypercolor = { + enable = mkEnableOption "Hypercolor RGB lighting daemon"; + + package = mkPackageOption pkgs "hypercolor" { }; + + autoStart = mkOption { + type = types.bool; + default = true; + description = '' + Start the daemon with every graphical login by wanting the user + service from `default.target`. Disable to keep the unit installed + but only start it on demand (`systemctl --user start hypercolor`). + ''; + }; + + logLevel = mkOption { + type = types.enum [ + "error" + "warn" + "info" + "debug" + "trace" + ]; + default = "info"; + description = "Value of `HYPERCOLOR_LOG` for the daemon service."; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "--bind" + "0.0.0.0:9420" + ]; + description = "Additional command-line arguments passed to `hypercolor-daemon`."; + }; + + smbus.enable = mkOption { + type = types.bool; + default = true; + description = '' + Load the `i2c-dev` kernel module so the daemon can discover SMBus + RGB controllers (motherboard headers, DRAM). The matching device-node + permissions ship in the package's udev rules. + ''; + }; + + input.allDevices = mkOption { + type = types.bool; + default = false; + description = '' + Grant the seated user read access to *every* keyboard and mouse + event node, not only the supported RGB vendors. This lets effects + react to laptop-internal keyboards, Bluetooth keyboards, and generic + mice, but it is equivalent to permitting session-wide keylogging by + any process running as that user. Leave it off unless you have + weighed that trade-off. + ''; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + # 99-hypercolor.rules (hidraw, usb, tty, i2c-dev) and the vendor-scoped + # 70-hypercolor-input.rules ship inside the package. + services.udev.packages = [ cfg.package ] ++ lib.optional cfg.input.allDevices allInputRules; + + boot.kernelModules = mkIf cfg.smbus.enable [ "i2c-dev" ]; + + systemd.user.services.hypercolor = { + description = "Hypercolor RGB Lighting Daemon"; + documentation = [ "https://github.com/hyperb1iss/hypercolor" ]; + after = [ + "graphical-session.target" + "dbus.socket" + ]; + wants = [ "graphical-session.target" ]; + wantedBy = mkIf cfg.autoStart [ "default.target" ]; + + environment = { + HYPERCOLOR_LOG = cfg.logLevel; + RUST_BACKTRACE = "1"; + HYPERCOLOR_SERVICE_IDENTITY = "user_service:systemd:hypercolor.service"; + }; + + serviceConfig = { + Type = "notify"; + ExecStart = escapeShellArgs ( + [ + "${cfg.package}/bin/hypercolor-daemon" + "--ui-dir" + "${cfg.package}/share/hypercolor/ui" + "--effects-dir" + "${cfg.package}/share/hypercolor/effects/bundled" + ] + ++ cfg.extraArgs + ); + WatchdogSec = 30; + Restart = "on-failure"; + RestartSec = 3; + + # Same hardening as the packaged unit for deb, rpm, and AUR. + ProtectHome = "read-only"; + ProtectSystem = "strict"; + ReadWritePaths = [ + "%h/.config/hypercolor" + "%h/.local/share/hypercolor" + "%h/.local/state/hypercolor" + ]; + PrivateTmp = true; + NoNewPrivileges = true; + }; + }; + + # Screen-reactive effects on Wayland capture through the desktop portal. + xdg.portal.enable = mkDefault true; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..7fe4bfbd3 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,190 @@ +# Hypercolor from the official Linux release tarball. +# +# This mirrors packaging/aur/PKGBUILD: the per-architecture tarball that CI +# builds on Ubuntu 24.04 is unpacked into the store and its ELF binaries are +# re-linked against nixpkgs libraries with autoPatchelf. A from-source +# derivation is deliberately out of scope for now: the daemon's default +# feature set builds Servo and SpiderMonkey, which is hours of compile and a +# build script that expects network access. +# +# `nix/release.json` pins the version and per-architecture checksums; the +# release pipeline refreshes it on every tagged release. +{ + lib, + stdenv, + fetchurl, + autoPatchelfHook, + addDriverRunpath, + makeWrapper, + # Daemon: linked + alsa-lib, + fontconfig, + freetype, + pipewire, + libpulseaudio, + udev, + zlib, + # Daemon: dlopen'd by Servo, wgpu, and winit + libGL, + libglvnd, + vulkan-loader, + wayland, + libxkbcommon, + libx11, + libxcb, + libxcursor, + libxi, + libxrandr, + # Desktop app shell (Tauri) + gtk3, + webkitgtk_4_1, + libsoup_3, + cairo, + gdk-pixbuf, + glib, + dbus, + libayatana-appindicator, + xdotool, + # hypercolor-open runtime + curl, + xdg-utils, + release ? lib.importJSON ./release.json, +}: +let + inherit (release) version; + platform = + { + x86_64-linux = "linux-amd64"; + aarch64-linux = "linux-arm64"; + } + .${stdenv.hostPlatform.system} + or (throw "hypercolor: no release tarball for ${stdenv.hostPlatform.system}"); + sha256 = release.sha256.${stdenv.hostPlatform.system}; +in +stdenv.mkDerivation { + pname = "hypercolor"; + inherit version; + + src = fetchurl { + url = "https://github.com/hyperb1iss/hypercolor/releases/download/v${version}/hypercolor-${version}-${platform}.tar.gz"; + inherit sha256; + }; + + sourceRoot = "hypercolor-${version}-${platform}"; + + nativeBuildInputs = [ + autoPatchelfHook + addDriverRunpath + makeWrapper + ]; + + buildInputs = [ + stdenv.cc.cc.lib + alsa-lib + fontconfig + freetype + pipewire + libpulseaudio + udev + zlib + gtk3 + webkitgtk_4_1 + libsoup_3 + cairo + gdk-pixbuf + glib + dbus + ]; + + # Libraries the binaries open at runtime rather than link against. They + # land on the RUNPATH so dlopen finds them without any environment setup. + runtimeDependencies = [ + libGL + libglvnd + vulkan-loader + wayland + libxkbcommon + libx11 + libxcb + libxcursor + libxi + libxrandr + libayatana-appindicator + xdotool + ]; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin $out/share $out/lib/udev/rules.d $out/lib/systemd/user + + install -Dm755 bin/hypercolor-daemon $out/bin/hypercolor-daemon + install -Dm755 bin/hypercolor $out/bin/hypercolor + install -Dm755 bin/hypercolor-app $out/bin/hypercolor-app + install -Dm755 bin/hypercolor-tui $out/bin/hypercolor-tui + install -Dm755 bin/hypercolor-open $out/bin/hypercolor-open + + # Web UI, bundled effects, docs, agent skills, desktop entry, icons, + # and shell completions keep the tarball layout so the daemon's + # /share/hypercolor discovery keeps working unchanged. + cp -R share/. $out/share/ + + # The desktop entry is stamped with the install prefix at dist time. + substituteInPlace $out/share/applications/hypercolor.desktop \ + --replace-fail "Exec=/usr/bin/hypercolor-open" "Exec=$out/bin/hypercolor-open" + + cp lib/udev/rules.d/*.rules $out/lib/udev/rules.d/ + + # Ship a user unit with store paths so non-NixOS systemd users can link + # it directly. The NixOS module defines its own unit from options. + sed \ + -e "s|/usr/bin/hypercolor-daemon|$out/bin/hypercolor-daemon|" \ + -e "s|/usr/share/hypercolor/ui|$out/share/hypercolor/ui --effects-dir $out/share/hypercolor/effects/bundled|" \ + lib/systemd/user/hypercolor.service.system \ + > $out/lib/systemd/user/hypercolor.service + + install -Dm644 etc/modules-load.d/i2c-dev.conf $out/lib/modules-load.d/i2c-dev.conf + install -Dm644 LICENSE $out/share/licenses/hypercolor/LICENSE + install -Dm644 NOTICE $out/share/licenses/hypercolor/NOTICE + + runHook postInstall + ''; + + postFixup = '' + # hypercolor-open shells out to systemctl, curl, and xdg-open. + wrapProgram $out/bin/hypercolor-open \ + --prefix PATH : ${ + lib.makeBinPath [ + curl + xdg-utils + ] + } + + # GPU drivers on NixOS live under /run/opengl-driver, which is not on + # any RUNPATH nixpkgs knows about at build time. + addDriverRunpath $out/bin/hypercolor-daemon + ''; + + passthru.platform = platform; + + meta = { + description = "Open-source RGB lighting orchestration engine"; + longDescription = '' + Hypercolor drives USB, HID, SMBus, and network RGB hardware from one + spatially aware render pipeline, with HTML and native effects, a web + UI, a TUI, a CLI, and a desktop app shell. + ''; + homepage = "https://hypercolor.lighting"; + changelog = "https://github.com/hyperb1iss/hypercolor/blob/v${version}/CHANGELOG.md"; + license = lib.licenses.asl20; + sourceProvenance = [ lib.sourceTypes.binaryNativeCode ]; + platforms = [ + "x86_64-linux" + "aarch64-linux" + ]; + mainProgram = "hypercolor"; + }; +} diff --git a/nix/release.json b/nix/release.json new file mode 100644 index 000000000..71c11b092 --- /dev/null +++ b/nix/release.json @@ -0,0 +1,7 @@ +{ + "version": "0.5.1", + "sha256": { + "x86_64-linux": "5f4102f3ea07d2cd96d019bd431fbddd7d8155ebbee1cb623ae2bbfe010cfd75", + "aarch64-linux": "29f71b267e118e4d43f42952427f135159c86b0f6b954fdb6444840c0c143e2f" + } +} From 200cf4bf227ad86480306ba8b482e1a2dc75d0cf Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 03:33:05 -0700 Subject: [PATCH 02/10] ci(nix): build the flake on changes and pin it after each release The nix job runs nix flake check plus a smoke test of the built package whenever the flake, nix/, or udev/ change, and enforces nixfmt so the files stay formatted the way nixpkgs expects. update-nix mirrors update-aur: after the stable release tarballs are published it recomputes their checksums, rewrites nix/release.json, and opens a pull request rather than pushing to main, so the pin lands through the normal review gate. --- .github/workflows/ci.yml | 121 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b17557bb0..067875a13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,7 @@ jobs: ui: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.ui }} compat: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.compat }} docs: ${{ steps.filter.outputs.docs }} + nix: ${{ steps.filter.outputs.nix }} workflow: ${{ steps.filter.outputs.workflow }} steps: - uses: actions/checkout@v7 @@ -211,6 +212,12 @@ jobs: - 'scripts/tests/release-workflow.test.mjs' - 'scripts/tests/resolve-release-version.test.mjs' - 'packaging/homebrew/**' + nix: + - 'flake.nix' + - 'flake.lock' + - 'nix/**' + - 'udev/**' + - '.github/workflows/ci.yml' # ── Workflow Lint ───────────────────────────────────────────── workflow-lint: @@ -941,6 +948,33 @@ jobs: - name: Verify compatibility matrix is current run: bun scripts/gen-compat.ts --check + # ── Nix Flake ─────────────────────────────────────────────────── + nix: + name: Nix Flake + needs: changes + if: needs.changes.outputs.nix == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + - name: Check flake formatting + run: nix run nixpkgs#nixfmt-tree -- --ci + - name: Build package and evaluate the NixOS module + run: nix flake check --print-build-logs + - name: Smoke-test the built package + run: | + set -euo pipefail + nix build .#hypercolor --print-build-logs + ./result/bin/hypercolor --version + ./result/bin/hypercolor-daemon --help >/dev/null + test -f ./result/lib/udev/rules.d/99-hypercolor.rules + test -f ./result/lib/udev/rules.d/70-hypercolor-input.rules + test -f ./result/share/hypercolor/ui/index.html + test -d ./result/share/hypercolor/effects/bundled + # ── Python Client ──────────────────────────────────────────────── python: name: Python Client @@ -2447,3 +2481,90 @@ jobs: -m "hypercolor: update to ${VERSION}" \ -m "Update formula and cask from the published release checksums." git push + + # ── Update Nix Release Pin ─────────────────────────────────────── + # Mirrors update-aur: the flake wraps the published tarballs, so the + # version and checksums in nix/release.json move after every stable + # release. Lands as a pull request so main keeps the review gate. + update-nix: + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + startsWith(github.ref, 'refs/tags/') && + !contains(github.ref_name, '-') + needs: create-release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + ref: main + + - name: Determine version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Download release artifacts and compute checksums + id: checksums + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + mkdir -p release-artifacts + for platform in linux-amd64 linux-arm64; do + tarball="hypercolor-${VERSION}-${platform}.tar.gz" + gh release download "v${VERSION}" \ + --repo "${{ github.repository }}" \ + --pattern "${tarball}" \ + --dir release-artifacts + sha=$(sha256sum "release-artifacts/${tarball}" | cut -d' ' -f1) + echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT" + echo " ${platform}: ${sha}" + done + + - name: Write nix/release.json + env: + VERSION: ${{ steps.version.outputs.version }} + SHA_AMD64: ${{ steps.checksums.outputs.sha256_linux_amd64 }} + SHA_ARM64: ${{ steps.checksums.outputs.sha256_linux_arm64 }} + run: | + set -euo pipefail + jq -n \ + --arg version "$VERSION" \ + --arg amd64 "$SHA_AMD64" \ + --arg arm64 "$SHA_ARM64" \ + '{version: $version, sha256: {"x86_64-linux": $amd64, "aarch64-linux": $arm64}}' \ + > nix/release.json + cat nix/release.json + + - name: Open pull request + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if git diff --quiet -- nix/release.json; then + echo "nix/release.json already points at ${VERSION}; nothing to do." + exit 0 + fi + branch="ci/nix-release-${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "${branch}" + git add nix/release.json + git commit -F - < Date: Sun, 13 Sep 2026 03:33:05 -0700 Subject: [PATCH 03/10] docs(install): document the Nix flake and NixOS module Adds a NixOS and Nix section to the download page with the nix run one-liner, a minimal flake input example, the services.hypercolor option summary (including the input.allDevices keylogging trade-off), and the non-NixOS profile install path. README points at it from the Linux install paragraph alongside the deb, AUR, and Homebrew options. --- README.md | 5 ++++- docs/content/download.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fb7a533bf..bfe0a871b 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,10 @@ themselves. Debian and Ubuntu users can install the `.deb` from the releases page. Arch users have [`hypercolor-bin`](https://aur.archlinux.org/packages/hypercolor-bin) on the AUR, and the -Homebrew formula works on Linux too: `brew install hyperb1iss/tap/hypercolor`. +Homebrew formula works on Linux too: `brew install hyperb1iss/tap/hypercolor`. NixOS users +get a flake with a `services.hypercolor` module; `nix run github:hyperb1iss/hypercolor` +tries the CLI without installing anything (details on the +[download page](https://hyperb1iss.github.io/hypercolor/download/)). Building from source instead: diff --git a/docs/content/download.md b/docs/content/download.md index 9f1d68109..97c677d8f 100644 --- a/docs/content/download.md +++ b/docs/content/download.md @@ -59,6 +59,46 @@ The `hypercolor-bin` AUR package updates automatically on every tagged release: yay -S hypercolor-bin ``` +### NixOS and Nix + +The repository is a flake that wraps the same release tarball and ships a +NixOS module. Try it without installing anything: + +```bash +nix run github:hyperb1iss/hypercolor -- devices +``` + +On NixOS, add the flake as an input and enable the module. It installs the +package, the udev rules, the `i2c-dev` kernel module, and a hardened systemd +user service that starts the daemon with every graphical login: + +```nix +{ + inputs.hypercolor.url = "github:hyperb1iss/hypercolor"; + + outputs = { nixpkgs, hypercolor, ... }: { + nixosConfigurations.rig = nixpkgs.lib.nixosSystem { + modules = [ + hypercolor.nixosModules.default + { services.hypercolor.enable = true; } + ]; + }; + }; +} +``` + +Options live under `services.hypercolor`: `autoStart` (default `true`), +`logLevel`, `extraArgs`, `smbus.enable` (default `true`), and +`input.allDevices` (default `false`; grants every keyboard and mouse event +node to the seated user, which is a session-wide keylogging grant, so read the +description before turning it on). Screen-reactive effects on Wayland capture +through the desktop portal, so the module enables `xdg.portal` by default. +Log out and back in after the first rebuild so logind replays the device ACLs. + +Outside NixOS, `nix profile install github:hyperb1iss/hypercolor` installs the +binaries, and the package ships a user unit at `lib/systemd/user/` with store +paths already filled in. + ## Windows Download the NSIS installer (`Hypercolor__x64-setup.exe`) from the From acf0e64259f47ebfac4033716979ed0b7c49bfdf Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 03:33:49 -0700 Subject: [PATCH 04/10] fix(daemon): treat epoch-normalized UI mtimes as unknown age Reproducible package stores such as Nix and Guix reset every file mtime to the Unix epoch, so the stale-web-UI heuristic in resolve_ui_dir read the bundled UI as twenty thousand days old and nagged about a rebuild on every daemon start. Modification times at or before epoch plus one second now count as an unknown build time and log the ordinary "Serving web UI" line. --- crates/hypercolor-daemon/src/daemon.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/hypercolor-daemon/src/daemon.rs b/crates/hypercolor-daemon/src/daemon.rs index 22efb4c01..ccaa8c60d 100644 --- a/crates/hypercolor-daemon/src/daemon.rs +++ b/crates/hypercolor-daemon/src/daemon.rs @@ -373,6 +373,10 @@ fn resolve_ui_dir(explicit: Option) -> Option { .metadata() .ok() .and_then(|meta| meta.modified().ok()) + // Reproducible package stores (Nix, Guix) normalize every mtime to + // the Unix epoch, which would read as decades stale. Treat those as + // an unknown build time rather than a rebuild nag on every boot. + .filter(|modified| *modified > std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)) .and_then(|modified| modified.elapsed().ok()); let age_label = match age { From 14a20761d89a1ba0149b83de34c5d5e5d735e536 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 09:36:39 -0700 Subject: [PATCH 05/10] ci(nix): validate the release pin before opening its pull request GitHub never runs pull_request workflows for PRs opened with GITHUB_TOKEN, so the nix job would not have checked the checksums the update-nix job writes. The job now installs nix, runs nix flake check, builds the package against the rewritten pin, and confirms the CLI reports the release version before it opens the pull request. The bot branch is pushed with a plain force: a lease against a branch the checkout never fetched rejects every rerun of the tag lane while the previous bot branch still exists, and the pull request fallback below it never gets a chance to run. --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 067875a13..3f92792f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2485,7 +2485,9 @@ jobs: # ── Update Nix Release Pin ─────────────────────────────────────── # Mirrors update-aur: the flake wraps the published tarballs, so the # version and checksums in nix/release.json move after every stable - # release. Lands as a pull request so main keeps the review gate. + # release. Lands as a pull request so main keeps the review gate. GitHub + # never runs pull_request workflows for PRs opened with GITHUB_TOKEN, so + # the job builds the flake against the new pin itself before opening it. update-nix: if: >- (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && @@ -2539,16 +2541,42 @@ jobs: > nix/release.json cat nix/release.json - - name: Open pull request + - name: Check whether the pin moved + id: pin env: - GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail if git diff --quiet -- nix/release.json; then echo "nix/release.json already points at ${VERSION}; nothing to do." - exit 0 + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" fi + + - uses: cachix/install-nix-action@v31 + if: steps.pin.outputs.changed == 'true' + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Build the flake against the new pin + if: steps.pin.outputs.changed == 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + nix flake check --print-build-logs + nix build .#hypercolor --print-build-logs + ./result/bin/hypercolor --version | grep -F "${VERSION}" + + - name: Open pull request + if: steps.pin.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail branch="ci/nix-release-${VERSION}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -2561,7 +2589,8 @@ jobs: wraps those artifacts, so the version and per-architecture checksums in nix/release.json follow every stable release. EOF - git push --force-with-lease origin "${branch}" + # The branch is bot-owned, so a rerun of the tag lane replaces it. + git push --force origin "${branch}" gh pr create \ --base main \ --head "${branch}" \ From 9d9d3371ff27e8f3e0bd196a580411d05bee929d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 09:36:39 -0700 Subject: [PATCH 06/10] docs(install): show how to activate the Nix profile unit systemd does not scan ~/.nix-profile, so the non-NixOS paragraph now links the shipped user unit into ~/.config/systemd/user, reloads and enables it, and copies the udev rules into /etc/udev/rules.d, instead of stopping at "the package ships a user unit". --- docs/content/download.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/content/download.md b/docs/content/download.md index 97c677d8f..da3eba523 100644 --- a/docs/content/download.md +++ b/docs/content/download.md @@ -96,8 +96,18 @@ through the desktop portal, so the module enables `xdg.portal` by default. Log out and back in after the first rebuild so logind replays the device ACLs. Outside NixOS, `nix profile install github:hyperb1iss/hypercolor` installs the -binaries, and the package ships a user unit at `lib/systemd/user/` with store -paths already filled in. +binaries, and the package ships a user unit with store paths already filled +in. systemd does not scan the Nix profile, so link the unit in and copy the +udev rules yourself: + +```bash +mkdir -p ~/.config/systemd/user +ln -sf ~/.nix-profile/lib/systemd/user/hypercolor.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now hypercolor.service +sudo cp ~/.nix-profile/lib/udev/rules.d/*hypercolor*.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules +``` ## Windows From c133ad3c5a9fe3e81b37d26a34eb200f4aaae15d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 09:36:39 -0700 Subject: [PATCH 07/10] chore(nix): clarify epoch mtime handling and trim the module check Nix and Guix stamp store files at one second past the Unix epoch, which is why the daemon's stale-UI filter compares strictly greater than that instant; the comment now says so before someone relaxes it to >=. The flake's module check dropped a grep branch that could never match because NixOS quotes Environment= values. --- crates/hypercolor-daemon/src/daemon.rs | 5 +++-- flake.nix | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-daemon/src/daemon.rs b/crates/hypercolor-daemon/src/daemon.rs index ccaa8c60d..9fdd356c1 100644 --- a/crates/hypercolor-daemon/src/daemon.rs +++ b/crates/hypercolor-daemon/src/daemon.rs @@ -374,8 +374,9 @@ fn resolve_ui_dir(explicit: Option) -> Option { .ok() .and_then(|meta| meta.modified().ok()) // Reproducible package stores (Nix, Guix) normalize every mtime to - // the Unix epoch, which would read as decades stale. Treat those as - // an unknown build time rather than a rebuild nag on every boot. + // one second past the Unix epoch, which would read as decades stale. + // Treat anything that early as an unknown build time rather than a + // rebuild nag on every boot. .filter(|modified| *modified > std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)) .and_then(|modified| modified.elapsed().ok()); diff --git a/flake.nix b/flake.nix index d706916f7..7c1ccf8ee 100644 --- a/flake.nix +++ b/flake.nix @@ -85,7 +85,7 @@ grep -q -- "--effects-dir ${hypercolor}/share/hypercolor/effects/bundled" "$unit" grep -q -- "--log-level debug" "$unit" grep -q "^ProtectSystem=strict" "$unit" - grep -q "^HYPERCOLOR_LOG=info" "$unit" || grep -q 'HYPERCOLOR_LOG=info' "$unit" + grep -q 'HYPERCOLOR_LOG=info' "$unit" touch $out ''; } From 3fb8bfde0d52b9f5f827b752f1a2f190f19711d9 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 10:11:46 -0700 Subject: [PATCH 08/10] ci(nix): harden the flake lane and stop swallowing pin PR failures The Nix job runs PR-controlled flake output, so it no longer persists the checkout token. The release-pin job now asks whether a pull request for the bot branch is already open before creating one, instead of treating every gh pr create failure as "already exists". Both checkouts move to actions/checkout@v7 to match the rest of the workflow. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014466GJcZeEz2om6JDZ3Chm --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f92792f5..636304f2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -955,7 +955,9 @@ jobs: if: needs.changes.outputs.nix == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: cachix/install-nix-action@v31 with: extra_nix_config: | @@ -2499,7 +2501,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: main @@ -2591,9 +2593,13 @@ jobs: EOF # The branch is bot-owned, so a rerun of the tag lane replaces it. git push --force origin "${branch}" + existing="$(gh pr list --head "${branch}" --state open --json number --jq 'length')" + if [[ "${existing}" != "0" ]]; then + echo "Pull request already open for ${branch}; the force-push refreshed it." + exit 0 + fi gh pr create \ --base main \ --head "${branch}" \ --title "chore(nix): pin flake to release ${VERSION}" \ - --body "Automated pin of the Nix flake to the v${VERSION} release tarballs. Checksums come straight from the published GitHub Release assets." \ - || echo "Pull request already open for ${branch}." + --body "Automated pin of the Nix flake to the v${VERSION} release tarballs. Checksums come straight from the published GitHub Release assets." From 58f41759416c7cbe617497bfc6231700ba918de9 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 10:11:46 -0700 Subject: [PATCH 09/10] fix(nix): create the daemon state directories before the unit starts ProtectHome=read-only with ReadWritePaths= requires every listed path to exist when systemd sets up the mount namespace. The deb and AUR installers create those directories at install time, but a NixOS module has no install step, so a fresh login would fail the unit before ExecStart. User tmpfiles rules create them at session start. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014466GJcZeEz2om6JDZ3Chm --- nix/module.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nix/module.nix b/nix/module.nix index fcd12530d..f06a6537d 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -100,6 +100,16 @@ in boot.kernelModules = mkIf cfg.smbus.enable [ "i2c-dev" ]; + # ProtectHome=read-only plus ReadWritePaths= needs every listed path to + # exist before systemd builds the mount namespace, and nothing else + # creates them on a fresh NixOS login (the deb and AUR installers do it + # at install time). User tmpfiles run at session start, ahead of the unit. + systemd.user.tmpfiles.rules = [ + "d %h/.config/hypercolor 0700 - - -" + "d %h/.local/share/hypercolor 0700 - - -" + "d %h/.local/state/hypercolor 0700 - - -" + ]; + systemd.user.services.hypercolor = { description = "Hypercolor RGB Lighting Daemon"; documentation = [ "https://github.com/hyperb1iss/hypercolor" ]; From 6d2bc0951776f2ab2f3bac021bd8318a79d0b2a5 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 13 Sep 2026 10:11:46 -0700 Subject: [PATCH 10/10] docs(install): declare the nixpkgs input in the NixOS flake example The example destructures nixpkgs in outputs without declaring it, so a copied flake failed evaluation with a missing-input error. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014466GJcZeEz2om6JDZ3Chm --- docs/content/download.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/content/download.md b/docs/content/download.md index da3eba523..3188328bb 100644 --- a/docs/content/download.md +++ b/docs/content/download.md @@ -74,10 +74,12 @@ user service that starts the daemon with every graphical login: ```nix { + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.hypercolor.url = "github:hyperb1iss/hypercolor"; outputs = { nixpkgs, hypercolor, ... }: { nixosConfigurations.rig = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; modules = [ hypercolor.nixosModules.default { services.hypercolor.enable = true; }