12 Commits
Author SHA1 Message Date
Asger Geel Weirsøe 198bbb2278 Build the PAM module on macOS (0.10.1)
Cargo names a cdylib .dylib on macOS, so copying libahfail_pam.so out of
the target directory failed there, as the first real macOS build in the
Homebrew tap's Actions showed. The installed name stays .so.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 21:59:59 +02:00
Asger Geel Weirsøe 8b777652be Move the Homebrew formula to the tap on GitHub
Homebrew could not clone the Gitea tap (git over HTTP is disabled), so
brew tap agw/ahfail never worked. The formula now lives in
asger-weirsoee/homebrew-tap, and releases update it with a deploy key.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 21:59:58 +02:00
Asger Geel Weirsøe 1934b86c13 Publish ahfail-bin, hosted on the tarballz index
A second AUR package with the same release prebuilt. The workflow
builds the tag into a staged meson install tree (--prefix=/usr, no
update check), names the tarball after its sha256 so a published name
never changes bytes, uploads it to asger.weirsoe.dk/tarballz with a key
that may only write ahfail-* tarballs, checks that the public URL serves
the same bytes, and only then pushes ahfail-bin to the AUR. A manual run
can now publish source, bin or both.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 21:41:11 +02:00
Asger Geel Weirsøe a1a9a741cc Obfuscate the maintainer address, and allow PKGBUILD-only releases
The Maintainer line uses the usual AUR form (asger at weircon dot dk)
instead of a raw address that spam crawlers harvest. The AUR workflow
can now also be started by hand with a version and pkgrel, so a fix to
the PKGBUILD alone can be published without a new upstream release.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 21:23:12 +02:00
Asger Geel Weirsøe 9538cefbf4 Fix the release token and the Homebrew tap update
Gitea refuses secret names starting with GITEA_, so GITEA_TOKEN could
never be set and every release failed; it is RELEASE_TOKEN now. git over
HTTP is disabled on this Gitea, so the tap formula is updated through
the contents API instead of a clone and push.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 20:03:00 +02:00
Asger Geel Weirsøe b71eb201b6 Add a disclaimer to the PKGBUILD and build the locker on Linux only
The PKGBUILD states what the package is: a real screen locker provided
as is, and parody media from Jurassic Park that the AGPL does not cover.
The ahfail command has only been tested on Linux, so macOS builds keep
the PAM hook and skip it; the README no longer promises it there.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 20:03:00 +02:00
Asger Geel Weirsøe 2cf3b06aeb Install openssh in the CI containers
The runner rewrites Gitea URLs to SSH on port 2222, and without openssh
git falls back to the 'simple' SSH variant, which cannot set a port, so
checkout failed.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 19:31:53 +02:00
Asger Geel Weirsøe 1156c1f927 Run CI and releases in an Arch container
The locker needs gtk-session-lock, which Ubuntu does not package, and
Arch is where the AUR package is built. The release tarball now carries
the ahfail binary and its PAM file as well.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 19:19:09 +02:00
Asger Geel Weirsøe cb41117932 Document ahfail as a locker
The README leads with ahfail, ahfail test and ahfail doctor, the
platform table, idle locking and what the lock does and does not
guarantee, including pam_faillock. The old gtklock module and PAM hook
move to a short legacy section. CLAUDE.md describes the five crates.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 19:18:21 +02:00
Asger Geel Weirsøe 79bb0e41d5 Build everything in one cargo run, and package for the AUR
Four cargo builds with four target directories compiled the GTK and
GStreamer bindings four times; one build with a shared target directory
takes a package build from about an hour to about seven minutes.
Adds the ahfail binary and /etc/pam.d/ahfail to the install, the
update_check option, an AUR PKGBUILD (offline build after cargo fetch
--locked) and a tag-triggered workflow that publishes it and mirrors the
PKGBUILD to the tarballz index.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 19:18:21 +02:00
Asger Geel Weirsøe a4b6297156 Make the update check optional and harden the legacy paths
The Gitea update check is now the default feature update-check of
ahfail-ui; distro builds drop it, and the HTTP client with it.
ahfail-display refuses to run as root and keeps its lock file in
XDG_RUNTIME_DIR instead of a fixed name in the shared /tmp, and the PAM
module no longer spawns it from a root context such as a greeter.
Removes pam_tests.rs, which tested helpers dropped in v0.9.0 and has not
compiled since.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 19:18:21 +02:00
Asger Geel Weirsøe 37e6c36aee Add ahfail, a screen locker of its own
ahfail locks the screen itself instead of hooking another locker: Wayland
through ext-session-lock-v1 (gtk-session-lock), X11 through an input grab.
It authenticates against its own PAM service, /etc/pam.d/ahfail, so a
package never has to edit another package's PAM file.

