Skip to main content

tauri_plugin_background_service/
validator.rs

1//! Setup validation for background service prerequisites.
2//!
3//! [`SetupValidator`] checks platform-specific prerequisites (permissions,
4//! manifest entries, service manager availability) and returns a
5//! [`SetupValidationReport`] with errors (blocking) and warnings (non-blocking).
6//!
7//! This module is available on all platforms. Platform-specific checks are
8//! gated by `cfg` attributes so they only run on the target platform.
9
10use crate::models::{Platform, SetupIssue, SetupValidationReport};
11
12/// Validates background service setup prerequisites for the current platform.
13///
14/// Returns a [`SetupValidationReport`] containing errors (blocking issues that
15/// prevent the service from working) and warnings (non-blocking issues that
16/// may cause degraded behavior).
17pub struct SetupValidator;
18
19impl SetupValidator {
20    /// Run all applicable checks for the current platform.
21    ///
22    /// The `platform` parameter is typically obtained from
23    /// [`crate::capabilities::CapabilityProvider::detect_platform`].
24    pub fn validate(platform: Platform) -> SetupValidationReport {
25        match platform {
26            Platform::Android => Self::android_checks(),
27            Platform::Ios => Self::ios_checks(),
28            Platform::Linux | Platform::Macos | Platform::Windows | Platform::Unknown => {
29                Self::desktop_checks(platform)
30            }
31        }
32    }
33
34    fn android_checks() -> SetupValidationReport {
35        let warnings = vec![
36            SetupIssue {
37                code: "android_fgs_type".into(),
38                message: "Ensure the foreground service type is declared in AndroidManifest.xml \
39                          with the matching permission"
40                    .into(),
41                platform: Platform::Android,
42                fix: Some(
43                    "Add <foregroundServiceType> to your <service> element and the \
44                     corresponding <uses-permission> to the manifest"
45                        .into(),
46                ),
47            },
48            SetupIssue {
49                code: "android_post_notifications".into(),
50                message: "Android 13+ requires POST_NOTIFICATIONS runtime permission for \
51                          foreground service notifications"
52                    .into(),
53                platform: Platform::Android,
54                fix: Some(
55                    "Request android.permission.POST_NOTIFICATIONS at runtime before \
56                     starting the service on Android 13+"
57                        .into(),
58                ),
59            },
60            SetupIssue {
61                code: "android_boot_receiver".into(),
62                message: "Boot recovery requires a registered BroadcastReceiver for \
63                          BOOT_COMPLETED"
64                    .into(),
65                platform: Platform::Android,
66                fix: Some(
67                    "Add RECEIVE_BOOT_COMPLETED permission and a <receiver> element for \
68                     BOOT_COMPLETED in AndroidManifest.xml"
69                        .into(),
70                ),
71            },
72            SetupIssue {
73                code: "android_special_use_subtype".into(),
74                message: "When using specialUse FGS type, PROPERTY_SPECIAL_USE_FGS_SUBTYPE \
75                          must be declared in the manifest"
76                    .into(),
77                platform: Platform::Android,
78                fix: Some(
79                    "Add <property android:name=\"android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE\" \
80                     android:value=\"your_reason\" /> to the <service> element"
81                        .into(),
82                ),
83            },
84        ];
85
86        SetupValidationReport {
87            ok: true,
88            errors: vec![],
89            warnings,
90        }
91    }
92
93    fn ios_checks() -> SetupValidationReport {
94        let warnings = vec![
95            SetupIssue {
96                code: "ios_ui_background_modes".into(),
97                message: "UIBackgroundModes must include 'background-fetch' and \
98                          'background-processing' in Info.plist"
99                    .into(),
100                platform: Platform::Ios,
101                fix: Some(
102                    "Add UIBackgroundModes array with 'background-fetch' and \
103                     'background-processing' to Info.plist"
104                        .into(),
105                ),
106            },
107            SetupIssue {
108                code: "ios_bg_task_identifiers".into(),
109                message: "BGTaskSchedulerPermittedIdentifiers must list your task \
110                          identifiers in Info.plist"
111                    .into(),
112                platform: Platform::Ios,
113                fix: Some(
114                    "Add BGTaskSchedulerPermittedIdentifiers array with \
115                     '$(BUNDLE_ID).bg-refresh' and '$(BUNDLE_ID).bg-processing' to Info.plist"
116                        .into(),
117                ),
118            },
119            SetupIssue {
120                code: "ios_background_refresh".into(),
121                message: "Background App Refresh must be enabled in iOS Settings for \
122                          BGTaskScheduler to work"
123                    .into(),
124                platform: Platform::Ios,
125                fix: Some(
126                    "Instruct users to enable Background App Refresh in Settings > General > \
127                     Background App Refresh"
128                        .into(),
129                ),
130            },
131        ];
132
133        SetupValidationReport {
134            ok: true,
135            errors: vec![],
136            warnings,
137        }
138    }
139
140    #[allow(unused_mut)]
141    fn desktop_checks(platform: Platform) -> SetupValidationReport {
142        let mut errors: Vec<SetupIssue> = vec![];
143        let mut warnings: Vec<SetupIssue> = vec![];
144
145        #[cfg(feature = "desktop-service")]
146        {
147            if matches!(platform, Platform::Linux) {
148                let systemctl = std::path::Path::new("/usr/bin/systemctl").exists()
149                    || std::path::Path::new("/bin/systemctl").exists()
150                    || which_exists("systemctl");
151
152                if !systemctl {
153                    errors.push(SetupIssue {
154                        code: "desktop_systemd_missing".into(),
155                        message: "systemctl not found — OS service mode requires systemd".into(),
156                        platform: Platform::Linux,
157                        fix: Some("Install systemd or use inProcess mode".into()),
158                    });
159                } else {
160                    let uid = unsafe { libc::getuid() };
161                    let linger_path = format!("/var/lib/systemd/linger/{uid}");
162                    let linger_ok = std::path::Path::new(&linger_path).exists()
163                        || std::env::var("USER")
164                            .ok()
165                            .map(|u| {
166                                std::path::Path::new(&format!("/var/lib/systemd/linger/{u}"))
167                                    .exists()
168                            })
169                            .unwrap_or(false);
170
171                    if !linger_ok {
172                        warnings.push(SetupIssue {
173                            code: "desktop_systemd_no_linger".into(),
174                            message: "systemd lingering is not enabled — user services \
175                                      will stop when you log out"
176                                .into(),
177                            platform: Platform::Linux,
178                            fix: Some(
179                                "Run 'loginctl enable-linger' to keep user services alive \
180                                 after logout"
181                                    .into(),
182                            ),
183                        });
184                    }
185                }
186            }
187
188            if matches!(platform, Platform::Macos) {
189                warnings.push(SetupIssue {
190                    code: "desktop_macos_sandbox".into(),
191                    message: "OS service mode is incompatible with macOS App Sandbox. \
192                              Ensure your app is not sandboxed or use inProcess mode"
193                        .into(),
194                    platform: Platform::Macos,
195                    fix: Some(
196                        "Disable App Sandbox in your app's entitlements, or use \
197                         desktopServiceMode: 'inProcess'"
198                            .into(),
199                    ),
200                });
201            }
202        }
203
204        #[cfg(not(feature = "desktop-service"))]
205        {
206            let _ = platform;
207        }
208
209        SetupValidationReport {
210            ok: errors.is_empty(),
211            errors,
212            warnings,
213        }
214    }
215}
216
217/// Check if a command exists in PATH.
218#[cfg(feature = "desktop-service")]
219fn which_exists(cmd: &str) -> bool {
220    std::process::Command::new("which")
221        .arg(cmd)
222        .stdout(std::process::Stdio::null())
223        .stderr(std::process::Stdio::null())
224        .status()
225        .map(|s| s.success())
226        .unwrap_or(false)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn android_returns_no_errors() {
235        let report = SetupValidator::validate(Platform::Android);
236        assert!(
237            report.errors.is_empty(),
238            "Android should have no hard errors (checks happen at build/Kotlin level)"
239        );
240        assert!(!report.warnings.is_empty(), "Android should have warnings");
241        assert!(report.ok, "ok should be true when errors is empty");
242    }
243
244    #[test]
245    fn android_has_fgs_type_warning() {
246        let report = SetupValidator::validate(Platform::Android);
247        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
248        assert!(
249            codes.contains(&"android_fgs_type"),
250            "Should warn about FGS type: {codes:?}"
251        );
252    }
253
254    #[test]
255    fn android_has_post_notifications_warning() {
256        let report = SetupValidator::validate(Platform::Android);
257        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
258        assert!(
259            codes.contains(&"android_post_notifications"),
260            "Should warn about POST_NOTIFICATIONS: {codes:?}"
261        );
262    }
263
264    #[test]
265    fn android_has_boot_receiver_warning() {
266        let report = SetupValidator::validate(Platform::Android);
267        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
268        assert!(
269            codes.contains(&"android_boot_receiver"),
270            "Should warn about boot receiver: {codes:?}"
271        );
272    }
273
274    #[test]
275    fn android_has_special_use_subtype_warning() {
276        let report = SetupValidator::validate(Platform::Android);
277        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
278        assert!(
279            codes.contains(&"android_special_use_subtype"),
280            "Should warn about specialUse subtype: {codes:?}"
281        );
282    }
283
284    #[test]
285    fn android_all_warnings_have_fix() {
286        let report = SetupValidator::validate(Platform::Android);
287        for w in &report.warnings {
288            assert!(
289                w.fix.is_some(),
290                "Warning '{}' should have a fix suggestion",
291                w.code
292            );
293        }
294    }
295
296    #[test]
297    fn android_all_warnings_are_android_platform() {
298        let report = SetupValidator::validate(Platform::Android);
299        for w in &report.warnings {
300            assert_eq!(
301                w.platform,
302                Platform::Android,
303                "Warning '{}' should be Android platform",
304                w.code
305            );
306        }
307    }
308
309    #[test]
310    fn ios_returns_no_errors() {
311        let report = SetupValidator::validate(Platform::Ios);
312        assert!(
313            report.errors.is_empty(),
314            "iOS should have no hard errors (checks happen at build/Swift level)"
315        );
316        assert!(!report.warnings.is_empty(), "iOS should have warnings");
317        assert!(report.ok, "ok should be true when errors is empty");
318    }
319
320    #[test]
321    fn ios_has_background_modes_warning() {
322        let report = SetupValidator::validate(Platform::Ios);
323        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
324        assert!(
325            codes.contains(&"ios_ui_background_modes"),
326            "Should warn about UIBackgroundModes: {codes:?}"
327        );
328    }
329
330    #[test]
331    fn ios_has_task_identifiers_warning() {
332        let report = SetupValidator::validate(Platform::Ios);
333        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
334        assert!(
335            codes.contains(&"ios_bg_task_identifiers"),
336            "Should warn about BGTaskSchedulerPermittedIdentifiers: {codes:?}"
337        );
338    }
339
340    #[test]
341    fn ios_has_background_refresh_warning() {
342        let report = SetupValidator::validate(Platform::Ios);
343        let codes: Vec<&str> = report.warnings.iter().map(|w| w.code.as_str()).collect();
344        assert!(
345            codes.contains(&"ios_background_refresh"),
346            "Should warn about background refresh: {codes:?}"
347        );
348    }
349
350    #[test]
351    fn ios_all_warnings_have_fix() {
352        let report = SetupValidator::validate(Platform::Ios);
353        for w in &report.warnings {
354            assert!(
355                w.fix.is_some(),
356                "Warning '{}' should have a fix suggestion",
357                w.code
358            );
359        }
360    }
361
362    #[test]
363    fn ios_all_warnings_are_ios_platform() {
364        let report = SetupValidator::validate(Platform::Ios);
365        for w in &report.warnings {
366            assert_eq!(
367                w.platform,
368                Platform::Ios,
369                "Warning '{}' should be iOS platform",
370                w.code
371            );
372        }
373    }
374
375    #[test]
376    fn desktop_linux_no_errors_by_default() {
377        let report = SetupValidator::validate(Platform::Linux);
378        assert!(
379            report.ok || !report.errors.is_empty(),
380            "Report should be consistent: ok == errors.is_empty()"
381        );
382        assert_eq!(report.ok, report.errors.is_empty());
383    }
384
385    #[test]
386    fn desktop_macos_no_errors_by_default() {
387        let report = SetupValidator::validate(Platform::Macos);
388        assert_eq!(report.ok, report.errors.is_empty());
389    }
390
391    #[test]
392    fn desktop_windows_no_errors() {
393        let report = SetupValidator::validate(Platform::Windows);
394        assert!(
395            report.errors.is_empty(),
396            "Windows should have no desktop-service errors (not yet supported)"
397        );
398        assert!(report.ok);
399    }
400
401    #[test]
402    fn desktop_unknown_no_errors() {
403        let report = SetupValidator::validate(Platform::Unknown);
404        assert!(report.errors.is_empty());
405        assert!(report.ok);
406    }
407
408    #[test]
409    fn all_issues_have_non_empty_message() {
410        for platform in [
411            Platform::Android,
412            Platform::Ios,
413            Platform::Linux,
414            Platform::Macos,
415            Platform::Windows,
416        ] {
417            let report = SetupValidator::validate(platform);
418            for issue in report.errors.iter().chain(report.warnings.iter()) {
419                assert!(
420                    !issue.message.is_empty(),
421                    "Issue '{}' on {:?} should have a non-empty message",
422                    issue.code,
423                    platform
424                );
425                assert!(
426                    !issue.code.is_empty(),
427                    "Found an issue with an empty code on {:?}",
428                    platform
429                );
430            }
431        }
432    }
433
434    #[test]
435    fn setup_issue_serde_roundtrip() {
436        let issue = SetupIssue {
437            code: "test_code".into(),
438            message: "Test message".into(),
439            platform: Platform::Android,
440            fix: Some("Do something".into()),
441        };
442        let json = serde_json::to_string(&issue).unwrap();
443        let de: SetupIssue = serde_json::from_str(&json).unwrap();
444        assert_eq!(de.code, "test_code");
445        assert_eq!(de.message, "Test message");
446        assert_eq!(de.platform, Platform::Android);
447        assert_eq!(de.fix, Some("Do something".into()));
448    }
449
450    #[test]
451    fn setup_issue_json_keys_camel_case() {
452        let issue = SetupIssue {
453            code: "c".into(),
454            message: "m".into(),
455            platform: Platform::Linux,
456            fix: Some("f".into()),
457        };
458        let json = serde_json::to_string(&issue).unwrap();
459        assert!(json.contains("\"code\":"), "{json}");
460        assert!(json.contains("\"message\":"), "{json}");
461        assert!(json.contains("\"platform\":"), "{json}");
462        assert!(json.contains("\"fix\":"), "{json}");
463    }
464
465    #[test]
466    fn setup_issue_fix_absent_when_none() {
467        let issue = SetupIssue {
468            code: "c".into(),
469            message: "m".into(),
470            platform: Platform::Linux,
471            fix: None,
472        };
473        let json = serde_json::to_string(&issue).unwrap();
474        assert!(
475            !json.contains("\"fix\""),
476            "fix should be absent when None: {json}"
477        );
478    }
479
480    #[test]
481    fn setup_validation_report_serde_roundtrip() {
482        let report = SetupValidationReport {
483            ok: true,
484            errors: vec![],
485            warnings: vec![SetupIssue {
486                code: "w1".into(),
487                message: "Warning 1".into(),
488                platform: Platform::Android,
489                fix: Some("Fix it".into()),
490            }],
491        };
492        let json = serde_json::to_string(&report).unwrap();
493        let de: SetupValidationReport = serde_json::from_str(&json).unwrap();
494        assert!(de.ok);
495        assert!(de.errors.is_empty());
496        assert_eq!(de.warnings.len(), 1);
497        assert_eq!(de.warnings[0].code, "w1");
498    }
499
500    #[test]
501    fn setup_validation_report_json_keys_camel_case() {
502        let report = SetupValidationReport {
503            ok: false,
504            errors: vec![SetupIssue {
505                code: "e1".into(),
506                message: "Error".into(),
507                platform: Platform::Ios,
508                fix: None,
509            }],
510            warnings: vec![],
511        };
512        let json = serde_json::to_string(&report).unwrap();
513        assert!(json.contains("\"ok\":"), "{json}");
514        assert!(json.contains("\"errors\":"), "{json}");
515        assert!(json.contains("\"warnings\":"), "{json}");
516    }
517
518    #[test]
519    fn setup_validation_report_ok_true_when_no_errors() {
520        let report = SetupValidationReport {
521            ok: true,
522            errors: vec![],
523            warnings: vec![SetupIssue {
524                code: "w".into(),
525                message: "warn".into(),
526                platform: Platform::Linux,
527                fix: None,
528            }],
529        };
530        assert!(report.ok);
531    }
532
533    #[test]
534    fn setup_validation_report_ok_false_with_errors() {
535        let report = SetupValidationReport {
536            ok: false,
537            errors: vec![SetupIssue {
538                code: "e".into(),
539                message: "err".into(),
540                platform: Platform::Linux,
541                fix: None,
542            }],
543            warnings: vec![],
544        };
545        assert!(!report.ok);
546    }
547
548    #[cfg(feature = "desktop-service")]
549    #[test]
550    fn which_exists_true_for_ls() {
551        assert!(which_exists("ls"), "ls should exist in PATH");
552    }
553
554    #[cfg(feature = "desktop-service")]
555    #[test]
556    fn which_exists_false_for_nonsense() {
557        assert!(
558            !which_exists("definitely_not_a_real_command_xyz_123"),
559            "nonsense command should not exist"
560        );
561    }
562
563    #[test]
564    fn android_error_prevents_ok() {
565        let report = SetupValidationReport {
566            ok: false,
567            errors: vec![SetupIssue {
568                code: "test_error".into(),
569                message: "test".into(),
570                platform: Platform::Android,
571                fix: None,
572            }],
573            warnings: vec![],
574        };
575        assert!(!report.ok);
576        assert_eq!(report.errors.len(), 1);
577    }
578
579    #[test]
580    fn warnings_do_not_affect_ok() {
581        let report = SetupValidationReport {
582            ok: true,
583            errors: vec![],
584            warnings: vec![SetupIssue {
585                code: "w".into(),
586                message: "just a warning".into(),
587                platform: Platform::Linux,
588                fix: None,
589            }],
590        };
591        assert!(report.ok);
592        assert!(!report.warnings.is_empty());
593    }
594}