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] {
82 match self {
83 Service::Camera => &["android.permission.CAMERA"],
84 Service::PhotoLibrary => &["android.permission.READ_MEDIA_IMAGES"],
85 Service::PhotoLibraryAdd => &[],
86 Service::Microphone => &["android.permission.RECORD_AUDIO"],
87 Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
88 Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
89 Service::Background => &[
90 "android.permission.FOREGROUND_SERVICE",
91 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
92 ],
93 Service::Media => &[
94 "android.permission.FOREGROUND_SERVICE",
95 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
96 ],
97 Service::Billing => &["com.android.vending.BILLING"],
98 Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
99 Service::Haptics => &["android.permission.VIBRATE"],
100 Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
101 Service::Network => &[
102 "android.permission.INTERNET",
103 "android.permission.ACCESS_NETWORK_STATE",
104 ],
105 }
106 }
107
108 pub const fn apple_key(self) -> Option<&'static str> {
110 match self {
111 Service::Camera => Some("NSCameraUsageDescription"),
112 Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
113 Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
114 Service::Microphone => Some("NSMicrophoneUsageDescription"),
115 Service::Location => Some("NSLocationWhenInUseUsageDescription"),
116 _ => None,
117 }
118 }
119
120 pub const fn takes_reason(self) -> bool {
122 self.apple_key().is_some()
123 }
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub struct Use {
129 service: Service,
130 reason: Option<&'static str>,
131}
132
133impl Use {
134 pub const fn camera(reason: &'static str) -> Self {
136 Self {
137 service: Service::Camera,
138 reason: Some(reason),
139 }
140 }
141
142 pub const fn photo_library(reason: &'static str) -> Self {
144 Self {
145 service: Service::PhotoLibrary,
146 reason: Some(reason),
147 }
148 }
149
150 pub const fn photo_library_add(reason: &'static str) -> Self {
152 Self {
153 service: Service::PhotoLibraryAdd,
154 reason: Some(reason),
155 }
156 }
157
158 pub const fn microphone(reason: &'static str) -> Self {
160 Self {
161 service: Service::Microphone,
162 reason: Some(reason),
163 }
164 }
165
166 pub const fn location(reason: &'static str) -> Self {
168 Self {
169 service: Service::Location,
170 reason: Some(reason),
171 }
172 }
173
174 pub const fn notifications() -> Self {
176 Self {
177 service: Service::Notifications,
178 reason: None,
179 }
180 }
181
182 pub const fn background() -> Self {
184 Self {
185 service: Service::Background,
186 reason: None,
187 }
188 }
189
190 pub const fn media() -> Self {
192 Self {
193 service: Service::Media,
194 reason: None,
195 }
196 }
197
198 pub const fn billing() -> Self {
200 Self {
201 service: Service::Billing,
202 reason: None,
203 }
204 }
205
206 pub const fn overlay() -> Self {
208 Self {
209 service: Service::Overlay,
210 reason: None,
211 }
212 }
213
214 pub const fn haptics() -> Self {
216 Self {
217 service: Service::Haptics,
218 reason: None,
219 }
220 }
221
222 pub const fn update() -> Self {
224 Self {
225 service: Service::Update,
226 reason: None,
227 }
228 }
229
230 pub const fn network() -> Self {
232 Self {
233 service: Service::Network,
234 reason: None,
235 }
236 }
237
238 pub const fn service(self) -> Service {
240 self.service
241 }
242
243 pub const fn reason(self) -> Option<&'static str> {
245 self.reason
246 }
247}
248
249#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
254pub enum Demand {
255 Camera,
257 Microphone,
259 Watch,
261 Telephony,
263 Bluetooth,
265 Nfc,
267 Wifi,
269 Location,
271}
272
273impl Demand {
274 pub const fn android_feature(self) -> &'static str {
276 match self {
277 Demand::Camera => "android.hardware.camera",
278 Demand::Microphone => "android.hardware.microphone",
279 Demand::Watch => "android.hardware.type.watch",
280 Demand::Telephony => "android.hardware.telephony",
281 Demand::Bluetooth => "android.hardware.bluetooth",
282 Demand::Nfc => "android.hardware.nfc",
283 Demand::Wifi => "android.hardware.wifi",
284 Demand::Location => "android.hardware.location",
285 }
286 }
287
288 pub const fn name(self) -> &'static str {
290 self.android_feature()
291 }
292}
293
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub struct Capabilities<'a> {
297 pub uses: &'a [Use],
299 pub demands: &'a [Demand],
301}
302
303impl Capabilities<'_> {
304 pub const NONE: Capabilities<'static> = Capabilities {
306 uses: &[],
307 demands: &[],
308 };
309
310 pub fn has(&self, service: Service) -> bool {
312 self.uses.iter().any(|entry| entry.service == service)
313 }
314
315 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
317 self.uses
318 .iter()
319 .find(|entry| entry.service == service)
320 .and_then(|entry| entry.reason())
321 }
322}
323
324#[must_use = "a declaration reaches the platform builds only through emit()"]
326#[derive(Clone, Copy, Debug)]
327pub struct Declaration<'a> {
328 capabilities: Capabilities<'a>,
329}
330
331pub const fn declare(uses: &[Use]) -> Declaration<'_> {
333 Declaration {
334 capabilities: Capabilities { uses, demands: &[] },
335 }
336}
337
338impl<'a> Declaration<'a> {
339 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
341 Declaration {
342 capabilities: Capabilities {
343 uses: self.capabilities.uses,
344 demands,
345 },
346 }
347 }
348
349 pub fn emit(self) {
355 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
356 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
357 write_file(
358 &out.join("cranpose_capabilities.rs"),
359 &rust_source(&self.capabilities),
360 );
361
362 let crate_dir = PathBuf::from(
363 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
364 );
365 let shared = workspace_root(&crate_dir).join("target").join("cranpose");
366 fs::create_dir_all(&shared).expect("the shared capabilities directory");
367 write_file(
368 &shared.join(format!("{package}-capabilities.json")),
369 &json(&self.capabilities),
370 );
371 write_file(
372 &shared.join(format!("{package}-permissions.xml")),
373 &android_manifest(&self.capabilities),
374 );
375 write_file(
376 &shared.join(format!("{package}-usage.plist")),
377 &apple_usage(&self.capabilities),
378 );
379 println!("cargo::rerun-if-changed=build.rs");
380 }
381}
382
383fn write_file(path: &Path, text: &str) {
384 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
385}
386
387fn workspace_root(crate_dir: &Path) -> PathBuf {
394 found_workspace(crate_dir, &|path| {
395 fs::read_to_string(path.join("Cargo.toml"))
396 .is_ok_and(|manifest| manifest.contains("[workspace]"))
397 })
398}
399
400fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
401 crate_dir
404 .ancestors()
405 .filter(|path| holds_workspace(path))
406 .last()
407 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
408}
409
410fn rust_source(capabilities: &Capabilities<'_>) -> String {
413 let mut text = String::from(
414 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
415 cranpose_capabilities::Capabilities {\n uses: &[\n",
416 );
417 for entry in capabilities.uses {
418 let call = constructor(entry);
419 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
420 }
421 text.push_str(" ],\n demands: &[\n");
422 for demand in capabilities.demands {
423 let _ = writeln!(
424 text,
425 " cranpose_capabilities::Demand::{demand:?},"
426 );
427 }
428 text.push_str(" ],\n };\n");
429 text
430}
431
432fn constructor(entry: &Use) -> String {
435 let name = entry.service.name().replace('-', "_");
436 match entry.reason {
437 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
438 None => format!("{name}()"),
439 }
440}
441
442fn escape(text: &str) -> String {
443 text.replace('\\', "\\\\").replace('"', "\\\"")
444}
445
446pub fn json(capabilities: &Capabilities<'_>) -> String {
448 let mut text = String::from("{\n \"services\": [\n");
449 for (at, entry) in capabilities.uses.iter().enumerate() {
450 let comma = if at + 1 == capabilities.uses.len() {
451 ""
452 } else {
453 ","
454 };
455 let reason = match entry.reason {
456 Some(reason) => format!("\"{}\"", escape_json(reason)),
457 None => String::from("null"),
458 };
459 let _ = writeln!(
460 text,
461 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
462 entry.service.name()
463 );
464 }
465 text.push_str(" ],\n \"permissions\": [\n");
466 let permissions = android_permissions(capabilities);
467 for (at, permission) in permissions.iter().enumerate() {
468 let comma = if at + 1 == permissions.len() { "" } else { "," };
469 let _ = writeln!(text, " \"{permission}\"{comma}");
470 }
471 text.push_str(" ],\n \"demands\": [\n");
472 for (at, demand) in capabilities.demands.iter().enumerate() {
473 let comma = if at + 1 == capabilities.demands.len() {
474 ""
475 } else {
476 ","
477 };
478 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
479 }
480 text.push_str(" ]\n}\n");
481 text
482}
483
484fn escape_json(text: &str) -> String {
485 escape(text).replace('\n', "\\n")
486}
487
488pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
491 let mut named = BTreeSet::new();
492 for entry in capabilities.uses {
493 for permission in entry.service.android_permissions() {
494 named.insert(*permission);
495 }
496 }
497 named.into_iter().collect()
498}
499
500pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
502 let mut text = String::from(
503 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
504 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
505 );
506 for permission in android_permissions(capabilities) {
507 let _ = writeln!(
508 text,
509 " <uses-permission android:name=\"{permission}\" />"
510 );
511 }
512 for demand in capabilities.demands {
513 let _ = writeln!(
514 text,
515 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
516 demand.android_feature()
517 );
518 }
519 text.push_str("</manifest>\n");
520 text
521}
522
523pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
526 let mut text = String::new();
527 for entry in capabilities.uses {
528 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
529 continue;
530 };
531 let _ = writeln!(text, "\t<key>{key}</key>");
532 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
533 }
534 text
535}
536
537fn escape_xml(text: &str) -> String {
538 text.replace('&', "&")
539 .replace('<', "<")
540 .replace('>', ">")
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 const READS: &str = "Reads a receipt with the camera.";
548
549 const SCANNER: Capabilities<'static> = Capabilities {
550 uses: &[Use::camera(READS), Use::notifications(), Use::billing()],
551 demands: &[],
552 };
553
554 #[test]
555 fn a_service_with_a_sentence_carries_it() {
556 assert_eq!(SCANNER.reason_for(Service::Camera), Some(READS));
557 assert_eq!(SCANNER.reason_for(Service::Notifications), None);
558 assert!(SCANNER.has(Service::Billing));
559 assert!(!SCANNER.has(Service::Haptics));
560 }
561
562 const BOTH_SERVICES: [Use; 2] = [Use::background(), Use::media()];
563 const ONE_HAPTIC: [Use; 1] = [Use::haptics()];
564 const WATCH: [Demand; 1] = [Demand::Watch];
565 const ONE_CAMERA: [Use; 1] = [Use::camera(READS)];
566 const CAMERA_HARDWARE: [Demand; 1] = [Demand::Camera];
567
568 #[test]
569 fn android_permissions_come_from_the_services_without_repeats() {
570 let both = Capabilities {
571 uses: &BOTH_SERVICES,
572 demands: &[],
573 };
574 assert_eq!(
575 android_permissions(&both),
576 vec![
577 "android.permission.FOREGROUND_SERVICE",
578 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
579 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
580 ]
581 );
582 }
583
584 #[test]
585 fn the_manifest_names_the_permissions_and_the_demanded_hardware() {
586 let watch = Capabilities {
587 uses: &ONE_HAPTIC,
588 demands: &WATCH,
589 };
590 let text = android_manifest(&watch);
591 assert!(text.contains("<uses-permission android:name=\"android.permission.VIBRATE\" />"));
592 assert!(text.contains(
593 "<uses-feature android:name=\"android.hardware.type.watch\" android:required=\"true\" />"
594 ));
595 }
596
597 #[test]
598 fn apple_takes_only_the_services_it_shows_a_sentence_for() {
599 let text = apple_usage(&SCANNER);
600 assert!(text.contains("<key>NSCameraUsageDescription</key>"));
601 assert!(text.contains(READS));
602 assert!(!text.contains("Notification"));
603 }
604
605 #[test]
606 fn the_json_lists_services_permissions_and_demands() {
607 let text = json(&Capabilities {
608 uses: &ONE_CAMERA,
609 demands: &CAMERA_HARDWARE,
610 });
611 assert!(text.contains("\"name\": \"camera\""));
612 assert!(text.contains("\"android.permission.CAMERA\""));
613 assert!(text.contains("\"android.hardware.camera\""));
614 }
615
616 #[test]
617 fn the_generated_rust_rebuilds_the_same_declaration() {
618 let text = rust_source(&SCANNER);
619 assert!(text.contains("Use::camera(\"Reads a receipt with the camera.\")"));
620 assert!(text.contains("Use::notifications()"));
621 assert!(text.contains("demands: &[\n ],"));
622 }
623
624 #[test]
625 fn a_quote_in_a_sentence_survives_the_generated_rust() {
626 let text = constructor(&Use::camera("Reads a \"receipt\"."));
627 assert_eq!(text, "camera(\"Reads a \\\"receipt\\\".\")");
628 }
629
630 #[test]
631 fn the_workspace_is_the_tree_the_platform_builds_know() {
632 let crate_dir = Path::new("/w/app");
633 let holds = |path: &Path| path == Path::new("/w");
634 assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
635 }
636
637 #[test]
638 fn a_workspace_inside_a_workspace_answers_with_the_outer_one() {
639 let crate_dir = Path::new("/w/vendor/thing/crates/one");
640 let holds = |path: &Path| path == Path::new("/w") || path == Path::new("/w/vendor/thing");
641 assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
642 }
643
644 #[test]
645 fn a_crate_in_no_workspace_answers_with_itself() {
646 let crate_dir = Path::new("/w/single");
647 assert_eq!(
648 found_workspace(crate_dir, &|_| false),
649 Path::new("/w/single")
650 );
651 }
652}