Compare commits

..

29 Commits

Author SHA1 Message Date
Asger Geel Weirsøe
e41ae1cd7f ci: use gitea-release-action@v1 for release upload
All checks were successful
Release / release (push) Successful in 5m23s
Test / test (push) Successful in 6m20s
Replaces the manual curl-based release creation with the standard action,
consistent with other Gitea repos. Also bumps checkout to @v4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:24:20 +02:00
Asger Geel Weirsøe
3e7bc18f65 fix: pass absolute path to pam_smoke_test via meson.current_build_dir()
All checks were successful
Test / test (push) Successful in 6m15s
dlopen with a bare filename searches LD_LIBRARY_PATH/ld.so cache, not the
build dir. Using the explicit absolute path avoids the lookup failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:13:51 +02:00
Asger Geel Weirsøe
1f927bdbb2 fix: run cargo test under xvfb-run; clean up unused imports and mut warning
Some checks failed
Test / test (push) Failing after 6m22s
ahfail_tests.rs calls on_activation which initialises GTK — needs a virtual
display in CI just like the Meson test step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:04:56 +02:00
Asger Geel Weirsøe
2c2e693193 ci: add libgstreamer-plugins-bad1.0-dev for gstreamer-player-1.0.pc
Some checks failed
Test / test (push) Failing after 2m57s
gstreamer-player-1.0.pc is shipped by the -bad dev package, not -base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:59:54 +02:00
Asger Geel Weirsøe
9dbca0ec51 ci: install meson via apt instead of pip (PEP 668 / Ubuntu 24.04)
Some checks failed
Test / test (push) Failing after 1m35s
ubuntu-latest now resolves to Ubuntu 24.04 which ships meson 1.3.2 (satisfies
>=1.3.0) and enforces PEP 668, blocking pip3 install without --break-system-packages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:57:30 +02:00
Asger Geel Weirsøe
702f449d0e fix: PID-based volume lock with state persistence and multiarch default path
Some checks failed
Test / test (push) Failing after 1m23s
Volume lock file now stores {pid}:{volume}:{muted} instead of "1":
- Allows recovery of saved volume state if the holder is SIGKILLed
- On stale lock detection (holder PID not alive), inherit saved volume state
  and take ownership — prevents permanent volume loss and infinite lockout

PAM module DEFAULT_PATH now baked in at build time via AHFAIL_LIBDIR env var
passed by Meson, fixing the wrong path on multiarch Debian/Ubuntu where libdir
is /usr/lib/x86_64-linux-gnu rather than /usr/lib.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:41:06 +02:00
Asger Geel Weirsøe
3323844c33 chore: remove accidental empty v file from remote merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:40:56 +02:00
Asger Geel Weirsøe
1f98f89fab Merge remote-tracking branch 'origin/master' 2026-05-06 12:40:52 +02:00
Asger Geel Weirsøe
c3bdb09bd3 fix: xvfb for headless CI, curl -f + set -euo pipefail in release, fix PAM paths in README 2026-05-06 12:13:47 +02:00
Asger Geel Weirsøe
2ddef2ac81 docs: add macOS build-from-source and PAM configuration instructions 2026-05-06 12:11:06 +02:00
Asger Geel Weirsøe
7323e08c95 ci: add Gitea Actions workflows for test and release 2026-05-06 12:10:55 +02:00
Asger Geel Weirsøe
692cd76ceb fix: separate cargo target-dirs to avoid parallel lock contention, add pam smoke test 2026-05-06 12:10:02 +02:00
Asger Geel Weirsøe
0f128d1c6f build: update meson.build for workspace — add PAM module and display binary targets 2026-05-06 12:06:49 +02:00
Asger Geel Weirsøe
4b9d69ffbc fix: pam_set_data error handling, setsid+fd sweep in grandchild, is_replace logic, null pamh guard 2026-05-06 12:05:43 +02:00
Asger Geel Weirsøe
c24bd26ba1 feat: add ahfail-pam PAM module with cleanup-based failure detection
Implements a C-ABI PAM shared library that registers a pam_set_data
cleanup callback to detect auth failures and spawn/kill ahfail-display
via a double-fork, without ever touching credentials.
2026-05-06 12:00:24 +02:00
Asger Geel Weirsøe
abf8aef1ef fix: defer volume lock to after setup, fix sighandler cast comment, check pkg-config status 2026-05-06 11:58:21 +02:00
Asger Geel Weirsøe
f93ca6267c feat: add ahfail-display standalone binary
Implements the ahfail-display binary crate: GTK popup window that spawns
the Nedry sprite and plays the audio clip, with SIGTERM handling, 15-minute
failsafe, deadzone CLI parsing, volume save/restore, and update check.
Adds a build.rs that compiles GResources via glib-compile-resources so the
binary can be built with plain `cargo build` outside of Meson.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:54:22 +02:00
Asger Geel Weirsøe
e1f8c1d58f fix: update check — handle spaced JSON, move cache touch after HTTP success, add timeouts and multi-monitor guard 2026-05-06 11:51:02 +02:00
Asger Geel Weirsøe
468699e316 feat: add rate-limited update check with desktop notification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:55:11 +02:00
Asger Geel Weirsøe
355828d4d9 fix: remove redundant volume_state=None, improve pactl volume parser for stereo sinks 2026-05-06 09:53:27 +02:00
Asger Geel Weirsøe
74e0f544a0 feat: add volume save/restore on failure/unload
On the first failed unlock attempt, save the current system volume and
mute state then set volume to maximum unmuted; restore on g_module_unload.
A lock file under XDG_RUNTIME_DIR prevents double-acquisition when
multiple gtklock windows are active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:50:49 +02:00
Asger Geel Weirsøe
7913ced403 chore: remove unused imports, stale deps, and unnecessary unsafe block in ahfail-gtklock 2026-05-06 09:47:39 +02:00
Asger Geel Weirsøe
097dd52998 refactor: wire ahfail-gtklock to use ahfail-ui for animation/audio/display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:38:35 +02:00
Asger Geel Weirsøe
f05e93b75e fix: image.show(), private ahfail_get_resource, saturating_sub in display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:35:02 +02:00
Asger Geel Weirsøe
3dc0733cd0 feat: add ahfail-ui crate with animation, audio, display, config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:28:14 +02:00
Asger Geel Weirsøe
2b89653be6 refactor: remove dead utils/bench.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:25:39 +02:00
Asger Geel Weirsøe
8dd06377fc refactor: convert to Cargo workspace, move gtklock crate
Replaces the single-crate Cargo.toml with a workspace containing
ahfail-gtklock (migrated from root src/) and three stub crates
(ahfail-ui, ahfail-pam, ahfail-display). Updates meson.build to
build with -p ahfail-gtklock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:23:31 +02:00
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
Asger Geel Weirsøe
3cdbc4fec9 Add design doc for PAM module + cross-platform rewrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:09:25 +02:00
38 changed files with 3912 additions and 175 deletions

