feat(menu): one fingerprint switch, an auto-login toggle, and the state bridge they needed
All checks were successful
Check / eval (push) Successful in 3m7s

Bernardo, post-reboot: "Use for login" was the wrong question. Whether the
finger works is one decision, not two, and whether login prompts at all is
a different decision that was never in the menu.

System › Fingerprint is now a single Fingerprint (on/off) switch, leading
the menu with enroll/list/verify/delete as the plumbing behind it. It
writes the one settings.fingerprint.pam key, and modules/home/idle.nix now
defaults idle.fingerprint from that same key — so the lock screen and
login/sudo move together instead of drifting apart the way they did until
e2de906. nomarchy-fingerprint does the two rebuilds this needs (sudo
system for PAM, home switch for hyprlock) and refuses to turn on with no
finger enrolled.

System › Auto-login is new (nomarchy-autologin), and it is what decides
whether anything is asked at boot: auto-login on means no prompt whatever
the fingerprint switch says; off means the greeter asks, for a password or
a finger. Installer-seeded ON for LUKS machines — the passphrase already
gates the disk — and off without it, where the greeter is the only thing
between power-on and the desktop.

Both had to become state-owned to be toggleable at all, which surfaced two
real bugs:

  * nomarchy.system.greeter.autoLogin defaulted from
    `config.nomarchy.settings…` — an attribute that exists ONLY on the Home
    Manager side. On NixOS it is absent and `or null` swallowed the error,
    so the default silently evaluated to null on every machine ever built.
    That is why the installer baked a Nix line: the state path never
    worked. Now read via theme-state-read.nix (the hardware.nix/timezone.nix
    pattern) and mkDefault'd, so the menu owns it and a hand-set line still
    pins it. Two more options read the same phantom bridge — BACKLOG #116.
  * `theme-sync get` printed Python's "None" for a JSON null, so every
    `case … null)` a caller writes would miss. Now prints "null", as the
    comment above it already promised for booleans.

The installer seeds the state instead of emitting the system.nix line,
because that line outranks the state and would strand the toggle.

V1 (V3 pending: HARDWARE-QUEUE). nix flake check --no-build, installer-
safety and option-docs all pass. Proved by eval/build, not assumed: a state
carrying autoLogin yields greetd initial_session {"user":"bernardo"}, the
template state (no autoLogin) yields none, and a hand-set null beats a state
that says otherwise; a state with only fingerprint.pam=true — nothing set by
hand — renders the hyprlock auth.fingerprint block; both new tools pass
bash -n and land in systemPackages (nomarchy-fingerprint only with a
reader); the patcher writes settings.greeter.autoLogin and no system.nix
line; and the get round trip prints null, so the menu reads "Auto-login
(off)" where it would have read "(on)".

The reader itself, the two rebuilds, and the reboot are hardware — queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 14:34:56 +01:00
parent 9792976b11
commit eb38008ebb
14 changed files with 334 additions and 60 deletions

View File

@@ -367,23 +367,29 @@ in
dockAudio.enable = lib.mkEnableOption "settled PipeWire/WirePlumber reprobe and automatic default-output switch to an available dock/monitor sink (HDMI/DisplayPort/USB) on fresh display hotplug, with a toast and journal result; a later manual choice sticks until the next plug" // { 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.fingerprint = lib.mkEnableOption ''
unlocking the lock screen with a fingerprint as well as the password,
and saying so on the input field.
idle.fingerprint = lib.mkOption {
type = lib.types.bool;
default = config.nomarchy.settings.fingerprint.pam or false;
defaultText = lib.literalExpression
"(settings.fingerprint.pam from theme-state.json) or false";
description = ''
Unlock the lock screen with a fingerprint as well as the password, and
say so on the input field.
Mirrors `nomarchy.hardware.fingerprint.pam` in system.nix keep the two
matched, the same way `nomarchy.keyboard.layout` matches
`services.xserver.xkb.layout`. It cannot be derived: hyprlock is
configured here, in standalone Home Manager, which has no `osConfig` to
read the NixOS side from.
Reads the SAME `settings.fingerprint.pam` state key that
`nomarchy.hardware.fingerprint.pam` defaults from, so the one
System Fingerprint toggle moves the lock screen and login/sudo
together "fingerprint on" is one decision, not two that can drift.
(It has to arrive by state, not by reading the NixOS side: hyprlock is
configured here, in standalone Home Manager, which has no `osConfig`.)
Deliberately separate from the PAM flag rather than implied by it:
hyprlock does NOT unlock by fingerprint through PAM. Its PAM stack only
runs on submit, so a parallel module never gets to poll hyprlock has
its own fprintd-over-D-Bus backend instead, and this is the switch for
it. With no reader present it advertises a scan that cannot happen, so
it stays opt-in
'';
The two remain separate *mechanisms*, which is why this option still
exists to be set by hand: hyprlock does NOT unlock by fingerprint
through PAM. Its PAM stack only runs on submit, so a parallel module
never gets to poll hyprlock has its own fprintd-over-D-Bus backend
instead, and this is the switch for it.
'';
};
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };
shell.enable = lib.mkEnableOption "the zsh shell experience (starship prompt, bat/eza/zoxide)" // { default = true; };

