Skip to main content

tauri_plugin_background_service/
lib.rs

1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.7.0")]
2
3//! # tauri-plugin-background-service
4//!
5//! A [Tauri](https://tauri.app) v2 plugin that manages long-lived background service
6//! lifecycle across **Android**, **iOS**, and **Desktop**.
7//!
8//! Users implement the [`BackgroundService`] trait; the plugin handles OS-specific
9//! keepalive (Android foreground service, iOS `BGTaskScheduler`), cancellation via
10//! [`CancellationToken`](tokio_util::sync::CancellationToken), and state management
11//! through an actor pattern.
12//!
13//! ## Quick Start
14//!
15//! ```rust,ignore
16//! use tauri_plugin_background_service::{
17//!     BackgroundService, ServiceContext, ServiceError, init_with_service,
18//! };
19//!
20//! struct MyService;
21//!
22//! #[async_trait::async_trait]
23//! impl<R: tauri::Runtime> BackgroundService<R> for MyService {
24//!     async fn init(&mut self, _ctx: &ServiceContext<R>) -> Result<(), ServiceError> {
25//!         Ok(())
26//!     }
27//!
28//!     async fn run(&mut self, ctx: &ServiceContext<R>) -> Result<(), ServiceError> {
29//!         tokio::select! {
30//!             _ = ctx.shutdown.cancelled() => Ok(()),
31//!             _ = do_work(ctx) => Ok(()),
32//!         }
33//!     }
34//! }
35//!
36//! tauri::Builder::default()
37//!     .plugin(init_with_service(|| MyService))
38//! ```
39//!
40//! ## Platform Behavior
41//!
42//! | Platform | Keepalive Mechanism | Auto-restart |
43//! |----------|-------------------|-------------|
44//! | Android | Foreground service with persistent notification (`START_STICKY`) | Yes |
45//! | iOS | `BGTaskScheduler` with expiration handler | No |
46//! | Desktop | Plain `tokio::spawn` | No |
47//!
48//! ## iOS Setup
49//!
50//! Add the following entries to your app's `Info.plist`:
51//!
52//! ```xml
53//! <key>BGTaskSchedulerPermittedIdentifiers</key>
54//! <array>
55//!     <string>$(BUNDLE_ID).bg-refresh</string>
56//!     <string>$(BUNDLE_ID).bg-processing</string>
57//! </array>
58//!
59//! <key>UIBackgroundModes</key>
60//! <array>
61//!     <string>background-processing</string>
62//!     <string>background-fetch</string>
63//! </array>
64//! ```
65//!
66//! Replace `$(BUNDLE_ID)` with your app's bundle identifier.
67//! Without these entries, `BGTaskScheduler.shared.submit(_:)` will throw at runtime.
68//!
69//! See the [project repository](https://github.com/dardourimohamed/tauri-background-service)
70//! for detailed platform guides and API documentation.
71
72pub mod capabilities;
73pub mod desired_state;
74pub mod error;
75pub mod manager;
76pub mod models;
77pub mod notifier;
78pub mod service_trait;
79pub mod validator;
80
81#[cfg(mobile)]
82pub mod mobile;
83
84#[cfg(feature = "desktop-service")]
85pub mod desktop;
86
87// ─── Public API Surface ──────────────────────────────────────────────────────
88
89pub use error::ServiceError;
90#[doc(hidden)]
91pub use manager::{manager_loop, OnCompleteCallback, ServiceFactory, ServiceManagerHandle};
92#[doc(hidden)]
93pub use models::AutoStartConfig;
94pub use models::{
95    IOSSchedulingStatus, LifecycleState, LifecycleStatus, PendingTaskInfo, Platform,
96    PlatformCapabilities, PluginConfig, PluginEvent, ServiceContext, ServiceState, ServiceStatus,
97    SetupIssue, SetupValidationReport, StartConfig, ValidationIssue,
98};
99pub use notifier::Notifier;
100pub use service_trait::BackgroundService;
101
102#[cfg(all(feature = "desktop-service", unix))]
103pub use desktop::headless::headless_main;
104
105// ─── Internal Imports ────────────────────────────────────────────────────────
106
107use tauri::{
108    plugin::{Builder, TauriPlugin},
109    AppHandle, Manager, Runtime,
110};
111
112use crate::manager::ManagerCommand;
113
114#[cfg(mobile)]
115use crate::manager::MobileKeepalive;
116
117#[cfg(mobile)]
118use mobile::MobileLifecycle;
119
120use std::sync::Arc;
121
122// ─── iOS Plugin Binding ──────────────────────────────────────────────────────
123// Must be at module level. Referenced by mobile::init() when registering
124// the iOS plugin. Only compiled when targeting iOS.
125
126#[cfg(target_os = "ios")]
127tauri::ios_plugin_binding!(init_plugin_background_service);
128
129// ─── iOS Lifecycle Helpers ────────────────────────────────────────────────────
130
131/// Set the on_complete callback so iOS `completeBgTask` fires when `run()` finishes.
132///
133/// Sends `SetOnComplete` to the actor. Must be called **before** `Start` because
134/// `handle_start` captures the callback via `take()` at spawn time.
135#[cfg(target_os = "ios")]
136async fn ios_set_on_complete_callback<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
137    let mobile = app.state::<Arc<MobileLifecycle<R>>>();
138    let mobile_handle = mobile.handle.clone();
139    let manager = app.state::<ServiceManagerHandle<R>>();
140
141    let mob_for_complete = MobileLifecycle {
142        handle: mobile_handle,
143    };
144    manager
145        .cmd_tx
146        .send(ManagerCommand::SetOnComplete {
147            callback: Box::new(move |success| {
148                let _ = mob_for_complete.complete_bg_task(success);
149            }),
150        })
151        .await
152        .map_err(|e| e.to_string())
153}
154
155#[cfg(not(target_os = "ios"))]
156async fn ios_set_on_complete_callback<R: Runtime>(_app: &AppHandle<R>) -> Result<(), String> {
157    Ok(())
158}
159
160/// Spawn a blocking thread that waits for the iOS expiration signal (`waitForCancel`).
161///
162/// Must be called **after** `Start` succeeds so the service is running when the
163/// cancel listener begins waiting. Sends `Stop` to the actor when cancelled.
164///
165/// Three outcomes:
166/// 1. **Resolved invoke** (safety timer / expiration) → `Ok(())` → send `StopWithReason(PlatformExpiration)`.
167/// 2. **Timeout** (default: 4h) → call `cancel_cancel_listener` to unblock the
168///    thread, then send `StopWithReason(PlatformTimeout)`.
169/// 3. **Rejected invoke** (explicit stop / natural completion) → `Err` → no action.
170///
171/// Core cancel listener logic, extracted for testability.
172///
173/// - `wait_fn`: blocking function simulating `wait_for_cancel` (returns `Ok(())` on resolve,
174///   `Err` on reject).
175/// - `cancel_fn`: called on timeout to unblock the `wait_fn` thread.
176/// - `cmd_tx`: channel to send `StopWithReason` command on resolve/timeout.
177/// - `timeout_secs`: how long to wait before treating the listener as timed out.
178///
179/// Returns `true` if a `StopWithReason` was sent, `false` otherwise.
180#[allow(dead_code)] // used on iOS + in tests
181async fn run_cancel_listener<R: Runtime>(
182    wait_fn: Box<dyn FnOnce() -> Result<(), ServiceError> + Send>,
183    cancel_fn: Box<dyn FnOnce() + Send>,
184    cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
185    timeout_secs: u64,
186) -> bool {
187    let handle = tokio::task::spawn_blocking(wait_fn);
188    let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), handle).await;
189    match result {
190        // Resolved invoke (safety timer or expiration) → graceful shutdown
191        Ok(Ok(Ok(()))) => {
192            let (tx, rx) = tokio::sync::oneshot::channel();
193            let _ = cmd_tx
194                .send(ManagerCommand::StopWithReason {
195                    reason: crate::models::StopReason::PlatformExpiration,
196                    reply: tx,
197                })
198                .await;
199            let _ = rx.await;
200            true
201        }
202        // Timeout → unblock the spawn_blocking thread, then graceful shutdown
203        Err(_) => {
204            cancel_fn();
205            let (tx, rx) = tokio::sync::oneshot::channel();
206            let _ = cmd_tx
207                .send(ManagerCommand::StopWithReason {
208                    reason: crate::models::StopReason::PlatformTimeout,
209                    reply: tx,
210                })
211                .await;
212            let _ = rx.await;
213            true
214        }
215        // Rejected invoke (explicit stop or natural completion) → no action
216        _ => false,
217    }
218}
219
220#[cfg(target_os = "ios")]
221fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
222    let mobile = app.state::<Arc<MobileLifecycle<R>>>();
223    let mobile_handle = mobile.handle.clone();
224    let mobile_handle_for_cancel = mobile.handle.clone();
225    let manager = app.state::<ServiceManagerHandle<R>>();
226    let cmd_tx = manager.cmd_tx.clone();
227
228    tokio::spawn(async move {
229        let wait_fn = Box::new(move || {
230            let mob = MobileLifecycle {
231                handle: mobile_handle,
232            };
233            mob.wait_for_cancel()
234        });
235        let cancel_fn = Box::new(move || {
236            let cancel_mob = MobileLifecycle {
237                handle: mobile_handle_for_cancel,
238            };
239            let _ = cancel_mob.cancel_cancel_listener();
240        });
241        // Ignore result — the listener fires-and-forgets.
242        let _ = run_cancel_listener(wait_fn, cancel_fn, cmd_tx, timeout_secs).await;
243    });
244}
245
246#[cfg(not(target_os = "ios"))]
247fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
248
249// ─── Tauri Commands ──────────────────────────────────────────────────────────
250
251#[tauri::command]
252async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
253    // OS service mode: route through persistent IPC client.
254    #[cfg(all(feature = "desktop-service", unix))]
255    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
256        // Check if IPC is connected before sending the start request.
257        if ipc_state.client.is_connected() {
258            return ipc_state
259                .client
260                .start(config)
261                .await
262                .map_err(|e| e.to_string());
263        }
264
265        // IPC is disconnected. Check if auto-start is enabled.
266        let plugin_config = app.state::<PluginConfig>();
267        if !plugin_config.desktop_start_service_if_missing {
268            return Err(ServiceError::Ipc("ipcUnavailable".into()).to_string());
269        }
270
271        // Try to start the OS service and wait for IPC readiness.
272        let socket_path = ipc_state.client.socket_path().display().to_string();
273        let timeout =
274            std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
275
276        use desktop::service_manager::{derive_service_label, DesktopServiceManager};
277        let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
278        let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
279        {
280            let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
281            mgr.start().map_err(|e| e.to_string())?;
282        }
283
284        let connected = ipc_state
285            .client
286            .wait_for_connected(timeout)
287            .await
288            .map_err(|e| e.to_string())?;
289
290        if !connected {
291            return Err(
292                ServiceError::Ipc(format!("ipcUnavailable: socket {socket_path}")).to_string(),
293            );
294        }
295
296        // IPC is now connected — send the start command.
297        return ipc_state
298            .client
299            .start(config)
300            .await
301            .map_err(|e| e.to_string());
302    }
303
304    // In-process mode (default).
305    // iOS: send SetOnComplete before Start so the callback is captured at spawn time.
306    ios_set_on_complete_callback(&app).await?;
307
308    // Mobile keepalive is now handled by the actor (Step 5).
309    // The actor calls start_keepalive AFTER the AlreadyRunning check.
310
311    let manager = app.state::<ServiceManagerHandle<R>>();
312    let (tx, rx) = tokio::sync::oneshot::channel();
313    manager
314        .cmd_tx
315        .send(ManagerCommand::Start {
316            config,
317            reply: tx,
318            app: app.clone(),
319        })
320        .await
321        .map_err(|e| e.to_string())?;
322
323    rx.await
324        .map_err(|e| e.to_string())?
325        .map_err(|e| e.to_string())?;
326
327    // iOS: spawn cancel listener after Start succeeds.
328    let plugin_config = app.state::<PluginConfig>();
329    ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
330
331    Ok(())
332}
333
334#[tauri::command]
335async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
336    // OS service mode: route through persistent IPC client.
337    #[cfg(all(feature = "desktop-service", unix))]
338    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
339        return ipc_state.client.stop().await.map_err(|e| e.to_string());
340    }
341
342    // In-process mode (default).
343    let manager = app.state::<ServiceManagerHandle<R>>();
344    let (tx, rx) = tokio::sync::oneshot::channel();
345    manager
346        .cmd_tx
347        .send(ManagerCommand::Stop { reply: tx })
348        .await
349        .map_err(|e| e.to_string())?;
350
351    rx.await
352        .map_err(|e| e.to_string())?
353        .map_err(|e| e.to_string())
354}
355
356#[tauri::command]
357async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
358    // OS service mode: route through persistent IPC client.
359    #[cfg(all(feature = "desktop-service", unix))]
360    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
361        return ipc_state.client.is_running().await.unwrap_or(false);
362    }
363
364    // In-process mode (default).
365    let manager = app.state::<ServiceManagerHandle<R>>();
366    let (tx, rx) = tokio::sync::oneshot::channel();
367    if manager
368        .cmd_tx
369        .send(ManagerCommand::IsRunning { reply: tx })
370        .await
371        .is_err()
372    {
373        return false;
374    }
375    rx.await.unwrap_or(false)
376}
377
378#[tauri::command]
379async fn get_service_state<R: Runtime>(app: AppHandle<R>) -> Result<models::ServiceStatus, String> {
380    // OS service mode: route through persistent IPC client.
381    #[cfg(all(feature = "desktop-service", unix))]
382    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
383        return ipc_state
384            .client
385            .get_state()
386            .await
387            .map_err(|e| e.to_string());
388    }
389
390    // In-process mode (default).
391    let manager = app.state::<ServiceManagerHandle<R>>();
392    Ok(manager.get_state().await)
393}
394
395#[tauri::command]
396#[allow(unused_variables)]
397async fn get_platform_capabilities<R: Runtime>(
398    app: AppHandle<R>,
399) -> Result<models::PlatformCapabilities, String> {
400    #[cfg(feature = "desktop-service")]
401    let plugin_config = app.state::<PluginConfig>();
402
403    #[cfg(feature = "desktop-service")]
404    let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
405    #[cfg(not(feature = "desktop-service"))]
406    let desktop_mode: Option<&str> = None;
407
408    let (platform, lifecycle_mode) =
409        capabilities::CapabilityProvider::detect_platform(desktop_mode);
410
411    #[cfg(all(feature = "desktop-service", unix))]
412    let os_service_installed = if matches!(lifecycle_mode, models::LifecycleMode::DesktopOsService)
413    {
414        use desktop::service_manager::{derive_service_label, DesktopServiceManager};
415        let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
416        let exec = std::env::current_exe().unwrap_or_default();
417        DesktopServiceManager::new(&label, exec)
418            .map(|_| true)
419            .unwrap_or(false)
420    } else {
421        false
422    };
423
424    #[cfg(not(all(feature = "desktop-service", unix)))]
425    let os_service_installed = false;
426
427    Ok(capabilities::CapabilityProvider::capabilities(
428        platform,
429        lifecycle_mode,
430        os_service_installed,
431    ))
432}
433
434/// Query the iOS scheduling status from the native layer.
435///
436/// Returns `IOSSchedulingStatus` on iOS with scheduling results and desired state.
437/// Returns a default status (not scheduled) on non-iOS platforms.
438#[tauri::command]
439async fn get_scheduling_status<R: Runtime>(
440    app: AppHandle<R>,
441) -> Result<models::IOSSchedulingStatus, String> {
442    #[cfg(target_os = "ios")]
443    {
444        let mobile = app.state::<Arc<MobileLifecycle<R>>>();
445        mobile
446            .get_scheduling_status()
447            .map_err(|e| e.to_string())
448            .and_then(|opt| opt.ok_or_else(|| "no scheduling status available".to_string()))
449    }
450    #[cfg(not(target_os = "ios"))]
451    {
452        let _ = app;
453        Ok(models::IOSSchedulingStatus {
454            refresh_scheduled: false,
455            processing_scheduled: false,
456            refresh_error: None,
457            processing_error: None,
458        })
459    }
460}
461
462/// Query the pending iOS background task info.
463///
464/// Returns `Some(PendingTaskInfo)` on iOS if the app was launched by iOS for
465/// a background task and the info hasn't been cleared yet.
466/// Returns `None` on non-iOS platforms or when no pending task exists.
467#[tauri::command]
468async fn get_pending_bg_task<R: Runtime>(
469    app: AppHandle<R>,
470) -> Result<Option<models::PendingTaskInfo>, String> {
471    #[cfg(target_os = "ios")]
472    {
473        let mobile = app.state::<Arc<MobileLifecycle<R>>>();
474        mobile.get_pending_bg_task().map_err(|e| e.to_string())
475    }
476    #[cfg(not(target_os = "ios"))]
477    {
478        let _ = app;
479        Ok(None)
480    }
481}
482
483/// Enable auto-restart for the background service.
484///
485/// Persists `desired_running=true` with an optional start config WITHOUT
486/// starting the service. This sets the intent for recovery after process
487/// kill or device reboot. The platform recovery mechanisms will use this
488/// to automatically restart the service when conditions allow.
489#[tauri::command]
490async fn enable_auto_restart<R: Runtime>(
491    app: AppHandle<R>,
492    config: Option<StartConfig>,
493) -> Result<(), String> {
494    // OS service mode: route through persistent IPC client.
495    #[cfg(all(feature = "desktop-service", unix))]
496    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
497        return ipc_state
498            .client
499            .enable_auto_restart(config)
500            .await
501            .map_err(|e| e.to_string());
502    }
503
504    let manager = app.state::<ServiceManagerHandle<R>>();
505    let (tx, rx) = tokio::sync::oneshot::channel();
506    manager
507        .cmd_tx
508        .send(ManagerCommand::EnableAutoRestart { config, reply: tx })
509        .await
510        .map_err(|e| e.to_string())?;
511    rx.await
512        .map_err(|e| e.to_string())?
513        .map_err(|e| e.to_string())
514}
515
516/// Disable auto-restart for the background service.
517///
518/// Persists `desired_running=false` and clears recovery fields WITHOUT
519/// stopping the service if it is currently running. After calling this,
520/// the platform recovery mechanisms will no longer attempt to restart the
521/// service after process kill or device reboot.
522#[tauri::command]
523async fn disable_auto_restart<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
524    // OS service mode: route through persistent IPC client.
525    #[cfg(all(feature = "desktop-service", unix))]
526    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
527        return ipc_state
528            .client
529            .disable_auto_restart()
530            .await
531            .map_err(|e| e.to_string());
532    }
533
534    let manager = app.state::<ServiceManagerHandle<R>>();
535    let (tx, rx) = tokio::sync::oneshot::channel();
536    manager
537        .cmd_tx
538        .send(ManagerCommand::DisableAutoRestart { reply: tx })
539        .await
540        .map_err(|e| e.to_string())?;
541    rx.await
542        .map_err(|e| e.to_string())?
543        .map_err(|e| e.to_string())
544}
545
546/// Get the persisted desired-state for the background service.
547///
548/// Returns `Some(DesiredState)` with the current recovery intent and metadata,
549/// or `None` if no persistence backend is configured on the current platform.
550#[tauri::command]
551async fn get_desired_service_state<R: Runtime>(
552    app: AppHandle<R>,
553) -> Result<Option<desired_state::DesiredState>, String> {
554    // OS service mode: route through persistent IPC client.
555    #[cfg(all(feature = "desktop-service", unix))]
556    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
557        return ipc_state
558            .client
559            .get_desired_state()
560            .await
561            .map_err(|e| e.to_string());
562    }
563
564    let manager = app.state::<ServiceManagerHandle<R>>();
565    let (tx, rx) = tokio::sync::oneshot::channel();
566    manager
567        .cmd_tx
568        .send(ManagerCommand::GetDesiredState { reply: tx })
569        .await
570        .map_err(|e| e.to_string())?;
571    rx.await.map_err(|e| e.to_string())
572}
573
574/// Notify the Rust actor of a native platform lifecycle event.
575///
576/// Called from the native layer (Kotlin/Swift) when the OS triggers a
577/// lifecycle action that the Rust actor must handle — e.g. the user pressed
578/// "Stop" on the Android foreground notification, or Android timed out the
579/// foreground service.
580///
581/// The actor maps each [`NativeLifecycleEvent`] variant to the appropriate
582/// [`StopReason`](models::StopReason) and dispatches through
583/// [`handle_stop_with_reason`](manager::handle_stop_with_reason).
584///
585/// This command is not intended for end-user consumption — it is called by
586/// the native plugin code.
587#[tauri::command]
588async fn native_lifecycle_event<R: Runtime>(
589    app: AppHandle<R>,
590    event: models::NativeLifecycleEvent,
591) -> Result<(), String> {
592    let manager = app.state::<ServiceManagerHandle<R>>();
593    manager
594        .send_native_lifecycle_event(event)
595        .await
596        .map_err(|e| e.to_string())
597}
598
599/// Validate the background service setup for the current platform.
600///
601/// Returns a [`SetupValidationReport`] with errors (blocking) and warnings
602/// (non-blocking) about platform-specific prerequisites.
603#[tauri::command]
604#[allow(unused_variables)]
605async fn validate_setup<R: Runtime>(
606    app: AppHandle<R>,
607) -> Result<models::SetupValidationReport, String> {
608    // OS service mode: route through persistent IPC client.
609    #[cfg(all(feature = "desktop-service", unix))]
610    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
611        return ipc_state
612            .client
613            .validate_setup()
614            .await
615            .map_err(|e| e.to_string());
616    }
617
618    #[cfg(feature = "desktop-service")]
619    let plugin_config = app.state::<PluginConfig>();
620
621    #[cfg(feature = "desktop-service")]
622    let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
623    #[cfg(not(feature = "desktop-service"))]
624    let desktop_mode: Option<&str> = None;
625
626    let (platform, _) = capabilities::CapabilityProvider::detect_platform(desktop_mode);
627    Ok(validator::SetupValidator::validate(platform))
628}
629
630/// Get the complete lifecycle status of the background service.
631///
632/// Returns a [`LifecycleStatus`] snapshot with current state, desired state,
633/// recovery status, platform capabilities, and validation issues.
634#[tauri::command]
635async fn get_lifecycle_status<R: Runtime>(
636    app: AppHandle<R>,
637) -> Result<models::LifecycleStatus, String> {
638    // OS service mode: route through persistent IPC client.
639    #[cfg(all(feature = "desktop-service", unix))]
640    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
641        return ipc_state
642            .client
643            .get_lifecycle_status()
644            .await
645            .map_err(|e| e.to_string());
646    }
647
648    #[cfg(feature = "desktop-service")]
649    let plugin_config = app.state::<PluginConfig>();
650
651    #[cfg(feature = "desktop-service")]
652    let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
653    #[cfg(not(feature = "desktop-service"))]
654    let desktop_mode: Option<&str> = None;
655
656    let manager = app.state::<ServiceManagerHandle<R>>();
657    let (tx, rx) = tokio::sync::oneshot::channel();
658    manager
659        .cmd_tx
660        .send(ManagerCommand::GetLifecycleStatus {
661            desktop_mode: desktop_mode.map(|s| s.to_string()),
662            reply: tx,
663        })
664        .await
665        .map_err(|e| e.to_string())?;
666
667    rx.await.map_err(|e| e.to_string())
668}
669
670/// Configure recovery (auto-restart) for the background service.
671///
672/// When `enabled` is `true`, persists `desired_running=true` with an optional
673/// start config (for recovery after process kill or device reboot).
674/// When `enabled` is `false`, clears the recovery intent.
675#[tauri::command]
676async fn configure_recovery<R: Runtime>(
677    app: AppHandle<R>,
678    enabled: bool,
679    config: Option<StartConfig>,
680) -> Result<(), String> {
681    if enabled {
682        enable_auto_restart(app, config).await
683    } else {
684        disable_auto_restart(app).await
685    }
686}
687
688// ─── Desktop OS Service State & Commands ──────────────────────────────────────
689
690/// Managed state indicating OS service mode via IPC.
691///
692/// When present as managed state, the `start`/`stop`/`is_running` commands
693/// route through the persistent IPC client instead of the in-process actor loop.
694#[cfg(all(feature = "desktop-service", unix))]
695struct DesktopIpcState {
696    client: desktop::ipc_client::PersistentIpcClientHandle,
697}
698
699#[cfg(feature = "desktop-service")]
700#[tauri::command]
701async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
702    use desktop::service_manager::{derive_service_label, DesktopServiceManager};
703    let plugin_config = app.state::<PluginConfig>();
704    let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
705    let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
706
707    // Validate that the executable exists and is executable.
708    if !exec_path.exists() {
709        return Err(format!(
710            "Current executable does not exist at {}: cannot install OS service",
711            exec_path.display()
712        ));
713    }
714
715    // Verify the binary supports --service-label by spawning it with the flag
716    // and checking for a specific exit behavior. We use a timeout to avoid
717    // hanging if the binary starts a GUI.
718    let validate_result = tokio::time::timeout(
719        std::time::Duration::from_secs(5),
720        tokio::process::Command::new(&exec_path)
721            .arg("--service-label")
722            .arg(&label)
723            .arg("--validate-service-install")
724            .output(),
725    )
726    .await;
727
728    match validate_result {
729        Ok(Ok(output)) => {
730            let stdout = String::from_utf8_lossy(&output.stdout);
731            if !stdout.trim().contains("ok") {
732                return Err("Binary does not handle --validate-service-install. \
733                     Ensure headless_main() is called from your app's main()."
734                    .into());
735            }
736        }
737        Ok(Err(e)) => {
738            return Err(format!(
739                "Failed to validate executable for --service-label: {e}"
740            ));
741        }
742        Err(_) => {
743            // Timed out — the binary probably started the GUI instead of handling
744            // the service flag. Warn but don't block installation.
745            log::warn!(
746                "Timeout validating --service-label support. \
747                 Ensure your app's main() handles the --service-label argument \
748                 and calls headless_main()."
749            );
750        }
751    }
752
753    let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
754    use desktop::service_manager::InstallOptions;
755    let options = InstallOptions {
756        autostart: plugin_config.desktop_service_autostart,
757        restart_delay_secs: None,
758        journal_output: true,
759        log_path: None,
760    };
761    mgr.install(&options).map_err(|e| e.to_string())
762}
763
764#[cfg(feature = "desktop-service")]
765#[tauri::command]
766async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
767    use desktop::service_manager::{derive_service_label, DesktopServiceManager};
768    let plugin_config = app.state::<PluginConfig>();
769    let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
770    let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
771    let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
772    mgr.uninstall().map_err(|e| e.to_string())
773}
774
775// ─── Desktop OS Service Start/Stop/Status Commands ────────────────────────────
776
777/// Returns the standard "not yet supported" error for Windows OS-service mode.
778#[cfg(feature = "desktop-service")]
779#[allow(dead_code)] // Used on non-Unix targets and in tests
780fn windows_os_service_unsupported() -> ServiceError {
781    ServiceError::Platform("Windows OS-service mode is not yet supported".into())
782}
783
784/// Build an [`OsServiceStatus`] from available information.
785///
786/// Gathers the service label, mode string, IPC connection state, socket path,
787/// and optional last error into a status snapshot.
788#[cfg(all(feature = "desktop-service", unix))]
789fn build_os_service_status(
790    label: &str,
791    ipc_connected: bool,
792    socket_path: Option<String>,
793    last_error: Option<String>,
794) -> models::OsServiceStatus {
795    let mode = if cfg!(target_os = "macos") {
796        "launchd"
797    } else {
798        "systemd"
799    };
800
801    let installed = if ipc_connected {
802        models::OsServiceInstallState::Running
803    } else {
804        // If not running via IPC, we can't easily determine install state
805        // without calling external tools. Default to Installed if the manager
806        // was constructable (caller checks this before calling build).
807        models::OsServiceInstallState::Installed
808    };
809
810    models::OsServiceStatus {
811        label: label.to_string(),
812        mode: mode.to_string(),
813        installed,
814        ipc_connected,
815        socket_path,
816        last_error,
817    }
818}
819
820/// Start the OS-level background service (desktop only).
821///
822/// On Unix, delegates to [`DesktopServiceManager::start()`].
823/// On Windows, returns `ServiceError::Platform`.
824#[cfg(feature = "desktop-service")]
825#[tauri::command]
826async fn start_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
827    #[cfg(unix)]
828    {
829        use desktop::service_manager::{derive_service_label, DesktopServiceManager};
830        let plugin_config = app.state::<PluginConfig>();
831        let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
832        let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
833        let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
834        mgr.start().map_err(|e| e.to_string())
835    }
836    #[cfg(not(unix))]
837    {
838        let _ = app;
839        Err(windows_os_service_unsupported().to_string())
840    }
841}
842
843/// Stop the OS-level background service (desktop only).
844///
845/// On Unix, delegates to [`DesktopServiceManager::stop()`].
846/// On Windows, returns `ServiceError::Platform`.
847#[cfg(feature = "desktop-service")]
848#[tauri::command]
849async fn stop_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
850    #[cfg(unix)]
851    {
852        use desktop::service_manager::{derive_service_label, DesktopServiceManager};
853        let plugin_config = app.state::<PluginConfig>();
854        let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
855        let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
856        let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
857        mgr.stop().map_err(|e| e.to_string())
858    }
859    #[cfg(not(unix))]
860    {
861        let _ = app;
862        Err(windows_os_service_unsupported().to_string())
863    }
864}
865
866/// Restart the OS-level background service (desktop only).
867///
868/// On Unix, calls stop then start. On Windows, returns `ServiceError::Platform`.
869#[cfg(feature = "desktop-service")]
870#[tauri::command]
871async fn restart_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
872    #[cfg(unix)]
873    {
874        use desktop::service_manager::{derive_service_label, DesktopServiceManager};
875        let plugin_config = app.state::<PluginConfig>();
876        let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
877        let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
878        let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
879        mgr.stop().ok(); // Best-effort stop — service may not be running.
880        mgr.start().map_err(|e| e.to_string())
881    }
882    #[cfg(not(unix))]
883    {
884        let _ = app;
885        Err(windows_os_service_unsupported().to_string())
886    }
887}
888
889/// Get the status of the OS-level background service (desktop only).
890///
891/// On Unix, returns [`OsServiceStatus`] with label, mode, IPC state, socket path.
892/// On Windows, returns `ServiceError::Platform`.
893#[cfg(feature = "desktop-service")]
894#[tauri::command]
895async fn get_os_service_status<R: Runtime>(
896    app: AppHandle<R>,
897) -> Result<models::OsServiceStatus, String> {
898    #[cfg(unix)]
899    {
900        use desktop::service_manager::derive_service_label;
901        let plugin_config = app.state::<PluginConfig>();
902        let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
903
904        let ipc_connected = app
905            .try_state::<DesktopIpcState>()
906            .map(|s| s.client.is_connected())
907            .unwrap_or(false);
908
909        let socket_path = desktop::ipc::socket_path(&label)
910            .ok()
911            .map(|p| p.to_string_lossy().to_string());
912
913        Ok(build_os_service_status(
914            &label,
915            ipc_connected,
916            socket_path,
917            None,
918        ))
919    }
920    #[cfg(not(unix))]
921    {
922        let _ = app;
923        Err(windows_os_service_unsupported().to_string())
924    }
925}
926
927// ─── Plugin Builder ──────────────────────────────────────────────────────────
928
929/// Create the Tauri plugin with your service factory.
930///
931/// ```rust,ignore
932/// // MyService must implement BackgroundService<R>
933/// tauri::Builder::default()
934///     .plugin(tauri_plugin_background_service::init_with_service(|| MyService::new()))
935/// ```
936pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
937where
938    R: Runtime,
939    S: BackgroundService<R>,
940    F: Fn() -> S + Send + Sync + 'static,
941{
942    let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
943
944    Builder::<R, PluginConfig>::new("background-service")
945        .invoke_handler(tauri::generate_handler![
946            start,
947            stop,
948            is_running,
949            get_service_state,
950            get_platform_capabilities,
951            get_scheduling_status,
952            get_pending_bg_task,
953            enable_auto_restart,
954            disable_auto_restart,
955            get_desired_service_state,
956            native_lifecycle_event,
957            validate_setup,
958            get_lifecycle_status,
959            configure_recovery,
960            #[cfg(feature = "desktop-service")]
961            install_service,
962            #[cfg(feature = "desktop-service")]
963            uninstall_service,
964            #[cfg(feature = "desktop-service")]
965            start_os_service,
966            #[cfg(feature = "desktop-service")]
967            stop_os_service,
968            #[cfg(feature = "desktop-service")]
969            restart_os_service,
970            #[cfg(feature = "desktop-service")]
971            get_os_service_status,
972        ])
973        .setup(move |app, api| {
974            let config = api.config().clone();
975            let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(config.channel_capacity);
976            #[cfg(mobile)]
977            let mobile_cmd_tx = cmd_tx.clone();
978            let handle = ServiceManagerHandle::new(cmd_tx);
979            app.manage(handle);
980
981            app.manage(config.clone());
982
983            let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
984            let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
985            let ios_earliest_refresh_begin_minutes = config.ios_earliest_refresh_begin_minutes;
986            let ios_earliest_processing_begin_minutes =
987                config.ios_earliest_processing_begin_minutes;
988            let ios_requires_external_power = config.ios_requires_external_power;
989            let ios_requires_network_connectivity = config.ios_requires_network_connectivity;
990
991            // Desktop: construct file-backed desired-state persistence backend.
992            #[cfg(not(mobile))]
993            let desired_state_backend: Option<Arc<dyn desired_state::DesiredStateBackend>> = {
994                match app.path().app_data_dir() {
995                    Ok(data_dir) => Some(Arc::new(desired_state::FileDesiredStateBackend::new(data_dir))),
996                    Err(e) => {
997                        log::warn!("Failed to get app data dir for desired-state persistence: {e}");
998                        None
999                    }
1000                }
1001            };
1002            #[cfg(mobile)]
1003            let desired_state_backend: Option<Arc<dyn desired_state::DesiredStateBackend>> = None;
1004
1005            // Mode dispatch: spawn in-process actor or configure IPC for OS service.
1006            #[cfg(all(feature = "desktop-service", unix))]
1007            if config.desktop_service_mode == "osService" {
1008                // OS service mode: spawn persistent IPC client.
1009                let label = desktop::service_manager::derive_service_label(
1010                    app,
1011                    config.desktop_service_label.as_deref(),
1012                );
1013                let socket_path = desktop::ipc::socket_path(&label)?;
1014                let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
1015                    socket_path,
1016                    app.app_handle().clone(),
1017                );
1018                app.manage(DesktopIpcState { client });
1019            } else {
1020                // In-process mode (default): spawn the actor loop.
1021                let factory = boxed_factory;
1022                tauri::async_runtime::spawn(manager_loop(
1023                    cmd_rx,
1024                    factory,
1025                    ios_safety_timeout_secs,
1026                    ios_processing_safety_timeout_secs,
1027                    ios_earliest_refresh_begin_minutes,
1028                    ios_earliest_processing_begin_minutes,
1029                    ios_requires_external_power,
1030                    ios_requires_network_connectivity,
1031                    desired_state_backend,
1032                ));
1033            }
1034
1035            #[cfg(all(feature = "desktop-service", not(unix)))]
1036            {
1037                // On non-Unix platforms, only in-process mode is available.
1038                let factory = boxed_factory;
1039                tauri::async_runtime::spawn(manager_loop(
1040                    cmd_rx,
1041                    factory,
1042                    ios_safety_timeout_secs,
1043                    ios_processing_safety_timeout_secs,
1044                    ios_earliest_refresh_begin_minutes,
1045                    ios_earliest_processing_begin_minutes,
1046                    ios_requires_external_power,
1047                    ios_requires_network_connectivity,
1048                    desired_state_backend,
1049                ));
1050            }
1051
1052            #[cfg(not(feature = "desktop-service"))]
1053            {
1054                let factory = boxed_factory;
1055                tauri::async_runtime::spawn(manager_loop(
1056                    cmd_rx,
1057                    factory,
1058                    ios_safety_timeout_secs,
1059                    ios_processing_safety_timeout_secs,
1060                    ios_earliest_refresh_begin_minutes,
1061                    ios_earliest_processing_begin_minutes,
1062                    ios_requires_external_power,
1063                    ios_requires_network_connectivity,
1064                    desired_state_backend,
1065                ));
1066            }
1067
1068            #[cfg(mobile)]
1069            {
1070                let lifecycle = mobile::init(app, api)?;
1071                let lifecycle_arc = Arc::new(lifecycle);
1072
1073                // Send SetMobile to actor so keepalive is managed by the actor.
1074                let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
1075                if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
1076                    mobile: mobile_trait,
1077                }) {
1078                    log::error!("Failed to send SetMobile command: {e}");
1079                }
1080
1081                // Store for iOS callbacks and Android auto-start helpers.
1082                app.manage(lifecycle_arc);
1083            }
1084
1085            // iOS: auto-start when launched by OS for a pending BGTask.
1086            // Checks native pending task info and desired_running flag.
1087            // If both are set, sends a Start command with the stored config.
1088            #[cfg(target_os = "ios")]
1089            {
1090                let mobile = app.state::<Arc<MobileLifecycle<R>>>();
1091
1092                match mobile.get_pending_bg_task() {
1093                    Ok(Some(_pending)) => {
1094                        // Check desired_running and last_start_config from native.
1095                        let should_start = mobile
1096                            .get_scheduling_status_raw()
1097                            .ok()
1098                            .and_then(|v| {
1099                                let desired = v.get("desiredRunning")?.as_bool()?;
1100                                let config_str = v.get("lastStartConfig")?.as_str()?;
1101                                Some((desired, config_str.to_string()))
1102                            });
1103
1104                        if let Some((true, config_str)) = should_start {
1105                            if let Ok(config) =
1106                                serde_json::from_str::<StartConfig>(&config_str)
1107                            {
1108                                let manager = app.state::<ServiceManagerHandle<R>>();
1109                                let cmd_tx = manager.cmd_tx.clone();
1110                                let app_clone = app.app_handle().clone();
1111
1112                                // Capture timeout before spawn for cancel listener.
1113                                let plugin_config = app.state::<PluginConfig>();
1114                                let timeout_secs = plugin_config.ios_cancel_listener_timeout_secs;
1115
1116                                // Set on_complete callback for iOS completeBgTask.
1117                                let mob_handle = mobile.handle.clone();
1118                                if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
1119                                    callback: Box::new(move |success| {
1120                                        let ml =
1121                                            MobileLifecycle { handle: mob_handle.clone() };
1122                                        let _ = ml.complete_bg_task(success);
1123                                    }),
1124                                }) {
1125                                    log::error!("Failed to send SetOnComplete for iOS auto-start: {e}");
1126                                }
1127
1128                                tauri::async_runtime::spawn(async move {
1129                                    let (tx, rx) = tokio::sync::oneshot::channel();
1130                                    if cmd_tx
1131                                        .send(ManagerCommand::Start {
1132                                            config,
1133                                            reply: tx,
1134                                            app: app_clone.clone(),
1135                                        })
1136                                        .await
1137                                        .is_err()
1138                                    {
1139                                        return;
1140                                    }
1141                                    if let Ok(Ok(())) = rx.await {
1142                                        ios_spawn_cancel_listener(&app_clone, timeout_secs);
1143                                    }
1144                                });
1145
1146                                log::info!("iOS: auto-starting service for pending BGTask");
1147                                let _ = mobile.clear_pending_bg_task();
1148                            } else {
1149                                log::warn!("iOS: failed to parse stored start config — preserving pending task info for diagnostics");
1150                            }
1151                        } else {
1152                            log::info!(
1153                                "iOS: pending BGTask but desired_running is false, skipping auto-start"
1154                            );
1155                            let _ = mobile.clear_pending_bg_task();
1156                        }
1157                    }
1158                    Ok(None) => {
1159                        // No pending BGTask — normal launch.
1160                    }
1161                    Err(e) => {
1162                        log::warn!("iOS: failed to get pending BGTask: {e}");
1163                    }
1164                }
1165            }
1166
1167            // Android: auto-start detection after OS-initiated service restart.
1168            // When LifecycleService is restarted by START_STICKY, it sets an
1169            // auto-start flag in SharedPreferences and launches the Activity.
1170            // This block detects that flag, clears it, and starts the service
1171            // via the actor.
1172            #[cfg(target_os = "android")]
1173            {
1174                let mobile = app.state::<Arc<MobileLifecycle<R>>>();
1175                if let Ok(Some(config)) = mobile.get_auto_start_config() {
1176                    let _ = mobile.clear_auto_start_config();
1177
1178                    // Keepalive is now handled by the actor's handle_start.
1179                    // Just send Start command — actor will call start_keepalive.
1180
1181                    let manager = app.state::<ServiceManagerHandle<R>>();
1182                    let cmd_tx = manager.cmd_tx.clone();
1183                    let app_clone = app.app_handle().clone();
1184
1185                    // Set a no-op on_complete callback for consistency with iOS path.
1186                    if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
1187                        callback: Box::new(|_| {}),
1188                    }) {
1189                        log::error!("Failed to send SetOnComplete command: {e}");
1190                    }
1191
1192                    tauri::async_runtime::spawn(async move {
1193                        let (tx, rx) = tokio::sync::oneshot::channel();
1194                        if cmd_tx
1195                            .send(ManagerCommand::Start {
1196                                config,
1197                                reply: tx,
1198                                app: app_clone,
1199                            })
1200                            .await
1201                            .is_err()
1202                        {
1203                            return;
1204                        }
1205                        let _ = rx.await;
1206                    });
1207
1208                    let _ = mobile.move_task_to_background();
1209                }
1210            }
1211
1212            Ok(())
1213        })
1214        .on_event(|app, event| {
1215            if let tauri::RunEvent::Exit = event {
1216                // In OS service mode, the service runs in a separate process — skip.
1217                #[cfg(all(feature = "desktop-service", unix))]
1218                if app.try_state::<DesktopIpcState>().is_some() {
1219                    return;
1220                }
1221                let manager = app.state::<ServiceManagerHandle<R>>();
1222                if let Err(e) = manager.stop_blocking() {
1223                    log::warn!("Failed to stop background service on app exit: {e}");
1224                }
1225            }
1226        })
1227        .build()
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233    use async_trait::async_trait;
1234    use std::sync::atomic::{AtomicUsize, Ordering};
1235    use std::sync::Arc;
1236
1237    /// Minimal service for testing type compatibility.
1238    struct DummyService;
1239
1240    #[async_trait]
1241    impl BackgroundService<tauri::Wry> for DummyService {
1242        async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
1243            Ok(())
1244        }
1245
1246        async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
1247            Ok(())
1248        }
1249    }
1250
1251    // ── Construction Tests ───────────────────────────────────────────────
1252
1253    #[test]
1254    fn service_manager_handle_constructs() {
1255        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
1256        let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
1257    }
1258
1259    #[test]
1260    fn factory_produces_boxed_service() {
1261        let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
1262        let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
1263    }
1264
1265    #[test]
1266    fn handle_factory_creates_fresh_instances() {
1267        let count = Arc::new(AtomicUsize::new(0));
1268        let count_clone = count.clone();
1269
1270        let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
1271            count_clone.fetch_add(1, Ordering::SeqCst);
1272            Box::new(DummyService)
1273        });
1274
1275        let _ = (factory)();
1276        let _ = (factory)();
1277
1278        assert_eq!(count.load(Ordering::SeqCst), 2);
1279    }
1280
1281    // ── Compile-time Tests ───────────────────────────────────────────────
1282
1283    /// Verify `init_with_service` returns `TauriPlugin<R>`.
1284    #[allow(dead_code)]
1285    fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
1286        factory: F,
1287    ) -> TauriPlugin<R, PluginConfig>
1288    where
1289        S: BackgroundService<R>,
1290        F: Fn() -> S + Send + Sync + 'static,
1291    {
1292        init_with_service(factory)
1293    }
1294
1295    /// Verify `start` command signature is generic over `R: Runtime`.
1296    #[allow(dead_code)]
1297    async fn start_command_signature<R: Runtime>(
1298        app: AppHandle<R>,
1299        config: StartConfig,
1300    ) -> Result<(), String> {
1301        start(app, config).await
1302    }
1303
1304    /// Verify `stop` command signature is generic over `R: Runtime`.
1305    #[allow(dead_code)]
1306    async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1307        stop(app).await
1308    }
1309
1310    /// Verify `is_running` command signature is async and generic over `R: Runtime`.
1311    #[allow(dead_code)]
1312    async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
1313        is_running(app).await
1314    }
1315
1316    /// Verify `get_service_state` command signature is async and generic over `R: Runtime`.
1317    #[allow(dead_code)]
1318    async fn get_service_state_command_signature<R: Runtime>(
1319        app: AppHandle<R>,
1320    ) -> Result<models::ServiceStatus, String> {
1321        get_service_state(app).await
1322    }
1323
1324    /// Verify `get_scheduling_status` command signature is async and generic over `R: Runtime`.
1325    #[allow(dead_code)]
1326    async fn get_scheduling_status_command_signature<R: Runtime>(
1327        app: AppHandle<R>,
1328    ) -> Result<models::IOSSchedulingStatus, String> {
1329        get_scheduling_status(app).await
1330    }
1331
1332    /// Verify `get_pending_bg_task` command signature is async and generic over `R: Runtime`.
1333    #[allow(dead_code)]
1334    async fn get_pending_bg_task_command_signature<R: Runtime>(
1335        app: AppHandle<R>,
1336    ) -> Result<Option<models::PendingTaskInfo>, String> {
1337        get_pending_bg_task(app).await
1338    }
1339
1340    /// Verify `enable_auto_restart` command signature is async and generic over `R: Runtime`.
1341    #[allow(dead_code)]
1342    async fn enable_auto_restart_command_signature<R: Runtime>(
1343        app: AppHandle<R>,
1344        config: Option<StartConfig>,
1345    ) -> Result<(), String> {
1346        enable_auto_restart(app, config).await
1347    }
1348
1349    /// Verify `disable_auto_restart` command signature is async and generic over `R: Runtime`.
1350    #[allow(dead_code)]
1351    async fn disable_auto_restart_command_signature<R: Runtime>(
1352        app: AppHandle<R>,
1353    ) -> Result<(), String> {
1354        disable_auto_restart(app).await
1355    }
1356
1357    /// Verify `get_desired_service_state` command signature is async and generic over `R: Runtime`.
1358    #[allow(dead_code)]
1359    async fn get_desired_service_state_command_signature<R: Runtime>(
1360        app: AppHandle<R>,
1361    ) -> Result<Option<desired_state::DesiredState>, String> {
1362        get_desired_service_state(app).await
1363    }
1364
1365    /// Verify `validate_setup` command signature is async and generic over `R: Runtime`.
1366    #[allow(dead_code)]
1367    async fn validate_setup_command_signature<R: Runtime>(
1368        app: AppHandle<R>,
1369    ) -> Result<models::SetupValidationReport, String> {
1370        validate_setup(app).await
1371    }
1372
1373    /// Verify `native_lifecycle_event` command signature is async and generic over `R: Runtime`.
1374    #[allow(dead_code)]
1375    async fn native_lifecycle_event_command_signature<R: Runtime>(
1376        app: AppHandle<R>,
1377        event: models::NativeLifecycleEvent,
1378    ) -> Result<(), String> {
1379        native_lifecycle_event(app, event).await
1380    }
1381
1382    /// Verify `get_lifecycle_status` command signature is async and generic over `R: Runtime`.
1383    #[allow(dead_code)]
1384    async fn get_lifecycle_status_command_signature<R: Runtime>(
1385        app: AppHandle<R>,
1386    ) -> Result<models::LifecycleStatus, String> {
1387        get_lifecycle_status(app).await
1388    }
1389
1390    /// Verify `configure_recovery` command signature is async and generic over `R: Runtime`.
1391    #[allow(dead_code)]
1392    async fn configure_recovery_command_signature<R: Runtime>(
1393        app: AppHandle<R>,
1394        enabled: bool,
1395        config: Option<StartConfig>,
1396    ) -> Result<(), String> {
1397        configure_recovery(app, enabled, config).await
1398    }
1399
1400    // ── Desktop IPC State Tests ─────────────────────────────────────────
1401
1402    /// Verify PersistentIpcClientHandle can be constructed.
1403    #[cfg(all(feature = "desktop-service", unix))]
1404    #[tokio::test]
1405    async fn desktop_ipc_state_with_persistent_client() {
1406        use desktop::ipc_client::PersistentIpcClientHandle;
1407        let app = tauri::test::mock_app();
1408        let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
1409        let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
1410        // The client is spawned but may not be connected yet — that's fine.
1411        // Just verify we can construct the state.
1412        let _state = DesktopIpcState { client };
1413    }
1414
1415    // ── Desktop Command Compile-time Tests ────────────────────────────────
1416
1417    /// Verify `install_service` command signature is generic over `R: Runtime`.
1418    #[cfg(feature = "desktop-service")]
1419    #[allow(dead_code)]
1420    async fn install_service_command_signature<R: Runtime>(
1421        app: AppHandle<R>,
1422    ) -> Result<(), String> {
1423        install_service(app).await
1424    }
1425
1426    /// Verify `uninstall_service` command signature is generic over `R: Runtime`.
1427    #[cfg(feature = "desktop-service")]
1428    #[allow(dead_code)]
1429    async fn uninstall_service_command_signature<R: Runtime>(
1430        app: AppHandle<R>,
1431    ) -> Result<(), String> {
1432        uninstall_service(app).await
1433    }
1434
1435    /// Verify `start_os_service` command signature is generic over `R: Runtime`.
1436    #[cfg(feature = "desktop-service")]
1437    #[allow(dead_code)]
1438    async fn start_os_service_command_signature<R: Runtime>(
1439        app: AppHandle<R>,
1440    ) -> Result<(), String> {
1441        start_os_service(app).await
1442    }
1443
1444    /// Verify `stop_os_service` command signature is generic over `R: Runtime`.
1445    #[cfg(feature = "desktop-service")]
1446    #[allow(dead_code)]
1447    async fn stop_os_service_command_signature<R: Runtime>(
1448        app: AppHandle<R>,
1449    ) -> Result<(), String> {
1450        stop_os_service(app).await
1451    }
1452
1453    /// Verify `restart_os_service` command signature is generic over `R: Runtime`.
1454    #[cfg(feature = "desktop-service")]
1455    #[allow(dead_code)]
1456    async fn restart_os_service_command_signature<R: Runtime>(
1457        app: AppHandle<R>,
1458    ) -> Result<(), String> {
1459        restart_os_service(app).await
1460    }
1461
1462    /// Verify `get_os_service_status` command signature is generic over `R: Runtime`.
1463    #[cfg(feature = "desktop-service")]
1464    #[allow(dead_code)]
1465    async fn get_os_service_status_command_signature<R: Runtime>(
1466        app: AppHandle<R>,
1467    ) -> Result<models::OsServiceStatus, String> {
1468        get_os_service_status(app).await
1469    }
1470
1471    // ── Desktop OS Service Command Routing Tests ──────────────────────────
1472
1473    /// Test that `windows_os_service_unsupported()` returns a Platform error.
1474    #[cfg(feature = "desktop-service")]
1475    #[test]
1476    fn windows_stub_returns_platform_error() {
1477        let err = windows_os_service_unsupported();
1478        assert!(
1479            matches!(err, ServiceError::Platform(ref msg) if msg.contains("not yet supported")),
1480            "Expected Platform error with 'not yet supported', got: {err}"
1481        );
1482    }
1483
1484    /// Test that `build_os_service_status` produces a valid OsServiceStatus
1485    /// with the correct fields populated.
1486    #[cfg(all(feature = "desktop-service", unix))]
1487    #[test]
1488    fn build_os_service_status_populates_fields() {
1489        let status = build_os_service_status(
1490            "com.example.bg-service",
1491            true,
1492            Some("/tmp/test.sock".to_string()),
1493            None,
1494        );
1495        assert_eq!(status.label, "com.example.bg-service");
1496        assert!(status.ipc_connected);
1497        assert_eq!(status.socket_path.as_deref(), Some("/tmp/test.sock"));
1498        assert!(status.last_error.is_none());
1499    }
1500
1501    /// Test that `build_os_service_status` includes the correct mode string.
1502    #[cfg(all(feature = "desktop-service", unix))]
1503    #[test]
1504    fn build_os_service_status_mode_is_correct() {
1505        let status = build_os_service_status("test", false, None, None);
1506        #[cfg(target_os = "linux")]
1507        assert_eq!(status.mode, "systemd");
1508        #[cfg(target_os = "macos")]
1509        assert_eq!(status.mode, "launchd");
1510    }
1511
1512    // ── On-Event Shutdown Compile-time Test ─────────────────────────────────
1513
1514    /// Verify the on_event closure accessing ServiceManagerHandle<R> from managed
1515    /// state type-checks. Ensures the generic R is properly threaded through in
1516    /// the on_event context where stop_blocking() is called synchronously.
1517    #[allow(dead_code)]
1518    fn on_event_shutdown_closure_type_checks<R: Runtime>(_app: &AppHandle<R>) {
1519        let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
1520            if let tauri::RunEvent::Exit = event {
1521                let manager = _app.state::<ServiceManagerHandle<R>>();
1522                if let Err(_e) = manager.stop_blocking() {
1523                    log::warn!("bg service shutdown on exit failed: {_e}");
1524                }
1525            }
1526        };
1527    }
1528
1529    // ── Cancel Listener Tests ───────────────────────────────────────────────
1530
1531    use crate::manager::ManagerCommand;
1532    use std::sync::atomic::AtomicBool;
1533
1534    /// Helper: spawn a background task that accepts one StopWithReason command and replies Ok(()).
1535    /// Returns a oneshot receiver that yields Some(reason) if StopWithReason was received.
1536    fn spawn_stop_drain(
1537        mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
1538    ) -> tokio::sync::oneshot::Receiver<Option<crate::models::StopReason>> {
1539        let (seen_tx, seen_rx) =
1540            tokio::sync::oneshot::channel::<Option<crate::models::StopReason>>();
1541        tokio::spawn(async move {
1542            let result =
1543                tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
1544            match result {
1545                Ok(Some(ManagerCommand::StopWithReason { reason, reply })) => {
1546                    let _ = reply.send(Ok(()));
1547                    let _ = seen_tx.send(Some(reason));
1548                }
1549                _ => {
1550                    let _ = seen_tx.send(None);
1551                }
1552            }
1553        });
1554        seen_rx
1555    }
1556
1557    #[tokio::test]
1558    async fn cancel_listener_resolved_invoke_sends_stop_with_reason() {
1559        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1560        let seen = spawn_stop_drain(cmd_rx);
1561
1562        // wait_fn returns Ok(()) → simulates resolved invoke (safety timer / expiration)
1563        let stop_sent = run_cancel_listener(
1564            Box::new(|| Ok(())),
1565            Box::new(|| {}),
1566            cmd_tx,
1567            5, // timeout, shouldn't matter since wait_fn returns immediately
1568        )
1569        .await;
1570
1571        assert!(stop_sent, "resolved invoke should return true");
1572        let reason = seen.await.unwrap();
1573        assert_eq!(
1574            reason,
1575            Some(crate::models::StopReason::PlatformExpiration),
1576            "StopWithReason(PlatformExpiration) should be sent on resolved invoke"
1577        );
1578    }
1579
1580    #[tokio::test]
1581    async fn cancel_listener_rejected_invoke_no_stop() {
1582        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1583        let seen = spawn_stop_drain(cmd_rx);
1584
1585        // wait_fn returns Err → simulates rejected invoke (explicit stop / completion)
1586        let stop_sent = run_cancel_listener(
1587            Box::new(|| Err(ServiceError::Platform("rejected".into()))),
1588            Box::new(|| {}),
1589            cmd_tx,
1590            5,
1591        )
1592        .await;
1593
1594        assert!(!stop_sent, "rejected invoke should return false");
1595        assert_eq!(
1596            seen.await.unwrap(),
1597            None,
1598            "StopWithReason should NOT be sent on rejected invoke"
1599        );
1600    }
1601
1602    #[tokio::test]
1603    async fn cancel_listener_timeout_sends_stop_with_reason() {
1604        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1605        let cancel_called = Arc::new(AtomicBool::new(false));
1606        let cancel_called_clone = cancel_called.clone();
1607        let seen = spawn_stop_drain(cmd_rx);
1608
1609        // Use a channel to unblock the wait_fn when cancel_fn is called,
1610        // simulating how the real cancelCancelListener rejects the invoke.
1611        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
1612
1613        let stop_sent = run_cancel_listener(
1614            Box::new(move || {
1615                // Block until cancel_fn signals us (simulates wait_for_cancel blocking)
1616                let _ = unblock_rx.recv();
1617                Ok(())
1618            }),
1619            Box::new(move || {
1620                cancel_called_clone.store(true, Ordering::SeqCst);
1621                let _ = unblock_tx.send(());
1622            }),
1623            cmd_tx,
1624            0, // immediate timeout
1625        )
1626        .await;
1627
1628        assert!(stop_sent, "timeout should return true");
1629        assert!(
1630            cancel_called.load(Ordering::SeqCst),
1631            "cancel_fn should be called on timeout"
1632        );
1633        let reason = seen.await.unwrap();
1634        assert_eq!(
1635            reason,
1636            Some(crate::models::StopReason::PlatformTimeout),
1637            "StopWithReason(PlatformTimeout) should be sent on timeout"
1638        );
1639    }
1640
1641    #[tokio::test]
1642    async fn cancel_listener_join_error_no_stop() {
1643        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1644        let seen = spawn_stop_drain(cmd_rx);
1645
1646        // wait_fn panics → simulates JoinError from spawn_blocking
1647        let stop_sent = run_cancel_listener(
1648            Box::new(|| panic!("simulated panic in wait_for_cancel")),
1649            Box::new(|| {}),
1650            cmd_tx,
1651            5,
1652        )
1653        .await;
1654
1655        // JoinError is Ok(Err(_)) which falls into the `_ => false` branch
1656        assert!(!stop_sent, "join error should return false (no stop sent)");
1657        assert_eq!(
1658            seen.await.unwrap(),
1659            None,
1660            "StopWithReason should NOT be sent on join error"
1661        );
1662    }
1663
1664    // ═══════════════════════════════════════════════════════════════════════
1665    //  IPC AUTO-START RECOVERY TESTS (Step 12)
1666    // ═══════════════════════════════════════════════════════════════════════
1667
1668    #[cfg(all(feature = "desktop-service", unix))]
1669    mod ipc_auto_start_tests {
1670        use super::*;
1671        use crate::desktop::ipc_client::PersistentIpcClientHandle;
1672        use crate::desktop::test_helpers::setup_server;
1673        use std::time::Duration;
1674
1675        /// Verify that `wait_for_connected` returns `false` when the timeout
1676        /// expires without a server, and that the error message includes
1677        /// the socket path.
1678        #[tokio::test]
1679        async fn wait_for_connected_timeout_returns_false() {
1680            let app = tauri::test::mock_app();
1681            let path = crate::desktop::test_helpers::unique_socket_path();
1682            let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
1683
1684            let connected = handle
1685                .wait_for_connected(Duration::from_millis(200))
1686                .await
1687                .unwrap();
1688            assert!(!connected, "should return false on timeout");
1689
1690            let _ = std::fs::remove_file(&path);
1691        }
1692
1693        /// Verify that `wait_for_connected` returns `true` once a server
1694        /// appears and the persistent client connects.
1695        #[tokio::test]
1696        async fn wait_for_connected_succeeds_with_server() {
1697            let (path, shutdown, _event_tx) = setup_server();
1698            let app = tauri::test::mock_app();
1699            let handle = PersistentIpcClientHandle::spawn(path, app.handle().clone());
1700
1701            let connected = handle
1702                .wait_for_connected(Duration::from_secs(5))
1703                .await
1704                .unwrap();
1705            assert!(connected, "should connect within timeout");
1706
1707            shutdown.cancel();
1708        }
1709
1710        /// Verify that `socket_path()` returns the path the handle was
1711        /// spawned with.
1712        #[tokio::test]
1713        async fn socket_path_accessor() {
1714            let app = tauri::test::mock_app();
1715            let path = crate::desktop::test_helpers::unique_socket_path();
1716            let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
1717            assert_eq!(
1718                handle.socket_path(),
1719                &path,
1720                "socket_path() should return the path passed to spawn"
1721            );
1722            let _ = std::fs::remove_file(&path);
1723        }
1724
1725        /// Verify the disconnected path with `desktop_start_service_if_missing=false`
1726        /// returns an IPC error containing "ipcUnavailable".
1727        ///
1728        /// This tests the `start` command handler's disconnected branch
1729        /// by directly checking the error construction logic.
1730        #[tokio::test]
1731        async fn start_disconnected_without_auto_start_returns_ipc_error() {
1732            let err = ServiceError::Ipc("ipcUnavailable".into());
1733            let msg = err.to_string();
1734            assert!(
1735                msg.contains("ipcUnavailable"),
1736                "error should contain 'ipcUnavailable': {msg}"
1737            );
1738        }
1739
1740        /// Verify the timeout error includes the socket path for diagnostics.
1741        #[tokio::test]
1742        async fn start_timeout_error_includes_socket_path() {
1743            let socket = "/tmp/test-socket-path.sock";
1744            let err = ServiceError::Ipc(format!("ipcUnavailable: socket {socket}"));
1745            let msg = err.to_string();
1746            assert!(
1747                msg.contains(socket),
1748                "error should contain socket path: {msg}"
1749            );
1750        }
1751    }
1752}