The lock screen: Quickshell, hyprlock as the fallback, and hypridle
Edit on GitHubThe lock is a Quickshell instance of its own (modules/home/desktop/quickshell/lock.qml and
quickshell/lock/), launched by modules/home/desktop/lockscreen/default.nix, with hyprlock kept
as a plain fallback and hypridle for the idle. Ref:
https://quickshell.org/docs/v0.3.2/types/Quickshell.Wayland/WlSessionLock
v6: the lock in QML (21/10/2026)
Why it moved. Up to v5.3 the lock was hyprlock, which can only show static images and text. Every
"glass" card was therefore an image pre-rendered in Python (render.py, Pillow) and pasted behind
hyprlock's labels; the labels were shell commands reading files that a Python collector
(observe.py) wrote while locked. It never looked like glass, it shared nothing with the glance
band, and it took about 4,200 lines in four languages. The user's verdict on 21/10/2026: the blocks
"do not look like glass and have no integration", and too much code. Quickshell, which already
draws the bar and the band, has the same secure lock protocol hyprlock uses (ext-session-lock
through WlSessionLock) and native PAM (Quickshell.Services.Pam), so the lock moved there.
What the user chose, from real captures of the prototype (headless sway):
- ASUS: type straight on the print, no cards. The clock stacked on the calmer side, the field and a short quote centered at the foot.
- LG: four rows of real glass, every card the same system: a quiet label, ONE hero number, one chart, one line of context.
- One accent: the hanko's vermilion (
#c4342c), only in the charts, the caret, a failure and Caps Lock. - Bokashi, stronger: where the print would break AA under the type, a graded wash of the paper's or the indigo's tone, as strong as the measured contrast needs.
- 220 MB of memory while locked is acceptable.
The repo's language rule came out of the same conversation: Nix, Lua, QML and Shell only, so the lock has no Python left. See languages.md.
The architecture
| Piece | Where | What |
|---|---|---|
| The unit | lockscreen.service (default.nix) | runs lockscreen-launch; one owner, idempotent |
| The launcher | modules/home/desktop/lockscreen/scripts/lock-launch.sh | finds the session's compositor, starts the QML lock, falls back to hyprlock |
| The lock | quickshell/lock.qml | WlSessionLock, one Face per output, quits after an unlock |
| Auth | quickshell/lock/Auth.qml | the ONE password buffer and the PAM conversation, shared by every output |
| Faces | quickshell/lock/AsusFace.qml, LgFace.qml | chosen by connector in Face.qml; keys from any output go to the buffer |
| The logo | quickshell/lock/Logo.qml, my.lockscreen.logo | the logo in the type's ink, beside the signature |
| The print | quickshell/lock/Wallpaper.qml | picks from the pools, decodes to cover the screen, crops at the sidecar's focus |
| The ink | quickshell/lock/Ink.qml, InkMath.qml | the side, the ink and the shade, measured on the shown print |
| The quote | quickshell/lock/Quotes.qml, QuoteMath.qml | the curated collection, no repeats, haiku beside prints |
| The glass | quickshell/widgets/LiquidGlass.qml | a live blur of the print under each card |
| The data | quickshell/data/Energy.qml, System.qml, Weather.qml, dash/ClaudeUsage.qml | the same singletons the band reads |
| The fallback | programs.hyprlock in default.nix | the palette, the time and a field on every output, nothing that can fail |
| PAM | modules/nixos/desktop/desktop.nix | quickshell-lock, and hyprlock for the fallback |
A store snapshot, never the live tree. The shell's QML is linked mutable so it hot-reloads on
save (quickshell.nix); the lock runs from builtins.path of the same tree in the store, so
editing the repo never reloads a running lock. A change to the lock's look takes a rebuild.
Its own instance. qs -p <snapshot>/lock.qml is a separate process from the bar and the band:
a bug there cannot reach the lock, and the lock's singletons (System, Energy) only sample while
it exists. root:/ is the same tree, so it reuses Theme, widgets/, Feed.qml and the band's
data.
The launcher and the fallback
lockscreen-launch (modules/home/desktop/lockscreen/scripts/lock-launch.sh):
- Finds the session's compositor as before (see below), even with a stale
WAYLAND_DISPLAY. - Starts the QML lock and waits up to 5 s for
$XDG_RUNTIME_DIR/lockscreen/secure, which the lock writes once the compositor confirms every output is covered (WlSessionLock.secure). - No mark in time means no lock: it kills the QML process and execs hyprlock, which locks.
- After the lock ends, only the word
unlockedin that file counts as an unlock. The lock writes it on a PAM success, before it releases the session. An exit without it (a crash, a signal, even a clean exit by mistake) left the compositor locked with no client, so the launcher execs hyprlock, which REATTACHES to the locked session (allow_session_lock_restore = trueinhypr/lua/appearance.lua) and draws a field.
So the screen is never left open by a broken QML lock, and never left locked with nothing to type into. Stopping the unit (the session ending) kills the launcher too, so it never falls back then.
The fallback is plain on purpose. No labels running commands, no images, no cards: the
palette's background, $TIME and a field on EVERY output (monitor = ""), so it unlocks even with
a monitor gone. It is the safety net, not a second design.
Auth, and every output typing into one field
Auth.qml holds one buffer. Each Face takes focus and sends printable keys to it: Enter submits,
Backspace erases, Ctrl+Backspace and Esc clear. The compositor gives keyboard focus to ONE lock
surface (often the LG), so a buffer per output would strand the password; one shared buffer means
typing on either screen fills the same field, as hyprlock's global buffer did.
PAM is quickshell-lock, the same default stack as hyprlock's ({ } in desktop.nix). The lock
answers ONE prompt per attempt; a second prompt (a module asking for more) aborts the attempt. A
failure clears the buffer, shakes the field and says "senha incorreta"; the harness has a fake PAM
(pam_exec with a test password) so both outcomes are tested without the real password.
With the ASUS unplugged the LG says "Tela principal desconectada · digite a senha e pressione Enter" and echoes the dots, so the session is never stranded.
Restart and shut down from the lock (03/11/2026)
Two icons sit under the signature, in the clock column (lock/PowerEntry.qml): the Center's
restart and power glyphs, in the column's ink. A click only asks, in words: the field and the quote give way to "Desligar o computador?" with "cancelar" and
the verb (lock/PowerAsk.qml). The state is Power.qml, the same machine as the NixOS Center's
confirmation (nixos-center), so the commands and the failure path are one.
- Only these two, without the password, as a login screen offers them. Log out would end a session nobody authenticated for; lock, darken and suspend make no sense from here.
- Chosen from a bake-off of real captures: the corner, under the signature, and two bare icons at the foot. Under the signature won because its ink is the clock column's MEASURED one, so it reads on every print. The words "reiniciar · desligar" became icons the same day, for a cleaner column; the question names the action, so an icon alone never acts unread. The verb is in the ink too, with the vermilion only as its underline: vermilion type was weak on dark prints.
- The keys while it asks: nothing types into the buffer, Escape answers "no". A click is the only way to say yes, so a password typed in a hurry never confirms a shut down.
- polkit asks nothing, checked with
pkcheckon 03/11/2026:reboot-multiple-sessionsisimplicit active: yeshere, and a lock does not make the session inactive, so it works with an SSH session open, from the lock'suser@process as from the session's. - A trap the prototype hit: a
MouseAreasized by itsRowloops the layout, and Qt then stops polishing the whole window, so the question never laid out. The area is the glyph's child.
Only the ASUS face has them; with the ASUS unplugged, the LG still unlocks but cannot restart.
The ASUS: type on the print
- The clock: HH over mm in Geist Light 220 px, the seconds at 64 px beside the minutes on
their baseline (softer, 70%), the date under it ("quarta, 21 de outubro"), on the CALMER side of
the print. Second precision from
SystemClock, which follows the system clock with no timer of its own: hyprlock's skipped-second workaround (update:995) went with it. MEASURED on 21/10/2026 in the harness: 2.00% of a core with the seconds, against 1.93% with minutes only. - The signature and the logo (21/10/2026, the user's choice): under the date, as a Japanese
print carries its artist's, my full name and
v1cferrin the clock's ink, and beside them the logo, 56 px, in the same ink with no frame (lock/Logo.qml; the SVG's alpha masks the ink, since colorization keeps black black). It began as a vermilion seal, and the user dropped the red the same day. The name is git'suser.nameand the handle the account's (LOCK_NAME,LOCK_USERfrom the launcher); the logo ismy.lockscreen.logo, a path to STATE (the SVG in Dropbox), never in git. No file, no logo; the lock locks the same. The clock's ink zone covers the signature, so its AA and bokashi hold. The mask is a RAMP centered at half alpha (maskThresholdMin0.5, spread 1.0) over the SVG rasterized at 4x and mipmapped: MultiEffect's default mask is a step that kept or dropped each pixel whole, which made the logo jagged and closed the gaps between its strokes. - The field: no box. A hairline, the dots, a vermilion caret; "verificando…" while PAM runs;
a failure shakes it; Caps Lock shows in vermilion under it (read from the keyboard LEDs in
/sys/class/leds, polled only while locked). - The quote: the text, the original smaller,
author · source · year.
Two inks, measured apart. Ink.qml grabs the shown print at 192 px wide and InkMath.qml
reads two zones: the clock column (left, and its mirror on the right) and the foot. For the clock
it picks the side with less detail (a tie stays left). For each zone it picks the ink, light text
or dark sumi, with the better WCAG contrast against the zone's worst pixels (the 2nd and 98th
percentiles, with a 10% margin over the target), and the SHADE: how opaque a wash of the opposite
tone must be under the type to reach AA. The shade is solved in sRGB, because that is where an
alpha blends (solving it in linear light made it a third too weak).
Bokashi, not a scrim. The shade becomes a graded band, the printmaker's own wash: across the foot (half the screen's height, fading upward) and along the clock's side, in the paper's tone behind dark ink or the indigo's behind light ink. Zero when the print already reads; never a box, never a dark scrim over the whole screen (the user rejected that in v5.2). A tight glyph halo of the same tone sits on the type itself.
The LG: one hero number per glass card
| Card | Hero | Chart | Context |
|---|---|---|---|
| Energia | measured CPU + GPU watts | the last 30 min, a line | CPU, GPU, the peak |
| Claude Code | session % and week %, each with when it renews | the week as a step line against its pace | none |
| Sistema | CPU % now | its last two minutes | RAM used, GPU temperature |
| Processos | the top three apps by CPU now, each with its icon | none | "% de um núcleo" |
| Clima (always) | the temperature and the sky now | the next 12 hours: temperature line over rain bars | max, min, rain, feels-like |
| Tocando (only while playing) | the title, the artist | a vermilion progress line | none |
The weather is ALWAYS on screen (21/10/2026): it used to give its place to Tocando whenever music
played, and the user missed it. Tocando is a compact row under it, only while a player PLAYS
(Quickshell.Services.Mpris, live). The old bridge script and the once-per-lock layout choice are
gone: the card follows the player while locked.
Every chart reads out under the pointer: a hairline and a dot at the nearest sample (the bar itself lit on bars) and a small label with its time and value, "19:47 · 86 W"; the weather reads hour by hour, "03h · 21°", and its rain bars "03h chuva · 40%". No click, nothing moves. The same readout lives in the band's charts (dash.md).
Loading is not "no data". Until a card's source has answered once, its content stays hidden
under a pulsing skeleton of the same shape (the hero, the chart, the context line; three rows for
Processos), and fades in when the data arrives. Each source says when it is still loading:
Energy.loading (power-log not read yet), ClaudeUsage.error === "loading" and Weather.loading
(glance-feed's cache not read yet), a CPU % that needs its second sample (2 s) and a process
ranking that needs its second (5 s). Only after that does a missing source say so in words.
The lock itself loads the same way (21/10/2026, the user's ask: loading for everything, the
components as well as their data). The print fades in when decoded. Until it is shown and its ink
measured, every LG card is already on screen as glass with its skeleton (face.ready joins each
card's own loading), and the ASUS shows pulsing blocks (lock/Bone.qml) on the real geometry of
the still hidden type: the clock, the date, the signature, the field's line and the quote. They
fade out as the type fades in. In the harness the whole wait is under half a second; it is there
for the slow decode of a big print, and so nothing ever pops in from nothing.
Real glass. LiquidGlass.qml samples the print under the card (ShaderEffectSource on the
region it covers), blurs it and carries its color through (MultiEffect: blur 0.6 of 64, saturation
0.35, brightness -0.08), then a tint of the palette's bg, a top sheen, a hairline rim and a soft
RectangularShadow. The glass is CLEAR, the user's pick from a bake-off of four levels on
21/10/2026: the tint is 0.6 of the shade an Ink over the cards' area measures (the dark tint
that would keep light text AA on THIS print), over a 0.06 floor, so the print shows through; a
dark halo on the card's content (contentHalo) holds the type where that thin tint alone would
not. The region is re-read after layout, because a binding cannot see an ancestor (the Column)
move its children. It samples an Item, not the compositor, so it works over the lock's own
print, not over windows.
The Claude card reads its week against its pace (21/10/2026): the two limits as heroes, each
with its name and renewal ("semana · renova qui 00:00"), and under them the band's own
widgets/ClaudeWeek.qml across the full width, in the lock's type, pt-BR and vermilion: the
share used as a step line over the limit's own week, and a dashed PACE line from the last
renewal (0%) to the next (100%), what even spending would show. Above it the week is being spent
faster than it renews; below it, there is room. Under the pointer: "ter 15:33 · 54% usado · ritmo
81%". It replaced 42 unlabelled 4-hour bars.
Each process wears its app's icon (21/10/2026): the name from the cgroup goes through
DesktopEntries.heuristicLookup, and the entry's icon through Quickshell.iconPath, as the
notifications do. The entries load asynchronously, so the lookup re-runs when they arrive. No
.desktop (Hyprland, quickshell, sunshine): a neutral deployed_code glyph keeps the column
aligned. An icon is only an icon; no command line ever leaves /proc.
The numbers are honest. Energy is the MEASURED CPU package plus GPU board, never "the PC's
power"; a minute with no sample is a gap in the line, never a zero; RAM is used from
MemAvailable (cache is not used); the GPU shows temperature, not a utilization the xe driver
does not report; processes are app names from their systemd unit, never a command line.
The data, shared with the band
| Singleton | Reads | Cadence |
|---|---|---|
data/Energy.qml (+ EnergyMath.qml) | power-log's last two monthly CSVs, through tail | 1 min |
data/System.qml (+ SystemMath.qml) | /proc/stat, /proc/meminfo, the xe sysfs (rc6, frequency, the pkg temperature), every /proc/*/stat and cgroup | 2 s, processes 5 s |
data/Weather.qml | glance-feed's cache (glance-feed read weather) and my.weather's pt-BR conditions | on change, and every 5 min |
dash/ClaudeUsage.qml | glance-feed's claude_usage document | on change |
The band's energy panel reads data/Energy.qml too, so the lock and the desktop cannot disagree.
Nothing here touches the network: glance-feed owns every fetch
(glance-feed.md).
The quote, one per lock
The quotes are a CURATED collection in the repo, modules/home/desktop/lockscreen/quotes.json.
Four kinds, mixed:
| Kind | What | Shown as |
|---|---|---|
stoic | Marcus Aurelius, Seneca, Epictetus | a free pt-BR translation, the Greek or Latin under it |
literature | Machado de Assis, Fernando Pessoa and his heteronyms | the original Portuguese, once |
haiku | Bashō, Buson, Issa | a pt-BR translation in its 3 lines, the Japanese under it |
computing | Kay, Dijkstra, Knuth, Hopper, Brooks, Perlis… | a pt-BR translation, the English under it |
The sources policy. An entry goes in only with a precise source (a work and its section, an EWD number, a paper or talk with its year), checked against a primary text (Perseus, Wikisource, Gutenberg, Arquivo Pessoa, the E.W. Dijkstra Archive) or Quote Investigator. A famous line whose earliest source is doubtful is left out on purpose. The translations are free translations made for this collection. Each entry:
{"id": "…", "kind": "stoic", "text": "pt-BR…", "original": "…", "lang": "grc",
"author": "Marco Aurélio", "source": "Meditações, IV.3", "year": "c. 170"}Choosing (QuoteMath.qml, ported rule for rule from the old renderer): the lock picks one not
shown yet; the ids go to ~/.local/state/lockscreen/quotes-seen.json (state, not cache), and the
history resets only when everything was seen, never repeating the last one. Haiku sit beside the
prints: they are eligible only when the ASUS print has a sidecar with an artist, and then about
one draw in three is a haiku. An entry that does not fit the measure (64 columns, 3 lines; the
original 90 columns, 2 lines) is skipped; the credit drops the year, then the source, before it
would overflow. With no usable collection it is the built-in Alan Kay quote. Polytonic Greek is set
in Noto Sans, which has it (Geist does not).
Adding a quote is editing quotes.json and ! rebuild.
The wallpapers
| Monitor | Pool (LOCK_POOL_* in default.nix) | No usable file |
|---|---|---|
| ASUS | ~/Pictures/lockscreen/wide/: Japanese landscape prints | the palette's gradient |
| LG | ~/Pictures/lockscreen/tall/: Hiroshige's One Hundred Famous Views of Edo | the palette's gradient |
Two collections, on purpose (20/10/2026). The lock is Japanese and contemplative, the desktop is real galaxies. The lock's landscape pool started with 16 prints from The Met's Open Access collection (Hiroshige's night rain, moon and snow views, Kyoto, Ōmi), public domain (CC0); each carries a sidecar with title, artist, date, credit line, the Met URL and the license.
The LG's pool (21/10/2026). The user swapped the standing monitor's old art (illustrations,
photos and loose ukiyo-e, now in ~/Pictures/lockscreen/archive/, nothing deleted) for 47
prints of Hiroshige's One Hundred Famous Views of Edo (1856 to 1858): a vertical series by nature,
and the same hand as the ASUS's landscapes. The Met's API blocked most lookups behind its bot wall
that day, so they came from Wikimedia Commons: the Library of Congress and Brooklyn Museum scans,
42 public domain and 5 CC0, the largest portrait JPEG per print, at least 2400 px tall in the
source. Each was reduced once to 2880 px tall (67 MB for all 47) and carries a sidecar with the
title, series, artist, credit, the Commons page, the image URL, the license and the source's
resolution. Downloaded by hand, once; never by the lock.
The images are STATE, not config (rule 6). No binary enters git, and nothing is downloaded at
lock time, or ever by the lock. Each lock draws a random file from its pool (symlinks followed),
skipping the last five shown on that monitor (seen-primary.json, seen-secondary.json in
~/.local/state/lockscreen). The image is decoded straight to cover the screen (sourceSize), so a
100 Mpx print never lands in memory at full size, and cropped around the sidecar's optional
"focus": [x, y].
Dimmed by 55% (21/10/2026), the user's pick from four levels in real captures: a black layer
over the print inside Wallpaper.qml, so the ink and the glass measure what is SHOWN and their
contrast already counts it (on the dimmed prints the ASUS's clock turns to the light ink by
itself). It is for the type, and for the mood; on these LCD panels the energy is the backlight's,
which the lamp lowers while locked (backlight.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 was
hypridle, through lock_cmd. With hypridle stopped (6 h on 10/08/2026, the Sunshine guard holding
it after a client died with no teardown) the bar's "Lock" did nothing, with no error.
Locking is a SECURITY FUNCTION and cannot be hostage to an IDLE daemon that anything can stop. The
lock is a unit of its own, lockscreen.service (it was hyprlock.service until v6), and whoever
wants to lock runs systemctl --user start lockscreen.service: hypridle's lock_cmd, the boot's
line in autostart.lua, the bar's Lock action.
Why a real unit and not a transient systemd-run:
- It stays OUTSIDE the
hypridle.servicecgroup, which prevents the REMOTE LOCKOUT of 03/08/2026: the Sunshine guard'ssystemctl --user stop hypridlekilled the whole cgroup and took the lock client with it, leaving the compositor LOCKED WITH NO CLIENT. - Idempotence for free:
starton an active unit is a no-op. Two session-lock clients confuse the keyboard grab; systemd guarantees one. - The name is not left occupied after the unlock.
ExecCondition (not ExecStartPre) skips the start silently while a hyprlock started by hand runs
(the TTY rescue). There is no Restart: a lock that exits is an unlock. No Install on
purpose. Zen's close-after-lock guard binds to this unit (zen.md).
Media and the idle lock
The 5 min timeout has no business landing in the middle of a movie, and music has no business
keeping the machine open. The gate is lockscreen-idle-lock.service: hypridle's on-timeout
starts it, and it calls loginctl lock-session unless FULLSCREEN VIDEO is playing.
The policy, since 19/10/2026. The lock is held back only while ONE process is both a window in
true fullscreen (Hyprland's state 2; maximized does not count) and the owner of an MPRIS name whose
PlaybackStatus is Playing. The PID of the MPRIS name comes from the BUS
(GetConnectionUnixProcessID), not from the player's own metadata, so a Spotify playing behind a
fullscreen game is two processes and does not hold. The hold is capped at 3 h, then it locks anyway.
| Situation | Before 19/10/2026 | Now |
|---|---|---|
| Spotify playing, nobody at the desk | never locked, all night | locks at 5 min |
| Anything paused | locked | locked |
| A film fullscreen in Zen or mpv | held until it stopped | held until it stops, at most 3 h |
| The same film in a window | held | locks at 5 min |
| A game fullscreen with music playing | held | locks at 5 min (the Sunshine guard covers streams) |
Manual lock (SUPER+L, the bar) | always | always: the gate is only on the idle path |
IT WAITS, IT DOES NOT SKIP. Each hypridle listener fires ONCE per idle cycle, so a gate that
returned without locking would leave the machine open until the next keystroke. Waiting keeps the
lock PENDING; it lands up to 30 s (the poll) after the video stops. Coming back to the keyboard
cancels it through the listener's on-resume. PartOf = hypridle.service, because a pending lock
belongs to the daemon that scheduled it: the Sunshine guard stops hypridle so a stream never locks.
Why ignore_dbus_inhibit stays true
An app's inhibit is UNBOUNDED: a browser that leaks one disables locking forever and in silence.
The gate is a single decision point, re-evaluated every 30 s, and it states its reason in the
journal (fullscreen video is playing, the idle lock waits, at notice priority). The Arch config
this replaced had false; the port flipped it on 24/07/2026, and it is a decision now.
When the lock cannot reach the compositor
The incident (18/10/2026, found in the journal). After the boot lock worked, EVERY idle lock
failed: the lock aborted with Couldn't connect to a wayland compositor, and the screen stayed
OPEN each time, in silence. The unit takes WAYLAND_DISPLAY from the systemd user manager, and
that value stopped pointing at the live compositor. The root cause was not found; the design flaw
was a lock depending on one inherited variable.
The fix, independent of the cause:
- The launcher asks
hyprctl instances, keeps those whose socket really exists, and locks the OLDEST one: the session's compositor is always older than a nested test one. A wrong value is logged at warning priority; with no Hyprland but a liveWAYLAND_DISPLAYit keeps that; with nothing alive it fails the unit. OnFailure=lockscreen-lock-failed.servicepushes an URGENT notification to the phone throughnotify(ntfy): a desktop bubble is useless when nobody is there.
Security model
- One unit, idempotent, outside hypridle's cgroup; the launcher never leaves the screen open (no
securein 5 s is hyprlock) nor locked with no client (nounlockedis hyprlock). - The lock runs from the store, never from the mutable tree.
- The password lives in one QML string while typed and goes only to PAM; it is never logged, never
on a command line. A QML
Processhere only reads (find,cat,tail,glance-feed read). - Strings from outside (titles, artists, quotes) are plain
Text, never markup that runs anything. - PAM, LightDM's autologin, the boot-time lock and Sunshine are untouched.
- No DPMS, brightness, refresh rate or power-profile change: Eco stays with
power-idle-eco.
Hardware lessons, durable and independent of the GPU. Do NOT touch
- A static print, never a screenshot of the desktop under the lock. hyprlock's
path = screenshotsegfaulted on wake (the DMA frame destroyed on exit), a lockout. The QML lock draws only its own print. - dpms-off REMOVED (jul/2026). Turning the screen off on idle broke Sunshine (wlr capture on
xegot a BLACK SCREEN) and toggling dpms under capture caused a GPU ENGINE RESET. Idle ONLY locks. Seesunshine.md.
Language: a deliberate exception
The system is en-US and the lock screen is pt-BR: the date, the field, the cards, the quotes (with the original under them). Those strings are the product, not untranslated debt.
Power and performance
Measured on 21/10/2026 in the headless sway (GL on the B580's render node, both outputs, 30 s of
/proc/<pid>/stat):
| CPU, % of one core | RSS | |
|---|---|---|
| v6, the QML lock, both faces, data sampling | 1.93 | 220 MB |
| v5.1, hyprlock plus its label children (the last measured hyprlock) | 2.12 | 93 MB |
The memory is Qt's and the two decoded prints, and only exists while locked: the process exits on unlock.
On the real desk, 01/11 to 03/11/2026, the power log's minutes inside a lock (934 of them, the first 2 min of each lock left out) against the rest:
| CPU package | GPU board | busy CPU | |
|---|---|---|---|
| locked | 18.8 W | 36.6 W | 7.6% |
| unlocked | 52.4 W | 48.8 W | 14.2% |
The GPU sits at the B580's floor with both panels on, the same 37 W v2's hyprlock had. The lock's OWN cost is higher than the harness said: a 4 h 26 min lock on 03/11 used 21 min of CPU, 7.9% of one core, against 1.93% in the headless sway, with 236 to 325 MB at peak. The real panels' refresh rate is the likely difference: something still repaints every frame while locked. Not chased yet.
Testing
| Check | What it pins |
|---|---|
nix build .#checks.x86_64-linux.lockscreen | modules/home/desktop/lockscreen/scripts/test.sh, bash: the launcher reaches the session's compositor with a stale, missing or hostile WAYLAND_DISPLAY; five fallback cases (unlock, death while locked, a clean exit without an unlock, never locking, failing to start); the idle gate's video policy |
nix build .#checks.x86_64-linux.quickshell-logic | quickshell/tests/, Qt's qmltestrunner, headless: quotes (wrap, schema, no repeat, haiku pairing), ink (contrast, the side, the shade), energy (holes, staleness, the Eco baseline), system (CPU, memory, app names) |
The harness (~/.cache/lockscreen-dev/harness, outside the repo) runs a headless sway with two
outputs and a guard that refuses unless the socket belongs to that sway; the BUILT launcher is run
there with the instance list pinned to the test sway, because a bare hyprctl instances would find
the live session and lock it. Checked there on 21/10/2026: a wrong password refused, the right one
(fake PAM) unlocking with exit 0 and no fallback, SIGKILL on the locked QML handing over to the
fallback, a QML file that does not load replaced in 5 s. wtype loses the first key of each run
(a keymap race in the harness only), so every run starts with a throwaway key.
Known limitations
- Proven by daily use since 30/10/2026: Hyprland's own lock handling and the real password, 35
locks and 35 PAM unlocks by the QML lock, none through the fallback. The one fallback in the
journal (30/10 18:27) was a
shutdownwhile locked: the compositor died first, so hyprlock had nothing to attach to. Expected; it is the shutdown order in open-items. - The fallback, proven on the desk on 03/11/2026: the QML lock killed 10 s into a lock, hyprlock drew its field 0.14 s later and unlocked with the real password.
- Unverified on the real desk: the look on the physical panels, a wrong password, a DP unplug while locked, and Caps Lock on a real keyboard. See the procedure below.
- A pool's file is not checked for orientation or size any more: a landscape file in the LG's pool is cropped to portrait.
- The glass blurs the lock's own print only; it cannot show windows (the lock hides them anyway).
- A very busy print still shows through the bokashi: the shade is measured on a reduced grab, so its worst pixel is not the single worst pixel (the foot's wash takes 1.3x the measured shade).
- Process memory is summed RSS, so an app with many processes reads high.
The first lock after switching, step by step
Nothing here locks the session by itself. After ! rebuild:
- Keep a way back open BEFORE the first lock: an SSH session from another machine.
SUPER+L. Check the ASUS (the clock on the calmer side, the field, the quote) and the LG (four rows of glass). Type a WRONG password first (the shake and "senha incorreta"), toggle Caps Lock, then the right one, and unlock.- Lock again and, from SSH,
killtheqsPID thatsystemctl --user status lockscreenlists. hyprlock must take over within a second and unlock with the password. - The old cache is dead data now:
rm -rf ~/.cache/lockscreen.
Troubleshooting
| Symptom | Look at |
|---|---|
| the palette's gradient instead of a print | the pool is empty or unreadable: ls ~/Pictures/lockscreen/wide |
| the plain hyprlock instead of the QML lock | journalctl --user -u lockscreen: "did not lock within 5 s" or "exited without an unlock", then the QML's own log under /run/user/1000/quickshell/by-id/ |
| a card says "sem dados" | its source: tail /var/lib/power-log/*.csv, glance-feed read claude_usage, glance-feed read weather |
| the same quotes coming back | ~/.local/state/lockscreen/quotes-seen.json: it resets only after the whole collection |
| the lock does not appear | systemctl --user status lockscreen and its ExecCondition (a hyprlock started by hand) |
Rollback
| Layer | How back |
|---|---|
| Configuration | git revert the v6 commits and ! rebuild, or boot the previous generation (sudo nixos-rebuild switch --rollback) |
| State | ~/.local/state/lockscreen holds only the seen-lists; deleting it only resets them |
| A blank or stuck lock | from a TTY (CTRL+ALT+F2) or SSH: WAYLAND_DISPLAY=wayland-1 hyprlock REATTACHES to the locked session and draws a field |
| The compositor itself | from SSH, the journal first; relaunching hyprlock as above is the fix for an orphaned lock, and restarting Hyprland (which ends the session) is the last resort |
Killing the QML lock is now SAFE (the launcher hands over to hyprlock), but killing hyprlock itself is not: with no launcher behind it, it leaves the compositor locked with nothing drawing the field, which is the lockout of 03/08/2026.