#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/1.0.0")]
pub mod capabilities;
pub mod desired_state;
pub mod error;
pub mod manager;
pub mod models;
pub mod notifier;
pub mod service_trait;
pub mod validator;
#[cfg(mobile)]
pub mod mobile;
#[cfg(feature = "desktop-service")]
pub mod desktop;
pub use error::ServiceError;
#[doc(hidden)]
pub use manager::{manager_loop, OnCompleteCallback, ServiceFactory, ServiceManagerHandle};
pub use models::{
IOSSchedulingStatus, LifecycleState, LifecycleStatus, PendingTaskInfo, Platform,
PlatformCapabilities, PluginConfig, PluginEvent, ServiceContext, ServiceState, ServiceStatus,
SetupIssue, SetupValidationReport, StartConfig, ValidationIssue,
};
pub use notifier::{Notifier, NotifierPolicy, NotifySink};
pub use service_trait::BackgroundService;
#[cfg(all(feature = "desktop-service", unix))]
pub use desktop::headless::{headless_main, headless_main_with_desired_state};
use tauri::{
plugin::{Builder, TauriPlugin},
AppHandle, Manager, Runtime,
};
use crate::manager::ManagerCommand;
#[cfg(mobile)]
use crate::manager::MobileKeepalive;
#[cfg(mobile)]
use mobile::MobileLifecycle;
use std::sync::Arc;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_background_service);
#[cfg(target_os = "ios")]
async fn ios_set_on_complete_callback<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
let mobile_handle = mobile.handle.clone();
let manager = app.state::<ServiceManagerHandle<R>>();
let mob_for_complete = MobileLifecycle {
handle: mobile_handle,
};
manager
.cmd_tx
.send(ManagerCommand::SetOnComplete {
callback: Box::new(move |success| {
let _ = mob_for_complete.complete_bg_task(success);
}),
})
.await
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "ios"))]
async fn ios_set_on_complete_callback<R: Runtime>(_app: &AppHandle<R>) -> Result<(), String> {
Ok(())
}
#[allow(dead_code)] async fn run_cancel_listener<R: Runtime>(
wait_fn: Box<dyn FnOnce() -> Result<(), ServiceError> + Send>,
cancel_fn: Box<dyn FnOnce() + Send>,
cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
timeout_secs: u64,
) -> bool {
let handle = tokio::task::spawn_blocking(wait_fn);
let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), handle).await;
match result {
Ok(Ok(Ok(()))) => {
let (tx, rx) = tokio::sync::oneshot::channel();
let _ = cmd_tx
.send(ManagerCommand::StopWithReason {
reason: crate::models::StopReason::PlatformExpiration,
reply: tx,
})
.await;
let _ = rx.await;
true
}
Err(_) => {
cancel_fn();
let (tx, rx) = tokio::sync::oneshot::channel();
let _ = cmd_tx
.send(ManagerCommand::StopWithReason {
reason: crate::models::StopReason::PlatformTimeout,
reply: tx,
})
.await;
let _ = rx.await;
true
}
_ => false,
}
}
#[cfg(target_os = "ios")]
fn ios_spawn_cancel_listener<R: Runtime>(app: &AppHandle<R>, timeout_secs: u64) {
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
let mobile_handle = mobile.handle.clone();
let mobile_handle_for_cancel = mobile.handle.clone();
let manager = app.state::<ServiceManagerHandle<R>>();
let cmd_tx = manager.cmd_tx.clone();
tokio::spawn(async move {
let wait_fn = Box::new(move || {
let mob = MobileLifecycle {
handle: mobile_handle,
};
mob.wait_for_cancel()
});
let cancel_fn = Box::new(move || {
let cancel_mob = MobileLifecycle {
handle: mobile_handle_for_cancel,
};
let _ = cancel_mob.cancel_cancel_listener();
});
let _ = run_cancel_listener(wait_fn, cancel_fn, cmd_tx, timeout_secs).await;
});
}
#[cfg(not(target_os = "ios"))]
fn ios_spawn_cancel_listener<R: Runtime>(_app: &AppHandle<R>, _timeout_secs: u64) {}
#[cfg(target_os = "ios")]
fn ios_spawn_cold_auto_start<R: Runtime>(app: &AppHandle<R>) {
let app = app.app_handle().clone();
tauri::async_runtime::spawn(async move {
ios_handle_cold_auto_start(&app).await;
});
}
#[cfg(target_os = "ios")]
async fn ios_handle_cold_auto_start<R: Runtime>(app: &AppHandle<R>) {
let mobile = app.state::<Arc<MobileLifecycle<R>>>().inner().clone();
let pending = match tokio::task::spawn_blocking({
let mobile = mobile.clone();
move || mobile.get_pending_bg_task()
})
.await
{
Ok(Ok(Some(pending))) => pending,
Ok(Ok(None)) => {
return;
}
Ok(Err(e)) => {
log::warn!("iOS: failed to get pending BGTask: {e}");
return;
}
Err(e) => {
log::warn!("iOS: failed to join pending BGTask query: {e}");
return;
}
};
let _ = pending;
let should_start = match tokio::task::spawn_blocking({
let mobile = mobile.clone();
move || mobile.get_desired_state_status()
})
.await
{
Ok(Ok(status)) => status.and_then(|status| {
let config_str = status.last_start_config?;
Some((status.desired_running, config_str))
}),
Ok(Err(e)) => {
log::warn!("iOS: failed to get desired-state status: {e}");
None
}
Err(e) => {
log::warn!("iOS: failed to join desired-state query: {e}");
None
}
};
let Some((true, config_str)) = should_start else {
log::info!(
"iOS: skipped auto-start: desired_running=false — clearing stale pending BGTask"
);
let _ = tokio::task::spawn_blocking({
let mobile = mobile.clone();
move || mobile.clear_pending_bg_task()
})
.await;
return;
};
let Ok(config) = serde_json::from_str::<StartConfig>(&config_str) else {
log::warn!(
"iOS: failed to parse stored start config — preserving pending task info for diagnostics"
);
return;
};
let manager = app.state::<ServiceManagerHandle<R>>();
let cmd_tx = manager.cmd_tx.clone();
let app_clone = app.app_handle().clone();
let timeout_secs = app.state::<PluginConfig>().ios_cancel_listener_timeout_secs;
let mob_handle = mobile.handle.clone();
if let Err(e) = cmd_tx
.send(ManagerCommand::SetOnComplete {
callback: Box::new(move |success| {
let ml = MobileLifecycle {
handle: mob_handle.clone(),
};
let _ = ml.complete_bg_task(success);
}),
})
.await
{
log::warn!("iOS: auto-start preserved pending BGTask after failure: {e}");
let _ = tokio::task::spawn_blocking(move || mobile.record_failed_pending()).await;
return;
}
let mobile_for_success = mobile.clone();
let mobile_for_failure = mobile.clone();
let app_for_listener = app_clone.clone();
log::info!("iOS: auto-starting service for pending BGTask");
let on_success = Box::new(move || {
let _ = mobile_for_success.clear_pending_bg_task();
ios_spawn_cancel_listener(&app_for_listener, timeout_secs);
});
let on_failure = Box::new(move || {
let _ = mobile_for_failure.record_failed_pending();
});
run_auto_start(config, app_clone, cmd_tx, on_success, on_failure).await;
}
#[cfg(target_os = "ios")]
fn ios_spawn_warm_listener<R: Runtime>(app: &AppHandle<R>) {
let app = app.app_handle().clone();
tauri::async_runtime::spawn(async move {
loop {
let mobile_handle = app.state::<Arc<MobileLifecycle<R>>>().handle.clone();
let wait = tokio::task::spawn_blocking(move || {
MobileLifecycle {
handle: mobile_handle,
}
.wait_for_bg_task()
})
.await;
match wait {
Ok(Ok(())) => {
ios_handle_warm_delivery(&app).await;
}
_ => {
log::info!("iOS: warm BGTask listener stopped");
break;
}
}
}
});
}
#[cfg(target_os = "ios")]
async fn ios_handle_warm_delivery<R: Runtime>(app: &AppHandle<R>) {
let mobile = app.state::<Arc<MobileLifecycle<R>>>().inner().clone();
let pending = match mobile.get_pending_bg_task() {
Ok(Some(p)) => p,
Ok(None) => {
log::debug!("iOS: warm delivery signalled with no pending BGTask");
return;
}
Err(e) => {
log::warn!("iOS: warm delivery — failed to get pending BGTask: {e}");
return;
}
};
let _ = pending;
let should_start = mobile
.get_desired_state_status()
.ok()
.flatten()
.and_then(|status| {
let config_str = status.last_start_config?;
Some((status.desired_running, config_str))
});
let Some((true, config_str)) = should_start else {
log::info!("iOS: warm delivery skipped: desired_running=false");
return;
};
let Ok(config) = serde_json::from_str::<StartConfig>(&config_str) else {
log::warn!(
"iOS: warm delivery — failed to parse stored start config; preserving pending task info"
);
return;
};
let manager = app.state::<ServiceManagerHandle<R>>();
let cmd_tx = manager.cmd_tx.clone();
let app_clone = app.app_handle().clone();
let timeout_secs = app.state::<PluginConfig>().ios_cancel_listener_timeout_secs;
let mob_handle = mobile.handle.clone();
let on_complete: OnCompleteCallback = Box::new(move |success| {
let ml = MobileLifecycle {
handle: mob_handle.clone(),
};
let _ = ml.complete_bg_task(success);
});
let mobile_for_success = mobile.clone();
let mobile_for_failure = mobile.clone();
let app_for_listener = app_clone.clone();
let on_success = Box::new(move || {
let _ = mobile_for_success.clear_pending_bg_task();
ios_spawn_cancel_listener(&app_for_listener, timeout_secs);
});
let on_failure = Box::new(move || {
let _ = mobile_for_failure.record_failed_pending();
});
log::info!("iOS: warm-starting service for delivered BGTask");
run_warm_start(
config,
app_clone,
cmd_tx,
on_complete,
on_success,
on_failure,
)
.await;
}
#[cfg(not(target_os = "ios"))]
#[allow(dead_code)]
fn ios_spawn_warm_listener<R: Runtime>(_app: &AppHandle<R>) {}
#[allow(dead_code)] async fn run_auto_start<R: Runtime>(
config: StartConfig,
app: AppHandle<R>,
cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
on_success: Box<dyn FnOnce() + Send>,
on_failure: Box<dyn FnOnce() + Send>,
) -> bool {
let (tx, rx) = tokio::sync::oneshot::channel();
if cmd_tx
.send(ManagerCommand::Start {
config,
reply: tx,
app,
})
.await
.is_err()
{
log::warn!(
"iOS: auto-start preserved pending BGTask after failure (command channel closed)"
);
on_failure();
return false;
}
match rx.await {
Ok(Ok(())) => {
log::info!("iOS: auto-start consumed pending BGTask after success");
on_success();
true
}
Ok(Err(e)) => {
log::warn!("iOS: auto-start preserved pending BGTask after failure: {e}");
on_failure();
false
}
Err(e) => {
log::warn!(
"iOS: auto-start preserved pending BGTask after failure (reply dropped: {e})"
);
on_failure();
false
}
}
}
#[allow(dead_code)] async fn run_warm_start<R: Runtime>(
config: StartConfig,
app: AppHandle<R>,
cmd_tx: tokio::sync::mpsc::Sender<ManagerCommand<R>>,
on_complete: OnCompleteCallback,
on_success: Box<dyn FnOnce() + Send>,
on_failure: Box<dyn FnOnce() + Send>,
) -> bool {
let (run_tx, run_rx) = tokio::sync::oneshot::channel();
if cmd_tx
.send(ManagerCommand::IsRunning { reply: run_tx })
.await
.is_err()
{
log::warn!(
"iOS: warm start preserved pending BGTask after failure (command channel closed)"
);
on_failure();
return false;
}
if run_rx.await.unwrap_or(false) {
log::info!("iOS: warm BGTask delivery while already running — no-op");
return false;
}
if cmd_tx
.send(ManagerCommand::SetOnComplete {
callback: on_complete,
})
.await
.is_err()
{
log::warn!("iOS: warm start preserved pending BGTask after failure (channel closed)");
on_failure();
return false;
}
let (tx, rx) = tokio::sync::oneshot::channel();
if cmd_tx
.send(ManagerCommand::Start {
config,
reply: tx,
app,
})
.await
.is_err()
{
log::warn!(
"iOS: warm start preserved pending BGTask after failure (command channel closed)"
);
on_failure();
return false;
}
match rx.await {
Ok(Ok(())) => {
log::info!("iOS: warm BGTask delivery started service; consumed pending BGTask");
on_success();
true
}
Ok(Err(ServiceError::AlreadyRunning)) => {
log::info!("iOS: warm BGTask delivery raced a running actor — no-op");
false
}
Ok(Err(e)) => {
log::warn!("iOS: warm start preserved pending BGTask after failure: {e}");
on_failure();
false
}
Err(e) => {
log::warn!(
"iOS: warm start preserved pending BGTask after failure (reply dropped: {e})"
);
on_failure();
false
}
}
}
#[tauri::command]
async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
if ipc_state.client.is_connected() {
return ipc_state
.client
.start(config)
.await
.map_err(|e| e.to_string());
}
let plugin_config = app.state::<PluginConfig>();
if !plugin_config.desktop_start_service_if_missing {
return Err(ServiceError::Ipc("ipcUnavailable".into()).to_string());
}
let socket_path = ipc_state.client.socket_path().display().to_string();
let timeout =
std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
{
let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
mgr.start().map_err(|e| e.to_string())?;
}
ipc_state.client.nudge_reconnect();
let connected = ipc_state
.client
.wait_for_connected(timeout)
.await
.map_err(|e| e.to_string())?;
if !connected {
return Err(
ServiceError::Ipc(format!("ipcUnavailable: socket {socket_path}")).to_string(),
);
}
return ipc_state
.client
.start(config)
.await
.map_err(|e| e.to_string());
}
ios_set_on_complete_callback(&app).await?;
let manager = app.state::<ServiceManagerHandle<R>>();
let (tx, rx) = tokio::sync::oneshot::channel();
manager
.cmd_tx
.send(ManagerCommand::Start {
config,
reply: tx,
app: app.clone(),
})
.await
.map_err(|e| e.to_string())?;
rx.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let plugin_config = app.state::<PluginConfig>();
ios_spawn_cancel_listener(&app, plugin_config.ios_cancel_listener_timeout_secs);
Ok(())
}
#[tauri::command]
async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state.client.stop().await.map_err(|e| e.to_string());
}
let manager = app.state::<ServiceManagerHandle<R>>();
let (tx, rx) = tokio::sync::oneshot::channel();
manager
.cmd_tx
.send(ManagerCommand::Stop { reply: tx })
.await
.map_err(|e| e.to_string())?;
rx.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn is_running<R: Runtime>(app: AppHandle<R>) -> bool {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state.client.is_running().await.unwrap_or(false);
}
let manager = app.state::<ServiceManagerHandle<R>>();
let (tx, rx) = tokio::sync::oneshot::channel();
if manager
.cmd_tx
.send(ManagerCommand::IsRunning { reply: tx })
.await
.is_err()
{
return false;
}
rx.await.unwrap_or(false)
}
#[tauri::command]
async fn get_service_state<R: Runtime>(app: AppHandle<R>) -> Result<models::ServiceStatus, String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state
.client
.get_state()
.await
.map_err(|e| e.to_string());
}
let manager = app.state::<ServiceManagerHandle<R>>();
Ok(manager.get_state().await)
}
#[tauri::command]
#[allow(unused_variables)]
async fn get_platform_capabilities<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::PlatformCapabilities, String> {
#[cfg(feature = "desktop-service")]
let plugin_config = app.state::<PluginConfig>();
#[cfg(feature = "desktop-service")]
let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
#[cfg(not(feature = "desktop-service"))]
let desktop_mode: Option<&str> = None;
let (platform, lifecycle_mode) =
capabilities::CapabilityProvider::detect_platform(desktop_mode);
#[cfg(all(feature = "desktop-service", unix))]
let os_service_installed = if matches!(lifecycle_mode, models::LifecycleMode::DesktopOsService)
{
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let exec = std::env::current_exe().unwrap_or_default();
DesktopServiceManager::new(&label, exec)
.map(|_| true)
.unwrap_or(false)
} else {
false
};
#[cfg(not(all(feature = "desktop-service", unix)))]
let os_service_installed = false;
Ok(capabilities::CapabilityProvider::capabilities(
platform,
lifecycle_mode,
os_service_installed,
))
}
#[tauri::command]
async fn get_scheduling_status<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::IOSSchedulingStatus, String> {
#[cfg(target_os = "ios")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
mobile
.get_scheduling_status()
.map_err(|e| e.to_string())
.and_then(|opt| opt.ok_or_else(|| "no scheduling status available".to_string()))
}
#[cfg(not(target_os = "ios"))]
{
let _ = app;
Ok(models::IOSSchedulingStatus {
refresh_scheduled: false,
processing_scheduled: false,
refresh_error: None,
processing_error: None,
})
}
}
#[tauri::command]
async fn request_battery_exemption<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
#[cfg(target_os = "android")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
mobile
.request_battery_exemption()
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
Ok(())
}
}
#[tauri::command]
async fn get_desired_state_status<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::IOSDesiredStateStatus, String> {
#[cfg(target_os = "ios")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
mobile
.get_desired_state_status()
.map_err(|e| e.to_string())
.and_then(|opt| opt.ok_or_else(|| "no desired-state status available".to_string()))
}
#[cfg(not(target_os = "ios"))]
{
let _ = app;
Ok(models::IOSDesiredStateStatus {
desired_running: false,
last_start_config: None,
last_task_kind: None,
last_task_started_at: None,
last_task_completed_at: None,
last_schedule_error: None,
last_completion_reason: None,
notification_granted: None,
})
}
}
#[tauri::command]
async fn get_pending_bg_task<R: Runtime>(
app: AppHandle<R>,
) -> Result<Option<models::PendingTaskInfo>, String> {
#[cfg(target_os = "ios")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
mobile.get_pending_bg_task().map_err(|e| e.to_string())
}
#[cfg(not(target_os = "ios"))]
{
let _ = app;
Ok(None)
}
}
#[tauri::command]
async fn get_notification_permission_status<R: Runtime>(
app: AppHandle<R>,
) -> Result<String, String> {
#[cfg(target_os = "android")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
mobile
.get_notification_permission_status()
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
Ok("granted".to_string())
}
}
#[tauri::command]
async fn request_notification_permission<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
#[cfg(target_os = "android")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>().inner().clone();
tokio::task::spawn_blocking(move || mobile.request_notification_permission())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
Ok("granted".to_string())
}
}
#[tauri::command]
async fn can_use_full_screen_intent<R: Runtime>(
app: AppHandle<R>,
) -> Result<serde_json::Value, String> {
#[cfg(target_os = "android")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
let can_use = mobile
.can_use_full_screen_intent()
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "canUse": can_use }))
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
Ok(serde_json::json!({ "canUse": true }))
}
}
#[tauri::command]
async fn open_full_screen_intent_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
#[cfg(target_os = "android")]
{
let mobile = app.state::<Arc<MobileLifecycle<R>>>();
mobile
.open_full_screen_intent_settings()
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "android"))]
{
let _ = app;
Ok(())
}
}
#[tauri::command]
async fn enable_auto_restart<R: Runtime>(
app: AppHandle<R>,
config: Option<StartConfig>,
) -> Result<(), String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state
.client
.enable_auto_restart(config)
.await
.map_err(|e| e.to_string());
}
let manager = app.state::<ServiceManagerHandle<R>>();
let (tx, rx) = tokio::sync::oneshot::channel();
manager
.cmd_tx
.send(ManagerCommand::EnableAutoRestart { config, reply: tx })
.await
.map_err(|e| e.to_string())?;
rx.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn disable_auto_restart<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state
.client
.disable_auto_restart()
.await
.map_err(|e| e.to_string());
}
let manager = app.state::<ServiceManagerHandle<R>>();
let (tx, rx) = tokio::sync::oneshot::channel();
manager
.cmd_tx
.send(ManagerCommand::DisableAutoRestart { reply: tx })
.await
.map_err(|e| e.to_string())?;
rx.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_desired_service_state<R: Runtime>(
app: AppHandle<R>,
) -> Result<Option<desired_state::DesiredState>, String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state
.client
.get_desired_state()
.await
.map_err(|e| e.to_string());
}
let manager = app.state::<ServiceManagerHandle<R>>();
let (tx, rx) = tokio::sync::oneshot::channel();
manager
.cmd_tx
.send(ManagerCommand::GetDesiredState { reply: tx })
.await
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn native_lifecycle_event<R: Runtime>(
app: AppHandle<R>,
event: models::NativeLifecycleEvent,
) -> Result<(), String> {
let manager = app.state::<ServiceManagerHandle<R>>();
manager
.send_native_lifecycle_event(event)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(unused_variables)]
async fn validate_setup<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::SetupValidationReport, String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state
.client
.validate_setup()
.await
.map_err(|e| e.to_string());
}
#[cfg(feature = "desktop-service")]
let plugin_config = app.state::<PluginConfig>();
#[cfg(feature = "desktop-service")]
let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
#[cfg(not(feature = "desktop-service"))]
let desktop_mode: Option<&str> = None;
let (platform, _) = capabilities::CapabilityProvider::detect_platform(desktop_mode);
Ok(validator::SetupValidator::validate(platform))
}
#[tauri::command]
async fn get_lifecycle_status<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::LifecycleStatus, String> {
#[cfg(all(feature = "desktop-service", unix))]
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
return ipc_state
.client
.get_lifecycle_status()
.await
.map_err(|e| e.to_string());
}
#[cfg(feature = "desktop-service")]
let plugin_config = app.state::<PluginConfig>();
#[cfg(feature = "desktop-service")]
let desktop_mode = Some(plugin_config.desktop_service_mode.as_str());
#[cfg(not(feature = "desktop-service"))]
let desktop_mode: Option<&str> = None;
let manager = app.state::<ServiceManagerHandle<R>>();
let (tx, rx) = tokio::sync::oneshot::channel();
manager
.cmd_tx
.send(ManagerCommand::GetLifecycleStatus {
desktop_mode: desktop_mode.map(|s| s.to_string()),
reply: tx,
})
.await
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn configure_recovery<R: Runtime>(
app: AppHandle<R>,
enabled: bool,
config: Option<StartConfig>,
) -> Result<(), String> {
if enabled {
enable_auto_restart(app, config).await
} else {
disable_auto_restart(app).await
}
}
#[cfg(all(feature = "desktop-service", unix))]
struct DesktopIpcState {
client: desktop::ipc_client::PersistentIpcClientHandle,
}
#[cfg(all(feature = "desktop-service", unix, not(mobile)))]
fn setup_os_service_ipc<R: Runtime>(
app: &AppHandle<R>,
config: &PluginConfig,
) -> Result<(), ServiceError> {
let label = desktop::service_manager::derive_service_label(
app,
config.desktop_service_label.as_deref(),
);
let socket_path = desktop::ipc::socket_path(&label)?;
let client = desktop::ipc_client::PersistentIpcClientHandle::spawn(
socket_path,
app.app_handle().clone(),
);
app.manage(DesktopIpcState { client });
let consent_dir = app.path().app_data_dir().ok().map(|d| d.join("data"));
let allow = consent_dir
.as_deref()
.map(|d| should_auto_provision(d, config.desktop_start_service_if_missing))
.unwrap_or(false);
if allow {
spawn_os_service_auto_provision(app.app_handle());
} else {
log::info!(
"Background service: OS-service auto-provision skipped \
(consent off or desktopStartServiceIfMissing disabled)"
);
}
Ok(())
}
#[cfg(feature = "desktop-service")]
const DESKTOP_CONSENT_FILENAME: &str = "background-service-consent.json";
#[cfg(feature = "desktop-service")]
#[derive(Debug, Default, serde::Deserialize)]
struct ProvisioningConsent {
#[serde(default)]
enabled: bool,
}
#[cfg(feature = "desktop-service")]
fn desktop_consent_allows_provisioning(data_dir: &std::path::Path) -> bool {
let path = data_dir.join(DESKTOP_CONSENT_FILENAME);
let record = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str::<ProvisioningConsent>(&text).ok())
.unwrap_or_default();
record.enabled
}
#[cfg(feature = "desktop-service")]
fn should_auto_provision(data_dir: &std::path::Path, start_if_missing: bool) -> bool {
start_if_missing && desktop_consent_allows_provisioning(data_dir)
}
#[cfg(feature = "desktop-service")]
#[tauri::command]
async fn install_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
install_service_inner(&app).await
}
#[cfg(feature = "desktop-service")]
const DEFAULT_RESTART_DELAY_SECS: u32 = 5;
#[cfg(feature = "desktop-service")]
const DEFAULT_START_LIMIT_BURST: u32 = 5;
#[cfg(feature = "desktop-service")]
const DEFAULT_START_LIMIT_INTERVAL_SECS: u32 = 60;
#[cfg(feature = "desktop-service")]
async fn install_service_inner<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let plugin_config = app.state::<PluginConfig>();
let label = derive_service_label(app, plugin_config.desktop_service_label.as_deref());
let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
if !exec_path.exists() {
return Err(format!(
"Current executable does not exist at {}: cannot install OS service",
exec_path.display()
));
}
let validate_result = tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::process::Command::new(&exec_path)
.arg("--service-label")
.arg(&label)
.arg("--validate-service-install")
.output(),
)
.await;
match validate_result {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().contains("ok") {
return Err("Binary does not handle --validate-service-install. \
Ensure headless_main() is called from your app's main()."
.into());
}
}
Ok(Err(e)) => {
return Err(format!(
"Failed to validate executable for --service-label: {e}"
));
}
Err(_) => {
log::warn!(
"Timeout validating --service-label support. \
Ensure your app's main() handles the --service-label argument \
and calls headless_main()."
);
}
}
{
let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
use desktop::service_manager::InstallOptions;
let options = InstallOptions {
autostart: plugin_config.desktop_service_autostart,
restart_delay_secs: Some(DEFAULT_RESTART_DELAY_SECS),
journal_output: true,
log_path: None,
start_limit_burst: Some(DEFAULT_START_LIMIT_BURST),
start_limit_interval_secs: Some(DEFAULT_START_LIMIT_INTERVAL_SECS),
};
mgr.install(&options).map_err(|e| e.to_string())?;
}
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
ipc_state.client.nudge_reconnect();
let timeout =
std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
ipc_state.client.wait_for_connected(timeout).await.ok();
}
Ok(())
}
#[cfg(all(feature = "desktop-service", unix, not(mobile)))]
fn spawn_os_service_auto_provision<R: Runtime>(app: &AppHandle<R>) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
let (start_if_missing, start_timeout_ms) = {
let plugin_config = app.state::<PluginConfig>();
(
plugin_config.desktop_start_service_if_missing,
plugin_config.desktop_service_start_timeout_ms,
)
};
if !start_if_missing {
return;
}
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
match ipc_state
.client
.wait_for_connected(std::time::Duration::from_secs(3))
.await
{
Ok(true) => {
log::info!("OS service already running — IPC connected");
return;
}
Ok(false) => {}
Err(e) => log::warn!("IPC wait failed during auto-provision: {e}"),
}
}
log::info!("OS service IPC unavailable — auto-provisioning (install + start)");
if let Err(e) = install_service_inner(&app).await {
log::warn!(
"OS service auto-install failed: {e}; \
app continues with in-process fallback"
);
return;
}
{
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let plugin_config = app.state::<PluginConfig>();
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let exec_path = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
log::warn!("OS service auto-start failed: cannot resolve current exe: {e}");
return;
}
};
match DesktopServiceManager::new(&label, exec_path) {
Ok(mgr) => {
if let Err(e) = mgr.start() {
log::warn!("OS service auto-start failed: {e}");
return;
}
}
Err(e) => {
log::warn!("OS service manager unavailable: {e}");
return;
}
}
}
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
ipc_state.client.nudge_reconnect();
let timeout = std::time::Duration::from_millis(start_timeout_ms);
match ipc_state.client.wait_for_connected(timeout).await {
Ok(true) => log::info!("OS service auto-provision complete — IPC connected"),
Ok(false) => log::warn!(
"OS service installed and started but IPC did not connect within {}ms",
timeout.as_millis()
),
Err(e) => log::warn!("IPC wait failed after auto-provision: {e}"),
}
}
});
}
#[cfg(feature = "desktop-service")]
#[tauri::command]
async fn uninstall_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let plugin_config = app.state::<PluginConfig>();
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
mgr.uninstall().map_err(|e| e.to_string())
}
#[cfg(all(feature = "desktop-service", unix))]
fn build_os_service_status(
label: &str,
ipc_connected: bool,
socket_path: Option<String>,
last_error: Option<String>,
native_status: Option<service_manager::ServiceStatus>,
) -> models::OsServiceStatus {
let mode = if cfg!(target_os = "macos") {
"launchd"
} else {
"systemd"
};
let installed = match native_status {
Some(service_manager::ServiceStatus::Running) => models::OsServiceInstallState::Running,
Some(service_manager::ServiceStatus::Stopped(_)) => {
models::OsServiceInstallState::Installed
}
Some(service_manager::ServiceStatus::NotInstalled) => {
models::OsServiceInstallState::NotInstalled
}
None => {
if ipc_connected {
models::OsServiceInstallState::Running
} else {
models::OsServiceInstallState::Installed
}
}
};
models::OsServiceStatus {
label: label.to_string(),
mode: mode.to_string(),
installed,
ipc_connected,
socket_path,
last_error,
}
}
#[cfg(feature = "desktop-service")]
#[tauri::command]
async fn start_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
#[cfg(unix)]
{
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let plugin_config = app.state::<PluginConfig>();
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
{
let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
mgr.start().map_err(|e| e.to_string())?;
}
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
ipc_state.client.nudge_reconnect();
let timeout =
std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
ipc_state.client.wait_for_connected(timeout).await.ok();
}
Ok(())
}
#[cfg(not(unix))]
{
let _ = app;
Err(os_service_unsupported_platform())
}
}
#[cfg(feature = "desktop-service")]
#[tauri::command]
async fn stop_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
#[cfg(unix)]
{
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let plugin_config = app.state::<PluginConfig>();
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
mgr.stop().map_err(|e| e.to_string())
}
#[cfg(not(unix))]
{
let _ = app;
Err(os_service_unsupported_platform())
}
}
#[cfg(feature = "desktop-service")]
#[tauri::command]
async fn restart_os_service<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
#[cfg(unix)]
{
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let plugin_config = app.state::<PluginConfig>();
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let exec_path = std::env::current_exe().map_err(|e| e.to_string())?;
{
let mgr =
DesktopServiceManager::new(&label, exec_path.clone()).map_err(|e| e.to_string())?;
mgr.stop().map_err(|e| e.to_string())?;
}
let timeout =
std::time::Duration::from_millis(plugin_config.desktop_service_start_timeout_ms);
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
ipc_state.client.nudge_reconnect();
let deadline = std::time::Instant::now() + timeout;
loop {
if !ipc_state.client.is_connected() || std::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
{
let mgr = DesktopServiceManager::new(&label, exec_path).map_err(|e| e.to_string())?;
mgr.start().map_err(|e| e.to_string())?;
}
if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
ipc_state.client.nudge_reconnect();
ipc_state.client.wait_for_connected(timeout).await.ok();
}
Ok(())
}
#[cfg(not(unix))]
{
let _ = app;
Err(os_service_unsupported_platform())
}
}
#[cfg(feature = "desktop-service")]
#[tauri::command]
async fn get_os_service_status<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::OsServiceStatus, String> {
#[cfg(unix)]
{
use desktop::service_manager::{derive_service_label, DesktopServiceManager};
let plugin_config = app.state::<PluginConfig>();
let label = derive_service_label(&app, plugin_config.desktop_service_label.as_deref());
let ipc_connected = app
.try_state::<DesktopIpcState>()
.map(|s| s.client.is_connected())
.unwrap_or(false);
let socket_path = desktop::ipc::socket_path(&label)
.ok()
.map(|p| p.to_string_lossy().to_string());
let native_status = std::env::current_exe()
.and_then(|exec_path| {
DesktopServiceManager::new(&label, exec_path)
.map_err(|e| std::io::Error::other(e.to_string()))
})
.and_then(|mgr| {
mgr.status()
.map_err(|e| std::io::Error::other(e.to_string()))
})
.ok();
Ok(build_os_service_status(
&label,
ipc_connected,
socket_path,
None,
native_status,
))
}
#[cfg(not(unix))]
{
let _ = app;
Err(os_service_unsupported_platform())
}
}
#[cfg(all(feature = "desktop-service", not(unix)))]
fn os_service_unsupported_platform() -> String {
ServiceError::Platform("OS-service mode is not supported on this platform".into()).to_string()
}
pub fn init_with_service<R, S, F>(factory: F) -> TauriPlugin<R, PluginConfig>
where
R: Runtime,
S: BackgroundService<R>,
F: Fn() -> S + Send + Sync + 'static,
{
let boxed_factory: ServiceFactory<R> = Box::new(move || Box::new(factory()));
Builder::<R, PluginConfig>::new("background-service")
.invoke_handler(tauri::generate_handler![
start,
stop,
is_running,
get_service_state,
get_platform_capabilities,
get_scheduling_status,
get_desired_state_status,
get_pending_bg_task,
get_notification_permission_status,
request_notification_permission,
can_use_full_screen_intent,
open_full_screen_intent_settings,
enable_auto_restart,
disable_auto_restart,
get_desired_service_state,
native_lifecycle_event,
validate_setup,
get_lifecycle_status,
configure_recovery,
request_battery_exemption,
#[cfg(feature = "desktop-service")]
install_service,
#[cfg(feature = "desktop-service")]
uninstall_service,
#[cfg(feature = "desktop-service")]
start_os_service,
#[cfg(feature = "desktop-service")]
stop_os_service,
#[cfg(feature = "desktop-service")]
restart_os_service,
#[cfg(feature = "desktop-service")]
get_os_service_status,
])
.setup(move |app, api| {
let config = api.config().clone();
if let Err(e) = config.validate() {
let msg = e.to_string();
log::error!("invalid background-service plugin config: {msg}");
return Err(msg.into());
}
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(config.channel_capacity);
#[cfg(mobile)]
let mobile_cmd_tx = cmd_tx.clone();
let handle = ServiceManagerHandle::new(cmd_tx);
app.manage(handle);
app.manage(config.clone());
let ios_safety_timeout_secs = config.ios_safety_timeout_secs;
let ios_processing_safety_timeout_secs = config.ios_processing_safety_timeout_secs;
let ios_earliest_refresh_begin_minutes = config.ios_earliest_refresh_begin_minutes;
let ios_earliest_processing_begin_minutes =
config.ios_earliest_processing_begin_minutes;
let ios_requires_external_power = config.ios_requires_external_power;
let ios_requires_network_connectivity = config.ios_requires_network_connectivity;
let ios_processing_ceiling_multiplier = config.ios_processing_ceiling_multiplier;
let android_fg_service_types = config.android_foreground_service_types.clone();
let android_validate_fg_type = config.android_validate_foreground_service_type;
let notifier_policy = NotifierPolicy::derive(&config, cfg!(target_os = "android"));
let notify_sink: Option<Arc<dyn NotifySink>> = Some(Arc::new(Notifier {
app: app.app_handle().clone(),
}));
let desired_state_backend: Option<Arc<dyn desired_state::DesiredStateBackend>> = {
match app.path().app_data_dir() {
Ok(data_dir) => Some(Arc::new(desired_state::FileDesiredStateBackend::new(
data_dir,
))),
Err(e) => {
log::warn!("Failed to get app data dir for desired-state persistence: {e}");
None
}
}
};
#[cfg(all(feature = "desktop-service", unix, not(mobile)))]
if config.desktop_service_mode == "osService" {
setup_os_service_ipc(app, &config)?;
} else {
let factory = boxed_factory;
tauri::async_runtime::spawn(manager_loop(
cmd_rx,
factory,
ios_safety_timeout_secs,
ios_processing_safety_timeout_secs,
ios_earliest_refresh_begin_minutes,
ios_earliest_processing_begin_minutes,
ios_requires_external_power,
ios_requires_network_connectivity,
ios_processing_ceiling_multiplier,
desired_state_backend,
android_fg_service_types.clone(),
android_validate_fg_type,
notifier_policy,
notify_sink,
None,
false,
));
}
#[cfg(all(feature = "desktop-service", unix, mobile))]
{
if config.desktop_service_mode == "osService" {
log::warn!(
"desktopServiceMode=osService is ignored on mobile; \
using the in-process service actor"
);
}
let factory = boxed_factory;
tauri::async_runtime::spawn(manager_loop(
cmd_rx,
factory,
ios_safety_timeout_secs,
ios_processing_safety_timeout_secs,
ios_earliest_refresh_begin_minutes,
ios_earliest_processing_begin_minutes,
ios_requires_external_power,
ios_requires_network_connectivity,
ios_processing_ceiling_multiplier,
desired_state_backend,
android_fg_service_types.clone(),
android_validate_fg_type,
notifier_policy,
notify_sink,
None,
false,
));
}
#[cfg(all(feature = "desktop-service", not(unix)))]
{
if config.desktop_service_mode == "osService" {
log::warn!(
"Desktop OS-service mode is not supported on this platform; \
background-service commands will fail instead of running in-process"
);
drop(cmd_rx);
} else {
let factory = boxed_factory;
tauri::async_runtime::spawn(manager_loop(
cmd_rx,
factory,
ios_safety_timeout_secs,
ios_processing_safety_timeout_secs,
ios_earliest_refresh_begin_minutes,
ios_earliest_processing_begin_minutes,
ios_requires_external_power,
ios_requires_network_connectivity,
ios_processing_ceiling_multiplier,
desired_state_backend,
android_fg_service_types.clone(),
android_validate_fg_type,
notifier_policy,
notify_sink,
None,
false,
));
}
}
#[cfg(not(feature = "desktop-service"))]
{
let factory = boxed_factory;
tauri::async_runtime::spawn(manager_loop(
cmd_rx,
factory,
ios_safety_timeout_secs,
ios_processing_safety_timeout_secs,
ios_earliest_refresh_begin_minutes,
ios_earliest_processing_begin_minutes,
ios_requires_external_power,
ios_requires_network_connectivity,
ios_processing_ceiling_multiplier,
desired_state_backend,
android_fg_service_types,
android_validate_fg_type,
notifier_policy,
notify_sink,
None,
false,
));
}
#[cfg(mobile)]
{
let lifecycle = mobile::init(app, api)?;
let lifecycle_arc = Arc::new(lifecycle);
let mobile_trait: Arc<dyn MobileKeepalive> = lifecycle_arc.clone();
if let Err(e) = mobile_cmd_tx.try_send(ManagerCommand::SetMobile {
mobile: mobile_trait,
}) {
log::error!("Failed to send SetMobile command: {e}");
}
app.manage(lifecycle_arc);
}
#[cfg(target_os = "ios")]
{
ios_spawn_cold_auto_start(app);
ios_spawn_warm_listener(app);
}
Ok(())
})
.on_event(|app, event| {
if let tauri::RunEvent::Exit = event {
#[cfg(target_os = "android")]
{
let _ = app;
return;
}
#[cfg(not(target_os = "android"))]
{
#[cfg(all(feature = "desktop-service", unix))]
if app.try_state::<DesktopIpcState>().is_some() {
return;
}
let manager = app.state::<ServiceManagerHandle<R>>();
#[cfg(target_os = "ios")]
let stop_result =
manager.stop_blocking_with_reason(crate::models::StopReason::ProcessExit);
#[cfg(not(target_os = "ios"))]
let stop_result = manager.stop_blocking();
if let Err(e) = stop_result {
log::warn!("Failed to stop background service on app exit: {e}");
}
}
}
})
.build()
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct DummyService;
#[async_trait]
impl BackgroundService<tauri::Wry> for DummyService {
async fn init(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
Ok(())
}
async fn run(&mut self, _ctx: &ServiceContext<tauri::Wry>) -> Result<(), ServiceError> {
Ok(())
}
}
#[test]
fn service_manager_handle_constructs() {
let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
let _handle: ServiceManagerHandle<tauri::Wry> = ServiceManagerHandle::new(cmd_tx);
}
#[test]
fn factory_produces_boxed_service() {
let factory: ServiceFactory<tauri::Wry> = Box::new(|| Box::new(DummyService));
let _service: Box<dyn BackgroundService<tauri::Wry>> = factory();
}
#[test]
fn handle_factory_creates_fresh_instances() {
let count = Arc::new(AtomicUsize::new(0));
let count_clone = count.clone();
let factory: ServiceFactory<tauri::Wry> = Box::new(move || {
count_clone.fetch_add(1, Ordering::SeqCst);
Box::new(DummyService)
});
let _ = (factory)();
let _ = (factory)();
assert_eq!(count.load(Ordering::SeqCst), 2);
}
#[allow(dead_code)]
fn init_with_service_returns_tauri_plugin<R: Runtime, S, F>(
factory: F,
) -> TauriPlugin<R, PluginConfig>
where
S: BackgroundService<R>,
F: Fn() -> S + Send + Sync + 'static,
{
init_with_service(factory)
}
#[allow(dead_code)]
async fn start_command_signature<R: Runtime>(
app: AppHandle<R>,
config: StartConfig,
) -> Result<(), String> {
start(app, config).await
}
#[allow(dead_code)]
async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
stop(app).await
}
#[allow(dead_code)]
async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
is_running(app).await
}
#[allow(dead_code)]
async fn get_service_state_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::ServiceStatus, String> {
get_service_state(app).await
}
#[allow(dead_code)]
async fn get_scheduling_status_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::IOSSchedulingStatus, String> {
get_scheduling_status(app).await
}
#[allow(dead_code)]
async fn get_desired_state_status_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::IOSDesiredStateStatus, String> {
get_desired_state_status(app).await
}
#[allow(dead_code)]
async fn get_pending_bg_task_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<Option<models::PendingTaskInfo>, String> {
get_pending_bg_task(app).await
}
#[allow(dead_code)]
async fn get_notification_permission_status_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<String, String> {
get_notification_permission_status(app).await
}
#[allow(dead_code)]
async fn request_notification_permission_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<String, String> {
request_notification_permission(app).await
}
#[allow(dead_code)]
async fn can_use_full_screen_intent_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<serde_json::Value, String> {
can_use_full_screen_intent(app).await
}
#[allow(dead_code)]
async fn open_full_screen_intent_settings_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<(), String> {
open_full_screen_intent_settings(app).await
}
#[allow(dead_code)]
async fn enable_auto_restart_command_signature<R: Runtime>(
app: AppHandle<R>,
config: Option<StartConfig>,
) -> Result<(), String> {
enable_auto_restart(app, config).await
}
#[allow(dead_code)]
async fn disable_auto_restart_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<(), String> {
disable_auto_restart(app).await
}
#[allow(dead_code)]
async fn get_desired_service_state_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<Option<desired_state::DesiredState>, String> {
get_desired_service_state(app).await
}
#[allow(dead_code)]
async fn validate_setup_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::SetupValidationReport, String> {
validate_setup(app).await
}
#[allow(dead_code)]
async fn native_lifecycle_event_command_signature<R: Runtime>(
app: AppHandle<R>,
event: models::NativeLifecycleEvent,
) -> Result<(), String> {
native_lifecycle_event(app, event).await
}
#[allow(dead_code)]
async fn get_lifecycle_status_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::LifecycleStatus, String> {
get_lifecycle_status(app).await
}
#[allow(dead_code)]
async fn configure_recovery_command_signature<R: Runtime>(
app: AppHandle<R>,
enabled: bool,
config: Option<StartConfig>,
) -> Result<(), String> {
configure_recovery(app, enabled, config).await
}
#[cfg(all(feature = "desktop-service", unix))]
#[tokio::test]
async fn desktop_ipc_state_with_persistent_client() {
use desktop::ipc_client::PersistentIpcClientHandle;
let app = tauri::test::mock_app();
let path = std::path::PathBuf::from("/tmp/test-persistent-client.sock");
let client = PersistentIpcClientHandle::spawn(path, app.handle().clone());
let _state = DesktopIpcState { client };
}
#[cfg(all(feature = "desktop-service", unix, not(mobile)))]
#[tokio::test]
async fn os_service_mode_constructs_ipc_state() {
let app = tauri::test::mock_app();
let handle = app.handle();
let config = PluginConfig {
desktop_service_mode: "osService".into(),
..Default::default()
};
handle.manage(config.clone());
setup_os_service_ipc(handle, &config).expect("osService IPC setup should succeed");
assert!(
handle.try_state::<DesktopIpcState>().is_some(),
"DesktopIpcState must be managed in osService mode"
);
}
#[cfg(feature = "desktop-service")]
#[test]
fn bgs12_no_autoprovision_without_consent() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
assert!(
!should_auto_provision(dir, true),
"consent off (no record): must NOT auto-provision even if start_if_missing=true"
);
std::fs::write(
dir.join(DESKTOP_CONSENT_FILENAME),
serde_json::json!({"enabled": true, "auto_unlock": true, "updated_at": 1}).to_string(),
)
.unwrap();
assert!(
should_auto_provision(dir, true),
"consent on + start_if_missing: auto-provision allowed"
);
assert!(
!should_auto_provision(dir, false),
"start_if_missing=false: must NOT auto-provision"
);
}
#[cfg(feature = "desktop-service")]
#[test]
fn bgs12_provisioning_gates_on_enabled_not_auto_unlock() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
std::fs::write(
dir.join(DESKTOP_CONSENT_FILENAME),
serde_json::json!({"enabled": true, "auto_unlock": false}).to_string(),
)
.unwrap();
assert!(
should_auto_provision(dir, true),
"enabled alone (service consent) must allow provisioning"
);
std::fs::write(dir.join(DESKTOP_CONSENT_FILENAME), b"not json {{{").unwrap();
assert!(
!should_auto_provision(dir, true),
"corrupt consent record must default off (no provisioning)"
);
}
#[cfg(feature = "desktop-service")]
#[test]
fn bgs12_setup_os_service_ipc_wires_consent_gate() {
let src = include_str!("lib.rs");
let call = ["should_auto", "_provision("].concat();
assert!(
src.contains(&call[..]),
"setup_os_service_ipc must call the consent-gate helper before spawning auto-provision"
);
let skip = ["OS-service auto-", "provision skipped"].concat();
assert!(
src.contains(&skip[..]),
"setup_os_service_ipc must skip the spawn when consent is off (the else branch)"
);
}
#[test]
fn bgs21_notification_permission_bridge_registered_and_wired() {
let build_rs = include_str!("../build.rs");
assert!(
build_rs.contains("\"get_notification_permission_status\""),
"get_notification_permission_status must be listed in build.rs COMMANDS"
);
assert!(
build_rs.contains("\"request_notification_permission\""),
"request_notification_permission must be listed in build.rs COMMANDS"
);
let src = include_str!("lib.rs");
let get_reg = ["get_notification_permission", "_status,"].concat();
let req_reg = ["request_notification_permiss", "ion,"].concat();
assert!(
src.contains(&get_reg[..]),
"get_notification_permission_status must be registered in generate_handler!"
);
assert!(
src.contains(&req_reg[..]),
"request_notification_permission must be registered in generate_handler!"
);
let mobile_rs = include_str!("mobile.rs");
assert!(
mobile_rs.contains("\"getNotificationPermissionStatus\""),
"mobile.rs must bridge getNotificationPermissionStatus via run_mobile_plugin"
);
assert!(
mobile_rs.contains("\"requestNotificationPermission\""),
"mobile.rs must bridge requestNotificationPermission via run_mobile_plugin"
);
}
#[test]
fn bgs22_battery_exemption_bridge_registered_and_wired() {
let build_rs = include_str!("../build.rs");
assert!(
build_rs.contains("\"request_battery_exemption\""),
"request_battery_exemption must be listed in build.rs COMMANDS"
);
let src = include_str!("lib.rs");
let reg = ["request_battery_exempt", "ion,"].concat();
assert!(
src.contains(®[..]),
"request_battery_exemption must be registered in generate_handler!"
);
let mobile_rs = include_str!("mobile.rs");
assert!(
mobile_rs.contains("\"requestBatteryExemption\""),
"mobile.rs must bridge requestBatteryExemption via run_mobile_plugin"
);
let default_toml = include_str!("../permissions/default.toml");
assert!(
default_toml.contains("\"allow-request-battery-exemption\""),
"allow-request-battery-exemption must be in permissions/default.toml"
);
let cmd_toml =
include_str!("../permissions/autogenerated/commands/request_battery_exemption.toml");
assert!(
cmd_toml.contains("\"request_battery_exemption\""),
"permissions/autogenerated/commands/request_battery_exemption.toml must allow request_battery_exemption"
);
}
#[test]
fn acl01_get_desired_state_status_reachable_on_all_four_axes() {
let build_rs = include_str!("../build.rs");
assert!(
build_rs.contains("\"get_desired_state_status\""),
"get_desired_state_status must be listed in build.rs COMMANDS"
);
let src = include_str!("lib.rs");
let reg = ["get_desired_state_stat", "us,"].concat();
assert!(
src.contains(®[..]),
"get_desired_state_status must be registered in generate_handler!"
);
let mobile_rs = include_str!("mobile.rs");
assert!(
mobile_rs.contains("\"getDesiredStateStatus\""),
"mobile.rs must bridge getDesiredStateStatus via run_mobile_plugin"
);
let default_toml = include_str!("../permissions/default.toml");
assert!(
default_toml.contains("\"allow-get-desired-state-status\""),
"allow-get-desired-state-status must be in permissions/default.toml"
);
let cmd_toml =
include_str!("../permissions/autogenerated/commands/get_desired_state_status.toml");
assert!(
cmd_toml.contains("\"get_desired_state_status\""),
"permissions/autogenerated/commands/get_desired_state_status.toml must allow get_desired_state_status"
);
}
#[cfg(feature = "desktop-service")]
#[allow(dead_code)]
async fn install_service_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<(), String> {
install_service(app).await
}
#[cfg(feature = "desktop-service")]
#[allow(dead_code)]
async fn uninstall_service_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<(), String> {
uninstall_service(app).await
}
#[cfg(feature = "desktop-service")]
#[allow(dead_code)]
async fn start_os_service_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<(), String> {
start_os_service(app).await
}
#[cfg(feature = "desktop-service")]
#[allow(dead_code)]
async fn stop_os_service_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<(), String> {
stop_os_service(app).await
}
#[cfg(feature = "desktop-service")]
#[allow(dead_code)]
async fn restart_os_service_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<(), String> {
restart_os_service(app).await
}
#[cfg(all(feature = "desktop-service", unix))]
#[test]
fn desk05_restart_does_not_swallow_stop_error() {
let src = include_str!("lib.rs");
let restart_body = src
.split("async fn restart_os_service")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("restart_os_service body must be present");
assert!(
!restart_body.contains("mgr.stop().ok()"),
"DESK-05: restart must not swallow stop errors with .ok()"
);
assert!(
restart_body.contains("mgr.stop().map_err"),
"DESK-05: restart must propagate stop errors via ?"
);
assert!(
restart_body.contains("deadline") || restart_body.contains("wait_for_connected"),
"DESK-05: restart must wait boundedly between stop and start"
);
}
#[cfg(feature = "desktop-service")]
#[allow(dead_code)]
async fn get_os_service_status_command_signature<R: Runtime>(
app: AppHandle<R>,
) -> Result<models::OsServiceStatus, String> {
get_os_service_status(app).await
}
#[cfg(all(feature = "desktop-service", unix))]
#[test]
fn build_os_service_status_populates_fields() {
let status = build_os_service_status(
"com.example.bg-service",
true,
Some("/tmp/test.sock".to_string()),
None,
None,
);
assert_eq!(status.label, "com.example.bg-service");
assert!(status.ipc_connected);
assert_eq!(status.socket_path.as_deref(), Some("/tmp/test.sock"));
assert!(status.last_error.is_none());
}
#[cfg(all(feature = "desktop-service", unix))]
#[test]
fn build_os_service_status_mode_is_correct() {
let status = build_os_service_status("test", false, None, None, None);
#[cfg(target_os = "linux")]
assert_eq!(status.mode, "systemd");
#[cfg(target_os = "macos")]
assert_eq!(status.mode, "launchd");
}
#[cfg(all(feature = "desktop-service", unix))]
#[test]
fn desk02_native_running_maps_to_running() {
let status = build_os_service_status(
"test",
false,
None,
None,
Some(service_manager::ServiceStatus::Running),
);
assert_eq!(status.installed, models::OsServiceInstallState::Running);
}
#[cfg(all(feature = "desktop-service", unix))]
#[test]
fn desk02_native_stopped_maps_to_installed() {
let status = build_os_service_status(
"test",
false,
None,
None,
Some(service_manager::ServiceStatus::Stopped(None)),
);
assert_eq!(status.installed, models::OsServiceInstallState::Installed);
}
#[cfg(all(feature = "desktop-service", unix))]
#[test]
fn desk02_native_not_installed_is_reachable() {
let status = build_os_service_status(
"test",
false,
None,
None,
Some(service_manager::ServiceStatus::NotInstalled),
);
assert_eq!(
status.installed,
models::OsServiceInstallState::NotInstalled,
"NotInstalled must surface when the native manager reports it"
);
}
#[cfg(all(feature = "desktop-service", unix))]
#[test]
fn desk02_native_unavailable_falls_back_to_ipc_signal() {
let running = build_os_service_status("test", true, None, None, None);
assert_eq!(running.installed, models::OsServiceInstallState::Running);
let stopped = build_os_service_status("test", false, None, None, None);
assert_eq!(stopped.installed, models::OsServiceInstallState::Installed);
}
#[allow(dead_code)]
fn on_event_shutdown_closure_type_checks<R: Runtime>(_app: &AppHandle<R>) {
let _closure = |_app: &AppHandle<R>, event: &tauri::RunEvent| {
if let tauri::RunEvent::Exit = event {
let manager = _app.state::<ServiceManagerHandle<R>>();
if let Err(_e) = manager.stop_blocking() {
log::warn!("bg service shutdown on exit failed: {_e}");
}
}
};
}
use crate::manager::ManagerCommand;
use std::sync::atomic::AtomicBool;
fn spawn_stop_drain(
mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
) -> tokio::sync::oneshot::Receiver<Option<crate::models::StopReason>> {
let (seen_tx, seen_rx) =
tokio::sync::oneshot::channel::<Option<crate::models::StopReason>>();
tokio::spawn(async move {
let result =
tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
match result {
Ok(Some(ManagerCommand::StopWithReason { reason, reply })) => {
let _ = reply.send(Ok(()));
let _ = seen_tx.send(Some(reason));
}
_ => {
let _ = seen_tx.send(None);
}
}
});
seen_rx
}
#[tokio::test]
async fn cancel_listener_resolved_invoke_sends_stop_with_reason() {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
let seen = spawn_stop_drain(cmd_rx);
let stop_sent = run_cancel_listener(
Box::new(|| Ok(())),
Box::new(|| {}),
cmd_tx,
5, )
.await;
assert!(stop_sent, "resolved invoke should return true");
let reason = seen.await.unwrap();
assert_eq!(
reason,
Some(crate::models::StopReason::PlatformExpiration),
"StopWithReason(PlatformExpiration) should be sent on resolved invoke"
);
}
#[tokio::test]
async fn cancel_listener_rejected_invoke_no_stop() {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
let seen = spawn_stop_drain(cmd_rx);
let stop_sent = run_cancel_listener(
Box::new(|| Err(ServiceError::Platform("rejected".into()))),
Box::new(|| {}),
cmd_tx,
5,
)
.await;
assert!(!stop_sent, "rejected invoke should return false");
assert_eq!(
seen.await.unwrap(),
None,
"StopWithReason should NOT be sent on rejected invoke"
);
}
#[tokio::test]
async fn cancel_listener_timeout_sends_stop_with_reason() {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
let cancel_called = Arc::new(AtomicBool::new(false));
let cancel_called_clone = cancel_called.clone();
let seen = spawn_stop_drain(cmd_rx);
let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
let stop_sent = run_cancel_listener(
Box::new(move || {
let _ = unblock_rx.recv();
Ok(())
}),
Box::new(move || {
cancel_called_clone.store(true, Ordering::SeqCst);
let _ = unblock_tx.send(());
}),
cmd_tx,
0, )
.await;
assert!(stop_sent, "timeout should return true");
assert!(
cancel_called.load(Ordering::SeqCst),
"cancel_fn should be called on timeout"
);
let reason = seen.await.unwrap();
assert_eq!(
reason,
Some(crate::models::StopReason::PlatformTimeout),
"StopWithReason(PlatformTimeout) should be sent on timeout"
);
}
#[tokio::test]
async fn cancel_listener_join_error_no_stop() {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
let seen = spawn_stop_drain(cmd_rx);
let stop_sent = run_cancel_listener(
Box::new(|| panic!("simulated panic in wait_for_cancel")),
Box::new(|| {}),
cmd_tx,
5,
)
.await;
assert!(!stop_sent, "join error should return false (no stop sent)");
assert_eq!(
seen.await.unwrap(),
None,
"StopWithReason should NOT be sent on join error"
);
}
fn spawn_start_drain(
mut cmd_rx: tokio::sync::mpsc::Receiver<ManagerCommand<tauri::test::MockRuntime>>,
reply_with: Result<(), ServiceError>,
) -> tokio::sync::oneshot::Receiver<bool> {
let (seen_tx, seen_rx) = tokio::sync::oneshot::channel::<bool>();
tokio::spawn(async move {
let result =
tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await;
match result {
Ok(Some(ManagerCommand::Start { reply, .. })) => {
let _ = reply.send(reply_with);
let _ = seen_tx.send(true);
}
_ => {
let _ = seen_tx.send(false);
}
}
});
seen_rx
}
#[tokio::test]
async fn auto_start_success_consumes_pending_exactly_once() {
let app = tauri::test::mock_app();
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
let seen = spawn_start_drain(cmd_rx, Ok(()));
let cleared = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let cleared_c = cleared.clone();
let failed_c = failed.clone();
let started = run_auto_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx,
Box::new(move || {
cleared_c.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
failed_c.fetch_add(1, Ordering::SeqCst);
}),
)
.await;
assert!(started, "successful Start should return true");
assert!(seen.await.unwrap(), "Start command should be received");
assert_eq!(
cleared.load(Ordering::SeqCst),
1,
"pending must be consumed exactly once on success"
);
assert_eq!(
failed.load(Ordering::SeqCst),
0,
"no failure marker on success"
);
}
#[tokio::test]
async fn auto_start_failure_preserves_pending_and_marks_failure() {
let app = tauri::test::mock_app();
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
let seen = spawn_start_drain(
cmd_rx,
Err(ServiceError::Platform("forced start failure".into())),
);
let cleared = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let cleared_c = cleared.clone();
let failed_c = failed.clone();
let started = run_auto_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx,
Box::new(move || {
cleared_c.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
failed_c.fetch_add(1, Ordering::SeqCst);
}),
)
.await;
assert!(!started, "failed Start should return false");
assert!(seen.await.unwrap(), "Start command should be received");
assert_eq!(
cleared.load(Ordering::SeqCst),
0,
"pending must be PRESERVED on failure (clear not called)"
);
assert_eq!(
failed.load(Ordering::SeqCst),
1,
"failure marker must be recorded exactly once on failure"
);
}
#[tokio::test]
async fn auto_start_channel_closed_preserves_pending() {
let app = tauri::test::mock_app();
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel::<ManagerCommand<_>>(16);
drop(cmd_rx);
let cleared = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let cleared_c = cleared.clone();
let failed_c = failed.clone();
let started = run_auto_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx,
Box::new(move || {
cleared_c.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
failed_c.fetch_add(1, Ordering::SeqCst);
}),
)
.await;
assert!(!started, "closed channel should return false");
assert_eq!(
cleared.load(Ordering::SeqCst),
0,
"pending must be preserved when the command never sends"
);
assert_eq!(
failed.load(Ordering::SeqCst),
1,
"failure marker recorded when the command channel is closed"
);
}
struct WarmBlockingService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for WarmBlockingService {
async fn init(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
async fn run(
&mut self,
ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
ctx.shutdown.cancelled().await;
Ok(())
}
}
struct WarmQuickService;
#[async_trait]
impl BackgroundService<tauri::test::MockRuntime> for WarmQuickService {
async fn init(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
async fn run(
&mut self,
_ctx: &ServiceContext<tauri::test::MockRuntime>,
) -> Result<(), ServiceError> {
Ok(())
}
}
fn spawn_real_manager(
factory: crate::manager::ServiceFactory<tauri::test::MockRuntime>,
) -> tokio::sync::mpsc::Sender<ManagerCommand<tauri::test::MockRuntime>> {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(16);
tokio::spawn(manager_loop(
cmd_rx,
factory,
28.0,
0.0,
15.0,
15.0,
false,
false,
4.0,
None,
vec!["remoteMessaging".into()],
true,
NotifierPolicy::default(),
None,
None,
false,
));
cmd_tx
}
async fn warm_is_running(
cmd_tx: &tokio::sync::mpsc::Sender<ManagerCommand<tauri::test::MockRuntime>>,
) -> bool {
let (tx, rx) = tokio::sync::oneshot::channel();
cmd_tx
.send(ManagerCommand::IsRunning { reply: tx })
.await
.unwrap();
rx.await.unwrap()
}
fn noop_on_complete() -> OnCompleteCallback {
Box::new(|_success| {})
}
#[tokio::test]
async fn warm_start_idle_starts_actor_and_consumes_pending() {
let app = tauri::test::mock_app();
let cmd_tx = spawn_real_manager(Box::new(|| Box::new(WarmBlockingService)));
let consumed = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let consumed_c = consumed.clone();
let failed_c = failed.clone();
let started = run_warm_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx.clone(),
noop_on_complete(),
Box::new(move || {
consumed_c.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
failed_c.fetch_add(1, Ordering::SeqCst);
}),
)
.await;
assert!(
started,
"warm delivery to idle actor should start the service"
);
assert!(
warm_is_running(&cmd_tx).await,
"is_running should flip true after warm start"
);
assert_eq!(
consumed.load(Ordering::SeqCst),
1,
"pending must be consumed exactly once on warm success"
);
assert_eq!(
failed.load(Ordering::SeqCst),
0,
"no failure marker on success"
);
}
#[tokio::test]
async fn warm_start_while_running_is_noop() {
let app = tauri::test::mock_app();
let cmd_tx = spawn_real_manager(Box::new(|| Box::new(WarmBlockingService)));
let consumed = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let consumed_1 = consumed.clone();
let failed_1 = failed.clone();
let first = run_warm_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx.clone(),
noop_on_complete(),
Box::new(move || {
consumed_1.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
failed_1.fetch_add(1, Ordering::SeqCst);
}),
)
.await;
assert!(first, "first warm delivery should start the service");
let consumed_2 = consumed.clone();
let failed_2 = failed.clone();
let second = run_warm_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx.clone(),
noop_on_complete(),
Box::new(move || {
consumed_2.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
failed_2.fetch_add(1, Ordering::SeqCst);
}),
)
.await;
assert!(
!second,
"warm delivery while running should be a no-op (false)"
);
assert!(
warm_is_running(&cmd_tx).await,
"service should still be running after the no-op warm delivery"
);
assert_eq!(
consumed.load(Ordering::SeqCst),
1,
"pending consumed exactly once across both deliveries"
);
assert_eq!(
failed.load(Ordering::SeqCst),
0,
"a no-op warm delivery must NOT record a failure marker"
);
}
#[tokio::test]
async fn warm_start_arms_captured_on_complete_callback() {
let app = tauri::test::mock_app();
let cmd_tx = spawn_real_manager(Box::new(|| Box::new(WarmQuickService)));
let fired = Arc::new(AtomicBool::new(false));
let fired_cb = fired.clone();
let on_complete: OnCompleteCallback = Box::new(move |success| {
if success {
fired_cb.store(true, Ordering::SeqCst);
}
});
let started = run_warm_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx.clone(),
on_complete,
Box::new(|| {}),
Box::new(|| {}),
)
.await;
assert!(started, "warm start should initiate the service");
let mut armed = false;
for _ in 0..50 {
if fired.load(Ordering::SeqCst) {
armed = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(
armed,
"captured on_complete callback must fire after warm start (SetOnComplete re-sent)"
);
}
#[tokio::test]
async fn warm_start_failure_preserves_pending_and_marks_failure() {
let app = tauri::test::mock_app();
let (cmd_tx, cmd_rx) =
tokio::sync::mpsc::channel::<ManagerCommand<tauri::test::MockRuntime>>(16);
tokio::spawn(async move {
let mut rx = cmd_rx;
while let Ok(Some(cmd)) =
tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()).await
{
match cmd {
ManagerCommand::IsRunning { reply } => {
let _ = reply.send(false);
}
ManagerCommand::SetOnComplete { .. } => {}
ManagerCommand::Start { reply, .. } => {
let _ = reply.send(Err(ServiceError::Platform("forced".into())));
break;
}
_ => {}
}
}
});
let consumed = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let consumed_c = consumed.clone();
let failed_c = failed.clone();
let started = run_warm_start(
StartConfig::default(),
app.handle().clone(),
cmd_tx,
noop_on_complete(),
Box::new(move || {
consumed_c.fetch_add(1, Ordering::SeqCst);
}),
Box::new(move || {
failed_c.fetch_add(1, Ordering::SeqCst);
}),
)
.await;
assert!(!started, "forced warm start failure should return false");
assert_eq!(
consumed.load(Ordering::SeqCst),
0,
"pending must be PRESERVED on genuine failure (clear not called)"
);
assert_eq!(
failed.load(Ordering::SeqCst),
1,
"failure marker recorded exactly once on genuine failure"
);
}
#[cfg(all(feature = "desktop-service", unix))]
mod ipc_auto_start_tests {
use super::*;
use crate::desktop::ipc_client::PersistentIpcClientHandle;
use crate::desktop::test_helpers::setup_server;
use std::time::Duration;
#[tokio::test]
async fn wait_for_connected_timeout_returns_false() {
let app = tauri::test::mock_app();
let path = crate::desktop::test_helpers::unique_socket_path();
let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
let connected = handle
.wait_for_connected(Duration::from_millis(200))
.await
.unwrap();
assert!(!connected, "should return false on timeout");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn wait_for_connected_succeeds_with_server() {
let (path, shutdown, _event_tx) = setup_server();
let app = tauri::test::mock_app();
let handle = PersistentIpcClientHandle::spawn(path, app.handle().clone());
let connected = handle
.wait_for_connected(Duration::from_secs(5))
.await
.unwrap();
assert!(connected, "should connect within timeout");
shutdown.cancel();
}
#[tokio::test]
async fn socket_path_accessor() {
let app = tauri::test::mock_app();
let path = crate::desktop::test_helpers::unique_socket_path();
let handle = PersistentIpcClientHandle::spawn(path.clone(), app.handle().clone());
assert_eq!(
handle.socket_path(),
&path,
"socket_path() should return the path passed to spawn"
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn start_disconnected_without_auto_start_returns_ipc_error() {
let err = ServiceError::Ipc("ipcUnavailable".into());
let msg = err.to_string();
assert!(
msg.contains("ipcUnavailable"),
"error should contain 'ipcUnavailable': {msg}"
);
}
#[tokio::test]
async fn start_timeout_error_includes_socket_path() {
let socket = "/tmp/test-socket-path.sock";
let err = ServiceError::Ipc(format!("ipcUnavailable: socket {socket}"));
let msg = err.to_string();
assert!(
msg.contains(socket),
"error should contain socket path: {msg}"
);
}
}
}