Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5e1bd5016 | ||
|
|
198bbb2278 | ||
|
|
8b777652be | ||
|
|
1934b86c13 | ||
|
|
a1a9a741cc | ||
|
|
9538cefbf4 | ||
|
|
b71eb201b6 | ||
|
|
2cf3b06aeb | ||
|
|
1156c1f927 | ||
|
|
cb41117932 | ||
|
|
79bb0e41d5 | ||
|
|
a4b6297156 | ||
|
|
37e6c36aee |
@@ -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
|
||||
@@ -8,25 +8,14 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# Built on Arch: the locker needs gtk-session-lock (not packaged by Ubuntu).
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgstreamer-plugins-bad1.0-dev \
|
||||
gstreamer1.0-plugins-good \
|
||||
libpam0g-dev \
|
||||
ninja-build \
|
||||
meson \
|
||||
libglib2.0-dev
|
||||
run: pacman -Syu --noconfirm --needed base-devel git openssh nodejs rust meson ninja gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad-libs pam curl python
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute source tarball SHA256
|
||||
run: |
|
||||
@@ -46,33 +35,40 @@ jobs:
|
||||
cp builddir/ahfail-module.so dist/
|
||||
cp builddir/libahfail_pam.so dist/
|
||||
cp builddir/ahfail-display dist/
|
||||
cp builddir/ahfail dist/
|
||||
cp data/pam.d/ahfail dist/ahfail.pam
|
||||
tar czf ahfail-linux-x86_64.tar.gz -C dist .
|
||||
|
||||
- name: Upload release asset
|
||||
# Requires a GITEA_TOKEN secret with write:repository scope.
|
||||
# Create it in repo Settings → Secrets.
|
||||
# RELEASE_TOKEN: an agw token with write:repository scope. Gitea refuses
|
||||
# secret names that start with GITEA_ or GITHUB_, so it cannot be called
|
||||
# GITEA_TOKEN.
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
server_url: ${{ github.server_url }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
files: ahfail-linux-x86_64.tar.gz
|
||||
|
||||
- name: Update Homebrew tap
|
||||
# The tap lives on GitHub (asger-weirsoee/homebrew-tap): Homebrew cannot
|
||||
# clone from this Gitea, where git over HTTP is disabled. The deploy key
|
||||
# can write only to that one repository; the tap's Actions then build
|
||||
# and test the formula on a real Mac.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone \
|
||||
"https://oauth2:${GITEA_TOKEN}@gitea.weircon.dk/agw/homebrew-ahfail.git" \
|
||||
tap
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > ~/.ssh/tap
|
||||
chmod 600 ~/.ssh/tap
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/tap -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
|
||||
git clone -q git@github.com:asger-weirsoee/homebrew-tap.git tap
|
||||
cd tap
|
||||
sed -i "s|url \".*\"|url \"https://gitea.weircon.dk/agw/gtk-ahfail/archive/${TAG}.tar.gz\"|" Formula/ahfail.rb
|
||||
sed -i "s|sha256 \".*\"|sha256 \"${SOURCE_SHA256}\"|" Formula/ahfail.rb
|
||||
git config user.email "actions@weircon.dk"
|
||||
git config user.name "Gitea Actions"
|
||||
git add Formula/ahfail.rb
|
||||
git diff --cached --quiet || git commit -m "chore: update ahfail to ${TAG}"
|
||||
git push
|
||||
URL="https://gitea.weircon.dk/agw/gtk-ahfail/archive/${TAG}.tar.gz"
|
||||
sed -i "s|^ url \".*\"| url \"$URL\"|; s|^ sha256 \".*\"| sha256 \"${SOURCE_SHA256}\"|" Formula/ahfail.rb
|
||||
if git diff --quiet; then echo "formula already up to date"; exit 0; fi
|
||||
git -c user.name="Asger Geel Weirsøe" -c user.email="github@weircon.dk" commit -q -am "ahfail ${TAG#v}"
|
||||
git push -q origin HEAD
|
||||
|
||||
@@ -7,26 +7,15 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# Arch, because the locker needs gtk-session-lock, which Ubuntu does not
|
||||
# package; it is also the platform the AUR package is built on.
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgstreamer-plugins-bad1.0-dev \
|
||||
gstreamer1.0-plugins-good \
|
||||
libpam0g-dev \
|
||||
ninja-build \
|
||||
meson \
|
||||
libglib2.0-dev \
|
||||
xvfb
|
||||
run: pacman -Syu --noconfirm --needed base-devel git openssh nodejs rust meson ninja gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad-libs pam xorg-server-xvfb
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Rust tests
|
||||
run: xvfb-run cargo test
|
||||
|
||||
@@ -4,66 +4,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What This Is
|
||||
|
||||
`ahfail` is a [`gtklock`](https://github.com/jovanlanik/gtklock) module. It compiles to `ahfail-module.so`, which gtklock loads at runtime. On each failed unlock attempt (`PW_FAILURE`), it spawns an animated "Nedry" sprite at a random screen location and plays an audio clip ("ah ah ah, you didn't say the magic word").
|
||||
`ahfail` is a screen locker. On every failed unlock it spawns an animated "Nedry" sprite at a random screen position and plays "ah ah ah, you didn't say the magic word". On Linux the `ahfail` binary is itself the locker (Wayland via ext-session-lock-v1, X11 via an input grab) and authenticates through its own PAM service, `/etc/pam.d/ahfail` (`data/pam.d/ahfail`). On macOS the system lock screen is used and the effect comes from the PAM hook.
|
||||
|
||||
## Build System
|
||||
The older integrations, a gtklock module and a PAM hook for other X11 lockers, are still built as "legacy integrations".
|
||||
|
||||
The build is a two-stage hybrid:
|
||||
## Workspace
|
||||
|
||||
1. **Meson** compiles GResources from `assets/ahfail.gresource.xml` into C, invokes Cargo to produce `libahfail_module.a`, then links everything into `ahfail-module.so`.
|
||||
2. **Cargo** handles only the Rust crate — it produces a `staticlib` that Meson links.
|
||||
Five crates under `crates/`:
|
||||
|
||||
Always use Meson for the final shared object; `cargo build` alone does not produce the loadable module.
|
||||
| Crate | Output | Responsibility |
|
||||
|---|---|---|
|
||||
| `ahfail` | `ahfail` binary | The CLI and the locker: `lock` (default), `test` (preview, no lock), `doctor` (environment check). `wayland.rs` (gtk-session-lock FFI), `x11.rs` (seat grab), `locker.rs` (shared lock UI), `pam.rs` (PAM client, service `ahfail`). |
|
||||
| `ahfail-ui` | rlib | Shared by all: sprite animation from GResources, sprite placement with deadzone, audio player, volume save/restore, optional update check (cargo feature `update-check`, default on). |
|
||||
| `ahfail-gtklock` | `libahfail_module.a` → `ahfail-module.so` | Legacy gtklock module. Meson links the staticlib with the GResources into the loadable `.so`. |
|
||||
| `ahfail-pam` | `libahfail_pam.so` | Legacy PAM hook for X11 lockers and macOS. Spawns `ahfail-display` on a failed attempt, never from a root process. Always returns `PAM_IGNORE`. |
|
||||
| `ahfail-display` | `ahfail-display` binary | Legacy standalone sprite window spawned by the PAM hook. Refuses to run as root; single-instance lock in `$XDG_RUNTIME_DIR`. |
|
||||
|
||||
Assets (`assets/*.png`, `assets/magic-word.mp3`) are embedded as GResources, via Meson for the gtklock module and via `build.rs` for the binaries, and read at runtime as `resource:///ahfail/sprites/...` and `resource:///ahfail/audio/magic-word.mp3`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# First-time setup
|
||||
meson setup builddir
|
||||
|
||||
# Build
|
||||
meson setup builddir --prefix=/usr # --prefix=/usr for Arch paths
|
||||
meson compile -C builddir
|
||||
sudo meson install -C builddir
|
||||
|
||||
# Run Rust tests only (no GTK display required)
|
||||
cargo test
|
||||
|
||||
# Run Meson tests (loads the .so, requires GTK)
|
||||
meson test -C builddir
|
||||
|
||||
# Manual integration test
|
||||
gtklock -d -m builddir/ahfail-module.so -- --deadzone=X,Y,W,H
|
||||
cargo build --release -p ahfail # just the locker
|
||||
cargo build --release -p ahfail-display --no-default-features # without the update check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
Always use Meson for `ahfail-module.so`: `cargo build` alone produces only the staticlib.
|
||||
|
||||
All FFI entry points live in `src/lib.rs` — these are the `extern "C"` functions that gtklock calls (`on_activation`, `on_window_create`, `on_window_destroy`, `on_idle_hide`, etc.).
|
||||
|
||||
**Module lifecycle:**
|
||||
- `on_activation` — called once at startup; initialises GTK/GStreamer, loads all sprite frames into a `PixbufSimpleAnim`, stores them in `MODULE_STATE`.
|
||||
- `on_window_create` — called per monitor; calls `WindowHandler::create`, which creates a `gtk::Fixed` overlay, a pool of pre-warmed GStreamer players, and wires up a signal on `error_label` that fires on each failed attempt.
|
||||
- `on_window_destroy` / `on_idle_hide` — clean up sprites and stop players.
|
||||
|
||||
**Key types:**
|
||||
|
||||
| Type | File | Purpose |
|
||||
|------|------|---------|
|
||||
| `MODULE_STATE` | `src/state.rs` | Thread-local `RefCell<ModuleState>` holding the shared animation, audio URI, and deadzone config |
|
||||
| `WindowData` | `src/state.rs` | Heap-allocated per-window state (sprites, player pool, GTK signal handle) |
|
||||
| `WindowContext` | `src/context.rs` | Safe wrapper around the raw `*mut Window` pointer passed from C |
|
||||
| `Window` / `GtkLock` | `src/context.rs` | `#[repr(C)]` mirrors of gtklock's internal structs — must match `include/gtklock-module.h` |
|
||||
| `WindowHandler` | `src/handler.rs` | Sprite placement logic (random position, deadzone avoidance with retries) and GStreamer player management |
|
||||
| `ModuleConfig` | `src/config.rs` | Parses the `--deadzone=x,y,w,h` CLI argument via glib's `GOptionEntry` |
|
||||
|
||||
**Assets** are embedded as GResources at build time. At runtime they are accessed via `resource:///ahfail/sprites/...` and `resource:///ahfail/audio/magic-word.mp3`.
|
||||
|
||||
## Safety Conventions
|
||||
|
||||
- All `unsafe` pointer work on `*mut Window` goes through `WindowContext` — never dereference the raw pointer outside that wrapper.
|
||||
- `WindowData` is heap-allocated via `Box::into_raw` and reclaimed via `Box::from_raw` in `WindowContext::take_data`. Do not free it any other way.
|
||||
- The `module_data` flexible array field on `Window` is a C ABI contract with gtklock — index 0 is this module's slot.
|
||||
Linux build dependencies: gtk3, gtk-session-lock, gstreamer (+ base/good plugins), pam, meson, ninja, rust.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/ahfail_tests.rs` — integration tests that construct mock `Window` / `GtkLock` structs directly to exercise handler logic without a running gtklock process. Run with `cargo test`.
|
||||
```bash
|
||||
cargo test # unit + integration (some need a display: use xvfb-run)
|
||||
xvfb-run meson test -C builddir --verbose # smoke tests: module symbols, PAM module
|
||||
```
|
||||
|
||||
`tests/benchmarks.rs` — criterion benchmarks for sprite placement.
|
||||
- `crates/ahfail-gtklock/tests/`: integration tests that build mock `Window`/`GtkLock` structs.
|
||||
- `tests/module_test.c`, `tests/pam_smoke_test.c`: C smoke tests built by Meson.
|
||||
- The locker itself is tested headless: X11 under Xvfb with `xdotool`, Wayland under `sway` with `WLR_BACKENDS=headless` and `wtype`.
|
||||
|
||||
`tests/module_test.c` — C smoke test built by Meson that dlopen-style verifies the exported symbols exist.
|
||||
## Safety Conventions
|
||||
|
||||
- `ahfail` and `ahfail-display` must never run as root; the PAM hook must never spawn anything from a root process.
|
||||
- The X11 lock is only the grab: if it cannot be taken, refuse to lock. Never show a lock screen without it.
|
||||
- The password lives in `pam::Secret`, which zeroes itself on drop, and PAM runs off the GTK thread.
|
||||
- gtklock module: all `unsafe` work on `*mut Window` goes through `WindowContext`; `WindowData` is created with `Box::into_raw` and reclaimed only in `WindowContext::take_data`. The `#[repr(C)]` structs must match `include/gtklock-module.h`.
|
||||
|
||||
Generated
+15
@@ -8,6 +8,21 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahfail"
|
||||
version = "0.10.2"
|
||||
dependencies = [
|
||||
"ahfail-ui",
|
||||
"cc",
|
||||
"gdk",
|
||||
"glib",
|
||||
"gstreamer",
|
||||
"gstreamer-player",
|
||||
"gtk",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahfail-display"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -4,5 +4,6 @@ members = [
|
||||
"crates/ahfail-gtklock",
|
||||
"crates/ahfail-pam",
|
||||
"crates/ahfail-display",
|
||||
"crates/ahfail",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -7,7 +7,7 @@ edition = "2021"
|
||||
name = "ahfail-display"
|
||||
|
||||
[dependencies]
|
||||
ahfail-ui = { path = "../ahfail-ui" }
|
||||
ahfail-ui = { path = "../ahfail-ui", default-features = false }
|
||||
gtk = { version = "0.15", package = "gtk", features = ["v3_24"] }
|
||||
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
|
||||
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
|
||||
@@ -15,6 +15,10 @@ glib = { version = "0.15", package = "glib" }
|
||||
cairo = { version = "0.15", package = "cairo-rs" }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["update-check"]
|
||||
update-check = ["ahfail-ui/update-check"]
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
pkg-config = "0.3"
|
||||
|
||||
@@ -78,15 +78,30 @@ mod x11_grab {
|
||||
}
|
||||
}
|
||||
|
||||
fn display_lock_path() -> std::path::PathBuf {
|
||||
match std::env::var("XDG_RUNTIME_DIR") {
|
||||
Ok(dir) if !dir.is_empty() => std::path::Path::new(&dir).join("ahfail-display.lock"),
|
||||
_ => std::path::PathBuf::from(format!("/tmp/ahfail-display-{}.lock", unsafe { libc::getuid() })),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
unsafe {
|
||||
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
|
||||
}
|
||||
|
||||
// Single-instance guard
|
||||
// Never run as root (the PAM module refuses to spawn us there, but the
|
||||
// binary can also be started by hand).
|
||||
if unsafe { libc::getuid() == 0 || libc::geteuid() == 0 } {
|
||||
eprintln!("[ahfail] ahfail-display refuses to run as root");
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-instance guard, per user: a fixed name in the shared /tmp could be
|
||||
// pre-created by another user to block us.
|
||||
let lock_file = std::fs::OpenOptions::new()
|
||||
.create(true).write(true)
|
||||
.open("/tmp/ahfail-display.lock");
|
||||
.open(display_lock_path());
|
||||
let lock_file = match lock_file {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -56,7 +56,13 @@ pub unsafe extern "C" fn pam_sm_authenticate(
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
spawn_display(display_path.clone(), &locker_name);
|
||||
// Never start a GTK program as root. Inside a greeter or display manager
|
||||
// (lightdm and friends) this module runs in a root process; spawning the
|
||||
// display there would run a whole GUI stack with root rights. Skip the effect
|
||||
// and let authentication carry on untouched.
|
||||
if !running_as_root() {
|
||||
spawn_display(display_path.clone(), &locker_name);
|
||||
}
|
||||
|
||||
let key = match CString::new("dk.weircon.ahfail") {
|
||||
Ok(k) => k,
|
||||
@@ -98,6 +104,11 @@ pub unsafe extern "C" fn pam_sm_chauthtok(
|
||||
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
|
||||
) -> c_int { PAM_IGNORE }
|
||||
|
||||
/// True if either the real or the effective uid is root.
|
||||
fn running_as_root() -> bool {
|
||||
unsafe { libc::getuid() == 0 || libc::geteuid() == 0 }
|
||||
}
|
||||
|
||||
unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] {
|
||||
if argv.is_null() || argc <= 0 { return &[]; }
|
||||
std::slice::from_raw_parts(argv, argc as usize)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
use ahfail_pam::{is_failure, is_success, is_replace, PAM_AUTH_ERR, PAM_SUCCESS, PAM_DATA_REPLACE};
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(is_failure(PAM_AUTH_ERR));
|
||||
assert!(is_success(PAM_SUCCESS));
|
||||
assert!(!is_failure(PAM_SUCCESS));
|
||||
assert!(!is_success(PAM_AUTH_ERR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_flag_detected() {
|
||||
let replace_status = PAM_AUTH_ERR | PAM_DATA_REPLACE;
|
||||
assert!(is_replace(replace_status));
|
||||
assert!(!is_replace(PAM_SUCCESS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_default_is_set() {
|
||||
assert!(!ahfail_pam::default_display_path().is_empty());
|
||||
}
|
||||
@@ -16,8 +16,15 @@ glib = { version = "0.15", package = "glib" }
|
||||
gio = { version = "0.15", package = "gio" }
|
||||
gdk-pixbuf = "0.15"
|
||||
rand = "0.8"
|
||||
ureq = "2"
|
||||
ureq = { version = "2", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
# Checks Gitea for a newer release once a day and shows a desktop notification.
|
||||
# Distro packages build without it (--no-default-features): there the package
|
||||
# manager handles updates, and a locker should not phone home.
|
||||
default = ["update-check"]
|
||||
update-check = ["dep:ureq"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -3,7 +3,9 @@ use rand::Rng;
|
||||
use crate::config::ModuleConfig;
|
||||
|
||||
const SPRITE_MARGIN: i32 = 100;
|
||||
const RETRY_ATTEMPTS: usize = 10;
|
||||
// Cheap, and with only 10 tries a sprite could still land on the password
|
||||
// field on a small screen.
|
||||
const RETRY_ATTEMPTS: usize = 100;
|
||||
|
||||
pub fn sprite_position(
|
||||
animation: &gdk_pixbuf::PixbufAnimation,
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
//! Optional update check (cargo feature `update-check`, on by default).
|
||||
//! Without the feature `check_for_update` is a no-op and nothing touches the
|
||||
//! network, which is how distro packages are built.
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::path::PathBuf;
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::process::Command;
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::time::SystemTime;
|
||||
#[cfg(feature = "update-check")]
|
||||
use std::fs;
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
const GITEA_API: &str =
|
||||
"https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest";
|
||||
#[cfg(feature = "update-check")]
|
||||
const UPDATE_NOTIFY_MSG: &str =
|
||||
"Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases";
|
||||
#[cfg(feature = "update-check")]
|
||||
const CHECK_INTERVAL_SECS: u64 = 86_400;
|
||||
|
||||
// Guards against multiple windows spawning duplicate checks on the same failure.
|
||||
#[cfg(feature = "update-check")]
|
||||
static CHECK_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn is_newer(latest: &str, current: &str) -> bool {
|
||||
@@ -26,6 +39,7 @@ pub fn is_newer(latest: &str, current: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn cache_file() -> PathBuf {
|
||||
let dir = std::env::var("HOME")
|
||||
.map(|h| PathBuf::from(h).join(".cache/ahfail"))
|
||||
@@ -37,6 +51,7 @@ fn cache_file() -> PathBuf {
|
||||
dir.join("last_update_check")
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn rate_limited() -> bool {
|
||||
let path = cache_file();
|
||||
if let Ok(meta) = fs::metadata(&path) {
|
||||
@@ -50,10 +65,12 @@ fn rate_limited() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn touch_cache() {
|
||||
let _ = fs::write(cache_file(), b"");
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn send_notification() {
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = Command::new("notify-send")
|
||||
@@ -69,6 +86,7 @@ fn send_notification() {
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
/// Run in a background thread. Checks Gitea for a newer release and sends a
|
||||
/// desktop notification if one exists. Rate-limited to once per 24 hours.
|
||||
/// Fails silently on any error.
|
||||
@@ -104,6 +122,7 @@ pub fn check_for_update(current_version: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "update-check")]
|
||||
fn extract_tag_name(json: &str) -> Option<String> {
|
||||
// Handle both compact ("tag_name":"v1.0") and spaced ("tag_name": "v1.0") JSON.
|
||||
let key = "\"tag_name\"";
|
||||
@@ -113,3 +132,7 @@ fn extract_tag_name(json: &str) -> Option<String> {
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
/// Built without the `update-check` feature: never contacts the network.
|
||||
#[cfg(not(feature = "update-check"))]
|
||||
pub fn check_for_update(_current_version: &str) {}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "ahfail"
|
||||
version = "0.10.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"
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
// Same GResource embedding as ahfail-display: the sprites and the audio clip are
|
||||
// compiled into the binary, and ahfail_ui::animation looks them up through the
|
||||
// `ahfail_get_resource` symbol that glib-compile-resources generates.
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = manifest_dir.join("../..").canonicalize().unwrap();
|
||||
let assets_dir = workspace_root.join("assets");
|
||||
let gresource_xml = assets_dir.join("ahfail.gresource.xml");
|
||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||
let c_src = out_dir.join("ahfail-resources.c");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", gresource_xml.display());
|
||||
for entry in std::fs::read_dir(&assets_dir).unwrap() {
|
||||
println!("cargo:rerun-if-changed={}", entry.unwrap().path().display());
|
||||
}
|
||||
|
||||
let status = Command::new("glib-compile-resources")
|
||||
.args([
|
||||
"--generate-source",
|
||||
"--target",
|
||||
c_src.to_str().unwrap(),
|
||||
"--sourcedir",
|
||||
assets_dir.to_str().unwrap(),
|
||||
gresource_xml.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("glib-compile-resources not found");
|
||||
assert!(status.success(), "glib-compile-resources failed");
|
||||
|
||||
let gio = pkg_config::probe_library("gio-2.0").expect("gio-2.0 not found");
|
||||
let mut build = cc::Build::new();
|
||||
build.file(&c_src).flag_if_supported("-w");
|
||||
for inc in &gio.include_paths {
|
||||
build.include(inc);
|
||||
}
|
||||
build.compile("ahfail_resources");
|
||||
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os == "linux" {
|
||||
// The Wayland backend drives ext-session-lock-v1 through gtk-session-lock,
|
||||
// the same library gtklock uses. The X11 backend and PAM need libpam.
|
||||
pkg_config::probe_library("gtk-session-lock-0").expect(
|
||||
"gtk-session-lock not found (Arch: pacman -S gtk-session-lock)",
|
||||
);
|
||||
println!("cargo:rustc-link-lib=pam");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! `ahfail doctor`: what works on this machine, what does not, and the exact
|
||||
//! fix. Exit code 1 if something would stop `ahfail lock` from working.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
struct Report {
|
||||
blocking: bool,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn ok(&self, what: &str) {
|
||||
println!(" \x1b[32m✓\x1b[0m {what}");
|
||||
}
|
||||
fn warn(&self, what: &str, fix: &str) {
|
||||
println!(" \x1b[33m!\x1b[0m {what}\n → {fix}");
|
||||
}
|
||||
fn fail(&mut self, what: &str, fix: &str) {
|
||||
self.blocking = true;
|
||||
println!(" \x1b[31m✗\x1b[0m {what}\n → {fix}");
|
||||
}
|
||||
fn info(&self, what: &str) {
|
||||
println!(" · {what}");
|
||||
}
|
||||
}
|
||||
|
||||
fn in_path(bin: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.map(|p| std::env::split_paths(&p).any(|d| d.join(bin).is_file()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn env(k: &str) -> String {
|
||||
std::env::var(k).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// PAM service files that still carry the old hook (libahfail_pam.so).
|
||||
fn legacy_pam_hooks(dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
rd.flatten()
|
||||
.filter(|e| {
|
||||
std::fs::read_to_string(e.path())
|
||||
.map(|s| s.lines().any(|l| !l.trim_start().starts_with('#') && l.contains("libahfail_pam")))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|e| e.path().display().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run() -> i32 {
|
||||
let mut r = Report { blocking: false };
|
||||
println!("ahfail {} doctor\n", crate::VERSION);
|
||||
#[cfg(target_os = "linux")]
|
||||
linux(&mut r);
|
||||
#[cfg(target_os = "macos")]
|
||||
macos(&mut r);
|
||||
println!();
|
||||
if r.blocking {
|
||||
println!("Something above stops `ahfail lock` from working.");
|
||||
1
|
||||
} else {
|
||||
println!("Ready. Try `ahfail test`, then `ahfail`.");
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux(r: &mut Report) {
|
||||
use crate::Backend;
|
||||
|
||||
println!("Session");
|
||||
let desktop = env("XDG_CURRENT_DESKTOP");
|
||||
r.info(&format!(
|
||||
"XDG_SESSION_TYPE={} XDG_CURRENT_DESKTOP={}",
|
||||
if env("XDG_SESSION_TYPE").is_empty() { "?".into() } else { env("XDG_SESSION_TYPE") },
|
||||
if desktop.is_empty() { "?" } else { &desktop }
|
||||
));
|
||||
let backend = crate::detect_backend();
|
||||
match backend {
|
||||
None => r.fail("no graphical session (WAYLAND_DISPLAY and DISPLAY are both empty)", "run ahfail from inside your desktop session, not over SSH"),
|
||||
Some(b) => r.ok(&format!("{b:?} session")),
|
||||
}
|
||||
if unsafe { libc::getuid() } == 0 {
|
||||
r.fail("running as root", "run ahfail as the user whose screen should be locked");
|
||||
}
|
||||
|
||||
if let Some(b) = backend {
|
||||
std::env::set_var("GDK_BACKEND", match b { Backend::Wayland => "wayland", Backend::X11 => "x11" });
|
||||
if gtk::init().is_err() {
|
||||
r.fail("GTK could not open the display", "check that the session variables are exported to this shell");
|
||||
} else if b == Backend::Wayland {
|
||||
if crate::wayland::supported() {
|
||||
r.ok("compositor supports ext-session-lock-v1");
|
||||
} else if desktop.to_uppercase().contains("GNOME") {
|
||||
r.fail("GNOME does not let third-party programs lock the screen on Wayland", "use GNOME's own lock, or log in to an X11 session for ahfail");
|
||||
} else {
|
||||
r.fail("compositor does not support ext-session-lock-v1", "sway, Hyprland, niri, river, labwc and KDE Plasma 6 do; update the compositor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nAuthentication");
|
||||
if Path::new("/etc/pam.d/ahfail").is_file() {
|
||||
r.ok("PAM service /etc/pam.d/ahfail");
|
||||
} else {
|
||||
r.fail("/etc/pam.d/ahfail is missing, so no password can ever be accepted", "install the package, or: sudo install -Dm644 data/pam.d/ahfail /etc/pam.d/ahfail");
|
||||
}
|
||||
|
||||
println!("\nSound");
|
||||
match gstreamer::init() {
|
||||
Err(_) => r.warn("GStreamer could not start", "install gstreamer and gst-plugins-good"),
|
||||
Ok(()) => {
|
||||
let decoder = ["mpg123audiodec", "avdec_mp3", "mad"]
|
||||
.iter()
|
||||
.find(|f| gstreamer::ElementFactory::find(f).is_some());
|
||||
match decoder {
|
||||
Some(d) => r.ok(&format!("mp3 decoder ({d})")),
|
||||
None => r.warn("no mp3 decoder: the clip will not play", "install gst-plugins-good (mpg123) or gst-libav"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_path("pactl") {
|
||||
r.ok("volume control via pactl");
|
||||
} else {
|
||||
r.warn("pactl not found: the volume cannot be raised to 100 %", "install pipewire-pulse (or libpulse); or run with --no-max-volume");
|
||||
}
|
||||
|
||||
println!("\nIdle locking");
|
||||
let hints = match backend {
|
||||
Some(Backend::Wayland) => ("swayidle", "swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'"),
|
||||
_ => ("xss-lock", "xss-lock -- ahfail"),
|
||||
};
|
||||
if in_path(hints.0) {
|
||||
r.ok(&format!("{} found; use: {}", hints.0, hints.1));
|
||||
} else {
|
||||
r.info(&format!("to lock automatically: {}", hints.1));
|
||||
}
|
||||
|
||||
let legacy = legacy_pam_hooks("/etc/pam.d");
|
||||
if !legacy.is_empty() {
|
||||
println!("\nOld integration");
|
||||
r.info(&format!(
|
||||
"the PAM hook libahfail_pam.so is still in {}; not needed with `ahfail lock`, but harmless if you keep using that locker",
|
||||
legacy.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos(r: &mut Report) {
|
||||
println!("macOS");
|
||||
let files = legacy_pam_hooks("/etc/pam.d");
|
||||
if files.is_empty() {
|
||||
r.fail(
|
||||
"the ahfail PAM line is not in the screensaver PAM file",
|
||||
"add `auth optional /usr/local/lib/ahfail/libahfail_pam.so` as the first auth line of /etc/pam.d/screensaverui",
|
||||
);
|
||||
} else {
|
||||
r.ok(&format!("PAM hook in {}", files.join(", ")));
|
||||
}
|
||||
r.info("`ahfail lock` uses the system lock (pmset displaysleepnow); enable \"Require password immediately\" in System Settings");
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The lock screen itself, shared by the Wayland and X11 backends and by the
|
||||
//! `ahfail test` preview: one full-screen window per monitor with a clock and a
|
||||
//! password field, and a layer on top where Nedry appears on every failure.
|
||||
//!
|
||||
//! Backends only decide how the windows become a lock (ext-session-lock-v1 on
|
||||
//! Wayland, an input grab on X11) and how it is released again.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ahfail_ui::config::ModuleConfig;
|
||||
use gtk::gdk_pixbuf::PixbufAnimation;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::pam;
|
||||
|
||||
pub const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
|
||||
|
||||
/// Space kept free of sprites around the password box, in pixels.
|
||||
const DEADZONE_MARGIN: i32 = 48;
|
||||
|
||||
const CSS: &str = r#"
|
||||
window.ahfail { background: radial-gradient(ellipse at center, #182232 0%, #0b0f17 70%); }
|
||||
.ahfail .clock { color: #e6e9ef; font-size: 88px; font-weight: 300; }
|
||||
.ahfail .date { color: #97a3b6; font-size: 20px; }
|
||||
.ahfail .user { color: #e6e9ef; font-size: 17px; font-weight: 600; margin-top: 28px; }
|
||||
.ahfail entry {
|
||||
min-width: 300px; min-height: 42px; font-size: 18px;
|
||||
border-radius: 21px; padding: 0 18px;
|
||||
background: rgba(255,255,255,0.08); color: #e6e9ef;
|
||||
border: 1px solid rgba(255,255,255,0.18); box-shadow: none;
|
||||
}
|
||||
.ahfail entry:focus { border-color: #38bdf8; }
|
||||
.ahfail .status { color: #97a3b6; font-size: 15px; min-height: 22px; }
|
||||
.ahfail .status.error { color: #f87171; font-weight: 600; }
|
||||
"#;
|
||||
|
||||
pub struct Options {
|
||||
/// Force the volume to 100 % on the first failure (restored on unlock).
|
||||
pub max_volume: bool,
|
||||
}
|
||||
|
||||
struct LockWindow {
|
||||
window: gtk::Window,
|
||||
clock: gtk::Label,
|
||||
date: gtk::Label,
|
||||
fixed: gtk::Fixed,
|
||||
center: gtk::Box,
|
||||
entry: gtk::Entry,
|
||||
status: gtk::Label,
|
||||
/// Whether this window shows the password field (on X11 only the primary does).
|
||||
has_entry: bool,
|
||||
}
|
||||
|
||||
enum AuthResult {
|
||||
Ok,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct Locker {
|
||||
windows: Vec<LockWindow>,
|
||||
animation: Option<PixbufAnimation>,
|
||||
players: Vec<gstreamer_player::Player>,
|
||||
volume: Option<ahfail_ui::volume::VolumeState>,
|
||||
failures: u32,
|
||||
busy: bool,
|
||||
user: String,
|
||||
opts: Options,
|
||||
sender: glib::Sender<AuthResult>,
|
||||
/// Set by the backend: releases the lock. Called once, after PAM said yes.
|
||||
on_unlock: Option<Box<dyn Fn()>>,
|
||||
unlocked: bool,
|
||||
}
|
||||
|
||||
pub type Shared = Rc<RefCell<Locker>>;
|
||||
|
||||
/// Must be called after gtk::init() and gstreamer::init().
|
||||
pub fn new(user: String, opts: Options) -> Shared {
|
||||
install_css();
|
||||
let (sender, receiver) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
let animation = unsafe { ahfail_ui::animation::load_animation() };
|
||||
if animation.is_none() {
|
||||
eprintln!("[ahfail] sprites could not be loaded; locking without them");
|
||||
}
|
||||
let locker = Rc::new(RefCell::new(Locker {
|
||||
windows: Vec::new(),
|
||||
animation,
|
||||
players: Vec::new(),
|
||||
volume: None,
|
||||
failures: 0,
|
||||
busy: false,
|
||||
user,
|
||||
opts,
|
||||
sender,
|
||||
on_unlock: None,
|
||||
unlocked: false,
|
||||
}));
|
||||
let weak = Rc::downgrade(&locker);
|
||||
receiver.attach(None, move |res| {
|
||||
if let Some(l) = weak.upgrade() {
|
||||
handle_result(&l, res);
|
||||
}
|
||||
glib::Continue(true)
|
||||
});
|
||||
start_clock(&locker);
|
||||
locker
|
||||
}
|
||||
|
||||
pub fn set_on_unlock(locker: &Shared, f: impl Fn() + 'static) {
|
||||
locker.borrow_mut().on_unlock = Some(Box::new(f));
|
||||
}
|
||||
|
||||
fn install_css() {
|
||||
let provider = gtk::CssProvider::new();
|
||||
if provider.load_from_data(CSS.as_bytes()).is_ok() {
|
||||
if let Some(screen) = gdk::Screen::default() {
|
||||
gtk::StyleContext::add_provider_for_screen(
|
||||
&screen,
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now(fmt: &str) -> String {
|
||||
glib::DateTime::now_local()
|
||||
.ok()
|
||||
.and_then(|d| d.format(fmt).ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn start_clock(locker: &Shared) {
|
||||
let weak = Rc::downgrade(locker);
|
||||
glib::timeout_add_seconds_local(1, move || {
|
||||
let Some(l) = weak.upgrade() else { return glib::Continue(false) };
|
||||
let (clock, date) = (now("%H:%M"), now("%A %-d. %B"));
|
||||
for w in &l.borrow().windows {
|
||||
w.clock.set_text(&clock);
|
||||
w.date.set_text(&date);
|
||||
}
|
||||
glib::Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the lock window for one monitor. The backend decides how it is shown
|
||||
/// (a session-lock surface, or a grabbed override-redirect window).
|
||||
pub fn build_window(locker: &Shared, kind: gtk::WindowType, with_entry: bool) -> gtk::Window {
|
||||
let window = gtk::Window::new(kind);
|
||||
window.style_context().add_class("ahfail");
|
||||
window.set_decorated(false);
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
let background = gtk::EventBox::new();
|
||||
overlay.add(&background);
|
||||
|
||||
// Sprites live on their own layer that never takes input, so a Nedry landing
|
||||
// on top of the field cannot swallow a click.
|
||||
let fixed = gtk::Fixed::new();
|
||||
overlay.add_overlay(&fixed);
|
||||
overlay.set_overlay_pass_through(&fixed, true);
|
||||
|
||||
let center = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
center.set_halign(gtk::Align::Center);
|
||||
center.set_valign(gtk::Align::Center);
|
||||
let clock = gtk::Label::new(Some(&now("%H:%M")));
|
||||
clock.style_context().add_class("clock");
|
||||
let date = gtk::Label::new(Some(&now("%A %-d. %B")));
|
||||
date.style_context().add_class("date");
|
||||
let user = gtk::Label::new(Some(&locker.borrow().user));
|
||||
user.style_context().add_class("user");
|
||||
let entry = gtk::Entry::new();
|
||||
entry.set_visibility(false);
|
||||
entry.set_input_purpose(gtk::InputPurpose::Password);
|
||||
entry.set_placeholder_text(Some("Password"));
|
||||
// No input method on a lock screen: keys must not be routed through an
|
||||
// external IM, and on Wayland the text-input IM swallowed Return before the
|
||||
// entry could turn it into "activate".
|
||||
entry.set_im_module(Some("gtk-im-context-simple"));
|
||||
entry.set_halign(gtk::Align::Center);
|
||||
let status = gtk::Label::new(None);
|
||||
status.style_context().add_class("status");
|
||||
for w in [clock.upcast_ref::<gtk::Widget>(), date.upcast_ref(), user.upcast_ref(), entry.upcast_ref(), status.upcast_ref()] {
|
||||
center.add(w);
|
||||
}
|
||||
if !with_entry {
|
||||
user.set_no_show_all(true);
|
||||
entry.set_no_show_all(true);
|
||||
status.set_no_show_all(true);
|
||||
}
|
||||
overlay.add_overlay(¢er);
|
||||
window.add(&overlay);
|
||||
|
||||
// Enter is handled here rather than through "activate", so submitting does
|
||||
// not depend on how the input stack translates the key.
|
||||
let weak = Rc::downgrade(locker);
|
||||
entry.connect_key_press_event(move |e, ev| {
|
||||
use gdk::keys::constants as key;
|
||||
let k = ev.keyval();
|
||||
if k == key::Return || k == key::KP_Enter || k == key::ISO_Enter {
|
||||
if let Some(l) = weak.upgrade() {
|
||||
submit(&l, e);
|
||||
}
|
||||
return gtk::Inhibit(true);
|
||||
}
|
||||
if k == key::Escape {
|
||||
e.set_text("");
|
||||
}
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
|
||||
locker.borrow_mut().windows.push(LockWindow { window: window.clone(), clock, date, fixed, center, entry, status, has_entry: with_entry });
|
||||
window
|
||||
}
|
||||
|
||||
fn submit(locker: &Shared, entry: >k::Entry) {
|
||||
let (user, sender) = {
|
||||
let mut l = locker.borrow_mut();
|
||||
if l.busy || l.unlocked {
|
||||
return;
|
||||
}
|
||||
l.busy = true;
|
||||
(l.user.clone(), l.sender.clone())
|
||||
};
|
||||
let secret = pam::Secret::new(entry.text().to_string());
|
||||
entry.set_text("");
|
||||
for w in &locker.borrow().windows {
|
||||
w.entry.set_sensitive(false);
|
||||
w.status.style_context().remove_class("error");
|
||||
w.status.set_text("Checking…");
|
||||
}
|
||||
// PAM may take seconds (pam_faillock delays, network auth): never on the GTK thread.
|
||||
std::thread::spawn(move || {
|
||||
let res = match pam::authenticate(&user, secret) {
|
||||
Ok(()) => AuthResult::Ok,
|
||||
Err(e) => {
|
||||
eprintln!("[ahfail] authentication failed: {e}");
|
||||
AuthResult::Failed
|
||||
}
|
||||
};
|
||||
let _ = sender.send(res);
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_result(locker: &Shared, res: AuthResult) {
|
||||
match res {
|
||||
AuthResult::Ok => unlock(locker),
|
||||
AuthResult::Failed => {
|
||||
fail(locker);
|
||||
let l = locker.borrow();
|
||||
for w in &l.windows {
|
||||
w.entry.set_sensitive(true);
|
||||
}
|
||||
if let Some(w) = l.windows.iter().find(|w| w.has_entry) {
|
||||
w.entry.grab_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
locker.borrow_mut().busy = false;
|
||||
}
|
||||
|
||||
/// One wrong password: a new Nedry on every monitor, and the clip.
|
||||
pub fn fail(locker: &Shared) {
|
||||
let mut l = locker.borrow_mut();
|
||||
l.failures += 1;
|
||||
let n = l.failures;
|
||||
if n == 1 && l.opts.max_volume {
|
||||
l.volume = ahfail_ui::volume::save_and_set_max();
|
||||
}
|
||||
let text = if n == 1 {
|
||||
"Ah ah ah, you didn't say the magic word!".to_string()
|
||||
} else {
|
||||
format!("Ah ah ah, you didn't say the magic word! ({n})")
|
||||
};
|
||||
for w in &l.windows {
|
||||
w.status.set_text(&text);
|
||||
w.status.style_context().add_class("error");
|
||||
}
|
||||
if let Some(anim) = l.animation.clone() {
|
||||
for w in &l.windows {
|
||||
let alloc = w.window.allocation();
|
||||
// Overlay children get their own GdkWindow in GTK3, so the box's
|
||||
// allocation is relative to itself (always 0,0). Translate it into the
|
||||
// window's coordinates, which are also the sprite layer's.
|
||||
let c = w.center.allocation();
|
||||
let (cx, cy) = w.center.translate_coordinates(&w.window, 0, 0).unwrap_or((c.x(), c.y()));
|
||||
let deadzone = gdk::Rectangle::new(
|
||||
cx - DEADZONE_MARGIN,
|
||||
cy - DEADZONE_MARGIN,
|
||||
c.width() + 2 * DEADZONE_MARGIN,
|
||||
c.height() + 2 * DEADZONE_MARGIN,
|
||||
);
|
||||
let cfg = ModuleConfig { deadzone: Some(deadzone) };
|
||||
ahfail_ui::display::place_sprite(&w.fixed, &anim, alloc.width(), alloc.height(), &cfg);
|
||||
}
|
||||
}
|
||||
let player = ahfail_ui::audio::create_player(AUDIO_URI);
|
||||
player.play();
|
||||
l.players.push(player);
|
||||
}
|
||||
|
||||
fn unlock(locker: &Shared) {
|
||||
let cb = {
|
||||
let mut l = locker.borrow_mut();
|
||||
if l.unlocked {
|
||||
return;
|
||||
}
|
||||
l.unlocked = true;
|
||||
for p in l.players.drain(..) {
|
||||
p.stop();
|
||||
}
|
||||
if let Some(v) = l.volume.take() {
|
||||
ahfail_ui::volume::restore(v);
|
||||
}
|
||||
l.on_unlock.take()
|
||||
};
|
||||
if let Some(cb) = cb {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the volume if we are leaving without a successful unlock (e.g. the
|
||||
/// compositor ended the lock, or the preview window was closed).
|
||||
pub fn cleanup(locker: &Shared) {
|
||||
let mut l = locker.borrow_mut();
|
||||
for p in l.players.drain(..) {
|
||||
p.stop();
|
||||
}
|
||||
if let Some(v) = l.volume.take() {
|
||||
ahfail_ui::volume::restore(v);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_entry(locker: &Shared) {
|
||||
if let Some(w) = locker.borrow().windows.iter().find(|w| w.has_entry) {
|
||||
w.entry.grab_focus();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn windows(locker: &Shared) -> Vec<gtk::Window> {
|
||||
locker.borrow().windows.iter().map(|w| w.window.clone()).collect()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! `ahfail`: one command in front of every platform.
|
||||
//!
|
||||
//! ahfail lock the screen (same as `ahfail lock`)
|
||||
//! ahfail test show the effect once, without locking
|
||||
//! ahfail doctor explain what works on this machine and what is missing
|
||||
//!
|
||||
//! Linux: ahfail is itself the locker (Wayland: ext-session-lock-v1, X11: input
|
||||
//! grab) and authenticates through its own PAM service, /etc/pam.d/ahfail.
|
||||
//! macOS: the lock screen belongs to the system, so `lock` asks the system to
|
||||
//! lock and the effect comes from the PAM module (see `ahfail doctor`).
|
||||
|
||||
mod doctor;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod locker;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod pam;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod preview;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11;
|
||||
|
||||
use std::process::exit;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
const HELP: &str = "\
|
||||
ahfail - ah ah ah, you didn't say the magic word
|
||||
|
||||
Usage:
|
||||
ahfail [lock] [--no-max-volume] lock the screen
|
||||
ahfail test [--max-volume] play the effect once, without locking
|
||||
ahfail doctor check this machine and explain what is missing
|
||||
ahfail --version
|
||||
|
||||
Linux picks the backend from the session: Wayland (ext-session-lock-v1, as used by
|
||||
sway, Hyprland, niri, river and KDE) or X11. GNOME does not allow third-party
|
||||
lockers on Wayland.
|
||||
|
||||
Idle locking:
|
||||
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
|
||||
xss-lock -- ahfail
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Backend {
|
||||
Wayland,
|
||||
X11,
|
||||
}
|
||||
|
||||
/// Which display server this session runs on, from the environment alone.
|
||||
pub fn detect_backend() -> Option<Backend> {
|
||||
let set = |k: &str| std::env::var(k).map(|v| !v.is_empty()).unwrap_or(false);
|
||||
if set("WAYLAND_DISPLAY") {
|
||||
Some(Backend::Wayland)
|
||||
} else if set("DISPLAY") {
|
||||
Some(Backend::X11)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let flag = |f: &str| args.iter().any(|a| a == f);
|
||||
let cmd = args.iter().find(|a| !a.starts_with('-')).map(String::as_str).unwrap_or("lock");
|
||||
|
||||
if flag("--version") || flag("-V") {
|
||||
println!("ahfail {VERSION}");
|
||||
return;
|
||||
}
|
||||
if flag("--help") || flag("-h") || cmd == "help" {
|
||||
print!("{HELP}");
|
||||
return;
|
||||
}
|
||||
let code = match cmd {
|
||||
"lock" => lock(!flag("--no-max-volume")),
|
||||
"test" => test(flag("--max-volume")),
|
||||
"doctor" => doctor::run(),
|
||||
other => {
|
||||
eprintln!("ahfail: unknown command '{other}'\n\n{HELP}");
|
||||
2
|
||||
}
|
||||
};
|
||||
exit(code);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn lock(_max_volume: bool) -> i32 {
|
||||
// The macOS lock screen is the system's; the ahfail PAM module hooks it.
|
||||
match std::process::Command::new("pmset").arg("displaysleepnow").status() {
|
||||
Ok(s) if s.success() => 0,
|
||||
_ => {
|
||||
eprintln!("ahfail: could not lock the screen (pmset displaysleepnow failed)");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn test(_max_volume: bool) -> i32 {
|
||||
eprintln!("ahfail: `test` is not available on macOS yet; lock the screen and type a wrong password");
|
||||
1
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn prepare(backend: Backend) -> Result<(), String> {
|
||||
if unsafe { libc::getuid() } == 0 {
|
||||
return Err("refusing to run as root: lock the screen as the logged-in user".into());
|
||||
}
|
||||
// Under XWayland both variables are set; pin GTK to the backend we chose so
|
||||
// it does not quietly pick the other one.
|
||||
std::env::set_var("GDK_BACKEND", match backend { Backend::Wayland => "wayland", Backend::X11 => "x11" });
|
||||
gtk::init().map_err(|e| format!("could not open the display: {e}"))?;
|
||||
gstreamer::init().map_err(|e| format!("could not start GStreamer: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Only one ahfail may lock at a time; a second call (e.g. swayidle firing again
|
||||
/// while locked) exits quietly. The lock file dies with the process.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn single_instance() -> Option<std::fs::File> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
let path = std::path::Path::new(&dir).join(format!("ahfail-{}.lock", unsafe { libc::getuid() }));
|
||||
let f = std::fs::OpenOptions::new().create(true).write(true).open(path).ok()?;
|
||||
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(f)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lock(max_volume: bool) -> i32 {
|
||||
let Some(backend) = detect_backend() else {
|
||||
eprintln!("ahfail: no graphical session found (neither WAYLAND_DISPLAY nor DISPLAY is set)");
|
||||
return 1;
|
||||
};
|
||||
let Some(_instance) = single_instance() else {
|
||||
return 0; // already locked
|
||||
};
|
||||
if let Err(e) = prepare(backend) {
|
||||
eprintln!("ahfail: {e}");
|
||||
return 1;
|
||||
}
|
||||
if backend == Backend::Wayland && !wayland::supported() {
|
||||
eprintln!("ahfail: this compositor does not support ext-session-lock-v1, so no third-party locker can lock it (GNOME is one of these). Run `ahfail doctor`.");
|
||||
return 1;
|
||||
}
|
||||
let Some(user) = pam::current_user() else {
|
||||
eprintln!("ahfail: could not determine the current user");
|
||||
return 1;
|
||||
};
|
||||
let l = locker::new(user, locker::Options { max_volume });
|
||||
match backend {
|
||||
Backend::Wayland => wayland::run(l),
|
||||
Backend::X11 => x11::run(l),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn test(max_volume: bool) -> i32 {
|
||||
let Some(backend) = detect_backend() else {
|
||||
eprintln!("ahfail: no graphical session found");
|
||||
return 1;
|
||||
};
|
||||
if let Err(e) = prepare(backend) {
|
||||
eprintln!("ahfail: {e}");
|
||||
return 1;
|
||||
}
|
||||
let user = pam::current_user().unwrap_or_default();
|
||||
preview::run(locker::new(user, locker::Options { max_volume }))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Minimal PAM client: authenticate the current user against the `ahfail`
|
||||
//! service (/etc/pam.d/ahfail, shipped by the package).
|
||||
//!
|
||||
//! This is the application side of PAM, the opposite of crates/ahfail-pam,
|
||||
//! which is a module that sits inside someone else's stack.
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::{c_char, c_int, c_void};
|
||||
use std::ptr;
|
||||
|
||||
const PAM_SUCCESS: c_int = 0;
|
||||
const PAM_PROMPT_ECHO_OFF: c_int = 1;
|
||||
const PAM_PROMPT_ECHO_ON: c_int = 2;
|
||||
const PAM_REFRESH_CRED: c_int = 0x0010;
|
||||
const PAM_CONV_ERR: c_int = 19;
|
||||
const PAM_BUF_ERR: c_int = 5;
|
||||
|
||||
#[repr(C)]
|
||||
struct PamMessage {
|
||||
msg_style: c_int,
|
||||
msg: *const c_char,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamResponse {
|
||||
resp: *mut c_char,
|
||||
resp_retcode: c_int,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamConv {
|
||||
conv: extern "C" fn(c_int, *mut *const PamMessage, *mut *mut PamResponse, *mut c_void) -> c_int,
|
||||
appdata_ptr: *mut c_void,
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn pam_start(
|
||||
service: *const c_char,
|
||||
user: *const c_char,
|
||||
conv: *const PamConv,
|
||||
pamh: *mut *mut c_void,
|
||||
) -> c_int;
|
||||
fn pam_authenticate(pamh: *mut c_void, flags: c_int) -> c_int;
|
||||
fn pam_setcred(pamh: *mut c_void, flags: c_int) -> c_int;
|
||||
fn pam_end(pamh: *mut c_void, status: c_int) -> c_int;
|
||||
fn pam_strerror(pamh: *mut c_void, errnum: c_int) -> *const c_char;
|
||||
}
|
||||
|
||||
/// The password, overwritten with zeros when dropped so it does not linger in
|
||||
/// the heap after the attempt.
|
||||
pub struct Secret(Vec<u8>);
|
||||
|
||||
impl Secret {
|
||||
pub fn new(s: String) -> Self {
|
||||
Secret(s.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Secret {
|
||||
fn drop(&mut self) {
|
||||
for b in self.0.iter_mut() {
|
||||
unsafe { ptr::write_volatile(b, 0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn conversation(
|
||||
num_msg: c_int,
|
||||
msg: *mut *const PamMessage,
|
||||
resp: *mut *mut PamResponse,
|
||||
appdata: *mut c_void,
|
||||
) -> c_int {
|
||||
if num_msg <= 0 || msg.is_null() || resp.is_null() || appdata.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let secret = unsafe { &*(appdata as *const Secret) };
|
||||
// PAM frees the array and each resp string with free(), so both must come
|
||||
// from the C allocator.
|
||||
let replies = unsafe {
|
||||
libc::calloc(num_msg as usize, std::mem::size_of::<PamResponse>()) as *mut PamResponse
|
||||
};
|
||||
if replies.is_null() {
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
for i in 0..num_msg as isize {
|
||||
// Linux-PAM passes an array of pointers; index it that way.
|
||||
let m = unsafe { *msg.offset(i) };
|
||||
if m.is_null() {
|
||||
continue;
|
||||
}
|
||||
let style = unsafe { (*m).msg_style };
|
||||
if style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON {
|
||||
let len = secret.0.len();
|
||||
let p = unsafe { libc::malloc(len + 1) as *mut u8 };
|
||||
if p.is_null() {
|
||||
unsafe { libc::free(replies as *mut c_void) };
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
unsafe {
|
||||
ptr::copy_nonoverlapping(secret.0.as_ptr(), p, len);
|
||||
*p.add(len) = 0;
|
||||
(*replies.offset(i)).resp = p as *mut c_char;
|
||||
}
|
||||
}
|
||||
// PAM_ERROR_MSG / PAM_TEXT_INFO need no answer: resp stays NULL.
|
||||
}
|
||||
unsafe { *resp = replies };
|
||||
PAM_SUCCESS
|
||||
}
|
||||
|
||||
/// Blocking: run it off the GTK thread. Ok(()) only on PAM_SUCCESS.
|
||||
pub fn authenticate(user: &str, password: Secret) -> Result<(), String> {
|
||||
let service = CString::new("ahfail").unwrap();
|
||||
let user_c = CString::new(user).map_err(|_| "invalid user name".to_string())?;
|
||||
let conv = PamConv {
|
||||
conv: conversation,
|
||||
appdata_ptr: &password as *const Secret as *mut c_void,
|
||||
};
|
||||
let mut handle: *mut c_void = ptr::null_mut();
|
||||
let rc = unsafe { pam_start(service.as_ptr(), user_c.as_ptr(), &conv, &mut handle) };
|
||||
if rc != PAM_SUCCESS || handle.is_null() {
|
||||
return Err(format!("pam_start failed ({rc})"));
|
||||
}
|
||||
let rc = unsafe { pam_authenticate(handle, 0) };
|
||||
let result = if rc == PAM_SUCCESS {
|
||||
// Refresh credentials (e.g. Kerberos tickets), as i3lock does. A failure
|
||||
// here is not a reason to keep the screen locked.
|
||||
unsafe { pam_setcred(handle, PAM_REFRESH_CRED) };
|
||||
Ok(())
|
||||
} else {
|
||||
let msg = unsafe { pam_strerror(handle, rc) };
|
||||
Err(if msg.is_null() {
|
||||
format!("PAM error {rc}")
|
||||
} else {
|
||||
unsafe { CStr::from_ptr(msg) }.to_string_lossy().into_owned()
|
||||
})
|
||||
};
|
||||
unsafe { pam_end(handle, rc) };
|
||||
drop(password);
|
||||
result
|
||||
}
|
||||
|
||||
/// Login name of the user running ahfail.
|
||||
pub fn current_user() -> Option<String> {
|
||||
let pw = unsafe { libc::getpwuid(libc::getuid()) };
|
||||
if pw.is_null() {
|
||||
return None;
|
||||
}
|
||||
let name = unsafe { CStr::from_ptr((*pw).pw_name) };
|
||||
Some(name.to_string_lossy().into_owned())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! `ahfail test`: the lock screen in an ordinary window, with one failure
|
||||
//! triggered straight away. Nothing is locked and no password is checked, so it
|
||||
//! is safe to run to see whether sprites and sound work.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::locker::{self, Shared};
|
||||
|
||||
const SHOW_FOR: Duration = Duration::from_secs(8);
|
||||
|
||||
pub fn run(locker: Shared) -> i32 {
|
||||
let window = locker::build_window(&locker, gtk::WindowType::Toplevel, false);
|
||||
window.set_title("ahfail test");
|
||||
window.set_decorated(true);
|
||||
window.set_default_size(960, 600);
|
||||
window.set_position(gtk::WindowPosition::Center);
|
||||
window.connect_delete_event(|_, _| {
|
||||
gtk::main_quit();
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
window.connect_key_press_event(|_, ev| {
|
||||
if ev.keyval() == gdk::keys::constants::Escape {
|
||||
gtk::main_quit();
|
||||
}
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
window.show_all();
|
||||
|
||||
// Wait for the first layout so the sprite lands inside the window.
|
||||
{
|
||||
let locker = locker.clone();
|
||||
glib::timeout_add_local_once(Duration::from_millis(400), move || locker::fail(&locker));
|
||||
}
|
||||
glib::timeout_add_local_once(SHOW_FOR, gtk::main_quit);
|
||||
|
||||
gtk::main();
|
||||
locker::cleanup(&locker);
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Wayland backend: ext-session-lock-v1 through gtk-session-lock.
|
||||
//!
|
||||
//! The protocol is what makes this safe: once the compositor has confirmed the
|
||||
//! lock, only our lock surfaces are shown, and if ahfail crashes or is killed the
|
||||
//! session stays locked (the compositor shows a fallback) rather than opening up.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::os::raw::c_void;
|
||||
use std::rc::Rc;
|
||||
|
||||
use glib::translate::{FromGlibPtrNone, ToGlibPtr};
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::locker::{self, Shared};
|
||||
|
||||
extern "C" {
|
||||
fn gtk_session_lock_is_supported() -> glib::ffi::gboolean;
|
||||
fn gtk_session_lock_prepare_lock() -> *mut c_void;
|
||||
fn gtk_session_lock_lock_lock(lock: *mut c_void);
|
||||
fn gtk_session_lock_lock_new_surface(lock: *mut c_void, window: *mut gtk::ffi::GtkWindow, monitor: *mut gdk::ffi::GdkMonitor);
|
||||
fn gtk_session_lock_lock_unlock_and_destroy(lock: *mut c_void);
|
||||
}
|
||||
|
||||
/// True when the compositor implements ext-session-lock-v1. Needs gtk::init().
|
||||
pub fn supported() -> bool {
|
||||
unsafe { gtk_session_lock_is_supported() != 0 }
|
||||
}
|
||||
|
||||
pub fn run(locker: Shared) -> i32 {
|
||||
let lock = unsafe { gtk_session_lock_prepare_lock() };
|
||||
if lock.is_null() {
|
||||
eprintln!("ahfail: could not create a session lock");
|
||||
return 1;
|
||||
}
|
||||
// A borrowed wrapper, only for connecting signals. The library owns the
|
||||
// object and frees it in unlock_and_destroy, so this wrapper is leaked on
|
||||
// purpose rather than dropped after that.
|
||||
let obj: glib::Object = unsafe { glib::Object::from_glib_none(lock as *mut glib::gobject_ffi::GObject) };
|
||||
|
||||
let locked = Rc::new(Cell::new(false));
|
||||
let exit_code = Rc::new(Cell::new(0));
|
||||
{
|
||||
let locked = locked.clone();
|
||||
obj.connect_local("locked", false, move |_| {
|
||||
locked.set(true);
|
||||
None
|
||||
});
|
||||
}
|
||||
{
|
||||
let locked = locked.clone();
|
||||
let exit_code = exit_code.clone();
|
||||
let locker = locker.clone();
|
||||
obj.connect_local("finished", false, move |_| {
|
||||
// Either the compositor refused the lock (another locker is running),
|
||||
// or it ended the lock on its own. Both mean we stop here.
|
||||
if !locked.get() {
|
||||
eprintln!("ahfail: the compositor refused the lock (is another locker running?)");
|
||||
exit_code.set(1);
|
||||
}
|
||||
locker::cleanup(&locker);
|
||||
gtk::main_quit();
|
||||
None
|
||||
});
|
||||
}
|
||||
|
||||
unsafe { gtk_session_lock_lock_lock(lock) };
|
||||
|
||||
let display = gdk::Display::default().expect("no display");
|
||||
let add_monitor = {
|
||||
let locker = locker.clone();
|
||||
move |monitor: &gdk::Monitor, primary: bool| {
|
||||
let window = locker::build_window(&locker, gtk::WindowType::Toplevel, true);
|
||||
let wptr: *mut gtk::ffi::GtkWindow = window.to_glib_none().0;
|
||||
let mptr: *mut gdk::ffi::GdkMonitor = monitor.to_glib_none().0;
|
||||
unsafe { gtk_session_lock_lock_new_surface(lock, wptr, mptr) };
|
||||
window.show_all();
|
||||
if primary {
|
||||
locker::focus_entry(&locker);
|
||||
}
|
||||
}
|
||||
};
|
||||
let primary = display.primary_monitor();
|
||||
for i in 0..display.n_monitors() {
|
||||
if let Some(m) = display.monitor(i) {
|
||||
let is_primary = primary.as_ref().map(|p| p == &m).unwrap_or(i == 0);
|
||||
add_monitor(&m, is_primary);
|
||||
}
|
||||
}
|
||||
// A monitor plugged in while locked must get a lock surface too, otherwise
|
||||
// the compositor shows its own blank fallback on it.
|
||||
display.connect_monitor_added(move |_, m| add_monitor(m, false));
|
||||
|
||||
locker::set_on_unlock(&locker, move || {
|
||||
unsafe { gtk_session_lock_lock_unlock_and_destroy(lock) };
|
||||
gtk::main_quit();
|
||||
});
|
||||
|
||||
gtk::main();
|
||||
std::mem::forget(obj);
|
||||
exit_code.get()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! X11 backend: override-redirect windows covering every monitor, plus a
|
||||
//! keyboard and pointer grab. This is the i3lock model, including its limit:
|
||||
//! on X11 nothing keeps the screen locked if the locker process dies.
|
||||
//!
|
||||
//! The grab is the whole lock. If it cannot be taken (a menu or another program
|
||||
//! holds it), ahfail refuses to "lock" rather than show a lock screen that
|
||||
//! keystrokes pass straight through.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::locker::{self, Shared};
|
||||
|
||||
const GRAB_ATTEMPTS: u32 = 100; // ~2 s, the same budget i3lock gives up after
|
||||
const GRAB_RETRY: Duration = Duration::from_millis(20);
|
||||
|
||||
fn pump() {
|
||||
while gtk::events_pending() {
|
||||
gtk::main_iteration();
|
||||
}
|
||||
}
|
||||
|
||||
fn grab(window: >k::Window) -> Result<gdk::Seat, String> {
|
||||
let gdk_window = window.window().ok_or("lock window is not realised")?;
|
||||
let display = gdk_window.display();
|
||||
let seat = display.default_seat().ok_or("no input seat")?;
|
||||
let mut last = gdk::GrabStatus::Success;
|
||||
for _ in 0..GRAB_ATTEMPTS {
|
||||
last = seat.grab(&gdk_window, gdk::SeatCapabilities::ALL, false, None, None, None);
|
||||
if last == gdk::GrabStatus::Success {
|
||||
return Ok(seat);
|
||||
}
|
||||
std::thread::sleep(GRAB_RETRY);
|
||||
pump();
|
||||
}
|
||||
Err(format!("could not grab keyboard and pointer ({last:?}); something else is holding them"))
|
||||
}
|
||||
|
||||
pub fn run(locker: Shared) -> i32 {
|
||||
let display = gdk::Display::default().expect("no display");
|
||||
let primary = display.primary_monitor().or_else(|| display.monitor(0));
|
||||
let mut main_window = None;
|
||||
|
||||
for i in 0..display.n_monitors() {
|
||||
let Some(m) = display.monitor(i) else { continue };
|
||||
let is_primary = primary.as_ref().map(|p| p == &m).unwrap_or(i == 0);
|
||||
// Only the primary monitor gets a password field: with the grab, every
|
||||
// key goes to the grabbing window anyway.
|
||||
let window = locker::build_window(&locker, gtk::WindowType::Popup, is_primary);
|
||||
let g = m.geometry();
|
||||
window.move_(g.x(), g.y());
|
||||
window.set_default_size(g.width(), g.height());
|
||||
window.resize(g.width(), g.height());
|
||||
window.set_keep_above(true);
|
||||
window.show_all();
|
||||
if is_primary {
|
||||
main_window = Some(window);
|
||||
}
|
||||
}
|
||||
let Some(main_window) = main_window else {
|
||||
eprintln!("ahfail: no monitor found");
|
||||
return 1;
|
||||
};
|
||||
pump();
|
||||
|
||||
let seat = match grab(&main_window) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("ahfail: refusing to lock: {e}");
|
||||
for w in locker::windows(&locker) {
|
||||
w.close();
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
locker::focus_entry(&locker);
|
||||
|
||||
// Notifications and other override-redirect windows may map above us later;
|
||||
// keep our windows on top.
|
||||
{
|
||||
let locker = locker.clone();
|
||||
glib::timeout_add_local(Duration::from_millis(500), move || {
|
||||
for w in locker::windows(&locker) {
|
||||
if let Some(gw) = w.window() {
|
||||
gw.raise();
|
||||
}
|
||||
}
|
||||
glib::Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
locker::set_on_unlock(&locker, {
|
||||
let locker = locker.clone();
|
||||
move || {
|
||||
seat.ungrab();
|
||||
for w in locker::windows(&locker) {
|
||||
w.close();
|
||||
}
|
||||
gtk::main_quit();
|
||||
}
|
||||
});
|
||||
|
||||
gtk::main();
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#%PAM-1.0
|
||||
# PAM service for the ahfail screen locker. Installed by the package as its own
|
||||
# file, the same way swaylock and gtklock ship theirs, so no other package's
|
||||
# PAM configuration is ever edited.
|
||||
auth include login
|
||||
+89
-64
@@ -1,95 +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 the absolute libdir so build.rs emits the correct AHFAIL_INSTALL_DIR.
|
||||
# get_option('libdir') is relative ("lib"); prefix it to get an absolute path.
|
||||
'AHFAIL_LIBDIR=' + get_option('prefix') / get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"'
|
||||
# The PAM module's build.rs needs the absolute libdir to know where
|
||||
# ahfail-display is installed; get_option('libdir') alone is relative.
|
||||
'AHFAIL_LIBDIR=' + get_option('prefix') / get_option('libdir')
|
||||
+ ' cargo build --release --locked'
|
||||
+ ' ' + cargo_pkgs + ' ' + cargo_features
|
||||
+ ' --target-dir "@OUTDIR@/target"'
|
||||
+ cargo_copy
|
||||
],
|
||||
build_by_default: true,
|
||||
build_always_stale: true, # cargo decides what is out of date, not meson
|
||||
install: true,
|
||||
install_dir: get_option('libdir') / 'ahfail',
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
option('update_check', type: 'boolean', value: true,
|
||||
description: 'Let the binaries check Gitea for new releases. Distro packages set this to false.')
|
||||
@@ -0,0 +1,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/"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
_ahfail_msg() {
|
||||
cat <<'MSG'
|
||||
==> ahfail is its own screen locker and uses its own PAM service (/etc/pam.d/ahfail).
|
||||
Nothing in your other PAM files has been changed.
|
||||
|
||||
Check your setup: ahfail doctor
|
||||
See the effect safely: ahfail test
|
||||
Lock the screen: ahfail
|
||||
|
||||
Lock automatically:
|
||||
Wayland: swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
|
||||
X11: xss-lock -- ahfail
|
||||
|
||||
The old PAM hook (auth optional .../libahfail_pam.so in e.g. /etc/pam.d/i3lock)
|
||||
is no longer needed with `ahfail`. Remove that line yourself if you added it.
|
||||
MSG
|
||||
}
|
||||
|
||||
post_install() {
|
||||
_ahfail_msg
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
# Only when coming from before 0.10.0, when ahfail became its own locker;
|
||||
# repeating the message on every update would just teach people to skip it.
|
||||
if (( $(vercmp "$2" 0.10.0) < 0 )); then
|
||||
_ahfail_msg
|
||||
fi
|
||||
}
|
||||
@@ -1,142 +1,78 @@
|
||||
# Ah ah ah, you didn't say the magic word
|
||||
|
||||
On a failed lock-screen unlock attempt, this spawns a looping animation of **the author's face photoshopped onto Dennis Nedry's body** and plays the "ah ah ah, you didn't say the magic word" clip from Jurassic Park. Each wrong guess adds another sprite at a random screen position. Volume is forced to 100% on the first failure and restored when the screen unlocks.
|
||||
`ahfail` is a screen locker. Get the password wrong and a looping animation of **the author's face photoshopped onto Dennis Nedry's body** appears on screen while the "ah ah ah, you didn't say the magic word" clip from Jurassic Park plays. Every wrong guess adds another Nedry. The volume is forced to 100% on the first failure and restored when you unlock.
|
||||
|
||||
```bash
|
||||
ahfail # lock the screen
|
||||
ahfail test # show the effect once, without locking anything
|
||||
ahfail doctor # check this machine and explain what is missing
|
||||
```
|
||||
|
||||
One command on Linux: `ahfail` works out whether you run Wayland or X11 and does the right thing. On macOS the effect comes from a PAM hook on the system lock screen (see below).
|
||||
|
||||
---
|
||||
|
||||
> **Security disclaimer**
|
||||
>
|
||||
> This project hooks into PAM and/or your screen locker's plugin API. PAM sits directly in the authentication critical path — a bug in this module could lock you out of your system or, in the worst case, weaken authentication.
|
||||
> This is a screen locker, so it sits directly in the authentication path. A bug in it could lock you out of your session or, in the worst case, weaken the lock.
|
||||
>
|
||||
> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are modifying a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
|
||||
> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are running a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.**
|
||||
|
||||
---
|
||||
|
||||
## Platform support
|
||||
## Install
|
||||
|
||||
The integration method differs by display server:
|
||||
|
||||
| Platform | Display server | Module loaded | How it works |
|
||||
|---|---|---|---|
|
||||
| Linux | **Wayland** | `ahfail-module.so` | Loaded directly by `gtklock` via its module API |
|
||||
| Linux | **X11** | `libahfail_pam.so` + `ahfail-display` | PAM cleanup hook spawns the display binary after each failed auth |
|
||||
| macOS | — | `libahfail_pam.so` + `ahfail-display` | Same PAM approach, hooks into the screensaver PAM stack |
|
||||
|
||||
The **gtklock module** is Wayland-only — it uses gtklock's internal window API to overlay sprites directly on the lock screen. The **PAM module** works on X11 and macOS by registering a cleanup callback that fires on each failed authentication attempt; it double-forks a standalone display binary (`ahfail-display`) that runs independently of the PAM stack.
|
||||
|
||||
---
|
||||
|
||||
## Linux (x86\_64) — binary install
|
||||
|
||||
Pre-built binaries for both Wayland and X11 are available on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases). The tarball contains all three files:
|
||||
|
||||
| File | Used by |
|
||||
|---|---|
|
||||
| `ahfail-module.so` | Wayland — loaded by `gtklock` |
|
||||
| `libahfail_pam.so` | X11 — loaded by PAM |
|
||||
| `ahfail-display` | X11 — spawned by the PAM module |
|
||||
### Arch Linux
|
||||
|
||||
```bash
|
||||
# Download and extract (replace v0.1.0 with the latest release)
|
||||
curl -fsSL https://gitea.weircon.dk/agw/gtk-ahfail/releases/download/v0.1.0/ahfail-linux-x86_64.tar.gz \
|
||||
| sudo tar -xz -C /tmp/ahfail-install
|
||||
|
||||
# Wayland (gtklock)
|
||||
sudo install -Dm755 /tmp/ahfail-install/ahfail-module.so /usr/lib/gtklock/ahfail-module.so
|
||||
|
||||
# X11 (PAM module + display binary)
|
||||
sudo install -Dm755 /tmp/ahfail-install/libahfail_pam.so /usr/lib/ahfail/libahfail_pam.so
|
||||
sudo install -Dm755 /tmp/ahfail-install/ahfail-display /usr/lib/ahfail/ahfail-display
|
||||
yay -S ahfail
|
||||
```
|
||||
|
||||
Then follow the [Wayland](#usage-linux---wayland-only-gtklock) or [X11](#usage-linux---x11-i3lock-xscreensaver-etc) configuration below.
|
||||
(Once it is published on the AUR.) The package installs the `ahfail` binary and its own PAM service file, `/etc/pam.d/ahfail`. Nothing in any other package's PAM configuration is touched, the same way swaylock and gtklock ship theirs.
|
||||
|
||||
---
|
||||
|
||||
## Linux compile
|
||||
|
||||
### Install dependencies and build from source
|
||||
### From source
|
||||
|
||||
```bash
|
||||
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gtklock
|
||||
sudo pacman -S meson ninja rust gtk3 gtk-session-lock gstreamer gst-plugins-base gst-plugins-good pam
|
||||
meson setup builddir --prefix=/usr
|
||||
meson compile -C builddir
|
||||
sudo meson install -C builddir
|
||||
```
|
||||
|
||||
`--prefix=/usr` matches Arch conventions (same as pacman) and ensures the compiled-in default path for `ahfail-display` matches where it is installed.
|
||||
`--prefix=/usr` matches Arch conventions and puts the PAM file where PAM looks for it.
|
||||
|
||||
Installs:
|
||||
- `/usr/lib/gtklock/ahfail-module.so` — gtklock module
|
||||
- `/usr/lib/ahfail/libahfail_pam.so` — PAM module (for X11)
|
||||
- `/usr/lib/ahfail/ahfail-display` — standalone display binary
|
||||
Then run `ahfail doctor`. It tells you whether the session type is supported, whether `/etc/pam.d/ahfail` is in place, whether GStreamer can play the clip and whether the volume can be controlled, with the exact fix for anything that is missing.
|
||||
|
||||
---
|
||||
|
||||
## Usage Linux - wayland (only gtklock)
|
||||
## Platform support
|
||||
|
||||
| Platform | How it locks | Notes |
|
||||
|---|---|---|
|
||||
| Linux, **Wayland** | ext-session-lock-v1 (through gtk-session-lock) | sway, Hyprland, niri, river, labwc and KDE Plasma 6. **Not GNOME**: GNOME does not let third-party programs lock the screen. |
|
||||
| Linux, **X11** | full-screen windows on every monitor plus a keyboard and pointer grab | The i3lock model. Works with any X11 window manager. |
|
||||
| **macOS** | the system lock screen | Lock the Mac as usual; the effect comes from the PAM hook installed via Homebrew (see below). The `ahfail` command itself is Linux only for now. |
|
||||
|
||||
## Locking automatically
|
||||
|
||||
Point your idle daemon at `ahfail`. Running it while already locked is harmless: a second instance exits quietly.
|
||||
|
||||
```bash
|
||||
gtklock -m /usr/lib/gtklock/ahfail-module.so
|
||||
# Wayland
|
||||
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail'
|
||||
|
||||
# X11
|
||||
xss-lock -- ahfail
|
||||
```
|
||||
|
||||
### Arguments
|
||||
Pass `--no-max-volume` to leave the volume alone.
|
||||
|
||||
Pass module arguments after `--`:
|
||||
## Security
|
||||
|
||||
```bash
|
||||
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
|
||||
```
|
||||
|
||||
- `--deadzone=X,Y,W,H` — rectangle where sprites will not spawn (keep your password field clear)
|
||||
- `--audio-uri=URI` — override the default audio clip, e.g. `file:///home/user/custom.mp3`
|
||||
|
||||
---
|
||||
|
||||
## Usage Linux - X11 (i3lock, xscreensaver, etc.)
|
||||
|
||||
The PAM module works with any X11 locker that authenticates via PAM. Supported lockers and their service file names:
|
||||
|
||||
| Locker | PAM service file |
|
||||
|---|---|
|
||||
| i3lock | `/etc/pam.d/i3lock` |
|
||||
| i3lock-color | `/etc/pam.d/i3lock-color` |
|
||||
| betterlockscreen | `/etc/pam.d/betterlockscreen` |
|
||||
| xscreensaver | `/etc/pam.d/xscreensaver` |
|
||||
| lightdm | `/etc/pam.d/lightdm` |
|
||||
|
||||
Add a line to the relevant file.
|
||||
|
||||
**Important:** The `ahfail` line must appear **before** `auth include system-auth` (or any equivalent include). If it comes after, PAM's internal flow control inside `system-auth` (`pam_faillock` with `[default=die]`) means our module is never reached on failed attempts — it only gets called on success.
|
||||
|
||||
Your PAM service file should look like this:
|
||||
|
||||
**Arch / standard (`--prefix=/usr`):**
|
||||
```
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
**Fedora / RHEL (multilib):**
|
||||
```
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib64/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
**Debian / Ubuntu (multiarch):**
|
||||
```
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
The full path is required — `$(libdir)/ahfail` is not in PAM's default search path.
|
||||
|
||||
If the display binary is not at the default location, add `display_path=`:
|
||||
```
|
||||
auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfail-display
|
||||
```
|
||||
- **Wayland:** once the compositor confirms the lock, only the lock surfaces are shown. If `ahfail` crashes or is killed, the session **stays locked** (the compositor shows its own fallback), it never opens up.
|
||||
- **X11:** the lock is the input grab. If the grab cannot be taken (a menu or another program holds it), `ahfail` refuses to lock rather than show a lock screen your keystrokes pass through. Like i3lock, nothing on X11 keeps the screen locked if the locker process dies.
|
||||
- `ahfail` refuses to run as root, and the password is checked in a separate thread through PAM service `ahfail` and wiped from memory afterwards.
|
||||
- **Too many wrong passwords can lock your account.** `/etc/pam.d/ahfail` includes `login`, so the system's `pam_faillock` policy applies. On Arch that means 3 wrong passwords within 15 minutes lock the account for 10 minutes (see `/etc/security/faillock.conf`). That is the system's rule, not ahfail's, and every PAM-based locker behaves the same, but it is worth knowing before you invite colleagues to try their luck.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,76 +81,48 @@ auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfa
|
||||
### Homebrew (recommended)
|
||||
|
||||
```bash
|
||||
brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
|
||||
brew tap asger-weirsoee/tap
|
||||
brew install ahfail
|
||||
```
|
||||
|
||||
After install, Homebrew prints the PAM line to add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` (macOS 12 and earlier). That one-line edit is the only manual step.
|
||||
After install, Homebrew prints the PAM line to add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` (macOS 12 and earlier). That one-line edit is the only manual step. Enable "Require password immediately" in System Settings so the lock screen asks for the password straight away.
|
||||
|
||||
To upgrade: `brew upgrade ahfail`.
|
||||
To upgrade: `brew upgrade ahfail`. To uninstall: `sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui` (or `screensaver`), then `brew uninstall ahfail`.
|
||||
|
||||
### Script install
|
||||
|
||||
```bash
|
||||
git clone https://gitea.weircon.dk/agw/gtk-ahfail.git
|
||||
cd gtk-ahfail
|
||||
bash scripts/install-macos.sh
|
||||
```
|
||||
|
||||
Installs Homebrew dependencies, builds from source, copies binaries to `/usr/local/lib/ahfail/`, and patches the screensaver PAM configuration automatically.
|
||||
|
||||
### Manual install
|
||||
|
||||
```bash
|
||||
brew install gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja
|
||||
# install Rust via https://rustup.rs if not present
|
||||
meson setup builddir && meson compile -C builddir
|
||||
sudo mkdir -p /usr/local/lib/ahfail
|
||||
sudo cp builddir/libahfail_pam.so builddir/ahfail-display /usr/local/lib/ahfail/
|
||||
```
|
||||
|
||||
Add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` as the **first** `auth` line (before any `auth include` or `auth required` entries):
|
||||
|
||||
```
|
||||
auth optional /usr/local/lib/ahfail/libahfail_pam.so
|
||||
```
|
||||
`bash scripts/install-macos.sh` and `bash scripts/uninstall-macos.sh` do the same from a source checkout.
|
||||
|
||||
---
|
||||
|
||||
## Uninstall
|
||||
## Legacy integrations
|
||||
|
||||
### Linux — X11
|
||||
Before `ahfail` became its own locker it hooked into other lockers. These still work and are still built, for anyone who wants to keep their current locker.
|
||||
|
||||
| File | Used by |
|
||||
|---|---|
|
||||
| `ahfail-module.so` | a module for **gtklock** (Wayland) |
|
||||
| `libahfail_pam.so` + `ahfail-display` | a PAM hook for **X11 lockers** (i3lock, i3lock-color, betterlockscreen, xscreensaver, lightdm) |
|
||||
|
||||
**gtklock:**
|
||||
|
||||
```bash
|
||||
bash scripts/uninstall-linux-x11.sh
|
||||
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
|
||||
```
|
||||
|
||||
Removes the `ahfail` line from common PAM service files (`/etc/pam.d/i3lock`, `xscreensaver`, `lightdm`, etc.) and removes the installed binaries. If `builddir` is present it uses `ninja uninstall`; otherwise it removes the known paths manually.
|
||||
`--deadzone=X,Y,W,H` keeps sprites away from your password field, `--audio-uri=URI` swaps the clip.
|
||||
|
||||
### Linux — Wayland
|
||||
**X11 PAM hook:** add this line to the locker's PAM file (for example `/etc/pam.d/i3lock`) **before** `auth include system-auth`. After it, `pam_faillock`'s `[default=die]` inside `system-auth` means the module is never reached on a failed attempt.
|
||||
|
||||
No PAM config was modified. Just stop passing the module to gtklock and optionally remove the file:
|
||||
|
||||
```bash
|
||||
sudo rm -f /usr/lib/gtklock/ahfail-module.so # --prefix=/usr install
|
||||
# or
|
||||
sudo rm -f /usr/local/lib/gtklock/ahfail-module.so
|
||||
```
|
||||
#%PAM-1.0
|
||||
auth optional /usr/lib/ahfail/libahfail_pam.so
|
||||
auth include system-auth
|
||||
```
|
||||
|
||||
### macOS — Homebrew
|
||||
Fedora and RHEL use `/usr/lib64/ahfail/`, Debian and Ubuntu `/usr/lib/x86_64-linux-gnu/ahfail/`; the full path is required. Add `display_path=/path/to/ahfail-display` if the display binary lives elsewhere. The hook never starts the display from a root process (a display manager, for example), so it cannot affect a login screen. `bash scripts/uninstall-linux-x11.sh` removes the line and the files again.
|
||||
|
||||
```bash
|
||||
sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui # or screensaver on macOS 12
|
||||
brew uninstall ahfail
|
||||
```
|
||||
Pre-built binaries of the legacy files are on the [releases page](https://gitea.weircon.dk/agw/gtk-ahfail/releases).
|
||||
|
||||
### macOS — manual / script install
|
||||
|
||||
```bash
|
||||
bash scripts/uninstall-macos.sh
|
||||
```
|
||||
|
||||
Removes the `ahfail` line from the screensaver PAM file and deletes `/usr/local/lib/ahfail/`.
|
||||
On swaylock and hyprlock the PAM hook fires, but the compositor hides everything except the locker itself, so only the audio plays. Use `ahfail` instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -222,34 +130,22 @@ Removes the `ahfail` line from the screensaver PAM file and deletes `/usr/local/
|
||||
|
||||
The sprite is the author's face on Nedry's body. To use your own:
|
||||
|
||||
1. Replace the sprite frames in `assets/sprites/` with your own PNG sequence.
|
||||
1. Replace the sprite frames in `assets/` with your own PNG sequence.
|
||||
2. Update `assets/ahfail.gresource.xml` if you add or remove files.
|
||||
3. Rebuild with Meson.
|
||||
|
||||
To replace the audio clip, swap `assets/audio/magic-word.mp3` and rebuild.
|
||||
To replace the audio clip, swap `assets/magic-word.mp3` and rebuild.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo test # unit + integration tests (needs display)
|
||||
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
|
||||
cargo clippy # lint
|
||||
meson setup builddir && meson compile -C builddir # full build including .so
|
||||
xvfb-run meson test -C builddir --verbose # Meson tests headless
|
||||
cargo test # unit + integration tests (some need a display)
|
||||
cargo test --test benchmarks -- --nocapture # sprite placement benchmarks
|
||||
cargo clippy # lint
|
||||
meson setup builddir && meson compile -C builddir # full build including the .so files
|
||||
xvfb-run meson test -C builddir --verbose # Meson tests headless
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### swaylock (Wayland)
|
||||
|
||||
The PAM module can be added to `/etc/pam.d/swaylock` and will fire on each failed attempt — but on Wayland the `ahfail-display` binary cannot draw on top of the lock screen. The Wayland session-lock protocol (`ext-session-lock-v1`) restricts rendering to the locker process itself, so the animation is suppressed by the compositor and only the audio plays.
|
||||
|
||||
Full support requires swaylock to expose a plugin API similar to gtklock's. There is an open request for this upstream. Once available, a dedicated swaylock module can be added alongside the existing gtklock one.
|
||||
|
||||
### hyprlock (Wayland)
|
||||
|
||||
Same situation as swaylock — PAM fires correctly but the display is blocked by the compositor. hyprlock does not currently have a plugin/module API. Full animation support is pending upstream plugin support.
|
||||
The update check (a daily look at the releases page with a desktop notification) is a cargo feature, `update-check`, on by default. Distro packages build without it, since the package manager handles updates there.
|
||||
|
||||
Reference in New Issue
Block a user