Files
att_lock/CLAUDE.md
T
2026-08-02 22:00:22 +02:00

10 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

att_lock — an Android-style 4×4 dot-pattern screen locker for wlroots-based Wayland compositors. Single C11 binary, ~2000 LOC across five src/*.c files. No test suite exists.

Build

Nix flakes are the build system; the host is NixOS and has no system Wayland/cairo/pam/xkbcommon dev libraries outside the flake, so meson/ninja must always run inside nix develop.

nix build                                                     # -> ./result/bin/att_lock
nix develop --command bash -c 'meson setup build && ninja -C build'   # -> ./build/att_lock
nix develop --command ninja -C build                          # incremental rebuild

Add new dependencies to nativeDeps/libDeps in flake.nix and to meson.build, never to a global environment.

Gotcha: the repo is a git repo now, so nix build only sees git-tracked files — a new src/*.c has to be git added before it will build, or meson fails with File src/... does not exist while ninja -C build keeps working fine. flake.nix additionally filters build and result* out via lib.cleanSourceWith; that filter predates the git repo (it kept a plain src = ./. from vacuuming a stale pre-configured build/ into the sandbox) and is harmless to keep.

Running it

att_lock takes over the screen via ext-session-lock-v1. Launching it in the compositor you are working in will lock that session — the protocol guarantees the session stays locked if the process dies. Test in a nested compositor (e.g. sway inside a window) rather than the live session. --setup re-records the pattern; delete $XDG_DATA_HOME/att_lock/pattern.hash to reset state.

Architecture

The Wayland protocol bindings are generated at build time by wayland-scanner from the vendored protocols/ext-session-lock-v1.xml into ext-session-lock-v1-client-protocol.h / -protocol.c — they live only in the build dir, so grepping the source for ext_session_lock_v1_* symbols finds call sites but not definitions.

One struct att (in src/att_lock.h) is the whole application state and is passed to essentially every listener; struct att_output and struct att_seat hang off it in wl_lists, one per compositor global.

Control flowmain() binds globals, calls ext_session_lock_manager_v1_lock(), then creates a lock surface per output. A lock surface only becomes visible once its configure handler commits a buffer, so lock_surface_configure() calls render_output() directly. Input handlers never render inline; they set a dirty flag via schedule_redraw() and the loop renders all outputs once per iteration.

The loop is the prepare_read/poll/read_events idiom rather than wl_display_dispatch(), because it needs a timeout: an active lockout has to wake the process to repaint its countdown, and wl_display_dispatch() would sleep until the next input event with the displayed seconds frozen. The timeout is computed to fire exactly when the displayed second changes, so an idle lockout costs one repaint per second and an idle unlock screen blocks indefinitely. If you touch this loop, every path must pair wl_display_prepare_read() with exactly one of wl_display_read_events() or wl_display_cancel_read() — leaking a prepared read deadlocks the locker, which means a session nobody can unlock.

Mode machineenum att_mode drives both input handling and what prompt_text()/render_output() draw: MODE_UNLOCKMODE_PASSWORD (any printable keypress enters password mode, Esc returns), and MODE_SETUP_FIRSTMODE_SETUP_CONFIRM on first run or --setup. Setup modes are pattern-only.

Failed-attempt throttlingnote_failure() / locked_out() in main.c. Both begin_stroke() and submit_password() are gated, deliberately sharing one counter so that switching to the password field cannot sidestep an active pattern lockout; setup modes are not counted (there is no secret to guess yet), and strokes shorter than min_dots are rejected before auth_verify() and so are not failures either. Deadlines use CLOCK_MONOTONIC (att_now_ms()) precisely because it ignores wall-clock changes and does not advance across suspend — a lockout must not be sleepable-off.

Coordinates — everything in struct grid and struct att_pattern is in logical (surface-local) pixels. render_output() allocates a device-pixel buffer of width * scale and applies cairo_scale(scale) so all drawing code stays logical. The grid is a centred square at 70% of the shorter output dimension (att_grid()), which is what makes rotation and arbitrary aspect ratios work with no special cases.

Output resize / rotation — the surface size comes from configure and only from configure: ext-session-lock makes a buffer that does not match the acked size a dimensions_mismatch error, which kills the client and leaves the session locked with nothing drawing on it. So the locker can never resize itself on its own initiative.

That matters because not every compositor reconfigures. River 0.4.5 configures a lock surface once, when it is created, and never again (LockSurface.create() is the only configure() call site, and nothing in Output.zig re-issues one), so rotating or re-moding an output used to leave the locker painting at the old size, clipped to the overlap. att_lock therefore watches wl_output (mode/geometry/scale), and if the output changed shape but no configure arrives within RESIZE_GRACE_MS, recreate_lock_surface() destroys the lock surface and makes a new one for the same output, which forces a fresh configure at the current size. The lock object is untouched, so the session stays locked across the swap.

Two guards keep this from misfiring, and both matter:

  • The change detector compares wl_output values against a snapshot of wl_output values taken at the last configure (cfg_*), never against the configured surface size. On a fractionally scaled output the integer wl_output.scale cannot reproduce the logical size, so those two would never match and every idle moment would look like a pending resize.
  • retry_* records the geometry a recreate was already attempted for, so a compositor that ignores the recreate is nagged once instead of being put in a destroy/create loop.

A compositor that does send a configure never reaches any of this: by the time the grace period expires the snapshot matches again and outputs_recheck_geometry() does nothing.

Pattern model (src/pattern.c) — a sequence of dot indices (row * 4 + col). pattern_add() implements the Android rule that unvisited dots lying exactly on the straight line between the previous and new dot get captured first, using the gcd of the row/col delta. pattern_to_string() produces the canonical "0-1-2-5" form, which feeds the hash input, so any change to indexing or serialisation invalidates every stored pattern.

Auth (src/auth.c) — the pattern is verified against a salted crypt_r hash (yescrypt $y$, falling back to sha512crypt $6$) at $XDG_DATA_HOME/att_lock/pattern.hash, mode 0600, dir 0700. PAM is used only for the password fallback (auth_pam(), service from --pam-service, default login). Comparison uses the constant-time ct_streq(), and secrets (password buffer, crypt_data, hash input, first setup entry) are wiped with explicit_bzero() — preserve both when editing.

Three things about the stored hash are deliberate and load-bearing:

  • The hash input is att_lock:v3:<user>:<pattern>, not the bare pattern (auth_input()). The pattern space is tiny — only 43,680 four-dot sequences — so binding the hash to the account stops an attacker amortising one cracking run across users or machines.
  • Cost is set explicitly, ATT_YESCRYPT_COST 9 / ATT_SHA512_ROUNDS 600000, not the 0 "low default". Cost 9 measures ~0.22 s and ~258 MB here; 11 would be ~0.88 s and ~1 GB, and an allocation that large in the unlock path can fail on a memory-pressured machine — a user who cannot unlock is worse than a marginally cheaper hash. Re-measure before changing it.
  • The file is versioned: line 1 is ATT_HASH_MAGIC, line 2 the crypt string. Any change to the hash input must bump that tag, otherwise stored patterns silently stop verifying and the user is stuck at "Wrong pattern" forever. auth_hash_state() returns AUTH_HASH_STALE for an untagged or corrupt file and main() routes that into the setup flow.

Rendering (src/render.c) — wl_shm + Cairo only, no EGL/GPU. Two buffers per output with a busy flag driven by wl_buffer.release; buffer_get() reallocates on size change. The background is drawn cover-fit (fmax of the two axis ratios), so it follows a rotation with no extra work.

Background images (src/image.c) — image_load() takes PNG or PPM (both P6 binary and P3 ASCII, 8- or 16-bit samples), by path or - for stdin: image_gen | att_lock -i -. Cairo only decodes PNG and only from a file or stream, so the input is always buffered whole and then sniffed by magic — which is also what lets a pipe work, since it can be neither reopened nor seeked. PPM decodes straight into CAIRO_FORMAT_RGB24, a native-endian 0x00RRGGBB word per pixel (write through a uint32_t*, don't assemble bytes by hand, or the channels swap on the way out).

Two things here are deliberate:

  • Every failure returns NULL and is non-fatal. A bad image must degrade to the plain dark background, never to a locked session with nothing drawn on it. Decoding happens in main() before wl_display_connect(), which also means -i - drains the pipe before taking the lock rather than holding a blank screen for as long as the producer runs.
  • -i - refuses an interactive stdin (isatty), because att_lock -i - typed at a shell would otherwise block on the terminal forever with the session not yet locked.

Conventions

  • Linux kernel style: tabs, snake_case, opening brace on its own line for functions.
  • Wayland listener structs are exhaustive — unused events get (void)-cast no-op stubs rather than NULL entries.
  • Failure while locked must never unlock: lock_finished() sets running = false without calling unlock_and_destroy(), and main() exits non-zero when app.lock still exists.
  • Built with warning_level=2 and -D_GNU_SOURCE; keep the build warning-free.