All checks were successful
Check / eval (push) Successful in 4m14s
The timer's OnCalendar now bakes the configured times (falling back like _hhmm_to_min on a hand-edited state), so the switch lands at the configured minute instead of up to 15 minutes late. The original poll's robustness case is covered without it: Persistent catches transitions missed while powered off, systemd fires elapsed calendar timers on resume, and OnStartupSec settles the theme after login. Baked times mean a time edit must rebuild: the menu's Sunrise/Sunset writes now run `auto --force` when enabled (one rebuild re-bakes the timer and lands on the right theme, same as the enable flow). Day and night slugs stay live reads — but with no poll to catch them up, editing the slot currently on screen applies immediately via a plain `auto`. New eval check `auto-theme-timer` asserts the bake state-bridges-style: configured times reach OnCalendar, garbage falls back to 07:00/20:00, disabled installs no unit. Docs + hardware-queue entry updated to the new semantics. V2: checks.auto-theme (KVM VM, passed) covers the auto engine the timer ticks; checks.auto-theme-timer (eval, passed) covers the unit; OnCalendar strings systemd-analyze-validated; the rebuilt menu script passes bash -n with the new branches in place. V3 pending: live timer firing at the exact configured minute on hardware (existing "Auto-theme pair flip" queue entry, refreshed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2699 lines
141 KiB
Nix
2699 lines
141 KiB
Nix
{
|
||
description = "Nomarchy — the rock-solid reproducibility of NixOS, the polish of an opinionated desktop";
|
||
|
||
inputs = {
|
||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||
|
||
# Second, newer channel — exists solely to feed the `unstable.*` overlay
|
||
# seam (#134), so downstream can pick individual newer apps without a
|
||
# second flake input of their own.
|
||
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||
|
||
home-manager = {
|
||
url = "github:nix-community/home-manager/release-26.05";
|
||
inputs.nixpkgs.follows = "nixpkgs";
|
||
};
|
||
|
||
stylix = {
|
||
# Track the release branch matching nixpkgs/HM — master is built
|
||
# against unstable and warns about the version skew on every
|
||
# activation. (No home-manager input to follow anymore: stylix
|
||
# vendors its HM integration.)
|
||
url = "github:nix-community/stylix/release-26.05";
|
||
inputs.nixpkgs.follows = "nixpkgs";
|
||
};
|
||
|
||
# Pinned by Nomarchy so distro + hardware quirks are tested together
|
||
# (consumed by lib.mkFlake's hardwareProfile). Its nixpkgs input is
|
||
# only used by its own CI — follow ours to keep locks/ISO lean.
|
||
nixos-hardware = {
|
||
url = "github:NixOS/nixos-hardware/master";
|
||
inputs.nixpkgs.follows = "nixpkgs";
|
||
};
|
||
|
||
# Wallpapers only — split out of this repo (ROADMAP § "Faster
|
||
# switches") so a `state.json` write stops re-copying ~94 MB of
|
||
# backgrounds/ before Nix can evaluate a 1 KB change. Runtime-only:
|
||
# never read at eval, only by nomarchy-state-sync's Python (swww
|
||
# path) via NOMARCHY_DEFAULT_THEMES. `flake = false` — it's just a
|
||
# content tree (`<slug>/backgrounds/*`), not a flake.
|
||
nomarchy-wallpapers = {
|
||
url = "git+https://git.bemagri.xyz/bernardo/Nomarchy-Wallpapers.git?ref=main";
|
||
flake = false;
|
||
};
|
||
};
|
||
|
||
outputs = { self, nixpkgs, nixpkgs-unstable, home-manager, stylix, nixos-hardware, nomarchy-wallpapers, ... }:
|
||
let
|
||
system = "x86_64-linux";
|
||
username = "nomarchy"; # reference host only; downstream picks its own
|
||
|
||
pkgs = import nixpkgs {
|
||
inherit system;
|
||
overlays = [ self.overlays.default ];
|
||
# Matches nixpkgs.config.allowUnfree in modules/nixos — the
|
||
# standalone HM generation must come from like-configured pkgs.
|
||
config.allowUnfree = true;
|
||
};
|
||
|
||
# The guided installer — live-ISO only (not in the overlay): it bakes
|
||
# in the downstream template, this checkout's flake.lock, and the
|
||
# source identity of this very revision so installs work offline.
|
||
gitUrl = "https://git.bemagri.xyz/bernardo/nomarchy.git";
|
||
nomarchyInstall = pkgs.callPackage ./pkgs/nomarchy-install {
|
||
templateDir = ./templates/downstream;
|
||
nomarchyLock = ./flake.lock;
|
||
hardwareModuleNames = pkgs.writeText "nixos-hardware-modules.txt"
|
||
(nixpkgs.lib.concatStringsSep "\n"
|
||
(builtins.attrNames nixos-hardware.nixosModules));
|
||
nixpkgsPath = nixpkgs.outPath;
|
||
# Branch model: `main` is development; `v1` is the release pointer
|
||
# (advanced to main after human QA). Installs from a *main-built* ISO
|
||
# must seed `?ref=main` — seeding v1 while it lags hundreds of
|
||
# commits left machines with installer-written `nomarchy.hardware.*`
|
||
# that v1 does not have (#124). When cutting a release ISO from the
|
||
# v1 pointer, change both of these to "v1".
|
||
flakeUrl = "git+${gitUrl}?ref=main";
|
||
# Future updates re-resolve from the forge (original); the install
|
||
# itself resolves from the ISO store (locked = path) — fully
|
||
# offline, even from a dirty-tree ISO.
|
||
originalNode = { type = "git"; url = gitUrl; ref = "main"; };
|
||
lockedNode = {
|
||
type = "path";
|
||
path = self.outPath;
|
||
narHash = self.narHash;
|
||
lastModified = self.lastModified or 0;
|
||
};
|
||
rev = self.rev or null;
|
||
};
|
||
in
|
||
{
|
||
# ─── Downstream API ────────────────────────────────────────────
|
||
# A user's machine flake imports these and layers its own
|
||
# system.nix / home.nix on top (see templates/downstream).
|
||
# Their state.json lives in THEIR flake and is wired up via
|
||
# the `nomarchy.stateFile` option — reading it stays pure.
|
||
|
||
overlays.default = final: prev: {
|
||
# A second, newer channel for individual apps (#134) — e.g. lmstudio,
|
||
# whose fast-moving upstream a pinned nixpkgs lags. Plain `import`,
|
||
# deliberately NOT final-recursive: it evaluates only when a config
|
||
# actually references `unstable.something`, so checkouts that never
|
||
# write `unstable.` pay nothing for it. allowUnfree mirrors the base
|
||
# set (lib.nix) so unfree apps — lmstudio is the motivating case —
|
||
# resolve here too.
|
||
unstable = import nixpkgs-unstable {
|
||
inherit (prev.stdenv.hostPlatform) system;
|
||
config.allowUnfree = true;
|
||
};
|
||
# The default themes root: this repo's ./themes (JSONs, previews,
|
||
# btop/waybar/rofi overrides — ~3 MB post wallpaper-split) merged
|
||
# with backgrounds/ symlinked in from the pinned nomarchy-wallpapers
|
||
# input. One tree so nomarchy-state-sync (baked-in fallback) and
|
||
# the theme-wholeswap check both see slug/backgrounds/ in the same
|
||
# place a downstream machine would via NOMARCHY_DEFAULT_THEMES.
|
||
nomarchy-default-themes =
|
||
let
|
||
wallpaperSlugs = builtins.attrNames
|
||
(nixpkgs.lib.filterAttrs (n: t: t == "directory")
|
||
(builtins.readDir nomarchy-wallpapers));
|
||
linkBackgrounds = slug: ''
|
||
if [ -d ${nomarchy-wallpapers}/${slug}/backgrounds ]; then
|
||
mkdir -p $out/${slug}
|
||
ln -s ${nomarchy-wallpapers}/${slug}/backgrounds $out/${slug}/backgrounds
|
||
fi
|
||
'';
|
||
in
|
||
final.runCommand "nomarchy-default-themes" { } ''
|
||
cp -r ${./themes} $out
|
||
chmod -R u+w $out
|
||
|
||
${nixpkgs.lib.concatMapStringsSep "\n" linkBackgrounds wallpaperSlugs}
|
||
|
||
for j in $out/*.json; do
|
||
slug=$(basename "$j" .json)
|
||
if [ ! -d "$out/$slug/backgrounds" ] || [ -z "$(ls -A "$out/$slug/backgrounds" 2>/dev/null)" ]; then
|
||
echo "nomarchy-default-themes: $slug has no non-empty backgrounds/ (drift vs nomarchy-wallpapers)" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
'';
|
||
nomarchy-state-sync = final.callPackage ./pkgs/nomarchy-state-sync {
|
||
# Presets baked into the package, so `nomarchy-state-sync list`
|
||
# works on machines that don't check out this repo. Already a
|
||
# store artifact (nomarchy-default-themes) — no copy needed.
|
||
themesDir = final.nomarchy-default-themes;
|
||
};
|
||
nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { };
|
||
nomarchy-battery-notify = final.callPackage ./pkgs/nomarchy-battery-notify { };
|
||
nomarchy-first-boot = final.callPackage ./pkgs/nomarchy-first-boot { };
|
||
nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { };
|
||
nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { };
|
||
nomarchy-airplane = final.callPackage ./pkgs/nomarchy-airplane { };
|
||
nomarchy-suspend = final.callPackage ./pkgs/nomarchy-suspend { };
|
||
nomarchy-gen-prune = final.callPackage ./pkgs/nomarchy-gen-prune { };
|
||
# pull / rebuild / home (+ legacy aliases) — also on HM so a home
|
||
# switch can refresh a broken system-package nomarchy-pull.
|
||
# Includes nomarchy-what-changed (generation readability, #82).
|
||
nomarchy-lifecycle = final.callPackage ./pkgs/nomarchy-lifecycle { };
|
||
pam-fprint-grosshack = final.callPackage ./pkgs/pam-fprint-grosshack { };
|
||
# NOTE: no chromium override here on purpose — the enableWideVine
|
||
# wrapper's duplicate unwrapped build is a real upstream bug but is
|
||
# worth ~195 MiB of download and ~19 MiB of disk, not the gigabytes it
|
||
# looks like. Decided not to work around it locally
|
||
# (ROADMAP § one chromium, not two, #121).
|
||
};
|
||
|
||
nixosModules.nomarchy = {
|
||
imports = [ ./modules/nixos ];
|
||
nixpkgs.overlays = [ self.overlays.default ];
|
||
};
|
||
|
||
homeModules.nomarchy = {
|
||
# stylix's home module is injected here because it needs the
|
||
# flake input; modules/home/stylix.nix only configures it.
|
||
imports = [ stylix.homeModules.stylix ./modules/home ];
|
||
};
|
||
|
||
# One-call downstream wrapper — see templates/downstream/flake.nix.
|
||
# The generated flake is never hand-edited; machines live in
|
||
# system.nix / home.nix.
|
||
lib = import ./lib.nix {
|
||
inherit nixpkgs home-manager nixos-hardware;
|
||
nomarchy = self;
|
||
};
|
||
|
||
templates.default = {
|
||
path = ./templates/downstream;
|
||
description = "Nomarchy machine configuration (downstream flake)";
|
||
};
|
||
|
||
packages.${system} = {
|
||
nomarchy-state-sync = pkgs.nomarchy-state-sync;
|
||
# #107 compat alias — same derivation; drop after release notes say so.
|
||
nomarchy-theme-sync = pkgs.nomarchy-state-sync;
|
||
nomarchy-install = nomarchyInstall;
|
||
# Overlay tools exported for `nix build .#nomarchy-doctor` etc.
|
||
# (maintainer/CI convenience — modules install them via the overlay).
|
||
nomarchy-doctor = pkgs.nomarchy-doctor;
|
||
nomarchy-lifecycle = pkgs.nomarchy-lifecycle;
|
||
nomarchy-battery-notify = pkgs.nomarchy-battery-notify;
|
||
nomarchy-first-boot = pkgs.nomarchy-first-boot;
|
||
nomarchy-what-changed = pkgs.nomarchy-what-changed;
|
||
nomarchy-detect-hw = pkgs.nomarchy-detect-hw;
|
||
default = pkgs.nomarchy-state-sync;
|
||
};
|
||
|
||
# ─── Checks ────────────────────────────────────────────────────
|
||
# `nix flake check` never evaluates templates/, so exercise the
|
||
# downstream template through mkFlake directly (this bypasses the
|
||
# template's own three-line flake.nix, which only resolves once
|
||
# instantiated — everything else is covered here, including the
|
||
# hardwareProfile → nixos-hardware mapping).
|
||
checks.${system} =
|
||
let
|
||
downstream = self.lib.mkFlake {
|
||
src = ./templates/downstream;
|
||
username = "me";
|
||
hardwareProfile = "framework-13-7040-amd"; # exercises the mapping
|
||
};
|
||
|
||
# state.json fixtures for the state-bridge checks below,
|
||
# built off the real shipped template so they stay honest about
|
||
# what a machine actually carries.
|
||
baseState = builtins.fromJSON
|
||
(builtins.readFile ./templates/downstream/state.json);
|
||
stateFileOf = name: overrides: pkgs.writeText name
|
||
(builtins.toJSON (nixpkgs.lib.recursiveUpdate baseState overrides));
|
||
# The shipped template carries none of these keys — the
|
||
# absent-key path every existing machine is on.
|
||
absentState = stateFileOf "state-absent.json" { };
|
||
flippedState = stateFileOf "state-flipped.json" {
|
||
settings.bluetooth.enable = false; # default true → flipped
|
||
settings.printing.enable = true; # default false → flipped
|
||
settings.greeter.autoLogin = "me";
|
||
settings.power.suspendThenHibernate = false; # default true → off
|
||
};
|
||
# #115: s2h on needs a resume device or logind never gets the policy.
|
||
s2hOnState = stateFileOf "state-s2h-on.json" {
|
||
settings.power.suspendThenHibernate = true;
|
||
};
|
||
in
|
||
{
|
||
downstream-template-system =
|
||
downstream.nixosConfigurations.default.config.system.build.toplevel;
|
||
downstream-template-home =
|
||
downstream.homeConfigurations.me.activationPackage;
|
||
|
||
# The NixOS-side state bridges (ROADMAP § state bridges, #116). `nomarchy.settings`
|
||
# exists ONLY on the Home Manager side, so a NixOS option that
|
||
# defaulted from `config.nomarchy.settings.… or X` silently got X
|
||
# forever — and `or` makes a dead bridge look exactly like a live
|
||
# one, which is why two Control Center toggles wrote JSON nothing
|
||
# read for as long as they existed. Nothing in a build fails when a
|
||
# bridge dies, so assert the flip: no VM, pure eval.
|
||
state-bridges =
|
||
let
|
||
configWith = stateFile: extra:
|
||
(self.nixosConfigurations.nomarchy.extendModules {
|
||
modules = [
|
||
({ lib, ... }: lib.mkMerge [
|
||
{ nomarchy.system.stateFile = lib.mkForce stateFile; }
|
||
extra
|
||
])
|
||
];
|
||
}).config;
|
||
|
||
absent = configWith absentState { };
|
||
flipped = configWith flippedState { };
|
||
# A hand-set value in system.nix must still outrank the state,
|
||
# or the documented "setting it by hand pins it" is a lie.
|
||
pinned = configWith flippedState {
|
||
nomarchy.system.bluetooth.enable = true;
|
||
nomarchy.services.printing.enable = false;
|
||
nomarchy.system.greeter.autoLogin = null;
|
||
nomarchy.system.power.suspendThenHibernate = true;
|
||
};
|
||
# #115: resume device required for s2h logind policy.
|
||
s2hOn = configWith s2hOnState {
|
||
boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-0000000000aa";
|
||
};
|
||
s2hOff = configWith flippedState {
|
||
boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-0000000000aa";
|
||
};
|
||
# Toggle true but no resume → must NOT claim s2h lid policy.
|
||
s2hNoResume = configWith s2hOnState { };
|
||
|
||
cases = [
|
||
{ n = "absent key leaves bluetooth at its default (true)";
|
||
ok = absent.hardware.bluetooth.enable == true; }
|
||
{ n = "absent key leaves printing at its default (false)";
|
||
ok = absent.services.printing.enable == false; }
|
||
{ n = "absent key leaves autoLogin unset";
|
||
ok = !(absent.services.greetd.settings ? initial_session); }
|
||
{ n = "state bluetooth=false reaches hardware.bluetooth";
|
||
ok = flipped.hardware.bluetooth.enable == false; }
|
||
{ n = "state bluetooth=false reaches services.blueman";
|
||
ok = flipped.services.blueman.enable == false; }
|
||
{ n = "state printing=true reaches services.printing";
|
||
ok = flipped.services.printing.enable == true; }
|
||
{ n = "state printing=true reaches services.avahi (mDNS)";
|
||
ok = flipped.services.avahi.enable == true; }
|
||
{ n = "state autoLogin reaches greetd initial_session";
|
||
ok = (flipped.services.greetd.settings.initial_session.user or null) == "me"; }
|
||
{ n = "hand-set bluetooth outranks the state";
|
||
ok = pinned.hardware.bluetooth.enable == true; }
|
||
{ n = "hand-set printing outranks the state";
|
||
ok = pinned.services.printing.enable == false; }
|
||
{ n = "hand-set autoLogin outranks the state";
|
||
ok = !(pinned.services.greetd.settings ? initial_session); }
|
||
{ n = "s2h on + resume → HandleLidSwitch=suspend-then-hibernate";
|
||
ok = (s2hOn.services.logind.settings.Login.HandleLidSwitch or null)
|
||
== "suspend-then-hibernate"; }
|
||
{ n = "s2h on + resume → HibernateDelaySec=1h";
|
||
ok = (s2hOn.systemd.sleep.settings.Sleep.HibernateDelaySec or null)
|
||
== "1h"; }
|
||
{ n = "s2h on + resume → HibernateOnACPower=false";
|
||
ok = (s2hOn.systemd.sleep.settings.Sleep.HibernateOnACPower or null)
|
||
== false; }
|
||
{ n = "s2h on + resume → ExternalPower lid = suspend";
|
||
ok = (s2hOn.services.logind.settings.Login.HandleLidSwitchExternalPower or null)
|
||
== "suspend"; }
|
||
{ n = "s2h off + resume → lid not forced to s2h";
|
||
ok = (s2hOff.services.logind.settings.Login.HandleLidSwitch or null)
|
||
!= "suspend-then-hibernate"; }
|
||
{ n = "s2h on without resume → no s2h lid policy";
|
||
ok = (s2hNoResume.services.logind.settings.Login.HandleLidSwitch or null)
|
||
!= "suspend-then-hibernate"; }
|
||
{ n = "hand-set s2h=true outranks state false";
|
||
ok = pinned.nomarchy.system.power.suspendThenHibernate == true; }
|
||
];
|
||
failed = builtins.filter (c: !c.ok) cases;
|
||
in
|
||
if failed != [ ] then
|
||
throw ''
|
||
State bridge broken — a settings.* key no longer reaches the config:
|
||
${nixpkgs.lib.concatMapStringsSep "\n" (c: " ✗ ${c.n}") failed}
|
||
Read the state via modules/state-read.nix and mkDefault it
|
||
in the module — `config.nomarchy.settings` does NOT exist on the
|
||
NixOS side, and `or <fallback>` swallows that silently.
|
||
See ROADMAP § "NixOS-side state bridges (#116)".''
|
||
else
|
||
pkgs.runCommand "nomarchy-state-bridges" { }
|
||
"touch $out"; # all ${toString (builtins.length cases)} assertions held at eval
|
||
|
||
# The baked auto-theme timer (#79 revision): sunrise/sunset land in
|
||
# the unit's OnCalendar at rebuild, and nothing in a build fails
|
||
# when that bake goes stale or produces a calendar systemd rejects
|
||
# — so assert it at eval, state-bridges style.
|
||
auto-theme-timer =
|
||
let
|
||
homeWith = stateFile:
|
||
(downstream.homeConfigurations.me.extendModules {
|
||
modules = [{
|
||
nomarchy.stateFile = nixpkgs.lib.mkForce stateFile;
|
||
}];
|
||
}).config;
|
||
atState = name: at: stateFileOf name { settings.autoTheme = at; };
|
||
on = homeWith (atState "state-autotheme-on.json" {
|
||
enable = true; day = "summer-day"; night = "summer-night";
|
||
sunrise = "06:30"; sunset = "21:45";
|
||
});
|
||
garbage = homeWith (atState "state-autotheme-garbage.json" {
|
||
enable = true; day = "summer-day"; night = "summer-night";
|
||
sunrise = "sometime"; sunset = "25:99";
|
||
});
|
||
off = homeWith absentState; # template carries no autoTheme key
|
||
cal = c: c.systemd.user.timers.nomarchy-auto-theme.Timer.OnCalendar;
|
||
cases = [
|
||
{ n = "configured times are baked into OnCalendar";
|
||
ok = cal on == [ "*-*-* 06:30:00" "*-*-* 21:45:00" ]; }
|
||
{ n = "garbage times fall back to 07:00/20:00";
|
||
ok = cal garbage == [ "*-*-* 07:00:00" "*-*-* 20:00:00" ]; }
|
||
{ n = "disabled → no timer unit installed";
|
||
ok = !(off.systemd.user.timers ? nomarchy-auto-theme); }
|
||
];
|
||
failed = builtins.filter (c: !c.ok) cases;
|
||
in
|
||
if failed != [ ] then
|
||
throw ''
|
||
Auto-theme timer bake broken — settings.autoTheme no longer
|
||
reaches the unit as expected:
|
||
${nixpkgs.lib.concatMapStringsSep "\n" (c: " ✗ ${c.n}") failed}''
|
||
else
|
||
pkgs.runCommand "nomarchy-auto-theme-timer" { }
|
||
"touch $out"; # all ${toString (builtins.length cases)} assertions held at eval
|
||
|
||
# The bridge, end to end on a booted machine: checks.state-bridges
|
||
# proves the option flips at eval, this proves a state key alone
|
||
# reaches a RUNNING service. The node sets no
|
||
# nomarchy.services.printing.enable anywhere — the only thing
|
||
# asking for CUPS is settings.printing.enable in the state file,
|
||
# which is exactly what Control Center's Printing toggle writes and
|
||
# what nothing read until #116 (ROADMAP § state bridges).
|
||
printing-from-state = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-printing-from-state";
|
||
nodes.machine = { ... }: {
|
||
imports = [ ./modules/nixos/options.nix ./modules/nixos/services.nix ];
|
||
nomarchy.system.stateFile = flippedState;
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
# CUPS is up because a JSON key said so, and for no other reason.
|
||
machine.wait_for_unit("cups.service")
|
||
# mDNS rides along, so a network printer is discoverable —
|
||
# the half of the toggle's promise that isn't the daemon.
|
||
machine.wait_for_unit("avahi-daemon.service")
|
||
'';
|
||
};
|
||
|
||
# Every hex-on-hex text pairing the generated swaync CSS uses
|
||
# must contrast in EVERY palette — summer-day's body text was
|
||
# invisible on hardware (item 25: subtext==base there). Cheap
|
||
# (no VM); extend the script's PAIRINGS as more generated
|
||
# surfaces move to palette-safe roles (item 27: waybar/rofi).
|
||
theme-contrast = pkgs.runCommand "nomarchy-theme-contrast"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-theme-contrast.py} ${./themes}
|
||
touch $out
|
||
'';
|
||
|
||
# Whole-swap waybar.css is read raw (no palette prepend) — every
|
||
# @color it references must be @define-color'd in the same file
|
||
# (neon-glass shipped zero defines; BACKLOG #62). Also gate
|
||
# preview.png + non-empty backgrounds/ per preset — run against
|
||
# the merged tree (pkgs.nomarchy-default-themes) so this doubles
|
||
# as the drift guard between this repo and nomarchy-wallpapers.
|
||
theme-wholeswap = pkgs.runCommand "nomarchy-theme-wholeswap"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-theme-wholeswap.py} ${pkgs.nomarchy-default-themes}
|
||
touch $out
|
||
'';
|
||
|
||
# The README option tables must track the live `nomarchy.*`
|
||
# surface. The surface is walked at eval time from the four
|
||
# option-declaring modules (their lazy `config` halves stay
|
||
# unevaluated), so the live side of the diff can't go stale;
|
||
# the script wants a table row per option (inline `.leaf`
|
||
# mentions ok for non-enable sub-knobs) and flags stale rows.
|
||
option-docs =
|
||
let
|
||
lib = nixpkgs.lib;
|
||
names = prefix: opts: lib.concatLists (lib.mapAttrsToList
|
||
(n: v:
|
||
if lib.isOption v
|
||
then lib.optional (!(v.readOnly or false)) "${prefix}${n}"
|
||
else names "${prefix}${n}." v)
|
||
opts);
|
||
surface = lib.concatLists [
|
||
(names "nomarchy."
|
||
((import ./modules/home/options.nix { inherit lib pkgs; config = { }; }).options.nomarchy))
|
||
(names "nomarchy.system."
|
||
((import ./modules/nixos/options.nix { inherit lib; config = { }; }).options.nomarchy.system))
|
||
(names "nomarchy.hardware."
|
||
((import ./modules/nixos/hardware.nix { inherit lib pkgs; config = { }; }).options.nomarchy.hardware))
|
||
(names "nomarchy.services."
|
||
((import ./modules/nixos/services.nix { inherit lib pkgs; config = { }; }).options.nomarchy.services))
|
||
];
|
||
in
|
||
pkgs.runCommand "nomarchy-option-docs"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-option-docs.py} \
|
||
${pkgs.writeText "option-surface.txt"
|
||
(lib.concatStringsSep "\n" surface + "\n")} \
|
||
${./README.md}
|
||
touch $out
|
||
'';
|
||
|
||
# The state-file friendly-errors contract (item 11): validate
|
||
# reports broken fixtures by field with a fix, good state
|
||
# passes, and an invalid `set` is refused BEFORE the write —
|
||
# the on-disk file must be byte-identical after the attempt.
|
||
state-sync-validate = pkgs.runCommand "nomarchy-state-sync-validate"
|
||
{ nativeBuildInputs = [ pkgs.python3 pkgs.jq ]; }
|
||
''
|
||
set -eu
|
||
tool=${./pkgs/nomarchy-state-sync/nomarchy-state-sync.py}
|
||
export NOMARCHY_DEFAULT_THEMES=${pkgs.nomarchy-default-themes}
|
||
|
||
mkdir good && cp ${./templates/downstream/state.json} good/state.json
|
||
chmod +w good/state.json
|
||
NOMARCHY_PATH=$PWD/good python3 "$tool" validate
|
||
|
||
mkdir syntax && printf '{ "slug": "x", }' > syntax/state.json
|
||
if NOMARCHY_PATH=$PWD/syntax python3 "$tool" validate 2>err; then
|
||
echo "FAIL: syntax fixture accepted"; exit 1; fi
|
||
grep -q "syntax error at line" err
|
||
|
||
mkdir badhex && jq '.colors.accent = "7aa2f7"' \
|
||
good/state.json > badhex/state.json
|
||
if NOMARCHY_PATH=$PWD/badhex python3 "$tool" validate 2>err; then
|
||
echo "FAIL: bad hex accepted"; exit 1; fi
|
||
grep -q "colors.accent" err && grep -q "fix:" err
|
||
|
||
mkdir badtype && jq '.ui.gapsOut = "12"' \
|
||
good/state.json > badtype/state.json
|
||
if NOMARCHY_PATH=$PWD/badtype python3 "$tool" validate 2>err; then
|
||
echo "FAIL: bad type accepted"; exit 1; fi
|
||
grep -q "ui.gapsOut" err
|
||
|
||
mkdir unknown && jq '.extraKey = 1' \
|
||
good/state.json > unknown/state.json
|
||
NOMARCHY_PATH=$PWD/unknown python3 "$tool" validate \
|
||
| grep -q "unknown top-level key"
|
||
|
||
cp good/state.json before.json
|
||
if NOMARCHY_PATH=$PWD/good python3 "$tool" --quiet \
|
||
set ui.gapsOut '"12"' --no-switch 2>err; then
|
||
echo "FAIL: invalid set was written"; exit 1; fi
|
||
grep -q "refusing to write" err
|
||
cmp good/state.json before.json
|
||
|
||
touch $out
|
||
'';
|
||
|
||
# The lifecycle "sweep before switch": with settings.autoCommit
|
||
# on, nomarchy-pull/-rebuild/-home first commit every dirty file
|
||
# in the machine flake (state-sync's own auto_commit stays
|
||
# pathspec-limited to state.json). Exercises the real
|
||
# helper binary against sandbox repos: flag on → one commit,
|
||
# clean tree, fallback identity, file list in the body; clean
|
||
# tree / flag off / no repo → untouched, never an error.
|
||
lifecycle-autocommit = pkgs.runCommand "nomarchy-lifecycle-autocommit"
|
||
{ nativeBuildInputs = [ pkgs.git ]; }
|
||
''
|
||
set -eu
|
||
tool=${pkgs.nomarchy-lifecycle.autocommit}/bin/nomarchy-autocommit
|
||
export HOME=$PWD # no global git config → fallback identity
|
||
|
||
mkdir flake && git -C flake init -q
|
||
printf '{ "settings": { "autoCommit": true } }' > flake/state.json
|
||
echo base > flake/system.nix
|
||
git -C flake add -A
|
||
git -C flake -c user.name=T -c user.email=t@t commit -qm init
|
||
|
||
echo edit >> flake/system.nix
|
||
echo lock > flake/flake.lock
|
||
NOMARCHY_PATH=$PWD/flake $tool rebuild
|
||
[ -z "$(git -C flake status --porcelain)" ] \
|
||
|| { echo "FAIL: tree still dirty after sweep"; exit 1; }
|
||
git -C flake log -1 --format=%s \
|
||
| grep -qx 'nomarchy: auto-commit before rebuild'
|
||
git -C flake log -1 --format=%ae | grep -qx 'nomarchy@localhost'
|
||
git -C flake log -1 --format=%b | grep -q 'flake.lock'
|
||
|
||
# Clean tree → no new commit
|
||
NOMARCHY_PATH=$PWD/flake $tool rebuild
|
||
[ "$(git -C flake rev-list --count HEAD)" = 2 ]
|
||
|
||
# Flag off → dirty stays dirty, no commit
|
||
printf '{ "settings": { "autoCommit": false } }' > flake/state.json
|
||
echo more >> flake/system.nix
|
||
NOMARCHY_PATH=$PWD/flake $tool rebuild
|
||
[ "$(git -C flake rev-list --count HEAD)" = 2 ]
|
||
[ -n "$(git -C flake status --porcelain)" ]
|
||
|
||
# Not a git repo → silent no-op, exit 0
|
||
mkdir plain
|
||
printf '{ "settings": { "autoCommit": true } }' > plain/state.json
|
||
NOMARCHY_PATH=$PWD/plain $tool rebuild
|
||
|
||
touch $out
|
||
'';
|
||
|
||
# Installer hardware DB ↔ nixos-hardware (item 49): every module
|
||
# name the DB table and the CPU/GPU/chassis autodetect reference
|
||
# must exist in the PINNED nixos-hardware.nixosModules — a lock
|
||
# bump can rename a module upstream (the X1 Carbon → x1-Nth-gen
|
||
# churn) and break profiled installs on just the matching DMI,
|
||
# invisible to any VM. The available-set is generated here from
|
||
# attrNames — the same value fed to nomarchy-install — so it
|
||
# tracks the lock automatically.
|
||
hardware-db-modules = pkgs.runCommand "nomarchy-hardware-db-modules"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-hardware-db.py} \
|
||
${./pkgs/nomarchy-install/hardware-db.sh} \
|
||
${pkgs.writeText "nixos-hardware-modules.txt"
|
||
(nixpkgs.lib.concatStringsSep "\n"
|
||
(builtins.attrNames nixos-hardware.nixosModules))}
|
||
touch $out
|
||
'';
|
||
|
||
# compose-lock.py offline-composer contract (item 49): the lock
|
||
# the installer writes on the target with no network must root at
|
||
# nomarchy, carry every upstream node one level down with its
|
||
# `follows` paths rebased, and fail closed on a missing narHash or
|
||
# a pre-existing nomarchy node. Pure — runs the script on fixtures.
|
||
installer-compose-lock = pkgs.runCommand "nomarchy-installer-compose-lock"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-compose-lock.py} \
|
||
${./pkgs/nomarchy-install/compose-lock.py}
|
||
touch $out
|
||
'';
|
||
|
||
# Installer safety guards (item 54): offline compose-lock fail-closed
|
||
# (no network flake lock), disk-signature warn regex, pre-activate
|
||
# recovery hint path, password floors, disk-picker filter (#112).
|
||
# Pure source+regex contract.
|
||
installer-safety = pkgs.runCommand "nomarchy-installer-safety"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-install-safety.py} \
|
||
${./pkgs/nomarchy-install/nomarchy-install.sh} \
|
||
${./pkgs/nomarchy-install/patch-template.py} \
|
||
${./templates/downstream}
|
||
touch $out
|
||
'';
|
||
|
||
# Menu Back/Left contract (#106): every internal rofi_menu list
|
||
# emits ↩ Back (external modi / free-text keep Esc exceptions).
|
||
menu-back = pkgs.runCommand "nomarchy-menu-back"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-menu-back.py} ${./modules/home/rofi.nix}
|
||
touch $out
|
||
'';
|
||
|
||
# Keybinds cheatsheet completeness + grouping (#108): every bind
|
||
# from keybinds.nix has a group, and every desc appears in the
|
||
# rendered cheatsheet (same source hyprland.nix binds from).
|
||
keybinds-cheatsheet =
|
||
let
|
||
inherit (nixpkgs.lib) assertMsg concatLists concatMapStringsSep
|
||
concatStringsSep elem filter hasInfix;
|
||
kb = import ./modules/home/keybinds.nix;
|
||
groups = [ "Window" "Workspace" "Menu" "Media" ];
|
||
allBinds = kb.binds ++ kb.multiLayoutBinds;
|
||
badGroup = filter (b: !(elem (b.group or null) groups)) allBinds;
|
||
extraBad = filter (e: !(elem (e.group or null) groups)) kb.extra;
|
||
descs = map (b: b.desc) allBinds ++ map (e: e.desc) kb.extra;
|
||
linesFor = g:
|
||
let rows = filter (b: b.group == g) allBinds
|
||
++ filter (e: e.group == g) kb.extra;
|
||
in if rows == [ ] then [ ]
|
||
else [ ("── " + g + " ──") ] ++ map (r: r.desc) rows;
|
||
sheet = concatStringsSep "\n" (concatLists (map linesFor groups));
|
||
missing = filter (d: !(hasInfix d sheet)) descs;
|
||
ok = badGroup == [ ] && extraBad == [ ] && missing == [ ]
|
||
&& hasInfix "── Window ──" sheet
|
||
&& hasInfix "── Workspace ──" sheet
|
||
&& hasInfix "── Menu ──" sheet
|
||
&& hasInfix "── Media ──" sheet;
|
||
msg =
|
||
(if badGroup != [ ] then
|
||
"binds missing/invalid group: "
|
||
+ concatMapStringsSep ", " (b: b.desc) badGroup + "\n"
|
||
else "")
|
||
+ (if extraBad != [ ] then
|
||
"extra missing/invalid group: "
|
||
+ concatMapStringsSep ", " (e: e.desc) extraBad + "\n"
|
||
else "")
|
||
+ (if missing != [ ] then
|
||
"descs missing from grouped sheet: "
|
||
+ concatStringsSep ", " missing + "\n"
|
||
else "");
|
||
in
|
||
assert assertMsg ok ("keybinds-cheatsheet: " + msg);
|
||
pkgs.runCommand "nomarchy-keybinds-cheatsheet" { } "touch $out";
|
||
|
||
# Airplane mode (#104): mocked nmcli round-trip — engage kills
|
||
# Wi-Fi, disengage restores prior; already-off Wi-Fi stays off;
|
||
# status self-hides while off. Real radios are V3 (HARDWARE-QUEUE).
|
||
airplane = pkgs.runCommand "nomarchy-airplane"
|
||
{ nativeBuildInputs = [ pkgs.bash pkgs.coreutils pkgs.gnugrep ]; }
|
||
''
|
||
set -eu
|
||
air=${pkgs.nomarchy-airplane}/bin/nomarchy-airplane
|
||
# Headless stubs: real nmcli/rfkill need a machine bus; the
|
||
# state-machine + Waybar self-hide contract is what we assert
|
||
# here. Prior-restore against live radios is V3.
|
||
# Shebang must be an absolute store bash — pure sandbox has
|
||
# no /usr/bin/env.
|
||
mock=$(mktemp -d)
|
||
shebang='#!${pkgs.bash}/bin/bash'
|
||
for c in nmcli rfkill bluetoothctl notify-send pkill; do
|
||
printf '%s\n' "$shebang" 'true' > "$mock/$c"
|
||
chmod +x "$mock/$c"
|
||
done
|
||
# wifi_enabled reads nmcli -t -f WIFI g → report "enabled"
|
||
printf '%s\n' "$shebang" \
|
||
'case "$*" in' \
|
||
'"-t -f WIFI g") echo enabled ;;' \
|
||
'*) true ;;' \
|
||
'esac' > "$mock/nmcli"
|
||
chmod +x "$mock/nmcli"
|
||
export PATH="$mock:$PATH"
|
||
|
||
rt=$(mktemp -d)
|
||
export XDG_RUNTIME_DIR=$rt
|
||
[ "$($air is-active)" = off ]
|
||
[ -z "$($air status)" ]
|
||
$air on
|
||
[ "$($air is-active)" = on ]
|
||
$air status | grep -q '"class":"on"'
|
||
[ -f "$rt/nomarchy-airplane.state" ]
|
||
grep -q 'wifi_prior=1' "$rt/nomarchy-airplane.state"
|
||
$air off
|
||
[ "$($air is-active)" = off ]
|
||
[ -z "$($air status)" ]
|
||
[ ! -f "$rt/nomarchy-airplane.state" ]
|
||
|
||
grep -q 'airplane' ${./modules/home/rofi.nix}
|
||
grep -q 'Airplane mode' ${./modules/home/rofi.nix}
|
||
grep -q 'nomarchy-menu airplane' ${./modules/home/keybinds.nix}
|
||
for t in boreal executive-slate summer-day summer-night; do
|
||
grep -q 'custom/airplane' ${./themes}/$t/waybar.jsonc
|
||
grep -q 'custom-airplane' ${./themes}/$t/waybar.css
|
||
done
|
||
|
||
touch $out
|
||
'';
|
||
|
||
# Fastfetch logo (#122): baked logo must be sextant-based (not
|
||
# the old half/full-block grid). Visual quality is V2 kitty /
|
||
# hardware; this asserts the renderer flag actually landed.
|
||
fastfetch-logo =
|
||
let
|
||
logo = pkgs.runCommand "nomarchy-fastfetch-logo-check"
|
||
{ nativeBuildInputs = [ pkgs.imagemagick pkgs.librsvg pkgs.chafa ]; }
|
||
''
|
||
rsvg-convert -w 360 ${./modules/nixos/branding/logo.svg} > logo.png
|
||
magick logo.png -fill "#B79BE8" -colorize 100 logo-c.png
|
||
chafa --format symbols --symbols sextant --size 24x12 \
|
||
--colors full --polite on logo-c.png > $out
|
||
'';
|
||
in
|
||
pkgs.runCommand "nomarchy-fastfetch-logo"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
set -eu
|
||
grep -q 'symbols sextant' ${./modules/home/fastfetch.nix}
|
||
grep -q 'size 24x12' ${./modules/home/fastfetch.nix}
|
||
python3 - <<'PY'
|
||
from pathlib import Path
|
||
t = Path("${logo}").read_text(encoding="utf-8")
|
||
sext = sum(1 for c in t if 0x1FB00 <= ord(c) <= 0x1FBFF)
|
||
if sext < 20:
|
||
raise SystemExit(f"too few sextant chars: {sext}")
|
||
print(f"fastfetch-logo: {sext} sextant chars")
|
||
PY
|
||
touch $out
|
||
'';
|
||
|
||
# Generation prune (#128): pure selection self-test (14d + keep ≥3
|
||
# past) baked into the script; assert timer+service on the distro.
|
||
gen-prune =
|
||
let
|
||
node = self.nixosConfigurations.nomarchy;
|
||
timerWanted =
|
||
builtins.elem "timers.target"
|
||
(node.config.systemd.timers.nomarchy-gen-prune.wantedBy or [ ]);
|
||
execStart =
|
||
node.config.systemd.services.nomarchy-gen-prune.serviceConfig.ExecStart or "";
|
||
gcOpts = node.config.nix.gc.options or "";
|
||
noAgeWipe =
|
||
gcOpts == ""
|
||
|| !(nixpkgs.lib.hasInfix "delete-older-than" gcOpts);
|
||
in
|
||
assert nixpkgs.lib.assertMsg timerWanted
|
||
"gen-prune: timer not wanted by timers.target";
|
||
assert nixpkgs.lib.assertMsg (execStart != "")
|
||
"gen-prune: service ExecStart empty";
|
||
assert nixpkgs.lib.assertMsg noAgeWipe
|
||
"gen-prune: nix.gc.options must not use --delete-older-than (got: ${gcOpts})";
|
||
pkgs.runCommand "nomarchy-gen-prune"
|
||
{ nativeBuildInputs = [ pkgs.bash ]; }
|
||
''
|
||
set -eu
|
||
${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --self-test
|
||
${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --dry-run >/dev/null
|
||
touch $out
|
||
'';
|
||
|
||
# Installer keyboard no-variant regression (item 91): gum's `(none)`
|
||
# row is display-only. Exercise it through the real template
|
||
# patcher, then build the exact us + empty-variant console keymap
|
||
# which previously failed as xkb-console-keymap.drv.
|
||
installer-keyboard =
|
||
let
|
||
keyboardSystem = nixpkgs.lib.nixosSystem {
|
||
inherit system;
|
||
modules = [{
|
||
console.useXkbConfig = true;
|
||
services.xserver.xkb.layout = "us";
|
||
services.xserver.xkb.variant = "";
|
||
system.stateVersion = "26.05";
|
||
}];
|
||
};
|
||
in
|
||
pkgs.runCommand "nomarchy-installer-keyboard"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-install-safety.py} \
|
||
${./pkgs/nomarchy-install/nomarchy-install.sh} \
|
||
${./pkgs/nomarchy-install/patch-template.py} \
|
||
${./templates/downstream}
|
||
test -s ${keyboardSystem.config.console.keyMap}
|
||
touch $out
|
||
'';
|
||
|
||
# Installer share ↔ templates/downstream SoT (item 72): the four
|
||
# files default.nix copies into share/.../template/ must stay
|
||
# byte-identical to the checkout template (single machine-seed
|
||
# SoT; no thinner second catalog). Cheap package build + cmp.
|
||
template-sot = pkgs.runCommand "nomarchy-template-sot"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-template-sot.py} \
|
||
${nomarchyInstall}/share/nomarchy-install/template \
|
||
${./templates/downstream}
|
||
touch $out
|
||
'';
|
||
|
||
# The installer's disko arg contract (item 49): swapSize "0"/"0G"
|
||
# (bare "0" is what the swap=0 install passes) must yield NO @swap
|
||
# subvolume, a sized value a correctly-sized one, and withLuks must
|
||
# gate the LUKS wrapper around the BTRFS root. Pure eval of
|
||
# disko-config.nix (a plain function) — no disko, no VM.
|
||
installer-disko =
|
||
let
|
||
diskoFor = args: import ./pkgs/nomarchy-install/disko-config.nix
|
||
({ mainDrive = "/dev/vda"; } // args);
|
||
rootContent = args:
|
||
(diskoFor args).disko.devices.disk.main.content.partitions.root.content;
|
||
btrfsOf = c: if c.type == "luks" then c.content else c;
|
||
subvols = args: (btrfsOf (rootContent args)).subvolumes;
|
||
hasSwap = args: builtins.hasAttr "@swap" (subvols args);
|
||
expect = cond: msg: nixpkgs.lib.assertMsg cond "installer-disko: ${msg}";
|
||
ok =
|
||
expect (! hasSwap { swapSize = "0"; })
|
||
''swapSize "0" produced an @swap subvol''
|
||
&& expect (! hasSwap { swapSize = "0G"; })
|
||
''swapSize "0G" produced an @swap subvol''
|
||
&& expect (hasSwap { swapSize = "2G"; })
|
||
''swapSize "2G" produced no @swap subvol''
|
||
&& expect ((subvols { swapSize = "2G"; })."@swap".swap.swapfile.size == "2G")
|
||
''the @swap swapfile size is not "2G"''
|
||
&& expect ((rootContent { withLuks = true; }).type == "luks")
|
||
"withLuks=true did not wrap root in luks"
|
||
&& expect ((rootContent { withLuks = true; }).name == "crypted")
|
||
"the luks device is not named 'crypted'"
|
||
&& expect ((rootContent { withLuks = false; }).type == "btrfs")
|
||
"withLuks=false did not put btrfs directly on root"
|
||
&& expect (builtins.hasAttr "@" (subvols { withLuks = false; }))
|
||
"the no-luks root lost its @ subvolume";
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-installer-disko" { } "touch $out";
|
||
|
||
# Hyprland windowrule dead-syntax guard (item 49): build the
|
||
# GENERATED hyprland.conf (the ISO's HM output) and fail on a
|
||
# regression to the pre-0.53 grammar — the `windowrulev2` keyword,
|
||
# rule-first order (`float, class:^…$`), or a bare `<prop>:`
|
||
# matcher. That syntax shipped a red config-error banner on every
|
||
# boot (fixed in ed7fd93); this locks the correct form. Building
|
||
# one config file is a pure derivation, not a VM.
|
||
windowrule-syntax =
|
||
let
|
||
hyprconf = self.nixosConfigurations.nomarchy-live.config
|
||
.home-manager.users.${username}.xdg.configFile."hypr/hyprland.conf".source;
|
||
in
|
||
pkgs.runCommand "nomarchy-windowrule-syntax"
|
||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||
''
|
||
python3 ${./tools/check-windowrule-syntax.py} ${hyprconf}
|
||
touch $out
|
||
'';
|
||
|
||
# Live-ISO baseline apps (ROADMAP § live-ISO baseline apps, #103)
|
||
# plus the mime-default presence guard (#94 / #119). The live
|
||
# session shipped no browser and no office for as long as it
|
||
# existed — the thing a user judges the distro by before installing,
|
||
# and boots to rescue a dead machine. Nothing in a build fails when
|
||
# an app silently leaves the live profile (it just stops being in
|
||
# the launcher), and a mime default that names a missing .desktop is
|
||
# silently skipped by GIO (no handler at all) — so assert both:
|
||
# binaries + launcher entries for the baseline suite, and that
|
||
# every Default Applications key has at least one listed .desktop
|
||
# present in the live profile or system path (thunar is system-side).
|
||
# Same artifacts the ISO ships, no ISO build.
|
||
live-baseline-apps =
|
||
let
|
||
liveHm =
|
||
self.nixosConfigurations.nomarchy-live.config.home-manager.users.${username};
|
||
gen = liveHm.home.activationPackage;
|
||
# system.path carries NixOS-side apps (thunar); HM-only
|
||
# home-path does not. A mime default that only names a
|
||
# system desktop must still resolve.
|
||
sysPath =
|
||
self.nixosConfigurations.nomarchy-live.config.system.path;
|
||
# Template install path: same mime defaults, different package
|
||
# set (vscode present, gnome-text-editor not). Guard both so
|
||
# a live-only fix cannot re-break the installed machine.
|
||
templateGen = downstream.homeConfigurations.me.activationPackage;
|
||
templateSys =
|
||
downstream.nixosConfigurations.default.config.system.path;
|
||
in
|
||
pkgs.runCommand "nomarchy-live-baseline-apps"
|
||
{ nativeBuildInputs = [ pkgs.gnugrep pkgs.findutils pkgs.python3 ]; }
|
||
''
|
||
set -euo pipefail
|
||
apps=${gen}/home-path/share/applications
|
||
bins=${gen}/home-path/bin
|
||
|
||
# Binary + launcher entry for each baseline app. The .desktop
|
||
# is what makes it discoverable — VISION's "discoverable
|
||
# without reading the README" — so a bin without one fails.
|
||
for d in chromium-browser.desktop \
|
||
startcenter.desktop writer.desktop calc.desktop \
|
||
impress.desktop \
|
||
org.gnome.TextEditor.desktop \
|
||
io.bassi.Amberol.desktop \
|
||
mpv.desktop \
|
||
org.gnome.Snapshot.desktop; do
|
||
test -f "$apps/$d" || { echo "live-baseline-apps: MISSING launcher entry $d"; exit 1; }
|
||
done
|
||
|
||
for b in chromium libreoffice gnome-text-editor amberol mpv snapshot; do
|
||
test -x "$bins/$b" || { echo "live-baseline-apps: MISSING binary $b"; exit 1; }
|
||
done
|
||
|
||
# HTTP must still resolve to the browser that is actually
|
||
# present (#94: mime named chromium while nothing shipped it,
|
||
# so GIO silently skipped the entry and the live session had
|
||
# no default browser at all).
|
||
mimes=${gen}/home-files/.config/mimeapps.list
|
||
grep -q '^x-scheme-handler/https=chromium-browser.desktop' "$mimes" \
|
||
|| { echo "live-baseline-apps: https mime must name chromium-browser.desktop (#94)"; exit 1; }
|
||
test -f "$apps/chromium-browser.desktop"
|
||
|
||
# text/plain must prefer vscode but fall through to the live
|
||
# editor (#119) — never a singleton that only names code.
|
||
grep -qE '^text/plain=.*org\.gnome\.TextEditor\.desktop' "$mimes" \
|
||
|| { echo "live-baseline-apps: text/plain must fall through to org.gnome.TextEditor.desktop (#119)"; exit 1; }
|
||
test -f "$apps/org.gnome.TextEditor.desktop"
|
||
|
||
# Every Default Applications key: at least one listed
|
||
# .desktop must exist in home-path OR system path. A key
|
||
# whose every entry is missing is the #94/#119 bug class.
|
||
# Covers live *and* the default template install.
|
||
python3 - "$mimes" "$apps" "${sysPath}/share/applications" \
|
||
"${templateGen}/home-files/.config/mimeapps.list" \
|
||
"${templateGen}/home-path/share/applications" \
|
||
"${templateSys}/share/applications" <<'PY'
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
def check(label, mimes, *app_dirs):
|
||
text = Path(mimes).read_text()
|
||
in_defaults = False
|
||
bad = []
|
||
for line in text.splitlines():
|
||
if line.startswith("["):
|
||
in_defaults = line.strip() == "[Default Applications]"
|
||
continue
|
||
if not in_defaults or not line.strip() or line.startswith("#"):
|
||
continue
|
||
if "=" not in line:
|
||
continue
|
||
key, val = line.split("=", 1)
|
||
desktops = [d for d in val.split(";") if d.strip()]
|
||
if not desktops:
|
||
bad.append(f"{key}= (empty)")
|
||
continue
|
||
if not any(
|
||
any((Path(d) / desk).is_file() for d in app_dirs)
|
||
for desk in desktops
|
||
):
|
||
bad.append(f"{key}={';'.join(desktops)} (none present)")
|
||
if bad:
|
||
print(f"live-baseline-apps: mime defaults missing on {label}:")
|
||
for b in bad:
|
||
print(f" {b}")
|
||
sys.exit(1)
|
||
print(f"live-baseline-apps: mime defaults ok on {label}")
|
||
|
||
check("live", sys.argv[1], sys.argv[2], sys.argv[3])
|
||
check("template", sys.argv[4], sys.argv[5], sys.argv[6])
|
||
PY
|
||
|
||
# Firefox is deliberately absent — a second browser is the one
|
||
# item in #103's list that costs a real closure, and chromium
|
||
# owns the mime default (Bernardo 2026-07-14). If it ever
|
||
# ships here, that is a size decision, not a drive-by.
|
||
test ! -f "$apps/firefox.desktop" || { echo "live-baseline-apps: firefox reappeared — size decision, see #103"; exit 1; }
|
||
|
||
echo "live-baseline-apps: ok — browser, office, editor, music, video, camera + mime defaults resolve"
|
||
touch $out
|
||
'';
|
||
|
||
# Live-ISO install affordance (BACKLOG #57): desktop entry baked
|
||
# into the live host's HM generation + installer on PATH + Tools
|
||
# menu gate in nomarchy-menu. Builds the real nomarchy-live HM
|
||
# package (not a full ISO) — V2 file-level smoke of the same
|
||
# artifacts the ISO ships.
|
||
live-install-entry =
|
||
let
|
||
liveHm =
|
||
self.nixosConfigurations.nomarchy-live.config.home-manager.users.${username};
|
||
gen = liveHm.home.activationPackage;
|
||
in
|
||
pkgs.runCommand "nomarchy-live-install-entry"
|
||
{
|
||
nativeBuildInputs = [ pkgs.gnugrep pkgs.findutils ];
|
||
passAsFile = [ ];
|
||
}
|
||
''
|
||
set -euo pipefail
|
||
desk=$(find ${gen}/home-path/share/applications \
|
||
-name nomarchy-install.desktop | head -1)
|
||
test -n "$desk"
|
||
grep -q '^Name=Install Nomarchy$' "$desk"
|
||
grep -q '^Terminal=true$' "$desk"
|
||
grep -q '^Exec=nomarchy-install$' "$desk"
|
||
test -x ${nomarchyInstall}/bin/nomarchy-install
|
||
# Tools › Install Nomarchy self-gate lives in nomarchy-menu
|
||
# (rofi.nix), which is on the live HM path.
|
||
menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1)
|
||
test -n "$menu"
|
||
grep -q 'Install Nomarchy' "$menu"
|
||
echo "live-install-entry: ok desk=$desk menu=$menu"
|
||
touch $out
|
||
'';
|
||
|
||
# smartd self-gate (BACKLOG #118). Where no drive answers SMART,
|
||
# smartd exits 17 and systemd marks the unit FAILED — so the doctor
|
||
# honestly reported a failed unit and Waybar went red on every QEMU
|
||
# guest and most live USBs. Both halves matter and they pull in
|
||
# opposite directions, which is why both are asserted here: a
|
||
# gate that never skips leaves the red icon, and a gate that always
|
||
# skips silently disables drive-health monitoring on real hardware —
|
||
# the failure nobody would notice until a disk died quietly.
|
||
smartd-gate = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-smartd-gate";
|
||
nodes = {
|
||
# QEMU virtio: no SMART. The machine Bernardo saw. Imports the
|
||
# REAL distro module, so this guards the shipped wiring rather
|
||
# than a restatement of it — runNixOSTest supplies the node's
|
||
# pkgs, so the module's own `nixpkgs.config` has to yield.
|
||
nosmart = { lib, pkgs, ... }: {
|
||
imports = [ ./modules/nixos/default.nix ];
|
||
nixpkgs.config = lib.mkForce { allowUnfree = true; };
|
||
nomarchy.system.greeter.enable = false;
|
||
# For the test's own probing only — the gate calls smartctl by
|
||
# store path, so this changes nothing about what is measured.
|
||
environment.systemPackages = [ pkgs.smartmontools ];
|
||
};
|
||
};
|
||
testScript = ''
|
||
nosmart.wait_for_unit("multi-user.target")
|
||
|
||
# The gate itself agrees there is nothing to monitor.
|
||
cond = nosmart.succeed(
|
||
"systemctl show -p ExecCondition --value smartd.service"
|
||
)
|
||
assert "smartd-any-smart-device" in cond, f"gate not wired: {cond}"
|
||
nosmart.succeed("smartctl --scan | tee /dev/stderr | (! grep -q .)")
|
||
|
||
# Inactive, NOT failed — the whole point. Before #118 this was
|
||
# 'failed' with Result=exit-code / status=17.
|
||
state = nosmart.succeed(
|
||
"systemctl show -p ActiveState --value smartd.service"
|
||
).strip()
|
||
assert state == "inactive", f"expected inactive, got {state}"
|
||
nosmart.fail("systemctl is-failed --quiet smartd.service")
|
||
|
||
# And therefore the thing the user actually sees: the doctor's
|
||
# failed-unit check is what reddened the Waybar icon.
|
||
failed = nosmart.succeed("systemctl --failed --no-legend --plain")
|
||
assert "smartd" not in failed, f"smartd still failed: {failed}"
|
||
|
||
# The other half: a machine WITH a SMART device must still run
|
||
# smartd. QEMU cannot answer SMART, so drive the gate's logic
|
||
# against a scan that finds one, rather than pretend otherwise.
|
||
nosmart.succeed(
|
||
"mkdir -p /tmp/fake && "
|
||
"printf '#!/bin/sh\\necho \"/dev/sda -d scsi # /dev/sda, SCSI device\"\\n' "
|
||
"> /tmp/fake/smartctl && chmod +x /tmp/fake/smartctl"
|
||
)
|
||
gate = cond.split("argv[]=")[1].split(" ")[0].strip("; ]")
|
||
nosmart.succeed(
|
||
f"sed 's|/nix/store/[^ ]*/bin/smartctl|/tmp/fake/smartctl|' {gate} > /tmp/gate2"
|
||
)
|
||
nosmart.succeed("chmod +x /tmp/gate2")
|
||
nosmart.succeed("/tmp/gate2") # exit 0 => smartd would start
|
||
'';
|
||
};
|
||
|
||
# nomarchy-doctor's contract: an induced failed unit flips the
|
||
# sheet to ✖/exit-1 and names the unit; with the failure
|
||
# cleared it reports healthy/exit-0. Minimal node (just the
|
||
# package) — the disk/flake/snapper checks self-skip in a VM.
|
||
doctor = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-doctor";
|
||
nodes.machine = { ... }: {
|
||
environment.systemPackages = [ pkgs.nomarchy-doctor ];
|
||
systemd.services.doomed = {
|
||
description = "deliberately failing unit (doctor test)";
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
ExecStart = "${pkgs.coreutils}/bin/false";
|
||
};
|
||
wantedBy = [ "multi-user.target" ];
|
||
};
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
machine.wait_until_succeeds("systemctl is-failed doomed.service")
|
||
out = machine.fail("nomarchy-doctor 2>&1")
|
||
assert "doomed.service" in out, f"doctor did not name the failed unit:\n{out}"
|
||
assert "journalctl" in out, f"doctor did not print a fix:\n{out}"
|
||
machine.succeed("systemctl reset-failed")
|
||
# The healthy run also exercises the #44 hardware section: in a
|
||
# bare VM every probe (nmcli/wpctl/vainfo/fprintd/fwupd) is
|
||
# absent, so each check must self-gate to a skip row and the
|
||
# sheet must still exit 0 — i.e. the section runs without
|
||
# erroring under `set -euo pipefail`.
|
||
healthy = machine.succeed("nomarchy-doctor 2>&1")
|
||
for probe in ["NetworkManager", "fingerprint", "fwupd", "VA-API", "hibernate", "battery health"]:
|
||
assert probe in healthy, f"doctor hardware section missing a self-gated '{probe}' row:\n{healthy}"
|
||
# Machine-state drift: linger (#147's worked example) — the
|
||
# flake never sets it, so a "yes" here is drift; doctor must
|
||
# warn (exit 0), not fail, and the warning must clear once the
|
||
# marker is gone.
|
||
machine.succeed("loginctl enable-linger root")
|
||
linger_on = machine.succeed("nomarchy-doctor 2>&1")
|
||
assert "Nomarchy never sets it" in linger_on, f"doctor did not flag linger drift:\n{linger_on}"
|
||
machine.succeed("loginctl disable-linger root")
|
||
linger_off = machine.succeed("nomarchy-doctor 2>&1")
|
||
assert "Nomarchy never sets it" not in linger_off, (
|
||
f"doctor still flags linger drift after disable-linger:\n{linger_off}"
|
||
)
|
||
# #80 battery health: faked sysfs (test_power has no cycle/design).
|
||
machine.succeed(
|
||
"mkdir -p /tmp/fake-ps/BAT0 && "
|
||
"echo Battery > /tmp/fake-ps/BAT0/type && "
|
||
"echo System > /tmp/fake-ps/BAT0/scope && "
|
||
"echo 150 > /tmp/fake-ps/BAT0/cycle_count && "
|
||
"echo 4000000 > /tmp/fake-ps/BAT0/charge_full && "
|
||
"echo 5000000 > /tmp/fake-ps/BAT0/charge_full_design"
|
||
)
|
||
bat = machine.succeed(
|
||
"NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1"
|
||
)
|
||
assert "BAT0 health" in bat, f"doctor missing battery health line:\n{bat}"
|
||
assert "150 cycles" in bat, f"doctor missing cycle_count:\n{bat}"
|
||
assert "80% of design capacity" in bat, f"doctor missing design %:\n{bat}"
|
||
# Peripheral Device-scope batteries must not produce a health line.
|
||
machine.succeed(
|
||
"mkdir -p /tmp/fake-ps/MOUSE0 && "
|
||
"echo Battery > /tmp/fake-ps/MOUSE0/type && "
|
||
"echo Device > /tmp/fake-ps/MOUSE0/scope && "
|
||
"echo 9 > /tmp/fake-ps/MOUSE0/cycle_count"
|
||
)
|
||
bat2 = machine.succeed(
|
||
"NOMARCHY_POWER_SUPPLY_ROOT=/tmp/fake-ps nomarchy-doctor 2>&1"
|
||
)
|
||
assert "MOUSE0" not in bat2, f"doctor reported Device-scope battery:\n{bat2}"
|
||
# #83 first-boot pre-activate fail flag: log present, no HM gen → ✖.
|
||
machine.succeed(
|
||
"mkdir -p /var/log && "
|
||
"echo 'error: activation failed' > /var/log/nomarchy-hm-preactivate.log"
|
||
)
|
||
# Bare test VM has no per-user HM profile for root.
|
||
pre = machine.fail("nomarchy-doctor 2>&1")
|
||
assert "pre-activat" in pre.lower() or "Home Manager generation" in pre, (
|
||
f"doctor missing pre-activate fail line:\n{pre}"
|
||
)
|
||
assert "home-manager switch" in pre, f"doctor missing recovery one-liner:\n{pre}"
|
||
# Once a generation exists, the flag clears (recovered).
|
||
machine.succeed(
|
||
"mkdir -p /nix/var/nix/profiles/per-user/root && "
|
||
"ln -sfn /run/current-system "
|
||
"/nix/var/nix/profiles/per-user/root/home-manager"
|
||
)
|
||
pre_ok = machine.succeed("nomarchy-doctor 2>&1")
|
||
assert "desktop generation is present" in pre_ok, (
|
||
f"doctor should ok when HM gen exists:\n{pre_ok}"
|
||
)
|
||
'';
|
||
};
|
||
|
||
# Config-level VM assertions for the nomarchy.hardware.* toggles —
|
||
# what they SET (kernel cmdline, fprintd, PAM). Real hardware
|
||
# behaviour (firmware/driver/device) needs bare metal and is out of
|
||
# scope here. Imports only hardware.nix, so the VM stays minimal.
|
||
# Three nodes: pam=true wires the PARALLEL module by default
|
||
# (pam_fprintd_grosshack in stock fprintd's rule slot, before
|
||
# pam_unix) and password-only auth must still work with no
|
||
# reader present — the lockout-safety invariant; parallel=false
|
||
# wires stock sequential pam_fprintd; pam=false wires neither
|
||
# (NixOS defaults fprintAuth from fprintd.enable — we force off).
|
||
hardware-toggles = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-hardware-toggles";
|
||
nodes = {
|
||
machine = { ... }: {
|
||
imports = [ ./modules/nixos/hardware.nix ];
|
||
nomarchy.hardware = {
|
||
intel.enable = true;
|
||
amd.enable = true;
|
||
fingerprint = { enable = true; pam = true; };
|
||
npu.enable = true;
|
||
};
|
||
users.users.alice = {
|
||
isNormalUser = true;
|
||
password = "t0ps3cret";
|
||
extraGroups = [ "wheel" ];
|
||
};
|
||
};
|
||
seqpam = { ... }: {
|
||
imports = [ ./modules/nixos/hardware.nix ];
|
||
nomarchy.hardware.fingerprint = {
|
||
enable = true; pam = true; parallel = false;
|
||
};
|
||
};
|
||
nopam = { ... }: {
|
||
imports = [ ./modules/nixos/hardware.nix ];
|
||
nomarchy.hardware.fingerprint = { enable = true; pam = false; };
|
||
};
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
cmdline = machine.succeed("cat /proc/cmdline")
|
||
assert "amd_pstate=active" in cmdline, cmdline
|
||
assert "i915.enable_guc=3" in cmdline, cmdline
|
||
machine.succeed("systemctl cat fprintd.service")
|
||
machine.succeed("grep -q pam_fprintd_grosshack /etc/pam.d/sudo")
|
||
# The parallel rule must be an auth line ordered BEFORE
|
||
# pam_unix — its typed password is only ferried to pam_unix.
|
||
machine.succeed(
|
||
"awk '/^auth/ && /pam_fprintd_grosshack/ {g=NR} "
|
||
"/^auth/ && /pam_unix/ {u=NR} "
|
||
"END {exit !(g && u && g < u)}' /etc/pam.d/sudo"
|
||
)
|
||
# Lockout safety: this VM has no fingerprint reader, so the
|
||
# grosshack rule can only fail through — the password alone
|
||
# must still authenticate sudo (and a wrong one must not).
|
||
machine.succeed("su - alice -c 'echo t0ps3cret | sudo -S -k true'")
|
||
machine.fail("su - alice -c 'echo wrongpw | sudo -S -k true'")
|
||
|
||
seqpam.wait_for_unit("multi-user.target")
|
||
seqpam.succeed("grep -q pam_fprintd /etc/pam.d/sudo")
|
||
seqpam.fail("grep -q pam_fprintd_grosshack /etc/pam.d/sudo")
|
||
|
||
nopam.wait_for_unit("multi-user.target")
|
||
nopam.succeed("systemctl cat fprintd.service")
|
||
nopam.fail("grep -q pam_fprintd /etc/pam.d/sudo")
|
||
nopam.fail("grep -q pam_fprintd /etc/pam.d/login")
|
||
'';
|
||
};
|
||
|
||
# Dock audio sink priority (#87): WirePlumber rules prefer
|
||
# HDMI/DP/USB sinks over built-in analog. Pure unit test of the
|
||
# rule set + a minimal VM that the conf file is installed where
|
||
# WirePlumber reads it (XDG_DATA_DIRS / share/wireplumber).
|
||
dock-audio =
|
||
let
|
||
inherit (nixpkgs) lib;
|
||
rules = import ./modules/nixos/dock-audio-rules.nix;
|
||
alsa = rules."monitor.alsa.rules" or [ ];
|
||
expect = cond: msg: lib.assertMsg cond "dock-audio: ${msg}";
|
||
json = builtins.toJSON alsa;
|
||
pureOk =
|
||
expect (builtins.isList alsa && builtins.length alsa >= 2)
|
||
"expected ≥2 alsa rule groups"
|
||
&& expect (lib.hasInfix "hdmi" json)
|
||
"HDMI match missing from rules JSON"
|
||
&& expect (lib.hasInfix "usb-" json || lib.hasInfix "usb" json)
|
||
"USB match missing from rules JSON"
|
||
&& expect (lib.hasInfix "priority.session" json)
|
||
"priority.session missing"
|
||
&& expect (lib.hasInfix "1100" json)
|
||
"HDMI session priority 1100 missing";
|
||
in
|
||
assert pureOk;
|
||
pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-dock-audio";
|
||
nodes.machine = { ... }: {
|
||
services.pipewire = {
|
||
enable = true;
|
||
alsa.enable = true;
|
||
pulse.enable = true;
|
||
wireplumber.enable = true;
|
||
wireplumber.extraConfig."90-nomarchy-dock-audio" =
|
||
import ./modules/nixos/dock-audio-rules.nix;
|
||
};
|
||
# WirePlumber is a user service; enable linger-less check via
|
||
# the system data dir the package puts conf into.
|
||
environment.systemPackages = [ pkgs.wireplumber ];
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
conf = machine.succeed(
|
||
"find /nix/store -path "
|
||
"'*wireplumber.conf.d/90-nomarchy-dock-audio.conf' "
|
||
"2>/dev/null | head -1"
|
||
).strip()
|
||
assert conf, "90-nomarchy-dock-audio.conf not in the store"
|
||
body = machine.succeed(f"cat '{conf}'")
|
||
assert "monitor.alsa.rules" in body, body
|
||
assert "priority.session" in body, body
|
||
assert "hdmi" in body.lower() or "HDMI" in body, body
|
||
assert "usb" in body.lower(), body
|
||
'';
|
||
};
|
||
|
||
# Clamshell / dock lid policy (#86): logind must ignore lid close
|
||
# when docked (external display present). Real multi-monitor lid
|
||
# events stay V3; this asserts the baked logind.conf + that
|
||
# logind is alive to consume it. Imports options + power only.
|
||
clamshell-logind = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-clamshell-logind";
|
||
nodes.machine = { ... }: {
|
||
imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ];
|
||
nomarchy.system.power = {
|
||
enable = true;
|
||
laptop = true;
|
||
};
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
machine.wait_for_unit("systemd-logind.service")
|
||
conf = machine.succeed("cat /etc/systemd/logind.conf")
|
||
assert "HandleLidSwitchDocked=ignore" in conf.replace(" ", ""), (
|
||
f"clamshell: logind.conf missing HandleLidSwitchDocked=ignore:\n{conf}"
|
||
)
|
||
# logind must be running (policy is dead if the unit is down).
|
||
machine.succeed("systemctl is-active systemd-logind.service")
|
||
# busctl property: Docked lid policy is not always exported;
|
||
# re-read conf after logind start is enough for the contract.
|
||
'';
|
||
};
|
||
|
||
# Runtime VM check for the battery-charge-limit re-apply: the udev
|
||
# rule must start the oneshot on an AC state change (firmware can
|
||
# clear the threshold on unplug). The `test_power` module fakes a
|
||
# Mains adapter we can toggle to emit a real power_supply uevent —
|
||
# so this exercises the trigger, not just the config wiring. (The
|
||
# sysfs *write* needs a real charge_control_end_threshold and stays
|
||
# an on-hardware check.) Imports options.nix (where the power
|
||
# options live) + power.nix, so the VM stays minimal.
|
||
battery-charge-limit = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-battery-charge-limit";
|
||
nodes.machine = { pkgs, ... }: {
|
||
imports = [ ./modules/nixos/options.nix ./modules/nixos/power.nix ];
|
||
boot.kernelModules = [ "test_power" ]; # fake Mains + battery
|
||
nomarchy.system.power = {
|
||
enable = true;
|
||
laptop = true;
|
||
batteryChargeLimit = 80;
|
||
};
|
||
# Keep the oneshot active long enough to prove that a burst of
|
||
# Mains events coalesces instead of interrupting it.
|
||
systemd.services.nomarchy-battery-charge-limit.serviceConfig.ExecStartPost =
|
||
"${pkgs.coreutils}/bin/sleep 3";
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
|
||
# The oneshot exists and the fake Mains adapter is present.
|
||
machine.succeed("systemctl cat nomarchy-battery-charge-limit.service")
|
||
machine.succeed("udevadm settle")
|
||
machine.succeed("grep -lx Mains /sys/class/power_supply/*/type")
|
||
|
||
# Let boot/add-event starts and their delayed passes settle,
|
||
# then sample the last successful invocation.
|
||
machine.succeed("sleep 5")
|
||
machine.wait_until_succeeds(
|
||
'test "$(systemctl show -p ActiveState --value '
|
||
'nomarchy-battery-charge-limit.service)" = inactive',
|
||
timeout=30,
|
||
)
|
||
before = machine.succeed(
|
||
"systemctl show -p InvocationID --value "
|
||
"nomarchy-battery-charge-limit.service"
|
||
).strip()
|
||
|
||
# Simulate an AC unplug, wait until the oneshot is in its
|
||
# three-second active window, then emit a dock-like burst of
|
||
# real power_supply change events during that same invocation.
|
||
machine.succeed("echo off > /sys/module/test_power/parameters/ac_online")
|
||
machine.succeed("udevadm settle")
|
||
machine.wait_until_succeeds(
|
||
'test "$(systemctl show -p ActiveState --value '
|
||
'nomarchy-battery-charge-limit.service)" = activating',
|
||
timeout=10,
|
||
)
|
||
mains = machine.succeed(
|
||
"grep -lx Mains /sys/class/power_supply/*/type"
|
||
).strip().removesuffix("/type")
|
||
machine.succeed(
|
||
f"for i in $(seq 1 12); do "
|
||
f"udevadm trigger --action=change {mains}; done; udevadm settle"
|
||
)
|
||
|
||
# The delayed starts have all fired, the service returned to a
|
||
# clean inactive state, and at least one new invocation ran.
|
||
machine.succeed("sleep 8")
|
||
machine.wait_until_succeeds(
|
||
'test "$(systemctl show -p ActiveState --value '
|
||
'nomarchy-battery-charge-limit.service)" = inactive',
|
||
timeout=30,
|
||
)
|
||
machine.fail(
|
||
"systemctl is-failed --quiet nomarchy-battery-charge-limit.service"
|
||
)
|
||
machine.succeed(
|
||
'test "$(systemctl show -p Result --value '
|
||
'nomarchy-battery-charge-limit.service)" = success'
|
||
)
|
||
after = machine.succeed(
|
||
"systemctl show -p InvocationID --value "
|
||
"nomarchy-battery-charge-limit.service"
|
||
).strip()
|
||
assert after != before, (
|
||
f"battery charge limit was not re-invoked: {before}"
|
||
)
|
||
service_log = machine.succeed(
|
||
"journalctl -b -u nomarchy-battery-charge-limit.service --no-pager"
|
||
)
|
||
assert "start-limit-hit" not in service_log, service_log
|
||
assert "Failed with result" not in service_log, service_log
|
||
|
||
# SPACED storm (T14s, 2026-07-13): when each run finishes
|
||
# before the next AC event arrives nothing coalesces, and 5
|
||
# successful starts inside systemd's default 10s window mark
|
||
# the unit failed (start-limit-hit) although every run
|
||
# succeeded. The unit must stay exempt from start rate
|
||
# limiting — assert the exemption, then prove repeated
|
||
# back-to-back starts leave a clean state.
|
||
machine.succeed(
|
||
'test "$(systemctl show -p StartLimitIntervalUSec --value '
|
||
'nomarchy-battery-charge-limit.service)" = 0'
|
||
)
|
||
machine.succeed(
|
||
"for i in $(seq 1 8); do "
|
||
"systemctl start nomarchy-battery-charge-limit.service; done"
|
||
)
|
||
machine.fail(
|
||
"systemctl is-failed --quiet nomarchy-battery-charge-limit.service"
|
||
)
|
||
service_log = machine.succeed(
|
||
"journalctl -b -u nomarchy-battery-charge-limit.service --no-pager"
|
||
)
|
||
assert "start-limit-hit" not in service_log, service_log
|
||
'';
|
||
};
|
||
|
||
# Unit test of the monitor-rule overlay (modules/home/
|
||
# monitor-rules.nix — pure, imported directly, no HM eval): the
|
||
# active profile must replace base entries whole by name, leave
|
||
# unnamed base outputs alone, and the disable-guard must stop a
|
||
# stale menu resolution pick (settings.monitors) resurrecting a
|
||
# panel the profile disables. Junk in settings.displayProfile
|
||
# degrades to base config, never an eval error.
|
||
display-profiles =
|
||
let
|
||
inherit (nixpkgs) lib;
|
||
monitorLib = import ./modules/home/monitor-rules.nix { inherit lib; };
|
||
entry = a: monitorLib.defaults // a;
|
||
fixture = {
|
||
base = [
|
||
(entry { name = "eDP-1"; position = "0x0"; })
|
||
(entry { name = "HDMI-A-1"; position = "auto-right"; })
|
||
];
|
||
profiles.docked = [
|
||
(entry { name = "eDP-1"; resolution = "disable"; })
|
||
(entry { name = "DP-3"; position = "0x0"; })
|
||
];
|
||
resOverrides."eDP-1" = "1920x1080@60"; # stale pick — must stay dead
|
||
};
|
||
rulesFor = active: map monitorLib.rule
|
||
(monitorLib.resolve (fixture // { inherit active; }));
|
||
docked = rulesFor "docked";
|
||
junk = rulesFor 42; # hand-edited state (validator only warns)
|
||
expect = cond: msg: lib.assertMsg cond
|
||
"display-profiles: ${msg} (rendered: ${builtins.toJSON docked})";
|
||
ok =
|
||
expect (lib.elem "eDP-1, disable" docked)
|
||
"the profile did not disable eDP-1"
|
||
&& expect (! lib.any (lib.hasInfix "1920x1080") docked)
|
||
"a stale resolution pick resurrected the disabled panel"
|
||
&& expect (lib.elem "DP-3, preferred, 0x0, 1" docked)
|
||
"the profile-only output's rule is missing"
|
||
&& expect (lib.elem "HDMI-A-1, preferred, auto-right, 1" docked)
|
||
"an unnamed base output lost its rule"
|
||
&& expect (lib.elem "eDP-1, 1920x1080@60, 0x0, 1" junk)
|
||
"junk active profile must degrade to base config (with the pick applied)"
|
||
&& expect (monitorLib.workspaceRule "1" "DP-3" == "1, monitor:DP-3")
|
||
"workspace pin did not render as a Hyprland workspace rule";
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-display-profiles" { } "touch $out";
|
||
|
||
# The layout indicator's gate, and the parity of a whole-swap bar
|
||
# with the generated one. Both halves are pure — the interesting
|
||
# inputs (a fresh runtime memory, junk device rows) are awkward to
|
||
# stage in a VM and trivial to state here.
|
||
waybar-language =
|
||
let
|
||
inherit (nixpkgs) lib;
|
||
langLib = import ./modules/home/waybar-language.nix { inherit lib; };
|
||
expect = cond: msg: lib.assertMsg cond "waybar-language: ${msg}";
|
||
swap = builtins.fromJSON (builtins.readFile ./themes/boreal/waybar.jsonc);
|
||
gatedOff = langLib.gate false swap;
|
||
inSwap = bar: lib.any
|
||
(n: builtins.isList bar.${n} && lib.elem "hyprland/language" bar.${n})
|
||
(lib.filter (lib.hasPrefix "modules-") (lib.attrNames bar));
|
||
ok =
|
||
expect (! langLib.show { layout = "us"; })
|
||
"a plain single-layout session must not show the indicator"
|
||
&& expect (langLib.show { layout = "us,pt"; })
|
||
"two session layouts must show the indicator"
|
||
&& expect (langLib.show {
|
||
layout = "us";
|
||
devices.dell-kb.layout = "pt";
|
||
})
|
||
"a declared per-device override must show the indicator"
|
||
&& expect (langLib.show {
|
||
layout = "us";
|
||
remembered."by-tech-gaming-keyboard" = "pt";
|
||
})
|
||
"a freshly remembered device must show the indicator before it graduates"
|
||
&& expect (! langLib.show {
|
||
layout = "us";
|
||
remembered = {
|
||
"generic-benq-gw2790qt" = "us";
|
||
"by-tech-gaming-keyboard-consumer-control" = "us";
|
||
};
|
||
})
|
||
"devices remembered at the session layout add no second answer"
|
||
&& expect (! langLib.show { layout = "us"; devices.dell-kb.layout = "us"; })
|
||
"a declared override at the session layout adds no second answer"
|
||
# The pool is picker candidates only (hyprland.nix never
|
||
# merges it into kb_layout), so it cannot raise the indicator
|
||
# by itself — a device actually using one of them does.
|
||
&& expect (! langLib.show { layout = "us"; })
|
||
"the candidate pool alone must not show the indicator"
|
||
&& expect (inSwap swap)
|
||
"fixture drift: boreal's whole-swap no longer lists the language module"
|
||
&& expect (! inSwap gatedOff)
|
||
"a whole-swap must hide the module wherever the generated bar does"
|
||
&& expect (langLib.gate true swap == swap)
|
||
"gating on must leave a whole-swap untouched";
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-waybar-language" { } "touch $out";
|
||
|
||
# A whole-swap's commands must not read the session environment.
|
||
# Waybar is spawned by Hyprland and never sources a login shell, so
|
||
# `home.sessionVariables` (TERMINAL, EDITOR …) simply are not there:
|
||
# the on-click expands to nothing, the click does nothing, and there
|
||
# is no error to notice (#141 — it went unseen because only the
|
||
# whole-swap themes hand-write these; the generated module bakes the
|
||
# value at eval time and cannot regress). Guards the class: the next
|
||
# hand-written jsonc reaching for an env var fails here instead.
|
||
waybar-swap-env =
|
||
let
|
||
inherit (nixpkgs) lib;
|
||
expect = cond: msg: lib.assertMsg cond "waybar-swap-env: ${msg}";
|
||
swaps = lib.filter
|
||
(n: builtins.pathExists (./themes + "/${n}/waybar.jsonc"))
|
||
(builtins.attrNames (builtins.readDir ./themes));
|
||
# `$1`/`$2` (awk, sh positionals) are fine — a *name* after the
|
||
# sigil is what marks an environment read.
|
||
readsEnv = s: builtins.match ".*\\$[A-Za-z_{].*" s != null;
|
||
isCmdKey = k:
|
||
lib.hasPrefix "on-click" k || lib.hasPrefix "on-scroll" k
|
||
|| k == "exec" || k == "exec-if";
|
||
offendersIn = name:
|
||
let bar = builtins.fromJSON (builtins.readFile (./themes + "/${name}/waybar.jsonc"));
|
||
in lib.concatMap (mod:
|
||
let m = bar.${mod}; in
|
||
if !builtins.isAttrs m then [ ] else
|
||
lib.concatMap (k:
|
||
let v = m.${k}; in
|
||
if isCmdKey k && builtins.isString v && readsEnv v
|
||
then [ "${name}: ${mod}.${k} = ${v}" ] else [ ])
|
||
(builtins.attrNames m))
|
||
(builtins.attrNames bar);
|
||
offenders = lib.concatMap offendersIn swaps;
|
||
ok =
|
||
expect (swaps != [ ])
|
||
"fixture drift: no theme ships a waybar.jsonc — the guard is checking nothing"
|
||
&& expect (offenders == [ ])
|
||
("a whole-swap command reads the environment, which Waybar does not have: "
|
||
+ lib.concatStringsSep "; " offenders);
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-waybar-swap-env" { } "touch $out";
|
||
|
||
# A text menu must fit its longest label, which is a count of
|
||
# characters — so the picker has to be sized in the theme's own font
|
||
# (`ch`), never in % of the monitor or fixed px (#131: boreal pinned
|
||
# 620px and truncated Recovery on a 2560 panel; 40% themes truncate at
|
||
# 1366 while looking fine at 2560, and a 14pt mono theme needs ~25%
|
||
# more room than an 11pt one on both). The `min` cap is the other half
|
||
# — without it a big font on a small panel sprawls across the screen.
|
||
# Grid views (theme picker, drun) legitimately size by icons and
|
||
# override width per-invocation; this only governs the base theme.
|
||
rofi-text-width =
|
||
let
|
||
inherit (nixpkgs) lib;
|
||
expect = cond: msg: lib.assertMsg cond "rofi-text-width: ${msg}";
|
||
swaps = lib.filter
|
||
(n: builtins.pathExists (./themes + "/${n}/rofi.rasi"))
|
||
(builtins.attrNames (builtins.readDir ./themes));
|
||
widthOf = name:
|
||
let
|
||
text = builtins.readFile (./themes + "/${name}/rofi.rasi");
|
||
m = builtins.match ".*window[[:space:]]*\\{[^}]*width:[[:space:]]*([^;]+);.*" text;
|
||
in
|
||
if m == null then null else lib.head m;
|
||
bad = lib.filter
|
||
(name:
|
||
let w = widthOf name; in
|
||
w == null
|
||
|| builtins.match ".*ch.*" w == null
|
||
|| builtins.match ".*min.*" w == null)
|
||
swaps;
|
||
ok =
|
||
expect (swaps != [ ])
|
||
"fixture drift: no theme ships a rofi.rasi — the guard is checking nothing"
|
||
&& expect (bad == [ ])
|
||
("a whole-swap sizes its text menu without `ch` and a `min` cap, so it "
|
||
+ "cannot fit its own font's labels: " + lib.concatStringsSep ", " bad);
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-rofi-text-width" { } "touch $out";
|
||
|
||
# The #107 rename shipped a compat shim: mkFlake prefers state.json
|
||
# and still accepts the legacy theme-state.json (lib.nix), because a
|
||
# machine only migrates on its next menu write. Nothing evaluated a
|
||
# checkout carrying ONLY the legacy name, so the shim could rot with
|
||
# every check green — and it is load-bearing for exactly the users who
|
||
# have not touched the menu since #107, i.e. the ones least likely to
|
||
# notice before their rebuild breaks.
|
||
#
|
||
# Cheap by construction: mkFlake wraps its return in
|
||
# `builtins.seq _themeState`, so forcing the attrset forces the state
|
||
# read and nothing else — no module system, no home.nix. Which is also
|
||
# why the fixture needs only that one file.
|
||
#
|
||
# Delete this check together with the shim and the
|
||
# `nomarchy-theme-sync` alias.
|
||
state-legacy-name =
|
||
let
|
||
inherit (nixpkgs) lib;
|
||
expect = cond: msg: lib.assertMsg cond "state-legacy-name: ${msg}";
|
||
legacy = ./tools/fixtures/legacy-state;
|
||
none = ./tools/fixtures/no-state;
|
||
evalsOk = src:
|
||
(builtins.tryEval
|
||
(builtins.isAttrs (self.lib.mkFlake { inherit src; username = "fixture"; })))
|
||
.success;
|
||
ok =
|
||
# Fixture drift: if someone adds a state.json here the check
|
||
# would still pass, while testing nothing at all.
|
||
expect (builtins.pathExists (legacy + "/theme-state.json"))
|
||
"fixture drift: tools/fixtures/legacy-state has no theme-state.json"
|
||
&& expect (! builtins.pathExists (legacy + "/state.json"))
|
||
("fixture drift: tools/fixtures/legacy-state also has a state.json, "
|
||
+ "so it no longer exercises the legacy-only path")
|
||
&& expect (evalsOk legacy)
|
||
("mkFlake cannot evaluate a checkout carrying only theme-state.json — "
|
||
+ "the #107 compat shim is broken and every pre-#107 machine "
|
||
+ "fails to rebuild")
|
||
# The other half of the shim's contract: no state at all must
|
||
# still fail, and fail here rather than deep in a module.
|
||
&& expect (! evalsOk none)
|
||
("mkFlake accepted a checkout with no state file at all — the "
|
||
+ "fail-closed gate in lib.nix is gone");
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-state-legacy-name" { } "touch $out";
|
||
|
||
# Docking artifact guard: default/no-profile Home Manager output
|
||
# must still launch both hotplug watchers, put Rofi on the focused
|
||
# monitor, expose the manual keyboard picker, and never ship the
|
||
# zero-output-prone "Laptop screen off" menu action again.
|
||
docking-ux =
|
||
let
|
||
hm = self.nixosConfigurations.nomarchy-live.config
|
||
.home-manager.users.${username};
|
||
gen = hm.home.activationPackage;
|
||
hyprconf = hm.xdg.configFile."hypr/hyprland.conf".source;
|
||
in
|
||
assert hm.programs.rofi.extraConfig.monitor == -1;
|
||
pkgs.runCommand "nomarchy-docking-ux"
|
||
{ nativeBuildInputs = [ pkgs.bash pkgs.findutils pkgs.gnugrep ]; }
|
||
''
|
||
set -euo pipefail
|
||
menu=$(find ${gen}/home-path/bin -name nomarchy-menu | head -1)
|
||
keyboard=$(find ${gen}/home-path/bin -name nomarchy-keyboard-layout | head -1)
|
||
kbwatch=$(find ${gen}/home-path/bin -name nomarchy-keyboard-watch | head -1)
|
||
watcher=$(find ${gen}/home-path/bin -name nomarchy-display-profile-watch | head -1)
|
||
transition=$(find ${gen}/home-path/bin -name nomarchy-display-transition | head -1)
|
||
audio=$(find ${gen}/home-path/bin -name nomarchy-dock-audio | head -1)
|
||
test -n "$menu" -a -n "$keyboard" -a -n "$watcher" \
|
||
-a -n "$transition" -a -n "$audio" -a -n "$kbwatch"
|
||
bash -n "$menu"
|
||
bash -n "$keyboard"
|
||
bash -n "$kbwatch"
|
||
bash -n "$watcher"
|
||
bash -n "$transition"
|
||
bash -n "$audio"
|
||
"$keyboard" layouts > layouts.txt
|
||
test "$(wc -l < layouts.txt)" -gt 50
|
||
grep -qx us layouts.txt
|
||
grep -qx gb layouts.txt
|
||
grep -qx pt layouts.txt
|
||
grep -q 'nomarchy-keyboard-watch' ${hyprconf}
|
||
grep -q 'nomarchy-menu keyboard' ${hyprconf}
|
||
grep -q 'nomarchy-display-profile-watch' ${hyprconf}
|
||
grep -q -- '--quiet wallpaper' "$watcher"
|
||
grep -q 'Dock mode' "$menu"
|
||
grep -q 'nomarchy-display-transition dock' "$menu"
|
||
grep -q -- '--what=handle-lid-switch' "$watcher"
|
||
grep -q 'systemd-inhibit' "$watcher"
|
||
grep -q 'systemd-inhibit --list --json=short' "$watcher"
|
||
grep -q 'lid-inhibitor=stale-cleaned' "$watcher"
|
||
grep -q 'setsid.*systemd-inhibit' "$watcher"
|
||
grep -q 'nomarchy-dock-lid-inhibitor' "$watcher"
|
||
! grep -q 'exec -a nomarchy-dock-lid-inhibitor' "$watcher"
|
||
grep -Fq 'kill -TERM -"$pid"' "$watcher"
|
||
grep -q 'exec 3< <(' "$watcher"
|
||
grep -q '"$TRANSITION" undock' "$watcher"
|
||
grep -q 'nomarchy-dock-audio reprobe monitoradded' "$watcher"
|
||
# A dock plugged while the desktop sits idle must still be
|
||
# seen: `socat -T` closed the IPC socket after a second of
|
||
# quiet and lost the event in the reconnect gap, so the
|
||
# output set — not the event — decides, on a read timeout.
|
||
! grep -q 'socat -T' "$watcher"
|
||
grep -Fq 'read -r -t 1 line <&3' "$watcher"
|
||
grep -q 'reconcile "$hint"' "$watcher"
|
||
grep -q 'outputs-changed added=' "$watcher"
|
||
# One physical keyboard exposes several key-capable evdev
|
||
# nodes; only udev's own classification may raise a prompt.
|
||
grep -q 'ID_INPUT_KEYBOARD=1' "$kbwatch"
|
||
grep -q 'typing_keyboards' "$kbwatch"
|
||
grep -q 'base_name' "$kbwatch"
|
||
grep -q 'hyprctl --batch' "$transition"
|
||
grep -q 'keyword monitor.*disable' "$transition"
|
||
grep -q 'result=no-lid-inhibitor' "$transition"
|
||
grep -q 'transition=undock.*action=enable\|keyword monitor.*preferred' "$transition"
|
||
grep -q 'nomarchy-keyboard-layout' "$menu"
|
||
! grep -q 'Laptop screen off' "$menu"
|
||
grep -q 'action=reprobe-start' "$audio"
|
||
grep -q 'restart.*pipewire\|pipewire.service' "$audio"
|
||
grep -q 'set-default-sink' "$audio"
|
||
grep -q 'subscription-closed' "$audio"
|
||
grep -q 'result=no-available-sink' "$audio"
|
||
# A pinned default sink outranks every priority rule, so on
|
||
# UCM cards (headphones = their own sink) the jack has to be
|
||
# followed explicitly or plugging in stops switching output.
|
||
grep -q 'headphone_sinks' "$audio"
|
||
grep -Fq '"$0" headphones jack' "$audio"
|
||
# pro-audio/off publish no routable sink, and the profile is
|
||
# stored per card — so a monitor's audio stays unreachable
|
||
# however it is picked until the card itself is repaired.
|
||
grep -q 'repair_dock_cards' "$audio"
|
||
grep -q 'set-card-profile' "$audio"
|
||
grep -q 'repaired-card=' "$audio"
|
||
# Auto-undock without auto-dock leaves the pair half manual.
|
||
grep -q 'auto_dock' "$watcher"
|
||
grep -q 'auto-dock internal=' "$watcher"
|
||
grep -Fq '"$TRANSITION" dock' "$watcher"
|
||
! grep -q 'power_save' "$audio"
|
||
echo "docking-ux: atomic display, lid inhibitor, and monitor-triggered audio reprobe present"
|
||
touch $out
|
||
'';
|
||
|
||
# Night-light geo mode: with BOTH lat/long set the backing unit
|
||
# flips hyprsunset → wlsunset (location-computed sunrise/sunset)
|
||
# and the live-state ExecCondition gate survives the swap (the
|
||
# toggle script's unit= comes from the same let-binding, so the
|
||
# gate landing on wlsunset covers the selection logic). Pure
|
||
# eval over the rendered HM units — nothing is built. The
|
||
# visible warm shift itself stays hardware-tier (HARDWARE-QUEUE),
|
||
# exactly like the original night-light full cycle.
|
||
nightlight-geo =
|
||
let
|
||
inherit (nixpkgs) lib;
|
||
hm = home-manager.lib.homeManagerConfiguration {
|
||
inherit pkgs;
|
||
modules = [
|
||
self.homeModules.nomarchy
|
||
{
|
||
nomarchy.stateFile = ./templates/downstream/state.json;
|
||
nomarchy.nightlight = {
|
||
enable = true;
|
||
latitude = "52.52";
|
||
longitude = "13.40";
|
||
};
|
||
home = { username = "geo"; homeDirectory = "/home/geo"; };
|
||
}
|
||
];
|
||
};
|
||
units = hm.config.systemd.user.services;
|
||
exec = toString units.wlsunset.Service.ExecStart;
|
||
cond = toString units.wlsunset.Service.ExecCondition;
|
||
expect = c: msg: lib.assertMsg c "nightlight-geo: ${msg}";
|
||
ok =
|
||
expect (lib.hasInfix "52.52" exec && lib.hasInfix "13.40" exec)
|
||
"wlsunset ExecStart lacks the configured coordinates: ${exec}"
|
||
&& expect (lib.hasInfix "4000" exec)
|
||
"wlsunset ExecStart lacks the night temperature: ${exec}"
|
||
&& expect (lib.hasInfix "should-start" cond)
|
||
"wlsunset lost the live-state ExecCondition gate"
|
||
&& expect (!(hm.config.services.hyprsunset.enable or false))
|
||
"geo mode must not also run hyprsunset";
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-nightlight-geo" { } "touch $out";
|
||
|
||
# The low-battery watcher's crossing logic: one toast per downward
|
||
# crossing (normal at 25%, critical at 10%), re-armed by charging.
|
||
# test_power fakes a battery whose capacity/status we script;
|
||
# notify-send is shimmed to a log via PATH (the package resolves
|
||
# it from PATH for exactly this reason). What stays on-hardware:
|
||
# the real toast rendering through swaync in a session.
|
||
battery-notify = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-battery-notify";
|
||
nodes.machine = { ... }: {
|
||
boot.kernelModules = [ "test_power" ]; # fake battery + Mains
|
||
environment.systemPackages = [ pkgs.nomarchy-battery-notify ];
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
|
||
# notify-send shim: log the calls instead of needing a session bus.
|
||
machine.succeed(
|
||
"mkdir -p /shim && printf '#!/bin/sh\necho \"$*\" >> /tmp/notifications\n'"
|
||
" > /shim/notify-send && chmod +x /shim/notify-send"
|
||
)
|
||
|
||
# Self-gate: with the fake battery gone, the watcher exits 0 at once.
|
||
machine.succeed("modprobe -r test_power")
|
||
machine.succeed("timeout 5 nomarchy-battery-notify 1")
|
||
machine.succeed("modprobe test_power")
|
||
|
||
# Healthy level while discharging: silence.
|
||
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
|
||
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
|
||
machine.succeed(
|
||
"systemd-run --unit=batwatch --setenv=PATH=/shim:/run/current-system/sw/bin"
|
||
" /run/current-system/sw/bin/nomarchy-battery-notify 1"
|
||
)
|
||
machine.sleep(3)
|
||
machine.fail("test -s /tmp/notifications")
|
||
|
||
# Crossing 25% fires the low toast exactly once, not per poll.
|
||
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
|
||
machine.wait_until_succeeds("grep -q 'Battery low' /tmp/notifications")
|
||
machine.succeed("echo 15 > /sys/module/test_power/parameters/battery_capacity")
|
||
machine.sleep(3)
|
||
assert machine.succeed("grep -c 'Battery low' /tmp/notifications").strip() == "1"
|
||
|
||
# Crossing 10% escalates to a critical-urgency toast, once.
|
||
machine.succeed("echo 5 > /sys/module/test_power/parameters/battery_capacity")
|
||
machine.wait_until_succeeds("grep -q 'Battery critical' /tmp/notifications")
|
||
assert "critical" in machine.succeed("grep 'Battery critical' /tmp/notifications")
|
||
machine.sleep(3)
|
||
assert machine.succeed("grep -c 'Battery critical' /tmp/notifications").strip() == "1"
|
||
|
||
# Back on the charger → re-armed: the next drain notifies again.
|
||
machine.succeed("echo charging > /sys/module/test_power/parameters/battery_status")
|
||
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
|
||
machine.sleep(3)
|
||
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
|
||
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
|
||
machine.wait_until_succeeds(
|
||
"test \"$(grep -c 'Battery low' /tmp/notifications)\" = 2"
|
||
)
|
||
'';
|
||
};
|
||
|
||
# First-session welcome toast gate (#81): fires once when
|
||
# settings.firstBootShown is unset, writes the marker via
|
||
# theme-sync, silent on re-run; skips hostname nomarchy-live.
|
||
# notify-send + state-sync shimmed on PATH (same pattern as
|
||
# battery-notify). Real toast rendering stays session/V3.
|
||
# Also covers the hardware-hints stage (VISION § B): silent when
|
||
# no gated tooling is on PATH, fires once when it appears
|
||
# (fwupdmgr shimmed here), silent again after.
|
||
first-boot = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-first-boot";
|
||
nodes.machine = { ... }: {
|
||
environment.systemPackages = [
|
||
pkgs.nomarchy-first-boot
|
||
pkgs.nomarchy-state-sync
|
||
pkgs.jq
|
||
];
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
|
||
# Writable flake checkout with a valid theme-state (template).
|
||
machine.succeed(
|
||
"mkdir -p /tmp/fake-nomarchy && "
|
||
"cp ${./templates/downstream/state.json} "
|
||
"/tmp/fake-nomarchy/state.json"
|
||
)
|
||
|
||
machine.succeed(
|
||
"mkdir -p /shim && "
|
||
"printf '#!/bin/sh\\necho \"$*\" >> /tmp/notifications\\n' > /shim/notify-send && "
|
||
"chmod +x /shim/notify-send"
|
||
)
|
||
env = (
|
||
"PATH=/shim:/run/current-system/sw/bin "
|
||
"NOMARCHY_PATH=/tmp/fake-nomarchy "
|
||
"HOME=/root"
|
||
)
|
||
|
||
# First run: toast + marker. Node has neither fwupdmgr nor
|
||
# fprintd-list, so the hardware-hints stage (VISION § B)
|
||
# must stay silent and must NOT set its marker (cheap
|
||
# re-check each session — see the script comment).
|
||
machine.succeed(f"{env} nomarchy-first-boot")
|
||
out = machine.succeed("cat /tmp/notifications")
|
||
assert "You're set" in out, f"welcome toast missing:\n{out}"
|
||
assert "SUPER+M" in out and "SUPER+T" in out, f"key pointers missing:\n{out}"
|
||
assert "Network" in out or "Wi" in out, f"network pointer missing:\n{out}"
|
||
assert "Hardware tips" not in out, f"unwanted hint toast:\n{out}"
|
||
shown = machine.succeed(
|
||
f"{env} nomarchy-state-sync get settings.firstBootShown"
|
||
).strip()
|
||
assert shown == "true", f"marker not written: {shown!r}"
|
||
hints_marker = machine.succeed(
|
||
f"{env} nomarchy-state-sync get settings.hardwareHintsShown 2>/dev/null || true"
|
||
).strip()
|
||
assert hints_marker != "true", f"hints marker set too early: {hints_marker!r}"
|
||
|
||
# Second run: silent (no second toast, still no matching
|
||
# hardware/tooling).
|
||
machine.succeed("rm -f /tmp/notifications")
|
||
machine.succeed(f"{env} nomarchy-first-boot")
|
||
machine.fail("test -s /tmp/notifications")
|
||
|
||
# fwupdmgr appears on PATH (e.g. fwupd enabled later): the
|
||
# hints stage fires once, mentioning Firmware, and sets its
|
||
# own marker without re-touching firstBootShown.
|
||
machine.succeed(
|
||
"printf '#!/bin/sh\\nexit 0\\n' > /shim/fwupdmgr && "
|
||
"chmod +x /shim/fwupdmgr"
|
||
)
|
||
machine.succeed(f"{env} nomarchy-first-boot")
|
||
out = machine.succeed("cat /tmp/notifications")
|
||
assert "Hardware tips" in out, f"hint toast missing:\n{out}"
|
||
assert "Firmware" in out, f"firmware hint missing:\n{out}"
|
||
hints_marker = machine.succeed(
|
||
f"{env} nomarchy-state-sync get settings.hardwareHintsShown"
|
||
).strip()
|
||
assert hints_marker == "true", f"hints marker not written: {hints_marker!r}"
|
||
|
||
# Run again: silent (hints marker now set).
|
||
machine.succeed("rm -f /tmp/notifications")
|
||
machine.succeed(f"{env} nomarchy-first-boot")
|
||
machine.fail("test -s /tmp/notifications")
|
||
|
||
# Live ISO hostname: skip even with markers cleared —
|
||
# covers both the welcome card and the hardware-hints stage.
|
||
machine.succeed(
|
||
f"{env} nomarchy-state-sync --quiet set settings.hardwareHintsShown false --no-switch"
|
||
)
|
||
machine.succeed(
|
||
f"{env} nomarchy-state-sync --quiet set settings.firstBootShown false --no-switch"
|
||
)
|
||
machine.succeed("rm -f /tmp/notifications")
|
||
machine.succeed(f"{env} hostname nomarchy-live || true")
|
||
# uname -n is the kernel nodename — set via hostnamectl/hostname.
|
||
machine.succeed("hostname nomarchy-live")
|
||
machine.succeed(f"{env} nomarchy-first-boot")
|
||
machine.fail("test -s /tmp/notifications")
|
||
'';
|
||
};
|
||
|
||
# Generation readability (#82): nomarchy-what-changed turns an
|
||
# nvd report into plain-language counts. Inject a fake nvd via
|
||
# NOMARCHY_NVD so the VM stays minimal (no real store closures).
|
||
what-changed = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-what-changed";
|
||
nodes.machine = { ... }: {
|
||
environment.systemPackages = [ pkgs.nomarchy-what-changed ];
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
|
||
# Fake nvd: args are `nvd --color never diff A B` — ignore paths.
|
||
machine.succeed(
|
||
"mkdir -p /shim && cat > /shim/fake-nvd <<'EOF'\n"
|
||
"#!/bin/sh\n"
|
||
"cat <<'R'\n"
|
||
"<<< /nix/store/old\n"
|
||
">>> /nix/store/new\n"
|
||
"Added packages:\n"
|
||
"[A.] #1 hello 2.12\n"
|
||
"[A.] #2 cowsay 3.04\n"
|
||
"Removed packages:\n"
|
||
"[R.] #1 fortune 1.0\n"
|
||
"Version changes:\n"
|
||
"[C.] #1 bash: 5.2 -> 5.3\n"
|
||
"Closure size: 100 -> 105 (10 paths added, 5 paths removed, delta +5).\n"
|
||
"R\n"
|
||
"EOF\n"
|
||
"chmod +x /shim/fake-nvd"
|
||
)
|
||
# Dummy before/after paths (script only needs them to exist and differ).
|
||
machine.succeed("mkdir -p /tmp/gen-a /tmp/gen-b && touch /tmp/gen-a/x /tmp/gen-b/y")
|
||
|
||
out = machine.succeed(
|
||
"NOMARCHY_NVD=/shim/fake-nvd nomarchy-what-changed --summary "
|
||
"--diff /tmp/gen-a /tmp/gen-b"
|
||
)
|
||
assert "2 added" in out, f"missing added count:\n{out}"
|
||
assert "1 removed" in out, f"missing removed count:\n{out}"
|
||
assert "1 updated" in out, f"missing updated count:\n{out}"
|
||
|
||
# Identical paths → no package changes (no nvd call needed).
|
||
out2 = machine.succeed(
|
||
"NOMARCHY_NVD=/shim/fake-nvd nomarchy-what-changed --summary "
|
||
"--diff /tmp/gen-a /tmp/gen-a"
|
||
)
|
||
assert "no package changes" in out2, f"identical paths should short-circuit:\n{out2}"
|
||
|
||
# Empty-ish nvd report.
|
||
machine.succeed(
|
||
"cat > /shim/fake-nvd-empty <<'EOF'\n"
|
||
"#!/bin/sh\n"
|
||
"echo 'No version or selection state changes.'\n"
|
||
"echo 'Closure size: 1 -> 1 (0 paths added, 0 paths removed, delta +0).'\n"
|
||
"EOF\n"
|
||
"chmod +x /shim/fake-nvd-empty"
|
||
)
|
||
out3 = machine.succeed(
|
||
"NOMARCHY_NVD=/shim/fake-nvd-empty nomarchy-what-changed --summary "
|
||
"--diff /tmp/gen-a /tmp/gen-b"
|
||
)
|
||
assert "no package changes" in out3, f"empty report:\n{out3}"
|
||
'';
|
||
};
|
||
|
||
# Memory-pressure protection (oom.nix): earlyoom must kill a
|
||
# runaway allocator BEFORE the kernel thrash point — and must not
|
||
# take anything else with it. A bystander unit survives the kill,
|
||
# proving the process-level granularity that motivated earlyoom
|
||
# over cgroup-level systemd-oomd (which would kill the whole
|
||
# Hyprland session scope). Imports only oom.nix so the VM stays
|
||
# minimal.
|
||
oom-protection = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-oom-protection";
|
||
nodes.machine = { ... }: {
|
||
imports = [ ./modules/nixos/oom.nix ];
|
||
environment.systemPackages = [ pkgs.python3 ];
|
||
virtualisation.memorySize = 1024;
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("earlyoom.service")
|
||
|
||
# One owner: oomd (nixpkgs default-on, inert) is disabled.
|
||
machine.fail("systemctl is-active --quiet systemd-oomd.service")
|
||
# The session avoid-list made it into the daemon invocation.
|
||
machine.succeed("systemctl cat earlyoom.service | grep -q -- --avoid")
|
||
|
||
# A bystander that must survive (stand-in for the session).
|
||
machine.succeed("systemd-run --unit=bystander sleep infinity")
|
||
|
||
# The hog: allocate in 20 MB chunks with a short sleep, so
|
||
# earlyoom's poll wins the race against the kernel OOM killer.
|
||
machine.succeed(
|
||
"systemd-run --unit=hog python3 -c 'import time; "
|
||
"exec(\"a=[]\\nwhile True:\\n a.append(bytearray(20*1024*1024)); time.sleep(0.05)\")'"
|
||
)
|
||
|
||
# earlyoom (not the kernel) pulled the trigger…
|
||
machine.wait_until_succeeds(
|
||
"journalctl -u earlyoom.service | grep -Eiq 'sending SIG(TERM|KILL)'",
|
||
timeout=120,
|
||
)
|
||
# …the hog is gone…
|
||
machine.wait_until_succeeds(
|
||
'test "$(systemctl show -p ActiveState --value hog.service)" != "active"',
|
||
timeout=60,
|
||
)
|
||
# …and the bystander is untouched.
|
||
machine.succeed("systemctl is-active --quiet bystander.service")
|
||
'';
|
||
};
|
||
|
||
# zram compressed-RAM swap (oom.nix, BACKLOG #76 slice): enabled
|
||
# by default as the memory-pressure layer *before* earlyoom. Prove
|
||
# the device comes up as an active swap, uses zstd, and carries the
|
||
# high priority that keeps day-to-day paging off any disk swapfile
|
||
# (which #76 reserves for the hibernate image). Imports oom.nix
|
||
# directly so the check tracks the module, not a hand-copy.
|
||
zram-swap = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-zram-swap";
|
||
nodes.machine = { ... }: {
|
||
imports = [ ./modules/nixos/oom.nix ];
|
||
virtualisation.memorySize = 1024;
|
||
};
|
||
testScript = ''
|
||
# zram-generator brings the device up as swap shortly after boot.
|
||
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
|
||
# Configured compression algorithm is the selected one ([zstd]).
|
||
machine.succeed("grep -q '\\[zstd\\]' /sys/block/zram0/comp_algorithm")
|
||
# High priority (100) so the kernel fills zram before any disk
|
||
# swap — the ordering #76's hibernate image depends on.
|
||
machine.succeed(
|
||
"awk '$1==\"/dev/zram0\" && $5==100 {ok=1} END{exit !ok}' /proc/swaps"
|
||
)
|
||
'';
|
||
};
|
||
|
||
# Hibernate → resume (BACKLOG #76): the load-bearing risk the
|
||
# hibernation default introduces is coexistence with the zram
|
||
# swap that oom.nix now enables by default. Prove, in the VM
|
||
# harness, that (a) a machine hibernates and resumes for real —
|
||
# a volatile ramfs marker survives resume but NOT a fresh boot —
|
||
# and (b) zram (priority 100) does not swallow the hibernate
|
||
# image: the disk swap (lower priority, explicit resumeDevice)
|
||
# carries it. Modeled on nixpkgs' hibernate.nix (partition swap +
|
||
# systemd-initrd resume). What QEMU genuinely can't do is feed a
|
||
# boot-time `resume_offset` for a btrfs *swapfile* (the offset
|
||
# only exists after the file is allocated on first boot) or drive
|
||
# a LUKS-initrd unlock end-to-end — so the encrypted @swap
|
||
# swapfile + offset path stays a V3 laptop check (HARDWARE-QUEUE),
|
||
# which already runs that exact layout. This test imports oom.nix
|
||
# so it tracks the real zram default, not a hand-copy.
|
||
hibernate = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-hibernate";
|
||
nodes.machine = { config, lib, pkgs, ... }: {
|
||
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
|
||
virtualisation.memorySize = 2048;
|
||
# Store + EFI must persist across the hibernate/resume/crash
|
||
# boots; a disk swap ≥ 2×RAM holds the image.
|
||
virtualisation.useNixStoreImage = true;
|
||
virtualisation.useEFIBoot = true;
|
||
virtualisation.emptyDiskImages = [ (2 * config.virtualisation.memorySize) ];
|
||
boot.initrd.systemd.enable = true; # systemd-initrd, like Nomarchy
|
||
# Disk swap at a LOWER priority than zram (100); explicit
|
||
# resumeDevice — exactly how the installer wires hibernation —
|
||
# so the image goes to disk regardless of zram's priority.
|
||
swapDevices = lib.mkOverride 0 [
|
||
{ device = "/dev/vdc"; options = [ "x-systemd.makefs" ]; priority = 1; }
|
||
];
|
||
boot.resumeDevice = "/dev/vdc";
|
||
# Let the driver's shell reconnect after resume.
|
||
systemd.services.backdoor.conflicts = [ "sleep.target" ];
|
||
powerManagement.resumeCommands = "systemctl --no-block restart backdoor.service";
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("default.target")
|
||
|
||
# zram (oom.nix) up at priority 100; disk swap present and lower
|
||
# — so the hibernate image lands on disk, not volatile zram.
|
||
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
|
||
machine.succeed(
|
||
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
|
||
"$1==\"/dev/vdc\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
|
||
)
|
||
|
||
# Marker in volatile RAM: survives a resume, never a fresh boot.
|
||
machine.succeed(
|
||
"mkdir /run/test",
|
||
"mount -t ramfs -o size=1m ramfs /run/test",
|
||
"echo not persisted to disk > /run/test/suspended",
|
||
)
|
||
|
||
# Hibernate, wait for real power-off, then resume.
|
||
machine.execute("systemctl hibernate >&2 &", check_return=False)
|
||
machine.wait_for_shutdown()
|
||
machine.start()
|
||
machine.succeed("grep 'not persisted to disk' /run/test/suspended")
|
||
|
||
# Prove it was a genuine resume: a crash-boot must lose the marker.
|
||
machine.crash()
|
||
machine.wait_for_unit("default.target")
|
||
machine.fail("grep 'not persisted to disk' /run/test/suspended")
|
||
'';
|
||
};
|
||
|
||
# The installer's BTRFS @swap SWAPFILE setup (#76) — the parts the
|
||
# partition hibernate test (checks.hibernate) can't reach. A full
|
||
# hibernate→resume from a *runtime* swapfile isn't reachable
|
||
# headlessly: systemd reports CanHibernate=no unless the swap is
|
||
# declared in the boot config, and feeding a runtime-computed
|
||
# resume_offset into the boot cmdline is a genuine chicken-and-egg.
|
||
# So the power-cycle stays a real-hardware check (the dev box runs
|
||
# exactly this layout — /proc/cmdline verified). What IS verified
|
||
# here, bounded and deterministic, is that the swapfile is built
|
||
# correctly and its resume offset is valid: NOCOW `mkswapfile`, a
|
||
# good `map-swapfile -r` offset the kernel accepts, swapon as a file,
|
||
# and zram (prio 100) above it so the image lands on disk, not zram.
|
||
hibernate-swapfile = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-hibernate-swapfile";
|
||
nodes.machine = { config, lib, pkgs, ... }: {
|
||
imports = [ ./modules/nixos/oom.nix ]; # zram default-on (#76)
|
||
virtualisation.memorySize = 2048;
|
||
# A dedicated disk for the btrfs @swap subvolume.
|
||
virtualisation.emptyDiskImages = [ (4 * config.virtualisation.memorySize) ];
|
||
environment.systemPackages = [ pkgs.btrfs-progs ];
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("default.target")
|
||
machine.wait_until_succeeds("grep -q /dev/zram0 /proc/swaps", timeout=60)
|
||
|
||
# Reproduce the installer layout: btrfs, @swap subvolume, then a
|
||
# NOCOW swapfile via `btrfs filesystem mkswapfile`.
|
||
# The lone empty data disk (root is vda; no separate store disk
|
||
# here since useNixStoreImage is off).
|
||
machine.succeed("mkfs.btrfs -f /dev/vdb")
|
||
machine.succeed(
|
||
"mkdir -p /mnt-btrfs && mount /dev/vdb /mnt-btrfs "
|
||
"&& btrfs subvolume create /mnt-btrfs/@swap && umount /mnt-btrfs"
|
||
)
|
||
machine.succeed("mkdir -p /swap && mount -o subvol=@swap /dev/vdb /swap")
|
||
machine.succeed("btrfs filesystem mkswapfile --size 3g --uuid clear /swap/swapfile")
|
||
# NOCOW is mandatory — a copy-on-write swapfile corrupts on btrfs.
|
||
machine.succeed("lsattr /swap/swapfile | grep -q C")
|
||
machine.succeed("swapon /swap/swapfile")
|
||
machine.succeed(
|
||
"awk '$1==\"/swap/swapfile\" && $2==\"file\"{f=1} END{exit !f}' /proc/swaps"
|
||
)
|
||
|
||
# The Nomarchy-specific offset math (patch-template bakes this
|
||
# into resume_offset): a valid physical offset the kernel accepts.
|
||
offset = machine.succeed(
|
||
"btrfs inspect-internal map-swapfile -r /swap/swapfile"
|
||
).strip()
|
||
assert offset.isdigit() and int(offset) > 0, f"bad resume offset: {offset!r}"
|
||
machine.succeed(f"echo {offset} > /sys/power/resume_offset")
|
||
|
||
# zram (prio 100) must sit above the disk swapfile, so a hibernate
|
||
# image would land on disk, not volatile zram.
|
||
machine.succeed(
|
||
"awk '$1==\"/dev/zram0\"&&$5==100{z=1} "
|
||
"$1==\"/swap/swapfile\"&&$5<100{d=1} END{exit !(z&&d)}' /proc/swaps"
|
||
)
|
||
'';
|
||
};
|
||
|
||
# Auto time-of-day theme (#79, VISION § D): simulate crossing
|
||
# sunset and sunrise by moving the VM clock, and assert
|
||
# `nomarchy-state-sync auto` switches the active theme accordingly
|
||
# — day stays day (no needless rebuild), sunset flips to night,
|
||
# a later tick is idempotent, sunrise flips back. The actual
|
||
# `home-manager switch` is stubbed via NOMARCHY_REBUILD (that
|
||
# generic apply path is exercised by every manual theme change);
|
||
# this isolates the auto decision + apply + rebuild-trigger.
|
||
# The state dir is a git repo with autoCommit on, so every set/apply
|
||
# also exercises auto_commit against a repo that has ONLY state.json
|
||
# — the post-#107 shape whose dead legacy pathspecs once aborted
|
||
# every commit silently.
|
||
auto-theme = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-auto-theme";
|
||
nodes.machine = { pkgs, ... }: {
|
||
environment.systemPackages = [ pkgs.nomarchy-state-sync pkgs.git ];
|
||
time.timeZone = "UTC"; # naive HH:MM == the clock we set
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
machine.succeed("timedatectl set-ntp false || true")
|
||
|
||
# Writable flake state (start on the day theme) + a rebuild stub
|
||
# that records each invocation instead of running home-manager.
|
||
machine.succeed("mkdir -p /root/.nomarchy")
|
||
machine.succeed(
|
||
"echo '{\"slug\":\"summer-day\"}' > /root/.nomarchy/state.json"
|
||
)
|
||
# Git-tracked like a real downstream flake — no legacy
|
||
# theme-state.json/theme.json anywhere in its history.
|
||
machine.succeed(
|
||
"git -C /root/.nomarchy init -q"
|
||
" && git -C /root/.nomarchy add state.json"
|
||
" && git -C /root/.nomarchy -c user.name=t -c user.email=t@t"
|
||
" commit -qm init"
|
||
)
|
||
machine.succeed(
|
||
"printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild"
|
||
" && chmod +x /root/rebuild"
|
||
)
|
||
|
||
env = ("NOMARCHY_PATH=/root/.nomarchy "
|
||
"NOMARCHY_DEFAULT_THEMES=${pkgs.nomarchy-default-themes} "
|
||
"NOMARCHY_REBUILD=/root/rebuild ")
|
||
|
||
# Pair + schedule (setup writes only, no rebuild). autoCommit
|
||
# first, so every following write must land as its own commit.
|
||
for kv in ["settings.autoCommit true",
|
||
"settings.autoTheme.enable true",
|
||
"settings.autoTheme.day summer-day",
|
||
"settings.autoTheme.night summer-night",
|
||
"settings.autoTheme.sunrise 07:00",
|
||
"settings.autoTheme.sunset 20:00"]:
|
||
machine.succeed(env + "nomarchy-state-sync --quiet set " + kv + " --no-switch")
|
||
|
||
# Every `set` above auto-committed (the regression: dead legacy
|
||
# pathspecs made `git commit` abort, skipping ALL of these).
|
||
machine.succeed(
|
||
"git -C /root/.nomarchy log --format=%s"
|
||
" | grep -qF 'set settings.autoTheme.sunset'"
|
||
)
|
||
machine.succeed("git -C /root/.nomarchy diff --quiet HEAD -- state.json")
|
||
|
||
def slug():
|
||
return machine.succeed(env + "nomarchy-state-sync get slug").strip()
|
||
def rebuilds():
|
||
return int(machine.succeed("wc -l < /root/rebuilds 2>/dev/null || echo 0").strip())
|
||
|
||
# Before sunset: already the day theme → no switch, no rebuild.
|
||
machine.succeed("date -s '2026-01-02 19:55:00'")
|
||
machine.succeed(env + "nomarchy-state-sync auto")
|
||
assert slug() == "summer-day", f"pre-sunset should stay day, got {slug()}"
|
||
assert rebuilds() == 0, f"no needless rebuild, got {rebuilds()}"
|
||
|
||
# Cross sunset → night theme, exactly one rebuild triggered.
|
||
machine.succeed("date -s '2026-01-02 20:05:00'")
|
||
machine.succeed(env + "nomarchy-state-sync auto")
|
||
assert slug() == "summer-night", f"post-sunset should be night, got {slug()}"
|
||
assert rebuilds() == 1, f"sunset should trigger one rebuild, got {rebuilds()}"
|
||
# The switch auto-committed too — nothing left dirty behind it.
|
||
machine.succeed(
|
||
"git -C /root/.nomarchy log --format=%s | grep -qF 'apply theme'"
|
||
)
|
||
machine.succeed("git -C /root/.nomarchy diff --quiet HEAD -- state.json")
|
||
|
||
# Another tick still after sunset → idempotent, no extra rebuild.
|
||
machine.succeed("date -s '2026-01-02 20:06:00'")
|
||
machine.succeed(env + "nomarchy-state-sync auto")
|
||
assert slug() == "summer-night"
|
||
assert rebuilds() == 1, f"idempotent tick must not rebuild, got {rebuilds()}"
|
||
|
||
# Cross sunrise the next morning → back to day, second rebuild.
|
||
machine.succeed("date -s '2026-01-03 07:30:00'")
|
||
machine.succeed(env + "nomarchy-state-sync auto")
|
||
assert slug() == "summer-day", f"post-sunrise should be day, got {slug()}"
|
||
assert rebuilds() == 2, f"sunrise should trigger a rebuild, got {rebuilds()}"
|
||
'';
|
||
};
|
||
|
||
# Snapshot GUI canary: btrfs-assistant crashes in libbtrfsutil's
|
||
# UNPRIVILEGED subvolume iteration (btrfs-progs 6.17.1, fixed
|
||
# upstream after 6.17.1) but runs fine as root — which is how the
|
||
# menu launches it (pkexec launcher). Guard the root path on a
|
||
# real btrfs volume so a lock bump that breaks it fails here, not
|
||
# on a user's machine. The GUI event loop is exercised offscreen
|
||
# (survives a 5s timeout = no startup crash past the Btrfs scan).
|
||
snapshot-gui = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-snapshot-gui";
|
||
nodes.machine = { pkgs, ... }: {
|
||
environment.systemPackages = [ pkgs.btrfs-assistant pkgs.btrfs-progs ];
|
||
virtualisation.emptyDiskImages = [ 512 ];
|
||
};
|
||
testScript = ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
machine.succeed(
|
||
"mkfs.btrfs /dev/vdb && mkdir -p /mnt && mount /dev/vdb /mnt "
|
||
"&& btrfs subvolume create /mnt/sub1"
|
||
)
|
||
# Root (the launcher's effective context): must work.
|
||
machine.succeed("QT_QPA_PLATFORM=offscreen btrfs-assistant-bin --version")
|
||
# The GUI proper survives startup (timeout kill = alive → 124).
|
||
machine.succeed(
|
||
"set +e; timeout 5 env QT_QPA_PLATFORM=offscreen btrfs-assistant-bin; "
|
||
"[ $? -eq 124 ]"
|
||
)
|
||
'';
|
||
};
|
||
|
||
# The distroId question (roadmap "Distro branding"): set
|
||
# distroId = "nomarchy" so /etc/os-release is honest (ID=nomarchy,
|
||
# ID_LIKE=nixos). The risk is switch-to-configuration's "is this
|
||
# NixOS?" guard — but it builds the check from the *configured*
|
||
# distroId (and /etc/NIXOS still exists), so a rebuild must still
|
||
# work. This boots such a system and runs `switch-to-configuration
|
||
# dry-activate` to prove the guard passes. Inline (not the full
|
||
# module) so the VM stays minimal — the real wiring in default.nix
|
||
# is covered by the downstream-template-system build.
|
||
distro-id = pkgs.testers.runNixOSTest {
|
||
name = "nomarchy-distro-id";
|
||
nodes.machine = { ... }: {
|
||
system.nixos.distroName = "Nomarchy";
|
||
system.nixos.distroId = "nomarchy";
|
||
# Test base omits switch-to-configuration by default; we need it
|
||
# to exercise the "is this NixOS?" guard.
|
||
system.switch.enable = true;
|
||
};
|
||
testScript = { nodes, ... }: ''
|
||
machine.wait_for_unit("multi-user.target")
|
||
|
||
# os-release reflects the rebrand, with the nixos lineage marker.
|
||
machine.succeed("grep -q '^ID=nomarchy$' /etc/os-release")
|
||
machine.succeed("grep -q '^ID_LIKE=nixos$' /etc/os-release")
|
||
machine.succeed("grep -q '^NAME=Nomarchy$' /etc/os-release")
|
||
|
||
# The rebuild path must still recognise this as a NixOS install
|
||
# (the guard is built from the configured distroId, not "nixos").
|
||
machine.succeed(
|
||
"${nodes.machine.system.build.toplevel}/bin/switch-to-configuration dry-activate"
|
||
)
|
||
'';
|
||
};
|
||
|
||
# #134: proves the `unstable.*` overlay seam exists and is really a
|
||
# second channel, without building anything from it — evaluation-only,
|
||
# so this stays cheap even though nixpkgs-unstable is a whole extra
|
||
# input. `pkgs` here already carries overlays.default (line ~35).
|
||
unstable-seam =
|
||
let
|
||
expect = cond: msg: nixpkgs.lib.assertMsg cond "unstable-seam: ${msg}";
|
||
ok =
|
||
expect (pkgs.unstable ? hello)
|
||
"pkgs.unstable has no `hello` — the overlay attr is missing or broken"
|
||
&& expect (pkgs.unstable.lib.version != pkgs.lib.version)
|
||
("pkgs.unstable.lib.version == pkgs.lib.version — this is the same "
|
||
+ "channel as the pinned nixpkgs, not a second one");
|
||
in
|
||
assert ok; pkgs.runCommand "nomarchy-unstable-seam" { } "touch $out";
|
||
};
|
||
|
||
# ─── Reference host ────────────────────────────────────────────
|
||
# Manually wired rather than via lib.mkFlake: the repo-root
|
||
# state.json is load-bearing (live ISO + in-repo CLI dev),
|
||
# which doesn't match mkFlake's src-layout convention.
|
||
# Deliberately split rebuild paths:
|
||
# system → sudo nixos-rebuild switch --flake .#nomarchy (rare)
|
||
# desktop → home-manager switch --flake .#nomarchy (no sudo;
|
||
# this is what `nomarchy-state-sync apply` runs)
|
||
# Theme changes never touch the system layer, so they never need
|
||
# root and never rebuild the world.
|
||
nixosConfigurations.nomarchy = nixpkgs.lib.nixosSystem {
|
||
inherit system;
|
||
specialArgs = { inherit username; };
|
||
modules = [
|
||
self.nixosModules.nomarchy
|
||
{ nomarchy.system.stateFile = ./state.json; }
|
||
./hosts/default/configuration.nix
|
||
];
|
||
};
|
||
|
||
homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
|
||
inherit pkgs;
|
||
modules = [
|
||
self.homeModules.nomarchy
|
||
{
|
||
# The single source of truth — pure read: the file is part
|
||
# of this flake's source.
|
||
nomarchy.stateFile = ./state.json;
|
||
home = {
|
||
inherit username;
|
||
homeDirectory = "/home/${username}";
|
||
};
|
||
}
|
||
];
|
||
};
|
||
|
||
# ─── Live ISO ──────────────────────────────────────────────────
|
||
# Full desktop on a bootable stick, no installation:
|
||
# nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage
|
||
# Test in QEMU with tools/test-live-iso.sh. See docs/TESTING.md.
|
||
nixosConfigurations.nomarchy-live = nixpkgs.lib.nixosSystem {
|
||
inherit system;
|
||
specialArgs = { inherit username; nomarchySrc = self; };
|
||
modules = [
|
||
"${nixpkgs}/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix"
|
||
self.nixosModules.nomarchy
|
||
./hosts/live.nix
|
||
|
||
# The ISO is a sealed artifact, so the desktop is baked in via
|
||
# the HM NixOS module (the installed-system split into
|
||
# nixos-rebuild vs home-manager switch doesn't apply here —
|
||
# though the seeded ~/.nomarchy flake still allows standalone
|
||
# `home-manager switch` for theme testing).
|
||
home-manager.nixosModules.home-manager
|
||
{
|
||
home-manager = {
|
||
useGlobalPkgs = true;
|
||
useUserPackages = true;
|
||
users.${username} = { pkgs, ... }: {
|
||
imports = [ self.homeModules.nomarchy ];
|
||
nomarchy.stateFile = ./state.json;
|
||
|
||
# Live-session baseline apps (ROADMAP § live-ISO baseline
|
||
# apps, #103). Until now the
|
||
# live desktop shipped the distro modules only — no browser,
|
||
# no office — which is what a user judges the distro by before
|
||
# installing, and what they boot to rescue a machine that
|
||
# won't start. Terminal tooling (yazi, btop) plus zathura/imv
|
||
# already ride along with the modules; this adds the GUI apps
|
||
# a non-Nix user reaches for.
|
||
#
|
||
# These cost almost NOTHING on the ISO: system.extraDependencies
|
||
# below already pins the template's HM closure for offline
|
||
# installs, so chromium/libreoffice-fresh/amberol are in the
|
||
# image's store either way — they were merely absent from the
|
||
# live user's profile, so nothing put them on PATH or in the
|
||
# launcher. Reuse the template's EXACT derivations and the
|
||
# store paths are shared; a plain `chromium` (no override)
|
||
# would be a second full browser build instead.
|
||
#
|
||
# Firefox is deliberately NOT here (Bernardo 2026-07-14):
|
||
# Chromium already owns the HTTP mime default and ships in the
|
||
# installed template, so it is the browser that matches what a
|
||
# user gets after installing — and a second browser is the one
|
||
# item in #103's list that would cost a real closure. It stays
|
||
# a template opt-in for installed machines.
|
||
home.packages = with pkgs; [
|
||
# Same override as templates/downstream/home.nix — must
|
||
# match exactly to share the store path (Widevine DRM;
|
||
# unfree CDM, allowUnfree is on for this system).
|
||
(chromium.override { enableWideVine = true; })
|
||
libreoffice-fresh # documents/sheets/slides off a dying disk
|
||
gnome-text-editor # the maintained gedit successor
|
||
amberol # music player (also in the template)
|
||
mpv # video (mime defaults → mpv.desktop; also
|
||
# template — free on the ISO via the pin)
|
||
snapshot # camera — the maintained Cheese successor
|
||
];
|
||
};
|
||
};
|
||
|
||
# The standalone CLI that `nomarchy-state-sync apply` runs for
|
||
# theme switching, from the same pinned input — plus the
|
||
# guided installer.
|
||
environment.systemPackages = [
|
||
home-manager.packages.${system}.home-manager
|
||
nomarchyInstall
|
||
];
|
||
|
||
# Keep the locked flake inputs in the ISO store so theme
|
||
# switching on the live system works without a network: the
|
||
# lockfile's narHashes resolve against these paths instead
|
||
# of fetching from GitHub. Must be *transitive* — evaluating
|
||
# the seeded flake also fetches stylix's own inputs.
|
||
system.extraDependencies =
|
||
let
|
||
collectInputs = input:
|
||
[ input.outPath ] ++ builtins.concatMap collectInputs
|
||
(builtins.attrValues (input.inputs or { }));
|
||
in
|
||
collectInputs self ++ (
|
||
# Pre-build a system shaped like what nomarchy-install
|
||
# GENERATES (snapper, auto-login, LUKS initrd, swapfile +
|
||
# resume — not the bare template!) plus the template
|
||
# desktop, so the offline install never builds packages
|
||
# from source: a custom username/hostname then changes
|
||
# only a handful of derivations. No hardware profile —
|
||
# matches a VM install; profiled installs share most of it.
|
||
let
|
||
template = self.lib.mkFlake {
|
||
src = ./templates/downstream;
|
||
username = username;
|
||
};
|
||
representativeInstall = nixpkgs.lib.nixosSystem {
|
||
inherit system;
|
||
specialArgs = { inherit username; };
|
||
modules = [
|
||
self.nixosModules.nomarchy
|
||
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
|
||
# The installer's autodetection emits these on real
|
||
# machines (and the intel CPU module pulls the whole
|
||
# iGPU stack) — without them an offline install on
|
||
# common hardware builds graphics drivers from
|
||
# source. Model-specific profiles may still need
|
||
# network; the common ground is covered.
|
||
nixos-hardware.nixosModules.common-cpu-intel
|
||
nixos-hardware.nixosModules.common-cpu-amd
|
||
nixos-hardware.nixosModules.common-gpu-amd
|
||
nixos-hardware.nixosModules.common-pc-laptop
|
||
nixos-hardware.nixosModules.common-pc-ssd
|
||
nixos-hardware.nixosModules.common-pc
|
||
(./templates/downstream + "/hardware-configuration.nix")
|
||
(./templates/downstream + "/system.nix")
|
||
({ lib, ... }: {
|
||
nomarchy.system.snapper.enable = true;
|
||
nomarchy.system.greeter.autoLogin = username;
|
||
# nixos-generate-config keys microcode off
|
||
# enableRedistributableFirmware (now on): the
|
||
# real machine enables exactly one vendor —
|
||
# pin both, or the offline install builds
|
||
# amd-ucode/intel-ucode from source (found the
|
||
# hard way: the 2700s regression hang).
|
||
hardware.cpu.amd.updateMicrocode = true;
|
||
hardware.cpu.intel.updateMicrocode = true;
|
||
swapDevices = [{ device = "/swap/swapfile"; }];
|
||
boot.resumeDevice = "/dev/disk/by-uuid/00000000-0000-0000-0000-000000000000";
|
||
boot.kernelParams = [ "resume_offset=1" ];
|
||
boot.initrd.luks.devices.crypted.device = "/dev/disk/by-partlabel/disk-main-root";
|
||
# The real install is BTRFS with a vfat ESP — the
|
||
# placeholder's bare ext4 would leave fs-gated bits
|
||
# out of this pin (snapper via btrfs; mtools and
|
||
# dosfstools enter systemPackages via vfat!).
|
||
fileSystems."/" = lib.mkForce {
|
||
device = "/dev/mapper/crypted";
|
||
fsType = "btrfs";
|
||
options = [ "subvol=@" "compress=zstd" "noatime" ];
|
||
};
|
||
fileSystems."/boot" = {
|
||
device = "/dev/disk/by-uuid/0000-0000";
|
||
fsType = "vfat";
|
||
options = [ "umask=0077" ];
|
||
};
|
||
})
|
||
];
|
||
};
|
||
in [
|
||
representativeInstall.config.system.build.toplevel
|
||
template.homeConfigurations.${username}.activationPackage
|
||
|
||
# The live session's `home-manager switch` builds the
|
||
# REPO's standalone generation (the seeded ~/.nomarchy
|
||
# flake) — not the template's, and not the NixOS-module
|
||
# one baked into the ISO. It has drvs the other two
|
||
# variants don't (user-dbus-services et al.), so pin it
|
||
# and its direct build inputs too.
|
||
self.homeConfigurations.${username}.activationPackage
|
||
self.homeConfigurations.${username}.activationPackage.inputDerivation
|
||
# home-files is rebuilt per theme and COPIES some of its
|
||
# inputs (user-dbus-services) instead of referencing
|
||
# them — the activation closure doesn't carry those, so
|
||
# pin home-files' direct build inputs separately.
|
||
self.homeConfigurations.${username}.config.home-files.inputDerivation
|
||
|
||
# A real install rebuilds the drvs that embed the
|
||
# user's hostname/username/UUIDs (etc, initrd, units,
|
||
# toplevel, HM activation). inputDerivation pins each
|
||
# one's direct build inputs…
|
||
representativeInstall.config.system.build.toplevel.inputDerivation
|
||
representativeInstall.config.system.build.etc.inputDerivation
|
||
representativeInstall.config.system.build.initialRamdisk.inputDerivation
|
||
template.homeConfigurations.${username}.activationPackage.inputDerivation
|
||
# home-files COPIES user-dbus-services instead of
|
||
# referencing it (same story as the self pin above) —
|
||
# a custom-username install rebuilds home-files and
|
||
# needs its direct inputs present.
|
||
template.homeConfigurations.${username}.config.home-files.inputDerivation
|
||
] ++ [
|
||
# A theme switch re-renders stylix's base16 templates
|
||
# (gtk.css, kvantum, …) on the machine: the renderer and
|
||
# the stdenv its trivial drvs build with must be on
|
||
# board, or an offline `apply <theme>` cascades into
|
||
# building mustache-go (and eventually stdenv) from
|
||
# source. Found with tools/vm/gap-analysis.py.
|
||
pkgs.mustache-go
|
||
pkgs.stdenv
|
||
] ++ [
|
||
# …and the second-level build tools those rebuilds need
|
||
# (their parents are themselves rebuilt, so parent
|
||
# inputDerivations don't reach them): kernel-module
|
||
# shrinking, unit-dir assembly, dbus config, /etc.
|
||
pkgs.kmod.dev
|
||
pkgs.nukeReferences
|
||
pkgs.xorg.lndir
|
||
pkgs.libxslt.bin
|
||
pkgs.python3Minimal
|
||
# system-path is rebuilt on EVERY machine: buildEnv's
|
||
# package-list order follows module-graph order, and any
|
||
# hardware profile/import reshuffles it. Its builder
|
||
# wants these.
|
||
pkgs.texinfo
|
||
pkgs.getconf
|
||
# greetd.toml embeds the auto-login user and is generated
|
||
# by remarshal — without this pin an offline install
|
||
# tries to BUILD remarshal, whose test closure pulls
|
||
# matplotlib/ffmpeg/x265 (the ~1900-drv cascade).
|
||
pkgs.remarshal
|
||
# system-path/initrd-bin-env are buildEnvs rebuilt per
|
||
# machine; their shared builder.pl is build-time-only
|
||
# (never in any runtime closure) — an empty env's
|
||
# inputDerivation retains it.
|
||
(pkgs.buildEnv { name = "nomarchy-pin-buildenv"; paths = [ ]; }).inputDerivation
|
||
# /etc/dbus-1 reassembly wants this setup hook.
|
||
pkgs.findXMLCatalogs
|
||
# nixos-generate-config keys microcode off
|
||
# enableRedistributableFirmware (now on): exactly one
|
||
# vendor per real machine, so pin both ucode cpios.
|
||
pkgs.microcode-amd
|
||
pkgs.microcode-intel
|
||
|
||
# …and one representative disko script: its tool closure
|
||
# (file, which, wrapper hooks, …) isn't otherwise on the
|
||
# ISO, and offline nix would try to build it from source.
|
||
# The user's actual args produce a different script drv,
|
||
# but with identical inputs — built offline in seconds.
|
||
(import "${pkgs.disko}/share/disko/cli.nix" {
|
||
inherit pkgs;
|
||
mode = "destroy,format,mount";
|
||
diskoFile = ./pkgs/nomarchy-install/disko-config.nix;
|
||
mainDrive = "/dev/vda";
|
||
withLuks = true;
|
||
swapSize = "2G";
|
||
})
|
||
]
|
||
);
|
||
}
|
||
];
|
||
};
|
||
};
|
||
}
|