Compare commits
15 Commits
702f449d0e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5817074f1a | ||
|
|
8ecc1501a1 | ||
|
|
a6de85650d | ||
|
|
6a931cf4f0 | ||
|
|
dc8c73a0dd | ||
|
|
2e40a0a23a | ||
|
|
3fae21c7a4 | ||
|
|
57054a7c02 | ||
|
|
b8a03d72bb | ||
|
|
f951cb9c6d | ||
|
|
e41ae1cd7f | ||
|
|
3e7bc18f65 | ||
|
|
1f927bdbb2 | ||
|
|
2c2e693193 | ||
|
|
9dbca0ec51 |
@@ -9,7 +9,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -18,16 +18,23 @@ jobs:
|
|||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libgstreamer1.0-dev \
|
libgstreamer1.0-dev \
|
||||||
libgstreamer-plugins-base1.0-dev \
|
libgstreamer-plugins-base1.0-dev \
|
||||||
|
libgstreamer-plugins-bad1.0-dev \
|
||||||
gstreamer1.0-plugins-good \
|
gstreamer1.0-plugins-good \
|
||||||
libpam0g-dev \
|
libpam0g-dev \
|
||||||
ninja-build \
|
ninja-build \
|
||||||
python3-pip \
|
meson \
|
||||||
libglib2.0-dev
|
libglib2.0-dev
|
||||||
pip3 install meson
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Compute source tarball SHA256
|
||||||
|
run: |
|
||||||
|
SHA256=$(curl -fsSL \
|
||||||
|
"https://gitea.weircon.dk/agw/gtk-ahfail/archive/${{ github.ref_name }}.tar.gz" \
|
||||||
|
| sha256sum | cut -d' ' -f1)
|
||||||
|
echo "SOURCE_SHA256=${SHA256}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build release
|
- name: Build release
|
||||||
run: |
|
run: |
|
||||||
meson setup builddir --buildtype=release
|
meson setup builddir --buildtype=release
|
||||||
@@ -41,26 +48,31 @@ jobs:
|
|||||||
cp builddir/ahfail-display dist/
|
cp builddir/ahfail-display dist/
|
||||||
tar czf ahfail-linux-x86_64.tar.gz -C dist .
|
tar czf ahfail-linux-x86_64.tar.gz -C dist .
|
||||||
|
|
||||||
- name: Create Gitea release and upload asset
|
- name: Upload release asset
|
||||||
# Requires a GITEA_TOKEN secret with write:repository scope.
|
# Requires a GITEA_TOKEN secret with write:repository scope.
|
||||||
# Create it in repo Settings → Secrets.
|
# 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
|
||||||
|
|
||||||
|
- name: Update Homebrew tap
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
GITEA_URL: https://gitea.weircon.dk
|
|
||||||
REPO: agw/gtk-ahfail
|
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
RELEASE_ID=$(curl -f -X POST \
|
git clone \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
|
"https://oauth2:${GITEA_TOKEN}@gitea.weircon.dk/agw/homebrew-ahfail.git" \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
tap
|
||||||
-H "Content-Type: application/json" \
|
cd tap
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false}" \
|
sed -i "s|url \".*\"|url \"https://gitea.weircon.dk/agw/gtk-ahfail/archive/${TAG}.tar.gz\"|" Formula/ahfail.rb
|
||||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
sed -i "s|sha256 \".*\"|sha256 \"${SOURCE_SHA256}\"|" Formula/ahfail.rb
|
||||||
|
git config user.email "actions@weircon.dk"
|
||||||
[ -n "${RELEASE_ID}" ] || { echo "ERROR: empty RELEASE_ID from API"; exit 1; }
|
git config user.name "Gitea Actions"
|
||||||
|
git add Formula/ahfail.rb
|
||||||
curl -f -X POST \
|
git diff --cached --quiet || git commit -m "chore: update ahfail to ${TAG}"
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
git push
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-F "attachment=@ahfail-linux-x86_64.tar.gz"
|
|
||||||
|
|||||||
@@ -17,19 +17,19 @@ jobs:
|
|||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libgstreamer1.0-dev \
|
libgstreamer1.0-dev \
|
||||||
libgstreamer-plugins-base1.0-dev \
|
libgstreamer-plugins-base1.0-dev \
|
||||||
|
libgstreamer-plugins-bad1.0-dev \
|
||||||
gstreamer1.0-plugins-good \
|
gstreamer1.0-plugins-good \
|
||||||
libpam0g-dev \
|
libpam0g-dev \
|
||||||
ninja-build \
|
ninja-build \
|
||||||
python3-pip \
|
meson \
|
||||||
libglib2.0-dev \
|
libglib2.0-dev \
|
||||||
xvfb
|
xvfb
|
||||||
pip3 install meson
|
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
run: cargo test
|
run: xvfb-run cargo test
|
||||||
|
|
||||||
- name: Meson build
|
- name: Meson build
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -13,6 +13,7 @@ name = "ahfail-display"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahfail-ui",
|
"ahfail-ui",
|
||||||
|
"cairo-rs",
|
||||||
"cc",
|
"cc",
|
||||||
"gdk",
|
"gdk",
|
||||||
"glib",
|
"glib",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ gtk = { version = "0.15", package = "gtk", features = ["v3_24"] }
|
|||||||
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
|
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
|
||||||
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
|
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
|
||||||
glib = { version = "0.15", package = "glib" }
|
glib = { version = "0.15", package = "glib" }
|
||||||
|
cairo = { version = "0.15", package = "cairo-rs" }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|||||||
@@ -49,4 +49,8 @@ fn main() {
|
|||||||
|
|
||||||
// Link against gio-2.0 for GResource support
|
// Link against gio-2.0 for GResource support
|
||||||
println!("cargo:rustc-link-lib=gio-2.0");
|
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 gtk::gdk;
|
||||||
use gstreamer as gst;
|
use gstreamer as gst;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
|
const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
|
||||||
const FAILSAFE_MINUTES: u32 = 15;
|
const FAILSAFE_MINUTES: u32 = 15;
|
||||||
@@ -12,63 +13,148 @@ extern "C" fn handle_sigterm(_: libc::c_int) {
|
|||||||
SIGTERM_RECEIVED.store(true, Ordering::Relaxed);
|
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() {
|
fn main() {
|
||||||
unsafe {
|
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);
|
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
|
||||||
}
|
}
|
||||||
|
|
||||||
if gtk::init().is_err() {
|
// Single-instance guard
|
||||||
eprintln!("[ahfail-display] GTK init failed");
|
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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if gtk::init().is_err() { return; }
|
||||||
|
if gst::init().is_err() { return; }
|
||||||
|
|
||||||
let animation = unsafe { ahfail_ui::animation::load_animation() };
|
let animation = unsafe { ahfail_ui::animation::load_animation() };
|
||||||
let Some(animation) = animation else {
|
let Some(animation) = animation else { return; };
|
||||||
eprintln!("[ahfail-display] No animation frames found");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(display) = gdk::Display::default() else {
|
let Some(display) = gdk::Display::default() else { return; };
|
||||||
eprintln!("[ahfail-display] No display");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(monitor) = display.primary_monitor().or_else(|| display.monitor(0)) else {
|
let Some(monitor) = display.primary_monitor().or_else(|| display.monitor(0)) else {
|
||||||
eprintln!("[ahfail-display] No monitor");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let geom = monitor.geometry();
|
let geom = monitor.geometry();
|
||||||
let screen_w = geom.width();
|
let (screen_w, screen_h) = (geom.width(), geom.height());
|
||||||
let screen_h = geom.height();
|
|
||||||
|
|
||||||
// On X11, WindowType::Popup creates an unmanaged override-redirect window (desired).
|
let config = parse_args();
|
||||||
// On Wayland, GTK3 falls back to a normal xdg_toplevel; the compositor controls stacking.
|
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);
|
let window = gtk::Window::new(gtk::WindowType::Popup);
|
||||||
window.set_decorated(false);
|
window.set_decorated(false);
|
||||||
window.set_keep_above(true);
|
window.set_keep_above(true);
|
||||||
window.set_skip_taskbar_hint(true);
|
window.set_skip_taskbar_hint(true);
|
||||||
window.move_(geom.x(), geom.y());
|
window.set_accept_focus(false);
|
||||||
window.set_default_size(screen_w, screen_h);
|
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();
|
// RGBA visual so sprite edges (from PNG alpha) composite correctly
|
||||||
fixed.set_size_request(screen_w, screen_h);
|
if let Some(screen) = window.screen() {
|
||||||
window.add(&fixed);
|
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();
|
let image = gtk::Image::from_animation(&animation);
|
||||||
ahfail_ui::display::place_sprite(&fixed, &animation, screen_w, screen_h, &config);
|
window.add(&image);
|
||||||
|
|
||||||
let player = ahfail_ui::audio::create_player(AUDIO_URI);
|
let player = ahfail_ui::audio::create_player(AUDIO_URI);
|
||||||
player.play();
|
player.play();
|
||||||
|
|
||||||
std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION));
|
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();
|
let volume_state = ahfail_ui::volume::save_and_set_max();
|
||||||
|
|
||||||
glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || {
|
glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || {
|
||||||
@@ -76,7 +162,7 @@ fn main() {
|
|||||||
glib::Continue(false)
|
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) {
|
if SIGTERM_RECEIVED.load(Ordering::Relaxed) {
|
||||||
gtk::main_quit();
|
gtk::main_quit();
|
||||||
return glib::Continue(false);
|
return glib::Continue(false);
|
||||||
@@ -84,7 +170,21 @@ fn main() {
|
|||||||
glib::Continue(true)
|
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();
|
window.show_all();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(gdk_win) = window.window() {
|
||||||
|
x11_grab::disable_compositor_shadow(&gdk_win);
|
||||||
|
}
|
||||||
|
|
||||||
gtk::main();
|
gtk::main();
|
||||||
|
|
||||||
player.stop();
|
player.stop();
|
||||||
@@ -99,9 +199,7 @@ fn parse_args() -> ahfail_ui::config::ModuleConfig {
|
|||||||
.and_then(|a| {
|
.and_then(|a| {
|
||||||
let val = a.trim_start_matches("--deadzone=");
|
let val = a.trim_start_matches("--deadzone=");
|
||||||
let p: Vec<&str> = val.split(',').collect();
|
let p: Vec<&str> = val.split(',').collect();
|
||||||
if p.len() != 4 {
|
if p.len() != 4 { return None; }
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let x: i32 = p[0].parse().ok()?;
|
let x: i32 = p[0].parse().ok()?;
|
||||||
let y: i32 = p[1].parse().ok()?;
|
let y: i32 = p[1].parse().ok()?;
|
||||||
let w: i32 = p[2].parse().ok()?;
|
let w: i32 = p[2].parse().ok()?;
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ fn run_test_09_idle_hide_cleanup() {
|
|||||||
flush_events();
|
flush_events();
|
||||||
|
|
||||||
// Mock GtkLock struct
|
// 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);
|
glib::ffi::g_array_append_vals(windows_array, &ctx_ptr as *const _ as *const c_void, 1);
|
||||||
|
|
||||||
let mut lock = GtkLock {
|
let mut lock = GtkLock {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use gtk::prelude::*;
|
use gtk::gdk_pixbuf;
|
||||||
use gtk::{gdk, gdk_pixbuf};
|
|
||||||
use gstreamer as gst;
|
use gstreamer as gst;
|
||||||
use gstreamer_player as gst_player;
|
use gstreamer_player as gst_player;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rustc-link-lib=pam");
|
println!("cargo:rustc-link-lib=pam");
|
||||||
|
|
||||||
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch
|
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch Linux
|
||||||
// systems (e.g. Debian/Ubuntu where libdir is /usr/lib/x86_64-linux-gnu).
|
// (e.g. /usr/lib/x86_64-linux-gnu) and on Apple Silicon macOS (/opt/homebrew/lib).
|
||||||
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back to /usr/lib otherwise.
|
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back per platform otherwise.
|
||||||
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| "/usr/lib".to_string());
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
|
let fallback = if target_os == "macos" { "/usr/local/lib" } else { "/usr/lib" };
|
||||||
|
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| fallback.to_string());
|
||||||
println!("cargo:rustc-env=AHFAIL_INSTALL_DIR={}/ahfail", libdir);
|
println!("cargo:rustc-env=AHFAIL_INSTALL_DIR={}/ahfail", libdir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
use libc::{c_char, c_int, c_void, pid_t};
|
use libc::{c_char, c_int, c_void, pid_t};
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
|
|
||||||
// --- PAM constants (platform-specific) ---
|
|
||||||
|
|
||||||
pub const PAM_SUCCESS: c_int = 0;
|
pub const PAM_SUCCESS: c_int = 0;
|
||||||
pub const PAM_IGNORE: c_int = 25;
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -20,22 +11,10 @@ pub const PAM_DATA_REPLACE: c_int = 0x00000002;
|
|||||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
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");
|
const DEFAULT_PATH: &str = concat!(env!("AHFAIL_INSTALL_DIR"), "/ahfail-display");
|
||||||
|
|
||||||
pub fn default_display_path() -> &'static str { DEFAULT_PATH }
|
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)]
|
#[repr(C)]
|
||||||
pub struct PamHandle { _private: [u8; 0] }
|
pub struct PamHandle { _private: [u8; 0] }
|
||||||
|
|
||||||
@@ -50,36 +29,16 @@ extern "C" {
|
|||||||
) -> c_int;
|
) -> c_int;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cleanup: fires on pam_end() or when data is replaced ---
|
|
||||||
unsafe extern "C" fn ahfail_cleanup(
|
unsafe extern "C" fn ahfail_cleanup(
|
||||||
_pamh: *mut PamHandle,
|
_pamh: *mut PamHandle,
|
||||||
data: *mut c_void,
|
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).
|
if !data.is_null() {
|
||||||
let path_override: Option<String> = if data.is_null() {
|
drop(Box::from_raw(data as *mut String));
|
||||||
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]
|
#[no_mangle]
|
||||||
pub unsafe extern "C" fn pam_sm_authenticate(
|
pub unsafe extern "C" fn pam_sm_authenticate(
|
||||||
pamh: *mut PamHandle,
|
pamh: *mut PamHandle,
|
||||||
@@ -92,20 +51,23 @@ pub unsafe extern "C" fn pam_sm_authenticate(
|
|||||||
let args = argc_argv(argc, argv);
|
let args = argc_argv(argc, argv);
|
||||||
let display_path = read_display_path_arg(args);
|
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") {
|
let key = match CString::new("dk.weircon.ahfail") {
|
||||||
Ok(k) => k,
|
Ok(k) => k,
|
||||||
Err(_) => return PAM_IGNORE,
|
Err(_) => return PAM_IGNORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store optional path override as heap data for the cleanup function.
|
|
||||||
let path_ptr: *mut c_void = match display_path {
|
let path_ptr: *mut c_void = match display_path {
|
||||||
Some(p) => Box::into_raw(Box::new(p)) as *mut c_void,
|
Some(p) => Box::into_raw(Box::new(p)) as *mut c_void,
|
||||||
None => std::ptr::null_mut(),
|
None => std::ptr::null_mut(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ret = pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup));
|
let ret = pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup));
|
||||||
if ret != PAM_SUCCESS && !path_ptr.is_null() {
|
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));
|
drop(Box::from_raw(path_ptr as *mut String));
|
||||||
}
|
}
|
||||||
PAM_IGNORE
|
PAM_IGNORE
|
||||||
@@ -136,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,
|
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
|
||||||
) -> c_int { PAM_IGNORE }
|
) -> c_int { PAM_IGNORE }
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] {
|
unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] {
|
||||||
if argv.is_null() || argc <= 0 { return &[]; }
|
if argv.is_null() || argc <= 0 { return &[]; }
|
||||||
std::slice::from_raw_parts(argv, argc as usize)
|
std::slice::from_raw_parts(argv, argc as usize)
|
||||||
@@ -155,40 +115,32 @@ fn read_display_path_arg(args: &[*const c_char]) -> Option<String> {
|
|||||||
None
|
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 path = path_override.unwrap_or_else(|| DEFAULT_PATH.to_string());
|
||||||
let cpath = match CString::new(path.as_str()) {
|
let cpath = match CString::new(path.as_str()) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(_) => return,
|
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 {
|
unsafe {
|
||||||
let pid: pid_t = libc::fork();
|
let pid: pid_t = libc::fork();
|
||||||
if pid < 0 { return; }
|
if pid < 0 { return; }
|
||||||
if pid > 0 {
|
if pid > 0 {
|
||||||
// Parent: wait for intermediate to exit immediately.
|
|
||||||
libc::waitpid(pid, std::ptr::null_mut(), 0);
|
libc::waitpid(pid, std::ptr::null_mut(), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Intermediate: fork again then exit immediately.
|
|
||||||
let pid2: pid_t = libc::fork();
|
let pid2: pid_t = libc::fork();
|
||||||
if pid2 != 0 { libc::_exit(0); }
|
if pid2 != 0 { libc::_exit(0); }
|
||||||
// Grandchild: detach from PAM daemon's session, close all inherited fds, exec.
|
// Grandchild: detach and exec.
|
||||||
libc::setsid();
|
libc::setsid();
|
||||||
let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as c_int;
|
let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as c_int;
|
||||||
for fd in 0..max_fd.min(4096) {
|
for fd in 0..max_fd.min(4096) { libc::close(fd); }
|
||||||
libc::close(fd);
|
let args: [*const c_char; 3] = [cpath.as_ptr(), cname_arg.as_ptr(), std::ptr::null()];
|
||||||
}
|
|
||||||
let args: [*const c_char; 2] = [cpath.as_ptr(), std::ptr::null()];
|
|
||||||
libc::execv(cpath.as_ptr(), args.as_ptr());
|
libc::execv(cpath.as_ptr(), args.as_ptr());
|
||||||
libc::_exit(1);
|
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 SPRITE_MARGIN: i32 = 100;
|
||||||
const RETRY_ATTEMPTS: usize = 10;
|
const RETRY_ATTEMPTS: usize = 10;
|
||||||
|
|
||||||
pub fn place_sprite(
|
pub fn sprite_position(
|
||||||
fixed: >k::Fixed,
|
|
||||||
animation: &gdk_pixbuf::PixbufAnimation,
|
animation: &gdk_pixbuf::PixbufAnimation,
|
||||||
screen_w: i32,
|
screen_w: i32,
|
||||||
screen_h: i32,
|
screen_h: i32,
|
||||||
config: &ModuleConfig,
|
config: &ModuleConfig,
|
||||||
) -> gtk::Image {
|
) -> (i32, i32) {
|
||||||
let sprite_w = animation.width();
|
let sprite_w = animation.width();
|
||||||
let sprite_h = animation.height();
|
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);
|
let image = gtk::Image::from_animation(animation);
|
||||||
image.show();
|
image.show();
|
||||||
fixed.put(&image, x, y);
|
fixed.put(&image, x, y);
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ pam_cargo = custom_target(
|
|||||||
output: ['libahfail_pam.so'],
|
output: ['libahfail_pam.so'],
|
||||||
command: [
|
command: [
|
||||||
'sh', '-c',
|
'sh', '-c',
|
||||||
# Pass libdir so build.rs emits the correct AHFAIL_INSTALL_DIR (needed for multiarch).
|
# Pass the absolute libdir so build.rs emits the correct AHFAIL_INSTALL_DIR.
|
||||||
'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@"'
|
# get_option('libdir') is relative ("lib"); prefix it to get an absolute path.
|
||||||
|
'AHFAIL_LIBDIR=' + get_option('prefix') / 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,
|
build_by_default: true,
|
||||||
install: true,
|
install: true,
|
||||||
@@ -91,4 +92,6 @@ pam_smoke = executable(
|
|||||||
dependencies: [cc.find_library('dl', required: true)]
|
dependencies: [cc.find_library('dl', required: true)]
|
||||||
)
|
)
|
||||||
|
|
||||||
test('pam symbols', pam_smoke, args: [pam_cargo[0]])
|
test('pam symbols', pam_smoke,
|
||||||
|
args: [meson.current_build_dir() / 'libahfail_pam.so'],
|
||||||
|
depends: pam_cargo)
|
||||||
|
|||||||
276
readme.md
276
readme.md
@@ -1,38 +1,79 @@
|
|||||||
# Ah ah ah, you didn't say the magic word
|
# Ah ah ah, you didn't say the magic word
|
||||||
|
|
||||||
This `gtklock` module listens for failed unlock attempts and recreates Dennis Nedry’s “ah ah ah” lockout scene from Jurassic Park.
|
On a failed lock-screen unlock attempt, this spawns a looping animation of **the author's face photoshopped onto Dennis Nedry's body** and plays the "ah ah ah, you didn't say the magic word" clip from Jurassic Park. Each wrong guess adds another sprite at a random screen position. Volume is forced to 100% on the first failure and restored when the screen unlocks.
|
||||||
|
|
||||||
* **Animation:** Spawns a looping "Nedry" sprite at a random location on the screen.
|
---
|
||||||
* **Audio:** Plays the "ah ah ah, you didn't say the magic word" clip.
|
|
||||||
* **Safety:** Sprites avoid overlapping a configurable "deadzone" (e.g., your login box).
|
|
||||||
* **Performance:** Uses pre-warmed audio players for low latency
|
|
||||||
|
|
||||||
## Requirements
|
> **Security disclaimer**
|
||||||
|
>
|
||||||
|
> This project hooks into PAM and/or your screen locker's plugin API. PAM sits directly in the authentication critical path — a bug in this module could lock you out of your system or, in the worst case, weaken authentication.
|
||||||
|
>
|
||||||
|
> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are modifying a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
|
||||||
|
|
||||||
* **Build:** Meson, Ninja, Rust (Cargo), GTK+3 development headers.
|
---
|
||||||
* **Runtime:** `gtklock`, `gstreamer`, `gst-plugins-base`, `gst-plugins-good` (for audio playback).
|
|
||||||
|
|
||||||
## Build & Install
|
## Platform support
|
||||||
|
|
||||||
1. **Install Dependencies:**
|
The integration method differs by display server:
|
||||||
```bash
|
|
||||||
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Build:**
|
| Platform | Display server | Module loaded | How it works |
|
||||||
```bash
|
|---|---|---|---|
|
||||||
meson setup builddir
|
| Linux | **Wayland** | `ahfail-module.so` | Loaded directly by `gtklock` via its module API |
|
||||||
meson compile -C builddir
|
| Linux | **X11** | `libahfail_pam.so` + `ahfail-display` | PAM cleanup hook spawns the display binary after each failed auth |
|
||||||
```
|
| macOS | — | `libahfail_pam.so` + `ahfail-display` | Same PAM approach, hooks into the screensaver PAM stack |
|
||||||
|
|
||||||
3. **Install:**
|
The **gtklock module** is Wayland-only — it uses gtklock's internal window API to overlay sprites directly on the lock screen. The **PAM module** works on X11 and macOS by registering a cleanup callback that fires on each failed authentication attempt; it double-forks a standalone display binary (`ahfail-display`) that runs independently of the PAM stack.
|
||||||
```bash
|
|
||||||
sudo meson install -C builddir
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
---
|
||||||
|
|
||||||
Run `gtklock` with the module path:
|
## Linux (x86\_64) — binary install
|
||||||
|
|
||||||
|
Pre-built binaries for both Wayland and X11 are available on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases). The tarball contains all three files:
|
||||||
|
|
||||||
|
| File | Used by |
|
||||||
|
|---|---|
|
||||||
|
| `ahfail-module.so` | Wayland — loaded by `gtklock` |
|
||||||
|
| `libahfail_pam.so` | X11 — loaded by PAM |
|
||||||
|
| `ahfail-display` | X11 — spawned by the PAM module |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download and extract (replace v0.1.0 with the latest release)
|
||||||
|
curl -fsSL https://gitea.weircon.dk/agw/gtk-ahfail/releases/download/v0.1.0/ahfail-linux-x86_64.tar.gz \
|
||||||
|
| sudo tar -xz -C /tmp/ahfail-install
|
||||||
|
|
||||||
|
# Wayland (gtklock)
|
||||||
|
sudo install -Dm755 /tmp/ahfail-install/ahfail-module.so /usr/lib/gtklock/ahfail-module.so
|
||||||
|
|
||||||
|
# X11 (PAM module + display binary)
|
||||||
|
sudo install -Dm755 /tmp/ahfail-install/libahfail_pam.so /usr/lib/ahfail/libahfail_pam.so
|
||||||
|
sudo install -Dm755 /tmp/ahfail-install/ahfail-display /usr/lib/ahfail/ahfail-display
|
||||||
|
```
|
||||||
|
|
||||||
|
Then follow the [Wayland](#usage-linux---wayland-only-gtklock) or [X11](#usage-linux---x11-i3lock-xscreensaver-etc) configuration below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linux compile
|
||||||
|
|
||||||
|
### Install dependencies and build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gtklock
|
||||||
|
meson setup builddir --prefix=/usr
|
||||||
|
meson compile -C builddir
|
||||||
|
sudo meson install -C builddir
|
||||||
|
```
|
||||||
|
|
||||||
|
`--prefix=/usr` matches Arch conventions (same as pacman) and ensures the compiled-in default path for `ahfail-display` matches where it is installed.
|
||||||
|
|
||||||
|
Installs:
|
||||||
|
- `/usr/lib/gtklock/ahfail-module.so` — gtklock module
|
||||||
|
- `/usr/lib/ahfail/libahfail_pam.so` — PAM module (for X11)
|
||||||
|
- `/usr/lib/ahfail/ahfail-display` — standalone display binary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Linux - wayland (only gtklock)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gtklock -m /usr/lib/gtklock/ahfail-module.so
|
gtklock -m /usr/lib/gtklock/ahfail-module.so
|
||||||
@@ -40,72 +81,175 @@ gtklock -m /usr/lib/gtklock/ahfail-module.so
|
|||||||
|
|
||||||
### Arguments
|
### Arguments
|
||||||
|
|
||||||
* `--deadzone=X,Y,W,H`: Defines a rectangle where sprites will *not* spawn (e.g., to keep your password field visible).
|
Pass module arguments after `--`:
|
||||||
```bash
|
|
||||||
gtklock -m ahfail-module.so -- --deadzone=860,440,200,200
|
|
||||||
```
|
|
||||||
*(Note the `--` separator before module arguments)*
|
|
||||||
|
|
||||||
* `--audio-uri=URI`: Override the default audio clip.
|
|
||||||
```bash
|
|
||||||
gtklock -m ahfail-module.so -- --audio-uri=file:///home/user/custom.mp3
|
|
||||||
```
|
|
||||||
|
|
||||||
## macOS (build from source)
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja rust
|
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build
|
- `--deadzone=X,Y,W,H` — rectangle where sprites will not spawn (keep your password field clear)
|
||||||
|
- `--audio-uri=URI` — override the default audio clip, e.g. `file:///home/user/custom.mp3`
|
||||||
|
|
||||||
```bash
|
---
|
||||||
meson setup builddir
|
|
||||||
meson compile -C builddir
|
## Usage Linux - X11 (i3lock, xscreensaver, etc.)
|
||||||
|
|
||||||
|
The PAM module works with any X11 locker that authenticates via PAM. Supported lockers and their service file names:
|
||||||
|
|
||||||
|
| Locker | PAM service file |
|
||||||
|
|---|---|
|
||||||
|
| i3lock | `/etc/pam.d/i3lock` |
|
||||||
|
| i3lock-color | `/etc/pam.d/i3lock-color` |
|
||||||
|
| betterlockscreen | `/etc/pam.d/betterlockscreen` |
|
||||||
|
| xscreensaver | `/etc/pam.d/xscreensaver` |
|
||||||
|
| lightdm | `/etc/pam.d/lightdm` |
|
||||||
|
|
||||||
|
Add a line to the relevant file.
|
||||||
|
|
||||||
|
**Important:** The `ahfail` line must appear **before** `auth include system-auth` (or any equivalent include). If it comes after, PAM's internal flow control inside `system-auth` (`pam_faillock` with `[default=die]`) means our module is never reached on failed attempts — it only gets called on success.
|
||||||
|
|
||||||
|
Your PAM service file should look like this:
|
||||||
|
|
||||||
|
**Arch / standard (`--prefix=/usr`):**
|
||||||
|
```
|
||||||
|
#%PAM-1.0
|
||||||
|
auth optional /usr/lib/ahfail/libahfail_pam.so
|
||||||
|
auth include system-auth
|
||||||
```
|
```
|
||||||
|
|
||||||
Produces:
|
**Fedora / RHEL (multilib):**
|
||||||
- `builddir/ahfail-module.so` — gtklock module (Wayland/Linux only)
|
```
|
||||||
- `builddir/libahfail_pam.so` — PAM module (macOS + X11 Linux)
|
#%PAM-1.0
|
||||||
- `builddir/ahfail-display` — display binary (spawned by PAM module)
|
auth optional /usr/lib64/ahfail/libahfail_pam.so
|
||||||
|
auth include system-auth
|
||||||
|
```
|
||||||
|
|
||||||
### Install
|
**Debian / Ubuntu (multiarch):**
|
||||||
|
```
|
||||||
|
#%PAM-1.0
|
||||||
|
auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so
|
||||||
|
auth include system-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
The full path is required — `$(libdir)/ahfail` is not in PAM's default search path.
|
||||||
|
|
||||||
|
If the display binary is not at the default location, add `display_path=`:
|
||||||
|
```
|
||||||
|
auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfail-display
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
### Homebrew (recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
|
||||||
|
brew install ahfail
|
||||||
|
```
|
||||||
|
|
||||||
|
After install, Homebrew prints the PAM line to add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` (macOS 12 and earlier). That one-line edit is the only manual step.
|
||||||
|
|
||||||
|
To upgrade: `brew upgrade ahfail`.
|
||||||
|
|
||||||
|
### Script install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.weircon.dk/agw/gtk-ahfail.git
|
||||||
|
cd gtk-ahfail
|
||||||
|
bash scripts/install-macos.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Installs Homebrew dependencies, builds from source, copies binaries to `/usr/local/lib/ahfail/`, and patches the screensaver PAM configuration automatically.
|
||||||
|
|
||||||
|
### Manual install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja
|
||||||
|
# install Rust via https://rustup.rs if not present
|
||||||
|
meson setup builddir && meson compile -C builddir
|
||||||
sudo mkdir -p /usr/local/lib/ahfail
|
sudo mkdir -p /usr/local/lib/ahfail
|
||||||
sudo cp builddir/libahfail_pam.so /usr/local/lib/ahfail/
|
sudo cp builddir/libahfail_pam.so builddir/ahfail-display /usr/local/lib/ahfail/
|
||||||
sudo cp builddir/ahfail-display /usr/local/lib/ahfail/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure PAM (macOS)
|
Add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` as the **first** `auth` line (before any `auth include` or `auth required` entries):
|
||||||
|
|
||||||
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
|
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:
|
## Uninstall
|
||||||
|
|
||||||
```
|
### Linux — X11
|
||||||
auth optional /usr/lib/ahfail/libahfail_pam.so
|
|
||||||
|
```bash
|
||||||
|
bash scripts/uninstall-linux-x11.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
On Fedora/RHEL replace `/usr/lib` with `/usr/lib64`.
|
Removes the `ahfail` line from common PAM service files (`/etc/pam.d/i3lock`, `xscreensaver`, `lightdm`, etc.) and removes the installed binaries. If `builddir` is present it uses `ninja uninstall`; otherwise it removes the known paths manually.
|
||||||
|
|
||||||
## Development
|
### Linux — Wayland
|
||||||
|
|
||||||
* **Run Tests:** `cargo test`
|
No PAM config was modified. Just stop passing the module to gtklock and optionally remove the file:
|
||||||
* **Benchmarks:** `cargo test --test benchmarks -- --nocapture`
|
|
||||||
* **Linting:** `cargo clippy`
|
```bash
|
||||||
|
sudo rm -f /usr/lib/gtklock/ahfail-module.so # --prefix=/usr install
|
||||||
|
# or
|
||||||
|
sudo rm -f /usr/local/lib/gtklock/ahfail-module.so
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS — Homebrew
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui # or screensaver on macOS 12
|
||||||
|
brew uninstall ahfail
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS — manual / script install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/uninstall-macos.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Removes the `ahfail` line from the screensaver PAM file and deletes `/usr/local/lib/ahfail/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
To change the default sprite or audio:
|
The sprite is the author's face on Nedry's body. To use your own:
|
||||||
1. Replace files in `assets/`.
|
|
||||||
2. Update `assets/ahfail.gresource.xml`.
|
1. Replace the sprite frames in `assets/sprites/` with your own PNG sequence.
|
||||||
3. Rebuild with Meson.
|
2. Update `assets/ahfail.gresource.xml` if you add or remove files.
|
||||||
|
3. Rebuild with Meson.
|
||||||
|
|
||||||
|
To replace the audio clip, swap `assets/audio/magic-word.mp3` and rebuild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test # unit + integration tests (needs display)
|
||||||
|
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
|
||||||
|
cargo clippy # lint
|
||||||
|
meson setup builddir && meson compile -C builddir # full build including .so
|
||||||
|
xvfb-run meson test -C builddir --verbose # Meson tests headless
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### swaylock (Wayland)
|
||||||
|
|
||||||
|
The PAM module can be added to `/etc/pam.d/swaylock` and will fire on each failed attempt — but on Wayland the `ahfail-display` binary cannot draw on top of the lock screen. The Wayland session-lock protocol (`ext-session-lock-v1`) restricts rendering to the locker process itself, so the animation is suppressed by the compositor and only the audio plays.
|
||||||
|
|
||||||
|
Full support requires swaylock to expose a plugin API similar to gtklock's. There is an open request for this upstream. Once available, a dedicated swaylock module can be added alongside the existing gtklock one.
|
||||||
|
|
||||||
|
### hyprlock (Wayland)
|
||||||
|
|
||||||
|
Same situation as swaylock — PAM fires correctly but the display is blocked by the compositor. hyprlock does not currently have a plugin/module API. Full animation support is pending upstream plugin support.
|
||||||
|
|||||||
99
scripts/install-macos.sh
Executable file
99
scripts/install-macos.sh
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install-macos.sh — build and install ahfail on macOS
|
||||||
|
# Run from the repository root: bash scripts/install-macos.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/usr/local/lib/ahfail"
|
||||||
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
|
|
||||||
|
step() { echo "${BOLD}==> $*${RESET}"; }
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ "$(uname)" == "Darwin" ]] || die "This script is for macOS only."
|
||||||
|
|
||||||
|
# ── 1. Homebrew ───────────────────────────────────────────────────────────────
|
||||||
|
if ! command -v brew &>/dev/null; then
|
||||||
|
die "Homebrew is required. Install it from https://brew.sh then re-run this script."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Installing Homebrew dependencies..."
|
||||||
|
brew install --quiet gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja
|
||||||
|
|
||||||
|
# ── 2. Rust ───────────────────────────────────────────────────────────────────
|
||||||
|
if ! command -v cargo &>/dev/null; then
|
||||||
|
step "Installing Rust via rustup..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Build ──────────────────────────────────────────────────────────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
step "Building ahfail..."
|
||||||
|
MESON_FLAGS=("--buildtype=release" "-Dlibdir=${INSTALL_DIR%/ahfail}")
|
||||||
|
if [[ -d builddir ]]; then
|
||||||
|
meson setup builddir "${MESON_FLAGS[@]}" --wipe
|
||||||
|
else
|
||||||
|
meson setup builddir "${MESON_FLAGS[@]}"
|
||||||
|
fi
|
||||||
|
meson compile -C builddir
|
||||||
|
|
||||||
|
# ── 4. Install binaries ───────────────────────────────────────────────────────
|
||||||
|
step "Installing binaries to ${INSTALL_DIR}/ (requires sudo)..."
|
||||||
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
|
sudo cp builddir/libahfail_pam.so "$INSTALL_DIR/"
|
||||||
|
sudo cp builddir/ahfail-display "$INSTALL_DIR/"
|
||||||
|
sudo chmod 755 "$INSTALL_DIR/libahfail_pam.so" "$INSTALL_DIR/ahfail-display"
|
||||||
|
|
||||||
|
# ── 5. Configure PAM ─────────────────────────────────────────────────────────
|
||||||
|
step "Configuring PAM..."
|
||||||
|
|
||||||
|
# macOS 13+ uses screensaverui; 12 and earlier uses screensaver
|
||||||
|
if [[ -f /etc/pam.d/screensaverui ]]; then
|
||||||
|
PAM_FILE="/etc/pam.d/screensaverui"
|
||||||
|
elif [[ -f /etc/pam.d/screensaver ]]; then
|
||||||
|
PAM_FILE="/etc/pam.d/screensaver"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo " Could not find a screensaver PAM file. Add this line manually"
|
||||||
|
echo " to the appropriate file in /etc/pam.d/ (after the auth entries):"
|
||||||
|
echo ""
|
||||||
|
echo " auth optional ${INSTALL_DIR}/libahfail_pam.so"
|
||||||
|
echo ""
|
||||||
|
echo "Done (PAM not configured automatically)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PAM_LINE="auth optional ${INSTALL_DIR}/libahfail_pam.so"
|
||||||
|
|
||||||
|
if grep -qF "$PAM_LINE" "$PAM_FILE"; then
|
||||||
|
echo " PAM already configured in ${PAM_FILE}."
|
||||||
|
else
|
||||||
|
# Insert after the last 'auth' line so we stay in the auth block.
|
||||||
|
sudo python3 - "$PAM_FILE" "$PAM_LINE" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
|
||||||
|
pam_file, new_line = sys.argv[1], sys.argv[2] + "\n"
|
||||||
|
lines = open(pam_file).readlines()
|
||||||
|
|
||||||
|
if new_line in lines:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Find the last line that starts with 'auth'
|
||||||
|
last_auth = max(
|
||||||
|
(i for i, l in enumerate(lines) if l.strip().startswith("auth")),
|
||||||
|
default=len(lines) - 1,
|
||||||
|
)
|
||||||
|
lines.insert(last_auth + 1, new_line)
|
||||||
|
open(pam_file, "w").writelines(lines)
|
||||||
|
PYEOF
|
||||||
|
echo " Added to ${PAM_FILE}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "${BOLD}Done.${RESET} Lock your screen and type the wrong password to test."
|
||||||
54
scripts/uninstall-linux-x11.sh
Executable file
54
scripts/uninstall-linux-x11.sh
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# uninstall-linux-x11.sh — remove ahfail PAM module from X11 screen lockers
|
||||||
|
# Run from the repository root (uses ninja uninstall if builddir is present).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
|
|
||||||
|
step() { echo "${BOLD}==> $*${RESET}"; }
|
||||||
|
|
||||||
|
[[ "$(uname)" == "Linux" ]] || { echo "This script is for Linux only." >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── 1. Remove PAM config lines ────────────────────────────────────────────────
|
||||||
|
step "Removing ahfail from PAM service files..."
|
||||||
|
removed_pam=0
|
||||||
|
for pam_file in /etc/pam.d/i3lock /etc/pam.d/xscreensaver /etc/pam.d/lightdm \
|
||||||
|
/etc/pam.d/gdm /etc/pam.d/sddm /etc/pam.d/gtklock; do
|
||||||
|
if [[ -f "$pam_file" ]] && grep -q "ahfail" "$pam_file"; then
|
||||||
|
echo " Patching ${pam_file}..."
|
||||||
|
sudo sed -i '/ahfail/d' "$pam_file"
|
||||||
|
removed_pam=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ $removed_pam -eq 0 ]] && echo " No ahfail PAM entries found in common service files."
|
||||||
|
|
||||||
|
# ── 2. Remove installed files ─────────────────────────────────────────────────
|
||||||
|
step "Removing installed files..."
|
||||||
|
|
||||||
|
# Prefer meson's own uninstall if the builddir is still present
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUILDDIR="${SCRIPT_DIR}/../builddir"
|
||||||
|
if [[ -f "${BUILDDIR}/build.ninja" ]]; then
|
||||||
|
echo " Running meson uninstall..."
|
||||||
|
sudo ninja -C "$BUILDDIR" uninstall
|
||||||
|
else
|
||||||
|
echo " builddir not found — removing known paths manually..."
|
||||||
|
for dir in /usr/lib/ahfail /usr/local/lib/ahfail \
|
||||||
|
/usr/lib64/ahfail /usr/lib/x86_64-linux-gnu/ahfail; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
sudo rm -rf "$dir"
|
||||||
|
echo " Removed ${dir}."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for f in /usr/lib/gtklock/ahfail-module.so \
|
||||||
|
/usr/local/lib/gtklock/ahfail-module.so; do
|
||||||
|
if [[ -f "$f" ]]; then
|
||||||
|
sudo rm -f "$f"
|
||||||
|
echo " Removed ${f}."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "${BOLD}Done.${RESET}"
|
||||||
36
scripts/uninstall-macos.sh
Executable file
36
scripts/uninstall-macos.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# uninstall-macos.sh — remove ahfail from macOS (manual/script install)
|
||||||
|
# For Homebrew installs: brew uninstall ahfail, then run this to clean up PAM.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/usr/local/lib/ahfail"
|
||||||
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
|
|
||||||
|
step() { echo "${BOLD}==> $*${RESET}"; }
|
||||||
|
|
||||||
|
[[ "$(uname)" == "Darwin" ]] || { echo "This script is for macOS only." >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── 1. Remove PAM config line ─────────────────────────────────────────────────
|
||||||
|
step "Removing ahfail from PAM configuration..."
|
||||||
|
removed_pam=0
|
||||||
|
for pam_file in /etc/pam.d/screensaverui /etc/pam.d/screensaver; do
|
||||||
|
if [[ -f "$pam_file" ]] && grep -q "ahfail" "$pam_file"; then
|
||||||
|
echo " Patching ${pam_file}..."
|
||||||
|
sudo sed -i '' '/ahfail/d' "$pam_file"
|
||||||
|
removed_pam=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ $removed_pam -eq 0 ]] && echo " No ahfail PAM entries found."
|
||||||
|
|
||||||
|
# ── 2. Remove binaries ────────────────────────────────────────────────────────
|
||||||
|
step "Removing binaries..."
|
||||||
|
if [[ -d "$INSTALL_DIR" ]]; then
|
||||||
|
sudo rm -rf "$INSTALL_DIR"
|
||||||
|
echo " Removed ${INSTALL_DIR}."
|
||||||
|
else
|
||||||
|
echo " ${INSTALL_DIR} not found — skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "${BOLD}Done.${RESET}"
|
||||||
Reference in New Issue
Block a user