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