23 Commits
Author SHA1 Message Date
Asger Geel Weirsøe d5e1bd5016 Build only the PAM hook on macOS (0.10.2)
The gtklock module is Wayland-only and its GNU ld link flags
(--whole-archive) do not exist on macOS, so the macOS build stopped
there. macOS now builds just the PAM hook and the display binary, which
is all the Homebrew formula installs; libdl is optional (it is part of
libSystem on macOS).

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-30 21:59:59 +02:00
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
Asger Geel WeirsøeandClaude Sonnet 4.6 18c5c05be1 release: v0.9.0 pre-release — X11 PAM integration, shadow suppression, macOS cfg guards
- PAM module now correctly fires on failed attempts (must precede auth includes)
- ahfail-display: sprite-sized window replaces full-screen overlay
- ahfail-display: XGrabKeyboard unlock detection replaces process scanning
- ahfail-display: _COMPTON_SHADOW=0 suppresses picom shadow without config changes
- ahfail-display: flock single-instance guard prevents stacking on rapid failures
- X11-specific code guarded behind #[cfg(target_os = "linux")] for macOS builds
- Removed debug logging (dlog/plog) from display and PAM binaries
- README: PAM ordering requirement, supported locker table, roadmap section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:35:12 +02:00
Asger Geel WeirsøeandClaude Sonnet 4.6 f2e32fd057 feat: add uninstall scripts for X11 and macOS
scripts/uninstall-linux-x11.sh — strips ahfail from common PAM service
files and removes binaries (via ninja uninstall or known paths).

scripts/uninstall-macos.sh — strips ahfail from screensaverui/screensaver
and removes /usr/local/lib/ahfail/.