View File

@@ -901,6 +901,16 @@ ${themeRows}
|| { notify-send "Auto timezone" "Unavailable on this machine."; exit 0; }
exec ${cfg.terminal} -e nomarchy-autotimezone toggle ;;
autologin)
# Toggle booting straight into the session (greetd initial_session).
# A SYSTEM setting baked at rebuild, so this sudos a system switch in
# a terminal, like Auto timezone and the change shows on the NEXT
# boot, not now. Off is what makes the greeter ask for a password (or
# a finger, when System Fingerprint is on).
command -v nomarchy-autologin >/dev/null 2>&1 \
|| { notify-send "Auto-login" "Unavailable on this machine."; exit 0; }
exec ${cfg.terminal} -e nomarchy-autologin toggle ;;
autocommit)
# Toggle opt-in auto-commit: every menu/theme mutation also commits
# theme-state.json (that file only) in the downstream flake, so
@@ -977,13 +987,17 @@ ${themeRows}
command -v fprintd-list >/dev/null 2>&1 \
|| { notify-send "Fingerprint" "fprintd not available (nomarchy.hardware.fingerprint.enable?)."; exit 0; }
pam=$(nomarchy-theme-sync get settings.fingerprint.pam 2>/dev/null || echo false)
case "$pam" in true|True) pam_label="Use for login (on)" ;; *) pam_label="Use for login (off)" ;; esac
case "$pam" in true|True) pam_label="Fingerprint (on)" ;; *) pam_label="Fingerprint (off)" ;; esac
choice=$( {
# The switch leads: it is the decision this menu exists for, and
# the rest (enroll/list/verify/delete) is the plumbing behind it
# all still usable while the switch is off, since turning it on
# requires an enrolled finger.
row "$pam_label" system-lock-screen
row "Enroll finger" preferences-desktop-user-password
row "List enrolled" view-list
row "Verify" dialog-password
row "Delete all" edit-delete
row "$pam_label" system-lock-screen
back
} | rofi_menu -show-icons -p Fingerprint) || exit 0
case "$choice" in
@@ -1013,17 +1027,15 @@ ${themeRows}
*) echo "Cancelled." ;;
esac
printf "\nEnter to close."; read -r _' ;;
*"Use for login"*)
case "$pam" in true|True) new=false ;; *) new=true ;; esac
if [ "$new" = true ]; then
if fprintd-list "$USER" 2>/dev/null | grep -qiE 'no fingers enrolled|No devices available'; then
notify-send "Fingerprint" "Enroll a finger first, then enable login."
exit 0
fi
fi
nomarchy-theme-sync --quiet set settings.fingerprint.pam "$new" --no-switch
notify-send "Fingerprint" "Use for login: $new applies on next nomarchy-rebuild."
;;
*"Fingerprint ("*)
# Drives BOTH sides off one state key (sudo/login PAM + the
# hyprlock unlock), so it sudos a system rebuild and runs a home
# switch in a terminal, like Auto timezone. The enrolled-finger
# guard and the state write live in the tool, so the CLI and this
# row can't disagree.
command -v nomarchy-fingerprint >/dev/null 2>&1 \
|| { notify-send "Fingerprint" "nomarchy-fingerprint not on PATH (hardware.fingerprint.enable?)."; exit 0; }
exec ${cfg.terminal} -e nomarchy-fingerprint toggle ;;
esac ;;
controlcenter)
@@ -1139,6 +1151,17 @@ ${themeRows}
then row "Auto timezone (on)" preferences-system-time
else row "Auto timezone (off)" preferences-system-time
fi
# Auto-login self-gated on the tool (greeter.enable). Off = the
# greeter asks; that's the prompt the Fingerprint switch feeds.
if command -v nomarchy-autologin >/dev/null 2>&1; then
# Unset (get exits 1, empty) and null both read as off. "None" is
# what an older theme-sync printed for null accepted so a menu
# from a newer generation can't misreport against an older tool.
case "$(nomarchy-theme-sync get settings.greeter.autoLogin 2>/dev/null)" in
null|""|None) row "Auto-login (off)" system-users ;;
*) row "Auto-login (on)" system-users ;;
esac
fi
if [ -e "''${NOMARCHY_PATH:-$HOME/.nomarchy}/.git" ]; then
if [ "$(nomarchy-theme-sync get settings.autoCommit 2>/dev/null)" = true ]
then row "Auto-commit (on)" git
@@ -1178,6 +1201,7 @@ ${themeRows}
*Printers*) exec "$0" printers ;;
*"Do Not Disturb"*) exec "$0" dnd ;;
*"Auto timezone"*) exec "$0" autotimezone ;;
*"Auto-login"*) exec "$0" autologin ;;
*"Auto-commit"*) exec "$0" autocommit ;;
*Snapshots*) exec "$0" snapshot ;;
*Rollback*) exec "$0" rollback ;;
@@ -1294,7 +1318,7 @@ ${themeRows}
esac ;;
*)
echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&2
echo "usage: nomarchy-menu [lookfeel|tools|system|power|power-profile|powermgmt|batterylimit|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|display|display-profile|keyboard|printers|capture|colorpicker|keybinds|ask|dnd|nightlight|autotimezone|autologin|autocommit|vpn|snapshot|doctor|firmware|fingerprint|controlcenter|rollback|whatchanged]" >&2
exit 64 ;;
esac
'';

