tauri-plugin-background-service 1.0.1

Background service lifecycle plugin for Tauri v2 — run long-lived tasks on Android, iOS, and desktop
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! Headless sidecar entry point for desktop OS service mode.
//!
//! The [`headless_main`] function serves as the entry point for the sidecar
//! binary that runs the background service as an OS-level service. It parses
//! CLI arguments, binds the IPC socket, spawns the service manager actor loop,
//! and runs the IPC server until shutdown.
//!
//! # Usage
//!
//! ```rust,ignore
//! // src/headless.rs (in the user's app crate)
//! use tauri_plugin_background_service::headless_main;
//!
//! fn main() {
//!     let app = tauri::Builder::default()
//!         .build(tauri::generate_context!())
//!         .expect("failed to build headless app");
//!     headless_main(
//!         || Box::new(MyBackgroundService::new()),
//!         app.handle().clone(),
//!     );
//! }
//! ```

use std::sync::Arc;

use tauri::{AppHandle, Listener, Runtime};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

use crate::desired_state::DesiredStateBackend;
use crate::desktop::ipc::{socket_path, IpcEvent};
use crate::desktop::ipc_server::IpcServer;
use crate::manager::{manager_loop, ManagerCommand, ServiceManagerHandle};
use crate::models::PluginEvent;
#[cfg(test)]
use crate::models::StopReason;
use crate::service_trait::BackgroundService;

/// Parsed CLI arguments for the headless sidecar.
#[derive(Debug)]
struct ParsedArgs {
    service_label: String,
    validate_install: bool,
}

/// Parse CLI arguments in a single pass, extracting `--service-label` and
/// detecting `--validate-service-install`.
///
/// Returns a [`ParsedArgs`] on success, or a descriptive error message on failure.
fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
    let mut label = None;
    let mut validate = false;
    let mut iter = args.iter().skip(1); // skip program name
    while let Some(arg) = iter.next() {
        if arg == "--service-label" {
            let value = iter
                .next()
                .ok_or_else(|| "--service-label requires a value".to_string())?;
            if value.is_empty() {
                return Err("--service-label value must not be empty".to_string());
            }
            label = Some(value.clone());
        } else if arg == "--validate-service-install" {
            validate = true;
        }
    }
    Ok(ParsedArgs {
        service_label: label.ok_or_else(|| {
            "--service-label is required. Usage: <binary> --service-label <label>".to_string()
        })?,
        validate_install: validate,
    })
}

/// Entry point for the headless sidecar binary (no desired-state backend).
///
/// Thin delegator over [`headless_main_with_desired_state`] passing `None` for
/// the desired-state backend (no boot Start-replay). Preserved for plugin API
/// compatibility — the documented `headless_main(factory, app)` call shape and
/// existing consumers keep working unchanged.
pub fn headless_main<F, R>(factory: F, app: AppHandle<R>)
where
    F: Fn() -> Box<dyn BackgroundService<R>> + Send + Sync + 'static,
    R: Runtime,
{
    // No desired-state backend ⇒ no boot replay regardless; consent=false is
    // the fail-closed default for this back-compat wrapper.
    headless_main_with_desired_state(factory, app, None, false);
}

/// Run the daemon's graceful SIGTERM/SIGINT shutdown path (BGS-31, doc-08 Step 9).
///
/// Sends `ManagerCommand::Stop` (stop-reason policy, desired-state bookkeeping,
/// `lifecycle_state → Stopped`) AND `ManagerCommand::ShutdownGracefully`
/// (bounded Core-level drain via the service's `shutdown_gracefully` hook), and
/// awaits both replies before the caller cancels the IPC `CancellationToken`.
/// Factored out of the signal-handler arm so the host-verifiable `bgs31` test
/// can drive the SAME code path the real handler runs — a real SIGTERM is not
/// deliverable in a unit test. Tolerates `NotRunning` (a daemon that never
/// started a service has nothing to stop) and any `ShutdownGracefully` error
/// (logged): the bookkeeping + drain are best-effort before exit, never fatal.
pub async fn graceful_sigterm_shutdown<R: Runtime>(cmd_tx: &mpsc::Sender<ManagerCommand<R>>) {
    let handle = ServiceManagerHandle::new(cmd_tx.clone());
    // Stop: drive the existing manager_loop Stop arm so stop-reason policy,
    // desired-state bookkeeping, and lifecycle_state→Stopped all run (the path
    // the old SIGTERM arm SKIPPED). NotRunning ⇒ nothing was running; tolerate.
    if let Err(e) = handle.stop().await {
        log::warn!("graceful_sigterm_shutdown: Stop returned {e:?}");
    }
    // ShutdownGracefully: bounded Core drain (background_tasks + awaited
    // network shutdown) instead of the abrupt process-exit Drop abort.
    if let Err(e) = handle.shutdown_gracefully().await {
        log::warn!("graceful_sigterm_shutdown: ShutdownGracefully returned {e:?}");
    }
}

