tauri_plugin_background_service/
lib.rs1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/0.2.2")]
2
3pub mod error;
52pub mod manager;
53pub mod models;
54pub mod notifier;
55pub mod service_trait;
56
57#[cfg(mobile)]
58pub mod mobile;
59
60#[cfg(feature = "desktop-service")]
61pub mod desktop;
62
63pub use error::ServiceError;
66#[doc(hidden)]
67pub use manager::{manager_loop, OnCompleteCallback, ServiceFactory, ServiceManagerHandle};
68#[doc(hidden)]
69pub use models::AutoStartConfig;
70pub use models::{PluginConfig, PluginEvent, ServiceContext, StartConfig};
71pub use notifier::Notifier;
72pub use service_trait::BackgroundService;
73
74#[cfg(feature = "desktop-service")]
75pub use desktop::headless::headless_main;
76
77use tauri::{
80 plugin::{Builder, TauriPlugin},
81 AppHandle, Manager, Runtime,
82};
83
84use crate::manager::ManagerCommand;
85
86#[cfg(mobile)]
87use crate::manager::MobileKeepalive;
88
89#[cfg(mobile)]
90use mobile::MobileLifecycle;
91
92#[cfg(mobile)]
93use std::sync::Arc;
94
95#[cfg(target_os = "ios")]
100tauri::ios_plugin_binding!(init_plugin_background_service);
101
102#[cfg(target_os = "ios")]
109async fn ios_set_on_complete_callback<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
110 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
111 let mobile_handle = mobile.handle.clone();
112 let manager = app.state::<ServiceManagerHandle<R>>();
113
114 let mob_for_complete = MobileLifecycle {
115 handle: mobile_handle,
116 };
117 manager
118 .cmd_tx
119 .send(ManagerCommand::SetOnComplete {
120 callback: Box::new(move |success| {
121 let _ = mob_for_complete.complete_bg_task(success);
122 }),
123 })
124 .await
125 .map_err(|e| e.to_string())
126}
127
128#[cfg(not(target_os = "ios"))]
129async fn ios_set_on_complete_callback<R: Runtime>(_app: &AppHandle<R>) -> Result<(), String> {
130 Ok(())
131}
132
133#[cfg(target_os = "ios")]
138fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
139 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
140 let mobile_handle = mobile.handle.clone();
141 let manager = app.state::<ServiceManagerHandle<R>>();
142 let cmd_tx = manager.cmd_tx.clone();
143
144 tokio::spawn(async move {
145 let handle = tokio::task::spawn_blocking(move || {
146 let mob = MobileLifecycle {
147 handle: mobile_handle,
148 };
149 mob.wait_for_cancel()
150 });
151 let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), handle).await;
154 if let Ok(Ok(Ok(()))) = result {
155 let (tx, rx) = tokio::sync::oneshot::channel();
156 let _ = cmd_tx.send(ManagerCommand::Stop { reply: tx }).await;
157 let _ = rx.await;
158 }
159 });
160}
161
162#[cfg(not(target_os = "ios"))]
163fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
164
165#[tauri::command]
168async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
169 #[cfg(feature = "desktop-service")]
171 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
172 return ipc_state.client.start(config).await.map_err(|e| e.to_string());
173 }
174
175 ios_set_on_complete_callback(&app).await?;
178
179 let manager = app.state::<ServiceManagerHandle<R>>();
183 let (tx, rx) = tokio::sync::oneshot::channel();
184 manager
185 .cmd_tx
186 .send(ManagerCommand::Start {
187 config,
188 reply: tx,
189 app: app.clone(),
190 })
191 .await
192 .map_err(|e| e.to_string())?;
193
194 rx.await.map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
195
196 let plugin_config = app.state::<PluginConfig>();
198 ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
199
200 Ok(())
201}
202
203#[tauri::command]
204async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
205 #[cfg(feature = "desktop-service")]
207 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
208 return ipc_state.client.stop().await.map_err(|e| e.to_string());
209 }
210
211 let manager = app.state::<ServiceManagerHandle<R>>();
213 let (tx, rx) = tokio::sync::oneshot::channel();
214 manager
215 .cmd_tx
216 .send(ManagerCommand::Stop { reply: tx })
217 .await
218 .map_err(|e| e.to_string())?;
219
220 rx.await.map_err(|e| e.to_string())?.map_err(|e| e.to_string())
221}
222
223#[tauri::command]
224async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
225 #[cfg(feature = "desktop-service")]
227 if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
228 return ipc_state.client.is_running().await.unwrap_or(false);
229 }
230
231 let manager = app.state::<ServiceManagerHandle<R>>();
233 let (tx, rx) = tokio::sync::oneshot::channel();
234 if manager
235 .cmd_tx
236 .send(ManagerCommand::IsRunning { reply: tx })
237 .await
238 .is_err()
239 {
240 return false;
241 }
242 rx.await.unwrap_or(false)
243}
244
245#[cfg(feature = "desktop-service")]
252struct DesktopIpcState {
253 client: desktop::ipc_client::PersistentIpcClientHandle,
254}
255
256#[cfg(feature = "desktop-service")]
257#[tauri::command]
258async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
259 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
260 let plugin_config = app.state::<PluginConfig>();
261 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
262 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
263
264 if !exec_path.exists() {
266 return Err(format!(
267 "Current executable does not exist at {}: cannot install OS service",
268 exec_path.display()
269 ));
270 }
271
272 let validate_result = tokio::time::timeout(
276 std::time::Duration::from_secs(5),
277 tokio::process::Command::new(&exec_path)
278 .arg("--service-label")
279 .arg(&label)
280 .arg("--validate-service-install")
281 .output(),
282 )
283 .await;
284
285 match validate_result {
286 Ok(Ok(output)) => {
287 let stdout = String::from_utf8_lossy(&output.stdout);
288 if !stdout.trim().contains("ok") {
289 return Err(
290 "Binary does not handle --validate-service-install. \
291 Ensure headless_main() is called from your app's main()."
292 .into(),
293 );
294 }
295 }
296 Ok(Err(e)) => {
297 return Err(format!(
298 "Failed to validate executable for --service-label: {e}"
299 ));
300 }
301 Err(_) => {
302 log::warn!(
305 "Timeout validating --service-label support. \
306 Ensure your app's main() handles the --service-label argument \
307 and calls headless_main()."
308 );
309 }
310 }
311
312 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
313 mgr.install().map_err(|e| e.to_string())
314}
315
316#[cfg(feature = "desktop-service")]
317#[tauri::command]
318async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
319 use desktop::service_manager::{derive_service_label, DesktopServiceManager};
320 let plugin_config = app.state::<PluginConfig>();
321 let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
322 let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
323 let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
324 mgr.uninstall().map_err(|e| e.to_string())
325}
326
327pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
337where
338 R: Runtime,
339 S: BackgroundService<R>,
340 F: Fn() -> S + Send + Sync + 'static,
341{
342 let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
343
344 Builder::<R, PluginConfig>::new("background-service")
345 .invoke_handler(tauri::generate_handler![
346 start,
347 stop,
348 is_running,
349 #[cfg(feature = "desktop-service")]
350 install_service,
351 #[cfg(feature = "desktop-service")]
352 uninstall_service,
353 ])
354 .setup(move |app, api| {
355 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
356 #[cfg(mobile)]
357 let mobile_cmd_tx = cmd_tx.clone();
358 let handle = ServiceManagerHandle::new(cmd_tx);
359 app.manage(handle);
360
361 let config = api.config().clone();
362 app.manage(config.clone());
363
364 let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
365 let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
366
367 #[cfg(feature = "desktop-service")]
369 if config.desktop_service_mode == "osService" {
370 let label = desktop::service_manager::derive_service_label(
372 app,
373 config.desktop_service_label.as_deref(),
374 );
375 let socket_path = desktop::ipc::socket_path(&label)?;
376 let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
377 socket_path,
378 app.app_handle().clone(),
379 );
380 app.manage(DesktopIpcState { client });
381 } else {
382 let factory = boxed_factory;
384 tauri::async_runtime::spawn(manager_loop(
385 cmd_rx,
386 factory,
387 ios_safety_timeout_secs,
388 ios_processing_safety_timeout_secs,
389 ));
390 }
391
392 #[cfg(not(feature = "desktop-service"))]
393 {
394 let factory = boxed_factory;
395 tauri::async_runtime::spawn(manager_loop(
396 cmd_rx,
397 factory,
398 ios_safety_timeout_secs,
399 ios_processing_safety_timeout_secs,
400 ));
401 }
402
403 #[cfg(mobile)]
404 {
405 let lifecycle = mobile::init(app, api)?;
406 let lifecycle_arc = Arc::new(lifecycle);
407
408 let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
410 if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile { mobile: mobile_trait }) {
411 log::error!("Failed to send SetMobile command: {e}");
412 }
413
414 app.manage(lifecycle_arc);
416 }
417
418 #[cfg(target_os = "android")]
424 {
425 let mobile = app.state::<Arc<MobileLifecycle<R>>>();
426 if let Ok(Some(config)) = mobile.get_auto_start_config() {
427 let _ = mobile.clear_auto_start_config();
428
429 let manager = app.state::<ServiceManagerHandle<R>>();
433 let cmd_tx = manager.cmd_tx.clone();
434 let app_clone = app.app_handle().clone();
435
436 if let Err(e) = cmd_tx.try_send(ManagerCommand::SetOnComplete {
438 callback: Box::new(|_| {}),
439 }) {
440 log::error!("Failed to send SetOnComplete command: {e}");
441 }
442
443 tauri::async_runtime::spawn(async move {
444 let (tx, rx) = tokio::sync::oneshot::channel();
445 if cmd_tx
446 .send(ManagerCommand::Start {
447 config,
448 reply: tx,
449 app: app_clone,
450 })
451 .await
452 .is_err()
453 {
454 return;
455 }
456 let _ = rx.await;
457 });
458
459 let _ = mobile.move_task_to_background();
460 }
461 }
462
463 Ok(())
464 })
465 .build()
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use async_trait::async_trait;
472 use std::sync::atomic::{AtomicUsize, Ordering};
473 use std::sync::Arc;
474
475 struct DummyService;
477
478 #[async_trait]
479 impl BackgroundService<tauri::Wry> for DummyService {
480 async fn init(
481 &mut self,
482 _ctx: &ServiceContext<tauri::Wry>,
483 ) -> Result<(), ServiceError> {
484 Ok(())
485 }
486
487 async fn run(
488 &mut self,
489 _ctx: &ServiceContext<tauri::Wry>,
490 ) -> Result<(), ServiceError> {
491 Ok(())
492 }
493 }
494
495 #[test]
498 fn service_manager_handle_constructs() {
499 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
500 let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
501 }
502
503 #[test]
504 fn factory_produces_boxed_service() {
505 let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
506 let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
507 }
508
509 #[test]
510 fn handle_factory_creates_fresh_instances() {
511 let count = Arc::new(AtomicUsize::new(0));
512 let count_clone = count.clone();
513
514 let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
515 count_clone.fetch_add(1, Ordering::SeqCst);
516 Box::new(DummyService)
517 });
518
519 let _ = (factory)();
520 let _ = (factory)();
521
522 assert_eq!(count.load(Ordering::SeqCst), 2);
523 }
524
525 #[allow(dead_code)]
529 fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
530 where
531 S: BackgroundService<R>,
532 F: Fn() -> S + Send + Sync + 'static,
533 {
534 init_with_service(factory)
535 }
536
537 #[allow(dead_code)]
539 async fn start_command_signature<R: Runtime>(
540 app: AppHandle<R>,
541 config: StartConfig,
542 ) -> Result<(), String> {
543 start(app, config).await
544 }
545
546 #[allow(dead_code)]
548 async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
549 stop(app).await
550 }
551
552 #[allow(dead_code)]
554 async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
555 is_running(app).await
556 }
557
558 #[cfg(feature = "desktop-service")]
562 #[tokio::test]
563 async fn desktop_ipc_state_with_persistent_client() {
564 use desktop::ipc_client::PersistentIpcClientHandle;
565 let app = tauri::test::mock_app();
566 let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
567 let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
568 let _state = DesktopIpcState { client };
571 }
572
573 #[cfg(feature = "desktop-service")]
577 #[allow(dead_code)]
578 async fn install_service_command_signature<R: Runtime>(
579 app: AppHandle<R>,
580 ) -> Result<(), String> {
581 install_service(app).await
582 }
583
584 #[cfg(feature = "desktop-service")]
586 #[allow(dead_code)]
587 async fn uninstall_service_command_signature<R: Runtime>(
588 app: AppHandle<R>,
589 ) -> Result<(), String> {
590 uninstall_service(app).await
591 }
592
593}