Files
gtk-ahfail/docs/plans/2026-05-05-pam-rewrite-impl.md
Asger Geel Weirsøe 9c546c69ee Add implementation plan for PAM rewrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:17:05 +02:00

40 KiB
Raw Blame History

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:

[workspace]
members = [
    "crates/ahfail-ui",
    "crates/ahfail-gtklock",
    "crates/ahfail-pam",
    "crates/ahfail-display",
]
resolver = "2"

Step 2: Create the gtklock crate

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:

[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

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

cargo build -p ahfail-gtklock

Expected: compiles successfully. Existing tests still pass:

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

meson setup builddir --wipe
meson compile -C builddir

Expected: ahfail-module.so produced in builddir/.

Step 7: Commit

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

[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

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<gdk_pixbuf::PixbufAnimation> {
    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<gdk_pixbuf::Pixbuf> = 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

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

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: &gtk::Overlay,
    fixed: &gtk::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

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

cargo build -p ahfail-ui

Expected: compiles (stubs in update/volume are empty).

Step 8: Commit

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:

[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:

pub use ahfail_ui::config::*;

Step 3: Replace animation loading in lib.rs

In on_activation, replace the frame-loading loop (lines 91126) with:

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

cargo test -p ahfail-gtklock

Expected: run_all_tests_sequentially ... ok

Step 6: Verify meson still builds

meson compile -C builddir

Expected: ahfail-module.so produced.

Step 7: Commit

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:

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

cargo test -p ahfail-ui volume_tests

Expected: FAIL — volume module not defined.

Step 3: Implement crates/ahfail-ui/src/volume.rs

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<VolumeState> {
    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<u32> {
    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

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:

pub volume_state: Option<ahfail_ui::volume::VolumeState>,

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:

// 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:

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

cargo test -p ahfail-gtklock

Expected: all pass (volume functions are not exercised in tests since they call system commands).

Step 8: Commit

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:

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

cargo test -p ahfail-ui update_tests

Expected: FAIL — update module not defined.

Step 3: Implement crates/ahfail-ui/src/update.rs

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<String> {
    // 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

cargo test -p ahfail-ui update_tests

Expected: PASS.

Step 5: Expose version constant

In crates/ahfail-ui/src/lib.rs add:

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:

std::thread::spawn(|| {
    ahfail_ui::update::check_for_update(ahfail_ui::VERSION);
});

Step 7: Run tests

cargo test -p ahfail-gtklock && cargo test -p ahfail-ui

Expected: all pass.

Step 8: Commit

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

[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

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:

pub fn place_sprite(
    fixed: &gtk::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

cargo build -p ahfail-display

Expected: compiles. (Cannot run without a display/GResources linked.)

Step 4: Commit

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

[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

fn main() {
    println!("cargo:rustc-link-lib=pam");
}

Step 3: Write failing test

Create crates/ahfail-pam/tests/pam_tests.rs:

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

cargo test -p ahfail-pam

Expected: FAIL — functions not defined.

Step 5: Write crates/ahfail-pam/src/lib.rs

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<CleanupFn>,
    ) -> 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<String> {
    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<String>) {
    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

cargo test -p ahfail-pam

Expected: all pass (no live PAM needed for unit tests).

Step 7: Build

cargo build -p ahfail-pam

Expected: libahfail_pam.so in target/debug/.

Step 8: Commit

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:

# 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

# 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

meson setup builddir --wipe
meson compile -C builddir

Expected: three artifacts in builddir/: ahfail-module.so, libahfail_pam.so, ahfail-display.

Step 5: Commit

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

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

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

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:

## macOS (build from source)

### Prerequisites

```bash
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja rust

Build

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

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:

# 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