tauri_plugin_background_service/
lib.rs1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.4.0")]
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(feature = "desktop-service")]
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(feature = "desktop-service")]
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(feature = "desktop-service")]
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(feature = "desktop-service")]
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(feature = "desktop-service")]
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(feature = "desktop-service")]
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(feature = "desktop-service")]
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(not(feature = "desktop-service"))]
492 {
493 let factory = boxed_factory;
494 tauri::async_runtime::spawn(manager_loop(
495 cmd_rx,
496 factory,
497 ios_safety_timeout_secs,
498 ios_processing_safety_timeout_secs,
499 ios_earliest_refresh_begin_minutes,
500 ios_earliest_processing_begin_minutes,
501 ios_requires_external_power,
502 ios_requires_network_connectivity,
503 ));
504 }
505
506 #[cfg(mobile)]
507 {
508 let lifecycle = mobile::init(app, api)?;
509 let lifecycle_arc = Arc::new(lifecycle);
510
511 let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
513 if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
514 mobile: mobile_trait,
515 }) {
516 log::error!("Failed to send SetMobile command: {e}");
517 }
518
519 app.manage(lifecycle_arc);
521 }
522
523 #[cfg(target_os = "android")]
529 {
530 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
531 if let Ok(Some(config)) = mobile.get_auto_start_config() {
532 let _ = mobile.clear_auto_start_config();
533
534 let manager = app.state::<ServiceManagerHandle<R>>();
538 let cmd_tx = manager.cmd_tx.clone();
539 let app_clone = app.app_handle().clone();
540
541 if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
543 callback: Box::new(|_| {}),
544 }) {
545 log::error!("Failed to send SetOnComplete command: {e}");
546 }
547
548 tauri::async_runtime::spawn(async move {
549 let (tx, rx) = tokio::sync::oneshot::channel();
550 if cmd_tx
551 .send(ManagerCommand::Start {
552 config,
553 reply: tx,
554 app: app_clone,
555 })
556 .await
557 .is_err()
558 {
559 return;
560 }
561 let _ = rx.await;
562 });
563
564 let _ = mobile.move_task_to_background();
565 }
566 }
567
568 Ok(())
569 })
570 .on_event(|app, event| {
571 if let tauri::RunEvent::Exit = event {
572 #[cfg(feature = "desktop-service")]
574 if app.try_state::<DesktopIpcState>().is_some() {
575 return;
576 }
577 let manager = app.state::<ServiceManagerHandle<R>>();
578 if let Err(e) = manager.stop_blocking() {
579 log::warn!("Failed to stop background service on app exit: {e}");
580 }
581 }
582 })
583 .build()
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use async_trait::async_trait;
590 use std::sync::atomic::{AtomicUsize, Ordering};
591 use std::sync::Arc;
592
593 struct DummyService;
595
596 #[async_trait]
597 impl BackgroundService<tauri::Wry> for DummyService {
598 async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
599 Ok(())
600 }
601
602 async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
603 Ok(())
604 }
605 }
606
607 #[test]
610 fn service_manager_handle_constructs() {
611 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
612 let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
613 }
614
615 #[test]
616 fn factory_produces_boxed_service() {
617 let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
618 let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
619 }
620
621 #[test]
622 fn handle_factory_creates_fresh_instances() {
623 let count = Arc::new(AtomicUsize::new(0));
624 let count_clone = count.clone();
625
626 let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
627 count_clone.fetch_add(1, Ordering::SeqCst);
628 Box::new(DummyService)
629 });
630
631 let _ = (factory)();
632 let _ = (factory)();
633
634 assert_eq!(count.load(Ordering::SeqCst), 2);
635 }
636
637 #[allow(dead_code)]
641 fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
642 factory: F,
643 ) -> TauriPlugin<R, PluginConfig>
644 where
645 S: BackgroundService<R>,
646 F: Fn() -> S + Send + Sync + 'static,
647 {
648 init_with_service(factory)
649 }
650
651 #[allow(dead_code)]
653 async fn start_command_signature<R: Runtime>(
654 app: AppHandle<R>,
655 config: StartConfig,
656 ) -> Result<(), String> {
657 start(app, config).await
658 }
659
660 #[allow(dead_code)]
662 async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
663 stop(app).await
664 }
665
666 #[allow(dead_code)]
668 async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
669 is_running(app).await
670 }
671
672 #[allow(dead_code)]
674 async fn get_service_state_command_signature<R: Runtime>(
675 app: AppHandle<R>,
676 ) -> Result<models::ServiceStatus, String> {
677 get_service_state(app).await
678 }
679
680 #[cfg(feature = "desktop-service")]
684 #[tokio::test]
685 async fn desktop_ipc_state_with_persistent_client() {
686 use desktop::ipc_client::PersistentIpcClientHandle;
687 let app = tauri::test::mock_app();
688 let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
689 let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
690 let _state = DesktopIpcState { client };
693 }
694
695 #[cfg(feature = "desktop-service")]
699 #[allow(dead_code)]
700 async fn install_service_command_signature<R: Runtime>(
701 app: AppHandle<R>,
702 ) -> Result<(), String> {
703 install_service(app).await
704 }
705
706 #[cfg(feature = "desktop-service")]
708 #[allow(dead_code)]
709 async fn uninstall_service_command_signature<R: Runtime>(
710 app: AppHandle<R>,
711 ) -> Result<(), String> {
712 uninstall_service(app).await
713 }
714
715 #[allow(dead_code)]
721 fn on_event_shutdown_closure_type_checks<R: Runtime>(app: &AppHandle<R>) {
722 let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
723 if let tauri::RunEvent::Exit = event {
724 let manager = _app.state::<ServiceManagerHandle<R>>();
725 if let Err(_e) = manager.stop_blocking() {
726 log::warn!("bg service shutdown on exit failed: {_e}");
727 }
728 }
729 };
730 }
731
732 use crate::manager::ManagerCommand;
735 use std::sync::atomic::AtomicBool;
736
737 fn spawn_stop_drain(
740 mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
741 ) -> tokio::sync::oneshot::Receiver<bool> {
742 let (seen_tx, seen_rx) = tokio::sync::oneshot::channel::<bool>();
743 tokio::spawn(async move {
744 let result =
745 tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
746 match result {
747 Ok(Some(ManagerCommand::Stop { reply })) => {
748 let _ = reply.send(Ok(()));
749 let _ = seen_tx.send(true);
750 }
751 _ => {
752 let _ = seen_tx.send(false);
753 }
754 }
755 });
756 seen_rx
757 }
758
759 #[tokio::test]
760 async fn cancel_listener_resolved_invoke_sends_stop() {
761 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
762 let seen = spawn_stop_drain(cmd_rx);
763
764 let stop_sent = run_cancel_listener(
766 Box::new(|| Ok(())),
767 Box::new(|| {}),
768 cmd_tx,
769 5, )
771 .await;
772
773 assert!(stop_sent, "resolved invoke should return true");
774 assert!(
775 seen.await.unwrap(),
776 "Stop command should be sent on resolved invoke"
777 );
778 }
779
780 #[tokio::test]
781 async fn cancel_listener_rejected_invoke_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(|| Err(ServiceError::Platform("rejected".into()))),
788 Box::new(|| {}),
789 cmd_tx,
790 5,
791 )
792 .await;
793
794 assert!(!stop_sent, "rejected invoke should return false");
795 assert!(
796 !seen.await.unwrap(),
797 "Stop command should NOT be sent on rejected invoke"
798 );
799 }
800
801 #[tokio::test]
802 async fn cancel_listener_timeout_sends_stop() {
803 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
804 let cancel_called = Arc::new(AtomicBool::new(false));
805 let cancel_called_clone = cancel_called.clone();
806 let seen = spawn_stop_drain(cmd_rx);
807
808 let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
811
812 let stop_sent = run_cancel_listener(
813 Box::new(move || {
814 let _ = unblock_rx.recv();
816 Ok(())
817 }),
818 Box::new(move || {
819 cancel_called_clone.store(true, Ordering::SeqCst);
820 let _ = unblock_tx.send(());
821 }),
822 cmd_tx,
823 0, )
825 .await;
826
827 assert!(stop_sent, "timeout should return true");
828 assert!(
829 cancel_called.load(Ordering::SeqCst),
830 "cancel_fn should be called on timeout"
831 );
832 assert!(
833 seen.await.unwrap(),
834 "Stop command should be sent on timeout"
835 );
836 }
837
838 #[tokio::test]
839 async fn cancel_listener_join_error_no_stop() {
840 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
841 let seen = spawn_stop_drain(cmd_rx);
842
843 let stop_sent = run_cancel_listener(
845 Box::new(|| panic!("simulated panic in wait_for_cancel")),
846 Box::new(|| {}),
847 cmd_tx,
848 5,
849 )
850 .await;
851
852 assert!(!stop_sent, "join error should return false (no stop sent)");
854 assert!(
855 !seen.await.unwrap(),
856 "Stop command should NOT be sent on join error"
857 );
858 }
859}