View File

@@ -0,0 +1,53 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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 \
libgstreamer-plugins-bad1.0-dev \
gstreamer1.0-plugins-good \
libpam0g-dev \
ninja-build \
meson \
libglib2.0-dev
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- 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: Upload release asset
# Requires a GITEA_TOKEN secret with write:repository scope.
# Create it in repo Settings → Secrets.
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
server_url: ${{ github.server_url }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
files: ahfail-linux-x86_64.tar.gz

40
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,40 @@
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 \
libgstreamer-plugins-bad1.0-dev \
gstreamer1.0-plugins-good \
libpam0g-dev \
ninja-build \
meson \
libglib2.0-dev \
xvfb
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Run Rust tests
run: xvfb-run cargo test
- name: Meson build
run: |
meson setup builddir
meson compile -C builddir
- name: Meson tests
run: xvfb-run meson test -C builddir --verbose

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Is
`ahfail` is a [`gtklock`](https://github.com/jovanlanik/gtklock) module. It compiles to `ahfail-module.so`, which gtklock loads at runtime. On each failed unlock attempt (`PW_FAILURE`), it spawns an animated "Nedry" sprite at a random screen location and plays an audio clip ("ah ah ah, you didn't say the magic word").
## Build System
The build is a two-stage hybrid:
1. **Meson** compiles GResources from `assets/ahfail.gresource.xml` into C, invokes Cargo to produce `libahfail_module.a`, then links everything into `ahfail-module.so`.
2. **Cargo** handles only the Rust crate — it produces a `staticlib` that Meson links.
Always use Meson for the final shared object; `cargo build` alone does not produce the loadable module.
```bash
# First-time setup
meson setup builddir
# Build
meson compile -C builddir
# Run Rust tests only (no GTK display required)
cargo test
# Run Meson tests (loads the .so, requires GTK)
meson test -C builddir
# Manual integration test
gtklock -d -m builddir/ahfail-module.so -- --deadzone=X,Y,W,H
```
## Architecture
All FFI entry points live in `src/lib.rs` — these are the `extern "C"` functions that gtklock calls (`on_activation`, `on_window_create`, `on_window_destroy`, `on_idle_hide`, etc.).
**Module lifecycle:**
- `on_activation` — called once at startup; initialises GTK/GStreamer, loads all sprite frames into a `PixbufSimpleAnim`, stores them in `MODULE_STATE`.
- `on_window_create` — called per monitor; calls `WindowHandler::create`, which creates a `gtk::Fixed` overlay, a pool of pre-warmed GStreamer players, and wires up a signal on `error_label` that fires on each failed attempt.
- `on_window_destroy` / `on_idle_hide` — clean up sprites and stop players.
**Key types:**
| Type | File | Purpose |
|------|------|---------|
| `MODULE_STATE` | `src/state.rs` | Thread-local `RefCell<ModuleState>` holding the shared animation, audio URI, and deadzone config |
| `WindowData` | `src/state.rs` | Heap-allocated per-window state (sprites, player pool, GTK signal handle) |
| `WindowContext` | `src/context.rs` | Safe wrapper around the raw `*mut Window` pointer passed from C |
| `Window` / `GtkLock` | `src/context.rs` | `#[repr(C)]` mirrors of gtklock's internal structs — must match `include/gtklock-module.h` |
| `WindowHandler` | `src/handler.rs` | Sprite placement logic (random position, deadzone avoidance with retries) and GStreamer player management |
| `ModuleConfig` | `src/config.rs` | Parses the `--deadzone=x,y,w,h` CLI argument via glib's `GOptionEntry` |
**Assets** are embedded as GResources at build time. At runtime they are accessed via `resource:///ahfail/sprites/...` and `resource:///ahfail/audio/magic-word.mp3`.
## Safety Conventions
- All `unsafe` pointer work on `*mut Window` goes through `WindowContext` — never dereference the raw pointer outside that wrapper.
- `WindowData` is heap-allocated via `Box::into_raw` and reclaimed via `Box::from_raw` in `WindowContext::take_data`. Do not free it any other way.
- The `module_data` flexible array field on `Window` is a C ABI contract with gtklock — index 0 is this module's slot.
## Tests
`tests/ahfail_tests.rs` — integration tests that construct mock `Window` / `GtkLock` structs directly to exercise handler logic without a running gtklock process. Run with `cargo test`.
`tests/benchmarks.rs` — criterion benchmarks for sprite placement.
`tests/module_test.c` — C smoke test built by Meson that dlopen-style verifies the exported symbols exist.

879
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,8 @@
[package]
name = "ahfail"
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"
once_cell = "1.10"
rand = "0.8"
[build-dependencies]
pkg-config = "0.3"
[workspace]
members = [
"crates/ahfail-ui",
"crates/ahfail-gtklock",
"crates/ahfail-pam",
"crates/ahfail-display",
]
resolver = "2"

56
GEMINI.md Normal file
View File

@@ -0,0 +1,56 @@
# GEMINI.md
## Project Overview
This project is a `gtklock` module named `ahfail`, written in Rust (using `gtk-rs`) with a Meson build system.
The module listens for failed unlock attempts (`PW_FAILURE`) in `gtklock`. Upon failure, it:
1. Spawns a looping "Nedry" sprite animation at a random screen location.
2. Plays an audio clip ("ah ah ah, you didn't say the magic word").
3. Avoids placing sprites in a user-configurable "deadzone".
All assets (images and audio) are compiled into the module binary as GResources.
## Build Architecture
* **Meson:** The primary build system. It handles:
* Compiling GResources (`assets/ahfail.gresource.xml`).
* Invoking Cargo to build the Rust code as a static library (`libahfail_module.a`).
* Linking the Rust static library, GResources, and C dependencies into the final shared object (`ahfail-module.so`).
* **Cargo:** Handles the Rust source code, dependencies (`gtk`, `gdk`, `gstreamer`), and tests.
## Key Files
* `src/lib.rs`: FFI entry points (`on_activation`, `on_window_create`, etc.) exported to C.
* `src/handler.rs`: Main logic for sprite placement and audio playback.
* `src/config.rs`: Argument parsing logic.
* `tests/ahfail_tests.rs`: Comprehensive integration tests mocking `gtklock` behavior.
* `meson.build`: Build definition bridging C and Rust.
## Building and Running
### Prerequisites
* Meson, Ninja, Rust (Cargo)
* `gtk3` development headers
* `gstreamer` + `gst-plugins-base` + `gst-plugins-good` (runtime)
### Commands
```bash
# Setup
meson setup builddir
# Build
meson compile -C builddir
# Test (Rust logic)
cargo test
# Run (Manual test)
gtklock -d -m builddir/ahfail-module.so -- --deadzone=X,Y,W,H
```
## Development Conventions
* **Safety:** Use `WindowContext` wrappers in `src/context.rs` to handle unsafe `Window` pointers.
* **State:** `MODULE_STATE` (thread-local) holds global config/assets. `WindowData` (heap-allocated) holds per-window sprites and players.
* **Tests:** `tests/ahfail_tests.rs` contains integration tests that mock `gtklock` structures. Run with `cargo test`.

View File

@@ -0,0 +1,19 @@
[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"] }
glib = { version = "0.15", package = "glib" }
libc = "0.2"
[build-dependencies]
cc = "1"
pkg-config = "0.3"

View File

@@ -0,0 +1,52 @@
use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Workspace root is two levels up from crates/ahfail-display/
let workspace_root = manifest_dir.join("../..").canonicalize().unwrap();
let assets_dir = workspace_root.join("assets");
let gresource_xml = assets_dir.join("ahfail.gresource.xml");
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let c_src = out_dir.join("ahfail-resources.c");
// Re-run if any asset changes
println!("cargo:rerun-if-changed={}", gresource_xml.display());
for entry in std::fs::read_dir(&assets_dir).unwrap() {
let entry = entry.unwrap();
println!("cargo:rerun-if-changed={}", entry.path().display());
}
let status = Command::new("glib-compile-resources")
.args([
"--generate-source",
"--target",
c_src.to_str().unwrap(),
"--sourcedir",
assets_dir.to_str().unwrap(),
gresource_xml.to_str().unwrap(),
])
.status()
.expect("glib-compile-resources not found — install libglib2.0-dev or equivalent");
assert!(status.success(), "glib-compile-resources failed");
// Use pkg-config to get include flags for gio-2.0
let gio_cflags = Command::new("pkg-config")
.args(["--cflags", "gio-2.0"])
.output()
.expect("pkg-config not found");
assert!(gio_cflags.status.success(), "pkg-config --cflags gio-2.0 failed");
let gio_cflags_str = String::from_utf8(gio_cflags.stdout).unwrap();
let mut build = cc::Build::new();
build.file(&c_src).flag_if_supported("-w"); // suppress warnings in generated code
for flag in gio_cflags_str.split_whitespace() {
build.flag(flag);
}
build.compile("ahfail_resources");
// Link against gio-2.0 for GResource support
println!("cargo:rustc-link-lib=gio-2.0");
}

View File

@@ -0,0 +1,112 @@
use gtk::prelude::*;
use gtk::gdk;
use gstreamer as gst;
use std::sync::atomic::{AtomicBool, Ordering};
const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
const FAILSAFE_MINUTES: u32 = 15;
static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigterm(_: libc::c_int) {
SIGTERM_RECEIVED.store(true, Ordering::Relaxed);
}
fn main() {
unsafe {
// SAFETY: handle_sigterm only stores to an AtomicBool — async-signal-safe.
// The *const () intermediate avoids a "direct cast to integer" warning because
// libc::sighandler_t is size_t on Linux.
libc::signal(libc::SIGTERM, handle_sigterm as *const () 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;
}
let animation = unsafe { ahfail_ui::animation::load_animation() };
let Some(animation) = animation else {
eprintln!("[ahfail-display] No animation frames found");
return;
};
let Some(display) = gdk::Display::default() else {
eprintln!("[ahfail-display] No display");
return;
};
let Some(monitor) = display.primary_monitor().or_else(|| display.monitor(0)) else {
eprintln!("[ahfail-display] No monitor");
return;
};
let geom = monitor.geometry();
let screen_w = geom.width();
let screen_h = geom.height();
// On X11, WindowType::Popup creates an unmanaged override-redirect window (desired).
// On Wayland, GTK3 falls back to a normal xdg_toplevel; the compositor controls stacking.
let window = gtk::Window::new(gtk::WindowType::Popup);
window.set_decorated(false);
window.set_keep_above(true);
window.set_skip_taskbar_hint(true);
window.move_(geom.x(), geom.y());
window.set_default_size(screen_w, screen_h);
let fixed = gtk::Fixed::new();
fixed.set_size_request(screen_w, screen_h);
window.add(&fixed);
let config = parse_args();
ahfail_ui::display::place_sprite(&fixed, &animation, screen_w, screen_h, &config);
let player = ahfail_ui::audio::create_player(AUDIO_URI);
player.play();
std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION));
// All setup succeeded — acquire volume lock now so early-exit paths above don't leave it held.
let volume_state = ahfail_ui::volume::save_and_set_max();
glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || {
gtk::main_quit();
glib::Continue(false)
});
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();
player.stop();
if let Some(vs) = volume_state {
ahfail_ui::volume::restore(vs);
}
}
fn parse_args() -> ahfail_ui::config::ModuleConfig {
let deadzone = std::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 }
}

