From 9c546c69ee6c4797e97a5a7f3cc9e18ebc643758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Tue, 5 May 2026 16:17:05 +0200 Subject: [PATCH] Add implementation plan for PAM rewrite Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-05-05-pam-rewrite-impl.md | 1521 +++++++++++++++++++++ 1 file changed, 1521 insertions(+) create mode 100644 docs/plans/2026-05-05-pam-rewrite-impl.md diff --git a/docs/plans/2026-05-05-pam-rewrite-impl.md b/docs/plans/2026-05-05-pam-rewrite-impl.md new file mode 100644 index 0000000..c96f2fa --- /dev/null +++ b/docs/plans/2026-05-05-pam-rewrite-impl.md @@ -0,0 +1,1521 @@ +# PAM Rewrite Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a PAM module and standalone display binary so ahfail works on macOS and X11, while keeping the existing gtklock/Wayland module intact. + +**Architecture:** Four-crate Cargo workspace — `ahfail-ui` (shared GTK/audio/volume/update logic), `ahfail-gtklock` (existing module, moved), `ahfail-pam` (minimal PAM observer, no GTK), `ahfail-display` (standalone binary spawned by PAM module). Meson continues to own linking and GResources compilation. + +**Tech Stack:** Rust, GTK3 (gtk-rs 0.15), GStreamer (gstreamer-player 0.18), libpam (raw FFI), ureq 2 (HTTP), Meson/Ninja, Gitea Actions. + +--- + +### Task 1: Convert to Cargo workspace + +**Files:** +- Replace: `Cargo.toml` +- Create: `crates/ahfail-gtklock/Cargo.toml` +- Create: `crates/ahfail-gtklock/src/` (copy of existing `src/`) +- Create: `crates/ahfail-gtklock/tests/` (copy of existing `tests/`) + +**Step 1: Create the workspace root Cargo.toml** + +Replace the entire `Cargo.toml` with: + +```toml +[workspace] +members = [ + "crates/ahfail-ui", + "crates/ahfail-gtklock", + "crates/ahfail-pam", + "crates/ahfail-display", +] +resolver = "2" +``` + +**Step 2: Create the gtklock crate** + +```bash +mkdir -p crates/ahfail-gtklock/src +cp -r src/* crates/ahfail-gtklock/src/ +mkdir -p crates/ahfail-gtklock/tests +cp tests/ahfail_tests.rs crates/ahfail-gtklock/tests/ +cp tests/benchmarks.rs crates/ahfail-gtklock/tests/ +``` + +Create `crates/ahfail-gtklock/Cargo.toml`: + +```toml +[package] +name = "ahfail-gtklock" +version = "0.1.0" +edition = "2021" + +[lib] +name = "ahfail_module" +crate-type = ["staticlib", "rlib"] + +[dependencies] +gtk = { version = "0.15", package = "gtk", features = ["v3_24"] } +gdk = { version = "0.15", package = "gdk", features = ["v3_24"] } +gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] } +gstreamer-player = { version = "0.18", package = "gstreamer-player" } +glib = { version = "0.15", package = "glib" } +gio = { version = "0.15", package = "gio" } +gdk-pixbuf = "0.15" +libc = "0.2" +rand = "0.8" + +[build-dependencies] +pkg-config = "0.3" +``` + +**Step 3: Create stub crates so the workspace resolves** + +```bash +mkdir -p crates/ahfail-ui/src +echo 'pub fn placeholder() {}' > crates/ahfail-ui/src/lib.rs +mkdir -p crates/ahfail-pam/src +echo 'pub fn placeholder() {}' > crates/ahfail-pam/src/lib.rs +mkdir -p crates/ahfail-display/src +echo 'fn main() {}' > crates/ahfail-display/src/main.rs +``` + +Create minimal Cargo.toml for each (edition = "2021", no deps, name matching directory). + +**Step 4: Verify workspace builds** + +```bash +cargo build -p ahfail-gtklock +``` + +Expected: compiles successfully. Existing tests still pass: + +```bash +cargo test -p ahfail-gtklock +``` + +Expected: `run_all_tests_sequentially ... ok` + +**Step 5: Update meson.build to point at new crate path** + +In `meson.build`, change `cargo build` command to: +``` +'cargo build --release -p ahfail-gtklock --target-dir "@OUTDIR@/target"' +``` + +Output lib name stays `libahfail_module.a`. + +**Step 6: Verify meson still builds** + +```bash +meson setup builddir --wipe +meson compile -C builddir +``` + +Expected: `ahfail-module.so` produced in `builddir/`. + +**Step 7: Commit** + +```bash +git add -A +git commit -m "refactor: convert to Cargo workspace, move gtklock crate" +``` + +--- + +### Task 2: Create `ahfail-ui` — animation and audio + +**Files:** +- Create: `crates/ahfail-ui/Cargo.toml` +- Create: `crates/ahfail-ui/src/lib.rs` +- Create: `crates/ahfail-ui/src/animation.rs` +- Create: `crates/ahfail-ui/src/audio.rs` +- Create: `crates/ahfail-ui/src/config.rs` +- Create: `crates/ahfail-ui/src/display.rs` + +**Step 1: Write `crates/ahfail-ui/Cargo.toml`** + +```toml +[package] +name = "ahfail-ui" +version = "0.1.0" +edition = "2021" + +[lib] +name = "ahfail_ui" +crate-type = ["rlib"] + +[dependencies] +gtk = { version = "0.15", package = "gtk", features = ["v3_24"] } +gdk = { version = "0.15", package = "gdk", features = ["v3_24"] } +gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] } +gstreamer-player = { version = "0.18", package = "gstreamer-player" } +glib = { version = "0.15", package = "glib" } +gio = { version = "0.15", package = "gio" } +gdk-pixbuf = "0.15" +rand = "0.8" +ureq = "2" +``` + +**Step 2: Write `crates/ahfail-ui/src/config.rs`** + +Move `ModuleConfig` and deadzone constants verbatim from `crates/ahfail-gtklock/src/config.rs`. Exports: `ModuleConfig`, `DEADZONE_ARG`, `DEADZONE_LONG`, `DEADZONE_DESC`, `DEADZONE_ARG_DESC`. + +**Step 3: Write `crates/ahfail-ui/src/animation.rs`** + +```rust +use gtk::{gdk_pixbuf, gio}; +use gdk_pixbuf::InterpType; + +const SPRITE_SCALE: f64 = 0.6; + +extern "C" { + pub fn ahfail_get_resource() -> *mut gio::ffi::GResource; +} + +/// Registers GResources and loads all sprite frames into a looping PixbufSimpleAnim. +/// Returns None if GResources unavailable or no frames found. +pub unsafe fn load_animation() -> Option { + use glib::translate::from_glib_none; + + let resource_ptr = ahfail_get_resource(); + if resource_ptr.is_null() { return None; } + let resource = from_glib_none::<_, gio::Resource>(resource_ptr); + gio::resources_register(&resource); + + let mut frames = gio::resources_enumerate_children( + "/ahfail/sprites", gio::ResourceLookupFlags::NONE, + ).ok()?; + frames.sort(); + + let mut loaded: Vec = Vec::new(); + for name in frames { + let path = format!("/ahfail/sprites/{}", name); + if let Ok(pb) = gdk_pixbuf::Pixbuf::from_resource(&path) { + let w = (pb.width() as f64 * SPRITE_SCALE) as i32; + let h = (pb.height() as f64 * SPRITE_SCALE) as i32; + let scaled = pb.scale_simple(w, h, InterpType::Bilinear).unwrap_or(pb); + loaded.push(scaled); + } + } + if loaded.is_empty() { return None; } + + let first = &loaded[0]; + let anim = gdk_pixbuf::PixbufSimpleAnim::new(first.width(), first.height(), 12.0); + anim.set_loop(true); + for frame in loaded { anim.add_frame(&frame); } + Some(anim.upcast()) +} +``` + +**Step 4: Write `crates/ahfail-ui/src/audio.rs`** + +```rust +use gstreamer_player as gst_player; +use gtk::glib; + +pub fn create_player(uri: &str) -> gst_player::Player { + let player = gst_player::Player::new(None, None); + player.set_uri(Some(uri)); + player.connect_end_of_stream(glib::clone!(@weak player => move |_| { + player.seek(gstreamer::ClockTime::from_seconds(0)); + })); + player.connect_error(|_, err| eprintln!("[ahfail] GStreamer error: {}", err)); + player +} +``` + +**Step 5: Write `crates/ahfail-ui/src/display.rs`** + +```rust +use gtk::prelude::*; +use gtk::{gdk, gdk_pixbuf}; +use rand::Rng; +use crate::config::ModuleConfig; + +const SPRITE_MARGIN: i32 = 100; +const RETRY_ATTEMPTS: usize = 10; + +/// Places a new animated sprite on `overlay` at a random position respecting +/// the deadzone in `config`. Returns the created Image widget. +pub fn place_sprite( + overlay: >k::Overlay, + fixed: >k::Fixed, + animation: &gdk_pixbuf::PixbufAnimation, + screen_w: i32, + screen_h: i32, + config: &ModuleConfig, +) -> gtk::Image { + let image = gtk::Image::from_animation(animation); + image.show(); + + let sprite_w = animation.width(); + let sprite_h = animation.height(); + let safe_w = screen_w - SPRITE_MARGIN; + let safe_h = screen_h - SPRITE_MARGIN; + let max_x = (safe_w - sprite_w).max(0); + let max_y = (safe_h - sprite_h).max(0); + + let mut rng = rand::thread_rng(); + let mut x = 0; + let mut y = 0; + + for _ in 0..RETRY_ATTEMPTS { + x = rng.gen_range(0..=max_x); + y = rng.gen_range(0..=max_y); + if let Some(dz) = &config.deadzone { + let r = gdk::Rectangle::new(x, y, sprite_w, sprite_h); + if dz.intersect(&r).is_none() { break; } + } else { + break; + } + } + + fixed.put(&image, x, y); + overlay.add_overlay(fixed); + overlay.set_overlay_pass_through(fixed, true); + image +} +``` + +**Step 6: Write `crates/ahfail-ui/src/lib.rs`** + +```rust +pub mod animation; +pub mod audio; +pub mod config; +pub mod display; +pub mod update; // stub for now: pub fn check_for_update(_: &str) {} +pub mod volume; // stub for now: pub struct VolumeState; pub fn save_and_set_max() -> VolumeState { VolumeState } pub fn restore(_: VolumeState) {} +``` + +**Step 7: Verify `ahfail-ui` compiles** + +```bash +cargo build -p ahfail-ui +``` + +Expected: compiles (stubs in update/volume are empty). + +**Step 8: Commit** + +```bash +git add crates/ahfail-ui/ +git commit -m "feat: add ahfail-ui crate with animation, audio, display, config" +``` + +--- + +### Task 3: Wire `ahfail-gtklock` to use `ahfail-ui` + +**Files:** +- Modify: `crates/ahfail-gtklock/Cargo.toml` +- Modify: `crates/ahfail-gtklock/src/handler.rs` +- Modify: `crates/ahfail-gtklock/src/lib.rs` +- Modify: `crates/ahfail-gtklock/src/config.rs` (re-export from ahfail-ui) + +**Step 1: Add `ahfail-ui` dependency** + +In `crates/ahfail-gtklock/Cargo.toml`: +```toml +[dependencies] +ahfail-ui = { path = "../ahfail-ui" } +# keep all existing deps +``` + +**Step 2: Replace config.rs with re-export** + +Replace `crates/ahfail-gtklock/src/config.rs` entirely: +```rust +pub use ahfail_ui::config::*; +``` + +**Step 3: Replace animation loading in `lib.rs`** + +In `on_activation`, replace the frame-loading loop (lines 91–126) with: +```rust +let anim_opt = unsafe { ahfail_ui::animation::load_animation() }; +``` + +Remove the `SPRITE_SCALE` constant and the manual frame-loading loop. Remove the `extern "C" { fn ahfail_get_resource() }` block (it now lives in `ahfail-ui::animation`). + +**Step 4: Replace `handler.rs` sprite placement** + +Replace the random-placement + deadzone block inside the signal closure with a call to `ahfail_ui::display::place_sprite(...)`. The `WindowHandler::create` method initialises `fixed` and sets it up before registering the signal; the closure calls `place_sprite` on each error. + +Replace `WindowHandler::create_player` with `ahfail_ui::audio::create_player`. + +**Step 5: Run tests** + +```bash +cargo test -p ahfail-gtklock +``` + +Expected: `run_all_tests_sequentially ... ok` + +**Step 6: Verify meson still builds** + +```bash +meson compile -C builddir +``` + +Expected: `ahfail-module.so` produced. + +**Step 7: Commit** + +```bash +git add crates/ahfail-gtklock/ crates/ahfail-ui/ +git commit -m "refactor: wire ahfail-gtklock to use ahfail-ui for animation/audio/display" +``` + +--- + +### Task 4: Add volume management + +**Files:** +- Create: `crates/ahfail-ui/src/volume.rs` +- Modify: `crates/ahfail-ui/src/lib.rs` +- Modify: `crates/ahfail-gtklock/src/state.rs` +- Modify: `crates/ahfail-gtklock/src/lib.rs` + +**Step 1: Write failing test for volume module** + +Create `crates/ahfail-ui/tests/volume_tests.rs`: + +```rust +use ahfail_ui::volume::{VolumeState, is_newer_volume_lock}; +use std::fs; +use tempfile::tempdir; + +#[test] +fn lock_file_created_and_prevents_second_save() { + let dir = tempdir().unwrap(); + let lock_path = dir.path().join("ahfail.lock"); + + // First acquisition succeeds + let acquired = ahfail_ui::volume::try_acquire_lock(&lock_path); + assert!(acquired); + assert!(lock_path.exists()); + + // Second acquisition fails + let acquired2 = ahfail_ui::volume::try_acquire_lock(&lock_path); + assert!(!acquired2); +} +``` + +Add `tempfile = "3"` to `crates/ahfail-ui/Cargo.toml` under `[dev-dependencies]`. + +**Step 2: Run test to verify it fails** + +```bash +cargo test -p ahfail-ui volume_tests +``` + +Expected: FAIL — `volume` module not defined. + +**Step 3: Implement `crates/ahfail-ui/src/volume.rs`** + +```rust +use std::path::PathBuf; +use std::process::Command; +use std::fs; + +pub struct VolumeState { + pub volume: u32, + pub muted: bool, + pub lock_path: PathBuf, +} + +/// Atomically creates lock file. Returns true if this process is the first (primary). +pub fn try_acquire_lock(path: &std::path::Path) -> bool { + use std::fs::OpenOptions; + use std::io::Write; + OpenOptions::new() + .write(true) + .create_new(true) // fails if exists + .open(path) + .map(|mut f| { let _ = f.write_all(b"1"); true }) + .unwrap_or(false) +} + +/// Returns lock file path: $XDG_RUNTIME_DIR/ahfail.lock or /tmp/ahfail-{uid}.lock +pub fn lock_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(dir).join("ahfail.lock") + } else { + PathBuf::from(format!("/tmp/ahfail-{}.lock", unsafe { libc::getuid() })) + } +} + +/// If primary: save current system volume/mute state, set volume to 100% unmuted. +/// Returns Some(VolumeState) if this process is the primary (should restore on exit). +pub fn save_and_set_max() -> Option { + let path = lock_path(); + if !try_acquire_lock(&path) { return None; } + + let (volume, muted) = get_current_volume(); + set_volume_max(); + Some(VolumeState { volume, muted, lock_path: path }) +} + +/// Restores volume from saved state and removes lock file. +pub fn restore(state: VolumeState) { + restore_volume(state.volume, state.muted); + let _ = fs::remove_file(&state.lock_path); +} + +// --- platform implementations --- + +#[cfg(target_os = "linux")] +fn get_current_volume() -> (u32, bool) { + let vol = Command::new("pactl") + .args(["get-sink-volume", "@DEFAULT_SINK@"]) + .output() + .ok() + .and_then(|o| parse_pactl_volume(&String::from_utf8_lossy(&o.stdout))) + .unwrap_or(100); + + let muted = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).contains("yes")) + .unwrap_or(false); + + (vol, muted) +} + +#[cfg(target_os = "linux")] +fn parse_pactl_volume(output: &str) -> Option { + output.split('%').next() + .and_then(|s| s.split_whitespace().last()) + .and_then(|s| s.parse().ok()) +} + +#[cfg(target_os = "linux")] +fn set_volume_max() { + let _ = Command::new("pactl").args(["set-sink-mute", "@DEFAULT_SINK@", "0"]).status(); + let _ = Command::new("pactl").args(["set-sink-volume", "@DEFAULT_SINK@", "65536"]).status(); +} + +#[cfg(target_os = "linux")] +fn restore_volume(volume: u32, muted: bool) { + let v = ((volume as u64 * 65536) / 100) as u32; + let _ = Command::new("pactl") + .args(["set-sink-volume", "@DEFAULT_SINK@", &v.to_string()]) + .status(); + let mute_arg = if muted { "1" } else { "0" }; + let _ = Command::new("pactl").args(["set-sink-mute", "@DEFAULT_SINK@", mute_arg]).status(); +} + +#[cfg(target_os = "macos")] +fn get_current_volume() -> (u32, bool) { + let output = Command::new("osascript") + .args(["-e", "output volume of (get volume settings)"]) + .output() + .ok(); + let vol = output.as_ref() + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok()) + .unwrap_or(100); + + let muted = Command::new("osascript") + .args(["-e", "output muted of (get volume settings)"]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true") + .unwrap_or(false); + + (vol, muted) +} + +#[cfg(target_os = "macos")] +fn set_volume_max() { + let _ = Command::new("osascript") + .args(["-e", "set volume output volume 100 without output muted"]) + .status(); +} + +#[cfg(target_os = "macos")] +fn restore_volume(volume: u32, muted: bool) { + let script = if muted { + format!("set volume output volume {} with output muted", volume) + } else { + format!("set volume output volume {} without output muted", volume) + }; + let _ = Command::new("osascript").args(["-e", &script]).status(); +} +``` + +Add `libc = "0.2"` to `crates/ahfail-ui/Cargo.toml` dependencies. + +**Step 4: Run test to verify it passes** + +```bash +cargo test -p ahfail-ui volume_tests +``` + +Expected: PASS. + +**Step 5: Add volume to `ahfail-gtklock` MODULE_STATE** + +In `crates/ahfail-gtklock/src/state.rs`, add to `ModuleState`: +```rust +pub volume_state: Option, +``` +Default: `None`. + +**Step 6: Save volume on first failure, restore on unload** + +In `crates/ahfail-gtklock/src/handler.rs`, at the top of the error_label signal closure, before spawning the sprite: +```rust +// Save volume once on first failure across all windows +MODULE_STATE.with(|s| { + let mut st = s.borrow_mut(); + if st.volume_state.is_none() { + st.volume_state = ahfail_ui::volume::save_and_set_max(); + } +}); +``` + +In `crates/ahfail-gtklock/src/lib.rs`, in `g_module_unload`: +```rust +MODULE_STATE.with(|state| { + let mut state = state.borrow_mut(); + if let Some(vs) = state.volume_state.take() { + ahfail_ui::volume::restore(vs); + } + state.animation = None; + state.audio_uri = None; + state.config.deadzone = None; +}); +``` + +**Step 7: Run tests** + +```bash +cargo test -p ahfail-gtklock +``` + +Expected: all pass (volume functions are not exercised in tests since they call system commands). + +**Step 8: Commit** + +```bash +git add crates/ahfail-ui/src/volume.rs crates/ahfail-gtklock/ +git commit -m "feat: add volume save/restore on failure/unload" +``` + +--- + +### Task 5: Add update check + +**Files:** +- Create: `crates/ahfail-ui/src/update.rs` +- Modify: `crates/ahfail-ui/src/lib.rs` +- Modify: `crates/ahfail-gtklock/src/handler.rs` + +**Step 1: Write failing test** + +Create `crates/ahfail-ui/tests/update_tests.rs`: + +```rust +use ahfail_ui::update::is_newer; + +#[test] +fn newer_version_detected() { + assert!(is_newer("v0.2.0", "v0.1.0")); + assert!(!is_newer("v0.1.0", "v0.1.0")); + assert!(!is_newer("v0.1.0", "v0.2.0")); + assert!(!is_newer("garbage", "v0.1.0")); +} + +#[test] +fn strips_v_prefix() { + assert!(is_newer("0.2.0", "0.1.0")); +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cargo test -p ahfail-ui update_tests +``` + +Expected: FAIL — `update` module not defined. + +**Step 3: Implement `crates/ahfail-ui/src/update.rs`** + +```rust +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; +use std::fs; + +const GITEA_API: &str = + "https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest"; +const UPDATE_NOTIFY_MSG: &str = + "Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases"; +const CHECK_INTERVAL_SECS: u64 = 86_400; // 24 hours + +pub fn is_newer(latest: &str, current: &str) -> bool { + let parse = |s: &str| -> Option<(u32, u32, u32)> { + let s = s.trim_start_matches('v'); + let p: Vec<&str> = s.splitn(3, '.').collect(); + if p.len() != 3 { return None; } + Some((p[0].parse().ok()?, p[1].parse().ok()?, p[2].parse().ok()?)) + }; + match (parse(latest), parse(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + +fn cache_file() -> PathBuf { + let dir = std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".cache/ahfail")) + .unwrap_or_else(|_| PathBuf::from("/tmp/ahfail-cache")); + let _ = fs::create_dir_all(&dir); + dir.join("last_update_check") +} + +fn rate_limited() -> bool { + let path = cache_file(); + if let Ok(meta) = fs::metadata(&path) { + if let Ok(modified) = meta.modified() { + let age = SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + return age.as_secs() < CHECK_INTERVAL_SECS; + } + } + false +} + +fn touch_cache() { + let _ = fs::write(cache_file(), b""); +} + +fn send_notification() { + #[cfg(target_os = "linux")] + let _ = Command::new("notify-send") + .args(["ahfail", UPDATE_NOTIFY_MSG]) + .spawn(); + + #[cfg(target_os = "macos")] + let _ = Command::new("osascript") + .args(["-e", &format!( + "display notification \"{}\" with title \"ahfail\"", + UPDATE_NOTIFY_MSG + )]) + .spawn(); +} + +/// Run in a background thread. Checks Gitea for a newer release and sends a +/// desktop notification if one exists. Rate-limited to once per 24 hours. +/// Fails silently on any error. +pub fn check_for_update(current_version: &str) { + if rate_limited() { return; } + touch_cache(); + + let Ok(resp) = ureq::get(GITEA_API).call() else { return }; + let Ok(body) = resp.into_string() else { return }; + + // Parse tag_name from JSON without a full serde dependency + if let Some(tag) = extract_tag_name(&body) { + if is_newer(&tag, current_version) { + send_notification(); + } + } +} + +fn extract_tag_name(json: &str) -> Option { + // Minimal: find `"tag_name":"v0.x.y"` pattern + let key = "\"tag_name\":\""; + let start = json.find(key)? + key.len(); + let end = json[start..].find('"')? + start; + Some(json[start..end].to_string()) +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cargo test -p ahfail-ui update_tests +``` + +Expected: PASS. + +**Step 5: Expose version constant** + +In `crates/ahfail-ui/src/lib.rs` add: +```rust +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +``` + +**Step 6: Call update check from gtklock on failure** + +In `crates/ahfail-gtklock/src/handler.rs`, after spawning the sprite inside the signal closure, add: +```rust +std::thread::spawn(|| { + ahfail_ui::update::check_for_update(ahfail_ui::VERSION); +}); +``` + +**Step 7: Run tests** + +```bash +cargo test -p ahfail-gtklock && cargo test -p ahfail-ui +``` + +Expected: all pass. + +**Step 8: Commit** + +```bash +git add crates/ahfail-ui/src/update.rs crates/ahfail-gtklock/ +git commit -m "feat: add rate-limited update check with desktop notification" +``` + +--- + +### Task 6: Create `ahfail-display` binary + +**Files:** +- Replace: `crates/ahfail-display/Cargo.toml` +- Replace: `crates/ahfail-display/src/main.rs` + +**Step 1: Write `crates/ahfail-display/Cargo.toml`** + +```toml +[package] +name = "ahfail-display" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "ahfail-display" + +[dependencies] +ahfail-ui = { path = "../ahfail-ui" } +gtk = { version = "0.15", package = "gtk", features = ["v3_24"] } +gdk = { version = "0.15", package = "gdk", features = ["v3_24"] } +gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] } +gstreamer-player = { version = "0.18", package = "gstreamer-player" } +glib = { version = "0.15", package = "glib" } +gio = { version = "0.15", package = "gio" } +gdk-pixbuf = "0.15" +rand = "0.8" +libc = "0.2" +``` + +**Step 2: Write `crates/ahfail-display/src/main.rs`** + +```rust +use gtk::prelude::*; +use gtk::{gdk, gdk_pixbuf}; +use gstreamer as gst; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3"; +const PLAYER_POOL_SIZE: usize = 3; +const FAILSAFE_MINUTES: u64 = 15; + +fn main() { + // Install SIGTERM handler before anything else + let should_exit = Arc::new(AtomicBool::new(false)); + { + let flag = should_exit.clone(); + unsafe { + libc::signal(libc::SIGTERM, handle_sigterm as libc::sighandler_t); + } + } + + if gtk::init().is_err() { eprintln!("[ahfail-display] GTK init failed"); return; } + if gst::init().is_err() { eprintln!("[ahfail-display] GStreamer init failed"); return; } + + // Register GResources + let animation = unsafe { ahfail_ui::animation::load_animation() }; + let Some(animation) = animation else { + eprintln!("[ahfail-display] No animation frames found"); + return; + }; + + // Acquire volume lock — first process sets volume, rest skip + let volume_state = ahfail_ui::volume::save_and_set_max(); + + // Get primary monitor geometry + let display = gdk::Display::default().expect("No display"); + let monitor = display.primary_monitor() + .or_else(|| display.monitor(0)) + .expect("No monitor"); + let geom = monitor.geometry(); + let screen_w = geom.width(); + let screen_h = geom.height(); + + // Create floating popup window — no decorations, above all other windows + let window = gtk::Window::new(gtk::WindowType::Popup); + window.set_decorated(false); + window.set_keep_above(true); + window.set_skip_taskbar_hint(true); + window.set_app_paintable(true); + + let fixed = gtk::Fixed::new(); + fixed.set_size_request(screen_w, screen_h); + window.add(&fixed); + + // Parse deadzone from argv: --deadzone=x,y,w,h + let config = parse_args(); + + // Place sprite + let overlay_fake = gtk::Overlay::new(); + let image = ahfail_ui::display::place_sprite( + &overlay_fake, &fixed, &animation, screen_w, screen_h, &config, + ); + // Note: place_sprite adds to fixed directly; overlay_fake unused here + // Simplified: just put image on fixed at random position + let _ = image; // image already added to fixed by place_sprite + + // Create GStreamer player pool + let players: Vec<_> = (0..PLAYER_POOL_SIZE) + .map(|_| ahfail_ui::audio::create_player(AUDIO_URI)) + .collect(); + if let Some(p) = players.first() { p.play(); } + + // Spawn update check thread + std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION)); + + // Failsafe: exit after N minutes + glib::timeout_add_seconds(FAILSAFE_MINUTES as u32 * 60, || { + gtk::main_quit(); + glib::Continue(false) + }); + + // Poll SIGTERM flag via idle + let flag = SIGTERM_RECEIVED.load(Ordering::Relaxed); + glib::timeout_add(std::time::Duration::from_millis(200), move || { + if SIGTERM_RECEIVED.load(Ordering::Relaxed) { + gtk::main_quit(); + return glib::Continue(false); + } + glib::Continue(true) + }); + + window.show_all(); + gtk::main(); + + // Cleanup + for p in &players { p.stop(); } + if let Some(vs) = volume_state { ahfail_ui::volume::restore(vs); } +} + +static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false); + +extern "C" fn handle_sigterm(_: libc::c_int) { + SIGTERM_RECEIVED.store(true, Ordering::Relaxed); +} + +fn parse_args() -> ahfail_ui::config::ModuleConfig { + // Look for --deadzone=x,y,w,h in argv + use std::env; + let deadzone = env::args() + .find(|a| a.starts_with("--deadzone=")) + .and_then(|a| { + let val = a.trim_start_matches("--deadzone="); + let p: Vec<&str> = val.split(',').collect(); + if p.len() != 4 { return None; } + let x: i32 = p[0].parse().ok()?; + let y: i32 = p[1].parse().ok()?; + let w: i32 = p[2].parse().ok()?; + let h: i32 = p[3].parse().ok()?; + Some(gdk::Rectangle::new(x, y, w, h)) + }); + ahfail_ui::config::ModuleConfig { deadzone } +} +``` + +Note: `place_sprite` needs a small refactor to work without the overlay (just put on `fixed` directly). Update `crates/ahfail-ui/src/display.rs` signature to: +```rust +pub fn place_sprite( + fixed: >k::Fixed, + animation: &gdk_pixbuf::PixbufAnimation, + screen_w: i32, screen_h: i32, + config: &ModuleConfig, +) -> gtk::Image +``` +Update the call in `ahfail-gtklock/src/handler.rs` accordingly (remove `overlay` param from `place_sprite`; the overlay add stays in handler.rs). + +**Step 3: Build and verify** + +```bash +cargo build -p ahfail-display +``` + +Expected: compiles. (Cannot run without a display/GResources linked.) + +**Step 4: Commit** + +```bash +git add crates/ahfail-display/ +git commit -m "feat: add ahfail-display standalone binary" +``` + +--- + +### Task 7: Create `ahfail-pam` PAM module + +**Files:** +- Replace: `crates/ahfail-pam/Cargo.toml` +- Replace: `crates/ahfail-pam/src/lib.rs` +- Create: `crates/ahfail-pam/build.rs` +- Create: `crates/ahfail-pam/tests/pam_tests.rs` + +**Step 1: Write `crates/ahfail-pam/Cargo.toml`** + +```toml +[package] +name = "ahfail-pam" +version = "0.1.0" +edition = "2021" + +[lib] +name = "ahfail_pam" +crate-type = ["cdylib"] + +[dependencies] +libc = "0.2" +``` + +**Step 2: Write `crates/ahfail-pam/build.rs`** + +```rust +fn main() { + println!("cargo:rustc-link-lib=pam"); +} +``` + +**Step 3: Write failing test** + +Create `crates/ahfail-pam/tests/pam_tests.rs`: + +```rust +use ahfail_pam::{is_failure, is_success, is_replace}; + +#[test] +fn status_classification() { + assert!(is_failure(ahfail_pam::PAM_AUTH_ERR)); + assert!(is_success(ahfail_pam::PAM_SUCCESS)); + assert!(!is_failure(ahfail_pam::PAM_SUCCESS)); +} + +#[test] +fn replace_flag_detected() { + let replace_status = ahfail_pam::PAM_AUTH_ERR | ahfail_pam::PAM_DATA_REPLACE; + assert!(is_replace(replace_status)); +} + +#[test] +fn display_path_default_is_set() { + assert!(!ahfail_pam::default_display_path().is_empty()); +} +``` + +Change `crates/ahfail-pam/Cargo.toml` lib crate-type to `["cdylib", "rlib"]` for tests. + +**Step 4: Run test to verify it fails** + +```bash +cargo test -p ahfail-pam +``` + +Expected: FAIL — functions not defined. + +**Step 5: Write `crates/ahfail-pam/src/lib.rs`** + +```rust +use libc::{c_char, c_int, c_void, pid_t}; +use std::ffi::CString; + +// --- PAM constants (platform-specific) --- + +pub const PAM_SUCCESS: c_int = 0; +pub const PAM_IGNORE: c_int = 25; + +#[cfg(target_os = "linux")] +pub const PAM_AUTH_ERR: c_int = 7; +#[cfg(target_os = "macos")] +pub const PAM_AUTH_ERR: c_int = 9; + +#[cfg(target_os = "linux")] +pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32; +#[cfg(target_os = "macos")] +pub const PAM_DATA_REPLACE: c_int = 0x00000002; + +// Default install path for ahfail-display +#[cfg(target_os = "linux")] +const DEFAULT_PATH: &str = "/usr/lib/ahfail/ahfail-display"; +#[cfg(target_os = "macos")] +const DEFAULT_PATH: &str = "/usr/local/lib/ahfail/ahfail-display"; + +pub fn default_display_path() -> &'static str { DEFAULT_PATH } +pub fn is_failure(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_AUTH_ERR } +pub fn is_success(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_SUCCESS } +pub fn is_replace(s: c_int) -> bool { s & PAM_DATA_REPLACE != 0 } + +// --- PAM opaque handle --- +#[repr(C)] +pub struct PamHandle { _private: [u8; 0] } + +type CleanupFn = unsafe extern "C" fn(*mut PamHandle, *mut c_void, c_int); + +extern "C" { + fn pam_set_data( + pamh: *mut PamHandle, + name: *const c_char, + data: *mut c_void, + cleanup: Option, + ) -> c_int; +} + +// --- Cleanup: fires on pam_end() or when data is replaced --- +unsafe extern "C" fn ahfail_cleanup( + _pamh: *mut PamHandle, + _data: *mut c_void, + error_status: c_int, +) { + if is_replace(error_status) { + // Previous attempt failed; another is starting + spawn_display(None); + return; + } + if is_failure(error_status) { + spawn_display(None); + } else if is_success(error_status) { + kill_display(); + } +} + +// --- Exported PAM module entry points --- + +#[no_mangle] +pub unsafe extern "C" fn pam_sm_authenticate( + pamh: *mut PamHandle, + _flags: c_int, + _argc: c_int, + argv: *const *const c_char, +) -> c_int { + let display_path = read_display_path_arg(argc_argv(_argc, argv)); + let key = CString::new("ahfail").unwrap(); + // Store path as data so cleanup can use it (pass as raw pointer to leaked CString) + let path_ptr = display_path + .map(|p| Box::into_raw(Box::new(p)) as *mut c_void) + .unwrap_or(std::ptr::null_mut()); + pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup)); + PAM_IGNORE +} + +#[no_mangle] +pub unsafe extern "C" fn pam_sm_setcred( + _pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char, +) -> c_int { PAM_IGNORE } + +#[no_mangle] +pub unsafe extern "C" fn pam_sm_acct_mgmt( + _pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char, +) -> c_int { PAM_IGNORE } + +#[no_mangle] +pub unsafe extern "C" fn pam_sm_open_session( + _pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char, +) -> c_int { PAM_IGNORE } + +#[no_mangle] +pub unsafe extern "C" fn pam_sm_close_session( + _pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char, +) -> c_int { PAM_IGNORE } + +#[no_mangle] +pub unsafe extern "C" fn pam_sm_chauthtok( + _pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char, +) -> c_int { PAM_IGNORE } + +// --- Helpers --- + +unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] { + if argv.is_null() || argc <= 0 { return &[]; } + std::slice::from_raw_parts(argv, argc as usize) +} + +fn read_display_path_arg(args: &[*const c_char]) -> Option { + let prefix = b"display_path="; + for &arg in args { + if arg.is_null() { continue; } + let s = unsafe { std::ffi::CStr::from_ptr(arg) }.to_bytes(); + if s.starts_with(prefix) { + return std::str::from_utf8(&s[prefix.len()..]).ok().map(|s| s.to_string()); + } + } + None +} + +fn spawn_display(path_override: Option) { + let path = path_override.unwrap_or_else(|| DEFAULT_PATH.to_string()); + let cpath = match CString::new(path.as_str()) { + Ok(p) => p, + Err(_) => return, + }; + // Double-fork: parent waits on intermediate → grandchild adopted by init + unsafe { + let pid: pid_t = libc::fork(); + if pid < 0 { return; } + if pid > 0 { + libc::waitpid(pid, std::ptr::null_mut(), 0); + return; + } + // Intermediate process: fork again then exit + let pid2: pid_t = libc::fork(); + if pid2 != 0 { libc::_exit(0); } + // Grandchild: exec ahfail-display + libc::close(0); libc::close(1); libc::close(2); + let args = [cpath.as_ptr(), std::ptr::null()]; + libc::execv(cpath.as_ptr(), args.as_ptr()); + libc::_exit(1); + } +} + +fn kill_display() { + // Send SIGTERM to all ahfail-display processes owned by this user + let _ = std::process::Command::new("pkill") + .args(["-SIGTERM", "-u", &unsafe { libc::getuid() }.to_string(), "ahfail-display"]) + .spawn(); +} +``` + +**Step 6: Run tests** + +```bash +cargo test -p ahfail-pam +``` + +Expected: all pass (no live PAM needed for unit tests). + +**Step 7: Build** + +```bash +cargo build -p ahfail-pam +``` + +Expected: `libahfail_pam.so` in `target/debug/`. + +**Step 8: Commit** + +```bash +git add crates/ahfail-pam/ +git commit -m "feat: add ahfail-pam PAM module with cleanup-based failure detection" +``` + +--- + +### Task 8: Update `meson.build` + +**Files:** +- Modify: `meson.build` + +**Step 1: Update the cargo_target to build from workspace** + +Replace the existing `cargo_target` custom_target with two targets: + +```meson +# gtklock module (existing, path updated) +gtklock_cargo = custom_target( + 'ahfail-gtklock-cargo-build', + input: ['crates/ahfail-gtklock/src/lib.rs', 'Cargo.toml'], + output: ['libahfail_module.a'], + command: [ + 'sh', '-c', + 'cargo build --release -p ahfail-gtklock --target-dir "@OUTDIR@/target" && cp "@OUTDIR@/target/release/libahfail_module.a" "@OUTPUT@"' + ], + build_by_default: true +) + +# PAM module +pam_cargo = custom_target( + 'ahfail-pam-cargo-build', + input: ['crates/ahfail-pam/src/lib.rs', 'Cargo.toml'], + output: ['libahfail_pam.so'], + command: [ + 'sh', '-c', + 'cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target" && cp "@OUTDIR@/target/release/libahfail_pam.so" "@OUTPUT@"' + ], + build_by_default: true +) + +# Display binary +display_cargo = custom_target( + 'ahfail-display-cargo-build', + input: ['crates/ahfail-display/src/main.rs', 'Cargo.toml'], + output: ['ahfail-display'], + command: [ + 'sh', '-c', + 'cargo build --release -p ahfail-display --target-dir "@OUTDIR@/target" && cp "@OUTDIR@/target/release/ahfail-display" "@OUTPUT@"' + ], + build_by_default: true +) +``` + +**Step 2: Add install targets for pam module and display binary** + +```meson +# PAM module installs to /usr/lib/ahfail/ (not /usr/lib/gtklock/) +install_data( + pam_cargo, + install_dir: get_option('libdir') / 'ahfail' +) + +# Display binary installs to /usr/lib/ahfail/ +install_data( + display_cargo, + install_dir: get_option('libdir') / 'ahfail' +) +``` + +**Step 3: Update the smoke test to cover PAM module symbols** + +Update `tests/module_test.c` to also check the PAM module: create a second test target `pam_smoke` that dlopens `ahfail-pam.so` and checks `pam_sm_authenticate` symbol exists. + +Or keep it simple: add a second executable in meson.build that links `libahfail_pam.so` and just calls `pam_sm_authenticate(NULL, 0, 0, NULL)` — it returns `PAM_IGNORE` (25) safely on null handle if coded defensively. + +**Step 4: Verify** + +```bash +meson setup builddir --wipe +meson compile -C builddir +``` + +Expected: three artifacts in `builddir/`: `ahfail-module.so`, `libahfail_pam.so`, `ahfail-display`. + +**Step 5: Commit** + +```bash +git add meson.build +git commit -m "build: update meson.build for workspace — add PAM module and display binary targets" +``` + +--- + +### Task 9: Add Gitea CI/CD workflows + +**Files:** +- Create: `.gitea/workflows/test.yml` +- Create: `.gitea/workflows/release.yml` + +**Step 1: Write `.gitea/workflows/test.yml`** + +```yaml +name: Test + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install system dependencies + run: | + sudo apt-get update -q + sudo apt-get install -y \ + libgtk-3-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-good \ + libpam0g-dev \ + ninja-build \ + python3-pip + pip3 install meson + + - name: Install Rust stable + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Run Rust tests + run: cargo test + + - name: Meson build + run: | + meson setup builddir + meson compile -C builddir + + - name: Meson tests (symbol smoke test) + run: meson test -C builddir --verbose +``` + +**Step 2: Write `.gitea/workflows/release.yml`** + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install system dependencies + run: | + sudo apt-get update -q + sudo apt-get install -y \ + libgtk-3-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-plugins-good \ + libpam0g-dev \ + ninja-build \ + python3-pip + pip3 install meson + + - name: Install Rust stable + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Build release + run: | + meson setup builddir --buildtype=release + meson compile -C builddir + + - name: Bundle artifacts + run: | + mkdir -p dist + cp builddir/ahfail-module.so dist/ + cp builddir/libahfail_pam.so dist/ + cp builddir/ahfail-display dist/ + tar czf ahfail-linux-x86_64.tar.gz -C dist . + + - name: Create Gitea release and upload asset + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_URL: https://gitea.weircon.dk + REPO: agw/gtk-ahfail + TAG: ${{ github.ref_name }} + run: | + # Create the release + RELEASE_ID=$(curl -s -X POST \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + + # Upload the tarball + curl -s -X POST \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@ahfail-linux-x86_64.tar.gz" +``` + +> Add a `GITEA_TOKEN` secret in your Gitea repo settings (Settings → Secrets) with a personal access token that has `write:repository` scope. + +**Step 3: Commit** + +```bash +git add .gitea/ +git commit -m "ci: add Gitea Actions workflows for test and release" +``` + +--- + +### Task 10: Update README with macOS instructions + +**Files:** +- Modify or create: `README.md` + +**Step 1: Add macOS build section** + +Add to README: + +```markdown +## macOS (build from source) + +### Prerequisites + +```bash +brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja rust +``` + +### Build + +```bash +meson setup builddir +meson compile -C builddir +``` + +Produces: +- `builddir/ahfail-module.so` — gtklock module (Wayland/Linux only) +- `builddir/libahfail_pam.so` — PAM module (macOS + X11 Linux) +- `builddir/ahfail-display` — display binary (spawned by PAM module) + +### Install + +```bash +sudo mkdir -p /usr/local/lib/ahfail +sudo cp builddir/libahfail_pam.so /usr/local/lib/ahfail/ +sudo cp builddir/ahfail-display /usr/local/lib/ahfail/ +``` + +### Configure PAM (macOS) + +Add to `/etc/pam.d/screensaver` (requires `sudo`): + +``` +auth optional /usr/local/lib/ahfail/libahfail_pam.so +``` + +Place it after the existing `auth` line(s) so it observes the real auth result. + +### Configure PAM (Linux/X11) + +Add to `/etc/pam.d/gtklock` (or `i3lock`, `xscreensaver`, etc.): + +``` +auth optional ahfail-pam.so +``` +``` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add macOS build-from-source and PAM configuration instructions" +``` + +--- + +## Verification checklist + +After all tasks are complete, run these in order: + +```bash +# All Rust tests pass +cargo test + +# Full Meson build succeeds +meson setup builddir --wipe +meson compile -C builddir +meson test -C builddir --verbose + +# Three artifacts present +ls builddir/ahfail-module.so builddir/libahfail_pam.so builddir/ahfail-display +```