Merge remote-tracking branch 'origin/main' into main
All checks were successful
Check / eval (push) Successful in 3m2s
Lock bump / bump (push) Successful in 6m30s

This commit is contained in:
2026-07-12 20:21:23 +01:00
26 changed files with 891 additions and 42 deletions

View File

@@ -36,6 +36,8 @@ jobs:
timeout-minutes: 90 timeout-minutes: 90
env: env:
NIX_CONFIG: | NIX_CONFIG: |
max-jobs = 1
cores = 2
experimental-features = nix-command flakes experimental-features = nix-command flakes
sandbox = false sandbox = false
NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt NIX_SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
@@ -68,9 +70,12 @@ jobs:
fi fi
id: update id: update
- name: Gate — nix flake check (eval only) - name: Gate — evaluate every output (eval tier, memory-bounded)
# Per-output processes, not one big `nix flake check --no-build`:
# the single-process walk peaks ~6 GB RSS and OOMs inside the
# runner's 2 GB container cap (details in tools/ci-eval.sh).
if: steps.update.outputs.changed == '1' if: steps.update.outputs.changed == '1'
run: nix flake check --no-build run: bash tools/ci-eval.sh
- name: Gate — V1 build (toplevel + home-manager) - name: Gate — V1 build (toplevel + home-manager)
if: steps.update.outputs.changed == '1' if: steps.update.outputs.changed == '1'

View File

@@ -57,11 +57,15 @@ jobs:
curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" | sh -s -- --no-daemon
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
- name: nix flake check (eval only) - name: Evaluate every output (eval tier, memory-bounded)
# Full module-system evaluation of every output — both nixos # Same coverage as `nix flake check --no-build` — both nixos
# configs, the home config, the checks.* derivations (instantiated, # configs, the home config, the checks.* derivations (instantiated,
# not run) and the downstream template through lib.mkFlake. # not run) and the downstream template through lib.mkFlake — but
run: nix flake check --no-build # ONE output per nix process. A single process walking everything
# peaks at ~6.0 GB RSS (measured 2026-07-12): it OOM'd the 4 GB
# VPS itself, and can never fit this runner's 2 GB container cap.
# Per-output processes cap at the largest single output (~1 GB).
run: bash tools/ci-eval.sh
- name: Python syntax (all tracked scripts) - name: Python syntax (all tracked scripts)
# Every tracked *.py — theme-sync, the installer composers # Every tracked *.py — theme-sync, the installer composers

View File

@@ -194,7 +194,10 @@ nomarchy-what-changed # plain-language last rebuild (system + desktop; --summar
`~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer `~/.nomarchy` is **your machine flake** (`system.nix` / `home.nix`). Newer
Nomarchy code arrives when `flake.lock` updates the `nomarchy` input Nomarchy code arrives when `flake.lock` updates the `nomarchy` input
(`nomarchy-pull`), not by `git pull` of that directory (many installs have (`nomarchy-pull`), not by `git pull` of that directory (many installs have
no remote there — only local commits from auto-commit). no remote there — only local commits from auto-commit). With auto-commit
on (menu: **System Auto-commit**), all three commands above also sweep
any pending hand edits into a commit before they switch, so
`git -C ~/.nomarchy log` mirrors your generations.
| When | Run | | When | Run |
|---|---| |---|---|
@@ -242,6 +245,7 @@ two tables below are split along exactly that line.
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher | | `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed | | `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery | | `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
| `nomarchy.dockAudio.enable` | `true` | Default audio output follows dock/monitor sinks (HDMI/DP/USB) on hotplug, with a toast; EasyEffects follows along; unplug falls back to built-in |
| `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout | | `nomarchy.firstBootWelcome.enable` | `true` | One dismissible “you're set” toast on the first session (SUPER+M / SUPER+T / SUPER+? + network pointer); marker is `settings.firstBootShown` in the flake checkout |
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) | | `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 15 min — battery-only) |
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins | | `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
@@ -293,6 +297,7 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
| `nomarchy.hardware.amd.rocm.enable` | `false` | Opt-in: ROCm HIP/OpenCL GPU compute (multi-GB); pair with `.gfxOverride` (e.g. `"11.0.0"`) for an unlisted iGPU | | `nomarchy.hardware.amd.rocm.enable` | `false` | Opt-in: ROCm HIP/OpenCL GPU compute (multi-GB); pair with `.gfxOverride` (e.g. `"11.0.0"`) for an unlisted iGPU |
| `nomarchy.hardware.fingerprint.enable` | `false` | fprintd for a detected fingerprint reader (installer-set); enroll with `fprintd-enroll` | | `nomarchy.hardware.fingerprint.enable` | `false` | fprintd for a detected fingerprint reader (installer-set); enroll with `fprintd-enroll` |
| `nomarchy.hardware.fingerprint.pam` | `false` | Opt-in: use the fingerprint for login + sudo (PAM) | | `nomarchy.hardware.fingerprint.pam` | `false` | Opt-in: use the fingerprint for login + sudo (PAM) |
| `nomarchy.hardware.fingerprint.parallel` | `true` | With PAM on: password *or* fingerprint at the same prompt (whichever comes first); `false` = stock sequential pam_fprintd. Password alone always stays sufficient |
| `nomarchy.hardware.npu.enable` | `false` | Opt-in/experimental: load the on-die NPU driver (`amdxdna`/`intel_vpu`); userspace runtime is BYO | | `nomarchy.hardware.npu.enable` | `false` | Opt-in/experimental: load the on-die NPU driver (`amdxdna`/`intel_vpu`); userspace runtime is BYO |
| `nomarchy.hardware.latestKernel` | `false` | Opt-in: ship `linuxPackages_latest` instead of the default kernel — for very new hardware whose drivers landed recently | | `nomarchy.hardware.latestKernel` | `false` | Opt-in: ship `linuxPackages_latest` instead of the default kernel — for very new hardware whose drivers landed recently |
| `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire's **v4l2** path so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex. Does **not** hide IR from libcamera / portal / Flatpak pickers — see [HARDWARE.md §7](docs/HARDWARE.md) | | `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire's **v4l2** path so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex. Does **not** hide IR from libcamera / portal / Flatpak pickers — see [HARDWARE.md §7](docs/HARDWARE.md) |

View File

@@ -93,18 +93,11 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.*
fixture. Control-center / MOTD already mention these; the gap is the fixture. Control-center / MOTD already mention these; the gap is the
silent first *graphical* session for people who never open those. silent first *graphical* session for people who never open those.
- **Fingerprint *or* password, in parallel** (2026-07-11, Bernardo) —
`fingerprint.pam` gives pam_fprintd's sequential UX: sudo waits on
the reader, password only after failure/timeout. Bernardo wants
either factor accepted at the same prompt (type the password *or*
touch the sensor, whichever comes first). Stock PAM can't express
this; needs a parallel-conversation module (e.g. `pam-any`, not in
nixpkgs — would be a new package + `fingerprint.pam = "any"` mode).
Cost: medium (package + PAM wiring + careful lockout testing on real
hardware). Security note: keep sudo fallback sane if fprintd hangs.
_(#80#83 + #85#88 shipped 2026-07-11. Theme A day-2 + neon-glass finish _(#80#83 + #85#88 shipped 2026-07-11. Theme A day-2 + neon-glass finish
shipped — VISION ✓. Dock/hibernate V3 → HARDWARE-QUEUE.)_ shipped — VISION ✓. Dock/hibernate V3 → HARDWARE-QUEUE. Parallel
fingerprint-or-password shipped 2026-07-12 (Bernardo promoted it live;
`fingerprint.parallel`, pam-fprint-grosshack) — reader V3 →
HARDWARE-QUEUE.)_
### v1.0 pointer ### v1.0 pointer

View File

