160 lines
10 KiB
Markdown
160 lines
10 KiB
Markdown
# 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`.
|
||
|
||
```sh
|
||
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 add`ed 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_list`s, one per
|
||
compositor global.
|
||
|
||
**Control flow** — `main()` 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 machine** — `enum att_mode` drives both input handling and what `prompt_text()`/`render_output()`
|
||
draw: `MODE_UNLOCK` ⇄ `MODE_PASSWORD` (any printable keypress enters password mode, Esc returns), and
|
||
`MODE_SETUP_FIRST` → `MODE_SETUP_CONFIRM` on first run or `--setup`. Setup modes are pattern-only.
|
||
|
||
**Failed-attempt throttling** — `note_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.
|