1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.7.0")]
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, LifecycleState, LifecycleStatus, PendingTaskInfo, Platform,
96 PlatformCapabilities, PluginConfig, PluginEvent, ServiceContext, ServiceState, ServiceStatus,
97 SetupIssue, SetupValidationReport, StartConfig, ValidationIssue,
98};
99pub use notifier::Notifier;
100pub use service_trait::BackgroundService;
101
102#[cfg(all(feature = "desktop-service", unix))]
103pub use desktop::headless::headless_main;
104
105use tauri::{
108 plugin::{Builder, TauriPlugin},
109 AppHandle, Manager, Runtime,
110};
111
112use crate::manager::ManagerCommand;
113
114#[cfg(mobile)]
115use crate::manager::MobileKeepalive;
116
117#[cfg(mobile)]
118use mobile::MobileLifecycle;
119
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(()))) => {
192 let (tx, rx) = tokio::sync::oneshot::channel();
193 let _ = cmd_tx
194 .send(ManagerCommand::StopWithReason {
195 reason: crate::models::StopReason::PlatformExpiration,
196 reply: tx,
197 })
198 .await;
199 let _ = rx.await;
200 true
201 }
202 Err(_) => {
204 cancel_fn();
205 let (tx, rx) = tokio::sync::oneshot::channel();
206 let _ = cmd_tx
207 .send(ManagerCommand::StopWithReason {
208 reason: crate::models::StopReason::PlatformTimeout,
209 reply: tx,
210 })
211 .await;
212 let _ = rx.await;
213 true
214 }
215 _ => false,
217 }
218}
219
220#[cfg(target_os = "ios")]
221fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
222 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
223 let mobile_handle = mobile.handle.clone();
224 let mobile_handle_for_cancel = mobile.handle.clone();
225 let manager = app.state::<ServiceManagerHandle<R>>();
226 let cmd_tx = manager.cmd_tx.clone();
227
228 tokio::spawn(async move {
229 let wait_fn = Box::new(move || {
230 let mob = MobileLifecycle {
231 handle: mobile_handle,
232 };
233 mob.wait_for_cancel()
234 });
235 let cancel_fn = Box::new(move || {
236 let cancel_mob = MobileLifecycle {
237 handle: mobile_handle_for_cancel,
238 };
239 let _ = cancel_mob.cancel_cancel_listener();
240 });
241 let _ = run_cancel_listener(wait_fn, cancel_fn, cmd_tx, timeout_secs).await;
243 });
244}
245
246#[cfg(not(target_os = "ios"))]
247fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
248
249#[tauri::command]
252async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
253 #[cfg(all(feature = "desktop-service", unix))]
255 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
256 if ipc_state.client.is_connected() {
258 return ipc_state
259 .client
260 .start(config)
261 .await
262 .map_err(|e| e.to_string());
263 }
264
265 let plugin_config = app.state::<PluginConfig>();
267 if !plugin_config.desktop_start_service_if_missing {
268 return Err(ServiceError::Ipc("ipcUnavailable".into()).to_string());
269 }
270
271 let socket_path = ipc_state.client.socket_path().display().to_string();
273 let timeout =
274 std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
275
276 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
277 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
278 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
279 {
280 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
281 mgr.start().map_err(|e| e.to_string())?;
282 }
283
284 let connected = ipc_state
285 .client
286 .wait_for_connected(timeout)
287 .await
288 .map_err(|e| e.to_string())?;
289
290 if !connected {
291 return Err(
292 ServiceError::Ipc(format!("ipcUnavailable: socket {socket_path}")).to_string(),
293 );
294 }
295
296 return ipc_state
298 .client
299 .start(config)
300 .await
301 .map_err(|e| e.to_string());
302 }
303
304 ios_set_on_complete_callback(&app).await?;
307
308 let manager = app.state::<ServiceManagerHandle<R>>();
312 let (tx, rx) = tokio::sync::oneshot::channel();
313 manager
314 .cmd_tx
315 .send(ManagerCommand::Start {
316 config,
317 reply: tx,
318 app: app.clone(),
319 })
320 .await
321 .map_err(|e| e.to_string())?;
322
323 rx.await
324 .map_err(|e| e.to_string())?
325 .map_err(|e| e.to_string())?;
326
327 let plugin_config = app.state::<PluginConfig>();
329 ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
330
331 Ok(())
332}
333
334#[tauri::command]
335async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
336 #[cfg(all(feature = "desktop-service", unix))]
338 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
339 return ipc_state.client.stop().await.map_err(|e| e.to_string());
340 }
341
342 let manager = app.state::<ServiceManagerHandle<R>>();
344 let (tx, rx) = tokio::sync::oneshot::channel();
345 manager
346 .cmd_tx
347 .send(ManagerCommand::Stop { reply: tx })
348 .await
349 .map_err(|e| e.to_string())?;
350
351 rx.await
352 .map_err(|e| e.to_string())?
353 .map_err(|e| e.to_string())
354}
355
356#[tauri::command]
357async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
358 #[cfg(all(feature = "desktop-service", unix))]
360 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
361 return ipc_state.client.is_running().await.unwrap_or(false);
362 }
363
364 let manager = app.state::<ServiceManagerHandle<R>>();
366 let (tx, rx) = tokio::sync::oneshot::channel();
367 if manager
368 .cmd_tx
369 .send(ManagerCommand::IsRunning { reply: tx })
370 .await
371 .is_err()
372 {
373 return false;
374 }
375 rx.await.unwrap_or(false)
376}
377
378#[tauri::command]
379async fn get_service_state<R: Runtime>(app: AppHandle<R>) -> Result<models::ServiceStatus, String> {
380 #[cfg(all(feature = "desktop-service", unix))]
382 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
383 return ipc_state
384 .client
385 .get_state()
386 .await
387 .map_err(|e| e.to_string());
388 }
389
390 let manager = app.state::<ServiceManagerHandle<R>>();
392 Ok(manager.get_state().await)
393}
394
395#[tauri::command]
396#[allow(unused_variables)]
397async fn get_platform_capabilities<R: Runtime>(
398 app: AppHandle<R>,
399) -> Result<models::PlatformCapabilities, String> {
400 #[cfg(feature = "desktop-service")]
401 let plugin_config = app.state::<PluginConfig>();
402
403 #[cfg(feature = "desktop-service")]
404 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
405 #[cfg(not(feature = "desktop-service"))]
406 let desktop_mode: Option<&str> = None;
407
408 let (platform, lifecycle_mode) =
409 capabilities::CapabilityProvider::detect_platform(desktop_mode);
410
411 #[cfg(all(feature = "desktop-service", unix))]
412 let os_service_installed = if matches!(lifecycle_mode, models::LifecycleMode::DesktopOsService)
413 {
414 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
415 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
416 let exec = std::env::current_exe().unwrap_or_default();
417 DesktopServiceManager::new(&label, exec)
418 .map(|_| true)
419 .unwrap_or(false)
420 } else {
421 false
422 };
423
424 #[cfg(not(all(feature = "desktop-service", unix)))]
425 let os_service_installed = false;
426
427 Ok(capabilities::CapabilityProvider::capabilities(
428 platform,
429 lifecycle_mode,
430 os_service_installed,
431 ))
432}
433
434#[tauri::command]
439async fn get_scheduling_status<R: Runtime>(
440 app: AppHandle<R>,
441) -> Result<models::IOSSchedulingStatus, String> {
442 #[cfg(target_os = "ios")]
443 {
444 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
445 mobile
446 .get_scheduling_status()
447 .map_err(|e| e.to_string())
448 .and_then(|opt| opt.ok_or_else(|| "no scheduling status available".to_string()))
449 }
450 #[cfg(not(target_os = "ios"))]
451 {
452 let _ = app;
453 Ok(models::IOSSchedulingStatus {
454 refresh_scheduled: false,
455 processing_scheduled: false,
456 refresh_error: None,
457 processing_error: None,
458 })
459 }
460}
461
462#[tauri::command]
468async fn get_pending_bg_task<R: Runtime>(
469 app: AppHandle<R>,
470) -> Result<Option<models::PendingTaskInfo>, String> {
471 #[cfg(target_os = "ios")]
472 {
473 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
474 mobile.get_pending_bg_task().map_err(|e| e.to_string())
475 }
476 #[cfg(not(target_os = "ios"))]
477 {
478 let _ = app;
479 Ok(None)
480 }
481}
482
483#[tauri::command]
490async fn enable_auto_restart<R: Runtime>(
491 app: AppHandle<R>,
492 config: Option<StartConfig>,
493) -> Result<(), String> {
494 #[cfg(all(feature = "desktop-service", unix))]
496 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
497 return ipc_state
498 .client
499 .enable_auto_restart(config)
500 .await
501 .map_err(|e| e.to_string());
502 }
503
504 let manager = app.state::<ServiceManagerHandle<R>>();
505 let (tx, rx) = tokio::sync::oneshot::channel();
506 manager
507 .cmd_tx
508 .send(ManagerCommand::EnableAutoRestart { config, reply: tx })
509 .await
510 .map_err(|e| e.to_string())?;
511 rx.await
512 .map_err(|e| e.to_string())?
513 .map_err(|e| e.to_string())
514}
515
516#[tauri::command]
523async fn disable_auto_restart<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
524 #[cfg(all(feature = "desktop-service", unix))]
526 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
527 return ipc_state
528 .client
529 .disable_auto_restart()
530 .await
531 .map_err(|e| e.to_string());
532 }
533
534 let manager = app.state::<ServiceManagerHandle<R>>();
535 let (tx, rx) = tokio::sync::oneshot::channel();
536 manager
537 .cmd_tx
538 .send(ManagerCommand::DisableAutoRestart { reply: tx })
539 .await
540 .map_err(|e| e.to_string())?;
541 rx.await
542 .map_err(|e| e.to_string())?
543 .map_err(|e| e.to_string())
544}
545
546#[tauri::command]
551async fn get_desired_service_state<R: Runtime>(
552 app: AppHandle<R>,
553) -> Result<Option<desired_state::DesiredState>, String> {
554 #[cfg(all(feature = "desktop-service", unix))]
556 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
557 return ipc_state
558 .client
559 .get_desired_state()
560 .await
561 .map_err(|e| e.to_string());
562 }
563
564 let manager = app.state::<ServiceManagerHandle<R>>();
565 let (tx, rx) = tokio::sync::oneshot::channel();
566 manager
567 .cmd_tx
568 .send(ManagerCommand::GetDesiredState { reply: tx })
569 .await
570 .map_err(|e| e.to_string())?;
571 rx.await.map_err(|e| e.to_string())
572}
573
574#[tauri::command]
588async fn native_lifecycle_event<R: Runtime>(
589 app: AppHandle<R>,
590 event: models::NativeLifecycleEvent,
591) -> Result<(), String> {
592 let manager = app.state::<ServiceManagerHandle<R>>();
593 manager
594 .send_native_lifecycle_event(event)
595 .await
596 .map_err(|e| e.to_string())
597}
598
599#[tauri::command]
604#[allow(unused_variables)]
605async fn validate_setup<R: Runtime>(
606 app: AppHandle<R>,
607) -> Result<models::SetupValidationReport, String> {
608 #[cfg(all(feature = "desktop-service", unix))]
610 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
611 return ipc_state
612 .client
613 .validate_setup()
614 .await
615 .map_err(|e| e.to_string());
616 }
617
618 #[cfg(feature = "desktop-service")]
619 let plugin_config = app.state::<PluginConfig>();
620
621 #[cfg(feature = "desktop-service")]
622 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
623 #[cfg(not(feature = "desktop-service"))]
624 let desktop_mode: Option<&str> = None;
625
626 let (platform, _) = capabilities::CapabilityProvider::detect_platform(desktop_mode);
627 Ok(validator::SetupValidator::validate(platform))
628}
629
630#[tauri::command]
635async fn get_lifecycle_status<R: Runtime>(
636 app: AppHandle<R>,
637) -> Result<models::LifecycleStatus, String> {
638 #[cfg(all(feature = "desktop-service", unix))]
640 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
641 return ipc_state
642 .client
643 .get_lifecycle_status()
644 .await
645 .map_err(|e| e.to_string());
646 }
647
648 #[cfg(feature = "desktop-service")]
649 let plugin_config = app.state::<PluginConfig>();
650
651 #[cfg(feature = "desktop-service")]
652 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
653 #[cfg(not(feature = "desktop-service"))]
654 let desktop_mode: Option<&str> = None;
655
656 let manager = app.state::<ServiceManagerHandle<R>>();
657 let (tx, rx) = tokio::sync::oneshot::channel();
658 manager
659 .cmd_tx
660 .send(ManagerCommand::GetLifecycleStatus {
661 desktop_mode: desktop_mode.map(|s| s.to_string()),
662 reply: tx,
663 })
664 .await
665 .map_err(|e| e.to_string())?;
666
667 rx.await.map_err(|e| e.to_string())
668}
669
670#[tauri::command]
676async fn configure_recovery<R: Runtime>(
677 app: AppHandle<R>,
678 enabled: bool,
679 config: Option<StartConfig>,
680) -> Result<(), String> {
681 if enabled {
682 enable_auto_restart(app, config).await
683 } else {
684 disable_auto_restart(app).await
685 }
686}
687
688#[cfg(all(feature = "desktop-service", unix))]
695struct DesktopIpcState {
696 client: desktop::ipc_client::PersistentIpcClientHandle,
697}
698
699#[cfg(feature = "desktop-service")]
700#[tauri::command]
701async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
702 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
703 let plugin_config = app.state::<PluginConfig>();
704 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
705 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
706
707 if !exec_path.exists() {
709 return Err(format!(
710 "Current executable does not exist at {}: cannot install OS service",
711 exec_path.display()
712 ));
713 }
714
715 let validate_result = tokio::time::timeout(
719 std::time::Duration::from_secs(5),
720 tokio::process::Command::new(&exec_path)
721 .arg("--service-label")
722 .arg(&label)
723 .arg("--validate-service-install")
724 .output(),
725 )
726 .await;
727
728 match validate_result {
729 Ok(Ok(output)) => {
730 let stdout = String::from_utf8_lossy(&output.stdout);
731 if !stdout.trim().contains("ok") {
732 return Err("Binary does not handle --validate-service-install. \
733 Ensure headless_main() is called from your app's main()."
734 .into());
735 }
736 }
737 Ok(Err(e)) => {
738 return Err(format!(
739 "Failed to validate executable for --service-label: {e}"
740 ));
741 }
742 Err(_) => {
743 log::warn!(
746 "Timeout validating --service-label support. \
747 Ensure your app's main() handles the --service-label argument \
748 and calls headless_main()."
749 );
750 }
751 }
752
753 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
754 use desktop::service_manager::InstallOptions;
755 let options = InstallOptions {
756 autostart: plugin_config.desktop_service_autostart,
757 restart_delay_secs: None,
758 journal_output: true,
759 log_path: None,
760 };
761 mgr.install(&options).map_err(|e| e.to_string())
762}
763
764#[cfg(feature = "desktop-service")]
765#[tauri::command]
766async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
767 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
768 let plugin_config = app.state::<PluginConfig>();
769 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
770 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
771 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
772 mgr.uninstall().map_err(|e| e.to_string())
773}
774
775#[cfg(feature = "desktop-service")]
779#[allow(dead_code)] fn windows_os_service_unsupported() -> ServiceError {
781 ServiceError::Platform("Windows OS-service mode is not yet supported".into())
782}
783
784#[cfg(all(feature = "desktop-service", unix))]
789fn build_os_service_status(
790 label: &str,
791 ipc_connected: bool,
792 socket_path: Option<String>,
793 last_error: Option<String>,
794) -> models::OsServiceStatus {
795 let mode = if cfg!(target_os = "macos") {
796 "launchd"
797 } else {
798 "systemd"
799 };
800
801 let installed = if ipc_connected {
802 models::OsServiceInstallState::Running
803 } else {
804 models::OsServiceInstallState::Installed
808 };
809
810 models::OsServiceStatus {
811 label: label.to_string(),
812 mode: mode.to_string(),
813 installed,
814 ipc_connected,
815 socket_path,
816 last_error,
817 }
818}
819
820#[cfg(feature = "desktop-service")]
825#[tauri::command]
826async fn start_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
827 #[cfg(unix)]
828 {
829 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
830 let plugin_config = app.state::<PluginConfig>();
831 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
832 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
833 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
834 mgr.start().map_err(|e| e.to_string())
835 }
836 #[cfg(not(unix))]
837 {
838 let _ = app;
839 Err(windows_os_service_unsupported().to_string())
840 }
841}
842
843#[cfg(feature = "desktop-service")]
848#[tauri::command]
849async fn stop_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
850 #[cfg(unix)]
851 {
852 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
853 let plugin_config = app.state::<PluginConfig>();
854 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
855 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
856 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
857 mgr.stop().map_err(|e| e.to_string())
858 }
859 #[cfg(not(unix))]
860 {
861 let _ = app;
862 Err(windows_os_service_unsupported().to_string())
863 }
864}
865
866#[cfg(feature = "desktop-service")]
870#[tauri::command]
871async fn restart_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
872 #[cfg(unix)]
873 {
874 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
875 let plugin_config = app.state::<PluginConfig>();
876 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
877 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
878 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
879 mgr.stop().ok(); mgr.start().map_err(|e| e.to_string())
881 }
882 #[cfg(not(unix))]
883 {
884 let _ = app;
885 Err(windows_os_service_unsupported().to_string())
886 }
887}
888
889#[cfg(feature = "desktop-service")]
894#[tauri::command]
895async fn get_os_service_status<R: Runtime>(
896 app: AppHandle<R>,
897) -> Result<models::OsServiceStatus, String> {
898 #[cfg(unix)]
899 {
900 use desktop::service_manager::derive_service_label;
901 let plugin_config = app.state::<PluginConfig>();
902 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
903
904 let ipc_connected = app
905 .try_state::<DesktopIpcState>()
906 .map(|s| s.client.is_connected())
907 .unwrap_or(false);
908
909 let socket_path = desktop::ipc::socket_path(&label)
910 .ok()
911 .map(|p| p.to_string_lossy().to_string());
912
913 Ok(build_os_service_status(
914 &label,
915 ipc_connected,
916 socket_path,
917 None,
918 ))
919 }
920 #[cfg(not(unix))]
921 {
922 let _ = app;
923 Err(windows_os_service_unsupported().to_string())
924 }
925}
926
927pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
937where
938 R: Runtime,
939 S: BackgroundService<R>,
940 F: Fn() -> S + Send + Sync + 'static,
941{
942 let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
943
944 Builder::<R, PluginConfig>::new("background-service")
945 .invoke_handler(tauri::generate_handler![
946 start,
947 stop,
948 is_running,
949 get_service_state,
950 get_platform_capabilities,
951 get_scheduling_status,
952 get_pending_bg_task,
953 enable_auto_restart,
954 disable_auto_restart,
955 get_desired_service_state,
956 native_lifecycle_event,
957 validate_setup,
958 get_lifecycle_status,
959 configure_recovery,
960 #[cfg(feature = "desktop-service")]
961 install_service,
962 #[cfg(feature = "desktop-service")]
963 uninstall_service,
964 #[cfg(feature = "desktop-service")]
965 start_os_service,
966 #[cfg(feature = "desktop-service")]
967 stop_os_service,
968 #[cfg(feature = "desktop-service")]
969 restart_os_service,
970 #[cfg(feature = "desktop-service")]
971 get_os_service_status,
972 ])
973 .setup(move |app, api| {
974 let config = api.config().clone();
975 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(config.channel_capacity);
976 #[cfg(mobile)]
977 let mobile_cmd_tx = cmd_tx.clone();
978 let handle = ServiceManagerHandle::new(cmd_tx);
979 app.manage(handle);
980
981 app.manage(config.clone());
982
983 let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
984 let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
985 let ios_earliest_refresh_begin_minutes = config.ios_earliest_refresh_begin_minutes;
986 let ios_earliest_processing_begin_minutes =
987 config.ios_earliest_processing_begin_minutes;
988 let ios_requires_external_power = config.ios_requires_external_power;
989 let ios_requires_network_connectivity = config.ios_requires_network_connectivity;
990
991 #[cfg(not(mobile))]
993 let desired_state_backend: Option<Arc<dyn desired_state::DesiredStateBackend>> = {
994 match app.path().app_data_dir() {
995 Ok(data_dir) => Some(Arc::new(desired_state::FileDesiredStateBackend::new(data_dir))),
996 Err(e) => {
997 log::warn!("Failed to get app data dir for desired-state persistence: {e}");
998 None
999 }
1000 }
1001 };
1002 #[cfg(mobile)]
1003 let desired_state_backend: Option<Arc<dyn desired_state::DesiredStateBackend>> = None;
1004
1005 #[cfg(all(feature = "desktop-service", unix))]
1007 if config.desktop_service_mode == "osService" {
1008 let label = desktop::service_manager::derive_service_label(
1010 app,
1011 config.desktop_service_label.as_deref(),
1012 );
1013 let socket_path = desktop::ipc::socket_path(&label)?;
1014 let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
1015 socket_path,
1016 app.app_handle().clone(),
1017 );
1018 app.manage(DesktopIpcState { client });
1019 } else {
1020 let factory = boxed_factory;
1022 tauri::async_runtime::spawn(manager_loop(
1023 cmd_rx,
1024 factory,
1025 ios_safety_timeout_secs,
1026 ios_processing_safety_timeout_secs,
1027 ios_earliest_refresh_begin_minutes,
1028 ios_earliest_processing_begin_minutes,
1029 ios_requires_external_power,
1030 ios_requires_network_connectivity,
1031 desired_state_backend,
1032 ));
1033 }
1034
1035 #[cfg(all(feature = "desktop-service", not(unix)))]
1036 {
1037 let factory = boxed_factory;
1039 tauri::async_runtime::spawn(manager_loop(
1040 cmd_rx,
1041 factory,
1042 ios_safety_timeout_secs,
1043 ios_processing_safety_timeout_secs,
1044 ios_earliest_refresh_begin_minutes,
1045 ios_earliest_processing_begin_minutes,
1046 ios_requires_external_power,
1047 ios_requires_network_connectivity,
1048 desired_state_backend,
1049 ));
1050 }
1051
1052 #[cfg(not(feature = "desktop-service"))]
1053 {
1054 let factory = boxed_factory;
1055 tauri::async_runtime::spawn(manager_loop(
1056 cmd_rx,
1057 factory,
1058 ios_safety_timeout_secs,
1059 ios_processing_safety_timeout_secs,
1060 ios_earliest_refresh_begin_minutes,
1061 ios_earliest_processing_begin_minutes,
1062 ios_requires_external_power,
1063 ios_requires_network_connectivity,
1064 desired_state_backend,
1065 ));
1066 }
1067
1068 #[cfg(mobile)]
1069 {
1070 let lifecycle = mobile::init(app, api)?;
1071 let lifecycle_arc = Arc::new(lifecycle);
1072
1073 let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
1075 if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
1076 mobile: mobile_trait,
1077 }) {
1078 log::error!("Failed to send SetMobile command: {e}");
1079 }
1080
1081 app.manage(lifecycle_arc);
1083 }
1084
1085 #[cfg(target_os = "ios")]
1089 {
1090 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
1091
1092 match mobile.get_pending_bg_task() {
1093 Ok(Some(_pending)) => {
1094 let should_start = mobile
1096 .get_scheduling_status_raw()
1097 .ok()
1098 .and_then(|v| {
1099 let desired = v.get("desiredRunning")?.as_bool()?;
1100 let config_str = v.get("lastStartConfig")?.as_str()?;
1101 Some((desired, config_str.to_string()))
1102 });
1103
1104 if let Some((true, config_str)) = should_start {
1105 if let Ok(config) =
1106 serde_json::from_str::<StartConfig>(&config_str)
1107 {
1108 let manager = app.state::<ServiceManagerHandle<R>>();
1109 let cmd_tx = manager.cmd_tx.clone();
1110 let app_clone = app.app_handle().clone();
1111
1112 let plugin_config = app.state::<PluginConfig>();
1114 let timeout_secs = plugin_config.ios_cancel_listener_timeout_secs;
1115
1116 let mob_handle = mobile.handle.clone();
1118 if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
1119 callback: Box::new(move |success| {
1120 let ml =
1121 MobileLifecycle { handle: mob_handle.clone() };
1122 let _ = ml.complete_bg_task(success);
1123 }),
1124 }) {
1125 log::error!("Failed to send SetOnComplete for iOS auto-start: {e}");
1126 }
1127
1128 tauri::async_runtime::spawn(async move {
1129 let (tx, rx) = tokio::sync::oneshot::channel();
1130 if cmd_tx
1131 .send(ManagerCommand::Start {
1132 config,
1133 reply: tx,
1134 app: app_clone.clone(),
1135 })
1136 .await
1137 .is_err()
1138 {
1139 return;
1140 }
1141 if let Ok(Ok(())) = rx.await {
1142 ios_spawn_cancel_listener(&app_clone, timeout_secs);
1143 }
1144 });
1145
1146 log::info!("iOS: auto-starting service for pending BGTask");
1147 let _ = mobile.clear_pending_bg_task();
1148 } else {
1149 log::warn!("iOS: failed to parse stored start config — preserving pending task info for diagnostics");
1150 }
1151 } else {
1152 log::info!(
1153 "iOS: pending BGTask but desired_running is false, skipping auto-start"
1154 );
1155 let _ = mobile.clear_pending_bg_task();
1156 }
1157 }
1158 Ok(None) => {
1159 }
1161 Err(e) => {
1162 log::warn!("iOS: failed to get pending BGTask: {e}");
1163 }
1164 }
1165 }
1166
1167 #[cfg(target_os = "android")]
1173 {
1174 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
1175 if let Ok(Some(config)) = mobile.get_auto_start_config() {
1176 let _ = mobile.clear_auto_start_config();
1177
1178 let manager = app.state::<ServiceManagerHandle<R>>();
1182 let cmd_tx = manager.cmd_tx.clone();
1183 let app_clone = app.app_handle().clone();
1184
1185 if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
1187 callback: Box::new(|_| {}),
1188 }) {
1189 log::error!("Failed to send SetOnComplete command: {e}");
1190 }
1191
1192 tauri::async_runtime::spawn(async move {
1193 let (tx, rx) = tokio::sync::oneshot::channel();
1194 if cmd_tx
1195 .send(ManagerCommand::Start {
1196 config,
1197 reply: tx,
1198 app: app_clone,
1199 })
1200 .await
1201 .is_err()
1202 {
1203 return;
1204 }
1205 let _ = rx.await;
1206 });
1207
1208 let _ = mobile.move_task_to_background();
1209 }
1210 }
1211
1212 Ok(())
1213 })
1214 .on_event(|app, event| {
1215 if let tauri::RunEvent::Exit = event {
1216 #[cfg(all(feature = "desktop-service", unix))]
1218 if app.try_state::<DesktopIpcState>().is_some() {
1219 return;
1220 }
1221 let manager = app.state::<ServiceManagerHandle<R>>();
1222 if let Err(e) = manager.stop_blocking() {
1223 log::warn!("Failed to stop background service on app exit: {e}");
1224 }
1225 }
1226 })
1227 .build()
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233 use async_trait::async_trait;
1234 use std::sync::atomic::{AtomicUsize, Ordering};
1235 use std::sync::Arc;
1236
1237 struct DummyService;
1239
1240 #[async_trait]
1241 impl BackgroundService<tauri::Wry> for DummyService {
1242 async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
1243 Ok(())
1244 }
1245
1246 async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
1247 Ok(())
1248 }
1249 }
1250
1251 #[test]
1254 fn service_manager_handle_constructs() {
1255 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
1256 let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
1257 }
1258
1259 #[test]
1260 fn factory_produces_boxed_service() {
1261 let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
1262 let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
1263 }
1264
1265 #[test]
1266 fn handle_factory_creates_fresh_instances() {
1267 let count = Arc::new(AtomicUsize::new(0));
1268 let count_clone = count.clone();
1269
1270 let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
1271 count_clone.fetch_add(1, Ordering::SeqCst);
1272 Box::new(DummyService)
1273 });
1274
1275 let _ = (factory)();
1276 let _ = (factory)();
1277
1278 assert_eq!(count.load(Ordering::SeqCst), 2);
1279 }
1280
1281 #[allow(dead_code)]
1285 fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
1286 factory: F,
1287 ) -> TauriPlugin<R, PluginConfig>
1288 where
1289 S: BackgroundService<R>,
1290 F: Fn() -> S + Send + Sync + 'static,
1291 {
1292 init_with_service(factory)
1293 }
1294
1295 #[allow(dead_code)]
1297 async fn start_command_signature<R: Runtime>(
1298 app: AppHandle<R>,
1299 config: StartConfig,
1300 ) -> Result<(), String> {
1301 start(app, config).await
1302 }
1303
1304 #[allow(dead_code)]
1306 async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1307 stop(app).await
1308 }
1309
1310 #[allow(dead_code)]
1312 async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
1313 is_running(app).await
1314 }
1315
1316 #[allow(dead_code)]
1318 async fn get_service_state_command_signature<R: Runtime>(
1319 app: AppHandle<R>,
1320 ) -> Result<models::ServiceStatus, String> {
1321 get_service_state(app).await
1322 }
1323
1324 #[allow(dead_code)]
1326 async fn get_scheduling_status_command_signature<R: Runtime>(
1327 app: AppHandle<R>,
1328 ) -> Result<models::IOSSchedulingStatus, String> {
1329 get_scheduling_status(app).await
1330 }
1331
1332 #[allow(dead_code)]
1334 async fn get_pending_bg_task_command_signature<R: Runtime>(
1335 app: AppHandle<R>,
1336 ) -> Result<Option<models::PendingTaskInfo>, String> {
1337 get_pending_bg_task(app).await
1338 }
1339
1340 #[allow(dead_code)]
1342 async fn enable_auto_restart_command_signature<R: Runtime>(
1343 app: AppHandle<R>,
1344 config: Option<StartConfig>,
1345 ) -> Result<(), String> {
1346 enable_auto_restart(app, config).await
1347 }
1348
1349 #[allow(dead_code)]
1351 async fn disable_auto_restart_command_signature<R: Runtime>(
1352 app: AppHandle<R>,
1353 ) -> Result<(), String> {
1354 disable_auto_restart(app).await
1355 }
1356
1357 #[allow(dead_code)]
1359 async fn get_desired_service_state_command_signature<R: Runtime>(
1360 app: AppHandle<R>,
1361 ) -> Result<Option<desired_state::DesiredState>, String> {
1362 get_desired_service_state(app).await
1363 }
1364
1365 #[allow(dead_code)]
1367 async fn validate_setup_command_signature<R: Runtime>(
1368 app: AppHandle<R>,
1369 ) -> Result<models::SetupValidationReport, String> {
1370 validate_setup(app).await
1371 }
1372
1373 #[allow(dead_code)]
1375 async fn native_lifecycle_event_command_signature<R: Runtime>(
1376 app: AppHandle<R>,
1377 event: models::NativeLifecycleEvent,
1378 ) -> Result<(), String> {
1379 native_lifecycle_event(app, event).await
1380 }
1381
1382 #[allow(dead_code)]
1384 async fn get_lifecycle_status_command_signature<R: Runtime>(
1385 app: AppHandle<R>,
1386 ) -> Result<models::LifecycleStatus, String> {
1387 get_lifecycle_status(app).await
1388 }
1389
1390 #[allow(dead_code)]
1392 async fn configure_recovery_command_signature<R: Runtime>(
1393 app: AppHandle<R>,
1394 enabled: bool,
1395 config: Option<StartConfig>,
1396 ) -> Result<(), String> {
1397 configure_recovery(app, enabled, config).await
1398 }
1399
1400 #[cfg(all(feature = "desktop-service", unix))]
1404 #[tokio::test]
1405 async fn desktop_ipc_state_with_persistent_client() {
1406 use desktop::ipc_client::PersistentIpcClientHandle;
1407 let app = tauri::test::mock_app();
1408 let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
1409 let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
1410 let _state = DesktopIpcState { client };
1413 }
1414
1415 #[cfg(feature = "desktop-service")]
1419 #[allow(dead_code)]
1420 async fn install_service_command_signature<R: Runtime>(
1421 app: AppHandle<R>,
1422 ) -> Result<(), String> {
1423 install_service(app).await
1424 }
1425
1426 #[cfg(feature = "desktop-service")]
1428 #[allow(dead_code)]
1429 async fn uninstall_service_command_signature<R: Runtime>(
1430 app: AppHandle<R>,
1431 ) -> Result<(), String> {
1432 uninstall_service(app).await
1433 }
1434
1435 #[cfg(feature = "desktop-service")]
1437 #[allow(dead_code)]
1438 async fn start_os_service_command_signature<R: Runtime>(
1439 app: AppHandle<R>,
1440 ) -> Result<(), String> {
1441 start_os_service(app).await
1442 }
1443
1444 #[cfg(feature = "desktop-service")]
1446 #[allow(dead_code)]
1447 async fn stop_os_service_command_signature<R: Runtime>(
1448 app: AppHandle<R>,
1449 ) -> Result<(), String> {
1450 stop_os_service(app).await
1451 }
1452
1453 #[cfg(feature = "desktop-service")]
1455 #[allow(dead_code)]
1456 async fn restart_os_service_command_signature<R: Runtime>(
1457 app: AppHandle<R>,
1458 ) -> Result<(), String> {
1459 restart_os_service(app).await
1460 }
1461
1462 #[cfg(feature = "desktop-service")]
1464 #[allow(dead_code)]
1465 async fn get_os_service_status_command_signature<R: Runtime>(
1466 app: AppHandle<R>,
1467 ) -> Result<models::OsServiceStatus, String> {
1468 get_os_service_status(app).await
1469 }
1470
1471 #[cfg(feature = "desktop-service")]
1475 #[test]
1476 fn windows_stub_returns_platform_error() {
1477 let err = windows_os_service_unsupported();
1478 assert!(
1479 matches!(err, ServiceError::Platform(ref msg) if msg.contains("not yet supported")),
1480 "Expected Platform error with 'not yet supported', got: {err}"
1481 );
1482 }
1483
1484 #[cfg(all(feature = "desktop-service", unix))]
1487 #[test]
1488 fn build_os_service_status_populates_fields() {
1489 let status = build_os_service_status(
1490 "com.example.bg-service",
1491 true,
1492 Some("/tmp/test.sock".to_string()),
1493 None,
1494 );
1495 assert_eq!(status.label, "com.example.bg-service");
1496 assert!(status.ipc_connected);
1497 assert_eq!(status.socket_path.as_deref(), Some("/tmp/test.sock"));
1498 assert!(status.last_error.is_none());
1499 }
1500
1501 #[cfg(all(feature = "desktop-service", unix))]
1503 #[test]
1504 fn build_os_service_status_mode_is_correct() {
1505 let status = build_os_service_status("test", false, None, None);
1506 #[cfg(target_os = "linux")]
1507 assert_eq!(status.mode, "systemd");
1508 #[cfg(target_os = "macos")]
1509 assert_eq!(status.mode, "launchd");
1510 }
1511
1512 #[allow(dead_code)]
1518 fn on_event_shutdown_closure_type_checks<R: Runtime>(_app: &AppHandle<R>) {
1519 let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
1520 if let tauri::RunEvent::Exit = event {
1521 let manager = _app.state::<ServiceManagerHandle<R>>();
1522 if let Err(_e) = manager.stop_blocking() {
1523 log::warn!("bg service shutdown on exit failed: {_e}");
1524 }
1525 }
1526 };
1527 }
1528
1529 use crate::manager::ManagerCommand;
1532 use std::sync::atomic::AtomicBool;
1533
1534 fn spawn_stop_drain(
1537 mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
1538 ) -> tokio::sync::oneshot::Receiver<Option<crate::models::StopReason>> {
1539 let (seen_tx, seen_rx) =
1540 tokio::sync::oneshot::channel::<Option<crate::models::StopReason>>();
1541 tokio::spawn(async move {
1542 let result =
1543 tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
1544 match result {
1545 Ok(Some(ManagerCommand::StopWithReason { reason, reply })) => {
1546 let _ = reply.send(Ok(()));
1547 let _ = seen_tx.send(Some(reason));
1548 }
1549 _ => {
1550 let _ = seen_tx.send(None);
1551 }
1552 }
1553 });
1554 seen_rx
1555 }
1556
1557 #[tokio::test]
1558 async fn cancel_listener_resolved_invoke_sends_stop_with_reason() {
1559 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1560 let seen = spawn_stop_drain(cmd_rx);
1561
1562 let stop_sent = run_cancel_listener(
1564 Box::new(|| Ok(())),
1565 Box::new(|| {}),
1566 cmd_tx,
1567 5, )
1569 .await;
1570
1571 assert!(stop_sent, "resolved invoke should return true");
1572 let reason = seen.await.unwrap();
1573 assert_eq!(
1574 reason,
1575 Some(crate::models::StopReason::PlatformExpiration),
1576 "StopWithReason(PlatformExpiration) should be sent on resolved invoke"
1577 );
1578 }
1579
1580 #[tokio::test]
1581 async fn cancel_listener_rejected_invoke_no_stop() {
1582 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1583 let seen = spawn_stop_drain(cmd_rx);
1584
1585 let stop_sent = run_cancel_listener(
1587 Box::new(|| Err(ServiceError::Platform("rejected".into()))),
1588 Box::new(|| {}),
1589 cmd_tx,
1590 5,
1591 )
1592 .await;
1593
1594 assert!(!stop_sent, "rejected invoke should return false");
1595 assert_eq!(
1596 seen.await.unwrap(),
1597 None,
1598 "StopWithReason should NOT be sent on rejected invoke"
1599 );
1600 }
1601
1602 #[tokio::test]
1603 async fn cancel_listener_timeout_sends_stop_with_reason() {
1604 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1605 let cancel_called = Arc::new(AtomicBool::new(false));
1606 let cancel_called_clone = cancel_called.clone();
1607 let seen = spawn_stop_drain(cmd_rx);
1608
1609 let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
1612
1613 let stop_sent = run_cancel_listener(
1614 Box::new(move || {
1615 let _ = unblock_rx.recv();
1617 Ok(())
1618 }),
1619 Box::new(move || {
1620 cancel_called_clone.store(true, Ordering::SeqCst);
1621 let _ = unblock_tx.send(());
1622 }),
1623 cmd_tx,
1624 0, )
1626 .await;
1627
1628 assert!(stop_sent, "timeout should return true");
1629 assert!(
1630 cancel_called.load(Ordering::SeqCst),
1631 "cancel_fn should be called on timeout"
1632 );
1633 let reason = seen.await.unwrap();
1634 assert_eq!(
1635 reason,
1636 Some(crate::models::StopReason::PlatformTimeout),
1637 "StopWithReason(PlatformTimeout) should be sent on timeout"
1638 );
1639 }
1640
1641 #[tokio::test]
1642 async fn cancel_listener_join_error_no_stop() {
1643 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
1644 let seen = spawn_stop_drain(cmd_rx);
1645
1646 let stop_sent = run_cancel_listener(
1648 Box::new(|| panic!("simulated panic in wait_for_cancel")),
1649 Box::new(|| {}),
1650 cmd_tx,
1651 5,
1652 )
1653 .await;
1654
1655 assert!(!stop_sent, "join error should return false (no stop sent)");
1657 assert_eq!(
1658 seen.await.unwrap(),
1659 None,
1660 "StopWithReason should NOT be sent on join error"
1661 );
1662 }
1663
1664 #[cfg(all(feature = "desktop-service", unix))]
1669 mod ipc_auto_start_tests {
1670 use super::*;
1671 use crate::desktop::ipc_client::PersistentIpcClientHandle;
1672 use crate::desktop::test_helpers::setup_server;
1673 use std::time::Duration;
1674
1675 #[tokio::test]
1679 async fn wait_for_connected_timeout_returns_false() {
1680 let app = tauri::test::mock_app();
1681 let path = crate::desktop::test_helpers::unique_socket_path();
1682 let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
1683
1684 let connected = handle
1685 .wait_for_connected(Duration::from_millis(200))
1686 .await
1687 .unwrap();
1688 assert!(!connected, "should return false on timeout");
1689
1690 let _ = std::fs::remove_file(&path);
1691 }
1692
1693 #[tokio::test]
1696 async fn wait_for_connected_succeeds_with_server() {
1697 let (path, shutdown, _event_tx) = setup_server();
1698 let app = tauri::test::mock_app();
1699 let handle = PersistentIpcClientHandle::spawn(path, app.handle().clone());
1700
1701 let connected = handle
1702 .wait_for_connected(Duration::from_secs(5))
1703 .await
1704 .unwrap();
1705 assert!(connected, "should connect within timeout");
1706
1707 shutdown.cancel();
1708 }
1709
1710 #[tokio::test]
1713 async fn socket_path_accessor() {
1714 let app = tauri::test::mock_app();
1715 let path = crate::desktop::test_helpers::unique_socket_path();
1716 let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
1717 assert_eq!(
1718 handle.socket_path(),
1719 &path,
1720 "socket_path() should return the path passed to spawn"
1721 );
1722 let _ = std::fs::remove_file(&path);
1723 }
1724
1725 #[tokio::test]
1731 async fn start_disconnected_without_auto_start_returns_ipc_error() {
1732 let err = ServiceError::Ipc("ipcUnavailable".into());
1733 let msg = err.to_string();
1734 assert!(
1735 msg.contains("ipcUnavailable"),
1736 "error should contain 'ipcUnavailable': {msg}"
1737 );
1738 }
1739
1740 #[tokio::test]
1742 async fn start_timeout_error_includes_socket_path() {
1743 let socket = "/tmp/test-socket-path.sock";
1744 let err = ServiceError::Ipc(format!("ipcUnavailable: socket {socket}"));
1745 let msg = err.to_string();
1746 assert!(
1747 msg.contains(socket),
1748 "error should contain socket path: {msg}"
1749 );
1750 }
1751 }
1752}