Compare commits
@@ -0,0 +1,96 @@
|
||||
name: Publish to AUR
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
aur:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
steps:
|
||||
- name: Install base tools
|
||||
run: pacman -Syu --noconfirm git openssh pacman-contrib nodejs sudo
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
echo "version=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing ahfail v$TAG to AUR"
|
||||
|
||||
- name: Configure SSH for AUR
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||
cat > ~/.ssh/config <<'EOF'
|
||||
Host aur.archlinux.org
|
||||
User aur
|
||||
IdentityFile ~/.ssh/aur
|
||||
StrictHostKeyChecking accept-new
|
||||
EOF
|
||||
|
||||
- name: Create unprivileged build user
|
||||
run: |
|
||||
useradd -m builder
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||
mkdir -p /home/builder/.ssh
|
||||
cp -r ~/.ssh/. /home/builder/.ssh/
|
||||
chown -R builder:builder /home/builder/.ssh
|
||||
chmod 600 /home/builder/.ssh/aur
|
||||
|
||||
- name: Clone existing AUR repo (or init new)
|
||||
run: |
|
||||
git clone ssh://aur@aur.archlinux.org/ahfail.git aur-pkg 2>&1 || true
|
||||
if [ ! -d aur-pkg/.git ]; then
|
||||
mkdir -p aur-pkg
|
||||
cd aur-pkg
|
||||
git init -b master
|
||||
git remote add origin ssh://aur@aur.archlinux.org/ahfail.git
|
||||
fi
|
||||
|
||||
- name: Render PKGBUILD for this release
|
||||
run: |
|
||||
cp packaging/aur/PKGBUILD packaging/aur/ahfail.install aur-pkg/
|
||||
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" aur-pkg/PKGBUILD
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" aur-pkg/PKGBUILD
|
||||
|
||||
- name: Update checksums and regenerate .SRCINFO
|
||||
run: |
|
||||
chown -R builder:builder aur-pkg
|
||||
cd aur-pkg
|
||||
sudo -u builder updpkgsums
|
||||
sudo -u builder makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
- name: Commit and push to AUR
|
||||
run: |
|
||||
cd aur-pkg
|
||||
git config user.name "Asger Geel Weirsoe"
|
||||
git config user.email "asger@weircon.dk"
|
||||
git add PKGBUILD .SRCINFO ahfail.install
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes — nothing to push."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update to ${{ steps.version.outputs.version }}"
|
||||
git push -u origin master
|
||||
|
||||
# Mirror the same PKGBUILD to https://asger.weirsøe.dk/tarballz/, the
|
||||
# human-readable package index. The key is pinned on the web host to a
|
||||
# forced command that can only write this one file, and a failure there
|
||||
# must never turn an otherwise good AUR release red.
|
||||
- name: Publish PKGBUILD to the tarballz index
|
||||
continue-on-error: true
|
||||
env:
|
||||
TARBALLZ_SSH_KEY: ${{ secrets.TARBALLZ_SSH_KEY }}
|
||||
run: |
|
||||
printf '%s\n' "$TARBALLZ_SSH_KEY" > ~/.ssh/tarballz
|
||||
chmod 600 ~/.ssh/tarballz
|
||||
ssh -i ~/.ssh/tarballz -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
|
||||
root@192.168.10.148 < aur-pkg/PKGBUILD
|
||||
@@ -8,25 +8,21 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# Built on Arch: the locker needs gtk-session-lock (not packaged by Ubuntu).
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: pacman -Syu --noconfirm --needed base-devel git openssh nodejs rust meson ninja gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad-libs pam curl python
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
- name: Compute source tarball SHA256
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgstreamer-plugins-bad1.0-dev \
|
||||
gstreamer1.0-plugins-good \
|
||||
libpam0g-dev \
|
||||
ninja-build \
|
||||
meson \
|
||||
libglib2.0-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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
|
||||
run: |
|
||||
@@ -39,15 +35,52 @@ jobs:
|
||||
cp builddir/ahfail-module.so dist/
|
||||
cp builddir/libahfail_pam.so dist/
|
||||
cp builddir/ahfail-display dist/
|
||||
cp builddir/ahfail dist/
|
||||
cp data/pam.d/ahfail dist/ahfail.pam
|
||||
tar czf ahfail-linux-x86_64.tar.gz -C dist .
|
||||
|
||||
- name: Upload release asset
|
||||
# Requires a GITEA_TOKEN secret with write:repository scope.
|
||||
# Create it in repo Settings → Secrets.
|
||||
# RELEASE_TOKEN: an agw token with write:repository scope. Gitea refuses
|
||||
# secret names that start with GITEA_ or GITHUB_, so it cannot be called
|
||||
# GITEA_TOKEN.
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
token: ${{ secrets.RELEASE_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
|
||||
# git over HTTP is disabled on this Gitea on purpose, so the formula is
|
||||
# updated through the contents API instead of a clone and push.
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/agw/homebrew-ahfail/contents/Formula/ahfail.rb
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import base64, json, os, re, urllib.request
|
||||
|
||||
api, token, tag = os.environ["API"], os.environ["RELEASE_TOKEN"], os.environ["TAG"]
|
||||
sha256 = os.environ["SOURCE_SHA256"]
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
|
||||
with urllib.request.urlopen(urllib.request.Request(api, headers=headers)) as r:
|
||||
current = json.load(r)
|
||||
formula = base64.b64decode(current["content"]).decode()
|
||||
new = re.sub(r'url ".*?"', f'url "https://gitea.weircon.dk/agw/gtk-ahfail/archive/{tag}.tar.gz"', formula, count=1)
|
||||
new = re.sub(r'sha256 ".*?"', f'sha256 "{sha256}"', new, count=1)
|
||||
if new == formula:
|
||||
print("formula already up to date")
|
||||
raise SystemExit(0)
|
||||
|
||||
body = json.dumps({
|
||||
"content": base64.b64encode(new.encode()).decode(),
|
||||
"sha": current["sha"],
|
||||
"message": f"chore: update ahfail to {tag}",
|
||||
}).encode()
|
||||
req = urllib.request.Request(api, data=body, headers=headers, method="PUT")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
print("formula updated:", r.status)
|
||||
PY
|
||||
|
||||
@@ -7,26 +7,15 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# Arch, because the locker needs gtk-session-lock, which Ubuntu does not
|
||||
# package; it is also the platform the AUR package is built on.
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgstreamer-plugins-bad1.0-dev \
|
||||
gstreamer1.0-plugins-good \
|
||||
libpam0g-dev \
|
||||
ninja-build \
|
||||
meson \
|
||||
libglib2.0-dev \
|
||||
xvfb
|
||||
run: pacman -Syu --noconfirm --needed base-devel git openssh nodejs rust meson ninja gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad-libs pam xorg-server-xvfb
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Rust tests
|
||||
run: xvfb-run cargo test
|
||||
|
||||
@@ -4,66 +4,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What This Is
|
||||
|
||||
`ahfail` is a [`gtklock`](https://github.com/jovanlanik/gtklock) module. It compiles to `ahfail-module.so`, which gtklock loads at runtime. On each failed unlock attempt (`PW_FAILURE`), it spawns an animated "Nedry" sprite at a random screen location and plays an audio clip ("ah ah ah, you didn't say the magic word").
|
||||
`ahfail` is a screen locker. On every failed unlock it spawns an animated "Nedry" sprite at a random screen position and plays "ah ah ah, you didn't say the magic word". On Linux the `ahfail` binary is itself the locker (Wayland via ext-session-lock-v1, X11 via an input grab) and authenticates through its own PAM service, `/etc/pam.d/ahfail` (`data/pam.d/ahfail`). On macOS the system lock screen is used and the effect comes from the PAM hook.
|
||||
|
||||
## Build System
|
||||
The older integrations, a gtklock module and a PAM hook for other X11 lockers, are still built as "legacy integrations".
|
||||
|
||||
The build is a two-stage hybrid:
|
||||
## Workspace
|
||||
|
||||
1. **Meson** compiles GResources from `assets/ahfail.gresource.xml` into C, invokes Cargo to produce `libahfail_module.a`, then links everything into `ahfail-module.so`.
|
||||
2. **Cargo** handles only the Rust crate — it produces a `staticlib` that Meson links.
|
||||
Five crates under `crates/`:
|
||||
|
||||
Always use Meson for the final shared object; `cargo build` alone does not produce the loadable module.
|
||||
| Crate | Output | Responsibility |
|
||||
|---|---|---|
|
||||
| `ahfail` | `ahfail` binary | The CLI and the locker: `lock` (default), `test` (preview, no lock), `doctor` (environment check). `wayland.rs` (gtk-session-lock FFI), `x11.rs` (seat grab), `locker.rs` (shared lock UI), `pam.rs` (PAM client, service `ahfail`). |
|
||||
| `ahfail-ui` | rlib | Shared by all: sprite animation from GResources, sprite placement with deadzone, audio player, volume save/restore, optional update check (cargo feature `update-check`, default on). |
|
||||
| `ahfail-gtklock` | `libahfail_module.a` → `ahfail-module.so` | Legacy gtklock module. Meson links the staticlib with the GResources into the loadable `.so`. |
|
||||
| `ahfail-pam` | `libahfail_pam.so` | Legacy PAM hook for X11 lockers and macOS. Spawns `ahfail-display` on a failed attempt, never from a root process. Always returns `PAM_IGNORE`. |
|
||||
| `ahfail-display` | `ahfail-display` binary | Legacy standalone sprite window spawned by the PAM hook. Refuses to run as root; single-instance lock in `$XDG_RUNTIME_DIR`. |
|
||||
|
||||
Assets (`assets/*.png`, `assets/magic-word.mp3`) are embedded as GResources, via Meson for the gtklock module and via `build.rs` for the binaries, and read at runtime as `resource:///ahfail/sprites/...` and `resource:///ahfail/audio/magic-word.mp3`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# First-time setup
|
||||
meson setup builddir
|
||||
|
||||
# Build
|
||||
meson setup builddir --prefix=/usr # --prefix=/usr for Arch paths
|
||||
meson compile -C builddir
|
||||
sudo meson install -C builddir
|
||||
|
||||
# Run Rust tests only (no GTK display required)
|
||||
cargo test
|
||||
|
||||
# Run Meson tests (loads the .so, requires GTK)
|
||||
meson test -C builddir
|
||||
|
||||
# Manual integration test
|
||||
gtklock -d -m builddir/ahfail-module.so -- --deadzone=X,Y,W,H
|
||||
cargo build --release -p ahfail # just the locker
|
||||
cargo build --release -p ahfail-display --no-default-features # without the update check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
Always use Meson for `ahfail-module.so`: `cargo build` alone produces only the staticlib.
|
||||
|
||||
All FFI entry points live in `src/lib.rs` — these are the `extern "C"` functions that gtklock calls (`on_activation`, `on_window_create`, `on_window_destroy`, `on_idle_hide`, etc.).
|
||||
|
||||
**Module lifecycle:**
|
||||
- `on_activation` — called once at startup; initialises GTK/GStreamer, loads all sprite frames into a `PixbufSimpleAnim`, stores them in `MODULE_STATE`.
|
||||
- `on_window_create` — called per monitor; calls `WindowHandler::create`, which creates a `gtk::Fixed` overlay, a pool of pre-warmed GStreamer players, and wires up a signal on `error_label` that fires on each failed attempt.
|
||||
- `on_window_destroy` / `on_idle_hide` — clean up sprites and stop players.
|
||||
|
||||
**Key types:**
|
||||
|
||||
| Type | File | Purpose |
|
||||
|------|------|---------|
|
||||
| `MODULE_STATE` | `src/state.rs` | Thread-local `RefCell<ModuleState>` holding the shared animation, audio URI, and deadzone config |
|
||||
| `WindowData` | `src/state.rs` | Heap-allocated per-window state (sprites, player pool, GTK signal handle) |
|
||||
| `WindowContext` | `src/context.rs` | Safe wrapper around the raw `*mut Window` pointer passed from C |
|
||||
| `Window` / `GtkLock` | `src/context.rs` | `#[repr(C)]` mirrors of gtklock's internal structs — must match `include/gtklock-module.h` |
|
||||
| `WindowHandler` | `src/handler.rs` | Sprite placement logic (random position, deadzone avoidance with retries) and GStreamer player management |
|
||||
| `ModuleConfig` | `src/config.rs` | Parses the `--deadzone=x,y,w,h` CLI argument via glib's `GOptionEntry` |
|
||||
|
||||
**Assets** are embedded as GResources at build time. At runtime they are accessed via `resource:///ahfail/sprites/...` and `resource:///ahfail/audio/magic-word.mp3`.
|
||||
|
||||
## Safety Conventions
|
||||
|
||||
- All `unsafe` pointer work on `*mut Window` goes through `WindowContext` — never dereference the raw pointer outside that wrapper.
|
||||
- `WindowData` is heap-allocated via `Box::into_raw` and reclaimed via `Box::from_raw` in `WindowContext::take_data`. Do not free it any other way.
|
||||
- The `module_data` flexible array field on `Window` is a C ABI contract with gtklock — index 0 is this module's slot.
|
||||
Linux build dependencies: gtk3, gtk-session-lock, gstreamer (+ base/good plugins), pam, meson, ninja, rust.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/ahfail_tests.rs` — integration tests that construct mock `Window` / `GtkLock` structs directly to exercise handler logic without a running gtklock process. Run with `cargo test`.
|
||||
```bash
|
||||
cargo test # unit + integration (some need a display: use xvfb-run)
|
||||
xvfb-run meson test -C builddir --verbose # smoke tests: module symbols, PAM module
|
||||
```
|
||||
|
||||
`tests/benchmarks.rs` — criterion benchmarks for sprite placement.
|
||||
- `crates/ahfail-gtklock/tests/`: integration tests that build mock `Window`/`GtkLock` structs.
|
||||
- `tests/module_test.c`, `tests/pam_smoke_test.c`: C smoke tests built by Meson.
|
||||
- The locker itself is tested headless: X11 under Xvfb with `xdotool`, Wayland under `sway` with `WLR_BACKENDS=headless` and `wtype`.
|
||||
|
||||
`tests/module_test.c` — C smoke test built by Meson that dlopen-style verifies the exported symbols exist.
|
||||
## Safety Conventions
|
||||
|
||||
- `ahfail` and `ahfail-display` must never run as root; the PAM hook must never spawn anything from a root process.
|
||||
- The X11 lock is only the grab: if it cannot be taken, refuse to lock. Never show a lock screen without it.
|
||||
- The password lives in `pam::Secret`, which zeroes itself on drop, and PAM runs off the GTK thread.
|
||||
- gtklock module: all `unsafe` work on `*mut Window` goes through `WindowContext`; `WindowData` is created with `Box::into_raw` and reclaimed only in `WindowContext::take_data`. The `#[repr(C)]` structs must match `include/gtklock-module.h`.
|
||||
|
||||
Generated
+16
@@ -8,11 +8,27 @@ 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"
|
||||
dependencies = [
|
||||
"ahfail-ui",
|
||||
"cairo-rs",
|
||||
"cc",
|
||||
"gdk",
|
||||
"glib",
|
||||
|
||||
@@ -4,5 +4,6 @@ members = [
|
||||
"crates/ahfail-gtklock",
|
||||
"crates/ahfail-pam",
|
||||
"crates/ahfail-display",
|
||||
"crates/ahfail",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -7,13 +7,18 @@ edition = "2021"
|
||||
name = "ahfail-display"
|
||||
|
||||
[dependencies]
|
||||
ahfail-ui = { path = "../ahfail-ui" }
|
||||
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"] }
|
||||
glib = { version = "0.15", package = "glib" }
|
||||
cairo = { version = "0.15", package = "cairo-rs" }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["update-check"]
|
||||
update-check = ["ahfail-ui/update-check"]
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
pkg-config = "0.3"
|
||||
|
||||
@@ -49,4 +49,8 @@ fn main() {
|
||||
|
||||
// Link against gio-2.0 for GResource support
|
||||
println!("cargo:rustc-link-lib=gio-2.0");
|
||||
// Link against X11 for XGrabKeyboard (Linux only)
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
|
||||
println!("cargo:rustc-link-lib=X11");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use gtk::prelude::*;
|
||||
use gtk::gdk;
|
||||
use gstreamer as gst;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
|
||||
const FAILSAFE_MINUTES: u32 = 15;
|
||||
@@ -12,63 +13,163 @@ extern "C" fn handle_sigterm(_: libc::c_int) {
|
||||
SIGTERM_RECEIVED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11_grab {
|
||||
use std::os::raw::{c_char, c_int, c_ulong, c_void};
|
||||
type XDisplay = c_void;
|
||||
type Window = c_ulong;
|
||||
type Atom = c_ulong;
|
||||
|
||||
const GRAB_SUCCESS: c_int = 0;
|
||||
const GRAB_MODE_ASYNC: c_int = 1;
|
||||
const XA_CARDINAL: Atom = 6;
|
||||
const PROP_MODE_REPLACE: c_int = 0;
|
||||
|
||||
extern "C" {
|
||||
fn XOpenDisplay(name: *const c_char) -> *mut XDisplay;
|
||||
fn XCloseDisplay(dpy: *mut XDisplay) -> c_int;
|
||||
fn XDefaultRootWindow(dpy: *mut XDisplay) -> Window;
|
||||
fn XGrabKeyboard(
|
||||
dpy: *mut XDisplay, grab_window: Window, owner_events: c_int,
|
||||
pointer_mode: c_int, keyboard_mode: c_int, time: c_ulong,
|
||||
) -> c_int;
|
||||
fn XUngrabKeyboard(dpy: *mut XDisplay, time: c_ulong) -> c_int;
|
||||
fn XInternAtom(dpy: *mut XDisplay, name: *const c_char, only_if_exists: c_int) -> Atom;
|
||||
fn XChangeProperty(
|
||||
dpy: *mut XDisplay, w: Window, property: Atom, type_: Atom,
|
||||
format: c_int, mode: c_int, data: *const u8, nelements: c_int,
|
||||
) -> c_int;
|
||||
fn XFlush(dpy: *mut XDisplay) -> c_int;
|
||||
fn gdk_x11_window_get_xid(window: *mut c_void) -> c_ulong;
|
||||
}
|
||||
|
||||
pub fn is_screen_unlocked() -> bool {
|
||||
unsafe {
|
||||
let dpy = XOpenDisplay(std::ptr::null());
|
||||
if dpy.is_null() { return false; }
|
||||
let root = XDefaultRootWindow(dpy);
|
||||
let result = XGrabKeyboard(dpy, root, 0, GRAB_MODE_ASYNC, GRAB_MODE_ASYNC, 0);
|
||||
if result == GRAB_SUCCESS {
|
||||
XUngrabKeyboard(dpy, 0);
|
||||
}
|
||||
XCloseDisplay(dpy);
|
||||
result == GRAB_SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell picom/compton not to draw a shadow around this window.
|
||||
/// Works by setting _COMPTON_SHADOW=0 directly on the X window,
|
||||
/// which the compositor respects regardless of its config.
|
||||
pub fn disable_compositor_shadow(gdk_window: &gdk::Window) {
|
||||
use glib::ObjectType;
|
||||
unsafe {
|
||||
let xid = gdk_x11_window_get_xid(gdk_window.as_ptr() as *mut c_void);
|
||||
let dpy = XOpenDisplay(std::ptr::null());
|
||||
if dpy.is_null() { return; }
|
||||
let atom = XInternAtom(dpy, b"_COMPTON_SHADOW\0".as_ptr() as *const c_char, 0);
|
||||
let value: u32 = 0;
|
||||
XChangeProperty(
|
||||
dpy, xid, atom, XA_CARDINAL, 32,
|
||||
PROP_MODE_REPLACE, &value as *const u32 as *const u8, 1,
|
||||
);
|
||||
XFlush(dpy);
|
||||
XCloseDisplay(dpy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_lock_path() -> std::path::PathBuf {
|
||||
match std::env::var("XDG_RUNTIME_DIR") {
|
||||
Ok(dir) if !dir.is_empty() => std::path::Path::new(&dir).join("ahfail-display.lock"),
|
||||
_ => std::path::PathBuf::from(format!("/tmp/ahfail-display-{}.lock", unsafe { libc::getuid() })),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
unsafe {
|
||||
// SAFETY: handle_sigterm only stores to an AtomicBool — async-signal-safe.
|
||||
// The *const () intermediate avoids a "direct cast to integer" warning because
|
||||
// libc::sighandler_t is size_t on Linux.
|
||||
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
|
||||
}
|
||||
|
||||
if gtk::init().is_err() {
|
||||
eprintln!("[ahfail-display] GTK init failed");
|
||||
// Never run as root (the PAM module refuses to spawn us there, but the
|
||||
// binary can also be started by hand).
|
||||
if unsafe { libc::getuid() == 0 || libc::geteuid() == 0 } {
|
||||
eprintln!("[ahfail] ahfail-display refuses to run as root");
|
||||
return;
|
||||
}
|
||||
if gst::init().is_err() {
|
||||
eprintln!("[ahfail-display] GStreamer init failed");
|
||||
|
||||
// Single-instance guard, per user: a fixed name in the shared /tmp could be
|
||||
// pre-created by another user to block us.
|
||||
let lock_file = std::fs::OpenOptions::new()
|
||||
.create(true).write(true)
|
||||
.open(display_lock_path());
|
||||
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;
|
||||
}
|
||||
|
||||
// Give PAM a moment to complete, then bail if auth already succeeded.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
#[cfg(target_os = "linux")]
|
||||
if x11_grab::is_screen_unlocked() {
|
||||
return;
|
||||
}
|
||||
|
||||
if gtk::init().is_err() { return; }
|
||||
if gst::init().is_err() { return; }
|
||||
|
||||
let animation = unsafe { ahfail_ui::animation::load_animation() };
|
||||
let Some(animation) = animation else {
|
||||
eprintln!("[ahfail-display] No animation frames found");
|
||||
return;
|
||||
};
|
||||
let Some(animation) = animation else { return; };
|
||||
|
||||
let Some(display) = gdk::Display::default() else {
|
||||
eprintln!("[ahfail-display] No display");
|
||||
return;
|
||||
};
|
||||
let Some(display) = gdk::Display::default() else { return; };
|
||||
let Some(monitor) = display.primary_monitor().or_else(|| display.monitor(0)) else {
|
||||
eprintln!("[ahfail-display] No monitor");
|
||||
return;
|
||||
};
|
||||
let geom = monitor.geometry();
|
||||
let screen_w = geom.width();
|
||||
let screen_h = geom.height();
|
||||
let (screen_w, screen_h) = (geom.width(), geom.height());
|
||||
|
||||
// On X11, WindowType::Popup creates an unmanaged override-redirect window (desired).
|
||||
// On Wayland, GTK3 falls back to a normal xdg_toplevel; the compositor controls stacking.
|
||||
let config = parse_args();
|
||||
let (sprite_x, sprite_y) = ahfail_ui::display::sprite_position(
|
||||
&animation, screen_w, screen_h, &config,
|
||||
);
|
||||
let sprite_w = animation.width();
|
||||
let sprite_h = animation.height();
|
||||
|
||||
// Window sized exactly to the sprite — no full-screen background to worry about
|
||||
let window = gtk::Window::new(gtk::WindowType::Popup);
|
||||
window.set_decorated(false);
|
||||
window.set_keep_above(true);
|
||||
window.set_skip_taskbar_hint(true);
|
||||
window.move_(geom.x(), geom.y());
|
||||
window.set_default_size(screen_w, screen_h);
|
||||
window.set_accept_focus(false);
|
||||
window.set_type_hint(gdk::WindowTypeHint::Notification);
|
||||
window.move_(geom.x() + sprite_x, geom.y() + sprite_y);
|
||||
window.set_default_size(sprite_w, sprite_h);
|
||||
|
||||
let fixed = gtk::Fixed::new();
|
||||
fixed.set_size_request(screen_w, screen_h);
|
||||
window.add(&fixed);
|
||||
// RGBA visual so sprite edges (from PNG alpha) composite correctly
|
||||
if let Some(screen) = window.screen() {
|
||||
if let Some(visual) = screen.rgba_visual() {
|
||||
window.set_visual(Some(&visual));
|
||||
}
|
||||
}
|
||||
window.set_app_paintable(true);
|
||||
window.connect_draw(|_, cr| {
|
||||
cr.set_source_rgba(0.0, 0.0, 0.0, 0.0);
|
||||
cr.set_operator(cairo::Operator::Source);
|
||||
let _ = cr.paint();
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
|
||||
let config = parse_args();
|
||||
ahfail_ui::display::place_sprite(&fixed, &animation, screen_w, screen_h, &config);
|
||||
let image = gtk::Image::from_animation(&animation);
|
||||
window.add(&image);
|
||||
|
||||
let player = ahfail_ui::audio::create_player(AUDIO_URI);
|
||||
player.play();
|
||||
|
||||
std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION));
|
||||
|
||||
// All setup succeeded — acquire volume lock now so early-exit paths above don't leave it held.
|
||||
let volume_state = ahfail_ui::volume::save_and_set_max();
|
||||
|
||||
glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || {
|
||||
@@ -76,7 +177,7 @@ fn main() {
|
||||
glib::Continue(false)
|
||||
});
|
||||
|
||||
glib::timeout_add(std::time::Duration::from_millis(200), move || {
|
||||
glib::timeout_add(Duration::from_millis(200), || {
|
||||
if SIGTERM_RECEIVED.load(Ordering::Relaxed) {
|
||||
gtk::main_quit();
|
||||
return glib::Continue(false);
|
||||
@@ -84,7 +185,21 @@ fn main() {
|
||||
glib::Continue(true)
|
||||
});
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
glib::timeout_add(Duration::from_millis(500), || {
|
||||
if x11_grab::is_screen_unlocked() {
|
||||
gtk::main_quit();
|
||||
return glib::Continue(false);
|
||||
}
|
||||
glib::Continue(true)
|
||||
});
|
||||
|
||||
window.show_all();
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(gdk_win) = window.window() {
|
||||
x11_grab::disable_compositor_shadow(&gdk_win);
|
||||
}
|
||||
|
||||
gtk::main();
|
||||
|
||||
player.stop();
|
||||
@@ -99,9 +214,7 @@ fn parse_args() -> ahfail_ui::config::ModuleConfig {
|
||||
.and_then(|a| {
|
||||
let val = a.trim_start_matches("--deadzone=");
|
||||
let p: Vec<&str> = val.split(',').collect();
|
||||
if p.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
if p.len() != 4 { return None; }
|
||||
let x: i32 = p[0].parse().ok()?;
|
||||
let y: i32 = p[1].parse().ok()?;
|
||||
let w: i32 = p[2].parse().ok()?;
|
||||
|
||||
@@ -8,7 +8,7 @@ name = "ahfail_module"
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
ahfail-ui = { path = "../ahfail-ui" }
|
||||
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"] }
|
||||
@@ -17,5 +17,9 @@ glib = { version = "0.15", package = "glib" }
|
||||
gdk-pixbuf = "0.15"
|
||||
rand = "0.8"
|
||||
|
||||
[features]
|
||||
default = ["update-check"]
|
||||
update-check = ["ahfail-ui/update-check"]
|
||||
|
||||
[build-dependencies]
|
||||
pkg-config = "0.3"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
fn main() {
|
||||
println!("cargo:rustc-link-lib=pam");
|
||||
|
||||
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch
|
||||
// systems (e.g. Debian/Ubuntu where libdir is /usr/lib/x86_64-linux-gnu).
|
||||
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back to /usr/lib otherwise.
|
||||
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| "/usr/lib".to_string());
|
||||
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch Linux
|
||||
// (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 per platform otherwise.
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
use libc::{c_char, c_int, c_void, pid_t};
|
||||
use std::ffi::CString;
|
||||
|
||||
// --- PAM constants (platform-specific) ---
|
||||
|
||||
pub const PAM_SUCCESS: c_int = 0;
|
||||
pub const PAM_IGNORE: c_int = 25;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const PAM_AUTH_ERR: c_int = 7;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const PAM_AUTH_ERR: c_int = 9;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub const PAM_AUTH_ERR: c_int = 7;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -20,22 +11,10 @@ pub const PAM_DATA_REPLACE: c_int = 0x00000002;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
||||
|
||||
// Default install path for ahfail-display.
|
||||
// On Linux: built from AHFAIL_INSTALL_DIR emitted by build.rs, which reads AHFAIL_LIBDIR
|
||||
// passed by Meson — correct on multiarch systems (e.g. /usr/lib/x86_64-linux-gnu/ahfail).
|
||||
#[cfg(target_os = "macos")]
|
||||
const DEFAULT_PATH: &str = "/usr/local/lib/ahfail/ahfail-display";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const DEFAULT_PATH: &str = concat!(env!("AHFAIL_INSTALL_DIR"), "/ahfail-display");
|
||||
|
||||
pub fn default_display_path() -> &'static str { DEFAULT_PATH }
|
||||
|
||||
pub fn is_failure(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_AUTH_ERR }
|
||||
pub fn is_success(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_SUCCESS }
|
||||
pub fn is_replace(s: c_int) -> bool { s & PAM_DATA_REPLACE != 0 }
|
||||
|
||||
// --- PAM opaque handle ---
|
||||
|
||||
#[repr(C)]
|
||||
pub struct PamHandle { _private: [u8; 0] }
|
||||
|
||||
@@ -50,36 +29,16 @@ extern "C" {
|
||||
) -> c_int;
|
||||
}
|
||||
|
||||
// --- Cleanup: fires on pam_end() or when data is replaced ---
|
||||
unsafe extern "C" fn ahfail_cleanup(
|
||||
_pamh: *mut PamHandle,
|
||||
data: *mut c_void,
|
||||
error_status: c_int,
|
||||
_error_status: c_int,
|
||||
) {
|
||||
// Reclaim any stored path string to avoid a leak (PAM guarantees this fires exactly once).
|
||||
let path_override: Option<String> = if data.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(*Box::from_raw(data as *mut String))
|
||||
};
|
||||
|
||||
if is_replace(error_status) {
|
||||
// Data replaced — a new pam_set_data call overwrote ours. Only spawn on failure;
|
||||
// on success (PAM_SUCCESS | PAM_DATA_REPLACE) we do nothing.
|
||||
if is_failure(error_status) {
|
||||
spawn_display(path_override);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if is_failure(error_status) {
|
||||
spawn_display(path_override);
|
||||
} else if is_success(error_status) {
|
||||
kill_display();
|
||||
if !data.is_null() {
|
||||
drop(Box::from_raw(data as *mut String));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exported PAM module entry points ---
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn pam_sm_authenticate(
|
||||
pamh: *mut PamHandle,
|
||||
@@ -92,20 +51,29 @@ pub unsafe extern "C" fn pam_sm_authenticate(
|
||||
let args = argc_argv(argc, argv);
|
||||
let display_path = read_display_path_arg(args);
|
||||
|
||||
// /proc/self/comm is the name of the current process (the locker that loaded us).
|
||||
let locker_name = std::fs::read_to_string("/proc/self/comm")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Never start a GTK program as root. Inside a greeter or display manager
|
||||
// (lightdm and friends) this module runs in a root process; spawning the
|
||||
// display there would run a whole GUI stack with root rights. Skip the effect
|
||||
// and let authentication carry on untouched.
|
||||
if !running_as_root() {
|
||||
spawn_display(display_path.clone(), &locker_name);
|
||||
}
|
||||
|
||||
let key = match CString::new("dk.weircon.ahfail") {
|
||||
Ok(k) => k,
|
||||
Err(_) => return PAM_IGNORE,
|
||||
};
|
||||
|
||||
// Store optional path override as heap data for the cleanup function.
|
||||
let path_ptr: *mut c_void = match display_path {
|
||||
Some(p) => Box::into_raw(Box::new(p)) as *mut c_void,
|
||||
None => std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let ret = pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup));
|
||||
if ret != PAM_SUCCESS && !path_ptr.is_null() {
|
||||
// pam_set_data failed — cleanup will never fire, so free the Box ourselves.
|
||||
drop(Box::from_raw(path_ptr as *mut String));
|
||||
}
|
||||
PAM_IGNORE
|
||||
@@ -136,7 +104,10 @@ pub unsafe extern "C" fn pam_sm_chauthtok(
|
||||
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
|
||||
) -> c_int { PAM_IGNORE }
|
||||
|
||||
// --- Helpers ---
|
||||
/// True if either the real or the effective uid is root.
|
||||
fn running_as_root() -> bool {
|
||||
unsafe { libc::getuid() == 0 || libc::geteuid() == 0 }
|
||||
}
|
||||
|
||||
unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] {
|
||||
if argv.is_null() || argc <= 0 { return &[]; }
|
||||
@@ -155,40 +126,32 @@ fn read_display_path_arg(args: &[*const c_char]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn spawn_display(path_override: Option<String>) {
|
||||
fn spawn_display(path_override: Option<String>, locker_name: &str) {
|
||||
let path = path_override.unwrap_or_else(|| DEFAULT_PATH.to_string());
|
||||
let cpath = match CString::new(path.as_str()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Double-fork: grandchild adopted by init, PAM stack not blocked.
|
||||
let name_arg = format!("--locker-name={}", locker_name);
|
||||
let cname_arg = match CString::new(name_arg.as_str()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
unsafe {
|
||||
let pid: pid_t = libc::fork();
|
||||
if pid < 0 { return; }
|
||||
if pid > 0 {
|
||||
// Parent: wait for intermediate to exit immediately.
|
||||
libc::waitpid(pid, std::ptr::null_mut(), 0);
|
||||
return;
|
||||
}
|
||||
// Intermediate: fork again then exit immediately.
|
||||
let pid2: pid_t = libc::fork();
|
||||
if pid2 != 0 { libc::_exit(0); }
|
||||
// Grandchild: detach from PAM daemon's session, close all inherited fds, exec.
|
||||
// Grandchild: detach and exec.
|
||||
libc::setsid();
|
||||
let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as c_int;
|
||||
for fd in 0..max_fd.min(4096) {
|
||||
libc::close(fd);
|
||||
}
|
||||
let args: [*const c_char; 2] = [cpath.as_ptr(), std::ptr::null()];
|
||||
for fd in 0..max_fd.min(4096) { libc::close(fd); }
|
||||
let args: [*const c_char; 3] = [cpath.as_ptr(), cname_arg.as_ptr(), std::ptr::null()];
|
||||
libc::execv(cpath.as_ptr(), args.as_ptr());
|
||||
libc::_exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_display() {
|
||||
// SIGTERM all ahfail-display processes owned by the current user.
|
||||
let uid = unsafe { libc::getuid() }.to_string();
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.args(["-SIGTERM", "-u", &uid, "ahfail-display"])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
use ahfail_pam::{is_failure, is_success, is_replace, PAM_AUTH_ERR, PAM_SUCCESS, PAM_DATA_REPLACE};
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(is_failure(PAM_AUTH_ERR));
|
||||
assert!(is_success(PAM_SUCCESS));
|
||||
assert!(!is_failure(PAM_SUCCESS));
|
||||
assert!(!is_success(PAM_AUTH_ERR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_flag_detected() {
|
||||
let replace_status = PAM_AUTH_ERR | PAM_DATA_REPLACE;
|
||||
assert!(is_replace(replace_status));
|
||||
assert!(!is_replace(PAM_SUCCESS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_default_is_set() {
|
||||
assert!(!ahfail_pam::default_display_path().is_empty());
|
||||
}
|
||||
@@ -16,8 +16,15 @@ glib = { version = "0.15", package = "glib" }
|
||||
gio = { version = "0.15", package = "gio" }
|
||||
gdk-pixbuf = "0.15"
|
||||
rand = "0.8"
|
||||
ureq = "2"
|
||||
ureq = { version = "2", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
# Checks Gitea for a newer release once a day and shows a desktop notification.
|
||||
# Distro packages build without it (--no-default-features): there the package
|
||||
# manager handles updates, and a locker should not phone home.
|
||||
default = ["update-check"]
|
||||
update-check = ["dep:ureq"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -3,15 +3,16 @@ 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 place_sprite(
|
||||
fixed: >k::Fixed,
|
||||
pub fn sprite_position(
|
||||
animation: &gdk_pixbuf::PixbufAnimation,
|
||||
screen_w: i32,
|
||||
screen_h: i32,
|
||||
config: &ModuleConfig,
|
||||
) -> gtk::Image {
|
||||
) -> (i32, i32) {
|
||||
let sprite_w = animation.width();
|
||||
let sprite_h = animation.height();
|
||||
|
||||
@@ -34,6 +35,17 @@ pub fn place_sprite(
|
||||
}
|
||||
}
|
||||
|
||||
(x, y)
|
||||
}
|
||||
|
||||
pub fn place_sprite(
|
||||
fixed: >k::Fixed,
|
||||
animation: &gdk_pixbuf::PixbufAnimation,
|
||||
screen_w: i32,
|
||||
screen_h: i32,
|
||||
config: &ModuleConfig,
|
||||
) -> gtk::Image {
|
||||
let (x, y) = sprite_position(animation, screen_w, screen_h, config);
|
||||
let image = gtk::Image::from_animation(animation);
|
||||
image.show();
|
||||
fixed.put(&image, x, y);
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
//! Optional update check (cargo feature `update-check`, on by default).
|
||||
//! Without the feature `check_for_update` is a no-op and nothing touches the
|
||||
//! network, which is how distro packages are built.
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::path::PathBuf;
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::process::Command;
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::time::SystemTime;
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::fs;
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
const GITEA_API: &str =
|
||||
"https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest";
|
||||
#[cfg(feature = "update-check")]
|
||||
const UPDATE_NOTIFY_MSG: &str =
|
||||
"Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases";
|
||||
#[cfg(feature = "update-check")]
|
||||
const CHECK_INTERVAL_SECS: u64 = 86_400;
|
||||
|
||||
// Guards against multiple windows spawning duplicate checks on the same failure.
|
||||
#[cfg(feature = "update-check")]
|
||||
static CHECK_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn is_newer(latest: &str, current: &str) -> bool {
|
||||
@@ -26,6 +39,7 @@ pub fn is_newer(latest: &str, current: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn cache_file() -> PathBuf {
|
||||
let dir = std::env::var("HOME")
|
||||
.map(|h| PathBuf::from(h).join(".cache/ahfail"))
|
||||
@@ -37,6 +51,7 @@ fn cache_file() -> PathBuf {
|
||||
dir.join("last_update_check")
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn rate_limited() -> bool {
|
||||
let path = cache_file();
|
||||
if let Ok(meta) = fs::metadata(&path) {
|
||||
@@ -50,10 +65,12 @@ fn rate_limited() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn touch_cache() {
|
||||
let _ = fs::write(cache_file(), b"");
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn send_notification() {
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = Command::new("notify-send")
|
||||
@@ -69,6 +86,7 @@ fn send_notification() {
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
/// Run in a background thread. Checks Gitea for a newer release and sends a
|
||||
/// desktop notification if one exists. Rate-limited to once per 24 hours.
|
||||
/// Fails silently on any error.
|
||||
@@ -104,6 +122,7 @@ pub fn check_for_update(current_version: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn extract_tag_name(json: &str) -> Option<String> {
|
||||
// Handle both compact ("tag_name":"v1.0") and spaced ("tag_name": "v1.0") JSON.
|
||||
let key = "\"tag_name\"";
|
||||
@@ -113,3 +132,7 @@ fn extract_tag_name(json: &str) -> Option<String> {
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
/// Built without the `update-check` feature: never contacts the network.
|
||||
#[cfg(not(feature = "update-check"))]
|
||||
pub fn check_for_update(_current_version: &str) {}
|
||||
|
||||
@@ -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
|
||||
+46
-33
@@ -1,12 +1,13 @@
|
||||
project(
|
||||
'ahfail',
|
||||
['c', 'rust'],
|
||||
version: '0.1.0',
|
||||
version: '0.10.0',
|
||||
meson_version: '>=1.3.0',
|
||||
default_options: ['warning_level=3', 'c_std=c11']
|
||||
)
|
||||
|
||||
ahfail_inc = include_directories('include')
|
||||
|
||||
cc = meson.get_compiler('c')
|
||||
m_dep = cc.find_library('m', required : false)
|
||||
gtk_dep = dependency('gtk+-3.0')
|
||||
@@ -20,47 +21,59 @@ resources = gnome.compile_resources(
|
||||
c_name: 'ahfail'
|
||||
)
|
||||
|
||||
# Each cargo target uses its own --target-dir to avoid parallel lock contention.
|
||||
cargo_target = custom_target(
|
||||
# All Rust artefacts come from ONE cargo invocation with one target directory.
|
||||
# Four separate builds with four target directories compiled the whole GTK and
|
||||
# GStreamer binding stack four times over (about an hour for a package build).
|
||||
#
|
||||
# -Dupdate_check=false (distro packages) builds without the ahfail-ui feature
|
||||
# `update-check`, so the binaries never ask Gitea for new releases and the HTTP
|
||||
# client is not compiled in at all.
|
||||
cargo_features = get_option('update_check') ? '' : '--no-default-features'
|
||||
|
||||
# The ahfail command is the Linux locker (Wayland/X11 + its own PAM service).
|
||||
# macOS keeps the PAM hook and is built without it until it has been tested there.
|
||||
is_linux = host_machine.system() == 'linux'
|
||||
cargo_pkgs = '-p ahfail-gtklock -p ahfail-pam -p ahfail-display' + (is_linux ? ' -p ahfail' : '')
|
||||
cargo_outputs = ['libahfail_module.a', 'libahfail_pam.so', 'ahfail-display'] + (is_linux ? ['ahfail'] : [])
|
||||
cargo_install_dirs = [false, get_option('libdir') / 'ahfail', get_option('libdir') / 'ahfail'] + (is_linux ? [get_option('bindir')] : [])
|
||||
cargo_copy = (' && cp "@OUTDIR@/target/release/libahfail_module.a" "@OUTPUT0@"'
|
||||
+ ' && cp "@OUTDIR@/target/release/libahfail_pam.so" "@OUTPUT1@"'
|
||||
+ ' && cp "@OUTDIR@/target/release/ahfail-display" "@OUTPUT2@"'
|
||||
+ (is_linux ? ' && cp "@OUTDIR@/target/release/ahfail" "@OUTPUT3@"' : ''))
|
||||
|
||||
cargo_all = custom_target(
|
||||
'ahfail-cargo-build',
|
||||
input: ['crates/ahfail-gtklock/src/lib.rs', 'Cargo.toml'],
|
||||
output: ['libahfail_module.a'],
|
||||
command: [
|
||||
'sh', '-c', 'cargo build --release -p ahfail-gtklock --target-dir "@OUTDIR@/target-gtklock" && cp "@OUTDIR@/target-gtklock/release/libahfail_module.a" "@OUTPUT@"'
|
||||
],
|
||||
build_by_default: true
|
||||
)
|
||||
|
||||
# PAM module (.so with C ABI — no GResources needed, Cargo self-contained via build.rs)
|
||||
pam_cargo = custom_target(
|
||||
'ahfail-pam-cargo-build',
|
||||
input: ['crates/ahfail-pam/src/lib.rs', 'Cargo.toml'],
|
||||
output: ['libahfail_pam.so'],
|
||||
input: ['Cargo.toml', 'Cargo.lock'],
|
||||
output: cargo_outputs,
|
||||
command: [
|
||||
'sh', '-c',
|
||||
# Pass libdir so build.rs emits the correct AHFAIL_INSTALL_DIR (needed for multiarch).
|
||||
'AHFAIL_LIBDIR=' + get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"'
|
||||
# The PAM module's build.rs needs the absolute libdir to know where
|
||||
# ahfail-display is installed; get_option('libdir') alone is relative.
|
||||
'AHFAIL_LIBDIR=' + get_option('prefix') / get_option('libdir')
|
||||
+ ' cargo build --release --locked'
|
||||
+ ' ' + cargo_pkgs + ' ' + cargo_features
|
||||
+ ' --target-dir "@OUTDIR@/target"'
|
||||
+ cargo_copy
|
||||
],
|
||||
build_by_default: true,
|
||||
build_always_stale: true, # cargo decides what is out of date, not meson
|
||||
install: true,
|
||||
install_dir: get_option('libdir') / 'ahfail',
|
||||
# module archive: linked below, not installed; PAM module + display binary:
|
||||
# libdir/ahfail; the ahfail command: bindir.
|
||||
install_dir: cargo_install_dirs,
|
||||
install_mode: 'rwxr-xr-x'
|
||||
)
|
||||
cargo_target = cargo_all[0]
|
||||
pam_cargo = cargo_all[1]
|
||||
|
||||
# Display binary (embeds GResources via its own build.rs using glib-compile-resources)
|
||||
display_cargo = custom_target(
|
||||
'ahfail-display-cargo-build',
|
||||
input: ['crates/ahfail-display/src/main.rs', 'Cargo.toml'],
|
||||
output: ['ahfail-display'],
|
||||
command: [
|
||||
'sh', '-c',
|
||||
'cargo build --release -p ahfail-display --target-dir "@OUTDIR@/target-display" && cp "@OUTDIR@/target-display/release/ahfail-display" "@OUTPUT@"'
|
||||
],
|
||||
build_by_default: true,
|
||||
install: true,
|
||||
install_dir: get_option('libdir') / 'ahfail',
|
||||
install_mode: 'rwxr-xr-x'
|
||||
)
|
||||
# The locker's own PAM service. Shipped as its own file, like swaylock and
|
||||
# gtklock do, so no other package's PAM configuration is ever touched.
|
||||
if is_linux
|
||||
install_data('data/pam.d/ahfail',
|
||||
install_dir: get_option('sysconfdir') / 'pam.d',
|
||||
install_mode: 'rw-r--r--'
|
||||
)
|
||||
endif
|
||||
|
||||
libahfail = shared_library(
|
||||
'ahfail-module',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
option('update_check', type: 'boolean', value: true,
|
||||
description: 'Let the binaries check Gitea for new releases. Distro packages set this to false.')
|
||||
@@ -0,0 +1,62 @@
|
||||
# Maintainer: Asger Geel Weirsoe <asger@weircon.dk>
|
||||
#
|
||||
# DISCLAIMER
|
||||
#
|
||||
# ahfail is a joke with a real screen locker underneath. Two things to know
|
||||
# before installing it:
|
||||
#
|
||||
# 1. Security. It sits in the authentication path of your session. It is
|
||||
# provided as is, without any warranty (see the AGPL-3.0 license). On
|
||||
# Wayland a crash leaves the session locked; on X11, as with i3lock, a
|
||||
# killed locker leaves it open. /etc/pam.d/ahfail includes `login`, so
|
||||
# the system's pam_faillock policy applies to wrong guesses.
|
||||
#
|
||||
# 2. Media. The sprite frames and the audio clip are a parody of the
|
||||
# "ah ah ah, you didn't say the magic word" scene from Jurassic Park
|
||||
# (1993). The film and its characters belong to their respective rights
|
||||
# holders; they are used here non-commercially for parody, and the
|
||||
# AGPL-3.0 license covers the code, not those assets. Rights holders who
|
||||
# object can reach the maintainer at the address above and the assets
|
||||
# will be replaced.
|
||||
pkgname=ahfail
|
||||
pkgver=0.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Screen locker that says 'ah ah ah, you didn't say the magic word' on a wrong password"
|
||||
arch=('x86_64')
|
||||
url="https://gitea.weircon.dk/agw/gtk-ahfail"
|
||||
license=('AGPL-3.0-only')
|
||||
# gst-plugins-bad-libs: libgstplayer. gst-plugins-base/-good are runtime plugins
|
||||
# (audio conversion, the mpg123 mp3 decoder) that namcap cannot see being used.
|
||||
depends=('gtk3' 'gtk-session-lock' 'gstreamer' 'gst-plugins-bad-libs' 'gst-plugins-base'
|
||||
'gst-plugins-good' 'pam' 'glib2' 'gdk-pixbuf2' 'cairo' 'pango' 'at-spi2-core' 'gst-plugins-base-libs' 'glibc' 'libgcc' 'libx11')
|
||||
makedepends=('meson' 'ninja' 'cargo')
|
||||
optdepends=('pipewire-pulse: raise the volume to 100% on a failed attempt (or libpulse)'
|
||||
'libpulse: raise the volume to 100% on a failed attempt'
|
||||
'swayidle: lock automatically on Wayland'
|
||||
'xss-lock: lock automatically on X11'
|
||||
'gtklock: legacy gtklock module (/usr/lib/gtklock/ahfail-module.so)')
|
||||
backup=('etc/pam.d/ahfail')
|
||||
install=ahfail.install
|
||||
options=('!lto')
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "$srcdir/gtk-ahfail"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$srcdir/gtk-ahfail"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_NET_OFFLINE=true
|
||||
arch-meson . build -Dupdate_check=false
|
||||
meson compile -C build
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/gtk-ahfail"
|
||||
meson install -C build --destdir "$pkgdir"
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
_ahfail_msg() {
|
||||
cat <<'MSG'
|
||||
==> ahfail is its own screen locker and uses its own PAM service (/etc/pam.d/ahfail).
|
||||
Nothing in your other PAM files has been changed.
|
||||
|
||||
Check your setup: ahfail doctor
|
||||
See the effect safely: ahfail test
|
||||
Lock the screen: ahfail
|
||||
|
||||
Lock automatically:
|
||||
Wayland: swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
|
||||
X11: xss-lock -- ahfail
|
||||
|
||||
The old PAM hook (auth optional .../libahfail_pam.so in e.g. /etc/pam.d/i3lock)
|
||||
is no longer needed with `ahfail`. Remove that line yourself if you added it.
|
||||
MSG
|
||||
}
|
||||
|
||||
post_install() {
|
||||
_ahfail_msg
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
# Only when coming from before 0.10.0, when ahfail became its own locker;
|
||||
# repeating the message on every update would just teach people to skip it.
|
||||
if (( $(vercmp "$2" 0.10.0) < 0 )); then
|
||||
_ahfail_msg
|
||||
fi
|
||||
}
|
||||
@@ -1,111 +1,151 @@
|
||||
# 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.
|
||||
|
||||
* **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
|
||||
|
||||
* **Build:** Meson, Ninja, Rust (Cargo), GTK+3 development headers.
|
||||
* **Runtime:** `gtklock`, `gstreamer`, `gst-plugins-base`, `gst-plugins-good` (for audio playback).
|
||||
|
||||
## Build & Install
|
||||
|
||||
1. **Install Dependencies:**
|
||||
```bash
|
||||
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good
|
||||
```
|
||||
|
||||
2. **Build:**
|
||||
```bash
|
||||
meson setup builddir
|
||||
meson compile -C builddir
|
||||
```
|
||||
|
||||
3. **Install:**
|
||||
```bash
|
||||
sudo meson install -C builddir
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run `gtklock` with the module path:
|
||||
`ahfail` is a screen locker. Get the password wrong and a looping animation of **the author's face photoshopped onto Dennis Nedry's body** appears on screen while the "ah ah ah, you didn't say the magic word" clip from Jurassic Park plays. Every wrong guess adds another Nedry. The volume is forced to 100% on the first failure and restored when you unlock.
|
||||
|
||||
```bash
|
||||
gtklock -m /usr/lib/gtklock/ahfail-module.so
|
||||
ahfail # lock the screen
|
||||
ahfail test # show the effect once, without locking anything
|
||||
ahfail doctor # check this machine and explain what is missing
|
||||
```
|
||||
|
||||
### Arguments
|
||||
One command on Linux: `ahfail` works out whether you run Wayland or X11 and does the right thing. On macOS the effect comes from a PAM hook on the system lock screen (see below).
|
||||
|
||||
* `--deadzone=X,Y,W,H`: Defines a rectangle where sprites will *not* spawn (e.g., to keep your password field visible).
|
||||
```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
|
||||
```
|
||||
> **Security disclaimer**
|
||||
>
|
||||
> This is a screen locker, so it sits directly in the authentication path. A bug in it could lock you out of your session or, in the worst case, weaken the lock.
|
||||
>
|
||||
> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are running a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
|
||||
|
||||
## macOS (build from source)
|
||||
---
|
||||
|
||||
### Prerequisites
|
||||
## Install
|
||||
|
||||
### Arch Linux
|
||||
|
||||
```bash
|
||||
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja rust
|
||||
yay -S ahfail
|
||||
```
|
||||
|
||||
### Build
|
||||
(Once it is published on the AUR.) The package installs the `ahfail` binary and its own PAM service file, `/etc/pam.d/ahfail`. Nothing in any other package's PAM configuration is touched, the same way swaylock and gtklock ship theirs.
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
meson setup builddir
|
||||
sudo pacman -S meson ninja rust gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good pam
|
||||
meson setup builddir --prefix=/usr
|
||||
meson compile -C builddir
|
||||
sudo meson install -C builddir
|
||||
```
|
||||
|
||||
Produces:
|
||||
- `builddir/ahfail-module.so` — gtklock module (Wayland/Linux only)
|
||||
- `builddir/libahfail_pam.so` — PAM module (macOS + X11 Linux)
|
||||
- `builddir/ahfail-display` — display binary (spawned by PAM module)
|
||||
`--prefix=/usr` matches Arch conventions and puts the PAM file where PAM looks for it.
|
||||
|
||||
### Install
|
||||
Then run `ahfail doctor`. It tells you whether the session type is supported, whether `/etc/pam.d/ahfail` is in place, whether GStreamer can play the clip and whether the volume can be controlled, with the exact fix for anything that is missing.
|
||||
|
||||
---
|
||||
|
||||
## Platform support
|
||||
|
||||
| Platform | How it locks | Notes |
|
||||
|---|---|---|
|
||||
| Linux, **Wayland** | ext-session-lock-v1 (through gtk-session-lock) | sway, Hyprland, niri, river, labwc and KDE Plasma 6. **Not GNOME**: GNOME does not let third-party programs lock the screen. |
|
||||
| Linux, **X11** | full-screen windows on every monitor plus a keyboard and pointer grab | The i3lock model. Works with any X11 window manager. |
|
||||
| **macOS** | the system lock screen | Lock the Mac as usual; the effect comes from the PAM hook installed via Homebrew (see below). The `ahfail` command itself is Linux only for now. |
|
||||
|
||||
## Locking automatically
|
||||
|
||||
Point your idle daemon at `ahfail`. Running it while already locked is harmless: a second instance exits quietly.
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /usr/local/lib/ahfail
|
||||
sudo cp builddir/libahfail_pam.so /usr/local/lib/ahfail/
|
||||
sudo cp builddir/ahfail-display /usr/local/lib/ahfail/
|
||||
# Wayland
|
||||
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
|
||||
|
||||
# X11
|
||||
xss-lock -- ahfail
|
||||
```
|
||||
|
||||
### Configure PAM (macOS)
|
||||
Pass `--no-max-volume` to leave the volume alone.
|
||||
|
||||
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`):
|
||||
## Security
|
||||
|
||||
```
|
||||
auth optional /usr/local/lib/ahfail/libahfail_pam.so
|
||||
- **Wayland:** once the compositor confirms the lock, only the lock surfaces are shown. If `ahfail` crashes or is killed, the session **stays locked** (the compositor shows its own fallback), it never opens up.
|
||||
- **X11:** the lock is the input grab. If the grab cannot be taken (a menu or another program holds it), `ahfail` refuses to lock rather than show a lock screen your keystrokes pass through. Like i3lock, nothing on X11 keeps the screen locked if the locker process dies.
|
||||
- `ahfail` refuses to run as root, and the password is checked in a separate thread through PAM service `ahfail` and wiped from memory afterwards.
|
||||
- **Too many wrong passwords can lock your account.** `/etc/pam.d/ahfail` includes `login`, so the system's `pam_faillock` policy applies. On Arch that means 3 wrong passwords within 15 minutes lock the account for 10 minutes (see `/etc/security/faillock.conf`). That is the system's rule, not ahfail's, and every PAM-based locker behaves the same, but it is worth knowing before you invite colleagues to try their luck.
|
||||
|
||||
---
|
||||
|
||||
## macOS
|
||||
|
||||
### Homebrew (recommended)
|
||||
|
||||
```bash
|
||||
brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
|
||||
brew install ahfail
|
||||
```
|
||||
|
||||
### Configure PAM (Linux/X11)
|
||||
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. Enable "Require password immediately" in System Settings so the lock screen asks for the password straight away.
|
||||
|
||||
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:
|
||||
To upgrade: `brew upgrade ahfail`. To uninstall: `sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui` (or `screensaver`), then `brew uninstall ahfail`.
|
||||
|
||||
`bash scripts/install-macos.sh` and `bash scripts/uninstall-macos.sh` do the same from a source checkout.
|
||||
|
||||
---
|
||||
|
||||
## Legacy integrations
|
||||
|
||||
Before `ahfail` became its own locker it hooked into other lockers. These still work and are still built, for anyone who wants to keep their current locker.
|
||||
|
||||
| File | Used by |
|
||||
|---|---|
|
||||
| `ahfail-module.so` | a module for **gtklock** (Wayland) |
|
||||
| `libahfail_pam.so` + `ahfail-display` | a PAM hook for **X11 lockers** (i3lock, i3lock-color, betterlockscreen, xscreensaver, lightdm) |
|
||||
|
||||
**gtklock:**
|
||||
|
||||
```bash
|
||||
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
|
||||
```
|
||||
|
||||
`--deadzone=X,Y,W,H` keeps sprites away from your password field, `--audio-uri=URI` swaps the clip.
|
||||
|
||||
**X11 PAM hook:** add this line to the locker's PAM file (for example `/etc/pam.d/i3lock`) **before** `auth include system-auth`. After it, `pam_faillock`'s `[default=die]` inside `system-auth` means the module is never reached on a failed attempt.
|
||||
|
||||
```
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
On Fedora/RHEL replace `/usr/lib` with `/usr/lib64`.
|
||||
Fedora and RHEL use `/usr/lib64/ahfail/`, Debian and Ubuntu `/usr/lib/x86_64-linux-gnu/ahfail/`; the full path is required. Add `display_path=/path/to/ahfail-display` if the display binary lives elsewhere. The hook never starts the display from a root process (a display manager, for example), so it cannot affect a login screen. `bash scripts/uninstall-linux-x11.sh` removes the line and the files again.
|
||||
|
||||
## Development
|
||||
Pre-built binaries of the legacy files are on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases).
|
||||
|
||||
* **Run Tests:** `cargo test`
|
||||
* **Benchmarks:** `cargo test --test benchmarks -- --nocapture`
|
||||
* **Linting:** `cargo clippy`
|
||||
On swaylock and hyprlock the PAM hook fires, but the compositor hides everything except the locker itself, so only the audio plays. Use `ahfail` instead.
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
To change the default sprite or audio:
|
||||
1. Replace files in `assets/`.
|
||||
2. Update `assets/ahfail.gresource.xml`.
|
||||
3. Rebuild with Meson.
|
||||
The sprite is the author's face on Nedry's body. To use your own:
|
||||
|
||||
1. Replace the sprite frames in `assets/` with your own PNG sequence.
|
||||
2. Update `assets/ahfail.gresource.xml` if you add or remove files.
|
||||
3. Rebuild with Meson.
|
||||
|
||||
To replace the audio clip, swap `assets/magic-word.mp3` and rebuild.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo test # unit + integration tests (some need a display)
|
||||
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
|
||||
cargo clippy # lint
|
||||
meson setup builddir && meson compile -C builddir # full build including the .so files
|
||||
xvfb-run meson test -C builddir --verbose # Meson tests headless
|
||||
```
|
||||
|
||||
The update check (a daily look at the releases page with a desktop notification) is a cargo feature, `update-check`, on by default. Distro packages build without it, since the package manager handles updates there.
|
||||
|
||||
Executable
+99
@@ -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."
|
||||
Executable
+54
@@ -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}"
|
||||
Executable
+36
@@ -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