This commit is contained in:
Asger Geel Weirsøe
2025-11-30 21:08:42 +01:00
parent 8c08e33ba0
commit 382b5a489e
42 changed files with 2192 additions and 0 deletions

58
.gitignore vendored Normal file
View File

@@ -0,0 +1,58 @@
# Generated by Cargo
# will have compiled files and executables
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.mypy_cache/
.pytest_cache/
/.project
/.pydevproject
/.settings
/.cproject
/.idea
/.vscode
__pycache__
/.coverage/
/.coveragerc
/install dir
/work area
/meson-test-run.txt
/meson-test-run.xml
/meson-cross-test-run.txt
/meson-cross-test-run.xml
.DS_Store
*~
*.swp
packagecache
.wraplock
/MANIFEST
/build
/dist
/meson.egg-info
/docs/built_docs
/docs/hotdoc-private*
*.pyc
/*venv*

1002
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "ahfail"
version = "0.1.0"
edition = "2021"
[lib]
name = "ahfail_module"
crate-type = ["staticlib", "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"
libc = "0.2"
once_cell = "1.10"
rand = "0.8"
[build-dependencies]
pkg-config = "0.3"

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/ahfail">
<file alias="audio/magic-word.mp3">magic-word.mp3</file>
<file alias="sprites/frame__0000.png">frame__0000.png</file>
<file alias="sprites/frame__0001.png">frame__0001.png</file>
<file alias="sprites/frame__0002.png">frame__0002.png</file>
<file alias="sprites/frame__0003.png">frame__0003.png</file>
<file alias="sprites/frame__0004.png">frame__0004.png</file>
<file alias="sprites/frame__0005.png">frame__0005.png</file>
<file alias="sprites/frame__0006.png">frame__0006.png</file>
<file alias="sprites/frame__0007.png">frame__0007.png</file>
<file alias="sprites/frame__0008.png">frame__0008.png</file>
<file alias="sprites/frame__0009.png">frame__0009.png</file>
<file alias="sprites/frame__0010.png">frame__0010.png</file>
<file alias="sprites/frame__0011.png">frame__0011.png</file>
<file alias="sprites/frame__0012.png">frame__0012.png</file>
<file alias="sprites/frame__0013.png">frame__0013.png</file>
<file alias="sprites/frame__0014.png">frame__0014.png</file>
<file alias="sprites/frame__0015.png">frame__0015.png</file>
<file alias="sprites/frame__0016.png">frame__0016.png</file>
<file alias="sprites/frame__0017.png">frame__0017.png</file>
<file alias="sprites/frame__0018.png">frame__0018.png</file>
<file alias="sprites/frame__0019.png">frame__0019.png</file>
<file alias="sprites/frame__0020.png">frame__0020.png</file>
<file alias="sprites/frame__0021.png">frame__0021.png</file>
<file alias="sprites/frame__0022.png">frame__0022.png</file>
<file alias="sprites/frame__0023.png">frame__0023.png</file>
<file alias="sprites/frame__0024.png">frame__0024.png</file>
</gresource>
</gresources>

BIN
assets/frame__0000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
assets/frame__0001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0003.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0004.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0005.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0006.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
assets/frame__0007.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
assets/frame__0008.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
assets/frame__0009.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
assets/frame__0010.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
assets/frame__0011.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
assets/frame__0012.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0013.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0014.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
assets/frame__0015.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0016.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0017.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0018.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0019.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0020.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0021.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
assets/frame__0022.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0023.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/frame__0024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
assets/magic-word.mp3 Normal file

Binary file not shown.

8
include/ahfail/module.h Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
#include "gtklock-module.h"
extern const char module_name[];
extern const guint module_major_version;
extern const guint module_minor_version;
extern GOptionEntry module_entries[];

100
include/gtklock-module.h Normal file
View File

@@ -0,0 +1,100 @@
// gtklock-userinfo-module
// Copyright (c) 2024 Jovan Lanik
// Module header
#include "gtk/gtk.h"
struct Window {
GdkMonitor *monitor;
GtkWidget *window;
GtkWidget *overlay;
GtkWidget *window_box;
GtkWidget *body_revealer;
GtkWidget *body_grid;
GtkWidget *input_label;
GtkWidget *input_field;
GtkWidget *message_revealer;
GtkWidget *message_scrolled_window;
GtkWidget *message_box;
GtkWidget *unlock_button;
GtkWidget *error_label;
GtkWidget *warning_label;
GtkWidget *info_box;
GtkWidget *time_box;
GtkWidget *clock_label;
GtkWidget *date_label;
void *module_data[];
};
struct GtkLock {
GtkApplication *app;
void *lock;
pid_t parent;
GArray *windows;
GArray *messages;
GArray *errors;
struct Window *focused_window;
gboolean hidden;
guint idle_timeout;
guint draw_time_source;
guint idle_hide_source;
gboolean follow_focus;
gboolean use_idle_hide;
char *time;
char *date;
char *time_format;
char *date_format;
char *config_path;
char *layout_path;
char *lock_command;
char *unlock_command;
GArray *modules;
};
const gchar *g_module_check_init(GModule *m);
void g_module_unload(GModule *m);
void on_activation(struct GtkLock *gtklock, int id);
void on_locked(struct GtkLock *gtklock);
void on_output_change(struct GtkLock *gtklock);
void on_focus_change(struct GtkLock *gtklock, struct Window *win, struct Window *old);
void on_idle_hide(struct GtkLock *gtklock);
void on_idle_show(struct GtkLock *gtklock);
void on_window_create(struct GtkLock *gtklock, struct Window *win);
void on_window_destroy(struct GtkLock *gtklock, struct Window *win);
/*
MIT Licence
Copyright (c) 2024 Jovan Lanik <jox969@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MIT Licence
*/

