Skip to main content

tauri_plugin_background_service/
manager.rs

1//! Actor-based service manager.
2//!
3//! The [`manager_loop`] function runs as a single-owner Tokio task that receives
4//! [`ManagerCommand`] messages through an `mpsc` channel. This serialises all
5//! state mutations (start, stop, is_running) and prevents concurrent interleaving.
6//!
7//! Most of this module is `pub(crate)` — the public API surface is re-exported
8//! from the crate root. Items that are `pub` only for the iOS lifecycle bridge
9//! are marked `#[doc(hidden)]`.
10
11use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::{Arc, Mutex};
13
14use tauri::{AppHandle, Emitter, Runtime};
15use tokio::sync::{mpsc, oneshot};
16use tokio_util::sync::CancellationToken;
17
18use crate::desired_state::DesiredStateBackend;
19use crate::error::ServiceError;
20use crate::models::{
21    validate_foreground_service_type, LifecycleMode, PluginEvent, ServiceContext,
22    ServiceState as ServiceLifecycle, ServiceStatus, StartConfig,
23};
24use crate::notifier::Notifier;
25use crate::service_trait::BackgroundService;
26
27/// Callback fired when the service task completes. Receives `true` on success.
28#[doc(hidden)]
29pub type OnCompleteCallback = Box<dyn Fn(bool) + Send + Sync>;
30
31/// Abstraction over mobile keepalive operations.
32///
33/// Defined here (not behind `#[cfg(mobile)]`) so the actor can reference it
34/// on all platforms. On desktop, `ServiceState.mobile` is `None` and these
35/// methods are never called. On mobile, `MobileLifecycle` implements this trait.
36pub(crate) trait MobileKeepalive: Send + Sync {
37    /// Start the OS-specific keepalive (Android foreground service / iOS BGTask).
38    #[allow(clippy::too_many_arguments)]
39    fn start_keepalive(
40        &self,
41        label: &str,
42        foreground_service_type: &str,
43        ios_safety_timeout_secs: Option<f64>,
44        ios_processing_safety_timeout_secs: Option<f64>,
45        ios_earliest_refresh_begin_minutes: Option<f64>,
46        ios_earliest_processing_begin_minutes: Option<f64>,
47        ios_requires_external_power: Option<bool>,
48        ios_requires_network_connectivity: Option<bool>,
49    ) -> Result<(), ServiceError>;
50    /// Stop the OS-specific keepalive.
51    fn stop_keepalive(&self) -> Result<(), ServiceError>;
52}
53
54/// Type-erased factory: produces a fresh `Box<dyn BackgroundService<R>>` on demand.
55#[doc(hidden)]
56pub type ServiceFactory<R> = Box<dyn Fn() -> Box<dyn BackgroundService<R>> + Send + Sync>;
57
58// ─── Commands ───────────────────────────────────────────────────────────
59
60/// Commands sent to the service manager actor.
61///
62/// Internal implementation detail — not part of the public API.
63///
64/// This enum is `#[non_exhaustive]` to prevent external construction.
65/// Use [`ServiceManagerHandle`] methods instead.
66#[non_exhaustive]
67pub enum ManagerCommand<R: Runtime> {
68    Start {
69        config: StartConfig,
70        reply: oneshot::Sender<Result<(), ServiceError>>,
71        app: AppHandle<R>,
72    },
73    Stop {
74        reply: oneshot::Sender<Result<(), ServiceError>>,
75    },
76    IsRunning {
77        reply: oneshot::Sender<bool>,
78    },
79    GetState {
80        reply: oneshot::Sender<ServiceStatus>,
81    },
82    SetOnComplete {
83        callback: OnCompleteCallback,
84    },
85    #[allow(dead_code, private_interfaces)]
86    SetMobile {
87        mobile: Arc<dyn MobileKeepalive>,
88    },
89    SetDesiredRunning {
90        desired: bool,
91        config: Option<StartConfig>,
92        reply: oneshot::Sender<Result<(), ServiceError>>,
93    },
94    EnableAutoRestart {
95        config: Option<StartConfig>,
96        reply: oneshot::Sender<Result<(), ServiceError>>,
97    },
98    DisableAutoRestart {
99        reply: oneshot::Sender<Result<(), ServiceError>>,
100    },
101    GetDesiredState {
102        reply: oneshot::Sender<Option<crate::desired_state::DesiredState>>,
103    },
104}
105
106// ─── Handle ────────────────────────────────────────────────────────────
107
108/// Handle to the service manager actor. Stored as Tauri managed state.
109///
110/// Tauri commands send messages through the internal channel; the actor
111/// task processes them sequentially, preventing concurrent start/stop
112/// interleaving.
113pub struct ServiceManagerHandle<R: Runtime> {
114    pub(crate) cmd_tx: mpsc::Sender<ManagerCommand<R>>,
115}
116
117impl<R: Runtime> ServiceManagerHandle<R> {
118    /// Create a new handle backed by the given channel sender.
119    pub fn new(cmd_tx: mpsc::Sender<ManagerCommand<R>>) -> Self {
120        Self { cmd_tx }
121    }
122
123    /// Start a background service.
124    ///
125    /// Sends a `Start` command to the actor. Returns `AlreadyRunning` if a
126    /// service is already active.
127    pub async fn start(&self, app: AppHandle<R>, config: StartConfig) -> Result<(), ServiceError> {
128        let (reply, rx) = oneshot::channel();
129        self.cmd_tx
130            .send(ManagerCommand::Start { config, reply, app })
131            .await
132            .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
133        rx.await
134            .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
135    }
136
137    /// Stop the running background service.
138    ///
139    /// Sends a `Stop` command to the actor. Returns `NotRunning` if no
140    /// service is active.
141    pub async fn stop(&self) -> Result<(), ServiceError> {
142        let (reply, rx) = oneshot::channel();
143        self.cmd_tx
144            .send(ManagerCommand::Stop { reply })
145            .await
146            .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
147        rx.await
148            .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
149    }
150
151    /// Stop the running background service synchronously.
152    ///
153    /// Uses `blocking_send` so this can be called from synchronous contexts
154    /// (e.g., a Tauri `on_event` closure). Returns `NotRunning` if no
155    /// service is active.
156    pub fn stop_blocking(&self) -> Result<(), ServiceError> {
157        let (reply, rx) = oneshot::channel();
158        self.cmd_tx
159            .blocking_send(ManagerCommand::Stop { reply })
160            .map_err(|_| ServiceError::Runtime("manager actor shut down".into()))?;
161        rx.blocking_recv()
162            .map_err(|_| ServiceError::Runtime("manager actor dropped reply".into()))?
163    }
164
165    /// Check whether a background service is currently running.
166    pub async fn is_running(&self) -> bool {
167        let (reply, rx) = oneshot::channel();
168        if self
169            .cmd_tx
170            .send(ManagerCommand::IsRunning { reply })
171            .await
172            .is_err()
173        {
174            return false;
175        }
176        rx.await.unwrap_or(false)
177    }
178
179    /// Set the callback fired when the service task completes.
180    ///
181    /// The callback is captured at spawn time (generation-guarded), so calling
182    /// this while a service is running will only affect the *next* start.
183    #[doc(hidden)]
184    pub async fn set_on_complete(&self, callback: OnCompleteCallback) {
185        let _ = self
186            .cmd_tx
187            .send(ManagerCommand::SetOnComplete { callback })
188            .await;
189    }
190
191    /// Get the current service lifecycle status.
192    pub async fn get_state(&self) -> ServiceStatus {
193        let (reply, rx) = oneshot::channel();
194        if self
195            .cmd_tx
196            .send(ManagerCommand::GetState { reply })
197            .await
198            .is_err()
199        {
200            return ServiceStatus {
201                state: ServiceLifecycle::Idle,
202                ..Default::default()
203            };
204        }
205        rx.await.unwrap_or(ServiceStatus {
206            state: ServiceLifecycle::Idle,
207            ..Default::default()
208        })
209    }
210}
211
212// ─── Actor State ───────────────────────────────────────────────────────
213
214/// Internal state owned exclusively by the actor task.
215struct ServiceState<R: Runtime> {
216    /// Fast path: `true` when a service task is active.
217    /// Set by `handle_start`, cleared by `handle_stop` or task cleanup.
218    /// Avoids acquiring the Mutex for status-only queries.
219    is_running: Arc<AtomicBool>,
220    /// Cancellation token: `Some` means a service is running.
221    /// Shared with the spawned service task via `Arc<Mutex<>>` so it can
222    /// clear the slot when the task finishes.
223    token: Arc<Mutex<Option<CancellationToken>>>,
224    /// Generation counter for the race-condition guard.
225    /// Incremented on each start; shared via `Arc<AtomicU64>`.
226    generation: Arc<AtomicU64>,
227    /// Callback fired once when the service task completes.
228    /// Captured via `take()` at spawn time so a new callback can be set
229    /// for the next start.
230    on_complete: Option<OnCompleteCallback>,
231    /// Factory that creates fresh service instances.
232    factory: ServiceFactory<R>,
233    /// Mobile keepalive handle. Set via `SetMobile` command on mobile platforms.
234    mobile: Option<Arc<dyn MobileKeepalive>>,
235    /// iOS safety timeout in seconds (from PluginConfig, default 28.0).
236    /// Passed to mobile via `start_keepalive`. Android ignores this field.
237    ios_safety_timeout_secs: f64,
238    /// iOS BGProcessingTask safety timeout in seconds (from PluginConfig, default 0.0).
239    /// When > 0.0, caps processing task duration. Passed as `Some(value)` to mobile.
240    /// When 0.0, passed as `None` (no cap).
241    ios_processing_safety_timeout_secs: f64,
242    /// iOS BGAppRefreshTask earliest begin date in minutes (default 15.0).
243    ios_earliest_refresh_begin_minutes: f64,
244    /// iOS BGProcessingTask earliest begin date in minutes (default 15.0).
245    ios_earliest_processing_begin_minutes: f64,
246    /// iOS BGProcessingTask requires external power (default false).
247    ios_requires_external_power: bool,
248    /// iOS BGProcessingTask requires network connectivity (default false).
249    ios_requires_network_connectivity: bool,
250    /// Current lifecycle state of the service.
251    /// Shared with spawned task for transitions (Initializing→Running→Stopped).
252    lifecycle_state: Arc<Mutex<ServiceLifecycle>>,
253    /// Last error message from init/run failure.
254    /// Shared with spawned task for error capture.
255    last_error: Arc<Mutex<Option<String>>>,
256    /// Desired-state persistence backend.
257    /// `None` on platforms that haven't set one up yet.
258    desired_state: Option<Arc<dyn DesiredStateBackend>>,
259    /// Current platform's lifecycle mode (FGS, BGTask, in-process, OS-service).
260    lifecycle_mode: LifecycleMode,
261}
262
263// ─── Actor Loop ────────────────────────────────────────────────────────
264
265/// Main actor loop: receives commands and dispatches to handlers.
266///
267/// Runs as a spawned Tokio task. The loop exits when all `Sender` halves
268/// are dropped (i.e., the handle is dropped).
269#[doc(hidden)]
270#[allow(clippy::too_many_arguments)]
271pub async fn manager_loop<R: Runtime>(
272    mut rx: mpsc::Receiver<ManagerCommand<R>>,
273    factory: ServiceFactory<R>,
274    // iOS safety timeout in seconds. From PluginConfig.
275    // Default: 28.0 (Apple recommends keeping BG tasks under ~30s).
276    // Passed to mobile via actor's `start_keepalive` call.
277    ios_safety_timeout_secs: f64,
278    // iOS BGProcessingTask safety timeout in seconds. From PluginConfig.
279    // Default: 0.0 (no cap). When > 0.0, passed as Some(value) to mobile.
280    ios_processing_safety_timeout_secs: f64,
281    // iOS BGAppRefreshTask earliest begin date in minutes. From PluginConfig.
282    ios_earliest_refresh_begin_minutes: f64,
283    // iOS BGProcessingTask earliest begin date in minutes. From PluginConfig.
284    ios_earliest_processing_begin_minutes: f64,
285    // iOS BGProcessingTask requires external power. From PluginConfig.
286    ios_requires_external_power: bool,
287    // iOS BGProcessingTask requires network connectivity. From PluginConfig.
288    ios_requires_network_connectivity: bool,
289    // Desired-state persistence backend. None if not configured.
290    desired_state_backend: Option<Arc<dyn DesiredStateBackend>>,
291) {
292    let lifecycle_mode = {
293        #[cfg(target_os = "android")]
294        {
295            LifecycleMode::AndroidForegroundService
296        }
297        #[cfg(target_os = "ios")]
298        {
299            LifecycleMode::IosBgTaskScheduler
300        }
301        #[cfg(not(any(target_os = "android", target_os = "ios")))]
302        {
303            LifecycleMode::DesktopInProcess
304        }
305    };
306
307    let mut state = ServiceState {
308        is_running: Arc::new(AtomicBool::new(false)),
309        token: Arc::new(Mutex::new(None)),
310        generation: Arc::new(AtomicU64::new(0)),
311        on_complete: None,
312        factory,
313        mobile: None,
314        ios_safety_timeout_secs,
315        ios_processing_safety_timeout_secs,
316        ios_earliest_refresh_begin_minutes,
317        ios_earliest_processing_begin_minutes,
318        ios_requires_external_power,
319        ios_requires_network_connectivity,
320        lifecycle_state: Arc::new(Mutex::new(ServiceLifecycle::Idle)),
321        last_error: Arc::new(Mutex::new(None)),
322        desired_state: desired_state_backend,
323        lifecycle_mode,
324    };
325
326    while let Some(cmd) = rx.recv().await {
327        match cmd {
328            ManagerCommand::Start { config, reply, app } => {
329                let _ = reply.send(handle_start(&mut state, app, config));
330            }
331            ManagerCommand::Stop { reply } => {
332                let _ = reply.send(handle_stop(&mut state));
333            }
334            ManagerCommand::IsRunning { reply } => {
335                let _ = reply.send(state.is_running.load(Ordering::SeqCst));
336            }
337            ManagerCommand::SetOnComplete { callback } => {
338                state.on_complete = Some(callback);
339            }
340            ManagerCommand::SetMobile { mobile } => {
341                state.mobile = Some(mobile);
342            }
343            ManagerCommand::GetState { reply } => {
344                let mut status = ServiceStatus {
345                    state: *state.lifecycle_state.lock().unwrap(),
346                    last_error: state.last_error.lock().unwrap().clone(),
347                    platform_mode: Some(state.lifecycle_mode),
348                    ..Default::default()
349                };
350
351                if let Some(ref backend) = state.desired_state {
352                    if let Ok(ds) = backend.load() {
353                        status.desired_running = Some(ds.desired_running);
354                        status.native_state = ds
355                            .last_native_state
356                            .as_deref()
357                            .and_then(|s| serde_json::from_str(&format!("\"{s}\"")).ok());
358                        status.last_start_config = ds
359                            .last_start_config
360                            .and_then(|v| serde_json::from_value(v).ok());
361                        status.last_heartbeat_at = ds.last_heartbeat_epoch_ms;
362                        status.restart_attempt = if ds.restart_attempt > 0 {
363                            Some(ds.restart_attempt)
364                        } else {
365                            None
366                        };
367                        status.recovery_reason = ds.recovery_reason;
368                        status.platform_error = ds.last_platform_error;
369                    }
370                }
371
372                let _ = reply.send(status);
373            }
374            ManagerCommand::SetDesiredRunning {
375                desired,
376                config,
377                reply,
378            } => {
379                let _ = reply.send(handle_set_desired_running(&mut state, desired, config));
380            }
381            ManagerCommand::EnableAutoRestart { config, reply } => {
382                let _ = reply.send(handle_enable_auto_restart(&mut state, config));
383            }
384            ManagerCommand::DisableAutoRestart { reply } => {
385                let _ = reply.send(handle_disable_auto_restart(&mut state));
386            }
387            ManagerCommand::GetDesiredState { reply } => {
388                let _ = reply.send(handle_get_desired_state(&state));
389            }
390        }
391    }
392}
393
394// ─── Command Handlers ──────────────────────────────────────────────────
395
396/// Handle a `Start` command.
397///
398/// Order of operations (critical for the race-condition fix):
399/// 1. Check `AlreadyRunning` — reject early, no side-effects.
400/// 2. Create token, increment generation.
401/// 3. Start mobile keepalive (AFTER AlreadyRunning check).
402///    On failure: rollback token and callback, return error.
403/// 4. Spawn service task (init -> run -> cleanup).
404fn handle_start<R: Runtime>(
405    state: &mut ServiceState<R>,
406    app: AppHandle<R>,
407    config: StartConfig,
408) -> Result<(), ServiceError> {
409    let mut guard = state.token.lock().unwrap();
410
411    if guard.is_some() {
412        return Err(ServiceError::AlreadyRunning);
413    }
414
415    // Validate foreground service type against the allowlist.
416    // Only relevant on mobile (Android foreground service types).
417    // On desktop the type is ignored — no OS enforcement mechanism.
418    if cfg!(mobile) {
419        validate_foreground_service_type(&config.foreground_service_type)?;
420    }
421
422    let token = CancellationToken::new();
423    let shutdown = token.clone();
424    *guard = Some(token);
425    let my_gen = state.generation.fetch_add(1, Ordering::Release) + 1;
426    state.is_running.store(true, Ordering::SeqCst);
427    *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Initializing;
428    *state.last_error.lock().unwrap() = None;
429
430    drop(guard);
431
432    // Capture on_complete at spawn time (generation-guarded).
433    // Takes the callback out of the slot so a new start can set a fresh one.
434    let captured_callback = state.on_complete.take();
435
436    // Start mobile keepalive AFTER AlreadyRunning check.
437    // On failure: rollback (clear token, restore callback).
438    if let Some(ref mobile) = state.mobile {
439        let processing_timeout = if state.ios_processing_safety_timeout_secs > 0.0 {
440            Some(state.ios_processing_safety_timeout_secs)
441        } else {
442            None
443        };
444        if let Err(e) = mobile.start_keepalive(
445            &config.service_label,
446            &config.foreground_service_type,
447            Some(state.ios_safety_timeout_secs),
448            processing_timeout,
449            Some(state.ios_earliest_refresh_begin_minutes),
450            Some(state.ios_earliest_processing_begin_minutes),
451            Some(state.ios_requires_external_power),
452            Some(state.ios_requires_network_connectivity),
453        ) {
454            // Rollback: clear the token we just set.
455            state.token.lock().unwrap().take();
456            state.is_running.store(false, Ordering::SeqCst);
457            *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Idle;
458            // Rollback: restore the callback we took.
459            state.on_complete = captured_callback;
460            return Err(e);
461        }
462    }
463
464    // Shared refs for the spawned task's cleanup logic.
465    let token_ref = state.token.clone();
466    let gen_ref = state.generation.clone();
467    let is_running_ref = state.is_running.clone();
468    let lifecycle_ref = state.lifecycle_state.clone();
469    let last_error_ref = state.last_error.clone();
470
471    let mut service = (state.factory)();
472
473    let ctx = ServiceContext {
474        notifier: Notifier { app: app.clone() },
475        app: app.clone(),
476        shutdown,
477        #[cfg(mobile)]
478        service_label: config.service_label,
479        #[cfg(mobile)]
480        foreground_service_type: config.foreground_service_type,
481    };
482
483    // Use tauri::async_runtime::spawn() instead of tokio::spawn() because
484    // the plugin setup closure may run before a Tokio runtime context is
485    // entered on the current thread (e.g. Android auto-start in setup).
486    tauri::async_runtime::spawn(async move {
487        // Phase 1: init
488        if let Err(e) = service.init(&ctx).await {
489            let _ = app.emit(
490                "background-service://event",
491                PluginEvent::Error {
492                    message: e.to_string(),
493                },
494            );
495            // Clear token only if generation hasn't advanced.
496            if gen_ref.load(Ordering::Acquire) == my_gen {
497                token_ref.lock().unwrap().take();
498                is_running_ref.store(false, Ordering::SeqCst);
499                // Initializing → Stopped on init failure.
500                {
501                    let mut lc = lifecycle_ref.lock().unwrap();
502                    if *lc == ServiceLifecycle::Initializing {
503                        *lc = ServiceLifecycle::Stopped;
504                    }
505                }
506                *last_error_ref.lock().unwrap() = Some(e.to_string());
507            }
508            // Fire callback with false on init failure.
509            if let Some(cb) = captured_callback {
510                cb(false);
511            }
512            return;
513        }
514
515        // Initializing → Running after successful init (generation + state guarded).
516        if gen_ref.load(Ordering::Acquire) == my_gen {
517            let mut lc = lifecycle_ref.lock().unwrap();
518            if *lc == ServiceLifecycle::Initializing {
519                *lc = ServiceLifecycle::Running;
520            }
521        }
522
523        // Emit Started
524        let _ = app.emit("background-service://event", PluginEvent::Started);
525
526        // Phase 2: run
527        let result = service.run(&ctx).await;
528
529        // Emit terminal event.
530        match result {
531            Ok(()) => {
532                let _ = app.emit(
533                    "background-service://event",
534                    PluginEvent::Stopped {
535                        reason: "completed".into(),
536                    },
537                );
538            }
539            Err(ref e) => {
540                let _ = app.emit(
541                    "background-service://event",
542                    PluginEvent::Error {
543                        message: e.to_string(),
544                    },
545                );
546            }
547        }
548
549        // Fire on_complete callback (captured at spawn time).
550        // MUST fire before clearing the token so that
551        // `wait_until_stopped` only returns after the callback ran.
552        if let Some(cb) = captured_callback {
553            cb(result.is_ok());
554        }
555
556        // Clear token only if generation hasn't advanced.
557        if gen_ref.load(Ordering::Acquire) == my_gen {
558            token_ref.lock().unwrap().take();
559            is_running_ref.store(false, Ordering::SeqCst);
560            // → Stopped on run completion (generation guarded).
561            {
562                let mut lc = lifecycle_ref.lock().unwrap();
563                if matches!(
564                    *lc,
565                    ServiceLifecycle::Initializing | ServiceLifecycle::Running
566                ) {
567                    *lc = ServiceLifecycle::Stopped;
568                }
569            }
570            if let Err(ref e) = result {
571                *last_error_ref.lock().unwrap() = Some(e.to_string());
572            }
573        }
574    });
575
576    // Persist desired_running=true after successful start.
577    save_desired_running(state, true, Some(&config));
578
579    Ok(())
580}
581
582/// Handle a `Stop` command.
583///
584/// Takes the token from state and cancels it, then stops mobile keepalive.
585/// Returns `NotRunning` if no service is active.
586fn handle_stop<R: Runtime>(state: &mut ServiceState<R>) -> Result<(), ServiceError> {
587    let mut guard = state.token.lock().unwrap();
588    match guard.take() {
589        Some(token) => {
590            token.cancel();
591            state.is_running.store(false, Ordering::SeqCst);
592            *state.lifecycle_state.lock().unwrap() = ServiceLifecycle::Stopped;
593            *state.last_error.lock().unwrap() = None;
594            drop(guard);
595            // Stop mobile keepalive after token cancellation.
596            if let Some(ref mobile) = state.mobile {
597                if let Err(e) = mobile.stop_keepalive() {
598                    log::warn!("stop_keepalive failed (service already cancelled): {e}");
599                }
600            }
601            // Persist desired_running=false with cleared recovery fields.
602            save_desired_running(state, false, None);
603            Ok(())
604        }
605        None => Err(ServiceError::NotRunning),
606    }
607}
608
609// ─── Desired-State Helpers ──────────────────────────────────────────────
610
611/// Save desired-state to the backend (if configured).
612///
613/// On `desired=true`: saves `desired_running=true` with config and timestamp.
614/// On `desired=false`: saves `desired_running=false` and clears recovery fields.
615fn save_desired_running<R: Runtime>(
616    state: &ServiceState<R>,
617    desired: bool,
618    config: Option<&StartConfig>,
619) {
620    let Some(ref backend) = state.desired_state else {
621        return;
622    };
623
624    let mut ds = backend.load().unwrap_or_default();
625    ds.desired_running = desired;
626    if desired {
627        ds.last_start_config = config.map(|c| serde_json::to_value(c).unwrap_or_default());
628        ds.last_start_epoch_ms = Some(
629            std::time::SystemTime::now()
630                .duration_since(std::time::UNIX_EPOCH)
631                .unwrap_or_default()
632                .as_millis() as u64,
633        );
634    } else {
635        ds.last_start_config = None;
636        ds.last_start_epoch_ms = None;
637        ds.recovery_pending = false;
638        ds.recovery_reason = None;
639        ds.restart_attempt = 0;
640    }
641    if let Err(e) = backend.save(&ds) {
642        log::warn!("failed to save desired state: {e}");
643    }
644}
645
646/// Handle a `SetDesiredRunning` command.
647///
648/// Persists the desired running state WITHOUT affecting the actual running state.
649/// This is used by `enableAutoRestart()` / `disableAutoRestart()` to set intent
650/// for recovery without starting/stopping the service.
651fn handle_set_desired_running<R: Runtime>(
652    state: &mut ServiceState<R>,
653    desired: bool,
654    config: Option<StartConfig>,
655) -> Result<(), ServiceError> {
656    save_desired_running(state, desired, config.as_ref());
657    Ok(())
658}
659
660/// Handle an `EnableAutoRestart` command.
661///
662/// Persists `desired_running=true` with the optional config WITHOUT starting
663/// the service. Used to set recovery intent for future restart/reboot.
664fn handle_enable_auto_restart<R: Runtime>(
665    state: &mut ServiceState<R>,
666    config: Option<StartConfig>,
667) -> Result<(), ServiceError> {
668    save_desired_running(state, true, config.as_ref());
669    Ok(())
670}
671
672/// Handle a `DisableAutoRestart` command.
673///
674/// Persists `desired_running=false` with cleared recovery fields WITHOUT
675/// stopping the service.
676fn handle_disable_auto_restart<R: Runtime>(
677    state: &mut ServiceState<R>,
678) -> Result<(), ServiceError> {
679    save_desired_running(state, false, None);
680    Ok(())
681}
682
683/// Handle a `GetDesiredState` command.
684///
685/// Returns the persisted desired state, or `None` if no backend is configured.
686fn handle_get_desired_state<R: Runtime>(
687    state: &ServiceState<R>,
688) -> Option<crate::desired_state::DesiredState> {
689    state
690        .desired_state
691        .as_ref()
692        .and_then(|backend| backend.load().ok())
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::desired_state::DesiredState;
699    use crate::models::NativeState;
700    use async_trait::async_trait;
701    use std::sync::atomic::{AtomicI8, AtomicU8, AtomicUsize};
702
703    // ── Mock mobile for keepalive testing ─────────────────────────────
704
705    /// Mock mobile that records start/stop_keepalive calls.
706    struct MockMobile {
707        start_called: AtomicUsize,
708        stop_called: AtomicUsize,
709        start_fail: bool,
710        last_label: std::sync::Mutex<Option<String>>,
711        last_fst: std::sync::Mutex<Option<String>>,
712        last_timeout_secs: std::sync::Mutex<Option<f64>>,
713        last_processing_timeout_secs: std::sync::Mutex<Option<f64>>,
714        last_earliest_refresh_begin_minutes: std::sync::Mutex<Option<f64>>,
715        last_earliest_processing_begin_minutes: std::sync::Mutex<Option<f64>>,
716        last_requires_external_power: std::sync::Mutex<Option<bool>>,
717        last_requires_network_connectivity: std::sync::Mutex<Option<bool>>,
718    }
719
720    impl MockMobile {
721        fn new() -> Arc<Self> {
722            Arc::new(Self {
723                start_called: AtomicUsize::new(0),
724                stop_called: AtomicUsize::new(0),
725                start_fail: false,
726                last_label: std::sync::Mutex::new(None),
727                last_fst: std::sync::Mutex::new(None),
728                last_timeout_secs: std::sync::Mutex::new(None),
729                last_processing_timeout_secs: std::sync::Mutex::new(None),
730                last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
731                last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
732                last_requires_external_power: std::sync::Mutex::new(None),
733                last_requires_network_connectivity: std::sync::Mutex::new(None),
734            })
735        }
736
737        fn new_failing() -> Arc<Self> {
738            Arc::new(Self {
739                start_called: AtomicUsize::new(0),
740                stop_called: AtomicUsize::new(0),
741                start_fail: true,
742                last_label: std::sync::Mutex::new(None),
743                last_fst: std::sync::Mutex::new(None),
744                last_timeout_secs: std::sync::Mutex::new(None),
745                last_processing_timeout_secs: std::sync::Mutex::new(None),
746                last_earliest_refresh_begin_minutes: std::sync::Mutex::new(None),
747                last_earliest_processing_begin_minutes: std::sync::Mutex::new(None),
748                last_requires_external_power: std::sync::Mutex::new(None),
749                last_requires_network_connectivity: std::sync::Mutex::new(None),
750            })
751        }
752    }
753
754    #[allow(clippy::too_many_arguments)]
755    fn mock_start_keepalive(
756        mock: &MockMobile,
757        label: &str,
758        foreground_service_type: &str,
759        ios_safety_timeout_secs: Option<f64>,
760        ios_processing_safety_timeout_secs: Option<f64>,
761        ios_earliest_refresh_begin_minutes: Option<f64>,
762        ios_earliest_processing_begin_minutes: Option<f64>,
763        ios_requires_external_power: Option<bool>,
764        ios_requires_network_connectivity: Option<bool>,
765    ) -> Result<(), ServiceError> {
766        mock.start_called.fetch_add(1, Ordering::Release);
767        *mock.last_label.lock().unwrap() = Some(label.to_string());
768        *mock.last_fst.lock().unwrap() = Some(foreground_service_type.to_string());
769        *mock.last_timeout_secs.lock().unwrap() = ios_safety_timeout_secs;
770        *mock.last_processing_timeout_secs.lock().unwrap() = ios_processing_safety_timeout_secs;
771        *mock.last_earliest_refresh_begin_minutes.lock().unwrap() =
772            ios_earliest_refresh_begin_minutes;
773        *mock.last_earliest_processing_begin_minutes.lock().unwrap() =
774            ios_earliest_processing_begin_minutes;
775        *mock.last_requires_external_power.lock().unwrap() = ios_requires_external_power;
776        *mock.last_requires_network_connectivity.lock().unwrap() =
777            ios_requires_network_connectivity;
778        if mock.start_fail {
779            return Err(ServiceError::Platform("mock keepalive failure".into()));
780        }
781        Ok(())
782    }
783
784    impl MobileKeepalive for MockMobile {
785        #[allow(clippy::too_many_arguments)]
786        fn start_keepalive(
787            &self,
788            label: &str,
789            foreground_service_type: &str,
790            ios_safety_timeout_secs: Option<f64>,
791            ios_processing_safety_timeout_secs: Option<f64>,
792            ios_earliest_refresh_begin_minutes: Option<f64>,
793            ios_earliest_processing_begin_minutes: Option<f64>,
794            ios_requires_external_power: Option<bool>,
795            ios_requires_network_connectivity: Option<bool>,
796        ) -> Result<(), ServiceError> {
797            mock_start_keepalive(
798                self,
799                label,
800                foreground_service_type,
801                ios_safety_timeout_secs,
802                ios_processing_safety_timeout_secs,
803                ios_earliest_refresh_begin_minutes,
804                ios_earliest_processing_begin_minutes,
805                ios_requires_external_power,
806                ios_requires_network_connectivity,
807            )
808        }
809
810        fn stop_keepalive(&self) -> Result<(), ServiceError> {
811            self.stop_called.fetch_add(1, Ordering::Release);
812            Ok(())
813        }
814    }
815
816    /// Service that blocks in run() until cancelled.
817    /// Used for lifecycle tests where is_running must remain true.
818    struct BlockingService;
819
820    #[async_trait]
821    impl BackgroundService<tauri::test::MockRuntime> for BlockingService {
822        async fn init(
823            &mut self,
824            _ctx: &ServiceContext<tauri::test::MockRuntime>,
825        ) -> Result<(), ServiceError> {
826            Ok(())
827        }
828
829        async fn run(
830            &mut self,
831            ctx: &ServiceContext<tauri::test::MockRuntime>,
832        ) -> Result<(), ServiceError> {
833            ctx.shutdown.cancelled().await;
834            Ok(())
835        }
836    }
837
838    /// Create a manager actor with a BlockingService factory.
839    fn setup_manager() -> ServiceManagerHandle<tauri::test::MockRuntime> {
840        setup_manager_with_backend(None)
841    }
842
843    /// Create a manager actor with a desired-state backend.
844    fn setup_manager_with_backend(
845        backend: Option<Arc<dyn DesiredStateBackend>>,
846    ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
847        let (cmd_tx, cmd_rx) = mpsc::channel(16);
848        let handle = ServiceManagerHandle::new(cmd_tx);
849        let factory: ServiceFactory<tauri::test::MockRuntime> =
850            Box::new(|| Box::new(BlockingService));
851        tokio::spawn(manager_loop(
852            cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
853        ));
854        handle
855    }
856
857    async fn send_start(
858        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
859        app: AppHandle<tauri::test::MockRuntime>,
860    ) -> Result<(), ServiceError> {
861        send_start_with_config(handle, StartConfig::default(), app).await
862    }
863
864    async fn send_start_with_config(
865        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
866        config: StartConfig,
867        app: AppHandle<tauri::test::MockRuntime>,
868    ) -> Result<(), ServiceError> {
869        let (tx, rx) = oneshot::channel();
870        handle
871            .cmd_tx
872            .send(ManagerCommand::Start {
873                config,
874                reply: tx,
875                app,
876            })
877            .await
878            .unwrap();
879        rx.await.unwrap()
880    }
881
882    async fn send_stop(
883        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
884    ) -> Result<(), ServiceError> {
885        let (tx, rx) = oneshot::channel();
886        handle
887            .cmd_tx
888            .send(ManagerCommand::Stop { reply: tx })
889            .await
890            .unwrap();
891        rx.await.unwrap()
892    }
893
894    async fn send_is_running(handle: &ServiceManagerHandle<tauri::test::MockRuntime>) -> bool {
895        let (tx, rx) = oneshot::channel();
896        handle
897            .cmd_tx
898            .send(ManagerCommand::IsRunning { reply: tx })
899            .await
900            .unwrap();
901        rx.await.unwrap()
902    }
903
904    // ── AC1: Start from idle succeeds ────────────────────────────────
905
906    #[tokio::test]
907    async fn start_from_idle() {
908        let handle = setup_manager();
909        let app = tauri::test::mock_app();
910
911        let result = send_start(&handle, app.handle().clone()).await;
912        assert!(result.is_ok(), "start should succeed from idle");
913        assert!(
914            send_is_running(&handle).await,
915            "should be running after start"
916        );
917    }
918
919    // ── AC2: Stop from running succeeds ──────────────────────────────
920
921    #[tokio::test]
922    async fn stop_from_running() {
923        let handle = setup_manager();
924        let app = tauri::test::mock_app();
925
926        send_start(&handle, app.handle().clone()).await.unwrap();
927
928        let result = send_stop(&handle).await;
929        assert!(result.is_ok(), "stop should succeed from running");
930        assert!(
931            !send_is_running(&handle).await,
932            "should not be running after stop"
933        );
934    }
935
936    // ── AC3: Double start returns AlreadyRunning ────────────────────
937
938    #[tokio::test]
939    async fn double_start_returns_already_running() {
940        let handle = setup_manager();
941        let app = tauri::test::mock_app();
942
943        send_start(&handle, app.handle().clone()).await.unwrap();
944
945        let result = send_start(&handle, app.handle().clone()).await;
946        assert!(
947            matches!(result, Err(ServiceError::AlreadyRunning)),
948            "second start should return AlreadyRunning"
949        );
950    }
951
952    // ── AC4: Stop when not running returns NotRunning ────────────────
953
954    #[tokio::test]
955    async fn stop_when_not_running_returns_not_running() {
956        let handle = setup_manager();
957
958        let result = send_stop(&handle).await;
959        assert!(
960            matches!(result, Err(ServiceError::NotRunning)),
961            "stop should return NotRunning when idle"
962        );
963    }
964
965    // ── AC5: Start-stop-restart cycle works ──────────────────────────
966
967    #[tokio::test]
968    async fn start_stop_restart_cycle() {
969        let handle = setup_manager();
970        let app = tauri::test::mock_app();
971
972        // Start
973        send_start(&handle, app.handle().clone()).await.unwrap();
974        assert!(send_is_running(&handle).await);
975
976        // Stop
977        send_stop(&handle).await.unwrap();
978        assert!(!send_is_running(&handle).await);
979
980        // Restart
981        let result = send_start(&handle, app.handle().clone()).await;
982        assert!(result.is_ok(), "restart should succeed after stop");
983        assert!(
984            send_is_running(&handle).await,
985            "should be running after restart"
986        );
987    }
988
989    // ── Test services for callback testing ────────────────────────────
990
991    /// Service that completes run() immediately with success.
992    struct ImmediateSuccessService;
993
994    #[async_trait]
995    impl BackgroundService<tauri::test::MockRuntime> for ImmediateSuccessService {
996        async fn init(
997            &mut self,
998            _ctx: &ServiceContext<tauri::test::MockRuntime>,
999        ) -> Result<(), ServiceError> {
1000            Ok(())
1001        }
1002
1003        async fn run(
1004            &mut self,
1005            _ctx: &ServiceContext<tauri::test::MockRuntime>,
1006        ) -> Result<(), ServiceError> {
1007            Ok(())
1008        }
1009    }
1010
1011    /// Service whose run() returns an error immediately.
1012    struct ImmediateErrorService;
1013
1014    #[async_trait]
1015    impl BackgroundService<tauri::test::MockRuntime> for ImmediateErrorService {
1016        async fn init(
1017            &mut self,
1018            _ctx: &ServiceContext<tauri::test::MockRuntime>,
1019        ) -> Result<(), ServiceError> {
1020            Ok(())
1021        }
1022
1023        async fn run(
1024            &mut self,
1025            _ctx: &ServiceContext<tauri::test::MockRuntime>,
1026        ) -> Result<(), ServiceError> {
1027            Err(ServiceError::Runtime("run error".into()))
1028        }
1029    }
1030
1031    /// Service whose init() fails.
1032    struct FailingInitService;
1033
1034    #[async_trait]
1035    impl BackgroundService<tauri::test::MockRuntime> for FailingInitService {
1036        async fn init(
1037            &mut self,
1038            _ctx: &ServiceContext<tauri::test::MockRuntime>,
1039        ) -> Result<(), ServiceError> {
1040            Err(ServiceError::Init("init error".into()))
1041        }
1042
1043        async fn run(
1044            &mut self,
1045            _ctx: &ServiceContext<tauri::test::MockRuntime>,
1046        ) -> Result<(), ServiceError> {
1047            Ok(())
1048        }
1049    }
1050
1051    /// Create a manager actor with a custom factory.
1052    fn setup_manager_with_factory(
1053        factory: ServiceFactory<tauri::test::MockRuntime>,
1054    ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
1055        setup_manager_with_factory_and_backend(factory, None)
1056    }
1057
1058    /// Create a manager actor with a custom factory and desired-state backend.
1059    fn setup_manager_with_factory_and_backend(
1060        factory: ServiceFactory<tauri::test::MockRuntime>,
1061        backend: Option<Arc<dyn DesiredStateBackend>>,
1062    ) -> ServiceManagerHandle<tauri::test::MockRuntime> {
1063        let (cmd_tx, cmd_rx) = mpsc::channel(16);
1064        let handle = ServiceManagerHandle::new(cmd_tx);
1065        tokio::spawn(manager_loop(
1066            cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, backend,
1067        ));
1068        handle
1069    }
1070
1071    async fn send_set_on_complete(
1072        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1073        callback: OnCompleteCallback,
1074    ) {
1075        handle
1076            .cmd_tx
1077            .send(ManagerCommand::SetOnComplete { callback })
1078            .await
1079            .unwrap();
1080    }
1081
1082    /// Wait for the service to finish (is_running becomes false).
1083    /// Polls with a short sleep between attempts.
1084    async fn wait_until_stopped(
1085        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1086        timeout_ms: u64,
1087    ) {
1088        let start = std::time::Instant::now();
1089        while start.elapsed().as_millis() < timeout_ms as u128 {
1090            if !send_is_running(handle).await {
1091                return;
1092            }
1093            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1094        }
1095        panic!("Service did not stop within {timeout_ms}ms");
1096    }
1097
1098    // ── AC6 (Step 3): Callback fires on success ──────────────────────
1099
1100    #[tokio::test]
1101    async fn callback_fires_on_success() {
1102        let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1103        let app = tauri::test::mock_app();
1104
1105        let called = Arc::new(AtomicI8::new(-1));
1106        let called_clone = called.clone();
1107        send_set_on_complete(
1108            &handle,
1109            Box::new(move |success| {
1110                called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1111            }),
1112        )
1113        .await;
1114
1115        send_start(&handle, app.handle().clone()).await.unwrap();
1116        wait_until_stopped(&handle, 1000).await;
1117
1118        assert_eq!(
1119            called.load(Ordering::Acquire),
1120            1,
1121            "callback should be called with true"
1122        );
1123    }
1124
1125    // ── AC7 (Step 3): Callback fires on error ────────────────────────
1126
1127    #[tokio::test]
1128    async fn callback_fires_on_error() {
1129        let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateErrorService)));
1130        let app = tauri::test::mock_app();
1131
1132        let called = Arc::new(AtomicI8::new(-1));
1133        let called_clone = called.clone();
1134        send_set_on_complete(
1135            &handle,
1136            Box::new(move |success| {
1137                called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1138            }),
1139        )
1140        .await;
1141
1142        send_start(&handle, app.handle().clone()).await.unwrap();
1143        wait_until_stopped(&handle, 1000).await;
1144
1145        assert_eq!(
1146            called.load(Ordering::Acquire),
1147            0,
1148            "callback should be called with false on error"
1149        );
1150    }
1151
1152    // ── AC8 (Step 3): Callback fires on init failure ─────────────────
1153
1154    #[tokio::test]
1155    async fn callback_fires_on_init_failure() {
1156        let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1157        let app = tauri::test::mock_app();
1158
1159        let called = Arc::new(AtomicI8::new(-1));
1160        let called_clone = called.clone();
1161        send_set_on_complete(
1162            &handle,
1163            Box::new(move |success| {
1164                called_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1165            }),
1166        )
1167        .await;
1168
1169        send_start(&handle, app.handle().clone()).await.unwrap();
1170
1171        // Init failure: service was never truly running, so token gets cleared quickly.
1172        // Wait a short time for the spawned task to complete.
1173        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1174
1175        assert_eq!(
1176            called.load(Ordering::Acquire),
1177            0,
1178            "callback should be called with false on init failure"
1179        );
1180        assert!(
1181            !send_is_running(&handle).await,
1182            "should not be running after init failure"
1183        );
1184    }
1185
1186    // ── AC9 (Step 3): No callback no panic ───────────────────────────
1187
1188    #[tokio::test]
1189    async fn no_callback_no_panic() {
1190        let handle = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1191        let app = tauri::test::mock_app();
1192
1193        // Deliberately do NOT call SetOnComplete.
1194        let result = send_start(&handle, app.handle().clone()).await;
1195        assert!(result.is_ok(), "start without callback should succeed");
1196
1197        wait_until_stopped(&handle, 1000).await;
1198        // If we get here without panicking, the test passes.
1199    }
1200
1201    // ── N2: is_running returns false after natural completion ────────
1202
1203    #[tokio::test]
1204    async fn is_running_false_after_natural_completion() {
1205        // Use a service that yields during run() so the is_running check
1206        // doesn't race with immediate completion.
1207        struct YieldingService;
1208
1209        #[async_trait]
1210        impl BackgroundService<tauri::test::MockRuntime> for YieldingService {
1211            async fn init(
1212                &mut self,
1213                _ctx: &ServiceContext<tauri::test::MockRuntime>,
1214            ) -> Result<(), ServiceError> {
1215                Ok(())
1216            }
1217
1218            async fn run(
1219                &mut self,
1220                _ctx: &ServiceContext<tauri::test::MockRuntime>,
1221            ) -> Result<(), ServiceError> {
1222                // Sleep long enough for the caller to observe is_running=true,
1223                // then complete naturally (no cancellation).
1224                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1225                Ok(())
1226            }
1227        }
1228
1229        let handle = setup_manager_with_factory(Box::new(|| Box::new(YieldingService)));
1230        let app = tauri::test::mock_app();
1231
1232        send_start(&handle, app.handle().clone()).await.unwrap();
1233        assert!(
1234            send_is_running(&handle).await,
1235            "should be running immediately after start"
1236        );
1237
1238        // Wait for the service to complete naturally (no stop).
1239        wait_until_stopped(&handle, 2000).await;
1240
1241        assert!(
1242            !send_is_running(&handle).await,
1243            "is_running should be false after natural completion"
1244        );
1245    }
1246
1247    // ── AC10 (Step 3): Generation guard prevents stale cleanup ───────
1248
1249    #[tokio::test]
1250    async fn generation_guard_prevents_stale_cleanup() {
1251        // First start with FailingInit (generation 1) — clears its own token.
1252        // Second start with ImmediateSuccess (generation 2) — should succeed
1253        // because the old task's cleanup shouldn't corrupt the new state.
1254        let call_count = Arc::new(AtomicU8::new(0));
1255        let call_count_clone = call_count.clone();
1256
1257        let handle = setup_manager_with_factory(Box::new(move || {
1258            let cc = call_count_clone.clone();
1259            // First call: FailingInit. Second call: ImmediateSuccess.
1260            // Use AtomicU8 to track which invocation this is.
1261            if cc.fetch_add(1, Ordering::AcqRel) == 0 {
1262                Box::new(FailingInitService) as Box<dyn BackgroundService<tauri::test::MockRuntime>>
1263            } else {
1264                Box::new(ImmediateSuccessService)
1265            }
1266        }));
1267        let app = tauri::test::mock_app();
1268
1269        // First start: init fails, token cleared by spawned task.
1270        send_start(&handle, app.handle().clone()).await.unwrap();
1271        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1272
1273        // Second start: should succeed — generation guard prevented stale cleanup.
1274        let result = send_start(&handle, app.handle().clone()).await;
1275        assert!(
1276            result.is_ok(),
1277            "second start should succeed after init failure: {result:?}"
1278        );
1279        assert!(
1280            send_is_running(&handle).await,
1281            "should be running after second start"
1282        );
1283    }
1284
1285    // ── AC11 (Step 3): Callback captured at spawn time ───────────────
1286
1287    #[tokio::test]
1288    async fn callback_captured_at_spawn_time() {
1289        let handle = setup_manager_with_factory(Box::new(|| Box::new(BlockingService)));
1290        let app = tauri::test::mock_app();
1291
1292        // Set callback A, start, then set callback B.
1293        // When the service completes, A should fire (not B).
1294        let which = Arc::new(AtomicU8::new(0)); // 0=none, 1=A, 2=B
1295        let which_clone_a = which.clone();
1296        let which_clone_b = which.clone();
1297
1298        send_set_on_complete(
1299            &handle,
1300            Box::new(move |_| {
1301                which_clone_a.store(1, Ordering::Release);
1302            }),
1303        )
1304        .await;
1305
1306        send_start(&handle, app.handle().clone()).await.unwrap();
1307
1308        // Service is blocking — set a NEW callback while it runs.
1309        send_set_on_complete(
1310            &handle,
1311            Box::new(move |_| {
1312                which_clone_b.store(2, Ordering::Release);
1313            }),
1314        )
1315        .await;
1316
1317        // Stop the service — this triggers cleanup and callback.
1318        send_stop(&handle).await.unwrap();
1319        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1320
1321        assert_eq!(
1322            which.load(Ordering::Acquire),
1323            1,
1324            "callback A should fire, not B"
1325        );
1326    }
1327
1328    // ── Mobile keepalive helpers ──────────────────────────────────────
1329
1330    async fn send_set_mobile(
1331        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1332        mobile: Arc<dyn MobileKeepalive>,
1333    ) {
1334        handle
1335            .cmd_tx
1336            .send(ManagerCommand::SetMobile { mobile })
1337            .await
1338            .unwrap();
1339    }
1340
1341    // ── AC1 (Step 5): start_keepalive called on start ────────────────
1342
1343    #[tokio::test]
1344    async fn start_keepalive_called_on_start() {
1345        let mock = MockMobile::new();
1346        let handle = setup_manager();
1347        let app = tauri::test::mock_app();
1348
1349        send_set_mobile(&handle, mock.clone()).await;
1350        send_start(&handle, app.handle().clone()).await.unwrap();
1351
1352        assert_eq!(
1353            mock.start_called.load(Ordering::Acquire),
1354            1,
1355            "start_keepalive should be called once"
1356        );
1357        assert_eq!(
1358            mock.last_label.lock().unwrap().as_deref(),
1359            Some("Service running"),
1360            "label should be forwarded"
1361        );
1362    }
1363
1364    // ── AC2 (Step 5): start_keepalive failure rollback ───────────────
1365
1366    #[tokio::test]
1367    async fn start_keepalive_failure_rollback() {
1368        let mock = MockMobile::new_failing();
1369        let handle = setup_manager();
1370        let app = tauri::test::mock_app();
1371
1372        let callback_called = Arc::new(AtomicI8::new(-1));
1373        let cb_clone = callback_called.clone();
1374        send_set_on_complete(
1375            &handle,
1376            Box::new(move |success| {
1377                cb_clone.store(if success { 1 } else { 0 }, Ordering::Release);
1378            }),
1379        )
1380        .await;
1381
1382        send_set_mobile(&handle, mock.clone()).await;
1383
1384        let result = send_start(&handle, app.handle().clone()).await;
1385        assert!(
1386            matches!(result, Err(ServiceError::Platform(_))),
1387            "start should return Platform error on keepalive failure: {result:?}"
1388        );
1389
1390        // Token should be cleared (not running).
1391        assert!(
1392            !send_is_running(&handle).await,
1393            "token should be rolled back after keepalive failure"
1394        );
1395
1396        // Callback should be restored — can be set again.
1397        let callback_called2 = Arc::new(AtomicI8::new(-1));
1398        let cb_clone2 = callback_called2.clone();
1399        send_set_on_complete(
1400            &handle,
1401            Box::new(move |success| {
1402                cb_clone2.store(if success { 1 } else { 0 }, Ordering::Release);
1403            }),
1404        )
1405        .await;
1406
1407        // Without the failing mobile, a start should succeed and callback should work.
1408        // Use a fresh manager without mobile to test callback restoration.
1409        let handle2 = setup_manager_with_factory(Box::new(|| Box::new(ImmediateSuccessService)));
1410        let callback_restored = Arc::new(AtomicI8::new(-1));
1411        let cb_r = callback_restored.clone();
1412        send_set_on_complete(
1413            &handle2,
1414            Box::new(move |success| {
1415                cb_r.store(if success { 1 } else { 0 }, Ordering::Release);
1416            }),
1417        )
1418        .await;
1419        send_start(&handle2, app.handle().clone()).await.unwrap();
1420        wait_until_stopped(&handle2, 1000).await;
1421        assert_eq!(
1422            callback_restored.load(Ordering::Acquire),
1423            1,
1424            "callback should fire after successful start (proves rollback restored it)"
1425        );
1426    }
1427
1428    // ── AC3 (Step 5): stop_keepalive called on stop ──────────────────
1429
1430    #[tokio::test]
1431    async fn stop_keepalive_called_on_stop() {
1432        let mock = MockMobile::new();
1433        let handle = setup_manager();
1434        let app = tauri::test::mock_app();
1435
1436        send_set_mobile(&handle, mock.clone()).await;
1437        send_start(&handle, app.handle().clone()).await.unwrap();
1438
1439        assert_eq!(
1440            mock.stop_called.load(Ordering::Acquire),
1441            0,
1442            "stop_keepalive should not be called yet"
1443        );
1444
1445        send_stop(&handle).await.unwrap();
1446
1447        assert_eq!(
1448            mock.stop_called.load(Ordering::Acquire),
1449            1,
1450            "stop_keepalive should be called once after stop"
1451        );
1452    }
1453
1454    // ── stop_keepalive failure does not propagate ──────────────────────────
1455
1456    /// Mock mobile where `stop_keepalive` always fails.
1457    struct MockMobileFailingStop;
1458
1459    #[allow(clippy::too_many_arguments)]
1460    impl MobileKeepalive for MockMobileFailingStop {
1461        fn start_keepalive(
1462            &self,
1463            _label: &str,
1464            _foreground_service_type: &str,
1465            _ios_safety_timeout_secs: Option<f64>,
1466            _ios_processing_safety_timeout_secs: Option<f64>,
1467            _ios_earliest_refresh_begin_minutes: Option<f64>,
1468            _ios_earliest_processing_begin_minutes: Option<f64>,
1469            _ios_requires_external_power: Option<bool>,
1470            _ios_requires_network_connectivity: Option<bool>,
1471        ) -> Result<(), ServiceError> {
1472            Ok(())
1473        }
1474
1475        fn stop_keepalive(&self) -> Result<(), ServiceError> {
1476            Err(ServiceError::Platform("mock stop failure".into()))
1477        }
1478    }
1479
1480    #[tokio::test]
1481    async fn stop_keepalive_failure_does_not_propagate() {
1482        let handle = setup_manager();
1483        let app = tauri::test::mock_app();
1484
1485        send_set_mobile(&handle, Arc::new(MockMobileFailingStop)).await;
1486        send_start(&handle, app.handle().clone()).await.unwrap();
1487
1488        let result = send_stop(&handle).await;
1489        assert!(
1490            result.is_ok(),
1491            "stop should succeed even when stop_keepalive fails"
1492        );
1493
1494        assert!(
1495            !send_is_running(&handle).await,
1496            "service should not be running after stop"
1497        );
1498    }
1499
1500    // ── iOS safety timeout passed to mobile ──────────────────────────────
1501
1502    #[tokio::test]
1503    async fn ios_safety_timeout_passed_to_mobile() {
1504        let mock = MockMobile::new();
1505        let (cmd_tx, cmd_rx) = mpsc::channel(16);
1506        let handle = ServiceManagerHandle::new(cmd_tx);
1507        let factory: ServiceFactory<tauri::test::MockRuntime> =
1508            Box::new(|| Box::new(BlockingService));
1509        // Use a custom timeout value (not default 28.0)
1510        tokio::spawn(manager_loop(
1511            cmd_rx, factory, 15.0, 0.0, 15.0, 15.0, false, false, None,
1512        ));
1513
1514        let app = tauri::test::mock_app();
1515
1516        send_set_mobile(&handle, mock.clone()).await;
1517        send_start(&handle, app.handle().clone()).await.unwrap();
1518
1519        // Verify the timeout was passed through to the mock
1520        let timeout = *mock.last_timeout_secs.lock().unwrap();
1521        assert_eq!(
1522            timeout,
1523            Some(15.0),
1524            "ios_safety_timeout_secs should be passed to mobile"
1525        );
1526    }
1527
1528    // ── iOS processing timeout passed to mobile ──────────────────────────────
1529
1530    #[tokio::test]
1531    async fn ios_processing_timeout_passed_to_mobile() {
1532        let mock = MockMobile::new();
1533        let (cmd_tx, cmd_rx) = mpsc::channel(16);
1534        let handle = ServiceManagerHandle::new(cmd_tx);
1535        let factory: ServiceFactory<tauri::test::MockRuntime> =
1536            Box::new(|| Box::new(BlockingService));
1537        // Use a custom processing timeout value
1538        tokio::spawn(manager_loop(
1539            cmd_rx, factory, 28.0, 60.0, 15.0, 15.0, false, false, None,
1540        ));
1541
1542        let app = tauri::test::mock_app();
1543
1544        send_set_mobile(&handle, mock.clone()).await;
1545        send_start(&handle, app.handle().clone()).await.unwrap();
1546
1547        // Verify the processing timeout was passed through to the mock
1548        let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1549        assert_eq!(
1550            timeout,
1551            Some(60.0),
1552            "ios_processing_safety_timeout_secs should be passed to mobile"
1553        );
1554    }
1555
1556    // ── Service that captures ServiceContext fields for inspection ──────
1557
1558    /// Service that captures `service_label` and `foreground_service_type`
1559    /// from the `ServiceContext` it receives in `init()`.
1560    /// Only compiled on mobile where those fields exist.
1561    #[cfg(mobile)]
1562    struct ContextCapturingService {
1563        captured_label: Arc<std::sync::Mutex<Option<String>>>,
1564        captured_fst: Arc<std::sync::Mutex<Option<String>>>,
1565    }
1566
1567    #[cfg(mobile)]
1568    #[async_trait]
1569    impl BackgroundService<tauri::test::MockRuntime> for ContextCapturingService {
1570        async fn init(
1571            &mut self,
1572            ctx: &ServiceContext<tauri::test::MockRuntime>,
1573        ) -> Result<(), ServiceError> {
1574            *self.captured_label.lock().unwrap() = Some(ctx.service_label.clone());
1575            *self.captured_fst.lock().unwrap() = Some(ctx.foreground_service_type.clone());
1576            Ok(())
1577        }
1578
1579        async fn run(
1580            &mut self,
1581            ctx: &ServiceContext<tauri::test::MockRuntime>,
1582        ) -> Result<(), ServiceError> {
1583            ctx.shutdown.cancelled().await;
1584            Ok(())
1585        }
1586    }
1587
1588    // ── AC (Step 11): ServiceContext fields are populated on mobile ────
1589
1590    #[cfg(mobile)]
1591    #[tokio::test]
1592    async fn service_context_fields_populated_on_mobile() {
1593        let captured_label: Arc<std::sync::Mutex<Option<String>>> =
1594            Arc::new(std::sync::Mutex::new(None));
1595        let captured_fst: Arc<std::sync::Mutex<Option<String>>> =
1596            Arc::new(std::sync::Mutex::new(None));
1597        let cl = captured_label.clone();
1598        let cf = captured_fst.clone();
1599
1600        let handle = setup_manager_with_factory(Box::new(move || {
1601            let cl = cl.clone();
1602            let cf = cf.clone();
1603            Box::new(ContextCapturingService {
1604                captured_label: cl,
1605                captured_fst: cf,
1606            })
1607        }));
1608        let app = tauri::test::mock_app();
1609
1610        let config = StartConfig {
1611            service_label: "Syncing".into(),
1612            foreground_service_type: "dataSync".into(),
1613        };
1614
1615        send_start_with_config(&handle, config, app.handle().clone())
1616            .await
1617            .unwrap();
1618
1619        // Give the spawned task time to run init() (which captures the values).
1620        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1621
1622        // On mobile, both fields should be populated as Strings
1623        assert_eq!(
1624            captured_label.lock().unwrap().as_deref(),
1625            Some("Syncing"),
1626            "service_label should be 'Syncing' on mobile"
1627        );
1628        assert_eq!(
1629            captured_fst.lock().unwrap().as_deref(),
1630            Some("dataSync"),
1631            "foreground_service_type should be 'dataSync' on mobile"
1632        );
1633
1634        send_stop(&handle).await.unwrap();
1635    }
1636
1637    // ── S1: handle_start accepts invalid foreground_service_type on desktop ──
1638
1639    #[tokio::test]
1640    async fn handle_start_accepts_invalid_foreground_service_type_on_desktop() {
1641        // On desktop (cfg!(mobile) == false), the foreground_service_type
1642        // validation is skipped. An arbitrary string should succeed.
1643        let handle = setup_manager();
1644        let app = tauri::test::mock_app();
1645
1646        let config = StartConfig {
1647            service_label: "test".into(),
1648            foreground_service_type: "bogusType".into(),
1649        };
1650
1651        let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1652        assert!(
1653            result.is_ok(),
1654            "start with invalid fg type should succeed on desktop: {result:?}"
1655        );
1656        assert!(
1657            send_is_running(&handle).await,
1658            "service should be running after start with invalid type on desktop"
1659        );
1660
1661        send_stop(&handle).await.unwrap();
1662    }
1663
1664    // ── handle_start accepts all valid foreground_service_types ────────
1665
1666    #[tokio::test]
1667    async fn handle_start_accepts_all_valid_foreground_service_types() {
1668        for &valid_type in crate::models::VALID_FOREGROUND_SERVICE_TYPES {
1669            let handle = setup_manager();
1670            let app = tauri::test::mock_app();
1671
1672            let config = StartConfig {
1673                service_label: "test".into(),
1674                foreground_service_type: valid_type.into(),
1675            };
1676
1677            let result = send_start_with_config(&handle, config, app.handle().clone()).await;
1678            assert!(
1679                result.is_ok(),
1680                "start with valid type '{valid_type}' should succeed: {result:?}"
1681            );
1682            assert!(send_is_running(&handle).await);
1683            // Stop for cleanup
1684            send_stop(&handle).await.unwrap();
1685        }
1686    }
1687
1688    // ── State transition helpers ────────────────────────────────────────
1689
1690    async fn send_get_state(
1691        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1692    ) -> ServiceStatus {
1693        let (tx, rx) = oneshot::channel();
1694        handle
1695            .cmd_tx
1696            .send(ManagerCommand::GetState { reply: tx })
1697            .await
1698            .unwrap();
1699        rx.await.unwrap()
1700    }
1701
1702    // ── State transition: initial state is Idle ───────────────────────
1703
1704    #[tokio::test]
1705    async fn get_state_returns_idle_initially() {
1706        let handle = setup_manager();
1707        let status = send_get_state(&handle).await;
1708        assert_eq!(status.state, ServiceLifecycle::Idle);
1709        assert_eq!(status.last_error, None);
1710    }
1711
1712    // ── State transition: Idle → Initializing → Running → Stopped ─────
1713
1714    #[tokio::test]
1715    async fn lifecycle_idle_to_running_to_stopped() {
1716        // Use BlockingService so we can reliably observe the Running state.
1717        let handle = setup_manager();
1718        let app = tauri::test::mock_app();
1719
1720        // Idle initially
1721        let status = send_get_state(&handle).await;
1722        assert_eq!(status.state, ServiceLifecycle::Idle);
1723
1724        // Start — transitions to Initializing, then Running after init()
1725        send_start(&handle, app.handle().clone()).await.unwrap();
1726
1727        // Small delay for spawned task to complete init() → Running
1728        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1729        let status = send_get_state(&handle).await;
1730        assert_eq!(status.state, ServiceLifecycle::Running);
1731
1732        // Stop → Stopped
1733        send_stop(&handle).await.unwrap();
1734        let status = send_get_state(&handle).await;
1735        assert_eq!(status.state, ServiceLifecycle::Stopped);
1736        assert_eq!(status.last_error, None);
1737    }
1738
1739    // ── State transition: Idle → Initializing → Stopped on init failure ─
1740
1741    #[tokio::test]
1742    async fn lifecycle_init_failure_sets_stopped_with_error() {
1743        let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1744        let app = tauri::test::mock_app();
1745
1746        send_start(&handle, app.handle().clone()).await.unwrap();
1747
1748        // Wait for init failure to propagate
1749        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1750
1751        let status = send_get_state(&handle).await;
1752        assert_eq!(status.state, ServiceLifecycle::Stopped);
1753        assert!(
1754            status.last_error.is_some(),
1755            "last_error should be set on init failure"
1756        );
1757        assert!(
1758            status.last_error.unwrap().contains("init error"),
1759            "error should mention init error"
1760        );
1761    }
1762
1763    // ── State transition: explicit stop sets Stopped, clears last_error ─
1764
1765    #[tokio::test]
1766    async fn lifecycle_explicit_stop_sets_stopped_clears_error() {
1767        let handle = setup_manager();
1768        let app = tauri::test::mock_app();
1769
1770        send_start(&handle, app.handle().clone()).await.unwrap();
1771        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1772
1773        let status = send_get_state(&handle).await;
1774        assert_eq!(status.state, ServiceLifecycle::Running);
1775
1776        send_stop(&handle).await.unwrap();
1777
1778        let status = send_get_state(&handle).await;
1779        assert_eq!(status.state, ServiceLifecycle::Stopped);
1780        assert_eq!(
1781            status.last_error, None,
1782            "explicit stop should clear last_error"
1783        );
1784    }
1785
1786    // ── State transition: restart clears stale last_error ─────────────
1787
1788    #[tokio::test]
1789    async fn restart_clears_stale_last_error() {
1790        // Step 1: start with a service whose init() fails → Stopped + last_error set
1791        let handle = setup_manager_with_factory(Box::new(|| Box::new(FailingInitService)));
1792        let app = tauri::test::mock_app();
1793
1794        send_start(&handle, app.handle().clone()).await.unwrap();
1795        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1796
1797        let status = send_get_state(&handle).await;
1798        assert_eq!(status.state, ServiceLifecycle::Stopped);
1799        assert!(
1800            status.last_error.is_some(),
1801            "should have error after init failure"
1802        );
1803
1804        // Step 2: restart with a succeeding service — last_error must be cleared
1805        // We can't swap the factory, but we CAN verify the field is cleared
1806        // by starting again with the same failing service and checking that
1807        // handle_start resets last_error before the spawn.
1808        // Instead, use a two-phase factory: first fails, then succeeds.
1809        let call_count = Arc::new(AtomicUsize::new(0));
1810        let count_clone = call_count.clone();
1811        let handle2 = setup_manager_with_factory(Box::new(move || {
1812            let n = count_clone.fetch_add(1, Ordering::SeqCst);
1813            if n == 0 {
1814                Box::new(FailingInitService)
1815            } else {
1816                Box::new(ImmediateSuccessService)
1817            }
1818        }));
1819        let app2 = tauri::test::mock_app();
1820
1821        // First start: init fails
1822        send_start(&handle2, app2.handle().clone()).await.unwrap();
1823        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1824
1825        let status = send_get_state(&handle2).await;
1826        assert_eq!(status.state, ServiceLifecycle::Stopped);
1827        assert!(
1828            status.last_error.is_some(),
1829            "first run should set last_error"
1830        );
1831
1832        // Second start: succeeds — last_error must be None
1833        send_start(&handle2, app2.handle().clone()).await.unwrap();
1834        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1835
1836        let status = send_get_state(&handle2).await;
1837        // After successful init + run completion, state is Stopped (natural completion)
1838        // but last_error should be cleared by handle_start
1839        assert_eq!(
1840            status.last_error, None,
1841            "last_error must be cleared on restart, not stale from previous failure"
1842        );
1843    }
1844
1845    // ── get_state via ServiceManagerHandle method ─────────────────────
1846
1847    #[tokio::test]
1848    async fn get_state_handle_method_returns_idle() {
1849        let handle = setup_manager();
1850        let status = handle.get_state().await;
1851        assert_eq!(status.state, ServiceLifecycle::Idle);
1852        assert_eq!(status.last_error, None);
1853    }
1854
1855    // ── stop_blocking sends Stop command and returns success from running ─
1856
1857    #[tokio::test]
1858    async fn stop_blocking_returns_success_from_running() {
1859        let handle = Arc::new(setup_manager());
1860        let app = tauri::test::mock_app();
1861
1862        send_start(&handle, app.handle().clone()).await.unwrap();
1863        assert!(send_is_running(&handle).await);
1864
1865        // Must call stop_blocking from outside the async runtime.
1866        let h = handle.clone();
1867        let result = tokio::task::spawn_blocking(move || h.stop_blocking())
1868            .await
1869            .expect("spawn_blocking panicked");
1870        assert!(
1871            result.is_ok(),
1872            "stop_blocking should succeed from running: {result:?}"
1873        );
1874        assert!(
1875            !send_is_running(&handle).await,
1876            "should not be running after stop_blocking"
1877        );
1878    }
1879
1880    // ── stop_blocking returns NotRunning when idle ───────────────────────
1881
1882    #[tokio::test]
1883    async fn stop_blocking_returns_not_running_when_idle() {
1884        let handle = Arc::new(setup_manager());
1885
1886        let h = handle.clone();
1887        let result = tokio::task::spawn_blocking(move || h.stop_blocking())
1888            .await
1889            .expect("spawn_blocking panicked");
1890        assert!(
1891            matches!(result, Err(ServiceError::NotRunning)),
1892            "stop_blocking should return NotRunning when idle: {result:?}"
1893        );
1894    }
1895
1896    #[tokio::test]
1897    async fn ios_processing_timeout_zero_passes_as_none() {
1898        let mock = MockMobile::new();
1899        let (cmd_tx, cmd_rx) = mpsc::channel(16);
1900        let handle = ServiceManagerHandle::new(cmd_tx);
1901        let factory: ServiceFactory<tauri::test::MockRuntime> =
1902            Box::new(|| Box::new(BlockingService));
1903        // Processing timeout = 0.0 (default, no cap)
1904        tokio::spawn(manager_loop(
1905            cmd_rx, factory, 28.0, 0.0, 15.0, 15.0, false, false, None,
1906        ));
1907
1908        let app = tauri::test::mock_app();
1909
1910        send_set_mobile(&handle, mock.clone()).await;
1911        send_start(&handle, app.handle().clone()).await.unwrap();
1912
1913        // Zero timeout should be passed as None
1914        let timeout = *mock.last_processing_timeout_secs.lock().unwrap();
1915        assert_eq!(
1916            timeout, None,
1917            "ios_processing_safety_timeout_secs of 0.0 should pass None to mobile"
1918        );
1919    }
1920
1921    // ── Desired-state MockBackend ─────────────────────────────────────────
1922
1923    /// Mock desired-state backend that records all saves in a Mutex<Vec>.
1924    struct MockDesiredStateBackend {
1925        saves: std::sync::Mutex<Vec<DesiredState>>,
1926    }
1927
1928    impl MockDesiredStateBackend {
1929        fn new() -> Arc<Self> {
1930            Arc::new(Self {
1931                saves: std::sync::Mutex::new(Vec::new()),
1932            })
1933        }
1934
1935        fn last_save(&self) -> Option<DesiredState> {
1936            self.saves.lock().unwrap().last().cloned()
1937        }
1938
1939        #[allow(dead_code)]
1940        fn save_count(&self) -> usize {
1941            self.saves.lock().unwrap().len()
1942        }
1943
1944        #[allow(dead_code)]
1945        fn saves(&self) -> std::sync::MutexGuard<'_, Vec<DesiredState>> {
1946            self.saves.lock().unwrap()
1947        }
1948    }
1949
1950    impl DesiredStateBackend for MockDesiredStateBackend {
1951        fn load(&self) -> Result<DesiredState, String> {
1952            Ok(self
1953                .saves
1954                .lock()
1955                .unwrap()
1956                .last()
1957                .cloned()
1958                .unwrap_or_default())
1959        }
1960
1961        fn save(&self, state: &DesiredState) -> Result<(), String> {
1962            self.saves.lock().unwrap().push(state.clone());
1963            Ok(())
1964        }
1965
1966        fn clear(&self) -> Result<(), String> {
1967            self.saves.lock().unwrap().clear();
1968            Ok(())
1969        }
1970    }
1971
1972    // ── Desired-state actor integration tests ─────────────────────────────
1973
1974    async fn send_set_desired_running(
1975        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
1976        desired: bool,
1977        config: Option<StartConfig>,
1978    ) -> Result<(), ServiceError> {
1979        let (tx, rx) = oneshot::channel();
1980        handle
1981            .cmd_tx
1982            .send(ManagerCommand::SetDesiredRunning {
1983                desired,
1984                config,
1985                reply: tx,
1986            })
1987            .await
1988            .unwrap();
1989        rx.await.unwrap()
1990    }
1991
1992    #[tokio::test]
1993    async fn start_saves_desired_running_true() {
1994        let backend = MockDesiredStateBackend::new();
1995        let handle = setup_manager_with_factory_and_backend(
1996            Box::new(|| Box::new(BlockingService)),
1997            Some(backend.clone()),
1998        );
1999        let app = tauri::test::mock_app();
2000
2001        let config = StartConfig {
2002            service_label: "Syncing".into(),
2003            ..Default::default()
2004        };
2005        send_start_with_config(&handle, config, app.handle().clone())
2006            .await
2007            .unwrap();
2008
2009        // Give the actor a moment to process the save (it happens after spawn).
2010        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2011
2012        let last = backend
2013            .last_save()
2014            .expect("should have saved desired state");
2015        assert!(
2016            last.desired_running,
2017            "desired_running should be true after start"
2018        );
2019        assert!(
2020            last.last_start_config.is_some(),
2021            "last_start_config should be set"
2022        );
2023        assert!(
2024            last.last_start_epoch_ms.is_some(),
2025            "last_start_epoch_ms should be set"
2026        );
2027    }
2028
2029    #[tokio::test]
2030    async fn stop_saves_desired_running_false_with_cleared_recovery() {
2031        let backend = MockDesiredStateBackend::new();
2032        let handle = setup_manager_with_factory_and_backend(
2033            Box::new(|| Box::new(BlockingService)),
2034            Some(backend.clone()),
2035        );
2036        let app = tauri::test::mock_app();
2037
2038        send_start(&handle, app.handle().clone()).await.unwrap();
2039
2040        // Simulate some recovery state that should be cleared on stop.
2041        {
2042            let mut saves = backend.saves.lock().unwrap();
2043            let last = saves.last_mut().unwrap();
2044            last.recovery_pending = true;
2045            last.recovery_reason = Some("boot".into());
2046            last.restart_attempt = 3;
2047        }
2048
2049        send_stop(&handle).await.unwrap();
2050
2051        let last = backend.last_save().expect("should have saved on stop");
2052        assert!(
2053            !last.desired_running,
2054            "desired_running should be false after stop"
2055        );
2056        assert!(
2057            last.last_start_config.is_none(),
2058            "last_start_config should be cleared"
2059        );
2060        assert!(
2061            last.last_start_epoch_ms.is_none(),
2062            "last_start_epoch_ms should be cleared"
2063        );
2064        assert!(!last.recovery_pending, "recovery_pending should be cleared");
2065        assert_eq!(
2066            last.recovery_reason, None,
2067            "recovery_reason should be cleared"
2068        );
2069        assert_eq!(last.restart_attempt, 0, "restart_attempt should be cleared");
2070    }
2071
2072    #[tokio::test]
2073    async fn set_desired_running_saves_without_affecting_is_running() {
2074        let backend = MockDesiredStateBackend::new();
2075        let handle = setup_manager_with_backend(Some(backend.clone()));
2076
2077        // Not running initially
2078        assert!(!send_is_running(&handle).await);
2079
2080        // Set desired_running=true WITHOUT starting
2081        let config = StartConfig {
2082            service_label: "AutoRestart".into(),
2083            ..Default::default()
2084        };
2085        send_set_desired_running(&handle, true, Some(config.clone()))
2086            .await
2087            .unwrap();
2088
2089        // Should NOT be running
2090        assert!(
2091            !send_is_running(&handle).await,
2092            "SetDesiredRunning should not affect is_running"
2093        );
2094
2095        // But desired state should be saved
2096        let last = backend.last_save().expect("should have saved");
2097        assert!(last.desired_running);
2098        assert!(last.last_start_config.is_some());
2099
2100        // Now set desired_running=false
2101        send_set_desired_running(&handle, false, None)
2102            .await
2103            .unwrap();
2104
2105        assert!(!send_is_running(&handle).await);
2106
2107        let last = backend.last_save().expect("should have saved");
2108        assert!(!last.desired_running);
2109    }
2110
2111    #[tokio::test]
2112    async fn no_backend_means_no_panic() {
2113        // No backend — should work fine without panicking.
2114        let handle = setup_manager();
2115        let app = tauri::test::mock_app();
2116
2117        send_start(&handle, app.handle().clone()).await.unwrap();
2118        send_stop(&handle).await.unwrap();
2119
2120        send_set_desired_running(&handle, true, None).await.unwrap();
2121        // If we got here, no panic occurred.
2122    }
2123
2124    #[tokio::test]
2125    async fn start_config_serialized_in_desired_state() {
2126        let backend = MockDesiredStateBackend::new();
2127        let handle = setup_manager_with_factory_and_backend(
2128            Box::new(|| Box::new(BlockingService)),
2129            Some(backend.clone()),
2130        );
2131        let app = tauri::test::mock_app();
2132
2133        let config = StartConfig {
2134            service_label: "CustomLabel".into(),
2135            foreground_service_type: "specialUse".into(),
2136        };
2137        send_start_with_config(&handle, config, app.handle().clone())
2138            .await
2139            .unwrap();
2140
2141        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2142
2143        let last = backend.last_save().expect("should have saved");
2144        let saved_config = last.last_start_config.expect("config should be set");
2145        assert_eq!(saved_config["serviceLabel"], "CustomLabel");
2146        assert_eq!(saved_config["foregroundServiceType"], "specialUse");
2147    }
2148
2149    // ── GetState population from desired-state backend (Step 4, task 1c5e) ──
2150
2151    #[tokio::test]
2152    async fn get_state_returns_desired_running_true_after_start() {
2153        let backend = MockDesiredStateBackend::new();
2154        let handle = setup_manager_with_factory_and_backend(
2155            Box::new(|| Box::new(BlockingService)),
2156            Some(backend.clone()),
2157        );
2158        let app = tauri::test::mock_app();
2159
2160        send_start(&handle, app.handle().clone()).await.unwrap();
2161        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2162
2163        let status = send_get_state(&handle).await;
2164        assert_eq!(
2165            status.desired_running,
2166            Some(true),
2167            "desired_running should be Some(true) after start with backend"
2168        );
2169    }
2170
2171    #[tokio::test]
2172    async fn get_state_returns_desired_running_false_after_stop() {
2173        let backend = MockDesiredStateBackend::new();
2174        let handle = setup_manager_with_factory_and_backend(
2175            Box::new(|| Box::new(BlockingService)),
2176            Some(backend.clone()),
2177        );
2178        let app = tauri::test::mock_app();
2179
2180        send_start(&handle, app.handle().clone()).await.unwrap();
2181        send_stop(&handle).await.unwrap();
2182
2183        let status = send_get_state(&handle).await;
2184        assert_eq!(
2185            status.desired_running,
2186            Some(false),
2187            "desired_running should be Some(false) after stop with backend"
2188        );
2189    }
2190
2191    #[tokio::test]
2192    async fn get_state_returns_none_fields_when_no_backend() {
2193        let handle = setup_manager();
2194        let app = tauri::test::mock_app();
2195
2196        send_start(&handle, app.handle().clone()).await.unwrap();
2197        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2198
2199        let status = send_get_state(&handle).await;
2200        assert_eq!(status.desired_running, None);
2201        assert_eq!(status.native_state, None);
2202        assert_eq!(status.last_start_config, None);
2203        assert_eq!(status.last_heartbeat_at, None);
2204        assert_eq!(status.restart_attempt, None);
2205        assert_eq!(status.recovery_reason, None);
2206        assert_eq!(status.platform_error, None);
2207    }
2208
2209    #[tokio::test]
2210    async fn get_state_returns_last_start_config_from_backend() {
2211        let backend = MockDesiredStateBackend::new();
2212        let handle = setup_manager_with_factory_and_backend(
2213            Box::new(|| Box::new(BlockingService)),
2214            Some(backend.clone()),
2215        );
2216        let app = tauri::test::mock_app();
2217
2218        let config = StartConfig {
2219            service_label: "TestService".into(),
2220            foreground_service_type: "specialUse".into(),
2221        };
2222        send_start_with_config(&handle, config, app.handle().clone())
2223            .await
2224            .unwrap();
2225        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2226
2227        let status = send_get_state(&handle).await;
2228        let cfg = status
2229            .last_start_config
2230            .expect("last_start_config should be populated from backend");
2231        assert_eq!(cfg.service_label, "TestService");
2232        assert_eq!(cfg.foreground_service_type, "specialUse");
2233    }
2234
2235    #[tokio::test]
2236    async fn get_state_populates_all_desired_state_fields() {
2237        let backend = MockDesiredStateBackend::new();
2238        let handle = setup_manager_with_factory_and_backend(
2239            Box::new(|| Box::new(BlockingService)),
2240            Some(backend.clone()),
2241        );
2242        let app = tauri::test::mock_app();
2243
2244        send_start(&handle, app.handle().clone()).await.unwrap();
2245        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2246
2247        // Mutate the backend state to simulate recovery fields being set.
2248        {
2249            let mut saves = backend.saves.lock().unwrap();
2250            let last = saves.last_mut().unwrap();
2251            last.last_native_state = Some("timeout".into());
2252            last.last_platform_error = Some("FGS timed out".into());
2253            last.restart_attempt = 3;
2254            last.recovery_reason = Some("boot recovery".into());
2255            last.last_heartbeat_epoch_ms = Some(1700000005000);
2256        }
2257
2258        let status = send_get_state(&handle).await;
2259        assert_eq!(status.desired_running, Some(true));
2260        assert_eq!(status.native_state, Some(NativeState::Timeout));
2261        assert_eq!(status.platform_error, Some("FGS timed out".into()));
2262        assert_eq!(status.restart_attempt, Some(3));
2263        assert_eq!(status.recovery_reason, Some("boot recovery".into()));
2264        assert_eq!(status.last_heartbeat_at, Some(1700000005000));
2265    }
2266
2267    #[tokio::test]
2268    async fn get_state_returns_platform_mode() {
2269        let handle = setup_manager();
2270
2271        let status = send_get_state(&handle).await;
2272        // On desktop (Linux test runner), should be DesktopInProcess.
2273        assert_eq!(
2274            status.platform_mode,
2275            Some(LifecycleMode::DesktopInProcess),
2276            "platform_mode should be populated even without backend"
2277        );
2278    }
2279
2280    // ── Step 13: EnableAutoRestart / DisableAutoRestart / GetDesiredState tests ──
2281
2282    async fn send_enable_auto_restart(
2283        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2284        config: Option<StartConfig>,
2285    ) -> Result<(), ServiceError> {
2286        let (tx, rx) = oneshot::channel();
2287        handle
2288            .cmd_tx
2289            .send(ManagerCommand::EnableAutoRestart { config, reply: tx })
2290            .await
2291            .unwrap();
2292        rx.await.unwrap()
2293    }
2294
2295    async fn send_disable_auto_restart(
2296        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2297    ) -> Result<(), ServiceError> {
2298        let (tx, rx) = oneshot::channel();
2299        handle
2300            .cmd_tx
2301            .send(ManagerCommand::DisableAutoRestart { reply: tx })
2302            .await
2303            .unwrap();
2304        rx.await.unwrap()
2305    }
2306
2307    async fn send_get_desired_state(
2308        handle: &ServiceManagerHandle<tauri::test::MockRuntime>,
2309    ) -> Option<DesiredState> {
2310        let (tx, rx) = oneshot::channel();
2311        handle
2312            .cmd_tx
2313            .send(ManagerCommand::GetDesiredState { reply: tx })
2314            .await
2315            .unwrap();
2316        rx.await.unwrap()
2317    }
2318
2319    #[tokio::test]
2320    async fn enable_auto_restart_saves_true_without_starting() {
2321        let backend = MockDesiredStateBackend::new();
2322        let handle = setup_manager_with_backend(Some(backend.clone()));
2323
2324        assert!(!send_is_running(&handle).await);
2325
2326        send_enable_auto_restart(&handle, None).await.unwrap();
2327
2328        // Should NOT start the service
2329        assert!(
2330            !send_is_running(&handle).await,
2331            "enableAutoRestart should not start the service"
2332        );
2333
2334        // But desired state should be saved as true
2335        let ds = backend.last_save().expect("should have saved");
2336        assert!(ds.desired_running, "desired_running should be true");
2337    }
2338
2339    #[tokio::test]
2340    async fn disable_auto_restart_saves_false_without_stopping() {
2341        let backend = MockDesiredStateBackend::new();
2342        let handle = setup_manager_with_factory_and_backend(
2343            Box::new(|| Box::new(BlockingService)),
2344            Some(backend.clone()),
2345        );
2346        let app = tauri::test::mock_app();
2347
2348        // Start the service first
2349        send_start(&handle, app.handle().clone()).await.unwrap();
2350        assert!(send_is_running(&handle).await);
2351
2352        // Disable auto restart
2353        send_disable_auto_restart(&handle).await.unwrap();
2354
2355        // Should NOT stop the service
2356        assert!(
2357            send_is_running(&handle).await,
2358            "disableAutoRestart should not stop the service"
2359        );
2360
2361        // But desired state should be saved as false
2362        let ds = backend.last_save().expect("should have saved");
2363        assert!(!ds.desired_running, "desired_running should be false");
2364    }
2365
2366    #[tokio::test]
2367    async fn enable_auto_restart_with_config_stores_config() {
2368        let backend = MockDesiredStateBackend::new();
2369        let handle = setup_manager_with_backend(Some(backend.clone()));
2370
2371        let config = StartConfig {
2372            service_label: "MyService".into(),
2373            foreground_service_type: "specialUse".into(),
2374        };
2375        send_enable_auto_restart(&handle, Some(config.clone()))
2376            .await
2377            .unwrap();
2378
2379        let ds = backend.last_save().expect("should have saved");
2380        assert!(ds.desired_running);
2381        let saved_config = ds.last_start_config.expect("config should be stored");
2382        assert_eq!(saved_config["serviceLabel"], "MyService");
2383        assert_eq!(saved_config["foregroundServiceType"], "specialUse");
2384        assert!(
2385            ds.last_start_epoch_ms.is_some(),
2386            "should set last_start_epoch_ms"
2387        );
2388    }
2389
2390    #[tokio::test]
2391    async fn disable_auto_restart_clears_recovery_fields() {
2392        let backend = MockDesiredStateBackend::new();
2393        let handle = setup_manager_with_backend(Some(backend.clone()));
2394
2395        // Enable with some recovery state
2396        send_enable_auto_restart(&handle, None).await.unwrap();
2397        {
2398            let mut saves = backend.saves.lock().unwrap();
2399            let last = saves.last_mut().unwrap();
2400            last.recovery_pending = true;
2401            last.recovery_reason = Some("boot".into());
2402            last.restart_attempt = 5;
2403        }
2404
2405        // Disable should clear recovery
2406        send_disable_auto_restart(&handle).await.unwrap();
2407
2408        let ds = backend.last_save().expect("should have saved");
2409        assert!(!ds.desired_running);
2410        assert!(!ds.recovery_pending, "recovery_pending should be cleared");
2411        assert_eq!(
2412            ds.recovery_reason, None,
2413            "recovery_reason should be cleared"
2414        );
2415        assert_eq!(ds.restart_attempt, 0, "restart_attempt should be cleared");
2416    }
2417
2418    #[tokio::test]
2419    async fn get_desired_state_returns_current_state() {
2420        let backend = MockDesiredStateBackend::new();
2421        let handle = setup_manager_with_backend(Some(backend.clone()));
2422
2423        // Initially returns default
2424        let ds = send_get_desired_state(&handle).await;
2425        assert!(ds.is_some());
2426        assert!(!ds.unwrap().desired_running);
2427
2428        // After enable, returns updated state
2429        let config = StartConfig {
2430            service_label: "Test".into(),
2431            ..Default::default()
2432        };
2433        send_enable_auto_restart(&handle, Some(config))
2434            .await
2435            .unwrap();
2436
2437        let ds = send_get_desired_state(&handle)
2438            .await
2439            .expect("should return state");
2440        assert!(ds.desired_running);
2441        assert!(ds.last_start_config.is_some());
2442    }
2443
2444    #[tokio::test]
2445    async fn get_desired_state_returns_none_without_backend() {
2446        let handle = setup_manager();
2447        let ds = send_get_desired_state(&handle).await;
2448        assert!(
2449            ds.is_none(),
2450            "GetDesiredState should return None without a backend"
2451        );
2452    }
2453
2454    #[tokio::test]
2455    async fn enable_disable_no_backend_no_panic() {
2456        let handle = setup_manager();
2457
2458        // These should succeed (no-op) without a backend
2459        send_enable_auto_restart(&handle, None).await.unwrap();
2460        send_disable_auto_restart(&handle).await.unwrap();
2461    }
2462
2463    #[tokio::test]
2464    async fn get_state_stop_clears_start_config_and_recovery() {
2465        let backend = MockDesiredStateBackend::new();
2466        let handle = setup_manager_with_factory_and_backend(
2467            Box::new(|| Box::new(BlockingService)),
2468            Some(backend.clone()),
2469        );
2470        let app = tauri::test::mock_app();
2471
2472        let config = StartConfig {
2473            service_label: "Syncing".into(),
2474            ..Default::default()
2475        };
2476        send_start_with_config(&handle, config, app.handle().clone())
2477            .await
2478            .unwrap();
2479        send_stop(&handle).await.unwrap();
2480
2481        let status = send_get_state(&handle).await;
2482        assert_eq!(status.desired_running, Some(false));
2483        assert_eq!(
2484            status.last_start_config, None,
2485            "last_start_config should be None after stop"
2486        );
2487        assert_eq!(
2488            status.restart_attempt, None,
2489            "restart_attempt should be None after stop"
2490        );
2491        assert_eq!(
2492            status.recovery_reason, None,
2493            "recovery_reason should be None after stop"
2494        );
2495    }
2496}