View File

@@ -14,16 +14,67 @@
# theme's colors). ANSI "black" stays dark even in light themes —
# the greeter reads terminal-dark there, the same convention every
# terminal applies to ANSI colors.
#
# Auto-login is in-flake state like the rest (settings.greeter.autoLogin,
# written by System Auto-login via nomarchy-autologin below), NOT a baked
# line in system.nix: a hand-set `nomarchy.system.greeter.autoLogin` outranks
# the state default, which would leave the menu toggle flipping JSON that
# nothing reads. The installer therefore seeds the STATE on LUKS machines and
# the template keeps its example commented (templates/downstream/system.nix).
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy.system;
distroName = config.system.nixos.distroName;
sync = lib.getExe pkgs.nomarchy-theme-sync;
# Menu/CLI toggle, same shape as nomarchy-autotimezone: runs as the normal
# user (it owns the flake checkout + writes the state), sudos only the
# system switch. greetd's initial_session is baked at system rebuild, so
# there is nothing to apply live — the next boot is the observable change.
nomarchy-autologin = pkgs.writeShellScriptBin "nomarchy-autologin" ''
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "nomarchy-autologin: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
cur=$(${sync} get settings.greeter.autoLogin 2>/dev/null) || cur=null
case "''${1:-toggle}" in
on) new="\"$USER\"" ;;
off) new=null ;;
toggle) case "$cur" in null|""|None) new="\"$USER\"" ;; *) new=null ;; esac ;;
status) echo "$cur"; exit 0 ;;
*) echo "usage: nomarchy-autologin [toggle|on|off|status]" >&2; exit 64 ;;
esac
${sync} --quiet set settings.greeter.autoLogin "$new" --no-switch
notify-send "Auto-login" "Rebuilding the system" 2>/dev/null || true
sudo nixos-rebuild switch --flake "$flake#default"
if [ "$new" = null ]; then
notify-send "Auto-login off" "The greeter asks who you are on the next boot." 2>/dev/null || true
else
notify-send "Auto-login on" "Next boot goes straight to the desktop." 2>/dev/null || true
fi
'';
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
else { };
# The auto-login user from the state, or null. Read here via the state file
# — NOT `config.nomarchy.settings`, which exists only on the Home Manager
# side: on NixOS that attribute is missing, and `or null` swallows the
# error, so the old default silently evaluated to null on every machine.
stateAutoLogin =
let v = (state.settings or { }).greeter.autoLogin or null;
in if builtins.isString v && v != "" then v else null;
# A sparse/hand-rolled state without a proper ansi block just skips the
# theming (stock tuigreet grey) — never an eval error.
ansi = state.ansi or [ ];
@@ -44,6 +95,15 @@ let
in
{
config = {
# Shipped unconditionally so the menu can turn auto-login back ON while
# it's off — the same reason nomarchy-autotimezone is unconditional.
environment.systemPackages = [ nomarchy-autologin ];
# Track the in-flake flag; mkDefault so a hand-set
# nomarchy.system.greeter.autoLogin in system.nix still wins (the
# autoTimezone pattern).
nomarchy.system.greeter.autoLogin = lib.mkDefault stateAutoLogin;
# VT palette from the theme (RRGGBB, no #; lands as vt.default_* kernel
# params). mkDefault so a downstream console.colors wins.
console.colors = lib.mkIf themed (lib.mkDefault (map (lib.removePrefix "#") ansi));

View File

@@ -23,6 +23,58 @@ let
then import ../theme-state-read.nix { inherit lib; } config.nomarchy.system.stateFile
else { };
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
sync = lib.getExe pkgs.nomarchy-theme-sync;
# The single fingerprint on/off switch (System Fingerprint). One state key
# for one user-facing decision — it drives login/sudo PAM here AND the
# hyprlock unlock in modules/home/idle.nix, which reads the same
# settings.fingerprint.pam. Two rebuilds, because the two live in different
# configurations: sudo the system switch (PAM), then a home switch
# (hyprlock). Same user-owns-the-flake shape as nomarchy-autotimezone.
#
# This does NOT decide whether login prompts at all — auto-login skips the
# greeter entirely, so "fingerprint on" adds the finger to whatever prompts
# actually happen (sudo, lock screen, and the greeter only when auto-login
# is off). See nomarchy-autologin in ./greeter.nix.
nomarchy-fingerprint = pkgs.writeShellScriptBin "nomarchy-fingerprint" ''
set -e
if [ "$(id -u)" -eq 0 ]; then
echo "nomarchy-fingerprint: run as your normal user (it sudos the rebuild itself)" >&2
exit 1
fi
flake="''${NOMARCHY_PATH:-$HOME/.nomarchy}"
cur=$(${sync} get settings.fingerprint.pam 2>/dev/null) || cur=false
case "''${1:-toggle}" in
on) new=true ;;
off) new=false ;;
toggle) case "$cur" in true|True) new=false ;; *) new=true ;; esac ;;
status) echo "$cur"; exit 0 ;;
*) echo "usage: nomarchy-fingerprint [toggle|on|off|status]" >&2; exit 64 ;;
esac
# Turning it ON with no enrolled finger would advertise a scan that cannot
# succeed on every prompt refuse instead, and say where to go.
if [ "$new" = true ] \
&& fprintd-list "$USER" 2>/dev/null | grep -qiE 'no fingers enrolled|No devices available'; then
notify-send "Fingerprint" "Enroll a finger first (System Fingerprint Enroll)." 2>/dev/null || true
echo "nomarchy-fingerprint: no finger enrolled run fprintd-enroll first" >&2
exit 1
fi
${sync} --quiet set settings.fingerprint.pam "$new" --no-switch
notify-send "Fingerprint" "Rebuilding" 2>/dev/null || true
sudo nixos-rebuild switch --flake "$flake#default"
home-manager switch --flake "$flake"
if [ "$new" = true ]; then
notify-send "Fingerprint on" "Password or finger at sudo, the lock screen, and the greeter." 2>/dev/null || true
else
notify-send "Fingerprint off" "Password only. Enrolled fingers are kept." 2>/dev/null || true
fi
'';
in
{
options.nomarchy.hardware = {
@@ -214,6 +266,9 @@ in
# every interactive service we care about to follow our opt-in flag.
(lib.mkIf cfg.fingerprint.enable {
services.fprintd.enable = true;
# Ships whenever a reader exists, regardless of the pam flag: the
# toggle's whole job is to turn the flag back on while it's off.
environment.systemPackages = [ nomarchy-fingerprint ];
security.pam.services = lib.genAttrs [
"login" "sudo" "su" "greetd" "hyprlock" "sshd"
"passwd" "chsh" "chfn" "chpasswd"

View File

@@ -11,13 +11,20 @@
greeter.autoLogin = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = config.nomarchy.settings.greeter.autoLogin or null;
default = null;
defaultText = lib.literalExpression
"(settings.greeter.autoLogin from theme-state.json) or null";
example = "ada";
description = ''
Log this user straight into Hyprland on boot (greetd
initial_session); logging out lands on the normal greeter.
The installer sets it on LUKS-encrypted machines the disk
passphrase already gates access, a second prompt is ceremony.
Normally you leave this alone and use System Auto-login, which
writes `settings.greeter.autoLogin` in theme-state.json
./greeter.nix mkDefaults this option from it. The installer seeds
that state on LUKS-encrypted machines: the disk passphrase already
gates access, so a second prompt is ceremony. Setting this option by
hand pins the choice and the menu toggle can no longer move it.
'';
};