feat: add ahfail-display standalone binary

Implements the ahfail-display binary crate: GTK popup window that spawns
the Nedry sprite and plays the audio clip, with SIGTERM handling, 15-minute
failsafe, deadzone CLI parsing, volume save/restore, and update check.
Adds a build.rs that compiles GResources via glib-compile-resources so the
binary can be built with plain `cargo build` outside of Meson.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-05-06 11:54:22 +02:00
parent e1f8c1d58f
commit f93ca6267c
4 changed files with 181 additions and 1 deletions

View File

@@ -2,3 +2,19 @@
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"] }
gstreamer-player = { version = "0.18", package = "gstreamer-player" }
glib = { version = "0.15", package = "glib" }
libc = "0.2"
[build-dependencies]
cc = "1"
pkg-config = "0.3"

View File

@@ -0,0 +1,51 @@
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");
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");
}

View File

@@ -1 +1,103 @@
fn main() {}
use gtk::prelude::*;
use gtk::gdk;
use gstreamer as gst;
use std::sync::atomic::{AtomicBool, Ordering};
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);
}
fn main() {
unsafe {
libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t);
}
if gtk::init().is_err() {
eprintln!("[ahfail-display] GTK init failed");
return;
}
if gst::init().is_err() {
eprintln!("[ahfail-display] GStreamer init failed");
return;
}
let animation = unsafe { ahfail_ui::animation::load_animation() };
let Some(animation) = animation else {
eprintln!("[ahfail-display] No animation frames found");
return;
};
let volume_state = ahfail_ui::volume::save_and_set_max();
let display = gdk::Display::default().expect("[ahfail-display] No display");
let monitor = display
.primary_monitor()
.or_else(|| display.monitor(0))
.expect("[ahfail-display] No monitor");
let geom = monitor.geometry();
let screen_w = geom.width();
let screen_h = geom.height();
let window = gtk::Window::new(gtk::WindowType::Popup);
window.set_decorated(false);
window.set_keep_above(true);
window.set_skip_taskbar_hint(true);
window.move_(geom.x(), geom.y());
window.set_default_size(screen_w, screen_h);
let fixed = gtk::Fixed::new();
fixed.set_size_request(screen_w, screen_h);
window.add(&fixed);
let config = parse_args();
ahfail_ui::display::place_sprite(&fixed, &animation, screen_w, screen_h, &config);
let player = ahfail_ui::audio::create_player(AUDIO_URI);
player.play();
std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION));
glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || {
gtk::main_quit();
glib::Continue(false)
});
glib::timeout_add(std::time::Duration::from_millis(200), move || {
if SIGTERM_RECEIVED.load(Ordering::Relaxed) {
gtk::main_quit();
return glib::Continue(false);
}
glib::Continue(true)
});
window.show_all();
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 }
}