From 85ac16f313c85b90687054c12bc4ee8951f16e40 Mon Sep 17 00:00:00 2001 From: ByteColtX Date: Sat, 16 May 2026 13:41:27 +0800 Subject: [PATCH] feat(tray): add auto reconnect --- src/app/facade.rs | 73 ++++++++++++++++++++ src/config/model.rs | 7 ++ src/platform/mod.rs | 9 +++ src/ui/tray/i18n.rs | 14 ++++ src/ui/tray/mod.rs | 151 ++++++++++++++++++++++++++++++++++++++--- src/ui/tray/windows.rs | 44 +++++++++++- tests/pairing_flow.rs | 24 +++++++ 7 files changed, 310 insertions(+), 12 deletions(-) diff --git a/src/app/facade.rs b/src/app/facade.rs index 8d65c9e..d9dc7ff 100644 --- a/src/app/facade.rs +++ b/src/app/facade.rs @@ -8,6 +8,7 @@ use crate::config::{ use crate::discovery::{DiscoveryService, MdnsDiscoveryService}; use crate::error::RairstreamError; use crate::pairing::ReceiverCredentials; +use crate::platform; use crate::receiver::{Receiver, selector}; use crate::session::{ PlaybackSession, pair_receiver_with_pin, play_capture, play_file, @@ -200,6 +201,29 @@ where Ok(()) } + pub fn set_auto_reconnect(&mut self, enabled: bool) -> Result<(), RairstreamError> { + self.set_auto_reconnect_with_start_at_login(enabled, platform::set_start_at_login_enabled) + } + + fn set_auto_reconnect_with_start_at_login( + &mut self, + enabled: bool, + set_start_at_login_enabled: F, + ) -> Result<(), RairstreamError> + where + F: FnOnce(bool) -> Result<(), String>, + { + if enabled { + set_start_at_login_enabled(true).map_err(|message| RairstreamError::Playback { + message: format!("failed to enable start at login for auto reconnect: {message}"), + })?; + } + + self.config.set_auto_reconnect(enabled); + self.persist_config()?; + Ok(()) + } + pub fn paired_forget( &mut self, selector_text: &str, @@ -694,6 +718,55 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn set_auto_reconnect_enables_start_at_login_before_persisting() { + let path = temp_config_path(); + let mut facade = AppFacade::with_config_path( + FixedDiscoveryService { + receivers: Vec::new(), + }, + path.clone(), + ) + .unwrap(); + + facade + .set_auto_reconnect_with_start_at_login(true, |enabled| { + assert!(enabled); + Ok(()) + }) + .unwrap(); + + assert!(facade.config().auto_reconnect); + let reloaded = crate::config::load_config(&path).unwrap(); + assert!(reloaded.auto_reconnect); + let _ = std::fs::remove_file(path); + } + + #[test] + fn set_auto_reconnect_failure_does_not_persist_enabled_state() { + let path = temp_config_path(); + let mut facade = AppFacade::with_config_path( + FixedDiscoveryService { + receivers: Vec::new(), + }, + path.clone(), + ) + .unwrap(); + + let error = facade + .set_auto_reconnect_with_start_at_login(true, |_| Err(String::from("registry denied"))) + .unwrap_err(); + + assert_eq!( + error.to_string(), + "playback failed: failed to enable start at login for auto reconnect: registry denied" + ); + assert!(!facade.config().auto_reconnect); + let reloaded = crate::config::load_config(&path).unwrap(); + assert!(!reloaded.auto_reconnect); + let _ = std::fs::remove_file(path); + } + #[test] fn play_file_resets_state_after_failure() { let path = temp_config_path(); diff --git a/src/config/model.rs b/src/config/model.rs index 29b208f..a680860 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -33,6 +33,8 @@ pub struct AppConfig { pub tray_selected_receiver_ids: Vec, #[serde(default)] pub tray_language: TrayLanguagePreference, + #[serde(default)] + pub auto_reconnect: bool, } impl Default for AppConfig { @@ -43,6 +45,7 @@ impl Default for AppConfig { receiver_cache: HashMap::new(), tray_selected_receiver_ids: Vec::new(), tray_language: TrayLanguagePreference::default(), + auto_reconnect: false, } } } @@ -76,6 +79,10 @@ impl AppConfig { pub fn set_tray_language(&mut self, language: TrayLanguagePreference) { self.tray_language = language; } + + pub fn set_auto_reconnect(&mut self, enabled: bool) { + self.auto_reconnect = enabled; + } } const fn default_sender_volume_percent() -> u16 { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 31f45c4..f50a497 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -7,6 +7,15 @@ mod windows; #[cfg(target_os = "windows")] pub use windows::{is_start_at_login_enabled, set_start_at_login_enabled}; +#[cfg(not(target_os = "windows"))] +pub fn set_start_at_login_enabled(enabled: bool) -> Result<(), String> { + if enabled { + return Err(String::from("start at login is only supported on Windows")); + } + + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlatformInfo { pub os: &'static str, diff --git a/src/ui/tray/i18n.rs b/src/ui/tray/i18n.rs index 833ee1f..1076b60 100644 --- a/src/ui/tray/i18n.rs +++ b/src/ui/tray/i18n.rs @@ -12,12 +12,14 @@ pub enum TrayText { StatusIdle, StatusWaitingForPin, StatusStreamingToDevices, + StatusReconnecting, RefreshDevices, PlaybackTargets, PairDevice, ForgetPairing, StartStreaming, StopStreaming, + AutoReconnect, StartAtLogin, Language, LanguageSystem, @@ -74,6 +76,14 @@ impl TrayI18n { ) } + #[must_use] + pub fn status_reconnecting(&self, count: usize, attempt: u32) -> String { + format!( + "{} {count} device(s) - attempt {attempt}", + self.text(TrayText::StatusReconnecting) + ) + } + #[must_use] pub fn saved_pairing_for(&self, label: &str) -> String { format!("{} {label}", self.text(TrayText::SavedPairingFor)) @@ -180,12 +190,14 @@ fn text_for(locale: TrayLocale, key: TrayText) -> &'static str { (TrayLocale::ZhCn, TrayText::StatusIdle) => "状态:空闲", (TrayLocale::ZhCn, TrayText::StatusWaitingForPin) => "状态:等待 PIN", (TrayLocale::ZhCn, TrayText::StatusStreamingToDevices) => "状态:正在串流到", + (TrayLocale::ZhCn, TrayText::StatusReconnecting) => "状态:正在重连到", (TrayLocale::ZhCn, TrayText::RefreshDevices) => "刷新设备", (TrayLocale::ZhCn, TrayText::PlaybackTargets) => "播放目标", (TrayLocale::ZhCn, TrayText::PairDevice) => "配对设备", (TrayLocale::ZhCn, TrayText::ForgetPairing) => "忘记配对", (TrayLocale::ZhCn, TrayText::StartStreaming) => "开始串流", (TrayLocale::ZhCn, TrayText::StopStreaming) => "停止串流", + (TrayLocale::ZhCn, TrayText::AutoReconnect) => "自动重连", (TrayLocale::ZhCn, TrayText::StartAtLogin) => "登录时启动", (TrayLocale::ZhCn, TrayText::Language) => "语言", (TrayLocale::ZhCn, TrayText::LanguageSystem) => "跟随系统", @@ -203,12 +215,14 @@ fn text_for(locale: TrayLocale, key: TrayText) -> &'static str { (_, TrayText::StatusIdle) => "Status: Idle", (_, TrayText::StatusWaitingForPin) => "Status: Waiting for PIN", (_, TrayText::StatusStreamingToDevices) => "Status: Streaming to", + (_, TrayText::StatusReconnecting) => "Status: Reconnecting to", (_, TrayText::RefreshDevices) => "Refresh Devices", (_, TrayText::PlaybackTargets) => "Playback Targets", (_, TrayText::PairDevice) => "Pair Device", (_, TrayText::ForgetPairing) => "Forget Pairing", (_, TrayText::StartStreaming) => "Start Streaming", (_, TrayText::StopStreaming) => "Stop Streaming", + (_, TrayText::AutoReconnect) => "Auto Reconnect", (_, TrayText::StartAtLogin) => "Start at login", (_, TrayText::Language) => "Language", (_, TrayText::LanguageSystem) => "Follow System", diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index 841ec86..6ccce89 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -3,6 +3,7 @@ use crate::config::TrayLanguagePreference; use crate::discovery::DiscoveryService; use crate::error::RairstreamError; use crate::session::PlaybackSession; +use std::time::{Duration, Instant}; pub mod i18n; @@ -28,6 +29,10 @@ pub enum TrayPhase { Streaming { receiver_ids: Vec, }, + Reconnecting { + receiver_ids: Vec, + attempt: u32, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,6 +40,7 @@ pub struct TraySnapshot { pub phase: TrayPhase, pub receivers: Vec, pub language: TrayLanguagePreference, + pub auto_reconnect: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -47,6 +53,7 @@ pub enum TrayCommand { ForgetPairing { receiver_id: String }, StartStreaming, StopStreaming, + SetAutoReconnect { enabled: bool }, SetLanguage { language: TrayLanguagePreference }, Quit, } @@ -98,6 +105,14 @@ impl TrayRuntimeState { self.phase = TrayPhase::Streaming { receiver_ids }; } + pub fn begin_reconnecting(&mut self, receiver_ids: Vec, attempt: u32) { + self.exit_after_stop = false; + self.phase = TrayPhase::Reconnecting { + receiver_ids, + attempt, + }; + } + #[must_use] pub fn stop_streaming(&mut self) -> bool { self.phase = TrayPhase::Idle; @@ -108,7 +123,10 @@ impl TrayRuntimeState { #[must_use] pub fn request_quit(&mut self) -> QuitAction { - if matches!(self.phase, TrayPhase::Streaming { .. }) { + if matches!( + self.phase, + TrayPhase::Streaming { .. } | TrayPhase::Reconnecting { .. } + ) { self.exit_after_stop = true; QuitAction::StopStreamingFirst } else { @@ -121,6 +139,7 @@ pub struct TrayWorker { facade: AppFacade, runtime: TrayRuntimeState, active_session: Option, + reconnect_due: Option, } impl TrayWorker @@ -132,6 +151,7 @@ where facade, runtime: TrayRuntimeState::default(), active_session: None, + reconnect_due: None, } } @@ -141,6 +161,7 @@ where phase: self.runtime.phase().clone(), receivers: self.facade.tray_receivers(), language: self.facade.config().tray_language, + auto_reconnect: self.facade.config().auto_reconnect, } } @@ -164,21 +185,22 @@ where TrayCommand::ForgetPairing { receiver_id } => self.forget_pairing(&receiver_id), TrayCommand::StartStreaming => self.start_streaming(), TrayCommand::StopStreaming => self.stop_streaming(), + TrayCommand::SetAutoReconnect { enabled } => self.set_auto_reconnect(enabled), TrayCommand::SetLanguage { language } => self.set_language(language), TrayCommand::Quit => self.quit(), } } pub fn poll(&mut self) -> Vec { - let Some(error) = self + if let Some(error) = self .active_session .as_ref() .and_then(PlaybackSession::transport_error) - else { - return Vec::new(); - }; + { + return self.handle_transport_error(error); + } - self.stop_active_session_with_error(error) + self.poll_reconnect(Instant::now()) } fn refresh_devices(&mut self) -> Vec { @@ -270,6 +292,7 @@ where match self.facade.play_capture(&selected_ids) { Ok(session) => { self.active_session = Some(session); + self.reconnect_due = None; self.runtime.start_streaming(selected_ids); vec![self.snapshot_event()] } @@ -290,6 +313,7 @@ where fn stop_active_session(&mut self, exit_after_stop: bool) -> Vec { let mut events = Vec::new(); + self.reconnect_due = None; if let Some(session) = self.active_session.take() { if let Err(error) = self.facade.stop_capture(session) { events.push(TrayEvent::Error(error.to_string())); @@ -304,7 +328,12 @@ where events } - fn stop_active_session_with_error(&mut self, error: RairstreamError) -> Vec { + fn handle_transport_error(&mut self, error: RairstreamError) -> Vec { + let receiver_ids = match self.runtime.phase() { + TrayPhase::Streaming { receiver_ids } => receiver_ids.clone(), + _ => Vec::new(), + }; + let error = if let Some(session) = self.active_session.take() { match self.facade.stop_capture(session) { Ok(()) => error, @@ -316,6 +345,12 @@ where error }; + if self.facade.config().auto_reconnect && !receiver_ids.is_empty() { + self.runtime.begin_reconnecting(receiver_ids, 1); + self.reconnect_due = Some(Instant::now()); + return vec![TrayEvent::Error(error.to_string()), self.snapshot_event()]; + } + let should_exit = self.runtime.stop_streaming(); let mut events = vec![TrayEvent::Error(error.to_string()), self.snapshot_event()]; if should_exit { @@ -324,6 +359,39 @@ where events } + fn poll_reconnect(&mut self, now: Instant) -> Vec { + let Some(due) = self.reconnect_due else { + return Vec::new(); + }; + if now < due { + return Vec::new(); + } + + let TrayPhase::Reconnecting { + receiver_ids, + attempt, + } = self.runtime.phase() + else { + self.reconnect_due = None; + return Vec::new(); + }; + let receiver_ids = receiver_ids.clone(); + let attempt = *attempt; + + let _ = self.facade.discover(); + if let Ok(session) = self.facade.play_capture(&receiver_ids) { + self.active_session = Some(session); + self.reconnect_due = None; + self.runtime.start_streaming(receiver_ids); + vec![self.snapshot_event()] + } else { + let next_attempt = attempt.saturating_add(1); + self.runtime.begin_reconnecting(receiver_ids, next_attempt); + self.reconnect_due = Some(now + reconnect_delay(next_attempt)); + vec![self.snapshot_event()] + } + } + fn finish_config_write(&mut self, result: Result<(), RairstreamError>) -> Vec { match result { Ok(()) => vec![self.snapshot_event()], @@ -349,11 +417,28 @@ where self.finish_config_write(result) } + fn set_auto_reconnect(&mut self, enabled: bool) -> Vec { + let result = self.facade.set_auto_reconnect(enabled); + self.finish_config_write(result) + } + fn i18n(&self) -> i18n::TrayI18n { i18n::TrayI18n::new(self.facade.config().tray_language) } } +#[must_use] +fn reconnect_delay(attempt: u32) -> Duration { + match attempt { + 0 | 1 => Duration::ZERO, + 2 => Duration::from_secs(2), + 3 => Duration::from_secs(5), + 4 => Duration::from_secs(10), + 5 => Duration::from_secs(30), + _ => Duration::from_secs(60), + } +} + #[must_use] pub fn tray_receiver_label(entry: &TrayReceiverEntry) -> String { entry @@ -367,7 +452,7 @@ mod tests { use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::app::AppFacade; use crate::config::{AppConfig, TrayLanguagePreference, save_config}; @@ -376,7 +461,10 @@ mod tests { AirPlayGeneration, AuthMethod, DeviceSupport, Receiver, ReceiverCapabilities, ReceiverKind, }; - use super::{QuitAction, TrayCommand, TrayEvent, TrayPhase, TrayRuntimeState, TrayWorker}; + use super::{ + QuitAction, TrayCommand, TrayEvent, TrayPhase, TrayRuntimeState, TrayWorker, + reconnect_delay, + }; use crate::discovery::DiscoveryService; #[derive(Clone)] @@ -469,6 +557,41 @@ mod tests { assert_eq!(state.phase(), &TrayPhase::Idle); } + #[test] + fn runtime_state_requests_stop_before_exit_while_reconnecting() { + let mut state = TrayRuntimeState::default(); + state.begin_reconnecting(vec![String::from("living-room")], 2); + + assert_eq!(state.request_quit(), QuitAction::StopStreamingFirst); + assert!(state.stop_streaming()); + assert_eq!(state.phase(), &TrayPhase::Idle); + } + + #[test] + fn runtime_state_tracks_reconnecting_attempt() { + let mut state = TrayRuntimeState::default(); + state.begin_reconnecting(vec![String::from("living-room")], 3); + + assert_eq!( + state.phase(), + &TrayPhase::Reconnecting { + receiver_ids: vec![String::from("living-room")], + attempt: 3, + } + ); + } + + #[test] + fn reconnect_delay_uses_capped_backoff() { + assert_eq!(reconnect_delay(1), Duration::ZERO); + assert_eq!(reconnect_delay(2), Duration::from_secs(2)); + assert_eq!(reconnect_delay(3), Duration::from_secs(5)); + assert_eq!(reconnect_delay(4), Duration::from_secs(10)); + assert_eq!(reconnect_delay(5), Duration::from_secs(30)); + assert_eq!(reconnect_delay(6), Duration::from_secs(60)); + assert_eq!(reconnect_delay(99), Duration::from_secs(60)); + } + #[test] fn runtime_state_clears_waiting_pin_on_cancel() { let mut state = TrayRuntimeState::default(); @@ -607,4 +730,14 @@ mod tests { assert_eq!(reloaded.tray_language, TrayLanguagePreference::ZhCn); let _ = std::fs::remove_file(path); } + + #[test] + fn worker_snapshot_includes_auto_reconnect_state() { + let mut config = AppConfig::default(); + config.set_auto_reconnect(true); + let (worker, path) = build_worker(&config, Vec::new()); + + assert!(worker.snapshot().auto_reconnect); + let _ = std::fs::remove_file(path); + } } diff --git a/src/ui/tray/windows.rs b/src/ui/tray/windows.rs index c1be1f2..b3275a8 100644 --- a/src/ui/tray/windows.rs +++ b/src/ui/tray/windows.rs @@ -29,6 +29,7 @@ const PLAYBACK_POLL_INTERVAL: Duration = Duration::from_millis(250); 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_AUTO_RECONNECT: &str = "auto-reconnect"; const MENU_ID_START_AT_LOGIN: &str = "start-at-login"; const MENU_ID_LANGUAGE_SYSTEM: &str = "language:system"; const MENU_ID_LANGUAGE_EN_US: &str = "language:en-us"; @@ -49,6 +50,7 @@ enum MenuAction { Refresh, StartStreaming, StopStreaming, + ToggleAutoReconnect, ToggleStartAtLogin, SetLanguage(TrayLanguagePreference), Quit, @@ -126,6 +128,10 @@ impl TrayApp { MenuAction::Refresh => TrayCommand::RefreshDevices, MenuAction::StartStreaming => TrayCommand::StartStreaming, MenuAction::StopStreaming => TrayCommand::StopStreaming, + MenuAction::ToggleAutoReconnect => { + let enabled = menu.auto_reconnect.is_checked(); + TrayCommand::SetAutoReconnect { enabled } + } MenuAction::ToggleStartAtLogin => { if let Some(menu) = self.menu.as_ref() { let enabled = menu.start_at_login.is_checked(); @@ -257,6 +263,7 @@ struct TrayMenu { forget_pairing: Submenu, start_streaming: MenuItem, stop_streaming: MenuItem, + auto_reconnect: CheckMenuItem, start_at_login: CheckMenuItem, language: Submenu, language_system: CheckMenuItem, @@ -300,6 +307,13 @@ impl TrayMenu { false, None, ); + let auto_reconnect = CheckMenuItem::with_id( + MENU_ID_AUTO_RECONNECT, + i18n.text(TrayText::AutoReconnect), + true, + false, + None, + ); let start_at_login = CheckMenuItem::with_id( MENU_ID_START_AT_LOGIN, i18n.text(TrayText::StartAtLogin), @@ -325,6 +339,7 @@ impl TrayMenu { &start_streaming, &stop_streaming, &separator_c, + &auto_reconnect, &start_at_login, &language_menu.submenu, &separator_d, @@ -341,6 +356,7 @@ impl TrayMenu { forget_pairing, start_streaming, stop_streaming, + auto_reconnect, start_at_login, language: language_menu.submenu, language_system: language_menu.system, @@ -354,6 +370,7 @@ impl TrayMenu { phase: TrayPhase::Idle, receivers: Vec::new(), language: TrayLanguagePreference::System, + auto_reconnect: false, })?; Ok(menu) } @@ -362,18 +379,26 @@ impl TrayMenu { self.language_preference = snapshot.language; self.apply_static_text(); self.status.set_text(format_status(snapshot)); + self.auto_reconnect.set_checked(snapshot.auto_reconnect); + if snapshot.auto_reconnect { + self.start_at_login.set_checked(true); + } self.sync_language_checks(); self.rebuild_targets(snapshot)?; self.rebuild_pair_devices(snapshot)?; self.rebuild_forget_pairing(snapshot)?; let is_streaming = matches!(snapshot.phase, TrayPhase::Streaming { .. }); + let is_reconnecting = matches!(snapshot.phase, TrayPhase::Reconnecting { .. }); let is_waiting_for_pin = matches!(snapshot.phase, TrayPhase::WaitingForPin { .. }); let has_selected_targets = snapshot.receivers.iter().any(|entry| entry.is_selected); self.refresh.set_enabled(!is_waiting_for_pin); - self.start_streaming - .set_enabled(!is_streaming && !is_waiting_for_pin && has_selected_targets); - self.stop_streaming.set_enabled(is_streaming); + self.start_streaming.set_enabled( + !is_streaming && !is_reconnecting && !is_waiting_for_pin && has_selected_targets, + ); + self.stop_streaming + .set_enabled(is_streaming || is_reconnecting); + self.auto_reconnect.set_enabled(true); self.start_at_login.set_enabled(true); self.quit.set_enabled(true); Ok(()) @@ -395,6 +420,8 @@ impl TrayMenu { .set_text(i18n.text(TrayText::StartStreaming)); self.stop_streaming .set_text(i18n.text(TrayText::StopStreaming)); + self.auto_reconnect + .set_text(i18n.text(TrayText::AutoReconnect)); self.start_at_login .set_text(i18n.text(TrayText::StartAtLogin)); self.language.set_text(i18n.text(TrayText::Language)); @@ -650,6 +677,10 @@ fn format_status(snapshot: &TraySnapshot) -> String { TrayPhase::Streaming { receiver_ids } => { i18n.status_streaming_to_devices(receiver_ids.len()) } + TrayPhase::Reconnecting { + receiver_ids, + attempt, + } => i18n.status_reconnecting(receiver_ids.len(), *attempt), } } @@ -713,6 +744,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_AUTO_RECONNECT { + return Some(MenuAction::ToggleAutoReconnect); + } if menu_id == MENU_ID_START_AT_LOGIN { return Some(MenuAction::ToggleStartAtLogin); } @@ -765,6 +799,10 @@ mod tests { parse_menu_action(&MenuId::new("start-streaming")), Some(MenuAction::StartStreaming) ); + assert_eq!( + parse_menu_action(&MenuId::new("auto-reconnect")), + Some(MenuAction::ToggleAutoReconnect) + ); assert_eq!( parse_menu_action(&MenuId::new("quit")), Some(MenuAction::Quit) diff --git a/tests/pairing_flow.rs b/tests/pairing_flow.rs index 7cb108a..daa6c4d 100644 --- a/tests/pairing_flow.rs +++ b/tests/pairing_flow.rs @@ -33,3 +33,27 @@ fn paired_credentials_round_trip_through_config_file() { assert_eq!(loaded.paired_receivers.len(), 1); let _ = std::fs::remove_file(path); } + +#[test] +fn auto_reconnect_round_trips_through_config_file() { + let path = temp_config_path(); + let mut config = AppConfig::default(); + config.set_auto_reconnect(true); + + save_config(&path, &config).unwrap(); + let loaded = load_config(&path).unwrap(); + + assert!(loaded.auto_reconnect); + let _ = std::fs::remove_file(path); +} + +#[test] +fn missing_auto_reconnect_defaults_to_disabled() { + let path = temp_config_path(); + std::fs::write(&path, r#"{"sender_volume_percent":100}"#).unwrap(); + + let loaded = load_config(&path).unwrap(); + + assert!(!loaded.auto_reconnect); + let _ = std::fs::remove_file(path); +}