View File

@@ -0,0 +1,21 @@
[package]
name = "ahfail-gtklock"
version = "0.1.0"
edition = "2021"
[lib]
name = "ahfail_module"
crate-type = ["staticlib", "rlib"]
[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" }
gdk-pixbuf = "0.15"
rand = "0.8"
[build-dependencies]
pkg-config = "0.3"

View File

@@ -0,0 +1 @@
pub use ahfail_ui::config::*;

View File

@@ -1,5 +1,4 @@
use gtk::prelude::*; // Keep this if extension traits are needed, otherwise remove. Window uses it.
use gtk::{glib, gdk}; // Window uses gdk, glib.
use gtk::{glib, gdk};
use gtk::gdk::Monitor;
use std::ffi::c_void;
use std::marker::PhantomData;

View File

@@ -1,15 +1,8 @@
use gtk::prelude::*;
use gtk::{glib, gdk, gdk_pixbuf, gio};
use gstreamer as gst;
use gstreamer_player as gst_player;
use rand::Rng;
use crate::state::{MODULE_STATE, WindowData};
use crate::context::WindowContext;
const SPRITE_MARGIN: i32 = 100;
const SPRITE_SCALE: f64 = 0.6;
const PLAYER_POOL_SIZE: usize = 3;
const RETRY_ATTEMPTS: usize = 10;
pub struct WindowHandler;
@@ -39,7 +32,7 @@ impl WindowHandler {
MODULE_STATE.with(|state| {
if let Some(audio_uri) = &state.borrow().audio_uri {
for _ in 0..PLAYER_POOL_SIZE {
ready_players.push(Self::create_player(audio_uri));
ready_players.push(ahfail_ui::audio::create_player(audio_uri));
}
}
});
@@ -63,66 +56,40 @@ impl WindowHandler {
}
println!("[ahfail] Error label changed to: '{}'", text_str);
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();
}
});
MODULE_STATE.with(|state| {
let state = state.borrow();
if let (Some(animation), Some(audio_uri)) = (&state.animation, &state.audio_uri) {
let data = unsafe { &mut *(ptr_addr as *mut WindowData) };
let image = gtk::Image::from_animation(animation);
image.show();
let sprite_w = animation.width();
let sprite_h = animation.height();
let mut rng = rand::thread_rng();
let safe_w = screen_w - SPRITE_MARGIN;
let safe_h = screen_h - SPRITE_MARGIN;
let max_x = if safe_w > sprite_w { safe_w - sprite_w } else { 0 };
let max_y = if safe_h > sprite_h { safe_h - sprite_h } else { 0 };
let mut x = 0;
let mut y = 0;
let mut found_safe_spot = false;
for _ in 0..RETRY_ATTEMPTS {
x = rng.gen_range(0..=max_x);
y = rng.gen_range(0..=max_y);
if let Some(deadzone) = &state.config.deadzone {
let sprite_rect = gdk::Rectangle::new(x, y, sprite_w, sprite_h);
if deadzone.intersect(&sprite_rect).is_none() {
found_safe_spot = true;
break;
}
} else {
found_safe_spot = true;
break;
}
}
if !found_safe_spot {
println!("[ahfail] Could not find safe spot after retries, placing anyway");
}
println!("[ahfail] Placing sprite at ({}, {})", x, y);
data.fixed.put(&image, x, y);
let image = ahfail_ui::display::place_sprite(
&data.fixed, animation, screen_w, screen_h, &state.config
);
data.sprites.push(image);
let player = if let Some(p) = data.ready_players.pop() {
p
} else {
Self::create_player(audio_uri)
ahfail_ui::audio::create_player(audio_uri)
};
player.play();
data.active_players.push(player);
let new_player = Self::create_player(audio_uri);
let new_player = ahfail_ui::audio::create_player(audio_uri);
data.ready_players.push(new_player);
}
});
std::thread::spawn(|| {
ahfail_ui::update::check_for_update(ahfail_ui::VERSION);
});
});
unsafe {
@@ -150,16 +117,5 @@ impl WindowHandler {
}
}
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(gst::ClockTime::from_seconds(0));
}));
player.connect_error(|_, err| {
eprintln!("[ahfail] GStreamer Player Error: {}", err);
});
player
}
}