@@ -19,13 +19,59 @@ the **T14s** (webcam case).
— should suspend as usual. Optional: with a docked display-profile — should suspend as usual. Optional: with a docked display-profile
(`eDP-1` disable), confirm clamshell layout still looks right after (`eDP-1` disable), confirm clamshell layout still looks right after
lid close. Pass = no surprise suspend when docked. lid close. Pass = no surprise suspend when docked.
- [ ] **#87 dock audio sink follow** — V2 asserts WirePlumber - [ ] **#87 round 2: dock audio auto-follow (watcher)** — round 1 result
`90-nomarchy-dock-audio` rules (HDMI/USB priority). On a laptop (Newton, 2026-07-12): audio stayed on speakers when docking; the
with HDMI or USB-dock audio: play sound on speakers, plug external running generation had no `90-nomarchy-dock-audio` rules yet, and
audio → default sink should move to HDMI/dock (`wpctl status` shows live testing showed the priority rules alone can never win anyway —
`*` on the external sink); unplug → `*` returns to built-in. WirePlumber's stored default (`default-nodes` state, written by any
Manual `wpctl set-default` still works; Bluetooth may outrank dock past explicit pick) outranks `priority.session`, and this machine
(by design). Pass = auto switch without menu intervention. (like most) has one. Fix shipped: the `nomarchy-dock-audio` user
service `pactl set-default-sink`s dock-class sinks on hotplug;
EasyEffects follows the default (verified live: EE's output links
moved to a fake dock-named null sink mid-playback). Retest after
pull+rebuild+home on ≥ this rev: play audio, plug the dock →
toast "Audio → <monitor/dock>" and sound moves with EE effects
still active; unplug → sound returns to speakers; while docked,
picking speakers in the Audio menu sticks until the next replug.
If it does NOT switch, capture for diagnosis: `pactl subscribe`
output during the plug, `pactl --format=json list sinks` while
docked (sink names may not match the dock regexes, or the sink
node may pre-exist and only flip route availability — the watcher
keys on new-sink events).
- [ ] **Docked display/audio menu rows** (2026-07-12) — with a real
external monitor: Display menu shows "Laptop screen off ·
everything → <ext>" (only with >1 active output), "Screen on ·
<name>" after a disable, "Move workspace · → next monitor", "Swap
workspaces" (exactly 2 outputs). Laptop-off must move its
workspaces to the external; Screen on restores it (preferred/auto —
declared scale/pos return at the next relogin/switch). Audio menu
shows "Send output → <dock sink>" when one exists and isn't
default; picking it moves output + toasts. Pass = rows appear/act
as described, and none of them show on a single-display session.
Also — **run this one with nothing important open**: with the
laptop screen off via the menu, physically unplug the dock. The
panel should re-enable within ~2s (`rescue_blackout` in the
display watcher). Known risk from the VM harness
(tools/monitor-fallback.nix): on Hyprland 0.55.4 the zero-output
state itself can crash the compositor faster than any rescue
(softGL VM: 4/5 runs; control with the panel active survives
always) — a crash on real DRM ends the session to the greeter on
the re-lit panel. If that happens on hardware, the "Laptop screen
off" row needs a rework (mirror instead of disable, or drop until
a Hyprland bump) — file it as a NOW bug with the coredump.
- [ ] **Parallel fingerprint-or-password on the real reader** (AMD dev
box, 2026-07-12) — with a finger enrolled and
`fingerprint.pam = true` (parallel is the default): `sudo -k true`
must show ONE prompt ("Enter Password or Place finger…"); typing
the password immediately works, touching the sensor instead works,
wrong-finger ×3 falls back to password, and password keeps working
with fprintd stopped (`systemctl stop fprintd`). Also check
hyprlock and the greeter accept both factors and don't wedge on a
leftover prompt after a fingerprint win (known cosmetic quirk of
the hack — pthread_cancel'd prompt). `fingerprint.parallel = false`
must restore the old sequential behavior. VM already asserts the
PAM stack shape + password-only lockout safety with no reader
(checks.hardware-toggles).
- [ ] **#55 fingerprint enroll on real reader** — with - [ ] **#55 fingerprint enroll on real reader** — with
`nomarchy.hardware.fingerprint.enable` and a physical reader: System `nomarchy.hardware.fingerprint.enable` and a physical reader: System
Fingerprint Enroll a finger; List shows it; Verify succeeds; optional Fingerprint Enroll a finger; List shows it; Verify succeeds; optional

View File

@@ -26,6 +26,117 @@ Template:
- **Pending:** #89 is fully closed. - **Pending:** #89 is fully closed.
- **Next suggestion:** Pick the next item from the BACKLOG. - **Next suggestion:** Pick the next item from the BACKLOG.
## 2026-07-12 — CI OOM: memory-bounded eval for check.yml/bump.yml
- **Task:** Bernardo: check.yml always red since he capped the runner
container at 2 GB (uncapped it OOM'd the whole 4 GB VPS); bump.yml
status unknown.
- **Did:** Measured (GNU time, eval-cache off): one-process `nix flake
check --no-build` peaks **6.0 GB** RSS — that's the whole story. New
`tools/ci-eval.sh`: same coverage, ONE output per nix process (heap
freed between outputs); both workflows now call it. bump.yml also got
max-jobs=1/cores=2. Facts: bump.yml WORKS — landed 8fded63 on
2026-07-06, on schedule; ROADMAP's "no CI today" was stale → fixed.
- **Verified:** V1+ — ci-eval.sh ran green locally end-to-end (3:22);
per-output cold peaks measured: worst check 0.99 GB
(hardware-toggles), nomarchy toplevel 0.78 GB, HM 0.60 GB, **except
nomarchy-live 2.69 GB** (live ISO eval) — fits only with the
container's swap allowance (docker default --memory-swap = 2×memory);
if Bernardo set swap off, that one output still OOMs → server-side
one-liner (--memory=2g --memory-swap=6g) or a skip-hatch decision.
- **Pending:** real proof is the next push's Actions run on the VPS —
watch nomarchy-live's step; can't be verified from this machine.
- **Next suggestion:** confirm green run on the remote, then hardware
V3 batch.
## 2026-07-12 — parallel fingerprint-or-password (interactive, promoted)
- **Task:** Bernardo promoted the PROPOSED "fingerprint or password in
parallel" item live; wants it default-on for fingerprint-PAM users.
- **Did:** Packaged pam-fprint-grosshack v0.3.0 (pkgs/, source-reviewed
first: all failure paths → PAM_AUTHINFO_UNAVAIL, typed password only
ferried via AUTHTOK to pam_unix try_first_pass — can't lock out
password login). New `nomarchy.hardware.fingerprint.parallel`
(default true; false = stock sequential). Wiring swaps stock fprintd's
rule modulePath (mkForce) so ordering is inherited. README/template
rows; BACKLOG PROPOSED entry closed into ROADMAP ✓.
- **Verified:** V2 — checks.hardware-toggles extended to three nodes and
green: parallel node asserts grosshack line before pam_unix in
/etc/pam.d/sudo AND `sudo -S` with the right password succeeds /
wrong password fails with NO reader present (the lockout invariant);
seqpam node = stock pam_fprintd, no grosshack; nopam = neither.
Package builds (meson needed libpam-wrapper + python3). flake check
green.
- **Pending:** V3 on the AMD dev box (HARDWARE-QUEUE): the actual
type-or-touch race, fprintd-stopped fallback, hyprlock/greeter
behavior after a fingerprint win (pthread_cancel'd prompt quirk).
- **Next suggestion:** dock + fingerprint V3 batch on hardware, then
#89 slice 3.
## 2026-07-12 — undock blackout rescue (interactive follow-up)
- **Task:** Bernardo: "if I turn the laptop screen off and undock, does
the panel come back?" Answer had to be tested, not recalled.
- **Did:** Built tools/monitor-fallback.nix (softGL desktop VM, crib of
theme-shot): headless "external" + menu's exact disable + output
remove. Verdict: Hyprland 0.55.4 does NOT re-enable → shipped
`rescue_blackout` in the display hotplug watcher (immediate + retried
keyword re-enable of disabled outputs on zero-active, toast).
- **Verified:** V2 for the hazard (VM reproduced the dead state);
rescue itself is V1+components — the VM crashes before rescue can act
in 4/5 runs (aquamarine ABRT in dispatchIdle on the zero-output
state; control with panel active survives 5/5 — discriminator run).
Crash predates rescue (run with rescue provably inert also crashed).
Also fixed: `grep -c .` exit-1-at-zero disabling the rescue's own
trigger. flake check green.
- **Pending:** real-dock V3 (HARDWARE-QUEUE, "nothing important open"
warning); if real DRM crashes too → rework row (mirror) or wait for
Hyprland bump; consider reporting upstream with the coredump.
- **Next suggestion:** dock V3 results, then #89 slice 3.
## 2026-07-12 — dock audio auto-follow + docked menu rows (interactive)
- **Task:** Bernardo's dock test (= #87's V3): audio stayed on speakers,
had to hand-tweak EasyEffects; wants docked Display/Audio menu options.
- **Did:** Root-caused live on Newton: WirePlumber's stored default
(default-nodes) outranks the #87 priority rules (control test: new
sink, no watcher → default pinned), and the running gen lacked the
rules anyway; EE 8 follows the default fine (kcfg default true +
observed links move). Shipped `modules/home/dock-audio.nix`
(`nomarchy.dockAudio.enable`, on): pactl-subscribe watcher
set-default-sinks new dock-class sinks (regexes imported from
dock-audio-rules.nix) + toast + startup sweep. Menu: Display gains
laptop-off/screen-on/move-workspace/swap rows (live-only keywords);
Audio gains self-gated "Send output → <dock sink>" row.
- **Verified:** V2-equivalent live on Newton — watcher: fake dock-named
null sink → default followed, unplug → fell back, EE links moved
mid-playback (pw-link); menu: generated script bash -n, dock-row jq
exercised against real pactl JSON, dispatchers probed on live
Hyprland. flake check green; option-docs green. Beware: kill the
subscribe pipe by PID — pkill -f self-matches the test shell.
- **Pending:** real-dock V3 ×2 in HARDWARE-QUEUE (watcher may miss
sinks that pre-exist and only flip route availability — capture pactl
subscribe + sink JSON if it fails again).
- **Next suggestion:** #89 slice 3 (Signal pixmap) or dock V3 feedback.
## 2026-07-12 — auto-commit sweep before pull/rebuild/home (interactive)
- **Task:** Bernardo's request (no BACKLOG item): with autoCommit on,
hand edits to `~/.nomarchy` (his had 4) stayed forever-dirty —
theme-sync's commit is pathspec-limited to theme-state.json by design.
- **Did:** New internal `nomarchy-autocommit` in nomarchy-lifecycle:
live-reads `settings.autoCommit`, commits *everything* dirty with
message `nomarchy: auto-commit before <label>` + file list in the
body; called (`|| true`, never blocks) at the top of nomarchy-pull /
-rebuild / -home. Exposed as `passthru.autocommit`; package exported.
Docs/comments swept: README, RECOVERY, ROADMAP ✓ entry, rofi toggle
text, control-center prompt, theme-sync docstring.
- **Verified:** V1+ — `nix flake check --no-build` green; new permanent
`checks.lifecycle-autocommit` builds (real binary, sandbox repos:
sweep/clean/off/non-repo); end-to-end on a *copy* of the real
~/.nomarchy via built nomarchy-rebuild with stubbed sudo/rebuild —
the 4 real dirty files landed in one commit, tree clean. No VM boot:
no session-visible surface. Real-machine confirmation lands after the
next nomarchy-pull picks up this rev.
- **Pending:** nothing queued; Bernardo's actual ~/.nomarchy still has
its 4 uncommitted files until his next pull/rebuild with the new rev.
- **Next suggestion:** back to BACKLOG (#89 slice 3, Signal pixmap).
## 2026-07-12 — #89 slice 2: EasyEffects tray icon themed monochrome (b98248f) ## 2026-07-12 — #89 slice 2: EasyEffects tray icon themed monochrome (b98248f)
- **Task:** BACKLOG #89 slice 2 — EasyEffects SNI shows Papirus' full- - **Task:** BACKLOG #89 slice 2 — EasyEffects SNI shows Papirus' full-
colour blue equaliser, clashing with the monochrome tray. colour blue equaliser, clashing with the monochrome tray.

View File

@@ -167,6 +167,7 @@ Elan, …) via `lsusb` or `/sys/bus/usb/.../idVendor`. On hit:
```nix ```nix
nomarchy.hardware.fingerprint.enable = true; # services.fprintd nomarchy.hardware.fingerprint.enable = true; # services.fprintd
# nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in) # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)
# nomarchy.hardware.fingerprint.parallel = false; # sequential prompt instead
``` ```
### Enroll (menu or CLI) ### Enroll (menu or CLI)
@@ -190,6 +191,21 @@ fprintd-list "$USER"
PAM stays opt-in on purpose: password-only remains the cautious default PAM stays opt-in on purpose: password-only remains the cautious default
until a finger is enrolled. Full enroll on a real reader is V3/hardware. until a finger is enrolled. Full enroll on a real reader is V3/hardware.
### Parallel prompt (password *or* finger, whichever first)
With `fingerprint.pam` on, sudo/login/hyprlock show **one** prompt that
accepts either factor — type the password or touch the sensor
(2026-07-12; `pam-fprint-grosshack`, an fprintd fork, since stock PAM
can't express parallel factors). This is the default;
`fingerprint.parallel = false` restores stock pam_fprintd's sequential
wait-for-the-reader-then-password. Lockout safety is structural: the
module never validates passwords itself — a typed password is handed to
the normal `pam_unix` rule, and every failure (no reader, fprintd hung,
timeout) falls through to password, so finger auth can only ever *add* a
way in. Verify the wiring with
`grep pam_fprintd_grosshack /etc/pam.d/sudo`. Known cosmetic quirk: after
a fingerprint win a leftover password prompt may linger on the console.
### Doctor ### Doctor
`nomarchy-doctor` reports fprintd unit + enroll status when present. `nomarchy-doctor` reports fprintd unit + enroll status when present.
@@ -438,7 +454,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
| `mkFlake.hardwareProfile` | nixos-hardware name or list | | `mkFlake.hardwareProfile` | nixos-hardware name or list |
| `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer | | `nomarchy.hardware.intel.enable` / `.guc` / `.computeRuntime` | Intel gap layer |
| `nomarchy.hardware.amd.enable` / `.pstate` / `.vaapi` / `.rocm.*` | AMD gap layer | | `nomarchy.hardware.amd.enable` / `.pstate` / `.vaapi` / `.rocm.*` | AMD gap layer |
| `nomarchy.hardware.fingerprint.enable` / `.pam` | fprintd + PAM | | `nomarchy.hardware.fingerprint.enable` / `.pam` / `.parallel` | fprintd + PAM (parallel password-or-finger prompt by default) |
| `nomarchy.hardware.npu.enable` | in-kernel NPU only | | `nomarchy.hardware.npu.enable` | in-kernel NPU only |
| `nomarchy.hardware.latestKernel` | `linuxPackages_latest` | | `nomarchy.hardware.latestKernel` | `linuxPackages_latest` |
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) | | `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) |

View File

@@ -269,6 +269,9 @@ default yet.
on whenever fprintd is enabled; Nomarchy forces PAM to follow the on whenever fprintd is enabled; Nomarchy forces PAM to follow the
`pam` flag, but only after a **system** rebuild. Verify with `pam` flag, but only after a **system** rebuild. Verify with
`grep pam_fprintd /etc/pam.d/sudo` (should be empty when pam is off). `grep pam_fprintd /etc/pam.d/sudo` (should be empty when pam is off).
With pam on, the prompt accepts password *or* finger in parallel by
default (`fingerprint.parallel = false` for stock sequential) — see
HARDWARE.md §5.
- **Browser profiles:** Nomarchy does not manage Chromium/Firefox state. - **Browser profiles:** Nomarchy does not manage Chromium/Firefox state.
Bookmarks/extensions live under `~/.config/chromium` (or Bookmarks/extensions live under `~/.config/chromium` (or
`~/.config/google-chrome` / ungoogled paths if that was your previous `~/.config/google-chrome` / ungoogled paths if that was your previous

View File

@@ -30,8 +30,10 @@ boreal` (or any preset). If a switch failed halfway, the state file is written
`home-manager switch --flake ~/.nomarchy` (or `nomarchy-home`). `home-manager switch --flake ~/.nomarchy` (or `nomarchy-home`).
Your flake checkout is a git repo, and with auto-commit enabled every Your flake checkout is a git repo, and with auto-commit enabled every
apply is a commit: `git -C ~/.nomarchy log` to see what changed, apply is a commit — and every `nomarchy-pull`/`-rebuild`/`-home` first
`git revert` the culprit, then `nomarchy-home`. sweeps pending hand edits into one, so history mirrors your generations:
`git -C ~/.nomarchy log` to see what changed, `git revert` the culprit,
then `nomarchy-home`.
## 2. The desktop won't start at all ## 2. The desktop won't start at all

View File

@@ -402,6 +402,68 @@ Design/decision records and a running log of shipped work (items marked
built-in. Rules in `modules/nixos/dock-audio-rules.nix`; built-in. Rules in `modules/nixos/dock-audio-rules.nix`;
`checks.dock-audio` pure + conf-file VM. Real hotplug V3 in `checks.dock-audio` pure + conf-file VM. Real hotplug V3 in
HARDWARE-QUEUE. HARDWARE-QUEUE.
✓ **Dock audio round 2 — auto-follow watcher (2026-07-12):** the T14s
dock test showed priority rules alone can't deliver "just works":
WirePlumber's *stored* default (default-nodes state, written by any
past explicit device pick — every real machine has one) outranks
`priority.session`, so the default never moves on plug. Fix:
`nomarchy-dock-audio` (modules/home/dock-audio.nix,
`nomarchy.dockAudio.enable`, default on) — a `pactl subscribe` watcher
that `set-default-sink`s new dock-class sinks (regex data imported
from dock-audio-rules.nix, single source) + toasts; startup sweep for
login-while-docked (only when the default isn't already dock-class, so
restarts don't override a manual pick). EasyEffects needs nothing: EE 8
`useDefaultOutputDevice` defaults true and verified live — with a
stream playing, EE's output links moved to a fake dock-named null sink
and back on unload. Unplug = WirePlumber priority fallback to built-in.
Docked menu rows shipped alongside: Display gains "Laptop screen off ·
everything → <ext>" (live-only `keyword monitor <eDP>,disable` — a
persisted disable could black-screen an undocked boot), "Screen on ·
<name>", "Move workspace · → next monitor"
(`movecurrentworkspacetomonitor +1`), "Swap workspaces"
(`swapactiveworkspaces`, exactly-2 gate); Audio gains a self-gated
"Send output → <dock sink>" quick row (the watcher's manual override).
Real-dock V3 for both in HARDWARE-QUEUE.
✓ **Undock blackout rescue (2026-07-12):** Bernardo asked whether the
panel comes back if you undock while "Laptop screen off" — VM answer
(tools/monitor-fallback.nix, full softGL desktop): **no**, Hyprland
0.55.4 leaves zero active outputs and never re-enables a soft-disabled
monitor. Fix: `rescue_blackout` in the display hotplug watcher
(hyprland.nix) — on monitorremoved with zero active outputs,
immediately (and with retries) `keyword monitor <name>,preferred,auto,
auto` every disabled output + toast; independent of the profile
auto-switch gate (safety invariant, not a preference). Sharp edge
found while testing: the zero-output state itself crashes Hyprland in
the VM ~4/5 runs (ABRT in aquamarine CBackend::dispatchIdle, ~0.5s —
faster than any userland rescue; control run with the panel active
survives 5/5, so it's the state, not headless removal; monitor-
disconnect crashes are a known upstream class). Posture: rescue ships
as defense-in-depth (recovers every surviving case; worst case on real
DRM is session-to-greeter on the re-lit panel, not a black brick);
V3 decides if the menu row needs a mirror-based rework. Also fixed in
the rescue: `grep -c .` exits 1 at count 0 — an `||`-guard on that
pipeline silently disabled the rescue exactly when it mattered.
✓ **Parallel fingerprint-or-password (2026-07-12, Bernardo promoted
from PROPOSED):** `nomarchy.hardware.fingerprint.parallel` (default
**true** — the better UX is what opting into fingerprint PAM gets you;
`false` restores stock sequential pam_fprintd). Implementation:
packaged `pam-fprint-grosshack` v0.3.0 (pkgs/, GitLab pin — the
field-standard fprintd fork; stock PAM cannot express parallel
factors, linux-pam#301; the cleaner-concept `pam-any` stays the
fallback candidate if grosshack fights greetd/hyprlock). Source
reviewed before packaging: every failure path returns
PAM_AUTHINFO_UNAVAIL (falls through to password) and a typed password
is only ferried via PAM_AUTHTOK to the stock
`auth sufficient pam_unix.so … try_first_pass` — the hack never
validates passwords itself, so password login cannot be locked out by
it. Wiring reuses stock fprintd's rule slot (mkForce'd modulePath) so
ordering is inherited, not recomputed. checks.hardware-toggles grew to
three nodes: parallel default (stack shape + password-works/
wrong-password-fails with no reader), parallel=false (stock
pam_fprintd), pam=false (neither). Real-reader race → HARDWARE-QUEUE
(AMD dev box). Accepted quirks (documented in the package): process-
wide SIGUSR1 handler, pthread_cancel'd prompt on fingerprint win,
prompt buffer not zeroized.
✓ **neon-glass finish (#88, `VISION § D`):** rewrote whole-swap ✓ **neon-glass finish (#88, `VISION § D`):** rewrote whole-swap
`rofi.rasi` to rofi-native `@color` glass panel (cyan rim, soft `rofi.rasi` to rofi-native `@color` glass panel (cyan rim, soft
selection wash — same hex-alpha glass pattern as Boreal); btop title/ selection wash — same hex-alpha glass pattern as Boreal); btop title/
@@ -938,10 +1000,21 @@ Design/decision records and a running log of shipped work (items marked
shrinks the security-patch latency the single-input model otherwise imposes shrinks the security-patch latency the single-input model otherwise imposes
(users are gated on the maintainer for nixpkgs CVEs), since a fix is usually (users are gated on the maintainer for nixpkgs CVEs), since a fix is usually
already eval/build/VM-tested and one hardware promotion away from `v1` — already eval/build/VM-tested and one hardware promotion away from `v1` —
worth fast-laning lock-only/security bumps ahead of feature batches. The repo worth fast-laning lock-only/security bumps ahead of feature batches.
has **no CI today** (manual `nix flake update` only). Explicitly *not* a **Shipped since:** `.gitea/workflows/check.yml` (eval tier on every main
binary cache: compile-from-source is a deliberate values call (Gentoo-style), push) and `bump.yml` (Monday 05:17 UTC scheduled lock bump, eval + V1
so this automates the *config/lock* channel, not artifact distribution. build gate, auto-lands on green — first landing 8fded63, 2026-07-06).
2026-07-12: both gates moved from one big `nix flake check --no-build`
(~6.0 GB peak RSS — OOM'd the 4 GB VPS, then could never fit the
runner's 2 GB container cap) to `tools/ci-eval.sh`, one output per nix
process. **nomarchy-live is excluded by decision** (Bernardo, same
day): the ISO is built/distributed manually, so its guard rides that
manual flow — and its eval alone is 2.69 GB, the one output that can't
fit the cap (Actions run 315: everything else green, it OOM-killed).
With it out, the worst CI output is 0.99 GB — no server change needed. Explicitly
*not* a binary cache: compile-from-source is a deliberate values call
(Gentoo-style), so this automates the *config/lock* channel, not
artifact distribution.
- ✓ **Opt-in auto-commit of state mutations (in-flake state, Phase 4):** - ✓ **Opt-in auto-commit of state mutations (in-flake state, Phase 4):**
`settings.autoCommit` (menu: **System Auto-commit**, self-gated on the `settings.autoCommit` (menu: **System Auto-commit**, self-gated on the
flake being a git repo) makes every `apply`/`set` also `git commit` flake being a git repo) makes every `apply`/`set` also `git commit`
@@ -960,6 +1033,18 @@ Design/decision records and a running log of shipped work (items marked
`get` now prints booleans JSON-style (`true`, not Python's `True`) — `get` now prints booleans JSON-style (`true`, not Python's `True`) —
which also un-sticks the System menu's "Auto timezone (on/off)" label, which also un-sticks the System menu's "Auto timezone (on/off)" label,
whose `= true` comparison could never match before. whose `= true` comparison could never match before.
- ✓ **Auto-commit sweep before switch (2026-07-12):** the pathspec limit
above left hand edits (system.nix/home.nix, lock bumps) forever-dirty
even with `settings.autoCommit` on. Now `nomarchy-pull`/`-rebuild`/
`-home` run `nomarchy-autocommit` (internal, in nomarchy-lifecycle)
first: same live-read flag, commits *everything* dirty with an honest
`nomarchy: auto-commit before <pull|rebuild|home switch>` message whose
body lists the swept files — so `git log` mirrors the generation list
and hand edits still never ride a settings-named commit. Same fallback
identity; never fatal (`|| true` at the call sites — a git hiccup can't
block a rebuild); before the ff-only pull, it also un-dirties the tree.
Guarded by `checks.lifecycle-autocommit` (sandbox-repo round trip of
the real binary: sweep, no-op on clean/off/non-repo).
- **"nomarchy" control center:** a single TUI/GUI front-end over the common - **"nomarchy" control center:** a single TUI/GUI front-end over the common
toggles — theme, power profile, opt-in services, display, DND — built on toggles — theme, power profile, opt-in services, display, DND — built on
the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already the same `nomarchy-theme-sync` / `nomarchy.*` surface the menu already

View File

@@ -105,6 +105,11 @@ unattended env it uses is in the script, and the same flow works
interactively from the live terminal. If the desktop comes up unthemed, interactively from the live terminal. If the desktop comes up unthemed,
read `/var/log/nomarchy-hm-preactivate.log` on the installed system. read `/var/log/nomarchy-hm-preactivate.log` on the installed system.
Two full-desktop VM harnesses (softGL Hyprland, too heavy for `checks.*`)
live next to the scripts: `tools/theme-shot.nix` (themed-desktop
screenshots) and `tools/monitor-fallback.nix` (undock blackout rescue —
disabled panel must re-enable when the last active output disappears).
## 5. VM-specific gotchas ## 5. VM-specific gotchas
- **No KVM** (e.g. nested without acceleration): everything works but - **No KVM** (e.g. nested without acceleration): everything works but

View File

@@ -93,6 +93,7 @@
# switch can refresh a broken system-package nomarchy-pull. # switch can refresh a broken system-package nomarchy-pull.
# Includes nomarchy-what-changed (generation readability, #82). # Includes nomarchy-what-changed (generation readability, #82).
nomarchy-lifecycle = final.callPackage ./pkgs/nomarchy-lifecycle { }; nomarchy-lifecycle = final.callPackage ./pkgs/nomarchy-lifecycle { };
pam-fprint-grosshack = final.callPackage ./pkgs/pam-fprint-grosshack { };
}; };
nixosModules.nomarchy = { nixosModules.nomarchy = {
@@ -125,6 +126,7 @@
# Overlay tools exported for `nix build .#nomarchy-doctor` etc. # Overlay tools exported for `nix build .#nomarchy-doctor` etc.
# (maintainer/CI convenience — modules install them via the overlay). # (maintainer/CI convenience — modules install them via the overlay).
nomarchy-doctor = pkgs.nomarchy-doctor; nomarchy-doctor = pkgs.nomarchy-doctor;
nomarchy-lifecycle = pkgs.nomarchy-lifecycle;
nomarchy-control-center = pkgs.nomarchy-control-center; nomarchy-control-center = pkgs.nomarchy-control-center;
nomarchy-battery-notify = pkgs.nomarchy-battery-notify; nomarchy-battery-notify = pkgs.nomarchy-battery-notify;
nomarchy-first-boot = pkgs.nomarchy-first-boot; nomarchy-first-boot = pkgs.nomarchy-first-boot;
@@ -259,6 +261,55 @@
touch $out touch $out
''; '';
# The lifecycle "sweep before switch": with settings.autoCommit
# on, nomarchy-pull/-rebuild/-home first commit every dirty file
# in the machine flake (theme-sync's own auto_commit stays
# pathspec-limited to theme-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/theme-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/theme-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/theme-state.json
NOMARCHY_PATH=$PWD/plain $tool rebuild
touch $out
'';
# Installer hardware DB ↔ nixos-hardware (item 49): every module # Installer hardware DB ↔ nixos-hardware (item 49): every module
# name the DB table and the CPU/GPU/chassis autodetect reference # name the DB table and the CPU/GPU/chassis autodetect reference
# must exist in the PINNED nixos-hardware.nixosModules — a lock # must exist in the PINNED nixos-hardware.nixosModules — a lock
@@ -489,7 +540,11 @@
# what they SET (kernel cmdline, fprintd, PAM). Real hardware # what they SET (kernel cmdline, fprintd, PAM). Real hardware
# behaviour (firmware/driver/device) needs bare metal and is out of # behaviour (firmware/driver/device) needs bare metal and is out of
# scope here. Imports only hardware.nix, so the VM stays minimal. # scope here. Imports only hardware.nix, so the VM stays minimal.
# Two nodes: pam=true must wire pam_fprintd; pam=false must NOT # 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). # (NixOS defaults fprintAuth from fprintd.enable — we force off).
hardware-toggles = pkgs.testers.runNixOSTest { hardware-toggles = pkgs.testers.runNixOSTest {
name = "nomarchy-hardware-toggles"; name = "nomarchy-hardware-toggles";
@@ -502,6 +557,17 @@
fingerprint = { enable = true; pam = true; }; fingerprint = { enable = true; pam = true; };
npu.enable = 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 = { ... }: { nopam = { ... }: {
imports = [ ./modules/nixos/hardware.nix ]; imports = [ ./modules/nixos/hardware.nix ];
@@ -514,7 +580,23 @@
assert "amd_pstate=active" in cmdline, cmdline assert "amd_pstate=active" in cmdline, cmdline
assert "i915.enable_guc=3" in cmdline, cmdline assert "i915.enable_guc=3" in cmdline, cmdline
machine.succeed("systemctl cat fprintd.service") machine.succeed("systemctl cat fprintd.service")
machine.succeed("grep -q pam_fprintd /etc/pam.d/sudo") 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.wait_for_unit("multi-user.target")
nopam.succeed("systemctl cat fprintd.service") nopam.succeed("systemctl cat fprintd.service")

View File

@@ -28,6 +28,7 @@
./mime.nix # default applications (mimeapps.list), degrades with the suite ./mime.nix # default applications (mimeapps.list), degrades with the suite
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺ ./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds ./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
./dock-audio.nix # default sink follows dock/monitor audio on hotplug (#87)
./first-boot.nix # one-shot "you're set" toast on first session (#81) ./first-boot.nix # one-shot "you're set" toast on first session (#81)
./satty.nix # satty screenshot annotation tool, themed ./satty.nix # satty screenshot annotation tool, themed
]; ];

View File

@@ -0,0 +1,90 @@
# Automatic audio-output follow for docks / external monitors (#87 round
# 2). The WirePlumber priority rules (modules/nixos/dock-audio-rules.nix)
# only decide among sinks when there is NO stored user default — but
# WirePlumber persists every explicit pick (menu, pavucontrol, pactl) in
# its default-nodes state, and that stored default outranks priority. So
# on any machine where a device was ever picked, docking never moves the
# default — Bernardo's T14s dock test, 2026-07-12. This watcher makes the
# switch explicit: when a dock-class sink appears (same regexes as the
# wireplumber rules — imported from that file, single source of truth) it
# `pactl set-default-sink`s it and toasts. EasyEffects needs no handling:
# EE 8's useDefaultOutputDevice defaults to true, so its output follows
# the default device and effects stay in the chain (a user who pinned a
# device inside EE keeps their pin — that's an explicit choice). Unplug
# needs no handling either: the stored dock sink vanishes and WirePlumber
# falls back by priority to the built-in output. A manual pick made while
# docked sticks until the next plug event (a plug is treated as intent).
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy;
rules = import ../nixos/dock-audio-rules.nix;
dockSinkRe = lib.concatStringsSep "|"
(map (m: lib.removePrefix "~" m."node.name")
(lib.concatMap (r: r.matches) rules."monitor.alsa.rules"));
watcher = pkgs.writeShellScript "nomarchy-dock-audio" ''
set -euo pipefail
PACTL=${pkgs.pulseaudio}/bin/pactl
JQ=${pkgs.jq}/bin/jq
TAB=$(printf '\t')
# Self-gate: no PipeWire pulse socket (nomarchy.audio off, TTY-only
# session) means nothing to watch exit clean, stay dead.
rt="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
for _ in $(seq 60); do [ -e "$rt/pulse/native" ] && break; sleep 1; done
[ -e "$rt/pulse/native" ] || exit 0
is_dock() {
printf '%s\n' "$1" | ${pkgs.gnugrep}/bin/grep -qE '^(${dockSinkRe})$'
}
switch_to() { # $1 = sink node name, $2 = description
[ "$($PACTL get-default-sink)" = "$1" ] && return 0
$PACTL set-default-sink "$1" 2>/dev/null || return 0
${pkgs.libnotify}/bin/notify-send -a Nomarchy "Audio" \
"Output ''${2:-$1}" 2>/dev/null || true
}
# Startup sweep (login/relogin while already docked): adopt a dock
# sink only when the current default is NOT one a service restart
# must not override a manual in-dock pick.
if ! is_dock "$($PACTL get-default-sink)"; then
$PACTL --format=json list sinks \
| $JQ -r '.[] | "\(.name)\t\(.description)"' \
| while IFS="$TAB" read -r name desc; do
if is_dock "$name"; then switch_to "$name" "$desc"; break; fi
done
fi
# Event loop: `pactl subscribe` prints "Event 'new' on sink #NN".
LC_ALL=C $PACTL subscribe | while read -r _ ev _ kind id; do
[ "$ev" = "'new'" ] && [ "$kind" = "sink" ] || continue
id=''${id#\#}
case "$id" in *[!0-9]*|"") continue ;; esac
sleep 0.5 # let the node's description/routes settle
line=$($PACTL --format=json list sinks | $JQ -r --argjson i "$id" \
'.[] | select(.index == $i) | "\(.name)\t\(.description)"')
[ -n "$line" ] || continue
name=''${line%%"$TAB"*}; desc=''${line#*"$TAB"}
if is_dock "$name"; then switch_to "$name" "$desc"; fi
done
'';
in
{
config = lib.mkIf cfg.dockAudio.enable {
systemd.user.services.nomarchy-dock-audio = {
Unit = {
Description = "Move the default audio sink to dock/monitor outputs on hotplug";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${watcher}";
Restart = "on-failure";
RestartSec = 2;
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}

View File

@@ -215,7 +215,43 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
case "$v" in true|True) return 0 ;; *) return 1 ;; esac case "$v" in true|True) return 0 ;; *) return 1 ;; esac
} }
outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; } outputs() { hyprctl monitors all -j 2>/dev/null | jq -r '.[].name' | sort; }
# Blackout rescue the session must never end up with ZERO active
# outputs. Hyprland does NOT re-enable a soft-disabled monitor when
# the last active one disconnects (VM-verified, tools/
# monitor-fallback.nix): menu "Laptop screen off" + sudden undock
# left a dead session. On every monitorremoved, if nothing is
# active, re-enable whatever is disabled (preferred/auto the
# declared rule returns at the next reload/relogin). Independent of
# the profile auto-switch and its settings gate: this is a safety
# invariant, not a layout preference.
active_count() {
# NB: `grep -c .` exits 1 when the count is 0 the case this whole
# rescue exists for so never `||`-guard on the pipeline status.
hyprctl monitors -j 2>/dev/null | jq -r '.[].name' | grep -c . || true
}
rescue_blackout() {
# Act IMMEDIATELY and retry: the zero-output state is not just a
# black screen, it can kill the compositor outright in the softGL
# VM harness aquamarine ABRT'd (CBackend::dispatchIdle) within
# ~0.5s of the last output going while it persisted, so shrinking
# the window is the priority; the keyword is idempotent and the
# retries cover a first attempt racing the removal teardown.
tries=0
while [ "$(active_count)" = 0 ] && [ "$tries" -lt 4 ]; do
hyprctl monitors all -j 2>/dev/null | jq -r '.[] | select(.disabled) | .name' \
| while read -r m; do
[ -n "$m" ] || continue
hyprctl keyword monitor "$m,preferred,auto,auto" >/dev/null 2>&1
done
tries=$((tries+1))
sleep 1
done
if [ "$tries" -gt 0 ] && [ "$(active_count)" != 0 ]; then
notify-send "Display" "Screen re-enabled the active output disappeared." 2>/dev/null || true
fi
}
check_monitors() { check_monitors() {
cur=$(outputs) cur=$(outputs)
[ "$cur" = "$1" ] && return [ "$cur" = "$1" ] && return
@@ -239,7 +275,12 @@ ${lib.concatStringsSep "\n" (lib.mapAttrsToList
# Listen to Hyprland's IPC socket for instant hotplug reaction # Listen to Hyprland's IPC socket for instant hotplug reaction
${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do ${pkgs.socat}/bin/socat -U - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock | while read -r line; do
case "$line" in case "$line" in
monitoradded*|monitorremoved*) monitorremoved*)
rescue_blackout
res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res"
;;
monitoradded*)
res=$(check_monitors "$prev") res=$(check_monitors "$prev")
[ -n "$res" ] && prev="$res" [ -n "$res" ] && prev="$res"
;; ;;

View File

@@ -364,6 +364,7 @@ in
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; }; rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; }; swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; }; batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; };
dockAudio.enable = lib.mkEnableOption "automatic default-output switch to dock/monitor audio (HDMI/DisplayPort/USB sinks) on hotplug, with a toast; EasyEffects follows along, and unplugging falls back to the built-in output" // { default = true; };
firstBootWelcome.enable = lib.mkEnableOption "one dismissible \"you're set\" toast on the first session (menu/themes/keys + network pointer); marker is settings.firstBootShown in the flake checkout" // { default = true; }; firstBootWelcome.enable = lib.mkEnableOption "one dismissible \"you're set\" toast on the first session (menu/themes/keys + network pointer); marker is settings.firstBootShown in the flake checkout" // { default = true; };
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; }; idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; }; yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };

