From 3e5e6c6fa8ab362272bb13ea9985f5eedec0f525 Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sun, 2 Aug 2026 22:00:22 +0200 Subject: [PATCH] add support for ppm images --- CLAUDE.md | 31 ++++-- meson.build | 1 + src/att_lock.h | 5 + src/image.c | 288 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.c | 24 ++--- 5 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 src/image.c diff --git a/CLAUDE.md b/CLAUDE.md index d6796ee..40a929f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project `att_lock` — an Android-style 4×4 dot-pattern screen locker for wlroots-based -Wayland compositors. Single C11 binary, ~1500 LOC across four `src/*.c` files. No test suite exists. +Wayland compositors. Single C11 binary, ~2000 LOC across five `src/*.c` files. No test suite exists. ## Build @@ -21,10 +21,11 @@ nix develop --command ninja -C build # incremental rebu Add new dependencies to `nativeDeps`/`libDeps` in `flake.nix` *and* to `meson.build`, never to a global environment. -**Gotcha:** this repo is not git-tracked, so a plain `src = ./.` would vacuum the local `build/` dir -into the Nix sandbox and break `nix build` (meson chokes on a stale pre-configured build dir). -`flake.nix` filters `build` and `result*` out via `lib.cleanSourceWith` — keep that filter (or init -a git repo) whenever touching `flake.nix`. +**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 @@ -129,8 +130,24 @@ Three things about the stored hash are deliberate and load-bearing: 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. Background -images are PNG-only (Cairo's built-in loader). +`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 diff --git a/meson.build b/meson.build index 6b69989..5ae5fbe 100644 --- a/meson.build +++ b/meson.build @@ -37,6 +37,7 @@ sources = files( 'src/main.c', 'src/pattern.c', 'src/render.c', + 'src/image.c', 'src/auth.c', ) diff --git a/src/att_lock.h b/src/att_lock.h index dbecb80..61bd444 100644 --- a/src/att_lock.h +++ b/src/att_lock.h @@ -152,6 +152,11 @@ int pattern_to_string(const struct att_pattern *p, char *buf, size_t n); void render_output(struct att_output *o); void render_all(struct att *app); +/* image.c ------------------------------------------------------------------ */ +/* PNG or PPM (P3/P6), by path or "-" for stdin. Returns a cairo_surface_t*, + * or NULL after reporting why -- a missing background is never fatal. */ +void *image_load(const char *path); + /* auth.c ------------------------------------------------------------------- */ enum auth_hash_state { AUTH_HASH_NONE, /* no stored pattern yet */ diff --git a/src/image.c b/src/image.c new file mode 100644 index 0000000..59c017d --- /dev/null +++ b/src/image.c @@ -0,0 +1,288 @@ +#include "att_lock.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Background image loading. + * + * The image is decoded once at startup, before the lock surface exists, so a + * malformed or truncated file costs a diagnostic and a plain dark background -- + * never a locked session with nothing drawn on it. Every failure path here + * returns NULL and lets main() carry on locking. + * + * Cairo only knows how to read PNG, and only from a file or a stream, so both + * formats are sniffed from a fully buffered copy of the input. That is what + * makes `-i -` work: a pipe cannot be reopened or seeked, so it has to be read + * to the end before anything can decide what it is. */ + +/* An 8K RGB16 PPM is ~200 MB; past this we are being handed garbage. */ +#define IMAGE_MAX_BYTES (512u * 1024 * 1024) +/* Bounds w*h*4 well inside size_t and keeps a bogus header from asking Cairo + * for an absurd allocation. */ +#define IMAGE_MAX_DIM 32767 + +struct buf { + const unsigned char *p; + size_t n; + size_t pos; +}; + +/* --- input ---------------------------------------------------------------- */ + +/* Read fd to EOF. Returns a malloc'd buffer, or NULL (with *len untouched). */ +static unsigned char *read_all(int fd, size_t *len) +{ + size_t cap = 1 << 16, n = 0; + unsigned char *b = malloc(cap); + if (!b) + return NULL; + + for (;;) { + if (n == cap) { + if (cap >= IMAGE_MAX_BYTES) { + fprintf(stderr, "att_lock: image exceeds %u MiB\n", + IMAGE_MAX_BYTES >> 20); + free(b); + return NULL; + } + size_t ncap = cap * 2; + unsigned char *nb = realloc(b, ncap); + if (!nb) { + free(b); + return NULL; + } + b = nb; + cap = ncap; + } + ssize_t r = read(fd, b + n, cap - n); + if (r < 0) { + if (errno == EINTR) + continue; + free(b); + return NULL; + } + if (r == 0) + break; + n += (size_t)r; + } + + *len = n; + return b; +} + +/* --- PPM ------------------------------------------------------------------ */ + +static int ppm_byte(struct buf *b) +{ + return b->pos < b->n ? b->p[b->pos++] : -1; +} + +/* Read one header integer, skipping whitespace and #-comments. + * + * Consumes exactly one byte after the digits. That is deliberate: PPM specifies + * a single whitespace character between the maxval and the start of binary + * raster data, so leaving the position here puts it on the first sample. */ +static bool ppm_uint(struct buf *b, unsigned long *out) +{ + int c; + + for (;;) { + c = ppm_byte(b); + if (c < 0) + return false; + if (c == '#') { + while ((c = ppm_byte(b)) >= 0 && c != '\n' && c != '\r') + ; + if (c < 0) + return false; + continue; + } + if (!isspace(c)) + break; + } + if (!isdigit(c)) + return false; + + unsigned long v = 0; + do { + if (v > (ULONG_MAX - 9) / 10) + return false; + v = v * 10 + (unsigned long)(c - '0'); + c = ppm_byte(b); + } while (c >= 0 && isdigit(c)); + + *out = v; + return true; +} + +static inline uint32_t scale_sample(unsigned long v, unsigned long maxval) +{ + if (v > maxval) + v = maxval; + return maxval == 255 ? (uint32_t)v + : (uint32_t)((v * 255 + maxval / 2) / maxval); +} + +/* Decode a P3 (ASCII) or P6 (binary) PPM. `b` is positioned after the magic. */ +static cairo_surface_t *ppm_decode(struct buf *b, bool ascii) +{ + unsigned long w, h, maxval; + + if (!ppm_uint(b, &w) || !ppm_uint(b, &h) || !ppm_uint(b, &maxval)) { + fprintf(stderr, "att_lock: malformed PPM header\n"); + return NULL; + } + if (w == 0 || h == 0 || w > IMAGE_MAX_DIM || h > IMAGE_MAX_DIM) { + fprintf(stderr, "att_lock: unsupported PPM size %lux%lu\n", w, h); + return NULL; + } + if (maxval == 0 || maxval > 65535) { + fprintf(stderr, "att_lock: unsupported PPM maxval %lu\n", maxval); + return NULL; + } + + int wide = maxval > 255; /* two bytes per sample */ + if (!ascii) { + size_t need = (size_t)w * h * 3 * (wide ? 2 : 1); + if (b->n - b->pos < need) { + fprintf(stderr, "att_lock: truncated PPM raster\n"); + return NULL; + } + } + + cairo_surface_t *surf = cairo_image_surface_create( + CAIRO_FORMAT_RGB24, (int)w, (int)h); + if (cairo_surface_status(surf) != CAIRO_STATUS_SUCCESS) { + fprintf(stderr, "att_lock: cannot allocate %lux%lu image: %s\n", + w, h, cairo_status_to_string(cairo_surface_status(surf))); + cairo_surface_destroy(surf); + return NULL; + } + + unsigned char *data = cairo_image_surface_get_data(surf); + int stride = cairo_image_surface_get_stride(surf); + + for (unsigned long y = 0; y < h; y++) { + /* RGB24 is a 32-bit 0x00RRGGBB word in native byte order, and + * Cairo guarantees the stride keeps rows 32-bit aligned. */ + uint32_t *row = (uint32_t *)(void *)(data + (size_t)y * (size_t)stride); + + for (unsigned long x = 0; x < w; x++) { + unsigned long s[3]; + + if (ascii) { + if (!ppm_uint(b, &s[0]) || !ppm_uint(b, &s[1]) || + !ppm_uint(b, &s[2])) { + fprintf(stderr, "att_lock: truncated PPM raster\n"); + cairo_surface_destroy(surf); + return NULL; + } + } else if (wide) { + const unsigned char *q = b->p + b->pos; + s[0] = ((unsigned long)q[0] << 8) | q[1]; + s[1] = ((unsigned long)q[2] << 8) | q[3]; + s[2] = ((unsigned long)q[4] << 8) | q[5]; + b->pos += 6; + } else { + const unsigned char *q = b->p + b->pos; + s[0] = q[0]; + s[1] = q[1]; + s[2] = q[2]; + b->pos += 3; + } + + row[x] = scale_sample(s[0], maxval) << 16 | + scale_sample(s[1], maxval) << 8 | + scale_sample(s[2], maxval); + } + } + + cairo_surface_mark_dirty(surf); + return surf; +} + +/* --- PNG ------------------------------------------------------------------ */ + +static cairo_status_t png_read(void *closure, unsigned char *data, + unsigned int length) +{ + struct buf *b = closure; + if (length > b->n - b->pos) + return CAIRO_STATUS_READ_ERROR; + memcpy(data, b->p + b->pos, length); + b->pos += length; + return CAIRO_STATUS_SUCCESS; +} + +static cairo_surface_t *png_decode(struct buf *b) +{ + cairo_surface_t *surf = + cairo_image_surface_create_from_png_stream(png_read, b); + if (cairo_surface_status(surf) == CAIRO_STATUS_SUCCESS) + return surf; + + fprintf(stderr, "att_lock: could not decode PNG: %s\n", + cairo_status_to_string(cairo_surface_status(surf))); + cairo_surface_destroy(surf); + return NULL; +} + +/* --- entry point ---------------------------------------------------------- */ + +void *image_load(const char *path) +{ + bool from_stdin = strcmp(path, "-") == 0; + int fd; + + if (from_stdin) { + /* Without this, `att_lock -i -` typed at a shell would block on + * the terminal forever -- with the session not yet locked. */ + if (isatty(STDIN_FILENO)) { + fprintf(stderr, "att_lock: -i - expects an image on stdin, " + "but stdin is a terminal\n"); + return NULL; + } + fd = STDIN_FILENO; + } else { + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + fprintf(stderr, "att_lock: cannot open image '%s': %s\n", + path, strerror(errno)); + return NULL; + } + } + + size_t len = 0; + unsigned char *bytes = read_all(fd, &len); + if (!from_stdin) + close(fd); + if (!bytes) { + fprintf(stderr, "att_lock: cannot read image '%s': %s\n", + path, strerror(errno)); + return NULL; + } + + struct buf b = { .p = bytes, .n = len, .pos = 0 }; + cairo_surface_t *surf = NULL; + + if (len >= 8 && memcmp(bytes, "\x89PNG\r\n\x1a\n", 8) == 0) { + surf = png_decode(&b); + } else if (len >= 2 && bytes[0] == 'P' && + (bytes[1] == '3' || bytes[1] == '6')) { + b.pos = 2; + surf = ppm_decode(&b, bytes[1] == '3'); + } else { + fprintf(stderr, "att_lock: '%s' is not a PNG or PPM image\n", path); + } + + free(bytes); + return surf; +} diff --git a/src/main.c b/src/main.c index 982c699..7dfbd47 100644 --- a/src/main.c +++ b/src/main.c @@ -830,7 +830,9 @@ static void usage(const char *argv0) "Android-style 4x4 pattern screen locker for wlroots compositors.\n" "\n" "Options:\n" -" -i, --image PATH Background image (PNG), also accepted positionally\n" +" -i, --image PATH Background image (PNG or PPM), also accepted\n" +" positionally; \"-\" reads it from stdin, e.g.\n" +" image_gen | %s -i -\n" " -p, --pam-service NAME PAM service for the password fallback (default: login)\n" " -m, --min-dots N Minimum dots in a pattern (default: 4)\n" " --setup Force (re)creation of the stored pattern\n" @@ -839,7 +841,7 @@ static void usage(const char *argv0) "On first run (or with --setup) you draw the pattern twice to store it as a\n" "salted hash under $XDG_DATA_HOME/att_lock/pattern.hash. Start typing at the\n" "unlock screen to fall back to PAM password authentication.\n", - argv0); + argv0, argv0); } int main(int argc, char **argv) @@ -890,18 +892,12 @@ int main(int argc, char **argv) app.mode = (force_setup || hstate != AUTH_HASH_OK) ? MODE_SETUP_FIRST : MODE_UNLOCK; - if (app.image_path) { - cairo_surface_t *bg = - cairo_image_surface_create_from_png(app.image_path); - if (cairo_surface_status(bg) == CAIRO_STATUS_SUCCESS) { - app.bg_surface = bg; - } else { - fprintf(stderr, "att_lock: could not load image '%s' " - "(PNG only): %s\n", app.image_path, - cairo_status_to_string(cairo_surface_status(bg))); - cairo_surface_destroy(bg); - } - } + /* Before connecting to the display on purpose: with `-i -` this drains the + * pipe to EOF, and doing that while already holding the lock would show a + * blank screen for as long as the producer takes. image_load() reports its + * own failures and returns NULL, which just means no background. */ + if (app.image_path) + app.bg_surface = image_load(app.image_path); app.display = wl_display_connect(NULL); if (!app.display) {