release: v0.9.0 pre-release — X11 PAM integration, shadow suppression, macOS cfg guards
Some checks failed
Test / test (push) Failing after 4m25s
Release / release (push) Failing after 6m37s

- PAM module now correctly fires on failed attempts (must precede auth includes)
- ahfail-display: sprite-sized window replaces full-screen overlay
- ahfail-display: XGrabKeyboard unlock detection replaces process scanning
- ahfail-display: _COMPTON_SHADOW=0 suppresses picom shadow without config changes
- ahfail-display: flock single-instance guard prevents stacking on rapid failures
- X11-specific code guarded behind #[cfg(target_os = "linux")] for macOS builds
- Removed debug logging (dlog/plog) from display and PAM binaries
- README: PAM ordering requirement, supported locker table, roadmap section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-05-07 22:01:02 +02:00
parent 8ecc1501a1
commit 5817074f1a
8 changed files with 226 additions and 112 deletions

View File

@@ -12,6 +12,7 @@ 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" }
cairo = { version = "0.15", package = "cairo-rs" }
libc = "0.2"
[build-dependencies]

View File

@@ -49,4 +49,8 @@ fn main() {
// Link against gio-2.0 for GResource support
println!("cargo:rustc-link-lib=gio-2.0");
// Link against X11 for XGrabKeyboard (Linux only)
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
println!("cargo:rustc-link-lib=X11");
}
}

View File