53
meson.build Normal file
View File

@@ -0,0 +1,53 @@
project(
'ahfail',
['c', 'rust'],
version: '0.1.0',
meson_version: '>=1.3.0',
default_options: ['warning_level=3', 'c_std=c11']
)
ahfail_inc = include_directories('include')
cc = meson.get_compiler('c')
m_dep = cc.find_library('m', required : false)
gtk_dep = dependency('gtk+-3.0')
gstplayer_dep = dependency('gstreamer-player-1.0')
gnome = import('gnome')
resources = gnome.compile_resources(
'ahfail-resources',
'assets/ahfail.gresource.xml',
source_dir: 'assets',
c_name: 'ahfail'
)
cargo_target = custom_target(
'ahfail-cargo-build',
input: ['src/lib.rs', 'Cargo.toml'],
output: ['libahfail_module.a'],
command: [
'sh', '-c', 'cargo build --release --target-dir "@OUTDIR@/target" && cp "@OUTDIR@/target/release/libahfail_module.a" "@OUTPUT@"'
],
build_by_default: true
)
libahfail = shared_library(
'ahfail-module',
resources,
link_args: ['-Wl,--whole-archive', meson.current_build_dir() / 'libahfail_module.a', '-Wl,--no-whole-archive'],
link_depends: cargo_target,
include_directories: ahfail_inc,
dependencies: [gtk_dep, gstplayer_dep, m_dep],
install: true,
install_dir: get_option('libdir') / 'gtklock',
name_prefix: ''
)
smoke = executable(
'module_smoke_test',
'tests/module_test.c',
include_directories: ahfail_inc,
link_with: libahfail,
dependencies: [gtk_dep, gstplayer_dep]
)
test('module symbols', smoke)

37
src/config.rs Normal file
View File

@@ -0,0 +1,37 @@
use gtk::{glib, gdk};
use std::ffi::CStr;
use std::ptr;
// Storage for the command line argument string
pub static mut DEADZONE_ARG: *mut std::os::raw::c_char = ptr::null_mut();
pub const DEADZONE_LONG: &[u8] = b"deadzone\0";
pub const DEADZONE_DESC: &[u8] = b"Area to avoid spawning sprites (x,y,w,h)\0";
pub const DEADZONE_ARG_DESC: &[u8] = b"x,y,w,h\0";
pub struct ModuleConfig {
pub deadzone: Option<gdk::Rectangle>,
}
impl ModuleConfig {
pub unsafe fn from_args() -> Self {
let mut deadzone = None;
if !DEADZONE_ARG.is_null() {
let c_str = CStr::from_ptr(DEADZONE_ARG);
if let Ok(s) = c_str.to_str() {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() == 4 {
if let (Ok(x), Ok(y), Ok(w), Ok(h)) = (parts[0].parse(), parts[1].parse(), parts[2].parse(), parts[3].parse()) {
deadzone = Some(gdk::Rectangle::new(x, y, w, h));
println!("[ahfail] Configured deadzone: {:?}", deadzone);
} else {
eprintln!("[ahfail] Invalid numbers in deadzone argument: {}", s);
}
} else {
eprintln!("[ahfail] Invalid format for deadzone argument (expected x,y,w,h): {}", s);
}
}
}
Self { deadzone }
}
}

