40 KiB
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 existingsrc/) - Create:
crates/ahfail-gtklock/tests/(copy of existingtests/)
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: >k::Overlay,
fixed: >k::Fixed,
animation: &gdk_pixbuf::PixbufAnimation,
screen_w: i32,
screen_h: i32,
config: &ModuleConfig,
) -> gtk::Image {
let image = gtk::Image::from_animation(animation);
image.show();
let sprite_w = animation.width();
let sprite_h = animation.height();
let safe_w = screen_w - SPRITE_MARGIN;
let safe_h = screen_h - SPRITE_MARGIN;
let max_x = (safe_w - sprite_w).max(0);
let max_y = (safe_h - sprite_h).max(0);
let mut rng = rand::thread_rng();
let mut x = 0;
let mut y = 0;
for _ in 0..RETRY_ATTEMPTS {
x = rng.gen_range(0..=max_x);
y = rng.gen_range(0..=max_y);
if let Some(dz) = &config.deadzone {
let r = gdk::Rectangle::new(x, y, sprite_w, sprite_h);
if dz.intersect(&r).is_none() { break; }
} else {
break;
}
}
fixed.put(&image, x, y);
overlay.add_overlay(fixed);
overlay.set_overlay_pass_through(fixed, true);
image
}
Step 6: Write crates/ahfail-ui/src/lib.rs
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 91–126) 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: >k::Fixed,
animation: &gdk_pixbuf::PixbufAnimation,
screen_w: i32, screen_h: i32,
config: &ModuleConfig,
) -> gtk::Image
Update the call in ahfail-gtklock/src/handler.rs accordingly (remove overlay param from place_sprite; the overlay add stays in handler.rs).
Step 3: Build and verify
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_TOKENsecret in your Gitea repo settings (Settings → Secrets) with a personal access token that haswrite:repositoryscope.
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