Files
Nomarchy/modules/home/idle.nix
Bernardo Magri a2151f424b
All checks were successful
Check / eval (push) Successful in 3m33s
fix(idle): #127 — input wakes the screen compositor-side; revert the clamshell skip
The idle brick was never DPMS. Hyprland ships misc:key_press_enables_dpms
and misc:mouse_move_enables_dpms OFF, so hypridle's on-resume was the ONLY
caller of `dpms on` in the session — an idle daemon with the display held
hostage behind it. hypridle then deadlocks on an unfixed upstream bug
(hyprwm/hypridle#171, "Disconnected from pollfd id 1", open since 2025-09,
absent from 0.1.7 and main) and hangs WITHOUT exiting, so Restart=always
never fires and systemd still reports it active (running). Blank + deadlock
= a black seat no keypress can escape. That was the brick: two mundane bugs,
not one exotic DRM state. 19 disconnects in 11 days on the dev box, incl.
the incident day; the compositor is healthy through all of them.

Both options on, so the wake lives in the compositor where no daemon can
lose it — a dead hypridle now costs auto-lock, not the machine. The #127
clamshell DPMS-off skip (060bf52) is therefore reverted: idle blanks again
in every dock/lid state, and the 27" panels stop being held lit all night
under a static lock screen. #135 (an option to opt back in) is retired
unbuilt — there is nothing left to opt into.

TEST B's first run was invalid and nearly bought the wrong answer: it ran
`hyprctl dispatch dpms off` by hand, which no wake path watches, so "input
did not wake it" was guaranteed on any hardware. The undocked control
returning the SAME result — where the theory demanded a difference — is what
exposed it. Driven through hypridle instead, input wakes it in all three
configurations, including clamshell-docked on the sole live output.

Filed from the diagnosis: #146 (hypridle dies silently, systemd calls it
healthy — the surviving bug) and #147 (a re-login leaves session units dead;
graphical-session.target never re-enters, which is why Waybar already runs
from exec-once).

V3 on the incident hardware (AMD dev box, clamshell): hypridle deliberately
stopped, `dpms off` -> keypress woke it in 6s; the identical test before the
options stayed black the full 45s. V1: nix flake check, checks.option-docs,
checks.clamshell-logind, checks.docking-ux, checks.display-profiles; read the
emitted hyprland.conf/hypridle.conf rather than the source.

V3 pending: the fix is proven via runtime `hyprctl keyword`, not yet from our
own Nix — HARDWARE-QUEUE re-checks it after nomarchy-home + relogin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:48:22 +01:00

184 lines
7.7 KiB
Nix

