Skip to main content

tauri_plugin_background_service/
lib.rs

1#![doc(html_root_url = "https://docs.rs/tauri-plugin-background-service/1.0.0")]
2
3//! # tauri-plugin-background-service
4//!
5//! A [Tauri](https://tauri.app) v2 plugin that manages long-lived background service
6//! lifecycle across **Android**, **iOS**, and **Desktop**.
7//!
8//! Users implement the [`BackgroundService`] trait; the plugin handles OS-specific
9//! keepalive (Android foreground service, iOS `BGTaskScheduler`), cancellation via
10//! [`CancellationToken`](tokio_util::sync::CancellationToken), and state management
11//! through an actor pattern.
12//!
13//! ## Quick Start
14//!
15//! ```rust,ignore
16//! use tauri_plugin_background_service::{
17//!     BackgroundService, ServiceContext, ServiceError, init_with_service,
18//! };
19//!
20//! struct MyService;
21//!
22//! #[async_trait::async_trait]
23//! impl<R: tauri::Runtime> BackgroundService<R> for MyService {
24//!     async fn init(&mut self, _ctx: &ServiceContext<R>) -> Result<(), ServiceError> {
25//!         Ok(())
26//!     }
27//!
28//!     async fn run(&mut self, ctx: &ServiceContext<R>) -> Result<(), ServiceError> {
29//!         tokio::select! {
30//!             _ = ctx.shutdown.cancelled() => Ok(()),
31//!             _ = do_work(ctx) => Ok(()),
32//!         }
33//!     }
34//! }
35//!
36//! tauri::Builder::default()
37//!     .plugin(init_with_service(|| MyService))
38//! ```
39//!
40//! ## Platform Behavior
41//!
42//! | Platform | Keepalive Mechanism | Auto-restart |
43//! |----------|-------------------|-------------|
44//! | Android | Foreground service with persistent notification (`START_STICKY`) | Yes |
45//! | iOS | `BGTaskScheduler` with expiration handler | No |
46//! | Desktop | Plain `tokio::spawn` | No |
47//!
48//! ## iOS Setup
49//!
50//! Add the following entries to your app's `Info.plist`:
51//!
52//! ```xml
53//! <key>BGTaskSchedulerPermittedIdentifiers</key>
54//! <array>
55//!     <string>$(BUNDLE_ID).bg-refresh</string>
56//!     <string>$(BUNDLE_ID).bg-processing</string>
57//! </array>
58//!
59//! <key>UIBackgroundModes</key>
60//! <array>
61//!     <string>processing</string>
62//!     <string>fetch</string>
63//! </array>
64//! ```
65//!
66//! Replace `$(BUNDLE_ID)` with your app's bundle identifier.
67//! Without these entries, `BGTaskScheduler.shared.submit(_:)` will throw at runtime.
68//!
69//! See the [project repository](https://github.com/dardourimohamed/tauri-background-service)
70//! for detailed platform guides and API documentation.
71
72pub 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
87// ─── Public API Surface ──────────────────────────────────────────────────────
88
89pub 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
103// ─── Internal Imports ────────────────────────────────────────────────────────
104
105use 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// `MobileLifecycle` is referenced from the iOS plugin bindings below AND from the
116// android-active notification-permission commands (NTF-09), so it must be in
117// scope on every mobile target (Android + iOS), not just iOS.
118#[cfg(mobile)]
119use mobile::MobileLifecycle;
120
121use std::sync::Arc;
122
123// ─── iOS Plugin Binding ──────────────────────────────────────────────────────
124// Must be at module level. Referenced by mobile::init() when registering
125// the iOS plugin. Only compiled when targeting iOS.
126
127#[cfg(target_os = "ios")]
128tauri::ios_plugin_binding!(init_plugin_background_service);
129
130// ─── iOS Lifecycle Helpers ────────────────────────────────────────────────────
131
132/// Set the on_complete callback so iOS `completeBgTask` fires when `run()` finishes.
133///
134/// Sends `SetOnComplete` to the actor. Must be called **before** `Start` because
135/// `handle_start` captures the callback via `take()` at spawn time.
136#[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/// Spawn a blocking thread that waits for the iOS expiration signal (`waitForCancel`).
162///
163/// Must be called **after** `Start` succeeds so the service is running when the
164/// cancel listener begins waiting. Sends `Stop` to the actor when cancelled.
165///
166/// Three outcomes:
167/// 1. **Resolved invoke** (safety timer / expiration) → `Ok(())` → send `StopWithReason(PlatformExpiration)`.
168/// 2. **Timeout** (default: 4h) → call `cancel_cancel_listener` to unblock the
169///    thread, then send `StopWithReason(PlatformTimeout)`.
170/// 3. **Rejected invoke** (explicit stop / natural completion) → `Err` → no action.
171///
172/// Core cancel listener logic, extracted for testability.
173///
174/// - `wait_fn`: blocking function simulating `wait_for_cancel` (returns `Ok(())` on resolve,
175///   `Err` on reject).
176/// - `cancel_fn`: called on timeout to unblock the `wait_fn` thread.
177/// - `cmd_tx`: channel to send `StopWithReason` command on resolve/timeout.
178/// - `timeout_secs`: how long to wait before treating the listener as timed out.
179///
180/// Returns `true` if a `StopWithReason` was sent, `false` otherwise.
181#[allow(dead_code)] // used on iOS + in tests
182async 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        // Resolved invoke (safety timer or expiration) → graceful shutdown
192        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        // Timeout → unblock the spawn_blocking thread, then graceful shutdown
204        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        // Rejected invoke (explicit stop or natural completion) → no action
217        _ => 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        // Ignore result — the listener fires-and-forgets.
243        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/// Spawn the iOS **cold BGTask auto-start** probe after plugin setup returns.
251///
252/// `run_mobile_plugin` is synchronous on the Rust side. Calling it directly from
253/// plugin setup can deadlock startup on iOS: setup runs on the main thread, while
254/// the Swift command handlers marshal their work back onto `DispatchQueue.main`.
255/// Spawning the probe lets Tauri finish building the app and keeps the native
256/// bridge calls off the main thread.
257#[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/// Handle iOS cold auto-start when the process was launched for a pending BGTask.
266#[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            // No pending BGTask — normal launch.
279            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    // Read desired_running + last_start_config from the typed DTO (no untyped
293    // JSON reads), exactly as the warm auto-start path does.
294    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    // Set on_complete callback for iOS completeBgTask before Start so the actor
339    // captures the callback at spawn time.
340    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    // H3: clear the pending BGTask only after Start succeeds; on failure
358    // preserve it + record a failure marker. The clear lives inside
359    // `run_auto_start`, gated on `rx.await == Ok(Ok(()))`.
360    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/// Spawn the iOS **warm BGTask-delivery** listener (H14).
376///
377/// A BGTask delivered to a warm/idle process never re-runs the cold auto-start
378/// block (that runs once at setup), so without this the process would only
379/// persist the pending record and wait. This listener blocks on the Swift
380/// `waitForBgTask` Pending Invoke; each time `handleBackgroundTask`/
381/// `handleProcessingTask` resolves it, Rust drives [`run_warm_start`] — mirroring
382/// the cold sequence (re-send `SetOnComplete` → `Start` → consume on success),
383/// while a delivery to an already-running actor is a clean no-op.
384///
385/// The loop re-blocks after each delivery and exits when the invoke is rejected
386/// (`cancel_warm_listener`) or the blocking thread fails.
387#[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            // Block until iOS delivers a BGTask to the warm process.
393            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                // Rejected invoke (teardown) or join error → stop listening.
407                _ => {
408                    log::info!("iOS: warm BGTask listener stopped");
409                    break;
410                }
411            }
412        }
413    });
414}
415
416/// Handle a single warm BGTask delivery: read the typed pending + desired state
417/// and drive [`run_warm_start`], mirroring the cold auto-start block.
418#[cfg(target_os = "ios")]
419async fn ios_handle_warm_delivery<R: Runtime>(app: &AppHandle<R>) {
420    // Clone owned values out of managed state so no `State` borrow is held
421    // across the `run_warm_start` await.
422    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    // Read desired_running + last_start_config from the typed DTO (no untyped
438    // JSON reads), exactly as the cold auto-start path does.
439    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    // Re-send SetOnComplete so completeBgTask fires (take()n at spawn).
466    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    // On success: consume the pending record (M14 part 2) + spawn the cancel
475    // listener. On genuine failure: preserve the evidence + record a marker.
476    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/// Drive the iOS cold auto-start sequence and consume the pending BGTask
504/// **exactly once, only on success** (H3).
505///
506/// The pending record is the evidence that iOS launched us for a background
507/// task. Clearing it before `Start` actually succeeds means a failed start
508/// silently discards that evidence — the task never reruns and we can't tell
509/// it failed. So the clear is gated on the actor replying `Ok(Ok(()))`:
510/// - **success** → `on_success` (clear pending + spawn the cancel listener),
511///   logged "consumed after success".
512/// - **failure** (command channel closed, reply dropped, or `Start` errored) →
513///   `on_failure` (record a failure marker; the pending record is preserved
514///   because it is *not* cleared), logged "preserved after failure".
515///
516/// Extracted for host testability — mirrors [`run_cancel_listener`]. The iOS
517/// wiring injects the mobile side-effects (clear / record-failure / cancel
518/// listener) as closures so this core gating logic runs on the macOS test gate.
519///
520/// Returns `true` if `Start` succeeded (pending consumed), `false` otherwise.
521#[allow(dead_code)] // used on iOS + in tests
522async 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/// Drive the iOS **warm** BGTask-delivery start sequence (H14, M14 part 2).
568///
569/// A BGTask delivered to a warm/idle process must actually start the Rust
570/// service, not merely persist pending state and wait. This mirrors
571/// [`run_auto_start`] (the *cold* launch path) but runs while the process is
572/// already alive, so it must be a clean no-op when the actor is already running:
573///
574/// 1. **Guard `AlreadyRunning`** — pre-check `is_running`. A warm delivery to a
575///    running actor returns `false` without arming a stale `on_complete` or
576///    consuming the pending record (M14 part 2: it cannot re-arm a cold
577///    auto-start).
578/// 2. **Re-send `SetOnComplete`** — the actor `take()`s the callback at spawn,
579///    so a fresh one is required for each start; this is how `completeBgTask`
580///    fires (not the iOS safety timer).
581/// 3. **`Start`** → on success consume the pending record (`on_success`, which
582///    also spawns the cancel listener in production); on a genuine failure
583///    preserve the evidence and record a marker (`on_failure`).
584///
585/// Extracted for host testability — the iOS wiring injects the mobile
586/// side-effects (clear / record-failure / cancel listener) as closures so this
587/// core gating logic runs on the macOS test gate.
588///
589/// Returns `true` if a warm `Start` succeeded (pending consumed), `false`
590/// otherwise (no-op while running, or preserved-on-failure).
591#[allow(dead_code)] // used on iOS + in tests
592async 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    // Guard AlreadyRunning: a warm delivery to a running actor is a clean no-op.
601    // Pre-checking `is_running` (rather than reacting to a `Start` rejection)
602    // avoids arming a stale `on_complete`, which would otherwise fire for the
603    // wrong BGTask on the next legitimate start. The pending record is left for
604    // the running service to consume on its own completion.
605    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    // Re-send SetOnComplete: the actor `take()`s it at spawn, so a fresh callback
623    // is required for each start so `completeBgTask` fires (not the safety timer).
624    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        // Lost the race after the IsRunning pre-check — still a clean no-op, not
660        // a failure: the actor became running between the check and Start.
661        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 Commands ──────────────────────────────────────────────────────────
681
682#[tauri::command]
683async fn start<R: Runtime>(app: AppHandle<R>, config: StartConfig) -> Result<(), String> {
684    // OS service mode: route through persistent IPC client.
685    #[cfg(all(feature = "desktop-service", any(unix, windows)))]
686    if let Some(ipc_state) = app.try_state::<DesktopIpcState>() {
687        // Check if IPC is connected before sending the start request.
688        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        // IPC is disconnected. Check if auto-start is enabled.
697        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        // Try to start the OS service and wait for IPC readiness.
703        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        // IPC is now connected — send the start command.
729        return ipc_state
730            .client
731            .start(config)
732            .await
733            .map_err(|e| e.to_string());
734    }
735
736    // In-process mode (default).
737    // iOS: send SetOnComplete before Start so the callback is captured at spawn time.
738    ios_set_on_complete_callback(&app).await?;
739
740    // Mobile keepalive is now handled by the actor (Step 5).
741    // The actor calls start_keepalive AFTER the AlreadyRunning check.
742
743    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    // iOS: spawn cancel listener after Start succeeds.
760    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    // OS service mode: route through persistent IPC client.
769    #[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    // In-process mode (default).
775    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    // OS service mode: route through persistent IPC client.
791    #[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    // In-process mode (default).
797    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    // OS service mode: route through persistent IPC client.
813    #[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    // In-process mode (default).
823    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/// Query the iOS scheduling status from the native layer.
867///
868/// Returns `IOSSchedulingStatus` on iOS with scheduling results and desired state.
869/// Returns a default status (not scheduled) on non-iOS platforms.
870#[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/// Request the Android battery-optimization (Doze) exemption (BGS-22, doc-08
895/// Step 14).
896///
897/// Forwards to the Kotlin `requestBatteryExemption` @Command, which fires
898/// `startActivity(Intent(ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
899/// "package:<app>"))` to surface the system Doze-exemption dialog. The
900/// `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission is declared in the plugin
901/// `android/src/main/AndroidManifest.xml` but was previously never requested —
902/// this wires the honest user-granted flow (preferring the flow over dropping
903/// the permission). No-op on non-Android targets (the Kotlin @Command does not
904/// exist; iOS/desktop have no Doze analogue). There is no Rust-side status
905/// mirror: the exemption is OS-only and not queryable through this plugin.
906#[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/// Query the persisted iOS desired-state status from the native layer.
923///
924/// Returns `IOSDesiredStateStatus` on iOS with the persisted desired state.
925/// Returns a default status (not desired) on non-iOS platforms.
926#[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/// Query the pending iOS background task info.
955///
956/// Returns `Some(PendingTaskInfo)` on iOS if the app was launched by iOS for
957/// a background task and the info hasn't been cleared yet.
958/// Returns `None` on non-iOS platforms or when no pending task exists.
959#[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/// Query the POST_NOTIFICATIONS permission status (NTF-09).
976///
977/// Android returns the current status (`granted` | `notDetermined` | `denied`)
978/// via the Kotlin `getNotificationPermissionStatus` command, which resolves
979/// immediately — so the mobile call is made directly (mirroring
980/// `get_scheduling_status`). Non-Android returns a default `{status: "granted"}`
981/// so the command is callable cross-platform. (The iOS UN-prompt half of NTF-09
982/// is Step 10c, not this command.)
983#[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/// Request POST_NOTIFICATIONS permission (NTF-09).
1004///
1005/// Android runs the Kotlin `requestNotificationPermission` command, which on
1006/// API 33+ defers resolution to the OS permission dialog via the
1007/// `@PermissionCallback` (Step 10a). That call blocks `run_mobile_plugin`'s
1008/// `rx.recv()` for the dialog duration, so it is wrapped in
1009/// `tokio::task::spawn_blocking` to avoid blocking the async runtime (the
1010/// `wait_for_cancel` class). Non-Android returns a default `{status: "granted"}`.
1011#[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/// Whether the app may post a full-screen intent (NTF-16, Step 12c).
1033///
1034/// Android queries `IncomingCallNotifier.canUseFullScreenIntent` via the Kotlin
1035/// `canUseFullScreenIntent` command, which resolves immediately — so the mobile
1036/// call is made directly (mirroring `get_notification_permission_status`). Non-
1037/// Android defaults to `canUse: true` so the re-grant affordance never shows
1038/// (FSI is irrelevant off-Android).
1039///
1040/// IPC SHAPE CONTRACT: returns a `serde_json::Value` OBJECT `{ "canUse": bool }`
1041/// (NOT a bare `bool`) so it matches the TS `invoke<{ canUse: boolean }>` wrapper
1042/// and the UI consumer `result.canUse`. A bare `Result<bool, String>` would
1043/// serde-serialize to a bare JSON boolean; the TS generic is only a compile-time
1044/// cast, so `result.canUse` would be `undefined` at runtime and the `=== false`
1045/// re-grant gate would NEVER fire — silently killing NTF-16 on Android (the only
1046/// platform where it matters). The Kotlin layer resolves `{canUse: bool}` either
1047/// way; this command re-wraps the typed bool the mobile bridge extracts so the
1048/// object shape flows end-to-end. The fully-mocked vitest cannot reach this Rust
1049/// serde shape, so it is pinned statically by the
1050/// `ntf16_full_screen_intent_wire_is_present_and_unique` integration test.
1051#[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/// Open the OS settings page to re-grant USE_FULL_SCREEN_INTENT (NTF-16).
1071///
1072/// Android runs the Kotlin `openFullScreenIntentSettings` command, which
1073/// resolves immediately (startActivity) — NO `spawn_blocking` (unlike
1074/// `request_notification_permission`, this is not a deferred @PermissionCallback
1075/// flow). Non-Android is a no-op (the affordance never shows there).
1076#[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/// Enable auto-restart for the background service.
1093///
1094/// Persists `desired_running=true` with an optional start config WITHOUT
1095/// starting the service. This sets the intent for recovery after process
1096/// kill or device reboot. The platform recovery mechanisms will use this
1097/// to automatically restart the service when conditions allow.
1098#[tauri::command]
1099async fn enable_auto_restart<R: Runtime>(
1100    app: AppHandle<R>,
1101    config: Option<StartConfig>,
1102) -> Result<(), String> {
1103    // OS service mode: route through persistent IPC client.
1104    #[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/// Disable auto-restart for the background service.
1126///
1127/// Persists `desired_running=false` and clears recovery fields WITHOUT
1128/// stopping the service if it is currently running. After calling this,
1129/// the platform recovery mechanisms will no longer attempt to restart the
1130/// service after process kill or device reboot.
1131#[tauri::command]
1132async fn disable_auto_restart<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
1133    // OS service mode: route through persistent IPC client.
1134    #[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/// Get the persisted desired-state for the background service.
1156///
1157/// Returns `Some(DesiredState)` with the current recovery intent and metadata,
1158/// or `None` if no persistence backend is configured on the current platform.
1159#[tauri::command]
1160async fn get_desired_service_state<R: Runtime>(
1161    app: AppHandle<R>,
1162) -> Result<Option<desired_state::DesiredState>, String> {
1163    // OS service mode: route through persistent IPC client.
1164    #[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/// Notify the Rust actor of a native platform lifecycle event.
1184///
1185/// Called from the native layer (Kotlin/Swift) when the OS triggers a
1186/// lifecycle action that the Rust actor must handle — e.g. the user pressed
1187/// "Stop" on the Android foreground notification, or Android timed out the
1188/// foreground service.
1189///
1190/// The actor maps each [`NativeLifecycleEvent`] variant to the appropriate
1191/// [`StopReason`](models::StopReason) and dispatches through
1192/// [`handle_stop_with_reason`](manager::handle_stop_with_reason).
1193///
1194/// This command is not intended for end-user consumption — it is called by
1195/// the native plugin code.
1196#[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/// Validate the background service setup for the current platform.
1209///
1210/// Returns a [`SetupValidationReport`] with errors (blocking) and warnings
1211/// (non-blocking) about platform-specific prerequisites.
1212#[tauri::command]
1213#[allow(unused_variables)]
1214async fn validate_setup<R: Runtime>(
1215    app: AppHandle<R>,
1216) -> Result<models::SetupValidationReport, String> {
1217    // OS service mode: route through persistent IPC client.
1218    #[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/// Get the complete lifecycle status of the background service.
1240///
1241/// Returns a [`LifecycleStatus`] snapshot with current state, desired state,
1242/// recovery status, platform capabilities, and validation issues.
1243#[tauri::command]
1244async fn get_lifecycle_status<R: Runtime>(
1245    app: AppHandle<R>,
1246) -> Result<models::LifecycleStatus, String> {
1247    // OS service mode: route through persistent IPC client.
1248    #[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/// Configure recovery (auto-restart) for the background service.
1280///
1281/// When `enabled` is `true`, persists `desired_running=true` with an optional
1282/// start config (for recovery after process kill or device reboot).
1283/// When `enabled` is `false`, clears the recovery intent.
1284#[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// ─── Desktop OS Service State & Commands ──────────────────────────────────────
1298
1299/// Managed state indicating OS service mode via IPC.
1300///
1301/// When present as managed state, the `start`/`stop`/`is_running` commands
1302/// route through the persistent IPC client instead of the in-process actor loop.
1303#[cfg(all(feature = "desktop-service", any(unix, windows)))]
1304struct DesktopIpcState {
1305    client: desktop::ipc_client::PersistentIpcClientHandle,
1306}
1307
1308/// Set up OS-service mode: spawn the persistent IPC client, manage
1309/// [`DesktopIpcState`], and kick off auto-provisioning.
1310///
1311/// Called from plugin setup when `desktopServiceMode` is `"osService"` on a
1312/// desktop platform. After this returns, commands route through the IPC
1313/// client (the in-process `cmd_rx` is intentionally unused in this mode).
1314#[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    // BGS-12: auto-provision ONLY when the user has consented to the
1331    // background service. Consent OFF ⇒ no auto-provision ⇒ a manual
1332    // `systemctl --user disable` is never reverted on the next launch (the
1333    // systemd unit re-install / re-enable / start inside
1334    // `spawn_os_service_auto_provision` → `install_service_inner` is skipped
1335    // entirely). The consent record is read LIVE from disk (the plugin
1336    // cannot import the app crate, so it reads the stable plain-JSON
1337    // `background-service-consent.json` contract directly).
1338    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/// The on-disk background-service consent filename (the stable plain-JSON
1355/// contract owned by the app's `consent` module; the plugin reads it directly
1356/// because it cannot import the app crate without a circular dependency).
1357#[cfg(feature = "desktop-service")]
1358const DESKTOP_CONSENT_FILENAME: &str = "background-service-consent.json";
1359
1360/// Minimal mirror of the app's `BackgroundServiceConsent` on-disk record.
1361///
1362/// Only the `enabled` bool is read; serde ignores the record's other fields
1363/// (`auto_unlock`, `updated_at`) by default. Default-off on a missing/corrupt
1364/// record, matching the app's `load()` semantics.
1365#[cfg(feature = "desktop-service")]
1366#[derive(Debug, Default, serde::Deserialize)]
1367struct ProvisioningConsent {
1368    #[serde(default)]
1369    enabled: bool,
1370}
1371
1372/// Whether the persisted consent record allows the OS service to be
1373/// auto-provisioned (BGS-12). Provisioning gates on the master service
1374/// consent (`enabled`); the `auto_unlock` sub-consent governs credential
1375/// auto-unlock (F3 / `perform_run`), a separate concern. Default-off on a
1376/// missing/corrupt record ⇒ provisioning blocked until consent is recorded.
1377#[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/// The full auto-provision decision (BGS-12): the config must opt in
1388/// (`desktop_start_service_if_missing`) AND the user must have consented
1389/// (`enabled`). Both gates are load-bearing — the config gate alone left a
1390/// silent install with no consent; the consent gate alone ignores a host's
1391/// `desktopStartServiceIfMissing=false`.
1392#[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// PRODUCT DECISION: daemon crash-loop + restart cadence (doc 08, BGS-05 Leg C).
1404// `RestartSec` (5s between restarts) + `StartLimitBurst`/`StartLimitIntervalSec`
1405// (5 restarts per 60s ⇒ systemd stops the crash-loop). `InstallOptions::default()`
1406// stays None for all three so tests + non-prod callers opt out; only this prod
1407// caller opts in. systemd-native; launchd ignores the StartLimit fields.
1408//
1409// BGS-05 re-fix (Critic Blocker 1, cfg-attribute displacement): EACH const AND
1410// `install_service_inner` carries its OWN `#[cfg(feature = "desktop-service")]`.
1411// The Step-6 original had a single cfg above the FIRST const; because `//`
1412// comments are trivia, the attribute bound ONLY to that first const and SILENTLY
1413// left `install_service_inner` + the other two consts ungated — breaking the
1414// default-features build (their bodies reference the cfg-gated `desktop` module
1415// + feature-gated `PluginConfig` fields). One cfg per item is displacement-proof.
1416#[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    // Validate that the executable exists and is executable.
1431    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    // Verify the binary supports --service-label by spawning it with the flag
1439    // and checking for a specific exit behavior. We use a timeout to avoid
1440    // hanging if the binary starts a GUI.
1441    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            // Timed out — the binary probably started the GUI instead of handling
1467            // the service flag. Warn but don't block installation.
1468            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            // BGS-05 Leg C: the prod daemon autostarts with a real restart cadence
1482            // (RestartSec) + a crash-loop cap (StartLimitBurst/Interval). The
1483            // struct default remains None for all three.
1484            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    // Nudge the persistent IPC client to skip backoff and reconnect.
1494    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/// Auto-provision the OS service at app startup (install + start + IPC wait).
1505///
1506/// Spawned from plugin setup in OS-service mode when
1507/// `desktopStartServiceIfMissing` is enabled. If the daemon's IPC socket does
1508/// not become reachable within a short grace period, the service unit is
1509/// installed (idempotent) and started, then the persistent IPC client is
1510/// nudged to reconnect. Failures are logged; the host app keeps whatever
1511/// in-process fallback it set up.
1512#[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        // Grace period: an already-running daemon connects almost immediately.
1528        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        // Explicitly start the unit (install may only enable autostart).
1553        {
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// ─── Desktop OS Service Start/Stop/Status Commands ────────────────────────────
1605
1606/// Build an [`OsServiceStatus`] from available information.
1607///
1608/// Gathers the service label, mode string, IPC connection state, socket path,
1609/// and optional last error into a status snapshot.
1610#[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        // If not running via IPC, we can't easily determine install state
1629        // without calling external tools. Default to Installed if the manager
1630        // was constructable (caller checks this before calling build).
1631        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/// Start the OS-level background service (desktop only).
1645///
1646/// Delegates to [`DesktopServiceManager::start()`] (systemd, launchd, or
1647/// Windows SCM), then nudges the persistent IPC client to reconnect.
1648#[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        // Nudge the persistent IPC client to skip backoff and reconnect.
1663        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/// Stop the OS-level background service (desktop only).
1680///
1681/// Delegates to [`DesktopServiceManager::stop()`] (systemd, launchd, or
1682/// Windows SCM).
1683#[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/// Restart the OS-level background service (desktop only).
1703///
1704/// Calls stop (best-effort) then start via the platform service manager.
1705#[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(); // Best-effort stop — service may not be running.
1716        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/// Get the status of the OS-level background service (desktop only).
1726///
1727/// Returns [`OsServiceStatus`] with label, mode (systemd / launchd / scm),
1728/// IPC state, and socket or pipe path.
1729#[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/// Error string for OS-service commands on platforms with no IPC transport
1764/// (neither Unix domain sockets nor Windows named pipes).
1765#[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
1770// ─── Plugin Builder ──────────────────────────────────────────────────────────
1771
1772/// Create the Tauri plugin with your service factory.
1773///
1774/// ```rust,ignore
1775/// // MyService must implement BackgroundService<R>
1776/// tauri::Builder::default()
1777///     .plugin(tauri_plugin_background_service::init_with_service(|| MyService::new()))
1778/// ```
1779pub 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            // D1: lifecycle-notification policy, derived once at spawn.
1844            // Defaults keep every notification off; DEC-002 suppresses the
1845            // Android paths already covered by native Kotlin notifications.
1846            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            // One authoritative Rust-backed desired-state model on every platform
1852            // (H4 / D1). The mobile arm was previously hardcoded `None`, so iOS
1853            // status lied (`desired_running` always false) and the recovery
1854            // commands silently no-op'd. With a `Some(...)` backend the actor
1855            // persists desired state in an app-data-dir file, `build_lifecycle_status`
1856            // reports the real desired state, and the recovery commands mirror
1857            // their effect into Swift `UserDefaults` + BGTask scheduling
1858            // (see `manager::mirror_desired_to_native` / `MobileLifecycle::mirror_desired_state`).
1859            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            // Mode dispatch: spawn in-process actor or configure IPC for OS service.
1872            //
1873            // OS-service mode is strictly a DESKTOP concept. Android and iOS are
1874            // `unix` targets, so the cfg below must exclude mobile: otherwise a
1875            // consumer that enables `desktop-service` unconditionally and sets
1876            // `desktopServiceMode: "osService"` would route mobile into the
1877            // desktop IPC path, never spawn the actor loop, drop `cmd_rx`, and
1878            // every ManagerCommand (including Start) would fail on a closed
1879            // channel — the native foreground service would never start.
1880            #[cfg(all(feature = "desktop-service", any(unix, windows), not(mobile)))]
1881            if config.desktop_service_mode == "osService" {
1882                // OS service mode: spawn persistent IPC client.
1883                setup_os_service_ipc(app, &config)?;
1884            } else {
1885                // In-process mode (default): spawn the actor loop.
1886                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            // Mobile: ALWAYS spawn the in-process actor, regardless of any
1908            // desktop-service configuration. `DesktopIpcState` is never managed
1909            // on mobile, so the command-level IPC early-returns are inert.
1910            #[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            // Unknown desktop platform class (neither unix nor windows): no
1940            // IPC transport exists, so osService mode cannot be honored.
1941            #[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                    // On non-Unix platforms, only explicit in-process mode is available.
1951                    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                // Send SetMobile to actor so keepalive is managed by the actor.
2002                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                // Store for iOS callbacks and Android auto-start helpers.
2010                app.manage(lifecycle_arc);
2011            }
2012
2013            // iOS: auto-start when launched by OS for a pending BGTask.
2014            // The native bridge calls are spawned after setup returns so Swift
2015            // can service their main-queue work while Tauri continues startup.
2016            #[cfg(target_os = "ios")]
2017            {
2018                ios_spawn_cold_auto_start(app);
2019
2020                // H14: listen for BGTasks delivered to the warm process and
2021                // start the Rust service for each (the cold block above only
2022                // runs once at launch).
2023                ios_spawn_warm_listener(app);
2024            }
2025
2026            Ok(())
2027        })
2028        .on_event(|app, event| {
2029            if let tauri::RunEvent::Exit = event {
2030                // Android foreground service mode owns its lifecycle outside the
2031                // Activity. Closing the UI must not stop the background Core.
2032                #[cfg(target_os = "android")]
2033                {
2034                    let _ = app;
2035                    return;
2036                }
2037
2038                #[cfg(not(target_os = "android"))]
2039                {
2040                    // In OS service mode, the service runs in a separate process — skip.
2041                    #[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                    // H2: on iOS, app `Exit` is OS-driven backgrounding/termination,
2047                    // not a user stop. Route it through `ProcessExit` so desired
2048                    // state + the BGTask schedule survive (recovery resumes
2049                    // delivery). On desktop, closing the app is a genuine stop.
2050                    #[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    /// Minimal service for testing type compatibility.
2072    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    // ── Construction Tests ───────────────────────────────────────────────
2086
2087    #[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    // ── Compile-time Tests ───────────────────────────────────────────────
2116
2117    /// Verify `init_with_service` returns `TauriPlugin<R>`.
2118    #[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    /// Verify `start` command signature is generic over `R: Runtime`.
2130    #[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    /// Verify `stop` command signature is generic over `R: Runtime`.
2139    #[allow(dead_code)]
2140    async fn stop_command_signature<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
2141        stop(app).await
2142    }
2143
2144    /// Verify `is_running` command signature is async and generic over `R: Runtime`.
2145    #[allow(dead_code)]
2146    async fn is_running_command_signature<R: Runtime>(app: AppHandle<R>) -> bool {
2147        is_running(app).await
2148    }
2149
2150    /// Verify `get_service_state` command signature is async and generic over `R: Runtime`.
2151    #[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    /// Verify `get_scheduling_status` command signature is async and generic over `R: Runtime`.
2159    #[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    /// Verify `get_desired_state_status` command signature is async and generic over `R: Runtime`.
2167    #[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    /// Verify `get_pending_bg_task` command signature is async and generic over `R: Runtime`.
2175    #[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    /// Verify `get_notification_permission_status` command signature is async and generic over `R: Runtime`.
2183    #[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    /// Verify `request_notification_permission` command signature is async and generic over `R: Runtime`.
2191    #[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    /// Verify `can_use_full_screen_intent` command signature is async and generic over `R: Runtime`.
2199    /// Return type mirrors the command (`Result<serde_json::Value, String>` — the `{canUse: bool}`
2200    /// object shape; see the command's IPC SHAPE CONTRACT doc).
2201    #[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    /// Verify `open_full_screen_intent_settings` command signature is async and generic over `R: Runtime`.
2209    #[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    /// Verify `enable_auto_restart` command signature is async and generic over `R: Runtime`.
2217    #[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    /// Verify `disable_auto_restart` command signature is async and generic over `R: Runtime`.
2226    #[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    /// Verify `get_desired_service_state` command signature is async and generic over `R: Runtime`.
2234    #[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    /// Verify `validate_setup` command signature is async and generic over `R: Runtime`.
2242    #[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    /// Verify `native_lifecycle_event` command signature is async and generic over `R: Runtime`.
2250    #[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    /// Verify `get_lifecycle_status` command signature is async and generic over `R: Runtime`.
2259    #[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    /// Verify `configure_recovery` command signature is async and generic over `R: Runtime`.
2267    #[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    // ── Desktop IPC State Tests ─────────────────────────────────────────
2277
2278    /// Verify PersistentIpcClientHandle can be constructed.
2279    #[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        // The client is spawned but may not be connected yet — that's fine.
2287        // Just verify we can construct the state.
2288        let _state = DesktopIpcState { client };
2289    }
2290
2291    /// AC2: a config with `desktopServiceMode: "osService"` on the current
2292    /// platform constructs the IPC-client state — `DesktopIpcState` is managed
2293    /// so commands route through IPC instead of failing on a closed channel.
2294    #[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        // The auto-provision task reads PluginConfig from managed state.
2304        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    // ── BGS-12: consent gate on OS-service auto-provisioning ──────────────
2315
2316    /// The OS service auto-provisions ONLY when the config opts in AND the
2317    /// user has consented to the background service. This is the AC decision
2318    /// predicate; the spawn itself is undrivable in a unit test (real IPC +
2319    /// service-manager), so it is pinned by the include_str static gate below.
2320    #[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        // No consent record ⇒ default off ⇒ NO auto-provision, even when the
2327        // config opts in. NV-MUT (neuter the helper to return `true`) → RED.
2328        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        // Consent ON (enabled) ⇒ auto-provision allowed when config opts in.
2334        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        // start_if_missing=false ⇒ NO auto-provision even with consent (a
2345        // host config that opts out is respected; a manual disable sticks).
2346        assert!(
2347            !should_auto_provision(dir, false),
2348            "start_if_missing=false: must NOT auto-provision"
2349        );
2350    }
2351
2352    /// Provisioning gates on `enabled` (the master service consent), NOT on
2353    /// `auto_unlock` (the credential auto-unlock sub-consent — a separate
2354    /// concern owned by the F3 / `perform_run` gates). A corrupt record
2355    /// defaults off.
2356    #[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        // enabled=true, auto_unlock=false ⇒ service consented ⇒ provision OK.
2362        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        // Corrupt record ⇒ default off ⇒ no provisioning.
2373        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    /// include_str! static gate (mem-1783121224-667 pattern): pin that
2381    /// `setup_os_service_ipc` wires the consent gate before the
2382    /// auto-provision spawn. Runtime concat so the asserted token never
2383    /// appears verbatim on this line (defeats self-referential false-pinning).
2384    #[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        // Pin the else branch (the skip). The literal is split so it never
2394        // appears verbatim in this test source (defeats self-referential
2395        // false-pinning against `include_str!("lib.rs")`).
2396        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    /// BGS-21 (doc-08 Step 12) Rust-side reachability pin (mem-1783277823-8dae):
2404    /// the two notification-permission commands must be (1) declared in build.rs
2405    /// `COMMANDS` (drives permission-token autogeneration), (2) registered in
2406    /// `generate_handler!` (JS reachability — Tauri v2 JS invokes resolve only
2407    /// against registered Rust commands), and (3) bridged in mobile.rs via
2408    /// `run_mobile_plugin` with the EXACT camelCase Kotlin @Command names.
2409    /// Dropping any single axis silently ships a dead JS binding that fails at
2410    /// runtime (command-not-found / capability denial) — the mem-1783371281-d310
2411    /// class. NV-MUT: drop a build.rs entry / a generate_handler! registration /
2412    /// change a run_mobile_plugin name ⇒ this test REDs on the matching axis.
2413    /// The same-file (lib.rs) registration token is built by runtime concat so
2414    /// the asserted string never appears verbatim on this line (mem-1783121224-667
2415    /// self-reference guard); the build.rs + mobile.rs axes are cross-file ⇒ a
2416    /// direct `contains` is safe.
2417    #[test]
2418    fn bgs21_notification_permission_bridge_registered_and_wired() {
2419        // (1) build.rs COMMANDS entries (cross-file ⇒ direct contains is safe).
2420        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        // (2) generate_handler! registration (SAME file ⇒ runtime concat so the
2431        // asserted name+comma token does not appear verbatim in this source).
2432        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        // (3) mobile.rs bridges via run_mobile_plugin with the exact camelCase
2445        // Kotlin @Command names (cross-file ⇒ direct contains is safe).
2446        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    /// BGS-22 (doc-08 Step 14): the `request_battery_exemption` JS binding is
2458    /// reachable on ALL FOUR production axes (mem-1783371281-d310): (1) declared
2459    /// in build.rs COMMANDS, (2) registered in `generate_handler!`, (3) bridged
2460    /// in mobile.rs via `run_mobile_plugin` with the EXACT camelCase Kotlin
2461    /// `requestBatteryExemption` name, and (4) covered by an `allow-request-
2462    /// battery-exemption` permission token in both the autogenerated command
2463    /// table and the default permission set. Dropping any single axis silently
2464    /// ships a dead JS binding that fails at runtime (command-not-found /
2465    /// capability denial). NV-MUT: drop a build.rs entry / a generate_handler!
2466    /// registration / change a run_mobile_plugin name / remove the permission
2467    /// token ⇒ this test REDs on the matching axis. The same-file (lib.rs)
2468    /// registration token is built by runtime concat so the asserted string
2469    /// never appears verbatim on this line (mem-1783121224-667 self-reference
2470    /// guard); the cross-file axes (build.rs + mobile.rs + permissions) ⇒ a
2471    /// direct `contains` is safe.
2472    #[test]
2473    fn bgs22_battery_exemption_bridge_registered_and_wired() {
2474        // (1) build.rs COMMANDS entry (cross-file ⇒ direct contains is safe).
2475        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        // (2) generate_handler! registration (SAME file ⇒ runtime concat so the
2482        // asserted name+comma token does not appear verbatim in this source).
2483        let src = include_str!("lib.rs");
2484        let reg = ["request_battery_exempt", "ion,"].concat();
2485        assert!(
2486            src.contains(&reg[..]),
2487            "request_battery_exemption must be registered in generate_handler!"
2488        );
2489
2490        // (3) mobile.rs bridges via run_mobile_plugin with the exact camelCase
2491        // Kotlin @Command name (cross-file ⇒ direct contains is safe).
2492        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        // (4) permission token: an autogenerated allow-request-battery-exemption
2499        // command token exists AND is in the default permission set (cross-file
2500        // ⇒ direct contains is safe). Without both, the JS invoke is capability-
2501        // denied at runtime even though the command compiles + is registered.
2502        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    // ── Desktop Command Compile-time Tests ────────────────────────────────
2516
2517    /// Verify `install_service` command signature is generic over `R: Runtime`.
2518    #[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    /// Verify `uninstall_service` command signature is generic over `R: Runtime`.
2527    #[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    /// Verify `start_os_service` command signature is generic over `R: Runtime`.
2536    #[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    /// Verify `stop_os_service` command signature is generic over `R: Runtime`.
2545    #[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    /// Verify `restart_os_service` command signature is generic over `R: Runtime`.
2554    #[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    /// Verify `get_os_service_status` command signature is generic over `R: Runtime`.
2563    #[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    // ── Desktop OS Service Command Routing Tests ──────────────────────────
2572
2573    /// Test that `build_os_service_status` produces a valid OsServiceStatus
2574    /// with the correct fields populated.
2575    #[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    /// Test that `build_os_service_status` includes the correct mode string.
2591    #[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    // ── On-Event Shutdown Compile-time Test ─────────────────────────────────
2604
2605    /// Verify the on_event closure accessing ServiceManagerHandle<R> from managed
2606    /// state type-checks. Ensures the generic R is properly threaded through in
2607    /// the on_event context where stop_blocking() is called synchronously.
2608    #[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    // ── Cancel Listener Tests ───────────────────────────────────────────────
2621
2622    use crate::manager::ManagerCommand;
2623    use std::sync::atomic::AtomicBool;
2624
2625    /// Helper: spawn a background task that accepts one StopWithReason command and replies Ok(()).
2626    /// Returns a oneshot receiver that yields Some(reason) if StopWithReason was received.
2627    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        // wait_fn returns Ok(()) → simulates resolved invoke (safety timer / expiration)
2654        let stop_sent = run_cancel_listener(
2655            Box::new(|| Ok(())),
2656            Box::new(|| {}),
2657            cmd_tx,
2658            5, // timeout, shouldn't matter since wait_fn returns immediately
2659        )
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        // wait_fn returns Err → simulates rejected invoke (explicit stop / completion)
2677        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        // Use a channel to unblock the wait_fn when cancel_fn is called,
2701        // simulating how the real cancelCancelListener rejects the invoke.
2702        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel::<()>();
2703
2704        let stop_sent = run_cancel_listener(
2705            Box::new(move || {
2706                // Block until cancel_fn signals us (simulates wait_for_cancel blocking)
2707                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, // immediate timeout
2716        )
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        // wait_fn panics → simulates JoinError from spawn_blocking
2738        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        // JoinError is Ok(Err(_)) which falls into the `_ => false` branch
2747        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    // ── iOS Cold Auto-Start Tests (H3) ──────────────────────────────────────
2756
2757    /// Helper: spawn a background task that accepts one `Start` command and
2758    /// replies with `reply_with`. Returns a receiver that yields `true` if a
2759    /// `Start` command was observed.
2760    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    /// AC2 (H3): a successful auto-start consumes the pending BGTask exactly once
2782    /// and never records a failure marker.
2783    #[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    /// AC1 (H3): a forced `Start` failure preserves the pending evidence — the
2822    /// clear is NOT called — and records a failure marker.
2823    #[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    /// H3 edge: if the actor channel is closed before `Start` is delivered, the
2865    /// pending evidence is preserved (failure path), not silently consumed.
2866    #[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 the receiver so the send fails immediately.
2871        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    // ── iOS Warm Auto-Start Tests (H14, M14 part 2) ─────────────────────────
2905    //
2906    // A BGTask delivered to a *warm/idle* process must actually start the Rust
2907    // service (H14), mirroring the cold auto-start sequence: pre-check
2908    // AlreadyRunning → re-send SetOnComplete → Start → consume pending on
2909    // success. A warm delivery to an already-running actor is a clean no-op
2910    // (no double-start, no failure marker, pending NOT consumed) (M14 part 2).
2911
2912    /// Service whose `run()` blocks until cancelled — keeps `is_running` true so
2913    /// the "warm delivery while running" no-op path can be exercised.
2914    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    /// Service that completes `run()` immediately with success — used to prove the
2934    /// captured `on_complete` callback fires (vs the iOS safety timer).
2935    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    /// Spawn a real manager actor (mirrors `manager::tests::setup_manager`) so the
2954    /// warm-start `AlreadyRunning` pre-check and `on_complete` arming run against
2955    /// genuine actor state rather than a fake drain.
2956    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    /// AC1 (H14): a warm BGTask delivered to an idle actor with desired_running
2997    /// starts the service (`is_running` flips true) and consumes the pending
2998    /// record exactly once.
2999    #[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    /// AC2 (M14 part 2): a warm delivery to an already-running actor is a clean
3044    /// no-op — it does NOT double-start, does NOT record a failure marker, and
3045    /// does NOT consume the pending record a second time.
3046    #[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        // First warm delivery starts the service.
3055        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        // Second warm delivery, while running, must be a clean no-op.
3073        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    /// AC1 (H14): after a warm start the captured `on_complete` callback fires
3110    /// (proving SetOnComplete was re-sent and captured at spawn), not the iOS
3111    /// safety timer.
3112    #[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        // Wait for the immediately-completing service to fire the captured callback.
3137        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    /// H3/M14: a genuine `Start` failure on the warm path preserves the pending
3152    /// evidence (clear not called) and records a failure marker — distinct from
3153    /// the AlreadyRunning no-op.
3154    #[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        // Drain: reply IsRunning=false, swallow SetOnComplete, fail the Start.
3161        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    // ═══════════════════════════════════════════════════════════════════════
3213    //  IPC AUTO-START RECOVERY TESTS (Step 12)
3214    // ═══════════════════════════════════════════════════════════════════════
3215
3216    // Unix-only: these tests bind real Unix domain sockets via `test_helpers`.
3217    #[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        /// Verify that `wait_for_connected` returns `false` when the timeout
3225        /// expires without a server, and that the error message includes
3226        /// the socket path.
3227        #[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        /// Verify that `wait_for_connected` returns `true` once a server
3243        /// appears and the persistent client connects.
3244        #[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        /// Verify that `socket_path()` returns the path the handle was
3260        /// spawned with.
3261        #[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        /// Verify the disconnected path with `desktop_start_service_if_missing=false`
3275        /// returns an IPC error containing "ipcUnavailable".
3276        ///
3277        /// This tests the `start` command handler's disconnected branch
3278        /// by directly checking the error construction logic.
3279        #[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        /// Verify the timeout error includes the socket path for diagnostics.
3290        #[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}