Add ahfail, a screen locker of its own
ahfail locks the screen itself instead of hooking another locker: Wayland through ext-session-lock-v1 (gtk-session-lock), X11 through an input grab. It authenticates against its own PAM service, /etc/pam.d/ahfail, so a package never has to edit another package's PAM file. One command for every platform: ahfail (lock), ahfail test (the effect without locking) and ahfail doctor (what is missing, with the fix). Enter is handled directly and the entry uses no input method, since the Wayland text-input IM swallowed Return. Sprites avoid the password box in window coordinates, and placement gets 100 tries instead of 10. Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
Generated
+15
@@ -8,6 +8,21 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahfail"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"ahfail-ui",
|
||||
"cc",
|
||||
"gdk",
|
||||
"glib",
|
||||
"gstreamer",
|
||||
"gstreamer-player",
|
||||
"gtk",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahfail-display"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -4,5 +4,6 @@ members = [
|
||||
"crates/ahfail-gtklock",
|
||||
"crates/ahfail-pam",
|
||||
"crates/ahfail-display",
|
||||
"crates/ahfail",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -3,7 +3,9 @@ use rand::Rng;
|
||||
use crate::config::ModuleConfig;
|
||||
|
||||
const SPRITE_MARGIN: i32 = 100;
|
||||
const RETRY_ATTEMPTS: usize = 10;
|
||||
// Cheap, and with only 10 tries a sprite could still land on the password
|
||||
// field on a small screen.
|
||||
const RETRY_ATTEMPTS: usize = 100;
|
||||
|
||||
pub fn sprite_position(
|
||||
animation: &gdk_pixbuf::PixbufAnimation,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "ahfail"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
description = "Screen locker that says ah ah ah when you get the password wrong"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
[[bin]]
|
||||
name = "ahfail"
|
||||
|
||||
[dependencies]
|
||||
# The locker never checks for updates itself, so it does not pull in the HTTP client.
|
||||
ahfail-ui = { path = "../ahfail-ui", default-features = false }
|
||||
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 = "0.18"
|
||||
glib = { version = "0.15", package = "glib" }
|
||||
libc = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
pkg-config = "0.3"
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
// Same GResource embedding as ahfail-display: the sprites and the audio clip are
|
||||
// compiled into the binary, and ahfail_ui::animation looks them up through the
|
||||
// `ahfail_get_resource` symbol that glib-compile-resources generates.
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
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");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", gresource_xml.display());
|
||||
for entry in std::fs::read_dir(&assets_dir).unwrap() {
|
||||
println!("cargo:rerun-if-changed={}", entry.unwrap().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");
|
||||
assert!(status.success(), "glib-compile-resources failed");
|
||||
|
||||
let gio = pkg_config::probe_library("gio-2.0").expect("gio-2.0 not found");
|
||||
let mut build = cc::Build::new();
|
||||
build.file(&c_src).flag_if_supported("-w");
|
||||
for inc in &gio.include_paths {
|
||||
build.include(inc);
|
||||
}
|
||||
build.compile("ahfail_resources");
|
||||
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os == "linux" {
|
||||
// The Wayland backend drives ext-session-lock-v1 through gtk-session-lock,
|
||||
// the same library gtklock uses. The X11 backend and PAM need libpam.
|
||||
pkg_config::probe_library("gtk-session-lock-0").expect(
|
||||
"gtk-session-lock not found (Arch: pacman -S gtk-session-lock)",
|
||||
);
|
||||
println!("cargo:rustc-link-lib=pam");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! `ahfail doctor`: what works on this machine, what does not, and the exact
|
||||
//! fix. Exit code 1 if something would stop `ahfail lock` from working.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
struct Report {
|
||||
blocking: bool,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn ok(&self, what: &str) {
|
||||
println!(" \x1b[32m✓\x1b[0m {what}");
|
||||
}
|
||||
fn warn(&self, what: &str, fix: &str) {
|
||||
println!(" \x1b[33m!\x1b[0m {what}\n → {fix}");
|
||||
}
|
||||
fn fail(&mut self, what: &str, fix: &str) {
|
||||
self.blocking = true;
|
||||
println!(" \x1b[31m✗\x1b[0m {what}\n → {fix}");
|
||||
}
|
||||
fn info(&self, what: &str) {
|
||||
println!(" · {what}");
|
||||
}
|
||||
}
|
||||
|
||||
fn in_path(bin: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.map(|p| std::env::split_paths(&p).any(|d| d.join(bin).is_file()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn env(k: &str) -> String {
|
||||
std::env::var(k).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// PAM service files that still carry the old hook (libahfail_pam.so).
|
||||
fn legacy_pam_hooks(dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
rd.flatten()
|
||||
.filter(|e| {
|
||||
std::fs::read_to_string(e.path())
|
||||
.map(|s| s.lines().any(|l| !l.trim_start().starts_with('#') && l.contains("libahfail_pam")))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|e| e.path().display().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run() -> i32 {
|
||||
let mut r = Report { blocking: false };
|
||||
println!("ahfail {} doctor\n", crate::VERSION);
|
||||
#[cfg(target_os = "linux")]
|
||||
linux(&mut r);
|
||||
#[cfg(target_os = "macos")]
|
||||
macos(&mut r);
|
||||
println!();
|
||||
if r.blocking {
|
||||
println!("Something above stops `ahfail lock` from working.");
|
||||
1
|
||||
} else {
|
||||
println!("Ready. Try `ahfail test`, then `ahfail`.");
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux(r: &mut Report) {
|
||||
use crate::Backend;
|
||||
|
||||
println!("Session");
|
||||
let desktop = env("XDG_CURRENT_DESKTOP");
|
||||
r.info(&format!(
|
||||
"XDG_SESSION_TYPE={} XDG_CURRENT_DESKTOP={}",
|
||||
if env("XDG_SESSION_TYPE").is_empty() { "?".into() } else { env("XDG_SESSION_TYPE") },
|
||||
if desktop.is_empty() { "?" } else { &desktop }
|
||||
));
|
||||
let backend = crate::detect_backend();
|
||||
match backend {
|
||||
None => r.fail("no graphical session (WAYLAND_DISPLAY and DISPLAY are both empty)", "run ahfail from inside your desktop session, not over SSH"),
|
||||
Some(b) => r.ok(&format!("{b:?} session")),
|
||||
}
|
||||
if unsafe { libc::getuid() } == 0 {
|
||||
r.fail("running as root", "run ahfail as the user whose screen should be locked");
|
||||
}
|
||||
|
||||
if let Some(b) = backend {
|
||||
std::env::set_var("GDK_BACKEND", match b { Backend::Wayland => "wayland", Backend::X11 => "x11" });
|
||||
if gtk::init().is_err() {
|
||||
r.fail("GTK could not open the display", "check that the session variables are exported to this shell");
|
||||
} else if b == Backend::Wayland {
|
||||
if crate::wayland::supported() {
|
||||
r.ok("compositor supports ext-session-lock-v1");
|
||||
} else if desktop.to_uppercase().contains("GNOME") {
|
||||
r.fail("GNOME does not let third-party programs lock the screen on Wayland", "use GNOME's own lock, or log in to an X11 session for ahfail");
|
||||
} else {
|
||||
r.fail("compositor does not support ext-session-lock-v1", "sway, Hyprland, niri, river, labwc and KDE Plasma 6 do; update the compositor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nAuthentication");
|
||||
if Path::new("/etc/pam.d/ahfail").is_file() {
|
||||
r.ok("PAM service /etc/pam.d/ahfail");
|
||||
} else {
|
||||
r.fail("/etc/pam.d/ahfail is missing, so no password can ever be accepted", "install the package, or: sudo install -Dm644 data/pam.d/ahfail /etc/pam.d/ahfail");
|
||||
}
|
||||
|
||||
println!("\nSound");
|
||||
match gstreamer::init() {
|
||||
Err(_) => r.warn("GStreamer could not start", "install gstreamer and gst-plugins-good"),
|
||||
Ok(()) => {
|
||||
let decoder = ["mpg123audiodec", "avdec_mp3", "mad"]
|
||||
.iter()
|
||||
.find(|f| gstreamer::ElementFactory::find(f).is_some());
|
||||
match decoder {
|
||||
Some(d) => r.ok(&format!("mp3 decoder ({d})")),
|
||||
None => r.warn("no mp3 decoder: the clip will not play", "install gst-plugins-good (mpg123) or gst-libav"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_path("pactl") {
|
||||
r.ok("volume control via pactl");
|
||||
} else {
|
||||
r.warn("pactl not found: the volume cannot be raised to 100 %", "install pipewire-pulse (or libpulse); or run with --no-max-volume");
|
||||
}
|
||||
|
||||
println!("\nIdle locking");
|
||||
let hints = match backend {
|
||||
Some(Backend::Wayland) => ("swayidle", "swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'"),
|
||||
_ => ("xss-lock", "xss-lock -- ahfail"),
|
||||
};
|
||||
if in_path(hints.0) {
|
||||
r.ok(&format!("{} found; use: {}", hints.0, hints.1));
|
||||
} else {
|
||||
r.info(&format!("to lock automatically: {}", hints.1));
|
||||
}
|
||||
|
||||
let legacy = legacy_pam_hooks("/etc/pam.d");
|
||||
if !legacy.is_empty() {
|
||||
println!("\nOld integration");
|
||||
r.info(&format!(
|
||||
"the PAM hook libahfail_pam.so is still in {}; not needed with `ahfail lock`, but harmless if you keep using that locker",
|
||||
legacy.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos(r: &mut Report) {
|
||||
println!("macOS");
|
||||
let files = legacy_pam_hooks("/etc/pam.d");
|
||||
if files.is_empty() {
|
||||
r.fail(
|
||||
"the ahfail PAM line is not in the screensaver PAM file",
|
||||
"add `auth optional /usr/local/lib/ahfail/libahfail_pam.so` as the first auth line of /etc/pam.d/screensaverui",
|
||||
);
|
||||
} else {
|
||||
r.ok(&format!("PAM hook in {}", files.join(", ")));
|
||||
}
|
||||
r.info("`ahfail lock` uses the system lock (pmset displaysleepnow); enable \"Require password immediately\" in System Settings");
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The lock screen itself, shared by the Wayland and X11 backends and by the
|
||||
//! `ahfail test` preview: one full-screen window per monitor with a clock and a
|
||||
//! password field, and a layer on top where Nedry appears on every failure.
|
||||
//!
|
||||
//! Backends only decide how the windows become a lock (ext-session-lock-v1 on
|
||||
//! Wayland, an input grab on X11) and how it is released again.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ahfail_ui::config::ModuleConfig;
|
||||
use gtk::gdk_pixbuf::PixbufAnimation;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::pam;
|
||||
|
||||
pub const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
|
||||
|
||||
/// Space kept free of sprites around the password box, in pixels.
|
||||
const DEADZONE_MARGIN: i32 = 48;
|
||||
|
||||
const CSS: &str = r#"
|
||||
window.ahfail { background: radial-gradient(ellipse at center, #182232 0%, #0b0f17 70%); }
|
||||
.ahfail .clock { color: #e6e9ef; font-size: 88px; font-weight: 300; }
|
||||
.ahfail .date { color: #97a3b6; font-size: 20px; }
|
||||
.ahfail .user { color: #e6e9ef; font-size: 17px; font-weight: 600; margin-top: 28px; }
|
||||
.ahfail entry {
|
||||
min-width: 300px; min-height: 42px; font-size: 18px;
|
||||
border-radius: 21px; padding: 0 18px;
|
||||
background: rgba(255,255,255,0.08); color: #e6e9ef;
|
||||
border: 1px solid rgba(255,255,255,0.18); box-shadow: none;
|
||||
}
|
||||
.ahfail entry:focus { border-color: #38bdf8; }
|
||||
.ahfail .status { color: #97a3b6; font-size: 15px; min-height: 22px; }
|
||||
.ahfail .status.error { color: #f87171; font-weight: 600; }
|
||||
"#;
|
||||
|
||||
pub struct Options {
|
||||
/// Force the volume to 100 % on the first failure (restored on unlock).
|
||||
pub max_volume: bool,
|
||||
}
|
||||
|
||||
struct LockWindow {
|
||||
window: gtk::Window,
|
||||
clock: gtk::Label,
|
||||
date: gtk::Label,
|
||||
fixed: gtk::Fixed,
|
||||
center: gtk::Box,
|
||||
entry: gtk::Entry,
|
||||
status: gtk::Label,
|
||||
/// Whether this window shows the password field (on X11 only the primary does).
|
||||
has_entry: bool,
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
Ok,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct Locker {
|
||||
windows: Vec<LockWindow>,
|
||||
animation: Option<PixbufAnimation>,
|
||||
players: Vec<gstreamer_player::Player>,
|
||||
volume: Option<ahfail_ui::volume::VolumeState>,
|
||||
failures: u32,
|
||||
busy: bool,
|
||||
user: String,
|
||||
opts: Options,
|
||||
sender: glib::Sender<AuthResult>,
|
||||
/// Set by the backend: releases the lock. Called once, after PAM said yes.
|
||||
on_unlock: Option<Box<dyn Fn()>>,
|
||||
unlocked: bool,
|
||||
}
|
||||
|
||||
pub type Shared = Rc<RefCell<Locker>>;
|
||||
|
||||
/// Must be called after gtk::init() and gstreamer::init().
|
||||
pub fn new(user: String, opts: Options) -> Shared {
|
||||
install_css();
|
||||
let (sender, receiver) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
let animation = unsafe { ahfail_ui::animation::load_animation() };
|
||||
if animation.is_none() {
|
||||
eprintln!("[ahfail] sprites could not be loaded; locking without them");
|
||||
}
|
||||
let locker = Rc::new(RefCell::new(Locker {
|
||||
windows: Vec::new(),
|
||||
animation,
|
||||
players: Vec::new(),
|
||||
volume: None,
|
||||
failures: 0,
|
||||
busy: false,
|
||||
user,
|
||||
opts,
|
||||
sender,
|
||||
on_unlock: None,
|
||||
unlocked: false,
|
||||
}));
|
||||
let weak = Rc::downgrade(&locker);
|
||||
receiver.attach(None, move |res| {
|
||||
if let Some(l) = weak.upgrade() {
|
||||
handle_result(&l, res);
|
||||
}
|
||||
glib::Continue(true)
|
||||
});
|
||||
start_clock(&locker);
|
||||
locker
|
||||
}
|
||||
|
||||
pub fn set_on_unlock(locker: &Shared, f: impl Fn() + 'static) {
|
||||
locker.borrow_mut().on_unlock = Some(Box::new(f));
|
||||
}
|
||||
|
||||
fn install_css() {
|
||||
let provider = gtk::CssProvider::new();
|
||||
if provider.load_from_data(CSS.as_bytes()).is_ok() {
|
||||
if let Some(screen) = gdk::Screen::default() {
|
||||
gtk::StyleContext::add_provider_for_screen(
|
||||
&screen,
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now(fmt: &str) -> String {
|
||||
glib::DateTime::now_local()
|
||||
.ok()
|
||||
.and_then(|d| d.format(fmt).ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn start_clock(locker: &Shared) {
|
||||
let weak = Rc::downgrade(locker);
|
||||
glib::timeout_add_seconds_local(1, move || {
|
||||
let Some(l) = weak.upgrade() else { return glib::Continue(false) };
|
||||
let (clock, date) = (now("%H:%M"), now("%A %-d. %B"));
|
||||
for w in &l.borrow().windows {
|
||||
w.clock.set_text(&clock);
|
||||
w.date.set_text(&date);
|
||||
}
|
||||
glib::Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the lock window for one monitor. The backend decides how it is shown
|
||||
/// (a session-lock surface, or a grabbed override-redirect window).
|
||||
pub fn build_window(locker: &Shared, kind: gtk::WindowType, with_entry: bool) -> gtk::Window {
|
||||
let window = gtk::Window::new(kind);
|
||||
window.style_context().add_class("ahfail");
|
||||
window.set_decorated(false);
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
let background = gtk::EventBox::new();
|
||||
overlay.add(&background);
|
||||
|
||||
// Sprites live on their own layer that never takes input, so a Nedry landing
|
||||
// on top of the field cannot swallow a click.
|
||||
let fixed = gtk::Fixed::new();
|
||||
overlay.add_overlay(&fixed);
|
||||
overlay.set_overlay_pass_through(&fixed, true);
|
||||
|
||||
let center = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
center.set_halign(gtk::Align::Center);
|
||||
center.set_valign(gtk::Align::Center);
|
||||
let clock = gtk::Label::new(Some(&now("%H:%M")));
|
||||
clock.style_context().add_class("clock");
|
||||
let date = gtk::Label::new(Some(&now("%A %-d. %B")));
|
||||
date.style_context().add_class("date");
|
||||
let user = gtk::Label::new(Some(&locker.borrow().user));
|
||||
user.style_context().add_class("user");
|
||||
let entry = gtk::Entry::new();
|
||||
entry.set_visibility(false);
|
||||
entry.set_input_purpose(gtk::InputPurpose::Password);
|
||||
entry.set_placeholder_text(Some("Password"));
|
||||
// No input method on a lock screen: keys must not be routed through an
|
||||
// external IM, and on Wayland the text-input IM swallowed Return before the
|
||||
// entry could turn it into "activate".
|
||||
entry.set_im_module(Some("gtk-im-context-simple"));
|
||||
entry.set_halign(gtk::Align::Center);
|
||||
let status = gtk::Label::new(None);
|
||||
status.style_context().add_class("status");
|
||||
for w in [clock.upcast_ref::<gtk::Widget>(), date.upcast_ref(), user.upcast_ref(), entry.upcast_ref(), status.upcast_ref()] {
|
||||
center.add(w);
|
||||
}
|
||||
if !with_entry {
|
||||
user.set_no_show_all(true);
|
||||
entry.set_no_show_all(true);
|
||||
status.set_no_show_all(true);
|
||||
}
|
||||
overlay.add_overlay(¢er);
|
||||
window.add(&overlay);
|
||||
|
||||
// Enter is handled here rather than through "activate", so submitting does
|
||||
// not depend on how the input stack translates the key.
|
||||
let weak = Rc::downgrade(locker);
|
||||
entry.connect_key_press_event(move |e, ev| {
|
||||
use gdk::keys::constants as key;
|
||||
let k = ev.keyval();
|
||||
if k == key::Return || k == key::KP_Enter || k == key::ISO_Enter {
|
||||
if let Some(l) = weak.upgrade() {
|
||||
submit(&l, e);
|
||||
}
|
||||
return gtk::Inhibit(true);
|
||||
}
|
||||
if k == key::Escape {
|
||||
e.set_text("");
|
||||
}
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
|
||||
locker.borrow_mut().windows.push(LockWindow { window: window.clone(), clock, date, fixed, center, entry, status, has_entry: with_entry });
|
||||
window
|
||||
}
|
||||
|
||||
fn submit(locker: &Shared, entry: >k::Entry) {
|
||||
let (user, sender) = {
|
||||
let mut l = locker.borrow_mut();
|
||||
if l.busy || l.unlocked {
|
||||
return;
|
||||
}
|
||||
l.busy = true;
|
||||
(l.user.clone(), l.sender.clone())
|
||||
};
|
||||
let secret = pam::Secret::new(entry.text().to_string());
|
||||
entry.set_text("");
|
||||
for w in &locker.borrow().windows {
|
||||
w.entry.set_sensitive(false);
|
||||
w.status.style_context().remove_class("error");
|
||||
w.status.set_text("Checking…");
|
||||
}
|
||||
// PAM may take seconds (pam_faillock delays, network auth): never on the GTK thread.
|
||||
std::thread::spawn(move || {
|
||||
let res = match pam::authenticate(&user, secret) {
|
||||
Ok(()) => AuthResult::Ok,
|
||||
Err(e) => {
|
||||
eprintln!("[ahfail] authentication failed: {e}");
|
||||
AuthResult::Failed
|
||||
}
|
||||
};
|
||||
let _ = sender.send(res);
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_result(locker: &Shared, res: AuthResult) {
|
||||
match res {
|
||||
AuthResult::Ok => unlock(locker),
|
||||
AuthResult::Failed => {
|
||||
fail(locker);
|
||||
let l = locker.borrow();
|
||||
for w in &l.windows {
|
||||
w.entry.set_sensitive(true);
|
||||
}
|
||||
if let Some(w) = l.windows.iter().find(|w| w.has_entry) {
|
||||
w.entry.grab_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
locker.borrow_mut().busy = false;
|
||||
}
|
||||
|
||||
/// One wrong password: a new Nedry on every monitor, and the clip.
|
||||
pub fn fail(locker: &Shared) {
|
||||
let mut l = locker.borrow_mut();
|
||||
l.failures += 1;
|
||||
let n = l.failures;
|
||||
if n == 1 && l.opts.max_volume {
|
||||
l.volume = ahfail_ui::volume::save_and_set_max();
|
||||
}
|
||||
let text = if n == 1 {
|
||||
"Ah ah ah, you didn't say the magic word!".to_string()
|
||||
} else {
|
||||
format!("Ah ah ah, you didn't say the magic word! ({n})")
|
||||
};
|
||||
for w in &l.windows {
|
||||
w.status.set_text(&text);
|
||||
w.status.style_context().add_class("error");
|
||||
}
|
||||
if let Some(anim) = l.animation.clone() {
|
||||
for w in &l.windows {
|
||||
let alloc = w.window.allocation();
|
||||
// Overlay children get their own GdkWindow in GTK3, so the box's
|
||||
// allocation is relative to itself (always 0,0). Translate it into the
|
||||
// window's coordinates, which are also the sprite layer's.
|
||||
let c = w.center.allocation();
|
||||
let (cx, cy) = w.center.translate_coordinates(&w.window, 0, 0).unwrap_or((c.x(), c.y()));
|
||||
let deadzone = gdk::Rectangle::new(
|
||||
cx - DEADZONE_MARGIN,
|
||||
cy - DEADZONE_MARGIN,
|
||||
c.width() + 2 * DEADZONE_MARGIN,
|
||||
c.height() + 2 * DEADZONE_MARGIN,
|
||||
);
|
||||
let cfg = ModuleConfig { deadzone: Some(deadzone) };
|
||||
ahfail_ui::display::place_sprite(&w.fixed, &anim, alloc.width(), alloc.height(), &cfg);
|
||||
}
|
||||
}
|
||||
let player = ahfail_ui::audio::create_player(AUDIO_URI);
|
||||
player.play();
|
||||
l.players.push(player);
|
||||
}
|
||||
|
||||
fn unlock(locker: &Shared) {
|
||||
let cb = {
|
||||
let mut l = locker.borrow_mut();
|
||||
if l.unlocked {
|
||||
return;
|
||||
}
|
||||
l.unlocked = true;
|
||||
for p in l.players.drain(..) {
|
||||
p.stop();
|
||||
}
|
||||
if let Some(v) = l.volume.take() {
|
||||
ahfail_ui::volume::restore(v);
|
||||
}
|
||||
l.on_unlock.take()
|
||||
};
|
||||
if let Some(cb) = cb {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the volume if we are leaving without a successful unlock (e.g. the
|
||||
/// compositor ended the lock, or the preview window was closed).
|
||||
pub fn cleanup(locker: &Shared) {
|
||||
let mut l = locker.borrow_mut();
|
||||
for p in l.players.drain(..) {
|
||||
p.stop();
|
||||
}
|
||||
if let Some(v) = l.volume.take() {
|
||||
ahfail_ui::volume::restore(v);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_entry(locker: &Shared) {
|
||||
if let Some(w) = locker.borrow().windows.iter().find(|w| w.has_entry) {
|
||||
w.entry.grab_focus();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn windows(locker: &Shared) -> Vec<gtk::Window> {
|
||||
locker.borrow().windows.iter().map(|w| w.window.clone()).collect()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! `ahfail`: one command in front of every platform.
|
||||
//!
|
||||
//! ahfail lock the screen (same as `ahfail lock`)
|
||||
//! ahfail test show the effect once, without locking
|
||||
//! ahfail doctor explain what works on this machine and what is missing
|
||||
//!
|
||||
//! Linux: ahfail is itself the locker (Wayland: ext-session-lock-v1, X11: input
|
||||
//! grab) and authenticates through its own PAM service, /etc/pam.d/ahfail.
|
||||
//! macOS: the lock screen belongs to the system, so `lock` asks the system to
|
||||
//! lock and the effect comes from the PAM module (see `ahfail doctor`).
|
||||
|
||||
mod doctor;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod locker;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod pam;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod preview;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11;
|
||||
|
||||
use std::process::exit;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
const HELP: &str = "\
|
||||
ahfail - ah ah ah, you didn't say the magic word
|
||||
|
||||
Usage:
|
||||
ahfail [lock] [--no-max-volume] lock the screen
|
||||
ahfail test [--max-volume] play the effect once, without locking
|
||||
ahfail doctor check this machine and explain what is missing
|
||||
ahfail --version
|
||||
|
||||
Linux picks the backend from the session: Wayland (ext-session-lock-v1, as used by
|
||||
sway, Hyprland, niri, river and KDE) or X11. GNOME does not allow third-party
|
||||
lockers on Wayland.
|
||||
|
||||
Idle locking:
|
||||
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
|
||||
xss-lock -- ahfail
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Backend {
|
||||
Wayland,
|
||||
X11,
|
||||
}
|
||||
|
||||
/// Which display server this session runs on, from the environment alone.
|
||||
pub fn detect_backend() -> Option<Backend> {
|
||||
let set = |k: &str| std::env::var(k).map(|v| !v.is_empty()).unwrap_or(false);
|
||||
if set("WAYLAND_DISPLAY") {
|
||||
Some(Backend::Wayland)
|
||||
} else if set("DISPLAY") {
|
||||
Some(Backend::X11)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let flag = |f: &str| args.iter().any(|a| a == f);
|
||||
let cmd = args.iter().find(|a| !a.starts_with('-')).map(String::as_str).unwrap_or("lock");
|
||||
|
||||
if flag("--version") || flag("-V") {
|
||||
println!("ahfail {VERSION}");
|
||||
return;
|
||||
}
|
||||
if flag("--help") || flag("-h") || cmd == "help" {
|
||||
print!("{HELP}");
|
||||
return;
|
||||
}
|
||||
let code = match cmd {
|
||||
"lock" => lock(!flag("--no-max-volume")),
|
||||
"test" => test(flag("--max-volume")),
|
||||
"doctor" => doctor::run(),
|
||||
other => {
|
||||
eprintln!("ahfail: unknown command '{other}'\n\n{HELP}");
|
||||
2
|
||||
}
|
||||
};
|
||||
exit(code);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn lock(_max_volume: bool) -> i32 {
|
||||
// The macOS lock screen is the system's; the ahfail PAM module hooks it.
|
||||
match std::process::Command::new("pmset").arg("displaysleepnow").status() {
|
||||
Ok(s) if s.success() => 0,
|
||||
_ => {
|
||||
eprintln!("ahfail: could not lock the screen (pmset displaysleepnow failed)");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn test(_max_volume: bool) -> i32 {
|
||||
eprintln!("ahfail: `test` is not available on macOS yet; lock the screen and type a wrong password");
|
||||
1
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn prepare(backend: Backend) -> Result<(), String> {
|
||||
if unsafe { libc::getuid() } == 0 {
|
||||
return Err("refusing to run as root: lock the screen as the logged-in user".into());
|
||||
}
|
||||
// Under XWayland both variables are set; pin GTK to the backend we chose so
|
||||
// it does not quietly pick the other one.
|
||||
std::env::set_var("GDK_BACKEND", match backend { Backend::Wayland => "wayland", Backend::X11 => "x11" });
|
||||
gtk::init().map_err(|e| format!("could not open the display: {e}"))?;
|
||||
gstreamer::init().map_err(|e| format!("could not start GStreamer: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Only one ahfail may lock at a time; a second call (e.g. swayidle firing again
|
||||
/// while locked) exits quietly. The lock file dies with the process.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn single_instance() -> Option<std::fs::File> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
let path = std::path::Path::new(&dir).join(format!("ahfail-{}.lock", unsafe { libc::getuid() }));
|
||||
let f = std::fs::OpenOptions::new().create(true).write(true).open(path).ok()?;
|
||||
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(f)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lock(max_volume: bool) -> i32 {
|
||||
let Some(backend) = detect_backend() else {
|
||||
eprintln!("ahfail: no graphical session found (neither WAYLAND_DISPLAY nor DISPLAY is set)");
|
||||
return 1;
|
||||
};
|
||||
let Some(_instance) = single_instance() else {
|
||||
return 0; // already locked
|
||||
};
|
||||
if let Err(e) = prepare(backend) {
|
||||
eprintln!("ahfail: {e}");
|
||||
return 1;
|
||||
}
|
||||
if backend == Backend::Wayland && !wayland::supported() {
|
||||
eprintln!("ahfail: this compositor does not support ext-session-lock-v1, so no third-party locker can lock it (GNOME is one of these). Run `ahfail doctor`.");
|
||||
return 1;
|
||||
}
|
||||
let Some(user) = pam::current_user() else {
|
||||
eprintln!("ahfail: could not determine the current user");
|
||||
return 1;
|
||||
};
|
||||
let l = locker::new(user, locker::Options { max_volume });
|
||||
match backend {
|
||||
Backend::Wayland => wayland::run(l),
|
||||
Backend::X11 => x11::run(l),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn test(max_volume: bool) -> i32 {
|
||||
let Some(backend) = detect_backend() else {
|
||||
eprintln!("ahfail: no graphical session found");
|
||||
return 1;
|
||||
};
|
||||
if let Err(e) = prepare(backend) {
|
||||
eprintln!("ahfail: {e}");
|
||||
return 1;
|
||||
}
|
||||
let user = pam::current_user().unwrap_or_default();
|
||||
preview::run(locker::new(user, locker::Options { max_volume }))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Minimal PAM client: authenticate the current user against the `ahfail`
|
||||
//! service (/etc/pam.d/ahfail, shipped by the package).
|
||||
//!
|
||||
//! This is the application side of PAM, the opposite of crates/ahfail-pam,
|
||||
//! which is a module that sits inside someone else's stack.
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::{c_char, c_int, c_void};
|
||||
use std::ptr;
|
||||
|
||||
const PAM_SUCCESS: c_int = 0;
|
||||
const PAM_PROMPT_ECHO_OFF: c_int = 1;
|
||||
const PAM_PROMPT_ECHO_ON: c_int = 2;
|
||||
const PAM_REFRESH_CRED: c_int = 0x0010;
|
||||
const PAM_CONV_ERR: c_int = 19;
|
||||
const PAM_BUF_ERR: c_int = 5;
|
||||
|
||||
#[repr(C)]
|
||||
struct PamMessage {
|
||||
msg_style: c_int,
|
||||
msg: *const c_char,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamResponse {
|
||||
resp: *mut c_char,
|
||||
resp_retcode: c_int,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamConv {
|
||||
conv: extern "C" fn(c_int, *mut *const PamMessage, *mut *mut PamResponse, *mut c_void) -> c_int,
|
||||
appdata_ptr: *mut c_void,
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn pam_start(
|
||||
service: *const c_char,
|
||||
user: *const c_char,
|
||||
conv: *const PamConv,
|
||||
pamh: *mut *mut c_void,
|
||||
) -> c_int;
|
||||
fn pam_authenticate(pamh: *mut c_void, flags: c_int) -> c_int;
|
||||
fn pam_setcred(pamh: *mut c_void, flags: c_int) -> c_int;
|
||||
fn pam_end(pamh: *mut c_void, status: c_int) -> c_int;
|
||||
fn pam_strerror(pamh: *mut c_void, errnum: c_int) -> *const c_char;
|
||||
}
|
||||
|
||||
/// The password, overwritten with zeros when dropped so it does not linger in
|
||||
/// the heap after the attempt.
|
||||
pub struct Secret(Vec<u8>);
|
||||
|
||||
impl Secret {
|
||||
pub fn new(s: String) -> Self {
|
||||
Secret(s.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Secret {
|
||||
fn drop(&mut self) {
|
||||
for b in self.0.iter_mut() {
|
||||
unsafe { ptr::write_volatile(b, 0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn conversation(
|
||||
num_msg: c_int,
|
||||
msg: *mut *const PamMessage,
|
||||
resp: *mut *mut PamResponse,
|
||||
appdata: *mut c_void,
|
||||
) -> c_int {
|
||||
if num_msg <= 0 || msg.is_null() || resp.is_null() || appdata.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let secret = unsafe { &*(appdata as *const Secret) };
|
||||
// PAM frees the array and each resp string with free(), so both must come
|
||||
// from the C allocator.
|
||||
let replies = unsafe {
|
||||
libc::calloc(num_msg as usize, std::mem::size_of::<PamResponse>()) as *mut PamResponse
|
||||
};
|
||||
if replies.is_null() {
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
for i in 0..num_msg as isize {
|
||||
// Linux-PAM passes an array of pointers; index it that way.
|
||||
let m = unsafe { *msg.offset(i) };
|
||||
if m.is_null() {
|
||||
continue;
|
||||
}
|
||||
let style = unsafe { (*m).msg_style };
|
||||
if style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON {
|
||||
let len = secret.0.len();
|
||||
let p = unsafe { libc::malloc(len + 1) as *mut u8 };
|
||||
if p.is_null() {
|
||||
unsafe { libc::free(replies as *mut c_void) };
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
unsafe {
|
||||
ptr::copy_nonoverlapping(secret.0.as_ptr(), p, len);
|
||||
*p.add(len) = 0;
|
||||
(*replies.offset(i)).resp = p as *mut c_char;
|
||||
}
|
||||
}
|
||||
// PAM_ERROR_MSG / PAM_TEXT_INFO need no answer: resp stays NULL.
|
||||
}
|
||||
unsafe { *resp = replies };
|
||||
PAM_SUCCESS
|
||||
}
|
||||
|
||||
/// Blocking: run it off the GTK thread. Ok(()) only on PAM_SUCCESS.
|
||||
pub fn authenticate(user: &str, password: Secret) -> Result<(), String> {
|
||||
let service = CString::new("ahfail").unwrap();
|
||||
let user_c = CString::new(user).map_err(|_| "invalid user name".to_string())?;
|
||||
let conv = PamConv {
|
||||
conv: conversation,
|
||||
appdata_ptr: &password as *const Secret as *mut c_void,
|
||||
};
|
||||
let mut handle: *mut c_void = ptr::null_mut();
|
||||
let rc = unsafe { pam_start(service.as_ptr(), user_c.as_ptr(), &conv, &mut handle) };
|
||||
if rc != PAM_SUCCESS || handle.is_null() {
|
||||
return Err(format!("pam_start failed ({rc})"));
|
||||
}
|
||||
let rc = unsafe { pam_authenticate(handle, 0) };
|
||||
let result = if rc == PAM_SUCCESS {
|
||||
// Refresh credentials (e.g. Kerberos tickets), as i3lock does. A failure
|
||||
// here is not a reason to keep the screen locked.
|
||||
unsafe { pam_setcred(handle, PAM_REFRESH_CRED) };
|
||||
Ok(())
|
||||
} else {
|
||||
let msg = unsafe { pam_strerror(handle, rc) };
|
||||
Err(if msg.is_null() {
|
||||
format!("PAM error {rc}")
|
||||
} else {
|
||||
unsafe { CStr::from_ptr(msg) }.to_string_lossy().into_owned()
|
||||
})
|
||||
};
|
||||
unsafe { pam_end(handle, rc) };
|
||||
drop(password);
|
||||
result
|
||||
}
|
||||
|
||||
/// Login name of the user running ahfail.
|
||||
pub fn current_user() -> Option<String> {
|
||||
let pw = unsafe { libc::getpwuid(libc::getuid()) };
|
||||
if pw.is_null() {
|
||||
return None;
|
||||
}
|
||||
let name = unsafe { CStr::from_ptr((*pw).pw_name) };
|
||||
Some(name.to_string_lossy().into_owned())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! `ahfail test`: the lock screen in an ordinary window, with one failure
|
||||
//! triggered straight away. Nothing is locked and no password is checked, so it
|
||||
//! is safe to run to see whether sprites and sound work.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::locker::{self, Shared};
|
||||
|
||||
const SHOW_FOR: Duration = Duration::from_secs(8);
|
||||
|
||||
pub fn run(locker: Shared) -> i32 {
|
||||
let window = locker::build_window(&locker, gtk::WindowType::Toplevel, false);
|
||||
window.set_title("ahfail test");
|
||||
window.set_decorated(true);
|
||||
window.set_default_size(960, 600);
|
||||
window.set_position(gtk::WindowPosition::Center);
|
||||
window.connect_delete_event(|_, _| {
|
||||
gtk::main_quit();
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
window.connect_key_press_event(|_, ev| {
|
||||
if ev.keyval() == gdk::keys::constants::Escape {
|
||||
gtk::main_quit();
|
||||
}
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
window.show_all();
|
||||
|
||||
// Wait for the first layout so the sprite lands inside the window.
|
||||
{
|
||||
let locker = locker.clone();
|
||||
glib::timeout_add_local_once(Duration::from_millis(400), move || locker::fail(&locker));
|
||||
}
|
||||
glib::timeout_add_local_once(SHOW_FOR, gtk::main_quit);
|
||||
|
||||
gtk::main();
|
||||
locker::cleanup(&locker);
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Wayland backend: ext-session-lock-v1 through gtk-session-lock.
|
||||
//!
|
||||
//! The protocol is what makes this safe: once the compositor has confirmed the
|
||||
//! lock, only our lock surfaces are shown, and if ahfail crashes or is killed the
|
||||
//! session stays locked (the compositor shows a fallback) rather than opening up.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::os::raw::c_void;
|
||||
use std::rc::Rc;
|
||||
|
||||
use glib::translate::{FromGlibPtrNone, ToGlibPtr};
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::locker::{self, Shared};
|
||||
|
||||
extern "C" {
|
||||
fn gtk_session_lock_is_supported() -> glib::ffi::gboolean;
|
||||
fn gtk_session_lock_prepare_lock() -> *mut c_void;
|
||||
fn gtk_session_lock_lock_lock(lock: *mut c_void);
|
||||
fn gtk_session_lock_lock_new_surface(lock: *mut c_void, window: *mut gtk::ffi::GtkWindow, monitor: *mut gdk::ffi::GdkMonitor);
|
||||
fn gtk_session_lock_lock_unlock_and_destroy(lock: *mut c_void);
|
||||
}
|
||||
|
||||
/// True when the compositor implements ext-session-lock-v1. Needs gtk::init().
|
||||
pub fn supported() -> bool {
|
||||
unsafe { gtk_session_lock_is_supported() != 0 }
|
||||
}
|
||||
|
||||
pub fn run(locker: Shared) -> i32 {
|
||||
let lock = unsafe { gtk_session_lock_prepare_lock() };
|
||||
if lock.is_null() {
|
||||
eprintln!("ahfail: could not create a session lock");
|
||||
return 1;
|
||||
}
|
||||
// A borrowed wrapper, only for connecting signals. The library owns the
|
||||
// object and frees it in unlock_and_destroy, so this wrapper is leaked on
|
||||
// purpose rather than dropped after that.
|
||||
let obj: glib::Object = unsafe { glib::Object::from_glib_none(lock as *mut glib::gobject_ffi::GObject) };
|
||||
|
||||
let locked = Rc::new(Cell::new(false));
|
||||
let exit_code = Rc::new(Cell::new(0));
|
||||
{
|
||||
let locked = locked.clone();
|
||||
obj.connect_local("locked", false, move |_| {
|
||||
locked.set(true);
|
||||
None
|
||||
});
|
||||
}
|
||||
{
|
||||
let locked = locked.clone();
|
||||
let exit_code = exit_code.clone();
|
||||
let locker = locker.clone();
|
||||
obj.connect_local("finished", false, move |_| {
|
||||
// Either the compositor refused the lock (another locker is running),
|
||||
// or it ended the lock on its own. Both mean we stop here.
|
||||
if !locked.get() {
|
||||
eprintln!("ahfail: the compositor refused the lock (is another locker running?)");
|
||||
exit_code.set(1);
|
||||
}
|
||||
locker::cleanup(&locker);
|
||||
gtk::main_quit();
|
||||
None
|
||||
});
|
||||
}
|
||||
|
||||
unsafe { gtk_session_lock_lock_lock(lock) };
|
||||
|
||||
let display = gdk::Display::default().expect("no display");
|
||||
let add_monitor = {
|
||||
let locker = locker.clone();
|
||||
move |monitor: &gdk::Monitor, primary: bool| {
|
||||
let window = locker::build_window(&locker, gtk::WindowType::Toplevel, true);
|
||||
let wptr: *mut gtk::ffi::GtkWindow = window.to_glib_none().0;
|
||||
let mptr: *mut gdk::ffi::GdkMonitor = monitor.to_glib_none().0;
|
||||
unsafe { gtk_session_lock_lock_new_surface(lock, wptr, mptr) };
|
||||
window.show_all();
|
||||
if primary {
|
||||
locker::focus_entry(&locker);
|
||||
}
|
||||
}
|
||||
};
|
||||
let primary = display.primary_monitor();
|
||||
for i in 0..display.n_monitors() {
|
||||
if let Some(m) = display.monitor(i) {
|
||||
let is_primary = primary.as_ref().map(|p| p == &m).unwrap_or(i == 0);
|
||||
add_monitor(&m, is_primary);
|
||||
}
|
||||
}
|
||||
// A monitor plugged in while locked must get a lock surface too, otherwise
|
||||
// the compositor shows its own blank fallback on it.
|
||||
display.connect_monitor_added(move |_, m| add_monitor(m, false));
|
||||
|
||||
locker::set_on_unlock(&locker, move || {
|
||||
unsafe { gtk_session_lock_lock_unlock_and_destroy(lock) };
|
||||
gtk::main_quit();
|
||||
});
|
||||
|
||||
gtk::main();
|
||||
std::mem::forget(obj);
|
||||
exit_code.get()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! X11 backend: override-redirect windows covering every monitor, plus a
|
||||
//! keyboard and pointer grab. This is the i3lock model, including its limit:
|
||||
//! on X11 nothing keeps the screen locked if the locker process dies.
|
||||
//!
|
||||
//! The grab is the whole lock. If it cannot be taken (a menu or another program
|
||||
//! holds it), ahfail refuses to "lock" rather than show a lock screen that
|
||||
//! keystrokes pass straight through.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::locker::{self, Shared};
|
||||
|
||||
const GRAB_ATTEMPTS: u32 = 100; // ~2 s, the same budget i3lock gives up after
|
||||
const GRAB_RETRY: Duration = Duration::from_millis(20);
|
||||
|
||||
fn pump() {
|
||||
while gtk::events_pending() {
|
||||
gtk::main_iteration();
|
||||
}
|
||||
}
|
||||
|
||||
fn grab(window: >k::Window) -> Result<gdk::Seat, String> {
|
||||
let gdk_window = window.window().ok_or("lock window is not realised")?;
|
||||
let display = gdk_window.display();
|
||||
let seat = display.default_seat().ok_or("no input seat")?;
|
||||
let mut last = gdk::GrabStatus::Success;
|
||||
for _ in 0..GRAB_ATTEMPTS {
|
||||
last = seat.grab(&gdk_window, gdk::SeatCapabilities::ALL, false, None, None, None);
|
||||
if last == gdk::GrabStatus::Success {
|
||||
return Ok(seat);
|
||||
}
|
||||
std::thread::sleep(GRAB_RETRY);
|
||||
pump();
|
||||
}
|
||||
Err(format!("could not grab keyboard and pointer ({last:?}); something else is holding them"))
|
||||
}
|
||||
|
||||
pub fn run(locker: Shared) -> i32 {
|
||||
let display = gdk::Display::default().expect("no display");
|
||||
let primary = display.primary_monitor().or_else(|| display.monitor(0));
|
||||
let mut main_window = None;
|
||||
|
||||
for i in 0..display.n_monitors() {
|
||||
let Some(m) = display.monitor(i) else { continue };
|
||||
let is_primary = primary.as_ref().map(|p| p == &m).unwrap_or(i == 0);
|
||||
// Only the primary monitor gets a password field: with the grab, every
|
||||
// key goes to the grabbing window anyway.
|
||||
let window = locker::build_window(&locker, gtk::WindowType::Popup, is_primary);
|
||||
let g = m.geometry();
|
||||
window.move_(g.x(), g.y());
|
||||
window.set_default_size(g.width(), g.height());
|
||||
window.resize(g.width(), g.height());
|
||||
window.set_keep_above(true);
|
||||
window.show_all();
|
||||
if is_primary {
|
||||
main_window = Some(window);
|
||||
}
|
||||
}
|
||||
let Some(main_window) = main_window else {
|
||||
eprintln!("ahfail: no monitor found");
|
||||
return 1;
|
||||
};
|
||||
pump();
|
||||
|
||||
let seat = match grab(&main_window) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("ahfail: refusing to lock: {e}");
|
||||
for w in locker::windows(&locker) {
|
||||
w.close();
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
locker::focus_entry(&locker);
|
||||
|
||||
// Notifications and other override-redirect windows may map above us later;
|
||||
// keep our windows on top.
|
||||
{
|
||||
let locker = locker.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(500), move || {
|
||||
for w in locker::windows(&locker) {
|
||||
if let Some(gw) = w.window() {
|
||||
gw.raise();
|
||||
}
|
||||
}
|
||||
glib::Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
locker::set_on_unlock(&locker, {
|
||||
let locker = locker.clone();
|
||||
move || {
|
||||
seat.ungrab();
|
||||
for w in locker::windows(&locker) {
|
||||
w.close();
|
||||
}
|
||||
gtk::main_quit();
|
||||
}
|
||||
});
|
||||
|
||||
gtk::main();
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#%PAM-1.0
|
||||
# PAM service for the ahfail screen locker. Installed by the package as its own
|
||||
# file, the same way swaylock and gtklock ship theirs, so no other package's
|
||||
# PAM configuration is ever edited.
|
||||
auth include login
|
||||
Reference in New Issue
Block a user