Skip to main content

tauri_plugin_background_service/
lib.rs

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