@@ -2,6 +2,7 @@ use gtk::prelude::*;
use gtk::gdk;
use gstreamer as gst;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
const FAILSAFE_MINUTES: u32 = 15;
@@ -12,63 +13,148 @@ extern "C" fn handle_sigterm(_: libc::c_int) {
SIGTERM_RECEIVED.store(true, Ordering::Relaxed);
}
#[cfg(target_os = "linux")]
mod x11_grab {
use std::os::raw::{c_char, c_int, c_ulong, c_void};
type XDisplay = c_void;
type Window = c_ulong;
type Atom = c_ulong;
const GRAB_SUCCESS: c_int = 0;
const GRAB_MODE_ASYNC: c_int = 1;
const XA_CARDINAL: Atom = 6;
const PROP_MODE_REPLACE: c_int = 0;
extern "C" {
fn XOpenDisplay(name: *const c_char) -> *mut XDisplay;
fn XCloseDisplay(dpy: *mut XDisplay) -> c_int;
fn XDefaultRootWindow(dpy: *mut XDisplay) -> Window;
fn XGrabKeyboard(
dpy: *mut XDisplay, grab_window: Window, owner_events: c_int,
pointer_mode: c_int, keyboard_mode: c_int, time: c_ulong,
) -> c_int;
fn XUngrabKeyboard(dpy: *mut XDisplay, time: c_ulong) -> c_int;
fn XInternAtom(dpy: *mut XDisplay, name: *const c_char, only_if_exists: c_int) -> Atom;
fn XChangeProperty(
dpy: *mut XDisplay, w: Window, property: Atom, type_: Atom,
format: c_int, mode: c_int, data: *const u8, nelements: c_int,
) -> c_int;
fn XFlush(dpy: *mut XDisplay) -> c_int;
fn gdk_x11_window_get_xid(window: *mut c_void) -> c_ulong;
}
pub fn is_screen_unlocked() -> bool {
unsafe {
let dpy = XOpenDisplay(std::ptr::null());
if dpy.is_null() { return false; }
let root = XDefaultRootWindow(dpy);
let result = XGrabKeyboard(dpy, root, 0, GRAB_MODE_ASYNC, GRAB_MODE_ASYNC, 0);
if result == GRAB_SUCCESS {
XUngrabKeyboard(dpy, 0);
}
XCloseDisplay(dpy);
result == GRAB_SUCCESS
}
}
/// Tell picom/compton not to draw a shadow around this window.
/// Works by setting _COMPTON_SHADOW=0 directly on the X window,
/// which the compositor respects regardless of its config.
pub fn disable_compositor_shadow(gdk_window: &gdk::Window) {
use glib::ObjectType;
unsafe {
let xid = gdk_x11_window_get_xid(gdk_window.as_ptr() as *mut c_void);
let dpy = XOpenDisplay(std::ptr::null());
if dpy.is_null() { return; }
let atom = XInternAtom(dpy, b"_COMPTON_SHADOW\0".as_ptr() as *const c_char, 0);
let value: u32 = 0;
XChangeProperty(
dpy, xid, atom, XA_CARDINAL, 32,
PROP_MODE_REPLACE, &value as *const u32 as *const u8, 1,
);
XFlush(dpy);
XCloseDisplay(dpy);
}
}
}
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");
// Single-instance guard
let lock_file = std::fs::OpenOptions::new()
.create(true).write(true)
.open("/tmp/ahfail-display.lock");
let lock_file = match lock_file {
Ok(f) => f,
Err(_) => return,
};
use std::os::unix::io::AsRawFd;
if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
return;
}
if gst::init().is_err() {
eprintln!("[ahfail-display] GStreamer init failed");
// Give PAM a moment to complete, then bail if auth already succeeded.
std::thread::sleep(Duration::from_millis(200));
#[cfg(target_os = "linux")]
if x11_grab::is_screen_unlocked() {
return;
}
if gtk::init().is_err() { return; }
if gst::init().is_err() { return; }
let animation = unsafe { ahfail_ui::animation::load_animation() };
let Some(animation) = animation else {
eprintln!("[ahfail-display] No animation frames found");
return;
};
let Some(animation) = animation else { return; };
let Some(display) = gdk::Display::default() else {
eprintln!("[ahfail-display] No display");
return;
};
let Some(display) = gdk::Display::default() else { 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();
let (screen_w, screen_h) = (geom.width(), 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 config = parse_args();
let (sprite_x, sprite_y) = ahfail_ui::display::sprite_position(
&animation, screen_w, screen_h, &config,
);
let sprite_w = animation.width();
let sprite_h = animation.height();
// Window sized exactly to the sprite — no full-screen background to worry about
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);
window.set_accept_focus(false);
window.set_type_hint(gdk::WindowTypeHint::Notification);
window.move_(geom.x() + sprite_x, geom.y() + sprite_y);
window.set_default_size(sprite_w, sprite_h);
let fixed = gtk::Fixed::new();
fixed.set_size_request(screen_w, screen_h);
window.add(&fixed);
// RGBA visual so sprite edges (from PNG alpha) composite correctly
if let Some(screen) = window.screen() {
if let Some(visual) = screen.rgba_visual() {
window.set_visual(Some(&visual));
}
}
window.set_app_paintable(true);
window.connect_draw(|_, cr| {
cr.set_source_rgba(0.0, 0.0, 0.0, 0.0);
cr.set_operator(cairo::Operator::Source);
let _ = cr.paint();
gtk::Inhibit(false)
});
let config = parse_args();
ahfail_ui::display::place_sprite(&fixed, &animation, screen_w, screen_h, &config);
let image = gtk::Image::from_animation(&animation);
window.add(&image);
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, || {
@@ -76,7 +162,7 @@ fn main() {
glib::Continue(false)
});
glib::timeout_add(std::time::Duration::from_millis(200), move || {
glib::timeout_add(Duration::from_millis(200), || {
if SIGTERM_RECEIVED.load(Ordering::Relaxed) {
gtk::main_quit();
return glib::Continue(false);
@@ -84,7 +170,21 @@ fn main() {
glib::Continue(true)
});
#[cfg(target_os = "linux")]
glib::timeout_add(Duration::from_millis(500), || {
if x11_grab::is_screen_unlocked() {
gtk::main_quit();
return glib::Continue(false);
}
glib::Continue(true)
});
window.show_all();
#[cfg(target_os = "linux")]
if let Some(gdk_win) = window.window() {
x11_grab::disable_compositor_shadow(&gdk_win);
}
gtk::main();
player.stop();
@@ -99,9 +199,7 @@ fn parse_args() -> ahfail_ui::config::ModuleConfig {
.and_then(|a| {
let val = a.trim_start_matches("--deadzone=");
let p: Vec<&str> = val.split(',').collect();
if p.len() != 4 {
return None;
}
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()?;