1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::{Arc, Mutex};
13
14use tauri::{AppHandle, Emitter, Runtime};
15use tokio::sync::{mpsc, oneshot};
16use tokio_util::sync::CancellationToken;
17
18use crate::desired_state::DesiredStateBackend;
19use crate::error::ServiceError;
20use crate::models::{
21 validate_foreground_service_type, LifecycleMode, PluginEvent, ServiceContext,
22 ServiceState as ServiceLifecycle, ServiceStatus, StartConfig,
23};
24use crate::notifier::Notifier;
25use crate::service_trait::BackgroundService;
26
27#[doc(hidden)]
29pub type OnCompleteCallback = Box<dyn Fn(bool) + Send + Sync>;
30
31pub(crate) trait MobileKeepalive: Send + Sync {
37 #[allow(clippy::too_many_arguments)]
39 fn start_keepalive(
40 &self,
41 label: &str,
42 foreground_service_type: &str,
43 ios_safety_timeout_secs: Option<f64>,
44 ios_processing_safety_timeout_secs: Option<f64>,
45 ios_earliest_refresh_begin_minutes: Option<f64>,
46 ios_earliest_processing_begin_minutes: Option<f64>,
47 ios_requires_external_power: Option<bool>,
48 ios_requires_network_connectivity: Option<bool>,
49 ) -> Result<(), ServiceError>;
50 fn stop_keepalive(&self) -> Result<(), ServiceError>;
52}
53
54#[doc(hidden)]
56pub type ServiceFactory<R> = Box<dyn Fn() -> Box<dyn BackgroundService<R>> + Send + Sync>;
57
58#[non_exhaustive]
67pub enum ManagerCommand<R: Runtime> {
68 Start {
69 config: StartConfig,
70 reply: oneshot::Sender<Result<(), ServiceError>>,
71 app: AppHandle<R>,
72 },
73 Stop {
74 reply: oneshot::Sender<Result<(), ServiceError>>,
75 },
76 IsRunning {
77 reply: oneshot::Sender<bool>,
78 },
79 GetState {
80 reply: oneshot::Sender<ServiceStatus>,
81 },
82 SetOnComplete {
83 callback: OnCompleteCallback,
84 },
85 #[allow(dead_code, private_interfaces)]
86 SetMobile {
87 mobile: Arc<dyn MobileKeepalive>,
88 },
89 SetDesiredRunning {
90 desired: bool,
91 config: Option<StartConfig>,
92 reply: oneshot::Sender<Result<(), ServiceError>>,
93 },
94 EnableAutoRestart {
95 config: Option<StartConfig>,
96 reply: oneshot::Sender<Result<(), ServiceError>>,
97 },
98 DisableAutoRestart {
99 reply: oneshot::Sender<Result<(), ServiceError>>,
100 },
101 GetDesiredState {
102 reply: oneshot::Sender<Option<crate::desired_state::DesiredState>>,
103 },
104}
105
106pub struct ServiceManagerHandle<R: Runtime> {
114 pub(crate) cmd_tx: mpsc::Sender<ManagerCommand<R>>,
115}
116
117impl<R: Runtime> ServiceManagerHandle<R> {
118 pub fn new(cmd_tx: mpsc::Sender<ManagerCommand<R>>) -> Self {
120 Self { cmd_tx }
121 }
122
123 pub async fn start(&self, app: AppHandle<R>, config: StartConfig) -> Result<(), ServiceError> {
128 let (reply, rx) = oneshot::channel();
129 self.cmd_tx
130 .send(ManagerCommand::Start { config, reply, app })
131 .await
132 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
133 rx.await
134 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
135 }
136
137 pub async fn stop(&self) -> Result<(), ServiceError> {
142 let (reply, rx) = oneshot::channel();
143 self.cmd_tx
144 .send(ManagerCommand::Stop { reply })
145 .await
146 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
147 rx.await
148 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
149 }
150
151 pub fn stop_blocking(&self) -> Result<(), ServiceError> {
157 let (reply, rx) = oneshot::channel();
158 self.cmd_tx
159 .blocking_send(ManagerCommand::Stop { reply })
160 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
161 rx.blocking_recv()
162 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
163 }
164
165 pub async fn is_running(&self) -> bool {
167 let (reply, rx) = oneshot::channel();
168 if self
169 .cmd_tx
170 .send(ManagerCommand::IsRunning { reply })
171 .await
172 .is_err()
173 {
174 return false;
175 }
176 rx.await.unwrap_or(false)
177 }
178
179 #[doc(hidden)]
184 pub async fn set_on_complete(&self, callback: OnCompleteCallback) {
185 let _ = self
186 .cmd_tx
187 .send(ManagerCommand::SetOnComplete { callback })
188 .await;
189 }
190
191 pub async fn get_state(&self) -> ServiceStatus {
193 let (reply, rx) = oneshot::channel();
194 if self
195 .cmd_tx
196 .send(ManagerCommand::GetState { reply })
197 .await
198 .is_err()
199 {
200 return ServiceStatus {
201 state: ServiceLifecycle::Idle,
202 ..Default::default()
203 };
204 }
205 rx.await.unwrap_or(ServiceStatus {
206 state: ServiceLifecycle::Idle,
207 ..Default::default()
208 })
209 }
210}
211
212struct ServiceState<R: Runtime> {
216 is_running: Arc<AtomicBool>,
220 token: Arc<Mutex<Option<CancellationToken>>>,
224 generation: Arc<AtomicU64>,
227 on_complete: Option<OnCompleteCallback>,
231 factory: ServiceFactory<R>,
233 mobile: Option<Arc<dyn MobileKeepalive>>,
235 ios_safety_timeout_secs: f64,
238 ios_processing_safety_timeout_secs: f64,
242 ios_earliest_refresh_begin_minutes: f64,
244 ios_earliest_processing_begin_minutes: f64,
246 ios_requires_external_power: bool,
248 ios_requires_network_connectivity: bool,
250 lifecycle_state: Arc<Mutex<ServiceLifecycle>>,
253 last_error: Arc<Mutex<Option<String>>>,
256 desired_state: Option<Arc<dyn DesiredStateBackend>>,
259 lifecycle_mode: LifecycleMode,
261}
262
263#[doc(hidden)]
270#[allow(clippy::too_many_arguments)]
271pub async fn manager_loop<R: Runtime>(
272 mut rx: mpsc::Receiver<ManagerCommand<R>>,
273 factory: ServiceFactory<R>,
274 ios_safety_timeout_secs: f64,
278 ios_processing_safety_timeout_secs: f64,
281 ios_earliest_refresh_begin_minutes: f64,
283 ios_earliest_processing_begin_minutes: f64,
285 ios_requires_external_power: bool,
287 ios_requires_network_connectivity: bool,
289 desired_state_backend: Option<Arc<dyn DesiredStateBackend>>,
291) {
292 let lifecycle_mode = {
293 #[cfg(target_os = "android")]
294 {
295 LifecycleMode::AndroidForegroundService
296 }
297 #[cfg(target_os = "ios")]
298 {
299 LifecycleMode::IosBgTaskScheduler
300 }
301 #[cfg(not(any(target_os = "android", target_os = "ios")))]
302 {
303 LifecycleMode::DesktopInProcess
304 }
305 };
306
307 let mut state = ServiceState {
308 is_running: Arc::new(AtomicBool::new(false)),
309 token: Arc::new(Mutex::new(None)),
310 generation: Arc::new(AtomicU64::new(0)),
311 on_complete: None,
312 factory,
313 mobile: None,
314 ios_safety_timeout_secs,
315 ios_processing_safety_timeout_secs,
316 ios_earliest_refresh_begin_minutes,
317 ios_earliest_processing_begin_minutes,
318 ios_requires_external_power,
319 ios_requires_network_connectivity,
320 lifecycle_state: Arc::new(Mutex::new(ServiceLifecycle::Idle)),
321 last_error: Arc::new(Mutex::new(None)),
322 desired_state: desired_state_backend,
323 lifecycle_mode,
324 };
325
326 while let Some(cmd) = rx.recv().await {
327 match cmd {
328 ManagerCommand::Start { config, reply, app } => {
329 let _ = reply.send(handle_start(&mut state, app, config));
330 }
331 ManagerCommand::Stop { reply } => {
332 let _ = reply.send(handle_stop(&mut state));
333 }
334 ManagerCommand::IsRunning { reply } => {
335 let _ = reply.send(state.is_running.load(Ordering::SeqCst));
336 }
337 ManagerCommand::SetOnComplete { callback } => {
338 state.on_complete = Some(callback);
339 }
340 ManagerCommand::SetMobile { mobile } => {
341 state.mobile = Some(mobile);
342 }
343 ManagerCommand::GetState { reply } => {
344 let mut status = ServiceStatus {
345 state: *state.lifecycle_state.lock().unwrap(),
346 last_error: state.last_error.lock().unwrap().clone(),
347 platform_mode: Some(state.lifecycle_mode),
348 ..Default::default()
349 };
350
351 if let Some(ref backend) = state.desired_state {
352 if let Ok(ds) = backend.load() {
353 status.desired_running = Some(ds.desired_running);
354 status.native_state = ds
355 .last_native_state
356 .as_deref()
357 .and_then(|s| serde_json::from_str(&format!("\"{s}\"")).ok());
358 status.last_start_config = ds
359 .last_start_config
360 .and_then(|v| serde_json::from_value(v).ok());
361 status.last_heartbeat_at = ds.last_heartbeat_epoch_ms;
362 status.restart_attempt = if ds.restart_attempt > 0 {
363 Some(ds.restart_attempt)
364 } else {
365 None
366 };
367 status.recovery_reason = ds.recovery_reason;
368 status.platform_error = ds.last_platform_error;
369 }
370 }
371
372 let _ = reply.send(status);
373 }
374 ManagerCommand::SetDesiredRunning {
375 desired,
376 config,
377 reply,
378 } => {
379 let _ = reply.send(handle_set_desired_running(&mut state, desired, config));
380 }
381 ManagerCommand::EnableAutoRestart { config, reply } => {
382 let _ = reply.send(handle_enable_auto_restart(&mut state, config));
383 }
384 ManagerCommand::DisableAutoRestart { reply } => {
385 let _ = reply.send(handle_disable_auto_restart(&mut state));
386 }
387 ManagerCommand::GetDesiredState { reply } => {
388 let _ = reply.send(handle_get_desired_state(&state));
389 }
390 }
391 }
392}
393
394fn handle_start<R: Runtime>(
405 state: &mut ServiceState<R>,
406 app: AppHandle<R>,
407 config: StartConfig,
408) -> Result<(), ServiceError> {
409 let mut guard = state.token.lock().unwrap();
410
411 if guard.is_some() {
412 return Err(ServiceError::AlreadyRunning);
413 }
414
415 if cfg!(mobile) {
419 validate_foreground_service_type(&config.foreground_service_type)?;
420 }
421
422 let token = CancellationToken::new();
423 let shutdown = token.clone();
424 *guard = Some(token);
425 let my_gen = state.generation.fetch_add(1, Ordering::Release) + 1;
426 state.is_running.store(true, Ordering::SeqCst);
427 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Initializing;
428 *state.last_error.lock().unwrap() = None;
429
430 drop(guard);
431
432 let captured_callback = state.on_complete.take();
435
436 if let Some(ref mobile) = state.mobile {
439 let processing_timeout = if state.ios_processing_safety_timeout_secs > 0.0 {
440 Some(state.ios_processing_safety_timeout_secs)
441 } else {
442 None
443 };
444 if let Err(e) = mobile.start_keepalive(
445 &config.service_label,
446 &config.foreground_service_type,
447 Some(state.ios_safety_timeout_secs),
448 processing_timeout,
449 Some(state.ios_earliest_refresh_begin_minutes),
450 Some(state.ios_earliest_processing_begin_minutes),
451 Some(state.ios_requires_external_power),
452 Some(state.ios_requires_network_connectivity),
453 ) {
454 state.token.lock().unwrap().take();
456 state.is_running.store(false, Ordering::SeqCst);
457 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Idle;
458 state.on_complete = captured_callback;
460 return Err(e);
461 }
462 }
463
464 let token_ref = state.token.clone();
466 let gen_ref = state.generation.clone();
467 let is_running_ref = state.is_running.clone();
468 let lifecycle_ref = state.lifecycle_state.clone();
469 let last_error_ref = state.last_error.clone();
470
471 let mut service = (state.factory)();
472
473 let ctx = ServiceContext {
474 notifier: Notifier { app: app.clone() },
475 app: app.clone(),
476 shutdown,
477 #[cfg(mobile)]
478 service_label: config.service_label,
479 #[cfg(mobile)]
480 foreground_service_type: config.foreground_service_type,
481 };
482
483 tauri::async_runtime::spawn(async move {
487 if let Err(e) = service.init(&ctx).await {
489 let _ = app.emit(
490 "background-service://event",
491 PluginEvent::Error {
492 message: e.to_string(),
493 },
494 );
495 if gen_ref.load(Ordering::Acquire) == my_gen {
497 token_ref.lock().unwrap().take();
498 is_running_ref.store(false, Ordering::SeqCst);
499 {
501 let mut lc = lifecycle_ref.lock().unwrap();
502 if *lc == ServiceLifecycle::Initializing {
503 *lc = ServiceLifecycle::Stopped;
504 }
505 }
506 *last_error_ref.lock().unwrap() = Some(e.to_string());
507 }
508 if let Some(cb) = captured_callback {
510 cb(false);
511 }
512 return;
513 }
514
515 if gen_ref.load(Ordering::Acquire) == my_gen {
517 let mut lc = lifecycle_ref.lock().unwrap();
518 if *lc == ServiceLifecycle::Initializing {
519 *lc = ServiceLifecycle::Running;
520 }
521 }
522
523 let _ = app.emit("background-service://event", PluginEvent::Started);
525
526 let result = service.run(&ctx).await;
528
529 match result {
531 Ok(()) => {
532 let _ = app.emit(
533 "background-service://event",
534 PluginEvent::Stopped {
535 reason: "completed".into(),
536 },
537 );
538 }
539 Err(ref e) => {
540 let _ = app.emit(
541 "background-service://event",
542 PluginEvent::Error {
543 message: e.to_string(),
544 },
545 );
546 }
547 }
548
549 if let Some(cb) = captured_callback {
553 cb(result.is_ok());
554 }
555
556 if gen_ref.load(Ordering::Acquire) == my_gen {
558 token_ref.lock().unwrap().take();
559 is_running_ref.store(false, Ordering::SeqCst);
560 {
562 let mut lc = lifecycle_ref.lock().unwrap();
563 if matches!(
564 *lc,
565 ServiceLifecycle::Initializing | ServiceLifecycle::Running
566 ) {
567 *lc = ServiceLifecycle::Stopped;
568 }
569 }
570 if let Err(ref e) = result {
571 *last_error_ref.lock().unwrap() = Some(e.to_string());
572 }
573 }
574 });
575
576 save_desired_running(state, true, Some(&config));
578
579 Ok(())
580}
581
582fn handle_stop<R: Runtime>(state: &mut ServiceState<R>) -> Result<(), ServiceError> {
587 let mut guard = state.token.lock().unwrap();
588 match guard.take() {
589 Some(token) => {
590 token.cancel();
591 state.is_running.store(false, Ordering::SeqCst);
592 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Stopped;
593 *state.last_error.lock().unwrap() = None;
594 drop(guard);
595 if let Some(ref mobile) = state.mobile {
597 if let Err(e) = mobile.stop_keepalive() {
598 log::warn!("stop_keepalive failed (service already cancelled): {e}");
599 }
600 }
601 save_desired_running(state, false, None);
603 Ok(())
604 }
605 None => Err(ServiceError::NotRunning),
606 }
607}
608
609fn save_desired_running<R: Runtime>(
616 state: &ServiceState<R>,
617 desired: bool,
618 config: Option<&StartConfig>,
619) {
620 let Some(ref backend) = state.desired_state else {
621 return;
622 };
623
624 let mut ds = backend.load().unwrap_or_default();
625 ds.desired_running = desired;
626 if desired {
627 ds.last_start_config = config.map(|c| serde_json::to_value(c).unwrap_or_default());
628 ds.last_start_epoch_ms = Some(
629 std::time::SystemTime::now()
630 .duration_since(std::time::UNIX_EPOCH)
631 .unwrap_or_default()
632 .as_millis() as u64,
633 );
634 } else {
635 ds.last_start_config = None;
636 ds.last_start_epoch_ms = None;
637 ds.recovery_pending = false;
638 ds.recovery_reason = None;
639 ds.restart_attempt = 0;
640 }
641 if let Err(e) = backend.save(&ds) {
642 log::warn!("failed to save desired state: {e}");
643 }
644}
645
646fn handle_set_desired_running<R: Runtime>(
652 state: &mut ServiceState<R>,
653 desired: bool,
654 config: Option<StartConfig>,
655) -> Result<(), ServiceError> {
656 save_desired_running(state, desired, config.as_ref());
657 Ok(())
658}
659
660fn handle_enable_auto_restart<R: Runtime>(
665 state: &mut ServiceState<R>,
666 config: Option<StartConfig>,
667) -> Result<(), ServiceError> {
668 save_desired_running(state, true, config.as_ref());
669 Ok(())
670}
671
672fn handle_disable_auto_restart<R: Runtime>(
677 state: &mut ServiceState<R>,
678) -> Result<(), ServiceError> {
679 save_desired_running(state, false, None);
680 Ok(())
681}
682
683fn handle_get_desired_state<R: Runtime>(
687 state: &ServiceState<R>,
688) -> Option<crate::desired_state::DesiredState> {
689 state
690 .desired_state
691 .as_ref()
692 .and_then(|backend| backend.load().ok())
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use crate::desired_state::DesiredState;
699 use crate::models::NativeState;
700 use async_trait::async_trait;
701 use std::sync::atomic::{AtomicI8, AtomicU8, AtomicUsize};
702
703 struct MockMobile {
707 start_called: AtomicUsize,
708 stop_called: AtomicUsize,
709 start_fail: bool,
710 last_label: std::sync::Mutex<Option<String>>,
711 last_fst: std::sync::Mutex<Option<String>>,
712 last_timeout_secs: std::sync::Mutex<Option<f64>>,
713 last_processing_timeout_secs: std::sync::Mutex<Option<f64>>,
714 last_earliest_refresh_begin_minutes: std::sync::Mutex<Option<f64>>,
715 last_earliest_processing_begin_minutes: std::sync::Mutex<Option<f64>>,
716 last_requires_external_power: std::sync::Mutex<Option<bool>>,
717 last_requires_network_connectivity: std::sync::Mutex<Option<bool>>,
718 }
719
720 impl MockMobile {
721 fn new() -> Arc<Self> {
722 Arc::new(Self {
723 start_called: AtomicUsize::new(0),
724 stop_called: AtomicUsize::new(0),
725 start_fail: false,
726 last_label: std::sync::Mutex::new(None),
727 last_fst: std::sync::Mutex::new(None),
728 last_timeout_secs: std::sync::Mutex::new(None),
729 last_processing_timeout_secs: std::sync::Mutex::new(None),
730 last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
731 last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
732 last_requires_external_power: std::sync::Mutex::new(None),
733 last_requires_network_connectivity: std::sync::Mutex::new(None),
734 })
735 }
736
737 fn new_failing() -> Arc<Self> {
738 Arc::new(Self {
739 start_called: AtomicUsize::new(0),
740 stop_called: AtomicUsize::new(0),
741 start_fail: true,
742 last_label: std::sync::Mutex::new(None),
743 last_fst: std::sync::Mutex::new(None),
744 last_timeout_secs: std::sync::Mutex::new(None),
745 last_processing_timeout_secs: std::sync::Mutex::new(None),
746 last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
747 last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
748 last_requires_external_power: std::sync::Mutex::new(None),
749 last_requires_network_connectivity: std::sync::Mutex::new(None),
750 })
751 }
752 }
753
754 #[allow(clippy::too_many_arguments)]
755 fn mock_start_keepalive(
756 mock: &MockMobile,
757 label: &str,
758 foreground_service_type: &str,
759 ios_safety_timeout_secs: Option<f64>,
760 ios_processing_safety_timeout_secs: Option<f64>,
761 ios_earliest_refresh_begin_minutes: Option<f64>,
762 ios_earliest_processing_begin_minutes: Option<f64>,
763 ios_requires_external_power: Option<bool>,
764 ios_requires_network_connectivity: Option<bool>,
765 ) -> Result<(), ServiceError> {
766 mock.start_called.fetch_add(1, Ordering::Release);
767 *mock.last_label.lock().unwrap() = Some(label.to_string());
768 *mock.last_fst.lock().unwrap() = Some(foreground_service_type.to_string());
769 *mock.last_timeout_secs.lock().unwrap() = ios_safety_timeout_secs;
770 *mock.last_processing_timeout_secs.lock().unwrap() = ios_processing_safety_timeout_secs;
771 *mock.last_earliest_refresh_begin_minutes.lock().unwrap() =
772 ios_earliest_refresh_begin_minutes;
773 *mock.last_earliest_processing_begin_minutes.lock().unwrap() =
774 ios_earliest_processing_begin_minutes;
775 *mock.last_requires_external_power.lock().unwrap() = ios_requires_external_power;
776 *mock.last_requires_network_connectivity.lock().unwrap() =
777 ios_requires_network_connectivity;
778 if mock.start_fail {
779 return Err(ServiceError::Platform("mock keepalive failure".into()));
780 }
781 Ok(())
782 }
783
784 impl MobileKeepalive for MockMobile {
785 #[allow(clippy::too_many_arguments)]
786 fn start_keepalive(
787 &self,
788 label: &str,
789 foreground_service_type: &str,
790 ios_safety_timeout_secs: Option<f64>,
791 ios_processing_safety_timeout_secs: Option<f64>,
792 ios_earliest_refresh_begin_minutes: Option<f64>,
793 ios_earliest_processing_begin_minutes: Option<f64>,
794 ios_requires_external_power: Option<bool>,
795 ios_requires_network_connectivity: Option<bool>,
796 ) -> Result<(), ServiceError> {
797 mock_start_keepalive(
798 self,
799 label,
800 foreground_service_type,
801 ios_safety_timeout_secs,
802 ios_processing_safety_timeout_secs,
803 ios_earliest_refresh_begin_minutes,
804 ios_earliest_processing_begin_minutes,
805 ios_requires_external_power,
806 ios_requires_network_connectivity,
807 )
808 }
809
810 fn stop_keepalive(&self) -> Result<(), ServiceError> {
811 self.stop_called.fetch_add(1, Ordering::Release);
812 Ok(())
813 }
814 }
815
816 struct BlockingService;
819
820 #[async_trait]
821 impl BackgroundService<tauri::test::MockRuntime> for BlockingService {
822 async fn init(
823 &mut self,
824 _ctx: &ServiceContext<tauri::test::MockRuntime>,
825 ) -> Result<(), ServiceError> {
826 Ok(())
827 }
828
829 async fn run(
830 &mut self,
831 ctx: &ServiceContext<tauri::test::MockRuntime>,
832 ) -> Result<(), ServiceError> {
833 ctx.shutdown.cancelled().await;
834 Ok(())
835 }
836 }
837
838 fn setup_manager() -> ServiceManagerHandle<tauri::test::MockRuntime> {
840 setup_manager_with_backend(None)
841 }
842
843 fn setup_manager_with_backend(
845 backend: Option<Arc<dyn DesiredStateBackend>>,
846 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
847 let (cmd_tx, cmd_rx) = mpsc::channel(16);
848 let handle = ServiceManagerHandle::new(cmd_tx);
849 let factory: ServiceFactory<tauri::test::MockRuntime> =
850 Box::new(|| Box::new(BlockingService));
851 tokio::spawn(manager_loop(
852 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
853 ));
854 handle
855 }
856
857 async fn send_start(
858 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
859 app: AppHandle<tauri::test::MockRuntime>,
860 ) -> Result<(), ServiceError> {
861 send_start_with_config(handle, StartConfig::default(), app).await
862 }
863
864 async fn send_start_with_config(
865 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
866 config: StartConfig,
867 app: AppHandle<tauri::test::MockRuntime>,
868 ) -> Result<(), ServiceError> {
869 let (tx, rx) = oneshot::channel();
870 handle
871 .cmd_tx
872 .send(ManagerCommand::Start {
873 config,
874 reply: tx,
875 app,
876 })
877 .await
878 .unwrap();
879 rx.await.unwrap()
880 }
881
882 async fn send_stop(
883 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
884 ) -> Result<(), ServiceError> {
885 let (tx, rx) = oneshot::channel();
886 handle
887 .cmd_tx
888 .send(ManagerCommand::Stop { reply: tx })
889 .await
890 .unwrap();
891 rx.await.unwrap()
892 }
893
894 async fn send_is_running(handle: &ServiceManagerHandle<tauri::test::MockRuntime>) -> bool {
895 let (tx, rx) = oneshot::channel();
896 handle
897 .cmd_tx
898 .send(ManagerCommand::IsRunning { reply: tx })
899 .await
900 .unwrap();
901 rx.await.unwrap()
902 }
903
904 #[tokio::test]
907 async fn start_from_idle() {
908 let handle = setup_manager();
909 let app = tauri::test::mock_app();
910
911 let result = send_start(&handle, app.handle().clone()).await;
912 assert!(result.is_ok(), "start should succeed from idle");
913 assert!(
914 send_is_running(&handle).await,
915 "should be running after start"
916 );
917 }
918
919 #[tokio::test]
922 async fn stop_from_running() {
923 let handle = setup_manager();
924 let app = tauri::test::mock_app();
925
926 send_start(&handle, app.handle().clone()).await.unwrap();
927
928 let result = send_stop(&handle).await;
929 assert!(result.is_ok(), "stop should succeed from running");
930 assert!(
931 !send_is_running(&handle).await,
932 "should not be running after stop"
933 );
934 }
935
936 #[tokio::test]
939 async fn double_start_returns_already_running() {
940 let handle = setup_manager();
941 let app = tauri::test::mock_app();
942
943 send_start(&handle, app.handle().clone()).await.unwrap();
944
945 let result = send_start(&handle, app.handle().clone()).await;
946 assert!(
947 matches!(result, Err(ServiceError::AlreadyRunning)),
948 "second start should return AlreadyRunning"
949 );
950 }
951
952 #[tokio::test]
955 async fn stop_when_not_running_returns_not_running() {
956 let handle = setup_manager();
957
958 let result = send_stop(&handle).await;
959 assert!(
960 matches!(result, Err(ServiceError::NotRunning)),
961 "stop should return NotRunning when idle"
962 );
963 }
964
965 #[tokio::test]
968 async fn start_stop_restart_cycle() {
969 let handle = setup_manager();
970 let app = tauri::test::mock_app();
971
972 send_start(&handle, app.handle().clone()).await.unwrap();
974 assert!(send_is_running(&handle).await);
975
976 send_stop(&handle).await.unwrap();
978 assert!(!send_is_running(&handle).await);
979
980 let result = send_start(&handle, app.handle().clone()).await;
982 assert!(result.is_ok(), "restart should succeed after stop");
983 assert!(
984 send_is_running(&handle).await,
985 "should be running after restart"
986 );
987 }
988
989 struct ImmediateSuccessService;
993
994 #[async_trait]
995 impl BackgroundService<tauri::test::MockRuntime> for ImmediateSuccessService {
996 async fn init(
997 &mut self,
998 _ctx: &ServiceContext<tauri::test::MockRuntime>,
999 ) -> Result<(), ServiceError> {
1000 Ok(())
1001 }
1002
1003 async fn run(
1004 &mut self,
1005 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1006 ) -> Result<(), ServiceError> {
1007 Ok(())
1008 }
1009 }
1010
1011 struct ImmediateErrorService;
1013
1014 #[async_trait]
1015 impl BackgroundService<tauri::test::MockRuntime> for ImmediateErrorService {
1016 async fn init(
1017 &mut self,
1018 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1019 ) -> Result<(), ServiceError> {
1020 Ok(())
1021 }
1022
1023 async fn run(
1024 &mut self,
1025 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1026 ) -> Result<(), ServiceError> {
1027 Err(ServiceError::Runtime("run error".into()))
1028 }
1029 }
1030
1031 struct FailingInitService;
1033
1034 #[async_trait]
1035 impl BackgroundService<tauri::test::MockRuntime> for FailingInitService {
1036 async fn init(
1037 &mut self,
1038 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1039 ) -> Result<(), ServiceError> {
1040 Err(ServiceError::Init("init error".into()))
1041 }
1042
1043 async fn run(
1044 &mut self,
1045 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1046 ) -> Result<(), ServiceError> {
1047 Ok(())
1048 }
1049 }
1050
1051 fn setup_manager_with_factory(
1053 factory: ServiceFactory<tauri::test::MockRuntime>,
1054 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
1055 setup_manager_with_factory_and_backend(factory, None)
1056 }
1057
1058 fn setup_manager_with_factory_and_backend(
1060 factory: ServiceFactory<tauri::test::MockRuntime>,
1061 backend: Option<Arc<dyn DesiredStateBackend>>,
1062 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
1063 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1064 let handle = ServiceManagerHandle::new(cmd_tx);
1065 tokio::spawn(manager_loop(
1066 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
1067 ));
1068 handle
1069 }
1070
1071 async fn send_set_on_complete(
1072 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1073 callback: OnCompleteCallback,
1074 ) {
1075 handle
1076 .cmd_tx
1077 .send(ManagerCommand::SetOnComplete { callback })
1078 .await
1079 .unwrap();
1080 }
1081
1082 async fn wait_until_stopped(
1085 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1086 timeout_ms: u64,
1087 ) {
1088 let start = std::time::Instant::now();
1089 while start.elapsed().as_millis() < timeout_ms as u128 {
1090 if !send_is_running(handle).await {
1091 return;
1092 }
1093 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1094 }
1095 panic!("Service did not stop within {timeout_ms}ms");
1096 }
1097
1098 #[tokio::test]
1101 async fn callback_fires_on_success() {
1102 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1103 let app = tauri::test::mock_app();
1104
1105 let called = Arc::new(AtomicI8::new(-1));
1106 let called_clone = called.clone();
1107 send_set_on_complete(
1108 &handle,
1109 Box::new(move |success| {
1110 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1111 }),
1112 )
1113 .await;
1114
1115 send_start(&handle, app.handle().clone()).await.unwrap();
1116 wait_until_stopped(&handle, 1000).await;
1117
1118 assert_eq!(
1119 called.load(Ordering::Acquire),
1120 1,
1121 "callback should be called with true"
1122 );
1123 }
1124
1125 #[tokio::test]
1128 async fn callback_fires_on_error() {
1129 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateErrorService)));
1130 let app = tauri::test::mock_app();
1131
1132 let called = Arc::new(AtomicI8::new(-1));
1133 let called_clone = called.clone();
1134 send_set_on_complete(
1135 &handle,
1136 Box::new(move |success| {
1137 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1138 }),
1139 )
1140 .await;
1141
1142 send_start(&handle, app.handle().clone()).await.unwrap();
1143 wait_until_stopped(&handle, 1000).await;
1144
1145 assert_eq!(
1146 called.load(Ordering::Acquire),
1147 0,
1148 "callback should be called with false on error"
1149 );
1150 }
1151
1152 #[tokio::test]
1155 async fn callback_fires_on_init_failure() {
1156 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1157 let app = tauri::test::mock_app();
1158
1159 let called = Arc::new(AtomicI8::new(-1));
1160 let called_clone = called.clone();
1161 send_set_on_complete(
1162 &handle,
1163 Box::new(move |success| {
1164 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1165 }),
1166 )
1167 .await;
1168
1169 send_start(&handle, app.handle().clone()).await.unwrap();
1170
1171 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1174
1175 assert_eq!(
1176 called.load(Ordering::Acquire),
1177 0,
1178 "callback should be called with false on init failure"
1179 );
1180 assert!(
1181 !send_is_running(&handle).await,
1182 "should not be running after init failure"
1183 );
1184 }
1185
1186 #[tokio::test]
1189 async fn no_callback_no_panic() {
1190 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1191 let app = tauri::test::mock_app();
1192
1193 let result = send_start(&handle, app.handle().clone()).await;
1195 assert!(result.is_ok(), "start without callback should succeed");
1196
1197 wait_until_stopped(&handle, 1000).await;
1198 }
1200
1201 #[tokio::test]
1204 async fn is_running_false_after_natural_completion() {
1205 struct YieldingService;
1208
1209 #[async_trait]
1210 impl BackgroundService<tauri::test::MockRuntime> for YieldingService {
1211 async fn init(
1212 &mut self,
1213 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1214 ) -> Result<(), ServiceError> {
1215 Ok(())
1216 }
1217
1218 async fn run(
1219 &mut self,
1220 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1221 ) -> Result<(), ServiceError> {
1222 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1225 Ok(())
1226 }
1227 }
1228
1229 let handle = setup_manager_with_factory(Box::new(|| Box::new(YieldingService)));
1230 let app = tauri::test::mock_app();
1231
1232 send_start(&handle, app.handle().clone()).await.unwrap();
1233 assert!(
1234 send_is_running(&handle).await,
1235 "should be running immediately after start"
1236 );
1237
1238 wait_until_stopped(&handle, 2000).await;
1240
1241 assert!(
1242 !send_is_running(&handle).await,
1243 "is_running should be false after natural completion"
1244 );
1245 }
1246
1247 #[tokio::test]
1250 async fn generation_guard_prevents_stale_cleanup() {
1251 let call_count = Arc::new(AtomicU8::new(0));
1255 let call_count_clone = call_count.clone();
1256
1257 let handle = setup_manager_with_factory(Box::new(move || {
1258 let cc = call_count_clone.clone();
1259 if cc.fetch_add(1, Ordering::AcqRel) == 0 {
1262 Box::new(FailingInitService) as Box<dyn BackgroundService<tauri::test::MockRuntime>>
1263 } else {
1264 Box::new(ImmediateSuccessService)
1265 }
1266 }));
1267 let app = tauri::test::mock_app();
1268
1269 send_start(&handle, app.handle().clone()).await.unwrap();
1271 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1272
1273 let result = send_start(&handle, app.handle().clone()).await;
1275 assert!(
1276 result.is_ok(),
1277 "second start should succeed after init failure: {result:?}"
1278 );
1279 assert!(
1280 send_is_running(&handle).await,
1281 "should be running after second start"
1282 );
1283 }
1284
1285 #[tokio::test]
1288 async fn callback_captured_at_spawn_time() {
1289 let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingService)));
1290 let app = tauri::test::mock_app();
1291
1292 let which = Arc::new(AtomicU8::new(0)); let which_clone_a = which.clone();
1296 let which_clone_b = which.clone();
1297
1298 send_set_on_complete(
1299 &handle,
1300 Box::new(move |_| {
1301 which_clone_a.store(1, Ordering::Release);
1302 }),
1303 )
1304 .await;
1305
1306 send_start(&handle, app.handle().clone()).await.unwrap();
1307
1308 send_set_on_complete(
1310 &handle,
1311 Box::new(move |_| {
1312 which_clone_b.store(2, Ordering::Release);
1313 }),
1314 )
1315 .await;
1316
1317 send_stop(&handle).await.unwrap();
1319 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1320
1321 assert_eq!(
1322 which.load(Ordering::Acquire),
1323 1,
1324 "callback A should fire, not B"
1325 );
1326 }
1327
1328 async fn send_set_mobile(
1331 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1332 mobile: Arc<dyn MobileKeepalive>,
1333 ) {
1334 handle
1335 .cmd_tx
1336 .send(ManagerCommand::SetMobile { mobile })
1337 .await
1338 .unwrap();
1339 }
1340
1341 #[tokio::test]
1344 async fn start_keepalive_called_on_start() {
1345 let mock = MockMobile::new();
1346 let handle = setup_manager();
1347 let app = tauri::test::mock_app();
1348
1349 send_set_mobile(&handle, mock.clone()).await;
1350 send_start(&handle, app.handle().clone()).await.unwrap();
1351
1352 assert_eq!(
1353 mock.start_called.load(Ordering::Acquire),
1354 1,
1355 "start_keepalive should be called once"
1356 );
1357 assert_eq!(
1358 mock.last_label.lock().unwrap().as_deref(),
1359 Some("Service running"),
1360 "label should be forwarded"
1361 );
1362 }
1363
1364 #[tokio::test]
1367 async fn start_keepalive_failure_rollback() {
1368 let mock = MockMobile::new_failing();
1369 let handle = setup_manager();
1370 let app = tauri::test::mock_app();
1371
1372 let callback_called = Arc::new(AtomicI8::new(-1));
1373 let cb_clone = callback_called.clone();
1374 send_set_on_complete(
1375 &handle,
1376 Box::new(move |success| {
1377 cb_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1378 }),
1379 )
1380 .await;
1381
1382 send_set_mobile(&handle, mock.clone()).await;
1383
1384 let result = send_start(&handle, app.handle().clone()).await;
1385 assert!(
1386 matches!(result, Err(ServiceError::Platform(_))),
1387 "start should return Platform error on keepalive failure: {result:?}"
1388 );
1389
1390 assert!(
1392 !send_is_running(&handle).await,
1393 "token should be rolled back after keepalive failure"
1394 );
1395
1396 let callback_called2 = Arc::new(AtomicI8::new(-1));
1398 let cb_clone2 = callback_called2.clone();
1399 send_set_on_complete(
1400 &handle,
1401 Box::new(move |success| {
1402 cb_clone2.store(if success { 1 } else { 0 }, Ordering::Release);
1403 }),
1404 )
1405 .await;
1406
1407 let handle2 = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1410 let callback_restored = Arc::new(AtomicI8::new(-1));
1411 let cb_r = callback_restored.clone();
1412 send_set_on_complete(
1413 &handle2,
1414 Box::new(move |success| {
1415 cb_r.store(if success { 1 } else { 0 }, Ordering::Release);
1416 }),
1417 )
1418 .await;
1419 send_start(&handle2, app.handle().clone()).await.unwrap();
1420 wait_until_stopped(&handle2, 1000).await;
1421 assert_eq!(
1422 callback_restored.load(Ordering::Acquire),
1423 1,
1424 "callback should fire after successful start (proves rollback restored it)"
1425 );
1426 }
1427
1428 #[tokio::test]
1431 async fn stop_keepalive_called_on_stop() {
1432 let mock = MockMobile::new();
1433 let handle = setup_manager();
1434 let app = tauri::test::mock_app();
1435
1436 send_set_mobile(&handle, mock.clone()).await;
1437 send_start(&handle, app.handle().clone()).await.unwrap();
1438
1439 assert_eq!(
1440 mock.stop_called.load(Ordering::Acquire),
1441 0,
1442 "stop_keepalive should not be called yet"
1443 );
1444
1445 send_stop(&handle).await.unwrap();
1446
1447 assert_eq!(
1448 mock.stop_called.load(Ordering::Acquire),
1449 1,
1450 "stop_keepalive should be called once after stop"
1451 );
1452 }
1453
1454 struct MockMobileFailingStop;
1458
1459 #[allow(clippy::too_many_arguments)]
1460 impl MobileKeepalive for MockMobileFailingStop {
1461 fn start_keepalive(
1462 &self,
1463 _label: &str,
1464 _foreground_service_type: &str,
1465 _ios_safety_timeout_secs: Option<f64>,
1466 _ios_processing_safety_timeout_secs: Option<f64>,
1467 _ios_earliest_refresh_begin_minutes: Option<f64>,
1468 _ios_earliest_processing_begin_minutes: Option<f64>,
1469 _ios_requires_external_power: Option<bool>,
1470 _ios_requires_network_connectivity: Option<bool>,
1471 ) -> Result<(), ServiceError> {
1472 Ok(())
1473 }
1474
1475 fn stop_keepalive(&self) -> Result<(), ServiceError> {
1476 Err(ServiceError::Platform("mock stop failure".into()))
1477 }
1478 }
1479
1480 #[tokio::test]
1481 async fn stop_keepalive_failure_does_not_propagate() {
1482 let handle = setup_manager();
1483 let app = tauri::test::mock_app();
1484
1485 send_set_mobile(&handle, Arc::new(MockMobileFailingStop)).await;
1486 send_start(&handle, app.handle().clone()).await.unwrap();
1487
1488 let result = send_stop(&handle).await;
1489 assert!(
1490 result.is_ok(),
1491 "stop should succeed even when stop_keepalive fails"
1492 );
1493
1494 assert!(
1495 !send_is_running(&handle).await,
1496 "service should not be running after stop"
1497 );
1498 }
1499
1500 #[tokio::test]
1503 async fn ios_safety_timeout_passed_to_mobile() {
1504 let mock = MockMobile::new();
1505 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1506 let handle = ServiceManagerHandle::new(cmd_tx);
1507 let factory: ServiceFactory<tauri::test::MockRuntime> =
1508 Box::new(|| Box::new(BlockingService));
1509 tokio::spawn(manager_loop(
1511 cmd_rx, factory, 15.0, 0.0, 15.0, 15.0, false, false, None,
1512 ));
1513
1514 let app = tauri::test::mock_app();
1515
1516 send_set_mobile(&handle, mock.clone()).await;
1517 send_start(&handle, app.handle().clone()).await.unwrap();
1518
1519 let timeout = *mock.last_timeout_secs.lock().unwrap();
1521 assert_eq!(
1522 timeout,
1523 Some(15.0),
1524 "ios_safety_timeout_secs should be passed to mobile"
1525 );
1526 }
1527
1528 #[tokio::test]
1531 async fn ios_processing_timeout_passed_to_mobile() {
1532 let mock = MockMobile::new();
1533 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1534 let handle = ServiceManagerHandle::new(cmd_tx);
1535 let factory: ServiceFactory<tauri::test::MockRuntime> =
1536 Box::new(|| Box::new(BlockingService));
1537 tokio::spawn(manager_loop(
1539 cmd_rx, factory, 28.0, 60.0, 15.0, 15.0, false, false, None,
1540 ));
1541
1542 let app = tauri::test::mock_app();
1543
1544 send_set_mobile(&handle, mock.clone()).await;
1545 send_start(&handle, app.handle().clone()).await.unwrap();
1546
1547 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1549 assert_eq!(
1550 timeout,
1551 Some(60.0),
1552 "ios_processing_safety_timeout_secs should be passed to mobile"
1553 );
1554 }
1555
1556 #[cfg(mobile)]
1562 struct ContextCapturingService {
1563 captured_label: Arc<std::sync::Mutex<Option<String>>>,
1564 captured_fst: Arc<std::sync::Mutex<Option<String>>>,
1565 }
1566
1567 #[cfg(mobile)]
1568 #[async_trait]
1569 impl BackgroundService<tauri::test::MockRuntime> for ContextCapturingService {
1570 async fn init(
1571 &mut self,
1572 ctx: &ServiceContext<tauri::test::MockRuntime>,
1573 ) -> Result<(), ServiceError> {
1574 *self.captured_label.lock().unwrap() = Some(ctx.service_label.clone());
1575 *self.captured_fst.lock().unwrap() = Some(ctx.foreground_service_type.clone());
1576 Ok(())
1577 }
1578
1579 async fn run(
1580 &mut self,
1581 ctx: &ServiceContext<tauri::test::MockRuntime>,
1582 ) -> Result<(), ServiceError> {
1583 ctx.shutdown.cancelled().await;
1584 Ok(())
1585 }
1586 }
1587
1588 #[cfg(mobile)]
1591 #[tokio::test]
1592 async fn service_context_fields_populated_on_mobile() {
1593 let captured_label: Arc<std::sync::Mutex<Option<String>>> =
1594 Arc::new(std::sync::Mutex::new(None));
1595 let captured_fst: Arc<std::sync::Mutex<Option<String>>> =
1596 Arc::new(std::sync::Mutex::new(None));
1597 let cl = captured_label.clone();
1598 let cf = captured_fst.clone();
1599
1600 let handle = setup_manager_with_factory(Box::new(move || {
1601 let cl = cl.clone();
1602 let cf = cf.clone();
1603 Box::new(ContextCapturingService {
1604 captured_label: cl,
1605 captured_fst: cf,
1606 })
1607 }));
1608 let app = tauri::test::mock_app();
1609
1610 let config = StartConfig {
1611 service_label: "Syncing".into(),
1612 foreground_service_type: "dataSync".into(),
1613 };
1614
1615 send_start_with_config(&handle, config, app.handle().clone())
1616 .await
1617 .unwrap();
1618
1619 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1621
1622 assert_eq!(
1624 captured_label.lock().unwrap().as_deref(),
1625 Some("Syncing"),
1626 "service_label should be 'Syncing' on mobile"
1627 );
1628 assert_eq!(
1629 captured_fst.lock().unwrap().as_deref(),
1630 Some("dataSync"),
1631 "foreground_service_type should be 'dataSync' on mobile"
1632 );
1633
1634 send_stop(&handle).await.unwrap();
1635 }
1636
1637 #[tokio::test]
1640 async fn handle_start_accepts_invalid_foreground_service_type_on_desktop() {
1641 let handle = setup_manager();
1644 let app = tauri::test::mock_app();
1645
1646 let config = StartConfig {
1647 service_label: "test".into(),
1648 foreground_service_type: "bogusType".into(),
1649 };
1650
1651 let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1652 assert!(
1653 result.is_ok(),
1654 "start with invalid fg type should succeed on desktop: {result:?}"
1655 );
1656 assert!(
1657 send_is_running(&handle).await,
1658 "service should be running after start with invalid type on desktop"
1659 );
1660
1661 send_stop(&handle).await.unwrap();
1662 }
1663
1664 #[tokio::test]
1667 async fn handle_start_accepts_all_valid_foreground_service_types() {
1668 for &valid_type in crate::models::VALID_FOREGROUND_SERVICE_TYPES {
1669 let handle = setup_manager();
1670 let app = tauri::test::mock_app();
1671
1672 let config = StartConfig {
1673 service_label: "test".into(),
1674 foreground_service_type: valid_type.into(),
1675 };
1676
1677 let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1678 assert!(
1679 result.is_ok(),
1680 "start with valid type '{valid_type}' should succeed: {result:?}"
1681 );
1682 assert!(send_is_running(&handle).await);
1683 send_stop(&handle).await.unwrap();
1685 }
1686 }
1687
1688 async fn send_get_state(
1691 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1692 ) -> ServiceStatus {
1693 let (tx, rx) = oneshot::channel();
1694 handle
1695 .cmd_tx
1696 .send(ManagerCommand::GetState { reply: tx })
1697 .await
1698 .unwrap();
1699 rx.await.unwrap()
1700 }
1701
1702 #[tokio::test]
1705 async fn get_state_returns_idle_initially() {
1706 let handle = setup_manager();
1707 let status = send_get_state(&handle).await;
1708 assert_eq!(status.state, ServiceLifecycle::Idle);
1709 assert_eq!(status.last_error, None);
1710 }
1711
1712 #[tokio::test]
1715 async fn lifecycle_idle_to_running_to_stopped() {
1716 let handle = setup_manager();
1718 let app = tauri::test::mock_app();
1719
1720 let status = send_get_state(&handle).await;
1722 assert_eq!(status.state, ServiceLifecycle::Idle);
1723
1724 send_start(&handle, app.handle().clone()).await.unwrap();
1726
1727 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1729 let status = send_get_state(&handle).await;
1730 assert_eq!(status.state, ServiceLifecycle::Running);
1731
1732 send_stop(&handle).await.unwrap();
1734 let status = send_get_state(&handle).await;
1735 assert_eq!(status.state, ServiceLifecycle::Stopped);
1736 assert_eq!(status.last_error, None);
1737 }
1738
1739 #[tokio::test]
1742 async fn lifecycle_init_failure_sets_stopped_with_error() {
1743 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1744 let app = tauri::test::mock_app();
1745
1746 send_start(&handle, app.handle().clone()).await.unwrap();
1747
1748 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1750
1751 let status = send_get_state(&handle).await;
1752 assert_eq!(status.state, ServiceLifecycle::Stopped);
1753 assert!(
1754 status.last_error.is_some(),
1755 "last_error should be set on init failure"
1756 );
1757 assert!(
1758 status.last_error.unwrap().contains("init error"),
1759 "error should mention init error"
1760 );
1761 }
1762
1763 #[tokio::test]
1766 async fn lifecycle_explicit_stop_sets_stopped_clears_error() {
1767 let handle = setup_manager();
1768 let app = tauri::test::mock_app();
1769
1770 send_start(&handle, app.handle().clone()).await.unwrap();
1771 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1772
1773 let status = send_get_state(&handle).await;
1774 assert_eq!(status.state, ServiceLifecycle::Running);
1775
1776 send_stop(&handle).await.unwrap();
1777
1778 let status = send_get_state(&handle).await;
1779 assert_eq!(status.state, ServiceLifecycle::Stopped);
1780 assert_eq!(
1781 status.last_error, None,
1782 "explicit stop should clear last_error"
1783 );
1784 }
1785
1786 #[tokio::test]
1789 async fn restart_clears_stale_last_error() {
1790 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1792 let app = tauri::test::mock_app();
1793
1794 send_start(&handle, app.handle().clone()).await.unwrap();
1795 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1796
1797 let status = send_get_state(&handle).await;
1798 assert_eq!(status.state, ServiceLifecycle::Stopped);
1799 assert!(
1800 status.last_error.is_some(),
1801 "should have error after init failure"
1802 );
1803
1804 let call_count = Arc::new(AtomicUsize::new(0));
1810 let count_clone = call_count.clone();
1811 let handle2 = setup_manager_with_factory(Box::new(move || {
1812 let n = count_clone.fetch_add(1, Ordering::SeqCst);
1813 if n == 0 {
1814 Box::new(FailingInitService)
1815 } else {
1816 Box::new(ImmediateSuccessService)
1817 }
1818 }));
1819 let app2 = tauri::test::mock_app();
1820
1821 send_start(&handle2, app2.handle().clone()).await.unwrap();
1823 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1824
1825 let status = send_get_state(&handle2).await;
1826 assert_eq!(status.state, ServiceLifecycle::Stopped);
1827 assert!(
1828 status.last_error.is_some(),
1829 "first run should set last_error"
1830 );
1831
1832 send_start(&handle2, app2.handle().clone()).await.unwrap();
1834 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1835
1836 let status = send_get_state(&handle2).await;
1837 assert_eq!(
1840 status.last_error, None,
1841 "last_error must be cleared on restart, not stale from previous failure"
1842 );
1843 }
1844
1845 #[tokio::test]
1848 async fn get_state_handle_method_returns_idle() {
1849 let handle = setup_manager();
1850 let status = handle.get_state().await;
1851 assert_eq!(status.state, ServiceLifecycle::Idle);
1852 assert_eq!(status.last_error, None);
1853 }
1854
1855 #[tokio::test]
1858 async fn stop_blocking_returns_success_from_running() {
1859 let handle = Arc::new(setup_manager());
1860 let app = tauri::test::mock_app();
1861
1862 send_start(&handle, app.handle().clone()).await.unwrap();
1863 assert!(send_is_running(&handle).await);
1864
1865 let h = handle.clone();
1867 let result = tokio::task::spawn_blocking(move || h.stop_blocking())
1868 .await
1869 .expect("spawn_blocking panicked");
1870 assert!(
1871 result.is_ok(),
1872 "stop_blocking should succeed from running: {result:?}"
1873 );
1874 assert!(
1875 !send_is_running(&handle).await,
1876 "should not be running after stop_blocking"
1877 );
1878 }
1879
1880 #[tokio::test]
1883 async fn stop_blocking_returns_not_running_when_idle() {
1884 let handle = Arc::new(setup_manager());
1885
1886 let h = handle.clone();
1887 let result = tokio::task::spawn_blocking(move || h.stop_blocking())
1888 .await
1889 .expect("spawn_blocking panicked");
1890 assert!(
1891 matches!(result, Err(ServiceError::NotRunning)),
1892 "stop_blocking should return NotRunning when idle: {result:?}"
1893 );
1894 }
1895
1896 #[tokio::test]
1897 async fn ios_processing_timeout_zero_passes_as_none() {
1898 let mock = MockMobile::new();
1899 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1900 let handle = ServiceManagerHandle::new(cmd_tx);
1901 let factory: ServiceFactory<tauri::test::MockRuntime> =
1902 Box::new(|| Box::new(BlockingService));
1903 tokio::spawn(manager_loop(
1905 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, None,
1906 ));
1907
1908 let app = tauri::test::mock_app();
1909
1910 send_set_mobile(&handle, mock.clone()).await;
1911 send_start(&handle, app.handle().clone()).await.unwrap();
1912
1913 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1915 assert_eq!(
1916 timeout, None,
1917 "ios_processing_safety_timeout_secs of 0.0 should pass None to mobile"
1918 );
1919 }
1920
1921 struct MockDesiredStateBackend {
1925 saves: std::sync::Mutex<Vec<DesiredState>>,
1926 }
1927
1928 impl MockDesiredStateBackend {
1929 fn new() -> Arc<Self> {
1930 Arc::new(Self {
1931 saves: std::sync::Mutex::new(Vec::new()),
1932 })
1933 }
1934
1935 fn last_save(&self) -> Option<DesiredState> {
1936 self.saves.lock().unwrap().last().cloned()
1937 }
1938
1939 #[allow(dead_code)]
1940 fn save_count(&self) -> usize {
1941 self.saves.lock().unwrap().len()
1942 }
1943
1944 #[allow(dead_code)]
1945 fn saves(&self) -> std::sync::MutexGuard<'_, Vec<DesiredState>> {
1946 self.saves.lock().unwrap()
1947 }
1948 }
1949
1950 impl DesiredStateBackend for MockDesiredStateBackend {
1951 fn load(&self) -> Result<DesiredState, String> {
1952 Ok(self
1953 .saves
1954 .lock()
1955 .unwrap()
1956 .last()
1957 .cloned()
1958 .unwrap_or_default())
1959 }
1960
1961 fn save(&self, state: &DesiredState) -> Result<(), String> {
1962 self.saves.lock().unwrap().push(state.clone());
1963 Ok(())
1964 }
1965
1966 fn clear(&self) -> Result<(), String> {
1967 self.saves.lock().unwrap().clear();
1968 Ok(())
1969 }
1970 }
1971
1972 async fn send_set_desired_running(
1975 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1976 desired: bool,
1977 config: Option<StartConfig>,
1978 ) -> Result<(), ServiceError> {
1979 let (tx, rx) = oneshot::channel();
1980 handle
1981 .cmd_tx
1982 .send(ManagerCommand::SetDesiredRunning {
1983 desired,
1984 config,
1985 reply: tx,
1986 })
1987 .await
1988 .unwrap();
1989 rx.await.unwrap()
1990 }
1991
1992 #[tokio::test]
1993 async fn start_saves_desired_running_true() {
1994 let backend = MockDesiredStateBackend::new();
1995 let handle = setup_manager_with_factory_and_backend(
1996 Box::new(|| Box::new(BlockingService)),
1997 Some(backend.clone()),
1998 );
1999 let app = tauri::test::mock_app();
2000
2001 let config = StartConfig {
2002 service_label: "Syncing".into(),
2003 ..Default::default()
2004 };
2005 send_start_with_config(&handle, config, app.handle().clone())
2006 .await
2007 .unwrap();
2008
2009 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2011
2012 let last = backend
2013 .last_save()
2014 .expect("should have saved desired state");
2015 assert!(
2016 last.desired_running,
2017 "desired_running should be true after start"
2018 );
2019 assert!(
2020 last.last_start_config.is_some(),
2021 "last_start_config should be set"
2022 );
2023 assert!(
2024 last.last_start_epoch_ms.is_some(),
2025 "last_start_epoch_ms should be set"
2026 );
2027 }
2028
2029 #[tokio::test]
2030 async fn stop_saves_desired_running_false_with_cleared_recovery() {
2031 let backend = MockDesiredStateBackend::new();
2032 let handle = setup_manager_with_factory_and_backend(
2033 Box::new(|| Box::new(BlockingService)),
2034 Some(backend.clone()),
2035 );
2036 let app = tauri::test::mock_app();
2037
2038 send_start(&handle, app.handle().clone()).await.unwrap();
2039
2040 {
2042 let mut saves = backend.saves.lock().unwrap();
2043 let last = saves.last_mut().unwrap();
2044 last.recovery_pending = true;
2045 last.recovery_reason = Some("boot".into());
2046 last.restart_attempt = 3;
2047 }
2048
2049 send_stop(&handle).await.unwrap();
2050
2051 let last = backend.last_save().expect("should have saved on stop");
2052 assert!(
2053 !last.desired_running,
2054 "desired_running should be false after stop"
2055 );
2056 assert!(
2057 last.last_start_config.is_none(),
2058 "last_start_config should be cleared"
2059 );
2060 assert!(
2061 last.last_start_epoch_ms.is_none(),
2062 "last_start_epoch_ms should be cleared"
2063 );
2064 assert!(!last.recovery_pending, "recovery_pending should be cleared");
2065 assert_eq!(
2066 last.recovery_reason, None,
2067 "recovery_reason should be cleared"
2068 );
2069 assert_eq!(last.restart_attempt, 0, "restart_attempt should be cleared");
2070 }
2071
2072 #[tokio::test]
2073 async fn set_desired_running_saves_without_affecting_is_running() {
2074 let backend = MockDesiredStateBackend::new();
2075 let handle = setup_manager_with_backend(Some(backend.clone()));
2076
2077 assert!(!send_is_running(&handle).await);
2079
2080 let config = StartConfig {
2082 service_label: "AutoRestart".into(),
2083 ..Default::default()
2084 };
2085 send_set_desired_running(&handle, true, Some(config.clone()))
2086 .await
2087 .unwrap();
2088
2089 assert!(
2091 !send_is_running(&handle).await,
2092 "SetDesiredRunning should not affect is_running"
2093 );
2094
2095 let last = backend.last_save().expect("should have saved");
2097 assert!(last.desired_running);
2098 assert!(last.last_start_config.is_some());
2099
2100 send_set_desired_running(&handle, false, None)
2102 .await
2103 .unwrap();
2104
2105 assert!(!send_is_running(&handle).await);
2106
2107 let last = backend.last_save().expect("should have saved");
2108 assert!(!last.desired_running);
2109 }
2110
2111 #[tokio::test]
2112 async fn no_backend_means_no_panic() {
2113 let handle = setup_manager();
2115 let app = tauri::test::mock_app();
2116
2117 send_start(&handle, app.handle().clone()).await.unwrap();
2118 send_stop(&handle).await.unwrap();
2119
2120 send_set_desired_running(&handle, true, None).await.unwrap();
2121 }
2123
2124 #[tokio::test]
2125 async fn start_config_serialized_in_desired_state() {
2126 let backend = MockDesiredStateBackend::new();
2127 let handle = setup_manager_with_factory_and_backend(
2128 Box::new(|| Box::new(BlockingService)),
2129 Some(backend.clone()),
2130 );
2131 let app = tauri::test::mock_app();
2132
2133 let config = StartConfig {
2134 service_label: "CustomLabel".into(),
2135 foreground_service_type: "specialUse".into(),
2136 };
2137 send_start_with_config(&handle, config, app.handle().clone())
2138 .await
2139 .unwrap();
2140
2141 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2142
2143 let last = backend.last_save().expect("should have saved");
2144 let saved_config = last.last_start_config.expect("config should be set");
2145 assert_eq!(saved_config["serviceLabel"], "CustomLabel");
2146 assert_eq!(saved_config["foregroundServiceType"], "specialUse");
2147 }
2148
2149 #[tokio::test]
2152 async fn get_state_returns_desired_running_true_after_start() {
2153 let backend = MockDesiredStateBackend::new();
2154 let handle = setup_manager_with_factory_and_backend(
2155 Box::new(|| Box::new(BlockingService)),
2156 Some(backend.clone()),
2157 );
2158 let app = tauri::test::mock_app();
2159
2160 send_start(&handle, app.handle().clone()).await.unwrap();
2161 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2162
2163 let status = send_get_state(&handle).await;
2164 assert_eq!(
2165 status.desired_running,
2166 Some(true),
2167 "desired_running should be Some(true) after start with backend"
2168 );
2169 }
2170
2171 #[tokio::test]
2172 async fn get_state_returns_desired_running_false_after_stop() {
2173 let backend = MockDesiredStateBackend::new();
2174 let handle = setup_manager_with_factory_and_backend(
2175 Box::new(|| Box::new(BlockingService)),
2176 Some(backend.clone()),
2177 );
2178 let app = tauri::test::mock_app();
2179
2180 send_start(&handle, app.handle().clone()).await.unwrap();
2181 send_stop(&handle).await.unwrap();
2182
2183 let status = send_get_state(&handle).await;
2184 assert_eq!(
2185 status.desired_running,
2186 Some(false),
2187 "desired_running should be Some(false) after stop with backend"
2188 );
2189 }
2190
2191 #[tokio::test]
2192 async fn get_state_returns_none_fields_when_no_backend() {
2193 let handle = setup_manager();
2194 let app = tauri::test::mock_app();
2195
2196 send_start(&handle, app.handle().clone()).await.unwrap();
2197 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2198
2199 let status = send_get_state(&handle).await;
2200 assert_eq!(status.desired_running, None);
2201 assert_eq!(status.native_state, None);
2202 assert_eq!(status.last_start_config, None);
2203 assert_eq!(status.last_heartbeat_at, None);
2204 assert_eq!(status.restart_attempt, None);
2205 assert_eq!(status.recovery_reason, None);
2206 assert_eq!(status.platform_error, None);
2207 }
2208
2209 #[tokio::test]
2210 async fn get_state_returns_last_start_config_from_backend() {
2211 let backend = MockDesiredStateBackend::new();
2212 let handle = setup_manager_with_factory_and_backend(
2213 Box::new(|| Box::new(BlockingService)),
2214 Some(backend.clone()),
2215 );
2216 let app = tauri::test::mock_app();
2217
2218 let config = StartConfig {
2219 service_label: "TestService".into(),
2220 foreground_service_type: "specialUse".into(),
2221 };
2222 send_start_with_config(&handle, config, app.handle().clone())
2223 .await
2224 .unwrap();
2225 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2226
2227 let status = send_get_state(&handle).await;
2228 let cfg = status
2229 .last_start_config
2230 .expect("last_start_config should be populated from backend");
2231 assert_eq!(cfg.service_label, "TestService");
2232 assert_eq!(cfg.foreground_service_type, "specialUse");
2233 }
2234
2235 #[tokio::test]
2236 async fn get_state_populates_all_desired_state_fields() {
2237 let backend = MockDesiredStateBackend::new();
2238 let handle = setup_manager_with_factory_and_backend(
2239 Box::new(|| Box::new(BlockingService)),
2240 Some(backend.clone()),
2241 );
2242 let app = tauri::test::mock_app();
2243
2244 send_start(&handle, app.handle().clone()).await.unwrap();
2245 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2246
2247 {
2249 let mut saves = backend.saves.lock().unwrap();
2250 let last = saves.last_mut().unwrap();
2251 last.last_native_state = Some("timeout".into());
2252 last.last_platform_error = Some("FGS timed out".into());
2253 last.restart_attempt = 3;
2254 last.recovery_reason = Some("boot recovery".into());
2255 last.last_heartbeat_epoch_ms = Some(1700000005000);
2256 }
2257
2258 let status = send_get_state(&handle).await;
2259 assert_eq!(status.desired_running, Some(true));
2260 assert_eq!(status.native_state, Some(NativeState::Timeout));
2261 assert_eq!(status.platform_error, Some("FGS timed out".into()));
2262 assert_eq!(status.restart_attempt, Some(3));
2263 assert_eq!(status.recovery_reason, Some("boot recovery".into()));
2264 assert_eq!(status.last_heartbeat_at, Some(1700000005000));
2265 }
2266
2267 #[tokio::test]
2268 async fn get_state_returns_platform_mode() {
2269 let handle = setup_manager();
2270
2271 let status = send_get_state(&handle).await;
2272 assert_eq!(
2274 status.platform_mode,
2275 Some(LifecycleMode::DesktopInProcess),
2276 "platform_mode should be populated even without backend"
2277 );
2278 }
2279
2280 async fn send_enable_auto_restart(
2283 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2284 config: Option<StartConfig>,
2285 ) -> Result<(), ServiceError> {
2286 let (tx, rx) = oneshot::channel();
2287 handle
2288 .cmd_tx
2289 .send(ManagerCommand::EnableAutoRestart { config, reply: tx })
2290 .await
2291 .unwrap();
2292 rx.await.unwrap()
2293 }
2294
2295 async fn send_disable_auto_restart(
2296 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2297 ) -> Result<(), ServiceError> {
2298 let (tx, rx) = oneshot::channel();
2299 handle
2300 .cmd_tx
2301 .send(ManagerCommand::DisableAutoRestart { reply: tx })
2302 .await
2303 .unwrap();
2304 rx.await.unwrap()
2305 }
2306
2307 async fn send_get_desired_state(
2308 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2309 ) -> Option<DesiredState> {
2310 let (tx, rx) = oneshot::channel();
2311 handle
2312 .cmd_tx
2313 .send(ManagerCommand::GetDesiredState { reply: tx })
2314 .await
2315 .unwrap();
2316 rx.await.unwrap()
2317 }
2318
2319 #[tokio::test]
2320 async fn enable_auto_restart_saves_true_without_starting() {
2321 let backend = MockDesiredStateBackend::new();
2322 let handle = setup_manager_with_backend(Some(backend.clone()));
2323
2324 assert!(!send_is_running(&handle).await);
2325
2326 send_enable_auto_restart(&handle, None).await.unwrap();
2327
2328 assert!(
2330 !send_is_running(&handle).await,
2331 "enableAutoRestart should not start the service"
2332 );
2333
2334 let ds = backend.last_save().expect("should have saved");
2336 assert!(ds.desired_running, "desired_running should be true");
2337 }
2338
2339 #[tokio::test]
2340 async fn disable_auto_restart_saves_false_without_stopping() {
2341 let backend = MockDesiredStateBackend::new();
2342 let handle = setup_manager_with_factory_and_backend(
2343 Box::new(|| Box::new(BlockingService)),
2344 Some(backend.clone()),
2345 );
2346 let app = tauri::test::mock_app();
2347
2348 send_start(&handle, app.handle().clone()).await.unwrap();
2350 assert!(send_is_running(&handle).await);
2351
2352 send_disable_auto_restart(&handle).await.unwrap();
2354
2355 assert!(
2357 send_is_running(&handle).await,
2358 "disableAutoRestart should not stop the service"
2359 );
2360
2361 let ds = backend.last_save().expect("should have saved");
2363 assert!(!ds.desired_running, "desired_running should be false");
2364 }
2365
2366 #[tokio::test]
2367 async fn enable_auto_restart_with_config_stores_config() {
2368 let backend = MockDesiredStateBackend::new();
2369 let handle = setup_manager_with_backend(Some(backend.clone()));
2370
2371 let config = StartConfig {
2372 service_label: "MyService".into(),
2373 foreground_service_type: "specialUse".into(),
2374 };
2375 send_enable_auto_restart(&handle, Some(config.clone()))
2376 .await
2377 .unwrap();
2378
2379 let ds = backend.last_save().expect("should have saved");
2380 assert!(ds.desired_running);
2381 let saved_config = ds.last_start_config.expect("config should be stored");
2382 assert_eq!(saved_config["serviceLabel"], "MyService");
2383 assert_eq!(saved_config["foregroundServiceType"], "specialUse");
2384 assert!(
2385 ds.last_start_epoch_ms.is_some(),
2386 "should set last_start_epoch_ms"
2387 );
2388 }
2389
2390 #[tokio::test]
2391 async fn disable_auto_restart_clears_recovery_fields() {
2392 let backend = MockDesiredStateBackend::new();
2393 let handle = setup_manager_with_backend(Some(backend.clone()));
2394
2395 send_enable_auto_restart(&handle, None).await.unwrap();
2397 {
2398 let mut saves = backend.saves.lock().unwrap();
2399 let last = saves.last_mut().unwrap();
2400 last.recovery_pending = true;
2401 last.recovery_reason = Some("boot".into());
2402 last.restart_attempt = 5;
2403 }
2404
2405 send_disable_auto_restart(&handle).await.unwrap();
2407
2408 let ds = backend.last_save().expect("should have saved");
2409 assert!(!ds.desired_running);
2410 assert!(!ds.recovery_pending, "recovery_pending should be cleared");
2411 assert_eq!(
2412 ds.recovery_reason, None,
2413 "recovery_reason should be cleared"
2414 );
2415 assert_eq!(ds.restart_attempt, 0, "restart_attempt should be cleared");
2416 }
2417
2418 #[tokio::test]
2419 async fn get_desired_state_returns_current_state() {
2420 let backend = MockDesiredStateBackend::new();
2421 let handle = setup_manager_with_backend(Some(backend.clone()));
2422
2423 let ds = send_get_desired_state(&handle).await;
2425 assert!(ds.is_some());
2426 assert!(!ds.unwrap().desired_running);
2427
2428 let config = StartConfig {
2430 service_label: "Test".into(),
2431 ..Default::default()
2432 };
2433 send_enable_auto_restart(&handle, Some(config))
2434 .await
2435 .unwrap();
2436
2437 let ds = send_get_desired_state(&handle)
2438 .await
2439 .expect("should return state");
2440 assert!(ds.desired_running);
2441 assert!(ds.last_start_config.is_some());
2442 }
2443
2444 #[tokio::test]
2445 async fn get_desired_state_returns_none_without_backend() {
2446 let handle = setup_manager();
2447 let ds = send_get_desired_state(&handle).await;
2448 assert!(
2449 ds.is_none(),
2450 "GetDesiredState should return None without a backend"
2451 );
2452 }
2453
2454 #[tokio::test]
2455 async fn enable_disable_no_backend_no_panic() {
2456 let handle = setup_manager();
2457
2458 send_enable_auto_restart(&handle, None).await.unwrap();
2460 send_disable_auto_restart(&handle).await.unwrap();
2461 }
2462
2463 #[tokio::test]
2464 async fn get_state_stop_clears_start_config_and_recovery() {
2465 let backend = MockDesiredStateBackend::new();
2466 let handle = setup_manager_with_factory_and_backend(
2467 Box::new(|| Box::new(BlockingService)),
2468 Some(backend.clone()),
2469 );
2470 let app = tauri::test::mock_app();
2471
2472 let config = StartConfig {
2473 service_label: "Syncing".into(),
2474 ..Default::default()
2475 };
2476 send_start_with_config(&handle, config, app.handle().clone())
2477 .await
2478 .unwrap();
2479 send_stop(&handle).await.unwrap();
2480
2481 let status = send_get_state(&handle).await;
2482 assert_eq!(status.desired_running, Some(false));
2483 assert_eq!(
2484 status.last_start_config, None,
2485 "last_start_config should be None after stop"
2486 );
2487 assert_eq!(
2488 status.restart_attempt, None,
2489 "restart_attempt should be None after stop"
2490 );
2491 assert_eq!(
2492 status.recovery_reason, None,
2493 "recovery_reason should be None after stop"
2494 );
2495 }
2496}