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