1522 lines
40 KiB
Markdown
1522 lines
40 KiB
Markdown
# 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: >k::Overlay,
|
||
fixed: >k::Fixed,
|
||
animation: &gdk_pixbuf::PixbufAnimation,
|
||
screen_w: i32,
|
||
screen_h: i32,
|
||
config: &ModuleConfig,
|
||
) -> gtk::Image {
|
||
let image = gtk::Image::from_animation(animation);
|
||
image.show();
|
||
|
||
let sprite_w = animation.width();
|
||
let sprite_h = animation.height();
|
||
let safe_w = screen_w - SPRITE_MARGIN;
|
||
let safe_h = screen_h - SPRITE_MARGIN;
|
||
let max_x = (safe_w - sprite_w).max(0);
|
||
let max_y = (safe_h - sprite_h).max(0);
|
||
|
||
let mut rng = rand::thread_rng();
|
||
let mut x = 0;
|
||
let mut y = 0;
|
||
|
||
for _ in 0..RETRY_ATTEMPTS {
|
||
x = rng.gen_range(0..=max_x);
|
||
y = rng.gen_range(0..=max_y);
|
||
if let Some(dz) = &config.deadzone {
|
||
let r = gdk::Rectangle::new(x, y, sprite_w, sprite_h);
|
||
if dz.intersect(&r).is_none() { break; }
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
fixed.put(&image, x, y);
|
||
overlay.add_overlay(fixed);
|
||
overlay.set_overlay_pass_through(fixed, true);
|
||
image
|
||
}
|
||
```
|
||
|
||
**Step 6: Write `crates/ahfail-ui/src/lib.rs`**
|
||
|
||
```rust
|
||
pub mod animation;
|
||
pub mod audio;
|
||
pub mod config;
|
||
pub mod display;
|
||
pub mod update; // stub for now: pub fn check_for_update(_: &str) {}
|
||
pub mod volume; // stub for now: pub struct VolumeState; pub fn save_and_set_max() -> VolumeState { VolumeState } pub fn restore(_: VolumeState) {}
|
||
```
|
||
|
||
**Step 7: Verify `ahfail-ui` compiles**
|
||
|
||
```bash
|
||
cargo build -p ahfail-ui
|
||
```
|
||
|
||
Expected: compiles (stubs in update/volume are empty).
|
||
|
||
**Step 8: Commit**
|
||
|
||
```bash
|
||
git add crates/ahfail-ui/
|
||
git commit -m "feat: add ahfail-ui crate with animation, audio, display, config"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Wire `ahfail-gtklock` to use `ahfail-ui`
|
||
|
||
**Files:**
|
||
- Modify: `crates/ahfail-gtklock/Cargo.toml`
|
||
- Modify: `crates/ahfail-gtklock/src/handler.rs`
|
||
- Modify: `crates/ahfail-gtklock/src/lib.rs`
|
||
- Modify: `crates/ahfail-gtklock/src/config.rs` (re-export from ahfail-ui)
|
||
|
||
**Step 1: Add `ahfail-ui` dependency**
|
||
|
||
In `crates/ahfail-gtklock/Cargo.toml`:
|
||
```toml
|
||
[dependencies]
|
||
ahfail-ui = { path = "../ahfail-ui" }
|
||
# keep all existing deps
|
||
```
|
||
|
||
**Step 2: Replace config.rs with re-export**
|
||
|
||
Replace `crates/ahfail-gtklock/src/config.rs` entirely:
|
||
```rust
|
||
pub use ahfail_ui::config::*;
|
||
```
|
||
|
||
**Step 3: Replace animation loading in `lib.rs`**
|
||
|
||
In `on_activation`, replace the frame-loading loop (lines 91–126) with:
|
||
```rust
|
||
let anim_opt = unsafe { ahfail_ui::animation::load_animation() };
|
||
```
|
||
|
||
Remove the `SPRITE_SCALE` constant and the manual frame-loading loop. Remove the `extern "C" { fn ahfail_get_resource() }` block (it now lives in `ahfail-ui::animation`).
|
||
|
||
**Step 4: Replace `handler.rs` sprite placement**
|
||
|
||
Replace the random-placement + deadzone block inside the signal closure with a call to `ahfail_ui::display::place_sprite(...)`. The `WindowHandler::create` method initialises `fixed` and sets it up before registering the signal; the closure calls `place_sprite` on each error.
|
||
|
||
Replace `WindowHandler::create_player` with `ahfail_ui::audio::create_player`.
|
||
|
||
**Step 5: Run tests**
|
||
|
||
```bash
|
||
cargo test -p ahfail-gtklock
|
||
```
|
||
|
||
Expected: `run_all_tests_sequentially ... ok`
|
||
|
||
**Step 6: Verify meson still builds**
|
||
|
||
```bash
|
||
meson compile -C builddir
|
||
```
|
||
|
||
Expected: `ahfail-module.so` produced.
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
git add crates/ahfail-gtklock/ crates/ahfail-ui/
|
||
git commit -m "refactor: wire ahfail-gtklock to use ahfail-ui for animation/audio/display"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Add volume management
|
||
|
||
**Files:**
|
||
- Create: `crates/ahfail-ui/src/volume.rs`
|
||
- Modify: `crates/ahfail-ui/src/lib.rs`
|
||
- Modify: `crates/ahfail-gtklock/src/state.rs`
|
||
- Modify: `crates/ahfail-gtklock/src/lib.rs`
|
||
|
||
**Step 1: Write failing test for volume module**
|
||
|
||
Create `crates/ahfail-ui/tests/volume_tests.rs`:
|
||
|
||
```rust
|
||
use ahfail_ui::volume::{VolumeState, is_newer_volume_lock};
|
||
use std::fs;
|
||
use tempfile::tempdir;
|
||
|
||
#[test]
|
||
fn lock_file_created_and_prevents_second_save() {
|
||
let dir = tempdir().unwrap();
|
||
let lock_path = dir.path().join("ahfail.lock");
|
||
|
||
// First acquisition succeeds
|
||
let acquired = ahfail_ui::volume::try_acquire_lock(&lock_path);
|
||
assert!(acquired);
|
||
assert!(lock_path.exists());
|
||
|
||
// Second acquisition fails
|
||
let acquired2 = ahfail_ui::volume::try_acquire_lock(&lock_path);
|
||
assert!(!acquired2);
|
||
}
|
||
```
|
||
|
||
Add `tempfile = "3"` to `crates/ahfail-ui/Cargo.toml` under `[dev-dependencies]`.
|
||
|
||
**Step 2: Run test to verify it fails**
|
||
|
||
```bash
|
||
cargo test -p ahfail-ui volume_tests
|
||
```
|
||
|
||
Expected: FAIL — `volume` module not defined.
|
||
|
||
**Step 3: Implement `crates/ahfail-ui/src/volume.rs`**
|
||
|
||
```rust
|
||
use std::path::PathBuf;
|
||
use std::process::Command;
|
||
use std::fs;
|
||
|
||
pub struct VolumeState {
|
||
pub volume: u32,
|
||
pub muted: bool,
|
||
pub lock_path: PathBuf,
|
||
}
|
||
|
||
/// Atomically creates lock file. Returns true if this process is the first (primary).
|
||
pub fn try_acquire_lock(path: &std::path::Path) -> bool {
|
||
use std::fs::OpenOptions;
|
||
use std::io::Write;
|
||
OpenOptions::new()
|
||
.write(true)
|
||
.create_new(true) // fails if exists
|
||
.open(path)
|
||
.map(|mut f| { let _ = f.write_all(b"1"); true })
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Returns lock file path: $XDG_RUNTIME_DIR/ahfail.lock or /tmp/ahfail-{uid}.lock
|
||
pub fn lock_path() -> PathBuf {
|
||
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||
PathBuf::from(dir).join("ahfail.lock")
|
||
} else {
|
||
PathBuf::from(format!("/tmp/ahfail-{}.lock", unsafe { libc::getuid() }))
|
||
}
|
||
}
|
||
|
||
/// If primary: save current system volume/mute state, set volume to 100% unmuted.
|
||
/// Returns Some(VolumeState) if this process is the primary (should restore on exit).
|
||
pub fn save_and_set_max() -> Option<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: >k::Fixed,
|
||
animation: &gdk_pixbuf::PixbufAnimation,
|
||
screen_w: i32, screen_h: i32,
|
||
config: &ModuleConfig,
|
||
) -> gtk::Image
|
||
```
|
||
Update the call in `ahfail-gtklock/src/handler.rs` accordingly (remove `overlay` param from `place_sprite`; the overlay add stays in handler.rs).
|
||
|
||
**Step 3: Build and verify**
|
||
|
||
```bash
|
||
cargo build -p ahfail-display
|
||
```
|
||
|
||
Expected: compiles. (Cannot run without a display/GResources linked.)
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/ahfail-display/
|
||
git commit -m "feat: add ahfail-display standalone binary"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Create `ahfail-pam` PAM module
|
||
|
||
**Files:**
|
||
- Replace: `crates/ahfail-pam/Cargo.toml`
|
||
- Replace: `crates/ahfail-pam/src/lib.rs`
|
||
- Create: `crates/ahfail-pam/build.rs`
|
||
- Create: `crates/ahfail-pam/tests/pam_tests.rs`
|
||
|
||
**Step 1: Write `crates/ahfail-pam/Cargo.toml`**
|
||
|
||
```toml
|
||
[package]
|
||
name = "ahfail-pam"
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
|
||
[lib]
|
||
name = "ahfail_pam"
|
||
crate-type = ["cdylib"]
|
||
|
||
[dependencies]
|
||
libc = "0.2"
|
||
```
|
||
|
||
**Step 2: Write `crates/ahfail-pam/build.rs`**
|
||
|
||
```rust
|
||
fn main() {
|
||
println!("cargo:rustc-link-lib=pam");
|
||
}
|
||
```
|
||
|
||
**Step 3: Write failing test**
|
||
|
||
Create `crates/ahfail-pam/tests/pam_tests.rs`:
|
||
|
||
```rust
|
||
use ahfail_pam::{is_failure, is_success, is_replace};
|
||
|
||
#[test]
|
||
fn status_classification() {
|
||
assert!(is_failure(ahfail_pam::PAM_AUTH_ERR));
|
||
assert!(is_success(ahfail_pam::PAM_SUCCESS));
|
||
assert!(!is_failure(ahfail_pam::PAM_SUCCESS));
|
||
}
|
||
|
||
#[test]
|
||
fn replace_flag_detected() {
|
||
let replace_status = ahfail_pam::PAM_AUTH_ERR | ahfail_pam::PAM_DATA_REPLACE;
|
||
assert!(is_replace(replace_status));
|
||
}
|
||
|
||
#[test]
|
||
fn display_path_default_is_set() {
|
||
assert!(!ahfail_pam::default_display_path().is_empty());
|
||
}
|
||
```
|
||
|
||
Change `crates/ahfail-pam/Cargo.toml` lib crate-type to `["cdylib", "rlib"]` for tests.
|
||
|
||
**Step 4: Run test to verify it fails**
|
||
|
||
```bash
|
||
cargo test -p ahfail-pam
|
||
```
|
||
|
||
Expected: FAIL — functions not defined.
|
||
|
||
**Step 5: Write `crates/ahfail-pam/src/lib.rs`**
|
||
|
||
```rust
|
||
use libc::{c_char, c_int, c_void, pid_t};
|
||
use std::ffi::CString;
|
||
|
||
// --- PAM constants (platform-specific) ---
|
||
|
||
pub const PAM_SUCCESS: c_int = 0;
|
||
pub const PAM_IGNORE: c_int = 25;
|
||
|
||
#[cfg(target_os = "linux")]
|
||
pub const PAM_AUTH_ERR: c_int = 7;
|
||
#[cfg(target_os = "macos")]
|
||
pub const PAM_AUTH_ERR: c_int = 9;
|
||
|
||
#[cfg(target_os = "linux")]
|
||
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
||
#[cfg(target_os = "macos")]
|
||
pub const PAM_DATA_REPLACE: c_int = 0x00000002;
|
||
|
||
// Default install path for ahfail-display
|
||
#[cfg(target_os = "linux")]
|
||
const DEFAULT_PATH: &str = "/usr/lib/ahfail/ahfail-display";
|
||
#[cfg(target_os = "macos")]
|
||
const DEFAULT_PATH: &str = "/usr/local/lib/ahfail/ahfail-display";
|
||
|
||
pub fn default_display_path() -> &'static str { DEFAULT_PATH }
|
||
pub fn is_failure(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_AUTH_ERR }
|
||
pub fn is_success(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_SUCCESS }
|
||
pub fn is_replace(s: c_int) -> bool { s & PAM_DATA_REPLACE != 0 }
|
||
|
||
// --- PAM opaque handle ---
|
||
#[repr(C)]
|
||
pub struct PamHandle { _private: [u8; 0] }
|
||
|
||
type CleanupFn = unsafe extern "C" fn(*mut PamHandle, *mut c_void, c_int);
|
||
|
||
extern "C" {
|
||
fn pam_set_data(
|
||
pamh: *mut PamHandle,
|
||
name: *const c_char,
|
||
data: *mut c_void,
|
||
cleanup: Option<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
|
||
```
|