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//! See the [project repository](https://github.com/dardourimohamed/tauri-background-service)
49//! for detailed platform guides and API documentation.
50
51pub mod error;
52pub mod manager;
53pub mod models;
54pub mod notifier;
55pub mod service_trait;
56
57#[cfg(mobile)]
58pub mod mobile;
59
60#[cfg(feature = "desktop-service")]
61pub mod desktop;
62
63// ─── Public API Surface ──────────────────────────────────────────────────────
64
65pub use error::ServiceError;
66#[doc(hidden)]
67pub use manager::{manager_loop, OnCompleteCallback, ServiceFactory, ServiceManagerHandle};
68#[doc(hidden)]
69pub use models::AutoStartConfig;
70pub use models::{PluginConfig, PluginEvent, ServiceContext, StartConfig};
71pub use notifier::Notifier;
72pub use service_trait::BackgroundService;
73
74#[cfg(feature = "desktop-service")]
75pub use desktop::headless::headless_main;
76
77// ─── Internal Imports ────────────────────────────────────────────────────────
78
79use tauri::{
80    plugin::{Builder, TauriPlugin},
81    AppHandle, Manager, Runtime,
82};
83
84use crate::manager::ManagerCommand;
85
86#[cfg(mobile)]
87use crate::manager::MobileKeepalive;
88
89#[cfg(mobile)]
90use mobile::MobileLifecycle;
91
92#[cfg(mobile)]
93use std::sync::Arc;
94
95// ─── iOS Plugin Binding ──────────────────────────────────────────────────────
96// Must be at module level. Referenced by mobile::init() when registering
97// the iOS plugin. Only compiled when targeting iOS.
98
99#[cfg(target_os = "ios")]
100tauri::ios_plugin_binding!(init_plugin_background_service);
101
102// ─── iOS Lifecycle Helpers ────────────────────────────────────────────────────
103
104/// Set the on_complete callback so iOS `completeBgTask` fires when `run()` finishes.
105///
106/// Sends `SetOnComplete` to the actor. Must be called **before** `Start` because
107/// `handle_start` captures the callback via `take()` at spawn time.
108#[cfg(target_os = "ios")]
109async fn ios_set_on_complete_callback<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
110    let mobile = app.state::<Arc<MobileLifecycle<R>>>();
111    let mobile_handle = mobile.handle.clone();
112    let manager = app.state::<ServiceManagerHandle<R>>();
113
114    let mob_for_complete = MobileLifecycle {
115        handle: mobile_handle,
116    };
117    manager
118        .cmd_tx
119        .send(ManagerCommand::SetOnComplete {
120            callback: Box::new(move |success| {
121                let _ = mob_for_complete.complete_bg_task(success);
122            }),
123        })
124        .await
125        .map_err(|e| e.to_string())
126}
127
128#[cfg(not(target_os = "ios"))]
129async fn ios_set_on_complete_callback<R: Runtime>(_app: &AppHandle<R>) -> Result<(), String> {
130    Ok(())
131}
132
133/// Spawn a blocking thread that waits for the iOS expiration signal (`waitForCancel`).
134///
135/// Must be called **after** `Start` succeeds so the service is running when the
136/// cancel listener begins waiting. Sends `Stop` to the actor when cancelled.
137#[cfg(target_os = "ios")]
138fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
139    let mobile = app.state::<Arc<MobileLifecycle<R>>>();
140    let mobile_handle = mobile.handle.clone();
141    let manager = app.state::<ServiceManagerHandle<R>>();
142    let cmd_tx = manager.cmd_tx.clone();
143
144    tokio::spawn(async move {
145        let handle = tokio::task::spawn_blocking(move || {
146            let mob = MobileLifecycle {
147                handle: mobile_handle,
148            };
149            mob.wait_for_cancel()
150        });
151        // Safety timeout prevents indefinite thread leaks if iOS
152        // invoke is never resolved (e.g., iOS kills the app).
153        let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), handle).await;
154        if let Ok(Ok(Ok(()))) = result {
155            let (tx, rx) = tokio::sync::oneshot::channel();
156            let _ = cmd_tx.send(ManagerCommand::Stop { reply: tx }).await;
157            let _ = rx.await;
158        }
159    });
160}
161
162#[cfg(not(target_os = "ios"))]
163fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
164
165// ─── Tauri Commands ──────────────────────────────────────────────────────────
166
167#[tauri::command]
168async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
169    // OS service mode: route through persistent IPC client.
170    #[cfg(feature = "desktop-service")]
171    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
172        return ipc_state.client.start(config).await.map_err(|e| e.to_string());
173    }
174
175    // In-process mode (default).
176    // iOS: send SetOnComplete before Start so the callback is captured at spawn time.
177    ios_set_on_complete_callback(&app).await?;
178
179    // Mobile keepalive is now handled by the actor (Step 5).
180    // The actor calls start_keepalive AFTER the AlreadyRunning check.
181
182    let manager = app.state::<ServiceManagerHandle<R>>();
183    let (tx, rx) = tokio::sync::oneshot::channel();
184    manager
185        .cmd_tx
186        .send(ManagerCommand::Start {
187            config,
188            reply: tx,
189            app: app.clone(),
190        })
191        .await
192        .map_err(|e| e.to_string())?;
193
194    rx.await.map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
195
196    // iOS: spawn cancel listener after Start succeeds.
197    let plugin_config = app.state::<PluginConfig>();
198    ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
199
200    Ok(())
201}
202
203#[tauri::command]
204async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
205    // OS service mode: route through persistent IPC client.
206    #[cfg(feature = "desktop-service")]
207    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
208        return ipc_state.client.stop().await.map_err(|e| e.to_string());
209    }
210
211    // In-process mode (default).
212    let manager = app.state::<ServiceManagerHandle<R>>();
213    let (tx, rx) = tokio::sync::oneshot::channel();
214    manager
215        .cmd_tx
216        .send(ManagerCommand::Stop { reply: tx })
217        .await
218        .map_err(|e| e.to_string())?;
219
220    rx.await.map_err(|e| e.to_string())?.map_err(|e| e.to_string())
221}
222
223#[tauri::command]
224async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
225    // OS service mode: route through persistent IPC client.
226    #[cfg(feature = "desktop-service")]
227    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
228        return ipc_state.client.is_running().await.unwrap_or(false);
229    }
230
231    // In-process mode (default).
232    let manager = app.state::<ServiceManagerHandle<R>>();
233    let (tx, rx) = tokio::sync::oneshot::channel();
234    if manager
235        .cmd_tx
236        .send(ManagerCommand::IsRunning { reply: tx })
237        .await
238        .is_err()
239    {
240        return false;
241    }
242    rx.await.unwrap_or(false)
243}
244
245// ─── Desktop OS Service State & Commands ──────────────────────────────────────
246
247/// Managed state indicating OS service mode via IPC.
248///
249/// When present as managed state, the `start`/`stop`/`is_running` commands
250/// route through the persistent IPC client instead of the in-process actor loop.
251#[cfg(feature = "desktop-service")]
252struct DesktopIpcState {
253    client: desktop::ipc_client::PersistentIpcClientHandle,
254}
255
256#[cfg(feature = "desktop-service")]
257#[tauri::command]
258async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
259    use desktop::service_manager::{derive_service_label, DesktopServiceManager};
260    let plugin_config = app.state::<PluginConfig>();
261    let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
262    let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
263
264    // Validate that the executable exists and is executable.
265    if !exec_path.exists() {
266        return Err(format!(
267            "Current executable does not exist at {}: cannot install OS service",
268            exec_path.display()
269        ));
270    }
271
272    // Verify the binary supports --service-label by spawning it with the flag
273    // and checking for a specific exit behavior. We use a timeout to avoid
274    // hanging if the binary starts a GUI.
275    let validate_result = tokio::time::timeout(
276        std::time::Duration::from_secs(5),
277        tokio::process::Command::new(&exec_path)
278            .arg("--service-label")
279            .arg(&label)
280            .arg("--validate-service-install")
281            .output(),
282    )
283    .await;
284
285    match validate_result {
286        Ok(Ok(output)) => {
287            let stdout = String::from_utf8_lossy(&output.stdout);
288            if !stdout.trim().contains("ok") {
289                return Err(
290                    "Binary does not handle --validate-service-install. \
291                     Ensure headless_main() is called from your app's main()."
292                        .into(),
293                );
294            }
295        }
296        Ok(Err(e)) => {
297            return Err(format!(
298                "Failed to validate executable for --service-label: {e}"
299            ));
300        }
301        Err(_) => {
302            // Timed out — the binary probably started the GUI instead of handling
303            // the service flag. Warn but don't block installation.
304            log::warn!(
305                "Timeout validating --service-label support. \
306                 Ensure your app's main() handles the --service-label argument \
307                 and calls headless_main()."
308            );
309        }
310    }
311
312    let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
313    mgr.install().map_err(|e| e.to_string())
314}
315
316#[cfg(feature = "desktop-service")]
317#[tauri::command]
318async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
319    use desktop::service_manager::{derive_service_label, DesktopServiceManager};
320    let plugin_config = app.state::<PluginConfig>();
321    let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
322    let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
323    let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
324    mgr.uninstall().map_err(|e| e.to_string())
325}
326
327// ─── Plugin Builder ──────────────────────────────────────────────────────────
328
329/// Create the Tauri plugin with your service factory.
330///
331/// ```rust,ignore
332/// // MyService must implement BackgroundService<R>
333/// tauri::Builder::default()
334///     .plugin(tauri_plugin_background_service::init_with_service(|| MyService::new()))
335/// ```
336pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
337where
338    R: Runtime,
339    S: BackgroundService<R>,
340    F: Fn() -> S + Send + Sync + 'static,
341{
342    let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
343
344    Builder::<R, PluginConfig>::new("background-service")
345        .invoke_handler(tauri::generate_handler![
346            start,
347            stop,
348            is_running,
349            #[cfg(feature = "desktop-service")]
350            install_service,
351            #[cfg(feature = "desktop-service")]
352            uninstall_service,
353        ])
354        .setup(move |app, api| {
355            let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
356            #[cfg(mobile)]
357            let mobile_cmd_tx = cmd_tx.clone();
358            let handle = ServiceManagerHandle::new(cmd_tx);
359            app.manage(handle);
360
361            let config = api.config().clone();
362            app.manage(config.clone());
363
364            let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
365            let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
366
367            // Mode dispatch: spawn in-process actor or configure IPC for OS service.
368            #[cfg(feature = "desktop-service")]
369            if config.desktop_service_mode == "osService" {
370                // OS service mode: spawn persistent IPC client.
371                let label = desktop::service_manager::derive_service_label(
372                    app,
373                    config.desktop_service_label.as_deref(),
374                );
375                let socket_path = desktop::ipc::socket_path(&label)?;
376                let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
377                    socket_path,
378                    app.app_handle().clone(),
379                );
380                app.manage(DesktopIpcState { client });
381            } else {
382                // In-process mode (default): spawn the actor loop.
383                let factory = boxed_factory;
384                tauri::async_runtime::spawn(manager_loop(
385                    cmd_rx,
386                    factory,
387                    ios_safety_timeout_secs,
388                    ios_processing_safety_timeout_secs,
389                ));
390            }
391
392            #[cfg(not(feature = "desktop-service"))]
393            {
394                let factory = boxed_factory;
395                tauri::async_runtime::spawn(manager_loop(
396                    cmd_rx,
397                    factory,
398                    ios_safety_timeout_secs,
399                    ios_processing_safety_timeout_secs,
400                ));
401            }
402
403            #[cfg(mobile)]
404            {
405                let lifecycle = mobile::init(app, api)?;
406                let lifecycle_arc = Arc::new(lifecycle);
407
408                // Send SetMobile to actor so keepalive is managed by the actor.
409                let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
410                if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile { mobile: mobile_trait }) {
411                    log::error!("Failed to send SetMobile command: {e}");
412                }
413
414                // Store for iOS callbacks and Android auto-start helpers.
415                app.manage(lifecycle_arc);
416            }
417
418            // Android: auto-start detection after OS-initiated service restart.
419            // When LifecycleService is restarted by START_STICKY, it sets an
420            // auto-start flag in SharedPreferences and launches the Activity.
421            // This block detects that flag, clears it, and starts the service
422            // via the actor.
423            #[cfg(target_os = "android")]
424            {
425                let mobile = app.state::<Arc<MobileLifecycle<R>>>();
426                if let Ok(Some(config)) = mobile.get_auto_start_config() {
427                    let _ = mobile.clear_auto_start_config();
428
429                    // Keepalive is now handled by the actor's handle_start.
430                    // Just send Start command — actor will call start_keepalive.
431
432                    let manager = app.state::<ServiceManagerHandle<R>>();
433                    let cmd_tx = manager.cmd_tx.clone();
434                    let app_clone = app.app_handle().clone();
435
436                    // Set a no-op on_complete callback for consistency with iOS path.
437                    if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
438                        callback: Box::new(|_| {}),
439                    }) {
440                        log::error!("Failed to send SetOnComplete command: {e}");
441                    }
442
443                    tauri::async_runtime::spawn(async move {
444                        let (tx, rx) = tokio::sync::oneshot::channel();
445                        if cmd_tx
446                            .send(ManagerCommand::Start {
447                                config,
448                                reply: tx,
449                                app: app_clone,
450                            })
451                            .await
452                            .is_err()
453                        {
454                            return;
455                        }
456                        let _ = rx.await;
457                    });
458
459                    let _ = mobile.move_task_to_background();
460                }
461            }
462
463            Ok(())
464        })
465        .build()
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use async_trait::async_trait;
472    use std::sync::atomic::{AtomicUsize, Ordering};
473    use std::sync::Arc;
474
475    /// Minimal service for testing type compatibility.
476    struct DummyService;
477
478    #[async_trait]
479    impl BackgroundService<tauri::Wry> for DummyService {
480        async fn init(
481            &mut self,
482            _ctx: &ServiceContext<tauri::Wry>,
483        ) -> Result<(), ServiceError> {
484            Ok(())
485        }
486
487        async fn run(
488            &mut self,
489            _ctx: &ServiceContext<tauri::Wry>,
490        ) -> Result<(), ServiceError> {
491            Ok(())
492        }
493    }
494
495    // ── Construction Tests ───────────────────────────────────────────────
496
497    #[test]
498    fn service_manager_handle_constructs() {
499        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
500        let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
501    }
502
503    #[test]
504    fn factory_produces_boxed_service() {
505        let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
506        let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
507    }
508
509    #[test]
510    fn handle_factory_creates_fresh_instances() {
511        let count = Arc::new(AtomicUsize::new(0));
512        let count_clone = count.clone();
513
514        let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
515            count_clone.fetch_add(1, Ordering::SeqCst);
516            Box::new(DummyService)
517        });
518
519        let _ = (factory)();
520        let _ = (factory)();
521
522        assert_eq!(count.load(Ordering::SeqCst), 2);
523    }
524
525    // ── Compile-time Tests ───────────────────────────────────────────────
526
527    /// Verify `init_with_service` returns `TauriPlugin<R>`.
528    #[allow(dead_code)]
529    fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
530    where
531        S: BackgroundService<R>,
532        F: Fn() -> S + Send + Sync + 'static,
533    {
534        init_with_service(factory)
535    }
536
537    /// Verify `start` command signature is generic over `R: Runtime`.
538    #[allow(dead_code)]
539    async fn start_command_signature<R: Runtime>(
540        app: AppHandle<R>,
541        config: StartConfig,
542    ) -> Result<(), String> {
543        start(app, config).await
544    }
545
546    /// Verify `stop` command signature is generic over `R: Runtime`.
547    #[allow(dead_code)]
548    async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
549        stop(app).await
550    }
551
552    /// Verify `is_running` command signature is async and generic over `R: Runtime`.
553    #[allow(dead_code)]
554    async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
555        is_running(app).await
556    }
557
558    // ── Desktop IPC State Tests ─────────────────────────────────────────
559
560    /// Verify PersistentIpcClientHandle can be constructed.
561    #[cfg(feature = "desktop-service")]
562    #[tokio::test]
563    async fn desktop_ipc_state_with_persistent_client() {
564        use desktop::ipc_client::PersistentIpcClientHandle;
565        let app = tauri::test::mock_app();
566        let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
567        let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
568        // The client is spawned but may not be connected yet — that's fine.
569        // Just verify we can construct the state.
570        let _state = DesktopIpcState { client };
571    }
572
573    // ── Desktop Command Compile-time Tests ────────────────────────────────
574
575    /// Verify `install_service` command signature is generic over `R: Runtime`.
576    #[cfg(feature = "desktop-service")]
577    #[allow(dead_code)]
578    async fn install_service_command_signature<R: Runtime>(
579        app: AppHandle<R>,
580    ) -> Result<(), String> {
581        install_service(app).await
582    }
583
584    /// Verify `uninstall_service` command signature is generic over `R: Runtime`.
585    #[cfg(feature = "desktop-service")]
586    #[allow(dead_code)]
587    async fn uninstall_service_command_signature<R: Runtime>(
588        app: AppHandle<R>,
589    ) -> Result<(), String> {
590        uninstall_service(app).await
591    }
592
593}