login greeter
git clone https://git.lucas.co/cce-display-manager.git
docs: rewrite the README, add a CLAUDE.md, install through install-system
The README described another program: wgpu and glyphon, the River WM, a
start-river.sh in Dropbox. It now covers the greeter's keys, the four
roles (daemon, greeter, fingerprint helper, session worker), installing,
logs and rollback, keeping the login-keyring section, which was right.
CLAUDE.md, as the sibling crates have: the rules that keep logins
working (no fork of the threaded daemon, no PAM session in the greeter,
the verified password verbatim, kill-session not terminate-session, one
keyring provider), where output goes, and that a new binary does not
reach the running daemon until F5 or a reboot.
make install ran ccebuild install alone, which never touched the
/usr/bin greeter the unit starts. It now runs install-user (the keyring
helper scripts the TPM drop-in needs) and install-system (the binary,
units and PAM stacks). make run starts the greeter; the daemon refuses
to run as a user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Makefile | 33 +++++++++++----
README.md | 114 ++++++++++++++++++++++++++++++++++++--------------
3 files changed, 248 insertions(+), 39 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..03773ef
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,140 @@
+# CLAUDE.md
+
+> This is the `cce-display-manager` crate, inside the larger **`cce` Cargo
+> workspace** — read `../cce-compositor/WORKSPACE.md` first for the multi-repo
+> layout, the standalone-build rule, `ccebuild`, and the `cce-ui` toolkit. This
+> file covers only what is specific to this crate. `README.md` is the
+> user-facing account (keys, install, logs, the keyring chain); read it too.
+
+`cce-display-manager` **is the login path.** A mistake here is not a broken
+window: it is a machine the user cannot log into, and the usual tools for
+finding out why (their session) are the thing that is missing. Every change
+here gets verified by a REAL login before it is called done, and every
+install leaves the rollback in place (see "Installing and verifying").
+
+The whole crate is `src/main.rs`. One binary, four roles, each its own process,
+chosen by argv in `main`:
+
+- **daemon** (no args, root, the unit's `ExecStart`) — owns the tty, runs the
+ resume watchdog thread, and loops: greeter → session → greeter.
+- **greeter** (`--greeter`, root, under `cage -s`) — the `cce-ui` login screen.
+ Checks the password (PAM `cce-display-manager-password`, authenticate +
+ acct_mgmt only — see "No PAM session in the greeter") and prints one line on
+ stdout for the daemon: `AUTH_SUCCESS|user|exec|is_wayland|password`
+ (`auth_success_line` / `parse_auth_success`; the password is the LAST field,
+ taken verbatim to end of line, because it may contain `|`).
+- **fingerprint helper** (`--fprint-auth <user>`) — one attempt on the
+ `cce-display-manager-fprint` stack, in a separate PROCESS because
+ `pam_fprintd` blocks uninterruptibly and only killing the process makes
+ fprintd release the sensor. `PR_SET_PDEATHSIG` ties it to the greeter.
+- **session worker** (`--session-worker <tty> <user> <is_wayland> <exec>`,
+ root) — opens the PAM/logind session, runs the session as the user, closes
+ PAM when it exits. Password on STDIN (never argv — world-readable in
+ /proc); it reports `SESSION_ID <id>` on stdout and then points stdout at
+ /dev/null.
+
+## The rules that keep logins working
+
+**The session worker is a fresh process, never a `fork()` of the daemon**
+(since 2026-09-25). The daemon is multi-threaded (the resume watchdog), and a
+forked child inherits whatever locks another thread held at that instant; the
+worker then does PAM, env writes, logging and spawns. That class of wedge froze
+the GREETER's login on "Authenticating…" (2026-09-18: `pam_gnome_keyring`'s
+auto_start forking out of the threaded Vulkan greeter). The daemon runs the
+worker with `Command` and NO `pre_exec`, so only async-signal-safe work
+happens between its fork and exec. Do not reintroduce `libc::fork` here, and do
+not add a `pre_exec` to the daemon's spawns.
+
+**No PAM session in the greeter.** The greeter authenticates only; the session
+worker opens the real session. `open_session` in the greeter registered a
+throwaway logind session under cage and ran session modules inside a threaded
+Vulkan process — the freeze above.
+
+**Hand the worker the password that was VERIFIED, verbatim.**
+`State::auth_password` is set by each attempt (empty for a fingerprint) and is
+what `AUTH_SUCCESS` carries. Two bugs lived here: the password was `trim()`ed
+(a password with a leading/trailing space could never log in), and a
+fingerprint success sent the password BOX's text, so a half-typed password
+sent the worker down the password stack to fail a login the greeter had
+accepted. An empty password means "already verified" to the worker
+(`session_pam_service` → the autologin stack, `pam_permit`) — so the greeter's
+word is the whole of a fingerprint login's authentication.
+
+**End the logind session when the session is over** (`terminate_session`,
+from the DAEMON after the worker exits — pam_systemd put the worker inside the
+scope). With logind's `KillUserProcesses=no`, closing PAM only marks a session
+`closing`; its leftover processes live on in the scope. Every login leaked that
+way until 2026-09-25 (eight sessions stuck `closing`, 16 orphaned 1Password
+helpers, 1.9 GB). Use **`kill-session`, not `terminate-session`**: once the
+leader has exited, logind has abandoned the scope and TerminateSession does
+NOTHING — no error, no journal line (measured). SIGTERM, poll up to 2 s for the
+session to go, then SIGKILL; polled rather than timed on a thread, because the
+next worker is spawned right after. This also runs on a compositor-restart
+relaunch: nothing survives into the new session from the old scope today (the
+new compositor starts its clients fresh); if clients ever reconnect ACROSS a
+restart, they must be carried into the new session instead.
+
+**One keyring provider: the TPM-sealed `gnome-keyring-daemon` unit** (README,
+"Login keyring"). `pam_gnome_keyring` must stay OUT of the PAM stacks —
+`no_pam_stack_starts_a_keyring` enforces it. In a stack it started a second
+daemon at every login that could not unlock the keyring (its password is the
+sealed random one) and raced the unit.
+
+## Where things go
+
+- **Session output** → `/run/user/<uid>/cce-session.log` (previous one `.old`),
+ opened in the session command's `pre_exec` AS THE USER — a root open in a
+ user-owned directory could be symlinked at any file. The Bash console
+ session (`CONSOLE_SESSION_EXEC`) keeps the tty instead. It used to inherit the
+ daemon's stdout: a world-readable root log, truncated at every daemon start.
+- **Daemon / greeter / cage / worker logs** → the journal. The units set
+ `StandardOutput/StandardError=journal`; the daemon skips its old
+ `/var/log/cce-display-manager-<tty>.log` only when `JOURNAL_STREAM` says
+ systemd connected it, so a new binary under an old unit (or an F5 re-exec of
+ a daemon an old unit started) still logs to the file. `JOURNAL_STREAM` is
+ removed from the session's environment.
+- **Persistent state** → `/var/lib/cce-display-manager/last_user`,
+ `last_session`. Runtime → `/run/cce-display-manager-<tty>/` (the greeter's
+ `XDG_RUNTIME_DIR`, 0700).
+- **Compositor restart** → `ccectl restart-compositor` writes
+ `/tmp/cce-restart-requested-<user>` and exits; the daemon relaunches the
+ same session on the autologin stack. The flag is honored only as a regular
+ file owned by that user (`symlink_metadata`), since anyone can create names
+ in /tmp.
+
+## Installing and verifying
+
+The login runs `/usr/bin/cce-display-manager`, the units in
+`/etc/systemd/system` and the stacks in `/etc/pam.d` — all installed by
+**`ccebuild install-system`** (root, backs up to `*.bak-<date>`), never by
+`ccebuild install`, which only puts the keyring helper scripts (needed: the TPM
+drop-in runs one) and an unused copy of the binary in `~/.local/bin`.
+`make install` runs both. `ccebuild install-system --dry-run` shows what would
+change, and is how to check a build actually differs from what is installed.
+
+**A new binary does not reach the running DAEMON.** Each login starts the
+greeter from `/usr/bin`, but the daemon keeps the code it started with (its
+`/proc/<pid>/exe` reads `(deleted)`): F5 at the greeter re-execs it from
+`/usr/bin`, a reboot restarts it. So daemon-side changes (the session worker,
+session termination) need a logout + F5 + login to be tested at all. Do NOT
+restart `cce-display-manager@tty1` from inside a session — it owns the tty the
+session is on.
+
+What the suite covers (`cargo test -p cce-display-manager`): the line
+protocols, arg building/parsing, PAM service choice, session-id validation,
+that no stack starts a keyring, and (`tests/session_worker.rs`, the real
+binary) that `--session-worker` refuses a non-root caller and a malformed argv.
+It cannot cover PAM, logind or the handoff — those need root and a seat. Verify
+on a real login, reading `journalctl -u cce-display-manager@tty1 -b` (or the
+/var/log file, see above), `loginctl list-sessions`, and the session log.
+Rollback is Ctrl+Alt+F2 (logind's auto-VTs), restore the `.bak`, reboot.
+
+## Known gaps
+
+- **Ctrl+C at the greeter stops the daemon** (exit 130 → `exit(0)`), and the
+ unit has no `Restart=`, so the login screen stays gone until a reboot.
+- `WLR_DRM_DEVICES=/dev/dri/card1:/dev/dri/card0` for the greeter's cage is
+ this machine's card numbering, hard-coded; `startcce` pins card1 the same way.
+- `busctl monitor` (the resume watchdog) and `chvt` / `loginctl` are shelled
+ out to, deliberately — a D-Bus crate would widen a root binary's
+ dependencies for three calls.
diff --git a/Makefile b/Makefile
index d45e830..6443797 100644
--- a/Makefile
+++ b/Makefile
@@ -1,18 +1,37 @@
-.PHONY: build install run clean
+.PHONY: build install install-user install-system run clean
build:
cargo build --release
-# Binaries, helper scripts and user units are enumerated by ccebuild from
-# cargo metadata, so this crate's extra [[bin]] targets are picked up without
-# being named here — hand-listing them is what left cce-bevel and the keyring
-# helpers uninstalled for weeks.
-install: build
+# The display manager lives in TWO places, and `install` does both:
+#
+# install-user ~/.local/bin + ~/.local/share/dbus-1: the keyring helper
+# scripts (the TPM drop-in runs cce-gnome-keyring-start from
+# there) and the Secret Service D-Bus file. It also drops a
+# copy of the binary in ~/.local/bin, which nothing runs.
+# install-system /usr/bin/cce-display-manager, the systemd units and the
+# PAM stacks — what the login actually runs. Root, one sudo
+# prompt; it backs up whatever it replaces (*.bak-<date>)
+# and covers every crate's root artifacts, not only ours.
+#
+# Until 2026-09-25 `install` was install-user alone, so it never touched the
+# greeter the unit starts. Binaries, helper scripts and units are enumerated
+# by ccebuild from cargo metadata and the crate's dirs — hand-listing them is
+# what left cce-bevel and the keyring helpers uninstalled for weeks.
+install: build install-user install-system
+
+install-user:
@command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
ccebuild install --no-build cce-display-manager
+install-system:
+ @command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+ ccebuild install-system
+
+# The greeter alone, in the current Wayland session — the daemon (no args)
+# must run as root and refuses otherwise.
run:
- cargo run
+ cargo run -- --greeter
clean:
cargo clean
diff --git a/README.md b/README.md
index 488b96c..615c90a 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +1,88 @@
-# CCE Display Manager
+# cce-display-manager
+
+The login screen of the cce desktop: a root daemon on a VT that shows a
+`cce-ui` greeter under [cage](https://github.com/cage-kiosk/cage), checks the
+user's password or fingerprint against PAM, and starts their session —
+normally `startcce`, the cce compositor.
+
+It runs as `cce-display-manager@tty1.service` (enabled; `getty@tty1` is its
+conflict). The machine's own session list comes from
+`/usr/share/wayland-sessions` and `/usr/share/xsessions`, plus a built-in
+**Bash Shell** entry for a console login on the tty.
+
+## Using the greeter
+
+- Type the username (the last user is filled in) and the password, then Enter.
+- **Fingerprint**: with the username filled in and fprintd enabled for
+ `cce-display-manager-fprint`, a scan starts on its own; Enter with an empty
+ password starts one again. Submitting a password cancels a scan in
+ progress.
+- **Up / Down** or **Ctrl+P / Ctrl+N** pick the session; **Tab** moves between
+ the two fields.
+- **F5** restarts the display manager daemon from `/usr/bin` — how a newly
+ installed version takes effect without a reboot.
+- **Ctrl+C** stops the display manager altogether. The unit does not restart
+ it, so the login screen stays gone until a reboot or
+ `sudo systemctl start cce-display-manager@tty1`.
+
+## How it is put together
+
+One binary, four roles, each its own process:
+
+| Role | Started as | Runs as | Job |
+|---|---|---|---|
+| daemon | `cce-display-manager` (the unit) | root | owns the tty; loops: greeter → session → greeter |
+| greeter | `cage -s -- cce-display-manager --greeter` | root | the login screen; verifies the user, tells the daemon |
+| fingerprint helper | `cce-display-manager --fprint-auth <user>` | root | one fingerprint attempt, killable (it holds the sensor) |
+| session worker | `cce-display-manager --session-worker …` | root, then the user | opens the PAM/logind session, runs it as the user, closes it |
+
+When the session ends the daemon ends its logind session too — a session's
+leftover processes do not outlive it — and shows the greeter again. A
+compositor restart (`ccectl restart-compositor`) relaunches the same session
+straight away, without the greeter.
+
+## Installing
-`cce-display-manager` is a premium GUI display manager greeter built using the `cce-ui` framework, leveraging Wayland via `smithay-client-toolkit` and GPU-accelerated graphics via `wgpu`.
-
-It integrates seamlessly with the rest of the **Clear OS** desktop ecosystem, offering a highly customized login greeter interface that transitions directly into the `clear-computing-environment-client` (River WM) or standard fallback sessions.
-
-## Features
-
-- **Premium Design Aesthetics**: Fully hardware-accelerated dark theme matching the design system of the Clear desktop environment.
-- **Session Selector**: Interactive session cyclist allowing selection between the Wayland-based River window manager and a fallback Bash login shell.
-- **Obfuscated Password Fields**: Dedicated custom password widget wrapper around `cce-ui` text inputs.
-- **Focus Cycle Navigation**: Easily navigate fields using standard `Tab` focus switching keys.
-- **Seamless Launch Integration**: Authenticates credentials and starts the session via `/home/lsgalante/Dropbox/Clear/clear-computing-environment-client/start-river.sh`.
-
-## Architecture
+```bash
+make install
+```
-- **Wayland Protocol Handling**: Managed using `smithay-client-toolkit` and `calloop` event dispatcher loop.
-- **Rendering Engine**: `wgpu` with WGSL custom shaders and `glyphon` text atlas system.
-- **Core GUI**: Designed as a layout grid inside a centered card frame containing:
- - Custom `LoginCard` container box
- - `TextBox` input fields
- - Cyclic `Button` session selector
- - `StatusLabel` validation indicator
+That is two installs, and both matter:
-## Running & Compiling
+- `ccebuild install cce-display-manager` — user-level: the keyring helper
+ scripts into `~/.local/bin` and the Secret Service D-Bus file.
+- `ccebuild install-system` — root: `/usr/bin/cce-display-manager`, the
+ systemd units and the PAM stacks in `/etc/pam.d`. This is what the login
+ runs. It asks for sudo once and backs up everything it replaces as
+ `*.bak-<date>`.
-Build the project locally:
+A new binary takes effect for the **greeter** at the next login, but the
+**daemon** keeps running the code it started with — press F5 at the greeter,
+or reboot.
-```bash
-cargo build --release
-```
+**If a login breaks** (the password is accepted, then no session): Ctrl+Alt+F2
+gives a text login; restore `/usr/bin/cce-display-manager.bak-<date>` over
+`/usr/bin/cce-display-manager` (and any `/etc/pam.d/cce-display-manager*.bak-<date>`
+you changed) and reboot.
-Run in an existing Wayland environment (for testing/development):
+## Logs
-```bash
-cargo run
-```
+- The daemon, greeter, cage and session worker:
+ `journalctl -u cce-display-manager@tty1 -b`. (Under an older unit, or after an
+ F5 restart of a daemon started by one, they go to
+ `/var/log/cce-display-manager-tty1.log` instead.)
+- The session's own output: `/run/user/<uid>/cce-session.log`, with the
+ previous session's as `cce-session.log.old`. The compositor also keeps its
+ own logs in `/run/user/<uid>/cce/`.
## Login keyring
-Login here is by fingerprint, so PAM never sees a password and
+Login here is often by fingerprint, so PAM never sees a password and
`pam_gnome_keyring` cannot unlock anything. The keyring password is instead
-sealed to the machine's TPM and fed to the daemon at startup.
+sealed to the machine's TPM and fed to the daemon at startup — and
+`pam_gnome_keyring` is deliberately **not** in the login stacks: it only
+started a second daemon at every login that could not unlock the keyring and
+raced this one.
- `scripts/cce-gnome-keyring-enroll` — one-time: seals a random password with
**tpm2-tools** into `~/.config/cce/keyring-seal.{pub,priv}` and creates the
@@ -52,6 +94,8 @@ sealed to the machine's TPM and fed to the daemon at startup.
unit at that script and sets `Restart=no`.
- `dbus/org.freedesktop.secrets.service` — routes bus activation to the same
unit, so there is only ever one provider.
+- `scripts/cce-keyring-selftest` — one-command verdict on whether this login's
+ keyring chain worked.
**`ccebuild` does not install drop-ins** — `unit_files()` matches only
`.service/.target/.timer/.socket/.path`. Install this one by hand:
@@ -80,3 +124,9 @@ Three traps, each of which broke a previous attempt:
Clients need `--password-store=gnome-libsecret`: Chromium picks its backend
from `XDG_CURRENT_DESKTOP`, does not recognise `cce`, and silently falls back
to plaintext even when the keyring is healthy.
+
+## Configuration
+
+`/etc/cce/cce.json` — `{"scale": 2.0}` scales the greeter for a HiDPI panel
+(cage reports a scale-1 output). The repo's `cce.json` is the one this machine
+uses.