Skip to main content

tauri_plugin_background_service/
lib.rs

1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.4.1")]
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(all(feature = "desktop-service", unix))]
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(all(feature = "desktop-service", unix))]
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(all(feature = "desktop-service", unix))]
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(all(feature = "desktop-service", unix))]
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(all(feature = "desktop-service", unix))]
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(all(feature = "desktop-service", unix))]
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(all(feature = "desktop-service", unix))]
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(all(feature = "desktop-service", not(unix)))]
492            {
493                // On non-Unix platforms, only in-process mode is available.
494                let factory = boxed_factory;
495                tauri::async_runtime::spawn(manager_loop(
496                    cmd_rx,
497                    factory,
498                    ios_safety_timeout_secs,
499                    ios_processing_safety_timeout_secs,
500                    ios_earliest_refresh_begin_minutes,
501                    ios_earliest_processing_begin_minutes,
502                    ios_requires_external_power,
503                    ios_requires_network_connectivity,
504                ));
505            }
506
507            #[cfg(not(feature = "desktop-service"))]
508            {
509                let factory = boxed_factory;
510                tauri::async_runtime::spawn(manager_loop(
511                    cmd_rx,
512                    factory,
513                    ios_safety_timeout_secs,
514                    ios_processing_safety_timeout_secs,
515                    ios_earliest_refresh_begin_minutes,
516                    ios_earliest_processing_begin_minutes,
517                    ios_requires_external_power,
518                    ios_requires_network_connectivity,
519                ));
520            }
521
522            #[cfg(mobile)]
523            {
524                let lifecycle = mobile::init(app, api)?;
525                let lifecycle_arc = Arc::new(lifecycle);
526
527                // Send SetMobile to actor so keepalive is managed by the actor.
528                let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
529                if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
530                    mobile: mobile_trait,
531                }) {
532                    log::error!("Failed to send SetMobile command: {e}");
533                }
534
535                // Store for iOS callbacks and Android auto-start helpers.
536                app.manage(lifecycle_arc);
537            }
538
539            // Android: auto-start detection after OS-initiated service restart.
540            // When LifecycleService is restarted by START_STICKY, it sets an
541            // auto-start flag in SharedPreferences and launches the Activity.
542            // This block detects that flag, clears it, and starts the service
543            // via the actor.
544            #[cfg(target_os = "android")]
545            {
546                let mobile = app.state::<Arc<MobileLifecycle<R>>>();
547                if let Ok(Some(config)) = mobile.get_auto_start_config() {
548                    let _ = mobile.clear_auto_start_config();
549
550                    // Keepalive is now handled by the actor's handle_start.
551                    // Just send Start command — actor will call start_keepalive.
552
553                    let manager = app.state::<ServiceManagerHandle<R>>();
554                    let cmd_tx = manager.cmd_tx.clone();
555                    let app_clone = app.app_handle().clone();
556
557                    // Set a no-op on_complete callback for consistency with iOS path.
558                    if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
559                        callback: Box::new(|_| {}),
560                    }) {
561                        log::error!("Failed to send SetOnComplete command: {e}");
562                    }
563
564                    tauri::async_runtime::spawn(async move {
565                        let (tx, rx) = tokio::sync::oneshot::channel();
566                        if cmd_tx
567                            .send(ManagerCommand::Start {
568                                config,
569                                reply: tx,
570                                app: app_clone,
571                            })
572                            .await
573                            .is_err()
574                        {
575                            return;
576                        }
577                        let _ = rx.await;
578                    });
579
580                    let _ = mobile.move_task_to_background();
581                }
582            }
583
584            Ok(())
585        })
586        .on_event(|app, event| {
587            if let tauri::RunEvent::Exit = event {
588                // In OS service mode, the service runs in a separate process — skip.
589                #[cfg(all(feature = "desktop-service", unix))]
590                if app.try_state::<DesktopIpcState>().is_some() {
591                    return;
592                }
593                let manager = app.state::<ServiceManagerHandle<R>>();
594                if let Err(e) = manager.stop_blocking() {
595                    log::warn!("Failed to stop background service on app exit: {e}");
596                }
597            }
598        })
599        .build()
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use async_trait::async_trait;
606    use std::sync::atomic::{AtomicUsize, Ordering};
607    use std::sync::Arc;
608
609    /// Minimal service for testing type compatibility.
610    struct DummyService;
611
612    #[async_trait]
613    impl BackgroundService<tauri::Wry> for DummyService {
614        async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
615            Ok(())
616        }
617
618        async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
619            Ok(())
620        }
621    }
622
623    // ── Construction Tests ───────────────────────────────────────────────
624
625    #[test]
626    fn service_manager_handle_constructs() {
627        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
628        let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
629    }
630
631    #[test]
632    fn factory_produces_boxed_service() {
633        let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
634        let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
635    }
636
637    #[test]
638    fn handle_factory_creates_fresh_instances() {
639        let count = Arc::new(AtomicUsize::new(0));
640        let count_clone = count.clone();
641
642        let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
643            count_clone.fetch_add(1, Ordering::SeqCst);
644            Box::new(DummyService)
645        });
646
647        let _ = (factory)();
648        let _ = (factory)();
649
650        assert_eq!(count.load(Ordering::SeqCst), 2);
651    }
652
653    // ── Compile-time Tests ───────────────────────────────────────────────
654
655    /// Verify `init_with_service` returns `TauriPlugin<R>`.
656    #[allow(dead_code)]
657    fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
658        factory: F,
659    ) -> TauriPlugin<R, PluginConfig>
660    where
661        S: BackgroundService<R>,
662        F: Fn() -> S + Send + Sync + 'static,
663    {
664        init_with_service(factory)
665    }
666
667    /// Verify `start` command signature is generic over `R: Runtime`.
668    #[allow(dead_code)]
669    async fn start_command_signature<R: Runtime>(
670        app: AppHandle<R>,
671        config: StartConfig,
672    ) -> Result<(), String> {
673        start(app, config).await
674    }
675
676    /// Verify `stop` command signature is generic over `R: Runtime`.
677    #[allow(dead_code)]
678    async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
679        stop(app).await
680    }
681
682    /// Verify `is_running` command signature is async and generic over `R: Runtime`.
683    #[allow(dead_code)]
684    async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
685        is_running(app).await
686    }
687
688    /// Verify `get_service_state` command signature is async and generic over `R: Runtime`.
689    #[allow(dead_code)]
690    async fn get_service_state_command_signature<R: Runtime>(
691        app: AppHandle<R>,
692    ) -> Result<models::ServiceStatus, String> {
693        get_service_state(app).await
694    }
695
696    // ── Desktop IPC State Tests ─────────────────────────────────────────
697
698    /// Verify PersistentIpcClientHandle can be constructed.
699    #[cfg(all(feature = "desktop-service", unix))]
700    #[tokio::test]
701    async fn desktop_ipc_state_with_persistent_client() {
702        use desktop::ipc_client::PersistentIpcClientHandle;
703        let app = tauri::test::mock_app();
704        let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
705        let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
706        // The client is spawned but may not be connected yet — that's fine.
707        // Just verify we can construct the state.
708        let _state = DesktopIpcState { client };
709    }
710
711    // ── Desktop Command Compile-time Tests ────────────────────────────────
712
713    /// Verify `install_service` command signature is generic over `R: Runtime`.
714    #[cfg(feature = "desktop-service")]
715    #[allow(dead_code)]
716    async fn install_service_command_signature<R: Runtime>(
717        app: AppHandle<R>,
718    ) -> Result<(), String> {
719        install_service(app).await
720    }
721
722    /// Verify `uninstall_service` command signature is generic over `R: Runtime`.
723    #[cfg(feature = "desktop-service")]
724    #[allow(dead_code)]
725    async fn uninstall_service_command_signature<R: Runtime>(
726        app: AppHandle<R>,
727    ) -> Result<(), String> {
728        uninstall_service(app).await
729    }
730
731    // ── On-Event Shutdown Compile-time Test ─────────────────────────────────
732
733    /// Verify the on_event closure accessing ServiceManagerHandle<R> from managed
734    /// state type-checks. Ensures the generic R is properly threaded through in
735    /// the on_event context where stop_blocking() is called synchronously.
736    #[allow(dead_code)]
737    fn on_event_shutdown_closure_type_checks<R: Runtime>(_app: &AppHandle<R>) {
738        let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
739            if let tauri::RunEvent::Exit = event {
740                let manager = _app.state::<ServiceManagerHandle<R>>();
741                if let Err(_e) = manager.stop_blocking() {
742                    log::warn!("bg service shutdown on exit failed: {_e}");
743                }
744            }
745        };
746    }
747
748    // ── Cancel Listener Tests ───────────────────────────────────────────────
749
750    use crate::manager::ManagerCommand;
751    use std::sync::atomic::AtomicBool;
752
753    /// Helper: spawn a background task that accepts one Stop command and replies Ok(()).
754    /// Returns a oneshot receiver that yields true if Stop was received.
755    fn spawn_stop_drain(
756        mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
757    ) -> tokio::sync::oneshot::Receiver<bool> {
758        let (seen_tx, seen_rx) = tokio::sync::oneshot::channel::<bool>();
759        tokio::spawn(async move {
760            let result =
761                tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
762            match result {
763                Ok(Some(ManagerCommand::Stop { reply })) => {
764                    let _ = reply.send(Ok(()));
765                    let _ = seen_tx.send(true);
766                }
767                _ => {
768                    let _ = seen_tx.send(false);
769                }
770            }
771        });
772        seen_rx
773    }
774
775    #[tokio::test]
776    async fn cancel_listener_resolved_invoke_sends_stop() {
777        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
778        let seen = spawn_stop_drain(cmd_rx);
779
780        // wait_fn returns Ok(()) → simulates resolved invoke (safety timer / expiration)
781        let stop_sent = run_cancel_listener(
782            Box::new(|| Ok(())),
783            Box::new(|| {}),
784            cmd_tx,
785            5, // timeout, shouldn't matter since wait_fn returns immediately
786        )
787        .await;
788
789        assert!(stop_sent, "resolved invoke should return true");
790        assert!(
791            seen.await.unwrap(),
792            "Stop command should be sent on resolved invoke"
793        );
794    }
795
796    #[tokio::test]
797    async fn cancel_listener_rejected_invoke_no_stop() {
798        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
799        let seen = spawn_stop_drain(cmd_rx);
800
801        // wait_fn returns Err → simulates rejected invoke (explicit stop / completion)
802        let stop_sent = run_cancel_listener(
803            Box::new(|| Err(ServiceError::Platform("rejected".into()))),
804            Box::new(|| {}),
805            cmd_tx,
806            5,
807        )
808        .await;
809
810        assert!(!stop_sent, "rejected invoke should return false");
811        assert!(
812            !seen.await.unwrap(),
813            "Stop command should NOT be sent on rejected invoke"
814        );
815    }
816
817    #[tokio::test]
818    async fn cancel_listener_timeout_sends_stop() {
819        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
820        let cancel_called = Arc::new(AtomicBool::new(false));
821        let cancel_called_clone = cancel_called.clone();
822        let seen = spawn_stop_drain(cmd_rx);
823
824        // Use a channel to unblock the wait_fn when cancel_fn is called,
825        // simulating how the real cancelCancelListener rejects the invoke.
826        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
827
828        let stop_sent = run_cancel_listener(
829            Box::new(move || {
830                // Block until cancel_fn signals us (simulates wait_for_cancel blocking)
831                let _ = unblock_rx.recv();
832                Ok(())
833            }),
834            Box::new(move || {
835                cancel_called_clone.store(true, Ordering::SeqCst);
836                let _ = unblock_tx.send(());
837            }),
838            cmd_tx,
839            0, // immediate timeout
840        )
841        .await;
842
843        assert!(stop_sent, "timeout should return true");
844        assert!(
845            cancel_called.load(Ordering::SeqCst),
846            "cancel_fn should be called on timeout"
847        );
848        assert!(
849            seen.await.unwrap(),
850            "Stop command should be sent on timeout"
851        );
852    }
853
854    #[tokio::test]
855    async fn cancel_listener_join_error_no_stop() {
856        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
857        let seen = spawn_stop_drain(cmd_rx);
858
859        // wait_fn panics → simulates JoinError from spawn_blocking
860        let stop_sent = run_cancel_listener(
861            Box::new(|| panic!("simulated panic in wait_for_cancel")),
862            Box::new(|| {}),
863            cmd_tx,
864            5,
865        )
866        .await;
867
868        // JoinError is Ok(Err(_)) which falls into the `_ => false` branch
869        assert!(!stop_sent, "join error should return false (no stop sent)");
870        assert!(
871            !seen.await.unwrap(),
872            "Stop command should NOT be sent on join error"
873        );
874    }
875}