View File

@@ -3,12 +3,9 @@ pub mod context;
pub mod state;
pub mod handler;
use gtk::prelude::*;
use gtk::{glib, gdk_pixbuf, gio};
use gtk::gdk_pixbuf::InterpType;
use gtk::glib;
use gstreamer as gst;
use std::ffi::{c_void, CStr};
use glib::translate::from_glib_none;
use std::ffi::c_void;
use std::os::raw::{c_char, c_int, c_uint};
use std::ptr;
@@ -18,9 +15,6 @@ pub use context::{Window, GtkLock, WindowContext, __IncompleteArrayField};
pub use state::{MODULE_STATE, WindowData, ModuleState};
pub use handler::WindowHandler;
// Scale factor to reduce the image size and avoid cropping/overlap
const SPRITE_SCALE: f64 = 0.6;
#[no_mangle]
pub static module_name: [c_char; 7] = [
b'a' as c_char,
@@ -45,7 +39,7 @@ pub static mut module_entries: [glib::ffi::GOptionEntry; 3] = [
short_name: 0,
flags: 0,
arg: glib::ffi::G_OPTION_ARG_STRING,
arg_data: unsafe { &raw mut DEADZONE_ARG as *mut _ },
arg_data: &raw mut DEADZONE_ARG as *mut _,
description: DEADZONE_DESC.as_ptr() as *const c_char,
arg_description: DEADZONE_ARG_DESC.as_ptr() as *const c_char
},
@@ -53,10 +47,6 @@ pub static mut module_entries: [glib::ffi::GOptionEntry; 3] = [
glib::ffi::GOptionEntry { long_name: ptr::null(), short_name: 0, flags: 0, arg: 0, arg_data: ptr::null_mut(), description: ptr::null(), arg_description: ptr::null() },
];
extern "C" {
fn ahfail_get_resource() -> *mut gio::ffi::GResource;
}
// Helper for tests to inspect window data
/// # Safety
/// `ctx` must be a valid Window pointer.
@@ -83,47 +73,7 @@ pub unsafe extern "C" fn on_activation(_gtklock: *mut GtkLock, _id: c_int) {
return;
}
let resource_ptr = ahfail_get_resource();
if !resource_ptr.is_null() {
let resource = from_glib_none::<_, gio::Resource>(resource_ptr);
gio::resources_register(&resource);
}
// Load frames
let mut loaded_frames: Vec<gdk_pixbuf::Pixbuf> = Vec::new();
match gio::resources_enumerate_children("/ahfail/sprites", gio::ResourceLookupFlags::NONE) {
Ok(mut frames) => {
frames.sort();
for frame_path in frames {
let full_path = format!("/ahfail/sprites/{}", frame_path);
match gdk_pixbuf::Pixbuf::from_resource(&full_path) {
Ok(pixbuf) => {
let w = (pixbuf.width() as f64 * SPRITE_SCALE) as i32;
let h = (pixbuf.height() as f64 * SPRITE_SCALE) as i32;
if let Some(scaled) = pixbuf.scale_simple(w, h, InterpType::Bilinear) {
loaded_frames.push(scaled);
} else {
loaded_frames.push(pixbuf);
}
},
Err(e) => eprintln!("Failed to load sprite frame {}: {}", full_path, e),
}
}
},
Err(e) => eprintln!("Failed to enumerate sprites: {}", e),
}
let anim_opt = if !loaded_frames.is_empty() {
let first = &loaded_frames[0];
let anim = gdk_pixbuf::PixbufSimpleAnim::new(first.width(), first.height(), 12.0);
anim.set_loop(true);
for frame in loaded_frames {
anim.add_frame(&frame);
}
Some(anim.upcast())
} else {
None
};
let anim_opt = unsafe { ahfail_ui::animation::load_animation() };
let config = ModuleConfig::from_args();
@@ -186,6 +136,9 @@ pub unsafe extern "C" fn on_idle_show(_gtklock: *mut GtkLock) {}
pub unsafe extern "C" fn g_module_unload(_module: *mut c_void) {
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;

View File

@@ -7,6 +7,7 @@ pub struct ModuleState {
pub animation: Option<gdk_pixbuf::PixbufAnimation>,
pub audio_uri: Option<String>,
pub config: ModuleConfig,
pub volume_state: Option<ahfail_ui::volume::VolumeState>,
}
pub struct WindowData {
@@ -22,5 +23,6 @@ thread_local! {
animation: None,
audio_uri: None,
config: ModuleConfig { deadzone: None },
volume_state: None,
}) };
}

View File

