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, LifecycleState, LifecycleStatus, PluginEvent,
22 ServiceContext, ServiceState as ServiceLifecycle, ServiceStatus, StartConfig, StopReason,
23 ValidationIssue,
24};
25use crate::notifier::Notifier;
26use crate::service_trait::BackgroundService;
27
28#[doc(hidden)]
30pub type OnCompleteCallback = Box<dyn Fn(bool) + Send + Sync>;
31
32pub(crate) trait MobileKeepalive: Send + Sync {
38 #[allow(clippy::too_many_arguments)]
40 fn start_keepalive(
41 &self,
42 label: &str,
43 foreground_service_type: &str,
44 ios_safety_timeout_secs: Option<f64>,
45 ios_processing_safety_timeout_secs: Option<f64>,
46 ios_earliest_refresh_begin_minutes: Option<f64>,
47 ios_earliest_processing_begin_minutes: Option<f64>,
48 ios_requires_external_power: Option<bool>,
49 ios_requires_network_connectivity: Option<bool>,
50 ) -> Result<(), ServiceError>;
51 fn stop_keepalive(&self) -> Result<(), ServiceError>;
53}
54
55#[doc(hidden)]
57pub type ServiceFactory<R> = Box<dyn Fn() -> Box<dyn BackgroundService<R>> + Send + Sync>;
58
59#[non_exhaustive]
68pub enum ManagerCommand<R: Runtime> {
69 Start {
70 config: StartConfig,
71 reply: oneshot::Sender<Result<(), ServiceError>>,
72 app: AppHandle<R>,
73 },
74 Stop {
75 reply: oneshot::Sender<Result<(), ServiceError>>,
76 },
77 StopWithReason {
78 reason: StopReason,
79 reply: oneshot::Sender<Result<(), ServiceError>>,
80 },
81 IsRunning {
82 reply: oneshot::Sender<bool>,
83 },
84 GetState {
85 reply: oneshot::Sender<ServiceStatus>,
86 },
87 SetOnComplete {
88 callback: OnCompleteCallback,
89 },
90 #[allow(dead_code, private_interfaces)]
91 SetMobile {
92 mobile: Arc<dyn MobileKeepalive>,
93 },
94 SetDesiredRunning {
95 desired: bool,
96 config: Option<StartConfig>,
97 reply: oneshot::Sender<Result<(), ServiceError>>,
98 },
99 EnableAutoRestart {
100 config: Option<StartConfig>,
101 reply: oneshot::Sender<Result<(), ServiceError>>,
102 },
103 DisableAutoRestart {
104 reply: oneshot::Sender<Result<(), ServiceError>>,
105 },
106 GetDesiredState {
107 reply: oneshot::Sender<Option<crate::desired_state::DesiredState>>,
108 },
109 NativeLifecycleEvent {
110 event: crate::models::NativeLifecycleEvent,
111 reply: oneshot::Sender<Result<(), ServiceError>>,
112 },
113 GetLifecycleStatus {
114 desktop_mode: Option<String>,
115 reply: oneshot::Sender<LifecycleStatus>,
116 },
117}
118
119pub struct ServiceManagerHandle<R: Runtime> {
127 pub(crate) cmd_tx: mpsc::Sender<ManagerCommand<R>>,
128}
129
130impl<R: Runtime> ServiceManagerHandle<R> {
131 pub fn new(cmd_tx: mpsc::Sender<ManagerCommand<R>>) -> Self {
133 Self { cmd_tx }
134 }
135
136 pub async fn start(&self, app: AppHandle<R>, config: StartConfig) -> Result<(), ServiceError> {
141 let (reply, rx) = oneshot::channel();
142 self.cmd_tx
143 .send(ManagerCommand::Start { config, reply, app })
144 .await
145 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
146 rx.await
147 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
148 }
149
150 pub async fn stop(&self) -> Result<(), ServiceError> {
155 let (reply, rx) = oneshot::channel();
156 self.cmd_tx
157 .send(ManagerCommand::Stop { reply })
158 .await
159 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
160 rx.await
161 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
162 }
163
164 pub fn stop_blocking(&self) -> Result<(), ServiceError> {
170 let (reply, rx) = oneshot::channel();
171 self.cmd_tx
172 .blocking_send(ManagerCommand::Stop { reply })
173 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
174 rx.blocking_recv()
175 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
176 }
177
178 pub async fn stop_with_reason(&self, reason: StopReason) -> Result<(), ServiceError> {
184 let (reply, rx) = oneshot::channel();
185 self.cmd_tx
186 .send(ManagerCommand::StopWithReason { reason, reply })
187 .await
188 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
189 rx.await
190 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
191 }
192
193 pub fn stop_blocking_with_reason(&self, reason: StopReason) -> Result<(), ServiceError> {
197 let (reply, rx) = oneshot::channel();
198 self.cmd_tx
199 .blocking_send(ManagerCommand::StopWithReason { reason, reply })
200 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
201 rx.blocking_recv()
202 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
203 }
204
205 pub async fn is_running(&self) -> bool {
207 let (reply, rx) = oneshot::channel();
208 if self
209 .cmd_tx
210 .send(ManagerCommand::IsRunning { reply })
211 .await
212 .is_err()
213 {
214 return false;
215 }
216 rx.await.unwrap_or(false)
217 }
218
219 #[doc(hidden)]
224 pub async fn set_on_complete(&self, callback: OnCompleteCallback) {
225 let _ = self
226 .cmd_tx
227 .send(ManagerCommand::SetOnComplete { callback })
228 .await;
229 }
230
231 pub async fn get_state(&self) -> ServiceStatus {
233 let (reply, rx) = oneshot::channel();
234 if self
235 .cmd_tx
236 .send(ManagerCommand::GetState { reply })
237 .await
238 .is_err()
239 {
240 return ServiceStatus {
241 state: ServiceLifecycle::Idle,
242 ..Default::default()
243 };
244 }
245 rx.await.unwrap_or(ServiceStatus {
246 state: ServiceLifecycle::Idle,
247 ..Default::default()
248 })
249 }
250
251 #[doc(hidden)]
256 pub async fn send_native_lifecycle_event(
257 &self,
258 event: crate::models::NativeLifecycleEvent,
259 ) -> Result<(), ServiceError> {
260 let (reply, rx) = oneshot::channel();
261 self.cmd_tx
262 .send(ManagerCommand::NativeLifecycleEvent { event, reply })
263 .await
264 .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
265 rx.await
266 .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
267 }
268}
269
270struct ServiceState<R: Runtime> {
274 is_running: Arc<AtomicBool>,
278 token: Arc<Mutex<Option<CancellationToken>>>,
282 generation: Arc<AtomicU64>,
285 on_complete: Option<OnCompleteCallback>,
289 factory: ServiceFactory<R>,
291 mobile: Option<Arc<dyn MobileKeepalive>>,
293 ios_safety_timeout_secs: f64,
296 ios_processing_safety_timeout_secs: f64,
300 ios_earliest_refresh_begin_minutes: f64,
302 ios_earliest_processing_begin_minutes: f64,
304 ios_requires_external_power: bool,
306 ios_requires_network_connectivity: bool,
308 lifecycle_state: Arc<Mutex<ServiceLifecycle>>,
311 last_error: Arc<Mutex<Option<String>>>,
314 desired_state: Option<Arc<dyn DesiredStateBackend>>,
317 lifecycle_mode: LifecycleMode,
319}
320
321#[doc(hidden)]
328#[allow(clippy::too_many_arguments)]
329pub async fn manager_loop<R: Runtime>(
330 mut rx: mpsc::Receiver<ManagerCommand<R>>,
331 factory: ServiceFactory<R>,
332 ios_safety_timeout_secs: f64,
336 ios_processing_safety_timeout_secs: f64,
339 ios_earliest_refresh_begin_minutes: f64,
341 ios_earliest_processing_begin_minutes: f64,
343 ios_requires_external_power: bool,
345 ios_requires_network_connectivity: bool,
347 desired_state_backend: Option<Arc<dyn DesiredStateBackend>>,
349) {
350 let lifecycle_mode = {
351 #[cfg(target_os = "android")]
352 {
353 LifecycleMode::AndroidForegroundService
354 }
355 #[cfg(target_os = "ios")]
356 {
357 LifecycleMode::IosBgTaskScheduler
358 }
359 #[cfg(not(any(target_os = "android", target_os = "ios")))]
360 {
361 LifecycleMode::DesktopInProcess
362 }
363 };
364
365 let mut state = ServiceState {
366 is_running: Arc::new(AtomicBool::new(false)),
367 token: Arc::new(Mutex::new(None)),
368 generation: Arc::new(AtomicU64::new(0)),
369 on_complete: None,
370 factory,
371 mobile: None,
372 ios_safety_timeout_secs,
373 ios_processing_safety_timeout_secs,
374 ios_earliest_refresh_begin_minutes,
375 ios_earliest_processing_begin_minutes,
376 ios_requires_external_power,
377 ios_requires_network_connectivity,
378 lifecycle_state: Arc::new(Mutex::new(ServiceLifecycle::Idle)),
379 last_error: Arc::new(Mutex::new(None)),
380 desired_state: desired_state_backend,
381 lifecycle_mode,
382 };
383
384 while let Some(cmd) = rx.recv().await {
385 match cmd {
386 ManagerCommand::Start { config, reply, app } => {
387 let _ = reply.send(handle_start(&mut state, app, config));
388 }
389 ManagerCommand::Stop { reply } => {
390 let _ = reply.send(handle_stop(&mut state));
391 }
392 ManagerCommand::StopWithReason { reason, reply } => {
393 let _ = reply.send(handle_stop_with_reason(&mut state, reason));
394 }
395 ManagerCommand::IsRunning { reply } => {
396 let _ = reply.send(state.is_running.load(Ordering::SeqCst));
397 }
398 ManagerCommand::SetOnComplete { callback } => {
399 state.on_complete = Some(callback);
400 }
401 ManagerCommand::SetMobile { mobile } => {
402 state.mobile = Some(mobile);
403 }
404 ManagerCommand::GetState { reply } => {
405 let mut status = ServiceStatus {
406 state: *state.lifecycle_state.lock().unwrap(),
407 last_error: state.last_error.lock().unwrap().clone(),
408 platform_mode: Some(state.lifecycle_mode),
409 ..Default::default()
410 };
411
412 if let Some(ref backend) = state.desired_state {
413 if let Ok(ds) = backend.load() {
414 status.desired_running = Some(ds.desired_running);
415 status.native_state = ds
416 .last_native_state
417 .as_deref()
418 .and_then(|s| serde_json::from_str(&format!("\"{s}\"")).ok());
419 status.last_start_config = ds
420 .last_start_config
421 .and_then(|v| serde_json::from_value(v).ok());
422 status.last_heartbeat_at = ds.last_heartbeat_epoch_ms;
423 status.restart_attempt = if ds.restart_attempt > 0 {
424 Some(ds.restart_attempt)
425 } else {
426 None
427 };
428 status.recovery_reason = ds.recovery_reason;
429 status.platform_error = ds.last_platform_error;
430 }
431 }
432
433 let _ = reply.send(status);
434 }
435 ManagerCommand::SetDesiredRunning {
436 desired,
437 config,
438 reply,
439 } => {
440 let _ = reply.send(handle_set_desired_running(&mut state, desired, config));
441 }
442 ManagerCommand::EnableAutoRestart { config, reply } => {
443 let _ = reply.send(handle_enable_auto_restart(&mut state, config));
444 }
445 ManagerCommand::DisableAutoRestart { reply } => {
446 let _ = reply.send(handle_disable_auto_restart(&mut state));
447 }
448 ManagerCommand::GetDesiredState { reply } => {
449 let _ = reply.send(handle_get_desired_state(&state));
450 }
451 ManagerCommand::NativeLifecycleEvent { event, reply } => {
452 let reason = event.to_stop_reason();
453 let _ = reply.send(handle_stop_with_reason(&mut state, reason));
454 }
455 ManagerCommand::GetLifecycleStatus {
456 desktop_mode,
457 reply,
458 } => {
459 let _ = reply.send(build_lifecycle_status(&state, desktop_mode.as_deref()));
460 }
461 }
462 }
463}
464
465fn handle_start<R: Runtime>(
476 state: &mut ServiceState<R>,
477 app: AppHandle<R>,
478 config: StartConfig,
479) -> Result<(), ServiceError> {
480 let mut guard = state.token.lock().unwrap();
481
482 if guard.is_some() {
483 return Err(ServiceError::AlreadyRunning);
484 }
485
486 if cfg!(mobile) {
490 validate_foreground_service_type(&config.foreground_service_type)?;
491 }
492
493 let token = CancellationToken::new();
494 let shutdown = token.clone();
495 *guard = Some(token);
496 let my_gen = state.generation.fetch_add(1, Ordering::Release) + 1;
497 state.is_running.store(true, Ordering::SeqCst);
498 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Initializing;
499 *state.last_error.lock().unwrap() = None;
500
501 drop(guard);
502
503 let captured_callback = state.on_complete.take();
506
507 if let Some(ref mobile) = state.mobile {
510 let processing_timeout = if state.ios_processing_safety_timeout_secs > 0.0 {
511 Some(state.ios_processing_safety_timeout_secs)
512 } else {
513 None
514 };
515 if let Err(e) = mobile.start_keepalive(
516 &config.service_label,
517 &config.foreground_service_type,
518 Some(state.ios_safety_timeout_secs),
519 processing_timeout,
520 Some(state.ios_earliest_refresh_begin_minutes),
521 Some(state.ios_earliest_processing_begin_minutes),
522 Some(state.ios_requires_external_power),
523 Some(state.ios_requires_network_connectivity),
524 ) {
525 state.token.lock().unwrap().take();
527 state.is_running.store(false, Ordering::SeqCst);
528 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Idle;
529 state.on_complete = captured_callback;
531 return Err(e);
532 }
533 }
534
535 let token_ref = state.token.clone();
537 let gen_ref = state.generation.clone();
538 let is_running_ref = state.is_running.clone();
539 let lifecycle_ref = state.lifecycle_state.clone();
540 let last_error_ref = state.last_error.clone();
541
542 let mut service = (state.factory)();
543
544 let ctx = ServiceContext {
545 notifier: Notifier { app: app.clone() },
546 app: app.clone(),
547 shutdown,
548 #[cfg(mobile)]
549 service_label: config.service_label.clone(),
550 #[cfg(mobile)]
551 foreground_service_type: config.foreground_service_type.clone(),
552 };
553
554 tauri::async_runtime::spawn(async move {
558 if let Err(e) = service.init(&ctx).await {
560 let _ = app.emit(
561 "background-service://event",
562 PluginEvent::Error {
563 message: e.to_string(),
564 },
565 );
566 if gen_ref.load(Ordering::Acquire) == my_gen {
568 token_ref.lock().unwrap().take();
569 is_running_ref.store(false, Ordering::SeqCst);
570 {
572 let mut lc = lifecycle_ref.lock().unwrap();
573 if *lc == ServiceLifecycle::Initializing {
574 *lc = ServiceLifecycle::Stopped;
575 }
576 }
577 *last_error_ref.lock().unwrap() = Some(e.to_string());
578 }
579 if let Some(cb) = captured_callback {
581 cb(false);
582 }
583 return;
584 }
585
586 if gen_ref.load(Ordering::Acquire) == my_gen {
588 let mut lc = lifecycle_ref.lock().unwrap();
589 if *lc == ServiceLifecycle::Initializing {
590 *lc = ServiceLifecycle::Running;
591 }
592 }
593
594 let _ = app.emit("background-service://event", PluginEvent::Started);
596
597 let result = service.run(&ctx).await;
599
600 match result {
602 Ok(()) => {
603 let _ = app.emit(
604 "background-service://event",
605 PluginEvent::Stopped {
606 reason: StopReason::TaskCompleted,
607 },
608 );
609 }
610 Err(ref e) => {
611 let _ = app.emit(
612 "background-service://event",
613 PluginEvent::Error {
614 message: e.to_string(),
615 },
616 );
617 }
618 }
619
620 if let Some(cb) = captured_callback {
624 cb(result.is_ok());
625 }
626
627 if gen_ref.load(Ordering::Acquire) == my_gen {
629 token_ref.lock().unwrap().take();
630 is_running_ref.store(false, Ordering::SeqCst);
631 {
633 let mut lc = lifecycle_ref.lock().unwrap();
634 if matches!(
635 *lc,
636 ServiceLifecycle::Initializing | ServiceLifecycle::Running
637 ) {
638 *lc = ServiceLifecycle::Stopped;
639 }
640 }
641 if let Err(ref e) = result {
642 *last_error_ref.lock().unwrap() = Some(e.to_string());
643 }
644 }
645 });
646
647 save_desired_running(state, true, Some(&config));
649
650 Ok(())
651}
652
653fn handle_stop<R: Runtime>(state: &mut ServiceState<R>) -> Result<(), ServiceError> {
658 handle_stop_with_reason(state, StopReason::UserStop)
659}
660
661fn handle_stop_with_reason<R: Runtime>(
669 state: &mut ServiceState<R>,
670 reason: StopReason,
671) -> Result<(), ServiceError> {
672 let mut guard = state.token.lock().unwrap();
673 match guard.take() {
674 Some(token) => {
675 token.cancel();
676 state.is_running.store(false, Ordering::SeqCst);
677 *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Stopped;
678 *state.last_error.lock().unwrap() = None;
679 drop(guard);
680 if should_stop_keepalive(reason) {
681 if let Some(ref mobile) = state.mobile {
682 if let Err(e) = mobile.stop_keepalive() {
683 log::warn!("stop_keepalive failed: {e}");
684 }
685 }
686 }
687 if should_clear_desired_state(reason) {
688 save_desired_running(state, false, None);
689 }
690 Ok(())
691 }
692 None => Err(ServiceError::NotRunning),
693 }
694}
695
696fn should_clear_desired_state(reason: StopReason) -> bool {
701 matches!(
702 reason,
703 StopReason::UserStop
704 | StopReason::AppStop
705 | StopReason::NativeNotificationStop
706 | StopReason::TaskCompleted
707 )
708}
709
710fn should_stop_keepalive(reason: StopReason) -> bool {
714 !matches!(reason, StopReason::PlatformExpiration)
715}
716
717fn save_desired_running<R: Runtime>(
724 state: &ServiceState<R>,
725 desired: bool,
726 config: Option<&StartConfig>,
727) {
728 let Some(ref backend) = state.desired_state else {
729 return;
730 };
731
732 let mut ds = backend.load().unwrap_or_default();
733 ds.desired_running = desired;
734 if desired {
735 ds.last_start_config = config.map(|c| serde_json::to_value(c).unwrap_or_default());
736 ds.last_start_epoch_ms = Some(
737 std::time::SystemTime::now()
738 .duration_since(std::time::UNIX_EPOCH)
739 .unwrap_or_default()
740 .as_millis() as u64,
741 );
742 } else {
743 ds.last_start_config = None;
744 ds.last_start_epoch_ms = None;
745 ds.recovery_pending = false;
746 ds.recovery_reason = None;
747 ds.restart_attempt = 0;
748 }
749 if let Err(e) = backend.save(&ds) {
750 log::warn!("failed to save desired state: {e}");
751 }
752}
753
754fn handle_set_desired_running<R: Runtime>(
760 state: &mut ServiceState<R>,
761 desired: bool,
762 config: Option<StartConfig>,
763) -> Result<(), ServiceError> {
764 save_desired_running(state, desired, config.as_ref());
765 Ok(())
766}
767
768fn handle_enable_auto_restart<R: Runtime>(
773 state: &mut ServiceState<R>,
774 config: Option<StartConfig>,
775) -> Result<(), ServiceError> {
776 save_desired_running(state, true, config.as_ref());
777 Ok(())
778}
779
780fn handle_disable_auto_restart<R: Runtime>(
785 state: &mut ServiceState<R>,
786) -> Result<(), ServiceError> {
787 save_desired_running(state, false, None);
788 Ok(())
789}
790
791fn handle_get_desired_state<R: Runtime>(
795 state: &ServiceState<R>,
796) -> Option<crate::desired_state::DesiredState> {
797 state
798 .desired_state
799 .as_ref()
800 .and_then(|backend| backend.load().ok())
801}
802
803fn build_lifecycle_status<R: Runtime>(
808 state: &ServiceState<R>,
809 desktop_mode: Option<&str>,
810) -> LifecycleStatus {
811 let lifecycle_state: LifecycleState = (*state.lifecycle_state.lock().unwrap()).into();
812 let last_error = state.last_error.lock().unwrap().clone();
813
814 let desired = state.desired_state.as_ref().and_then(|b| b.load().ok());
816
817 let desired_running = desired.as_ref().is_some_and(|d| d.desired_running);
818 let recovery_enabled = desired_running;
819 let recovery_pending = desired.as_ref().is_some_and(|d| d.recovery_pending);
820 let recovery_reason = desired.as_ref().and_then(|d| d.recovery_reason.clone());
821 let last_start_config = desired
822 .as_ref()
823 .and_then(|d| d.last_start_config.clone())
824 .and_then(|v| serde_json::from_value(v).ok());
825 let last_platform_state = desired.as_ref().and_then(|d| d.last_native_state.clone());
826 let last_platform_error = desired.as_ref().and_then(|d| d.last_platform_error.clone());
827
828 let (platform, _) = crate::capabilities::CapabilityProvider::detect_platform(desktop_mode);
829 let capabilities = crate::capabilities::CapabilityProvider::capabilities(
830 platform,
831 state.lifecycle_mode,
832 false,
833 );
834 let report = crate::validator::SetupValidator::validate(platform);
835 let mut issues: Vec<ValidationIssue> = report
836 .errors
837 .into_iter()
838 .map(|i| ValidationIssue {
839 severity: crate::models::Severity::Error,
840 code: i.code,
841 message: i.message,
842 fix: i.fix,
843 platform,
844 })
845 .collect();
846 issues.extend(report.warnings.into_iter().map(|i| ValidationIssue {
847 severity: crate::models::Severity::Warning,
848 code: i.code,
849 message: i.message,
850 fix: i.fix,
851 platform,
852 }));
853
854 LifecycleStatus {
855 state: lifecycle_state,
856 desired_running,
857 recovery_enabled,
858 recovery_pending,
859 recovery_reason,
860 last_start_config,
861 last_platform_state,
862 last_platform_error,
863 last_error,
864 platform,
865 capabilities,
866 issues,
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use crate::desired_state::DesiredState;
874 use crate::models::{NativeLifecycleEvent, NativeState};
875 use async_trait::async_trait;
876 use std::sync::atomic::{AtomicI8, AtomicU8, AtomicUsize};
877
878 struct MockMobile {
882 start_called: AtomicUsize,
883 stop_called: AtomicUsize,
884 start_fail: bool,
885 last_label: std::sync::Mutex<Option<String>>,
886 last_fst: std::sync::Mutex<Option<String>>,
887 last_timeout_secs: std::sync::Mutex<Option<f64>>,
888 last_processing_timeout_secs: std::sync::Mutex<Option<f64>>,
889 last_earliest_refresh_begin_minutes: std::sync::Mutex<Option<f64>>,
890 last_earliest_processing_begin_minutes: std::sync::Mutex<Option<f64>>,
891 last_requires_external_power: std::sync::Mutex<Option<bool>>,
892 last_requires_network_connectivity: std::sync::Mutex<Option<bool>>,
893 }
894
895 impl MockMobile {
896 fn new() -> Arc<Self> {
897 Arc::new(Self {
898 start_called: AtomicUsize::new(0),
899 stop_called: AtomicUsize::new(0),
900 start_fail: false,
901 last_label: std::sync::Mutex::new(None),
902 last_fst: std::sync::Mutex::new(None),
903 last_timeout_secs: std::sync::Mutex::new(None),
904 last_processing_timeout_secs: std::sync::Mutex::new(None),
905 last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
906 last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
907 last_requires_external_power: std::sync::Mutex::new(None),
908 last_requires_network_connectivity: std::sync::Mutex::new(None),
909 })
910 }
911
912 fn new_failing() -> Arc<Self> {
913 Arc::new(Self {
914 start_called: AtomicUsize::new(0),
915 stop_called: AtomicUsize::new(0),
916 start_fail: true,
917 last_label: std::sync::Mutex::new(None),
918 last_fst: std::sync::Mutex::new(None),
919 last_timeout_secs: std::sync::Mutex::new(None),
920 last_processing_timeout_secs: std::sync::Mutex::new(None),
921 last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
922 last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
923 last_requires_external_power: std::sync::Mutex::new(None),
924 last_requires_network_connectivity: std::sync::Mutex::new(None),
925 })
926 }
927 }
928
929 #[allow(clippy::too_many_arguments)]
930 fn mock_start_keepalive(
931 mock: &MockMobile,
932 label: &str,
933 foreground_service_type: &str,
934 ios_safety_timeout_secs: Option<f64>,
935 ios_processing_safety_timeout_secs: Option<f64>,
936 ios_earliest_refresh_begin_minutes: Option<f64>,
937 ios_earliest_processing_begin_minutes: Option<f64>,
938 ios_requires_external_power: Option<bool>,
939 ios_requires_network_connectivity: Option<bool>,
940 ) -> Result<(), ServiceError> {
941 mock.start_called.fetch_add(1, Ordering::Release);
942 *mock.last_label.lock().unwrap() = Some(label.to_string());
943 *mock.last_fst.lock().unwrap() = Some(foreground_service_type.to_string());
944 *mock.last_timeout_secs.lock().unwrap() = ios_safety_timeout_secs;
945 *mock.last_processing_timeout_secs.lock().unwrap() = ios_processing_safety_timeout_secs;
946 *mock.last_earliest_refresh_begin_minutes.lock().unwrap() =
947 ios_earliest_refresh_begin_minutes;
948 *mock.last_earliest_processing_begin_minutes.lock().unwrap() =
949 ios_earliest_processing_begin_minutes;
950 *mock.last_requires_external_power.lock().unwrap() = ios_requires_external_power;
951 *mock.last_requires_network_connectivity.lock().unwrap() =
952 ios_requires_network_connectivity;
953 if mock.start_fail {
954 return Err(ServiceError::Platform("mock keepalive failure".into()));
955 }
956 Ok(())
957 }
958
959 impl MobileKeepalive for MockMobile {
960 #[allow(clippy::too_many_arguments)]
961 fn start_keepalive(
962 &self,
963 label: &str,
964 foreground_service_type: &str,
965 ios_safety_timeout_secs: Option<f64>,
966 ios_processing_safety_timeout_secs: Option<f64>,
967 ios_earliest_refresh_begin_minutes: Option<f64>,
968 ios_earliest_processing_begin_minutes: Option<f64>,
969 ios_requires_external_power: Option<bool>,
970 ios_requires_network_connectivity: Option<bool>,
971 ) -> Result<(), ServiceError> {
972 mock_start_keepalive(
973 self,
974 label,
975 foreground_service_type,
976 ios_safety_timeout_secs,
977 ios_processing_safety_timeout_secs,
978 ios_earliest_refresh_begin_minutes,
979 ios_earliest_processing_begin_minutes,
980 ios_requires_external_power,
981 ios_requires_network_connectivity,
982 )
983 }
984
985 fn stop_keepalive(&self) -> Result<(), ServiceError> {
986 self.stop_called.fetch_add(1, Ordering::Release);
987 Ok(())
988 }
989 }
990
991 struct BlockingService;
994
995 #[async_trait]
996 impl BackgroundService<tauri::test::MockRuntime> for BlockingService {
997 async fn init(
998 &mut self,
999 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1000 ) -> Result<(), ServiceError> {
1001 Ok(())
1002 }
1003
1004 async fn run(
1005 &mut self,
1006 ctx: &ServiceContext<tauri::test::MockRuntime>,
1007 ) -> Result<(), ServiceError> {
1008 ctx.shutdown.cancelled().await;
1009 Ok(())
1010 }
1011 }
1012
1013 fn setup_manager() -> ServiceManagerHandle<tauri::test::MockRuntime> {
1015 setup_manager_with_backend(None)
1016 }
1017
1018 fn setup_manager_with_backend(
1020 backend: Option<Arc<dyn DesiredStateBackend>>,
1021 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
1022 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1023 let handle = ServiceManagerHandle::new(cmd_tx);
1024 let factory: ServiceFactory<tauri::test::MockRuntime> =
1025 Box::new(|| Box::new(BlockingService));
1026 tokio::spawn(manager_loop(
1027 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
1028 ));
1029 handle
1030 }
1031
1032 async fn send_start(
1033 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1034 app: AppHandle<tauri::test::MockRuntime>,
1035 ) -> Result<(), ServiceError> {
1036 send_start_with_config(handle, StartConfig::default(), app).await
1037 }
1038
1039 async fn send_start_with_config(
1040 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1041 config: StartConfig,
1042 app: AppHandle<tauri::test::MockRuntime>,
1043 ) -> Result<(), ServiceError> {
1044 let (tx, rx) = oneshot::channel();
1045 handle
1046 .cmd_tx
1047 .send(ManagerCommand::Start {
1048 config,
1049 reply: tx,
1050 app,
1051 })
1052 .await
1053 .unwrap();
1054 rx.await.unwrap()
1055 }
1056
1057 async fn send_stop(
1058 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1059 ) -> Result<(), ServiceError> {
1060 let (tx, rx) = oneshot::channel();
1061 handle
1062 .cmd_tx
1063 .send(ManagerCommand::Stop { reply: tx })
1064 .await
1065 .unwrap();
1066 rx.await.unwrap()
1067 }
1068
1069 async fn send_is_running(handle: &ServiceManagerHandle<tauri::test::MockRuntime>) -> bool {
1070 let (tx, rx) = oneshot::channel();
1071 handle
1072 .cmd_tx
1073 .send(ManagerCommand::IsRunning { reply: tx })
1074 .await
1075 .unwrap();
1076 rx.await.unwrap()
1077 }
1078
1079 #[tokio::test]
1082 async fn start_from_idle() {
1083 let handle = setup_manager();
1084 let app = tauri::test::mock_app();
1085
1086 let result = send_start(&handle, app.handle().clone()).await;
1087 assert!(result.is_ok(), "start should succeed from idle");
1088 assert!(
1089 send_is_running(&handle).await,
1090 "should be running after start"
1091 );
1092 }
1093
1094 #[tokio::test]
1097 async fn stop_from_running() {
1098 let handle = setup_manager();
1099 let app = tauri::test::mock_app();
1100
1101 send_start(&handle, app.handle().clone()).await.unwrap();
1102
1103 let result = send_stop(&handle).await;
1104 assert!(result.is_ok(), "stop should succeed from running");
1105 assert!(
1106 !send_is_running(&handle).await,
1107 "should not be running after stop"
1108 );
1109 }
1110
1111 #[tokio::test]
1114 async fn double_start_returns_already_running() {
1115 let handle = setup_manager();
1116 let app = tauri::test::mock_app();
1117
1118 send_start(&handle, app.handle().clone()).await.unwrap();
1119
1120 let result = send_start(&handle, app.handle().clone()).await;
1121 assert!(
1122 matches!(result, Err(ServiceError::AlreadyRunning)),
1123 "second start should return AlreadyRunning"
1124 );
1125 }
1126
1127 #[tokio::test]
1130 async fn stop_when_not_running_returns_not_running() {
1131 let handle = setup_manager();
1132
1133 let result = send_stop(&handle).await;
1134 assert!(
1135 matches!(result, Err(ServiceError::NotRunning)),
1136 "stop should return NotRunning when idle"
1137 );
1138 }
1139
1140 #[tokio::test]
1143 async fn start_stop_restart_cycle() {
1144 let handle = setup_manager();
1145 let app = tauri::test::mock_app();
1146
1147 send_start(&handle, app.handle().clone()).await.unwrap();
1149 assert!(send_is_running(&handle).await);
1150
1151 send_stop(&handle).await.unwrap();
1153 assert!(!send_is_running(&handle).await);
1154
1155 let result = send_start(&handle, app.handle().clone()).await;
1157 assert!(result.is_ok(), "restart should succeed after stop");
1158 assert!(
1159 send_is_running(&handle).await,
1160 "should be running after restart"
1161 );
1162 }
1163
1164 struct ImmediateSuccessService;
1168
1169 #[async_trait]
1170 impl BackgroundService<tauri::test::MockRuntime> for ImmediateSuccessService {
1171 async fn init(
1172 &mut self,
1173 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1174 ) -> Result<(), ServiceError> {
1175 Ok(())
1176 }
1177
1178 async fn run(
1179 &mut self,
1180 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1181 ) -> Result<(), ServiceError> {
1182 Ok(())
1183 }
1184 }
1185
1186 struct ImmediateErrorService;
1188
1189 #[async_trait]
1190 impl BackgroundService<tauri::test::MockRuntime> for ImmediateErrorService {
1191 async fn init(
1192 &mut self,
1193 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1194 ) -> Result<(), ServiceError> {
1195 Ok(())
1196 }
1197
1198 async fn run(
1199 &mut self,
1200 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1201 ) -> Result<(), ServiceError> {
1202 Err(ServiceError::Runtime("run error".into()))
1203 }
1204 }
1205
1206 struct FailingInitService;
1208
1209 #[async_trait]
1210 impl BackgroundService<tauri::test::MockRuntime> for FailingInitService {
1211 async fn init(
1212 &mut self,
1213 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1214 ) -> Result<(), ServiceError> {
1215 Err(ServiceError::Init("init error".into()))
1216 }
1217
1218 async fn run(
1219 &mut self,
1220 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1221 ) -> Result<(), ServiceError> {
1222 Ok(())
1223 }
1224 }
1225
1226 fn setup_manager_with_factory(
1228 factory: ServiceFactory<tauri::test::MockRuntime>,
1229 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
1230 setup_manager_with_factory_and_backend(factory, None)
1231 }
1232
1233 fn setup_manager_with_factory_and_backend(
1235 factory: ServiceFactory<tauri::test::MockRuntime>,
1236 backend: Option<Arc<dyn DesiredStateBackend>>,
1237 ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
1238 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1239 let handle = ServiceManagerHandle::new(cmd_tx);
1240 tokio::spawn(manager_loop(
1241 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
1242 ));
1243 handle
1244 }
1245
1246 async fn send_set_on_complete(
1247 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1248 callback: OnCompleteCallback,
1249 ) {
1250 handle
1251 .cmd_tx
1252 .send(ManagerCommand::SetOnComplete { callback })
1253 .await
1254 .unwrap();
1255 }
1256
1257 async fn wait_until_stopped(
1260 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1261 timeout_ms: u64,
1262 ) {
1263 let start = std::time::Instant::now();
1264 while start.elapsed().as_millis() < timeout_ms as u128 {
1265 if !send_is_running(handle).await {
1266 return;
1267 }
1268 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1269 }
1270 panic!("Service did not stop within {timeout_ms}ms");
1271 }
1272
1273 #[tokio::test]
1276 async fn callback_fires_on_success() {
1277 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1278 let app = tauri::test::mock_app();
1279
1280 let called = Arc::new(AtomicI8::new(-1));
1281 let called_clone = called.clone();
1282 send_set_on_complete(
1283 &handle,
1284 Box::new(move |success| {
1285 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1286 }),
1287 )
1288 .await;
1289
1290 send_start(&handle, app.handle().clone()).await.unwrap();
1291 wait_until_stopped(&handle, 1000).await;
1292
1293 assert_eq!(
1294 called.load(Ordering::Acquire),
1295 1,
1296 "callback should be called with true"
1297 );
1298 }
1299
1300 #[tokio::test]
1303 async fn callback_fires_on_error() {
1304 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateErrorService)));
1305 let app = tauri::test::mock_app();
1306
1307 let called = Arc::new(AtomicI8::new(-1));
1308 let called_clone = called.clone();
1309 send_set_on_complete(
1310 &handle,
1311 Box::new(move |success| {
1312 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1313 }),
1314 )
1315 .await;
1316
1317 send_start(&handle, app.handle().clone()).await.unwrap();
1318 wait_until_stopped(&handle, 1000).await;
1319
1320 assert_eq!(
1321 called.load(Ordering::Acquire),
1322 0,
1323 "callback should be called with false on error"
1324 );
1325 }
1326
1327 #[tokio::test]
1330 async fn callback_fires_on_init_failure() {
1331 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1332 let app = tauri::test::mock_app();
1333
1334 let called = Arc::new(AtomicI8::new(-1));
1335 let called_clone = called.clone();
1336 send_set_on_complete(
1337 &handle,
1338 Box::new(move |success| {
1339 called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1340 }),
1341 )
1342 .await;
1343
1344 send_start(&handle, app.handle().clone()).await.unwrap();
1345
1346 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1349
1350 assert_eq!(
1351 called.load(Ordering::Acquire),
1352 0,
1353 "callback should be called with false on init failure"
1354 );
1355 assert!(
1356 !send_is_running(&handle).await,
1357 "should not be running after init failure"
1358 );
1359 }
1360
1361 #[tokio::test]
1364 async fn no_callback_no_panic() {
1365 let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1366 let app = tauri::test::mock_app();
1367
1368 let result = send_start(&handle, app.handle().clone()).await;
1370 assert!(result.is_ok(), "start without callback should succeed");
1371
1372 wait_until_stopped(&handle, 1000).await;
1373 }
1375
1376 #[tokio::test]
1379 async fn is_running_false_after_natural_completion() {
1380 struct YieldingService;
1383
1384 #[async_trait]
1385 impl BackgroundService<tauri::test::MockRuntime> for YieldingService {
1386 async fn init(
1387 &mut self,
1388 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1389 ) -> Result<(), ServiceError> {
1390 Ok(())
1391 }
1392
1393 async fn run(
1394 &mut self,
1395 _ctx: &ServiceContext<tauri::test::MockRuntime>,
1396 ) -> Result<(), ServiceError> {
1397 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1400 Ok(())
1401 }
1402 }
1403
1404 let handle = setup_manager_with_factory(Box::new(|| Box::new(YieldingService)));
1405 let app = tauri::test::mock_app();
1406
1407 send_start(&handle, app.handle().clone()).await.unwrap();
1408 assert!(
1409 send_is_running(&handle).await,
1410 "should be running immediately after start"
1411 );
1412
1413 wait_until_stopped(&handle, 2000).await;
1415
1416 assert!(
1417 !send_is_running(&handle).await,
1418 "is_running should be false after natural completion"
1419 );
1420 }
1421
1422 #[tokio::test]
1425 async fn generation_guard_prevents_stale_cleanup() {
1426 let call_count = Arc::new(AtomicU8::new(0));
1430 let call_count_clone = call_count.clone();
1431
1432 let handle = setup_manager_with_factory(Box::new(move || {
1433 let cc = call_count_clone.clone();
1434 if cc.fetch_add(1, Ordering::AcqRel) == 0 {
1437 Box::new(FailingInitService) as Box<dyn BackgroundService<tauri::test::MockRuntime>>
1438 } else {
1439 Box::new(ImmediateSuccessService)
1440 }
1441 }));
1442 let app = tauri::test::mock_app();
1443
1444 send_start(&handle, app.handle().clone()).await.unwrap();
1446 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1447
1448 let result = send_start(&handle, app.handle().clone()).await;
1450 assert!(
1451 result.is_ok(),
1452 "second start should succeed after init failure: {result:?}"
1453 );
1454 assert!(
1455 send_is_running(&handle).await,
1456 "should be running after second start"
1457 );
1458 }
1459
1460 #[tokio::test]
1463 async fn callback_captured_at_spawn_time() {
1464 let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingService)));
1465 let app = tauri::test::mock_app();
1466
1467 let which = Arc::new(AtomicU8::new(0)); let which_clone_a = which.clone();
1471 let which_clone_b = which.clone();
1472
1473 send_set_on_complete(
1474 &handle,
1475 Box::new(move |_| {
1476 which_clone_a.store(1, Ordering::Release);
1477 }),
1478 )
1479 .await;
1480
1481 send_start(&handle, app.handle().clone()).await.unwrap();
1482
1483 send_set_on_complete(
1485 &handle,
1486 Box::new(move |_| {
1487 which_clone_b.store(2, Ordering::Release);
1488 }),
1489 )
1490 .await;
1491
1492 send_stop(&handle).await.unwrap();
1494 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1495
1496 assert_eq!(
1497 which.load(Ordering::Acquire),
1498 1,
1499 "callback A should fire, not B"
1500 );
1501 }
1502
1503 async fn send_set_mobile(
1506 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1507 mobile: Arc<dyn MobileKeepalive>,
1508 ) {
1509 handle
1510 .cmd_tx
1511 .send(ManagerCommand::SetMobile { mobile })
1512 .await
1513 .unwrap();
1514 }
1515
1516 #[tokio::test]
1519 async fn start_keepalive_called_on_start() {
1520 let mock = MockMobile::new();
1521 let handle = setup_manager();
1522 let app = tauri::test::mock_app();
1523
1524 send_set_mobile(&handle, mock.clone()).await;
1525 send_start(&handle, app.handle().clone()).await.unwrap();
1526
1527 assert_eq!(
1528 mock.start_called.load(Ordering::Acquire),
1529 1,
1530 "start_keepalive should be called once"
1531 );
1532 assert_eq!(
1533 mock.last_label.lock().unwrap().as_deref(),
1534 Some("Service running"),
1535 "label should be forwarded"
1536 );
1537 }
1538
1539 #[tokio::test]
1542 async fn start_keepalive_failure_rollback() {
1543 let mock = MockMobile::new_failing();
1544 let handle = setup_manager();
1545 let app = tauri::test::mock_app();
1546
1547 let callback_called = Arc::new(AtomicI8::new(-1));
1548 let cb_clone = callback_called.clone();
1549 send_set_on_complete(
1550 &handle,
1551 Box::new(move |success| {
1552 cb_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1553 }),
1554 )
1555 .await;
1556
1557 send_set_mobile(&handle, mock.clone()).await;
1558
1559 let result = send_start(&handle, app.handle().clone()).await;
1560 assert!(
1561 matches!(result, Err(ServiceError::Platform(_))),
1562 "start should return Platform error on keepalive failure: {result:?}"
1563 );
1564
1565 assert!(
1567 !send_is_running(&handle).await,
1568 "token should be rolled back after keepalive failure"
1569 );
1570
1571 let callback_called2 = Arc::new(AtomicI8::new(-1));
1573 let cb_clone2 = callback_called2.clone();
1574 send_set_on_complete(
1575 &handle,
1576 Box::new(move |success| {
1577 cb_clone2.store(if success { 1 } else { 0 }, Ordering::Release);
1578 }),
1579 )
1580 .await;
1581
1582 let handle2 = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1585 let callback_restored = Arc::new(AtomicI8::new(-1));
1586 let cb_r = callback_restored.clone();
1587 send_set_on_complete(
1588 &handle2,
1589 Box::new(move |success| {
1590 cb_r.store(if success { 1 } else { 0 }, Ordering::Release);
1591 }),
1592 )
1593 .await;
1594 send_start(&handle2, app.handle().clone()).await.unwrap();
1595 wait_until_stopped(&handle2, 1000).await;
1596 assert_eq!(
1597 callback_restored.load(Ordering::Acquire),
1598 1,
1599 "callback should fire after successful start (proves rollback restored it)"
1600 );
1601 }
1602
1603 #[tokio::test]
1606 async fn stop_keepalive_called_on_stop() {
1607 let mock = MockMobile::new();
1608 let handle = setup_manager();
1609 let app = tauri::test::mock_app();
1610
1611 send_set_mobile(&handle, mock.clone()).await;
1612 send_start(&handle, app.handle().clone()).await.unwrap();
1613
1614 assert_eq!(
1615 mock.stop_called.load(Ordering::Acquire),
1616 0,
1617 "stop_keepalive should not be called yet"
1618 );
1619
1620 send_stop(&handle).await.unwrap();
1621
1622 assert_eq!(
1623 mock.stop_called.load(Ordering::Acquire),
1624 1,
1625 "stop_keepalive should be called once after stop"
1626 );
1627 }
1628
1629 struct MockMobileFailingStop;
1633
1634 #[allow(clippy::too_many_arguments)]
1635 impl MobileKeepalive for MockMobileFailingStop {
1636 fn start_keepalive(
1637 &self,
1638 _label: &str,
1639 _foreground_service_type: &str,
1640 _ios_safety_timeout_secs: Option<f64>,
1641 _ios_processing_safety_timeout_secs: Option<f64>,
1642 _ios_earliest_refresh_begin_minutes: Option<f64>,
1643 _ios_earliest_processing_begin_minutes: Option<f64>,
1644 _ios_requires_external_power: Option<bool>,
1645 _ios_requires_network_connectivity: Option<bool>,
1646 ) -> Result<(), ServiceError> {
1647 Ok(())
1648 }
1649
1650 fn stop_keepalive(&self) -> Result<(), ServiceError> {
1651 Err(ServiceError::Platform("mock stop failure".into()))
1652 }
1653 }
1654
1655 #[tokio::test]
1656 async fn stop_keepalive_failure_does_not_propagate() {
1657 let handle = setup_manager();
1658 let app = tauri::test::mock_app();
1659
1660 send_set_mobile(&handle, Arc::new(MockMobileFailingStop)).await;
1661 send_start(&handle, app.handle().clone()).await.unwrap();
1662
1663 let result = send_stop(&handle).await;
1664 assert!(
1665 result.is_ok(),
1666 "stop should succeed even when stop_keepalive fails"
1667 );
1668
1669 assert!(
1670 !send_is_running(&handle).await,
1671 "service should not be running after stop"
1672 );
1673 }
1674
1675 #[tokio::test]
1678 async fn ios_safety_timeout_passed_to_mobile() {
1679 let mock = MockMobile::new();
1680 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1681 let handle = ServiceManagerHandle::new(cmd_tx);
1682 let factory: ServiceFactory<tauri::test::MockRuntime> =
1683 Box::new(|| Box::new(BlockingService));
1684 tokio::spawn(manager_loop(
1686 cmd_rx, factory, 15.0, 0.0, 15.0, 15.0, false, false, None,
1687 ));
1688
1689 let app = tauri::test::mock_app();
1690
1691 send_set_mobile(&handle, mock.clone()).await;
1692 send_start(&handle, app.handle().clone()).await.unwrap();
1693
1694 let timeout = *mock.last_timeout_secs.lock().unwrap();
1696 assert_eq!(
1697 timeout,
1698 Some(15.0),
1699 "ios_safety_timeout_secs should be passed to mobile"
1700 );
1701 }
1702
1703 #[tokio::test]
1706 async fn ios_processing_timeout_passed_to_mobile() {
1707 let mock = MockMobile::new();
1708 let (cmd_tx, cmd_rx) = mpsc::channel(16);
1709 let handle = ServiceManagerHandle::new(cmd_tx);
1710 let factory: ServiceFactory<tauri::test::MockRuntime> =
1711 Box::new(|| Box::new(BlockingService));
1712 tokio::spawn(manager_loop(
1714 cmd_rx, factory, 28.0, 60.0, 15.0, 15.0, false, false, None,
1715 ));
1716
1717 let app = tauri::test::mock_app();
1718
1719 send_set_mobile(&handle, mock.clone()).await;
1720 send_start(&handle, app.handle().clone()).await.unwrap();
1721
1722 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1724 assert_eq!(
1725 timeout,
1726 Some(60.0),
1727 "ios_processing_safety_timeout_secs should be passed to mobile"
1728 );
1729 }
1730
1731 #[cfg(mobile)]
1737 struct ContextCapturingService {
1738 captured_label: Arc<std::sync::Mutex<Option<String>>>,
1739 captured_fst: Arc<std::sync::Mutex<Option<String>>>,
1740 }
1741
1742 #[cfg(mobile)]
1743 #[async_trait]
1744 impl BackgroundService<tauri::test::MockRuntime> for ContextCapturingService {
1745 async fn init(
1746 &mut self,
1747 ctx: &ServiceContext<tauri::test::MockRuntime>,
1748 ) -> Result<(), ServiceError> {
1749 *self.captured_label.lock().unwrap() = Some(ctx.service_label.clone());
1750 *self.captured_fst.lock().unwrap() = Some(ctx.foreground_service_type.clone());
1751 Ok(())
1752 }
1753
1754 async fn run(
1755 &mut self,
1756 ctx: &ServiceContext<tauri::test::MockRuntime>,
1757 ) -> Result<(), ServiceError> {
1758 ctx.shutdown.cancelled().await;
1759 Ok(())
1760 }
1761 }
1762
1763 #[cfg(mobile)]
1766 #[tokio::test]
1767 async fn service_context_fields_populated_on_mobile() {
1768 let captured_label: Arc<std::sync::Mutex<Option<String>>> =
1769 Arc::new(std::sync::Mutex::new(None));
1770 let captured_fst: Arc<std::sync::Mutex<Option<String>>> =
1771 Arc::new(std::sync::Mutex::new(None));
1772 let cl = captured_label.clone();
1773 let cf = captured_fst.clone();
1774
1775 let handle = setup_manager_with_factory(Box::new(move || {
1776 let cl = cl.clone();
1777 let cf = cf.clone();
1778 Box::new(ContextCapturingService {
1779 captured_label: cl,
1780 captured_fst: cf,
1781 })
1782 }));
1783 let app = tauri::test::mock_app();
1784
1785 let config = StartConfig {
1786 service_label: "Syncing".into(),
1787 foreground_service_type: "dataSync".into(),
1788 };
1789
1790 send_start_with_config(&handle, config, app.handle().clone())
1791 .await
1792 .unwrap();
1793
1794 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1796
1797 assert_eq!(
1799 captured_label.lock().unwrap().as_deref(),
1800 Some("Syncing"),
1801 "service_label should be 'Syncing' on mobile"
1802 );
1803 assert_eq!(
1804 captured_fst.lock().unwrap().as_deref(),
1805 Some("dataSync"),
1806 "foreground_service_type should be 'dataSync' on mobile"
1807 );
1808
1809 send_stop(&handle).await.unwrap();
1810 }
1811
1812 #[tokio::test]
1815 async fn handle_start_accepts_invalid_foreground_service_type_on_desktop() {
1816 let handle = setup_manager();
1819 let app = tauri::test::mock_app();
1820
1821 let config = StartConfig {
1822 service_label: "test".into(),
1823 foreground_service_type: "bogusType".into(),
1824 };
1825
1826 let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1827 assert!(
1828 result.is_ok(),
1829 "start with invalid fg type should succeed on desktop: {result:?}"
1830 );
1831 assert!(
1832 send_is_running(&handle).await,
1833 "service should be running after start with invalid type on desktop"
1834 );
1835
1836 send_stop(&handle).await.unwrap();
1837 }
1838
1839 #[tokio::test]
1842 async fn handle_start_accepts_all_valid_foreground_service_types() {
1843 for &valid_type in crate::models::VALID_FOREGROUND_SERVICE_TYPES {
1844 let handle = setup_manager();
1845 let app = tauri::test::mock_app();
1846
1847 let config = StartConfig {
1848 service_label: "test".into(),
1849 foreground_service_type: valid_type.into(),
1850 };
1851
1852 let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1853 assert!(
1854 result.is_ok(),
1855 "start with valid type '{valid_type}' should succeed: {result:?}"
1856 );
1857 assert!(send_is_running(&handle).await);
1858 send_stop(&handle).await.unwrap();
1860 }
1861 }
1862
1863 async fn send_get_state(
1866 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1867 ) -> ServiceStatus {
1868 let (tx, rx) = oneshot::channel();
1869 handle
1870 .cmd_tx
1871 .send(ManagerCommand::GetState { reply: tx })
1872 .await
1873 .unwrap();
1874 rx.await.unwrap()
1875 }
1876
1877 #[tokio::test]
1880 async fn get_state_returns_idle_initially() {
1881 let handle = setup_manager();
1882 let status = send_get_state(&handle).await;
1883 assert_eq!(status.state, ServiceLifecycle::Idle);
1884 assert_eq!(status.last_error, None);
1885 }
1886
1887 #[tokio::test]
1890 async fn lifecycle_idle_to_running_to_stopped() {
1891 let handle = setup_manager();
1893 let app = tauri::test::mock_app();
1894
1895 let status = send_get_state(&handle).await;
1897 assert_eq!(status.state, ServiceLifecycle::Idle);
1898
1899 send_start(&handle, app.handle().clone()).await.unwrap();
1901
1902 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1904 let status = send_get_state(&handle).await;
1905 assert_eq!(status.state, ServiceLifecycle::Running);
1906
1907 send_stop(&handle).await.unwrap();
1909 let status = send_get_state(&handle).await;
1910 assert_eq!(status.state, ServiceLifecycle::Stopped);
1911 assert_eq!(status.last_error, None);
1912 }
1913
1914 #[tokio::test]
1917 async fn lifecycle_init_failure_sets_stopped_with_error() {
1918 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1919 let app = tauri::test::mock_app();
1920
1921 send_start(&handle, app.handle().clone()).await.unwrap();
1922
1923 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1925
1926 let status = send_get_state(&handle).await;
1927 assert_eq!(status.state, ServiceLifecycle::Stopped);
1928 assert!(
1929 status.last_error.is_some(),
1930 "last_error should be set on init failure"
1931 );
1932 assert!(
1933 status.last_error.unwrap().contains("init error"),
1934 "error should mention init error"
1935 );
1936 }
1937
1938 #[tokio::test]
1941 async fn lifecycle_explicit_stop_sets_stopped_clears_error() {
1942 let handle = setup_manager();
1943 let app = tauri::test::mock_app();
1944
1945 send_start(&handle, app.handle().clone()).await.unwrap();
1946 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1947
1948 let status = send_get_state(&handle).await;
1949 assert_eq!(status.state, ServiceLifecycle::Running);
1950
1951 send_stop(&handle).await.unwrap();
1952
1953 let status = send_get_state(&handle).await;
1954 assert_eq!(status.state, ServiceLifecycle::Stopped);
1955 assert_eq!(
1956 status.last_error, None,
1957 "explicit stop should clear last_error"
1958 );
1959 }
1960
1961 #[tokio::test]
1964 async fn restart_clears_stale_last_error() {
1965 let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1967 let app = tauri::test::mock_app();
1968
1969 send_start(&handle, app.handle().clone()).await.unwrap();
1970 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1971
1972 let status = send_get_state(&handle).await;
1973 assert_eq!(status.state, ServiceLifecycle::Stopped);
1974 assert!(
1975 status.last_error.is_some(),
1976 "should have error after init failure"
1977 );
1978
1979 let call_count = Arc::new(AtomicUsize::new(0));
1985 let count_clone = call_count.clone();
1986 let handle2 = setup_manager_with_factory(Box::new(move || {
1987 let n = count_clone.fetch_add(1, Ordering::SeqCst);
1988 if n == 0 {
1989 Box::new(FailingInitService)
1990 } else {
1991 Box::new(ImmediateSuccessService)
1992 }
1993 }));
1994 let app2 = tauri::test::mock_app();
1995
1996 send_start(&handle2, app2.handle().clone()).await.unwrap();
1998 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1999
2000 let status = send_get_state(&handle2).await;
2001 assert_eq!(status.state, ServiceLifecycle::Stopped);
2002 assert!(
2003 status.last_error.is_some(),
2004 "first run should set last_error"
2005 );
2006
2007 send_start(&handle2, app2.handle().clone()).await.unwrap();
2009 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2010
2011 let status = send_get_state(&handle2).await;
2012 assert_eq!(
2015 status.last_error, None,
2016 "last_error must be cleared on restart, not stale from previous failure"
2017 );
2018 }
2019
2020 #[tokio::test]
2023 async fn get_state_handle_method_returns_idle() {
2024 let handle = setup_manager();
2025 let status = handle.get_state().await;
2026 assert_eq!(status.state, ServiceLifecycle::Idle);
2027 assert_eq!(status.last_error, None);
2028 }
2029
2030 #[tokio::test]
2033 async fn stop_blocking_returns_success_from_running() {
2034 let handle = Arc::new(setup_manager());
2035 let app = tauri::test::mock_app();
2036
2037 send_start(&handle, app.handle().clone()).await.unwrap();
2038 assert!(send_is_running(&handle).await);
2039
2040 let h = handle.clone();
2042 let result = tokio::task::spawn_blocking(move || h.stop_blocking())
2043 .await
2044 .expect("spawn_blocking panicked");
2045 assert!(
2046 result.is_ok(),
2047 "stop_blocking should succeed from running: {result:?}"
2048 );
2049 assert!(
2050 !send_is_running(&handle).await,
2051 "should not be running after stop_blocking"
2052 );
2053 }
2054
2055 #[tokio::test]
2058 async fn stop_blocking_returns_not_running_when_idle() {
2059 let handle = Arc::new(setup_manager());
2060
2061 let h = handle.clone();
2062 let result = tokio::task::spawn_blocking(move || h.stop_blocking())
2063 .await
2064 .expect("spawn_blocking panicked");
2065 assert!(
2066 matches!(result, Err(ServiceError::NotRunning)),
2067 "stop_blocking should return NotRunning when idle: {result:?}"
2068 );
2069 }
2070
2071 #[tokio::test]
2072 async fn ios_processing_timeout_zero_passes_as_none() {
2073 let mock = MockMobile::new();
2074 let (cmd_tx, cmd_rx) = mpsc::channel(16);
2075 let handle = ServiceManagerHandle::new(cmd_tx);
2076 let factory: ServiceFactory<tauri::test::MockRuntime> =
2077 Box::new(|| Box::new(BlockingService));
2078 tokio::spawn(manager_loop(
2080 cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, None,
2081 ));
2082
2083 let app = tauri::test::mock_app();
2084
2085 send_set_mobile(&handle, mock.clone()).await;
2086 send_start(&handle, app.handle().clone()).await.unwrap();
2087
2088 let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
2090 assert_eq!(
2091 timeout, None,
2092 "ios_processing_safety_timeout_secs of 0.0 should pass None to mobile"
2093 );
2094 }
2095
2096 struct MockDesiredStateBackend {
2100 saves: std::sync::Mutex<Vec<DesiredState>>,
2101 }
2102
2103 impl MockDesiredStateBackend {
2104 fn new() -> Arc<Self> {
2105 Arc::new(Self {
2106 saves: std::sync::Mutex::new(Vec::new()),
2107 })
2108 }
2109
2110 fn last_save(&self) -> Option<DesiredState> {
2111 self.saves.lock().unwrap().last().cloned()
2112 }
2113
2114 #[allow(dead_code)]
2115 fn save_count(&self) -> usize {
2116 self.saves.lock().unwrap().len()
2117 }
2118
2119 #[allow(dead_code)]
2120 fn saves(&self) -> std::sync::MutexGuard<'_, Vec<DesiredState>> {
2121 self.saves.lock().unwrap()
2122 }
2123 }
2124
2125 impl DesiredStateBackend for MockDesiredStateBackend {
2126 fn load(&self) -> Result<DesiredState, String> {
2127 Ok(self
2128 .saves
2129 .lock()
2130 .unwrap()
2131 .last()
2132 .cloned()
2133 .unwrap_or_default())
2134 }
2135
2136 fn save(&self, state: &DesiredState) -> Result<(), String> {
2137 self.saves.lock().unwrap().push(state.clone());
2138 Ok(())
2139 }
2140
2141 fn clear(&self) -> Result<(), String> {
2142 self.saves.lock().unwrap().clear();
2143 Ok(())
2144 }
2145 }
2146
2147 async fn send_set_desired_running(
2150 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2151 desired: bool,
2152 config: Option<StartConfig>,
2153 ) -> Result<(), ServiceError> {
2154 let (tx, rx) = oneshot::channel();
2155 handle
2156 .cmd_tx
2157 .send(ManagerCommand::SetDesiredRunning {
2158 desired,
2159 config,
2160 reply: tx,
2161 })
2162 .await
2163 .unwrap();
2164 rx.await.unwrap()
2165 }
2166
2167 #[tokio::test]
2168 async fn start_saves_desired_running_true() {
2169 let backend = MockDesiredStateBackend::new();
2170 let handle = setup_manager_with_factory_and_backend(
2171 Box::new(|| Box::new(BlockingService)),
2172 Some(backend.clone()),
2173 );
2174 let app = tauri::test::mock_app();
2175
2176 let config = StartConfig {
2177 service_label: "Syncing".into(),
2178 ..Default::default()
2179 };
2180 send_start_with_config(&handle, config, app.handle().clone())
2181 .await
2182 .unwrap();
2183
2184 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2186
2187 let last = backend
2188 .last_save()
2189 .expect("should have saved desired state");
2190 assert!(
2191 last.desired_running,
2192 "desired_running should be true after start"
2193 );
2194 assert!(
2195 last.last_start_config.is_some(),
2196 "last_start_config should be set"
2197 );
2198 assert!(
2199 last.last_start_epoch_ms.is_some(),
2200 "last_start_epoch_ms should be set"
2201 );
2202 }
2203
2204 #[tokio::test]
2205 async fn stop_saves_desired_running_false_with_cleared_recovery() {
2206 let backend = MockDesiredStateBackend::new();
2207 let handle = setup_manager_with_factory_and_backend(
2208 Box::new(|| Box::new(BlockingService)),
2209 Some(backend.clone()),
2210 );
2211 let app = tauri::test::mock_app();
2212
2213 send_start(&handle, app.handle().clone()).await.unwrap();
2214
2215 {
2217 let mut saves = backend.saves.lock().unwrap();
2218 let last = saves.last_mut().unwrap();
2219 last.recovery_pending = true;
2220 last.recovery_reason = Some("boot".into());
2221 last.restart_attempt = 3;
2222 }
2223
2224 send_stop(&handle).await.unwrap();
2225
2226 let last = backend.last_save().expect("should have saved on stop");
2227 assert!(
2228 !last.desired_running,
2229 "desired_running should be false after stop"
2230 );
2231 assert!(
2232 last.last_start_config.is_none(),
2233 "last_start_config should be cleared"
2234 );
2235 assert!(
2236 last.last_start_epoch_ms.is_none(),
2237 "last_start_epoch_ms should be cleared"
2238 );
2239 assert!(!last.recovery_pending, "recovery_pending should be cleared");
2240 assert_eq!(
2241 last.recovery_reason, None,
2242 "recovery_reason should be cleared"
2243 );
2244 assert_eq!(last.restart_attempt, 0, "restart_attempt should be cleared");
2245 }
2246
2247 #[tokio::test]
2248 async fn set_desired_running_saves_without_affecting_is_running() {
2249 let backend = MockDesiredStateBackend::new();
2250 let handle = setup_manager_with_backend(Some(backend.clone()));
2251
2252 assert!(!send_is_running(&handle).await);
2254
2255 let config = StartConfig {
2257 service_label: "AutoRestart".into(),
2258 ..Default::default()
2259 };
2260 send_set_desired_running(&handle, true, Some(config.clone()))
2261 .await
2262 .unwrap();
2263
2264 assert!(
2266 !send_is_running(&handle).await,
2267 "SetDesiredRunning should not affect is_running"
2268 );
2269
2270 let last = backend.last_save().expect("should have saved");
2272 assert!(last.desired_running);
2273 assert!(last.last_start_config.is_some());
2274
2275 send_set_desired_running(&handle, false, None)
2277 .await
2278 .unwrap();
2279
2280 assert!(!send_is_running(&handle).await);
2281
2282 let last = backend.last_save().expect("should have saved");
2283 assert!(!last.desired_running);
2284 }
2285
2286 #[tokio::test]
2287 async fn no_backend_means_no_panic() {
2288 let handle = setup_manager();
2290 let app = tauri::test::mock_app();
2291
2292 send_start(&handle, app.handle().clone()).await.unwrap();
2293 send_stop(&handle).await.unwrap();
2294
2295 send_set_desired_running(&handle, true, None).await.unwrap();
2296 }
2298
2299 #[tokio::test]
2300 async fn start_config_serialized_in_desired_state() {
2301 let backend = MockDesiredStateBackend::new();
2302 let handle = setup_manager_with_factory_and_backend(
2303 Box::new(|| Box::new(BlockingService)),
2304 Some(backend.clone()),
2305 );
2306 let app = tauri::test::mock_app();
2307
2308 let config = StartConfig {
2309 service_label: "CustomLabel".into(),
2310 foreground_service_type: "specialUse".into(),
2311 };
2312 send_start_with_config(&handle, config, app.handle().clone())
2313 .await
2314 .unwrap();
2315
2316 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2317
2318 let last = backend.last_save().expect("should have saved");
2319 let saved_config = last.last_start_config.expect("config should be set");
2320 assert_eq!(saved_config["serviceLabel"], "CustomLabel");
2321 assert_eq!(saved_config["foregroundServiceType"], "specialUse");
2322 }
2323
2324 #[tokio::test]
2327 async fn get_state_returns_desired_running_true_after_start() {
2328 let backend = MockDesiredStateBackend::new();
2329 let handle = setup_manager_with_factory_and_backend(
2330 Box::new(|| Box::new(BlockingService)),
2331 Some(backend.clone()),
2332 );
2333 let app = tauri::test::mock_app();
2334
2335 send_start(&handle, app.handle().clone()).await.unwrap();
2336 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2337
2338 let status = send_get_state(&handle).await;
2339 assert_eq!(
2340 status.desired_running,
2341 Some(true),
2342 "desired_running should be Some(true) after start with backend"
2343 );
2344 }
2345
2346 #[tokio::test]
2347 async fn get_state_returns_desired_running_false_after_stop() {
2348 let backend = MockDesiredStateBackend::new();
2349 let handle = setup_manager_with_factory_and_backend(
2350 Box::new(|| Box::new(BlockingService)),
2351 Some(backend.clone()),
2352 );
2353 let app = tauri::test::mock_app();
2354
2355 send_start(&handle, app.handle().clone()).await.unwrap();
2356 send_stop(&handle).await.unwrap();
2357
2358 let status = send_get_state(&handle).await;
2359 assert_eq!(
2360 status.desired_running,
2361 Some(false),
2362 "desired_running should be Some(false) after stop with backend"
2363 );
2364 }
2365
2366 #[tokio::test]
2367 async fn get_state_returns_none_fields_when_no_backend() {
2368 let handle = setup_manager();
2369 let app = tauri::test::mock_app();
2370
2371 send_start(&handle, app.handle().clone()).await.unwrap();
2372 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2373
2374 let status = send_get_state(&handle).await;
2375 assert_eq!(status.desired_running, None);
2376 assert_eq!(status.native_state, None);
2377 assert_eq!(status.last_start_config, None);
2378 assert_eq!(status.last_heartbeat_at, None);
2379 assert_eq!(status.restart_attempt, None);
2380 assert_eq!(status.recovery_reason, None);
2381 assert_eq!(status.platform_error, None);
2382 }
2383
2384 #[tokio::test]
2385 async fn get_state_returns_last_start_config_from_backend() {
2386 let backend = MockDesiredStateBackend::new();
2387 let handle = setup_manager_with_factory_and_backend(
2388 Box::new(|| Box::new(BlockingService)),
2389 Some(backend.clone()),
2390 );
2391 let app = tauri::test::mock_app();
2392
2393 let config = StartConfig {
2394 service_label: "TestService".into(),
2395 foreground_service_type: "specialUse".into(),
2396 };
2397 send_start_with_config(&handle, config, app.handle().clone())
2398 .await
2399 .unwrap();
2400 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2401
2402 let status = send_get_state(&handle).await;
2403 let cfg = status
2404 .last_start_config
2405 .expect("last_start_config should be populated from backend");
2406 assert_eq!(cfg.service_label, "TestService");
2407 assert_eq!(cfg.foreground_service_type, "specialUse");
2408 }
2409
2410 #[tokio::test]
2411 async fn get_state_populates_all_desired_state_fields() {
2412 let backend = MockDesiredStateBackend::new();
2413 let handle = setup_manager_with_factory_and_backend(
2414 Box::new(|| Box::new(BlockingService)),
2415 Some(backend.clone()),
2416 );
2417 let app = tauri::test::mock_app();
2418
2419 send_start(&handle, app.handle().clone()).await.unwrap();
2420 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2421
2422 {
2424 let mut saves = backend.saves.lock().unwrap();
2425 let last = saves.last_mut().unwrap();
2426 last.last_native_state = Some("timeout".into());
2427 last.last_platform_error = Some("FGS timed out".into());
2428 last.restart_attempt = 3;
2429 last.recovery_reason = Some("boot recovery".into());
2430 last.last_heartbeat_epoch_ms = Some(1700000005000);
2431 }
2432
2433 let status = send_get_state(&handle).await;
2434 assert_eq!(status.desired_running, Some(true));
2435 assert_eq!(status.native_state, Some(NativeState::Timeout));
2436 assert_eq!(status.platform_error, Some("FGS timed out".into()));
2437 assert_eq!(status.restart_attempt, Some(3));
2438 assert_eq!(status.recovery_reason, Some("boot recovery".into()));
2439 assert_eq!(status.last_heartbeat_at, Some(1700000005000));
2440 }
2441
2442 #[tokio::test]
2443 async fn get_state_returns_platform_mode() {
2444 let handle = setup_manager();
2445
2446 let status = send_get_state(&handle).await;
2447 assert_eq!(
2449 status.platform_mode,
2450 Some(LifecycleMode::DesktopInProcess),
2451 "platform_mode should be populated even without backend"
2452 );
2453 }
2454
2455 async fn send_enable_auto_restart(
2458 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2459 config: Option<StartConfig>,
2460 ) -> Result<(), ServiceError> {
2461 let (tx, rx) = oneshot::channel();
2462 handle
2463 .cmd_tx
2464 .send(ManagerCommand::EnableAutoRestart { config, reply: tx })
2465 .await
2466 .unwrap();
2467 rx.await.unwrap()
2468 }
2469
2470 async fn send_disable_auto_restart(
2471 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2472 ) -> Result<(), ServiceError> {
2473 let (tx, rx) = oneshot::channel();
2474 handle
2475 .cmd_tx
2476 .send(ManagerCommand::DisableAutoRestart { reply: tx })
2477 .await
2478 .unwrap();
2479 rx.await.unwrap()
2480 }
2481
2482 async fn send_get_desired_state(
2483 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2484 ) -> Option<DesiredState> {
2485 let (tx, rx) = oneshot::channel();
2486 handle
2487 .cmd_tx
2488 .send(ManagerCommand::GetDesiredState { reply: tx })
2489 .await
2490 .unwrap();
2491 rx.await.unwrap()
2492 }
2493
2494 #[tokio::test]
2495 async fn enable_auto_restart_saves_true_without_starting() {
2496 let backend = MockDesiredStateBackend::new();
2497 let handle = setup_manager_with_backend(Some(backend.clone()));
2498
2499 assert!(!send_is_running(&handle).await);
2500
2501 send_enable_auto_restart(&handle, None).await.unwrap();
2502
2503 assert!(
2505 !send_is_running(&handle).await,
2506 "enableAutoRestart should not start the service"
2507 );
2508
2509 let ds = backend.last_save().expect("should have saved");
2511 assert!(ds.desired_running, "desired_running should be true");
2512 }
2513
2514 #[tokio::test]
2515 async fn disable_auto_restart_saves_false_without_stopping() {
2516 let backend = MockDesiredStateBackend::new();
2517 let handle = setup_manager_with_factory_and_backend(
2518 Box::new(|| Box::new(BlockingService)),
2519 Some(backend.clone()),
2520 );
2521 let app = tauri::test::mock_app();
2522
2523 send_start(&handle, app.handle().clone()).await.unwrap();
2525 assert!(send_is_running(&handle).await);
2526
2527 send_disable_auto_restart(&handle).await.unwrap();
2529
2530 assert!(
2532 send_is_running(&handle).await,
2533 "disableAutoRestart should not stop the service"
2534 );
2535
2536 let ds = backend.last_save().expect("should have saved");
2538 assert!(!ds.desired_running, "desired_running should be false");
2539 }
2540
2541 #[tokio::test]
2542 async fn enable_auto_restart_with_config_stores_config() {
2543 let backend = MockDesiredStateBackend::new();
2544 let handle = setup_manager_with_backend(Some(backend.clone()));
2545
2546 let config = StartConfig {
2547 service_label: "MyService".into(),
2548 foreground_service_type: "specialUse".into(),
2549 };
2550 send_enable_auto_restart(&handle, Some(config.clone()))
2551 .await
2552 .unwrap();
2553
2554 let ds = backend.last_save().expect("should have saved");
2555 assert!(ds.desired_running);
2556 let saved_config = ds.last_start_config.expect("config should be stored");
2557 assert_eq!(saved_config["serviceLabel"], "MyService");
2558 assert_eq!(saved_config["foregroundServiceType"], "specialUse");
2559 assert!(
2560 ds.last_start_epoch_ms.is_some(),
2561 "should set last_start_epoch_ms"
2562 );
2563 }
2564
2565 #[tokio::test]
2566 async fn disable_auto_restart_clears_recovery_fields() {
2567 let backend = MockDesiredStateBackend::new();
2568 let handle = setup_manager_with_backend(Some(backend.clone()));
2569
2570 send_enable_auto_restart(&handle, None).await.unwrap();
2572 {
2573 let mut saves = backend.saves.lock().unwrap();
2574 let last = saves.last_mut().unwrap();
2575 last.recovery_pending = true;
2576 last.recovery_reason = Some("boot".into());
2577 last.restart_attempt = 5;
2578 }
2579
2580 send_disable_auto_restart(&handle).await.unwrap();
2582
2583 let ds = backend.last_save().expect("should have saved");
2584 assert!(!ds.desired_running);
2585 assert!(!ds.recovery_pending, "recovery_pending should be cleared");
2586 assert_eq!(
2587 ds.recovery_reason, None,
2588 "recovery_reason should be cleared"
2589 );
2590 assert_eq!(ds.restart_attempt, 0, "restart_attempt should be cleared");
2591 }
2592
2593 #[tokio::test]
2594 async fn get_desired_state_returns_current_state() {
2595 let backend = MockDesiredStateBackend::new();
2596 let handle = setup_manager_with_backend(Some(backend.clone()));
2597
2598 let ds = send_get_desired_state(&handle).await;
2600 assert!(ds.is_some());
2601 assert!(!ds.unwrap().desired_running);
2602
2603 let config = StartConfig {
2605 service_label: "Test".into(),
2606 ..Default::default()
2607 };
2608 send_enable_auto_restart(&handle, Some(config))
2609 .await
2610 .unwrap();
2611
2612 let ds = send_get_desired_state(&handle)
2613 .await
2614 .expect("should return state");
2615 assert!(ds.desired_running);
2616 assert!(ds.last_start_config.is_some());
2617 }
2618
2619 #[tokio::test]
2620 async fn get_desired_state_returns_none_without_backend() {
2621 let handle = setup_manager();
2622 let ds = send_get_desired_state(&handle).await;
2623 assert!(
2624 ds.is_none(),
2625 "GetDesiredState should return None without a backend"
2626 );
2627 }
2628
2629 #[tokio::test]
2630 async fn enable_disable_no_backend_no_panic() {
2631 let handle = setup_manager();
2632
2633 send_enable_auto_restart(&handle, None).await.unwrap();
2635 send_disable_auto_restart(&handle).await.unwrap();
2636 }
2637
2638 #[tokio::test]
2639 async fn get_state_stop_clears_start_config_and_recovery() {
2640 let backend = MockDesiredStateBackend::new();
2641 let handle = setup_manager_with_factory_and_backend(
2642 Box::new(|| Box::new(BlockingService)),
2643 Some(backend.clone()),
2644 );
2645 let app = tauri::test::mock_app();
2646
2647 let config = StartConfig {
2648 service_label: "Syncing".into(),
2649 ..Default::default()
2650 };
2651 send_start_with_config(&handle, config, app.handle().clone())
2652 .await
2653 .unwrap();
2654 send_stop(&handle).await.unwrap();
2655
2656 let status = send_get_state(&handle).await;
2657 assert_eq!(status.desired_running, Some(false));
2658 assert_eq!(
2659 status.last_start_config, None,
2660 "last_start_config should be None after stop"
2661 );
2662 assert_eq!(
2663 status.restart_attempt, None,
2664 "restart_attempt should be None after stop"
2665 );
2666 assert_eq!(
2667 status.recovery_reason, None,
2668 "recovery_reason should be None after stop"
2669 );
2670 }
2671
2672 use crate::desired_state::FileDesiredStateBackend;
2675 use std::path::PathBuf;
2676
2677 fn temp_state_dir() -> PathBuf {
2678 tempfile::tempdir().unwrap().keep()
2679 }
2680
2681 fn file_backend(dir: PathBuf) -> Arc<dyn DesiredStateBackend> {
2682 Arc::new(FileDesiredStateBackend::new(dir))
2683 }
2684
2685 #[tokio::test]
2686 async fn enable_auto_restart_persists_desired_running_true_to_file() {
2687 let dir = temp_state_dir();
2688 let backend = file_backend(dir.clone());
2689 let handle = setup_manager_with_backend(Some(backend));
2690
2691 send_enable_auto_restart(&handle, None).await.unwrap();
2692
2693 let file_backend = FileDesiredStateBackend::new(dir);
2695 let state = file_backend.load().unwrap();
2696 assert!(
2697 state.desired_running,
2698 "file should contain desired_running=true after enable_auto_restart"
2699 );
2700 }
2701
2702 #[tokio::test]
2703 async fn simulated_process_restart_loads_persisted_state() {
2704 let dir = temp_state_dir();
2705 let backend = file_backend(dir.clone());
2706 let config = StartConfig {
2707 service_label: "PersistentSvc".into(),
2708 foreground_service_type: "dataSync".into(),
2709 };
2710
2711 let handle1 = setup_manager_with_backend(Some(backend));
2713 send_enable_auto_restart(&handle1, Some(config.clone()))
2714 .await
2715 .unwrap();
2716
2717 drop(handle1);
2719 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2720
2721 let backend2 = file_backend(dir.clone());
2723 let handle2 = setup_manager_with_backend(Some(backend2));
2724
2725 let ds = send_get_desired_state(&handle2)
2727 .await
2728 .expect("should return persisted state");
2729 assert!(
2730 ds.desired_running,
2731 "persisted desired_running should be true after simulated restart"
2732 );
2733 let saved_config = ds
2734 .last_start_config
2735 .expect("config should be persisted across restart");
2736 assert_eq!(saved_config["serviceLabel"], "PersistentSvc");
2737 }
2738
2739 #[tokio::test]
2740 async fn disable_auto_restart_clears_file_backed_state() {
2741 let dir = temp_state_dir();
2742 let backend = file_backend(dir.clone());
2743 let handle = setup_manager_with_backend(Some(backend));
2744
2745 send_enable_auto_restart(&handle, None).await.unwrap();
2747 let ds = send_get_desired_state(&handle)
2748 .await
2749 .expect("should return state");
2750 assert!(ds.desired_running, "should be true after enable");
2751
2752 send_disable_auto_restart(&handle).await.unwrap();
2754
2755 let file_backend = FileDesiredStateBackend::new(dir);
2757 let state = file_backend.load().unwrap();
2758 assert!(
2759 !state.desired_running,
2760 "file should contain desired_running=false after disable"
2761 );
2762 assert!(
2763 state.last_start_config.is_none(),
2764 "config should be cleared"
2765 );
2766 assert!(
2767 state.last_start_epoch_ms.is_none(),
2768 "epoch should be cleared"
2769 );
2770 assert!(!state.recovery_pending, "recovery should be cleared");
2771 assert_eq!(state.restart_attempt, 0, "restart_attempt should be 0");
2772 }
2773
2774 #[tokio::test]
2775 async fn file_backend_get_desired_state_returns_none_without_backend() {
2776 let handle = setup_manager();
2777
2778 let ds = send_get_desired_state(&handle).await;
2779 assert!(
2780 ds.is_none(),
2781 "get_desired_state should return None without backend (existing behavior preserved)"
2782 );
2783 }
2784
2785 async fn send_stop_with_reason(
2788 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2789 reason: StopReason,
2790 ) -> Result<(), ServiceError> {
2791 let (tx, rx) = oneshot::channel();
2792 handle
2793 .cmd_tx
2794 .send(ManagerCommand::StopWithReason { reason, reply: tx })
2795 .await
2796 .unwrap();
2797 rx.await.unwrap()
2798 }
2799
2800 #[tokio::test]
2801 async fn stop_with_reason_user_stop_clears_desired_state() {
2802 let backend = MockDesiredStateBackend::new();
2803 let handle = setup_manager_with_factory_and_backend(
2804 Box::new(|| Box::new(BlockingService)),
2805 Some(backend.clone()),
2806 );
2807 let app = tauri::test::mock_app();
2808
2809 send_start(&handle, app.handle().clone()).await.unwrap();
2810 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2811
2812 let saves_before = backend.saves.lock().unwrap().len();
2813
2814 send_stop_with_reason(&handle, StopReason::UserStop)
2815 .await
2816 .unwrap();
2817
2818 let saves = backend.saves.lock().unwrap();
2820 assert_eq!(
2821 saves.len(),
2822 saves_before + 1,
2823 "UserStop should save a new desired state"
2824 );
2825 let last = saves.last().unwrap();
2826 assert!(
2827 !last.desired_running,
2828 "UserStop should clear desired_running"
2829 );
2830 assert!(last.last_start_config.is_none(), "config should be cleared");
2831 }
2832
2833 #[tokio::test]
2834 async fn stop_with_reason_app_stop_clears_desired_state() {
2835 let backend = MockDesiredStateBackend::new();
2836 let handle = setup_manager_with_factory_and_backend(
2837 Box::new(|| Box::new(BlockingService)),
2838 Some(backend.clone()),
2839 );
2840 let app = tauri::test::mock_app();
2841
2842 send_start(&handle, app.handle().clone()).await.unwrap();
2843 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2844
2845 let saves_before = backend.saves.lock().unwrap().len();
2846
2847 send_stop_with_reason(&handle, StopReason::AppStop)
2848 .await
2849 .unwrap();
2850
2851 let saves = backend.saves.lock().unwrap();
2852 assert_eq!(saves.len(), saves_before + 1);
2853 assert!(
2854 !saves.last().unwrap().desired_running,
2855 "AppStop should clear desired_running"
2856 );
2857 }
2858
2859 #[tokio::test]
2860 async fn stop_with_reason_native_notification_stop_clears_desired_state() {
2861 let backend = MockDesiredStateBackend::new();
2862 let handle = setup_manager_with_factory_and_backend(
2863 Box::new(|| Box::new(BlockingService)),
2864 Some(backend.clone()),
2865 );
2866 let app = tauri::test::mock_app();
2867
2868 send_start(&handle, app.handle().clone()).await.unwrap();
2869 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2870
2871 let saves_before = backend.saves.lock().unwrap().len();
2872
2873 send_stop_with_reason(&handle, StopReason::NativeNotificationStop)
2874 .await
2875 .unwrap();
2876
2877 let saves = backend.saves.lock().unwrap();
2878 assert_eq!(saves.len(), saves_before + 1);
2879 assert!(
2880 !saves.last().unwrap().desired_running,
2881 "NativeNotificationStop should clear desired_running"
2882 );
2883 }
2884
2885 #[tokio::test]
2886 async fn stop_with_reason_task_completed_clears_desired_state() {
2887 let backend = MockDesiredStateBackend::new();
2888 let handle = setup_manager_with_factory_and_backend(
2889 Box::new(|| Box::new(BlockingService)),
2890 Some(backend.clone()),
2891 );
2892 let app = tauri::test::mock_app();
2893
2894 send_start(&handle, app.handle().clone()).await.unwrap();
2895 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2896
2897 let saves_before = backend.saves.lock().unwrap().len();
2898
2899 send_stop_with_reason(&handle, StopReason::TaskCompleted)
2900 .await
2901 .unwrap();
2902
2903 let saves = backend.saves.lock().unwrap();
2904 assert_eq!(saves.len(), saves_before + 1);
2905 assert!(
2906 !saves.last().unwrap().desired_running,
2907 "TaskCompleted should clear desired_running"
2908 );
2909 }
2910
2911 #[tokio::test]
2912 async fn stop_with_reason_platform_expiration_preserves_desired_state() {
2913 let backend = MockDesiredStateBackend::new();
2914 let handle = setup_manager_with_factory_and_backend(
2915 Box::new(|| Box::new(BlockingService)),
2916 Some(backend.clone()),
2917 );
2918 let app = tauri::test::mock_app();
2919
2920 send_start(&handle, app.handle().clone()).await.unwrap();
2921 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2922
2923 let saves_before = backend.saves.lock().unwrap().len();
2924
2925 send_stop_with_reason(&handle, StopReason::PlatformExpiration)
2926 .await
2927 .unwrap();
2928
2929 let saves = backend.saves.lock().unwrap();
2930 assert_eq!(
2931 saves.len(),
2932 saves_before,
2933 "PlatformExpiration should not save new desired state"
2934 );
2935 assert!(
2936 saves.last().unwrap().desired_running,
2937 "desired_running should remain true"
2938 );
2939 }
2940
2941 #[tokio::test]
2942 async fn stop_with_reason_platform_timeout_preserves_desired_state() {
2943 let backend = MockDesiredStateBackend::new();
2944 let handle = setup_manager_with_factory_and_backend(
2945 Box::new(|| Box::new(BlockingService)),
2946 Some(backend.clone()),
2947 );
2948 let app = tauri::test::mock_app();
2949
2950 send_start(&handle, app.handle().clone()).await.unwrap();
2951 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2952
2953 let saves_before = backend.saves.lock().unwrap().len();
2954
2955 send_stop_with_reason(&handle, StopReason::PlatformTimeout)
2956 .await
2957 .unwrap();
2958
2959 let saves = backend.saves.lock().unwrap();
2960 assert_eq!(
2961 saves.len(),
2962 saves_before,
2963 "PlatformTimeout should not save new desired state"
2964 );
2965 assert!(
2966 saves.last().unwrap().desired_running,
2967 "desired_running should remain true"
2968 );
2969 }
2970
2971 #[tokio::test]
2972 async fn stop_with_reason_error_preserves_desired_state() {
2973 let backend = MockDesiredStateBackend::new();
2974 let handle = setup_manager_with_factory_and_backend(
2975 Box::new(|| Box::new(BlockingService)),
2976 Some(backend.clone()),
2977 );
2978 let app = tauri::test::mock_app();
2979
2980 send_start(&handle, app.handle().clone()).await.unwrap();
2981 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2982
2983 let saves_before = backend.saves.lock().unwrap().len();
2984
2985 send_stop_with_reason(&handle, StopReason::Error)
2986 .await
2987 .unwrap();
2988
2989 let saves = backend.saves.lock().unwrap();
2990 assert_eq!(
2991 saves.len(),
2992 saves_before,
2993 "Error should not save new desired state"
2994 );
2995 assert!(
2996 saves.last().unwrap().desired_running,
2997 "desired_running should remain true"
2998 );
2999 }
3000
3001 #[tokio::test]
3002 async fn stop_with_reason_not_running_returns_not_running() {
3003 let handle = setup_manager();
3004
3005 let result = send_stop_with_reason(&handle, StopReason::UserStop).await;
3006 assert!(
3007 matches!(result, Err(ServiceError::NotRunning)),
3008 "StopWithReason should return NotRunning when idle"
3009 );
3010 }
3011
3012 #[tokio::test]
3013 async fn stop_with_reason_cancels_service() {
3014 let handle = setup_manager();
3015 let app = tauri::test::mock_app();
3016
3017 send_start(&handle, app.handle().clone()).await.unwrap();
3018 assert!(send_is_running(&handle).await);
3019
3020 send_stop_with_reason(&handle, StopReason::UserStop)
3021 .await
3022 .unwrap();
3023
3024 assert!(
3025 !send_is_running(&handle).await,
3026 "service should be stopped after StopWithReason"
3027 );
3028 }
3029
3030 #[tokio::test]
3031 async fn stop_with_reason_stops_mobile_keepalive() {
3032 let mock = MockMobile::new();
3033 let handle = setup_manager();
3034 let app = tauri::test::mock_app();
3035
3036 send_set_mobile(&handle, mock.clone()).await;
3037 send_start(&handle, app.handle().clone()).await.unwrap();
3038
3039 assert_eq!(mock.stop_called.load(Ordering::Acquire), 0);
3040
3041 send_stop_with_reason(&handle, StopReason::UserStop)
3042 .await
3043 .unwrap();
3044
3045 assert_eq!(
3046 mock.stop_called.load(Ordering::Acquire),
3047 1,
3048 "stop_keepalive should be called once after StopWithReason"
3049 );
3050 }
3051
3052 #[tokio::test]
3055 async fn stop_delegates_to_stop_with_reason_user_stop_clears_desired() {
3056 let backend = MockDesiredStateBackend::new();
3057 let handle = setup_manager_with_factory_and_backend(
3058 Box::new(|| Box::new(BlockingService)),
3059 Some(backend.clone()),
3060 );
3061 let app = tauri::test::mock_app();
3062
3063 send_start(&handle, app.handle().clone()).await.unwrap();
3064 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3065
3066 let saves_before = backend.saves.lock().unwrap().len();
3067
3068 send_stop(&handle).await.unwrap();
3070
3071 let saves = backend.saves.lock().unwrap();
3072 assert_eq!(
3073 saves.len(),
3074 saves_before + 1,
3075 "Stop should save desired state (delegates to StopWithReason(UserStop))"
3076 );
3077 assert!(
3078 !saves.last().unwrap().desired_running,
3079 "Stop should clear desired_running"
3080 );
3081 }
3082
3083 #[tokio::test]
3086 async fn stop_with_reason_handle_method_stops_service() {
3087 let handle = setup_manager();
3088 let app = tauri::test::mock_app();
3089
3090 send_start(&handle, app.handle().clone()).await.unwrap();
3091 assert!(send_is_running(&handle).await);
3092
3093 handle.stop_with_reason(StopReason::UserStop).await.unwrap();
3094
3095 assert!(
3096 !send_is_running(&handle).await,
3097 "service should be stopped after stop_with_reason"
3098 );
3099 }
3100
3101 #[tokio::test]
3102 async fn stop_with_reason_handle_method_preserves_desired_for_platform_timeout() {
3103 let backend = MockDesiredStateBackend::new();
3104 let handle = setup_manager_with_factory_and_backend(
3105 Box::new(|| Box::new(BlockingService)),
3106 Some(backend.clone()),
3107 );
3108 let app = tauri::test::mock_app();
3109
3110 send_start(&handle, app.handle().clone()).await.unwrap();
3111 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3112
3113 let saves_before = backend.saves.lock().unwrap().len();
3114
3115 handle
3116 .stop_with_reason(StopReason::PlatformTimeout)
3117 .await
3118 .unwrap();
3119
3120 let saves = backend.saves.lock().unwrap();
3121 assert_eq!(
3122 saves.len(),
3123 saves_before,
3124 "PlatformTimeout should not save new desired state"
3125 );
3126 assert!(
3127 saves.last().unwrap().desired_running,
3128 "desired_running should remain true"
3129 );
3130 }
3131
3132 #[tokio::test]
3133 async fn stop_with_reason_handle_method_returns_not_running_when_idle() {
3134 let handle = setup_manager();
3135
3136 let result = handle.stop_with_reason(StopReason::UserStop).await;
3137 assert!(
3138 matches!(result, Err(ServiceError::NotRunning)),
3139 "stop_with_reason should return NotRunning when idle"
3140 );
3141 }
3142
3143 #[tokio::test]
3146 async fn stop_blocking_with_reason_stops_service() {
3147 let handle = Arc::new(setup_manager());
3148 let app = tauri::test::mock_app();
3149
3150 send_start(&handle, app.handle().clone()).await.unwrap();
3151 assert!(send_is_running(&handle).await);
3152
3153 let h = handle.clone();
3154 let result =
3155 tokio::task::spawn_blocking(move || h.stop_blocking_with_reason(StopReason::AppStop))
3156 .await
3157 .expect("spawn_blocking panicked");
3158
3159 assert!(
3160 result.is_ok(),
3161 "stop_blocking_with_reason should succeed: {result:?}"
3162 );
3163 assert!(
3164 !send_is_running(&handle).await,
3165 "service should be stopped after stop_blocking_with_reason"
3166 );
3167 }
3168
3169 #[tokio::test]
3170 async fn stop_blocking_with_reason_returns_not_running_when_idle() {
3171 let handle = Arc::new(setup_manager());
3172
3173 let h = handle.clone();
3174 let result =
3175 tokio::task::spawn_blocking(move || h.stop_blocking_with_reason(StopReason::UserStop))
3176 .await
3177 .expect("spawn_blocking panicked");
3178
3179 assert!(
3180 matches!(result, Err(ServiceError::NotRunning)),
3181 "stop_blocking_with_reason should return NotRunning when idle: {result:?}"
3182 );
3183 }
3184
3185 #[tokio::test]
3188 async fn stop_with_reason_idempotent_second_returns_not_running() {
3189 let backend = MockDesiredStateBackend::new();
3190 let handle = setup_manager_with_factory_and_backend(
3191 Box::new(|| Box::new(BlockingService)),
3192 Some(backend.clone()),
3193 );
3194 let app = tauri::test::mock_app();
3195
3196 send_start(&handle, app.handle().clone()).await.unwrap();
3197 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3198
3199 send_stop_with_reason(&handle, StopReason::UserStop)
3201 .await
3202 .unwrap();
3203
3204 let saves_after_first = backend.saves.lock().unwrap().len();
3205
3206 let result = send_stop_with_reason(&handle, StopReason::UserStop).await;
3208 assert!(
3209 matches!(result, Err(ServiceError::NotRunning)),
3210 "second StopWithReason should return NotRunning: {result:?}"
3211 );
3212
3213 let saves_after_second = backend.saves.lock().unwrap().len();
3214 assert_eq!(
3215 saves_after_first, saves_after_second,
3216 "second StopWithReason should not produce additional desired-state saves"
3217 );
3218 }
3219
3220 #[tokio::test]
3221 async fn stop_with_reason_platform_expiration_skips_stop_keepalive() {
3222 let mock = MockMobile::new();
3223 let backend = MockDesiredStateBackend::new();
3224 let handle = setup_manager_with_factory_and_backend(
3225 Box::new(|| Box::new(BlockingService)),
3226 Some(backend.clone()),
3227 );
3228 let app = tauri::test::mock_app();
3229
3230 send_set_mobile(&handle, mock.clone()).await;
3231 send_start(&handle, app.handle().clone()).await.unwrap();
3232 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3233
3234 assert_eq!(
3235 mock.stop_called.load(Ordering::Acquire),
3236 0,
3237 "stop_keepalive should not be called yet"
3238 );
3239
3240 let saves_before = backend.saves.lock().unwrap().len();
3241
3242 send_stop_with_reason(&handle, StopReason::PlatformExpiration)
3243 .await
3244 .unwrap();
3245
3246 assert!(!send_is_running(&handle).await, "service should be stopped");
3247 assert_eq!(
3248 mock.stop_called.load(Ordering::Acquire),
3249 0,
3250 "PlatformExpiration should NOT call stop_keepalive"
3251 );
3252
3253 let saves = backend.saves.lock().unwrap();
3255 assert_eq!(
3256 saves.len(),
3257 saves_before,
3258 "PlatformExpiration should not save new desired state"
3259 );
3260 assert!(
3261 saves.last().unwrap().desired_running,
3262 "desired_running should remain true"
3263 );
3264 }
3265
3266 #[tokio::test]
3274 async fn cancel_listener_platform_timeout_preserves_desired_and_stops_keepalive() {
3275 let mock = MockMobile::new();
3276 let backend = MockDesiredStateBackend::new();
3277 let handle = setup_manager_with_factory_and_backend(
3278 Box::new(|| Box::new(BlockingService)),
3279 Some(backend.clone()),
3280 );
3281 let app = tauri::test::mock_app();
3282
3283 send_set_mobile(&handle, mock.clone()).await;
3284 send_start(&handle, app.handle().clone()).await.unwrap();
3285 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3286
3287 let saves_before = backend.saves.lock().unwrap().len();
3288
3289 send_stop_with_reason(&handle, StopReason::PlatformTimeout)
3291 .await
3292 .unwrap();
3293
3294 assert!(!send_is_running(&handle).await, "service should be stopped");
3295
3296 assert_eq!(
3298 mock.stop_called.load(Ordering::Acquire),
3299 1,
3300 "PlatformTimeout should call stop_keepalive"
3301 );
3302
3303 let saves = backend.saves.lock().unwrap();
3305 assert_eq!(
3306 saves.len(),
3307 saves_before,
3308 "PlatformTimeout should not save new desired state"
3309 );
3310 assert!(
3311 saves.last().unwrap().desired_running,
3312 "desired_running should remain true"
3313 );
3314 }
3315
3316 #[tokio::test]
3317 async fn cancel_listener_user_stop_clears_desired_and_stops_keepalive() {
3318 let mock = MockMobile::new();
3319 let backend = MockDesiredStateBackend::new();
3320 let handle = setup_manager_with_factory_and_backend(
3321 Box::new(|| Box::new(BlockingService)),
3322 Some(backend.clone()),
3323 );
3324 let app = tauri::test::mock_app();
3325
3326 send_set_mobile(&handle, mock.clone()).await;
3327 send_start(&handle, app.handle().clone()).await.unwrap();
3328 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3329
3330 send_stop(&handle).await.unwrap();
3332
3333 assert!(!send_is_running(&handle).await, "service should be stopped");
3334
3335 assert_eq!(
3337 mock.stop_called.load(Ordering::Acquire),
3338 1,
3339 "UserStop should call stop_keepalive"
3340 );
3341
3342 let last = backend
3344 .last_save()
3345 .expect("should have saved desired state");
3346 assert!(
3347 !last.desired_running,
3348 "UserStop should clear desired_running to false"
3349 );
3350 }
3351
3352 async fn send_native_event(
3355 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
3356 event: NativeLifecycleEvent,
3357 ) -> Result<(), ServiceError> {
3358 let (tx, rx) = oneshot::channel();
3359 handle
3360 .cmd_tx
3361 .send(ManagerCommand::NativeLifecycleEvent { event, reply: tx })
3362 .await
3363 .unwrap();
3364 rx.await.unwrap()
3365 }
3366
3367 #[tokio::test]
3368 async fn native_lifecycle_notification_stop_clears_desired_state() {
3369 let mock = MockMobile::new();
3370 let backend = MockDesiredStateBackend::new();
3371 let handle = setup_manager_with_factory_and_backend(
3372 Box::new(|| Box::new(BlockingService)),
3373 Some(backend.clone()),
3374 );
3375 let app = tauri::test::mock_app();
3376
3377 send_set_mobile(&handle, mock.clone()).await;
3378 send_start(&handle, app.handle().clone()).await.unwrap();
3379 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3380
3381 let saves_before = backend.saves.lock().unwrap().len();
3382
3383 send_native_event(&handle, NativeLifecycleEvent::AndroidNotificationStop)
3384 .await
3385 .unwrap();
3386
3387 assert!(!send_is_running(&handle).await, "service should be stopped");
3388
3389 let saves = backend.saves.lock().unwrap();
3391 assert_eq!(saves.len(), saves_before + 1);
3392 assert!(
3393 !saves.last().unwrap().desired_running,
3394 "AndroidNotificationStop should clear desired_running"
3395 );
3396
3397 assert_eq!(
3399 mock.stop_called.load(Ordering::Acquire),
3400 1,
3401 "AndroidNotificationStop should call stop_keepalive"
3402 );
3403 }
3404
3405 #[tokio::test]
3406 async fn native_lifecycle_timeout_preserves_desired_state() {
3407 let mock = MockMobile::new();
3408 let backend = MockDesiredStateBackend::new();
3409 let handle = setup_manager_with_factory_and_backend(
3410 Box::new(|| Box::new(BlockingService)),
3411 Some(backend.clone()),
3412 );
3413 let app = tauri::test::mock_app();
3414
3415 send_set_mobile(&handle, mock.clone()).await;
3416 send_start(&handle, app.handle().clone()).await.unwrap();
3417 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3418
3419 let saves_before = backend.saves.lock().unwrap().len();
3420
3421 send_native_event(
3422 &handle,
3423 NativeLifecycleEvent::AndroidTimeout {
3424 fgs_type: Some("dataSync".into()),
3425 },
3426 )
3427 .await
3428 .unwrap();
3429
3430 assert!(!send_is_running(&handle).await, "service should be stopped");
3431
3432 let saves = backend.saves.lock().unwrap();
3434 assert_eq!(
3435 saves.len(),
3436 saves_before,
3437 "AndroidTimeout should not save new desired state"
3438 );
3439 assert!(
3440 saves.last().unwrap().desired_running,
3441 "desired_running should remain true"
3442 );
3443
3444 assert_eq!(
3446 mock.stop_called.load(Ordering::Acquire),
3447 1,
3448 "AndroidTimeout should call stop_keepalive"
3449 );
3450 }
3451
3452 #[tokio::test]
3453 async fn native_lifecycle_event_idempotent_when_already_stopped() {
3454 let backend = MockDesiredStateBackend::new();
3455 let handle = setup_manager_with_factory_and_backend(
3456 Box::new(|| Box::new(BlockingService)),
3457 Some(backend.clone()),
3458 );
3459 let app = tauri::test::mock_app();
3460
3461 send_start(&handle, app.handle().clone()).await.unwrap();
3462 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3463
3464 send_stop(&handle).await.unwrap();
3466 assert!(!send_is_running(&handle).await);
3467
3468 let saves_before = backend.saves.lock().unwrap().len();
3469
3470 let result =
3472 send_native_event(&handle, NativeLifecycleEvent::AndroidNotificationStop).await;
3473 assert!(
3474 matches!(result, Err(ServiceError::NotRunning)),
3475 "native event while stopped should return NotRunning: {result:?}"
3476 );
3477
3478 {
3480 let saves = backend.saves.lock().unwrap();
3481 assert_eq!(
3482 saves.len(),
3483 saves_before,
3484 "no additional saves when already stopped"
3485 );
3486 }
3487
3488 let result = send_native_event(
3490 &handle,
3491 NativeLifecycleEvent::AndroidTimeout { fgs_type: None },
3492 )
3493 .await;
3494 assert!(
3495 matches!(result, Err(ServiceError::NotRunning)),
3496 "timeout while stopped should return NotRunning: {result:?}"
3497 );
3498 }
3499
3500 async fn send_get_lifecycle_status(
3504 handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
3505 ) -> LifecycleStatus {
3506 let (reply, rx) = oneshot::channel();
3507 handle
3508 .cmd_tx
3509 .send(ManagerCommand::GetLifecycleStatus {
3510 desktop_mode: None,
3511 reply,
3512 })
3513 .await
3514 .expect("send GetLifecycleStatus");
3515 rx.await.expect("receive LifecycleStatus")
3516 }
3517
3518 #[tokio::test]
3519 async fn get_lifecycle_status_returns_idle_initially() {
3520 let handle = setup_manager();
3521 let status = send_get_lifecycle_status(&handle).await;
3522 assert!(
3523 matches!(status.state, LifecycleState::Idle),
3524 "expected Idle, got {:?}",
3525 status.state
3526 );
3527 assert!(!status.desired_running);
3528 assert!(!status.recovery_enabled);
3529 assert!(!status.recovery_pending);
3530 assert!(status.last_error.is_none());
3531 assert!(status.last_start_config.is_none());
3532 }
3533
3534 #[tokio::test]
3535 async fn get_lifecycle_status_returns_running_after_start() {
3536 let handle =
3537 setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
3538 let app = tauri::test::mock_app();
3539 send_start(&handle, app.handle().clone()).await.unwrap();
3540 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3541
3542 let status = send_get_lifecycle_status(&handle).await;
3543 assert!(
3544 matches!(status.state, LifecycleState::Running),
3545 "expected Running, got {:?}",
3546 status.state
3547 );
3548 }
3549
3550 #[tokio::test]
3551 async fn get_lifecycle_status_reflects_desired_state() {
3552 let backend = MockDesiredStateBackend::new();
3553 let handle = setup_manager_with_factory_and_backend(
3554 Box::new(|| Box::new(BlockingService)),
3555 Some(backend.clone()),
3556 );
3557
3558 send_enable_auto_restart(&handle, None).await.unwrap();
3560
3561 let status = send_get_lifecycle_status(&handle).await;
3562 assert!(
3563 status.desired_running,
3564 "expected desired_running=true after enable_auto_restart"
3565 );
3566 assert!(
3567 status.recovery_enabled,
3568 "expected recovery_enabled=true when desired_running=true"
3569 );
3570 }
3571
3572 #[tokio::test]
3573 async fn get_lifecycle_status_clears_after_disable_recovery() {
3574 let backend = MockDesiredStateBackend::new();
3575 let handle = setup_manager_with_factory_and_backend(
3576 Box::new(|| Box::new(BlockingService)),
3577 Some(backend.clone()),
3578 );
3579
3580 send_enable_auto_restart(&handle, None).await.unwrap();
3582 send_disable_auto_restart(&handle).await.unwrap();
3583
3584 let status = send_get_lifecycle_status(&handle).await;
3585 assert!(
3586 !status.desired_running,
3587 "expected desired_running=false after disable"
3588 );
3589 assert!(
3590 !status.recovery_enabled,
3591 "expected recovery_enabled=false after disable"
3592 );
3593 }
3594
3595 #[tokio::test]
3596 async fn get_lifecycle_status_includes_platform_and_capabilities() {
3597 let handle = setup_manager();
3598 let status = send_get_lifecycle_status(&handle).await;
3599
3600 #[cfg(target_os = "linux")]
3602 assert!(
3603 matches!(status.platform, crate::models::Platform::Linux),
3604 "expected Linux platform, got {:?}",
3605 status.platform
3606 );
3607 assert!(
3609 !status.capabilities.limitations.is_empty()
3610 || !status.capabilities.required_setup.is_empty(),
3611 "capabilities should have some content"
3612 );
3613 }
3614
3615 #[tokio::test]
3616 async fn get_lifecycle_status_returns_stopped_after_stop() {
3617 let handle =
3618 setup_manager_with_factory_and_backend(Box::new(|| Box::new(BlockingService)), None);
3619 let app = tauri::test::mock_app();
3620 send_start(&handle, app.handle().clone()).await.unwrap();
3621 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3622
3623 send_stop(&handle).await.unwrap();
3624
3625 let status = send_get_lifecycle_status(&handle).await;
3626 assert!(
3627 matches!(status.state, LifecycleState::Stopped),
3628 "expected Stopped, got {:?}",
3629 status.state
3630 );
3631 }
3632}