1use crate::models::{Platform, SetupIssue, SetupValidationReport};
11
12pub struct SetupValidator;
18
19impl SetupValidator {
20 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#[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}