110
src/context.rs Normal file
View File

@@ -0,0 +1,110 @@
use gtk::prelude::*; // Keep this if extension traits are needed, otherwise remove. Window uses it.
use gtk::{glib, gdk}; // Window uses gdk, glib.
use gtk::gdk::Monitor;
use std::ffi::c_void;
use std::marker::PhantomData;
use std::ptr; // Import ptr
use glib::translate::from_glib_none;
use crate::state::WindowData;
#[repr(C)]
pub struct __IncompleteArrayField<T>(PhantomData<T>);
impl<T> __IncompleteArrayField<T> {
pub const fn new() -> Self {
Self(PhantomData)
}
/// # Safety
/// Returns a raw pointer to the field. The caller must ensure the struct was allocated
/// with enough space for this flexible array member.
pub unsafe fn as_ptr(&self) -> *mut T {
self as *const _ as *mut T
}
}
impl<T> Default for __IncompleteArrayField<T> {
fn default() -> Self {
Self::new()
}
}
#[repr(C)]
pub struct Window {
pub monitor: *mut gdk::ffi::GdkMonitor,
pub window: *mut gtk::ffi::GtkWidget,
pub overlay: *mut gtk::ffi::GtkOverlay,
pub window_box: *mut gtk::ffi::GtkWidget,
pub body_revealer: *mut gtk::ffi::GtkWidget,
pub body_grid: *mut gtk::ffi::GtkWidget,
pub input_label: *mut gtk::ffi::GtkWidget,
pub input_field: *mut gtk::ffi::GtkWidget,
pub message_revealer: *mut gtk::ffi::GtkWidget,
pub message_scrolled_window: *mut gtk::ffi::GtkWidget,
pub message_box: *mut gtk::ffi::GtkWidget,
pub unlock_button: *mut gtk::ffi::GtkWidget,
pub error_label: *mut gtk::ffi::GtkWidget,
pub warning_label: *mut gtk::ffi::GtkWidget,
pub info_box: *mut gtk::ffi::GtkWidget,
pub time_box: *mut gtk::ffi::GtkWidget,
pub clock_label: *mut gtk::ffi::GtkWidget,
pub date_label: *mut gtk::ffi::GtkWidget,
pub module_data: __IncompleteArrayField<*mut c_void>,
}
#[repr(C)]
pub struct GtkLock {
pub windows: *mut glib::ffi::GArray,
}
// Safe wrapper for Window pointer operations
pub struct WindowContext(*mut Window);
impl WindowContext {
/// # Safety
/// `ctx` must be a valid pointer to a `Window` struct.
pub unsafe fn new(ctx: *mut Window) -> Option<Self> {
if ctx.is_null() {
None
} else {
Some(Self(ctx))
}
}
pub unsafe fn get_error_label(&self) -> gtk::Label {
from_glib_none((*self.0).error_label as *mut gtk::ffi::GtkLabel)
}
pub unsafe fn get_overlay(&self) -> gtk::Overlay {
from_glib_none((*self.0).overlay as *mut gtk::ffi::GtkOverlay)
}
pub unsafe fn get_monitor(&self) -> Monitor {
from_glib_none((*self.0).monitor as *mut gdk::ffi::GdkMonitor)
}
/// # Safety
/// The caller must ensure `module_data` has been initialized and points to a valid `WindowData`.
pub unsafe fn set_data(&self, data: Box<WindowData>) {
let raw_ptr = Box::into_raw(data);
(*(*self.0).module_data.as_ptr()) = raw_ptr as *mut c_void;
}
pub unsafe fn get_data_ptr(&self) -> *mut WindowData {
*(*self.0).module_data.as_ptr() as *mut WindowData
}
/// # Safety
/// The caller takes ownership of the data and must drop it.
pub unsafe fn take_data(&self) -> Option<Box<WindowData>> {
let ptr_ref = (*self.0).module_data.as_ptr();
let ptr = *ptr_ref;
if ptr.is_null() {
None
} else {
*ptr_ref = ptr::null_mut(); // Clear it
Some(Box::from_raw(ptr as *mut WindowData))
}
}
}

