Skip to main content

tauri_plugin_background_service/
models.rs

1//! Data types shared between the plugin's Rust core and the JS/Tauri layer.
2//!
3//! - [`ServiceContext`] is passed to every [`BackgroundService`](crate::BackgroundService) method.
4//! - [`StartConfig`] and [`PluginConfig`] control service and plugin behaviour.
5//! - [`PluginEvent`] represents events emitted to the JavaScript front-end.
6
7use serde::{Deserialize, Serialize};
8use tauri::Runtime;
9use tokio_util::sync::CancellationToken;
10
11use crate::error::ServiceError;
12use crate::notifier::Notifier;
13
14/// The 14 valid Android foreground service types.
15///
16/// Sourced from <https://developer.android.com/about/versions/14/changes/fg-types>.
17/// Unknown types are rejected with [`ServiceError::Platform`] at both the Rust
18/// and Kotlin layers.
19pub const VALID_FOREGROUND_SERVICE_TYPES: &[&str] = &[
20    "dataSync",
21    "mediaPlayback",
22    "phoneCall",
23    "location",
24    "connectedDevice",
25    "mediaProjection",
26    "camera",
27    "microphone",
28    "health",
29    "remoteMessaging",
30    "systemExempted",
31    "shortService",
32    "specialUse",
33    "mediaProcessing",
34];
35
36/// Validate a foreground service type against the allowlist.
37///
38/// Returns `Ok(())` if the type is one of the 14 valid Android foreground
39/// service types, or `Err(ServiceError::Platform)` for unknown strings.
40pub fn validate_foreground_service_type(t: &str) -> Result<(), ServiceError> {
41    if VALID_FOREGROUND_SERVICE_TYPES.contains(&t) {
42        Ok(())
43    } else {
44        Err(ServiceError::Platform(format!(
45            "invalid foreground_service_type '{}'. Valid types: {:?}",
46            t, VALID_FOREGROUND_SERVICE_TYPES
47        )))
48    }
49}
50
51/// Passed into both `init` and `run`.
52/// Gives your service everything it needs to interact with the outside world.
53pub struct ServiceContext<R: Runtime> {
54    /// Fire a local notification. Works on all platforms.
55    pub notifier: Notifier<R>,
56
57    /// Emit an event to the JS UI layer.
58    pub app: tauri::AppHandle<R>,
59
60    /// Cancelled when `stopService()` is called.
61    pub shutdown: CancellationToken,
62
63    /// Text shown in the Android persistent notification.
64    /// Only available on mobile platforms.
65    #[cfg(mobile)]
66    pub service_label: String,
67
68    /// Android foreground service type (e.g. "dataSync", "specialUse").
69    /// Only available on mobile platforms.
70    #[cfg(mobile)]
71    pub foreground_service_type: String,
72}
73
74/// Optional startup configuration forwarded from JS through the plugin.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct StartConfig {
78    /// Text shown in the Android persistent foreground notification.
79    #[serde(default = "default_label")]
80    pub service_label: String,
81
82    /// Android foreground service type (e.g. "dataSync", "specialUse").
83    #[serde(default = "default_foreground_service_type")]
84    pub foreground_service_type: String,
85}
86
87fn default_label() -> String {
88    "Service running".into()
89}
90
91fn default_foreground_service_type() -> String {
92    "dataSync".into()
93}
94
95/// Plugin-level configuration, deserialized from the Tauri plugin config.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct PluginConfig {
99    /// iOS safety timeout in seconds for the expiration handler.
100    /// Default: 28.0 (Apple recommends keeping BG tasks under ~30s).
101    #[serde(default = "default_ios_safety_timeout")]
102    pub ios_safety_timeout_secs: f64,
103
104    /// iOS cancel listener timeout in seconds.
105    /// Default: 14400 (4 hours). Balances leak risk vs. service lifetime.
106    #[serde(default = "default_ios_cancel_listener_timeout_secs")]
107    pub ios_cancel_listener_timeout_secs: u64,
108
109    /// iOS BGProcessingTask safety timeout in seconds.
110    /// Default: 0.0 (no cap). Processing tasks can run for minutes/hours,
111    /// so unlike BGAppRefreshTask (28s default), this defaults to uncapped.
112    /// Set to a positive value to impose a hard cap on processing task duration.
113    #[serde(default = "default_ios_processing_safety_timeout_secs")]
114    pub ios_processing_safety_timeout_secs: f64,
115
116    /// iOS `BGAppRefreshTask` earliest begin date in minutes from now.
117    /// Default: 15.0. Controls how soon iOS can launch the refresh task.
118    #[serde(default = "default_ios_earliest_refresh_begin_minutes")]
119    pub ios_earliest_refresh_begin_minutes: f64,
120
121    /// iOS `BGProcessingTask` earliest begin date in minutes from now.
122    /// Default: 15.0. Controls how soon iOS can launch the processing task.
123    #[serde(default = "default_ios_earliest_processing_begin_minutes")]
124    pub ios_earliest_processing_begin_minutes: f64,
125
126    /// iOS `BGProcessingTask` requires external power.
127    /// Default: false. When true, iOS only launches the processing task
128    /// while the device is connected to power.
129    #[serde(default)]
130    pub ios_requires_external_power: bool,
131
132    /// iOS `BGProcessingTask` requires network connectivity.
133    /// Default: false. When true, iOS only launches the processing task
134    /// when the device has network access.
135    #[serde(default)]
136    pub ios_requires_network_connectivity: bool,
137
138    /// Capacity for the manager command channel (mpsc).
139    /// Default: 16. Increase for high-throughput scenarios with many
140    /// concurrent start/stop/is-running calls.
141    #[serde(default = "default_channel_capacity")]
142    pub channel_capacity: usize,
143
144    /// Desktop service mode: "inProcess" (default) or "osService".
145    /// Controls whether the background service runs in-process or as a
146    /// registered OS service/daemon.
147    #[cfg(feature = "desktop-service")]
148    #[serde(default = "default_desktop_service_mode")]
149    pub desktop_service_mode: String,
150
151    /// Optional custom label for the desktop OS service registration.
152    /// When `None`, the label is auto-derived from the app identifier.
153    #[cfg(feature = "desktop-service")]
154    #[serde(default)]
155    pub desktop_service_label: Option<String>,
156}
157
158fn default_ios_safety_timeout() -> f64 {
159    28.0
160}
161
162fn default_ios_cancel_listener_timeout_secs() -> u64 {
163    14400
164}
165
166fn default_ios_processing_safety_timeout_secs() -> f64 {
167    0.0
168}
169
170fn default_ios_earliest_refresh_begin_minutes() -> f64 {
171    15.0
172}
173
174fn default_ios_earliest_processing_begin_minutes() -> f64 {
175    15.0
176}
177
178fn default_channel_capacity() -> usize {
179    16
180}
181
182#[cfg(feature = "desktop-service")]
183fn default_desktop_service_mode() -> String {
184    "inProcess".into()
185}
186
187impl Default for StartConfig {
188    fn default() -> Self {
189        Self {
190            service_label: default_label(),
191            foreground_service_type: default_foreground_service_type(),
192        }
193    }
194}
195
196/// Lifecycle state of the background service.
197///
198/// Exposed via the `get-service-state` command to provide
199/// fine-grained status beyond a simple boolean.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202#[non_exhaustive]
203pub enum ServiceState {
204    /// No service has been started, or the last run has fully cleaned up.
205    Idle,
206    /// `init()` is in progress (between `Start` and successful `init()`).
207    Initializing,
208    /// `init()` succeeded; `run()` is executing.
209    Running,
210    /// Service stopped (by `stop()`, natural completion, or error).
211    Stopped,
212}
213
214/// Snapshot of the service lifecycle status.
215///
216/// Returned by the `get-service-state` command.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct ServiceStatus {
220    /// Current lifecycle state.
221    pub state: ServiceState,
222    /// Last error message, if the service stopped due to an error.
223    pub last_error: Option<String>,
224}
225
226/// Built-in event types emitted by the runner itself.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase", tag = "type")]
229#[non_exhaustive]
230pub enum PluginEvent {
231    /// init() completed successfully
232    Started,
233    /// run() returned or was cancelled
234    Stopped { reason: String },
235    /// init() or run() returned an error
236    Error { message: String },
237}
238
239impl Default for PluginConfig {
240    fn default() -> Self {
241        Self {
242            ios_safety_timeout_secs: default_ios_safety_timeout(),
243            ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
244            ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
245            ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
246            ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
247            ios_requires_external_power: false,
248            ios_requires_network_connectivity: false,
249            channel_capacity: default_channel_capacity(),
250            #[cfg(feature = "desktop-service")]
251            desktop_service_mode: default_desktop_service_mode(),
252            #[cfg(feature = "desktop-service")]
253            desktop_service_label: None,
254        }
255    }
256}
257
258/// Arguments sent to the native `startKeepalive` handler.
259///
260/// Lives in `models.rs` (not `mobile.rs`) so serde tests run on all platforms.
261#[derive(Debug, Serialize)]
262#[serde(rename_all = "camelCase")]
263#[allow(dead_code)]
264pub(crate) struct StartKeepaliveArgs<'a> {
265    pub label: &'a str,
266    pub foreground_service_type: &'a str,
267    /// iOS safety timeout in seconds. Only sent to iOS; `None` omits the key.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub ios_safety_timeout_secs: Option<f64>,
270    /// iOS BGProcessingTask safety timeout in seconds. Only sent to iOS; `None` omits the key.
271    /// When `Some(positive)`, caps the processing task duration.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub ios_processing_safety_timeout_secs: Option<f64>,
274    /// iOS BGAppRefreshTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub ios_earliest_refresh_begin_minutes: Option<f64>,
277    /// iOS BGProcessingTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub ios_earliest_processing_begin_minutes: Option<f64>,
280    /// iOS BGProcessingTask requires external power. Only sent to iOS; `None` omits the key.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub ios_requires_external_power: Option<bool>,
283    /// iOS BGProcessingTask requires network connectivity. Only sent to iOS; `None` omits the key.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub ios_requires_network_connectivity: Option<bool>,
286}
287
288/// Auto-start config returned by the Kotlin bridge.
289///
290/// Deserialized from SharedPreferences values read by `getAutoStartConfig`.
291/// Only used on Android (the iOS path doesn't have auto-start).
292#[doc(hidden)]
293#[derive(Debug, Clone, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct AutoStartConfig {
296    pub pending: bool,
297    pub label: Option<String>,
298    pub service_type: Option<String>,
299}
300
301impl AutoStartConfig {
302    /// Convert to `StartConfig` if auto-start is pending and label is available.
303    pub fn into_start_config(self) -> Option<StartConfig> {
304        if self.pending {
305            self.label.map(|label| StartConfig {
306                service_label: label,
307                foreground_service_type: self
308                    .service_type
309                    .unwrap_or_else(default_foreground_service_type),
310            })
311        } else {
312            None
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    // --- StartConfig tests ---
322
323    #[test]
324    fn start_config_default_label() {
325        let config = StartConfig::default();
326        assert_eq!(config.service_label, "Service running");
327    }
328
329    #[test]
330    fn start_config_custom_label() {
331        let config = StartConfig {
332            service_label: "Syncing data".into(),
333            ..Default::default()
334        };
335        assert_eq!(config.service_label, "Syncing data");
336    }
337
338    #[test]
339    fn start_config_serde_roundtrip_default() {
340        let config = StartConfig::default();
341        let json = serde_json::to_string(&config).unwrap();
342        let de: StartConfig = serde_json::from_str(&json).unwrap();
343        assert_eq!(de.service_label, config.service_label);
344    }
345
346    #[test]
347    fn start_config_serde_roundtrip_custom() {
348        let config = StartConfig {
349            service_label: "My service".into(),
350            ..Default::default()
351        };
352        let json = serde_json::to_string(&config).unwrap();
353        let de: StartConfig = serde_json::from_str(&json).unwrap();
354        assert_eq!(de.service_label, "My service");
355    }
356
357    #[test]
358    fn start_config_deserialize_missing_field_uses_default() {
359        // An empty JSON object should produce the default label
360        let json = "{}";
361        let de: StartConfig = serde_json::from_str(json).unwrap();
362        assert_eq!(de.service_label, "Service running");
363    }
364
365    #[test]
366    fn start_config_json_key_is_camel_case() {
367        let config = StartConfig {
368            service_label: "test".into(),
369            ..Default::default()
370        };
371        let json = serde_json::to_string(&config).unwrap();
372        assert!(
373            json.contains("serviceLabel"),
374            "JSON should use camelCase: {json}"
375        );
376    }
377
378    // --- PluginEvent tests ---
379
380    #[test]
381    fn plugin_event_started_serde_roundtrip() {
382        let event = PluginEvent::Started;
383        let json = serde_json::to_string(&event).unwrap();
384        let de: PluginEvent = serde_json::from_str(&json).unwrap();
385        assert!(matches!(de, PluginEvent::Started));
386    }
387
388    #[test]
389    fn plugin_event_stopped_serde_roundtrip() {
390        let event = PluginEvent::Stopped {
391            reason: "cancelled".into(),
392        };
393        let json = serde_json::to_string(&event).unwrap();
394        let de: PluginEvent = serde_json::from_str(&json).unwrap();
395        match de {
396            PluginEvent::Stopped { reason } => assert_eq!(reason, "cancelled"),
397            other => panic!("Expected Stopped, got {other:?}"),
398        }
399    }
400
401    #[test]
402    fn plugin_event_error_serde_roundtrip() {
403        let event = PluginEvent::Error {
404            message: "init failed".into(),
405        };
406        let json = serde_json::to_string(&event).unwrap();
407        let de: PluginEvent = serde_json::from_str(&json).unwrap();
408        match de {
409            PluginEvent::Error { message } => assert_eq!(message, "init failed"),
410            other => panic!("Expected Error, got {other:?}"),
411        }
412    }
413
414    #[test]
415    fn plugin_event_tagged_json_format() {
416        let event = PluginEvent::Started;
417        let json = serde_json::to_string(&event).unwrap();
418        assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
419    }
420
421    #[test]
422    fn plugin_event_stopped_json_keys_camel_case() {
423        let event = PluginEvent::Stopped {
424            reason: "done".into(),
425        };
426        let json = serde_json::to_string(&event).unwrap();
427        assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
428        assert!(json.contains("\"reason\":\"done\""), "Reason: {json}");
429    }
430
431    #[test]
432    fn plugin_event_error_json_keys_camel_case() {
433        let event = PluginEvent::Error {
434            message: "oops".into(),
435        };
436        let json = serde_json::to_string(&event).unwrap();
437        assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
438        assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
439    }
440
441    // --- StartConfig foreground_service_type tests ---
442
443    #[test]
444    fn start_config_default_service_type() {
445        let config = StartConfig::default();
446        assert_eq!(config.foreground_service_type, "dataSync");
447    }
448
449    #[test]
450    fn start_config_custom_service_type() {
451        let config = StartConfig {
452            service_label: "test".into(),
453            foreground_service_type: "specialUse".into(),
454        };
455        assert_eq!(config.foreground_service_type, "specialUse");
456    }
457
458    #[test]
459    fn start_config_serde_roundtrip_service_type() {
460        let config = StartConfig {
461            service_label: "test".into(),
462            foreground_service_type: "specialUse".into(),
463        };
464        let json = serde_json::to_string(&config).unwrap();
465        let de: StartConfig = serde_json::from_str(&json).unwrap();
466        assert_eq!(de.foreground_service_type, "specialUse");
467    }
468
469    #[test]
470    fn start_config_deserialize_missing_service_type() {
471        let json = r#"{"serviceLabel":"test"}"#;
472        let de: StartConfig = serde_json::from_str(json).unwrap();
473        assert_eq!(de.foreground_service_type, "dataSync");
474    }
475
476    #[test]
477    fn start_config_deserialize_special_use() {
478        let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
479        let de: StartConfig = serde_json::from_str(json).unwrap();
480        assert_eq!(de.foreground_service_type, "specialUse");
481    }
482
483    #[test]
484    fn start_config_unrecognized_type_rejected_by_validation() {
485        // Deserialization still passes through any string.
486        let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
487        let de: StartConfig = serde_json::from_str(json).unwrap();
488        assert_eq!(de.foreground_service_type, "customType");
489        // But validation rejects it.
490        let result = validate_foreground_service_type(&de.foreground_service_type);
491        assert!(
492            result.is_err(),
493            "validation should reject unrecognized type"
494        );
495        let err_msg = result.unwrap_err().to_string();
496        assert!(
497            err_msg.contains("customType"),
498            "error should mention the invalid type: {err_msg}"
499        );
500    }
501
502    #[test]
503    fn start_config_json_key_is_camel_case_service_type() {
504        let config = StartConfig {
505            service_label: "test".into(),
506            foreground_service_type: "specialUse".into(),
507        };
508        let json = serde_json::to_string(&config).unwrap();
509        assert!(
510            json.contains("foregroundServiceType"),
511            "JSON should use camelCase: {json}"
512        );
513    }
514
515    // --- AutoStartConfig tests ---
516
517    #[test]
518    fn auto_start_config_pending_with_label_returns_start_config() {
519        let json = r#"{"pending": true, "label": "Syncing"}"#;
520        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
521        let result = config.into_start_config();
522        assert!(result.is_some());
523        let start_config = result.unwrap();
524        assert_eq!(start_config.service_label, "Syncing");
525        assert_eq!(start_config.foreground_service_type, "dataSync");
526    }
527
528    #[test]
529    fn auto_start_config_not_pending_returns_none() {
530        let json = r#"{"pending": false, "label": null}"#;
531        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
532        let result = config.into_start_config();
533        assert!(result.is_none());
534    }
535
536    #[test]
537    fn auto_start_config_pending_no_label_returns_none() {
538        let json = r#"{"pending": true, "label": null}"#;
539        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
540        let result = config.into_start_config();
541        assert!(result.is_none());
542    }
543
544    #[test]
545    fn auto_start_config_with_service_type_preserves_it() {
546        let json = r#"{"pending":true,"label":"test","serviceType":"specialUse"}"#;
547        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
548        assert_eq!(config.service_type, Some("specialUse".to_string()));
549        let result = config.into_start_config();
550        assert!(result.is_some());
551        let start_config = result.unwrap();
552        assert_eq!(start_config.foreground_service_type, "specialUse");
553    }
554
555    #[test]
556    fn auto_start_config_without_service_type_uses_default() {
557        let json = r#"{"pending":true,"label":"test"}"#;
558        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
559        assert_eq!(config.service_type, None);
560        let result = config.into_start_config();
561        assert!(result.is_some());
562        assert_eq!(result.unwrap().foreground_service_type, "dataSync");
563    }
564
565    #[test]
566    fn auto_start_config_null_service_type_uses_default() {
567        let json = r#"{"pending":true,"label":"test","serviceType":null}"#;
568        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
569        assert_eq!(config.service_type, None);
570        let result = config.into_start_config();
571        assert!(result.is_some());
572        assert_eq!(result.unwrap().foreground_service_type, "dataSync");
573    }
574
575    // --- PluginConfig tests ---
576
577    #[test]
578    fn plugin_config_default_ios_safety_timeout() {
579        let json = "{}";
580        let config: PluginConfig = serde_json::from_str(json).unwrap();
581        assert_eq!(config.ios_safety_timeout_secs, 28.0);
582    }
583
584    #[test]
585    fn plugin_config_custom_ios_safety_timeout() {
586        let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
587        let config: PluginConfig = serde_json::from_str(json).unwrap();
588        assert_eq!(config.ios_safety_timeout_secs, 15.0);
589    }
590
591    #[test]
592    fn plugin_config_serde_roundtrip_preserves_value() {
593        let config = PluginConfig {
594            ios_safety_timeout_secs: 30.0,
595            ios_cancel_listener_timeout_secs: 14400,
596            ios_processing_safety_timeout_secs: 0.0,
597            ios_earliest_refresh_begin_minutes: 20.0,
598            ios_earliest_processing_begin_minutes: 30.0,
599            ios_requires_external_power: true,
600            ios_requires_network_connectivity: true,
601            ..Default::default()
602        };
603        let json = serde_json::to_string(&config).unwrap();
604        let de: PluginConfig = serde_json::from_str(&json).unwrap();
605        assert_eq!(de.ios_safety_timeout_secs, 30.0);
606        assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
607        assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
608        assert!(de.ios_requires_external_power);
609        assert!(de.ios_requires_network_connectivity);
610    }
611
612    #[test]
613    fn plugin_config_default_impl() {
614        let config = PluginConfig::default();
615        assert_eq!(config.ios_safety_timeout_secs, 28.0);
616        assert_eq!(config.channel_capacity, 16);
617    }
618
619    #[test]
620    fn plugin_config_default_cancel_timeout() {
621        let json = "{}";
622        let config: PluginConfig = serde_json::from_str(json).unwrap();
623        assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
624    }
625
626    #[test]
627    fn plugin_config_custom_cancel_timeout() {
628        let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
629        let config: PluginConfig = serde_json::from_str(json).unwrap();
630        assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
631    }
632
633    #[test]
634    fn plugin_config_cancel_timeout_serde_roundtrip() {
635        let config = PluginConfig {
636            ios_cancel_listener_timeout_secs: 3600,
637            ..Default::default()
638        };
639        let json = serde_json::to_string(&config).unwrap();
640        let de: PluginConfig = serde_json::from_str(&json).unwrap();
641        assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
642    }
643
644    // --- PluginConfig ios_processing_safety_timeout_secs tests ---
645
646    #[test]
647    fn plugin_config_processing_timeout_default() {
648        let json = "{}";
649        let config: PluginConfig = serde_json::from_str(json).unwrap();
650        assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
651    }
652
653    #[test]
654    fn plugin_config_processing_timeout_custom() {
655        let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
656        let config: PluginConfig = serde_json::from_str(json).unwrap();
657        assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
658    }
659
660    #[test]
661    fn plugin_config_processing_timeout_serde_roundtrip() {
662        let config = PluginConfig {
663            ios_processing_safety_timeout_secs: 120.0,
664            ..Default::default()
665        };
666        let json = serde_json::to_string(&config).unwrap();
667        let de: PluginConfig = serde_json::from_str(&json).unwrap();
668        assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
669    }
670
671    // --- StartKeepaliveArgs tests ---
672
673    #[test]
674    fn start_keepalive_args_with_timeout() {
675        let args = StartKeepaliveArgs {
676            label: "Test",
677            foreground_service_type: "dataSync",
678            ios_safety_timeout_secs: Some(15.0),
679            ios_processing_safety_timeout_secs: None,
680            ios_earliest_refresh_begin_minutes: None,
681            ios_earliest_processing_begin_minutes: None,
682            ios_requires_external_power: None,
683            ios_requires_network_connectivity: None,
684        };
685        let json = serde_json::to_string(&args).unwrap();
686        assert!(
687            json.contains("\"iosSafetyTimeoutSecs\":15.0"),
688            "JSON should contain iosSafetyTimeoutSecs: {json}"
689        );
690    }
691
692    #[test]
693    fn start_keepalive_args_without_timeout() {
694        let args = StartKeepaliveArgs {
695            label: "Test",
696            foreground_service_type: "dataSync",
697            ios_safety_timeout_secs: None,
698            ios_processing_safety_timeout_secs: None,
699            ios_earliest_refresh_begin_minutes: None,
700            ios_earliest_processing_begin_minutes: None,
701            ios_requires_external_power: None,
702            ios_requires_network_connectivity: None,
703        };
704        let json = serde_json::to_string(&args).unwrap();
705        assert!(
706            !json.contains("iosSafetyTimeoutSecs"),
707            "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
708        );
709    }
710
711    #[test]
712    fn start_keepalive_args_processing_timeout() {
713        let args = StartKeepaliveArgs {
714            label: "Test",
715            foreground_service_type: "dataSync",
716            ios_safety_timeout_secs: None,
717            ios_processing_safety_timeout_secs: Some(60.0),
718            ios_earliest_refresh_begin_minutes: None,
719            ios_earliest_processing_begin_minutes: None,
720            ios_requires_external_power: None,
721            ios_requires_network_connectivity: None,
722        };
723        let json = serde_json::to_string(&args).unwrap();
724        assert!(
725            json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
726            "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
727        );
728    }
729
730    #[test]
731    fn start_keepalive_args_no_processing_timeout() {
732        let args = StartKeepaliveArgs {
733            label: "Test",
734            foreground_service_type: "dataSync",
735            ios_safety_timeout_secs: None,
736            ios_processing_safety_timeout_secs: None,
737            ios_earliest_refresh_begin_minutes: None,
738            ios_earliest_processing_begin_minutes: None,
739            ios_requires_external_power: None,
740            ios_requires_network_connectivity: None,
741        };
742        let json = serde_json::to_string(&args).unwrap();
743        assert!(
744            !json.contains("iosProcessingSafetyTimeoutSecs"),
745            "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
746        );
747    }
748
749    #[test]
750    fn start_keepalive_args_camel_case_keys() {
751        let args = StartKeepaliveArgs {
752            label: "Test",
753            foreground_service_type: "specialUse",
754            ios_safety_timeout_secs: None,
755            ios_processing_safety_timeout_secs: None,
756            ios_earliest_refresh_begin_minutes: None,
757            ios_earliest_processing_begin_minutes: None,
758            ios_requires_external_power: None,
759            ios_requires_network_connectivity: None,
760        };
761        let json = serde_json::to_string(&args).unwrap();
762        assert!(json.contains("\"label\""), "label: {json}");
763        assert!(
764            json.contains("\"foregroundServiceType\""),
765            "foregroundServiceType: {json}"
766        );
767    }
768
769    #[test]
770    fn start_keepalive_args_scheduling_intervals() {
771        let args = StartKeepaliveArgs {
772            label: "Test",
773            foreground_service_type: "dataSync",
774            ios_safety_timeout_secs: None,
775            ios_processing_safety_timeout_secs: None,
776            ios_earliest_refresh_begin_minutes: Some(30.0),
777            ios_earliest_processing_begin_minutes: Some(60.0),
778            ios_requires_external_power: None,
779            ios_requires_network_connectivity: None,
780        };
781        let json = serde_json::to_string(&args).unwrap();
782        assert!(
783            json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
784            "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
785        );
786        assert!(
787            json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
788            "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
789        );
790    }
791
792    #[test]
793    fn start_keepalive_args_processing_options() {
794        let args = StartKeepaliveArgs {
795            label: "Test",
796            foreground_service_type: "dataSync",
797            ios_safety_timeout_secs: None,
798            ios_processing_safety_timeout_secs: None,
799            ios_earliest_refresh_begin_minutes: None,
800            ios_earliest_processing_begin_minutes: None,
801            ios_requires_external_power: Some(true),
802            ios_requires_network_connectivity: Some(true),
803        };
804        let json = serde_json::to_string(&args).unwrap();
805        assert!(
806            json.contains("\"iosRequiresExternalPower\":true"),
807            "JSON should contain iosRequiresExternalPower: {json}"
808        );
809        assert!(
810            json.contains("\"iosRequiresNetworkConnectivity\":true"),
811            "JSON should contain iosRequiresNetworkConnectivity: {json}"
812        );
813    }
814
815    // --- PluginConfig new scheduling fields tests ---
816
817    #[test]
818    fn plugin_config_earliest_refresh_default() {
819        let json = "{}";
820        let config: PluginConfig = serde_json::from_str(json).unwrap();
821        assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
822    }
823
824    #[test]
825    fn plugin_config_earliest_processing_default() {
826        let json = "{}";
827        let config: PluginConfig = serde_json::from_str(json).unwrap();
828        assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
829    }
830
831    #[test]
832    fn plugin_config_requires_external_power_default() {
833        let json = "{}";
834        let config: PluginConfig = serde_json::from_str(json).unwrap();
835        assert!(!config.ios_requires_external_power);
836    }
837
838    #[test]
839    fn plugin_config_requires_network_connectivity_default() {
840        let json = "{}";
841        let config: PluginConfig = serde_json::from_str(json).unwrap();
842        assert!(!config.ios_requires_network_connectivity);
843    }
844
845    #[test]
846    fn plugin_config_custom_scheduling_intervals() {
847        let json =
848            r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
849        let config: PluginConfig = serde_json::from_str(json).unwrap();
850        assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
851        assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
852    }
853
854    #[test]
855    fn plugin_config_custom_processing_options() {
856        let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
857        let config: PluginConfig = serde_json::from_str(json).unwrap();
858        assert!(config.ios_requires_external_power);
859        assert!(config.ios_requires_network_connectivity);
860    }
861
862    // --- PluginConfig channel_capacity tests ---
863
864    #[test]
865    fn plugin_config_channel_capacity_default() {
866        let json = "{}";
867        let config: PluginConfig = serde_json::from_str(json).unwrap();
868        assert_eq!(config.channel_capacity, 16);
869    }
870
871    #[test]
872    fn plugin_config_channel_capacity_custom() {
873        let json = r#"{"channelCapacity":32}"#;
874        let config: PluginConfig = serde_json::from_str(json).unwrap();
875        assert_eq!(config.channel_capacity, 32);
876    }
877
878    #[test]
879    fn plugin_config_channel_capacity_serde_roundtrip() {
880        let config = PluginConfig {
881            channel_capacity: 64,
882            ..Default::default()
883        };
884        let json = serde_json::to_string(&config).unwrap();
885        let de: PluginConfig = serde_json::from_str(&json).unwrap();
886        assert_eq!(de.channel_capacity, 64);
887    }
888
889    #[test]
890    fn plugin_config_channel_capacity_json_key_camel_case() {
891        let config = PluginConfig {
892            channel_capacity: 32,
893            ..Default::default()
894        };
895        let json = serde_json::to_string(&config).unwrap();
896        assert!(
897            json.contains("channelCapacity"),
898            "JSON should use camelCase: {json}"
899        );
900    }
901
902    // --- PluginConfig desktop fields tests (feature-gated) ---
903
904    #[cfg(feature = "desktop-service")]
905    #[test]
906    fn plugin_config_desktop_mode_default() {
907        let json = "{}";
908        let config: PluginConfig = serde_json::from_str(json).unwrap();
909        assert_eq!(config.desktop_service_mode, "inProcess");
910    }
911
912    #[cfg(feature = "desktop-service")]
913    #[test]
914    fn plugin_config_desktop_mode_custom() {
915        let json = r#"{"desktopServiceMode":"osService"}"#;
916        let config: PluginConfig = serde_json::from_str(json).unwrap();
917        assert_eq!(config.desktop_service_mode, "osService");
918    }
919
920    #[cfg(feature = "desktop-service")]
921    #[test]
922    fn plugin_config_desktop_mode_serde_roundtrip() {
923        let config = PluginConfig {
924            desktop_service_mode: "osService".into(),
925            ..Default::default()
926        };
927        let json = serde_json::to_string(&config).unwrap();
928        let de: PluginConfig = serde_json::from_str(&json).unwrap();
929        assert_eq!(de.desktop_service_mode, "osService");
930    }
931
932    #[cfg(feature = "desktop-service")]
933    #[test]
934    fn plugin_config_desktop_label_default() {
935        let json = "{}";
936        let config: PluginConfig = serde_json::from_str(json).unwrap();
937        assert_eq!(config.desktop_service_label, None);
938    }
939
940    #[cfg(feature = "desktop-service")]
941    #[test]
942    fn plugin_config_desktop_label_custom() {
943        let json = r#"{"desktopServiceLabel":"my.svc"}"#;
944        let config: PluginConfig = serde_json::from_str(json).unwrap();
945        assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
946    }
947
948    use tauri::AppHandle;
949
950    // --- ServiceContext mobile fields tests ---
951
952    /// Compile-time + runtime test: ServiceContext mobile fields are String.
953    #[cfg(mobile)]
954    #[allow(dead_code)]
955    fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
956        let ctx = ServiceContext {
957            notifier: Notifier { app: app.clone() },
958            app,
959            shutdown: CancellationToken::new(),
960            service_label: "Syncing".into(),
961            foreground_service_type: "dataSync".into(),
962        };
963        assert_eq!(ctx.service_label, "Syncing");
964        assert_eq!(ctx.foreground_service_type, "dataSync");
965    }
966
967    /// Compile-time + runtime test: ServiceContext on desktop has no mobile fields.
968    #[cfg(not(mobile))]
969    #[allow(dead_code)]
970    fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
971        let ctx = ServiceContext {
972            notifier: Notifier { app: app.clone() },
973            app,
974            shutdown: CancellationToken::new(),
975        };
976        // Compiles — service_label and foreground_service_type are absent.
977        let _ = ctx;
978    }
979
980    // --- Foreground service type validation tests ---
981
982    #[test]
983    fn validate_data_sync_passes() {
984        assert!(
985            validate_foreground_service_type("dataSync").is_ok(),
986            "dataSync should be valid"
987        );
988    }
989
990    #[test]
991    fn validate_special_use_passes() {
992        assert!(
993            validate_foreground_service_type("specialUse").is_ok(),
994            "specialUse should be valid"
995        );
996    }
997
998    #[test]
999    fn validate_invalid_type_returns_platform_error() {
1000        let result = validate_foreground_service_type("invalidType");
1001        assert!(result.is_err(), "invalidType should be rejected");
1002        match result {
1003            Err(crate::error::ServiceError::Platform(msg)) => {
1004                assert!(
1005                    msg.contains("invalidType"),
1006                    "error should mention the type: {msg}"
1007                );
1008            }
1009            other => panic!("Expected Platform error, got: {other:?}"),
1010        }
1011    }
1012
1013    #[test]
1014    fn validate_all_14_types_pass() {
1015        for &t in VALID_FOREGROUND_SERVICE_TYPES {
1016            assert!(
1017                validate_foreground_service_type(t).is_ok(),
1018                "{t} should be valid"
1019            );
1020        }
1021    }
1022
1023    #[test]
1024    fn valid_types_count_is_14() {
1025        assert_eq!(
1026            VALID_FOREGROUND_SERVICE_TYPES.len(),
1027            14,
1028            "should have exactly 14 valid types"
1029        );
1030    }
1031
1032    #[test]
1033    fn validate_empty_string_returns_error() {
1034        let result = validate_foreground_service_type("");
1035        assert!(result.is_err(), "empty string should be rejected");
1036    }
1037
1038    #[test]
1039    fn validate_case_sensitive() {
1040        // "DataSync" (capitalized) should NOT pass — case-sensitive.
1041        let result = validate_foreground_service_type("DataSync");
1042        assert!(
1043            result.is_err(),
1044            "validation should be case-sensitive: DataSync should fail"
1045        );
1046    }
1047
1048    // --- ServiceState serde tests ---
1049
1050    #[test]
1051    fn service_state_idle_serde_roundtrip() {
1052        let state = ServiceState::Idle;
1053        let json = serde_json::to_string(&state).unwrap();
1054        let de: ServiceState = serde_json::from_str(&json).unwrap();
1055        assert_eq!(de, ServiceState::Idle);
1056    }
1057
1058    #[test]
1059    fn service_state_initializing_serde_roundtrip() {
1060        let state = ServiceState::Initializing;
1061        let json = serde_json::to_string(&state).unwrap();
1062        let de: ServiceState = serde_json::from_str(&json).unwrap();
1063        assert_eq!(de, ServiceState::Initializing);
1064    }
1065
1066    #[test]
1067    fn service_state_running_serde_roundtrip() {
1068        let state = ServiceState::Running;
1069        let json = serde_json::to_string(&state).unwrap();
1070        let de: ServiceState = serde_json::from_str(&json).unwrap();
1071        assert_eq!(de, ServiceState::Running);
1072    }
1073
1074    #[test]
1075    fn service_state_stopped_serde_roundtrip() {
1076        let state = ServiceState::Stopped;
1077        let json = serde_json::to_string(&state).unwrap();
1078        let de: ServiceState = serde_json::from_str(&json).unwrap();
1079        assert_eq!(de, ServiceState::Stopped);
1080    }
1081
1082    #[test]
1083    fn service_state_json_values_are_camel_case() {
1084        assert_eq!(
1085            serde_json::to_string(&ServiceState::Idle).unwrap(),
1086            "\"idle\""
1087        );
1088        assert_eq!(
1089            serde_json::to_string(&ServiceState::Initializing).unwrap(),
1090            "\"initializing\""
1091        );
1092        assert_eq!(
1093            serde_json::to_string(&ServiceState::Running).unwrap(),
1094            "\"running\""
1095        );
1096        assert_eq!(
1097            serde_json::to_string(&ServiceState::Stopped).unwrap(),
1098            "\"stopped\""
1099        );
1100    }
1101
1102    // --- ServiceStatus serde tests ---
1103
1104    #[test]
1105    fn service_status_serde_roundtrip_idle() {
1106        let status = ServiceStatus {
1107            state: ServiceState::Idle,
1108            last_error: None,
1109        };
1110        let json = serde_json::to_string(&status).unwrap();
1111        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
1112        assert_eq!(de.state, ServiceState::Idle);
1113        assert_eq!(de.last_error, None);
1114    }
1115
1116    #[test]
1117    fn service_status_serde_roundtrip_with_error() {
1118        let status = ServiceStatus {
1119            state: ServiceState::Stopped,
1120            last_error: Some("init failed".into()),
1121        };
1122        let json = serde_json::to_string(&status).unwrap();
1123        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
1124        assert_eq!(de.state, ServiceState::Stopped);
1125        assert_eq!(de.last_error, Some("init failed".into()));
1126    }
1127
1128    #[test]
1129    fn service_status_json_keys_camel_case() {
1130        let status = ServiceStatus {
1131            state: ServiceState::Running,
1132            last_error: None,
1133        };
1134        let json = serde_json::to_string(&status).unwrap();
1135        assert!(json.contains("\"state\":"), "state key: {json}");
1136        assert!(json.contains("\"lastError\":"), "lastError key: {json}");
1137    }
1138
1139    #[test]
1140    fn service_status_json_null_last_error() {
1141        let status = ServiceStatus {
1142            state: ServiceState::Idle,
1143            last_error: None,
1144        };
1145        let json = serde_json::to_string(&status).unwrap();
1146        assert!(
1147            json.contains("\"lastError\":null"),
1148            "lastError should be null: {json}"
1149        );
1150    }
1151}