# hyprlock + hypridle — screen locking and idle management, themed from
# state.json. One concern, one file: hypridle drives WHEN (idle
# lock, display off, suspend, lock-before-sleep), hyprlock is the
# themed lock screen itself (also behind the power menu's Lock entry).
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy;
t = cfg.theme;
c = t.colors;
inherit (config.nomarchy.lib) rgb;
# Exits 0 when running on AC: a mains adapter reports `online` 1 (battery
# supplies have no `online` node, so they never match). Used as
# `${onAc} || <action>` so the action is skipped while plugged in.
onAc = pkgs.writeShellScript "nomarchy-on-ac" ''
for f in /sys/class/power_supply/*/online; do
[ -r "$f" ] && [ "$(cat "$f")" = "1" ] && exit 0
done
exit 1
'';
# Absolute store paths for hypridle (thin PATH). Wake reuses the shared
# helper; a mini transition avoids a circular import on hyprland.nix.
displayWake =
let
miniTransition = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
set -u
case "''${1:-}" in
undock|enable)
internal="''${2:-}"
[ -n "$internal" ] || exit 64
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
if ! hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1; then
hyprctl reload >/dev/null 2>&1 || true
sleep 0.5
fi
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
hyprctl dispatch dpms on >/dev/null 2>&1 || true
hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1
;;
*) exit 64 ;;
esac
'';
in
(import ./display-tools.nix {
inherit pkgs;
displayTransition = miniTransition;
}).displayWakeTool;
in
{
config = lib.mkIf cfg.idle.enable {
# Smart suspend on PATH for manual use; hypridle uses the store path.
home.packages = [ pkgs.nomarchy-suspend ];
programs.hyprlock = {
enable = true;
settings = {
general.hide_cursor = true;
background = [{
monitor = "";
color = rgb c.base;
}];
input-field = [{
monitor = "";
size = "300, 50";
outline_thickness = t.ui.borderSize;
dots_size = 0.25;
outer_color = rgb c.accent;
inner_color = rgb c.surface;
font_color = rgb c.text;
check_color = rgb c.warn;
fail_color = rgb c.bad;
rounding = t.ui.rounding;
# Sentence case, matching the $FPRINTPROMPT label below: both can be
# on screen at once, so they must not read as two different voices.
placeholder_text = "<i>Password</i>";
}];
# The lock screen is deliberately just the clock: the input field
# fades out while empty (hyprlock's fade_on_empty default) and only
# appears once you type. So the fingerprint hint CANNOT live in the
# field's placeholder — $FPRINTPROMPT renders faithfully into a widget
# nobody sees until they have already given up on the reader and
# started typing. It needs a surface that is visible at rest, and a
# label is the one that keeps the clock-only look. (Confirmed on
# hardware 2026-07-14: labels do expand $FPRINTPROMPT, so the line is
# live — the ready message, then the present message on touch — and
# not static text. Both live in auth.fingerprint below.)
label = [{
monitor = "";
text = "$TIME";
color = rgb c.text;
font_size = 64;
font_family = t.fonts.ui;
position = "0, 120";
halign = "center";
valign = "center";
}] ++ lib.optional cfg.idle.fingerprint {
monitor = "";
text = "$FPRINTPROMPT";
# subtext-on-base is the palette's secondary-text role and is held
# to a 3.0 contrast floor on every theme by checks.theme-contrast —
# the same guard that exists because two themes once shipped
# subtext == base and made hint text invisible.
color = rgb c.subtext;
font_size = 16;
font_family = t.fonts.ui;
position = "0, -160";
halign = "center";
valign = "center";
};
} // lib.optionalAttrs cfg.idle.fingerprint {
# hyprlock does NOT take a fingerprint through PAM: its PAM stack runs
# only on submit, so a parallel module never gets to poll the reader.
# This is a separate backend of its own, talking to fprintd over
# D-Bus, and `nomarchy.hardware.fingerprint.pam` does not reach it —
# which is why finger-unlock at the lock screen did nothing at all
# until this option existed, while sudo took a finger happily.
auth.fingerprint = {
enabled = true;
ready_message = "Enter password or scan your finger";
present_message = "Scanning your finger";
};
};
};
services.hypridle = {
enable = true;
settings = {
general = {
lock_cmd = "pidof hyprlock || hyprlock";
# No before_sleep_cmd here: locking before sleep is driven
# system-side so it can fire for suspend but skip an encrypted
# hibernate — whose LUKS resume already gates the machine, so a
# hyprlock on top is a second password (and a Wayland session-lock
# can't be safely dropped after the fact — killing the locker trips
# its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep
# in modules/nixos/default.nix.
# #127: not only dpms on — if dock mode left zero enabled
# outputs (eDP disabled + external gone/black), re-enable the
# internal. Absolute store path: hypridle's PATH is thin.
after_sleep_cmd = "${lib.getExe displayWake}";
};
listener = [
# Lock and screen-off are the same on either power source —
# they're about privacy and the panel, not battery.
{ timeout = 300; on-timeout = "loginctl lock-session"; }
{
timeout = 600;
# Blanks in every dock/lid state — the #127 clamshell skip is
# gone: it was a cure for a cause that does not exist (proven on
# hardware 2026-07-16, see ROADMAP). The wake no longer depends
# on this daemon surviving — misc:{key_press,mouse_move}_enables_dpms
# in hyprland.nix means input wakes the screen compositor-side.
on-timeout = "hyprctl dispatch dpms off";
# Same rescue path as after_sleep (#127): re-enables a disabled
# internal, which plain `dpms on` cannot do.
on-resume = "${lib.getExe displayWake}";
}
# Suspend only on battery, and sooner than the old fixed 30 min
# (it now only fires unplugged). Plugged in, the machine stays
# up — long builds, media, presentations aren't killed mid-idle.
# nomarchy-suspend (#115): on battery + hibernate wired + toggle
# on → suspend-then-hibernate (1h → disk); else plain suspend.
# Lid close is logind's job (not hypridle): undocked lid still
# suspends / s2h (HandleLidSwitch); docked/clamshell lid is ignore
# (HandleLidSwitchDocked — modules/nixos/power.nix, #86).
{
timeout = 900;
on-timeout = "${onAc} || ${lib.getExe pkgs.nomarchy-suspend}";
}
];
};
};
};
}