View File

@@ -15,6 +15,14 @@ let
px = n: mkLiteral "${toString n}px"; px = n: mkLiteral "${toString n}px";
# Dock-class audio sinks (HDMI/DP/USB) — the same regex data the
# WirePlumber priority rules and the dock-audio watcher consume
# (modules/nixos/dock-audio-rules.nix is the single source).
dockSinkRe = lib.concatStringsSep "|"
(map (m: lib.removePrefix "~" m."node.name")
(lib.concatMap (r: r.matches)
(import ../nixos/dock-audio-rules.nix)."monitor.alsa.rules"));
# Per-theme override probe (same convention as waybar.css). # Per-theme override probe (same convention as waybar.css).
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi"; rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
hasRasiOverride = builtins.pathExists rasiOverride; hasRasiOverride = builtins.pathExists rasiOverride;
@@ -498,13 +506,30 @@ ${themeRows}
if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then if [ ! -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ]; then
notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0 notify-send "Audio" "No PipeWire/PulseAudio socket (nomarchy.audio off?)."; exit 0
fi fi
# Docked quick row: the first dock-class sink (HDMI/DP/USB
# dockSinkRe, single source with the wireplumber rules) that is
# not already the default. One pick sends output there; the
# dock-audio watcher normally does this on plug, so this row is
# the manual override / catch-up. pactl by store path it is
# not on PATH (PipeWire stack, wpctl only).
sinks=$(${pkgs.pulseaudio}/bin/pactl --format=json list sinks 2>/dev/null || true)
cursink=$(${pkgs.pulseaudio}/bin/pactl get-default-sink 2>/dev/null || true)
dockname=$(printf '%s' "$sinks" | jq -r --arg cur "$cursink" --arg re '${dockSinkRe}' \
'[.[] | select((.name | test("^(" + $re + ")$")) and .name != $cur)][0].name // empty')
dockdesc=
[ -n "$dockname" ] && dockdesc=$(printf '%s' "$sinks" \
| jq -r --arg n "$dockname" '[.[] | select(.name == $n)][0].description // empty')
choice=$( { choice=$( {
[ -n "$dockname" ] && row "Send output ''${dockdesc:-$dockname}" video-display
row "Output device" audio-volume-high row "Output device" audio-volume-high
row "Input device" audio-input-microphone row "Input device" audio-input-microphone
back back
} | rofi_menu -show-icons -p Audio) || exit 0 } | rofi_menu -show-icons -p Audio) || exit 0
case "$choice" in case "$choice" in
"$BACK") exec "$0" system ;; "$BACK") exec "$0" system ;;
"Send output"*)
${pkgs.pulseaudio}/bin/pactl set-default-sink "$dockname" \
&& notify-send "Audio" "Output ''${dockdesc:-$dockname}" ;;
*Output*) exec rofi-pulse-select sink ;; *Output*) exec rofi-pulse-select sink ;;
*Input*) exec rofi-pulse-select source ;; *Input*) exec rofi-pulse-select source ;;
esac ;; esac ;;
@@ -522,6 +547,14 @@ ${themeRows}
|| { notify-send "Display" "Hyprland is not running."; exit 0; } || { notify-send "Display" "Hyprland is not running."; exit 0; }
mons=$(hyprctl monitors -j) mons=$(hyprctl monitors -j)
# Docked context: which outputs are active, which is the built-in
# panel, is anything currently soft-disabled (monitors all shows
# disabled outputs; plain monitors does not).
nActive=$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)
internal=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)"))][0] // empty')
external=$(printf '%s' "$mons" | jq -r '[.[].name | select(test("^(eDP|LVDS|DSI)") | not)][0] // empty')
offmon=$(printf '%s' "$(hyprctl monitors all -j)" | jq -r '[.[] | select(.disabled)][0].name // empty')
# Choose the output (skip the chooser when only one is connected and # Choose the output (skip the chooser when only one is connected and
# there are no profiles the Profiles row needs the chooser to live in). # there are no profiles the Profiles row needs the chooser to live in).
# Where the mode picker's Back returns to: the output chooser when it # Where the mode picker's Back returns to: the output chooser when it
@@ -530,14 +563,54 @@ ${themeRows}
# `display` would just re-open this same mode picker (item 40). # `display` would just re-open this same mode picker (item 40).
modeBack=system modeBack=system
profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1 profs=; command -v nomarchy-display-profile >/dev/null 2>&1 && profs=1
if [ -n "$profs" ] || [ "$(printf '%s' "$mons" | jq -r '.[].name' | grep -c .)" -gt 1 ]; then if [ -n "$profs" ] || [ "$nActive" -gt 1 ] || [ -n "$offmon" ]; then
name=$( { name=$( {
[ -n "$profs" ] && printf 'Profiles \n' [ -n "$profs" ] && printf 'Profiles \n'
# Docked rows self-gated: never offer to disable the only
# active display; the re-enable row appears whenever an
# output is soft-off (so an undocked laptop can recover too).
[ "$nActive" -gt 1 ] && [ -n "$internal" ] && [ -n "$external" ] \
&& printf 'Laptop screen off · everything %s\n' "$external"
[ -n "$offmon" ] && printf 'Screen on · %s\n' "$offmon"
[ "$nActive" -gt 1 ] && printf 'Move workspace · next monitor\n'
[ "$nActive" -eq 2 ] && printf 'Swap workspaces · %s %s\n' \
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
"$(printf '%s' "$mons" | jq -r '.[1].name')"
printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"' printf '%s' "$mons" | jq -r '.[] | "\(.name) · \(.width)x\(.height)@\(.refreshRate|round)Hz"'
printf '%s\n' "$BACK" printf '%s\n' "$BACK"
} | rofi_menu -p Display ) || exit 0 } | rofi_menu -p Display ) || exit 0
[ "$name" = "$BACK" ] && exec "$0" system [ "$name" = "$BACK" ] && exec "$0" system
[ "$name" = "Profiles " ] && exec "$0" display-profile [ "$name" = "Profiles " ] && exec "$0" display-profile
case "$name" in
"Laptop screen off"*)
# Live-only on purpose: Hyprland moves the panel's
# workspaces to the remaining output on disable; persisting
# a disable could black-screen an undocked boot, so the
# declared rule returns at the next relogin/switch.
if hyprctl keyword monitor "$internal,disable" >/dev/null 2>&1; then
notify-send "Display" "Laptop screen off workspaces moved to $external (this session; turn it back on from this menu)."
else
notify-send "Display" "Could not disable $internal."
fi
exit 0 ;;
"Screen on"*)
# preferred/auto/auto is good enough live; the declared
# scale/position come back at the next relogin/switch.
if hyprctl keyword monitor "$offmon,preferred,auto,auto" >/dev/null 2>&1; then
notify-send "Display" "$offmon is back on."
else
notify-send "Display" "Could not enable $offmon."
fi
exit 0 ;;
"Move workspace"*)
hyprctl dispatch movecurrentworkspacetomonitor +1 >/dev/null 2>&1
exit 0 ;;
"Swap workspaces"*)
hyprctl dispatch swapactiveworkspaces \
"$(printf '%s' "$mons" | jq -r '.[0].name')" \
"$(printf '%s' "$mons" | jq -r '.[1].name')" >/dev/null 2>&1
exit 0 ;;
esac
name=''${name%% ·*} # strip the " · WxH@R" hint name=''${name%% ·*} # strip the " · WxH@R" hint
modeBack=display modeBack=display
else else
@@ -806,7 +879,9 @@ ${themeRows}
autocommit) autocommit)
# Toggle opt-in auto-commit: every menu/theme mutation also commits # Toggle opt-in auto-commit: every menu/theme mutation also commits
# theme-state.json (that file only) in the downstream flake, so # theme-state.json (that file only) in the downstream flake, so
# settings history is `git log`. Nothing in Nix consumes the flag # settings history is `git log`; nomarchy-pull/-rebuild/-home
# additionally sweep everything else dirty (hand edits, lock bumps)
# into a commit first. Nothing in Nix consumes the flag
# the tool reads the live state on each write so the toggle is # the tool reads the live state on each write so the toggle is
# instant, no rebuild. The off-write commits too (the tool fires # instant, no rebuild. The off-write commits too (the tool fires
# when the flag was on before OR after), keeping history consistent. # when the flag was on before OR after), keeping history consistent.
@@ -815,7 +890,7 @@ ${themeRows}
notify-send "Auto-commit" "Off menu changes stay uncommitted in your flake." notify-send "Auto-commit" "Off menu changes stay uncommitted in your flake."
else else
nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch nomarchy-theme-sync --quiet set settings.autoCommit true --no-switch
notify-send "Auto-commit" "On each change commits theme-state.json in your flake." notify-send "Auto-commit" "On menu changes commit; rebuilds sweep pending edits in too."
fi fi
exit 0 ;; exit 0 ;;

