1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.4.1")]
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::{
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
100use 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#[cfg(target_os = "ios")]
123tauri::ios_plugin_binding!(init_plugin_background_service);
124
125#[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#[allow(dead_code)] async 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 Ok(Ok(Ok(()))) | Err(_) => {
188 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 _ => 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 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::command]
234async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
235 #[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 ios_set_on_complete_callback(&app).await?;
248
249 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 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 #[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 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 #[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 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 #[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 let manager = app.state::<ServiceManagerHandle<R>>();
333 Ok(manager.get_state().await)
334}
335
336#[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 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 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 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
416pub 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 #[cfg(all(feature = "desktop-service", unix))]
464 if config.desktop_service_mode == "osService" {
465 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 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 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 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 app.manage(lifecycle_arc);
537 }
538
539 #[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 let manager = app.state::<ServiceManagerHandle<R>>();
554 let cmd_tx = manager.cmd_tx.clone();
555 let app_clone = app.app_handle().clone();
556
557 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 #[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 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 #[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 #[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 #[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 #[allow(dead_code)]
678 async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
679 stop(app).await
680 }
681
682 #[allow(dead_code)]
684 async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
685 is_running(app).await
686 }
687
688 #[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 #[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 let _state = DesktopIpcState { client };
709 }
710
711 #[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 #[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 #[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 use crate::manager::ManagerCommand;
751 use std::sync::atomic::AtomicBool;
752
753 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 let stop_sent = run_cancel_listener(
782 Box::new(|| Ok(())),
783 Box::new(|| {}),
784 cmd_tx,
785 5, )
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 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 let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
827
828 let stop_sent = run_cancel_listener(
829 Box::new(move || {
830 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, )
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 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 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}