@@ -241,7 +241,7 @@ fn run_test_09_idle_hide_cleanup() {
flush_events();
// Mock GtkLock struct
let mut windows_array = glib::ffi::g_array_new(0, 0, std::mem::size_of::<*mut Window>() as u32);
let windows_array = glib::ffi::g_array_new(0, 0, std::mem::size_of::<*mut Window>() as u32);
glib::ffi::g_array_append_vals(windows_array, &ctx_ptr as *const _ as *const c_void, 1);
let mut lock = GtkLock {

View File

@@ -1,5 +1,4 @@
use gtk::prelude::*;
use gtk::{gdk, gdk_pixbuf};
use gtk::gdk_pixbuf;
use gstreamer as gst;
use gstreamer_player as gst_player;
use std::time::Instant;

View File

@@ -0,0 +1,11 @@
[package]
name = "ahfail-pam"
version = "0.1.0"
edition = "2021"
[lib]
name = "ahfail_pam"
crate-type = ["cdylib", "rlib"]
[dependencies]
libc = "0.2"

View File

@@ -0,0 +1,9 @@
fn main() {
println!("cargo:rustc-link-lib=pam");
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch
// systems (e.g. Debian/Ubuntu where libdir is /usr/lib/x86_64-linux-gnu).
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back to /usr/lib otherwise.
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| "/usr/lib".to_string());
println!("cargo:rustc-env=AHFAIL_INSTALL_DIR={}/ahfail", libdir);
}

View File

@@ -0,0 +1,194 @@
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(not(any(target_os = "linux", target_os = "macos")))]
pub const PAM_AUTH_ERR: c_int = 7;
#[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;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
// Default install path for ahfail-display.
// On Linux: built from AHFAIL_INSTALL_DIR emitted by build.rs, which reads AHFAIL_LIBDIR
// passed by Meson — correct on multiarch systems (e.g. /usr/lib/x86_64-linux-gnu/ahfail).
#[cfg(target_os = "macos")]
const DEFAULT_PATH: &str = "/usr/local/lib/ahfail/ahfail-display";
#[cfg(not(target_os = "macos"))]
const DEFAULT_PATH: &str = concat!(env!("AHFAIL_INSTALL_DIR"), "/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,
) {
// Reclaim any stored path string to avoid a leak (PAM guarantees this fires exactly once).
let path_override: Option<String> = if data.is_null() {
None
} else {
Some(*Box::from_raw(data as *mut String))
};
if is_replace(error_status) {
// Data replaced — a new pam_set_data call overwrote ours. Only spawn on failure;
// on success (PAM_SUCCESS | PAM_DATA_REPLACE) we do nothing.
if is_failure(error_status) {
spawn_display(path_override);
}
return;
}
if is_failure(error_status) {
spawn_display(path_override);
} 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 {
if pamh.is_null() { return PAM_IGNORE; }
let args = argc_argv(argc, argv);
let display_path = read_display_path_arg(args);
let key = match CString::new("dk.weircon.ahfail") {
Ok(k) => k,
Err(_) => return PAM_IGNORE,
};
// Store optional path override as heap data for the cleanup function.
let path_ptr: *mut c_void = match display_path {
Some(p) => Box::into_raw(Box::new(p)) as *mut c_void,
None => std::ptr::null_mut(),
};
let ret = pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup));
if ret != PAM_SUCCESS && !path_ptr.is_null() {
// pam_set_data failed — cleanup will never fire, so free the Box ourselves.
drop(Box::from_raw(path_ptr as *mut String));
}
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: grandchild adopted by init, PAM stack not blocked.
unsafe {
let pid: pid_t = libc::fork();
if pid < 0 { return; }
if pid > 0 {
// Parent: wait for intermediate to exit immediately.
libc::waitpid(pid, std::ptr::null_mut(), 0);
return;
}
// Intermediate: fork again then exit immediately.
let pid2: pid_t = libc::fork();
if pid2 != 0 { libc::_exit(0); }
// Grandchild: detach from PAM daemon's session, close all inherited fds, exec.
libc::setsid();
let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as c_int;
for fd in 0..max_fd.min(4096) {
libc::close(fd);
}
let args: [*const c_char; 2] = [cpath.as_ptr(), std::ptr::null()];
libc::execv(cpath.as_ptr(), args.as_ptr());
libc::_exit(1);
}
}
fn kill_display() {
// SIGTERM all ahfail-display processes owned by the current user.
let uid = unsafe { libc::getuid() }.to_string();
let _ = std::process::Command::new("pkill")
.args(["-SIGTERM", "-u", &uid, "ahfail-display"])
.spawn();
}

View File

@@ -0,0 +1,21 @@
use ahfail_pam::{is_failure, is_success, is_replace, PAM_AUTH_ERR, PAM_SUCCESS, PAM_DATA_REPLACE};
#[test]
fn status_classification() {
assert!(is_failure(PAM_AUTH_ERR));
assert!(is_success(PAM_SUCCESS));
assert!(!is_failure(PAM_SUCCESS));
assert!(!is_success(PAM_AUTH_ERR));
}
#[test]
fn replace_flag_detected() {
let replace_status = PAM_AUTH_ERR | PAM_DATA_REPLACE;
assert!(is_replace(replace_status));
assert!(!is_replace(PAM_SUCCESS));
}
#[test]
fn display_path_default_is_set() {
assert!(!ahfail_pam::default_display_path().is_empty());
}

View File

@@ -0,0 +1,23 @@
[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"
libc = "0.2"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,43 @@
use gtk::{gdk_pixbuf, gio};
use gdk_pixbuf::InterpType;
use glib::Cast;
const SPRITE_SCALE: f64 = 0.6;
extern "C" {
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())
}

View File

@@ -0,0 +1,12 @@
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
}

View File

@@ -1,4 +1,4 @@
use gtk::{glib, gdk};
use gtk::gdk;
use std::ffi::CStr;
use std::ptr;

View File

@@ -0,0 +1,41 @@
use gtk::{gdk_pixbuf, prelude::*};
use rand::Rng;
use crate::config::ModuleConfig;
const SPRITE_MARGIN: i32 = 100;
const RETRY_ATTEMPTS: usize = 10;
pub fn place_sprite(
fixed: &gtk::Fixed,
animation: &gdk_pixbuf::PixbufAnimation,
screen_w: i32,
screen_h: i32,
config: &ModuleConfig,
) -> gtk::Image {
let sprite_w = animation.width();
let sprite_h = animation.height();
let max_x = screen_w.saturating_sub(SPRITE_MARGIN).saturating_sub(sprite_w).max(0);
let max_y = screen_h.saturating_sub(SPRITE_MARGIN).saturating_sub(sprite_h).max(0);
let mut rng = rand::thread_rng();
let mut x = rng.gen_range(0..=max_x);
let mut y = rng.gen_range(0..=max_y);
if let Some(dz) = config.deadzone {
for _ in 0..RETRY_ATTEMPTS {
let overlaps_x = x < dz.x() + dz.width() && x + sprite_w > dz.x();
let overlaps_y = y < dz.y() + dz.height() && y + sprite_h > dz.y();
if !(overlaps_x && overlaps_y) {
break;
}
x = rng.gen_range(0..=max_x);
y = rng.gen_range(0..=max_y);
}
}
let image = gtk::Image::from_animation(animation);
image.show();
fixed.put(&image, x, y);
image
}

View File

