1use std::{
22 collections::BTreeSet,
23 env,
24 ffi::OsString,
25 fmt::Write as _,
26 fs,
27 path::{Path, PathBuf},
28};
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
32pub enum Service {
33 Camera,
35 PhotoLibrary,
37 PhotoLibraryAdd,
39 Microphone,
41 Location,
43 Notifications,
45 Background,
47 Media,
49 Billing,
51 Overlay,
53 Haptics,
55 Update,
57 Network,
59}
60
61impl Service {
62 pub const fn name(self) -> &'static str {
64 match self {
65 Service::Camera => "camera",
66 Service::PhotoLibrary => "photo-library",
67 Service::PhotoLibraryAdd => "photo-library-add",
68 Service::Microphone => "microphone",
69 Service::Location => "location",
70 Service::Notifications => "notifications",
71 Service::Background => "background",
72 Service::Media => "media",
73 Service::Billing => "billing",
74 Service::Overlay => "overlay",
75 Service::Haptics => "haptics",
76 Service::Update => "update",
77 Service::Network => "network",
78 }
79 }
80
81 pub const fn android_permissions(self) -> &'static [&'static str] {
87 match self {
88 Service::Camera => &["android.permission.CAMERA"],
89 Service::PhotoLibrary => &[],
90 Service::PhotoLibraryAdd => &[],
91 Service::Microphone => &["android.permission.RECORD_AUDIO"],
92 Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
93 Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
94 Service::Background => &[
95 "android.permission.FOREGROUND_SERVICE",
96 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
97 ],
98 Service::Media => &[
99 "android.permission.FOREGROUND_SERVICE",
100 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
101 ],
102 Service::Billing => &["com.android.vending.BILLING"],
103 Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
104 Service::Haptics => &["android.permission.VIBRATE"],
105 Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
106 Service::Network => &[
107 "android.permission.INTERNET",
108 "android.permission.ACCESS_NETWORK_STATE",
109 ],
110 }
111 }
112
113 pub const fn apple_key(self) -> Option<&'static str> {
115 match self {
116 Service::Camera => Some("NSCameraUsageDescription"),
117 Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
118 Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
119 Service::Microphone => Some("NSMicrophoneUsageDescription"),
120 Service::Location => Some("NSLocationWhenInUseUsageDescription"),
121 _ => None,
122 }
123 }
124
125 pub const fn takes_reason(self) -> bool {
127 self.apple_key().is_some()
128 }
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub struct Use {
134 service: Service,
135 reason: Option<&'static str>,
136}
137
138impl Use {
139 pub const fn camera(reason: &'static str) -> Self {
141 Self {
142 service: Service::Camera,
143 reason: Some(reason),
144 }
145 }
146
147 pub const fn photo_library(reason: &'static str) -> Self {
149 Self {
150 service: Service::PhotoLibrary,
151 reason: Some(reason),
152 }
153 }
154
155 pub const fn photo_library_add(reason: &'static str) -> Self {
157 Self {
158 service: Service::PhotoLibraryAdd,
159 reason: Some(reason),
160 }
161 }
162
163 pub const fn microphone(reason: &'static str) -> Self {
165 Self {
166 service: Service::Microphone,
167 reason: Some(reason),
168 }
169 }
170
171 pub const fn location(reason: &'static str) -> Self {
173 Self {
174 service: Service::Location,
175 reason: Some(reason),
176 }
177 }
178
179 pub const fn notifications() -> Self {
181 Self {
182 service: Service::Notifications,
183 reason: None,
184 }
185 }
186
187 pub const fn background() -> Self {
189 Self {
190 service: Service::Background,
191 reason: None,
192 }
193 }
194
195 pub const fn media() -> Self {
197 Self {
198 service: Service::Media,
199 reason: None,
200 }
201 }
202
203 pub const fn billing() -> Self {
205 Self {
206 service: Service::Billing,
207 reason: None,
208 }
209 }
210
211 pub const fn overlay() -> Self {
213 Self {
214 service: Service::Overlay,
215 reason: None,
216 }
217 }
218
219 pub const fn haptics() -> Self {
221 Self {
222 service: Service::Haptics,
223 reason: None,
224 }
225 }
226
227 pub const fn update() -> Self {
229 Self {
230 service: Service::Update,
231 reason: None,
232 }
233 }
234
235 pub const fn network() -> Self {
237 Self {
238 service: Service::Network,
239 reason: None,
240 }
241 }
242
243 pub const fn service(self) -> Service {
245 self.service
246 }
247
248 pub const fn reason(self) -> Option<&'static str> {
250 self.reason
251 }
252}
253
254#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
259pub enum Demand {
260 Camera,
262 Microphone,
264 Watch,
266 Telephony,
268 Bluetooth,
270 Nfc,
272 Wifi,
274 Location,
276}
277
278impl Demand {
279 pub const fn android_feature(self) -> &'static str {
281 match self {
282 Demand::Camera => "android.hardware.camera",
283 Demand::Microphone => "android.hardware.microphone",
284 Demand::Watch => "android.hardware.type.watch",
285 Demand::Telephony => "android.hardware.telephony",
286 Demand::Bluetooth => "android.hardware.bluetooth",
287 Demand::Nfc => "android.hardware.nfc",
288 Demand::Wifi => "android.hardware.wifi",
289 Demand::Location => "android.hardware.location",
290 }
291 }
292
293 pub const fn name(self) -> &'static str {
295 self.android_feature()
296 }
297}
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub struct Capabilities<'a> {
302 pub uses: &'a [Use],
304 pub demands: &'a [Demand],
306}
307
308impl Capabilities<'_> {
309 pub const NONE: Capabilities<'static> = Capabilities {
311 uses: &[],
312 demands: &[],
313 };
314
315 pub fn has(&self, service: Service) -> bool {
317 self.uses.iter().any(|entry| entry.service == service)
318 }
319
320 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
322 self.uses
323 .iter()
324 .find(|entry| entry.service == service)
325 .and_then(|entry| entry.reason())
326 }
327}
328
329#[must_use = "a declaration reaches the platform builds only through emit()"]
331#[derive(Clone, Copy, Debug)]
332pub struct Declaration<'a> {
333 capabilities: Capabilities<'a>,
334}
335
336pub const fn declare(uses: &[Use]) -> Declaration<'_> {
338 Declaration {
339 capabilities: Capabilities { uses, demands: &[] },
340 }
341}
342
343impl<'a> Declaration<'a> {
344 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
346 Declaration {
347 capabilities: Capabilities {
348 uses: self.capabilities.uses,
349 demands,
350 },
351 }
352 }
353
354 pub fn emit(self) {
360 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
361 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
362 write_file(
363 &out.join("cranpose_capabilities.rs"),
364 &rust_source(&self.capabilities),
365 );
366
367 let crate_dir = PathBuf::from(
368 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
369 );
370 let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
371 fs::create_dir_all(&shared).expect("the shared capabilities directory");
372 let outputs = shared_outputs(&shared, &package);
373 let contents = [
374 json(&self.capabilities),
375 android_manifest(&self.capabilities),
376 apple_usage(&self.capabilities),
377 ];
378 for (path, text) in outputs.iter().zip(contents.iter()) {
379 write_file(path, text);
380 }
381 println!("cargo::rerun-if-changed=build.rs");
382 println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
383 for directive in rerun_directives(&outputs) {
384 println!("{directive}");
385 }
386 }
387}
388
389fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
394 [
395 shared.join(format!("{package}-capabilities.json")),
396 shared.join(format!("{package}-permissions.xml")),
397 shared.join(format!("{package}-usage.plist")),
398 ]
399}
400
401fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
412 outputs
413 .iter()
414 .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
415 .collect()
416}
417
418fn write_file(path: &Path, text: &str) {
419 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
420}
421
422const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
428
429fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
432 match named.filter(|value| !value.is_empty()) {
433 Some(value) => PathBuf::from(value),
434 None => workspace_root(crate_dir).join("target").join("cranpose"),
435 }
436}
437
438fn workspace_root(crate_dir: &Path) -> PathBuf {
445 found_workspace(crate_dir, &|path| {
446 fs::read_to_string(path.join("Cargo.toml"))
447 .is_ok_and(|manifest| manifest.contains("[workspace]"))
448 })
449}
450
451fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
452 crate_dir
455 .ancestors()
456 .filter(|path| holds_workspace(path))
457 .last()
458 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
459}
460
461fn rust_source(capabilities: &Capabilities<'_>) -> String {
464 let mut text = String::from(
465 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
466 cranpose_capabilities::Capabilities {\n uses: &[\n",
467 );
468 for entry in capabilities.uses {
469 let call = constructor(entry);
470 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
471 }
472 text.push_str(" ],\n demands: &[\n");
473 for demand in capabilities.demands {
474 let _ = writeln!(
475 text,
476 " cranpose_capabilities::Demand::{demand:?},"
477 );
478 }
479 text.push_str(" ],\n };\n");
480 text
481}
482
483fn constructor(entry: &Use) -> String {
486 let name = entry.service.name().replace('-', "_");
487 match entry.reason {
488 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
489 None => format!("{name}()"),
490 }
491}
492
493fn escape(text: &str) -> String {
494 text.replace('\\', "\\\\").replace('"', "\\\"")
495}
496
497pub fn json(capabilities: &Capabilities<'_>) -> String {
499 let mut text = String::from("{\n \"services\": [\n");
500 for (at, entry) in capabilities.uses.iter().enumerate() {
501 let comma = if at + 1 == capabilities.uses.len() {
502 ""
503 } else {
504 ","
505 };
506 let reason = match entry.reason {
507 Some(reason) => format!("\"{}\"", escape_json(reason)),
508 None => String::from("null"),
509 };
510 let _ = writeln!(
511 text,
512 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
513 entry.service.name()
514 );
515 }
516 text.push_str(" ],\n \"permissions\": [\n");
517 let permissions = android_permissions(capabilities);
518 for (at, permission) in permissions.iter().enumerate() {
519 let comma = if at + 1 == permissions.len() { "" } else { "," };
520 let _ = writeln!(text, " \"{permission}\"{comma}");
521 }
522 text.push_str(" ],\n \"demands\": [\n");
523 for (at, demand) in capabilities.demands.iter().enumerate() {
524 let comma = if at + 1 == capabilities.demands.len() {
525 ""
526 } else {
527 ","
528 };
529 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
530 }
531 text.push_str(" ]\n}\n");
532 text
533}
534
535fn escape_json(text: &str) -> String {
536 escape(text).replace('\n', "\\n")
537}
538
539pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
542 let mut named = BTreeSet::new();
543 for entry in capabilities.uses {
544 for permission in entry.service.android_permissions() {
545 named.insert(*permission);
546 }
547 }
548 named.into_iter().collect()
549}
550
551pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
553 let mut text = String::from(
554 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
555 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
556 );
557 for permission in android_permissions(capabilities) {
558 let _ = writeln!(
559 text,
560 " <uses-permission android:name=\"{permission}\" />"
561 );
562 }
563 for demand in capabilities.demands {
564 let _ = writeln!(
565 text,
566 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
567 demand.android_feature()
568 );
569 }
570 text.push_str("</manifest>\n");
571 text
572}
573
574pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
584 let mut text = String::from(
585 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
586 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
587 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
588 <plist version=\"1.0\">\n<dict>\n",
589 );
590 for entry in capabilities.uses {
591 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
592 continue;
593 };
594 let _ = writeln!(text, "\t<key>{key}</key>");
595 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
596 }
597 text.push_str("</dict>\n</plist>\n");
598 text
599}
600
601fn escape_xml(text: &str) -> String {
602 text.replace('&', "&")
603 .replace('<', "<")
604 .replace('>', ">")
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 const READS: &str = "Reads a receipt with the camera.";
612
613 const SCANNER: Capabilities<'static> = Capabilities {
614 uses: &[Use::camera(READS), Use::notifications(), Use::billing()],
615 demands: &[],
616 };
617
618 #[test]
619 fn a_service_with_a_sentence_carries_it() {
620 assert_eq!(SCANNER.reason_for(Service::Camera), Some(READS));
621 assert_eq!(SCANNER.reason_for(Service::Notifications), None);
622 assert!(SCANNER.has(Service::Billing));
623 assert!(!SCANNER.has(Service::Haptics));
624 }
625
626 const BOTH_SERVICES: [Use; 2] = [Use::background(), Use::media()];
627 const ONE_HAPTIC: [Use; 1] = [Use::haptics()];
628 const WATCH: [Demand; 1] = [Demand::Watch];
629 const ONE_CAMERA: [Use; 1] = [Use::camera(READS)];
630 const CAMERA_HARDWARE: [Demand; 1] = [Demand::Camera];
631
632 #[test]
633 fn android_permissions_come_from_the_services_without_repeats() {
634 let both = Capabilities {
635 uses: &BOTH_SERVICES,
636 demands: &[],
637 };
638 assert_eq!(
639 android_permissions(&both),
640 vec![
641 "android.permission.FOREGROUND_SERVICE",
642 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
643 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
644 ]
645 );
646 }
647
648 #[test]
649 fn the_manifest_names_the_permissions_and_the_demanded_hardware() {
650 let watch = Capabilities {
651 uses: &ONE_HAPTIC,
652 demands: &WATCH,
653 };
654 let text = android_manifest(&watch);
655 assert!(text.contains("<uses-permission android:name=\"android.permission.VIBRATE\" />"));
656 assert!(text.contains(
657 "<uses-feature android:name=\"android.hardware.type.watch\" android:required=\"true\" />"
658 ));
659 }
660
661 #[test]
662 fn the_build_names_where_the_declaration_goes() {
663 let named = shared_dir(
664 Path::new("/checkout/crates/app"),
665 Some(OsString::from("/checkout/target/cranpose")),
666 );
667 assert_eq!(named, PathBuf::from("/checkout/target/cranpose"));
668 }
669
670 #[test]
671 fn an_empty_name_is_no_name() {
672 let crate_dir = Path::new("/checkout/crates/app");
673 assert_eq!(
674 shared_dir(crate_dir, Some(OsString::new())),
675 shared_dir(crate_dir, None)
676 );
677 }
678
679 #[test]
680 fn apple_takes_only_the_services_it_shows_a_sentence_for() {
681 let text = apple_usage(&SCANNER);
682 assert!(text.starts_with("<?xml"));
683 assert!(text.contains("<key>NSCameraUsageDescription</key>"));
684 assert!(text.contains(READS));
685 assert!(text.ends_with("</dict>\n</plist>\n"));
686 assert!(!text.contains("Notification"));
687 }
688
689 #[test]
690 fn the_json_lists_services_permissions_and_demands() {
691 let text = json(&Capabilities {
692 uses: &ONE_CAMERA,
693 demands: &CAMERA_HARDWARE,
694 });
695 assert!(text.contains("\"name\": \"camera\""));
696 assert!(text.contains("\"android.permission.CAMERA\""));
697 assert!(text.contains("\"android.hardware.camera\""));
698 }
699
700 #[test]
701 fn the_generated_rust_rebuilds_the_same_declaration() {
702 let text = rust_source(&SCANNER);
703 assert!(text.contains("Use::camera(\"Reads a receipt with the camera.\")"));
704 assert!(text.contains("Use::notifications()"));
705 assert!(text.contains("demands: &[\n ],"));
706 }
707
708 #[test]
709 fn a_quote_in_a_sentence_survives_the_generated_rust() {
710 let text = constructor(&Use::camera("Reads a \"receipt\"."));
711 assert_eq!(text, "camera(\"Reads a \\\"receipt\\\".\")");
712 }
713
714 #[test]
715 fn the_workspace_is_the_tree_the_platform_builds_know() {
716 let crate_dir = Path::new("/w/app");
717 let holds = |path: &Path| path == Path::new("/w");
718 assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
719 }
720
721 #[test]
722 fn a_workspace_inside_a_workspace_answers_with_the_outer_one() {
723 let crate_dir = Path::new("/w/vendor/thing/crates/one");
724 let holds = |path: &Path| path == Path::new("/w") || path == Path::new("/w/vendor/thing");
725 assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
726 }
727
728 #[test]
729 fn a_crate_in_no_workspace_answers_with_itself() {
730 let crate_dir = Path::new("/w/single");
731 assert_eq!(
732 found_workspace(crate_dir, &|_| false),
733 Path::new("/w/single")
734 );
735 }
736
737 #[test]
738 fn the_declaration_is_three_files_beside_the_generated_source() {
739 let outputs = shared_outputs(Path::new("/w/target/cranpose"), "desktop-app-platform");
740 let names: Vec<String> = outputs
741 .iter()
742 .map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
743 .collect();
744 assert_eq!(
745 names,
746 vec![
747 "desktop-app-platform-capabilities.json",
748 "desktop-app-platform-permissions.xml",
749 "desktop-app-platform-usage.plist",
750 ]
751 );
752 }
753
754 #[test]
755 fn every_file_written_outside_out_dir_is_named_to_cargo() {
756 let outputs = shared_outputs(Path::new("/w/target/cranpose"), "example");
757 let directives = rerun_directives(&outputs);
758 assert_eq!(
759 directives.len(),
760 outputs.len(),
761 "a file cargo is not told about is a file nothing will rewrite once it is gone"
762 );
763 for path in &outputs {
764 let expected = format!("cargo::rerun-if-changed={}", path.display());
765 assert!(
766 directives.contains(&expected),
767 "{} is written but never named to cargo: {directives:?}",
768 path.display()
769 );
770 }
771 }
772
773 #[test]
777 fn emit_writes_the_shared_files_only_through_the_named_list() {
778 let source = include_str!("lib.rs");
779 let emit = source
780 .split_once("pub fn emit(self)")
781 .expect("emit is still a function")
782 .1
783 .split_once("\n}")
784 .expect("emit still ends")
785 .0;
786 for name in ["-capabilities.json", "-permissions.xml", "-usage.plist"] {
787 assert!(
788 !emit.contains(name),
789 "emit names {name} directly instead of going through shared_outputs, \
790 so cargo is never told the file exists"
791 );
792 }
793 assert!(
794 emit.contains("shared_outputs(&shared, &package)")
795 && emit.contains("rerun_directives(&outputs)"),
796 "emit must write and announce the same list of files"
797 );
798 }
799}