Files
Nomarchy/flake.nix
Bernardo Magri 55a516e1c7
Some checks failed
Check / eval (push) Has been cancelled
fix(ui): #139 sheets size themselves; #141 waybar clicks stop reading the env
#139 — calcurse/doctor floated full-screen. Two faults stacked, which is
why it looked like the float rules were dead:
  1. Hyprland 0.55.4 silently ignores percentage `size` rules. Matrix on
     hardware (both orders, kitty memory off): `size 60% 65%` never
     applies, `size 1536 936` applies either way, configerrors empty both
     times. Rule order was a red herring.
  2. Kitty defaults to remember_window_size=yes and replays the last OS
     window's size into every float — so after a tiled terminal, a sheet
     opens maximized. This is the Ghostty regression: Ghostty had no such
     memory, so fault 1 stayed invisible until #95.

Fix: remember_window_size=no (floats must be deterministic) and the sheets
ask for their own size — term-sheet.nix reads the focused monitor and
hands kitty the px, because px in a rule cannot mean "a fraction of *this*
screen" and one pair cannot serve 2560x1440 and the 1366x768 Acer (#131).
Those windows keep float/center and deliberately carry no size rule.

#141 — the updates click did nothing: whole-swaps hand-wrote
`sh -c '$TERMINAL …'` and Waybar has no TERMINAL (home.sessionVariables →
login shells only; the bar is spawned by Hyprland). Fixed at the root:
`nomarchy-updates upgrade-window` opens its own window, so all five call
sites name one env-free command and a theme file stops having an opinion
about terminals. checks.waybar-swap-env guards the class.

Bernardo asked mid-task for the update window to float — it is now the
third caller of the sheet helper (com.nomarchy.updates, 45%x50%).

V3 on the dev box: calendar 1536x936, doctor 1408x1008, updates 1152x720
— exactly the intended fractions, floating and centred; #141 driven under
`env -i` with no TERMINAL, matching Waybar's real environment; the guard
proven by reintroducing the bug. Acer V3 queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:04:28 +01:00

2420 lines
126 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
description = "Nomarchy the rock-solid reproducibility of NixOS, the polish of an opinionated desktop";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
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";
};
};
outputs = { self, nixpkgs, home-manager, stylix, nixos-hardware, ... }:
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: {
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.
themesDir = ./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 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.
theme-wholeswap = pkgs.runCommand "nomarchy-theme-wholeswap"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-theme-wholeswap.py} ${./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=${./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}"
# #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";
# 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.
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.
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}"
shown = machine.succeed(
f"{env} nomarchy-state-sync get settings.firstBootShown"
).strip()
assert shown == "true", f"marker not written: {shown!r}"
# Second run: silent (no second toast).
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 marker cleared.
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.
auto-theme = pkgs.testers.runNixOSTest {
name = "nomarchy-auto-theme";
nodes.machine = { pkgs, ... }: {
environment.systemPackages = [ pkgs.nomarchy-state-sync ];
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"
)
machine.succeed(
"printf '#!/bin/sh\\necho switch >> /root/rebuilds\\n' > /root/rebuild"
" && chmod +x /root/rebuild"
)
env = ("NOMARCHY_PATH=/root/.nomarchy "
"NOMARCHY_DEFAULT_THEMES=${./themes} "
"NOMARCHY_REBUILD=/root/rebuild ")
# Pair + schedule (setup writes only, no rebuild).
for kv in ["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")
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()}"
# 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"
)
'';
};
};
# ─── 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";
})
]
);
}
];
};
};
}