Compare commits
39 Commits
9492ddb50e
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5817074f1a | ||
|
|
8ecc1501a1 | ||
|
|
a6de85650d | ||
|
|
6a931cf4f0 | ||
|
|
dc8c73a0dd | ||
|
|
2e40a0a23a | ||
|
|
3fae21c7a4 | ||
|
|
57054a7c02 | ||
|
|
b8a03d72bb | ||
|
|
f951cb9c6d | ||
|
|
e41ae1cd7f | ||
|
|
3e7bc18f65 | ||
|
|
1f927bdbb2 | ||
|
|
2c2e693193 | ||
|
|
9dbca0ec51 | ||
|
|
702f449d0e | ||
|
|
3323844c33 | ||
|
|
1f98f89fab | ||
|
|
c3bdb09bd3 | ||
|
|
2ddef2ac81 | ||
|
|
7323e08c95 | ||
|
|
692cd76ceb | ||
|
|
0f128d1c6f | ||
|
|
4b9d69ffbc | ||
|
|
c24bd26ba1 | ||
|
|
abf8aef1ef | ||
|
|
f93ca6267c | ||
|
|
e1f8c1d58f | ||
|
|
468699e316 | ||
|
|
355828d4d9 | ||
|
|
74e0f544a0 | ||
|
|
7913ced403 | ||
|
|
097dd52998 | ||
|
|
f05e93b75e | ||
|
|
3dc0733cd0 | ||
|
|
2b89653be6 | ||
|
|
8dd06377fc | ||
|
|
9c546c69ee | ||
|
|
3cdbc4fec9 |
78
.gitea/workflows/release.yml
Normal file
78
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Compute source tarball SHA256
|
||||||
|
run: |
|
||||||
|
SHA256=$(curl -fsSL \
|
||||||
|
"https://gitea.weircon.dk/agw/gtk-ahfail/archive/${{ github.ref_name }}.tar.gz" \
|
||||||
|
| sha256sum | cut -d' ' -f1)
|
||||||
|
echo "SOURCE_SHA256=${SHA256}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build release
|
||||||
|
run: |
|
||||||
|
meson setup builddir --buildtype=release
|
||||||
|
meson compile -C builddir
|
||||||
|
|
||||||
|
- name: Bundle artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
cp builddir/ahfail-module.so dist/
|
||||||
|
cp builddir/libahfail_pam.so dist/
|
||||||
|
cp builddir/ahfail-display dist/
|
||||||
|
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.
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITEA_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
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone \
|
||||||
|
"https://oauth2:${GITEA_TOKEN}@gitea.weircon.dk/agw/homebrew-ahfail.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
|
||||||
40
.gitea/workflows/test.yml
Normal file
40
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Run Rust tests
|
||||||
|
run: xvfb-run cargo test
|
||||||
|
|
||||||
|
- name: Meson build
|
||||||
|
run: |
|
||||||
|
meson setup builddir
|
||||||
|
meson compile -C builddir
|
||||||
|
|
||||||
|
- name: Meson tests
|
||||||
|
run: xvfb-run meson test -C builddir --verbose
|
||||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 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").
|
||||||
|
|
||||||
|
## Build System
|
||||||
|
|
||||||
|
The build is a two-stage hybrid:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Always use Meson for the final shared object; `cargo build` alone does not produce the loadable module.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First-time setup
|
||||||
|
meson setup builddir
|
||||||
|
|
||||||
|
# Build
|
||||||
|
meson compile -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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
`tests/benchmarks.rs` — criterion benchmarks for sprite placement.
|
||||||
|
|
||||||
|
`tests/module_test.c` — C smoke test built by Meson that dlopen-style verifies the exported symbols exist.
|
||||||
880
Cargo.lock
generated
880
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
31
Cargo.toml
@@ -1,23 +1,8 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "ahfail"
|
members = [
|
||||||
version = "0.1.0"
|
"crates/ahfail-ui",
|
||||||
edition = "2021"
|
"crates/ahfail-gtklock",
|
||||||
|
"crates/ahfail-pam",
|
||||||
[lib]
|
"crates/ahfail-display",
|
||||||
name = "ahfail_module"
|
]
|
||||||
crate-type = ["staticlib", "rlib"]
|
resolver = "2"
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
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 = { version = "0.18", package = "gstreamer-player" }
|
|
||||||
glib = { version = "0.15", package = "glib" }
|
|
||||||
gio = { version = "0.15", package = "gio" }
|
|
||||||
gdk-pixbuf = "0.15"
|
|
||||||
libc = "0.2"
|
|
||||||
once_cell = "1.10"
|
|
||||||
rand = "0.8"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
pkg-config = "0.3"
|
|
||||||
|
|||||||
56
GEMINI.md
Normal file
56
GEMINI.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# GEMINI.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This project is a `gtklock` module named `ahfail`, written in Rust (using `gtk-rs`) with a Meson build system.
|
||||||
|
|
||||||
|
The module listens for failed unlock attempts (`PW_FAILURE`) in `gtklock`. Upon failure, it:
|
||||||
|
1. Spawns a looping "Nedry" sprite animation at a random screen location.
|
||||||
|
2. Plays an audio clip ("ah ah ah, you didn't say the magic word").
|
||||||
|
3. Avoids placing sprites in a user-configurable "deadzone".
|
||||||
|
|
||||||
|
All assets (images and audio) are compiled into the module binary as GResources.
|
||||||
|
|
||||||
|
## Build Architecture
|
||||||
|
|
||||||
|
* **Meson:** The primary build system. It handles:
|
||||||
|
* Compiling GResources (`assets/ahfail.gresource.xml`).
|
||||||
|
* Invoking Cargo to build the Rust code as a static library (`libahfail_module.a`).
|
||||||
|
* Linking the Rust static library, GResources, and C dependencies into the final shared object (`ahfail-module.so`).
|
||||||
|
* **Cargo:** Handles the Rust source code, dependencies (`gtk`, `gdk`, `gstreamer`), and tests.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
* `src/lib.rs`: FFI entry points (`on_activation`, `on_window_create`, etc.) exported to C.
|
||||||
|
* `src/handler.rs`: Main logic for sprite placement and audio playback.
|
||||||
|
* `src/config.rs`: Argument parsing logic.
|
||||||
|
* `tests/ahfail_tests.rs`: Comprehensive integration tests mocking `gtklock` behavior.
|
||||||
|
* `meson.build`: Build definition bridging C and Rust.
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
* Meson, Ninja, Rust (Cargo)
|
||||||
|
* `gtk3` development headers
|
||||||
|
* `gstreamer` + `gst-plugins-base` + `gst-plugins-good` (runtime)
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
meson setup builddir
|
||||||
|
|
||||||
|
# Build
|
||||||
|
meson compile -C builddir
|
||||||
|
|
||||||
|
# Test (Rust logic)
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run (Manual test)
|
||||||
|
gtklock -d -m builddir/ahfail-module.so -- --deadzone=X,Y,W,H
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **Safety:** Use `WindowContext` wrappers in `src/context.rs` to handle unsafe `Window` pointers.
|
||||||
|
* **State:** `MODULE_STATE` (thread-local) holds global config/assets. `WindowData` (heap-allocated) holds per-window sprites and players.
|
||||||
|
* **Tests:** `tests/ahfail_tests.rs` contains integration tests that mock `gtklock` structures. Run with `cargo test`.
|
||||||
20
crates/ahfail-display/Cargo.toml
Normal file
20
crates/ahfail-display/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "ahfail-display"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ahfail-display"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ahfail-ui = { path = "../ahfail-ui" }
|
||||||
|
gtk = { version = "0.15", package = "gtk", features = ["v3_24"] }
|
||||||
|
gdk = { version = "0.15", package = "gdk", features = ["v3_24"] }
|
||||||
|
gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] }
|
||||||
|
glib = { version = "0.15", package = "glib" }
|
||||||
|
cairo = { version = "0.15", package = "cairo-rs" }
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
cc = "1"
|
||||||
|
pkg-config = "0.3"
|
||||||
56
crates/ahfail-display/build.rs
Normal file
56
crates/ahfail-display/build.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
// Workspace root is two levels up from crates/ahfail-display/
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Re-run if any asset changes
|
||||||
|
println!("cargo:rerun-if-changed={}", gresource_xml.display());
|
||||||
|
for entry in std::fs::read_dir(&assets_dir).unwrap() {
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
println!("cargo:rerun-if-changed={}", entry.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 — install libglib2.0-dev or equivalent");
|
||||||
|
|
||||||
|
assert!(status.success(), "glib-compile-resources failed");
|
||||||
|
|
||||||
|
// Use pkg-config to get include flags for gio-2.0
|
||||||
|
let gio_cflags = Command::new("pkg-config")
|
||||||
|
.args(["--cflags", "gio-2.0"])
|
||||||
|
.output()
|
||||||
|
.expect("pkg-config not found");
|
||||||
|
assert!(gio_cflags.status.success(), "pkg-config --cflags gio-2.0 failed");
|
||||||
|
let gio_cflags_str = String::from_utf8(gio_cflags.stdout).unwrap();
|
||||||
|
|
||||||
|
let mut build = cc::Build::new();
|
||||||
|
build.file(&c_src).flag_if_supported("-w"); // suppress warnings in generated code
|
||||||
|
for flag in gio_cflags_str.split_whitespace() {
|
||||||
|
build.flag(flag);
|
||||||
|
}
|
||||||
|
build.compile("ahfail_resources");
|
||||||
|
|
||||||
|
// Link against gio-2.0 for GResource support
|
||||||
|
println!("cargo:rustc-link-lib=gio-2.0");
|
||||||
|
// Link against X11 for XGrabKeyboard (Linux only)
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
|
||||||
|
println!("cargo:rustc-link-lib=X11");
|
||||||
|
}
|
||||||
|
}
|
||||||
210
crates/ahfail-display/src/main.rs
Normal file
210
crates/ahfail-display/src/main.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::gdk;
|
||||||
|
use gstreamer as gst;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3";
|
||||||
|
const FAILSAFE_MINUTES: u32 = 15;
|
||||||
|
|
||||||
|
static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
extern "C" fn handle_sigterm(_: libc::c_int) {
|
||||||
|
SIGTERM_RECEIVED.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod x11_grab {
|
||||||
|
use std::os::raw::{c_char, c_int, c_ulong, c_void};
|
||||||
|
type XDisplay = c_void;
|
||||||
|
type Window = c_ulong;
|
||||||
|
type Atom = c_ulong;
|
||||||
|
|
||||||
|
const GRAB_SUCCESS: c_int = 0;
|
||||||
|
const GRAB_MODE_ASYNC: c_int = 1;
|
||||||
|
const XA_CARDINAL: Atom = 6;
|
||||||
|
const PROP_MODE_REPLACE: c_int = 0;
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
fn XOpenDisplay(name: *const c_char) -> *mut XDisplay;
|
||||||
|
fn XCloseDisplay(dpy: *mut XDisplay) -> c_int;
|
||||||
|
fn XDefaultRootWindow(dpy: *mut XDisplay) -> Window;
|
||||||
|
fn XGrabKeyboard(
|
||||||
|
dpy: *mut XDisplay, grab_window: Window, owner_events: c_int,
|
||||||
|
pointer_mode: c_int, keyboard_mode: c_int, time: c_ulong,
|
||||||
|
) -> c_int;
|
||||||
|
fn XUngrabKeyboard(dpy: *mut XDisplay, time: c_ulong) -> c_int;
|
||||||
|
fn XInternAtom(dpy: *mut XDisplay, name: *const c_char, only_if_exists: c_int) -> Atom;
|
||||||
|
fn XChangeProperty(
|
||||||
|
dpy: *mut XDisplay, w: Window, property: Atom, type_: Atom,
|
||||||
|
format: c_int, mode: c_int, data: *const u8, nelements: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
fn XFlush(dpy: *mut XDisplay) -> c_int;
|
||||||
|
fn gdk_x11_window_get_xid(window: *mut c_void) -> c_ulong;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_screen_unlocked() -> bool {
|
||||||
|
unsafe {
|
||||||
|
let dpy = XOpenDisplay(std::ptr::null());
|
||||||
|
if dpy.is_null() { return false; }
|
||||||
|
let root = XDefaultRootWindow(dpy);
|
||||||
|
let result = XGrabKeyboard(dpy, root, 0, GRAB_MODE_ASYNC, GRAB_MODE_ASYNC, 0);
|
||||||
|
if result == GRAB_SUCCESS {
|
||||||
|
XUngrabKeyboard(dpy, 0);
|
||||||
|
}
|
||||||
|
XCloseDisplay(dpy);
|
||||||
|
result == GRAB_SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell picom/compton not to draw a shadow around this window.
|
||||||
|
/// Works by setting _COMPTON_SHADOW=0 directly on the X window,
|
||||||
|
/// which the compositor respects regardless of its config.
|
||||||
|
pub fn disable_compositor_shadow(gdk_window: &gdk::Window) {
|
||||||
|
use glib::ObjectType;
|
||||||
|
unsafe {
|
||||||
|
let xid = gdk_x11_window_get_xid(gdk_window.as_ptr() as *mut c_void);
|
||||||
|
let dpy = XOpenDisplay(std::ptr::null());
|
||||||
|
if dpy.is_null() { return; }
|
||||||
|
let atom = XInternAtom(dpy, b"_COMPTON_SHADOW\0".as_ptr() as *const c_char, 0);
|
||||||
|
let value: u32 = 0;
|
||||||
|
XChangeProperty(
|
||||||
|
dpy, xid, atom, XA_CARDINAL, 32,
|
||||||
|
PROP_MODE_REPLACE, &value as *const u32 as *const u8, 1,
|
||||||
|
);
|
||||||
|
XFlush(dpy);
|
||||||
|
XCloseDisplay(dpy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
unsafe {
|
||||||
|
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-instance guard
|
||||||
|
let lock_file = std::fs::OpenOptions::new()
|
||||||
|
.create(true).write(true)
|
||||||
|
.open("/tmp/ahfail-display.lock");
|
||||||
|
let lock_file = match lock_file {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give PAM a moment to complete, then bail if auth already succeeded.
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if x11_grab::is_screen_unlocked() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if gtk::init().is_err() { return; }
|
||||||
|
if gst::init().is_err() { return; }
|
||||||
|
|
||||||
|
let animation = unsafe { ahfail_ui::animation::load_animation() };
|
||||||
|
let Some(animation) = animation else { return; };
|
||||||
|
|
||||||
|
let Some(display) = gdk::Display::default() else { return; };
|
||||||
|
let Some(monitor) = display.primary_monitor().or_else(|| display.monitor(0)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let geom = monitor.geometry();
|
||||||
|
let (screen_w, screen_h) = (geom.width(), geom.height());
|
||||||
|
|
||||||
|
let config = parse_args();
|
||||||
|
let (sprite_x, sprite_y) = ahfail_ui::display::sprite_position(
|
||||||
|
&animation, screen_w, screen_h, &config,
|
||||||
|
);
|
||||||
|
let sprite_w = animation.width();
|
||||||
|
let sprite_h = animation.height();
|
||||||
|
|
||||||
|
// Window sized exactly to the sprite — no full-screen background to worry about
|
||||||
|
let window = gtk::Window::new(gtk::WindowType::Popup);
|
||||||
|
window.set_decorated(false);
|
||||||
|
window.set_keep_above(true);
|
||||||
|
window.set_skip_taskbar_hint(true);
|
||||||
|
window.set_accept_focus(false);
|
||||||
|
window.set_type_hint(gdk::WindowTypeHint::Notification);
|
||||||
|
window.move_(geom.x() + sprite_x, geom.y() + sprite_y);
|
||||||
|
window.set_default_size(sprite_w, sprite_h);
|
||||||
|
|
||||||
|
// RGBA visual so sprite edges (from PNG alpha) composite correctly
|
||||||
|
if let Some(screen) = window.screen() {
|
||||||
|
if let Some(visual) = screen.rgba_visual() {
|
||||||
|
window.set_visual(Some(&visual));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.set_app_paintable(true);
|
||||||
|
window.connect_draw(|_, cr| {
|
||||||
|
cr.set_source_rgba(0.0, 0.0, 0.0, 0.0);
|
||||||
|
cr.set_operator(cairo::Operator::Source);
|
||||||
|
let _ = cr.paint();
|
||||||
|
gtk::Inhibit(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let image = gtk::Image::from_animation(&animation);
|
||||||
|
window.add(&image);
|
||||||
|
|
||||||
|
let player = ahfail_ui::audio::create_player(AUDIO_URI);
|
||||||
|
player.play();
|
||||||
|
|
||||||
|
std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION));
|
||||||
|
|
||||||
|
let volume_state = ahfail_ui::volume::save_and_set_max();
|
||||||
|
|
||||||
|
glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || {
|
||||||
|
gtk::main_quit();
|
||||||
|
glib::Continue(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
glib::timeout_add(Duration::from_millis(200), || {
|
||||||
|
if SIGTERM_RECEIVED.load(Ordering::Relaxed) {
|
||||||
|
gtk::main_quit();
|
||||||
|
return glib::Continue(false);
|
||||||
|
}
|
||||||
|
glib::Continue(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
glib::timeout_add(Duration::from_millis(500), || {
|
||||||
|
if x11_grab::is_screen_unlocked() {
|
||||||
|
gtk::main_quit();
|
||||||
|
return glib::Continue(false);
|
||||||
|
}
|
||||||
|
glib::Continue(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
window.show_all();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(gdk_win) = window.window() {
|
||||||
|
x11_grab::disable_compositor_shadow(&gdk_win);
|
||||||
|
}
|
||||||
|
|
||||||
|
gtk::main();
|
||||||
|
|
||||||
|
player.stop();
|
||||||
|
if let Some(vs) = volume_state {
|
||||||
|
ahfail_ui::volume::restore(vs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> ahfail_ui::config::ModuleConfig {
|
||||||
|
let deadzone = std::env::args()
|
||||||
|
.find(|a| a.starts_with("--deadzone="))
|
||||||
|
.and_then(|a| {
|
||||||
|
let val = a.trim_start_matches("--deadzone=");
|
||||||
|
let p: Vec<&str> = val.split(',').collect();
|
||||||
|
if p.len() != 4 { return None; }
|
||||||
|
let x: i32 = p[0].parse().ok()?;
|
||||||
|
let y: i32 = p[1].parse().ok()?;
|
||||||
|
let w: i32 = p[2].parse().ok()?;
|
||||||
|
let h: i32 = p[3].parse().ok()?;
|
||||||
|
Some(gdk::Rectangle::new(x, y, w, h))
|
||||||
|
});
|
||||||
|
ahfail_ui::config::ModuleConfig { deadzone }
|
||||||
|
}
|
||||||
21
crates/ahfail-gtklock/Cargo.toml
Normal file
21
crates/ahfail-gtklock/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "ahfail-gtklock"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ahfail_module"
|
||||||
|
crate-type = ["staticlib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ahfail-ui = { path = "../ahfail-ui" }
|
||||||
|
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 = { version = "0.18", package = "gstreamer-player" }
|
||||||
|
glib = { version = "0.15", package = "glib" }
|
||||||
|
gdk-pixbuf = "0.15"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
pkg-config = "0.3"
|
||||||
1
crates/ahfail-gtklock/src/config.rs
Normal file
1
crates/ahfail-gtklock/src/config.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub use ahfail_ui::config::*;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
use gtk::prelude::*; // Keep this if extension traits are needed, otherwise remove. Window uses it.
|
use gtk::{glib, gdk};
|
||||||
use gtk::{glib, gdk}; // Window uses gdk, glib.
|
|
||||||
use gtk::gdk::Monitor;
|
use gtk::gdk::Monitor;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
use gtk::{glib, gdk, gdk_pixbuf, gio};
|
|
||||||
use gstreamer as gst;
|
|
||||||
use gstreamer_player as gst_player;
|
|
||||||
use rand::Rng;
|
|
||||||
use crate::state::{MODULE_STATE, WindowData};
|
use crate::state::{MODULE_STATE, WindowData};
|
||||||
use crate::context::WindowContext;
|
use crate::context::WindowContext;
|
||||||
|
|
||||||
const SPRITE_MARGIN: i32 = 100;
|
|
||||||
const SPRITE_SCALE: f64 = 0.6;
|
|
||||||
const PLAYER_POOL_SIZE: usize = 3;
|
const PLAYER_POOL_SIZE: usize = 3;
|
||||||
const RETRY_ATTEMPTS: usize = 10;
|
|
||||||
|
|
||||||
pub struct WindowHandler;
|
pub struct WindowHandler;
|
||||||
|
|
||||||
@@ -39,7 +32,7 @@ impl WindowHandler {
|
|||||||
MODULE_STATE.with(|state| {
|
MODULE_STATE.with(|state| {
|
||||||
if let Some(audio_uri) = &state.borrow().audio_uri {
|
if let Some(audio_uri) = &state.borrow().audio_uri {
|
||||||
for _ in 0..PLAYER_POOL_SIZE {
|
for _ in 0..PLAYER_POOL_SIZE {
|
||||||
ready_players.push(Self::create_player(audio_uri));
|
ready_players.push(ahfail_ui::audio::create_player(audio_uri));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -63,66 +56,40 @@ impl WindowHandler {
|
|||||||
}
|
}
|
||||||
println!("[ahfail] Error label changed to: '{}'", text_str);
|
println!("[ahfail] Error label changed to: '{}'", text_str);
|
||||||
|
|
||||||
|
MODULE_STATE.with(|s| {
|
||||||
|
let mut st = s.borrow_mut();
|
||||||
|
if st.volume_state.is_none() {
|
||||||
|
st.volume_state = ahfail_ui::volume::save_and_set_max();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
MODULE_STATE.with(|state| {
|
MODULE_STATE.with(|state| {
|
||||||
let state = state.borrow();
|
let state = state.borrow();
|
||||||
if let (Some(animation), Some(audio_uri)) = (&state.animation, &state.audio_uri) {
|
if let (Some(animation), Some(audio_uri)) = (&state.animation, &state.audio_uri) {
|
||||||
let data = unsafe { &mut *(ptr_addr as *mut WindowData) };
|
let data = unsafe { &mut *(ptr_addr as *mut WindowData) };
|
||||||
|
|
||||||
let image = gtk::Image::from_animation(animation);
|
let image = ahfail_ui::display::place_sprite(
|
||||||
image.show();
|
&data.fixed, animation, screen_w, screen_h, &state.config
|
||||||
|
);
|
||||||
let sprite_w = animation.width();
|
|
||||||
let sprite_h = animation.height();
|
|
||||||
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
|
|
||||||
let safe_w = screen_w - SPRITE_MARGIN;
|
|
||||||
let safe_h = screen_h - SPRITE_MARGIN;
|
|
||||||
|
|
||||||
let max_x = if safe_w > sprite_w { safe_w - sprite_w } else { 0 };
|
|
||||||
let max_y = if safe_h > sprite_h { safe_h - sprite_h } else { 0 };
|
|
||||||
|
|
||||||
let mut x = 0;
|
|
||||||
let mut y = 0;
|
|
||||||
let mut found_safe_spot = false;
|
|
||||||
|
|
||||||
for _ in 0..RETRY_ATTEMPTS {
|
|
||||||
x = rng.gen_range(0..=max_x);
|
|
||||||
y = rng.gen_range(0..=max_y);
|
|
||||||
|
|
||||||
if let Some(deadzone) = &state.config.deadzone {
|
|
||||||
let sprite_rect = gdk::Rectangle::new(x, y, sprite_w, sprite_h);
|
|
||||||
if deadzone.intersect(&sprite_rect).is_none() {
|
|
||||||
found_safe_spot = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
found_safe_spot = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found_safe_spot {
|
|
||||||
println!("[ahfail] Could not find safe spot after retries, placing anyway");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("[ahfail] Placing sprite at ({}, {})", x, y);
|
|
||||||
data.fixed.put(&image, x, y);
|
|
||||||
data.sprites.push(image);
|
data.sprites.push(image);
|
||||||
|
|
||||||
let player = if let Some(p) = data.ready_players.pop() {
|
let player = if let Some(p) = data.ready_players.pop() {
|
||||||
p
|
p
|
||||||
} else {
|
} else {
|
||||||
Self::create_player(audio_uri)
|
ahfail_ui::audio::create_player(audio_uri)
|
||||||
};
|
};
|
||||||
|
|
||||||
player.play();
|
player.play();
|
||||||
data.active_players.push(player);
|
data.active_players.push(player);
|
||||||
|
|
||||||
let new_player = Self::create_player(audio_uri);
|
let new_player = ahfail_ui::audio::create_player(audio_uri);
|
||||||
data.ready_players.push(new_player);
|
data.ready_players.push(new_player);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
ahfail_ui::update::check_for_update(ahfail_ui::VERSION);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -150,16 +117,5 @@ impl WindowHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_player(uri: &str) -> gst_player::Player {
|
|
||||||
let player = gst_player::Player::new(None, None);
|
|
||||||
player.set_uri(Some(uri));
|
|
||||||
player.connect_end_of_stream(glib::clone!(@weak player => move |_| {
|
|
||||||
player.seek(gst::ClockTime::from_seconds(0));
|
|
||||||
}));
|
|
||||||
player.connect_error(|_, err| {
|
|
||||||
eprintln!("[ahfail] GStreamer Player Error: {}", err);
|
|
||||||
});
|
|
||||||
player
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,12 +3,9 @@ pub mod context;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
|
|
||||||
use gtk::prelude::*;
|
use gtk::glib;
|
||||||
use gtk::{glib, gdk_pixbuf, gio};
|
|
||||||
use gtk::gdk_pixbuf::InterpType;
|
|
||||||
use gstreamer as gst;
|
use gstreamer as gst;
|
||||||
use std::ffi::{c_void, CStr};
|
use std::ffi::c_void;
|
||||||
use glib::translate::from_glib_none;
|
|
||||||
use std::os::raw::{c_char, c_int, c_uint};
|
use std::os::raw::{c_char, c_int, c_uint};
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
|
|
||||||
@@ -18,9 +15,6 @@ pub use context::{Window, GtkLock, WindowContext, __IncompleteArrayField};
|
|||||||
pub use state::{MODULE_STATE, WindowData, ModuleState};
|
pub use state::{MODULE_STATE, WindowData, ModuleState};
|
||||||
pub use handler::WindowHandler;
|
pub use handler::WindowHandler;
|
||||||
|
|
||||||
// Scale factor to reduce the image size and avoid cropping/overlap
|
|
||||||
const SPRITE_SCALE: f64 = 0.6;
|
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub static module_name: [c_char; 7] = [
|
pub static module_name: [c_char; 7] = [
|
||||||
b'a' as c_char,
|
b'a' as c_char,
|
||||||
@@ -45,7 +39,7 @@ pub static mut module_entries: [glib::ffi::GOptionEntry; 3] = [
|
|||||||
short_name: 0,
|
short_name: 0,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
arg: glib::ffi::G_OPTION_ARG_STRING,
|
arg: glib::ffi::G_OPTION_ARG_STRING,
|
||||||
arg_data: unsafe { &raw mut DEADZONE_ARG as *mut _ },
|
arg_data: &raw mut DEADZONE_ARG as *mut _,
|
||||||
description: DEADZONE_DESC.as_ptr() as *const c_char,
|
description: DEADZONE_DESC.as_ptr() as *const c_char,
|
||||||
arg_description: DEADZONE_ARG_DESC.as_ptr() as *const c_char
|
arg_description: DEADZONE_ARG_DESC.as_ptr() as *const c_char
|
||||||
},
|
},
|
||||||
@@ -53,10 +47,6 @@ pub static mut module_entries: [glib::ffi::GOptionEntry; 3] = [
|
|||||||
glib::ffi::GOptionEntry { long_name: ptr::null(), short_name: 0, flags: 0, arg: 0, arg_data: ptr::null_mut(), description: ptr::null(), arg_description: ptr::null() },
|
glib::ffi::GOptionEntry { long_name: ptr::null(), short_name: 0, flags: 0, arg: 0, arg_data: ptr::null_mut(), description: ptr::null(), arg_description: ptr::null() },
|
||||||
];
|
];
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
fn ahfail_get_resource() -> *mut gio::ffi::GResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for tests to inspect window data
|
// Helper for tests to inspect window data
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `ctx` must be a valid Window pointer.
|
/// `ctx` must be a valid Window pointer.
|
||||||
@@ -83,47 +73,7 @@ pub unsafe extern "C" fn on_activation(_gtklock: *mut GtkLock, _id: c_int) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resource_ptr = ahfail_get_resource();
|
let anim_opt = unsafe { ahfail_ui::animation::load_animation() };
|
||||||
if !resource_ptr.is_null() {
|
|
||||||
let resource = from_glib_none::<_, gio::Resource>(resource_ptr);
|
|
||||||
gio::resources_register(&resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load frames
|
|
||||||
let mut loaded_frames: Vec<gdk_pixbuf::Pixbuf> = Vec::new();
|
|
||||||
match gio::resources_enumerate_children("/ahfail/sprites", gio::ResourceLookupFlags::NONE) {
|
|
||||||
Ok(mut frames) => {
|
|
||||||
frames.sort();
|
|
||||||
for frame_path in frames {
|
|
||||||
let full_path = format!("/ahfail/sprites/{}", frame_path);
|
|
||||||
match gdk_pixbuf::Pixbuf::from_resource(&full_path) {
|
|
||||||
Ok(pixbuf) => {
|
|
||||||
let w = (pixbuf.width() as f64 * SPRITE_SCALE) as i32;
|
|
||||||
let h = (pixbuf.height() as f64 * SPRITE_SCALE) as i32;
|
|
||||||
if let Some(scaled) = pixbuf.scale_simple(w, h, InterpType::Bilinear) {
|
|
||||||
loaded_frames.push(scaled);
|
|
||||||
} else {
|
|
||||||
loaded_frames.push(pixbuf);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => eprintln!("Failed to load sprite frame {}: {}", full_path, e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => eprintln!("Failed to enumerate sprites: {}", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
let anim_opt = if !loaded_frames.is_empty() {
|
|
||||||
let first = &loaded_frames[0];
|
|
||||||
let anim = gdk_pixbuf::PixbufSimpleAnim::new(first.width(), first.height(), 12.0);
|
|
||||||
anim.set_loop(true);
|
|
||||||
for frame in loaded_frames {
|
|
||||||
anim.add_frame(&frame);
|
|
||||||
}
|
|
||||||
Some(anim.upcast())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = ModuleConfig::from_args();
|
let config = ModuleConfig::from_args();
|
||||||
|
|
||||||
@@ -186,6 +136,9 @@ pub unsafe extern "C" fn on_idle_show(_gtklock: *mut GtkLock) {}
|
|||||||
pub unsafe extern "C" fn g_module_unload(_module: *mut c_void) {
|
pub unsafe extern "C" fn g_module_unload(_module: *mut c_void) {
|
||||||
MODULE_STATE.with(|state| {
|
MODULE_STATE.with(|state| {
|
||||||
let mut state = state.borrow_mut();
|
let mut state = state.borrow_mut();
|
||||||
|
if let Some(vs) = state.volume_state.take() {
|
||||||
|
ahfail_ui::volume::restore(vs);
|
||||||
|
}
|
||||||
state.animation = None;
|
state.animation = None;
|
||||||
state.audio_uri = None;
|
state.audio_uri = None;
|
||||||
state.config.deadzone = None;
|
state.config.deadzone = None;
|
||||||
@@ -7,6 +7,7 @@ pub struct ModuleState {
|
|||||||
pub animation: Option<gdk_pixbuf::PixbufAnimation>,
|
pub animation: Option<gdk_pixbuf::PixbufAnimation>,
|
||||||
pub audio_uri: Option<String>,
|
pub audio_uri: Option<String>,
|
||||||
pub config: ModuleConfig,
|
pub config: ModuleConfig,
|
||||||
|
pub volume_state: Option<ahfail_ui::volume::VolumeState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct WindowData {
|
pub struct WindowData {
|
||||||
@@ -22,5 +23,6 @@ thread_local! {
|
|||||||
animation: None,
|
animation: None,
|
||||||
audio_uri: None,
|
audio_uri: None,
|
||||||
config: ModuleConfig { deadzone: None },
|
config: ModuleConfig { deadzone: None },
|
||||||
|
volume_state: None,
|
||||||
}) };
|
}) };
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ fn run_test_09_idle_hide_cleanup() {
|
|||||||
flush_events();
|
flush_events();
|
||||||
|
|
||||||
// Mock GtkLock struct
|
// Mock GtkLock struct
|
||||||
let mut windows_array = glib::ffi::g_array_new(0, 0, std::mem::size_of::<*mut Window>() as u32);
|
let windows_array = glib::ffi::g_array_new(0, 0, std::mem::size_of::<*mut Window>() as u32);
|
||||||
glib::ffi::g_array_append_vals(windows_array, &ctx_ptr as *const _ as *const c_void, 1);
|
glib::ffi::g_array_append_vals(windows_array, &ctx_ptr as *const _ as *const c_void, 1);
|
||||||
|
|
||||||
let mut lock = GtkLock {
|
let mut lock = GtkLock {
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
use gtk::prelude::*;
|
use gtk::gdk_pixbuf;
|
||||||
use gtk::{gdk, gdk_pixbuf};
|
|
||||||
use gstreamer as gst;
|
use gstreamer as gst;
|
||||||
use gstreamer_player as gst_player;
|
use gstreamer_player as gst_player;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
11
crates/ahfail-pam/Cargo.toml
Normal file
11
crates/ahfail-pam/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "ahfail-pam"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ahfail_pam"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libc = "0.2"
|
||||||
11
crates/ahfail-pam/build.rs
Normal file
11
crates/ahfail-pam/build.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rustc-link-lib=pam");
|
||||||
|
|
||||||
|
// Emit AHFAIL_INSTALL_DIR so DEFAULT_PATH in lib.rs is correct on multiarch Linux
|
||||||
|
// (e.g. /usr/lib/x86_64-linux-gnu) and on Apple Silicon macOS (/opt/homebrew/lib).
|
||||||
|
// Meson passes AHFAIL_LIBDIR=<libdir> when building; fall back per platform otherwise.
|
||||||
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
|
let fallback = if target_os == "macos" { "/usr/local/lib" } else { "/usr/lib" };
|
||||||
|
let libdir = std::env::var("AHFAIL_LIBDIR").unwrap_or_else(|_| fallback.to_string());
|
||||||
|
println!("cargo:rustc-env=AHFAIL_INSTALL_DIR={}/ahfail", libdir);
|
||||||
|
}
|
||||||
146
crates/ahfail-pam/src/lib.rs
Normal file
146
crates/ahfail-pam/src/lib.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use libc::{c_char, c_int, c_void, pid_t};
|
||||||
|
use std::ffi::CString;
|
||||||
|
|
||||||
|
pub const PAM_SUCCESS: c_int = 0;
|
||||||
|
pub const PAM_IGNORE: c_int = 25;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub const PAM_DATA_REPLACE: c_int = 0x00000002;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32;
|
||||||
|
|
||||||
|
const DEFAULT_PATH: &str = concat!(env!("AHFAIL_INSTALL_DIR"), "/ahfail-display");
|
||||||
|
|
||||||
|
pub fn default_display_path() -> &'static str { DEFAULT_PATH }
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct PamHandle { _private: [u8; 0] }
|
||||||
|
|
||||||
|
type CleanupFn = unsafe extern "C" fn(*mut PamHandle, *mut c_void, c_int);
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
fn pam_set_data(
|
||||||
|
pamh: *mut PamHandle,
|
||||||
|
name: *const c_char,
|
||||||
|
data: *mut c_void,
|
||||||
|
cleanup: Option<CleanupFn>,
|
||||||
|
) -> c_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn ahfail_cleanup(
|
||||||
|
_pamh: *mut PamHandle,
|
||||||
|
data: *mut c_void,
|
||||||
|
_error_status: c_int,
|
||||||
|
) {
|
||||||
|
if !data.is_null() {
|
||||||
|
drop(Box::from_raw(data as *mut String));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn pam_sm_authenticate(
|
||||||
|
pamh: *mut PamHandle,
|
||||||
|
_flags: c_int,
|
||||||
|
argc: c_int,
|
||||||
|
argv: *const *const c_char,
|
||||||
|
) -> c_int {
|
||||||
|
if pamh.is_null() { return PAM_IGNORE; }
|
||||||
|
|
||||||
|
let args = argc_argv(argc, argv);
|
||||||
|
let display_path = read_display_path_arg(args);
|
||||||
|
|
||||||
|
// /proc/self/comm is the name of the current process (the locker that loaded us).
|
||||||
|
let locker_name = std::fs::read_to_string("/proc/self/comm")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
spawn_display(display_path.clone(), &locker_name);
|
||||||
|
|
||||||
|
let key = match CString::new("dk.weircon.ahfail") {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(_) => return PAM_IGNORE,
|
||||||
|
};
|
||||||
|
let path_ptr: *mut c_void = match display_path {
|
||||||
|
Some(p) => Box::into_raw(Box::new(p)) as *mut c_void,
|
||||||
|
None => std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
let ret = pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup));
|
||||||
|
if ret != PAM_SUCCESS && !path_ptr.is_null() {
|
||||||
|
drop(Box::from_raw(path_ptr as *mut String));
|
||||||
|
}
|
||||||
|
PAM_IGNORE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn pam_sm_setcred(
|
||||||
|
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
|
||||||
|
) -> c_int { PAM_IGNORE }
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn pam_sm_acct_mgmt(
|
||||||
|
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
|
||||||
|
) -> c_int { PAM_IGNORE }
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn pam_sm_open_session(
|
||||||
|
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
|
||||||
|
) -> c_int { PAM_IGNORE }
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn pam_sm_close_session(
|
||||||
|
_pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char,
|
||||||
|
) -> c_int { PAM_IGNORE }
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
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 }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_display_path_arg(args: &[*const c_char]) -> Option<String> {
|
||||||
|
let prefix = b"display_path=";
|
||||||
|
for &arg in args {
|
||||||
|
if arg.is_null() { continue; }
|
||||||
|
let s = unsafe { std::ffi::CStr::from_ptr(arg) }.to_bytes();
|
||||||
|
if s.starts_with(prefix) {
|
||||||
|
return std::str::from_utf8(&s[prefix.len()..]).ok().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_display(path_override: Option<String>, locker_name: &str) {
|
||||||
|
let path = path_override.unwrap_or_else(|| DEFAULT_PATH.to_string());
|
||||||
|
let cpath = match CString::new(path.as_str()) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let name_arg = format!("--locker-name={}", locker_name);
|
||||||
|
let cname_arg = match CString::new(name_arg.as_str()) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
unsafe {
|
||||||
|
let pid: pid_t = libc::fork();
|
||||||
|
if pid < 0 { return; }
|
||||||
|
if pid > 0 {
|
||||||
|
libc::waitpid(pid, std::ptr::null_mut(), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pid2: pid_t = libc::fork();
|
||||||
|
if pid2 != 0 { libc::_exit(0); }
|
||||||
|
// Grandchild: detach and exec.
|
||||||
|
libc::setsid();
|
||||||
|
let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as c_int;
|
||||||
|
for fd in 0..max_fd.min(4096) { libc::close(fd); }
|
||||||
|
let args: [*const c_char; 3] = [cpath.as_ptr(), cname_arg.as_ptr(), std::ptr::null()];
|
||||||
|
libc::execv(cpath.as_ptr(), args.as_ptr());
|
||||||
|
libc::_exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/ahfail-pam/tests/pam_tests.rs
Normal file
21
crates/ahfail-pam/tests/pam_tests.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
23
crates/ahfail-ui/Cargo.toml
Normal file
23
crates/ahfail-ui/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "ahfail-ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ahfail_ui"
|
||||||
|
crate-type = ["rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
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 = { version = "0.18", package = "gstreamer-player" }
|
||||||
|
glib = { version = "0.15", package = "glib" }
|
||||||
|
gio = { version = "0.15", package = "gio" }
|
||||||
|
gdk-pixbuf = "0.15"
|
||||||
|
rand = "0.8"
|
||||||
|
ureq = "2"
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
43
crates/ahfail-ui/src/animation.rs
Normal file
43
crates/ahfail-ui/src/animation.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use gtk::{gdk_pixbuf, gio};
|
||||||
|
use gdk_pixbuf::InterpType;
|
||||||
|
use glib::Cast;
|
||||||
|
|
||||||
|
const SPRITE_SCALE: f64 = 0.6;
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
fn ahfail_get_resource() -> *mut gio::ffi::GResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers GResources and loads all sprite frames into a looping PixbufSimpleAnim.
|
||||||
|
/// Returns None if GResources unavailable or no frames found.
|
||||||
|
pub unsafe fn load_animation() -> Option<gdk_pixbuf::PixbufAnimation> {
|
||||||
|
use glib::translate::from_glib_none;
|
||||||
|
|
||||||
|
let resource_ptr = ahfail_get_resource();
|
||||||
|
if resource_ptr.is_null() { return None; }
|
||||||
|
let resource = from_glib_none::<_, gio::Resource>(resource_ptr);
|
||||||
|
gio::resources_register(&resource);
|
||||||
|
|
||||||
|
let mut frames = gio::resources_enumerate_children(
|
||||||
|
"/ahfail/sprites", gio::ResourceLookupFlags::NONE,
|
||||||
|
).ok()?;
|
||||||
|
frames.sort();
|
||||||
|
|
||||||
|
let mut loaded: Vec<gdk_pixbuf::Pixbuf> = Vec::new();
|
||||||
|
for name in frames {
|
||||||
|
let path = format!("/ahfail/sprites/{}", name);
|
||||||
|
if let Ok(pb) = gdk_pixbuf::Pixbuf::from_resource(&path) {
|
||||||
|
let w = (pb.width() as f64 * SPRITE_SCALE) as i32;
|
||||||
|
let h = (pb.height() as f64 * SPRITE_SCALE) as i32;
|
||||||
|
let scaled = pb.scale_simple(w, h, InterpType::Bilinear).unwrap_or(pb);
|
||||||
|
loaded.push(scaled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loaded.is_empty() { return None; }
|
||||||
|
|
||||||
|
let first = &loaded[0];
|
||||||
|
let anim = gdk_pixbuf::PixbufSimpleAnim::new(first.width(), first.height(), 12.0);
|
||||||
|
anim.set_loop(true);
|
||||||
|
for frame in loaded { anim.add_frame(&frame); }
|
||||||
|
Some(anim.upcast())
|
||||||
|
}
|
||||||
12
crates/ahfail-ui/src/audio.rs
Normal file
12
crates/ahfail-ui/src/audio.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use gstreamer_player as gst_player;
|
||||||
|
use gtk::glib;
|
||||||
|
|
||||||
|
pub fn create_player(uri: &str) -> gst_player::Player {
|
||||||
|
let player = gst_player::Player::new(None, None);
|
||||||
|
player.set_uri(Some(uri));
|
||||||
|
player.connect_end_of_stream(glib::clone!(@weak player => move |_| {
|
||||||
|
player.seek(gstreamer::ClockTime::from_seconds(0));
|
||||||
|
}));
|
||||||
|
player.connect_error(|_, err| eprintln!("[ahfail] GStreamer error: {}", err));
|
||||||
|
player
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use gtk::{glib, gdk};
|
use gtk::gdk;
|
||||||
use std::ffi::CStr;
|
use std::ffi::CStr;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
|
|
||||||
51
crates/ahfail-ui/src/display.rs
Normal file
51
crates/ahfail-ui/src/display.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use gtk::{gdk_pixbuf, prelude::*};
|
||||||
|
use rand::Rng;
|
||||||
|
use crate::config::ModuleConfig;
|
||||||
|
|
||||||
|
const SPRITE_MARGIN: i32 = 100;
|
||||||
|
const RETRY_ATTEMPTS: usize = 10;
|
||||||
|
|
||||||
|
pub fn sprite_position(
|
||||||
|
animation: &gdk_pixbuf::PixbufAnimation,
|
||||||
|
screen_w: i32,
|
||||||
|
screen_h: i32,
|
||||||
|
config: &ModuleConfig,
|
||||||
|
) -> (i32, i32) {
|
||||||
|
let sprite_w = animation.width();
|
||||||
|
let sprite_h = animation.height();
|
||||||
|
|
||||||
|
let max_x = screen_w.saturating_sub(SPRITE_MARGIN).saturating_sub(sprite_w).max(0);
|
||||||
|
let max_y = screen_h.saturating_sub(SPRITE_MARGIN).saturating_sub(sprite_h).max(0);
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let mut x = rng.gen_range(0..=max_x);
|
||||||
|
let mut y = rng.gen_range(0..=max_y);
|
||||||
|
|
||||||
|
if let Some(dz) = config.deadzone {
|
||||||
|
for _ in 0..RETRY_ATTEMPTS {
|
||||||
|
let overlaps_x = x < dz.x() + dz.width() && x + sprite_w > dz.x();
|
||||||
|
let overlaps_y = y < dz.y() + dz.height() && y + sprite_h > dz.y();
|
||||||
|
if !(overlaps_x && overlaps_y) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
x = rng.gen_range(0..=max_x);
|
||||||
|
y = rng.gen_range(0..=max_y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn place_sprite(
|
||||||
|
fixed: >k::Fixed,
|
||||||
|
animation: &gdk_pixbuf::PixbufAnimation,
|
||||||
|
screen_w: i32,
|
||||||
|
screen_h: i32,
|
||||||
|
config: &ModuleConfig,
|
||||||
|
) -> gtk::Image {
|
||||||
|
let (x, y) = sprite_position(animation, screen_w, screen_h, config);
|
||||||
|
let image = gtk::Image::from_animation(animation);
|
||||||
|
image.show();
|
||||||
|
fixed.put(&image, x, y);
|
||||||
|
image
|
||||||
|
}
|
||||||
8
crates/ahfail-ui/src/lib.rs
Normal file
8
crates/ahfail-ui/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod animation;
|
||||||
|
pub mod audio;
|
||||||
|
pub mod config;
|
||||||
|
pub mod display;
|
||||||
|
pub mod update;
|
||||||
|
pub mod volume;
|
||||||
|
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
115
crates/ahfail-ui/src/update.rs
Normal file
115
crates/ahfail-ui/src/update.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
const GITEA_API: &str =
|
||||||
|
"https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest";
|
||||||
|
const UPDATE_NOTIFY_MSG: &str =
|
||||||
|
"Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases";
|
||||||
|
const CHECK_INTERVAL_SECS: u64 = 86_400;
|
||||||
|
|
||||||
|
// Guards against multiple windows spawning duplicate checks on the same failure.
|
||||||
|
static CHECK_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub fn is_newer(latest: &str, current: &str) -> bool {
|
||||||
|
let parse = |s: &str| -> Option<(u32, u32, u32)> {
|
||||||
|
let s = s.trim_start_matches('v');
|
||||||
|
let p: Vec<&str> = s.splitn(3, '.').collect();
|
||||||
|
if p.len() != 3 { return None; }
|
||||||
|
Some((p[0].parse().ok()?, p[1].parse().ok()?, p[2].parse().ok()?))
|
||||||
|
};
|
||||||
|
match (parse(latest), parse(current)) {
|
||||||
|
(Some(l), Some(c)) => l > c,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_file() -> PathBuf {
|
||||||
|
let dir = std::env::var("HOME")
|
||||||
|
.map(|h| PathBuf::from(h).join(".cache/ahfail"))
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
PathBuf::from(format!("/tmp/ahfail-cache-{uid}"))
|
||||||
|
});
|
||||||
|
let _ = fs::create_dir_all(&dir);
|
||||||
|
dir.join("last_update_check")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rate_limited() -> bool {
|
||||||
|
let path = cache_file();
|
||||||
|
if let Ok(meta) = fs::metadata(&path) {
|
||||||
|
if let Ok(modified) = meta.modified() {
|
||||||
|
let age = SystemTime::now()
|
||||||
|
.duration_since(modified)
|
||||||
|
.unwrap_or_default();
|
||||||
|
return age.as_secs() < CHECK_INTERVAL_SECS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touch_cache() {
|
||||||
|
let _ = fs::write(cache_file(), b"");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_notification() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let _ = Command::new("notify-send")
|
||||||
|
.args(["ahfail", UPDATE_NOTIFY_MSG])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let _ = Command::new("osascript")
|
||||||
|
.args(["-e", &format!(
|
||||||
|
"display notification \"{}\" with title \"ahfail\"",
|
||||||
|
UPDATE_NOTIFY_MSG
|
||||||
|
)])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn check_for_update(current_version: &str) {
|
||||||
|
if rate_limited() { return; }
|
||||||
|
// Prevent concurrent threads (e.g. multi-monitor) from all firing at once.
|
||||||
|
if CHECK_IN_FLIGHT.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let agent = ureq::AgentBuilder::new()
|
||||||
|
.timeout_connect(std::time::Duration::from_secs(5))
|
||||||
|
.timeout_read(std::time::Duration::from_secs(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let Ok(resp) = agent.get(GITEA_API).call() else {
|
||||||
|
CHECK_IN_FLIGHT.store(false, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
touch_cache();
|
||||||
|
|
||||||
|
let Ok(body) = resp.into_string() else {
|
||||||
|
CHECK_IN_FLIGHT.store(false, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
CHECK_IN_FLIGHT.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if let Some(tag) = extract_tag_name(&body) {
|
||||||
|
if is_newer(&tag, current_version) {
|
||||||
|
send_notification();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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\"";
|
||||||
|
let after_key = json.find(key)? + key.len();
|
||||||
|
let rest = json[after_key..].trim_start_matches(|c: char| c == ':' || c.is_ascii_whitespace());
|
||||||
|
let rest = rest.strip_prefix('"')?;
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
263
crates/ahfail-ui/src/volume.rs
Normal file
263
crates/ahfail-ui/src/volume.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
pub struct VolumeState {
|
||||||
|
pub volume: u32,
|
||||||
|
pub muted: bool,
|
||||||
|
pub lock_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock file format: "{pid}:{volume}:{muted}\n"
|
||||||
|
// Written atomically on acquire; persists volume state so it survives SIGKILL.
|
||||||
|
fn lock_content(pid: u32, volume: u32, muted: bool) -> String {
|
||||||
|
format!("{}:{}:{}\n", pid, volume, u8::from(muted))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_lock_content(s: &str) -> Option<(u32, u32, bool)> {
|
||||||
|
let mut parts = s.trim().splitn(3, ':');
|
||||||
|
let pid: u32 = parts.next()?.parse().ok()?;
|
||||||
|
let vol: u32 = parts.next()?.parse().ok().filter(|&v| v <= 100)?;
|
||||||
|
let muted: bool = parts.next()?.trim() == "1";
|
||||||
|
Some((pid, vol, muted))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pid_is_alive(pid: u32) -> bool {
|
||||||
|
let pid_t = pid as libc::pid_t;
|
||||||
|
if pid_t <= 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// kill(pid, 0) returns 0 if the process exists, -1 (ESRCH) if not.
|
||||||
|
unsafe { libc::kill(pid_t, 0) == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns lock file path: $XDG_RUNTIME_DIR/ahfail.lock or /tmp/ahfail-{uid}.lock
|
||||||
|
pub fn lock_path() -> PathBuf {
|
||||||
|
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||||
|
PathBuf::from(dir).join("ahfail.lock")
|
||||||
|
} else {
|
||||||
|
PathBuf::from(format!("/tmp/ahfail-{}.lock", get_uid()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_uid() -> u32 {
|
||||||
|
unsafe { libc::getuid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If primary: save current system volume/mute state, set volume to 100% unmuted.
|
||||||
|
/// Returns Some(VolumeState) if this process is the primary (should restore on exit).
|
||||||
|
///
|
||||||
|
/// Uses a PID-based lock file that persists the saved volume state. If a stale lock
|
||||||
|
/// exists (holder process dead), recovers the saved volume from the file — preventing
|
||||||
|
/// permanent volume loss after SIGKILL — and takes ownership.
|
||||||
|
pub fn save_and_set_max() -> Option<VolumeState> {
|
||||||
|
let path = lock_path();
|
||||||
|
let our_pid = std::process::id();
|
||||||
|
|
||||||
|
// Try atomic create (O_CREAT | O_EXCL — race-free on POSIX local filesystems).
|
||||||
|
if fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)
|
||||||
|
.map(|mut f| { let _ = f.write_all(b"0:100:0\n"); })
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
// We are primary. Read current volume, overwrite placeholder, set max.
|
||||||
|
let (volume, muted) = get_current_volume();
|
||||||
|
let _ = fs::write(&path, lock_content(our_pid, volume, muted));
|
||||||
|
set_volume_max();
|
||||||
|
return Some(VolumeState { volume, muted, lock_path: path });
|
||||||
|
}
|
||||||
|
|
||||||
|
// File exists — check if the holder PID is still alive.
|
||||||
|
if let Ok(existing) = fs::read_to_string(&path) {
|
||||||
|
if let Some((holder_pid, saved_vol, saved_muted)) = parse_lock_content(&existing) {
|
||||||
|
if !pid_is_alive(holder_pid) {
|
||||||
|
// Stale lock: recover saved volume state and take ownership.
|
||||||
|
// The previous holder already set volume to max, so we skip set_volume_max().
|
||||||
|
let content = lock_content(our_pid, saved_vol, saved_muted);
|
||||||
|
if fs::write(&path, content).is_ok() {
|
||||||
|
return Some(VolumeState { volume: saved_vol, muted: saved_muted, lock_path: path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores volume from saved state and removes lock file.
|
||||||
|
pub fn restore(state: VolumeState) {
|
||||||
|
restore_volume(state.volume, state.muted);
|
||||||
|
let _ = fs::remove_file(&state.lock_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn get_current_volume() -> (u32, bool) {
|
||||||
|
let vol = Command::new("pactl")
|
||||||
|
.args(["get-sink-volume", "@DEFAULT_SINK@"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| parse_pactl_volume(&String::from_utf8_lossy(&o.stdout)))
|
||||||
|
.unwrap_or(100);
|
||||||
|
|
||||||
|
let muted = Command::new("pactl")
|
||||||
|
.args(["get-sink-mute", "@DEFAULT_SINK@"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).contains("yes"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
(vol, muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn parse_pactl_volume(output: &str) -> Option<u32> {
|
||||||
|
for token in output.split_whitespace() {
|
||||||
|
if let Some(pct) = token.strip_suffix('%') {
|
||||||
|
if let Ok(v) = pct.parse::<u32>() {
|
||||||
|
if v <= 100 {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn set_volume_max() {
|
||||||
|
let _ = Command::new("pactl").args(["set-sink-mute", "@DEFAULT_SINK@", "0"]).status();
|
||||||
|
let _ = Command::new("pactl").args(["set-sink-volume", "@DEFAULT_SINK@", "65536"]).status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn restore_volume(volume: u32, muted: bool) {
|
||||||
|
let v = ((volume as u64 * 65536) / 100) as u32;
|
||||||
|
let _ = Command::new("pactl")
|
||||||
|
.args(["set-sink-volume", "@DEFAULT_SINK@", &v.to_string()])
|
||||||
|
.status();
|
||||||
|
let mute_arg = if muted { "1" } else { "0" };
|
||||||
|
let _ = Command::new("pactl").args(["set-sink-mute", "@DEFAULT_SINK@", mute_arg]).status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn get_current_volume() -> (u32, bool) {
|
||||||
|
let output = Command::new("osascript")
|
||||||
|
.args(["-e", "output volume of (get volume settings)"])
|
||||||
|
.output()
|
||||||
|
.ok();
|
||||||
|
let vol = output.as_ref()
|
||||||
|
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
|
||||||
|
.unwrap_or(100);
|
||||||
|
|
||||||
|
let muted = Command::new("osascript")
|
||||||
|
.args(["-e", "output muted of (get volume settings)"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
(vol, muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn set_volume_max() {
|
||||||
|
let _ = Command::new("osascript")
|
||||||
|
.args(["-e", "set volume output volume 100 without output muted"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn restore_volume(volume: u32, muted: bool) {
|
||||||
|
let script = if muted {
|
||||||
|
format!("set volume output volume {} with output muted", volume)
|
||||||
|
} else {
|
||||||
|
format!("set volume output volume {} without output muted", volume)
|
||||||
|
};
|
||||||
|
let _ = Command::new("osascript").args(["-e", &script]).status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
fn get_current_volume() -> (u32, bool) { (100, false) }
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
fn set_volume_max() {}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
fn restore_volume(_volume: u32, _muted: bool) {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_lock_content_roundtrip() {
|
||||||
|
let content = lock_content(1234, 75, true);
|
||||||
|
let (pid, vol, muted) = parse_lock_content(&content).unwrap();
|
||||||
|
assert_eq!(pid, 1234);
|
||||||
|
assert_eq!(vol, 75);
|
||||||
|
assert!(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_lock_content_rejects_invalid_volume() {
|
||||||
|
assert!(parse_lock_content("123:101:0").is_none());
|
||||||
|
assert!(parse_lock_content("123:abc:0").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pid_is_alive_current_process() {
|
||||||
|
assert!(pid_is_alive(std::process::id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pid_is_alive_zero_is_not_treated_as_alive() {
|
||||||
|
// PID 0 has special kill() semantics; we guard against it.
|
||||||
|
assert!(!pid_is_alive(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_lock_creates_file_and_stale_is_recovered() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let lock = dir.path().join("ahfail.lock");
|
||||||
|
|
||||||
|
// Write a stale lock file: PID 99_999_999 is above Linux's max_pid (4_194_304)
|
||||||
|
// and will always fail kill(pid, 0) with ESRCH.
|
||||||
|
std::fs::write(&lock, "99999999:60:1\n").unwrap();
|
||||||
|
|
||||||
|
// Simulate save_and_set_max() stale-recovery path directly.
|
||||||
|
let our_pid = std::process::id();
|
||||||
|
if let Ok(existing) = std::fs::read_to_string(&lock) {
|
||||||
|
if let Some((holder_pid, saved_vol, saved_muted)) = parse_lock_content(&existing) {
|
||||||
|
assert!(!pid_is_alive(holder_pid), "test PID should not be alive");
|
||||||
|
let content = lock_content(our_pid, saved_vol, saved_muted);
|
||||||
|
std::fs::write(&lock, content).unwrap();
|
||||||
|
let (pid, vol, muted) = parse_lock_content(&std::fs::read_to_string(&lock).unwrap()).unwrap();
|
||||||
|
assert_eq!(pid, our_pid);
|
||||||
|
assert_eq!(vol, 60);
|
||||||
|
assert!(muted);
|
||||||
|
} else {
|
||||||
|
panic!("parse_lock_content failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_acquisition_blocked_by_alive_pid() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let lock = dir.path().join("ahfail.lock");
|
||||||
|
|
||||||
|
// Write a lock file held by our own (alive) PID.
|
||||||
|
let our_pid = std::process::id();
|
||||||
|
std::fs::write(&lock, lock_content(our_pid, 80, false)).unwrap();
|
||||||
|
|
||||||
|
// Attempt to acquire — should be blocked because holder is alive.
|
||||||
|
if let Ok(existing) = std::fs::read_to_string(&lock) {
|
||||||
|
if let Some((holder_pid, _, _)) = parse_lock_content(&existing) {
|
||||||
|
assert!(pid_is_alive(holder_pid), "our own PID should be alive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/ahfail-ui/tests/update_tests.rs
Normal file
20
crates/ahfail-ui/tests/update_tests.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use ahfail_ui::update::is_newer;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newer_version_detected() {
|
||||||
|
assert!(is_newer("v0.2.0", "v0.1.0"));
|
||||||
|
assert!(!is_newer("v0.1.0", "v0.1.0"));
|
||||||
|
assert!(!is_newer("v0.1.0", "v0.2.0"));
|
||||||
|
assert!(!is_newer("garbage", "v0.1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_v_prefix() {
|
||||||
|
assert!(is_newer("0.2.0", "0.1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_digit_minor_version() {
|
||||||
|
assert!(is_newer("v0.10.0", "v0.9.0"));
|
||||||
|
assert!(!is_newer("v0.9.0", "v0.10.0"));
|
||||||
|
}
|
||||||
19
crates/ahfail-ui/tests/volume_tests.rs
Normal file
19
crates/ahfail-ui/tests/volume_tests.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Lock file behavior is tested via unit tests in src/volume.rs.
|
||||||
|
// Integration-level: verify that a fresh save_and_set_max() call creates the lock file.
|
||||||
|
// (pactl/osascript may not be available in CI — volume operations are best-effort.)
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_set_max_creates_lock_file() {
|
||||||
|
// Override XDG_RUNTIME_DIR so we use a temp path, not the real user's runtime dir.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::env::set_var("XDG_RUNTIME_DIR", dir.path());
|
||||||
|
|
||||||
|
let result = ahfail_ui::volume::save_and_set_max();
|
||||||
|
// Should succeed (lock file didn't exist).
|
||||||
|
assert!(result.is_some());
|
||||||
|
assert!(dir.path().join("ahfail.lock").exists());
|
||||||
|
|
||||||
|
// Cleanup: restore() removes the lock file.
|
||||||
|
ahfail_ui::volume::restore(result.unwrap());
|
||||||
|
assert!(!dir.path().join("ahfail.lock").exists());
|
||||||
|
}
|
||||||
180
docs/plans/2026-05-05-pam-rewrite-design.md
Normal file
180
docs/plans/2026-05-05-pam-rewrite-design.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Design: PAM module + cross-platform support
|
||||||
|
|
||||||
|
**Date:** 2026-05-05
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`ahfail-module.so` is a gtklock-specific plugin. It works only on Wayland via gtklock. The goal is to also support X11 Linux lock screens and macOS, while keeping the existing Wayland/gtklock integration intact.
|
||||||
|
|
||||||
|
## Decision: Approach A — Two integration points, shared core
|
||||||
|
|
||||||
|
Keep the gtklock module for Wayland. Add a PAM module for X11 Linux and macOS. Share all display/audio/update logic through a common `ahfail-ui` crate.
|
||||||
|
|
||||||
|
| Platform | Lock screen | Integration | Display |
|
||||||
|
|----------|------------|-------------|---------|
|
||||||
|
| Wayland | gtklock | `ahfail-module.so` (gtklock module, kept) | inline GTK overlay via `ahfail-ui` |
|
||||||
|
| X11 Linux | any PAM locker (i3lock, xscreensaver…) | `ahfail-pam.so` | `ahfail-display` binary via `ahfail-ui` |
|
||||||
|
| macOS | loginwindow / screensaver | `ahfail-pam.so` | `ahfail-display` binary via `ahfail-ui` |
|
||||||
|
|
||||||
|
macOS CI is out of scope — users build from source via Homebrew. Linux x86_64 release binaries are produced by Gitea Actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cargo workspace layout
|
||||||
|
|
||||||
|
```
|
||||||
|
ahfail/
|
||||||
|
├── Cargo.toml ← workspace root
|
||||||
|
├── meson.build ← updated for new crate layout
|
||||||
|
├── crates/
|
||||||
|
│ ├── ahfail-gtklock/ → ahfail-module.so (existing module, moved)
|
||||||
|
│ ├── ahfail-pam/ → ahfail-pam.so (new)
|
||||||
|
│ ├── ahfail-display/ → ahfail-display (new binary)
|
||||||
|
│ └── ahfail-ui/ → lib (shared display/audio/update logic)
|
||||||
|
├── assets/ ← unchanged
|
||||||
|
├── include/ ← unchanged
|
||||||
|
└── .gitea/workflows/
|
||||||
|
├── test.yml
|
||||||
|
└── release.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency graph:**
|
||||||
|
- `ahfail-gtklock` → `ahfail-ui`
|
||||||
|
- `ahfail-display` → `ahfail-ui`
|
||||||
|
- `ahfail-pam` → nothing (no GTK/GStreamer; minimal by design)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: `ahfail-ui` (shared crate)
|
||||||
|
|
||||||
|
Contains everything that `ahfail-gtklock` and `ahfail-display` share:
|
||||||
|
|
||||||
|
- Animation loading (GResources → `PixbufSimpleAnim`, `set_loop(true)`)
|
||||||
|
- GTK display: `show_animation_on_overlay(overlay, monitor, deadzone)` — works for both an in-process gtklock overlay and a standalone window
|
||||||
|
- GStreamer audio playback (player pool, seek-to-0 on end-of-stream loop)
|
||||||
|
- Deadzone logic (random placement with retry, `--deadzone=x,y,w,h`)
|
||||||
|
- Update check (see below)
|
||||||
|
- System volume save/restore (`pactl` on Linux, `osascript` on macOS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: PAM module (`ahfail-pam`)
|
||||||
|
|
||||||
|
A minimal C-ABI shared library. No GTK. No GStreamer. No credential handling.
|
||||||
|
|
||||||
|
**Security principle:** we never touch `PAM_AUTHTOK` or any credential. All authentication is performed exclusively by the native host (pam_unix, pam_opendirectory, etc.). We are a pure side-effect observer.
|
||||||
|
|
||||||
|
**`pam_sm_authenticate`:**
|
||||||
|
1. Register a cleanup function via `pam_set_data(pamh, "ahfail", …, cleanup_fn)`
|
||||||
|
2. Return `PAM_IGNORE`
|
||||||
|
|
||||||
|
All other `pam_sm_*` functions return `PAM_IGNORE`.
|
||||||
|
|
||||||
|
**Cleanup function fires in two cases:**
|
||||||
|
|
||||||
|
| `error_status` | Meaning | Action |
|
||||||
|
|---------------|---------|--------|
|
||||||
|
| `PAM_DATA_REPLACE` set | `pam_sm_authenticate` called again (previous attempt failed, same handle) | spawn `ahfail-display` |
|
||||||
|
| `PAM_AUTH_ERR` (no replace) | final failure from `pam_end()` | spawn `ahfail-display` |
|
||||||
|
| `PAM_SUCCESS` (no replace) | auth succeeded from `pam_end()` | `pkill -SIGTERM ahfail-display`; restore volume via state file |
|
||||||
|
|
||||||
|
This handles both lock screens that loop `pam_authenticate()` on one handle and those that create a fresh PAM transaction per attempt.
|
||||||
|
|
||||||
|
**Finding `ahfail-display`:** compile-time default path (`/usr/lib/ahfail/ahfail-display` on Linux, `/usr/local/lib/ahfail/ahfail-display` on macOS), overridable via PAM argument `display_path=/custom/path`.
|
||||||
|
|
||||||
|
**PAM config lines:**
|
||||||
|
```
|
||||||
|
# Linux — /etc/pam.d/gtklock (or i3lock, xscreensaver, etc.)
|
||||||
|
auth optional ahfail-pam.so
|
||||||
|
|
||||||
|
# macOS — /etc/pam.d/screensaver
|
||||||
|
auth optional ahfail-pam.so display_path=/usr/local/lib/ahfail/ahfail-display
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Display binary (`ahfail-display`)
|
||||||
|
|
||||||
|
Spawned by `ahfail-pam.so` via double-fork (fully detached). Uses `ahfail-ui` for all rendering.
|
||||||
|
|
||||||
|
**Startup sequence:**
|
||||||
|
1. Initialise GTK + GStreamer
|
||||||
|
2. Register GResources (same embedded assets as gtklock module)
|
||||||
|
3. Acquire volume lock: atomically create `$XDG_RUNTIME_DIR/ahfail.lock`
|
||||||
|
- If created (first instance): save current volume/mute state into the file, set system volume to 100% unmuted
|
||||||
|
- If already exists: skip volume management
|
||||||
|
4. Open floating, decoration-free, always-on-top GTK window
|
||||||
|
5. Call `ahfail-ui::show_animation_on_overlay(...)` — places Nedry at random position respecting `--deadzone`
|
||||||
|
6. Loop animation (`PixbufSimpleAnim`, `set_loop(true)`) and audio (GStreamer, seek-to-0 on end-of-stream)
|
||||||
|
7. Concurrently: run update check in a background thread (see below)
|
||||||
|
8. Block on GTK main loop until `SIGTERM` or 15-minute failsafe timeout
|
||||||
|
|
||||||
|
**On `SIGTERM` (clean exit):**
|
||||||
|
- If holding volume lock: restore volume/mute state from lock file, delete lock file
|
||||||
|
- Exit
|
||||||
|
|
||||||
|
**Multiple instances:** each failed attempt spawns one additional `ahfail-display`. Each shows one Nedry sprite independently. Only the first instance (lock file holder) manages volume. On success, `pkill -SIGTERM ahfail-display` terminates all instances simultaneously.
|
||||||
|
|
||||||
|
**Volume control:**
|
||||||
|
- Linux: `pactl set-sink-mute @DEFAULT_SINK@ 0 && pactl set-sink-volume @DEFAULT_SINK@ 100%`
|
||||||
|
- macOS: `osascript -e 'set volume output volume 100 without output muted'`
|
||||||
|
- Restore: reverse using saved state from lock file
|
||||||
|
|
||||||
|
**Update check (background thread):**
|
||||||
|
- Calls `https://gitea.weircon.dk/api/v1/repos/agw/gtk-ahfail/releases/latest`, parses `tag_name`
|
||||||
|
- Rate-limited: skips network call if `~/.cache/ahfail/last_update_check` was written within 24 hours
|
||||||
|
- If newer version found: fires desktop notification
|
||||||
|
- Linux: `notify-send "ahfail" "Update available — visit https://gitea.weircon.dk/agw/gtk-ahfail/releases"`
|
||||||
|
- macOS: `osascript -e 'display notification "..." with title "ahfail"'`
|
||||||
|
- Fails silently on any error (no network, bad response, parse failure)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: gtklock module (`ahfail-gtklock`)
|
||||||
|
|
||||||
|
The existing module, moved to `crates/ahfail-gtklock/`. Integration mechanism is unchanged.
|
||||||
|
|
||||||
|
**Changes from current `src/`:**
|
||||||
|
- Display/audio logic extracted into `ahfail-ui`; module calls `ahfail-ui` functions instead of implementing them inline
|
||||||
|
- Volume: on first `error_label` signal (first failure), acquire volume lock and set to 100%. On `on_window_destroy`, restore volume.
|
||||||
|
- Update check: called from `ahfail-ui` on each failure (same shared code path as display binary)
|
||||||
|
- Multiple Nerdys on multiple failures: unchanged — each `error_label` signal adds a new sprite to the overlay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5: Gitea CI/CD
|
||||||
|
|
||||||
|
**`.gitea/workflows/test.yml`** — on push and pull request:
|
||||||
|
```
|
||||||
|
deps: libgtk-3-dev libgstreamer1.0-dev gstreamer-plugins-base libpam0g-dev
|
||||||
|
→ cargo test
|
||||||
|
→ meson setup builddir && meson compile -C builddir
|
||||||
|
→ meson test -C builddir
|
||||||
|
```
|
||||||
|
|
||||||
|
**`.gitea/workflows/release.yml`** — on `v*` tag push:
|
||||||
|
```
|
||||||
|
→ same build with --buildtype=release
|
||||||
|
→ bundle into ahfail-linux-x86_64.tar.gz:
|
||||||
|
ahfail-module.so (gtklock/Wayland)
|
||||||
|
ahfail-pam.so (PAM/X11+macOS)
|
||||||
|
ahfail-display (display binary)
|
||||||
|
→ create Gitea release + upload asset via curl to Gitea API
|
||||||
|
using GITEA_TOKEN secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS:** no CI. README documents:
|
||||||
|
```bash
|
||||||
|
brew install gtk+3 gstreamer gst-plugins-good gst-plugins-base
|
||||||
|
cargo build --release
|
||||||
|
meson setup builddir && meson compile -C builddir
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- macOS CI / cross-compilation (SDK licensing + native dep complexity)
|
||||||
|
- Supporting Wayland compositors other than gtklock (protocol blocks external surfaces during lock)
|
||||||
|
- Auto-installing updates (notification only)
|
||||||
1521
docs/plans/2026-05-05-pam-rewrite-impl.md
Normal file
1521
docs/plans/2026-05-05-pam-rewrite-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
48
meson.build
48
meson.build
@@ -20,16 +20,49 @@ resources = gnome.compile_resources(
|
|||||||
c_name: 'ahfail'
|
c_name: 'ahfail'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Each cargo target uses its own --target-dir to avoid parallel lock contention.
|
||||||
cargo_target = custom_target(
|
cargo_target = custom_target(
|
||||||
'ahfail-cargo-build',
|
'ahfail-cargo-build',
|
||||||
input: ['src/lib.rs', 'Cargo.toml'],
|
input: ['crates/ahfail-gtklock/src/lib.rs', 'Cargo.toml'],
|
||||||
output: ['libahfail_module.a'],
|
output: ['libahfail_module.a'],
|
||||||
command: [
|
command: [
|
||||||
'sh', '-c', 'cargo build --release --target-dir "@OUTDIR@/target" && cp "@OUTDIR@/target/release/libahfail_module.a" "@OUTPUT@"'
|
'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
|
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'],
|
||||||
|
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@"'
|
||||||
|
],
|
||||||
|
build_by_default: true,
|
||||||
|
install: true,
|
||||||
|
install_dir: get_option('libdir') / 'ahfail',
|
||||||
|
install_mode: 'rwxr-xr-x'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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'
|
||||||
|
)
|
||||||
|
|
||||||
libahfail = shared_library(
|
libahfail = shared_library(
|
||||||
'ahfail-module',
|
'ahfail-module',
|
||||||
resources,
|
resources,
|
||||||
@@ -51,3 +84,14 @@ smoke = executable(
|
|||||||
)
|
)
|
||||||
|
|
||||||
test('module symbols', smoke)
|
test('module symbols', smoke)
|
||||||
|
|
||||||
|
# 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)]
|
||||||
|
)
|
||||||
|
|
||||||
|
test('pam symbols', pam_smoke,
|
||||||
|
args: [meson.current_build_dir() / 'libahfail_pam.so'],
|
||||||
|
depends: pam_cargo)
|
||||||
|
|||||||
272
readme.md
272
readme.md
@@ -1,38 +1,79 @@
|
|||||||
# Ah ah ah, you didn't say the magic word
|
# Ah ah ah, you didn't say the magic word
|
||||||
|
|
||||||
This `gtklock` module listens for failed unlock attempts and recreates Dennis Nedry’s “ah ah ah” lockout scene from Jurassic Park.
|
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.
|
||||||
|
|
||||||
* **Animation:** Spawns a looping "Nedry" sprite at a random location on the screen.
|
---
|
||||||
* **Audio:** Plays the "ah ah ah, you didn't say the magic word" clip.
|
|
||||||
* **Safety:** Sprites avoid overlapping a configurable "deadzone" (e.g., your login box).
|
|
||||||
* **Performance:** Uses pre-warmed audio players for low latency
|
|
||||||
|
|
||||||
## Requirements
|
> **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.
|
||||||
|
>
|
||||||
|
> 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.**
|
||||||
|
|
||||||
* **Build:** Meson, Ninja, Rust (Cargo), GTK+3 development headers.
|
---
|
||||||
* **Runtime:** `gtklock`, `gstreamer`, `gst-plugins-base`, `gst-plugins-good` (for audio playback).
|
|
||||||
|
|
||||||
## Build & Install
|
## Platform support
|
||||||
|
|
||||||
1. **Install Dependencies:**
|
The integration method differs by display server:
|
||||||
```bash
|
|
||||||
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Build:**
|
| Platform | Display server | Module loaded | How it works |
|
||||||
```bash
|
|---|---|---|---|
|
||||||
meson setup builddir
|
| Linux | **Wayland** | `ahfail-module.so` | Loaded directly by `gtklock` via its module API |
|
||||||
meson compile -C builddir
|
| 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 |
|
||||||
|
|
||||||
3. **Install:**
|
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.
|
||||||
```bash
|
|
||||||
sudo meson install -C builddir
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
---
|
||||||
|
|
||||||
Run `gtklock` with the module path:
|
## 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 |
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Then follow the [Wayland](#usage-linux---wayland-only-gtklock) or [X11](#usage-linux---x11-i3lock-xscreensaver-etc) configuration below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linux compile
|
||||||
|
|
||||||
|
### Install dependencies and build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gtklock
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Linux - wayland (only gtklock)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gtklock -m /usr/lib/gtklock/ahfail-module.so
|
gtklock -m /usr/lib/gtklock/ahfail-module.so
|
||||||
@@ -40,26 +81,175 @@ gtklock -m /usr/lib/gtklock/ahfail-module.so
|
|||||||
|
|
||||||
### Arguments
|
### Arguments
|
||||||
|
|
||||||
* `--deadzone=X,Y,W,H`: Defines a rectangle where sprites will *not* spawn (e.g., to keep your password field visible).
|
Pass module arguments after `--`:
|
||||||
```bash
|
|
||||||
gtklock -m ahfail-module.so -- --deadzone=860,440,200,200
|
|
||||||
```
|
|
||||||
*(Note the `--` separator before module arguments)*
|
|
||||||
|
|
||||||
* `--audio-uri=URI`: Override the default audio clip.
|
```bash
|
||||||
```bash
|
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
|
||||||
gtklock -m ahfail-module.so -- --audio-uri=file:///home/user/custom.mp3
|
```
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
- `--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`
|
||||||
|
|
||||||
* **Run Tests:** `cargo test`
|
---
|
||||||
* **Benchmarks:** `cargo test --test benchmarks -- --nocapture`
|
|
||||||
* **Linting:** `cargo clippy`
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
### Homebrew (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
|
||||||
|
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.
|
||||||
|
|
||||||
|
To upgrade: `brew upgrade 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
### Linux — X11
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/uninstall-linux-x11.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Linux — Wayland
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS — Homebrew
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sed -i '' '/ahfail/d' /etc/pam.d/screensaverui # or screensaver on macOS 12
|
||||||
|
brew uninstall ahfail
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
To change the default sprite or audio:
|
The sprite is the author's face on Nedry's body. To use your own:
|
||||||
1. Replace files in `assets/`.
|
|
||||||
2. Update `assets/ahfail.gresource.xml`.
|
1. Replace the sprite frames in `assets/sprites/` with your own PNG sequence.
|
||||||
3. Rebuild with Meson.
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
99
scripts/install-macos.sh
Executable file
99
scripts/install-macos.sh
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install-macos.sh — build and install ahfail on macOS
|
||||||
|
# Run from the repository root: bash scripts/install-macos.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/usr/local/lib/ahfail"
|
||||||
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
|
|
||||||
|
step() { echo "${BOLD}==> $*${RESET}"; }
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ "$(uname)" == "Darwin" ]] || die "This script is for macOS only."
|
||||||
|
|
||||||
|
# ── 1. Homebrew ───────────────────────────────────────────────────────────────
|
||||||
|
if ! command -v brew &>/dev/null; then
|
||||||
|
die "Homebrew is required. Install it from https://brew.sh then re-run this script."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Installing Homebrew dependencies..."
|
||||||
|
brew install --quiet gtk+3 gstreamer gst-plugins-base gst-plugins-good meson ninja
|
||||||
|
|
||||||
|
# ── 2. Rust ───────────────────────────────────────────────────────────────────
|
||||||
|
if ! command -v cargo &>/dev/null; then
|
||||||
|
step "Installing Rust via rustup..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Build ──────────────────────────────────────────────────────────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
step "Building ahfail..."
|
||||||
|
MESON_FLAGS=("--buildtype=release" "-Dlibdir=${INSTALL_DIR%/ahfail}")
|
||||||
|
if [[ -d builddir ]]; then
|
||||||
|
meson setup builddir "${MESON_FLAGS[@]}" --wipe
|
||||||
|
else
|
||||||
|
meson setup builddir "${MESON_FLAGS[@]}"
|
||||||
|
fi
|
||||||
|
meson compile -C builddir
|
||||||
|
|
||||||
|
# ── 4. Install binaries ───────────────────────────────────────────────────────
|
||||||
|
step "Installing binaries to ${INSTALL_DIR}/ (requires sudo)..."
|
||||||
|
sudo mkdir -p "$INSTALL_DIR"
|
||||||
|
sudo cp builddir/libahfail_pam.so "$INSTALL_DIR/"
|
||||||
|
sudo cp builddir/ahfail-display "$INSTALL_DIR/"
|
||||||
|
sudo chmod 755 "$INSTALL_DIR/libahfail_pam.so" "$INSTALL_DIR/ahfail-display"
|
||||||
|
|
||||||
|
# ── 5. Configure PAM ─────────────────────────────────────────────────────────
|
||||||
|
step "Configuring PAM..."
|
||||||
|
|
||||||
|
# macOS 13+ uses screensaverui; 12 and earlier uses screensaver
|
||||||
|
if [[ -f /etc/pam.d/screensaverui ]]; then
|
||||||
|
PAM_FILE="/etc/pam.d/screensaverui"
|
||||||
|
elif [[ -f /etc/pam.d/screensaver ]]; then
|
||||||
|
PAM_FILE="/etc/pam.d/screensaver"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo " Could not find a screensaver PAM file. Add this line manually"
|
||||||
|
echo " to the appropriate file in /etc/pam.d/ (after the auth entries):"
|
||||||
|
echo ""
|
||||||
|
echo " auth optional ${INSTALL_DIR}/libahfail_pam.so"
|
||||||
|
echo ""
|
||||||
|
echo "Done (PAM not configured automatically)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PAM_LINE="auth optional ${INSTALL_DIR}/libahfail_pam.so"
|
||||||
|
|
||||||
|
if grep -qF "$PAM_LINE" "$PAM_FILE"; then
|
||||||
|
echo " PAM already configured in ${PAM_FILE}."
|
||||||
|
else
|
||||||
|
# Insert after the last 'auth' line so we stay in the auth block.
|
||||||
|
sudo python3 - "$PAM_FILE" "$PAM_LINE" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
|
||||||
|
pam_file, new_line = sys.argv[1], sys.argv[2] + "\n"
|
||||||
|
lines = open(pam_file).readlines()
|
||||||
|
|
||||||
|
if new_line in lines:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Find the last line that starts with 'auth'
|
||||||
|
last_auth = max(
|
||||||
|
(i for i, l in enumerate(lines) if l.strip().startswith("auth")),
|
||||||
|
default=len(lines) - 1,
|
||||||
|
)
|
||||||
|
lines.insert(last_auth + 1, new_line)
|
||||||
|
open(pam_file, "w").writelines(lines)
|
||||||
|
PYEOF
|
||||||
|
echo " Added to ${PAM_FILE}."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "${BOLD}Done.${RESET} Lock your screen and type the wrong password to test."
|
||||||
54
scripts/uninstall-linux-x11.sh
Executable file
54
scripts/uninstall-linux-x11.sh
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# uninstall-linux-x11.sh — remove ahfail PAM module from X11 screen lockers
|
||||||
|
# Run from the repository root (uses ninja uninstall if builddir is present).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
|
|
||||||
|
step() { echo "${BOLD}==> $*${RESET}"; }
|
||||||
|
|
||||||
|
[[ "$(uname)" == "Linux" ]] || { echo "This script is for Linux only." >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── 1. Remove PAM config lines ────────────────────────────────────────────────
|
||||||
|
step "Removing ahfail from PAM service files..."
|
||||||
|
removed_pam=0
|
||||||
|
for pam_file in /etc/pam.d/i3lock /etc/pam.d/xscreensaver /etc/pam.d/lightdm \
|
||||||
|
/etc/pam.d/gdm /etc/pam.d/sddm /etc/pam.d/gtklock; do
|
||||||
|
if [[ -f "$pam_file" ]] && grep -q "ahfail" "$pam_file"; then
|
||||||
|
echo " Patching ${pam_file}..."
|
||||||
|
sudo sed -i '/ahfail/d' "$pam_file"
|
||||||
|
removed_pam=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ $removed_pam -eq 0 ]] && echo " No ahfail PAM entries found in common service files."
|
||||||
|
|
||||||
|
# ── 2. Remove installed files ─────────────────────────────────────────────────
|
||||||
|
step "Removing installed files..."
|
||||||
|
|
||||||
|
# Prefer meson's own uninstall if the builddir is still present
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUILDDIR="${SCRIPT_DIR}/../builddir"
|
||||||
|
if [[ -f "${BUILDDIR}/build.ninja" ]]; then
|
||||||
|
echo " Running meson uninstall..."
|
||||||
|
sudo ninja -C "$BUILDDIR" uninstall
|
||||||
|
else
|
||||||
|
echo " builddir not found — removing known paths manually..."
|
||||||
|
for dir in /usr/lib/ahfail /usr/local/lib/ahfail \
|
||||||
|
/usr/lib64/ahfail /usr/lib/x86_64-linux-gnu/ahfail; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
sudo rm -rf "$dir"
|
||||||
|
echo " Removed ${dir}."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for f in /usr/lib/gtklock/ahfail-module.so \
|
||||||
|
/usr/local/lib/gtklock/ahfail-module.so; do
|
||||||
|
if [[ -f "$f" ]]; then
|
||||||
|
sudo rm -f "$f"
|
||||||
|
echo " Removed ${f}."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "${BOLD}Done.${RESET}"
|
||||||
36
scripts/uninstall-macos.sh
Executable file
36
scripts/uninstall-macos.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# uninstall-macos.sh — remove ahfail from macOS (manual/script install)
|
||||||
|
# For Homebrew installs: brew uninstall ahfail, then run this to clean up PAM.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/usr/local/lib/ahfail"
|
||||||
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
|
|
||||||
|
step() { echo "${BOLD}==> $*${RESET}"; }
|
||||||
|
|
||||||
|
[[ "$(uname)" == "Darwin" ]] || { echo "This script is for macOS only." >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── 1. Remove PAM config line ─────────────────────────────────────────────────
|
||||||
|
step "Removing ahfail from PAM configuration..."
|
||||||
|
removed_pam=0
|
||||||
|
for pam_file in /etc/pam.d/screensaverui /etc/pam.d/screensaver; do
|
||||||
|
if [[ -f "$pam_file" ]] && grep -q "ahfail" "$pam_file"; then
|
||||||
|
echo " Patching ${pam_file}..."
|
||||||
|
sudo sed -i '' '/ahfail/d' "$pam_file"
|
||||||
|
removed_pam=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ $removed_pam -eq 0 ]] && echo " No ahfail PAM entries found."
|
||||||
|
|
||||||
|
# ── 2. Remove binaries ────────────────────────────────────────────────────────
|
||||||
|
step "Removing binaries..."
|
||||||
|
if [[ -d "$INSTALL_DIR" ]]; then
|
||||||
|
sudo rm -rf "$INSTALL_DIR"
|
||||||
|
echo " Removed ${INSTALL_DIR}."
|
||||||
|
else
|
||||||
|
echo " ${INSTALL_DIR} not found — skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "${BOLD}Done.${RESET}"
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
pub fn time_execution<F, T>(name: &str, f: F) -> T
|
|
||||||
where
|
|
||||||
F: FnOnce() -> T,
|
|
||||||
{
|
|
||||||
let start = Instant::now();
|
|
||||||
let result = f();
|
|
||||||
let duration = start.elapsed();
|
|
||||||
println!("[benchmark] {}: {:?}", name, duration);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
18
tests/pam_smoke_test.c
Normal file
18
tests/pam_smoke_test.c
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#include <dlfcn.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
if (argc < 2) {
|
||||||
|
fprintf(stderr, "Usage: %s <libahfail_pam.so>\n", argv[0]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
void *lib = dlopen(argv[1], RTLD_NOW);
|
||||||
|
if (!lib) { fprintf(stderr, "dlopen: %s\n", dlerror()); return 1; }
|
||||||
|
if (!dlsym(lib, "pam_sm_authenticate")) {
|
||||||
|
fprintf(stderr, "pam_sm_authenticate not found\n");
|
||||||
|
dlclose(lib);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
dlclose(lib);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user