fix: migration first-boot, fingerprint PAM, menus, mic LED
Some checks failed
Check / eval (push) Failing after 9m12s

Post-migration annoyances from TuringMachine:

- Force fprintAuth from fingerprint.pam (NixOS defaults it on with fprintd)
- First-boot toast waits for swaync, retries, only marks after success
- Night light status/toggle handles masked units + hyprsunset/wlsunset
- VPN menu: define rofi_menu in nomarchy-vpn (was a silent no-op)
- Left arrow = ↩ Back in list menus; GTK menu contrast via Stylix
- Mic-mute LED udev + sync after wpctl; EasyEffects starts after tray
- Migration guide: HM before GUI login, fingerprint/browser/VPN notes

Verified: flake check --no-build; checks.first-boot; checks.hardware-toggles
(pam=false node). V3 session items pending after user rebuild.
This commit is contained in:
Bernardo Magri
2026-07-11 15:06:21 +01:00
parent f2fb4adbce
commit 05bab5576e
11 changed files with 278 additions and 51 deletions

View File

@@ -40,7 +40,16 @@
services.udiskie.enable = true;
# Microphone noise cancellation (rnnoise) and audio EQ.
# Tray icon is built into EasyEffects 8 (Qt StatusNotifierItem); the
# daemon must start after a tray host (Waybar) or the icon is missing
# for the whole session (wwmm/easyeffects#4636).
services.easyeffects.enable = true;
systemd.user.services.easyeffects = {
Unit = {
After = [ "graphical-session.target" "tray.target" "waybar.service" ];
Wants = [ "tray.target" ];
};
};
# Wifi from the bar: nm-applet lives in waybar's tray (SNI flag via
# preferStatusNotifierItems — without it there is no tray icon).

View File

@@ -11,8 +11,18 @@
Unit = {
Description = "Nomarchy first-session welcome toast";
PartOf = [ "graphical-session.target" ];
# After the session (and preferably the notification daemon) is up.
After = [ "graphical-session.target" "graphical-session-pre.target" ];
# After the session AND the notification daemon. Without
# After=swaync, first login races D-Bus Notifications and the
# toast times out (migration: journal "Timeout was reached") —
# or worse, a later success writes firstBootShown without the
# user ever seeing the toast. Wants= so we still run if swaync
# is disabled (notify-send will fail and leave the marker unset).
After = [
"graphical-session.target"
"graphical-session-pre.target"
"swaync.service"
];
Wants = [ "swaync.service" ];
};
Service = {
Type = "oneshot";
@@ -27,8 +37,8 @@
]}"
"NOMARCHY_PATH=%h/.nomarchy"
];
# Brief settle so swaync is accepting toasts (live ISO uses sleep 3).
ExecStartPre = "${pkgs.coreutils}/bin/sleep 2";
# Brief settle after swaync is up; the script also retries.
ExecStartPre = "${pkgs.coreutils}/bin/sleep 1";
ExecStart = "${pkgs.nomarchy-first-boot}/bin/nomarchy-first-boot";
};
Install.WantedBy = [ "graphical-session.target" ];

View File