165
src/handler.rs Normal file
View File

@@ -0,0 +1,165 @@
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::context::WindowContext;
const SPRITE_MARGIN: i32 = 100;
const SPRITE_SCALE: f64 = 0.6;
const PLAYER_POOL_SIZE: usize = 3;
const RETRY_ATTEMPTS: usize = 10;
pub struct WindowHandler;
impl WindowHandler {
pub unsafe fn create(ctx: &WindowContext) {
println!("[ahfail] on_window_create called");
let error_label = ctx.get_error_label();
let overlay = ctx.get_overlay();
let monitor = ctx.get_monitor();
let geom = monitor.geometry();
let screen_w = geom.width();
let screen_h = geom.height();
let fixed = gtk::Fixed::new();
fixed.set_size_request(screen_w, screen_h);
fixed.set_halign(gtk::Align::Fill);
fixed.set_valign(gtk::Align::Fill);
fixed.set_hexpand(true);
fixed.set_vexpand(true);
overlay.add_overlay(&fixed);
overlay.set_overlay_pass_through(&fixed, true);
let mut ready_players = Vec::new();
MODULE_STATE.with(|state| {
if let Some(audio_uri) = &state.borrow().audio_uri {
for _ in 0..PLAYER_POOL_SIZE {
ready_players.push(Self::create_player(audio_uri));
}
}
});
let data = Box::new(WindowData {
sprites: Vec::new(),
active_players: Vec::new(),
ready_players,
fixed: fixed.clone(),
signal_id: None,
});
ctx.set_data(data);
let ptr_addr = ctx.get_data_ptr() as usize;
let signal_id = error_label.connect_notify(Some("label"), move |label, _| {
let text = label.text();
let text_str = text.as_str();
if text_str.is_empty() {
return;
}
println!("[ahfail] Error label changed to: '{}'", text_str);
MODULE_STATE.with(|state| {
let state = state.borrow();
if let (Some(animation), Some(audio_uri)) = (&state.animation, &state.audio_uri) {
let data = unsafe { &mut *(ptr_addr as *mut WindowData) };
let image = gtk::Image::from_animation(animation);
image.show();
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);
let player = if let Some(p) = data.ready_players.pop() {
p
} else {
Self::create_player(audio_uri)
};
player.play();
data.active_players.push(player);
let new_player = Self::create_player(audio_uri);
data.ready_players.push(new_player);
}
});
});
unsafe {
(*ctx.get_data_ptr()).signal_id = Some(signal_id);
}
}
pub unsafe fn destroy(ctx: &WindowContext) {
if let Some(data) = ctx.take_data() {
if let Some(signal_id) = data.signal_id {
let error_label = ctx.get_error_label();
error_label.disconnect(signal_id);
}
for sprite in data.sprites {
sprite.destroy();
}
for player in data.active_players {
player.stop();
}
for player in data.ready_players {
player.stop();
}
data.fixed.destroy();
}
}
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
}
}

193
src/lib.rs Normal file
View File