@@ -0,0 +1,8 @@
pub mod animation;
pub mod audio;
pub mod config;
pub mod display;
pub mod update;
pub mod volume;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -0,0 +1,115 @@
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::SystemTime;
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;
// Guards against multiple windows spawning duplicate checks on the same failure.
static CHECK_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
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(|_| {
let uid = unsafe { libc::getuid() };
PathBuf::from(format!("/tmp/ahfail-cache-{uid}"))
});
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])
.status();
#[cfg(target_os = "macos")]
let _ = Command::new("osascript")
.args(["-e", &format!(
"display notification \"{}\" with title \"ahfail\"",
UPDATE_NOTIFY_MSG
)])
.status();
}
/// 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; }
// Prevent concurrent threads (e.g. multi-monitor) from all firing at once.
if CHECK_IN_FLIGHT.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
return;
}
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout_read(std::time::Duration::from_secs(10))
.build();
let Ok(resp) = agent.get(GITEA_API).call() else {
CHECK_IN_FLIGHT.store(false, Ordering::SeqCst);
return;
};
touch_cache();
let Ok(body) = resp.into_string() else {
CHECK_IN_FLIGHT.store(false, Ordering::SeqCst);
return;
};
CHECK_IN_FLIGHT.store(false, Ordering::SeqCst);
if let Some(tag) = extract_tag_name(&body) {
if is_newer(&tag, current_version) {
send_notification();
}
}
}
fn extract_tag_name(json: &str) -> Option<String> {
// Handle both compact ("tag_name":"v1.0") and spaced ("tag_name": "v1.0") JSON.
let key = "\"tag_name\"";
let after_key = json.find(key)? + key.len();
let rest = json[after_key..].trim_start_matches(|c: char| c == ':' || c.is_ascii_whitespace());
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}

View File

@@ -0,0 +1,263 @@
use std::path::PathBuf;
use std::process::Command;
use std::fs;
use std::io::Write;
pub struct VolumeState {
pub volume: u32,
pub muted: bool,
pub lock_path: PathBuf,
}
// Lock file format: "{pid}:{volume}:{muted}\n"
// Written atomically on acquire; persists volume state so it survives SIGKILL.
fn lock_content(pid: u32, volume: u32, muted: bool) -> String {
format!("{}:{}:{}\n", pid, volume, u8::from(muted))
}
fn parse_lock_content(s: &str) -> Option<(u32, u32, bool)> {
let mut parts = s.trim().splitn(3, ':');
let pid: u32 = parts.next()?.parse().ok()?;
let vol: u32 = parts.next()?.parse().ok().filter(|&v| v <= 100)?;
let muted: bool = parts.next()?.trim() == "1";
Some((pid, vol, muted))
}
fn pid_is_alive(pid: u32) -> bool {
let pid_t = pid as libc::pid_t;
if pid_t <= 0 {
return false;
}
// kill(pid, 0) returns 0 if the process exists, -1 (ESRCH) if not.
unsafe { libc::kill(pid_t, 0) == 0 }
}
/// 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", get_uid()))
}
}
fn get_uid() -> u32 {
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).
///
/// Uses a PID-based lock file that persists the saved volume state. If a stale lock
/// exists (holder process dead), recovers the saved volume from the file — preventing
/// permanent volume loss after SIGKILL — and takes ownership.
pub fn save_and_set_max() -> Option<VolumeState> {
let path = lock_path();
let our_pid = std::process::id();
// Try atomic create (O_CREAT | O_EXCL — race-free on POSIX local filesystems).
if fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map(|mut f| { let _ = f.write_all(b"0:100:0\n"); })
.is_ok()
{
// We are primary. Read current volume, overwrite placeholder, set max.
let (volume, muted) = get_current_volume();
let _ = fs::write(&path, lock_content(our_pid, volume, muted));
set_volume_max();
return Some(VolumeState { volume, muted, lock_path: path });
}
// File exists — check if the holder PID is still alive.
if let Ok(existing) = fs::read_to_string(&path) {
if let Some((holder_pid, saved_vol, saved_muted)) = parse_lock_content(&existing) {
if !pid_is_alive(holder_pid) {
// Stale lock: recover saved volume state and take ownership.
// The previous holder already set volume to max, so we skip set_volume_max().
let content = lock_content(our_pid, saved_vol, saved_muted);
if fs::write(&path, content).is_ok() {
return Some(VolumeState { volume: saved_vol, muted: saved_muted, lock_path: path });
}
}
}
}
None
}
/// 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);
}
#[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> {
for token in output.split_whitespace() {
if let Some(pct) = token.strip_suffix('%') {
if let Ok(v) = pct.parse::<u32>() {
if v <= 100 {
return Some(v);
}
}
}
}
None
}
#[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();
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn get_current_volume() -> (u32, bool) { (100, false) }
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn set_volume_max() {}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn restore_volume(_volume: u32, _muted: bool) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_lock_content_roundtrip() {
let content = lock_content(1234, 75, true);
let (pid, vol, muted) = parse_lock_content(&content).unwrap();
assert_eq!(pid, 1234);
assert_eq!(vol, 75);
assert!(muted);
}
#[test]
fn parse_lock_content_rejects_invalid_volume() {
assert!(parse_lock_content("123:101:0").is_none());
assert!(parse_lock_content("123:abc:0").is_none());
}
#[test]
fn pid_is_alive_current_process() {
assert!(pid_is_alive(std::process::id()));
}
#[test]
fn pid_is_alive_zero_is_not_treated_as_alive() {
// PID 0 has special kill() semantics; we guard against it.
assert!(!pid_is_alive(0));
}
#[test]
fn fresh_lock_creates_file_and_stale_is_recovered() {
let dir = tempfile::tempdir().unwrap();
let lock = dir.path().join("ahfail.lock");
// Write a stale lock file: PID 99_999_999 is above Linux's max_pid (4_194_304)
// and will always fail kill(pid, 0) with ESRCH.
std::fs::write(&lock, "99999999:60:1\n").unwrap();
// Simulate save_and_set_max() stale-recovery path directly.
let our_pid = std::process::id();
if let Ok(existing) = std::fs::read_to_string(&lock) {
if let Some((holder_pid, saved_vol, saved_muted)) = parse_lock_content(&existing) {
assert!(!pid_is_alive(holder_pid), "test PID should not be alive");
let content = lock_content(our_pid, saved_vol, saved_muted);
std::fs::write(&lock, content).unwrap();
let (pid, vol, muted) = parse_lock_content(&std::fs::read_to_string(&lock).unwrap()).unwrap();
assert_eq!(pid, our_pid);
assert_eq!(vol, 60);
assert!(muted);
} else {
panic!("parse_lock_content failed");
}
}
}
#[test]
fn second_acquisition_blocked_by_alive_pid() {
let dir = tempfile::tempdir().unwrap();
let lock = dir.path().join("ahfail.lock");
// Write a lock file held by our own (alive) PID.
let our_pid = std::process::id();
std::fs::write(&lock, lock_content(our_pid, 80, false)).unwrap();
// Attempt to acquire — should be blocked because holder is alive.
if let Ok(existing) = std::fs::read_to_string(&lock) {
if let Some((holder_pid, _, _)) = parse_lock_content(&existing) {
assert!(pid_is_alive(holder_pid), "our own PID should be alive");
}
}
}
}