@@ -20,14 +20,29 @@ let
# --custom-progress is the mic level (0.0-1.0), --custom-icon the
# muted/active glyph. Pure bash builtins for parsing so the script needs no
# PATH beyond the two pinned tools.
#
# Hardware mic-mute LEDs (ThinkPad platform::micmute, HDA *::micmute, …)
# are driven by the kernel's audio-micmute trigger, which tracks ALSA —
# not PipeWire software mute. Sync the LED from the resulting mute state
# when the node is group-writable (udev rule in modules/nixos/default.nix).
micMute = pkgs.writeShellScript "nomarchy-mic-mute" ''
${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_SOURCE@ toggle
out=$(${pkgs.wireplumber}/bin/wpctl get-volume @DEFAULT_SOURCE@)
set -- $out # "Volume: 0.85 [MUTED]" -> $2 is the level
case "$out" in
*MUTED*) icon=microphone-sensitivity-muted-symbolic ;;
*) icon=microphone-sensitivity-high-symbolic ;;
*MUTED*) icon=microphone-sensitivity-muted-symbolic; led=1 ;;
*) icon=microphone-sensitivity-high-symbolic; led=0 ;;
esac
for led_dir in /sys/class/leds/*micmute* /sys/class/leds/*::micmute; do
[ -e "$led_dir/brightness" ] || continue
if [ -w "$led_dir/brightness" ]; then
# Drop the kernel trigger so a stuck audio-micmute state cannot
# override our write (verified: ALSA Capture mute alone does not
# flip platform::micmute on ThinkPad T14s).
[ -w "$led_dir/trigger" ] && echo none > "$led_dir/trigger" 2>/dev/null || true
echo "$led" > "$led_dir/brightness" 2>/dev/null || true
fi
done
${pkgs.swayosd}/bin/swayosd-client --custom-icon "$icon" --custom-progress "$2"
'';
@@ -446,6 +461,10 @@ in
];
misc = {
# Kill the water-drop splash + the yellow "autogenerated config"
# banner. Migrations that log into Hyprland before the first
# home-manager switch still see the banner (no managed conf yet) —
# see docs/MIGRATION.md. Once HM owns hyprland.conf these stay off.
disable_hyprland_logo = lib.mkDefault true;
disable_splash_rendering = lib.mkDefault true;
force_default_wallpaper = lib.mkDefault 0;

View File

@@ -45,29 +45,42 @@ let
v=$(${sync} get settings.nightlight.on 2>/dev/null) || v=${onDefault}
case "$v" in true|True) return 0 ;; *) return 1 ;; esac
}
installed() { systemctl --user cat "$unit" >/dev/null 2>&1; }
start() { systemctl --user start "$unit" 2>/dev/null || true; }
# LoadState=loaded means a real unit (not the empty-file mask HM/packages
# leave behind for unused hyprsunset). `systemctl cat` alone succeeds on
# a masked unit and wrongly reported "installed" silent no-op start.
installed() {
[ "$(systemctl --user show -p LoadState --value "$unit" 2>/dev/null)" = loaded ]
}
active() { systemctl --user is-active --quiet "$unit" 2>/dev/null; }
start() {
systemctl --user daemon-reload 2>/dev/null || true
systemctl --user unmask "$unit" 2>/dev/null || true
systemctl --user start "$unit" 2>/dev/null || true
}
stop() { systemctl --user stop "$unit" 2>/dev/null || true; }
case "''${1:-toggle}" in
should-start) is_on ;; # ExecCondition gate (login/reboot)
status)
# Waybar (polls every 3s): moon while running; print nothing otherwise so
# the module self-hides enable / re-enable from the System menu.
systemctl --user is-active --quiet "$unit" \
# the module self-hides enable / re-enable from Look & Feel.
# `active` alone is the truth for the moon; menu labels use the same.
active \
&& printf '{"text":"󰖔","tooltip":"Night light on ${if geo then "follows your location" else "warm on schedule"} (click to disable)","class":"on"}\n'
exit 0 ;;
# Plain on/off string for menus (hyprsunset OR wlsunset; not hard-coded).
is-active) if active; then echo on; else echo off; fi ;;
on)
if installed; then write_on true; start; else install_feature; start; fi ;;
off) write_on false; stop ;;
toggle)
if systemctl --user is-active --quiet "$unit"; then
if active; then
write_on false; stop # on -> off (instant)
elif installed; then
write_on true; start # installed, off -> on (instant)
else
install_feature; start # first enable (rebuilds)
fi ;;
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start]" >&2; exit 64 ;;
*) echo "usage: nomarchy-nightlight [toggle|status|on|off|should-start|is-active]" >&2; exit 64 ;;
esac
'';
in

View File

@@ -234,7 +234,34 @@ let
# when config.rasi has case-sensitive: false for drun/modi so
# SUPER+Space (apps) matched "ste"Steam while the main Menu filter
# did not match "sys"System. Always pass -i on menu pickers.
rofi_menu() { command rofi -dmenu -i "$@"; }
#
# Left arrow one level up (same as choosing " Back"). rofi's
# -kb-custom-1 returns exit 10; Esc still returns non-zero and quits
# the whole menu. Free-text prompts should treat "$BACK" as cancel.
# Pass --grid as the first arg to skip Left=Back (multi-column theme
# picker needs Left for column motion).
rofi_menu() {
local out rc left_back=1
if [ "''${1:-}" = --grid ]; then
left_back=0
shift
fi
if [ "$left_back" -eq 1 ]; then
out=$(command rofi -dmenu -i -kb-custom-1 Left "$@" </dev/stdin)
else
out=$(command rofi -dmenu -i "$@" </dev/stdin)
fi
rc=$?
if [ "$left_back" -eq 1 ] && [ "$rc" -eq 10 ]; then
printf '%s\n' "$BACK"
return 0
elif [ "$rc" -eq 0 ]; then
printf '%s\n' "$out"
return 0
else
return 1
fi
}
case "''${1:-}" in
power)
@@ -395,7 +422,7 @@ ${themeSlugMap}
choice=$( {
${themeRows}
back
} | rofi_menu -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0
} | rofi_menu --grid -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0
[ "$choice" = "$BACK" ] && exec "$0"
choice="''${choice% }" # drop the active marker if present
slug="''${THEME_SLUG[$choice]:-}"
@@ -429,7 +456,7 @@ ${themeRows}
web)
q=$(rofi_menu -p search < /dev/null) || exit 0
[ -n "$q" ] || exit 0
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
exec xdg-open "https://www.google.com/search?q=$(urlencode "$q")" ;;
network)
@@ -599,7 +626,10 @@ ${themeRows}
row "Theme${menuHint "theme"}" preferences-desktop-theme
row "Next wallpaper${hintForAction "exec, nomarchy-theme-sync bg next"}" preferences-desktop-wallpaper
row "Reset wallpaper (auto)" preferences-desktop-wallpaper
if systemctl --user is-active --quiet hyprsunset.service 2>/dev/null
# Use the toggle script (knows hyprsunset vs wlsunset); a hard-coded
# hyprsunset-only check always showed (off) in geo mode and when
# the unit was still the empty HM mask from a disabled package unit.
if [ "$(nomarchy-nightlight is-active 2>/dev/null)" = on ]
then row "Night light (on)" weather-clear-night
else row "Night light (off)" weather-clear-night
fi
@@ -680,7 +710,7 @@ ${themeRows}
# which lags behind model releases; npx caches after first run. The
# REPL stays open for follow-ups.
q=$(rofi_menu -p claude < /dev/null) || exit 0
[ -n "$q" ] || exit 0
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
exec ${cfg.terminal} -e npx --yes @anthropic-ai/claude-code@latest "$q" ;;
dnd)
@@ -752,9 +782,9 @@ ${themeRows}
case "$choice" in "Sunset"*) key=sunset; label=Sunset; cur="''${sunset:-20:00}" ;; esac
t=$(rofi_menu -p "$label HH:MM (now $cur)" < /dev/null) || exec "$0" autotheme
case "$t" in
""|"$BACK") : ;;
[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9])
nomarchy-theme-sync --quiet set "settings.autoTheme.$key" "$t" --no-switch ;;
"") : ;;
*) notify-send "Auto theme" "Ignored '$t' use HH:MM (24-hour)." ;;
esac
exec "$0" autotheme ;;
@@ -1119,7 +1149,7 @@ ${themeRows}
*60%*) set_limit 60 "Set to 60%" ;;
*Custom*)
limit=$(rofi_menu -p "Charge limit % (50100)" < /dev/null) || exit 0
[ -n "$limit" ] || exit 0
{ [ -n "$limit" ] && [ "$limit" != "$BACK" ]; } || exit 0
case "$limit" in
*[!0-9]*) notify-send "Battery limit" "Not a whole number: $limit"; exit 0 ;;
esac
@@ -1172,6 +1202,23 @@ ${themeRows}
nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" ''
set -u
BACK=" Back"
# Own helper this is a separate package from nomarchy-menu, so the
# menu's rofi_menu function is not in scope (was a silent no-op: every
# VPN submenu died with "rofi_menu: command not found").
rofi_menu() {
local out rc
out=$(command rofi -dmenu -i -kb-custom-1 Left "$@" </dev/stdin)
rc=$?
if [ "$rc" -eq 10 ]; then
printf '%s\n' "$BACK"
return 0
elif [ "$rc" -eq 0 ]; then
printf '%s\n' "$out"
return 0
else
return 1
fi
}
conn_rows() {
nmcli -t -f NAME,TYPE,STATE connection show 2>/dev/null \
@@ -1180,12 +1227,15 @@ ${themeRows}
toggle_conn() {
mark=''${1%% *}; name=''${1#* }
err=
if [ "$mark" = "" ]; then
nmcli connection down "$name" >/dev/null 2>&1 \
&& notify-send "VPN" "Disconnected $name" || notify-send "VPN" "Could not disconnect $name"
err=$(nmcli connection down "$name" 2>&1) \
&& notify-send "VPN" "Disconnected $name" \
|| notify-send "VPN" "Could not disconnect $name''${err:+ $err}"
else
nmcli connection up "$name" >/dev/null 2>&1 \
&& notify-send "VPN" "Connected $name" || notify-send "VPN" "Could not connect $name (auth or bad config?)"
err=$(nmcli connection up "$name" 2>&1) \
&& notify-send "VPN" "Connected $name" \
|| notify-send "VPN" "Could not connect $name''${err:+ $err}"
fi
}
@@ -1196,11 +1246,9 @@ ${themeRows}
| rofi_menu -p "Import VPN config" ) || return
{ [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return
case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac
if nmcli connection import type "$type" file "$HOME/$file" >/dev/null 2>&1; then
notify-send "VPN" "Imported $type config: $(basename "$file")"
else
notify-send "VPN" "Import failed ($type plugin missing, or bad file?)"
fi
err=$(nmcli connection import type "$type" file "$HOME/$file" 2>&1) \
&& notify-send "VPN" "Imported $type config: $(basename "$file")" \
|| notify-send "VPN" "Import failed''${err:+ $err}"
}
ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; }
@@ -1257,14 +1305,18 @@ ${themeRows}
while :; do
have_ts=""
command -v tailscale >/dev/null 2>&1 && have_ts=1
rows=$(conn_rows)
choice=$( {
conn_rows
if [ -n "$rows" ]; then printf '%s\n' "$rows"
else printf 'No VPNs yet import a config or enable Tailscale\n'
fi
printf 'Import config\n'
[ -n "$have_ts" ] && printf 'Tailscale \n'
printf '%s\n' "$BACK"
} | rofi_menu -p VPN ) || exit 0
case "$choice" in
"$BACK"|"") exec nomarchy-menu system ;;
"No VPNs yet"*) ;; # hint row stay in menu
"Import config") import_config ;;
"Tailscale ") tailscale_menu ;;
" "*|" "*) toggle_conn "$choice" ;;

View File

@@ -45,7 +45,56 @@ in
base16Scheme = base16;
targets = {
gtk.enable = true;
gtk = {
enable = true;
# nm-applet / classic GTK menus: Stylix base16 selection can
# collapse into surface/muted on dark themes (Boreal: selected
# row and submenu arrows were effectively invisible). High-
# contrast selected rows + explicit arrow colour. Appended to
# Stylix's generated gtk.css (not gtk.gtk3.extraCss — that is a
# no-op under Stylix).
extraCss = ''
menu menuitem, .menu menuitem, .context-menu menuitem {
color: #${base16.base05};
}
menu menuitem:hover, menu menuitem:selected,
.menu menuitem:hover, .menu menuitem:selected,
.context-menu menuitem:hover, .context-menu menuitem:selected,
menubar > menuitem:hover {
background-color: #${base16.base0D};
color: #${base16.base00};
}
menu menuitem:disabled, .menu menuitem:disabled {
color: #${base16.base03};
}
/* Submenu disclosure arrows without this they pick muted. */
menu menuitem arrow, .menu menuitem arrow,
menu menuitem:hover arrow, menu menuitem:selected arrow {
min-width: 16px;
min-height: 16px;
margin-left: 8px;
color: inherit;
-gtk-icon-source: -gtk-icontheme("pan-end-symbolic");
}
menu menuitem:hover arrow, menu menuitem:selected arrow {
color: #${base16.base00};
}
check, radio {
min-width: 14px;
min-height: 14px;
color: inherit;
}
/* GTK4 / popover menus (some portals). */
popover.menu contents modelbutton {
color: #${base16.base05};
}
popover.menu contents modelbutton:hover,
popover.menu contents modelbutton:selected {
background-color: #${base16.base0D};
color: #${base16.base00};
}
'';
};
qt.enable = true;
# No-op unless programs.zathura is on (viewers.nix enables it).
zathura.enable = true;