README: add Uninstall section covering all four cases (X11, Wayland,
Homebrew macOS, manual macOS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:21:52 +02:00
Asger Geel WeirsøeandClaude Sonnet 4.6 1f0d42b24d docs: make X11 PAM config instructions unambiguous
Replace bare config snippets with actual tee -a commands so there is no
way to mistake a file entry for a shell command to run directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:20:32 +02:00
Asger Geel WeirsøeandClaude Sonnet 4.6 bab73b1ee8 docs: add --prefix=/usr to Arch build instructions
Without it meson defaults to /usr/local, so the installed paths don't
match the README and DEFAULT_PATH compiled into the PAM module is wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:17:43 +02:00
Asger Geel Weirsøe 181372b2fe updates readme.md 2026-05-06 20:15:44 +02:00
Asger Geel Weirsøe 5e9fce4fd4 updates readme.md 2026-05-06 20:15:23 +02:00
Asger Geel WeirsøeandClaude Sonnet 4.6 59c68f8187 docs: add binary install section for Linux, Homebrew section for macOS
Linux x86_64 binary install covers both Wayland and X11 from the same
tarball — clarified with a table showing which file is used by which.

macOS section now leads with Homebrew (brew tap + brew install) as the
recommended path, with script and manual install as fallbacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:14:03 +02:00
Asger Geel WeirsøeandClaude Sonnet 4.6 12fb29eb0e ci: auto-update Homebrew tap on release
After uploading the release asset, the workflow now:
1. Computes the SHA256 of the source tarball from the Gitea archive endpoint
2. Clones homebrew-ahfail via token auth, patches url + sha256 in Formula/ahfail.rb
3. Commits and pushes — so `brew upgrade ahfail` works without any manual steps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:13:03 +02:00
Asger Geel WeirsøeandClaude Sonnet 4.6 179da7b277 fix: use build-time AHFAIL_INSTALL_DIR on macOS, not hardcoded /usr/local/lib
Apple Silicon Homebrew uses /opt/homebrew as prefix, so hardcoding
/usr/local/lib/ahfail/ahfail-display as DEFAULT_PATH breaks there.
build.rs now falls back to /usr/local/lib on macOS and /usr/lib on Linux
when AHFAIL_LIBDIR is not set; macOS uses the same concat!(env!(...))
approach as Linux.

install-macos.sh passes -Dlibdir so the baked-in path matches INSTALL_DIR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:12:37 +02:00
Asger Geel WeirsøeandClaude Sonnet 4.6 554bb00a69 docs: rewrite readme; add macOS install script
- Clarify that the sprite is the author's face on Nedry's body
- Explicit Wayland (gtklock module) vs X11/macOS (PAM module) comparison table
  with an explanation of how each integration works
- Accurate per-distro PAM paths (standard/Fedora multilib/Debian multiarch)
- scripts/install-macos.sh: one-shot installer for macOS — checks Homebrew,
  installs brew deps, builds from source, copies binaries, and patches
  /etc/pam.d/screensaverui (or screensaver) after the last auth line

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:10:55 +02:00
36 changed files with 2310 additions and 347 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
+39 -18
View File
@@ -8,25 +8,21 @@ on:
jobs:
release:
runs-on: ubuntu-latest
# Built on Arch: the locker needs gtk-session-lock (not packaged by Ubuntu).
container:
image: archlinux:base-devel
steps:
- name: Install system dependencies
run: pacman -Syu --noconfirm --needed base-devel git openssh nodejs rust meson ninja gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad-libs pam curl python
- uses: actions/checkout@v4
- name: Install system dependencies
- name: Compute source tarball SHA256
run: |
sudo apt-get update -q
sudo apt-get install -y \
libgtk-3-dev \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgstreamer-plugins-bad1.0-dev \
gstreamer1.0-plugins-good \
libpam0g-dev \
ninja-build \
meson \
libglib2.0-dev
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
SHA256=$(curl -fsSL \
"https://gitea.weircon.dk/agw/gtk-ahfail/archive/${{ github.ref_name }}.tar.gz" \
| sha256sum | cut -d' ' -f1)
echo "SOURCE_SHA256=${SHA256}" >> $GITHUB_ENV
- name: Build release
run: |
@@ -39,15 +35,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:
HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
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
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
+16
View File
@@ -8,11 +8,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahfail"
version = "0.10.2"
dependencies = [
"ahfail-ui",
"cc",
"gdk",
"glib",
"gstreamer",
"gstreamer-player",
"gtk",
"libc",
"pkg-config",
]
[[package]]
name = "ahfail-display"
version = "0.1.0"
dependencies = [
"ahfail-ui",
"cairo-rs",
"cc",
"gdk",
"glib",
+1
View File
@@ -4,5 +4,6 @@ members = [
"crates/ahfail-gtklock",
"crates/ahfail-pam",
"crates/ahfail-display",
"crates/ahfail",
]
resolver = "2"
+6 -1
View File
@@ -7,13 +7,18 @@ edition = "2021"
name = "ahfail-display"
[dependencies]
ahfail-ui = { path = "../ahfail-ui" }
ahfail-ui = { path = "../ahfail-ui", default-features = false }
gtk = { version = "0.15", package = "gtk", features = ["v3_24"] }
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
glib = { version = "0.15", package = "glib" }
cairo = { version = "0.15", package = "cairo-rs" }
libc = "0.2"
[features]
default = ["update-check"]
update-check = ["ahfail-ui/update-check"]
[build-dependencies]
cc = "1"
pkg-config = "0.3"
+4
View File
@@ -49,4 +49,8 @@ fn main() {
// Link against gio-2.0 for GResource support
println!("cargo:rustc-link-lib=gio-2.0");
// Link against X11 for XGrabKeyboard (Linux only)
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
println!("cargo:rustc-link-lib=X11");
}
}
+145 -32
View File
@@ -2,6 +2,7 @@ use gtk::prelude::*;
use gtk::gdk;
use gstreamer as gst;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
const FAILSAFE_MINUTES: u32 = 15;
@@ -12,63 +13,163 @@ extern "C" fn handle_sigterm(_: libc::c_int) {
SIGTERM_RECEIVED.store(true, Ordering::Relaxed);
}
#[cfg(target_os = "linux")]
mod x11_grab {
use std::os::raw::{c_char, c_int, c_ulong, c_void};
type XDisplay = c_void;
type Window = c_ulong;
type Atom = c_ulong;
const GRAB_SUCCESS: c_int = 0;
const GRAB_MODE_ASYNC: c_int = 1;
const XA_CARDINAL: Atom = 6;
const PROP_MODE_REPLACE: c_int = 0;
extern "C" {
fn XOpenDisplay(name: *const c_char) -> *mut XDisplay;
fn XCloseDisplay(dpy: *mut XDisplay) -> c_int;
fn XDefaultRootWindow(dpy: *mut XDisplay) -> Window;
fn XGrabKeyboard(
dpy: *mut XDisplay, grab_window: Window, owner_events: c_int,
pointer_mode: c_int, keyboard_mode: c_int, time: c_ulong,
) -> c_int;
fn XUngrabKeyboard(dpy: *mut XDisplay, time: c_ulong) -> c_int;
fn XInternAtom(dpy: *mut XDisplay, name: *const c_char, only_if_exists: c_int) -> Atom;
fn XChangeProperty(
dpy: *mut XDisplay, w: Window, property: Atom, type_: Atom,
format: c_int, mode: c_int, data: *const u8, nelements: c_int,
) -> c_int;
fn XFlush(dpy: *mut XDisplay) -> c_int;
fn gdk_x11_window_get_xid(window: *mut c_void) -> c_ulong;
}
pub fn is_screen_unlocked() -> bool {
unsafe {
let dpy = XOpenDisplay(std::ptr::null());
if dpy.is_null() { return false; }
let root = XDefaultRootWindow(dpy);
let result = XGrabKeyboard(dpy, root, 0, GRAB_MODE_ASYNC, GRAB_MODE_ASYNC, 0);
if result == GRAB_SUCCESS {
XUngrabKeyboard(dpy, 0);
}
XCloseDisplay(dpy);
result == GRAB_SUCCESS
}
}
/// Tell picom/compton not to draw a shadow around this window.
/// Works by setting _COMPTON_SHADOW=0 directly on the X window,
/// which the compositor respects regardless of its config.
pub fn disable_compositor_shadow(gdk_window: &gdk::Window) {
use glib::ObjectType;
unsafe {
let xid = gdk_x11_window_get_xid(gdk_window.as_ptr() as *mut c_void);
let dpy = XOpenDisplay(std::ptr::null());
if dpy.is_null() { return; }
let atom = XInternAtom(dpy, b"_COMPTON_SHADOW\0".as_ptr() as *const c_char, 0);
let value: u32 = 0;
XChangeProperty(
dpy, xid, atom, XA_CARDINAL, 32,
PROP_MODE_REPLACE, &value as *const u32 as *const u8, 1,
);
XFlush(dpy);
XCloseDisplay(dpy);
}
}
}
fn display_lock_path() -> std::path::PathBuf {
match std::env::var("XDG_RUNTIME_DIR") {
Ok(dir) if !dir.is_empty() => std::path::Path::new(&dir).join("ahfail-display.lock"),
_ => std::path::PathBuf::from(format!("/tmp/ahfail-display-{}.lock", unsafe { libc::getuid() })),
}
}
fn main() {
unsafe {
// SAFETY: handle_sigterm only stores to an AtomicBool — async-signal-safe.
// The *const () intermediate avoids a "direct cast to integer" warning because
// libc::sighandler_t is size_t on Linux.
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
}
if gtk::init().is_err() {
eprintln!("[ahfail-display] GTK init failed");
// Never run as root (the PAM module refuses to spawn us there, but the
// binary can also be started by hand).
if unsafe { libc::getuid() == 0 || libc::geteuid() == 0 } {
eprintln!("[ahfail] ahfail-display refuses to run as root");
return;
}
if gst::init().is_err() {
eprintln!("[ahfail-display] GStreamer init failed");
// Single-instance guard, per user: a fixed name in the shared /tmp could be
// pre-created by another user to block us.
let lock_file = std::fs::OpenOptions::new()
.create(true).write(true)
.open(display_lock_path());
let lock_file = match lock_file {
Ok(f) => f,
Err(_) => return,
};
use std::os::unix::io::AsRawFd;
if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
return;
}
// Give PAM a moment to complete, then bail if auth already succeeded.
std::thread::sleep(Duration::from_millis(200));
#[cfg(target_os = "linux")]
if x11_grab::is_screen_unlocked() {
return;
}
if gtk::init().is_err() { return; }
if gst::init().is_err() { return; }
let animation = unsafe { ahfail_ui::animation::load_animation() };
let Some(animation) = animation else {
eprintln!("[ahfail-display] No animation frames found");
return;
};
let Some(animation) = animation else { return; };
let Some(display) = gdk::Display::default() else {
eprintln!("[ahfail-display] No display");
return;
};
let Some(display) = gdk::Display::default() else { return; };
let Some(monitor) = display.primary_monitor().or_else(|| display.monitor(0)) else {
eprintln!("[ahfail-display] No monitor");
return;
};
let geom = monitor.geometry();
let screen_w = geom.width();
let screen_h = geom.height();
let (screen_w, screen_h) = (geom.width(), geom.height());
// On X11, WindowType::Popup creates an unmanaged override-redirect window (desired).
// On Wayland, GTK3 falls back to a normal xdg_toplevel; the compositor controls stacking.
let config = parse_args();
let (sprite_x, sprite_y) = ahfail_ui::display::sprite_position(
&animation, screen_w, screen_h, &config,
);
let sprite_w = animation.width();
let sprite_h = animation.height();
// Window sized exactly to the sprite — no full-screen background to worry about
let window = gtk::Window::new(gtk::WindowType::Popup);
window.set_decorated(false);
window.set_keep_above(true);
window.set_skip_taskbar_hint(true);
window.move_(geom.x(), geom.y());
window.set_default_size(screen_w, screen_h);
window.set_accept_focus(false);
window.set_type_hint(gdk::WindowTypeHint::Notification);
window.move_(geom.x() + sprite_x, geom.y() + sprite_y);
window.set_default_size(sprite_w, sprite_h);
let fixed = gtk::Fixed::new();
fixed.set_size_request(screen_w, screen_h);
window.add(&fixed);
// RGBA visual so sprite edges (from PNG alpha) composite correctly
if let Some(screen) = window.screen() {
if let Some(visual) = screen.rgba_visual() {
window.set_visual(Some(&visual));
}
}
window.set_app_paintable(true);
window.connect_draw(|_, cr| {
cr.set_source_rgba(0.0, 0.0, 0.0, 0.0);
cr.set_operator(cairo::Operator::Source);
let _ = cr.paint();
gtk::Inhibit(false)
});
let config = parse_args();
ahfail_ui::display::place_sprite(&fixed, &animation, screen_w, screen_h, &config);
let image = gtk::Image::from_animation(&animation);
window.add(&image);
let player = ahfail_ui::audio::create_player(AUDIO_URI);
player.play();
std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION));
// All setup succeeded — acquire volume lock now so early-exit paths above don't leave it held.
let volume_state = ahfail_ui::volume::save_and_set_max();
glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || {
@@ -76,7 +177,7 @@ fn main() {
glib::Continue(false)
});
glib::timeout_add(std::time::Duration::from_millis(200), move || {
glib::timeout_add(Duration::from_millis(200), || {
if SIGTERM_RECEIVED.load(Ordering::Relaxed) {
gtk::main_quit();
return glib::Continue(false);
@@ -84,7 +185,21 @@ fn main() {
glib::Continue(true)
});
#[cfg(target_os = "linux")]
glib::timeout_add(Duration::from_millis(500), || {
if x11_grab::is_screen_unlocked() {
gtk::main_quit();
return glib::Continue(false);
}
glib::Continue(true)
});
window.show_all();
#[cfg(target_os = "linux")]
if let Some(gdk_win) = window.window() {
x11_grab::disable_compositor_shadow(&gdk_win);
}
gtk::main();
player.stop();
@@ -99,9 +214,7 @@ fn parse_args() -> ahfail_ui::config::ModuleConfig {
.and_then(|a| {
let val = a.trim_start_matches("--deadzone=");
let p: Vec<&str> = val.split(',').collect();
if p.len() != 4 {
return None;
}
if p.len() != 4 { return None; }
let x: i32 = p[0].parse().ok()?;
let y: i32 = p[1].parse().ok()?;
let w: i32 = p[2].parse().ok()?;
+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"
+6 -4
View File
@@ -1,9 +1,11 @@
fn main() {
println!("cargo:rustc-link-lib=pam");
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch
// systems (e.g. Debian/Ubuntu where libdir is /usr/lib/x86_64-linux-gnu).
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back to /usr/lib otherwise.
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| "/usr/lib".to_string());
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch Linux
// (e.g. /usr/lib/x86_64-linux-gnu) and on Apple Silicon macOS (/opt/homebrew/lib).
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back per platform otherwise.
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let fallback = if target_os == "macos" { "/usr/local/lib" } else { "/usr/lib" };
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| fallback.to_string());
println!("cargo:rustc-env=AHFAIL_INSTALL_DIR={}/ahfail", libdir);
}
+29 -66
View File
@@ -1,18 +1,9 @@
use libc::{c_char, c_int, c_void, pid_t};
use std::ffi::CString;
// --- PAM constants (platform-specific) ---
pub const PAM_SUCCESS: c_int = 0;
pub const PAM_IGNORE: c_int = 25;
#[cfg(target_os = "linux")]
pub const PAM_AUTH_ERR: c_int = 7;
#[cfg(target_os = "macos")]
pub const PAM_AUTH_ERR: c_int = 9;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub const PAM_AUTH_ERR: c_int = 7;
#[cfg(target_os = "linux")]
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
#[cfg(target_os = "macos")]
@@ -20,22 +11,10 @@ pub const PAM_DATA_REPLACE: c_int = 0x00000002;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
// Default install path for ahfail-display.
// On Linux: built from AHFAIL_INSTALL_DIR emitted by build.rs, which reads AHFAIL_LIBDIR
// passed by Meson — correct on multiarch systems (e.g. /usr/lib/x86_64-linux-gnu/ahfail).
#[cfg(target_os = "macos")]
const DEFAULT_PATH: &str = "/usr/local/lib/ahfail/ahfail-display";
#[cfg(not(target_os = "macos"))]
const DEFAULT_PATH: &str = concat!(env!("AHFAIL_INSTALL_DIR"), "/ahfail-display");
pub fn default_display_path() -> &'static str { DEFAULT_PATH }
pub fn is_failure(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_AUTH_ERR }
pub fn is_success(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_SUCCESS }
pub fn is_replace(s: c_int) -> bool { s & PAM_DATA_REPLACE != 0 }
// --- PAM opaque handle ---
#[repr(C)]
pub struct PamHandle { _private: [u8; 0] }
@@ -50,36 +29,16 @@ extern "C" {
) -> c_int;
}
// --- Cleanup: fires on pam_end() or when data is replaced ---
unsafe extern "C" fn ahfail_cleanup(
_pamh: *mut PamHandle,
data: *mut c_void,
error_status: c_int,
_error_status: c_int,
) {
// Reclaim any stored path string to avoid a leak (PAM guarantees this fires exactly once).
let path_override: Option<String> = if data.is_null() {
None
} else {
Some(*Box::from_raw(data as *mut String))
};
if is_replace(error_status) {
// Data replaced — a new pam_set_data call overwrote ours. Only spawn on failure;
// on success (PAM_SUCCESS | PAM_DATA_REPLACE) we do nothing.
if is_failure(error_status) {
spawn_display(path_override);
}
return;
}
if is_failure(error_status) {
spawn_display(path_override);
} else if is_success(error_status) {
kill_display();
if !data.is_null() {
drop(Box::from_raw(data as *mut String));
}
}
// --- Exported PAM module entry points ---
#[no_mangle]
pub unsafe extern "C" fn pam_sm_authenticate(
pamh: *mut PamHandle,
@@ -92,20 +51,29 @@ pub unsafe extern "C" fn pam_sm_authenticate(
let args = argc_argv(argc, argv);
let display_path = read_display_path_arg(args);
// /proc/self/comm is the name of the current process (the locker that loaded us).
let locker_name = std::fs::read_to_string("/proc/self/comm")
.map(|s| s.trim().to_string())
.unwrap_or_default();
// Never start a GTK program as root. Inside a greeter or display manager
// (lightdm and friends) this module runs in a root process; spawning the
// display there would run a whole GUI stack with root rights. Skip the effect
// and let authentication carry on untouched.
if !running_as_root() {
spawn_display(display_path.clone(), &locker_name);
}
let key = match CString::new("dk.weircon.ahfail") {
Ok(k) => k,
Err(_) => return PAM_IGNORE,
};
// Store optional path override as heap data for the cleanup function.
let path_ptr: *mut c_void = match display_path {
Some(p) => Box::into_raw(Box::new(p)) as *mut c_void,
None => std::ptr::null_mut(),
};
let ret = pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup));
if ret != PAM_SUCCESS && !path_ptr.is_null() {
// pam_set_data failed — cleanup will never fire, so free the Box ourselves.
drop(Box::from_raw(path_ptr as *mut String));
}
PAM_IGNORE
@@ -136,7 +104,10 @@ pub unsafe extern "C" fn pam_sm_chauthtok(
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
) -> c_int { PAM_IGNORE }
// --- Helpers ---
/// True if either the real or the effective uid is root.
fn running_as_root() -> bool {
unsafe { libc::getuid() == 0 || libc::geteuid() == 0 }
}
unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] {
if argv.is_null() || argc <= 0 { return &[]; }
@@ -155,40 +126,32 @@ fn read_display_path_arg(args: &[*const c_char]) -> Option<String> {
None
}
fn spawn_display(path_override: Option<String>) {
fn spawn_display(path_override: Option<String>, locker_name: &str) {
let path = path_override.unwrap_or_else(|| DEFAULT_PATH.to_string());
let cpath = match CString::new(path.as_str()) {
Ok(p) => p,
Err(_) => return,
};
// Double-fork: grandchild adopted by init, PAM stack not blocked.
let name_arg = format!("--locker-name={}", locker_name);
let cname_arg = match CString::new(name_arg.as_str()) {
Ok(p) => p,
Err(_) => return,
};
unsafe {
let pid: pid_t = libc::fork();
if pid < 0 { return; }
if pid > 0 {
// Parent: wait for intermediate to exit immediately.
libc::waitpid(pid, std::ptr::null_mut(), 0);
return;
}
// Intermediate: fork again then exit immediately.
let pid2: pid_t = libc::fork();
if pid2 != 0 { libc::_exit(0); }
// Grandchild: detach from PAM daemon's session, close all inherited fds, exec.
// Grandchild: detach and exec.
libc::setsid();
let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as c_int;
for fd in 0..max_fd.min(4096) {
libc::close(fd);
}
let args: [*const c_char; 2] = [cpath.as_ptr(), std::ptr::null()];
for fd in 0..max_fd.min(4096) { libc::close(fd); }
let args: [*const c_char; 3] = [cpath.as_ptr(), cname_arg.as_ptr(), std::ptr::null()];
libc::execv(cpath.as_ptr(), args.as_ptr());
libc::_exit(1);
}
}
fn kill_display() {
// SIGTERM all ahfail-display processes owned by the current user.
let uid = unsafe { libc::getuid() }.to_string();
let _ = std::process::Command::new("pkill")
.args(["-SIGTERM", "-u", &uid, "ahfail-display"])
.spawn();
}
-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"
+16 -4
View File
@@ -3,15 +3,16 @@ use rand::Rng;
use crate::config::ModuleConfig;
const SPRITE_MARGIN: i32 = 100;
const RETRY_ATTEMPTS: usize = 10;
// Cheap, and with only 10 tries a sprite could still land on the password
// field on a small screen.
const RETRY_ATTEMPTS: usize = 100;
pub fn place_sprite(
fixed: &gtk::Fixed,
pub fn sprite_position(
animation: &gdk_pixbuf::PixbufAnimation,
screen_w: i32,
screen_h: i32,
config: &ModuleConfig,
) -> gtk::Image {
) -> (i32, i32) {
let sprite_w = animation.width();
let sprite_h = animation.height();
@@ -34,6 +35,17 @@ pub fn place_sprite(
}
}
(x, y)
}
pub fn place_sprite(
fixed: &gtk::Fixed,
animation: &gdk_pixbuf::PixbufAnimation,
screen_w: i32,
screen_h: i32,
config: &ModuleConfig,
) -> gtk::Image {
let (x, y) = sprite_position(animation, screen_w, screen_h, config);
let image = gtk::Image::from_animation(animation);
image.show();
fixed.put(&image, x, y);
+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.2"
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
+89 -63
View File
@@ -1,94 +1,120 @@
project(
'ahfail',
['c', 'rust'],
version: '0.1.0',
version: '0.10.2',
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')
gstplayer_dep = dependency('gstreamer-player-1.0')
gnome = import('gnome')
is_linux = host_machine.system() == 'linux'
resources = gnome.compile_resources(
'ahfail-resources',
'assets/ahfail.gresource.xml',
source_dir: 'assets',
c_name: 'ahfail'
)
# 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'
# Each cargo target uses its own --target-dir to avoid parallel lock contention.
cargo_target = custom_target(
# Linux builds everything: the ahfail locker, the gtklock module (Wayland), the
# PAM hook and its display binary. macOS only has the PAM hook + display binary:
# gtklock and the locker are Linux-only, and the module's GNU-ld link flags do
# not exist on macOS.
# 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'
if is_linux
cargo_pkgs = '-p ahfail-gtklock -p ahfail-pam -p ahfail-display -p ahfail'
cargo_outputs = ['libahfail_module.a', 'libahfail_pam.so', 'ahfail-display', 'ahfail']
# module archive: linked below, not installed; PAM hook + display: libdir/ahfail; locker: bindir.
cargo_install_dirs = [false, get_option('libdir') / 'ahfail', get_option('libdir') / 'ahfail', get_option('bindir')]
cargo_copy = (' && cp "@OUTDIR@/target/release/libahfail_module.a" "@OUTPUT0@"'
+ ' && cp "@OUTDIR@/target/release/libahfail_pam.so" "@OUTPUT1@"'
+ ' && cp "@OUTDIR@/target/release/ahfail-display" "@OUTPUT2@"'
+ ' && cp "@OUTDIR@/target/release/ahfail" "@OUTPUT3@"')
else
cargo_pkgs = '-p ahfail-pam -p ahfail-display'
cargo_outputs = ['libahfail_pam.so', 'ahfail-display']
cargo_install_dirs = [get_option('libdir') / 'ahfail', get_option('libdir') / 'ahfail']
cargo_copy = (' && cp "@OUTDIR@/target/release/' + pam_built + '" "@OUTPUT0@"'
+ ' && cp "@OUTDIR@/target/release/ahfail-display" "@OUTPUT1@"')
endif
cargo_all = custom_target(
'ahfail-cargo-build',
input: ['crates/ahfail-gtklock/src/lib.rs', 'Cargo.toml'],
output: ['libahfail_module.a'],
command: [
'sh', '-c', 'cargo build --release -p ahfail-gtklock --target-dir "@OUTDIR@/target-gtklock" && cp "@OUTDIR@/target-gtklock/release/libahfail_module.a" "@OUTPUT@"'
],
build_by_default: true
)
# PAM module (.so with C ABI — no GResources needed, Cargo self-contained via build.rs)
pam_cargo = custom_target(
'ahfail-pam-cargo-build',
input: ['crates/ahfail-pam/src/lib.rs', 'Cargo.toml'],
output: ['libahfail_pam.so'],
input: ['Cargo.toml', 'Cargo.lock'],
output: cargo_outputs,
command: [
'sh', '-c',
# Pass libdir so build.rs emits the correct AHFAIL_INSTALL_DIR (needed for multiarch).
'AHFAIL_LIBDIR=' + get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"'
# The PAM module's build.rs needs the absolute libdir to know where
# ahfail-display is installed; get_option('libdir') alone is relative.
'AHFAIL_LIBDIR=' + get_option('prefix') / get_option('libdir')
+ ' cargo build --release --locked'
+ ' ' + cargo_pkgs + ' ' + cargo_features
+ ' --target-dir "@OUTDIR@/target"'
+ cargo_copy
],
build_by_default: true,
build_always_stale: true, # cargo decides what is out of date, not meson
install: true,
install_dir: get_option('libdir') / 'ahfail',
install_dir: cargo_install_dirs,
install_mode: 'rwxr-xr-x'
)
pam_cargo = is_linux ? cargo_all[1] : cargo_all[0]
# 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',
resources,
link_args: ['-Wl,--whole-archive', meson.current_build_dir() / 'libahfail_module.a', '-Wl,--no-whole-archive'],
link_depends: cargo_target,
include_directories: ahfail_inc,
dependencies: [gtk_dep, gstplayer_dep, m_dep],
install: true,
install_dir: get_option('libdir') / 'gtklock',
name_prefix: ''
)
if is_linux
m_dep = cc.find_library('m', required : false)
gtk_dep = dependency('gtk+-3.0')
gstplayer_dep = dependency('gstreamer-player-1.0')
resources = import('gnome').compile_resources(
'ahfail-resources',
'assets/ahfail.gresource.xml',
source_dir: 'assets',
c_name: 'ahfail'
)
smoke = executable(
'module_smoke_test',
'tests/module_test.c',
include_directories: ahfail_inc,
link_with: libahfail,
dependencies: [gtk_dep, gstplayer_dep]
)
# The gtklock module (Wayland): the Rust staticlib linked whole into a .so.
libahfail = shared_library(
'ahfail-module',
resources,
link_args: ['-Wl,--whole-archive', meson.current_build_dir() / 'libahfail_module.a', '-Wl,--no-whole-archive'],
link_depends: cargo_all[0],
include_directories: ahfail_inc,
dependencies: [gtk_dep, gstplayer_dep, m_dep],
install: true,
install_dir: get_option('libdir') / 'gtklock',
name_prefix: ''
)
test('module symbols', smoke)
smoke = executable(
'module_smoke_test',
'tests/module_test.c',
include_directories: ahfail_inc,
link_with: libahfail,
dependencies: [gtk_dep, gstplayer_dep]
)
test('module symbols', smoke)
endif
# PAM module smoke test: dlopen libahfail_pam.so and verify pam_sm_authenticate is exported.
pam_smoke = executable(
'pam_smoke_test',
'tests/pam_smoke_test.c',
dependencies: [cc.find_library('dl', required: true)]
dependencies: [cc.find_library('dl', required: false)] # part of libSystem on macOS
)
test('pam symbols', pam_smoke,
+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
}
+112 -72
View File
@@ -1,111 +1,151 @@
# Ah ah ah, you didn't say the magic word
This `gtklock` module listens for failed unlock attempts and recreates Dennis Nedry’s “ah ah ah” lockout scene from Jurassic Park.
* **Animation:** Spawns a looping "Nedry" sprite at a random location on the screen.
* **Audio:** Plays the "ah ah ah, you didn't say the magic word" clip.
* **Safety:** Sprites avoid overlapping a configurable "deadzone" (e.g., your login box).
* **Performance:** Uses pre-warmed audio players for low latency
## Requirements
* **Build:** Meson, Ninja, Rust (Cargo), GTK+3 development headers.
* **Runtime:** `gtklock`, `gstreamer`, `gst-plugins-base`, `gst-plugins-good` (for audio playback).
## Build & Install
1. **Install Dependencies:**
```bash
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good
```
2. **Build:**
```bash
meson setup builddir
meson compile -C builddir
```
3. **Install:**
```bash
sudo meson install -C builddir
```
## Usage
Run `gtklock` with the module path:
`ahfail` is a screen locker. Get the password wrong and a looping animation of **the author's face photoshopped onto Dennis Nedry's body** appears on screen while the "ah ah ah, you didn't say the magic word" clip from Jurassic Park plays. Every wrong guess adds another Nedry. The volume is forced to 100% on the first failure and restored when you unlock.
```bash
gtklock -m /usr/lib/gtklock/ahfail-module.so
ahfail # lock the screen
ahfail test # show the effect once, without locking anything
ahfail doctor # check this machine and explain what is missing
```
### Arguments
One command on Linux: `ahfail` works out whether you run Wayland or X11 and does the right thing. On macOS the effect comes from a PAM hook on the system lock screen (see below).
* `--deadzone=X,Y,W,H`: Defines a rectangle where sprites will *not* spawn (e.g., to keep your password field visible).
```bash
gtklock -m ahfail-module.so -- --deadzone=860,440,200,200
```
*(Note the `--` separator before module arguments)*
---
* `--audio-uri=URI`: Override the default audio clip.
```bash
gtklock -m ahfail-module.so -- --audio-uri=file:///home/user/custom.mp3
```
> **Security disclaimer**
>
> This is a screen locker, so it sits directly in the authentication path. A bug in it could lock you out of your session or, in the worst case, weaken the lock.
>
> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are running a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
## macOS (build from source)
---
### Prerequisites
## Install
### Arch Linux
```bash
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja rust
yay -S ahfail
```
### Build
(Once it is published on the AUR.) The package installs the `ahfail` binary and its own PAM service file, `/etc/pam.d/ahfail`. Nothing in any other package's PAM configuration is touched, the same way swaylock and gtklock ship theirs.
### From source
```bash
meson setup builddir
sudo pacman -S meson ninja rust gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good pam
meson setup builddir --prefix=/usr
meson compile -C builddir
sudo meson install -C builddir
```
Produces:
- `builddir/ahfail-module.so` — gtklock module (Wayland/Linux only)
- `builddir/libahfail_pam.so` — PAM module (macOS + X11 Linux)
- `builddir/ahfail-display` — display binary (spawned by PAM module)
`--prefix=/usr` matches Arch conventions and puts the PAM file where PAM looks for it.
### Install
Then run `ahfail doctor`. It tells you whether the session type is supported, whether `/etc/pam.d/ahfail` is in place, whether GStreamer can play the clip and whether the volume can be controlled, with the exact fix for anything that is missing.
---
## Platform support
| Platform | How it locks | Notes |
|---|---|---|
| Linux, **Wayland** | ext-session-lock-v1 (through gtk-session-lock) | sway, Hyprland, niri, river, labwc and KDE Plasma 6. **Not GNOME**: GNOME does not let third-party programs lock the screen. |
| Linux, **X11** | full-screen windows on every monitor plus a keyboard and pointer grab | The i3lock model. Works with any X11 window manager. |
| **macOS** | the system lock screen | Lock the Mac as usual; the effect comes from the PAM hook installed via Homebrew (see below). The `ahfail` command itself is Linux only for now. |
## Locking automatically
Point your idle daemon at `ahfail`. Running it while already locked is harmless: a second instance exits quietly.
```bash
sudo mkdir -p /usr/local/lib/ahfail
sudo cp builddir/libahfail_pam.so /usr/local/lib/ahfail/
sudo cp builddir/ahfail-display /usr/local/lib/ahfail/
# Wayland
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
# X11
xss-lock -- ahfail
```
### Configure PAM (macOS)
Pass `--no-max-volume` to leave the volume alone.
Find your screen locker's PAM service file. On macOS 13+ the screensaver uses `/etc/pam.d/screensaverui`; on older versions it may be `/etc/pam.d/screensaver`. Add the following line after the existing `auth` entries (requires `sudo`):
## Security
```
auth optional /usr/local/lib/ahfail/libahfail_pam.so
- **Wayland:** once the compositor confirms the lock, only the lock surfaces are shown. If `ahfail` crashes or is killed, the session **stays locked** (the compositor shows its own fallback), it never opens up.
- **X11:** the lock is the input grab. If the grab cannot be taken (a menu or another program holds it), `ahfail` refuses to lock rather than show a lock screen your keystrokes pass through. Like i3lock, nothing on X11 keeps the screen locked if the locker process dies.
- `ahfail` refuses to run as root, and the password is checked in a separate thread through PAM service `ahfail` and wiped from memory afterwards.
- **Too many wrong passwords can lock your account.** `/etc/pam.d/ahfail` includes `login`, so the system's `pam_faillock` policy applies. On Arch that means 3 wrong passwords within 15 minutes lock the account for 10 minutes (see `/etc/security/faillock.conf`). That is the system's rule, not ahfail's, and every PAM-based locker behaves the same, but it is worth knowing before you invite colleagues to try their luck.
---
## macOS
### Homebrew (recommended)
```bash
brew tap asger-weirsoee/tap
brew install ahfail
```
### Configure PAM (Linux/X11)
After install, Homebrew prints the PAM line to add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` (macOS 12 and earlier). That one-line edit is the only manual step. Enable "Require password immediately" in System Settings so the lock screen asks for the password straight away.
Add to `/etc/pam.d/gtklock` (or `i3lock`, `xscreensaver`, etc.). Use the full path because `$(libdir)/ahfail` is not in PAM's default search path:
To upgrade: `brew upgrade ahfail`. To uninstall: `sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui` (or `screensaver`), then `brew uninstall ahfail`.
`bash scripts/install-macos.sh` and `bash scripts/uninstall-macos.sh` do the same from a source checkout.
---
## Legacy integrations
Before `ahfail` became its own locker it hooked into other lockers. These still work and are still built, for anyone who wants to keep their current locker.
| File | Used by |
|---|---|
| `ahfail-module.so` | a module for **gtklock** (Wayland) |
| `libahfail_pam.so` + `ahfail-display` | a PAM hook for **X11 lockers** (i3lock, i3lock-color, betterlockscreen, xscreensaver, lightdm) |
**gtklock:**
```bash
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
```
`--deadzone=X,Y,W,H` keeps sprites away from your password field, `--audio-uri=URI` swaps the clip.
**X11 PAM hook:** add this line to the locker's PAM file (for example `/etc/pam.d/i3lock`) **before** `auth include system-auth`. After it, `pam_faillock`'s `[default=die]` inside `system-auth` means the module is never reached on a failed attempt.
```
#%PAM-1.0
auth optional /usr/lib/ahfail/libahfail_pam.so
auth include system-auth
```
On Fedora/RHEL replace `/usr/lib` with `/usr/lib64`.
Fedora and RHEL use `/usr/lib64/ahfail/`, Debian and Ubuntu `/usr/lib/x86_64-linux-gnu/ahfail/`; the full path is required. Add `display_path=/path/to/ahfail-display` if the display binary lives elsewhere. The hook never starts the display from a root process (a display manager, for example), so it cannot affect a login screen. `bash scripts/uninstall-linux-x11.sh` removes the line and the files again.
## Development
Pre-built binaries of the legacy files are on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases).
* **Run Tests:** `cargo test`
* **Benchmarks:** `cargo test --test benchmarks -- --nocapture`
* **Linting:** `cargo clippy`
On swaylock and hyprlock the PAM hook fires, but the compositor hides everything except the locker itself, so only the audio plays. Use `ahfail` instead.
---
## Customization
To change the default sprite or audio:
1. Replace files in `assets/`.
2. Update `assets/ahfail.gresource.xml`.
3. Rebuild with Meson.
The sprite is the author's face on Nedry's body. To use your own:
1. Replace the sprite frames in `assets/` with your own PNG sequence.
2. Update `assets/ahfail.gresource.xml` if you add or remove files.
3. Rebuild with Meson.
To replace the audio clip, swap `assets/magic-word.mp3` and rebuild.
---
## Development
```bash
cargo test # unit + integration tests (some need a display)
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
cargo clippy # lint
meson setup builddir && meson compile -C builddir # full build including the .so files
xvfb-run meson test -C builddir --verbose # Meson tests headless
```
The update check (a daily look at the releases page with a desktop notification) is a cargo feature, `update-check`, on by default. Distro packages build without it, since the package manager handles updates there.
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# install-macos.sh — build and install ahfail on macOS
# Run from the repository root: bash scripts/install-macos.sh
set -euo pipefail
INSTALL_DIR="/usr/local/lib/ahfail"
BOLD=$(tput bold 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
step() { echo "${BOLD}==> $*${RESET}"; }
die() { echo "ERROR: $*" >&2; exit 1; }
[[ "$(uname)" == "Darwin" ]] || die "This script is for macOS only."
# ── 1. Homebrew ───────────────────────────────────────────────────────────────
if ! command -v brew &>/dev/null; then
die "Homebrew is required. Install it from https://brew.sh then re-run this script."
fi
step "Installing Homebrew dependencies..."
brew install --quiet gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja
# ── 2. Rust ───────────────────────────────────────────────────────────────────
if ! command -v cargo &>/dev/null; then
step "Installing Rust via rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
# shellcheck source=/dev/null
source "$HOME/.cargo/env"
fi
# ── 3. Build ──────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "$PROJECT_DIR"
step "Building ahfail..."
MESON_FLAGS=("--buildtype=release" "-Dlibdir=${INSTALL_DIR%/ahfail}")
if [[ -d builddir ]]; then
meson setup builddir "${MESON_FLAGS[@]}" --wipe
else
meson setup builddir "${MESON_FLAGS[@]}"
fi
meson compile -C builddir
# ── 4. Install binaries ───────────────────────────────────────────────────────
step "Installing binaries to ${INSTALL_DIR}/ (requires sudo)..."
sudo mkdir -p "$INSTALL_DIR"
sudo cp builddir/libahfail_pam.so "$INSTALL_DIR/"
sudo cp builddir/ahfail-display "$INSTALL_DIR/"
sudo chmod 755 "$INSTALL_DIR/libahfail_pam.so" "$INSTALL_DIR/ahfail-display"
# ── 5. Configure PAM ─────────────────────────────────────────────────────────
step "Configuring PAM..."
# macOS 13+ uses screensaverui; 12 and earlier uses screensaver
if [[ -f /etc/pam.d/screensaverui ]]; then
PAM_FILE="/etc/pam.d/screensaverui"
elif [[ -f /etc/pam.d/screensaver ]]; then
PAM_FILE="/etc/pam.d/screensaver"
else
echo ""
echo " Could not find a screensaver PAM file. Add this line manually"
echo " to the appropriate file in /etc/pam.d/ (after the auth entries):"
echo ""
echo " auth optional ${INSTALL_DIR}/libahfail_pam.so"
echo ""
echo "Done (PAM not configured automatically)."
exit 0
fi
PAM_LINE="auth optional ${INSTALL_DIR}/libahfail_pam.so"
if grep -qF "$PAM_LINE" "$PAM_FILE"; then
echo " PAM already configured in ${PAM_FILE}."
else
# Insert after the last 'auth' line so we stay in the auth block.
sudo python3 - "$PAM_FILE" "$PAM_LINE" <<'PYEOF'
import sys
pam_file, new_line = sys.argv[1], sys.argv[2] + "\n"
lines = open(pam_file).readlines()
if new_line in lines:
sys.exit(0)
# Find the last line that starts with 'auth'
last_auth = max(
(i for i, l in enumerate(lines) if l.strip().startswith("auth")),
default=len(lines) - 1,
)
lines.insert(last_auth + 1, new_line)
open(pam_file, "w").writelines(lines)
PYEOF
echo " Added to ${PAM_FILE}."
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
echo "${BOLD}Done.${RESET} Lock your screen and type the wrong password to test."
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# uninstall-linux-x11.sh — remove ahfail PAM module from X11 screen lockers
# Run from the repository root (uses ninja uninstall if builddir is present).
set -euo pipefail
BOLD=$(tput bold 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
step() { echo "${BOLD}==> $*${RESET}"; }
[[ "$(uname)" == "Linux" ]] || { echo "This script is for Linux only." >&2; exit 1; }
# ── 1. Remove PAM config lines ────────────────────────────────────────────────
step "Removing ahfail from PAM service files..."
removed_pam=0
for pam_file in /etc/pam.d/i3lock /etc/pam.d/xscreensaver /etc/pam.d/lightdm \
/etc/pam.d/gdm /etc/pam.d/sddm /etc/pam.d/gtklock; do
if [[ -f "$pam_file" ]] && grep -q "ahfail" "$pam_file"; then
echo " Patching ${pam_file}..."
sudo sed -i '/ahfail/d' "$pam_file"
removed_pam=1
fi
done
[[ $removed_pam -eq 0 ]] && echo " No ahfail PAM entries found in common service files."
# ── 2. Remove installed files ─────────────────────────────────────────────────
step "Removing installed files..."
# Prefer meson's own uninstall if the builddir is still present
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILDDIR="${SCRIPT_DIR}/../builddir"
if [[ -f "${BUILDDIR}/build.ninja" ]]; then
echo " Running meson uninstall..."
sudo ninja -C "$BUILDDIR" uninstall
else
echo " builddir not found — removing known paths manually..."
for dir in /usr/lib/ahfail /usr/local/lib/ahfail \
/usr/lib64/ahfail /usr/lib/x86_64-linux-gnu/ahfail; do
if [[ -d "$dir" ]]; then
sudo rm -rf "$dir"
echo " Removed ${dir}."
fi
done
for f in /usr/lib/gtklock/ahfail-module.so \
/usr/local/lib/gtklock/ahfail-module.so; do
if [[ -f "$f" ]]; then
sudo rm -f "$f"
echo " Removed ${f}."
fi
done
fi
echo ""
echo "${BOLD}Done.${RESET}"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# uninstall-macos.sh — remove ahfail from macOS (manual/script install)
# For Homebrew installs: brew uninstall ahfail, then run this to clean up PAM.
set -euo pipefail
INSTALL_DIR="/usr/local/lib/ahfail"
BOLD=$(tput bold 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
step() { echo "${BOLD}==> $*${RESET}"; }
[[ "$(uname)" == "Darwin" ]] || { echo "This script is for macOS only." >&2; exit 1; }
# ── 1. Remove PAM config line ─────────────────────────────────────────────────
step "Removing ahfail from PAM configuration..."
removed_pam=0
for pam_file in /etc/pam.d/screensaverui /etc/pam.d/screensaver; do
if [[ -f "$pam_file" ]] && grep -q "ahfail" "$pam_file"; then
echo " Patching ${pam_file}..."
sudo sed -i '' '/ahfail/d' "$pam_file"
removed_pam=1
fi
done
[[ $removed_pam -eq 0 ]] && echo " No ahfail PAM entries found."
# ── 2. Remove binaries ────────────────────────────────────────────────────────
step "Removing binaries..."
if [[ -d "$INSTALL_DIR" ]]; then
sudo rm -rf "$INSTALL_DIR"
echo " Removed ${INSTALL_DIR}."
else
echo " ${INSTALL_DIR} not found — skipping."
fi
echo ""
echo "${BOLD}Done.${RESET}"