release: v0.9.0 pre-release — X11 PAM integration, shadow suppression, macOS cfg guards
- 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:
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
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")]
|
||||
@@ -20,19 +11,10 @@ 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 — baked in at build time via AHFAIL_INSTALL_DIR,
|
||||
// which build.rs derives from AHFAIL_LIBDIR (passed by Meson or the install script).
|
||||
// This is correct on multiarch Linux and on both Intel and Apple Silicon 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] }
|
||||
|
||||
@@ -47,36 +29,16 @@ extern "C" {
|
||||
) -> 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,
|
||||
_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();
|
||||
if !data.is_null() {
|
||||
drop(Box::from_raw(data as *mut String));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exported PAM module entry points ---
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn pam_sm_authenticate(
|
||||
pamh: *mut PamHandle,
|
||||
@@ -89,20 +51,23 @@ pub unsafe extern "C" fn pam_sm_authenticate(
|
||||
let args = argc_argv(argc, argv);
|
||||
let display_path = read_display_path_arg(args);
|
||||
|
||||
// /proc/self/comm is the name of the current process (the locker that loaded us).
|
||||
let locker_name = std::fs::read_to_string("/proc/self/comm")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
spawn_display(display_path.clone(), &locker_name);
|
||||
|
||||
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
|
||||
@@ -133,8 +98,6 @@ 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)
|
||||
@@ -152,40 +115,32 @@ fn read_display_path_arg(args: &[*const c_char]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn spawn_display(path_override: Option<String>) {
|
||||
fn spawn_display(path_override: Option<String>, locker_name: &str) {
|
||||
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.
|
||||
let name_arg = format!("--locker-name={}", locker_name);
|
||||
let cname_arg = match CString::new(name_arg.as_str()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
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.
|
||||
// Grandchild: detach and 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()];
|
||||
for fd in 0..max_fd.min(4096) { libc::close(fd); }
|
||||
let args: [*const c_char; 3] = [cpath.as_ptr(), cname_arg.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();
|
||||
}
|
||||
|
||||
@@ -5,13 +5,12 @@ use crate::config::ModuleConfig;
|
||||
const SPRITE_MARGIN: i32 = 100;
|
||||
const RETRY_ATTEMPTS: usize = 10;
|
||||
|
||||
pub fn place_sprite(
|
||||
fixed: >k::Fixed,
|
||||
pub fn sprite_position(
|
||||
animation: &gdk_pixbuf::PixbufAnimation,
|
||||
screen_w: i32,
|
||||
screen_h: i32,
|
||||
config: &ModuleConfig,
|
||||
) -> gtk::Image {
|
||||
) -> (i32, i32) {
|
||||
let sprite_w = animation.width();
|
||||
let sprite_h = animation.height();
|
||||
|
||||
@@ -34,6 +33,17 @@ pub fn place_sprite(
|
||||
}
|
||||
}
|
||||
|
||||
(x, y)
|
||||
}
|
||||
|
||||
pub fn place_sprite(
|
||||
fixed: >k::Fixed,
|
||||
animation: &gdk_pixbuf::PixbufAnimation,
|
||||
screen_w: i32,
|
||||
screen_h: i32,
|
||||
config: &ModuleConfig,
|
||||
) -> gtk::Image {
|
||||
let (x, y) = sprite_position(animation, screen_w, screen_h, config);
|
||||
let image = gtk::Image::from_animation(animation);
|
||||
image.show();
|
||||
fixed.put(&image, x, y);
|
||||
|
||||
Reference in New Issue
Block a user