feat: add volume save/restore on failure/unload

On the first failed unlock attempt, save the current system volume and
mute state then set volume to maximum unmuted; restore on g_module_unload.
A lock file under XDG_RUNTIME_DIR prevents double-acquisition when
multiple gtklock windows are active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-05-06 09:50:49 +02:00
parent 7913ced403
commit 74e0f544a0
7 changed files with 471 additions and 21 deletions

View File

@@ -56,6 +56,13 @@ impl WindowHandler {
}
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| {
let state = state.borrow();
if let (Some(animation), Some(audio_uri)) = (&state.animation, &state.audio_uri) {

View File

@@ -136,8 +136,12 @@ pub unsafe extern "C" fn on_idle_show(_gtklock: *mut GtkLock) {}
pub unsafe extern "C" fn g_module_unload(_module: *mut c_void) {
MODULE_STATE.with(|state| {
let mut state = state.borrow_mut();
if let Some(vs) = state.volume_state.take() {
ahfail_ui::volume::restore(vs);
}
state.animation = None;
state.audio_uri = None;
state.config.deadzone = None;
state.volume_state = None;
});
}

View File

@@ -7,6 +7,7 @@ pub struct ModuleState {
pub animation: Option<gdk_pixbuf::PixbufAnimation>,
pub audio_uri: Option<String>,
pub config: ModuleConfig,
pub volume_state: Option<ahfail_ui::volume::VolumeState>,
}
pub struct WindowData {
@@ -22,5 +23,6 @@ thread_local! {
animation: None,
audio_uri: None,
config: ModuleConfig { deadzone: None },
volume_state: None,
}) };
}

View File

@@ -17,3 +17,7 @@ gio = { version = "0.15", package = "gio" }
gdk-pixbuf = "0.15"
rand = "0.8"
ureq = "2"
libc = "0.2"
[dev-dependencies]
tempfile = "3"

View File

@@ -1,7 +1,139 @@
pub struct VolumeState;
use std::path::PathBuf;
use std::process::Command;
use std::fs;
use std::io::Write;
pub fn save_and_set_max() -> Option<VolumeState> {
None
pub struct VolumeState {
pub volume: u32,
pub muted: bool,
pub lock_path: PathBuf,
}
pub fn restore(_state: VolumeState) {}
/// Atomically creates lock file. Returns true if this process is first (primary).
pub fn try_acquire_lock(path: &std::path::Path) -> bool {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map(|mut f| { let _ = f.write_all(b"1"); true })
.unwrap_or(false)
}
/// 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).
pub fn save_and_set_max() -> Option<VolumeState> {
let path = lock_path();
if !try_acquire_lock(&path) { return None; }
let (volume, muted) = get_current_volume();
set_volume_max();
Some(VolumeState { volume, muted, lock_path: path })
}
/// 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> {
output.split('%').next()
.and_then(|s| s.split_whitespace().last())
.and_then(|s| s.parse().ok())
}
#[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) {}

View File

@@ -0,0 +1,12 @@
#[test]
fn lock_file_created_and_prevents_second_acquisition() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("ahfail.lock");
let acquired = ahfail_ui::volume::try_acquire_lock(&lock_path);
assert!(acquired);
assert!(lock_path.exists());
let acquired2 = ahfail_ui::volume::try_acquire_lock(&lock_path);
assert!(!acquired2);
}