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

1522 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 91126) 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
```