View File

@@ -0,0 +1,20 @@
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"));
}
#[test]
fn multi_digit_minor_version() {
assert!(is_newer("v0.10.0", "v0.9.0"));
assert!(!is_newer("v0.9.0", "v0.10.0"));
}

View File

@@ -0,0 +1,19 @@
// Lock file behavior is tested via unit tests in src/volume.rs.
// Integration-level: verify that a fresh save_and_set_max() call creates the lock file.
// (pactl/osascript may not be available in CI — volume operations are best-effort.)
#[test]
fn save_and_set_max_creates_lock_file() {
// Override XDG_RUNTIME_DIR so we use a temp path, not the real user's runtime dir.
let dir = tempfile::tempdir().unwrap();
std::env::set_var("XDG_RUNTIME_DIR", dir.path());
let result = ahfail_ui::volume::save_and_set_max();
// Should succeed (lock file didn't exist).
assert!(result.is_some());
assert!(dir.path().join("ahfail.lock").exists());
// Cleanup: restore() removes the lock file.
ahfail_ui::volume::restore(result.unwrap());
assert!(!dir.path().join("ahfail.lock").exists());
}

View File

@@ -0,0 +1,180 @@
# Design: PAM module + cross-platform support
**Date:** 2026-05-05
**Status:** Approved
## Problem
`ahfail-module.so` is a gtklock-specific plugin. It works only on Wayland via gtklock. The goal is to also support X11 Linux lock screens and macOS, while keeping the existing Wayland/gtklock integration intact.
## Decision: Approach A — Two integration points, shared core
Keep the gtklock module for Wayland. Add a PAM module for X11 Linux and macOS. Share all display/audio/update logic through a common `ahfail-ui` crate.
| Platform | Lock screen | Integration | Display |
|----------|------------|-------------|---------|
| Wayland | gtklock | `ahfail-module.so` (gtklock module, kept) | inline GTK overlay via `ahfail-ui` |
| X11 Linux | any PAM locker (i3lock, xscreensaver…) | `ahfail-pam.so` | `ahfail-display` binary via `ahfail-ui` |
| macOS | loginwindow / screensaver | `ahfail-pam.so` | `ahfail-display` binary via `ahfail-ui` |
macOS CI is out of scope — users build from source via Homebrew. Linux x86_64 release binaries are produced by Gitea Actions.
---
## Cargo workspace layout
```
ahfail/
├── Cargo.toml ← workspace root
├── meson.build ← updated for new crate layout
├── crates/
│ ├── ahfail-gtklock/ → ahfail-module.so (existing module, moved)
│ ├── ahfail-pam/ → ahfail-pam.so (new)
│ ├── ahfail-display/ → ahfail-display (new binary)
│ └── ahfail-ui/ → lib (shared display/audio/update logic)
├── assets/ ← unchanged
├── include/ ← unchanged
└── .gitea/workflows/
├── test.yml
└── release.yml
```
**Dependency graph:**
- `ahfail-gtklock``ahfail-ui`
- `ahfail-display``ahfail-ui`
- `ahfail-pam` → nothing (no GTK/GStreamer; minimal by design)
---
## Section 1: `ahfail-ui` (shared crate)
Contains everything that `ahfail-gtklock` and `ahfail-display` share:
- Animation loading (GResources → `PixbufSimpleAnim`, `set_loop(true)`)
- GTK display: `show_animation_on_overlay(overlay, monitor, deadzone)` — works for both an in-process gtklock overlay and a standalone window
- GStreamer audio playback (player pool, seek-to-0 on end-of-stream loop)
- Deadzone logic (random placement with retry, `--deadzone=x,y,w,h`)
- Update check (see below)
- System volume save/restore (`pactl` on Linux, `osascript` on macOS)
---
## Section 2: PAM module (`ahfail-pam`)
A minimal C-ABI shared library. No GTK. No GStreamer. No credential handling.
**Security principle:** we never touch `PAM_AUTHTOK` or any credential. All authentication is performed exclusively by the native host (pam_unix, pam_opendirectory, etc.). We are a pure side-effect observer.
**`pam_sm_authenticate`:**
1. Register a cleanup function via `pam_set_data(pamh, "ahfail", …, cleanup_fn)`
2. Return `PAM_IGNORE`
All other `pam_sm_*` functions return `PAM_IGNORE`.
**Cleanup function fires in two cases:**
| `error_status` | Meaning | Action |
|---------------|---------|--------|
| `PAM_DATA_REPLACE` set | `pam_sm_authenticate` called again (previous attempt failed, same handle) | spawn `ahfail-display` |
| `PAM_AUTH_ERR` (no replace) | final failure from `pam_end()` | spawn `ahfail-display` |
| `PAM_SUCCESS` (no replace) | auth succeeded from `pam_end()` | `pkill -SIGTERM ahfail-display`; restore volume via state file |
This handles both lock screens that loop `pam_authenticate()` on one handle and those that create a fresh PAM transaction per attempt.
**Finding `ahfail-display`:** compile-time default path (`/usr/lib/ahfail/ahfail-display` on Linux, `/usr/local/lib/ahfail/ahfail-display` on macOS), overridable via PAM argument `display_path=/custom/path`.
**PAM config lines:**
```
# Linux — /etc/pam.d/gtklock (or i3lock, xscreensaver, etc.)
auth optional ahfail-pam.so
# macOS — /etc/pam.d/screensaver
auth optional ahfail-pam.so display_path=/usr/local/lib/ahfail/ahfail-display
```
---
## Section 3: Display binary (`ahfail-display`)
Spawned by `ahfail-pam.so` via double-fork (fully detached). Uses `ahfail-ui` for all rendering.
**Startup sequence:**
1. Initialise GTK + GStreamer
2. Register GResources (same embedded assets as gtklock module)
3. Acquire volume lock: atomically create `$XDG_RUNTIME_DIR/ahfail.lock`
- If created (first instance): save current volume/mute state into the file, set system volume to 100% unmuted
- If already exists: skip volume management
4. Open floating, decoration-free, always-on-top GTK window
5. Call `ahfail-ui::show_animation_on_overlay(...)` — places Nedry at random position respecting `--deadzone`
6. Loop animation (`PixbufSimpleAnim`, `set_loop(true)`) and audio (GStreamer, seek-to-0 on end-of-stream)
7. Concurrently: run update check in a background thread (see below)
8. Block on GTK main loop until `SIGTERM` or 15-minute failsafe timeout
**On `SIGTERM` (clean exit):**
- If holding volume lock: restore volume/mute state from lock file, delete lock file
- Exit
**Multiple instances:** each failed attempt spawns one additional `ahfail-display`. Each shows one Nedry sprite independently. Only the first instance (lock file holder) manages volume. On success, `pkill -SIGTERM ahfail-display` terminates all instances simultaneously.
**Volume control:**
- Linux: `pactl set-sink-mute @DEFAULT_SINK@ 0 && pactl set-sink-volume @DEFAULT_SINK@ 100%`
- macOS: `osascript -e 'set volume output volume 100 without output muted'`
- Restore: reverse using saved state from lock file
**Update check (background thread):**
- Calls `https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest`, parses `tag_name`
- Rate-limited: skips network call if `~/.cache/ahfail/last_update_check` was written within 24 hours
- If newer version found: fires desktop notification
- Linux: `notify-send "ahfail" "Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases"`
- macOS: `osascript -e 'display notification "..." with title "ahfail"'`
- Fails silently on any error (no network, bad response, parse failure)
---
## Section 4: gtklock module (`ahfail-gtklock`)
The existing module, moved to `crates/ahfail-gtklock/`. Integration mechanism is unchanged.
**Changes from current `src/`:**
- Display/audio logic extracted into `ahfail-ui`; module calls `ahfail-ui` functions instead of implementing them inline
- Volume: on first `error_label` signal (first failure), acquire volume lock and set to 100%. On `on_window_destroy`, restore volume.
- Update check: called from `ahfail-ui` on each failure (same shared code path as display binary)
- Multiple Nerdys on multiple failures: unchanged — each `error_label` signal adds a new sprite to the overlay
---
## Section 5: Gitea CI/CD
**`.gitea/workflows/test.yml`** — on push and pull request:
```
deps: libgtk-3-dev libgstreamer1.0-dev gstreamer-plugins-base libpam0g-dev
→ cargo test
→ meson setup builddir && meson compile -C builddir
→ meson test -C builddir
```
**`.gitea/workflows/release.yml`** — on `v*` tag push:
```
→ same build with --buildtype=release
→ bundle into ahfail-linux-x86_64.tar.gz:
ahfail-module.so (gtklock/Wayland)
ahfail-pam.so (PAM/X11+macOS)
ahfail-display (display binary)
→ create Gitea release + upload asset via curl to Gitea API
using GITEA_TOKEN secret
```
**macOS:** no CI. README documents:
```bash
brew install gtk+3 gstreamer gst-plugins-good gst-plugins-base
cargo build --release
meson setup builddir && meson compile -C builddir
```
---
## Out of scope
- macOS CI / cross-compilation (SDK licensing + native dep complexity)
- Supporting Wayland compositors other than gtklock (protocol blocks external surfaces during lock)
- Auto-installing updates (notification only)