@@ -0,0 +1,193 @@
pub mod config;
pub mod context;
pub mod state;
pub mod handler;
use gtk::prelude::*;
use gtk::{glib, gdk_pixbuf, gio};
use gtk::gdk_pixbuf::InterpType;
use gstreamer as gst;
use std::ffi::{c_void, CStr};
use glib::translate::from_glib_none;
use std::os::raw::{c_char, c_int, c_uint};
use std::ptr;
// Re-export types for external use (e.g. integration tests)
pub use config::{ModuleConfig, DEADZONE_ARG, DEADZONE_LONG, DEADZONE_DESC, DEADZONE_ARG_DESC};
pub use context::{Window, GtkLock, WindowContext, __IncompleteArrayField};
pub use state::{MODULE_STATE, WindowData, ModuleState};
pub use handler::WindowHandler;
// Scale factor to reduce the image size and avoid cropping/overlap
const SPRITE_SCALE: f64 = 0.6;
#[no_mangle]
pub static module_name: [c_char; 7] = [
b'a' as c_char,
b'h' as c_char,
b'f' as c_char,
b'a' as c_char,
b'i' as c_char,
b'l' as c_char,
0,
];
#[no_mangle]
pub static module_major_version: c_uint = 4;
#[no_mangle]
pub static module_minor_version: c_uint = 0;
#[no_mangle]
pub static mut module_entries: [glib::ffi::GOptionEntry; 3] = [
glib::ffi::GOptionEntry {
long_name: DEADZONE_LONG.as_ptr() as *const c_char,
short_name: 0,
flags: 0,
arg: glib::ffi::G_OPTION_ARG_STRING,
arg_data: unsafe { &raw mut DEADZONE_ARG as *mut _ },
description: DEADZONE_DESC.as_ptr() as *const c_char,
arg_description: DEADZONE_ARG_DESC.as_ptr() as *const c_char
},
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
/// # Safety
/// `ctx` must be a valid Window pointer.
pub unsafe fn get_window_data_ref(ctx: *mut Window) -> Option<&'static state::WindowData> {
let ptr = *(*ctx).module_data.as_ptr();
if ptr.is_null() {
None
} else {
Some(&*(ptr as *mut state::WindowData))
}
}
/// # Safety
/// This function is called by the C host.
#[no_mangle]
pub unsafe extern "C" fn on_activation(_gtklock: *mut GtkLock, _id: c_int) {
println!("[ahfail] on_activation called");
if let Err(e) = gtk::init() {
eprintln!("Failed to initialize GTK bindings: {}", e);
return;
}
if let Err(e) = gst::init() {
eprintln!("Failed to initialize GStreamer: {}", e);
return;
}
let resource_ptr = ahfail_get_resource();
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();
MODULE_STATE.with(|state| {
let mut state = state.borrow_mut();
state.animation = anim_opt;
state.audio_uri = Some("resource:///ahfail/audio/magic-word.mp3".to_string());
state.config = config;
});
}
/// # Safety
/// This function is called by the C host.
#[no_mangle]
pub unsafe extern "C" fn on_window_create(_gtklock: *mut GtkLock, ctx: *mut Window) {
if let Some(win_ctx) = WindowContext::new(ctx) {
WindowHandler::create(&win_ctx);
}
}
/// # Safety
/// This function is called by the C host.
#[no_mangle]
pub unsafe extern "C" fn on_window_destroy(_gtklock: *mut GtkLock, ctx: *mut Window) {
if let Some(win_ctx) = WindowContext::new(ctx) {
WindowHandler::destroy(&win_ctx);
}
}
/// # Safety
/// This function is called by the C host.
#[no_mangle]
pub unsafe extern "C" fn on_idle_hide(gtklock: *mut GtkLock) {
if gtklock.is_null() { return; }
let garray = (*gtklock).windows;
if !garray.is_null() {
let len = (*garray).len;
let data_ptr = (*garray).data as *mut *mut Window;
let windows = std::slice::from_raw_parts(data_ptr, len as usize);
for &window_ptr in windows {
on_window_destroy(ptr::null_mut(), window_ptr);
}
}
}
/// # Safety
/// This function is called by the C host.
#[no_mangle]
pub unsafe extern "C" fn on_focus_change(_gtklock: *mut GtkLock, _win: *mut Window, _old: *mut Window) {}
/// # Safety
/// This function is called by the C host.
#[no_mangle]
pub unsafe extern "C" fn on_idle_show(_gtklock: *mut GtkLock) {}
/// # Safety
/// This function is called by the C host.
#[no_mangle]
pub unsafe extern "C" fn g_module_unload(_module: *mut c_void) {
MODULE_STATE.with(|state| {
let mut state = state.borrow_mut();
state.animation = None;
state.audio_uri = None;
state.config.deadzone = None;
});
}

