1use serde::{Deserialize, Serialize};
8use tauri::Runtime;
9use tokio_util::sync::CancellationToken;
10
11use crate::notifier::Notifier;
12
13pub struct ServiceContext<R: Runtime> {
16 pub notifier: Notifier<R>,
18
19 pub app: tauri::AppHandle<R>,
21
22 pub shutdown: CancellationToken,
24
25 pub service_label: Option<String>,
28
29 pub foreground_service_type: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct StartConfig {
38 #[serde(default = "default_label")]
40 pub service_label: String,
41
42 #[serde(default = "default_foreground_service_type")]
44 pub foreground_service_type: String,
45}
46
47fn default_label() -> String {
48 "Service running".into()
49}
50
51fn default_foreground_service_type() -> String {
52 "dataSync".into()
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct PluginConfig {
59 #[serde(default = "default_ios_safety_timeout")]
62 pub ios_safety_timeout_secs: f64,
63
64 #[serde(default = "default_ios_cancel_listener_timeout_secs")]
67 pub ios_cancel_listener_timeout_secs: u64,
68
69 #[serde(default = "default_ios_processing_safety_timeout_secs")]
74 pub ios_processing_safety_timeout_secs: f64,
75
76 #[cfg(feature = "desktop-service")]
80 #[serde(default = "default_desktop_service_mode")]
81 pub desktop_service_mode: String,
82
83 #[cfg(feature = "desktop-service")]
86 #[serde(default)]
87 pub desktop_service_label: Option<String>,
88}
89
90fn default_ios_safety_timeout() -> f64 {
91 28.0
92}
93
94fn default_ios_cancel_listener_timeout_secs() -> u64 {
95 14400
96}
97
98fn default_ios_processing_safety_timeout_secs() -> f64 {
99 0.0
100}
101
102#[cfg(feature = "desktop-service")]
103fn default_desktop_service_mode() -> String {
104 "inProcess".into()
105}
106
107impl Default for StartConfig {
108 fn default() -> Self {
109 Self {
110 service_label: default_label(),
111 foreground_service_type: default_foreground_service_type(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase", tag = "type")]
119#[non_exhaustive]
120pub enum PluginEvent {
121 Started,
123 Stopped { reason: String },
125 Error { message: String },
127}
128
129impl Default for PluginConfig {
130 fn default() -> Self {
131 Self {
132 ios_safety_timeout_secs: default_ios_safety_timeout(),
133 ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
134 ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
135 #[cfg(feature = "desktop-service")]
136 desktop_service_mode: default_desktop_service_mode(),
137 #[cfg(feature = "desktop-service")]
138 desktop_service_label: None,
139 }
140 }
141}
142
143#[derive(Debug, Serialize)]
147#[serde(rename_all = "camelCase")]
148#[allow(dead_code)]
149pub(crate) struct StartKeepaliveArgs<'a> {
150 pub label: &'a str,
151 pub foreground_service_type: &'a str,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub ios_safety_timeout_secs: Option<f64>,
155 #[serde(skip_serializing_if = "Option::is_none")]
158 pub ios_processing_safety_timeout_secs: Option<f64>,
159}
160
161#[doc(hidden)]
166#[derive(Debug, Clone, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct AutoStartConfig {
169 pub pending: bool,
170 pub label: Option<String>,
171 pub service_type: Option<String>,
172}
173
174impl AutoStartConfig {
175 pub fn into_start_config(self) -> Option<StartConfig> {
177 if self.pending {
178 self.label.map(|label| StartConfig {
179 service_label: label,
180 foreground_service_type: self
181 .service_type
182 .unwrap_or_else(default_foreground_service_type),
183 })
184 } else {
185 None
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
197 fn start_config_default_label() {
198 let config = StartConfig::default();
199 assert_eq!(config.service_label, "Service running");
200 }
201
202 #[test]
203 fn start_config_custom_label() {
204 let config = StartConfig {
205 service_label: "Syncing data".into(),
206 ..Default::default()
207 };
208 assert_eq!(config.service_label, "Syncing data");
209 }
210
211 #[test]
212 fn start_config_serde_roundtrip_default() {
213 let config = StartConfig::default();
214 let json = serde_json::to_string(&config).unwrap();
215 let de: StartConfig = serde_json::from_str(&json).unwrap();
216 assert_eq!(de.service_label, config.service_label);
217 }
218
219 #[test]
220 fn start_config_serde_roundtrip_custom() {
221 let config = StartConfig {
222 service_label: "My service".into(),
223 ..Default::default()
224 };
225 let json = serde_json::to_string(&config).unwrap();
226 let de: StartConfig = serde_json::from_str(&json).unwrap();
227 assert_eq!(de.service_label, "My service");
228 }
229
230 #[test]
231 fn start_config_deserialize_missing_field_uses_default() {
232 let json = "{}";
234 let de: StartConfig = serde_json::from_str(json).unwrap();
235 assert_eq!(de.service_label, "Service running");
236 }
237
238 #[test]
239 fn start_config_json_key_is_camel_case() {
240 let config = StartConfig {
241 service_label: "test".into(),
242 ..Default::default()
243 };
244 let json = serde_json::to_string(&config).unwrap();
245 assert!(json.contains("serviceLabel"), "JSON should use camelCase: {json}");
246 }
247
248 #[test]
251 fn plugin_event_started_serde_roundtrip() {
252 let event = PluginEvent::Started;
253 let json = serde_json::to_string(&event).unwrap();
254 let de: PluginEvent = serde_json::from_str(&json).unwrap();
255 assert!(matches!(de, PluginEvent::Started));
256 }
257
258 #[test]
259 fn plugin_event_stopped_serde_roundtrip() {
260 let event = PluginEvent::Stopped {
261 reason: "cancelled".into(),
262 };
263 let json = serde_json::to_string(&event).unwrap();
264 let de: PluginEvent = serde_json::from_str(&json).unwrap();
265 match de {
266 PluginEvent::Stopped { reason } => assert_eq!(reason, "cancelled"),
267 other => panic!("Expected Stopped, got {other:?}"),
268 }
269 }
270
271 #[test]
272 fn plugin_event_error_serde_roundtrip() {
273 let event = PluginEvent::Error {
274 message: "init failed".into(),
275 };
276 let json = serde_json::to_string(&event).unwrap();
277 let de: PluginEvent = serde_json::from_str(&json).unwrap();
278 match de {
279 PluginEvent::Error { message } => assert_eq!(message, "init failed"),
280 other => panic!("Expected Error, got {other:?}"),
281 }
282 }
283
284 #[test]
285 fn plugin_event_tagged_json_format() {
286 let event = PluginEvent::Started;
287 let json = serde_json::to_string(&event).unwrap();
288 assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
289 }
290
291 #[test]
292 fn plugin_event_stopped_json_keys_camel_case() {
293 let event = PluginEvent::Stopped {
294 reason: "done".into(),
295 };
296 let json = serde_json::to_string(&event).unwrap();
297 assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
298 assert!(json.contains("\"reason\":\"done\""), "Reason: {json}");
299 }
300
301 #[test]
302 fn plugin_event_error_json_keys_camel_case() {
303 let event = PluginEvent::Error {
304 message: "oops".into(),
305 };
306 let json = serde_json::to_string(&event).unwrap();
307 assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
308 assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
309 }
310
311 #[test]
314 fn start_config_default_service_type() {
315 let config = StartConfig::default();
316 assert_eq!(config.foreground_service_type, "dataSync");
317 }
318
319 #[test]
320 fn start_config_custom_service_type() {
321 let config = StartConfig {
322 service_label: "test".into(),
323 foreground_service_type: "specialUse".into(),
324 };
325 assert_eq!(config.foreground_service_type, "specialUse");
326 }
327
328 #[test]
329 fn start_config_serde_roundtrip_service_type() {
330 let config = StartConfig {
331 service_label: "test".into(),
332 foreground_service_type: "specialUse".into(),
333 };
334 let json = serde_json::to_string(&config).unwrap();
335 let de: StartConfig = serde_json::from_str(&json).unwrap();
336 assert_eq!(de.foreground_service_type, "specialUse");
337 }
338
339 #[test]
340 fn start_config_deserialize_missing_service_type() {
341 let json = r#"{"serviceLabel":"test"}"#;
342 let de: StartConfig = serde_json::from_str(json).unwrap();
343 assert_eq!(de.foreground_service_type, "dataSync");
344 }
345
346 #[test]
347 fn start_config_deserialize_special_use() {
348 let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
349 let de: StartConfig = serde_json::from_str(json).unwrap();
350 assert_eq!(de.foreground_service_type, "specialUse");
351 }
352
353 #[test]
354 fn start_config_unrecognized_type_passes_through() {
355 let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
356 let de: StartConfig = serde_json::from_str(json).unwrap();
357 assert_eq!(de.foreground_service_type, "customType");
358 }
359
360 #[test]
361 fn start_config_json_key_is_camel_case_service_type() {
362 let config = StartConfig {
363 service_label: "test".into(),
364 foreground_service_type: "specialUse".into(),
365 };
366 let json = serde_json::to_string(&config).unwrap();
367 assert!(
368 json.contains("foregroundServiceType"),
369 "JSON should use camelCase: {json}"
370 );
371 }
372
373 #[test]
376 fn auto_start_config_pending_with_label_returns_start_config() {
377 let json = r#"{"pending": true, "label": "Syncing"}"#;
378 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
379 let result = config.into_start_config();
380 assert!(result.is_some());
381 let start_config = result.unwrap();
382 assert_eq!(start_config.service_label, "Syncing");
383 assert_eq!(start_config.foreground_service_type, "dataSync");
384 }
385
386 #[test]
387 fn auto_start_config_not_pending_returns_none() {
388 let json = r#"{"pending": false, "label": null}"#;
389 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
390 let result = config.into_start_config();
391 assert!(result.is_none());
392 }
393
394 #[test]
395 fn auto_start_config_pending_no_label_returns_none() {
396 let json = r#"{"pending": true, "label": null}"#;
397 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
398 let result = config.into_start_config();
399 assert!(result.is_none());
400 }
401
402 #[test]
403 fn auto_start_config_with_service_type_preserves_it() {
404 let json = r#"{"pending":true,"label":"test","serviceType":"specialUse"}"#;
405 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
406 assert_eq!(config.service_type, Some("specialUse".to_string()));
407 let result = config.into_start_config();
408 assert!(result.is_some());
409 let start_config = result.unwrap();
410 assert_eq!(start_config.foreground_service_type, "specialUse");
411 }
412
413 #[test]
414 fn auto_start_config_without_service_type_uses_default() {
415 let json = r#"{"pending":true,"label":"test"}"#;
416 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
417 assert_eq!(config.service_type, None);
418 let result = config.into_start_config();
419 assert!(result.is_some());
420 assert_eq!(result.unwrap().foreground_service_type, "dataSync");
421 }
422
423 #[test]
424 fn auto_start_config_null_service_type_uses_default() {
425 let json = r#"{"pending":true,"label":"test","serviceType":null}"#;
426 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
427 assert_eq!(config.service_type, None);
428 let result = config.into_start_config();
429 assert!(result.is_some());
430 assert_eq!(result.unwrap().foreground_service_type, "dataSync");
431 }
432
433 #[test]
436 fn plugin_config_default_ios_safety_timeout() {
437 let json = "{}";
438 let config: PluginConfig = serde_json::from_str(json).unwrap();
439 assert_eq!(config.ios_safety_timeout_secs, 28.0);
440 }
441
442 #[test]
443 fn plugin_config_custom_ios_safety_timeout() {
444 let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
445 let config: PluginConfig = serde_json::from_str(json).unwrap();
446 assert_eq!(config.ios_safety_timeout_secs, 15.0);
447 }
448
449 #[test]
450 fn plugin_config_serde_roundtrip_preserves_value() {
451 let config = PluginConfig {
452 ios_safety_timeout_secs: 30.0,
453 ios_cancel_listener_timeout_secs: 14400,
454 ios_processing_safety_timeout_secs: 0.0,
455 ..Default::default()
456 };
457 let json = serde_json::to_string(&config).unwrap();
458 let de: PluginConfig = serde_json::from_str(&json).unwrap();
459 assert_eq!(de.ios_safety_timeout_secs, 30.0);
460 }
461
462 #[test]
463 fn plugin_config_default_impl() {
464 let config = PluginConfig::default();
465 assert_eq!(config.ios_safety_timeout_secs, 28.0);
466 }
467
468 #[test]
469 fn plugin_config_default_cancel_timeout() {
470 let json = "{}";
471 let config: PluginConfig = serde_json::from_str(json).unwrap();
472 assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
473 }
474
475 #[test]
476 fn plugin_config_custom_cancel_timeout() {
477 let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
478 let config: PluginConfig = serde_json::from_str(json).unwrap();
479 assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
480 }
481
482 #[test]
483 fn plugin_config_cancel_timeout_serde_roundtrip() {
484 let config = PluginConfig {
485 ios_safety_timeout_secs: 28.0,
486 ios_cancel_listener_timeout_secs: 3600,
487 ios_processing_safety_timeout_secs: 0.0,
488 ..Default::default()
489 };
490 let json = serde_json::to_string(&config).unwrap();
491 let de: PluginConfig = serde_json::from_str(&json).unwrap();
492 assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
493 }
494
495 #[test]
498 fn plugin_config_processing_timeout_default() {
499 let json = "{}";
500 let config: PluginConfig = serde_json::from_str(json).unwrap();
501 assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
502 }
503
504 #[test]
505 fn plugin_config_processing_timeout_custom() {
506 let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
507 let config: PluginConfig = serde_json::from_str(json).unwrap();
508 assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
509 }
510
511 #[test]
512 fn plugin_config_processing_timeout_serde_roundtrip() {
513 let config = PluginConfig {
514 ios_safety_timeout_secs: 28.0,
515 ios_cancel_listener_timeout_secs: 14400,
516 ios_processing_safety_timeout_secs: 120.0,
517 ..Default::default()
518 };
519 let json = serde_json::to_string(&config).unwrap();
520 let de: PluginConfig = serde_json::from_str(&json).unwrap();
521 assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
522 }
523
524 #[test]
527 fn start_keepalive_args_with_timeout() {
528 let args = StartKeepaliveArgs {
529 label: "Test",
530 foreground_service_type: "dataSync",
531 ios_safety_timeout_secs: Some(15.0),
532 ios_processing_safety_timeout_secs: None,
533 };
534 let json = serde_json::to_string(&args).unwrap();
535 assert!(
536 json.contains("\"iosSafetyTimeoutSecs\":15.0"),
537 "JSON should contain iosSafetyTimeoutSecs: {json}"
538 );
539 }
540
541 #[test]
542 fn start_keepalive_args_without_timeout() {
543 let args = StartKeepaliveArgs {
544 label: "Test",
545 foreground_service_type: "dataSync",
546 ios_safety_timeout_secs: None,
547 ios_processing_safety_timeout_secs: None,
548 };
549 let json = serde_json::to_string(&args).unwrap();
550 assert!(
551 !json.contains("iosSafetyTimeoutSecs"),
552 "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
553 );
554 }
555
556 #[test]
557 fn start_keepalive_args_processing_timeout() {
558 let args = StartKeepaliveArgs {
559 label: "Test",
560 foreground_service_type: "dataSync",
561 ios_safety_timeout_secs: None,
562 ios_processing_safety_timeout_secs: Some(60.0),
563 };
564 let json = serde_json::to_string(&args).unwrap();
565 assert!(
566 json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
567 "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
568 );
569 }
570
571 #[test]
572 fn start_keepalive_args_no_processing_timeout() {
573 let args = StartKeepaliveArgs {
574 label: "Test",
575 foreground_service_type: "dataSync",
576 ios_safety_timeout_secs: None,
577 ios_processing_safety_timeout_secs: None,
578 };
579 let json = serde_json::to_string(&args).unwrap();
580 assert!(
581 !json.contains("iosProcessingSafetyTimeoutSecs"),
582 "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
583 );
584 }
585
586 #[test]
587 fn start_keepalive_args_camel_case_keys() {
588 let args = StartKeepaliveArgs {
589 label: "Test",
590 foreground_service_type: "specialUse",
591 ios_safety_timeout_secs: None,
592 ios_processing_safety_timeout_secs: None,
593 };
594 let json = serde_json::to_string(&args).unwrap();
595 assert!(json.contains("\"label\""), "label: {json}");
596 assert!(
597 json.contains("\"foregroundServiceType\""),
598 "foregroundServiceType: {json}"
599 );
600 }
601
602 #[cfg(feature = "desktop-service")]
605 #[test]
606 fn plugin_config_desktop_mode_default() {
607 let json = "{}";
608 let config: PluginConfig = serde_json::from_str(json).unwrap();
609 assert_eq!(config.desktop_service_mode, "inProcess");
610 }
611
612 #[cfg(feature = "desktop-service")]
613 #[test]
614 fn plugin_config_desktop_mode_custom() {
615 let json = r#"{"desktopServiceMode":"osService"}"#;
616 let config: PluginConfig = serde_json::from_str(json).unwrap();
617 assert_eq!(config.desktop_service_mode, "osService");
618 }
619
620 #[cfg(feature = "desktop-service")]
621 #[test]
622 fn plugin_config_desktop_mode_serde_roundtrip() {
623 let config = PluginConfig {
624 desktop_service_mode: "osService".into(),
625 ..Default::default()
626 };
627 let json = serde_json::to_string(&config).unwrap();
628 let de: PluginConfig = serde_json::from_str(&json).unwrap();
629 assert_eq!(de.desktop_service_mode, "osService");
630 }
631
632 #[cfg(feature = "desktop-service")]
633 #[test]
634 fn plugin_config_desktop_label_default() {
635 let json = "{}";
636 let config: PluginConfig = serde_json::from_str(json).unwrap();
637 assert_eq!(config.desktop_service_label, None);
638 }
639
640 #[cfg(feature = "desktop-service")]
641 #[test]
642 fn plugin_config_desktop_label_custom() {
643 let json = r#"{"desktopServiceLabel":"my.svc"}"#;
644 let config: PluginConfig = serde_json::from_str(json).unwrap();
645 assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
646 }
647
648 use tauri::AppHandle;
649
650 #[allow(dead_code)]
654 fn service_context_new_fields_default_to_none<R: Runtime>(app: AppHandle<R>) {
655 let ctx = ServiceContext {
656 notifier: Notifier { app: app.clone() },
657 app,
658 shutdown: CancellationToken::new(),
659 service_label: None,
660 foreground_service_type: None,
661 };
662 assert_eq!(ctx.service_label, None);
663 assert_eq!(ctx.foreground_service_type, None);
664 }
665
666 #[allow(dead_code)]
668 fn service_context_new_fields_with_values<R: Runtime>(app: AppHandle<R>) {
669 let ctx = ServiceContext {
670 notifier: Notifier { app: app.clone() },
671 app,
672 shutdown: CancellationToken::new(),
673 service_label: Some("Syncing".into()),
674 foreground_service_type: Some("dataSync".into()),
675 };
676 assert_eq!(ctx.service_label.as_deref(), Some("Syncing"));
677 assert_eq!(ctx.foreground_service_type.as_deref(), Some("dataSync"));
678 }
679}