Skip to main content

tauri_plugin_background_service/
lib.rs

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