diff --git a/src/app/mod.rs b/src/app/mod.rs index 4c7d219f..62ac42d4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,6 +4,7 @@ mod usage; use crate::backend::ExitAfterCaptureMode; use crate::cli::Cli; +use crate::daemon::DaemonToggleRequest; use crate::paths::overlay_lock_file; use crate::session::try_lock_exclusive; use crate::session_override::set_runtime_session_override; @@ -91,6 +92,19 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { return Ok(()); } + if cli.daemon_toggle { + let request = DaemonToggleRequest { + mode: cli.mode, + freeze: cli.freeze, + exit_after_capture: cli.exit_after_capture, + no_exit_after_capture: cli.no_exit_after_capture, + resume_session: cli.resume_session, + no_resume_session: cli.no_resume_session, + }; + crate::daemon::send_daemon_toggle_request(&request)?; + return Ok(()); + } + if cli.clear_session || cli.session_info { run_session_cli_commands(&cli)?; return Ok(()); @@ -111,6 +125,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { log::info!("Tray disabled via --no-tray / WAYSCRIBER_NO_TRAY"); } let mut daemon = crate::daemon::Daemon::new(cli.mode, !tray_disabled, session_override); + daemon.set_freeze_on_show(cli.freeze_on_show); daemon.run()?; } else if cli.active || cli.freeze { if maybe_detach_active(&cli)? { diff --git a/src/app/usage.rs b/src/app/usage.rs index f092c936..20e4c5d5 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -168,6 +168,15 @@ pub(crate) fn print_usage() { println!( " wayscriber -d, --daemon Run as background daemon (bind a toggle like Super+D)" ); + println!( + " wayscriber --daemon --freeze-on-show Run daemon with frozen activation by default" + ); + println!( + " wayscriber --daemon-toggle [--freeze] [--mode MODE] Toggle running daemon with launch args" + ); + println!( + " [--exit-after-capture|--no-exit-after-capture] [--resume-session|--no-resume-session]" + ); println!(" wayscriber -a, --active Show overlay immediately (one-shot mode)"); println!(" wayscriber --freeze Start overlay already frozen"); println!( @@ -186,11 +195,11 @@ pub(crate) fn print_usage() { println!(); println!("Daemon mode (recommended). Example Hyprland setup:"); println!(" 1. Run: wayscriber --daemon"); + println!(" Optional: wayscriber --daemon --freeze-on-show"); println!(" 2. Add to Hyprland config:"); println!(" exec-once = wayscriber --daemon"); - println!( - " bind = SUPER, D, exec, bash -lc \"kill -USR1 $(pgrep -fo 'wayscriber --daemon')\"" - ); + println!(" bind = SUPER, D, exec, wayscriber --daemon-toggle"); + println!(" bind = SUPER SHIFT, D, exec, wayscriber --daemon-toggle --freeze"); println!(" 3. Press your bound shortcut (e.g. Super+D) to toggle overlay on/off"); println!(); println!("Requirements:"); diff --git a/src/cli.rs b/src/cli.rs index 9d8d9d77..8cb881cd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,6 +11,22 @@ pub struct Cli { #[arg(long, short = 'd', action = ArgAction::SetTrue)] pub daemon: bool, + /// Send a toggle request to the running daemon (supports --mode/--freeze/exit/session overrides) + #[arg( + long, + action = ArgAction::SetTrue, + conflicts_with_all = [ + "daemon", + "active", + "no_tray", + "freeze_on_show", + "clear_session", + "session_info", + "about" + ] + )] + pub daemon_toggle: bool, + /// Start active (show overlay immediately, one-shot mode) #[arg(long, short = 'a', action = ArgAction::SetTrue)] pub active: bool, @@ -23,6 +39,15 @@ pub struct Cli { #[arg(long, action = ArgAction::SetTrue)] pub no_tray: bool, + /// Start daemon activations with frozen mode active (same compositor support as --freeze) + #[arg( + long, + action = ArgAction::SetTrue, + requires = "daemon", + conflicts_with_all = ["active", "freeze", "clear_session", "session_info"] + )] + pub freeze_on_show: bool, + /// Delete persisted session data and backups #[arg( long, @@ -76,9 +101,11 @@ pub struct Cli { action = ArgAction::SetTrue, conflicts_with_all = [ "daemon", + "daemon_toggle", "active", "mode", "no_tray", + "freeze_on_show", "clear_session", "session_info", "freeze", @@ -101,6 +128,32 @@ mod tests { assert_eq!(cli.mode.as_deref(), Some("whiteboard")); } + #[test] + fn daemon_mode_accepts_freeze_on_show() { + let cli = Cli::try_parse_from(["wayscriber", "--daemon", "--freeze-on-show"]).unwrap(); + assert!(cli.daemon); + assert!(cli.freeze_on_show); + } + + #[test] + fn daemon_toggle_accepts_overlay_launch_args() { + let cli = Cli::try_parse_from([ + "wayscriber", + "--daemon-toggle", + "--freeze", + "--mode", + "whiteboard", + "--exit-after-capture", + "--resume-session", + ]) + .unwrap(); + assert!(cli.daemon_toggle); + assert!(cli.freeze); + assert_eq!(cli.mode.as_deref(), Some("whiteboard")); + assert!(cli.exit_after_capture); + assert!(cli.resume_session); + } + #[test] fn cli_conflicting_flags_fail() { let result = Cli::try_parse_from(["wayscriber", "--active", "--clear-session"]); @@ -109,4 +162,13 @@ mod tests { "expected conflicting flags (--active and --clear-session) to error" ); } + + #[test] + fn freeze_on_show_requires_daemon() { + let result = Cli::try_parse_from(["wayscriber", "--freeze-on-show"]); + assert!( + result.is_err(), + "expected --freeze-on-show without --daemon to error" + ); + } } diff --git a/src/daemon/control.rs b/src/daemon/control.rs new file mode 100644 index 00000000..7e2dc5b9 --- /dev/null +++ b/src/daemon/control.rs @@ -0,0 +1,600 @@ +use anyhow::{Context, Result, anyhow}; +use log::warn; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::fs::OpenOptions; +use std::io::ErrorKind; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::paths::{daemon_command_dir, daemon_command_file, daemon_lock_file, daemon_pid_file}; +use crate::session::try_lock_exclusive; + +const MAX_DAEMON_TOGGLE_REQUEST_AGE: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DaemonRuntimeInfo { + pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + token: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DaemonToggleEnvelope { + daemon_token: String, + requested_at_unix_ms: u64, + request: DaemonToggleRequest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub(crate) struct DaemonToggleRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) mode: Option, + #[serde(default)] + pub(crate) freeze: bool, + #[serde(default)] + pub(crate) exit_after_capture: bool, + #[serde(default)] + pub(crate) no_exit_after_capture: bool, + #[serde(default)] + pub(crate) resume_session: bool, + #[serde(default)] + pub(crate) no_resume_session: bool, +} + +impl DaemonToggleRequest { + pub(crate) fn is_empty(&self) -> bool { + self.mode.is_none() + && !self.freeze + && !self.exit_after_capture + && !self.no_exit_after_capture + && !self.resume_session + && !self.no_resume_session + } + + pub(crate) fn session_resume_override(&self) -> Option { + if self.resume_session { + Some(true) + } else if self.no_resume_session { + Some(false) + } else { + None + } + } +} + +fn current_unix_millis() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")?; + Ok(duration + .as_secs() + .saturating_mul(1000) + .saturating_add(duration.subsec_millis() as u64)) +} + +pub(crate) fn generate_daemon_instance_token() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{:x}-{:x}", std::process::id(), now) +} + +fn clear_file(path: &std::path::Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), + } +} + +fn clear_dir(path: &std::path::Path) -> Result<()> { + match fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), + } +} + +fn atomic_temp_path(path: &std::path::Path) -> Result { + let parent = path + .parent() + .ok_or_else(|| anyhow!("{} has no parent directory", path.display()))?; + let file_name = path + .file_name() + .ok_or_else(|| anyhow!("{} has no file name", path.display()))? + .to_string_lossy(); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Ok(parent.join(format!( + ".{}.{}.{}.tmp", + file_name, + std::process::id(), + stamp + ))) +} + +fn next_daemon_toggle_request_path() -> Result { + let dir = daemon_command_dir(); + fs::create_dir_all(&dir) + .with_context(|| format!("failed to create runtime directory {}", dir.display()))?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Ok(dir.join(format!("{:032x}-{:08x}.json", stamp, std::process::id()))) +} + +fn write_file_atomic(path: &std::path::Path, payload: &[u8]) -> Result<()> { + let tmp_path = atomic_temp_path(path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; + } + + fs::write(&tmp_path, payload) + .with_context(|| format!("failed to write {}", tmp_path.display()))?; + + #[cfg(unix)] + { + if let Err(err) = fs::rename(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(err).with_context(|| { + format!( + "failed to atomically replace {} via {}", + path.display(), + tmp_path.display() + ) + }); + } + } + + #[cfg(not(unix))] + { + let _ = fs::remove_file(path); + if let Err(err) = fs::rename(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(err).with_context(|| { + format!( + "failed to replace {} via {}", + path.display(), + tmp_path.display() + ) + }); + } + } + + Ok(()) +} + +pub(crate) fn clear_daemon_toggle_request_file() -> Result<()> { + clear_file(&daemon_command_file())?; + clear_dir(&daemon_command_dir()) +} + +fn write_daemon_toggle_request(request: &DaemonToggleRequest, daemon_token: &str) -> Result<()> { + let envelope = DaemonToggleEnvelope { + daemon_token: daemon_token.to_string(), + requested_at_unix_ms: current_unix_millis()?, + request: request.clone(), + }; + let payload = + serde_json::to_vec(&envelope).context("failed to serialize daemon toggle request")?; + let path = next_daemon_toggle_request_path()?; + write_file_atomic(&path, &payload)?; + Ok(()) +} + +pub(crate) fn take_daemon_toggle_requests( + expected_token: &str, +) -> Result> { + let dir = daemon_command_dir(); + let mut paths = match fs::read_dir(&dir) { + Ok(entries) => entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.is_file()) + .collect::>(), + Err(err) if err.kind() == ErrorKind::NotFound => Vec::new(), + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", dir.display())); + } + }; + paths.sort_by(|left, right| left.file_name().cmp(&right.file_name())); + + let mut requests = Vec::new(); + for path in paths { + let payload = match fs::read(&path) { + Ok(payload) => payload, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => { + warn!("Failed to read {}: {}", path.display(), err); + continue; + } + }; + + if let Err(err) = clear_file(&path) { + warn!("Failed to remove {}: {}", path.display(), err); + continue; + } + + let envelope: DaemonToggleEnvelope = match serde_json::from_slice(&payload) { + Ok(envelope) => envelope, + Err(err) => { + warn!( + "Ignoring malformed daemon toggle request {}: {}", + path.display(), + err + ); + continue; + } + }; + + if envelope.daemon_token != expected_token { + warn!("Ignoring daemon toggle request for a different daemon instance"); + continue; + } + + let age_ms = current_unix_millis()?.saturating_sub(envelope.requested_at_unix_ms); + if Duration::from_millis(age_ms) > MAX_DAEMON_TOGGLE_REQUEST_AGE { + warn!("Ignoring stale daemon toggle request older than 5s"); + continue; + } + + requests.push(envelope.request); + } + + Ok(requests) +} + +pub(crate) fn write_daemon_pid_file(pid: u32, token: &str) -> Result<()> { + let path = daemon_pid_file(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; + } + let payload = serde_json::to_vec(&DaemonRuntimeInfo { + pid, + token: Some(token.to_string()), + }) + .context("failed to serialize daemon pid file")?; + fs::write(&path, payload).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +pub(crate) fn clear_daemon_pid_file() -> Result<()> { + clear_file(&daemon_pid_file()) +} + +fn daemon_lock_is_held() -> Result { + let path = daemon_lock_file(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create runtime directory {}", parent.display()))?; + } + + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("failed to open daemon lock {}", path.display()))?; + + match try_lock_exclusive(&lock_file) { + Ok(()) => Ok(false), + Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(true), + Err(err) => Err(err).context("failed to inspect daemon lock"), + } +} + +fn clear_stale_daemon_state() { + if let Err(err) = clear_daemon_pid_file() { + warn!("Failed to clear stale daemon pid file: {}", err); + } + if let Err(err) = clear_daemon_toggle_request_file() { + warn!("Failed to clear stale daemon command file: {}", err); + } +} + +fn read_daemon_runtime_info() -> Result { + if !daemon_lock_is_held()? { + clear_stale_daemon_state(); + return Err(anyhow!("wayscriber daemon is not running")); + } + + let path = daemon_pid_file(); + let raw = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + + if let Ok(info) = serde_json::from_str::(&raw) { + return Ok(info); + } + + let pid = raw + .trim() + .parse::() + .context("failed to parse daemon pid file")?; + Ok(DaemonRuntimeInfo { pid, token: None }) +} + +fn signal_daemon_pid(pid: u32) -> Result<()> { + #[cfg(unix)] + { + let pid = i32::try_from(pid).context("daemon pid does not fit into i32")?; + if pid <= 0 { + return Err(anyhow!("invalid daemon pid {}", pid)); + } + + if unsafe { libc::kill(pid, libc::SIGUSR1) } != 0 { + return Err(anyhow!( + "failed to signal wayscriber daemon {}: {}", + pid, + std::io::Error::last_os_error() + )); + } + Ok(()) + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "daemon control is only supported on Unix platforms" + )) + } +} + +pub(crate) fn send_daemon_toggle_request(request: &DaemonToggleRequest) -> Result<()> { + let runtime = read_daemon_runtime_info()?; + + if let Some(token) = runtime.token.as_deref() { + write_daemon_toggle_request(request, token)?; + } else if !request.is_empty() { + return Err(anyhow!( + "running daemon does not support typed control; restart wayscriber daemon" + )); + } + + if let Err(err) = signal_daemon_pid(runtime.pid) { + clear_stale_daemon_state(); + return Err(err); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + #[test] + fn empty_toggle_request_reports_empty() { + assert!(DaemonToggleRequest::default().is_empty()); + } + + #[test] + fn toggle_request_reports_session_override() { + let request = DaemonToggleRequest { + resume_session: true, + ..Default::default() + }; + assert_eq!(request.session_resume_override(), Some(true)); + + let request = DaemonToggleRequest { + no_resume_session: true, + ..Default::default() + }; + assert_eq!(request.session_resume_override(), Some(false)); + } + + #[test] + fn daemon_pid_file_round_trips_runtime_info() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let prev = env::var_os("XDG_RUNTIME_DIR"); + unsafe { + env::set_var("XDG_RUNTIME_DIR", tmp.path()); + } + + write_daemon_pid_file(1234, "daemon-token").unwrap(); + let info = read_daemon_runtime_info().unwrap_err(); + assert!( + info.to_string() + .contains("wayscriber daemon is not running") + ); + + match prev { + Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) }, + None => unsafe { env::remove_var("XDG_RUNTIME_DIR") }, + } + } + + #[test] + fn take_daemon_toggle_request_round_trips_payload() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let prev = env::var_os("XDG_RUNTIME_DIR"); + unsafe { + env::set_var("XDG_RUNTIME_DIR", tmp.path()); + } + + let request = DaemonToggleRequest { + mode: Some("whiteboard".into()), + freeze: true, + exit_after_capture: true, + ..Default::default() + }; + write_daemon_toggle_request(&request, "daemon-token").unwrap(); + assert_eq!( + take_daemon_toggle_requests("daemon-token").unwrap(), + vec![request] + ); + assert!( + take_daemon_toggle_requests("daemon-token") + .unwrap() + .is_empty() + ); + + match prev { + Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) }, + None => unsafe { env::remove_var("XDG_RUNTIME_DIR") }, + } + } + + #[test] + fn write_daemon_toggle_request_queues_multiple_files_without_leaking_temp_files() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let prev = env::var_os("XDG_RUNTIME_DIR"); + unsafe { + env::set_var("XDG_RUNTIME_DIR", tmp.path()); + } + + write_daemon_toggle_request( + &DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + "daemon-token", + ) + .unwrap(); + write_daemon_toggle_request( + &DaemonToggleRequest { + mode: Some("whiteboard".into()), + ..Default::default() + }, + "daemon-token", + ) + .unwrap(); + + let command_dir = daemon_command_dir(); + let entries = fs::read_dir(&command_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + assert_eq!(entries.len(), 2); + assert!(entries.iter().all(|name| name.ends_with(".json"))); + assert!(!entries.iter().any(|name| name.ends_with(".tmp"))); + + match prev { + Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) }, + None => unsafe { env::remove_var("XDG_RUNTIME_DIR") }, + } + } + + #[test] + fn take_daemon_toggle_request_drains_multiple_payloads_in_order() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let prev = env::var_os("XDG_RUNTIME_DIR"); + unsafe { + env::set_var("XDG_RUNTIME_DIR", tmp.path()); + } + + let first = DaemonToggleRequest { + freeze: true, + ..Default::default() + }; + let second = DaemonToggleRequest { + mode: Some("whiteboard".into()), + ..Default::default() + }; + write_daemon_toggle_request(&first, "daemon-token").unwrap(); + write_daemon_toggle_request(&second, "daemon-token").unwrap(); + + assert_eq!( + take_daemon_toggle_requests("daemon-token").unwrap(), + vec![first, second] + ); + assert!( + daemon_command_dir() + .read_dir() + .map(|mut entries| entries.next().is_none()) + .unwrap_or(true) + ); + + match prev { + Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) }, + None => unsafe { env::remove_var("XDG_RUNTIME_DIR") }, + } + } + + #[test] + fn take_daemon_toggle_request_ignores_mismatched_token() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let prev = env::var_os("XDG_RUNTIME_DIR"); + unsafe { + env::set_var("XDG_RUNTIME_DIR", tmp.path()); + } + + write_daemon_toggle_request( + &DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + "other-daemon", + ) + .unwrap(); + + assert!( + take_daemon_toggle_requests("daemon-token") + .unwrap() + .is_empty() + ); + + match prev { + Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) }, + None => unsafe { env::remove_var("XDG_RUNTIME_DIR") }, + } + } + + #[test] + fn take_daemon_toggle_request_ignores_stale_payload() { + let _guard = ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let prev = env::var_os("XDG_RUNTIME_DIR"); + unsafe { + env::set_var("XDG_RUNTIME_DIR", tmp.path()); + } + + let payload = serde_json::to_vec(&DaemonToggleEnvelope { + daemon_token: "daemon-token".into(), + requested_at_unix_ms: current_unix_millis().unwrap() - 60_000, + request: DaemonToggleRequest { + freeze: true, + ..Default::default() + }, + }) + .unwrap(); + fs::create_dir_all(daemon_command_dir()).unwrap(); + fs::write(daemon_command_dir().join("stale.json"), payload).unwrap(); + + assert!( + take_daemon_toggle_requests("daemon-token") + .unwrap() + .is_empty() + ); + + match prev { + Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) }, + None => unsafe { env::remove_var("XDG_RUNTIME_DIR") }, + } + } +} diff --git a/src/daemon/core.rs b/src/daemon/core.rs index c25b62bb..f44ff629 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -19,6 +19,7 @@ use crate::paths::daemon_lock_file; use crate::session::try_lock_exclusive; use crate::{RESUME_SESSION_ENV, decode_session_override, encode_session_override}; +use super::control::DaemonToggleRequest; use super::global_shortcuts::start_global_shortcuts_listener; use super::tray::start_system_tray; #[cfg(feature = "tray")] @@ -29,7 +30,10 @@ pub struct Daemon { pub(super) overlay_state: OverlayState, pub(super) should_quit: Arc, pub(super) toggle_requested: Arc, + pub(super) signal_toggle_requested: Arc, pub(super) initial_mode: Option, + pub(super) instance_token: String, + pub(super) freeze_on_show: bool, pub(super) tray_enabled: bool, pub(super) backend_runner: Option>, pub(super) tray_thread: Option>, @@ -37,6 +41,7 @@ pub struct Daemon { pub(super) overlay_child: Option, pub(super) overlay_pid: Arc, pub(super) pending_activation_token: Option, + pub(super) pending_toggle_request: Option, pub(super) portal_activation_token_slot: Arc>>, pub(super) session_resume_override: Arc, pub(super) lock_file: Option, @@ -60,7 +65,10 @@ impl Daemon { overlay_state: OverlayState::Hidden, should_quit: Arc::new(AtomicBool::new(false)), toggle_requested: Arc::new(AtomicBool::new(false)), + signal_toggle_requested: Arc::new(AtomicBool::new(false)), initial_mode, + instance_token: crate::daemon::generate_daemon_instance_token(), + freeze_on_show: false, tray_enabled, backend_runner: None, tray_thread: None, @@ -68,6 +76,7 @@ impl Daemon { overlay_child: None, overlay_pid: Arc::new(AtomicU32::new(0)), pending_activation_token: None, + pending_toggle_request: None, portal_activation_token_slot: Arc::new(Mutex::new(None)), session_resume_override: override_state, lock_file: None, @@ -89,7 +98,10 @@ impl Daemon { overlay_state: OverlayState::Hidden, should_quit: Arc::new(AtomicBool::new(false)), toggle_requested: Arc::new(AtomicBool::new(false)), + signal_toggle_requested: Arc::new(AtomicBool::new(false)), initial_mode, + instance_token: crate::daemon::generate_daemon_instance_token(), + freeze_on_show: false, tray_enabled: true, backend_runner: Some(backend_runner), tray_thread: None, @@ -97,6 +109,7 @@ impl Daemon { overlay_child: None, overlay_pid: Arc::new(AtomicU32::new(0)), pending_activation_token: None, + pending_toggle_request: None, portal_activation_token_slot: Arc::new(Mutex::new(None)), session_resume_override: override_state, lock_file: None, @@ -116,6 +129,55 @@ impl Daemon { Self::with_backend_runner_internal(initial_mode, backend_runner) } + pub fn set_freeze_on_show(&mut self, enabled: bool) { + self.freeze_on_show = enabled; + } + + fn process_single_toggle( + &mut self, + request: Option, + activation_token: Option, + ) -> Result<()> { + self.pending_activation_token = activation_token; + self.pending_toggle_request = request.filter(|request| !request.is_empty()); + if let Err(err) = self.toggle_overlay() { + self.pending_activation_token = None; + self.pending_toggle_request = None; + return Err(err); + } + Ok(()) + } + + fn process_pending_toggles( + &mut self, + activation_token: Option, + signal_toggle_requested: bool, + ) -> Result<()> { + let queued_requests = if signal_toggle_requested { + crate::daemon::take_daemon_toggle_requests(&self.instance_token)? + } else { + Vec::new() + }; + + if !signal_toggle_requested || activation_token.is_some() { + self.process_single_toggle(None, activation_token)?; + } + + if signal_toggle_requested { + let requests = if queued_requests.is_empty() { + vec![DaemonToggleRequest::default()] + } else { + queued_requests + }; + + for request in requests { + self.process_single_toggle(Some(request), None)?; + } + } + + Ok(()) + } + pub(super) fn session_resume_override(&self) -> Option { decode_session_override(self.session_resume_override.load(Ordering::Acquire)) } @@ -163,20 +225,29 @@ impl Daemon { /// Run daemon with signal handling pub fn run(&mut self) -> Result<()> { info!("Starting wayscriber daemon"); - info!( - "Send SIGUSR1 to toggle overlay (e.g., kill -USR1 $(pgrep -fo 'wayscriber --daemon'))" - ); - info!( - "Configure Hyprland: bind = SUPER, D, exec, bash -lc \"kill -USR1 $(pgrep -fo 'wayscriber --daemon')\"" - ); + if self.freeze_on_show { + info!("Daemon activations will request frozen mode on show"); + } + info!("Daemon control command: wayscriber --daemon-toggle [--freeze] [--mode …]"); + info!("Preferred external control: wayscriber --daemon-toggle"); + info!("Legacy raw SIGUSR1 toggle still works, but cannot carry launch args"); self.acquire_daemon_lock()?; + if let Err(err) = crate::daemon::clear_daemon_toggle_request_file() { + warn!( + "Failed to clear stale daemon toggle request on startup: {}", + err + ); + } // Set up signal handling let mut signals = Signals::new([SIGUSR1, SIGTERM, SIGINT]) .context("Failed to register signal handler")?; + crate::daemon::write_daemon_pid_file(std::process::id(), &self.instance_token)?; + let toggle_flag = self.toggle_requested.clone(); + let signal_toggle_flag = self.signal_toggle_requested.clone(); let quit_flag = self.should_quit.clone(); // Spawn signal handler thread @@ -195,6 +266,7 @@ impl Daemon { info!("Received SIGUSR1 - toggling overlay"); // Use Release ordering to ensure all prior memory operations // are visible to the thread that reads this flag + signal_toggle_flag.store(true, Ordering::Release); toggle_flag.store(true, Ordering::Release); } SIGTERM | SIGINT => { @@ -264,6 +336,8 @@ impl Daemon { // Use Acquire ordering to ensure we see all memory operations // that happened before the flag was set if self.toggle_requested.swap(false, Ordering::Acquire) { + let signal_toggle_requested = + self.signal_toggle_requested.swap(false, Ordering::Acquire); let pending_token = self .portal_activation_token_slot .lock() @@ -272,10 +346,9 @@ impl Daemon { poisoned.into_inner() }) .take(); - self.pending_activation_token = pending_token; - - if let Err(err) = self.toggle_overlay() { - self.pending_activation_token = None; + if let Err(err) = + self.process_pending_toggles(pending_token, signal_toggle_requested) + { warn!("Toggle overlay failed: {}", err); } } @@ -302,6 +375,12 @@ impl Daemon { Err(err) => warn!("Global shortcuts listener thread panicked: {:?}", err), } } + if let Err(err) = crate::daemon::clear_daemon_toggle_request_file() { + warn!("Failed to clear daemon toggle request file: {}", err); + } + if let Err(err) = crate::daemon::clear_daemon_pid_file() { + warn!("Failed to clear daemon pid file: {}", err); + } Ok(()) } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index e4485166..e907cdf9 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,5 +1,6 @@ //! Daemon mode implementation: background service with toggle activation +mod control; mod core; mod global_shortcuts; mod icons; @@ -11,5 +12,10 @@ mod types; #[cfg(test)] mod tests; +pub(crate) use control::{ + DaemonToggleRequest, clear_daemon_pid_file, clear_daemon_toggle_request_file, + generate_daemon_instance_token, send_daemon_toggle_request, take_daemon_toggle_requests, + write_daemon_pid_file, +}; pub use core::Daemon; pub use types::AlreadyRunningError; diff --git a/src/daemon/overlay/mod.rs b/src/daemon/overlay/mod.rs index c1732663..2f1acce4 100644 --- a/src/daemon/overlay/mod.rs +++ b/src/daemon/overlay/mod.rs @@ -37,9 +37,19 @@ impl Daemon { info!("Overlay state set to Visible"); self.clear_overlay_spawn_error(); let previous_override = runtime_session_override(); - set_runtime_session_override(self.session_resume_override()); - let result = runner(self.initial_mode.clone()); + let request_override = self + .pending_toggle_request + .as_ref() + .and_then(|request| request.session_resume_override()); + set_runtime_session_override(request_override.or(self.session_resume_override())); + let requested_mode = self + .pending_toggle_request + .as_ref() + .and_then(|request| request.mode.clone()) + .or_else(|| self.initial_mode.clone()); + let result = runner(requested_mode); set_runtime_session_override(previous_override); + self.pending_toggle_request = None; self.overlay_state = OverlayState::Hidden; info!("Overlay closed, back to daemon mode"); return result; @@ -54,6 +64,7 @@ impl Daemon { return Err(err); } self.clear_overlay_spawn_error(); + self.pending_toggle_request = None; Ok(()) } @@ -67,11 +78,13 @@ impl Daemon { if self.backend_runner.is_some() { // Internal runner does not keep additional state to tear down debug!("Internal backend runner hidden"); + self.pending_toggle_request = None; self.overlay_state = OverlayState::Hidden; return Ok(()); } self.terminate_overlay_process()?; + self.pending_toggle_request = None; self.overlay_state = OverlayState::Hidden; Ok(()) } diff --git a/src/daemon/overlay/spawn.rs b/src/daemon/overlay/spawn.rs index 441cc96f..203c1d6b 100644 --- a/src/daemon/overlay/spawn.rs +++ b/src/daemon/overlay/spawn.rs @@ -127,6 +127,15 @@ impl Daemon { fn build_overlay_command(&self, program: &OsStr) -> Command { let mut command = Command::new(program); command.arg("--active"); + let request = self.pending_toggle_request.as_ref(); + if request.is_some_and(|request| request.freeze) || self.freeze_on_show { + command.arg("--freeze"); + } + if request.is_some_and(|request| request.exit_after_capture) { + command.arg("--exit-after-capture"); + } else if request.is_some_and(|request| request.no_exit_after_capture) { + command.arg("--no-exit-after-capture"); + } // Overlay children launched by daemon are already backgrounded and tracked. // Prevent `--active` from spawning another detached grandchild process. command.env("WAYSCRIBER_NO_DETACH", "1"); @@ -137,8 +146,20 @@ impl Daemon { command.env_remove("XDG_ACTIVATION_TOKEN"); command.env_remove("DESKTOP_STARTUP_ID"); } - self.apply_session_override_env(&mut command); - if let Some(mode) = &self.initial_mode { + if let Some(request_override) = + request.and_then(|request| request.session_resume_override()) + { + match request_override { + true => command.env(crate::RESUME_SESSION_ENV, "on"), + false => command.env(crate::RESUME_SESSION_ENV, "off"), + }; + } else { + self.apply_session_override_env(&mut command); + } + if let Some(mode) = request + .and_then(|request| request.mode.as_ref()) + .or(self.initial_mode.as_ref()) + { command.arg("--mode").arg(mode); } command.stdin(Stdio::null()); @@ -199,6 +220,13 @@ impl Daemon { mod tests { use super::*; + fn command_args(command: &Command) -> Vec { + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + #[test] fn backoff_duration_grows_and_caps() { let mut daemon = Daemon::new(None, false, None); @@ -242,6 +270,55 @@ mod tests { assert!(!daemon.overlay_spawn_backoff_logged); } + #[test] + fn build_overlay_command_includes_freeze_when_enabled() { + let mut daemon = Daemon::new(Some("whiteboard".into()), false, None); + daemon.set_freeze_on_show(true); + + let command = daemon.build_overlay_command(OsStr::new("wayscriber")); + + assert_eq!( + command_args(&command), + vec!["--active", "--freeze", "--mode", "whiteboard"] + ); + } + + #[test] + fn build_overlay_command_uses_toggle_request_args() { + let mut daemon = Daemon::new(Some("whiteboard".into()), false, None); + daemon.pending_toggle_request = Some(crate::daemon::DaemonToggleRequest { + mode: Some("transparent".into()), + freeze: true, + exit_after_capture: true, + ..Default::default() + }); + + let command = daemon.build_overlay_command(OsStr::new("wayscriber")); + + assert_eq!( + command_args(&command), + vec![ + "--active", + "--freeze", + "--exit-after-capture", + "--mode", + "transparent" + ] + ); + } + + #[test] + fn build_overlay_command_omits_freeze_by_default() { + let daemon = Daemon::new(Some("whiteboard".into()), false, None); + + let command = daemon.build_overlay_command(OsStr::new("wayscriber")); + + assert_eq!( + command_args(&command), + vec!["--active", "--mode", "whiteboard"] + ); + } + #[test] fn push_spawn_candidate_deduplicates_programs() { let mut candidates = Vec::new(); diff --git a/src/paths/mod.rs b/src/paths/mod.rs index 7059b217..acda52d5 100644 --- a/src/paths/mod.rs +++ b/src/paths/mod.rs @@ -73,6 +73,24 @@ pub fn tray_action_file() -> PathBuf { runtime_root().join("tray_action") } +/// Location for transient daemon toggle requests. +/// Uses XDG_RUNTIME_DIR when available; falls back to data/home/temp. +pub fn daemon_command_file() -> PathBuf { + runtime_root().join("daemon_command.json") +} + +/// Location for queued daemon toggle requests. +/// Uses XDG_RUNTIME_DIR when available; falls back to data/home/temp. +pub fn daemon_command_dir() -> PathBuf { + runtime_root().join("daemon-commands") +} + +/// Location for the running daemon PID. +/// Uses XDG_RUNTIME_DIR when available; falls back to data/home/temp. +pub fn daemon_pid_file() -> PathBuf { + runtime_root().join("wayscriber.pid") +} + /// Location for persistent logs. #[allow(dead_code)] pub fn log_dir() -> PathBuf { diff --git a/src/paths/tests.rs b/src/paths/tests.rs index 876c2aa1..5ebdc36e 100644 --- a/src/paths/tests.rs +++ b/src/paths/tests.rs @@ -19,6 +19,9 @@ fn tray_action_prefers_runtime_dir_when_set() { let path = tray_action_file(); assert!(path.starts_with(tmp.path())); + assert!(daemon_command_file().starts_with(tmp.path())); + assert!(daemon_command_dir().starts_with(tmp.path())); + assert!(daemon_pid_file().starts_with(tmp.path())); if let Some(prev) = prev { unsafe { diff --git a/tests/cli.rs b/tests/cli.rs index 06c5c753..59114b16 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -41,6 +41,15 @@ fn wayscriber_help_prints_usage() { )); } +#[test] +fn bare_usage_mentions_freeze_on_show() { + wayscriber_cmd() + .assert() + .success() + .stdout(predicate::str::contains("--freeze-on-show")) + .stdout(predicate::str::contains("--daemon-toggle")); +} + #[test] fn active_mode_requires_wayland_env() { wayscriber_cmd() diff --git a/tools/install.sh b/tools/install.sh index 96148d4c..65d7433b 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -10,6 +10,8 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" INSTALL_DIR="${WAYSCRIBER_INSTALL_DIR:-/usr/bin}" BINARY_NAME="wayscriber" +INSTALLED_BINARY="$INSTALL_DIR/$BINARY_NAME" +BIND_COMMAND="$INSTALL_DIR/$BINARY_NAME --daemon-toggle" CONFIG_DIR="$HOME/.config/wayscriber" HYPR_CONFIG="$HOME/.config/hypr/hyprland.conf" @@ -94,18 +96,18 @@ echo " Setup Instructions" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "1. Test the installation:" -echo " $BINARY_NAME --help" +echo " $INSTALLED_BINARY --help" echo "" echo "2. Run in daemon mode (recommended):" -echo " $BINARY_NAME --daemon &" +echo " $INSTALLED_BINARY --daemon &" echo "" echo "3. For Hyprland integration, add to $HYPR_CONFIG:" echo "" echo " # Autostart wayscriber daemon" -echo " exec-once = $INSTALL_DIR/$BINARY_NAME --daemon" +echo " exec-once = $INSTALLED_BINARY --daemon" echo "" echo " # Toggle overlay with Super+D" -echo " bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME" +echo " bind = SUPER, D, exec, $BIND_COMMAND" echo "" # Setup autostart options @@ -155,7 +157,7 @@ case $REPLY in ensure_replacement \ "$TARGET_SERVICE" \ "ExecStart=/usr/bin/wayscriber --daemon" \ - "ExecStart=$INSTALL_DIR/$BINARY_NAME --daemon" \ + "ExecStart=$INSTALLED_BINARY --daemon" \ "ExecStart override" ensure_replacement \ @@ -192,12 +194,14 @@ case $REPLY in read -p "Add Super+D keybind to Hyprland config? (y/n) " -n 1 -r echo "" if [[ $REPLY =~ ^[Yy]$ ]]; then - if grep -q "pkill -SIGUSR1 $BINARY_NAME" "$HYPR_CONFIG"; then + if grep -Fq "pkill -SIGUSR1 $BINARY_NAME" "$HYPR_CONFIG" \ + || grep -Fq "$BINARY_NAME --daemon-toggle" "$HYPR_CONFIG" \ + || grep -Fq "$BIND_COMMAND" "$HYPR_CONFIG"; then echo "⚠️ Keybind already configured" else echo "" >> "$HYPR_CONFIG" echo "# wayscriber toggle keybind" >> "$HYPR_CONFIG" - echo "bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME" >> "$HYPR_CONFIG" + echo "bind = SUPER, D, exec, $BIND_COMMAND" >> "$HYPR_CONFIG" echo "✅ Keybind added to Hyprland config" echo "" echo "Reload Hyprland: hyprctl reload" @@ -215,8 +219,8 @@ case $REPLY in else echo "" >> "$HYPR_CONFIG" echo "# wayscriber - Screen annotation tool" >> "$HYPR_CONFIG" - echo "exec-once = $INSTALL_DIR/$BINARY_NAME --daemon" >> "$HYPR_CONFIG" - echo "bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME" >> "$HYPR_CONFIG" + echo "exec-once = $INSTALLED_BINARY --daemon" >> "$HYPR_CONFIG" + echo "bind = SUPER, D, exec, $BIND_COMMAND" >> "$HYPR_CONFIG" echo "✅ Added to Hyprland config" fi echo "" @@ -225,14 +229,14 @@ case $REPLY in else echo "⚠️ Hyprland config not found at $HYPR_CONFIG" echo "Add these lines manually to your Hyprland config:" - echo " exec-once = $INSTALL_DIR/$BINARY_NAME --daemon" - echo " bind = SUPER, D, exec, pkill -SIGUSR1 $BINARY_NAME" + echo " exec-once = $INSTALLED_BINARY --daemon" + echo " bind = SUPER, D, exec, $BIND_COMMAND" fi ;; 3) echo "Skipping autostart setup." - echo "To start manually: $BINARY_NAME --daemon &" + echo "To start manually: $INSTALLED_BINARY --daemon &" ;; *)