One command for every platform: ahfail (lock), ahfail test (the effect
without locking) and ahfail doctor (what is missing, with the fix).
Enter is handled directly and the entry uses no input method, since the
Wayland text-input IM swallowed Return. Sprites avoid the password box
in window coordinates, and placement gets 100 tries instead of 10.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 19:18:21 +02:00
31 changed files with 1858 additions and 332 deletions
+247
View File
@@ -0,0 +1,247 @@
name: Publish to AUR
on:
push:
tags:
- 'v*'
# Re-publish without a new upstream release, e.g. a PKGBUILD-only fix:
# run it on master with the released version and the next pkgrel.
workflow_dispatch:
inputs:
version:
description: 'Released version to package, without the v (e.g. 1.2.3)'
required: true
pkgrel:
description: 'Package release number'
required: true
default: '1'
packages:
description: 'Which AUR packages: both, source (ahfail) or bin (ahfail-bin)'
required: true
default: 'both'
jobs:
aur:
if: github.event_name != 'workflow_dispatch' || inputs.packages != 'bin'
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: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
PKGREL="${{ inputs.pkgrel }}"
else
VERSION="${GITHUB_REF#refs/tags/v}"
PKGREL=1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "pkgrel=$PKGREL" >> "$GITHUB_OUTPUT"
echo "Publishing $VERSION-$PKGREL 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=${{ steps.version.outputs.pkgrel }}/" 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 }}-${{ steps.version.outputs.pkgrel }}"
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
# ahfail-bin: the same release, prebuilt. The binary is built from the TAG
# (also when this runs by hand from master), staged as a `meson install` tree,
# hosted on https://asger.weirsøe.dk/tarballz/ next to the other packages, and
# the AUR package only unpacks it.
aur-bin:
if: github.event_name != 'workflow_dispatch' || inputs.packages != 'source'
runs-on: ubuntu-latest
container:
image: archlinux:base-devel
steps:
- name: Install build and packaging tools
run: >
pacman -Syu --noconfirm --needed git openssh nodejs sudo pacman-contrib curl
rust meson ninja gtk3 gtk-session-lock gstreamer gst-plugins-base
gst-plugins-good gst-plugins-bad-libs pam
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
PKGREL="${{ inputs.pkgrel }}"
else
VERSION="${GITHUB_REF#refs/tags/v}"
PKGREL=1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "pkgrel=$PKGREL" >> "$GITHUB_OUTPUT"
- name: Build the tagged source into a staged tree
run: |
# The packaging files come from this checkout; the binary from the tag.
cp -r packaging/aur-bin /tmp/aur-bin
git -c advice.detachedHead=false checkout "v${{ steps.version.outputs.version }}"
meson setup build --prefix=/usr --buildtype=release -Dupdate_check=false
meson compile -C build
DESTDIR="$PWD/stage" meson install -C build
find stage -type f | sort
- name: Pack the tarball
id: tarball
run: |
# Reproducible-ish: fixed owner, sorted entries, mtime of the tagged commit.
EPOCH="$(git log -1 --format=%ct)"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@$EPOCH" \
--zstd -cf ahfail.tar.zst -C stage usr etc
SHA="$(sha256sum ahfail.tar.zst | cut -d' ' -f1)"
# The hash is part of the name, so a published name never changes bytes.
NAME="ahfail-${{ steps.version.outputs.version }}-${SHA:0:12}-x86_64.tar.zst"
mv ahfail.tar.zst "$NAME"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
ls -l "$NAME"
- name: Upload the tarball to tarballz
env:
TARBALLZ_BIN_SSH_KEY: ${{ secrets.TARBALLZ_BIN_SSH_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$TARBALLZ_BIN_SSH_KEY" > ~/.ssh/tarballz-bin
chmod 600 ~/.ssh/tarballz-bin
# Forced command on the web host: this key may only write ahfail-*.tar.*
# and ahfail-bin.PKGBUILD, and keeps the newest three tarballs.
ssh -i ~/.ssh/tarballz-bin -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
root@192.168.10.148 "tarball ${{ steps.tarball.outputs.name }}" < "${{ steps.tarball.outputs.name }}"
- name: Verify the public URL serves the same bytes
run: |
URL="https://asger.weirsoe.dk/tarballz/${{ steps.tarball.outputs.name }}"
GOT="$(curl -fsSL --retry 3 "$URL" | sha256sum | cut -d' ' -f1)"
if [ "$GOT" != "${{ steps.tarball.outputs.sha }}" ]; then
echo "error: $URL does not serve the uploaded tarball" >&2
exit 1
fi
- name: Configure SSH for AUR
run: |
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
useradd -m builder
- name: Render PKGBUILD and .SRCINFO
run: |
git clone ssh://aur@aur.archlinux.org/ahfail-bin.git aur-bin-pkg 2>&1 || true
if [ ! -d aur-bin-pkg/.git ]; then
mkdir -p aur-bin-pkg && cd aur-bin-pkg
git init -b master
git remote add origin ssh://aur@aur.archlinux.org/ahfail-bin.git
cd ..
fi
cp /tmp/aur-bin/PKGBUILD /tmp/aur-bin/ahfail.install aur-bin-pkg/
cd aur-bin-pkg
sed -i "s/@TARBALL@/${{ steps.tarball.outputs.name }}/g; s/@SHA256@/${{ steps.tarball.outputs.sha }}/" PKGBUILD
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/; s/^pkgrel=.*/pkgrel=${{ steps.version.outputs.pkgrel }}/" PKGBUILD
chown -R builder: .
sudo -u builder makepkg --printsrcinfo > .SRCINFO
cat PKGBUILD | tail -20
- name: Commit and push to AUR
run: |
cd aur-bin-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 }}-${{ steps.version.outputs.pkgrel }}"
git push -u origin master
- name: Publish PKGBUILD to the tarballz index
continue-on-error: true
run: |
ssh -i ~/.ssh/tarballz-bin -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
root@192.168.10.148 < aur-bin-pkg/PKGBUILD
+26 -30
View File
@@ -8,25 +8,14 @@ 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:
- uses: actions/checkout@v4
- 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
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
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@v4
- name: Compute source tarball SHA256
run: |
@@ -46,33 +35,40 @@ 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
# The tap lives on GitHub (asger-weirsoee/homebrew-tap): Homebrew cannot
# clone from this Gitea, where git over HTTP is disabled. The deploy key
# can write only to that one repository; the tap's Actions then build
# and test the formula on a real Mac.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git clone \
"https://oauth2:${GITEA_TOKEN}@gitea.weircon.dk/agw/homebrew-ahfail.git" \
tap
mkdir -p ~/.ssh
printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > ~/.ssh/tap
chmod 600 ~/.ssh/tap
export GIT_SSH_COMMAND="ssh -i ~/.ssh/tap -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
git clone -q git@github.com:asger-weirsoee/homebrew-tap.git tap
cd tap
sed -i "s|url \".*\"|url \"https://gitea.weircon.dk/agw/gtk-ahfail/archive/${TAG}.tar.gz\"|" Formula/ahfail.rb
sed -i "s|sha256 \".*\"|sha256 \"${SOURCE_SHA256}\"|" Formula/ahfail.rb
git config user.email "actions@weircon.dk"
git config user.name "Gitea Actions"
git add Formula/ahfail.rb
git diff --cached --quiet || git commit -m "chore: update ahfail to ${TAG}"
git push
URL="https://gitea.weircon.dk/agw/gtk-ahfail/archive/${TAG}.tar.gz"
sed -i "s|^ url \".*\"| url \"$URL\"|; s|^ sha256 \".*\"| sha256 \"${SOURCE_SHA256}\"|" Formula/ahfail.rb
if git diff --quiet; then echo "formula already up to date"; exit 0; fi
git -c user.name="Asger Geel Weirsøe" -c user.email="github@weircon.dk" commit -q -am "ahfail ${TAG#v}"
git push -q origin HEAD
+6 -17
View File
@@ -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
+34 -47
View File
@@ -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
+15
View File
@@ -8,6 +8,21 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahfail"
version = "0.10.1"
dependencies = [
"ahfail-ui",
"cc",
"gdk",
"glib",
"gstreamer",
"gstreamer-player",
"gtk",
"libc",
"pkg-config",
]
[[package]]
name = "ahfail-display"
version = "0.1.0"
+1
View File
@@ -4,5 +4,6 @@ members = [
"crates/ahfail-gtklock",
"crates/ahfail-pam",
"crates/ahfail-display",
"crates/ahfail",
]
resolver = "2"
+5 -1
View File
@@ -7,7 +7,7 @@ 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"] }
@@ -15,6 +15,10 @@ 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"
+17 -2
View File
@@ -78,15 +78,30 @@ mod x11_grab {
}
}
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 {
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
}
// Single-instance guard
// 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;
}
// 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("/tmp/ahfail-display.lock");
.open(display_lock_path());
let lock_file = match lock_file {
Ok(f) => f,
Err(_) => return,
+5 -1
View File
@@ -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"
+12 -1
View File
@@ -56,7 +56,13 @@ pub unsafe extern "C" fn pam_sm_authenticate(
.map(|s| s.trim().to_string())
.unwrap_or_default();
spawn_display(display_path.clone(), &locker_name);
// 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,
@@ -98,6 +104,11 @@ 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 }
/// 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 &[]; }
std::slice::from_raw_parts(argv, argc as usize)
-21
View File
@@ -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());
}
+8 -1
View File
@@ -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 -1
View File
@@ -3,7 +3,9 @@ use rand::Rng;
use crate::config::ModuleConfig;
const SPRITE_MARGIN: i32 = 100;
const RETRY_ATTEMPTS: usize = 10;
// Cheap, and with only 10 tries a sprite could still land on the password
// field on a small screen.
const RETRY_ATTEMPTS: usize = 100;
pub fn sprite_position(
animation: &gdk_pixbuf::PixbufAnimation,
+23
View File
@@ -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) {}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "ahfail"
version = "0.10.1"
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"
+50
View File
@@ -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");
}
}
+161
View File
@@ -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");
}
+343
View File
@@ -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(&center);
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: &gtk::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()
}
+174
View File
@@ -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 }))
}
+151
View File
@@ -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())
}
+41
View File
@@ -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
}
+101
View File
@@ -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()
}
+106
View File
@@ -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: &gtk::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
}
+5
View File
@@ -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
+49 -34
View File
@@ -1,12 +1,13 @@
project(
'ahfail',
['c', 'rust'],
version: '0.1.0',
version: '0.10.1',
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,48 +21,62 @@ 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 names a cdylib .dylib on macOS. The installed name stays
# libahfail_pam.so: PAM loads the module by path, whatever the extension.
pam_built = host_machine.system() == 'darwin' ? 'libahfail_pam.dylib' : 'libahfail_pam.so'
cargo_copy = (' && cp "@OUTDIR@/target/release/libahfail_module.a" "@OUTPUT0@"'
+ ' && cp "@OUTDIR@/target/release/' + pam_built + '" "@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 the absolute libdir so build.rs emits the correct AHFAIL_INSTALL_DIR.
# get_option('libdir') is relative ("lib"); prefix it to get an absolute path.
'AHFAIL_LIBDIR=' + get_option('prefix') / get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"'
# 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',
+2
View File
@@ -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.')
+58
View File
@@ -0,0 +1,58 @@
# Maintainer: Asger Geel Weirsoe <asger at weircon dot 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.
#
# PROVENANCE OF THE BINARY
#
# Built by the gtk-ahfail release workflow from the tagged source, in an
# archlinux:base-devel container, with --prefix=/usr and without the Gitea
# update check. The same recipe builds the source package `ahfail`.
#
# build recipe: https://gitea.weircon.dk/agw/gtk-ahfail
# download: https://asger.weirsoe.dk/tarballz/@TARBALL@
pkgname=ahfail-bin
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 (prebuilt binary)"
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')
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)')
provides=("ahfail=$pkgver")
conflicts=('ahfail')
backup=('etc/pam.d/ahfail')
install=ahfail.install
options=('!strip' '!debug')
source=("https://asger.weirsoe.dk/tarballz/@TARBALL@")
sha256sums=('@SHA256@')
package() {
# The tarball is a staged `meson install` tree: usr/ and etc/ at its root.
cp -a "$srcdir/usr" "$srcdir/etc" "$pkgdir/"
}
+29
View File
@@ -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
}
+62
View File
@@ -0,0 +1,62 @@
# Maintainer: Asger Geel Weirsoe <asger at weircon dot 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"
}
+29
View File
@@ -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
}
+72 -176
View File
@@ -1,142 +1,78 @@
# Ah ah ah, you didn't say the magic word
On a failed lock-screen unlock attempt, this spawns a looping animation of **the author's face photoshopped onto Dennis Nedry's body** and plays the "ah ah ah, you didn't say the magic word" clip from Jurassic Park. Each wrong guess adds another sprite at a random screen position. Volume is forced to 100% on the first failure and restored when the screen unlocks.
`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
ahfail # lock the screen
ahfail test # show the effect once, without locking anything
ahfail doctor # check this machine and explain what is missing
```
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).
---
> **Security disclaimer**
>
> This project hooks into PAM and/or your screen locker's plugin API. PAM sits directly in the authentication critical path — a bug in this module could lock you out of your system or, in the worst case, weaken authentication.
> 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 modifying a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
> 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.**
---
## Platform support
## Install
The integration method differs by display server:
| Platform | Display server | Module loaded | How it works |
|---|---|---|---|
| Linux | **Wayland** | `ahfail-module.so` | Loaded directly by `gtklock` via its module API |
| Linux | **X11** | `libahfail_pam.so` + `ahfail-display` | PAM cleanup hook spawns the display binary after each failed auth |
| macOS | — | `libahfail_pam.so` + `ahfail-display` | Same PAM approach, hooks into the screensaver PAM stack |
The **gtklock module** is Wayland-only — it uses gtklock's internal window API to overlay sprites directly on the lock screen. The **PAM module** works on X11 and macOS by registering a cleanup callback that fires on each failed authentication attempt; it double-forks a standalone display binary (`ahfail-display`) that runs independently of the PAM stack.
---
## Linux (x86\_64) — binary install
Pre-built binaries for both Wayland and X11 are available on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases). The tarball contains all three files:
| File | Used by |
|---|---|
| `ahfail-module.so` | Wayland — loaded by `gtklock` |
| `libahfail_pam.so` | X11 — loaded by PAM |
| `ahfail-display` | X11 — spawned by the PAM module |
### Arch Linux
```bash
# Download and extract (replace v0.1.0 with the latest release)
curl -fsSL https://gitea.weircon.dk/agw/gtk-ahfail/releases/download/v0.1.0/ahfail-linux-x86_64.tar.gz \
| sudo tar -xz -C /tmp/ahfail-install
# Wayland (gtklock)
sudo install -Dm755 /tmp/ahfail-install/ahfail-module.so /usr/lib/gtklock/ahfail-module.so
# X11 (PAM module + display binary)
sudo install -Dm755 /tmp/ahfail-install/libahfail_pam.so /usr/lib/ahfail/libahfail_pam.so
sudo install -Dm755 /tmp/ahfail-install/ahfail-display /usr/lib/ahfail/ahfail-display
yay -S ahfail
```
Then follow the [Wayland](#usage-linux---wayland-only-gtklock) or [X11](#usage-linux---x11-i3lock-xscreensaver-etc) configuration below.
(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.
---
## Linux compile
### Install dependencies and build from source
### From source
```bash
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gtklock
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
```
`--prefix=/usr` matches Arch conventions (same as pacman) and ensures the compiled-in default path for `ahfail-display` matches where it is installed.
`--prefix=/usr` matches Arch conventions and puts the PAM file where PAM looks for it.
Installs:
- `/usr/lib/gtklock/ahfail-module.so` — gtklock module
- `/usr/lib/ahfail/libahfail_pam.so` — PAM module (for X11)
- `/usr/lib/ahfail/ahfail-display` — standalone display binary
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.
---
## Usage Linux - wayland (only gtklock)
## 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
gtklock -m /usr/lib/gtklock/ahfail-module.so
# Wayland
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
# X11
xss-lock -- ahfail
```
### Arguments
Pass `--no-max-volume` to leave the volume alone.
Pass module arguments after `--`:
## Security
```bash
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
```
- `--deadzone=X,Y,W,H` — rectangle where sprites will not spawn (keep your password field clear)
- `--audio-uri=URI` — override the default audio clip, e.g. `file:///home/user/custom.mp3`
---
## Usage Linux - X11 (i3lock, xscreensaver, etc.)
The PAM module works with any X11 locker that authenticates via PAM. Supported lockers and their service file names:
| Locker | PAM service file |
|---|---|
| i3lock | `/etc/pam.d/i3lock` |
| i3lock-color | `/etc/pam.d/i3lock-color` |
| betterlockscreen | `/etc/pam.d/betterlockscreen` |
| xscreensaver | `/etc/pam.d/xscreensaver` |
| lightdm | `/etc/pam.d/lightdm` |
Add a line to the relevant file.
**Important:** The `ahfail` line must appear **before** `auth include system-auth` (or any equivalent include). If it comes after, PAM's internal flow control inside `system-auth` (`pam_faillock` with `[default=die]`) means our module is never reached on failed attempts — it only gets called on success.
Your PAM service file should look like this:
**Arch / standard (`--prefix=/usr`):**
```
#%PAM-1.0
auth optional /usr/lib/ahfail/libahfail_pam.so
auth include system-auth
```
**Fedora / RHEL (multilib):**
```
#%PAM-1.0
auth optional /usr/lib64/ahfail/libahfail_pam.so
auth include system-auth
```
**Debian / Ubuntu (multiarch):**
```
#%PAM-1.0
auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so
auth include system-auth
```
The full path is required — `$(libdir)/ahfail` is not in PAM's default search path.
If the display binary is not at the default location, add `display_path=`:
```
auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfail-display
```
- **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.
---
@@ -145,76 +81,48 @@ auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfa
### Homebrew (recommended)
```bash
brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
brew tap asger-weirsoee/tap
brew install ahfail
```
After install, Homebrew prints the PAM line to add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` (macOS 12 and earlier). That one-line edit is the only manual step.
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.
To upgrade: `brew upgrade ahfail`.
To upgrade: `brew upgrade ahfail`. To uninstall: `sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui` (or `screensaver`), then `brew uninstall ahfail`.
### Script install
```bash
git clone https://gitea.weircon.dk/agw/gtk-ahfail.git
cd gtk-ahfail
bash scripts/install-macos.sh
```
Installs Homebrew dependencies, builds from source, copies binaries to `/usr/local/lib/ahfail/`, and patches the screensaver PAM configuration automatically.
### Manual install
```bash
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja
# install Rust via https://rustup.rs if not present
meson setup builddir && meson compile -C builddir
sudo mkdir -p /usr/local/lib/ahfail
sudo cp builddir/libahfail_pam.so builddir/ahfail-display /usr/local/lib/ahfail/
```
Add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` as the **first** `auth` line (before any `auth include` or `auth required` entries):
```
auth optional /usr/local/lib/ahfail/libahfail_pam.so
```
`bash scripts/install-macos.sh` and `bash scripts/uninstall-macos.sh` do the same from a source checkout.
---
## Uninstall
## Legacy integrations
### Linux — X11
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
bash scripts/uninstall-linux-x11.sh
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
```
Removes the `ahfail` line from common PAM service files (`/etc/pam.d/i3lock`, `xscreensaver`, `lightdm`, etc.) and removes the installed binaries. If `builddir` is present it uses `ninja uninstall`; otherwise it removes the known paths manually.
`--deadzone=X,Y,W,H` keeps sprites away from your password field, `--audio-uri=URI` swaps the clip.
### Linux — Wayland
**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.
No PAM config was modified. Just stop passing the module to gtklock and optionally remove the file:
```bash
sudo rm -f /usr/lib/gtklock/ahfail-module.so # --prefix=/usr install
# or
sudo rm -f /usr/local/lib/gtklock/ahfail-module.so
```
#%PAM-1.0
auth optional /usr/lib/ahfail/libahfail_pam.so
auth include system-auth
```
### macOS — Homebrew
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.
```bash
sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui # or screensaver on macOS 12
brew uninstall ahfail
```
Pre-built binaries of the legacy files are on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases).
### macOS — manual / script install
```bash
bash scripts/uninstall-macos.sh
```
Removes the `ahfail` line from the screensaver PAM file and deletes `/usr/local/lib/ahfail/`.
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.
---
@@ -222,34 +130,22 @@ Removes the `ahfail` line from the screensaver PAM file and deletes `/usr/local/
The sprite is the author's face on Nedry's body. To use your own:
1. Replace the sprite frames in `assets/sprites/` with your own PNG sequence.
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/audio/magic-word.mp3` and rebuild.
To replace the audio clip, swap `assets/magic-word.mp3` and rebuild.
---
## Development
```bash
cargo test # unit + integration tests (needs display)
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
cargo clippy # lint
meson setup builddir && meson compile -C builddir # full build including .so
xvfb-run meson test -C builddir --verbose # Meson tests headless
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
```
---
## Roadmap
### swaylock (Wayland)
The PAM module can be added to `/etc/pam.d/swaylock` and will fire on each failed attempt — but on Wayland the `ahfail-display` binary cannot draw on top of the lock screen. The Wayland session-lock protocol (`ext-session-lock-v1`) restricts rendering to the locker process itself, so the animation is suppressed by the compositor and only the audio plays.
Full support requires swaylock to expose a plugin API similar to gtklock's. There is an open request for this upstream. Once available, a dedicated swaylock module can be added alongside the existing gtklock one.
### hyprlock (Wayland)
Same situation as swaylock — PAM fires correctly but the display is blocked by the compositor. hyprlock does not currently have a plugin/module API. Full animation support is pending upstream plugin support.
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.