Skip to main content

tauri_plugin_background_service/
lib.rs

1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.4.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 error;
73pub mod manager;
74pub mod models;
75pub mod notifier;
76pub mod service_trait;
77
78#[cfg(mobile)]
79pub mod mobile;
80
81#[cfg(feature = "desktop-service")]
82pub mod desktop;
83
84// ─── Public API Surface ──────────────────────────────────────────────────────
85
86pub use error::ServiceError;
87#[doc(hidden)]
88pub use manager::{manager_loop, OnCompleteCallback, ServiceFactory, ServiceManagerHandle};
89#[doc(hidden)]
90pub use models::AutoStartConfig;
91pub use models::{
92    PluginConfig, PluginEvent, ServiceContext, ServiceState, ServiceStatus, StartConfig,
93};
94pub use notifier::Notifier;
95pub use service_trait::BackgroundService;
96
97#[cfg(feature = "desktop-service")]
98pub use desktop::headless::headless_main;
99
100// ─── Internal Imports ────────────────────────────────────────────────────────
101
102use tauri::{
103    plugin::{Builder, TauriPlugin},
104    AppHandle, Manager, Runtime,
105};
106
107use crate::manager::ManagerCommand;
108
109#[cfg(mobile)]
110use crate::manager::MobileKeepalive;
111
112#[cfg(mobile)]
113use mobile::MobileLifecycle;
114
115#[cfg(mobile)]
116use std::sync::Arc;
117
118// ─── iOS Plugin Binding ──────────────────────────────────────────────────────
119// Must be at module level. Referenced by mobile::init() when registering
120// the iOS plugin. Only compiled when targeting iOS.
121
122#[cfg(target_os = "ios")]
123tauri::ios_plugin_binding!(init_plugin_background_service);
124
125// ─── iOS Lifecycle Helpers ────────────────────────────────────────────────────
126
127/// Set the on_complete callback so iOS `completeBgTask` fires when `run()` finishes.
128///
129/// Sends `SetOnComplete` to the actor. Must be called **before** `Start` because
130/// `handle_start` captures the callback via `take()` at spawn time.
131#[cfg(target_os = "ios")]
132async fn ios_set_on_complete_callback<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
133    let mobile = app.state::<Arc<MobileLifecycle<R>>>();
134    let mobile_handle = mobile.handle.clone();
135    let manager = app.state::<ServiceManagerHandle<R>>();
136
137    let mob_for_complete = MobileLifecycle {
138        handle: mobile_handle,
139    };
140    manager
141        .cmd_tx
142        .send(ManagerCommand::SetOnComplete {
143            callback: Box::new(move |success| {
144                let _ = mob_for_complete.complete_bg_task(success);
145            }),
146        })
147        .await
148        .map_err(|e| e.to_string())
149}
150
151#[cfg(not(target_os = "ios"))]
152async fn ios_set_on_complete_callback<R: Runtime>(_app: &AppHandle<R>) -> Result<(), String> {
153    Ok(())
154}
155
156/// Spawn a blocking thread that waits for the iOS expiration signal (`waitForCancel`).
157///
158/// Must be called **after** `Start` succeeds so the service is running when the
159/// cancel listener begins waiting. Sends `Stop` to the actor when cancelled.
160///
161/// Three outcomes:
162/// 1. **Resolved invoke** (safety timer / expiration) → `Ok(())` → send `Stop`.
163/// 2. **Timeout** (default: 4h) → call `cancel_cancel_listener` to unblock the
164///    thread, then send `Stop`.
165/// 3. **Rejected invoke** (explicit stop / natural completion) → `Err` → no action.
166///
167/// Core cancel listener logic, extracted for testability.
168///
169/// - `wait_fn`: blocking function simulating `wait_for_cancel` (returns `Ok(())` on resolve,
170///   `Err` on reject).
171/// - `cancel_fn`: called on timeout to unblock the `wait_fn` thread.
172/// - `cmd_tx`: channel to send `Stop` command on resolve/timeout.
173/// - `timeout_secs`: how long to wait before treating the listener as timed out.
174///
175/// Returns `true` if a `Stop` was sent, `false` otherwise.
176#[allow(dead_code)] // used on iOS + in tests
177async fn run_cancel_listener<R: Runtime>(
178    wait_fn: Box<dyn FnOnce() -> Result<(), ServiceError> + Send>,
179    cancel_fn: Box<dyn FnOnce() + Send>,
180    cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
181    timeout_secs: u64,
182) -> bool {
183    let handle = tokio::task::spawn_blocking(wait_fn);
184    let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), handle).await;
185    match result {
186        // Resolved invoke (safety timer or expiration) → graceful shutdown
187        Ok(Ok(Ok(()))) | Err(_) => {
188            // On timeout, unblock the spawn_blocking thread first.
189            if result.is_err() {
190                cancel_fn();
191            }
192            let (tx, rx) = tokio::sync::oneshot::channel();
193            let _ = cmd_tx.send(ManagerCommand::Stop { reply: tx }).await;
194            let _ = rx.await;
195            true
196        }
197        // Rejected invoke (explicit stop or natural completion) → no action
198        _ => false,
199    }
200}
201
202#[cfg(target_os = "ios")]
203fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
204    let mobile = app.state::<Arc<MobileLifecycle<R>>>();
205    let mobile_handle = mobile.handle.clone();
206    let mobile_handle_for_cancel = mobile.handle.clone();
207    let manager = app.state::<ServiceManagerHandle<R>>();
208    let cmd_tx = manager.cmd_tx.clone();
209
210    tokio::spawn(async move {
211        let wait_fn = Box::new(move || {
212            let mob = MobileLifecycle {
213                handle: mobile_handle,
214            };
215            mob.wait_for_cancel()
216        });
217        let cancel_fn = Box::new(move || {
218            let cancel_mob = MobileLifecycle {
219                handle: mobile_handle_for_cancel,
220            };
221            let _ = cancel_mob.cancel_cancel_listener();
222        });
223        // Ignore result — the listener fires-and-forgets.
224        let _ = run_cancel_listener(wait_fn, cancel_fn, cmd_tx, timeout_secs).await;
225    });
226}
227
228#[cfg(not(target_os = "ios"))]
229fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
230
231// ─── Tauri Commands ──────────────────────────────────────────────────────────
232
233#[tauri::command]
234async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
235    // OS service mode: route through persistent IPC client.
236    #[cfg(feature = "desktop-service")]
237    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
238        return ipc_state
239            .client
240            .start(config)
241            .await
242            .map_err(|e| e.to_string());
243    }
244
245    // In-process mode (default).
246    // iOS: send SetOnComplete before Start so the callback is captured at spawn time.
247    ios_set_on_complete_callback(&app).await?;
248
249    // Mobile keepalive is now handled by the actor (Step 5).
250    // The actor calls start_keepalive AFTER the AlreadyRunning check.
251
252    let manager = app.state::<ServiceManagerHandle<R>>();
253    let (tx, rx) = tokio::sync::oneshot::channel();
254    manager
255        .cmd_tx
256        .send(ManagerCommand::Start {
257            config,
258            reply: tx,
259            app: app.clone(),
260        })
261        .await
262        .map_err(|e| e.to_string())?;
263
264    rx.await
265        .map_err(|e| e.to_string())?
266        .map_err(|e| e.to_string())?;
267
268    // iOS: spawn cancel listener after Start succeeds.
269    let plugin_config = app.state::<PluginConfig>();
270    ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
271
272    Ok(())
273}
274
275#[tauri::command]
276async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
277    // OS service mode: route through persistent IPC client.
278    #[cfg(feature = "desktop-service")]
279    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
280        return ipc_state.client.stop().await.map_err(|e| e.to_string());
281    }
282
283    // In-process mode (default).
284    let manager = app.state::<ServiceManagerHandle<R>>();
285    let (tx, rx) = tokio::sync::oneshot::channel();
286    manager
287        .cmd_tx
288        .send(ManagerCommand::Stop { reply: tx })
289        .await
290        .map_err(|e| e.to_string())?;
291
292    rx.await
293        .map_err(|e| e.to_string())?
294        .map_err(|e| e.to_string())
295}
296
297#[tauri::command]
298async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
299    // OS service mode: route through persistent IPC client.
300    #[cfg(feature = "desktop-service")]
301    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
302        return ipc_state.client.is_running().await.unwrap_or(false);
303    }
304
305    // In-process mode (default).
306    let manager = app.state::<ServiceManagerHandle<R>>();
307    let (tx, rx) = tokio::sync::oneshot::channel();
308    if manager
309        .cmd_tx
310        .send(ManagerCommand::IsRunning { reply: tx })
311        .await
312        .is_err()
313    {
314        return false;
315    }
316    rx.await.unwrap_or(false)
317}
318
319#[tauri::command]
320async fn get_service_state<R: Runtime>(app: AppHandle<R>) -> Result<models::ServiceStatus, String> {
321    // OS service mode: route through persistent IPC client.
322    #[cfg(feature = "desktop-service")]
323    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
324        return ipc_state
325            .client
326            .get_state()
327            .await
328            .map_err(|e| e.to_string());
329    }
330
331    // In-process mode (default).
332    let manager = app.state::<ServiceManagerHandle<R>>();
333    Ok(manager.get_state().await)
334}
335
336// ─── Desktop OS Service State & Commands ──────────────────────────────────────
337
338/// Managed state indicating OS service mode via IPC.
339///
340/// When present as managed state, the `start`/`stop`/`is_running` commands
341/// route through the persistent IPC client instead of the in-process actor loop.
342#[cfg(feature = "desktop-service")]
343struct DesktopIpcState {
344    client: desktop::ipc_client::PersistentIpcClientHandle,
345}
346
347#[cfg(feature = "desktop-service")]
348#[tauri::command]
349async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
350    use desktop::service_manager::{derive_service_label, DesktopServiceManager};
351    let plugin_config = app.state::<PluginConfig>();
352    let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
353    let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
354
355    // Validate that the executable exists and is executable.
356    if !exec_path.exists() {
357        return Err(format!(
358            "Current executable does not exist at {}: cannot install OS service",
359            exec_path.display()
360        ));
361    }
362
363    // Verify the binary supports --service-label by spawning it with the flag
364    // and checking for a specific exit behavior. We use a timeout to avoid
365    // hanging if the binary starts a GUI.
366    let validate_result = tokio::time::timeout(
367        std::time::Duration::from_secs(5),
368        tokio::process::Command::new(&exec_path)
369            .arg("--service-label")
370            .arg(&label)
371            .arg("--validate-service-install")
372            .output(),
373    )
374    .await;
375
376    match validate_result {
377        Ok(Ok(output)) => {
378            let stdout = String::from_utf8_lossy(&output.stdout);
379            if !stdout.trim().contains("ok") {
380                return Err("Binary does not handle --validate-service-install. \
381                     Ensure headless_main() is called from your app's main()."
382                    .into());
383            }
384        }
385        Ok(Err(e)) => {
386            return Err(format!(
387                "Failed to validate executable for --service-label: {e}"
388            ));
389        }
390        Err(_) => {
391            // Timed out — the binary probably started the GUI instead of handling
392            // the service flag. Warn but don't block installation.
393            log::warn!(
394                "Timeout validating --service-label support. \
395                 Ensure your app's main() handles the --service-label argument \
396                 and calls headless_main()."
397            );
398        }
399    }
400
401    let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
402    mgr.install().map_err(|e| e.to_string())
403}
404
405#[cfg(feature = "desktop-service")]
406#[tauri::command]
407async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
408    use desktop::service_manager::{derive_service_label, DesktopServiceManager};
409    let plugin_config = app.state::<PluginConfig>();
410    let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
411    let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
412    let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
413    mgr.uninstall().map_err(|e| e.to_string())
414}
415
416// ─── Plugin Builder ──────────────────────────────────────────────────────────
417
418/// Create the Tauri plugin with your service factory.
419///
420/// ```rust,ignore
421/// // MyService must implement BackgroundService<R>
422/// tauri::Builder::default()
423///     .plugin(tauri_plugin_background_service::init_with_service(|| MyService::new()))
424/// ```
425pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
426where
427    R: Runtime,
428    S: BackgroundService<R>,
429    F: Fn() -> S + Send + Sync + 'static,
430{
431    let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
432
433    Builder::<R, PluginConfig>::new("background-service")
434        .invoke_handler(tauri::generate_handler![
435            start,
436            stop,
437            is_running,
438            get_service_state,
439            #[cfg(feature = "desktop-service")]
440            install_service,
441            #[cfg(feature = "desktop-service")]
442            uninstall_service,
443        ])
444        .setup(move |app, api| {
445            let config = api.config().clone();
446            let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(config.channel_capacity);
447            #[cfg(mobile)]
448            let mobile_cmd_tx = cmd_tx.clone();
449            let handle = ServiceManagerHandle::new(cmd_tx);
450            app.manage(handle);
451
452            app.manage(config.clone());
453
454            let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
455            let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
456            let ios_earliest_refresh_begin_minutes = config.ios_earliest_refresh_begin_minutes;
457            let ios_earliest_processing_begin_minutes =
458                config.ios_earliest_processing_begin_minutes;
459            let ios_requires_external_power = config.ios_requires_external_power;
460            let ios_requires_network_connectivity = config.ios_requires_network_connectivity;
461
462            // Mode dispatch: spawn in-process actor or configure IPC for OS service.
463            #[cfg(feature = "desktop-service")]
464            if config.desktop_service_mode == "osService" {
465                // OS service mode: spawn persistent IPC client.
466                let label = desktop::service_manager::derive_service_label(
467                    app,
468                    config.desktop_service_label.as_deref(),
469                );
470                let socket_path = desktop::ipc::socket_path(&label)?;
471                let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
472                    socket_path,
473                    app.app_handle().clone(),
474                );
475                app.manage(DesktopIpcState { client });
476            } else {
477                // In-process mode (default): spawn the actor loop.
478                let factory = boxed_factory;
479                tauri::async_runtime::spawn(manager_loop(
480                    cmd_rx,
481                    factory,
482                    ios_safety_timeout_secs,
483                    ios_processing_safety_timeout_secs,
484                    ios_earliest_refresh_begin_minutes,
485                    ios_earliest_processing_begin_minutes,
486                    ios_requires_external_power,
487                    ios_requires_network_connectivity,
488                ));
489            }
490
491            #[cfg(not(feature = "desktop-service"))]
492            {
493                let factory = boxed_factory;
494                tauri::async_runtime::spawn(manager_loop(
495                    cmd_rx,
496                    factory,
497                    ios_safety_timeout_secs,
498                    ios_processing_safety_timeout_secs,
499                    ios_earliest_refresh_begin_minutes,
500                    ios_earliest_processing_begin_minutes,
501                    ios_requires_external_power,
502                    ios_requires_network_connectivity,
503                ));
504            }
505
506            #[cfg(mobile)]
507            {
508                let lifecycle = mobile::init(app, api)?;
509                let lifecycle_arc = Arc::new(lifecycle);
510
511                // Send SetMobile to actor so keepalive is managed by the actor.
512                let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
513                if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
514                    mobile: mobile_trait,
515                }) {
516                    log::error!("Failed to send SetMobile command: {e}");
517                }
518
519                // Store for iOS callbacks and Android auto-start helpers.
520                app.manage(lifecycle_arc);
521            }
522
523            // Android: auto-start detection after OS-initiated service restart.
524            // When LifecycleService is restarted by START_STICKY, it sets an
525            // auto-start flag in SharedPreferences and launches the Activity.
526            // This block detects that flag, clears it, and starts the service
527            // via the actor.
528            #[cfg(target_os = "android")]
529            {
530                let mobile = app.state::<Arc<MobileLifecycle<R>>>();
531                if let Ok(Some(config)) = mobile.get_auto_start_config() {
532                    let _ = mobile.clear_auto_start_config();
533
534                    // Keepalive is now handled by the actor's handle_start.
535                    // Just send Start command — actor will call start_keepalive.
536
537                    let manager = app.state::<ServiceManagerHandle<R>>();
538                    let cmd_tx = manager.cmd_tx.clone();
539                    let app_clone = app.app_handle().clone();
540
541                    // Set a no-op on_complete callback for consistency with iOS path.
542                    if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
543                        callback: Box::new(|_| {}),
544                    }) {
545                        log::error!("Failed to send SetOnComplete command: {e}");
546                    }
547
548                    tauri::async_runtime::spawn(async move {
549                        let (tx, rx) = tokio::sync::oneshot::channel();
550                        if cmd_tx
551                            .send(ManagerCommand::Start {
552                                config,
553                                reply: tx,
554                                app: app_clone,
555                            })
556                            .await
557                            .is_err()
558                        {
559                            return;
560                        }
561                        let _ = rx.await;
562                    });
563
564                    let _ = mobile.move_task_to_background();
565                }
566            }
567
568            Ok(())
569        })
570        .on_event(|app, event| {
571            if let tauri::RunEvent::Exit = event {
572                // In OS service mode, the service runs in a separate process — skip.
573                #[cfg(feature = "desktop-service")]
574                if app.try_state::<DesktopIpcState>().is_some() {
575                    return;
576                }
577                let manager = app.state::<ServiceManagerHandle<R>>();
578                if let Err(e) = manager.stop_blocking() {
579                    log::warn!("Failed to stop background service on app exit: {e}");
580                }
581            }
582        })
583        .build()
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use async_trait::async_trait;
590    use std::sync::atomic::{AtomicUsize, Ordering};
591    use std::sync::Arc;
592
593    /// Minimal service for testing type compatibility.
594    struct DummyService;
595
596    #[async_trait]
597    impl BackgroundService<tauri::Wry> for DummyService {
598        async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
599            Ok(())
600        }
601
602        async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
603            Ok(())
604        }
605    }
606
607    // ── Construction Tests ───────────────────────────────────────────────
608
609    #[test]
610    fn service_manager_handle_constructs() {
611        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
612        let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
613    }
614
615    #[test]
616    fn factory_produces_boxed_service() {
617        let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
618        let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
619    }
620
621    #[test]
622    fn handle_factory_creates_fresh_instances() {
623        let count = Arc::new(AtomicUsize::new(0));
624        let count_clone = count.clone();
625
626        let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
627            count_clone.fetch_add(1, Ordering::SeqCst);
628            Box::new(DummyService)
629        });
630
631        let _ = (factory)();
632        let _ = (factory)();
633
634        assert_eq!(count.load(Ordering::SeqCst), 2);
635    }
636
637    // ── Compile-time Tests ───────────────────────────────────────────────
638
639    /// Verify `init_with_service` returns `TauriPlugin<R>`.
640    #[allow(dead_code)]
641    fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
642        factory: F,
643    ) -> TauriPlugin<R, PluginConfig>
644    where
645        S: BackgroundService<R>,
646        F: Fn() -> S + Send + Sync + 'static,
647    {
648        init_with_service(factory)
649    }
650
651    /// Verify `start` command signature is generic over `R: Runtime`.
652    #[allow(dead_code)]
653    async fn start_command_signature<R: Runtime>(
654        app: AppHandle<R>,
655        config: StartConfig,
656    ) -> Result<(), String> {
657        start(app, config).await
658    }
659
660    /// Verify `stop` command signature is generic over `R: Runtime`.
661    #[allow(dead_code)]
662    async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
663        stop(app).await
664    }
665
666    /// Verify `is_running` command signature is async and generic over `R: Runtime`.
667    #[allow(dead_code)]
668    async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
669        is_running(app).await
670    }
671
672    /// Verify `get_service_state` command signature is async and generic over `R: Runtime`.
673    #[allow(dead_code)]
674    async fn get_service_state_command_signature<R: Runtime>(
675        app: AppHandle<R>,
676    ) -> Result<models::ServiceStatus, String> {
677        get_service_state(app).await
678    }
679
680    // ── Desktop IPC State Tests ─────────────────────────────────────────
681
682    /// Verify PersistentIpcClientHandle can be constructed.
683    #[cfg(feature = "desktop-service")]
684    #[tokio::test]
685    async fn desktop_ipc_state_with_persistent_client() {
686        use desktop::ipc_client::PersistentIpcClientHandle;
687        let app = tauri::test::mock_app();
688        let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
689        let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
690        // The client is spawned but may not be connected yet — that's fine.
691        // Just verify we can construct the state.
692        let _state = DesktopIpcState { client };
693    }
694
695    // ── Desktop Command Compile-time Tests ────────────────────────────────
696
697    /// Verify `install_service` command signature is generic over `R: Runtime`.
698    #[cfg(feature = "desktop-service")]
699    #[allow(dead_code)]
700    async fn install_service_command_signature<R: Runtime>(
701        app: AppHandle<R>,
702    ) -> Result<(), String> {
703        install_service(app).await
704    }
705
706    /// Verify `uninstall_service` command signature is generic over `R: Runtime`.
707    #[cfg(feature = "desktop-service")]
708    #[allow(dead_code)]
709    async fn uninstall_service_command_signature<R: Runtime>(
710        app: AppHandle<R>,
711    ) -> Result<(), String> {
712        uninstall_service(app).await
713    }
714
715    // ── On-Event Shutdown Compile-time Test ─────────────────────────────────
716
717    /// Verify the on_event closure accessing ServiceManagerHandle<R> from managed
718    /// state type-checks. Ensures the generic R is properly threaded through in
719    /// the on_event context where stop_blocking() is called synchronously.
720    #[allow(dead_code)]
721    fn on_event_shutdown_closure_type_checks<R: Runtime>(app: &AppHandle<R>) {
722        let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
723            if let tauri::RunEvent::Exit = event {
724                let manager = _app.state::<ServiceManagerHandle<R>>();
725                if let Err(_e) = manager.stop_blocking() {
726                    log::warn!("bg service shutdown on exit failed: {_e}");
727                }
728            }
729        };
730    }
731
732    // ── Cancel Listener Tests ───────────────────────────────────────────────
733
734    use crate::manager::ManagerCommand;
735    use std::sync::atomic::AtomicBool;
736
737    /// Helper: spawn a background task that accepts one Stop command and replies Ok(()).
738    /// Returns a oneshot receiver that yields true if Stop was received.
739    fn spawn_stop_drain(
740        mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
741    ) -> tokio::sync::oneshot::Receiver<bool> {
742        let (seen_tx, seen_rx) = tokio::sync::oneshot::channel::<bool>();
743        tokio::spawn(async move {
744            let result =
745                tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
746            match result {
747                Ok(Some(ManagerCommand::Stop { reply })) => {
748                    let _ = reply.send(Ok(()));
749                    let _ = seen_tx.send(true);
750                }
751                _ => {
752                    let _ = seen_tx.send(false);
753                }
754            }
755        });
756        seen_rx
757    }
758
759    #[tokio::test]
760    async fn cancel_listener_resolved_invoke_sends_stop() {
761        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
762        let seen = spawn_stop_drain(cmd_rx);
763
764        // wait_fn returns Ok(()) → simulates resolved invoke (safety timer / expiration)
765        let stop_sent = run_cancel_listener(
766            Box::new(|| Ok(())),
767            Box::new(|| {}),
768            cmd_tx,
769            5, // timeout, shouldn't matter since wait_fn returns immediately
770        )
771        .await;
772
773        assert!(stop_sent, "resolved invoke should return true");
774        assert!(
775            seen.await.unwrap(),
776            "Stop command should be sent on resolved invoke"
777        );
778    }
779
780    #[tokio::test]
781    async fn cancel_listener_rejected_invoke_no_stop() {
782        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
783        let seen = spawn_stop_drain(cmd_rx);
784
785        // wait_fn returns Err → simulates rejected invoke (explicit stop / completion)
786        let stop_sent = run_cancel_listener(
787            Box::new(|| Err(ServiceError::Platform("rejected".into()))),
788            Box::new(|| {}),
789            cmd_tx,
790            5,
791        )
792        .await;
793
794        assert!(!stop_sent, "rejected invoke should return false");
795        assert!(
796            !seen.await.unwrap(),
797            "Stop command should NOT be sent on rejected invoke"
798        );
799    }
800
801    #[tokio::test]
802    async fn cancel_listener_timeout_sends_stop() {
803        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
804        let cancel_called = Arc::new(AtomicBool::new(false));
805        let cancel_called_clone = cancel_called.clone();
806        let seen = spawn_stop_drain(cmd_rx);
807
808        // Use a channel to unblock the wait_fn when cancel_fn is called,
809        // simulating how the real cancelCancelListener rejects the invoke.
810        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
811
812        let stop_sent = run_cancel_listener(
813            Box::new(move || {
814                // Block until cancel_fn signals us (simulates wait_for_cancel blocking)
815                let _ = unblock_rx.recv();
816                Ok(())
817            }),
818            Box::new(move || {
819                cancel_called_clone.store(true, Ordering::SeqCst);
820                let _ = unblock_tx.send(());
821            }),
822            cmd_tx,
823            0, // immediate timeout
824        )
825        .await;
826
827        assert!(stop_sent, "timeout should return true");
828        assert!(
829            cancel_called.load(Ordering::SeqCst),
830            "cancel_fn should be called on timeout"
831        );
832        assert!(
833            seen.await.unwrap(),
834            "Stop command should be sent on timeout"
835        );
836    }
837
838    #[tokio::test]
839    async fn cancel_listener_join_error_no_stop() {
840        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
841        let seen = spawn_stop_drain(cmd_rx);
842
843        // wait_fn panics → simulates JoinError from spawn_blocking
844        let stop_sent = run_cancel_listener(
845            Box::new(|| panic!("simulated panic in wait_for_cancel")),
846            Box::new(|| {}),
847            cmd_tx,
848            5,
849        )
850        .await;
851
852        // JoinError is Ok(Err(_)) which falls into the `_ => false` branch
853        assert!(!stop_sent, "join error should return false (no stop sent)");
854        assert!(
855            !seen.await.unwrap(),
856            "Stop command should NOT be sent on join error"
857        );
858    }
859}