diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b17557bb0..636304f2e 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,35 @@ 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@v7 + with: + persist-credentials: false + - 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 +2483,123 @@ 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. 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') && + 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@v7 + 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: Check whether the pin moved + id: pin + env: + 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." + 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" + git checkout -b "${branch}" + git add nix/release.json + git commit -F - <) -> Option { .metadata() .ok() .and_then(|meta| meta.modified().ok()) + // Reproducible package stores (Nix, Guix) normalize every mtime to + // 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()); let age_label = match age { diff --git a/docs/content/download.md b/docs/content/download.md index 9f1d68109..3188328bb 100644 --- a/docs/content/download.md +++ b/docs/content/download.md @@ -59,6 +59,58 @@ 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.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; } + ]; + }; + }; +} +``` + +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 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 Download the NSIS installer (`Hypercolor__x64-setup.exe`) from the 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..7c1ccf8ee --- /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" + touch $out + ''; + } + ); + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 000000000..f06a6537d --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,161 @@ +# 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" ]; + + # 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" ]; + 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" + } +}