View File

@@ -112,6 +112,20 @@ in
menu) when set; otherwise false. menu) when set; otherwise false.
''; '';
}; };
parallel = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
With fingerprint PAM on, accept the password OR a fingerprint at the
same prompt (type or touch, whichever comes first) instead of stock
pam_fprintd's sequential wait-for-the-reader-then-password. Uses the
pam-fprint-grosshack module (an fprintd fork source-reviewed; every
failure path falls through to the normal password rule, so password
login can never be locked out by it). Set false for the stock
sequential behavior.
'';
};
}; };
npu.enable = lib.mkEnableOption '' npu.enable = lib.mkEnableOption ''
@@ -205,7 +219,20 @@ in
"passwd" "chsh" "chfn" "chpasswd" "passwd" "chsh" "chfn" "chpasswd"
"polkit-1" "swaylock" "polkit-1" "swaylock"
"groupadd" "groupdel" "groupmod" "groupmems" "groupadd" "groupdel" "groupmod" "groupmems"
] (_: { fprintAuth = cfg.fingerprint.pam; }); ] (_: {
fprintAuth = cfg.fingerprint.pam;
} // lib.optionalAttrs (cfg.fingerprint.pam && cfg.fingerprint.parallel) {
# Parallel mode: same rule slot as stock fprintd (so ordering —
# sufficient, before pam_unix — is inherited), different module.
# grosshack prompts for the password itself while polling the
# reader; whichever lands first wins. A typed password makes the
# rule FAIL with the token stored, and the stock
# `auth sufficient pam_unix.so … try_first_pass` right after it
# does the actual validation — password stays sufficient on its
# own, so a broken reader/fprintd can never lock login out.
rules.auth.fprintd.modulePath = lib.mkForce
"${pkgs.pam-fprint-grosshack}/lib/security/pam_fprintd_grosshack.so";
});
}) })
# ── Newest kernel for very-new hardware (opt-in escape hatch) ────── # ── Newest kernel for very-new hardware (opt-in escape hatch) ──────

