From 3cdbc4fec95d8a993176a0fb81de45fc3e8bac9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Tue, 5 May 2026 16:09:25 +0200 Subject: [PATCH] Add design doc for PAM module + cross-platform rewrite Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-05-05-pam-rewrite-design.md | 180 ++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/plans/2026-05-05-pam-rewrite-design.md diff --git a/docs/plans/2026-05-05-pam-rewrite-design.md b/docs/plans/2026-05-05-pam-rewrite-design.md new file mode 100644 index 0000000..f296726 --- /dev/null +++ b/docs/plans/2026-05-05-pam-rewrite-design.md @@ -0,0 +1,180 @@ +# Design: PAM module + cross-platform support + +**Date:** 2026-05-05 +**Status:** Approved + +## Problem + +`ahfail-module.so` is a gtklock-specific plugin. It works only on Wayland via gtklock. The goal is to also support X11 Linux lock screens and macOS, while keeping the existing Wayland/gtklock integration intact. + +## Decision: Approach A — Two integration points, shared core + +Keep the gtklock module for Wayland. Add a PAM module for X11 Linux and macOS. Share all display/audio/update logic through a common `ahfail-ui` crate. + +| Platform | Lock screen | Integration | Display | +|----------|------------|-------------|---------| +| Wayland | gtklock | `ahfail-module.so` (gtklock module, kept) | inline GTK overlay via `ahfail-ui` | +| X11 Linux | any PAM locker (i3lock, xscreensaver…) | `ahfail-pam.so` | `ahfail-display` binary via `ahfail-ui` | +| macOS | loginwindow / screensaver | `ahfail-pam.so` | `ahfail-display` binary via `ahfail-ui` | + +macOS CI is out of scope — users build from source via Homebrew. Linux x86_64 release binaries are produced by Gitea Actions. + +--- + +## Cargo workspace layout + +``` +ahfail/ +├── Cargo.toml ← workspace root +├── meson.build ← updated for new crate layout +├── crates/ +│ ├── ahfail-gtklock/ → ahfail-module.so (existing module, moved) +│ ├── ahfail-pam/ → ahfail-pam.so (new) +│ ├── ahfail-display/ → ahfail-display (new binary) +│ └── ahfail-ui/ → lib (shared display/audio/update logic) +├── assets/ ← unchanged +├── include/ ← unchanged +└── .gitea/workflows/ + ├── test.yml + └── release.yml +``` + +**Dependency graph:** +- `ahfail-gtklock` → `ahfail-ui` +- `ahfail-display` → `ahfail-ui` +- `ahfail-pam` → nothing (no GTK/GStreamer; minimal by design) + +--- + +## Section 1: `ahfail-ui` (shared crate) + +Contains everything that `ahfail-gtklock` and `ahfail-display` share: + +- Animation loading (GResources → `PixbufSimpleAnim`, `set_loop(true)`) +- GTK display: `show_animation_on_overlay(overlay, monitor, deadzone)` — works for both an in-process gtklock overlay and a standalone window +- GStreamer audio playback (player pool, seek-to-0 on end-of-stream loop) +- Deadzone logic (random placement with retry, `--deadzone=x,y,w,h`) +- Update check (see below) +- System volume save/restore (`pactl` on Linux, `osascript` on macOS) + +--- + +## Section 2: PAM module (`ahfail-pam`) + +A minimal C-ABI shared library. No GTK. No GStreamer. No credential handling. + +**Security principle:** we never touch `PAM_AUTHTOK` or any credential. All authentication is performed exclusively by the native host (pam_unix, pam_opendirectory, etc.). We are a pure side-effect observer. + +**`pam_sm_authenticate`:** +1. Register a cleanup function via `pam_set_data(pamh, "ahfail", …, cleanup_fn)` +2. Return `PAM_IGNORE` + +All other `pam_sm_*` functions return `PAM_IGNORE`. + +**Cleanup function fires in two cases:** + +| `error_status` | Meaning | Action | +|---------------|---------|--------| +| `PAM_DATA_REPLACE` set | `pam_sm_authenticate` called again (previous attempt failed, same handle) | spawn `ahfail-display` | +| `PAM_AUTH_ERR` (no replace) | final failure from `pam_end()` | spawn `ahfail-display` | +| `PAM_SUCCESS` (no replace) | auth succeeded from `pam_end()` | `pkill -SIGTERM ahfail-display`; restore volume via state file | + +This handles both lock screens that loop `pam_authenticate()` on one handle and those that create a fresh PAM transaction per attempt. + +**Finding `ahfail-display`:** compile-time default path (`/usr/lib/ahfail/ahfail-display` on Linux, `/usr/local/lib/ahfail/ahfail-display` on macOS), overridable via PAM argument `display_path=/custom/path`. + +**PAM config lines:** +``` +# Linux — /etc/pam.d/gtklock (or i3lock, xscreensaver, etc.) +auth optional ahfail-pam.so + +# macOS — /etc/pam.d/screensaver +auth optional ahfail-pam.so display_path=/usr/local/lib/ahfail/ahfail-display +``` + +--- + +## Section 3: Display binary (`ahfail-display`) + +Spawned by `ahfail-pam.so` via double-fork (fully detached). Uses `ahfail-ui` for all rendering. + +**Startup sequence:** +1. Initialise GTK + GStreamer +2. Register GResources (same embedded assets as gtklock module) +3. Acquire volume lock: atomically create `$XDG_RUNTIME_DIR/ahfail.lock` + - If created (first instance): save current volume/mute state into the file, set system volume to 100% unmuted + - If already exists: skip volume management +4. Open floating, decoration-free, always-on-top GTK window +5. Call `ahfail-ui::show_animation_on_overlay(...)` — places Nedry at random position respecting `--deadzone` +6. Loop animation (`PixbufSimpleAnim`, `set_loop(true)`) and audio (GStreamer, seek-to-0 on end-of-stream) +7. Concurrently: run update check in a background thread (see below) +8. Block on GTK main loop until `SIGTERM` or 15-minute failsafe timeout + +**On `SIGTERM` (clean exit):** +- If holding volume lock: restore volume/mute state from lock file, delete lock file +- Exit + +**Multiple instances:** each failed attempt spawns one additional `ahfail-display`. Each shows one Nedry sprite independently. Only the first instance (lock file holder) manages volume. On success, `pkill -SIGTERM ahfail-display` terminates all instances simultaneously. + +**Volume control:** +- Linux: `pactl set-sink-mute @DEFAULT_SINK@ 0 && pactl set-sink-volume @DEFAULT_SINK@ 100%` +- macOS: `osascript -e 'set volume output volume 100 without output muted'` +- Restore: reverse using saved state from lock file + +**Update check (background thread):** +- Calls `https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest`, parses `tag_name` +- Rate-limited: skips network call if `~/.cache/ahfail/last_update_check` was written within 24 hours +- If newer version found: fires desktop notification + - Linux: `notify-send "ahfail" "Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases"` + - macOS: `osascript -e 'display notification "..." with title "ahfail"'` +- Fails silently on any error (no network, bad response, parse failure) + +--- + +## Section 4: gtklock module (`ahfail-gtklock`) + +The existing module, moved to `crates/ahfail-gtklock/`. Integration mechanism is unchanged. + +**Changes from current `src/`:** +- Display/audio logic extracted into `ahfail-ui`; module calls `ahfail-ui` functions instead of implementing them inline +- Volume: on first `error_label` signal (first failure), acquire volume lock and set to 100%. On `on_window_destroy`, restore volume. +- Update check: called from `ahfail-ui` on each failure (same shared code path as display binary) +- Multiple Nerdys on multiple failures: unchanged — each `error_label` signal adds a new sprite to the overlay + +--- + +## Section 5: Gitea CI/CD + +**`.gitea/workflows/test.yml`** — on push and pull request: +``` +deps: libgtk-3-dev libgstreamer1.0-dev gstreamer-plugins-base libpam0g-dev +→ cargo test +→ meson setup builddir && meson compile -C builddir +→ meson test -C builddir +``` + +**`.gitea/workflows/release.yml`** — on `v*` tag push: +``` +→ same build with --buildtype=release +→ bundle into ahfail-linux-x86_64.tar.gz: + ahfail-module.so (gtklock/Wayland) + ahfail-pam.so (PAM/X11+macOS) + ahfail-display (display binary) +→ create Gitea release + upload asset via curl to Gitea API + using GITEA_TOKEN secret +``` + +**macOS:** no CI. README documents: +```bash +brew install gtk+3 gstreamer gst-plugins-good gst-plugins-base +cargo build --release +meson setup builddir && meson compile -C builddir +``` + +--- + +## Out of scope + +- macOS CI / cross-compilation (SDK licensing + native dep complexity) +- Supporting Wayland compositors other than gtklock (protocol blocks external surfaces during lock) +- Auto-installing updates (notification only)