1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/1.0.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};
92pub use models::{
93 IOSSchedulingStatus, LifecycleState, LifecycleStatus, PendingTaskInfo, Platform,
94 PlatformCapabilities, PluginConfig, PluginEvent, ServiceContext, ServiceState, ServiceStatus,
95 SetupIssue, SetupValidationReport, StartConfig, ValidationIssue,
96};
97pub use notifier::{Notifier, NotifierPolicy, NotifySink};
98pub use service_trait::BackgroundService;
99
100#[cfg(all(feature = "desktop-service", any(unix, windows)))]
101pub use desktop::headless::{headless_main, headless_main_with_desired_state};
102
103use tauri::{
106 plugin::{Builder, TauriPlugin},
107 AppHandle, Manager, Runtime,
108};
109
110use crate::manager::ManagerCommand;
111
112#[cfg(mobile)]
113use crate::manager::MobileKeepalive;
114
115#[cfg(mobile)]
119use mobile::MobileLifecycle;
120
121use std::sync::Arc;
122
123#[cfg(target_os = "ios")]
128tauri::ios_plugin_binding!(init_plugin_background_service);
129
130#[cfg(target_os = "ios")]
137async fn ios_set_on_complete_callback<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
138 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
139 let mobile_handle = mobile.handle.clone();
140 let manager = app.state::<ServiceManagerHandle<R>>();
141
142 let mob_for_complete = MobileLifecycle {
143 handle: mobile_handle,
144 };
145 manager
146 .cmd_tx
147 .send(ManagerCommand::SetOnComplete {
148 callback: Box::new(move |success| {
149 let _ = mob_for_complete.complete_bg_task(success);
150 }),
151 })
152 .await
153 .map_err(|e| e.to_string())
154}
155
156#[cfg(not(target_os = "ios"))]
157async fn ios_set_on_complete_callback<R: Runtime>(_app: &AppHandle<R>) -> Result<(), String> {
158 Ok(())
159}
160
161#[allow(dead_code)] async fn run_cancel_listener<R: Runtime>(
183 wait_fn: Box<dyn FnOnce() -> Result<(), ServiceError> + Send>,
184 cancel_fn: Box<dyn FnOnce() + Send>,
185 cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
186 timeout_secs: u64,
187) -> bool {
188 let handle = tokio::task::spawn_blocking(wait_fn);
189 let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), handle).await;
190 match result {
191 Ok(Ok(Ok(()))) => {
193 let (tx, rx) = tokio::sync::oneshot::channel();
194 let _ = cmd_tx
195 .send(ManagerCommand::StopWithReason {
196 reason: crate::models::StopReason::PlatformExpiration,
197 reply: tx,
198 })
199 .await;
200 let _ = rx.await;
201 true
202 }
203 Err(_) => {
205 cancel_fn();
206 let (tx, rx) = tokio::sync::oneshot::channel();
207 let _ = cmd_tx
208 .send(ManagerCommand::StopWithReason {
209 reason: crate::models::StopReason::PlatformTimeout,
210 reply: tx,
211 })
212 .await;
213 let _ = rx.await;
214 true
215 }
216 _ => false,
218 }
219}
220
221#[cfg(target_os = "ios")]
222fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
223 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
224 let mobile_handle = mobile.handle.clone();
225 let mobile_handle_for_cancel = mobile.handle.clone();
226 let manager = app.state::<ServiceManagerHandle<R>>();
227 let cmd_tx = manager.cmd_tx.clone();
228
229 tokio::spawn(async move {
230 let wait_fn = Box::new(move || {
231 let mob = MobileLifecycle {
232 handle: mobile_handle,
233 };
234 mob.wait_for_cancel()
235 });
236 let cancel_fn = Box::new(move || {
237 let cancel_mob = MobileLifecycle {
238 handle: mobile_handle_for_cancel,
239 };
240 let _ = cancel_mob.cancel_cancel_listener();
241 });
242 let _ = run_cancel_listener(wait_fn, cancel_fn, cmd_tx, timeout_secs).await;
244 });
245}
246
247#[cfg(not(target_os = "ios"))]
248fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
249
250#[cfg(target_os = "ios")]
258fn ios_spawn_cold_auto_start<R: Runtime>(app: &AppHandle<R>) {
259 let app = app.app_handle().clone();
260 tauri::async_runtime::spawn(async move {
261 ios_handle_cold_auto_start(&app).await;
262 });
263}
264
265#[cfg(target_os = "ios")]
267async fn ios_handle_cold_auto_start<R: Runtime>(app: &AppHandle<R>) {
268 let mobile = app.state::<Arc<MobileLifecycle<R>>>().inner().clone();
269
270 let pending = match tokio::task::spawn_blocking({
271 let mobile = mobile.clone();
272 move || mobile.get_pending_bg_task()
273 })
274 .await
275 {
276 Ok(Ok(Some(pending))) => pending,
277 Ok(Ok(None)) => {
278 return;
280 }
281 Ok(Err(e)) => {
282 log::warn!("iOS: failed to get pending BGTask: {e}");
283 return;
284 }
285 Err(e) => {
286 log::warn!("iOS: failed to join pending BGTask query: {e}");
287 return;
288 }
289 };
290 let _ = pending;
291
292 let should_start = match tokio::task::spawn_blocking({
295 let mobile = mobile.clone();
296 move || mobile.get_desired_state_status()
297 })
298 .await
299 {
300 Ok(Ok(status)) => status.and_then(|status| {
301 let config_str = status.last_start_config?;
302 Some((status.desired_running, config_str))
303 }),
304 Ok(Err(e)) => {
305 log::warn!("iOS: failed to get desired-state status: {e}");
306 None
307 }
308 Err(e) => {
309 log::warn!("iOS: failed to join desired-state query: {e}");
310 None
311 }
312 };
313
314 let Some((true, config_str)) = should_start else {
315 log::info!(
316 "iOS: skipped auto-start: desired_running=false — clearing stale pending BGTask"
317 );
318 let _ = tokio::task::spawn_blocking({
319 let mobile = mobile.clone();
320 move || mobile.clear_pending_bg_task()
321 })
322 .await;
323 return;
324 };
325
326 let Ok(config) = serde_json::from_str::<StartConfig>(&config_str) else {
327 log::warn!(
328 "iOS: failed to parse stored start config — preserving pending task info for diagnostics"
329 );
330 return;
331 };
332
333 let manager = app.state::<ServiceManagerHandle<R>>();
334 let cmd_tx = manager.cmd_tx.clone();
335 let app_clone = app.app_handle().clone();
336 let timeout_secs = app.state::<PluginConfig>().ios_cancel_listener_timeout_secs;
337
338 let mob_handle = mobile.handle.clone();
341 if let Err(e) = cmd_tx
342 .send(ManagerCommand::SetOnComplete {
343 callback: Box::new(move |success| {
344 let ml = MobileLifecycle {
345 handle: mob_handle.clone(),
346 };
347 let _ = ml.complete_bg_task(success);
348 }),
349 })
350 .await
351 {
352 log::warn!("iOS: auto-start preserved pending BGTask after failure: {e}");
353 let _ = tokio::task::spawn_blocking(move || mobile.record_failed_pending()).await;
354 return;
355 }
356
357 let mobile_for_success = mobile.clone();
361 let mobile_for_failure = mobile.clone();
362 let app_for_listener = app_clone.clone();
363
364 log::info!("iOS: auto-starting service for pending BGTask");
365 let on_success = Box::new(move || {
366 let _ = mobile_for_success.clear_pending_bg_task();
367 ios_spawn_cancel_listener(&app_for_listener, timeout_secs);
368 });
369 let on_failure = Box::new(move || {
370 let _ = mobile_for_failure.record_failed_pending();
371 });
372 run_auto_start(config, app_clone, cmd_tx, on_success, on_failure).await;
373}
374
375#[cfg(target_os = "ios")]
388fn ios_spawn_warm_listener<R: Runtime>(app: &AppHandle<R>) {
389 let app = app.app_handle().clone();
390 tauri::async_runtime::spawn(async move {
391 loop {
392 let mobile_handle = app.state::<Arc<MobileLifecycle<R>>>().handle.clone();
394 let wait = tokio::task::spawn_blocking(move || {
395 MobileLifecycle {
396 handle: mobile_handle,
397 }
398 .wait_for_bg_task()
399 })
400 .await;
401
402 match wait {
403 Ok(Ok(())) => {
404 ios_handle_warm_delivery(&app).await;
405 }
406 _ => {
408 log::info!("iOS: warm BGTask listener stopped");
409 break;
410 }
411 }
412 }
413 });
414}
415
416#[cfg(target_os = "ios")]
419async fn ios_handle_warm_delivery<R: Runtime>(app: &AppHandle<R>) {
420 let mobile = app.state::<Arc<MobileLifecycle<R>>>().inner().clone();
423
424 let pending = match mobile.get_pending_bg_task() {
425 Ok(Some(p)) => p,
426 Ok(None) => {
427 log::debug!("iOS: warm delivery signalled with no pending BGTask");
428 return;
429 }
430 Err(e) => {
431 log::warn!("iOS: warm delivery — failed to get pending BGTask: {e}");
432 return;
433 }
434 };
435 let _ = pending;
436
437 let should_start = mobile
440 .get_desired_state_status()
441 .ok()
442 .flatten()
443 .and_then(|status| {
444 let config_str = status.last_start_config?;
445 Some((status.desired_running, config_str))
446 });
447
448 let Some((true, config_str)) = should_start else {
449 log::info!("iOS: warm delivery skipped: desired_running=false");
450 return;
451 };
452
453 let Ok(config) = serde_json::from_str::<StartConfig>(&config_str) else {
454 log::warn!(
455 "iOS: warm delivery — failed to parse stored start config; preserving pending task info"
456 );
457 return;
458 };
459
460 let manager = app.state::<ServiceManagerHandle<R>>();
461 let cmd_tx = manager.cmd_tx.clone();
462 let app_clone = app.app_handle().clone();
463 let timeout_secs = app.state::<PluginConfig>().ios_cancel_listener_timeout_secs;
464
465 let mob_handle = mobile.handle.clone();
467 let on_complete: OnCompleteCallback = Box::new(move |success| {
468 let ml = MobileLifecycle {
469 handle: mob_handle.clone(),
470 };
471 let _ = ml.complete_bg_task(success);
472 });
473
474 let mobile_for_success = mobile.clone();
477 let mobile_for_failure = mobile.clone();
478 let app_for_listener = app_clone.clone();
479 let on_success = Box::new(move || {
480 let _ = mobile_for_success.clear_pending_bg_task();
481 ios_spawn_cancel_listener(&app_for_listener, timeout_secs);
482 });
483 let on_failure = Box::new(move || {
484 let _ = mobile_for_failure.record_failed_pending();
485 });
486
487 log::info!("iOS: warm-starting service for delivered BGTask");
488 run_warm_start(
489 config,
490 app_clone,
491 cmd_tx,
492 on_complete,
493 on_success,
494 on_failure,
495 )
496 .await;
497}
498
499#[cfg(not(target_os = "ios"))]
500#[allow(dead_code)]
501fn ios_spawn_warm_listener<R: Runtime>(_app: &AppHandle<R>) {}
502
503#[allow(dead_code)] async fn run_auto_start<R: Runtime>(
523 config: StartConfig,
524 app: AppHandle<R>,
525 cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
526 on_success: Box<dyn FnOnce() + Send>,
527 on_failure: Box<dyn FnOnce() + Send>,
528) -> bool {
529 let (tx, rx) = tokio::sync::oneshot::channel();
530 if cmd_tx
531 .send(ManagerCommand::Start {
532 config,
533 reply: tx,
534 app,
535 })
536 .await
537 .is_err()
538 {
539 log::warn!(
540 "iOS: auto-start preserved pending BGTask after failure (command channel closed)"
541 );
542 on_failure();
543 return false;
544 }
545
546 match rx.await {
547 Ok(Ok(())) => {
548 log::info!("iOS: auto-start consumed pending BGTask after success");
549 on_success();
550 true
551 }
552 Ok(Err(e)) => {
553 log::warn!("iOS: auto-start preserved pending BGTask after failure: {e}");
554 on_failure();
555 false
556 }
557 Err(e) => {
558 log::warn!(
559 "iOS: auto-start preserved pending BGTask after failure (reply dropped: {e})"
560 );
561 on_failure();
562 false
563 }
564 }
565}
566
567#[allow(dead_code)] async fn run_warm_start<R: Runtime>(
593 config: StartConfig,
594 app: AppHandle<R>,
595 cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
596 on_complete: OnCompleteCallback,
597 on_success: Box<dyn FnOnce() + Send>,
598 on_failure: Box<dyn FnOnce() + Send>,
599) -> bool {
600 let (run_tx, run_rx) = tokio::sync::oneshot::channel();
606 if cmd_tx
607 .send(ManagerCommand::IsRunning { reply: run_tx })
608 .await
609 .is_err()
610 {
611 log::warn!(
612 "iOS: warm start preserved pending BGTask after failure (command channel closed)"
613 );
614 on_failure();
615 return false;
616 }
617 if run_rx.await.unwrap_or(false) {
618 log::info!("iOS: warm BGTask delivery while already running — no-op");
619 return false;
620 }
621
622 if cmd_tx
625 .send(ManagerCommand::SetOnComplete {
626 callback: on_complete,
627 })
628 .await
629 .is_err()
630 {
631 log::warn!("iOS: warm start preserved pending BGTask after failure (channel closed)");
632 on_failure();
633 return false;
634 }
635
636 let (tx, rx) = tokio::sync::oneshot::channel();
637 if cmd_tx
638 .send(ManagerCommand::Start {
639 config,
640 reply: tx,
641 app,
642 })
643 .await
644 .is_err()
645 {
646 log::warn!(
647 "iOS: warm start preserved pending BGTask after failure (command channel closed)"
648 );
649 on_failure();
650 return false;
651 }
652
653 match rx.await {
654 Ok(Ok(())) => {
655 log::info!("iOS: warm BGTask delivery started service; consumed pending BGTask");
656 on_success();
657 true
658 }
659 Ok(Err(ServiceError::AlreadyRunning)) => {
662 log::info!("iOS: warm BGTask delivery raced a running actor — no-op");
663 false
664 }
665 Ok(Err(e)) => {
666 log::warn!("iOS: warm start preserved pending BGTask after failure: {e}");
667 on_failure();
668 false
669 }
670 Err(e) => {
671 log::warn!(
672 "iOS: warm start preserved pending BGTask after failure (reply dropped: {e})"
673 );
674 on_failure();
675 false
676 }
677 }
678}
679
680#[tauri::command]
683async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
684 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
686 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
687 if ipc_state.client.is_connected() {
689 return ipc_state
690 .client
691 .start(config)
692 .await
693 .map_err(|e| e.to_string());
694 }
695
696 let plugin_config = app.state::<PluginConfig>();
698 if !plugin_config.desktop_start_service_if_missing {
699 return Err(ServiceError::Ipc("ipcUnavailable".into()).to_string());
700 }
701
702 let socket_path = ipc_state.client.socket_path().display().to_string();
704 let timeout =
705 std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
706
707 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
708 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
709 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
710 {
711 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
712 mgr.start().map_err(|e| e.to_string())?;
713 }
714
715 ipc_state.client.nudge_reconnect();
716 let connected = ipc_state
717 .client
718 .wait_for_connected(timeout)
719 .await
720 .map_err(|e| e.to_string())?;
721
722 if !connected {
723 return Err(
724 ServiceError::Ipc(format!("ipcUnavailable: socket {socket_path}")).to_string(),
725 );
726 }
727
728 return ipc_state
730 .client
731 .start(config)
732 .await
733 .map_err(|e| e.to_string());
734 }
735
736 ios_set_on_complete_callback(&app).await?;
739
740 let manager = app.state::<ServiceManagerHandle<R>>();
744 let (tx, rx) = tokio::sync::oneshot::channel();
745 manager
746 .cmd_tx
747 .send(ManagerCommand::Start {
748 config,
749 reply: tx,
750 app: app.clone(),
751 })
752 .await
753 .map_err(|e| e.to_string())?;
754
755 rx.await
756 .map_err(|e| e.to_string())?
757 .map_err(|e| e.to_string())?;
758
759 let plugin_config = app.state::<PluginConfig>();
761 ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
762
763 Ok(())
764}
765
766#[tauri::command]
767async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
768 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
770 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
771 return ipc_state.client.stop().await.map_err(|e| e.to_string());
772 }
773
774 let manager = app.state::<ServiceManagerHandle<R>>();
776 let (tx, rx) = tokio::sync::oneshot::channel();
777 manager
778 .cmd_tx
779 .send(ManagerCommand::Stop { reply: tx })
780 .await
781 .map_err(|e| e.to_string())?;
782
783 rx.await
784 .map_err(|e| e.to_string())?
785 .map_err(|e| e.to_string())
786}
787
788#[tauri::command]
789async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
790 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
792 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
793 return ipc_state.client.is_running().await.unwrap_or(false);
794 }
795
796 let manager = app.state::<ServiceManagerHandle<R>>();
798 let (tx, rx) = tokio::sync::oneshot::channel();
799 if manager
800 .cmd_tx
801 .send(ManagerCommand::IsRunning { reply: tx })
802 .await
803 .is_err()
804 {
805 return false;
806 }
807 rx.await.unwrap_or(false)
808}
809
810#[tauri::command]
811async fn get_service_state<R: Runtime>(app: AppHandle<R>) -> Result<models::ServiceStatus, String> {
812 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
814 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
815 return ipc_state
816 .client
817 .get_state()
818 .await
819 .map_err(|e| e.to_string());
820 }
821
822 let manager = app.state::<ServiceManagerHandle<R>>();
824 Ok(manager.get_state().await)
825}
826
827#[tauri::command]
828#[allow(unused_variables)]
829async fn get_platform_capabilities<R: Runtime>(
830 app: AppHandle<R>,
831) -> Result<models::PlatformCapabilities, String> {
832 #[cfg(feature = "desktop-service")]
833 let plugin_config = app.state::<PluginConfig>();
834
835 #[cfg(feature = "desktop-service")]
836 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
837 #[cfg(not(feature = "desktop-service"))]
838 let desktop_mode: Option<&str> = None;
839
840 let (platform, lifecycle_mode) =
841 capabilities::CapabilityProvider::detect_platform(desktop_mode);
842
843 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
844 let os_service_installed = if matches!(lifecycle_mode, models::LifecycleMode::DesktopOsService)
845 {
846 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
847 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
848 let exec = std::env::current_exe().unwrap_or_default();
849 DesktopServiceManager::new(&label, exec)
850 .map(|_| true)
851 .unwrap_or(false)
852 } else {
853 false
854 };
855
856 #[cfg(not(all(feature = "desktop-service", any(unix, windows))))]
857 let os_service_installed = false;
858
859 Ok(capabilities::CapabilityProvider::capabilities(
860 platform,
861 lifecycle_mode,
862 os_service_installed,
863 ))
864}
865
866#[tauri::command]
871async fn get_scheduling_status<R: Runtime>(
872 app: AppHandle<R>,
873) -> Result<models::IOSSchedulingStatus, String> {
874 #[cfg(target_os = "ios")]
875 {
876 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
877 mobile
878 .get_scheduling_status()
879 .map_err(|e| e.to_string())
880 .and_then(|opt| opt.ok_or_else(|| "no scheduling status available".to_string()))
881 }
882 #[cfg(not(target_os = "ios"))]
883 {
884 let _ = app;
885 Ok(models::IOSSchedulingStatus {
886 refresh_scheduled: false,
887 processing_scheduled: false,
888 refresh_error: None,
889 processing_error: None,
890 })
891 }
892}
893
894#[tauri::command]
907async fn request_battery_exemption<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
908 #[cfg(target_os = "android")]
909 {
910 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
911 mobile
912 .request_battery_exemption()
913 .map_err(|e| e.to_string())
914 }
915 #[cfg(not(target_os = "android"))]
916 {
917 let _ = app;
918 Ok(())
919 }
920}
921
922#[tauri::command]
927async fn get_desired_state_status<R: Runtime>(
928 app: AppHandle<R>,
929) -> Result<models::IOSDesiredStateStatus, String> {
930 #[cfg(target_os = "ios")]
931 {
932 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
933 mobile
934 .get_desired_state_status()
935 .map_err(|e| e.to_string())
936 .and_then(|opt| opt.ok_or_else(|| "no desired-state status available".to_string()))
937 }
938 #[cfg(not(target_os = "ios"))]
939 {
940 let _ = app;
941 Ok(models::IOSDesiredStateStatus {
942 desired_running: false,
943 last_start_config: None,
944 last_task_kind: None,
945 last_task_started_at: None,
946 last_task_completed_at: None,
947 last_schedule_error: None,
948 last_completion_reason: None,
949 notification_granted: None,
950 })
951 }
952}
953
954#[tauri::command]
960async fn get_pending_bg_task<R: Runtime>(
961 app: AppHandle<R>,
962) -> Result<Option<models::PendingTaskInfo>, String> {
963 #[cfg(target_os = "ios")]
964 {
965 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
966 mobile.get_pending_bg_task().map_err(|e| e.to_string())
967 }
968 #[cfg(not(target_os = "ios"))]
969 {
970 let _ = app;
971 Ok(None)
972 }
973}
974
975#[tauri::command]
984async fn get_notification_permission_status<R: Runtime>(
985 app: AppHandle<R>,
986) -> Result<models::NotificationPermissionStatus, String> {
987 #[cfg(target_os = "android")]
988 {
989 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
990 mobile
991 .get_notification_permission_status()
992 .map_err(|e| e.to_string())
993 }
994 #[cfg(not(target_os = "android"))]
995 {
996 let _ = app;
997 Ok(models::NotificationPermissionStatus {
998 status: "granted".to_string(),
999 })
1000 }
1001}
1002
1003#[tauri::command]
1012async fn request_notification_permission<R: Runtime>(
1013 app: AppHandle<R>,
1014) -> Result<models::NotificationPermissionStatus, String> {
1015 #[cfg(target_os = "android")]
1016 {
1017 let mobile = app.state::<Arc<MobileLifecycle<R>>>().inner().clone();
1018 tokio::task::spawn_blocking(move || mobile.request_notification_permission())
1019 .await
1020 .map_err(|e| e.to_string())?
1021 .map_err(|e| e.to_string())
1022 }
1023 #[cfg(not(target_os = "android"))]
1024 {
1025 let _ = app;
1026 Ok(models::NotificationPermissionStatus {
1027 status: "granted".to_string(),
1028 })
1029 }
1030}
1031
1032#[tauri::command]
1052async fn can_use_full_screen_intent<R: Runtime>(
1053 app: AppHandle<R>,
1054) -> Result<serde_json::Value, String> {
1055 #[cfg(target_os = "android")]
1056 {
1057 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
1058 let can_use = mobile
1059 .can_use_full_screen_intent()
1060 .map_err(|e| e.to_string())?;
1061 Ok(serde_json::json!({ "canUse": can_use }))
1062 }
1063 #[cfg(not(target_os = "android"))]
1064 {
1065 let _ = app;
1066 Ok(serde_json::json!({ "canUse": true }))
1067 }
1068}
1069
1070#[tauri::command]
1077async fn open_full_screen_intent_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1078 #[cfg(target_os = "android")]
1079 {
1080 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
1081 mobile
1082 .open_full_screen_intent_settings()
1083 .map_err(|e| e.to_string())
1084 }
1085 #[cfg(not(target_os = "android"))]
1086 {
1087 let _ = app;
1088 Ok(())
1089 }
1090}
1091
1092#[tauri::command]
1099async fn enable_auto_restart<R: Runtime>(
1100 app: AppHandle<R>,
1101 config: Option<StartConfig>,
1102) -> Result<(), String> {
1103 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
1105 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1106 return ipc_state
1107 .client
1108 .enable_auto_restart(config)
1109 .await
1110 .map_err(|e| e.to_string());
1111 }
1112
1113 let manager = app.state::<ServiceManagerHandle<R>>();
1114 let (tx, rx) = tokio::sync::oneshot::channel();
1115 manager
1116 .cmd_tx
1117 .send(ManagerCommand::EnableAutoRestart { config, reply: tx })
1118 .await
1119 .map_err(|e| e.to_string())?;
1120 rx.await
1121 .map_err(|e| e.to_string())?
1122 .map_err(|e| e.to_string())
1123}
1124
1125#[tauri::command]
1132async fn disable_auto_restart<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1133 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
1135 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1136 return ipc_state
1137 .client
1138 .disable_auto_restart()
1139 .await
1140 .map_err(|e| e.to_string());
1141 }
1142
1143 let manager = app.state::<ServiceManagerHandle<R>>();
1144 let (tx, rx) = tokio::sync::oneshot::channel();
1145 manager
1146 .cmd_tx
1147 .send(ManagerCommand::DisableAutoRestart { reply: tx })
1148 .await
1149 .map_err(|e| e.to_string())?;
1150 rx.await
1151 .map_err(|e| e.to_string())?
1152 .map_err(|e| e.to_string())
1153}
1154
1155#[tauri::command]
1160async fn get_desired_service_state<R: Runtime>(
1161 app: AppHandle<R>,
1162) -> Result<Option<desired_state::DesiredState>, String> {
1163 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
1165 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1166 return ipc_state
1167 .client
1168 .get_desired_state()
1169 .await
1170 .map_err(|e| e.to_string());
1171 }
1172
1173 let manager = app.state::<ServiceManagerHandle<R>>();
1174 let (tx, rx) = tokio::sync::oneshot::channel();
1175 manager
1176 .cmd_tx
1177 .send(ManagerCommand::GetDesiredState { reply: tx })
1178 .await
1179 .map_err(|e| e.to_string())?;
1180 rx.await.map_err(|e| e.to_string())
1181}
1182
1183#[tauri::command]
1197async fn native_lifecycle_event<R: Runtime>(
1198 app: AppHandle<R>,
1199 event: models::NativeLifecycleEvent,
1200) -> Result<(), String> {
1201 let manager = app.state::<ServiceManagerHandle<R>>();
1202 manager
1203 .send_native_lifecycle_event(event)
1204 .await
1205 .map_err(|e| e.to_string())
1206}
1207
1208#[tauri::command]
1213#[allow(unused_variables)]
1214async fn validate_setup<R: Runtime>(
1215 app: AppHandle<R>,
1216) -> Result<models::SetupValidationReport, String> {
1217 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
1219 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1220 return ipc_state
1221 .client
1222 .validate_setup()
1223 .await
1224 .map_err(|e| e.to_string());
1225 }
1226
1227 #[cfg(feature = "desktop-service")]
1228 let plugin_config = app.state::<PluginConfig>();
1229
1230 #[cfg(feature = "desktop-service")]
1231 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
1232 #[cfg(not(feature = "desktop-service"))]
1233 let desktop_mode: Option<&str> = None;
1234
1235 let (platform, _) = capabilities::CapabilityProvider::detect_platform(desktop_mode);
1236 Ok(validator::SetupValidator::validate(platform))
1237}
1238
1239#[tauri::command]
1244async fn get_lifecycle_status<R: Runtime>(
1245 app: AppHandle<R>,
1246) -> Result<models::LifecycleStatus, String> {
1247 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
1249 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1250 return ipc_state
1251 .client
1252 .get_lifecycle_status()
1253 .await
1254 .map_err(|e| e.to_string());
1255 }
1256
1257 #[cfg(feature = "desktop-service")]
1258 let plugin_config = app.state::<PluginConfig>();
1259
1260 #[cfg(feature = "desktop-service")]
1261 let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
1262 #[cfg(not(feature = "desktop-service"))]
1263 let desktop_mode: Option<&str> = None;
1264
1265 let manager = app.state::<ServiceManagerHandle<R>>();
1266 let (tx, rx) = tokio::sync::oneshot::channel();
1267 manager
1268 .cmd_tx
1269 .send(ManagerCommand::GetLifecycleStatus {
1270 desktop_mode: desktop_mode.map(|s| s.to_string()),
1271 reply: tx,
1272 })
1273 .await
1274 .map_err(|e| e.to_string())?;
1275
1276 rx.await.map_err(|e| e.to_string())
1277}
1278
1279#[tauri::command]
1285async fn configure_recovery<R: Runtime>(
1286 app: AppHandle<R>,
1287 enabled: bool,
1288 config: Option<StartConfig>,
1289) -> Result<(), String> {
1290 if enabled {
1291 enable_auto_restart(app, config).await
1292 } else {
1293 disable_auto_restart(app).await
1294 }
1295}
1296
1297#[cfg(all(feature = "desktop-service", any(unix, windows)))]
1304struct DesktopIpcState {
1305 client: desktop::ipc_client::PersistentIpcClientHandle,
1306}
1307
1308#[cfg(all(feature = "desktop-service", any(unix, windows), not(mobile)))]
1315fn setup_os_service_ipc<R: Runtime>(
1316 app: &AppHandle<R>,
1317 config: &PluginConfig,
1318) -> Result<(), ServiceError> {
1319 let label = desktop::service_manager::derive_service_label(
1320 app,
1321 config.desktop_service_label.as_deref(),
1322 );
1323 let socket_path = desktop::ipc::socket_path(&label)?;
1324 let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
1325 socket_path,
1326 app.app_handle().clone(),
1327 );
1328 app.manage(DesktopIpcState { client });
1329
1330 let consent_dir = app.path().app_data_dir().ok().map(|d| d.join("data"));
1339 let allow = consent_dir
1340 .as_deref()
1341 .map(|d| should_auto_provision(d, config.desktop_start_service_if_missing))
1342 .unwrap_or(false);
1343 if allow {
1344 spawn_os_service_auto_provision(app.app_handle());
1345 } else {
1346 log::info!(
1347 "Background service: OS-service auto-provision skipped \
1348 (consent off or desktopStartServiceIfMissing disabled)"
1349 );
1350 }
1351 Ok(())
1352}
1353
1354#[cfg(feature = "desktop-service")]
1358const DESKTOP_CONSENT_FILENAME: &str = "background-service-consent.json";
1359
1360#[cfg(feature = "desktop-service")]
1366#[derive(Debug, Default, serde::Deserialize)]
1367struct ProvisioningConsent {
1368 #[serde(default)]
1369 enabled: bool,
1370}
1371
1372#[cfg(feature = "desktop-service")]
1378fn desktop_consent_allows_provisioning(data_dir: &std::path::Path) -> bool {
1379 let path = data_dir.join(DESKTOP_CONSENT_FILENAME);
1380 let record = std::fs::read_to_string(&path)
1381 .ok()
1382 .and_then(|text| serde_json::from_str::<ProvisioningConsent>(&text).ok())
1383 .unwrap_or_default();
1384 record.enabled
1385}
1386
1387#[cfg(feature = "desktop-service")]
1393fn should_auto_provision(data_dir: &std::path::Path, start_if_missing: bool) -> bool {
1394 start_if_missing && desktop_consent_allows_provisioning(data_dir)
1395}
1396
1397#[cfg(feature = "desktop-service")]
1398#[tauri::command]
1399async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1400 install_service_inner(&app).await
1401}
1402
1403#[cfg(feature = "desktop-service")]
1417const DEFAULT_RESTART_DELAY_SECS: u32 = 5;
1418#[cfg(feature = "desktop-service")]
1419const DEFAULT_START_LIMIT_BURST: u32 = 5;
1420#[cfg(feature = "desktop-service")]
1421const DEFAULT_START_LIMIT_INTERVAL_SECS: u32 = 60;
1422
1423#[cfg(feature = "desktop-service")]
1424async fn install_service_inner<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
1425 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
1426 let plugin_config = app.state::<PluginConfig>();
1427 let label = derive_service_label(app, plugin_config.desktop_service_label.as_deref());
1428 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
1429
1430 if !exec_path.exists() {
1432 return Err(format!(
1433 "Current executable does not exist at {}: cannot install OS service",
1434 exec_path.display()
1435 ));
1436 }
1437
1438 let validate_result = tokio::time::timeout(
1442 std::time::Duration::from_secs(5),
1443 tokio::process::Command::new(&exec_path)
1444 .arg("--service-label")
1445 .arg(&label)
1446 .arg("--validate-service-install")
1447 .output(),
1448 )
1449 .await;
1450
1451 match validate_result {
1452 Ok(Ok(output)) => {
1453 let stdout = String::from_utf8_lossy(&output.stdout);
1454 if !stdout.trim().contains("ok") {
1455 return Err("Binary does not handle --validate-service-install. \
1456 Ensure headless_main() is called from your app's main()."
1457 .into());
1458 }
1459 }
1460 Ok(Err(e)) => {
1461 return Err(format!(
1462 "Failed to validate executable for --service-label: {e}"
1463 ));
1464 }
1465 Err(_) => {
1466 log::warn!(
1469 "Timeout validating --service-label support. \
1470 Ensure your app's main() handles the --service-label argument \
1471 and calls headless_main()."
1472 );
1473 }
1474 }
1475
1476 {
1477 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
1478 use desktop::service_manager::InstallOptions;
1479 let options = InstallOptions {
1480 autostart: plugin_config.desktop_service_autostart,
1481 restart_delay_secs: Some(DEFAULT_RESTART_DELAY_SECS),
1485 journal_output: true,
1486 log_path: None,
1487 start_limit_burst: Some(DEFAULT_START_LIMIT_BURST),
1488 start_limit_interval_secs: Some(DEFAULT_START_LIMIT_INTERVAL_SECS),
1489 };
1490 mgr.install(&options).map_err(|e| e.to_string())?;
1491 }
1492
1493 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1495 ipc_state.client.nudge_reconnect();
1496 let timeout =
1497 std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
1498 ipc_state.client.wait_for_connected(timeout).await.ok();
1499 }
1500
1501 Ok(())
1502}
1503
1504#[cfg(all(feature = "desktop-service", any(unix, windows), not(mobile)))]
1513fn spawn_os_service_auto_provision<R: Runtime>(app: &AppHandle<R>) {
1514 let app = app.clone();
1515 tauri::async_runtime::spawn(async move {
1516 let (start_if_missing, start_timeout_ms) = {
1517 let plugin_config = app.state::<PluginConfig>();
1518 (
1519 plugin_config.desktop_start_service_if_missing,
1520 plugin_config.desktop_service_start_timeout_ms,
1521 )
1522 };
1523 if !start_if_missing {
1524 return;
1525 }
1526
1527 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1529 match ipc_state
1530 .client
1531 .wait_for_connected(std::time::Duration::from_secs(3))
1532 .await
1533 {
1534 Ok(true) => {
1535 log::info!("OS service already running — IPC connected");
1536 return;
1537 }
1538 Ok(false) => {}
1539 Err(e) => log::warn!("IPC wait failed during auto-provision: {e}"),
1540 }
1541 }
1542
1543 log::info!("OS service IPC unavailable — auto-provisioning (install + start)");
1544 if let Err(e) = install_service_inner(&app).await {
1545 log::warn!(
1546 "OS service auto-install failed: {e}; \
1547 app continues with in-process fallback"
1548 );
1549 return;
1550 }
1551
1552 {
1554 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
1555 let plugin_config = app.state::<PluginConfig>();
1556 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
1557 let exec_path = match std::env::current_exe() {
1558 Ok(p) => p,
1559 Err(e) => {
1560 log::warn!("OS service auto-start failed: cannot resolve current exe: {e}");
1561 return;
1562 }
1563 };
1564 match DesktopServiceManager::new(&label, exec_path) {
1565 Ok(mgr) => {
1566 if let Err(e) = mgr.start() {
1567 log::warn!("OS service auto-start failed: {e}");
1568 return;
1569 }
1570 }
1571 Err(e) => {
1572 log::warn!("OS service manager unavailable: {e}");
1573 return;
1574 }
1575 }
1576 }
1577
1578 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1579 ipc_state.client.nudge_reconnect();
1580 let timeout = std::time::Duration::from_millis(start_timeout_ms);
1581 match ipc_state.client.wait_for_connected(timeout).await {
1582 Ok(true) => log::info!("OS service auto-provision complete — IPC connected"),
1583 Ok(false) => log::warn!(
1584 "OS service installed and started but IPC did not connect within {}ms",
1585 timeout.as_millis()
1586 ),
1587 Err(e) => log::warn!("IPC wait failed after auto-provision: {e}"),
1588 }
1589 }
1590 });
1591}
1592
1593#[cfg(feature = "desktop-service")]
1594#[tauri::command]
1595async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1596 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
1597 let plugin_config = app.state::<PluginConfig>();
1598 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
1599 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
1600 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
1601 mgr.uninstall().map_err(|e| e.to_string())
1602}
1603
1604#[cfg(all(feature = "desktop-service", any(unix, windows)))]
1611fn build_os_service_status(
1612 label: &str,
1613 ipc_connected: bool,
1614 socket_path: Option<String>,
1615 last_error: Option<String>,
1616) -> models::OsServiceStatus {
1617 let mode = if cfg!(target_os = "macos") {
1618 "launchd"
1619 } else if cfg!(windows) {
1620 "scm"
1621 } else {
1622 "systemd"
1623 };
1624
1625 let installed = if ipc_connected {
1626 models::OsServiceInstallState::Running
1627 } else {
1628 models::OsServiceInstallState::Installed
1632 };
1633
1634 models::OsServiceStatus {
1635 label: label.to_string(),
1636 mode: mode.to_string(),
1637 installed,
1638 ipc_connected,
1639 socket_path,
1640 last_error,
1641 }
1642}
1643
1644#[cfg(feature = "desktop-service")]
1649#[tauri::command]
1650async fn start_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1651 #[cfg(any(unix, windows))]
1652 {
1653 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
1654 let plugin_config = app.state::<PluginConfig>();
1655 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
1656 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
1657 {
1658 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
1659 mgr.start().map_err(|e| e.to_string())?;
1660 }
1661
1662 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
1664 ipc_state.client.nudge_reconnect();
1665 let timeout =
1666 std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
1667 ipc_state.client.wait_for_connected(timeout).await.ok();
1668 }
1669
1670 Ok(())
1671 }
1672 #[cfg(not(any(unix, windows)))]
1673 {
1674 let _ = app;
1675 Err(os_service_unsupported_platform())
1676 }
1677}
1678
1679#[cfg(feature = "desktop-service")]
1684#[tauri::command]
1685async fn stop_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1686 #[cfg(any(unix, windows))]
1687 {
1688 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
1689 let plugin_config = app.state::<PluginConfig>();
1690 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
1691 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
1692 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
1693 mgr.stop().map_err(|e| e.to_string())
1694 }
1695 #[cfg(not(any(unix, windows)))]
1696 {
1697 let _ = app;
1698 Err(os_service_unsupported_platform())
1699 }
1700}
1701
1702#[cfg(feature = "desktop-service")]
1706#[tauri::command]
1707async fn restart_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1708 #[cfg(any(unix, windows))]
1709 {
1710 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
1711 let plugin_config = app.state::<PluginConfig>();
1712 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
1713 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
1714 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
1715 mgr.stop().ok(); mgr.start().map_err(|e| e.to_string())
1717 }
1718 #[cfg(not(any(unix, windows)))]
1719 {
1720 let _ = app;
1721 Err(os_service_unsupported_platform())
1722 }
1723}
1724
1725#[cfg(feature = "desktop-service")]
1730#[tauri::command]
1731async fn get_os_service_status<R: Runtime>(
1732 app: AppHandle<R>,
1733) -> Result<models::OsServiceStatus, String> {
1734 #[cfg(any(unix, windows))]
1735 {
1736 use desktop::service_manager::derive_service_label;
1737 let plugin_config = app.state::<PluginConfig>();
1738 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
1739
1740 let ipc_connected = app
1741 .try_state::<DesktopIpcState>()
1742 .map(|s| s.client.is_connected())
1743 .unwrap_or(false);
1744
1745 let socket_path = desktop::ipc::socket_path(&label)
1746 .ok()
1747 .map(|p| p.to_string_lossy().to_string());
1748
1749 Ok(build_os_service_status(
1750 &label,
1751 ipc_connected,
1752 socket_path,
1753 None,
1754 ))
1755 }
1756 #[cfg(not(any(unix, windows)))]
1757 {
1758 let _ = app;
1759 Err(os_service_unsupported_platform())
1760 }
1761}
1762
1763#[cfg(all(feature = "desktop-service", not(any(unix, windows))))]
1766fn os_service_unsupported_platform() -> String {
1767 ServiceError::Platform("OS-service mode is not supported on this platform".into()).to_string()
1768}
1769
1770pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
1780where
1781 R: Runtime,
1782 S: BackgroundService<R>,
1783 F: Fn() -> S + Send + Sync + 'static,
1784{
1785 let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
1786
1787 Builder::<R, PluginConfig>::new("background-service")
1788 .invoke_handler(tauri::generate_handler![
1789 start,
1790 stop,
1791 is_running,
1792 get_service_state,
1793 get_platform_capabilities,
1794 get_scheduling_status,
1795 get_desired_state_status,
1796 get_pending_bg_task,
1797 get_notification_permission_status,
1798 request_notification_permission,
1799 can_use_full_screen_intent,
1800 open_full_screen_intent_settings,
1801 enable_auto_restart,
1802 disable_auto_restart,
1803 get_desired_service_state,
1804 native_lifecycle_event,
1805 validate_setup,
1806 get_lifecycle_status,
1807 configure_recovery,
1808 request_battery_exemption,
1809 #[cfg(feature = "desktop-service")]
1810 install_service,
1811 #[cfg(feature = "desktop-service")]
1812 uninstall_service,
1813 #[cfg(feature = "desktop-service")]
1814 start_os_service,
1815 #[cfg(feature = "desktop-service")]
1816 stop_os_service,
1817 #[cfg(feature = "desktop-service")]
1818 restart_os_service,
1819 #[cfg(feature = "desktop-service")]
1820 get_os_service_status,
1821 ])
1822 .setup(move |app, api| {
1823 let config = api.config().clone();
1824 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(config.channel_capacity);
1825 #[cfg(mobile)]
1826 let mobile_cmd_tx = cmd_tx.clone();
1827 let handle = ServiceManagerHandle::new(cmd_tx);
1828 app.manage(handle);
1829
1830 app.manage(config.clone());
1831
1832 let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
1833 let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
1834 let ios_earliest_refresh_begin_minutes = config.ios_earliest_refresh_begin_minutes;
1835 let ios_earliest_processing_begin_minutes =
1836 config.ios_earliest_processing_begin_minutes;
1837 let ios_requires_external_power = config.ios_requires_external_power;
1838 let ios_requires_network_connectivity = config.ios_requires_network_connectivity;
1839 let ios_processing_ceiling_multiplier = config.ios_processing_ceiling_multiplier;
1840 let android_fg_service_types = config.android_foreground_service_types.clone();
1841 let android_validate_fg_type = config.android_validate_foreground_service_type;
1842
1843 let notifier_policy = NotifierPolicy::derive(&config, cfg!(target_os = "android"));
1847 let notify_sink: Option<Arc<dyn NotifySink>> = Some(Arc::new(Notifier {
1848 app: app.app_handle().clone(),
1849 }));
1850
1851 let desired_state_backend: Option<Arc<dyn desired_state::DesiredStateBackend>> = {
1860 match app.path().app_data_dir() {
1861 Ok(data_dir) => Some(Arc::new(desired_state::FileDesiredStateBackend::new(
1862 data_dir,
1863 ))),
1864 Err(e) => {
1865 log::warn!("Failed to get app data dir for desired-state persistence: {e}");
1866 None
1867 }
1868 }
1869 };
1870
1871 #[cfg(all(feature = "desktop-service", any(unix, windows), not(mobile)))]
1881 if config.desktop_service_mode == "osService" {
1882 setup_os_service_ipc(app, &config)?;
1884 } else {
1885 let factory = boxed_factory;
1887 tauri::async_runtime::spawn(manager_loop(
1888 cmd_rx,
1889 factory,
1890 ios_safety_timeout_secs,
1891 ios_processing_safety_timeout_secs,
1892 ios_earliest_refresh_begin_minutes,
1893 ios_earliest_processing_begin_minutes,
1894 ios_requires_external_power,
1895 ios_requires_network_connectivity,
1896 ios_processing_ceiling_multiplier,
1897 desired_state_backend,
1898 android_fg_service_types.clone(),
1899 android_validate_fg_type,
1900 notifier_policy,
1901 notify_sink,
1902 None,
1903 false,
1904 ));
1905 }
1906
1907 #[cfg(all(feature = "desktop-service", unix, mobile))]
1911 {
1912 if config.desktop_service_mode == "osService" {
1913 log::warn!(
1914 "desktopServiceMode=osService is ignored on mobile; \
1915 using the in-process service actor"
1916 );
1917 }
1918 let factory = boxed_factory;
1919 tauri::async_runtime::spawn(manager_loop(
1920 cmd_rx,
1921 factory,
1922 ios_safety_timeout_secs,
1923 ios_processing_safety_timeout_secs,
1924 ios_earliest_refresh_begin_minutes,
1925 ios_earliest_processing_begin_minutes,
1926 ios_requires_external_power,
1927 ios_requires_network_connectivity,
1928 ios_processing_ceiling_multiplier,
1929 desired_state_backend,
1930 android_fg_service_types.clone(),
1931 android_validate_fg_type,
1932 notifier_policy,
1933 notify_sink,
1934 None,
1935 false,
1936 ));
1937 }
1938
1939 #[cfg(all(feature = "desktop-service", not(any(unix, windows))))]
1942 {
1943 if config.desktop_service_mode == "osService" {
1944 log::warn!(
1945 "Desktop OS-service mode is not supported on this platform; \
1946 background-service commands will fail instead of running in-process"
1947 );
1948 drop(cmd_rx);
1949 } else {
1950 let factory = boxed_factory;
1952 tauri::async_runtime::spawn(manager_loop(
1953 cmd_rx,
1954 factory,
1955 ios_safety_timeout_secs,
1956 ios_processing_safety_timeout_secs,
1957 ios_earliest_refresh_begin_minutes,
1958 ios_earliest_processing_begin_minutes,
1959 ios_requires_external_power,
1960 ios_requires_network_connectivity,
1961 ios_processing_ceiling_multiplier,
1962 desired_state_backend,
1963 android_fg_service_types.clone(),
1964 android_validate_fg_type,
1965 notifier_policy,
1966 notify_sink,
1967 None,
1968 false,
1969 ));
1970 }
1971 }
1972
1973 #[cfg(not(feature = "desktop-service"))]
1974 {
1975 let factory = boxed_factory;
1976 tauri::async_runtime::spawn(manager_loop(
1977 cmd_rx,
1978 factory,
1979 ios_safety_timeout_secs,
1980 ios_processing_safety_timeout_secs,
1981 ios_earliest_refresh_begin_minutes,
1982 ios_earliest_processing_begin_minutes,
1983 ios_requires_external_power,
1984 ios_requires_network_connectivity,
1985 ios_processing_ceiling_multiplier,
1986 desired_state_backend,
1987 android_fg_service_types,
1988 android_validate_fg_type,
1989 notifier_policy,
1990 notify_sink,
1991 None,
1992 false,
1993 ));
1994 }
1995
1996 #[cfg(mobile)]
1997 {
1998 let lifecycle = mobile::init(app, api)?;
1999 let lifecycle_arc = Arc::new(lifecycle);
2000
2001 let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
2003 if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
2004 mobile: mobile_trait,
2005 }) {
2006 log::error!("Failed to send SetMobile command: {e}");
2007 }
2008
2009 app.manage(lifecycle_arc);
2011 }
2012
2013 #[cfg(target_os = "ios")]
2017 {
2018 ios_spawn_cold_auto_start(app);
2019
2020 ios_spawn_warm_listener(app);
2024 }
2025
2026 Ok(())
2027 })
2028 .on_event(|app, event| {
2029 if let tauri::RunEvent::Exit = event {
2030 #[cfg(target_os = "android")]
2033 {
2034 let _ = app;
2035 return;
2036 }
2037
2038 #[cfg(not(target_os = "android"))]
2039 {
2040 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
2042 if app.try_state::<DesktopIpcState>().is_some() {
2043 return;
2044 }
2045 let manager = app.state::<ServiceManagerHandle<R>>();
2046 #[cfg(target_os = "ios")]
2051 let stop_result =
2052 manager.stop_blocking_with_reason(crate::models::StopReason::ProcessExit);
2053 #[cfg(not(target_os = "ios"))]
2054 let stop_result = manager.stop_blocking();
2055 if let Err(e) = stop_result {
2056 log::warn!("Failed to stop background service on app exit: {e}");
2057 }
2058 }
2059 }
2060 })
2061 .build()
2062}
2063
2064#[cfg(test)]
2065mod tests {
2066 use super::*;
2067 use async_trait::async_trait;
2068 use std::sync::atomic::{AtomicUsize, Ordering};
2069 use std::sync::Arc;
2070
2071 struct DummyService;
2073
2074 #[async_trait]
2075 impl BackgroundService<tauri::Wry> for DummyService {
2076 async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
2077 Ok(())
2078 }
2079
2080 async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
2081 Ok(())
2082 }
2083 }
2084
2085 #[test]
2088 fn service_manager_handle_constructs() {
2089 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
2090 let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
2091 }
2092
2093 #[test]
2094 fn factory_produces_boxed_service() {
2095 let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
2096 let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
2097 }
2098
2099 #[test]
2100 fn handle_factory_creates_fresh_instances() {
2101 let count = Arc::new(AtomicUsize::new(0));
2102 let count_clone = count.clone();
2103
2104 let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
2105 count_clone.fetch_add(1, Ordering::SeqCst);
2106 Box::new(DummyService)
2107 });
2108
2109 let _ = (factory)();
2110 let _ = (factory)();
2111
2112 assert_eq!(count.load(Ordering::SeqCst), 2);
2113 }
2114
2115 #[allow(dead_code)]
2119 fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
2120 factory: F,
2121 ) -> TauriPlugin<R, PluginConfig>
2122 where
2123 S: BackgroundService<R>,
2124 F: Fn() -> S + Send + Sync + 'static,
2125 {
2126 init_with_service(factory)
2127 }
2128
2129 #[allow(dead_code)]
2131 async fn start_command_signature<R: Runtime>(
2132 app: AppHandle<R>,
2133 config: StartConfig,
2134 ) -> Result<(), String> {
2135 start(app, config).await
2136 }
2137
2138 #[allow(dead_code)]
2140 async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
2141 stop(app).await
2142 }
2143
2144 #[allow(dead_code)]
2146 async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
2147 is_running(app).await
2148 }
2149
2150 #[allow(dead_code)]
2152 async fn get_service_state_command_signature<R: Runtime>(
2153 app: AppHandle<R>,
2154 ) -> Result<models::ServiceStatus, String> {
2155 get_service_state(app).await
2156 }
2157
2158 #[allow(dead_code)]
2160 async fn get_scheduling_status_command_signature<R: Runtime>(
2161 app: AppHandle<R>,
2162 ) -> Result<models::IOSSchedulingStatus, String> {
2163 get_scheduling_status(app).await
2164 }
2165
2166 #[allow(dead_code)]
2168 async fn get_desired_state_status_command_signature<R: Runtime>(
2169 app: AppHandle<R>,
2170 ) -> Result<models::IOSDesiredStateStatus, String> {
2171 get_desired_state_status(app).await
2172 }
2173
2174 #[allow(dead_code)]
2176 async fn get_pending_bg_task_command_signature<R: Runtime>(
2177 app: AppHandle<R>,
2178 ) -> Result<Option<models::PendingTaskInfo>, String> {
2179 get_pending_bg_task(app).await
2180 }
2181
2182 #[allow(dead_code)]
2184 async fn get_notification_permission_status_command_signature<R: Runtime>(
2185 app: AppHandle<R>,
2186 ) -> Result<models::NotificationPermissionStatus, String> {
2187 get_notification_permission_status(app).await
2188 }
2189
2190 #[allow(dead_code)]
2192 async fn request_notification_permission_command_signature<R: Runtime>(
2193 app: AppHandle<R>,
2194 ) -> Result<models::NotificationPermissionStatus, String> {
2195 request_notification_permission(app).await
2196 }
2197
2198 #[allow(dead_code)]
2202 async fn can_use_full_screen_intent_command_signature<R: Runtime>(
2203 app: AppHandle<R>,
2204 ) -> Result<serde_json::Value, String> {
2205 can_use_full_screen_intent(app).await
2206 }
2207
2208 #[allow(dead_code)]
2210 async fn open_full_screen_intent_settings_command_signature<R: Runtime>(
2211 app: AppHandle<R>,
2212 ) -> Result<(), String> {
2213 open_full_screen_intent_settings(app).await
2214 }
2215
2216 #[allow(dead_code)]
2218 async fn enable_auto_restart_command_signature<R: Runtime>(
2219 app: AppHandle<R>,
2220 config: Option<StartConfig>,
2221 ) -> Result<(), String> {
2222 enable_auto_restart(app, config).await
2223 }
2224
2225 #[allow(dead_code)]
2227 async fn disable_auto_restart_command_signature<R: Runtime>(
2228 app: AppHandle<R>,
2229 ) -> Result<(), String> {
2230 disable_auto_restart(app).await
2231 }
2232
2233 #[allow(dead_code)]
2235 async fn get_desired_service_state_command_signature<R: Runtime>(
2236 app: AppHandle<R>,
2237 ) -> Result<Option<desired_state::DesiredState>, String> {
2238 get_desired_service_state(app).await
2239 }
2240
2241 #[allow(dead_code)]
2243 async fn validate_setup_command_signature<R: Runtime>(
2244 app: AppHandle<R>,
2245 ) -> Result<models::SetupValidationReport, String> {
2246 validate_setup(app).await
2247 }
2248
2249 #[allow(dead_code)]
2251 async fn native_lifecycle_event_command_signature<R: Runtime>(
2252 app: AppHandle<R>,
2253 event: models::NativeLifecycleEvent,
2254 ) -> Result<(), String> {
2255 native_lifecycle_event(app, event).await
2256 }
2257
2258 #[allow(dead_code)]
2260 async fn get_lifecycle_status_command_signature<R: Runtime>(
2261 app: AppHandle<R>,
2262 ) -> Result<models::LifecycleStatus, String> {
2263 get_lifecycle_status(app).await
2264 }
2265
2266 #[allow(dead_code)]
2268 async fn configure_recovery_command_signature<R: Runtime>(
2269 app: AppHandle<R>,
2270 enabled: bool,
2271 config: Option<StartConfig>,
2272 ) -> Result<(), String> {
2273 configure_recovery(app, enabled, config).await
2274 }
2275
2276 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
2280 #[tokio::test]
2281 async fn desktop_ipc_state_with_persistent_client() {
2282 use desktop::ipc_client::PersistentIpcClientHandle;
2283 let app = tauri::test::mock_app();
2284 let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
2285 let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
2286 let _state = DesktopIpcState { client };
2289 }
2290
2291 #[cfg(all(feature = "desktop-service", any(unix, windows), not(mobile)))]
2295 #[tokio::test]
2296 async fn os_service_mode_constructs_ipc_state() {
2297 let app = tauri::test::mock_app();
2298 let handle = app.handle();
2299 let config = PluginConfig {
2300 desktop_service_mode: "osService".into(),
2301 ..Default::default()
2302 };
2303 handle.manage(config.clone());
2305
2306 setup_os_service_ipc(handle, &config).expect("osService IPC setup should succeed");
2307
2308 assert!(
2309 handle.try_state::<DesktopIpcState>().is_some(),
2310 "DesktopIpcState must be managed in osService mode"
2311 );
2312 }
2313
2314 #[cfg(feature = "desktop-service")]
2321 #[test]
2322 fn bgs12_no_autoprovision_without_consent() {
2323 let temp = tempfile::tempdir().unwrap();
2324 let dir = temp.path();
2325
2326 assert!(
2329 !should_auto_provision(dir, true),
2330 "consent off (no record): must NOT auto-provision even if start_if_missing=true"
2331 );
2332
2333 std::fs::write(
2335 dir.join(DESKTOP_CONSENT_FILENAME),
2336 serde_json::json!({"enabled": true, "auto_unlock": true, "updated_at": 1}).to_string(),
2337 )
2338 .unwrap();
2339 assert!(
2340 should_auto_provision(dir, true),
2341 "consent on + start_if_missing: auto-provision allowed"
2342 );
2343
2344 assert!(
2347 !should_auto_provision(dir, false),
2348 "start_if_missing=false: must NOT auto-provision"
2349 );
2350 }
2351
2352 #[cfg(feature = "desktop-service")]
2357 #[test]
2358 fn bgs12_provisioning_gates_on_enabled_not_auto_unlock() {
2359 let temp = tempfile::tempdir().unwrap();
2360 let dir = temp.path();
2361 std::fs::write(
2363 dir.join(DESKTOP_CONSENT_FILENAME),
2364 serde_json::json!({"enabled": true, "auto_unlock": false}).to_string(),
2365 )
2366 .unwrap();
2367 assert!(
2368 should_auto_provision(dir, true),
2369 "enabled alone (service consent) must allow provisioning"
2370 );
2371
2372 std::fs::write(dir.join(DESKTOP_CONSENT_FILENAME), b"not json {{{").unwrap();
2374 assert!(
2375 !should_auto_provision(dir, true),
2376 "corrupt consent record must default off (no provisioning)"
2377 );
2378 }
2379
2380 #[cfg(feature = "desktop-service")]
2385 #[test]
2386 fn bgs12_setup_os_service_ipc_wires_consent_gate() {
2387 let src = include_str!("lib.rs");
2388 let call = ["should_auto", "_provision("].concat();
2389 assert!(
2390 src.contains(&call[..]),
2391 "setup_os_service_ipc must call the consent-gate helper before spawning auto-provision"
2392 );
2393 let skip = ["OS-service auto-", "provision skipped"].concat();
2397 assert!(
2398 src.contains(&skip[..]),
2399 "setup_os_service_ipc must skip the spawn when consent is off (the else branch)"
2400 );
2401 }
2402
2403 #[test]
2418 fn bgs21_notification_permission_bridge_registered_and_wired() {
2419 let build_rs = include_str!("../build.rs");
2421 assert!(
2422 build_rs.contains("\"get_notification_permission_status\""),
2423 "get_notification_permission_status must be listed in build.rs COMMANDS"
2424 );
2425 assert!(
2426 build_rs.contains("\"request_notification_permission\""),
2427 "request_notification_permission must be listed in build.rs COMMANDS"
2428 );
2429
2430 let src = include_str!("lib.rs");
2433 let get_reg = ["get_notification_permission", "_status,"].concat();
2434 let req_reg = ["request_notification_permiss", "ion,"].concat();
2435 assert!(
2436 src.contains(&get_reg[..]),
2437 "get_notification_permission_status must be registered in generate_handler!"
2438 );
2439 assert!(
2440 src.contains(&req_reg[..]),
2441 "request_notification_permission must be registered in generate_handler!"
2442 );
2443
2444 let mobile_rs = include_str!("mobile.rs");
2447 assert!(
2448 mobile_rs.contains("\"getNotificationPermissionStatus\""),
2449 "mobile.rs must bridge getNotificationPermissionStatus via run_mobile_plugin"
2450 );
2451 assert!(
2452 mobile_rs.contains("\"requestNotificationPermission\""),
2453 "mobile.rs must bridge requestNotificationPermission via run_mobile_plugin"
2454 );
2455 }
2456
2457 #[test]
2473 fn bgs22_battery_exemption_bridge_registered_and_wired() {
2474 let build_rs = include_str!("../build.rs");
2476 assert!(
2477 build_rs.contains("\"request_battery_exemption\""),
2478 "request_battery_exemption must be listed in build.rs COMMANDS"
2479 );
2480
2481 let src = include_str!("lib.rs");
2484 let reg = ["request_battery_exempt", "ion,"].concat();
2485 assert!(
2486 src.contains(®[..]),
2487 "request_battery_exemption must be registered in generate_handler!"
2488 );
2489
2490 let mobile_rs = include_str!("mobile.rs");
2493 assert!(
2494 mobile_rs.contains("\"requestBatteryExemption\""),
2495 "mobile.rs must bridge requestBatteryExemption via run_mobile_plugin"
2496 );
2497
2498 let default_toml = include_str!("../permissions/default.toml");
2503 assert!(
2504 default_toml.contains("\"allow-request-battery-exemption\""),
2505 "allow-request-battery-exemption must be in permissions/default.toml"
2506 );
2507 let cmd_toml =
2508 include_str!("../permissions/autogenerated/commands/request_battery_exemption.toml");
2509 assert!(
2510 cmd_toml.contains("\"request_battery_exemption\""),
2511 "permissions/autogenerated/commands/request_battery_exemption.toml must allow request_battery_exemption"
2512 );
2513 }
2514
2515 #[cfg(feature = "desktop-service")]
2519 #[allow(dead_code)]
2520 async fn install_service_command_signature<R: Runtime>(
2521 app: AppHandle<R>,
2522 ) -> Result<(), String> {
2523 install_service(app).await
2524 }
2525
2526 #[cfg(feature = "desktop-service")]
2528 #[allow(dead_code)]
2529 async fn uninstall_service_command_signature<R: Runtime>(
2530 app: AppHandle<R>,
2531 ) -> Result<(), String> {
2532 uninstall_service(app).await
2533 }
2534
2535 #[cfg(feature = "desktop-service")]
2537 #[allow(dead_code)]
2538 async fn start_os_service_command_signature<R: Runtime>(
2539 app: AppHandle<R>,
2540 ) -> Result<(), String> {
2541 start_os_service(app).await
2542 }
2543
2544 #[cfg(feature = "desktop-service")]
2546 #[allow(dead_code)]
2547 async fn stop_os_service_command_signature<R: Runtime>(
2548 app: AppHandle<R>,
2549 ) -> Result<(), String> {
2550 stop_os_service(app).await
2551 }
2552
2553 #[cfg(feature = "desktop-service")]
2555 #[allow(dead_code)]
2556 async fn restart_os_service_command_signature<R: Runtime>(
2557 app: AppHandle<R>,
2558 ) -> Result<(), String> {
2559 restart_os_service(app).await
2560 }
2561
2562 #[cfg(feature = "desktop-service")]
2564 #[allow(dead_code)]
2565 async fn get_os_service_status_command_signature<R: Runtime>(
2566 app: AppHandle<R>,
2567 ) -> Result<models::OsServiceStatus, String> {
2568 get_os_service_status(app).await
2569 }
2570
2571 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
2576 #[test]
2577 fn build_os_service_status_populates_fields() {
2578 let status = build_os_service_status(
2579 "com.example.bg-service",
2580 true,
2581 Some("/tmp/test.sock".to_string()),
2582 None,
2583 );
2584 assert_eq!(status.label, "com.example.bg-service");
2585 assert!(status.ipc_connected);
2586 assert_eq!(status.socket_path.as_deref(), Some("/tmp/test.sock"));
2587 assert!(status.last_error.is_none());
2588 }
2589
2590 #[cfg(all(feature = "desktop-service", any(unix, windows)))]
2592 #[test]
2593 fn build_os_service_status_mode_is_correct() {
2594 let status = build_os_service_status("test", false, None, None);
2595 #[cfg(target_os = "linux")]
2596 assert_eq!(status.mode, "systemd");
2597 #[cfg(target_os = "macos")]
2598 assert_eq!(status.mode, "launchd");
2599 #[cfg(windows)]
2600 assert_eq!(status.mode, "scm");
2601 }
2602
2603 #[allow(dead_code)]
2609 fn on_event_shutdown_closure_type_checks<R: Runtime>(_app: &AppHandle<R>) {
2610 let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
2611 if let tauri::RunEvent::Exit = event {
2612 let manager = _app.state::<ServiceManagerHandle<R>>();
2613 if let Err(_e) = manager.stop_blocking() {
2614 log::warn!("bg service shutdown on exit failed: {_e}");
2615 }
2616 }
2617 };
2618 }
2619
2620 use crate::manager::ManagerCommand;
2623 use std::sync::atomic::AtomicBool;
2624
2625 fn spawn_stop_drain(
2628 mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
2629 ) -> tokio::sync::oneshot::Receiver<Option<crate::models::StopReason>> {
2630 let (seen_tx, seen_rx) =
2631 tokio::sync::oneshot::channel::<Option<crate::models::StopReason>>();
2632 tokio::spawn(async move {
2633 let result =
2634 tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
2635 match result {
2636 Ok(Some(ManagerCommand::StopWithReason { reason, reply })) => {
2637 let _ = reply.send(Ok(()));
2638 let _ = seen_tx.send(Some(reason));
2639 }
2640 _ => {
2641 let _ = seen_tx.send(None);
2642 }
2643 }
2644 });
2645 seen_rx
2646 }
2647
2648 #[tokio::test]
2649 async fn cancel_listener_resolved_invoke_sends_stop_with_reason() {
2650 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
2651 let seen = spawn_stop_drain(cmd_rx);
2652
2653 let stop_sent = run_cancel_listener(
2655 Box::new(|| Ok(())),
2656 Box::new(|| {}),
2657 cmd_tx,
2658 5, )
2660 .await;
2661
2662 assert!(stop_sent, "resolved invoke should return true");
2663 let reason = seen.await.unwrap();
2664 assert_eq!(
2665 reason,
2666 Some(crate::models::StopReason::PlatformExpiration),
2667 "StopWithReason(PlatformExpiration) should be sent on resolved invoke"
2668 );
2669 }
2670
2671 #[tokio::test]
2672 async fn cancel_listener_rejected_invoke_no_stop() {
2673 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
2674 let seen = spawn_stop_drain(cmd_rx);
2675
2676 let stop_sent = run_cancel_listener(
2678 Box::new(|| Err(ServiceError::Platform("rejected".into()))),
2679 Box::new(|| {}),
2680 cmd_tx,
2681 5,
2682 )
2683 .await;
2684
2685 assert!(!stop_sent, "rejected invoke should return false");
2686 assert_eq!(
2687 seen.await.unwrap(),
2688 None,
2689 "StopWithReason should NOT be sent on rejected invoke"
2690 );
2691 }
2692
2693 #[tokio::test]
2694 async fn cancel_listener_timeout_sends_stop_with_reason() {
2695 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
2696 let cancel_called = Arc::new(AtomicBool::new(false));
2697 let cancel_called_clone = cancel_called.clone();
2698 let seen = spawn_stop_drain(cmd_rx);
2699
2700 let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
2703
2704 let stop_sent = run_cancel_listener(
2705 Box::new(move || {
2706 let _ = unblock_rx.recv();
2708 Ok(())
2709 }),
2710 Box::new(move || {
2711 cancel_called_clone.store(true, Ordering::SeqCst);
2712 let _ = unblock_tx.send(());
2713 }),
2714 cmd_tx,
2715 0, )
2717 .await;
2718
2719 assert!(stop_sent, "timeout should return true");
2720 assert!(
2721 cancel_called.load(Ordering::SeqCst),
2722 "cancel_fn should be called on timeout"
2723 );
2724 let reason = seen.await.unwrap();
2725 assert_eq!(
2726 reason,
2727 Some(crate::models::StopReason::PlatformTimeout),
2728 "StopWithReason(PlatformTimeout) should be sent on timeout"
2729 );
2730 }
2731
2732 #[tokio::test]
2733 async fn cancel_listener_join_error_no_stop() {
2734 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
2735 let seen = spawn_stop_drain(cmd_rx);
2736
2737 let stop_sent = run_cancel_listener(
2739 Box::new(|| panic!("simulated panic in wait_for_cancel")),
2740 Box::new(|| {}),
2741 cmd_tx,
2742 5,
2743 )
2744 .await;
2745
2746 assert!(!stop_sent, "join error should return false (no stop sent)");
2748 assert_eq!(
2749 seen.await.unwrap(),
2750 None,
2751 "StopWithReason should NOT be sent on join error"
2752 );
2753 }
2754
2755 fn spawn_start_drain(
2761 mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
2762 reply_with: Result<(), ServiceError>,
2763 ) -> tokio::sync::oneshot::Receiver<bool> {
2764 let (seen_tx, seen_rx) = tokio::sync::oneshot::channel::<bool>();
2765 tokio::spawn(async move {
2766 let result =
2767 tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
2768 match result {
2769 Ok(Some(ManagerCommand::Start { reply, .. })) => {
2770 let _ = reply.send(reply_with);
2771 let _ = seen_tx.send(true);
2772 }
2773 _ => {
2774 let _ = seen_tx.send(false);
2775 }
2776 }
2777 });
2778 seen_rx
2779 }
2780
2781 #[tokio::test]
2784 async fn auto_start_success_consumes_pending_exactly_once() {
2785 let app = tauri::test::mock_app();
2786 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
2787 let seen = spawn_start_drain(cmd_rx, Ok(()));
2788
2789 let cleared = Arc::new(AtomicUsize::new(0));
2790 let failed = Arc::new(AtomicUsize::new(0));
2791 let cleared_c = cleared.clone();
2792 let failed_c = failed.clone();
2793
2794 let started = run_auto_start(
2795 StartConfig::default(),
2796 app.handle().clone(),
2797 cmd_tx,
2798 Box::new(move || {
2799 cleared_c.fetch_add(1, Ordering::SeqCst);
2800 }),
2801 Box::new(move || {
2802 failed_c.fetch_add(1, Ordering::SeqCst);
2803 }),
2804 )
2805 .await;
2806
2807 assert!(started, "successful Start should return true");
2808 assert!(seen.await.unwrap(), "Start command should be received");
2809 assert_eq!(
2810 cleared.load(Ordering::SeqCst),
2811 1,
2812 "pending must be consumed exactly once on success"
2813 );
2814 assert_eq!(
2815 failed.load(Ordering::SeqCst),
2816 0,
2817 "no failure marker on success"
2818 );
2819 }
2820
2821 #[tokio::test]
2824 async fn auto_start_failure_preserves_pending_and_marks_failure() {
2825 let app = tauri::test::mock_app();
2826 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
2827 let seen = spawn_start_drain(
2828 cmd_rx,
2829 Err(ServiceError::Platform("forced start failure".into())),
2830 );
2831
2832 let cleared = Arc::new(AtomicUsize::new(0));
2833 let failed = Arc::new(AtomicUsize::new(0));
2834 let cleared_c = cleared.clone();
2835 let failed_c = failed.clone();
2836
2837 let started = run_auto_start(
2838 StartConfig::default(),
2839 app.handle().clone(),
2840 cmd_tx,
2841 Box::new(move || {
2842 cleared_c.fetch_add(1, Ordering::SeqCst);
2843 }),
2844 Box::new(move || {
2845 failed_c.fetch_add(1, Ordering::SeqCst);
2846 }),
2847 )
2848 .await;
2849
2850 assert!(!started, "failed Start should return false");
2851 assert!(seen.await.unwrap(), "Start command should be received");
2852 assert_eq!(
2853 cleared.load(Ordering::SeqCst),
2854 0,
2855 "pending must be PRESERVED on failure (clear not called)"
2856 );
2857 assert_eq!(
2858 failed.load(Ordering::SeqCst),
2859 1,
2860 "failure marker must be recorded exactly once on failure"
2861 );
2862 }
2863
2864 #[tokio::test]
2867 async fn auto_start_channel_closed_preserves_pending() {
2868 let app = tauri::test::mock_app();
2869 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel::<ManagerCommand<_>>(16);
2870 drop(cmd_rx);
2872
2873 let cleared = Arc::new(AtomicUsize::new(0));
2874 let failed = Arc::new(AtomicUsize::new(0));
2875 let cleared_c = cleared.clone();
2876 let failed_c = failed.clone();
2877
2878 let started = run_auto_start(
2879 StartConfig::default(),
2880 app.handle().clone(),
2881 cmd_tx,
2882 Box::new(move || {
2883 cleared_c.fetch_add(1, Ordering::SeqCst);
2884 }),
2885 Box::new(move || {
2886 failed_c.fetch_add(1, Ordering::SeqCst);
2887 }),
2888 )
2889 .await;
2890
2891 assert!(!started, "closed channel should return false");
2892 assert_eq!(
2893 cleared.load(Ordering::SeqCst),
2894 0,
2895 "pending must be preserved when the command never sends"
2896 );
2897 assert_eq!(
2898 failed.load(Ordering::SeqCst),
2899 1,
2900 "failure marker recorded when the command channel is closed"
2901 );
2902 }
2903
2904 struct WarmBlockingService;
2915
2916 #[async_trait]
2917 impl BackgroundService<tauri::test::MockRuntime> for WarmBlockingService {
2918 async fn init(
2919 &mut self,
2920 _ctx: &ServiceContext<tauri::test::MockRuntime>,
2921 ) -> Result<(), ServiceError> {
2922 Ok(())
2923 }
2924 async fn run(
2925 &mut self,
2926 ctx: &ServiceContext<tauri::test::MockRuntime>,
2927 ) -> Result<(), ServiceError> {
2928 ctx.shutdown.cancelled().await;
2929 Ok(())
2930 }
2931 }
2932
2933 struct WarmQuickService;
2936
2937 #[async_trait]
2938 impl BackgroundService<tauri::test::MockRuntime> for WarmQuickService {
2939 async fn init(
2940 &mut self,
2941 _ctx: &ServiceContext<tauri::test::MockRuntime>,
2942 ) -> Result<(), ServiceError> {
2943 Ok(())
2944 }
2945 async fn run(
2946 &mut self,
2947 _ctx: &ServiceContext<tauri::test::MockRuntime>,
2948 ) -> Result<(), ServiceError> {
2949 Ok(())
2950 }
2951 }
2952
2953 fn spawn_real_manager(
2957 factory: crate::manager::ServiceFactory<tauri::test::MockRuntime>,
2958 ) -> tokio::sync::mpsc::Sender<ManagerCommand<tauri::test::MockRuntime>> {
2959 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
2960 tokio::spawn(manager_loop(
2961 cmd_rx,
2962 factory,
2963 28.0,
2964 0.0,
2965 15.0,
2966 15.0,
2967 false,
2968 false,
2969 4.0,
2970 None,
2971 vec!["remoteMessaging".into()],
2972 true,
2973 NotifierPolicy::default(),
2974 None,
2975 None,
2976 false,
2977 ));
2978 cmd_tx
2979 }
2980
2981 async fn warm_is_running(
2982 cmd_tx: &tokio::sync::mpsc::Sender<ManagerCommand<tauri::test::MockRuntime>>,
2983 ) -> bool {
2984 let (tx, rx) = tokio::sync::oneshot::channel();
2985 cmd_tx
2986 .send(ManagerCommand::IsRunning { reply: tx })
2987 .await
2988 .unwrap();
2989 rx.await.unwrap()
2990 }
2991
2992 fn noop_on_complete() -> OnCompleteCallback {
2993 Box::new(|_success| {})
2994 }
2995
2996 #[tokio::test]
3000 async fn warm_start_idle_starts_actor_and_consumes_pending() {
3001 let app = tauri::test::mock_app();
3002 let cmd_tx = spawn_real_manager(Box::new(|| Box::new(WarmBlockingService)));
3003
3004 let consumed = Arc::new(AtomicUsize::new(0));
3005 let failed = Arc::new(AtomicUsize::new(0));
3006 let consumed_c = consumed.clone();
3007 let failed_c = failed.clone();
3008
3009 let started = run_warm_start(
3010 StartConfig::default(),
3011 app.handle().clone(),
3012 cmd_tx.clone(),
3013 noop_on_complete(),
3014 Box::new(move || {
3015 consumed_c.fetch_add(1, Ordering::SeqCst);
3016 }),
3017 Box::new(move || {
3018 failed_c.fetch_add(1, Ordering::SeqCst);
3019 }),
3020 )
3021 .await;
3022
3023 assert!(
3024 started,
3025 "warm delivery to idle actor should start the service"
3026 );
3027 assert!(
3028 warm_is_running(&cmd_tx).await,
3029 "is_running should flip true after warm start"
3030 );
3031 assert_eq!(
3032 consumed.load(Ordering::SeqCst),
3033 1,
3034 "pending must be consumed exactly once on warm success"
3035 );
3036 assert_eq!(
3037 failed.load(Ordering::SeqCst),
3038 0,
3039 "no failure marker on success"
3040 );
3041 }
3042
3043 #[tokio::test]
3047 async fn warm_start_while_running_is_noop() {
3048 let app = tauri::test::mock_app();
3049 let cmd_tx = spawn_real_manager(Box::new(|| Box::new(WarmBlockingService)));
3050
3051 let consumed = Arc::new(AtomicUsize::new(0));
3052 let failed = Arc::new(AtomicUsize::new(0));
3053
3054 let consumed_1 = consumed.clone();
3056 let failed_1 = failed.clone();
3057 let first = run_warm_start(
3058 StartConfig::default(),
3059 app.handle().clone(),
3060 cmd_tx.clone(),
3061 noop_on_complete(),
3062 Box::new(move || {
3063 consumed_1.fetch_add(1, Ordering::SeqCst);
3064 }),
3065 Box::new(move || {
3066 failed_1.fetch_add(1, Ordering::SeqCst);
3067 }),
3068 )
3069 .await;
3070 assert!(first, "first warm delivery should start the service");
3071
3072 let consumed_2 = consumed.clone();
3074 let failed_2 = failed.clone();
3075 let second = run_warm_start(
3076 StartConfig::default(),
3077 app.handle().clone(),
3078 cmd_tx.clone(),
3079 noop_on_complete(),
3080 Box::new(move || {
3081 consumed_2.fetch_add(1, Ordering::SeqCst);
3082 }),
3083 Box::new(move || {
3084 failed_2.fetch_add(1, Ordering::SeqCst);
3085 }),
3086 )
3087 .await;
3088
3089 assert!(
3090 !second,
3091 "warm delivery while running should be a no-op (false)"
3092 );
3093 assert!(
3094 warm_is_running(&cmd_tx).await,
3095 "service should still be running after the no-op warm delivery"
3096 );
3097 assert_eq!(
3098 consumed.load(Ordering::SeqCst),
3099 1,
3100 "pending consumed exactly once across both deliveries"
3101 );
3102 assert_eq!(
3103 failed.load(Ordering::SeqCst),
3104 0,
3105 "a no-op warm delivery must NOT record a failure marker"
3106 );
3107 }
3108
3109 #[tokio::test]
3113 async fn warm_start_arms_captured_on_complete_callback() {
3114 let app = tauri::test::mock_app();
3115 let cmd_tx = spawn_real_manager(Box::new(|| Box::new(WarmQuickService)));
3116
3117 let fired = Arc::new(AtomicBool::new(false));
3118 let fired_cb = fired.clone();
3119 let on_complete: OnCompleteCallback = Box::new(move |success| {
3120 if success {
3121 fired_cb.store(true, Ordering::SeqCst);
3122 }
3123 });
3124
3125 let started = run_warm_start(
3126 StartConfig::default(),
3127 app.handle().clone(),
3128 cmd_tx.clone(),
3129 on_complete,
3130 Box::new(|| {}),
3131 Box::new(|| {}),
3132 )
3133 .await;
3134 assert!(started, "warm start should initiate the service");
3135
3136 let mut armed = false;
3138 for _ in 0..50 {
3139 if fired.load(Ordering::SeqCst) {
3140 armed = true;
3141 break;
3142 }
3143 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3144 }
3145 assert!(
3146 armed,
3147 "captured on_complete callback must fire after warm start (SetOnComplete re-sent)"
3148 );
3149 }
3150
3151 #[tokio::test]
3155 async fn warm_start_failure_preserves_pending_and_marks_failure() {
3156 let app = tauri::test::mock_app();
3157 let (cmd_tx, cmd_rx) =
3158 tokio::sync::mpsc::channel::<ManagerCommand<tauri::test::MockRuntime>>(16);
3159
3160 tokio::spawn(async move {
3162 let mut rx = cmd_rx;
3163 while let Ok(Some(cmd)) =
3164 tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()).await
3165 {
3166 match cmd {
3167 ManagerCommand::IsRunning { reply } => {
3168 let _ = reply.send(false);
3169 }
3170 ManagerCommand::SetOnComplete { .. } => {}
3171 ManagerCommand::Start { reply, .. } => {
3172 let _ = reply.send(Err(ServiceError::Platform("forced".into())));
3173 break;
3174 }
3175 _ => {}
3176 }
3177 }
3178 });
3179
3180 let consumed = Arc::new(AtomicUsize::new(0));
3181 let failed = Arc::new(AtomicUsize::new(0));
3182 let consumed_c = consumed.clone();
3183 let failed_c = failed.clone();
3184
3185 let started = run_warm_start(
3186 StartConfig::default(),
3187 app.handle().clone(),
3188 cmd_tx,
3189 noop_on_complete(),
3190 Box::new(move || {
3191 consumed_c.fetch_add(1, Ordering::SeqCst);
3192 }),
3193 Box::new(move || {
3194 failed_c.fetch_add(1, Ordering::SeqCst);
3195 }),
3196 )
3197 .await;
3198
3199 assert!(!started, "forced warm start failure should return false");
3200 assert_eq!(
3201 consumed.load(Ordering::SeqCst),
3202 0,
3203 "pending must be PRESERVED on genuine failure (clear not called)"
3204 );
3205 assert_eq!(
3206 failed.load(Ordering::SeqCst),
3207 1,
3208 "failure marker recorded exactly once on genuine failure"
3209 );
3210 }
3211
3212 #[cfg(all(feature = "desktop-service", unix))]
3218 mod ipc_auto_start_tests {
3219 use super::*;
3220 use crate::desktop::ipc_client::PersistentIpcClientHandle;
3221 use crate::desktop::test_helpers::setup_server;
3222 use std::time::Duration;
3223
3224 #[tokio::test]
3228 async fn wait_for_connected_timeout_returns_false() {
3229 let app = tauri::test::mock_app();
3230 let path = crate::desktop::test_helpers::unique_socket_path();
3231 let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
3232
3233 let connected = handle
3234 .wait_for_connected(Duration::from_millis(200))
3235 .await
3236 .unwrap();
3237 assert!(!connected, "should return false on timeout");
3238
3239 let _ = std::fs::remove_file(&path);
3240 }
3241
3242 #[tokio::test]
3245 async fn wait_for_connected_succeeds_with_server() {
3246 let (path, shutdown, _event_tx) = setup_server();
3247 let app = tauri::test::mock_app();
3248 let handle = PersistentIpcClientHandle::spawn(path, app.handle().clone());
3249
3250 let connected = handle
3251 .wait_for_connected(Duration::from_secs(5))
3252 .await
3253 .unwrap();
3254 assert!(connected, "should connect within timeout");
3255
3256 shutdown.cancel();
3257 }
3258
3259 #[tokio::test]
3262 async fn socket_path_accessor() {
3263 let app = tauri::test::mock_app();
3264 let path = crate::desktop::test_helpers::unique_socket_path();
3265 let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
3266 assert_eq!(
3267 handle.socket_path(),
3268 &path,
3269 "socket_path() should return the path passed to spawn"
3270 );
3271 let _ = std::fs::remove_file(&path);
3272 }
3273
3274 #[tokio::test]
3280 async fn start_disconnected_without_auto_start_returns_ipc_error() {
3281 let err = ServiceError::Ipc("ipcUnavailable".into());
3282 let msg = err.to_string();
3283 assert!(
3284 msg.contains("ipcUnavailable"),
3285 "error should contain 'ipcUnavailable': {msg}"
3286 );
3287 }
3288
3289 #[tokio::test]
3291 async fn start_timeout_error_includes_socket_path() {
3292 let socket = "/tmp/test-socket-path.sock";
3293 let err = ServiceError::Ipc(format!("ipcUnavailable: socket {socket}"));
3294 let msg = err.to_string();
3295 assert!(
3296 msg.contains(socket),
3297 "error should contain socket path: {msg}"
3298 );
3299 }
3300 }
3301}