View File

@@ -25,7 +25,7 @@ function first_boot() {
nomarchy-theme-sync apply "$chosen_theme" nomarchy-theme-sync apply "$chosen_theme"
fi fi
if gum confirm "Enable auto-commit for settings?"; then if gum confirm "Enable auto-commit for your config? (settings changes + a sweep of pending edits before each rebuild)"; then
set_state "settings.autoCommit" "true" set_state "settings.autoCommit" "true"
else else
set_state "settings.autoCommit" "false" set_state "settings.autoCommit" "false"

View File

@@ -15,6 +15,41 @@ let
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}" flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
''; '';
# Opt-in sweep (settings.autoCommit — the same flag theme-sync honours):
# commit EVERYTHING dirty in the machine flake before a pull/rebuild/
# home switch, so hand edits to system.nix/home.nix and lock bumps land
# in history at the moment they become live — `git log` mirrors the
# generation list. Complements nomarchy-theme-sync's auto_commit, which
# is pathspec-limited to theme-state.json on menu writes precisely so
# half-finished hand edits never ride a settings-named commit; here the
# sweep is the point, and the commit body lists what was swept. Never
# fatal — callers `|| true` so a git hiccup can't block a rebuild. Not
# in the symlinkJoin paths (internal; callers use the store path) but
# exposed as passthru.autocommit for checks.lifecycle-autocommit.
nomarchy-autocommit = writeShellScriptBin "nomarchy-autocommit" ''
${preamble "nomarchy-autocommit"}
label="''${1:-rebuild}"
[ -d "$flake/.git" ] || exit 0
command -v git >/dev/null 2>&1 || exit 0
flag=$(${jq}/bin/jq -r '.settings.autoCommit // false' \
"$flake/theme-state.json" 2>/dev/null || true)
[ "$flag" = "true" ] || exit 0
dirty=$(git -C "$flake" status --porcelain || true)
[ -n "$dirty" ] || exit 0
g=(git -C "$flake")
if [ -z "$("''${g[@]}" config user.email 2>/dev/null || true)" ]; then
g+=(-c user.name=Nomarchy -c user.email=nomarchy@localhost)
fi
"''${g[@]}" add -A
if "''${g[@]}" commit --quiet -m "nomarchy: auto-commit before $label" \
-m "$dirty"; then
echo "nomarchy-autocommit: committed pending flake changes before $label:"
echo "$dirty" | sed 's/^/ /'
else
echo "nomarchy-autocommit: commit failed continuing without it" >&2
fi
'';
nomarchy-pull = writeShellScriptBin "nomarchy-pull" '' nomarchy-pull = writeShellScriptBin "nomarchy-pull" ''
${preamble "nomarchy-pull"} ${preamble "nomarchy-pull"}
if [ ! -e "$flake/flake.nix" ]; then if [ ! -e "$flake/flake.nix" ]; then
@@ -22,6 +57,9 @@ let
echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2 echo " Set NOMARCHY_PATH or put your machine flake in ~/.nomarchy." >&2
exit 1 exit 1
fi fi
# With settings.autoCommit on, sweep pending hand edits into a commit
# first also keeps the ff-only pull below safe on a dirty tree.
${nomarchy-autocommit}/bin/nomarchy-autocommit pull || true
# Optional: pull *your* machine config only if this checkout tracks a # Optional: pull *your* machine config only if this checkout tracks a
# remote. Distro updates come from the nomarchy flake *input* below # remote. Distro updates come from the nomarchy flake *input* below
# many installs have no upstream on ~/.nomarchy (local auto-commits). # many installs have no upstream on ~/.nomarchy (local auto-commits).
@@ -52,6 +90,9 @@ let
echo "nomarchy-rebuild: no flake.nix at $flake" >&2 echo "nomarchy-rebuild: no flake.nix at $flake" >&2
exit 1 exit 1
fi fi
# With settings.autoCommit on, sweep pending hand edits (system.nix,
# lock bumps, ) into a commit so `git log` mirrors the generations.
${nomarchy-autocommit}/bin/nomarchy-autocommit rebuild || true
before=$(readlink -f /run/current-system) before=$(readlink -f /run/current-system)
log=$(mktemp) log=$(mktemp)
trap 'rm -f "$log"' EXIT trap 'rm -f "$log"' EXIT
@@ -94,6 +135,9 @@ let
echo "nomarchy-home: no flake.nix at $flake" >&2 echo "nomarchy-home: no flake.nix at $flake" >&2
exit 1 exit 1
fi fi
# With settings.autoCommit on, sweep pending hand edits (home.nix, )
# into a commit so `git log` mirrors the generations.
${nomarchy-autocommit}/bin/nomarchy-autocommit "home switch" || true
# Snapshot the active HM generation before the switch so we can nvd it. # Snapshot the active HM generation before the switch so we can nvd it.
hm_before="" hm_before=""
for d in \ for d in \
@@ -164,6 +208,7 @@ symlinkJoin {
home-update home-update
nomarchy-what-changed nomarchy-what-changed
]; ];
passthru.autocommit = nomarchy-autocommit;
meta = { meta = {
description = "Nomarchy machine-flake lifecycle: pull, rebuild, home"; description = "Nomarchy machine-flake lifecycle: pull, rebuild, home";
mainProgram = "nomarchy-pull"; mainProgram = "nomarchy-pull";

View File

@@ -143,7 +143,10 @@ def auto_commit(message: str) -> None:
fire this when the flag is on before OR after the write, so the fire this when the flag is on before OR after the write, so the
disable-toggle itself lands in history instead of staying dirty. disable-toggle itself lands in history instead of staying dirty.
`bg` is deliberately excluded (runtime wallpaper churn); the wallpaper `bg` is deliberately excluded (runtime wallpaper churn); the wallpaper
path rides along with the next apply/set commit.""" path rides along with the next apply/set commit. The rest of a dirty
tree (hand edits, lock bumps) is swept by nomarchy-lifecycle's
nomarchy-autocommit right before a pull/rebuild/home switch — same
flag, honest message — so it never rides a settings-named commit."""
if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None: if not (FLAKE_DIR / ".git").exists() or shutil.which("git") is None:
return return
git = ["git", "-C", str(FLAKE_DIR)] git = ["git", "-C", str(FLAKE_DIR)]

View File

@@ -0,0 +1,50 @@
# pam_fprintd_grosshack.so — fprintd's PAM module forked to accept the
# password OR a fingerprint at the same prompt (upstream calls itself a
# "gross hack"; it is also the field-standard answer to this PAM gap —
# stock PAM cannot express parallel factors, linux-pam#301).
#
# Source reviewed before packaging (2026-07-12, v0.3.0 = fprintd 1.94.2
# base + ~60-line delta): every failure path — no reader, no enrolled
# prints, fprintd absent, timeout, password typed — returns
# PAM_AUTHINFO_UNAVAIL, i.e. the module FAILS and PAM falls through to
# the password rule; a typed password is never validated here, only
# ferried via PAM_AUTHTOK to pam_unix (try_first_pass). Accepted
# quirks: installs a process-wide SIGUSR1 handler in the calling
# process, pthread_cancels the prompt thread on fingerprint win, and
# does not zeroize the prompt buffer. Consumed by
# modules/nixos/hardware.nix (nomarchy.hardware.fingerprint.parallel).
{ lib, stdenv, fetchFromGitLab, meson, ninja, pkg-config, gettext, python3
, glib, libfprint, polkit, pam, systemd, dbus, libpam-wrapper }:
stdenv.mkDerivation rec {
pname = "pam-fprint-grosshack";
version = "0.3.0";
src = fetchFromGitLab {
owner = "mishakmak";
repo = "pam-fprint-grosshack";
rev = "v${version}";
hash = "sha256-obczZbf/oH4xGaVvp3y3ZyDdYhZnxlCWvL0irgEYIi0=";
};
# Only the PAM module is built (the fork disables every other fprintd
# subdir), but the top-level meson still resolves the full fprintd
# dependency set — hence libfprint/polkit in buildInputs.
# pam_wrapper is only exercised by the (disabled) test suite, but the
# top-level meson marks it required whenever the pam option is on.
nativeBuildInputs = [ meson ninja pkg-config gettext python3 ];
buildInputs = [ glib libfprint polkit pam systemd dbus libpam-wrapper ];
mesonFlags = [
"-Dpam_modules_dir=${placeholder "out"}/lib/security"
"-Dsystemd=false"
"-Dman=false"
];
meta = {
description = "PAM module accepting password or fingerprint in parallel (fprintd fork)";
homepage = "https://gitlab.com/mishakmak/pam-fprint-grosshack";
license = lib.licenses.gpl2Plus;
platforms = lib.platforms.linux;
};
}

View File

@@ -17,6 +17,7 @@
# nomarchy.terminal = "kitty"; # swap the default terminal # nomarchy.terminal = "kitty"; # swap the default terminal
# nomarchy.waybar.enable = false; # bring your own bar # nomarchy.waybar.enable = false; # bring your own bar
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming # nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
# nomarchy.dockAudio.enable = false; # keep audio put when docking
# #
# Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and # Icon pack: Papirus ships by default (auto Dark/Light by theme mode) and
# is the ONLY icon pack in your closure. To use another, set the `icons` # is the ONLY icon pack in your closure. To use another, set the `icons`

View File

@@ -63,6 +63,7 @@
# amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU # amd.rocm.gfxOverride = "11.0.0"; # HSA override for an unlisted iGPU
# fingerprint.enable = true; # fprintd; installer-set when a reader is detected # fingerprint.enable = true; # fprintd; installer-set when a reader is detected
# fingerprint.pam = true; # use the fingerprint for login + sudo # fingerprint.pam = true; # use the fingerprint for login + sudo
# fingerprint.parallel = false; # sequential prompt instead of password-or-finger
# npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO) # npu.enable = true; # on-die NPU driver (experimental; userspace runtime BYO)
# latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default) # latestKernel = true; # newest kernel for very-new hardware (drivers not yet in the default)
# camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam # camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node so apps get the color cam

47
tools/ci-eval.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# CI eval tier — same coverage as `nix flake check --no-build`, bounded
# memory. One evaluator process walking every output accumulates ~6.0 GB
# peak RSS on this repo (measured 2026-07-12, eval-cache off): that
# OOM'd the whole 4 GB VPS behind git.bemagri.xyz, and can never fit the
# runner's 2 GB container cap. Evaluating ONE output per fresh nix
# process caps the peak at the largest single output (~0.93 GB,
# downstream-template-system): the heap is freed when each process
# exits. Wall time is worse (shared nixpkgs re-eval per process);
# irrelevant next to the memory cap. nomarchy-live is skipped by
# decision (see the loop below); with it out, the worst output is
# hardware-toggles at 0.99 GB. Used by .gitea/workflows/check.yml
# and bump.yml; runs anywhere (`bash tools/ci-eval.sh`).
set -euo pipefail
sys=${1:-x86_64-linux}
list() {
nix eval --raw ".#$1" \
--apply 'a: builtins.concatStringsSep "\n" (builtins.attrNames a)'
}
evaluate() {
echo "eval: $1"
nix eval --raw ".#$1" >/dev/null
}
for a in $(list "checks.$sys"); do
evaluate "checks.$sys.$a.drvPath"
done
for c in $(list nixosConfigurations); do
# nomarchy-live (the installer ISO) is deliberately NOT CI-gated
# (Bernardo, 2026-07-12): the ISO is built and distributed manually,
# so its guard lives with that manual flow (local `nix flake check` /
# the ISO build itself) — and it is the one output whose EVAL alone
# (~2.7 GB) cannot fit the runner's 2 GB cap (Actions run 315: every
# other output green, this one OOM-killed).
if [ "$c" = "nomarchy-live" ]; then
echo "skip: nixosConfigurations.$c (manual ISO flow — see comment)"
continue
fi
evaluate "nixosConfigurations.$c.config.system.build.toplevel.drvPath"
done
for h in $(list homeConfigurations); do
evaluate "homeConfigurations.$h.activationPackage.drvPath"
done
echo "ci-eval: all outputs evaluated."

110
tools/monitor-fallback.nix Normal file
View File

@@ -0,0 +1,110 @@
# Undock blackout-rescue harness — boots the full desktop headlessly
# (softGL Hyprland, same recipe as theme-shot.nix) and replays the
# dangerous sequence: add a headless "external", soft-disable the
# internal panel (the Display menu's "Laptop screen off" command), then
# remove the external. Hyprland does NOT re-enable a disabled monitor on
# its own (verified 2026-07-12 — the session ends with zero active
# outputs), so the display-profile watcher's rescue_blackout
# (modules/home/hyprland.nix) must bring the panel back; this asserts it
# does. KNOWN FLAKE (2026-07-12, Hyprland 0.55.4): the zero-active-output
# state itself crashes Hyprland in this VM ~4/5 runs (ABRT in aquamarine
# CBackend::dispatchIdle, faster than any userland rescue) — a control
# run removing the external with the internal ACTIVE survives every
# time, so it's the zero-output state, not headless removal. A crashed
# run is that upstream bug, not a rescue regression; the assertion can
# only pass on a surviving run. Maintainer tool, NOT a checks.* gate
# (full-desktop VM — same weight call as theme-shot.nix). Run:
# nix build --impure -f tools/monitor-fallback.nix --no-link -L
let
flake = builtins.getFlake ("git+file://" + toString ../.);
inherit (flake.inputs) nixpkgs home-manager;
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [ flake.overlays.default ];
config.allowUnfree = true;
};
in
pkgs.testers.runNixOSTest {
name = "monitor-fallback";
node.pkgsReadOnly = false;
node.specialArgs = { username = "nomarchy"; };
nodes.machine = { pkgs, lib, ... }: {
imports = [ flake.nixosModules.nomarchy home-manager.nixosModules.home-manager ];
virtualisation.memorySize = 4096;
virtualisation.cores = 4;
hardware.graphics.enable = true;
environment.variables.LIBGL_ALWAYS_SOFTWARE = "1";
boot.initrd.availableKernelModules = [ "virtio_gpu" ];
virtualisation.qemu.options = [ "-vga none" "-device virtio-gpu-pci,xres=1920,yres=1080" ];
users.users.nomarchy = {
isNormalUser = true;
password = "test";
extraGroups = [ "wheel" "video" ];
};
nomarchy.system.stateFile = flake + "/themes/boreal.json";
systemd.tmpfiles.rules = [
"d /home/nomarchy/.nomarchy 0755 nomarchy users -"
"C /home/nomarchy/.nomarchy/theme-state.json 0644 nomarchy users - ${flake + "/themes/boreal.json"}"
];
services.greetd.settings.initial_session = {
command = "start-hyprland";
user = "nomarchy";
};
home-manager.useGlobalPkgs = true;
home-manager.users.nomarchy = {
imports = [ flake.homeModules.nomarchy ];
nomarchy.stateFile = flake + "/themes/boreal.json";
nomarchy.idle.enable = false;
};
};
testScript = ''
import json, time
machine.wait_for_unit("multi-user.target")
machine.wait_for_file("/run/user/1000/hypr", 180)
machine.sleep(20) # softGL first paint settle
hy = ("su - nomarchy -c 'XDG_RUNTIME_DIR=/run/user/1000 "
"HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1) hyprctl ")
def mons(all=False):
out = machine.succeed(hy + ("-j monitors all'" if all else "-j monitors'"))
return json.loads(out)
initial = mons()
assert len(initial) == 1, f"expected 1 initial monitor, got {initial}"
internal = initial[0]["name"]
print(f"internal panel: {internal}")
# "Plug the dock": add a headless output as the external monitor.
machine.succeed(hy + "output create headless'")
for _ in range(20):
if len(mons()) == 2: break
time.sleep(1)
names = [x["name"] for x in mons()]
ext = [n for n in names if n != internal][0]
print(f"external (headless): {ext}")
# Menu action: "Laptop screen off" the exact command the row runs.
machine.succeed(hy + f"keyword monitor {internal},disable'")
for _ in range(20):
if [x["name"] for x in mons()] == [ext]: break
time.sleep(1)
assert [x["name"] for x in mons()] == [ext], f"disable failed: {mons()}"
alln = {x["name"]: x.get("disabled", False) for x in mons(all=True)}
assert alln.get(internal) is True, f"{internal} not marked disabled: {alln}"
print("laptop panel disabled, only external active now yank it")
# Sudden undock: remove the external while the internal is disabled.
machine.succeed(hy + f"output remove {ext}'")
recovered = False
for i in range(30):
if [x["name"] for x in mons()] == [internal]:
recovered = True
print(f"recovered after {i+1}s: {internal} re-enabled")
break
time.sleep(1)
print(f"monitors now: {mons(all=True)}")
assert recovered, "internal panel did NOT come back after undock"
'';
}