# 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)