/// Entry point for the headless sidecar binary.
///
/// Parses `--service-label <label>` from CLI arguments, constructs the service
/// manager actor loop, binds the IPC socket, and runs the IPC server until
/// either the server shuts down or `SIGINT` (Ctrl+C) is received.
///
/// # Arguments
///
/// * `factory` — Factory closure that creates a fresh `Box<dyn BackgroundService<R>>`
///   per start. Must match the same factory used in the GUI app's `init_with_service()`.
/// * `app` — A minimal headless `AppHandle<R>`. Constructed via
///   `tauri::Builder::default().build(tauri::generate_context!())` with no
///   webview features enabled.
///
/// # Panics / Exit
///
/// Prints an error message to stderr and exits with code 1 if:
/// - `--service-label` is missing or invalid
/// - The tokio runtime fails to initialize
/// - The IPC socket fails to bind
pub fn headless_main_with_desired_state<F, R>(
    factory: F,
    app: AppHandle<R>,
    desired_state_backend: Option<Arc<dyn DesiredStateBackend>>,
    // BGS-05 re-fix (Critic Blocker 2 — Leg A/Leg B coordination): the host app
    // app's live consent decision (`consent.enabled && consent.auto_unlock`),
    // threaded into the manager loop's boot Start-replay guard. Fail-closed:
    // callers without a consent policy pass `false` (no replay). See manager_loop
    // for why this is belt-and-suspenders alongside the F3 builder gate.
    consent_allows_auto_unlock: bool,
) where
    F: Fn() -> Box<dyn BackgroundService<R>> + Send + Sync + 'static,
    R: Runtime,
{
    let args: Vec<String> = std::env::args().collect();

    let parsed = parse_args(&args).unwrap_or_else(|e| {
        eprintln!("error: {e}");
        std::process::exit(1);
    });

    // Early-exit for install validation: the GUI process spawns us with
    // --validate-service-install to confirm we handle --service-label.
    // Exit immediately before binding sockets or spawning tasks.
    if parsed.validate_install {
        println!("ok");
        std::process::exit(0);
    }

    let label = parsed.service_label;

    tauri::async_runtime::block_on(async move {
        let (cmd_tx, cmd_rx) = mpsc::channel(16);
        tauri::async_runtime::spawn(manager_loop(
            cmd_rx,
            Box::new(factory),
            0.0,
            0.0,
            0.0,
            0.0,
            false,
            false,
            4.0,
            desired_state_backend,
            vec!["remoteMessaging".into()],
            false,
            crate::notifier::NotifierPolicy::default(),
            None,
            // BGS-05 Leg B: thread the headless AppHandle so the manager loop
            // can replay a Start on boot from the persisted desired-state.
            Some(app.clone()),
            consent_allows_auto_unlock,
        ));

        let path = match socket_path(&label) {
            Ok(p) => p,
            Err(e) => {
                eprintln!("error: invalid service label: {e}");
                return;
            }
        };
        // Clone app handle for event relay listener before moving into IpcServer.
        let app_for_events = app.clone();
        // BGS-31 (doc-08 Step 9): clone cmd_tx before it is moved into
        // IpcServer::bind so the SIGTERM/SIGINT handler can drive the graceful
        // ManagerCommand::Stop + ShutdownGracefully path (the IPC server owns
        // the original sender for the lifetime of the run loop).
        let cmd_tx_for_signal = cmd_tx.clone();
        let server = match IpcServer::bind(path, cmd_tx, app) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("error: failed to bind IPC socket: {e}");
                return;
            }
        };

        // Set up event relay: subscribe to actor-emitted PluginEvents on the
        // headless AppHandle and forward them as IpcEvents to connected clients.
        // This must happen BEFORE server.run() to avoid missing early events.
        let event_tx = server.event_sender();
        let _listener = app_for_events.listen("background-service://event", move |event| {
            if let Ok(plugin_event) = serde_json::from_str::<PluginEvent>(event.payload()) {
                let ipc_event = match plugin_event {
                    PluginEvent::Started => IpcEvent::Started,
                    PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
                    PluginEvent::Error { message } => IpcEvent::Error { message },
                };
                if event_tx.send(ipc_event).is_err() {
                    log::warn!("headless event relay: broadcast channel closed during shutdown");
                }
            }
        });

        let shutdown = CancellationToken::new();

        // Handle both SIGINT (Ctrl+C) and SIGTERM (systemd stop).
        // SIGTERM is Unix-only; Windows doesn't have it.
        #[cfg(unix)]
        {
            let mut sigterm =
                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
                    .expect("failed to install SIGTERM handler");
            tokio::select! {
                _ = server.run(shutdown.clone()) => {}
                _ = tokio::signal::ctrl_c() => {
                    // BGS-31: graceful stop (Stop bookkeeping + bounded Core
                    // drain) BEFORE the IPC token cancel, so SIGINT does not
                    // tear the Core down purely by process-exit Drop abort.
                    graceful_sigterm_shutdown(&cmd_tx_for_signal).await;
                    shutdown.cancel();
                }
                _ = sigterm.recv() => {
                    // BGS-31: on SIGTERM (systemctl stop / package upgrade) run
                    // the bookkeeping Stop + the bounded Core drain before the
                    // IPC token cancel — peers see a clean disconnect instead of
                    // an abrupt Drop abort mid-ingest.
                    graceful_sigterm_shutdown(&cmd_tx_for_signal).await;
                    shutdown.cancel();
                }
            }
        }
        #[cfg(not(unix))]
        {
            tokio::select! {
                _ = server.run(shutdown.clone()) => {}
                _ = tokio::signal::ctrl_c() => {
                    // BGS-31: graceful stop (Stop bookkeeping + bounded Core
                    // drain) BEFORE the IPC token cancel.
                    graceful_sigterm_shutdown(&cmd_tx_for_signal).await;
                    shutdown.cancel();
                }
            }
        }
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── AC1: CLI arg parsing works ─────────────────────────────────────

    #[test]
    fn parse_args_extracts_service_label() {
        let args = vec![
            "my-app-headless".to_string(),
            "--service-label".to_string(),
            "com.example.svc".to_string(),
        ];
        let parsed = parse_args(&args).unwrap();
        assert_eq!(parsed.service_label, "com.example.svc");
        assert!(!parsed.validate_install);
    }

    #[test]
    fn parse_args_extracts_label_with_other_args() {
        let args = vec![
            "my-app-headless".to_string(),
            "--verbose".to_string(),
            "--service-label".to_string(),
            "com.example.svc".to_string(),
            "--other".to_string(),
        ];
        let parsed = parse_args(&args).unwrap();
        assert_eq!(parsed.service_label, "com.example.svc");
    }

    // ── AC2: Missing label produces error ──────────────────────────────

    #[test]
    fn parse_args_rejects_missing_label() {
        let args = vec!["my-app-headless".to_string()];
        let result = parse_args(&args);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("--service-label"),
            "Error should mention --service-label: {err}"
        );
    }

    #[test]
    fn parse_args_rejects_label_without_value() {
        let args = vec!["my-app-headless".to_string(), "--service-label".to_string()];
        let result = parse_args(&args);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("value"),
            "Error should mention missing value: {err}"
        );
    }

    #[test]
    fn parse_args_rejects_empty_label() {
        let args = vec![
            "my-app-headless".to_string(),
            "--service-label".to_string(),
            "".to_string(),
        ];
        let result = parse_args(&args);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("empty"),
            "Error should mention empty value: {err}"
        );
    }

    // ── AC3: --validate-service-install flag detection ────────────────────

    #[test]
    fn parse_args_detects_validate_flag() {
        let args = vec![
            "my-app-headless".to_string(),
            "--service-label".to_string(),
            "com.example.svc".to_string(),
            "--validate-service-install".to_string(),
        ];
        let parsed = parse_args(&args).unwrap();
        assert!(parsed.validate_install);
        assert_eq!(parsed.service_label, "com.example.svc");
    }

    #[test]
    fn parse_args_validate_flag_absent() {
        let args = vec![
            "my-app-headless".to_string(),
            "--service-label".to_string(),
            "com.example.svc".to_string(),
        ];
        let parsed = parse_args(&args).unwrap();
        assert!(!parsed.validate_install);
    }

    // ── Combined: both flags in mixed order ────────────────────────────────

    #[test]
    fn parse_args_both_flags_in_mixed_order() {
        let args = vec![
            "my-app-headless".to_string(),
            "--validate-service-install".to_string(),
            "--service-label".to_string(),
            "com.example.svc".to_string(),
        ];
        let parsed = parse_args(&args).unwrap();
        assert_eq!(parsed.service_label, "com.example.svc");
        assert!(parsed.validate_install);
    }

    // ── Event mapping: PluginEvent → IpcEvent ─────────────────────────────

    #[test]
    fn plugin_event_maps_to_ipc_event_started() {
        let plugin_event = PluginEvent::Started;
        let json = serde_json::to_string(&plugin_event).unwrap();
        let parsed: PluginEvent = serde_json::from_str(&json).unwrap();
        let ipc_event: IpcEvent = match parsed {
            PluginEvent::Started => IpcEvent::Started,
            PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
            PluginEvent::Error { message } => IpcEvent::Error { message },
        };
        assert!(matches!(ipc_event, IpcEvent::Started));
    }

    #[test]
    fn plugin_event_maps_to_ipc_event_stopped() {
        let plugin_event = PluginEvent::Stopped {
            reason: StopReason::TaskCompleted,
        };
        let json = serde_json::to_string(&plugin_event).unwrap();
        let parsed: PluginEvent = serde_json::from_str(&json).unwrap();
        let ipc_event: IpcEvent = match parsed {
            PluginEvent::Started => IpcEvent::Started,
            PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
            PluginEvent::Error { message } => IpcEvent::Error { message },
        };
        match ipc_event {
            IpcEvent::Stopped { reason } => assert_eq!(reason, StopReason::TaskCompleted),
            other => panic!("Expected Stopped, got {other:?}"),
        }
    }

    #[test]
    fn plugin_event_maps_to_ipc_event_error() {
        let plugin_event = PluginEvent::Error {
            message: "init failed".into(),
        };
        let json = serde_json::to_string(&plugin_event).unwrap();
        let parsed: PluginEvent = serde_json::from_str(&json).unwrap();
        let ipc_event: IpcEvent = match parsed {
            PluginEvent::Started => IpcEvent::Started,
            PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
            PluginEvent::Error { message } => IpcEvent::Error { message },
        };
        match ipc_event {
            IpcEvent::Error { message } => assert_eq!(message, "init failed"),
            other => panic!("Expected Error, got {other:?}"),
        }
    }

    #[test]
    fn event_sender_broadcasts_mapped_events() {
        use tokio::sync::broadcast;

        let (tx, _) = broadcast::channel::<IpcEvent>(32);
        // Subscribe BEFORE sending (broadcast only delivers to active receivers)
        let mut rx = tx.subscribe();

        let plugin_event = PluginEvent::Error {
            message: "test error".into(),
        };
        let ipc_event = match plugin_event {
            PluginEvent::Started => IpcEvent::Started,
            PluginEvent::Stopped { reason } => IpcEvent::Stopped { reason },
            PluginEvent::Error { message } => IpcEvent::Error { message },
        };
        let _ = tx.send(ipc_event);

        let received = rx.try_recv().unwrap();
        match received {
            IpcEvent::Error { message } => assert_eq!(message, "test error"),
            other => panic!("Expected Error event, got {other:?}"),
        }
    }
}