View File

@@ -450,6 +450,17 @@ in
# users without root. Harmless baseline even if the OSD is disabled.
services.udev.packages = [ pkgs.swayosd ];
# Mic-mute LED sysfs nodes (ThinkPad platform::micmute, HDA *::micmute)
# default to root-only. PipeWire mute does not drive the kernel's
# audio-micmute trigger, so nomarchy-mic-mute writes brightness itself.
# Group `video` matches the backlight udev pattern (swayosd) and the
# template login user's extraGroups.
services.udev.extraRules = lib.mkAfter ''
ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*micmute*", \
RUN+="${pkgs.coreutils}/bin/chgrp video /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger", \
RUN+="${pkgs.coreutils}/bin/chmod g+w /sys/class/leds/%k/brightness /sys/class/leds/%k/trigger"
'';
# ── Nix itself ───────────────────────────────────────────────────
nix = {
settings = {

View File

@@ -193,12 +193,19 @@ in
})
# ── Fingerprint ────────────────────────────────────────────────────
# NixOS defaults security.pam.services.*.fprintAuth to
# services.fprintd.enable — so turning on fprintd alone would enable
# finger auth for login/sudo/greetd/passwd/… even when the user (or
# the migration template) left fingerprint.pam commented off. Force
# every interactive service we care about to follow our opt-in flag.
(lib.mkIf cfg.fingerprint.enable {
services.fprintd.enable = true;
})
(lib.mkIf (cfg.fingerprint.enable && cfg.fingerprint.pam) {
security.pam.services.login.fprintAuth = true;
security.pam.services.sudo.fprintAuth = true;
security.pam.services = lib.genAttrs [
"login" "sudo" "su" "greetd" "hyprlock" "sshd"
"passwd" "chsh" "chfn" "chpasswd"
"polkit-1" "swaylock"
"groupadd" "groupdel" "groupmod" "groupmems"
] (_: { fprintAuth = cfg.fingerprint.pam; });
})
# ── Newest kernel for very-new hardware (opt-in escape hatch) ──────