add support for ppm images

This commit is contained in:
2026-08-02 22:00:22 +02:00
parent 9dba86f3ca
commit 3e5e6c6fa8
5 changed files with 328 additions and 21 deletions
+24 -7
View File
@@ -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
+1
View File
@@ -37,6 +37,7 @@ sources = files(
'src/main.c',
'src/pattern.c',
'src/render.c',
'src/image.c',
'src/auth.c',
)
+5
View File
@@ -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 */
+288
View File
@@ -0,0 +1,288 @@
#include "att_lock.h"
#include <cairo.h>
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* 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;
}
+10 -14
View File
@@ -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) {