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