The lock screen: hyprlock plus hypridle¶
home/desktop/lockscreen.nix. Ref: https://wiki.hypr.land/Hypr-Ecosystem/hyprlock/
The philosophy: no loose .sh scripts¶
The heavy logic lives where it is declarative and reproducible, in the BUILD (pure Nix) or in
SYSTEMD, and the runtime is a 1-line command with the binary at an ABSOLUTE path
(${pkgs...}/bin/x), not depending on the PATH. That is how this survives upgrades (rule 7,
durable into 2032+).
| Piece | Where the work happens | What the lock runs |
|---|---|---|
| quote | a service plus a daily timer: ZenQuotes, DeepL, pango | shuf -n1 |
| weather | a service plus a 10-min timer: Open-Meteo | cat |
| idle | hypridle, and it ONLY locks | systemctl --user start (the gate) |
| media | lockscreen-idle-lock.service: it waits while a player is Playing |
loginctl lock-session |
| lock | hyprlock.service, a declared unit |
systemctl --user start |
The folder rule¶
USER apps go to home/. programs.hyprlock installs hyprlock and services.hypridle brings the
daemon up (systemd --user, like hyprsunset), which is why hypridle left system/packages.nix.
PAM (system/desktop/desktop.nix) is MANDATORY: without it hyprlock does not authenticate and
LOCKS YOU OUT. The pt_BR locale (system/core/core.nix) is for the clock's spelled-out date.
Language: a deliberate exception¶
The system is en-US, and the LOCKSCREEN is full pt-BR: the spelled-out date, the weather, "Digite a senha…", the DeepL-translated quotes. That decision is recorded in the July history, so those strings are NOT untranslated debt, they are the product.
Hardware lessons, durable and independent of the GPU. Do NOT touch¶
- A STATIC wallpaper, never
path = screenshot. screencopy/DMA segfaults hyprlock when waking from idle, because the DMA frame is destroyed on exit. That is a lockout. - No GIF and no continuous reload. The asynchronous gatherer races with
exit()and corrupts the heap (SIGABRT on unlock). - dpms-off REMOVED (jul/2026). Turning the screen off on idle BROKE remote access: Sunshine
(wlr capture on the
xedriver) got a BLACK SCREEN from the powered-off monitor, and toggling dpms under capture caused a GPU ENGINE RESET (xe RCS, which froze the scanout). Now idle ONLY locks. If dimming is ever wanted, use hyprsunset's gamma. Seesunshine.md.
Locking cannot depend on hypridle¶
Until 10/08/2026 the only path to locking was loginctl lock-session, which does NOT lock anything
on its own: it only sets LockedHint and emits the Lock signal on D-Bus. What listened and
brought hyprlock up was hypridle, through lock_cmd.
The consequence: with hypridle stopped, the signal fell into the VOID. The bar's "Lock" button clicked and nothing happened, with no error at all. That is what happened for 6 h on 10/08/2026, with the Sunshine guard holding hypridle stopped after a client died with no teardown.
It is the wrong dependency. Locking the screen is a SECURITY FUNCTION and cannot be hostage to an
IDLE daemon that anything can stop (the Sunshine guard, the bar's pill, a crash). Now hyprlock is a
unit of its own and whoever wants to lock runs systemctl --user start hyprlock.service.
Why a real unit and not a transient systemd-run¶
That was the previous trick. A real unit wins for the reason above PLUS three more:
- It stays OUTSIDE the
hypridle.servicecgroup, which is what prevents the REMOTE LOCKOUT diagnosed on 03/08/2026: the Sunshine guard'ssystemctl --user stop hypridlekilled the whole cgroup (KillMode=control-group) and took hyprlock with it, leaving the compositor LOCKED WITH NO CLIENT to draw the password field. - Idempotence for free, since
starton an already active unit is a no-op, so the oldpidof hyprlock ||goes away. Two session-lock surfaces confuse the keyboard grab and the password field stops typing; systemd guarantees one. - The name is not left occupied after the unlock, which was
--collect's job.
The env (WAYLAND_DISPLAY, HYPRLAND_INSTANCE_SIGNATURE) comes from systemd --user, which the
session already imports. There is no Restart: a hyprlock that exits is an unlock, not a failure.
ExecCondition and not ExecStartPre, on purpose: failing there SKIPS the start silently and the
unit stays inactive, while a StartPre would mark it failed. It is the heir of the old
pidof hyprlock || and today only covers a hyprlock that was NOT born from this unit, which is
just the TTY lockout rescue (allow_session_lock_restore in hypr/lua/appearance.lua).
No Install/wantedBy on purpose: what locks is the click or the idle, never the boot. The
hyprlock at boot is still the autostart one.
Media holds the lock back¶
The 5 min timeout has no business landing in the middle of a movie. The gate is
lockscreen-idle-lock.service: hypridle's on-timeout starts it, and it only calls
loginctl lock-session when NO MPRIS player is in Playing.
IT WAITS, IT DOES NOT SKIP, and that is the whole design. Each hypridle listener fires ONCE per idle cycle, so a gate that simply returned without locking would leave the machine open until the next keystroke: a movie ending at 3 AM would mean an unlocked desktop until morning. Waiting keeps the lock PENDING, and it lands up to 30 s after the last player stops, which is the poll interval.
Coming back to the keyboard cancels it, through the listener's on-resume (systemctl --user
stop). The remaining race is the poll's width: the media would have to stop inside the same 30 s
window in which I come back, and the worst case there is a lock I was already getting anyway.
PartOf = hypridle.service because a PENDING lock belongs to the daemon that scheduled it. That is
what keeps the Sunshine guard working: stream-begin stops hypridle so the remote session does not
lock mid-way, and a gate left waiting in the background would fire the moment the music ended.
THE SIGNAL IS MPRIS (playerctl --all-players status), the same D-Bus interface behind the bar's
Spotify pill, so it covers every player the bar already sees. The consequence is deliberate: AUDIO
COUNTS, and Spotify left playing overnight keeps the machine unlocked. Narrowing it to video would
mean filtering the players, not trusting an inhibit, which is the next section.
Why ignore_dbus_inhibit stays true¶
Because the opposite looks like the obvious fix and is not. An app's inhibit is UNBOUNDED: a
browser that leaks one disables locking forever and in silence, and nothing on screen says so. The
gate is a single decision point, re-evaluated every 30 s, and it states its reason in the journal
(media is playing, the idle lock waits, at notice priority).
Worth recording because it was not always so: the Arch hypridle.conf this module replaced carried
ignore_dbus_inhibit = false, and the port on 24/07/2026 flipped it to true with no reason
written down. It is a decision now, not a leftover.
The quote cache¶
It replaced a vendored quotes.tsv. The timer fetches a BATCH of ~50 from zenquotes.io
/api/quotes (EN), filters it (non-empty, <= 120 chars, without the rate-limit placeholder) and
TRANSLATES it to pt-BR through DeepL in a SINGLE batched request, with the key from
/run/secrets/deepl_api_key (sops). The author stays in the original.
It escapes &<> for pango and writes atomically, so hyprlock never reads a half-written cache.
The fallbacks are layered: with no key or DeepL down it uses the ENGLISH batch; a length mismatch
between request and response also falls back to EN; and with no network on the first run there is
one built-in pt-BR quote, so shuf is never empty.
Renewed once a day: ~50 quotes leaves plenty of room in DeepL's free quota of 500k characters a month.
The weather cache¶
São Carlos/SP by COORDINATES, so there is no geocoding ambiguity. It writes atomically (.tmp plus
mv). A stable source, with no HTML scraping. Refreshed every 10 min, first fetch 1 min after
boot.
The SOURCE and the pt-BR words are the SSOT in my.weather (home/desktop/weather.nix), the same
one the bar reads, and the script's case arms are generated from it. It used to be wttr.in with
its own coordinates, and on 22/08/2026 the lock said 18°C while the bar said 22°C in the same
minute. The full reasoning, and every measured failure path, is in weather.md.
The standing monitor's wallpaper, drawn from my own folder¶
The secondary shows a random image out of ~/Pictures/lockscreen/ on every lock, and the packaged
nixos-artwork one when that folder is empty.
The images are STATE, not config (rule 6). They are art I collect by hand, not something the
build should carry: no binary enters git, no URL can rot the build a year from now, and adding one
is dropping a file in a folder with no rebuild and no commit. Restic already covers it, since it
backs up /home/v1cferr minus a short exclude list that this folder is not on.
The link is EXTENSIONLESS on purpose. hyprlock loads images through hyprgraphics, whose
formatFromFile() includes <magic.h>: the decoder is chosen by libmagic from the file's CONTENT
and never from its name. So one fixed path, ~/.cache/lockscreen/secondary, serves png, jpg, webp
and bmp from the same folder, and the picker can symlink whatever it drew without renaming it.
Three fallbacks, because a lock screen has no acceptable failure. The picker falls back to the
packaged image when the folder is empty or missing; hyprlock keeps its color under the image, so
even a dangling link shows the palette's background and not black; and the ExecStartPre carries a
leading -, so a picker that stumbles cannot turn into a lock that refuses to open. A security
function taken down by a wallpaper would be an absurd way to lose a session.
It runs in TWO places for one reason each: ExecStartPre on hyprlock.service is what makes the
draw happen per lock, and the home-manager activation is what makes the link exist before the FIRST
lock after a switch.
Animated GIFs do not work and it is not a config mistake: hyprlock's formats are png, jpg and webp with no animation (upstream issue #632, open). The community fork that adds it was last pushed in 08/2025 against a project that shipped v0.9.6 in 07/2026, and a year-stale fork is not what should stand between a locked session and the machine.
The visual details worth keeping¶
The wallpapers are the official NixOS ones through pkgs.nixos-artwork, so there is no binary in
git and they bump along with nixpkgs. blur_passes was eased from 3 to 2 so the wallpaper shows
more while the widgets stay legible, and brightness = 0.40 is dark enough for the clock and quote
without hiding the image.
The monitors come from the SSOT (system/desktop/monitors.nix, rule 11): the primary gets the
blurred desktop plus the login, the secondary gets a static image plus a discreet padlock.
The colors come from my.theme and the font from my.fonts.ui.
The date label uses LC_TIME=pt_BR.UTF-8 for the spelled-out form, with sed capitalizing the
first letter and adding the week number.