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

```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: &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`**

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

```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<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:
```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<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**

```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: &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**

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

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