ADR 0010
The lock screen is the login screen
Context
Locking was swaylock -f -c 0b121e: a blue screen with a circle in the middle
of it. Six things ran it — Super+L under Hyprland, W-l and the menu under
labwc, the system menu's Lock, a lid set to lock, and the idle watch at the
blank timeout and before sleeping — so it was the one part of Alpymist that
everybody saw regularly and that looked like nothing else in it. It does not
say whose session it is, what time it is, or that it is Alpymist at all.
ADR 0009 left this open on purpose:
If a lock screen of Alpymist's own is ever wanted, the drawing here is what it would show, and
ext-session-lock-v1is where it would go. Nothing here forecloses that; it is a separate decision, and this one does not pretend to have made it.
This is that decision.
A lock screen has to do four things: cover every output, hold the keyboard,
stay up even if the program drawing it dies, and check a password. Only the
last one is ours to invent; the other three are ext-session-lock-v1 and the
compositor.
Decision
1. It is an ext-session-lock-v1 client, not a layer surface
ADR 0009 wrote down what an overlay surface costs: "an overlay surface is not a
guard. A compositor crash, or the process being killed, uncovers the screen."
That is exactly why the screensaver is one and the lock is not. With a session
lock the compositor blanks every output the moment the lock is asked for,
shows nothing but this client's surfaces until it is told the session is
unlocked, and keeps the session covered if this process dies. kill does not
uncover the screen; it strands it.
It also answers ADR 0003's lookalike question by construction. Anything can draw a convincing fake lock screen on a layer surface — but it cannot be the lock, because the compositor grants one session lock at a time and while a real one is up there is nothing else on screen at all. Ctrl+Alt+Delete still answers the other direction, as it does for the polkit prompt.
The cost of the protocol is the failure mode it is built on: a lock that cannot
check a password is a session that has to be rescued from a text console
(Ctrl+Alt+F2, loginctl unlock-session or killing the compositor). That is why
§5 is about failing before the screen is covered.
2. It is the greeter's screen, not a copy of it
alpymist-greeter was already a library whose App knows nothing about
greetd: it takes a list of accounts, an Authenticator closure, a size, input
as actions and a canvas to draw on. alpymist-lock builds that same App with
PAM in place of greetd. The mountains, the clock, the card, the caret, the
message line and the wrapping are one implementation, drawn from one place.
Three things differ, and they are a Purpose on the App rather than a second
screen:
- One account, this session's, found by user id in
/proc/self/statusrather than by$USER— the environment is whatever started the session and can say anything. There is nobody to switch to: switching users is what the login screen is for, and it is behind Log out. - No footer buttons. F11 Restart and F12 Power off belong on a login
screen and not on a locked one. The whole of that is
purpose == Purpose::Loginin three places. - "Enter Unlock" on the button instead of "Enter Log in".
Caps Lock is said in the message line, which the login screen has no way to know about — it reads evdev, where the lock is told by the compositor.
3. PAM directly, as its own service, and only pam_authenticate
/etc/pam.d/alpymist-lock is auth include base-auth: the same stack
/etc/pam.d/alpymist-greetd includes, so what unlocks the screen is what logs
the account in. An account with no password unlocks on Enter for the same
reason it logs in on Enter, and a machine with such an account is not made safe
by a lock screen.
It runs as the account, not as root: pam_unix hands the check to its own
setuid unix_chkpwd, so nothing here ever reads /etc/shadow.
Account management is deliberately not asked. pam_acct_mgmt answers
"may this account log in" — expiry, allowed hours — and a lock screen is not a
login. Refusing somebody entry to a session that is already running, over a
password that expired while they were at lunch, answers a question nobody
asked and loses their work.
Two alternatives were considered:
- polkit, reusing
alpymist-auth's conversation withpolkit-agent-helper-1, which already runs the whole PAM exchange. It would have added no new dependency at all. It was declined because it puts polkitd between somebody and their own session: one more daemon that has to be alive for a locked screen to open. - Writing the PAM conversation ourselves over raw bindings. The callback
PAM wants is an
extern "C"function that allocates the replies, and ADR 0004 keepsunsafeto one crate that does one job.nonstickis a safe wrapper whose bindings are written out rather than generated, so the builder needslinux-pam-devand not clang. Linking a C library through a crate that contains theunsafeis whatalpymist-menualready does for libxkbcommon; ADR 0004's rule — nounsafein first-party code — is intact, andalpymist-lockcarries#![forbid(unsafe_code)]like every other crate.
4. -f runs the program again rather than forking
swayidle runs one command before sleeping and waits for it, so the command has
to return when the screen is covered and not before, or the machine sleeps with
the desktop still showing. swaylock -f forks. Forking is unsafe in Rust, so
alpymist-lock -f starts alpymist-lock instead, waits to be told down a pipe
that the first frame is committed, and returns. The second run is what stays.
The spelling is swaylock's because the setting that holds this command is the
person's to change (~/.config/alpymist/power.toml), and alpymist-lock -f
being the same shape as what was there is one less thing to explain.
5. Everything that can fail, fails before the screen is covered
Locking a session is easy to do and hard to undo. So alpymist-lock checks,
in this order and before it asks for the lock: that there is a Wayland session,
that /etc/pam.d/alpymist-lock exists, that this session's account can be
found, and that the compositor has ext-session-lock-v1. Each of those is a
message on the terminal and an exit code, with the desktop untouched.
After the lock is granted there is nothing left that can fail but drawing, and a lock that draws nothing is still a lock.
6. The compositor draws the pointer, and the callback is only a hint
Two things were measured on a VM with no GPU acceleration — virtio-gpu, so Hyprland composites in software — and both are worth writing down, because neither is visible on a machine where the GPU hides it.
Following the pointer costs a screen. The login screen draws its own
pointer, because on DRM there is nothing else to draw one. Doing the same here
meant a full frame for every motion event, and a compositor that re-renders a
screen behind every one of them: moving the mouse made the whole machine
crawl. So App draws a pointer only for Purpose::Login, the compositor's own
is left alone, and the lock asks about the pointer only where it was clicked.
Damage is a courtesy, not a contract. The surface is opaque — Xrgb8888
plus an opaque region, so a compositor has nothing to blend — and it reports
only what changed, which is the card, the clock's band and where the pointer
was. Measured over 100 keystrokes, Hyprland spent the same on all three of
damaging the card, damaging the whole screen, and a deliberate one-pixel lie:
272, 276 and 270 hundredths of a second. It re-renders the monitor whichever
it is told. The reporting stays because it is correct and costs nothing, and
because the next compositor may believe it.
What the same hundred keystrokes cost this program: 12 hundredths, about half a millisecond a frame, with frames going out every 10 ms.
A compositor may never ask for a frame at all. A client paints when its frame callback arrives, and a compositor with nothing else to repaint — no windows, no animation, nobody moving the mouse, which is exactly a locked screen — may not send one. The symptom was a password that appeared only when the mouse was jogged. So the callback is a hint: if none has arrived in 80 ms and there is something to show, it is shown anyway. Below noticing, far above any frame time here, and never reached by a compositor behaving normally.
Consequences
swaylockcomes offalpymist-desktop-waylandandalpymist-power, and every one of the six call sites runsalpymist-lock.power.tomlstill accepts any command, so anybody who preferred swaylock can put it back in one line — and the test that proves it round-trips uses swaylock, on purpose.alpymist-lockis a package, andalpymist-greeteris now a library two programs are built from. Building the lock builds the greeter's screen.- The Legacy tier gains nothing, as with the screensaver: X11 has
i3lockand none of this. - Two outputs of different sizes recompose the scene on every frame, because
the
Appis one screen's worth of state laid out for one size and is drawn on each output in turn. Equal-sized outputs, and the single screens these machines have, cost nothing. Making the picture per-output would mean a scene, a layout and a password field per output, and the second password field is the part that is actually wrong. - The greeter's
App::drawnow returns the region that may differ from the frame before, which the login screen is free to ignore and does. - Nothing here shows the screensaver behind the lock, and nothing here unlocks by fingerprint. Both would be this program's to grow; neither is missed yet.
Addendum — 2026-09-30: a finger unlocks it too, when turned on
The last consequence above said nothing here unlocks by fingerprint. Now
something does, when an administrator turns it on. With Settings › System ›
Unlock with a fingerprint on, the lock asks a second PAM service,
alpymist-lock-fingerprint, beside the password's, and an enrolled finger
unlocks as the password does. The password service and everything else here
are unchanged. ADR 0016 has the reasons, and the
reasons the login screen still takes only the password.
Addendum — 2026-10-03: accounts already made are given the lock too
The first consequence above said every call site runs alpymist-lock. That
was true of what ships, and not of an account made before it: its
hyprland.conf kept swaylock -f -c 0b121e on Super+L, and its power.toml
kept the same as the command that locks before sleeping. With swaylock off the
system, the key did nothing, and, a failed lock not stopping a suspend, a
closed lid slept and woke unlocked. The one thing that put the key right ran
only when a Displays setting was changed, and nothing put power.toml right.
alpymist session prepare, which runs before every session and for each
account when the desktop package is upgraded, now changes both lines where
they are still exactly what Alpymist wrote, and keeps each file as it was
beside it as .bak-lock. A Hyprland already running is told to read its
configuration again, since it does not notice the file replaced. Any other
lock command, swaylock with other arguments included, is somebody's choice
and is left alone, as power.toml accepting any command always meant.
Addendum — 2026-10-04: the PAM bindings are ours
The decision above declined writing the PAM conversation ourselves, and took
nonstick so that no unsafe was first-party. That is reversed.
alpymist-pam is the conversation, written here, and the second crate
permitted unsafe; nonstick is gone. What it gave up is said plainly in
ADR 0024: ADR 0004's one place is now two.
What it bought is that the code between a password and the account is code
that has been read. The lock's services, and what it asks of PAM, are
unchanged, and alpymist-lock still carries #![forbid(unsafe_code)].