1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.5.3")]
2
3pub mod capabilities;
73pub mod desired_state;
74pub mod error;
75pub mod manager;
76pub mod models;
77pub mod notifier;
78pub mod service_trait;
79pub mod validator;
80
81#[cfg(mobile)]
82pub mod mobile;
83
84#[cfg(feature = "desktop-service")]
85pub mod desktop;
86
87pub use error::ServiceError;
90#[doc(hidden)]
91pub use manager::{manager_loop, OnCompleteCallback, ServiceFactory, ServiceManagerHandle};
92#[doc(hidden)]
93pub use models::AutoStartConfig;
94pub use models::{
95 IOSSchedulingStatus, PendingTaskInfo, PluginConfig, PluginEvent, ServiceContext, ServiceState,
96 ServiceStatus, SetupIssue, SetupValidationReport, StartConfig,
97};
98pub use notifier::Notifier;
99pub use service_trait::BackgroundService;
100
101#[cfg(all(feature = "desktop-service", unix))]
102pub use desktop::headless::headless_main;
103
104use tauri::{
107 plugin::{Builder, TauriPlugin},
108 AppHandle, Manager, Runtime,
109};
110
111use crate::manager::ManagerCommand;
112
113#[cfg(mobile)]
114use crate::manager::MobileKeepalive;
115
116#[cfg(mobile)]
117use mobile::MobileLifecycle;
118
119#[cfg(mobile)]
120use std::sync::Arc;
121
122#[cfg(target_os = "ios")]
127tauri::ios_plugin_binding!(init_plugin_background_service);
128
129#[cfg(target_os = "ios")]
136async fn ios_set_on_complete_callback<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
137 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
138 let mobile_handle = mobile.handle.clone();
139 let manager = app.state::<ServiceManagerHandle<R>>();
140
141 let mob_for_complete = MobileLifecycle {
142 handle: mobile_handle,
143 };
144 manager
145 .cmd_tx
146 .send(ManagerCommand::SetOnComplete {
147 callback: Box::new(move |success| {
148 let _ = mob_for_complete.complete_bg_task(success);
149 }),
150 })
151 .await
152 .map_err(|e| e.to_string())
153}
154
155#[cfg(not(target_os = "ios"))]
156async fn ios_set_on_complete_callback<R: Runtime>(_app: &AppHandle<R>) -> Result<(), String> {
157 Ok(())
158}
159
160#[allow(dead_code)] async fn run_cancel_listener<R: Runtime>(
182 wait_fn: Box<dyn FnOnce() -> Result<(), ServiceError> + Send>,
183 cancel_fn: Box<dyn FnOnce() + Send>,
184 cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
185 timeout_secs: u64,
186) -> bool {
187 let handle = tokio::task::spawn_blocking(wait_fn);
188 let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), handle).await;
189 match result {
190 Ok(Ok(Ok(()))) | Err(_) => {
192 if result.is_err() {
194 cancel_fn();
195 }
196 let (tx, rx) = tokio::sync::oneshot::channel();
197 let _ = cmd_tx.send(ManagerCommand::Stop { reply: tx }).await;
198 let _ = rx.await;
199 true
200 }
201 _ => false,
203 }
204}
205
206#[cfg(target_os = "ios")]
207fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
208 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
209 let mobile_handle = mobile.handle.clone();
210 let mobile_handle_for_cancel = mobile.handle.clone();
211 let manager = app.state::<ServiceManagerHandle<R>>();
212 let cmd_tx = manager.cmd_tx.clone();
213
214 tokio::spawn(async move {
215 let wait_fn = Box::new(move || {
216 let mob = MobileLifecycle {
217 handle: mobile_handle,
218 };
219 mob.wait_for_cancel()
220 });
221 let cancel_fn = Box::new(move || {
222 let cancel_mob = MobileLifecycle {
223 handle: mobile_handle_for_cancel,
224 };
225 let _ = cancel_mob.cancel_cancel_listener();
226 });
227 let _ = run_cancel_listener(wait_fn, cancel_fn, cmd_tx, timeout_secs).await;
229 });
230}
231
232#[cfg(not(target_os = "ios"))]
233fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
234
235#[tauri::command]
238async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
239 #[cfg(all(feature = "desktop-service", unix))]
241 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
242 if ipc_state.client.is_connected() {
244 return ipc_state
245 .client
246 .start(config)
247 .await
248 .map_err(|e| e.to_string());
249 }
250
251 let plugin_config = app.state::<PluginConfig>();
253 if !plugin_config.desktop_start_service_if_missing {
254 return Err(ServiceError::Ipc("ipcUnavailable".into()).to_string());
255 }
256
257 let socket_path = ipc_state.client.socket_path().display().to_string();
259 let timeout =
260 std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
261
262 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
263 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
264 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
265 {
266 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
267 mgr.start().map_err(|e| e.to_string())?;
268 }
269
270 let connected = ipc_state
271 .client
272 .wait_for_connected(timeout)
273 .await
274 .map_err(|e| e.to_string())?;
275
276 if !connected {
277 return Err(
278 ServiceError::Ipc(format!("ipcUnavailable: socket {socket_path}")).to_string(),
279 );
280 }
281
282 return ipc_state
284 .client
285 .start(config)
286 .await
287 .map_err(|e| e.to_string());
288 }
289
290 ios_set_on_complete_callback(&app).await?;
293
294 let manager = app.state::<ServiceManagerHandle<R>>();
298 let (tx, rx) = tokio::sync::oneshot::channel();
299 manager
300 .cmd_tx
301 .send(ManagerCommand::Start {
302 config,
303 reply: tx,
304 app: app.clone(),
305 })
306 .await
307 .map_err(|e| e.to_string())?;
308
309 rx.await
310 .map_err(|e| e.to_string())?
311 .map_err(|e| e.to_string())?;
312
313 let plugin_config = app.state::<PluginConfig>();
315 ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
316
317 Ok(())
318}
319
320#[tauri::command]
321async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
322 #[cfg(all(feature = "desktop-service", unix))]
324 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
325 return ipc_state.client.stop().await.map_err(|e| e.to_string());
326 }
327
328 let manager = app.state::<ServiceManagerHandle<R>>();
330 let (tx, rx) = tokio::sync::oneshot::channel();
331 manager
332 .cmd_tx
333 .send(ManagerCommand::Stop { reply: tx })
334 .await
335 .map_err(|e| e.to_string())?;
336
337 rx.await
338 .map_err(|e| e.to_string())?
339 .map_err(|e| e.to_string())
340}
341
342#[tauri::command]
343async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
344 #[cfg(all(feature = "desktop-service", unix))]
346 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
347 return ipc_state.client.is_running().await.unwrap_or(false);
348 }
349
350 let manager = app.state::<ServiceManagerHandle<R>>();
352 let (tx, rx) = tokio::sync::oneshot::channel();
353 if manager
354 .cmd_tx
355 .send(ManagerCommand::IsRunning { reply: tx })
356 .await
357 .is_err()
358 {
359 return false;
360 }
361 rx.await.unwrap_or(false)
362}
363
364#[tauri::command]
365async fn get_service_state<R: Runtime>(app: AppHandle<R>) -> Result<models::ServiceStatus, String> {
366 #[cfg(all(feature = "desktop-service", unix))]
368 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
369 return ipc_state
370 .client
371 .get_state()
372 .await
373 .map_err(|e| e.to_string());
374 }
375
376 let manager = app.state::<ServiceManagerHandle<R>>();
378 Ok(manager.get_state().await)
379}
380
381#[tauri::command]
382#[allow(unused_variables)]
383async fn get_platform_capabilities<R: Runtime>(
384 app: AppHandle<R>,
385) -> Result<models::PlatformCapabilities, String> {
386 #[cfg(feature = "desktop-service")]
387 let plugin_config = app.state::<PluginConfig>();
388
389 #[cfg(feature = "desktop-service")]
390 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
391 #[cfg(not(feature = "desktop-service"))]
392 let desktop_mode: Option<&str> = None;
393
394 let (platform, lifecycle_mode) =
395 capabilities::CapabilityProvider::detect_platform(desktop_mode);
396
397 #[cfg(all(feature = "desktop-service", unix))]
398 let os_service_installed = if matches!(lifecycle_mode, models::LifecycleMode::DesktopOsService)
399 {
400 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
401 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
402 let exec = std::env::current_exe().unwrap_or_default();
403 DesktopServiceManager::new(&label, exec)
404 .map(|_| true)
405 .unwrap_or(false)
406 } else {
407 false
408 };
409
410 #[cfg(not(all(feature = "desktop-service", unix)))]
411 let os_service_installed = false;
412
413 Ok(capabilities::CapabilityProvider::capabilities(
414 platform,
415 lifecycle_mode,
416 os_service_installed,
417 ))
418}
419
420#[tauri::command]
425async fn get_scheduling_status<R: Runtime>(
426 app: AppHandle<R>,
427) -> Result<models::IOSSchedulingStatus, String> {
428 #[cfg(target_os = "ios")]
429 {
430 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
431 mobile
432 .get_scheduling_status()
433 .map_err(|e| e.to_string())
434 .and_then(|opt| opt.ok_or_else(|| "no scheduling status available".to_string()))
435 }
436 #[cfg(not(target_os = "ios"))]
437 {
438 let _ = app;
439 Ok(models::IOSSchedulingStatus {
440 refresh_scheduled: false,
441 processing_scheduled: false,
442 refresh_error: None,
443 processing_error: None,
444 })
445 }
446}
447
448#[tauri::command]
454async fn get_pending_bg_task<R: Runtime>(
455 app: AppHandle<R>,
456) -> Result<Option<models::PendingTaskInfo>, String> {
457 #[cfg(target_os = "ios")]
458 {
459 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
460 mobile.get_pending_bg_task().map_err(|e| e.to_string())
461 }
462 #[cfg(not(target_os = "ios"))]
463 {
464 let _ = app;
465 Ok(None)
466 }
467}
468
469#[tauri::command]
476async fn enable_auto_restart<R: Runtime>(
477 app: AppHandle<R>,
478 config: Option<StartConfig>,
479) -> Result<(), String> {
480 #[cfg(all(feature = "desktop-service", unix))]
482 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
483 return ipc_state
484 .client
485 .enable_auto_restart(config)
486 .await
487 .map_err(|e| e.to_string());
488 }
489
490 let manager = app.state::<ServiceManagerHandle<R>>();
491 let (tx, rx) = tokio::sync::oneshot::channel();
492 manager
493 .cmd_tx
494 .send(ManagerCommand::EnableAutoRestart { config, reply: tx })
495 .await
496 .map_err(|e| e.to_string())?;
497 rx.await
498 .map_err(|e| e.to_string())?
499 .map_err(|e| e.to_string())
500}
501
502#[tauri::command]
509async fn disable_auto_restart<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
510 #[cfg(all(feature = "desktop-service", unix))]
512 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
513 return ipc_state
514 .client
515 .disable_auto_restart()
516 .await
517 .map_err(|e| e.to_string());
518 }
519
520 let manager = app.state::<ServiceManagerHandle<R>>();
521 let (tx, rx) = tokio::sync::oneshot::channel();
522 manager
523 .cmd_tx
524 .send(ManagerCommand::DisableAutoRestart { reply: tx })
525 .await
526 .map_err(|e| e.to_string())?;
527 rx.await
528 .map_err(|e| e.to_string())?
529 .map_err(|e| e.to_string())
530}
531
532#[tauri::command]
537async fn get_desired_service_state<R: Runtime>(
538 app: AppHandle<R>,
539) -> Result<Option<desired_state::DesiredState>, String> {
540 #[cfg(all(feature = "desktop-service", unix))]
542 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
543 return ipc_state
544 .client
545 .get_desired_state()
546 .await
547 .map_err(|e| e.to_string());
548 }
549
550 let manager = app.state::<ServiceManagerHandle<R>>();
551 let (tx, rx) = tokio::sync::oneshot::channel();
552 manager
553 .cmd_tx
554 .send(ManagerCommand::GetDesiredState { reply: tx })
555 .await
556 .map_err(|e| e.to_string())?;
557 rx.await.map_err(|e| e.to_string())
558}
559
560#[tauri::command]
565#[allow(unused_variables)]
566async fn validate_setup<R: Runtime>(
567 app: AppHandle<R>,
568) -> Result<models::SetupValidationReport, String> {
569 #[cfg(all(feature = "desktop-service", unix))]
571 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
572 return ipc_state
573 .client
574 .validate_setup()
575 .await
576 .map_err(|e| e.to_string());
577 }
578
579 #[cfg(feature = "desktop-service")]
580 let plugin_config = app.state::<PluginConfig>();
581
582 #[cfg(feature = "desktop-service")]
583 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
584 #[cfg(not(feature = "desktop-service"))]
585 let desktop_mode: Option<&str> = None;
586
587 let (platform, _) = capabilities::CapabilityProvider::detect_platform(desktop_mode);
588 Ok(validator::SetupValidator::validate(platform))
589}
590
591#[cfg(all(feature = "desktop-service", unix))]
598struct DesktopIpcState {
599 client: desktop::ipc_client::PersistentIpcClientHandle,
600}
601
602#[cfg(feature = "desktop-service")]
603#[tauri::command]
604async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
605 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
606 let plugin_config = app.state::<PluginConfig>();
607 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
608 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
609
610 if !exec_path.exists() {
612 return Err(format!(
613 "Current executable does not exist at {}: cannot install OS service",
614 exec_path.display()
615 ));
616 }
617
618 let validate_result = tokio::time::timeout(
622 std::time::Duration::from_secs(5),
623 tokio::process::Command::new(&exec_path)
624 .arg("--service-label")
625 .arg(&label)
626 .arg("--validate-service-install")
627 .output(),
628 )
629 .await;
630
631 match validate_result {
632 Ok(Ok(output)) => {
633 let stdout = String::from_utf8_lossy(&output.stdout);
634 if !stdout.trim().contains("ok") {
635 return Err("Binary does not handle --validate-service-install. \
636 Ensure headless_main() is called from your app's main()."
637 .into());
638 }
639 }
640 Ok(Err(e)) => {
641 return Err(format!(
642 "Failed to validate executable for --service-label: {e}"
643 ));
644 }
645 Err(_) => {
646 log::warn!(
649 "Timeout validating --service-label support. \
650 Ensure your app's main() handles the --service-label argument \
651 and calls headless_main()."
652 );
653 }
654 }
655
656 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
657 use desktop::service_manager::InstallOptions;
658 let options = InstallOptions {
659 autostart: plugin_config.desktop_service_autostart,
660 restart_delay_secs: None,
661 journal_output: true,
662 log_path: None,
663 };
664 mgr.install(&options).map_err(|e| e.to_string())
665}
666
667#[cfg(feature = "desktop-service")]
668#[tauri::command]
669async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
670 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
671 let plugin_config = app.state::<PluginConfig>();
672 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
673 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
674 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
675 mgr.uninstall().map_err(|e| e.to_string())
676}
677
678#[cfg(feature = "desktop-service")]
682#[allow(dead_code)] fn windows_os_service_unsupported() -> ServiceError {
684 ServiceError::Platform("Windows OS-service mode is not yet supported".into())
685}
686
687#[cfg(all(feature = "desktop-service", unix))]
692fn build_os_service_status(
693 label: &str,
694 ipc_connected: bool,
695 socket_path: Option<String>,
696 last_error: Option<String>,
697) -> models::OsServiceStatus {
698 let mode = if cfg!(target_os = "macos") {
699 "launchd"
700 } else {
701 "systemd"
702 };
703
704 let installed = if ipc_connected {
705 models::OsServiceInstallState::Running
706 } else {
707 models::OsServiceInstallState::Installed
711 };
712
713 models::OsServiceStatus {
714 label: label.to_string(),
715 mode: mode.to_string(),
716 installed,
717 ipc_connected,
718 socket_path,
719 last_error,
720 }
721}
722
723#[cfg(feature = "desktop-service")]
728#[tauri::command]
729async fn start_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
730 #[cfg(unix)]
731 {
732 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
733 let plugin_config = app.state::<PluginConfig>();
734 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
735 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
736 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
737 mgr.start().map_err(|e| e.to_string())
738 }
739 #[cfg(not(unix))]
740 {
741 let _ = app;
742 Err(windows_os_service_unsupported().to_string())
743 }
744}
745
746#[cfg(feature = "desktop-service")]
751#[tauri::command]
752async fn stop_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
753 #[cfg(unix)]
754 {
755 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
756 let plugin_config = app.state::<PluginConfig>();
757 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
758 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
759 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
760 mgr.stop().map_err(|e| e.to_string())
761 }
762 #[cfg(not(unix))]
763 {
764 let _ = app;
765 Err(windows_os_service_unsupported().to_string())
766 }
767}
768
769#[cfg(feature = "desktop-service")]
773#[tauri::command]
774async fn restart_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
775 #[cfg(unix)]
776 {
777 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
778 let plugin_config = app.state::<PluginConfig>();
779 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
780 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
781 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
782 mgr.stop().ok(); mgr.start().map_err(|e| e.to_string())
784 }
785 #[cfg(not(unix))]
786 {
787 let _ = app;
788 Err(windows_os_service_unsupported().to_string())
789 }
790}
791
792#[cfg(feature = "desktop-service")]
797#[tauri::command]
798async fn get_os_service_status<R: Runtime>(
799 app: AppHandle<R>,
800) -> Result<models::OsServiceStatus, String> {
801 #[cfg(unix)]
802 {
803 use desktop::service_manager::derive_service_label;
804 let plugin_config = app.state::<PluginConfig>();
805 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
806
807 let ipc_connected = app
808 .try_state::<DesktopIpcState>()
809 .map(|s| s.client.is_connected())
810 .unwrap_or(false);
811
812 let socket_path = desktop::ipc::socket_path(&label)
813 .ok()
814 .map(|p| p.to_string_lossy().to_string());
815
816 Ok(build_os_service_status(
817 &label,
818 ipc_connected,
819 socket_path,
820 None,
821 ))
822 }
823 #[cfg(not(unix))]
824 {
825 let _ = app;
826 Err(windows_os_service_unsupported().to_string())
827 }
828}
829
830pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
840where
841 R: Runtime,
842 S: BackgroundService<R>,
843 F: Fn() -> S + Send + Sync + 'static,
844{
845 let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
846
847 Builder::<R, PluginConfig>::new("background-service")
848 .invoke_handler(tauri::generate_handler![
849 start,
850 stop,
851 is_running,
852 get_service_state,
853 get_platform_capabilities,
854 get_scheduling_status,
855 get_pending_bg_task,
856 enable_auto_restart,
857 disable_auto_restart,
858 get_desired_service_state,
859 validate_setup,
860 #[cfg(feature = "desktop-service")]
861 install_service,
862 #[cfg(feature = "desktop-service")]
863 uninstall_service,
864 #[cfg(feature = "desktop-service")]
865 start_os_service,
866 #[cfg(feature = "desktop-service")]
867 stop_os_service,
868 #[cfg(feature = "desktop-service")]
869 restart_os_service,
870 #[cfg(feature = "desktop-service")]
871 get_os_service_status,
872 ])
873 .setup(move |app, api| {
874 let config = api.config().clone();
875 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(config.channel_capacity);
876 #[cfg(mobile)]
877 let mobile_cmd_tx = cmd_tx.clone();
878 let handle = ServiceManagerHandle::new(cmd_tx);
879 app.manage(handle);
880
881 app.manage(config.clone());
882
883 let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
884 let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
885 let ios_earliest_refresh_begin_minutes = config.ios_earliest_refresh_begin_minutes;
886 let ios_earliest_processing_begin_minutes =
887 config.ios_earliest_processing_begin_minutes;
888 let ios_requires_external_power = config.ios_requires_external_power;
889 let ios_requires_network_connectivity = config.ios_requires_network_connectivity;
890
891 #[cfg(all(feature = "desktop-service", unix))]
893 if config.desktop_service_mode == "osService" {
894 let label = desktop::service_manager::derive_service_label(
896 app,
897 config.desktop_service_label.as_deref(),
898 );
899 let socket_path = desktop::ipc::socket_path(&label)?;
900 let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
901 socket_path,
902 app.app_handle().clone(),
903 );
904 app.manage(DesktopIpcState { client });
905 } else {
906 let factory = boxed_factory;
908 tauri::async_runtime::spawn(manager_loop(
909 cmd_rx,
910 factory,
911 ios_safety_timeout_secs,
912 ios_processing_safety_timeout_secs,
913 ios_earliest_refresh_begin_minutes,
914 ios_earliest_processing_begin_minutes,
915 ios_requires_external_power,
916 ios_requires_network_connectivity,
917 None,
918 ));
919 }
920
921 #[cfg(all(feature = "desktop-service", not(unix)))]
922 {
923 let factory = boxed_factory;
925 tauri::async_runtime::spawn(manager_loop(
926 cmd_rx,
927 factory,
928 ios_safety_timeout_secs,
929 ios_processing_safety_timeout_secs,
930 ios_earliest_refresh_begin_minutes,
931 ios_earliest_processing_begin_minutes,
932 ios_requires_external_power,
933 ios_requires_network_connectivity,
934 None,
935 ));
936 }
937
938 #[cfg(not(feature = "desktop-service"))]
939 {
940 let factory = boxed_factory;
941 tauri::async_runtime::spawn(manager_loop(
942 cmd_rx,
943 factory,
944 ios_safety_timeout_secs,
945 ios_processing_safety_timeout_secs,
946 ios_earliest_refresh_begin_minutes,
947 ios_earliest_processing_begin_minutes,
948 ios_requires_external_power,
949 ios_requires_network_connectivity,
950 None,
951 ));
952 }
953
954 #[cfg(mobile)]
955 {
956 let lifecycle = mobile::init(app, api)?;
957 let lifecycle_arc = Arc::new(lifecycle);
958
959 let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
961 if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
962 mobile: mobile_trait,
963 }) {
964 log::error!("Failed to send SetMobile command: {e}");
965 }
966
967 app.manage(lifecycle_arc);
969 }
970
971 #[cfg(target_os = "ios")]
975 {
976 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
977
978 match mobile.get_pending_bg_task() {
979 Ok(Some(_pending)) => {
980 let should_start = mobile
982 .get_scheduling_status_raw()
983 .ok()
984 .and_then(|v| {
985 let desired = v.get("desiredRunning")?.as_bool()?;
986 let config_str = v.get("lastStartConfig")?.as_str()?;
987 Some((desired, config_str.to_string()))
988 });
989
990 if let Some((true, config_str)) = should_start {
991 if let Ok(config) =
992 serde_json::from_str::<StartConfig>(&config_str)
993 {
994 let manager = app.state::<ServiceManagerHandle<R>>();
995 let cmd_tx = manager.cmd_tx.clone();
996 let app_clone = app.app_handle().clone();
997
998 let plugin_config = app.state::<PluginConfig>();
1000 let timeout_secs = plugin_config.ios_cancel_listener_timeout_secs;
1001
1002 let mob_handle = mobile.handle.clone();
1004 if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
1005 callback: Box::new(move |success| {
1006 let ml =
1007 MobileLifecycle { handle: mob_handle.clone() };
1008 let _ = ml.complete_bg_task(success);
1009 }),
1010 }) {
1011 log::error!("Failed to send SetOnComplete for iOS auto-start: {e}");
1012 }
1013
1014 tauri::async_runtime::spawn(async move {
1015 let (tx, rx) = tokio::sync::oneshot::channel();
1016 if cmd_tx
1017 .send(ManagerCommand::Start {
1018 config,
1019 reply: tx,
1020 app: app_clone.clone(),
1021 })
1022 .await
1023 .is_err()
1024 {
1025 return;
1026 }
1027 if let Ok(Ok(())) = rx.await {
1028 ios_spawn_cancel_listener(&app_clone, timeout_secs);
1029 }
1030 });
1031
1032 log::info!("iOS: auto-starting service for pending BGTask");
1033 } else {
1034 log::warn!("iOS: failed to parse stored start config");
1035 }
1036 } else {
1037 log::info!(
1038 "iOS: pending BGTask but desired_running is false, skipping auto-start"
1039 );
1040 }
1041
1042 let _ = mobile.clear_pending_bg_task();
1043 }
1044 Ok(None) => {
1045 }
1047 Err(e) => {
1048 log::warn!("iOS: failed to get pending BGTask: {e}");
1049 }
1050 }
1051 }
1052
1053 #[cfg(target_os = "android")]
1059 {
1060 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
1061 if let Ok(Some(config)) = mobile.get_auto_start_config() {
1062 let _ = mobile.clear_auto_start_config();
1063
1064 let manager = app.state::<ServiceManagerHandle<R>>();
1068 let cmd_tx = manager.cmd_tx.clone();
1069 let app_clone = app.app_handle().clone();
1070
1071 if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
1073 callback: Box::new(|_| {}),
1074 }) {
1075 log::error!("Failed to send SetOnComplete command: {e}");
1076 }
1077
1078 tauri::async_runtime::spawn(async move {
1079 let (tx, rx) = tokio::sync::oneshot::channel();
1080 if cmd_tx
1081 .send(ManagerCommand::Start {
1082 config,
1083 reply: tx,
1084 app: app_clone,
1085 })
1086 .await
1087 .is_err()
1088 {
1089 return;
1090 }
1091 let _ = rx.await;
1092 });
1093
1094 let _ = mobile.move_task_to_background();
1095 }
1096 }
1097
1098 Ok(())
1099 })
1100 .on_event(|app, event| {
1101 if let tauri::RunEvent::Exit = event {
1102 #[cfg(all(feature = "desktop-service", unix))]
1104 if app.try_state::<DesktopIpcState>().is_some() {
1105 return;
1106 }
1107 let manager = app.state::<ServiceManagerHandle<R>>();
1108 if let Err(e) = manager.stop_blocking() {
1109 log::warn!("Failed to stop background service on app exit: {e}");
1110 }
1111 }
1112 })
1113 .build()
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use async_trait::async_trait;
1120 use std::sync::atomic::{AtomicUsize, Ordering};
1121 use std::sync::Arc;
1122
1123 struct DummyService;
1125
1126 #[async_trait]
1127 impl BackgroundService<tauri::Wry> for DummyService {
1128 async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
1129 Ok(())
1130 }
1131
1132 async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
1133 Ok(())
1134 }
1135 }
1136
1137 #[test]
1140 fn service_manager_handle_constructs() {
1141 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
1142 let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
1143 }
1144
1145 #[test]
1146 fn factory_produces_boxed_service() {
1147 let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
1148 let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
1149 }
1150
1151 #[test]
1152 fn handle_factory_creates_fresh_instances() {
1153 let count = Arc::new(AtomicUsize::new(0));
1154 let count_clone = count.clone();
1155
1156 let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
1157 count_clone.fetch_add(1, Ordering::SeqCst);
1158 Box::new(DummyService)
1159 });
1160
1161 let _ = (factory)();
1162 let _ = (factory)();
1163
1164 assert_eq!(count.load(Ordering::SeqCst), 2);
1165 }
1166
1167 #[allow(dead_code)]
1171 fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
1172 factory: F,
1173 ) -> TauriPlugin<R, PluginConfig>
1174 where
1175 S: BackgroundService<R>,
1176 F: Fn() -> S + Send + Sync + 'static,
1177 {
1178 init_with_service(factory)
1179 }
1180
1181 #[allow(dead_code)]
1183 async fn start_command_signature<R: Runtime>(
1184 app: AppHandle<R>,
1185 config: StartConfig,
1186 ) -> Result<(), String> {
1187 start(app, config).await
1188 }
1189
1190 #[allow(dead_code)]
1192 async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1193 stop(app).await
1194 }
1195
1196 #[allow(dead_code)]
1198 async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
1199 is_running(app).await
1200 }
1201
1202 #[allow(dead_code)]
1204 async fn get_service_state_command_signature<R: Runtime>(
1205 app: AppHandle<R>,
1206 ) -> Result<models::ServiceStatus, String> {
1207 get_service_state(app).await
1208 }
1209
1210 #[allow(dead_code)]
1212 async fn get_scheduling_status_command_signature<R: Runtime>(
1213 app: AppHandle<R>,
1214 ) -> Result<models::IOSSchedulingStatus, String> {
1215 get_scheduling_status(app).await
1216 }
1217
1218 #[allow(dead_code)]
1220 async fn get_pending_bg_task_command_signature<R: Runtime>(
1221 app: AppHandle<R>,
1222 ) -> Result<Option<models::PendingTaskInfo>, String> {
1223 get_pending_bg_task(app).await
1224 }
1225
1226 #[allow(dead_code)]
1228 async fn enable_auto_restart_command_signature<R: Runtime>(
1229 app: AppHandle<R>,
1230 config: Option<StartConfig>,
1231 ) -> Result<(), String> {
1232 enable_auto_restart(app, config).await
1233 }
1234
1235 #[allow(dead_code)]
1237 async fn disable_auto_restart_command_signature<R: Runtime>(
1238 app: AppHandle<R>,
1239 ) -> Result<(), String> {
1240 disable_auto_restart(app).await
1241 }
1242
1243 #[allow(dead_code)]
1245 async fn get_desired_service_state_command_signature<R: Runtime>(
1246 app: AppHandle<R>,
1247 ) -> Result<Option<desired_state::DesiredState>, String> {
1248 get_desired_service_state(app).await
1249 }
1250
1251 #[allow(dead_code)]
1253 async fn validate_setup_command_signature<R: Runtime>(
1254 app: AppHandle<R>,
1255 ) -> Result<models::SetupValidationReport, String> {
1256 validate_setup(app).await
1257 }
1258
1259 #[cfg(all(feature = "desktop-service", unix))]
1263 #[tokio::test]
1264 async fn desktop_ipc_state_with_persistent_client() {
1265 use desktop::ipc_client::PersistentIpcClientHandle;
1266 let app = tauri::test::mock_app();
1267 let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
1268 let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
1269 let _state = DesktopIpcState { client };
1272 }
1273
1274 #[cfg(feature = "desktop-service")]
1278 #[allow(dead_code)]
1279 async fn install_service_command_signature<R: Runtime>(
1280 app: AppHandle<R>,
1281 ) -> Result<(), String> {
1282 install_service(app).await
1283 }
1284
1285 #[cfg(feature = "desktop-service")]
1287 #[allow(dead_code)]
1288 async fn uninstall_service_command_signature<R: Runtime>(
1289 app: AppHandle<R>,
1290 ) -> Result<(), String> {
1291 uninstall_service(app).await
1292 }
1293
1294 #[cfg(feature = "desktop-service")]
1296 #[allow(dead_code)]
1297 async fn start_os_service_command_signature<R: Runtime>(
1298 app: AppHandle<R>,
1299 ) -> Result<(), String> {
1300 start_os_service(app).await
1301 }
1302
1303 #[cfg(feature = "desktop-service")]
1305 #[allow(dead_code)]
1306 async fn stop_os_service_command_signature<R: Runtime>(
1307 app: AppHandle<R>,
1308 ) -> Result<(), String> {
1309 stop_os_service(app).await
1310 }
1311
1312 #[cfg(feature = "desktop-service")]
1314 #[allow(dead_code)]
1315 async fn restart_os_service_command_signature<R: Runtime>(
1316 app: AppHandle<R>,
1317 ) -> Result<(), String> {
1318 restart_os_service(app).await
1319 }
1320
1321 #[cfg(feature = "desktop-service")]
1323 #[allow(dead_code)]
1324 async fn get_os_service_status_command_signature<R: Runtime>(
1325 app: AppHandle<R>,
1326 ) -> Result<models::OsServiceStatus, String> {
1327 get_os_service_status(app).await
1328 }
1329
1330 #[cfg(feature = "desktop-service")]
1334 #[test]
1335 fn windows_stub_returns_platform_error() {
1336 let err = windows_os_service_unsupported();
1337 assert!(
1338 matches!(err, ServiceError::Platform(ref msg) if msg.contains("not yet supported")),
1339 "Expected Platform error with 'not yet supported', got: {err}"
1340 );
1341 }
1342
1343 #[cfg(all(feature = "desktop-service", unix))]
1346 #[test]
1347 fn build_os_service_status_populates_fields() {
1348 let status = build_os_service_status(
1349 "com.example.bg-service",
1350 true,
1351 Some("/tmp/test.sock".to_string()),
1352 None,
1353 );
1354 assert_eq!(status.label, "com.example.bg-service");
1355 assert!(status.ipc_connected);
1356 assert_eq!(status.socket_path.as_deref(), Some("/tmp/test.sock"));
1357 assert!(status.last_error.is_none());
1358 }
1359
1360 #[cfg(all(feature = "desktop-service", unix))]
1362 #[test]
1363 fn build_os_service_status_mode_is_correct() {
1364 let status = build_os_service_status("test", false, None, None);
1365 #[cfg(target_os = "linux")]
1366 assert_eq!(status.mode, "systemd");
1367 #[cfg(target_os = "macos")]
1368 assert_eq!(status.mode, "launchd");
1369 }
1370
1371 #[allow(dead_code)]
1377 fn on_event_shutdown_closure_type_checks<R: Runtime>(_app: &AppHandle<R>) {
1378 let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
1379 if let tauri::RunEvent::Exit = event {
1380 let manager = _app.state::<ServiceManagerHandle<R>>();
1381 if let Err(_e) = manager.stop_blocking() {
1382 log::warn!("bg service shutdown on exit failed: {_e}");
1383 }
1384 }
1385 };
1386 }
1387
1388 use crate::manager::ManagerCommand;
1391 use std::sync::atomic::AtomicBool;
1392
1393 fn spawn_stop_drain(
1396 mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
1397 ) -> tokio::sync::oneshot::Receiver<bool> {
1398 let (seen_tx, seen_rx) = tokio::sync::oneshot::channel::<bool>();
1399 tokio::spawn(async move {
1400 let result =
1401 tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
1402 match result {
1403 Ok(Some(ManagerCommand::Stop { reply })) => {
1404 let _ = reply.send(Ok(()));
1405 let _ = seen_tx.send(true);
1406 }
1407 _ => {
1408 let _ = seen_tx.send(false);
1409 }
1410 }
1411 });
1412 seen_rx
1413 }
1414
1415 #[tokio::test]
1416 async fn cancel_listener_resolved_invoke_sends_stop() {
1417 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1418 let seen = spawn_stop_drain(cmd_rx);
1419
1420 let stop_sent = run_cancel_listener(
1422 Box::new(|| Ok(())),
1423 Box::new(|| {}),
1424 cmd_tx,
1425 5, )
1427 .await;
1428
1429 assert!(stop_sent, "resolved invoke should return true");
1430 assert!(
1431 seen.await.unwrap(),
1432 "Stop command should be sent on resolved invoke"
1433 );
1434 }
1435
1436 #[tokio::test]
1437 async fn cancel_listener_rejected_invoke_no_stop() {
1438 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1439 let seen = spawn_stop_drain(cmd_rx);
1440
1441 let stop_sent = run_cancel_listener(
1443 Box::new(|| Err(ServiceError::Platform("rejected".into()))),
1444 Box::new(|| {}),
1445 cmd_tx,
1446 5,
1447 )
1448 .await;
1449
1450 assert!(!stop_sent, "rejected invoke should return false");
1451 assert!(
1452 !seen.await.unwrap(),
1453 "Stop command should NOT be sent on rejected invoke"
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn cancel_listener_timeout_sends_stop() {
1459 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1460 let cancel_called = Arc::new(AtomicBool::new(false));
1461 let cancel_called_clone = cancel_called.clone();
1462 let seen = spawn_stop_drain(cmd_rx);
1463
1464 let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
1467
1468 let stop_sent = run_cancel_listener(
1469 Box::new(move || {
1470 let _ = unblock_rx.recv();
1472 Ok(())
1473 }),
1474 Box::new(move || {
1475 cancel_called_clone.store(true, Ordering::SeqCst);
1476 let _ = unblock_tx.send(());
1477 }),
1478 cmd_tx,
1479 0, )
1481 .await;
1482
1483 assert!(stop_sent, "timeout should return true");
1484 assert!(
1485 cancel_called.load(Ordering::SeqCst),
1486 "cancel_fn should be called on timeout"
1487 );
1488 assert!(
1489 seen.await.unwrap(),
1490 "Stop command should be sent on timeout"
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn cancel_listener_join_error_no_stop() {
1496 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1497 let seen = spawn_stop_drain(cmd_rx);
1498
1499 let stop_sent = run_cancel_listener(
1501 Box::new(|| panic!("simulated panic in wait_for_cancel")),
1502 Box::new(|| {}),
1503 cmd_tx,
1504 5,
1505 )
1506 .await;
1507
1508 assert!(!stop_sent, "join error should return false (no stop sent)");
1510 assert!(
1511 !seen.await.unwrap(),
1512 "Stop command should NOT be sent on join error"
1513 );
1514 }
1515
1516 #[cfg(all(feature = "desktop-service", unix))]
1521 mod ipc_auto_start_tests {
1522 use super::*;
1523 use crate::desktop::ipc_client::PersistentIpcClientHandle;
1524 use crate::desktop::test_helpers::setup_server;
1525 use std::time::Duration;
1526
1527 #[tokio::test]
1531 async fn wait_for_connected_timeout_returns_false() {
1532 let app = tauri::test::mock_app();
1533 let path = crate::desktop::test_helpers::unique_socket_path();
1534 let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
1535
1536 let connected = handle
1537 .wait_for_connected(Duration::from_millis(200))
1538 .await
1539 .unwrap();
1540 assert!(!connected, "should return false on timeout");
1541
1542 let _ = std::fs::remove_file(&path);
1543 }
1544
1545 #[tokio::test]
1548 async fn wait_for_connected_succeeds_with_server() {
1549 let (path, shutdown, _event_tx) = setup_server();
1550 let app = tauri::test::mock_app();
1551 let handle = PersistentIpcClientHandle::spawn(path, app.handle().clone());
1552
1553 let connected = handle
1554 .wait_for_connected(Duration::from_secs(5))
1555 .await
1556 .unwrap();
1557 assert!(connected, "should connect within timeout");
1558
1559 shutdown.cancel();
1560 }
1561
1562 #[tokio::test]
1565 async fn socket_path_accessor() {
1566 let app = tauri::test::mock_app();
1567 let path = crate::desktop::test_helpers::unique_socket_path();
1568 let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
1569 assert_eq!(
1570 handle.socket_path(),
1571 &path,
1572 "socket_path() should return the path passed to spawn"
1573 );
1574 let _ = std::fs::remove_file(&path);
1575 }
1576
1577 #[tokio::test]
1583 async fn start_disconnected_without_auto_start_returns_ipc_error() {
1584 let err = ServiceError::Ipc("ipcUnavailable".into());
1585 let msg = err.to_string();
1586 assert!(
1587 msg.contains("ipcUnavailable"),
1588 "error should contain 'ipcUnavailable': {msg}"
1589 );
1590 }
1591
1592 #[tokio::test]
1594 async fn start_timeout_error_includes_socket_path() {
1595 let socket = "/tmp/test-socket-path.sock";
1596 let err = ServiceError::Ipc(format!("ipcUnavailable: socket {socket}"));
1597 let msg = err.to_string();
1598 assert!(
1599 msg.contains(socket),
1600 "error should contain socket path: {msg}"
1601 );
1602 }
1603 }
1604}