1use serde::{Deserialize, Serialize};
8use tauri::Runtime;
9use tokio_util::sync::CancellationToken;
10
11use crate::error::ServiceError;
12use crate::notifier::Notifier;
13
14pub 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
36pub 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
51pub struct ServiceContext<R: Runtime> {
54 pub notifier: Notifier<R>,
56
57 pub app: tauri::AppHandle<R>,
59
60 pub shutdown: CancellationToken,
62
63 #[cfg(mobile)]
66 pub service_label: String,
67
68 #[cfg(mobile)]
71 pub foreground_service_type: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct StartConfig {
78 #[serde(default = "default_label")]
80 pub service_label: String,
81
82 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct PluginConfig {
99 #[serde(default = "default_ios_safety_timeout")]
102 pub ios_safety_timeout_secs: f64,
103
104 #[serde(default = "default_ios_cancel_listener_timeout_secs")]
107 pub ios_cancel_listener_timeout_secs: u64,
108
109 #[serde(default = "default_ios_processing_safety_timeout_secs")]
114 pub ios_processing_safety_timeout_secs: f64,
115
116 #[serde(default = "default_ios_earliest_refresh_begin_minutes")]
119 pub ios_earliest_refresh_begin_minutes: f64,
120
121 #[serde(default = "default_ios_earliest_processing_begin_minutes")]
124 pub ios_earliest_processing_begin_minutes: f64,
125
126 #[serde(default)]
130 pub ios_requires_external_power: bool,
131
132 #[serde(default)]
136 pub ios_requires_network_connectivity: bool,
137
138 #[serde(default = "default_channel_capacity")]
142 pub channel_capacity: usize,
143
144 #[cfg(feature = "desktop-service")]
148 #[serde(default = "default_desktop_service_mode")]
149 pub desktop_service_mode: String,
150
151 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202#[non_exhaustive]
203pub enum ServiceState {
204 Idle,
206 Initializing,
208 Running,
210 Stopped,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct ServiceStatus {
220 pub state: ServiceState,
222 pub last_error: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase", tag = "type")]
229#[non_exhaustive]
230pub enum PluginEvent {
231 Started,
233 Stopped { reason: String },
235 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#[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 #[serde(skip_serializing_if = "Option::is_none")]
269 pub ios_safety_timeout_secs: Option<f64>,
270 #[serde(skip_serializing_if = "Option::is_none")]
273 pub ios_processing_safety_timeout_secs: Option<f64>,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub ios_earliest_refresh_begin_minutes: Option<f64>,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub ios_earliest_processing_begin_minutes: Option<f64>,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub ios_requires_external_power: Option<bool>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub ios_requires_network_connectivity: Option<bool>,
286}
287
288#[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 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 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let _ = ctx;
978 }
979
980 #[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 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 #[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 #[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}