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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}) };
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
12
crates/ahfail-ui/tests/volume_tests.rs
Normal file
12
crates/ahfail-ui/tests/volume_tests.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user