26
src/state.rs Normal file
View File

@@ -0,0 +1,26 @@
use gtk::{glib, gdk_pixbuf};
use gstreamer_player as gst_player;
use std::cell::RefCell;
use crate::config::ModuleConfig;
pub struct ModuleState {
pub animation: Option<gdk_pixbuf::PixbufAnimation>,
pub audio_uri: Option<String>,
pub config: ModuleConfig,
}
pub struct WindowData {
pub sprites: Vec<gtk::Image>,
pub active_players: Vec<gst_player::Player>,
pub ready_players: Vec<gst_player::Player>,
pub fixed: gtk::Fixed,
pub signal_id: Option<glib::SignalHandlerId>,
}
thread_local! {
pub static MODULE_STATE: RefCell<ModuleState> = const { RefCell::new(ModuleState {
animation: None,
audio_uri: None,
config: ModuleConfig { deadzone: None },
}) };
}

12
src/utils/bench.rs Normal file
View File

@@ -0,0 +1,12 @@
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
}

299
tests/ahfail_tests.rs Normal file
View File

@@ -0,0 +1,299 @@
use ahfail_module::*;
use gtk::prelude::*;
use gtk::{glib, gdk_pixbuf, gdk};
use gstreamer as gst;
use std::ptr;
use std::ffi::c_void;
use gtk::glib::translate::{ToGlibPtr, Stash};
// Mock implementation of the resource getter for tests
#[no_mangle]
extern "C" fn ahfail_get_resource() -> *mut gtk::gio::ffi::GResource {
ptr::null_mut()
}
// Helper struct to simulate the C flexible array member allocation
#[repr(C)]
struct WindowWithStorage {
window: Window,
// Reserve space for the 'module_data' flexible array (1 pointer)
storage: *mut c_void,
}
fn setup_test_environment() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let _ = gtk::init();
let _ = gst::init();
});
}
fn flush_events() {
while gtk::events_pending() {
gtk::main_iteration();
}
}
fn create_mock_context() -> (WindowWithStorage, gtk::Window, gtk::Label, gtk::Overlay, gdk::Display) {
setup_test_environment();
let error_label = gtk::Label::new(Some(""));
let overlay = gtk::Overlay::new();
let window_widget = gtk::Window::new(gtk::WindowType::Toplevel);
let display = gdk::Display::default().expect("No display available for testing");
let monitor = display.monitor(0).expect("No monitor available");
// Explicitly type the Stash to avoid ambiguity
let monitor_stash: Stash<*mut gdk::ffi::GdkMonitor, _> = monitor.to_glib_none();
let window_stash: Stash<*mut gtk::ffi::GtkWindow, _> = window_widget.to_glib_none();
let overlay_stash: Stash<*mut gtk::ffi::GtkOverlay, _> = overlay.to_glib_none();
let label_stash: Stash<*mut gtk::ffi::GtkLabel, _> = error_label.to_glib_none();
let win = Window {
monitor: monitor_stash.0,
window: window_stash.0 as *mut gtk::ffi::GtkWidget,
overlay: overlay_stash.0,
window_box: ptr::null_mut(),
body_revealer: ptr::null_mut(),
body_grid: ptr::null_mut(),
input_label: ptr::null_mut(),
input_field: ptr::null_mut(),
message_revealer: ptr::null_mut(),
message_scrolled_window: ptr::null_mut(),
message_box: ptr::null_mut(),
unlock_button: ptr::null_mut(),
error_label: label_stash.0 as *mut gtk::ffi::GtkWidget,
warning_label: ptr::null_mut(),
info_box: ptr::null_mut(),
time_box: ptr::null_mut(),
clock_label: ptr::null_mut(),
date_label: ptr::null_mut(),
module_data: __IncompleteArrayField::new(),
};
let storage = WindowWithStorage {
window: win,
storage: ptr::null_mut(),
};
(storage, window_widget, error_label, overlay, display)
}
fn inject_test_state() {
let pixbuf = gdk_pixbuf::Pixbuf::new(gdk_pixbuf::Colorspace::Rgb, false, 8, 1, 1).unwrap();
let anim = gdk_pixbuf::PixbufSimpleAnim::new(1, 1, 1.0);
anim.add_frame(&pixbuf);
// Use a minimal valid WAV (silent) to avoid GStreamer errors
let wav_base64 = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQEAAAAA";
let data_uri = format!("data:audio/wav;base64,{}", wav_base64);
MODULE_STATE.with(|state| {
let mut state = state.borrow_mut();
state.animation = Some(anim.upcast());
state.audio_uri = Some(data_uri);
});
}
fn run_test_01_initialization() {
unsafe {
on_activation(ptr::null_mut(), 0);
}
MODULE_STATE.with(|state| {
let state = state.borrow();
// It should have set the audio URI to the default
assert_eq!(state.audio_uri.as_deref(), Some("resource:///ahfail/audio/magic-word.mp3"));
});
}
fn run_test_02_window_create_null() {
unsafe {
on_window_create(ptr::null_mut(), ptr::null_mut());
}
}
fn run_test_03_window_create_valid() {
let (mut win_storage, _w, _l, _o, _) = create_mock_context();
let ctx_ptr = &mut win_storage.window as *mut Window;
unsafe {
on_window_create(ptr::null_mut(), ctx_ptr);
let data = get_window_data_ref(ctx_ptr);
assert!(data.is_some(), "WindowData should be initialized");
on_window_destroy(ptr::null_mut(), ctx_ptr);
flush_events();
}
}
fn run_test_04_trigger_effect_sprite() {
let (mut win_storage, _w, label, _o, _) = create_mock_context();
let ctx_ptr = &mut win_storage.window as *mut Window;
inject_test_state();
unsafe {
on_window_create(ptr::null_mut(), ctx_ptr);
label.set_text("Error");
flush_events();
let data = get_window_data_ref(ctx_ptr).unwrap();
assert_eq!(data.sprites.len(), 1);
on_window_destroy(ptr::null_mut(), ctx_ptr);
flush_events();
}
}
fn run_test_05_trigger_effect_audio() {
let (mut win_storage, _w, label, _o, _) = create_mock_context();
let ctx_ptr = &mut win_storage.window as *mut Window;
inject_test_state();
unsafe {
on_window_create(ptr::null_mut(), ctx_ptr);
label.set_text("Error");
flush_events();
let data = get_window_data_ref(ctx_ptr).unwrap();
assert_eq!(data.active_players.len(), 1);
on_window_destroy(ptr::null_mut(), ctx_ptr);
flush_events();
}
}
fn run_test_06_multiple_triggers() {
let (mut win_storage, _w, label, _o, _) = create_mock_context();
let ctx_ptr = &mut win_storage.window as *mut Window;
inject_test_state();
unsafe {
on_window_create(ptr::null_mut(), ctx_ptr);
for _ in 0..5 {
label.set_text("Error");
flush_events();
}
let data = get_window_data_ref(ctx_ptr).unwrap();
assert_eq!(data.sprites.len(), 5);
assert_eq!(data.active_players.len(), 5);
on_window_destroy(ptr::null_mut(), ctx_ptr);
flush_events();
}
}
fn run_test_07_window_cleanup() {
let (mut win_storage, _w, label, _o, _) = create_mock_context();
let ctx_ptr = &mut win_storage.window as *mut Window;
inject_test_state();
unsafe {
on_window_create(ptr::null_mut(), ctx_ptr);
label.set_text("Error");
flush_events();
assert!(get_window_data_ref(ctx_ptr).is_some());
on_window_destroy(ptr::null_mut(), ctx_ptr);
assert!(get_window_data_ref(ctx_ptr).is_none());
flush_events();
}
}
fn run_test_08_multiple_windows() {
let (mut win1, _w1, label1, _o1, _) = create_mock_context();
let (mut win2, _w2, label2, _o2, _) = create_mock_context();
let ctx1 = &mut win1.window as *mut Window;
let ctx2 = &mut win2.window as *mut Window;
inject_test_state();
unsafe {
on_window_create(ptr::null_mut(), ctx1);
on_window_create(ptr::null_mut(), ctx2);
label1.set_text("E1");
flush_events();
label2.set_text("E2");
label2.set_text("E2 again");
flush_events();
let data1 = get_window_data_ref(ctx1).unwrap();
let data2 = get_window_data_ref(ctx2).unwrap();
assert_eq!(data1.sprites.len(), 1);
assert_eq!(data2.sprites.len(), 2);
on_window_destroy(ptr::null_mut(), ctx1);
on_window_destroy(ptr::null_mut(), ctx2);
flush_events();
}
}
fn run_test_09_idle_hide_cleanup() {
let (mut win_storage, _w, label, _o, _) = create_mock_context();
let ctx_ptr = &mut win_storage.window as *mut Window;
inject_test_state();
unsafe {
on_window_create(ptr::null_mut(), ctx_ptr);
label.set_text("Error");
flush_events();
// Mock GtkLock struct
let mut 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);
let mut lock = GtkLock {
windows: windows_array,
};
on_idle_hide(&mut lock);
assert!(get_window_data_ref(ctx_ptr).is_none());
glib::ffi::g_array_free(windows_array, 1);
flush_events();
}
}
fn run_test_10_module_unload() {
inject_test_state();
MODULE_STATE.with(|state| {
assert!(state.borrow().audio_uri.is_some());
});
unsafe {
g_module_unload(ptr::null_mut());
}
MODULE_STATE.with(|state| {
let state = state.borrow();
assert!(state.animation.is_none());
assert!(state.audio_uri.is_none());
});
}
#[test]
fn run_all_tests_sequentially() {
println!("Running test 01...");
run_test_01_initialization();
println!("Running test 02...");
run_test_02_window_create_null();
println!("Running test 03...");
run_test_03_window_create_valid();
println!("Running test 04...");
run_test_04_trigger_effect_sprite();
println!("Running test 05...");
run_test_05_trigger_effect_audio();
println!("Running test 06...");
run_test_06_multiple_triggers();
println!("Running test 07...");
run_test_07_window_cleanup();
println!("Running test 08...");
run_test_08_multiple_windows();
println!("Running test 09...");
run_test_09_idle_hide_cleanup();
println!("Running test 10...");
run_test_10_module_unload();
// Final flush
flush_events();
}

