1use std::{
22 collections::BTreeSet,
23 env,
24 fmt::Write as _,
25 fs,
26 path::{Path, PathBuf},
27};
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
31pub enum Service {
32 Camera,
34 PhotoLibrary,
36 PhotoLibraryAdd,
38 Microphone,
40 Location,
42 Notifications,
44 Background,
46 Media,
48 Billing,
50 Overlay,
52 Haptics,
54 Update,
56 Network,
58}
59
60impl Service {
61 pub const fn name(self) -> &'static str {
63 match self {
64 Service::Camera => "camera",
65 Service::PhotoLibrary => "photo-library",
66 Service::PhotoLibraryAdd => "photo-library-add",
67 Service::Microphone => "microphone",
68 Service::Location => "location",
69 Service::Notifications => "notifications",
70 Service::Background => "background",
71 Service::Media => "media",
72 Service::Billing => "billing",
73 Service::Overlay => "overlay",
74 Service::Haptics => "haptics",
75 Service::Update => "update",
76 Service::Network => "network",
77 }
78 }
79
80 pub const fn android_permissions(self) -> &'static [&'static str] {
86 match self {
87 Service::Camera => &["android.permission.CAMERA"],
88 Service::PhotoLibrary => &[],
89 Service::PhotoLibraryAdd => &[],
90 Service::Microphone => &["android.permission.RECORD_AUDIO"],
91 Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
92 Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
93 Service::Background => &[
94 "android.permission.FOREGROUND_SERVICE",
95 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
96 ],
97 Service::Media => &[
98 "android.permission.FOREGROUND_SERVICE",
99 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
100 ],
101 Service::Billing => &["com.android.vending.BILLING"],
102 Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
103 Service::Haptics => &["android.permission.VIBRATE"],
104 Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
105 Service::Network => &[
106 "android.permission.INTERNET",
107 "android.permission.ACCESS_NETWORK_STATE",
108 ],
109 }
110 }
111
112 pub const fn apple_key(self) -> Option<&'static str> {
114 match self {
115 Service::Camera => Some("NSCameraUsageDescription"),
116 Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
117 Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
118 Service::Microphone => Some("NSMicrophoneUsageDescription"),
119 Service::Location => Some("NSLocationWhenInUseUsageDescription"),
120 _ => None,
121 }
122 }
123
124 pub const fn takes_reason(self) -> bool {
126 self.apple_key().is_some()
127 }
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub struct Use {
133 service: Service,
134 reason: Option<&'static str>,
135}
136
137impl Use {
138 pub const fn camera(reason: &'static str) -> Self {
140 Self {
141 service: Service::Camera,
142 reason: Some(reason),
143 }
144 }
145
146 pub const fn photo_library(reason: &'static str) -> Self {
148 Self {
149 service: Service::PhotoLibrary,
150 reason: Some(reason),
151 }
152 }
153
154 pub const fn photo_library_add(reason: &'static str) -> Self {
156 Self {
157 service: Service::PhotoLibraryAdd,
158 reason: Some(reason),
159 }
160 }
161
162 pub const fn microphone(reason: &'static str) -> Self {
164 Self {
165 service: Service::Microphone,
166 reason: Some(reason),
167 }
168 }
169
170 pub const fn location(reason: &'static str) -> Self {
172 Self {
173 service: Service::Location,
174 reason: Some(reason),
175 }
176 }
177
178 pub const fn notifications() -> Self {
180 Self {
181 service: Service::Notifications,
182 reason: None,
183 }
184 }
185
186 pub const fn background() -> Self {
188 Self {
189 service: Service::Background,
190 reason: None,
191 }
192 }
193
194 pub const fn media() -> Self {
196 Self {
197 service: Service::Media,
198 reason: None,
199 }
200 }
201
202 pub const fn billing() -> Self {
204 Self {
205 service: Service::Billing,
206 reason: None,
207 }
208 }
209
210 pub const fn overlay() -> Self {
212 Self {
213 service: Service::Overlay,
214 reason: None,
215 }
216 }
217
218 pub const fn haptics() -> Self {
220 Self {
221 service: Service::Haptics,
222 reason: None,
223 }
224 }
225
226 pub const fn update() -> Self {
228 Self {
229 service: Service::Update,
230 reason: None,
231 }
232 }
233
234 pub const fn network() -> Self {
236 Self {
237 service: Service::Network,
238 reason: None,
239 }
240 }
241
242 pub const fn service(self) -> Service {
244 self.service
245 }
246
247 pub const fn reason(self) -> Option<&'static str> {
249 self.reason
250 }
251}
252
253#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
258pub enum Demand {
259 Camera,
261 Microphone,
263 Watch,
265 Telephony,
267 Bluetooth,
269 Nfc,
271 Wifi,
273 Location,
275}
276
277impl Demand {
278 pub const fn android_feature(self) -> &'static str {
280 match self {
281 Demand::Camera => "android.hardware.camera",
282 Demand::Microphone => "android.hardware.microphone",
283 Demand::Watch => "android.hardware.type.watch",
284 Demand::Telephony => "android.hardware.telephony",
285 Demand::Bluetooth => "android.hardware.bluetooth",
286 Demand::Nfc => "android.hardware.nfc",
287 Demand::Wifi => "android.hardware.wifi",
288 Demand::Location => "android.hardware.location",
289 }
290 }
291
292 pub const fn name(self) -> &'static str {
294 self.android_feature()
295 }
296}
297
298#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub struct Capabilities<'a> {
301 pub uses: &'a [Use],
303 pub demands: &'a [Demand],
305}
306
307impl Capabilities<'_> {
308 pub const NONE: Capabilities<'static> = Capabilities {
310 uses: &[],
311 demands: &[],
312 };
313
314 pub fn has(&self, service: Service) -> bool {
316 self.uses.iter().any(|entry| entry.service == service)
317 }
318
319 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
321 self.uses
322 .iter()
323 .find(|entry| entry.service == service)
324 .and_then(|entry| entry.reason())
325 }
326}
327
328#[must_use = "a declaration reaches the platform builds only through emit()"]
330#[derive(Clone, Copy, Debug)]
331pub struct Declaration<'a> {
332 capabilities: Capabilities<'a>,
333}
334
335pub const fn declare(uses: &[Use]) -> Declaration<'_> {
337 Declaration {
338 capabilities: Capabilities { uses, demands: &[] },
339 }
340}
341
342impl<'a> Declaration<'a> {
343 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
345 Declaration {
346 capabilities: Capabilities {
347 uses: self.capabilities.uses,
348 demands,
349 },
350 }
351 }
352
353 pub fn emit(self) {
359 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
360 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
361 write_file(
362 &out.join("cranpose_capabilities.rs"),
363 &rust_source(&self.capabilities),
364 );
365
366 let crate_dir = PathBuf::from(
367 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
368 );
369 let shared = workspace_root(&crate_dir).join("target").join("cranpose");
370 fs::create_dir_all(&shared).expect("the shared capabilities directory");
371 write_file(
372 &shared.join(format!("{package}-capabilities.json")),
373 &json(&self.capabilities),
374 );
375 write_file(
376 &shared.join(format!("{package}-permissions.xml")),
377 &android_manifest(&self.capabilities),
378 );
379 write_file(
380 &shared.join(format!("{package}-usage.plist")),
381 &apple_usage(&self.capabilities),
382 );
383 println!("cargo::rerun-if-changed=build.rs");
384 }
385}
386
387fn write_file(path: &Path, text: &str) {
388 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
389}
390
391fn workspace_root(crate_dir: &Path) -> PathBuf {
398 found_workspace(crate_dir, &|path| {
399 fs::read_to_string(path.join("Cargo.toml"))
400 .is_ok_and(|manifest| manifest.contains("[workspace]"))
401 })
402}
403
404fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
405 crate_dir
408 .ancestors()
409 .filter(|path| holds_workspace(path))
410 .last()
411 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
412}
413
414fn rust_source(capabilities: &Capabilities<'_>) -> String {
417 let mut text = String::from(
418 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
419 cranpose_capabilities::Capabilities {\n uses: &[\n",
420 );
421 for entry in capabilities.uses {
422 let call = constructor(entry);
423 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
424 }
425 text.push_str(" ],\n demands: &[\n");
426 for demand in capabilities.demands {
427 let _ = writeln!(
428 text,
429 " cranpose_capabilities::Demand::{demand:?},"
430 );
431 }
432 text.push_str(" ],\n };\n");
433 text
434}
435
436fn constructor(entry: &Use) -> String {
439 let name = entry.service.name().replace('-', "_");
440 match entry.reason {
441 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
442 None => format!("{name}()"),
443 }
444}
445
446fn escape(text: &str) -> String {
447 text.replace('\\', "\\\\").replace('"', "\\\"")
448}
449
450pub fn json(capabilities: &Capabilities<'_>) -> String {
452 let mut text = String::from("{\n \"services\": [\n");
453 for (at, entry) in capabilities.uses.iter().enumerate() {
454 let comma = if at + 1 == capabilities.uses.len() {
455 ""
456 } else {
457 ","
458 };
459 let reason = match entry.reason {
460 Some(reason) => format!("\"{}\"", escape_json(reason)),
461 None => String::from("null"),
462 };
463 let _ = writeln!(
464 text,
465 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
466 entry.service.name()
467 );
468 }
469 text.push_str(" ],\n \"permissions\": [\n");
470 let permissions = android_permissions(capabilities);
471 for (at, permission) in permissions.iter().enumerate() {
472 let comma = if at + 1 == permissions.len() { "" } else { "," };
473 let _ = writeln!(text, " \"{permission}\"{comma}");
474 }
475 text.push_str(" ],\n \"demands\": [\n");
476 for (at, demand) in capabilities.demands.iter().enumerate() {
477 let comma = if at + 1 == capabilities.demands.len() {
478 ""
479 } else {
480 ","
481 };
482 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
483 }
484 text.push_str(" ]\n}\n");
485 text
486}
487
488fn escape_json(text: &str) -> String {
489 escape(text).replace('\n', "\\n")
490}
491
492pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
495 let mut named = BTreeSet::new();
496 for entry in capabilities.uses {
497 for permission in entry.service.android_permissions() {
498 named.insert(*permission);
499 }
500 }
501 named.into_iter().collect()
502}
503
504pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
506 let mut text = String::from(
507 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
508 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
509 );
510 for permission in android_permissions(capabilities) {
511 let _ = writeln!(
512 text,
513 " <uses-permission android:name=\"{permission}\" />"
514 );
515 }
516 for demand in capabilities.demands {
517 let _ = writeln!(
518 text,
519 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
520 demand.android_feature()
521 );
522 }
523 text.push_str("</manifest>\n");
524 text
525}
526
527pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
537 let mut text = String::from(
538 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
539 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
540 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
541 <plist version=\"1.0\">\n<dict>\n",
542 );
543 for entry in capabilities.uses {
544 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
545 continue;
546 };
547 let _ = writeln!(text, "\t<key>{key}</key>");
548 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
549 }
550 text.push_str("</dict>\n</plist>\n");
551 text
552}
553
554fn escape_xml(text: &str) -> String {
555 text.replace('&', "&")
556 .replace('<', "<")
557 .replace('>', ">")
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 const READS: &str = "Reads a receipt with the camera.";
565
566 const SCANNER: Capabilities<'static> = Capabilities {
567 uses: &[Use::camera(READS), Use::notifications(), Use::billing()],
568 demands: &[],
569 };
570
571 #[test]
572 fn a_service_with_a_sentence_carries_it() {
573 assert_eq!(SCANNER.reason_for(Service::Camera), Some(READS));
574 assert_eq!(SCANNER.reason_for(Service::Notifications), None);
575 assert!(SCANNER.has(Service::Billing));
576 assert!(!SCANNER.has(Service::Haptics));
577 }
578
579 const BOTH_SERVICES: [Use; 2] = [Use::background(), Use::media()];
580 const ONE_HAPTIC: [Use; 1] = [Use::haptics()];
581 const WATCH: [Demand; 1] = [Demand::Watch];
582 const ONE_CAMERA: [Use; 1] = [Use::camera(READS)];
583 const CAMERA_HARDWARE: [Demand; 1] = [Demand::Camera];
584
585 #[test]
586 fn android_permissions_come_from_the_services_without_repeats() {
587 let both = Capabilities {
588 uses: &BOTH_SERVICES,
589 demands: &[],
590 };
591 assert_eq!(
592 android_permissions(&both),
593 vec![
594 "android.permission.FOREGROUND_SERVICE",
595 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
596 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
597 ]
598 );
599 }
600
601 #[test]
602 fn the_manifest_names_the_permissions_and_the_demanded_hardware() {
603 let watch = Capabilities {
604 uses: &ONE_HAPTIC,
605 demands: &WATCH,
606 };
607 let text = android_manifest(&watch);
608 assert!(text.contains("<uses-permission android:name=\"android.permission.VIBRATE\" />"));
609 assert!(text.contains(
610 "<uses-feature android:name=\"android.hardware.type.watch\" android:required=\"true\" />"
611 ));
612 }
613
614 #[test]
615 fn apple_takes_only_the_services_it_shows_a_sentence_for() {
616 let text = apple_usage(&SCANNER);
617 assert!(text.starts_with("<?xml"));
618 assert!(text.contains("<key>NSCameraUsageDescription</key>"));
619 assert!(text.contains(READS));
620 assert!(text.ends_with("</dict>\n</plist>\n"));
621 assert!(!text.contains("Notification"));
622 }
623
624 #[test]
625 fn the_json_lists_services_permissions_and_demands() {
626 let text = json(&Capabilities {
627 uses: &ONE_CAMERA,
628 demands: &CAMERA_HARDWARE,
629 });
630 assert!(text.contains("\"name\": \"camera\""));
631 assert!(text.contains("\"android.permission.CAMERA\""));
632 assert!(text.contains("\"android.hardware.camera\""));
633 }
634
635 #[test]
636 fn the_generated_rust_rebuilds_the_same_declaration() {
637 let text = rust_source(&SCANNER);
638 assert!(text.contains("Use::camera(\"Reads a receipt with the camera.\")"));
639 assert!(text.contains("Use::notifications()"));
640 assert!(text.contains("demands: &[\n ],"));
641 }
642
643 #[test]
644 fn a_quote_in_a_sentence_survives_the_generated_rust() {
645 let text = constructor(&Use::camera("Reads a \"receipt\"."));
646 assert_eq!(text, "camera(\"Reads a \\\"receipt\\\".\")");
647 }
648
649 #[test]
650 fn the_workspace_is_the_tree_the_platform_builds_know() {
651 let crate_dir = Path::new("/w/app");
652 let holds = |path: &Path| path == Path::new("/w");
653 assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
654 }
655
656 #[test]
657 fn a_workspace_inside_a_workspace_answers_with_the_outer_one() {
658 let crate_dir = Path::new("/w/vendor/thing/crates/one");
659 let holds = |path: &Path| path == Path::new("/w") || path == Path::new("/w/vendor/thing");
660 assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
661 }
662
663 #[test]
664 fn a_crate_in_no_workspace_answers_with_itself() {
665 let crate_dir = Path::new("/w/single");
666 assert_eq!(
667 found_workspace(crate_dir, &|_| false),
668 Path::new("/w/single")
669 );
670 }
671}