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