diff --git a/Cargo.lock b/Cargo.lock index 12d3359..b158ad2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2393,6 +2393,7 @@ dependencies = [ "tracing-subscriber", "tray-icon", "wasapi", + "windows-registry", "winit", "winresource", "x25519-dalek", @@ -3804,6 +3805,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 4e48edd..cc237a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ x25519-dalek = "2.0.1" native-dialog = "0.9.6" tray-icon = { version = "0.23.1", default-features = false } wasapi = "0.23.0" +windows-registry = "0.6.1" winit = { version = "0.30.12", default-features = false } [build-dependencies] diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 9ae7239..31f45c4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -4,6 +4,9 @@ mod unsupported; #[cfg(target_os = "windows")] mod windows; +#[cfg(target_os = "windows")] +pub use windows::{is_start_at_login_enabled, set_start_at_login_enabled}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlatformInfo { pub os: &'static str, diff --git a/src/platform/windows.rs b/src/platform/windows.rs index ca8c13b..9fe3a41 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,6 +1,69 @@ pub const OS_NAME: &str = "windows"; +const RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; +const RUN_VALUE_NAME: &str = "Rairstream"; + #[must_use] pub const fn supports_system_audio_capture() -> bool { true } + +pub fn is_start_at_login_enabled() -> Result { + let run_key = windows_registry::CURRENT_USER + .open(RUN_KEY) + .map_err(|error| format!("failed to open Windows Run registry key: {error}"))?; + + match run_key.get_string(RUN_VALUE_NAME) { + Ok(command) => Ok(command == start_at_login_command()?), + Err(_) => Ok(false), + } +} + +pub fn set_start_at_login_enabled(enabled: bool) -> Result<(), String> { + let run_key = windows_registry::CURRENT_USER + .create(RUN_KEY) + .map_err(|error| format!("failed to open Windows Run registry key: {error}"))?; + + if enabled { + run_key + .set_string(RUN_VALUE_NAME, start_at_login_command()?) + .map_err(|error| format!("failed to enable Windows start at login: {error}"))?; + } else if is_start_at_login_enabled()? { + run_key + .remove_value(RUN_VALUE_NAME) + .map_err(|error| format!("failed to disable Windows start at login: {error}"))?; + } + + Ok(()) +} + +fn start_at_login_command() -> Result { + let exe_path = std::env::current_exe() + .map_err(|error| format!("failed to resolve current executable: {error}"))?; + Ok(quote_command_path(&exe_path.to_string_lossy())) +} + +fn quote_command_path(path: &str) -> String { + format!("\"{}\"", path.replace('"', r#"\""#)) +} + +#[cfg(test)] +mod tests { + use super::quote_command_path; + + #[test] + fn quote_command_path_wraps_executable_path() { + assert_eq!( + quote_command_path(r"C:\Program Files\Rairstream\rairstream.exe"), + r#""C:\Program Files\Rairstream\rairstream.exe""# + ); + } + + #[test] + fn quote_command_path_escapes_embedded_quotes() { + assert_eq!( + quote_command_path(r#"C:\Apps\Rain"stream.exe"#), + r#""C:\Apps\Rain\"stream.exe""# + ); + } +} diff --git a/src/ui/tray/windows.rs b/src/ui/tray/windows.rs index 6cda3d8..10d8ee0 100644 --- a/src/ui/tray/windows.rs +++ b/src/ui/tray/windows.rs @@ -15,6 +15,7 @@ use winit::window::WindowId; use crate::app::AppFacade; use crate::discovery::MdnsDiscoveryService; +use crate::platform; use super::{TrayCommand, TrayEvent, TrayPhase, TraySnapshot, TrayWorker, tray_receiver_label}; @@ -24,6 +25,7 @@ const POWERSHELL_CREATE_NO_WINDOW: u32 = 0x0800_0000; const MENU_ID_REFRESH: &str = "refresh"; const MENU_ID_START_STREAMING: &str = "start-streaming"; const MENU_ID_STOP_STREAMING: &str = "stop-streaming"; +const MENU_ID_START_AT_LOGIN: &str = "start-at-login"; const MENU_ID_QUIT: &str = "quit"; const MENU_ID_TARGET_PREFIX: &str = "target:"; const MENU_ID_PAIR_PREFIX: &str = "pair:"; @@ -40,6 +42,7 @@ enum MenuAction { Refresh, StartStreaming, StopStreaming, + ToggleStartAtLogin, Quit, ToggleReceiver { receiver_id: String }, RequestPairing { receiver_id: String }, @@ -115,6 +118,16 @@ impl TrayApp { MenuAction::Refresh => TrayCommand::RefreshDevices, MenuAction::StartStreaming => TrayCommand::StartStreaming, MenuAction::StopStreaming => TrayCommand::StopStreaming, + MenuAction::ToggleStartAtLogin => { + if let Some(menu) = self.menu.as_ref() { + let enabled = menu.start_at_login.is_checked(); + if let Err(error) = platform::set_start_at_login_enabled(enabled) { + menu.start_at_login.set_checked(!enabled); + show_alert(MessageLevel::Error, &error); + } + } + return; + } MenuAction::Quit => TrayCommand::Quit, MenuAction::ToggleReceiver { receiver_id } => { let Some(selected) = menu.checked_state(&receiver_id) else { @@ -228,6 +241,7 @@ struct TrayMenu { forget_pairing: Submenu, start_streaming: MenuItem, stop_streaming: MenuItem, + start_at_login: CheckMenuItem, quit: MenuItem, target_items: HashMap, } @@ -244,11 +258,19 @@ impl TrayMenu { MenuItem::with_id(MENU_ID_START_STREAMING, "Start Streaming", false, None); let stop_streaming = MenuItem::with_id(MENU_ID_STOP_STREAMING, "Stop Streaming", false, None); + let start_at_login = CheckMenuItem::with_id( + MENU_ID_START_AT_LOGIN, + "Start at login", + true, + platform::is_start_at_login_enabled().unwrap_or(false), + None, + ); let quit = MenuItem::with_id(MENU_ID_QUIT, "Quit", true, None); let separator_a = PredefinedMenuItem::separator(); let separator_b = PredefinedMenuItem::separator(); let separator_c = PredefinedMenuItem::separator(); + let separator_d = PredefinedMenuItem::separator(); root.append_items(&[ &status, &separator_a, @@ -260,6 +282,8 @@ impl TrayMenu { &start_streaming, &stop_streaming, &separator_c, + &start_at_login, + &separator_d, &quit, ]) .map_err(|error| format!("failed to build tray menu: {error}"))?; @@ -273,6 +297,7 @@ impl TrayMenu { forget_pairing, start_streaming, stop_streaming, + start_at_login, quit, target_items: HashMap::new(), }; @@ -296,6 +321,7 @@ impl TrayMenu { self.start_streaming .set_enabled(!is_streaming && !is_waiting_for_pin && has_selected_targets); self.stop_streaming.set_enabled(is_streaming); + self.start_at_login.set_enabled(true); self.quit.set_enabled(true); Ok(()) } @@ -530,6 +556,9 @@ fn parse_menu_action(menu_id: &MenuId) -> Option { if menu_id == MENU_ID_STOP_STREAMING { return Some(MenuAction::StopStreaming); } + if menu_id == MENU_ID_START_AT_LOGIN { + return Some(MenuAction::ToggleStartAtLogin); + } if menu_id == MENU_ID_QUIT { return Some(MenuAction::Quit); } @@ -572,6 +601,10 @@ mod tests { parse_menu_action(&MenuId::new("quit")), Some(MenuAction::Quit) ); + assert_eq!( + parse_menu_action(&MenuId::new("start-at-login")), + Some(MenuAction::ToggleStartAtLogin) + ); } #[test]