tauri_plugin_background_service/
lib.rs1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.2.2")]
2
3pub 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
84pub 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
98use 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#[cfg(target_os = "ios")]
121tauri::ios_plugin_binding!(init_plugin_background_service);
122
123#[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#[allow(dead_code)] async 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 Ok(Ok(Ok(()))) | Err(_) => {
186 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 _ => 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 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::command]
232async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
233 #[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 ios_set_on_complete_callback(&app).await?;
246
247 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 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 #[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 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 #[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 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#[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 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 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 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
397pub 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 #[cfg(feature = "desktop-service")]
444 if config.desktop_service_mode == "osService" {
445 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 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 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 app.manage(lifecycle_arc);
501 }
502
503 #[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 let manager = app.state::<ServiceManagerHandle<R>>();
518 let cmd_tx = manager.cmd_tx.clone();
519 let app_clone = app.app_handle().clone();
520
521 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 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 #[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 #[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 #[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 #[allow(dead_code)]
629 async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
630 stop(app).await
631 }
632
633 #[allow(dead_code)]
635 async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
636 is_running(app).await
637 }
638
639 #[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 let _state = DesktopIpcState { client };
652 }
653
654 #[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 #[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 use crate::manager::ManagerCommand;
677 use std::sync::atomic::AtomicBool;
678
679 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 let stop_sent = run_cancel_listener(
708 Box::new(|| Ok(())),
709 Box::new(|| {}),
710 cmd_tx,
711 5, )
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 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 let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
753
754 let stop_sent = run_cancel_listener(
755 Box::new(move || {
756 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, )
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 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 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}