File diff suppressed because it is too large Load Diff

View File

@@ -20,16 +20,48 @@ resources = gnome.compile_resources(
c_name: 'ahfail'
)
# Each cargo target uses its own --target-dir to avoid parallel lock contention.
cargo_target = custom_target(
'ahfail-cargo-build',
input: ['src/lib.rs', 'Cargo.toml'],
input: ['crates/ahfail-gtklock/src/lib.rs', 'Cargo.toml'],
output: ['libahfail_module.a'],
command: [
'sh', '-c', 'cargo build --release --target-dir "@OUTDIR@/target" && cp "@OUTDIR@/target/release/libahfail_module.a" "@OUTPUT@"'
'sh', '-c', 'cargo build --release -p ahfail-gtklock --target-dir "@OUTDIR@/target-gtklock" && cp "@OUTDIR@/target-gtklock/release/libahfail_module.a" "@OUTPUT@"'
],
build_by_default: true
)
# PAM module (.so with C ABI — no GResources needed, Cargo self-contained via build.rs)
pam_cargo = custom_target(
'ahfail-pam-cargo-build',
input: ['crates/ahfail-pam/src/lib.rs', 'Cargo.toml'],
output: ['libahfail_pam.so'],
command: [
'sh', '-c',
# Pass libdir so build.rs emits the correct AHFAIL_INSTALL_DIR (needed for multiarch).
'AHFAIL_LIBDIR=' + get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"'
],
build_by_default: true,
install: true,
install_dir: get_option('libdir') / 'ahfail',
install_mode: 'rwxr-xr-x'
)
# Display binary (embeds GResources via its own build.rs using glib-compile-resources)
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-display" && cp "@OUTDIR@/target-display/release/ahfail-display" "@OUTPUT@"'
],
build_by_default: true,
install: true,
install_dir: get_option('libdir') / 'ahfail',
install_mode: 'rwxr-xr-x'
)
libahfail = shared_library(
'ahfail-module',
resources,
@@ -51,3 +83,14 @@ smoke = executable(
)
test('module symbols', smoke)
# PAM module smoke test: dlopen libahfail_pam.so and verify pam_sm_authenticate is exported.
pam_smoke = executable(
'pam_smoke_test',
'tests/pam_smoke_test.c',
dependencies: [cc.find_library('dl', required: true)]
)
test('pam symbols', pam_smoke,
args: [meson.current_build_dir() / 'libahfail_pam.so'],
depends: pam_cargo)

View File

@@ -51,6 +51,52 @@ gtklock -m /usr/lib/gtklock/ahfail-module.so
gtklock -m ahfail-module.so -- --audio-uri=file:///home/user/custom.mp3
```
## 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)
Find your screen locker's PAM service file. On macOS 13+ the screensaver uses `/etc/pam.d/screensaverui`; on older versions it may be `/etc/pam.d/screensaver`. Add the following line after the existing `auth` entries (requires `sudo`):
```
auth optional /usr/local/lib/ahfail/libahfail_pam.so
```
### Configure PAM (Linux/X11)
Add to `/etc/pam.d/gtklock` (or `i3lock`, `xscreensaver`, etc.). Use the full path because `$(libdir)/ahfail` is not in PAM's default search path:
```
auth optional /usr/lib/ahfail/libahfail_pam.so
```
On Fedora/RHEL replace `/usr/lib` with `/usr/lib64`.
## Development
* **Run Tests:** `cargo test`

View File

@@ -1,12 +0,0 @@
use std::time::Instant;
pub fn time_execution<F, T>(name: &str, f: F) -> T
where
F: FnOnce() -> T,
{
let start = Instant::now();
let result = f();
let duration = start.elapsed();
println!("[benchmark] {}: {:?}", name, duration);
result
}

18
tests/pam_smoke_test.c Normal file
View File

@@ -0,0 +1,18 @@
#include <dlfcn.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <libahfail_pam.so>\n", argv[0]);
return 1;
}
void *lib = dlopen(argv[1], RTLD_NOW);
if (!lib) { fprintf(stderr, "dlopen: %s\n", dlerror()); return 1; }
if (!dlsym(lib, "pam_sm_authenticate")) {
fprintf(stderr, "pam_sm_authenticate not found\n");
dlclose(lib);
return 1;
}
dlclose(lib);
return 0;
}

0
v
View File