57
tests/benchmarks.rs Normal file
View File

@@ -0,0 +1,57 @@
use gtk::prelude::*;
use gtk::{gdk, gdk_pixbuf};
use gstreamer as gst;
use gstreamer_player as gst_player;
use std::time::Instant;
#[cfg(test)]
mod benchmarks {
use super::*;
use std::sync::Once;
static INIT: Once = Once::new();
fn init() {
INIT.call_once(|| {
gtk::init().unwrap();
gst::init().unwrap();
});
}
#[test]
fn bench_pixbuf_loading() {
init();
let start = Instant::now();
// Create a 1x1 pixbuf to simulate loading
let _pixbuf = gdk_pixbuf::Pixbuf::new(gdk_pixbuf::Colorspace::Rgb, false, 8, 220, 220).unwrap();
println!("Pixbuf creation: {:?}", start.elapsed());
}
#[test]
fn bench_animation_creation() {
init();
let start = Instant::now();
let _anim = gdk_pixbuf::PixbufSimpleAnim::new(220, 220, 12.0);
println!("Animation creation: {:?}", start.elapsed());
}
#[test]
fn bench_player_creation() {
init();
let start = Instant::now();
let _player = gst_player::Player::new(None, None);
println!("GstPlayer creation: {:?}", start.elapsed());
}
#[test]
fn bench_coord_calculation() {
use rand::Rng;
let start = Instant::now();
let mut rng = rand::thread_rng();
for _ in 0..1000 {
let _x = rng.gen_range(0..1920);
let _y = rng.gen_range(0..1080);
}
println!("1000 RNG calculations: {:?}", start.elapsed());
}
}

18
tests/module_test.c Normal file
View File

@@ -0,0 +1,18 @@
#include <stddef.h>
#include "ahfail/module.h"
int main(void) {
if (module_name[0] == '\0') {
return 1;
}
on_activation(NULL, 42);
on_window_create(NULL, NULL);
on_focus_change(NULL, NULL, NULL);
on_idle_show(NULL);
on_idle_hide(NULL);
on_window_destroy(NULL, NULL);
return 0;
}