1#![deny(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4use std::{
5 collections::BTreeSet,
6 env,
7 ffi::OsString,
8 fmt::Write as _,
9 fs,
10 path::{Path, PathBuf},
11};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Service {
16 Camera,
18 PhotoLibrary,
20 PhotoLibraryAdd,
22 Microphone,
24 Location,
26 Notifications,
28 Background,
30 Media,
32 Billing,
34 Overlay,
36 Haptics,
38 Update,
40 Network,
42 HeartRate,
44 MicrophoneStandby,
47 Wearable,
50}
51
52impl Service {
53 pub const fn name(self) -> &'static str {
55 match self {
56 Service::Camera => "camera",
57 Service::PhotoLibrary => "photo-library",
58 Service::PhotoLibraryAdd => "photo-library-add",
59 Service::Microphone => "microphone",
60 Service::Location => "location",
61 Service::Notifications => "notifications",
62 Service::Background => "background",
63 Service::Media => "media",
64 Service::Billing => "billing",
65 Service::Overlay => "overlay",
66 Service::Haptics => "haptics",
67 Service::Update => "update",
68 Service::Network => "network",
69 Service::HeartRate => "heart-rate",
70 Service::MicrophoneStandby => "microphone-standby",
71 Service::Wearable => "wearable",
72 }
73 }
74
75 pub const fn android_permissions(self) -> &'static [&'static str] {
81 match self {
82 Service::Camera => &["android.permission.CAMERA"],
83 Service::PhotoLibrary => &[],
84 Service::PhotoLibraryAdd => &[],
85 Service::Microphone => &["android.permission.RECORD_AUDIO"],
86 Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
87 Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
88 Service::Background => &[
89 "android.permission.FOREGROUND_SERVICE",
90 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
91 ],
92 Service::Media => &[
93 "android.permission.FOREGROUND_SERVICE",
94 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
95 ],
96 Service::Billing => &["com.android.vending.BILLING"],
97 Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
98 Service::Haptics => &["android.permission.VIBRATE"],
99 Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
100 Service::Network => &[
101 "android.permission.INTERNET",
102 "android.permission.ACCESS_NETWORK_STATE",
103 ],
104 Service::HeartRate => &["android.permission.health.READ_HEART_RATE"],
108 Service::MicrophoneStandby => &[
109 "android.permission.FOREGROUND_SERVICE",
110 "android.permission.FOREGROUND_SERVICE_MICROPHONE",
111 "android.permission.RECORD_AUDIO",
112 ],
113 Service::Wearable => &[],
116 }
117 }
118
119 pub const fn android_permissions_up_to(self) -> &'static [(&'static str, u32)] {
123 match self {
124 Service::HeartRate => &[("android.permission.BODY_SENSORS", 35)],
125 _ => &[],
126 }
127 }
128
129 pub const fn apple_key(self) -> Option<&'static str> {
131 match self {
132 Service::Camera => Some("NSCameraUsageDescription"),
133 Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
134 Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
135 Service::Microphone => Some("NSMicrophoneUsageDescription"),
136 Service::Location => Some("NSLocationWhenInUseUsageDescription"),
137 Service::HeartRate => Some("NSHealthShareUsageDescription"),
138 _ => None,
139 }
140 }
141
142 pub const fn apple_more_keys(self) -> &'static [&'static str] {
147 match self {
148 Service::HeartRate => &["NSHealthUpdateUsageDescription"],
149 _ => &[],
150 }
151 }
152
153 pub const fn watch_background_modes(self) -> &'static [&'static str] {
156 match self {
157 Service::HeartRate => &["workout-processing"],
158 _ => &[],
159 }
160 }
161
162 pub const fn takes_reason(self) -> bool {
164 self.apple_key().is_some()
165 }
166}
167
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub struct Use {
171 service: Service,
172 reason: Option<&'static str>,
173}
174
175impl Use {
176 pub const fn camera(reason: &'static str) -> Self {
178 Self {
179 service: Service::Camera,
180 reason: Some(reason),
181 }
182 }
183
184 pub const fn photo_library(reason: &'static str) -> Self {
186 Self {
187 service: Service::PhotoLibrary,
188 reason: Some(reason),
189 }
190 }
191
192 pub const fn photo_library_add(reason: &'static str) -> Self {
194 Self {
195 service: Service::PhotoLibraryAdd,
196 reason: Some(reason),
197 }
198 }
199
200 pub const fn microphone(reason: &'static str) -> Self {
202 Self {
203 service: Service::Microphone,
204 reason: Some(reason),
205 }
206 }
207
208 pub const fn location(reason: &'static str) -> Self {
210 Self {
211 service: Service::Location,
212 reason: Some(reason),
213 }
214 }
215
216 pub const fn notifications() -> Self {
218 Self {
219 service: Service::Notifications,
220 reason: None,
221 }
222 }
223
224 pub const fn background() -> Self {
226 Self {
227 service: Service::Background,
228 reason: None,
229 }
230 }
231
232 pub const fn media() -> Self {
234 Self {
235 service: Service::Media,
236 reason: None,
237 }
238 }
239
240 pub const fn billing() -> Self {
242 Self {
243 service: Service::Billing,
244 reason: None,
245 }
246 }
247
248 pub const fn overlay() -> Self {
250 Self {
251 service: Service::Overlay,
252 reason: None,
253 }
254 }
255
256 pub const fn haptics() -> Self {
258 Self {
259 service: Service::Haptics,
260 reason: None,
261 }
262 }
263
264 pub const fn microphone_standby() -> Self {
267 Self {
268 service: Service::MicrophoneStandby,
269 reason: None,
270 }
271 }
272
273 pub const fn wearable() -> Self {
276 Self {
277 service: Service::Wearable,
278 reason: None,
279 }
280 }
281
282 pub const fn update() -> Self {
284 Self {
285 service: Service::Update,
286 reason: None,
287 }
288 }
289
290 pub const fn network() -> Self {
292 Self {
293 service: Service::Network,
294 reason: None,
295 }
296 }
297
298 pub const fn heart_rate(reason: &'static str) -> Self {
303 Self {
304 service: Service::HeartRate,
305 reason: Some(reason),
306 }
307 }
308
309 pub const fn service(self) -> Service {
311 self.service
312 }
313
314 pub const fn reason(self) -> Option<&'static str> {
316 self.reason
317 }
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
325pub enum Demand {
326 Camera,
328 Microphone,
330 Watch,
332 Telephony,
334 Bluetooth,
336 Nfc,
338 Wifi,
340 Location,
342}
343
344impl Demand {
345 pub const fn android_feature(self) -> &'static str {
347 match self {
348 Demand::Camera => "android.hardware.camera",
349 Demand::Microphone => "android.hardware.microphone",
350 Demand::Watch => "android.hardware.type.watch",
351 Demand::Telephony => "android.hardware.telephony",
352 Demand::Bluetooth => "android.hardware.bluetooth",
353 Demand::Nfc => "android.hardware.nfc",
354 Demand::Wifi => "android.hardware.wifi",
355 Demand::Location => "android.hardware.location",
356 }
357 }
358
359 pub const fn name(self) -> &'static str {
361 self.android_feature()
362 }
363}
364
365#[derive(Clone, Copy, Debug, PartialEq, Eq)]
367pub struct Capabilities<'a> {
368 pub uses: &'a [Use],
370 pub demands: &'a [Demand],
372 pub opens: &'a [&'a str],
377}
378
379impl Capabilities<'_> {
380 pub const NONE: Capabilities<'static> = Capabilities {
382 uses: &[],
383 demands: &[],
384 opens: &[],
385 };
386
387 pub fn has(&self, service: Service) -> bool {
389 self.uses.iter().any(|entry| entry.service == service)
390 }
391
392 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
394 self.uses
395 .iter()
396 .find(|entry| entry.service == service)
397 .and_then(|entry| entry.reason())
398 }
399}
400
401#[must_use = "a declaration reaches the platform builds only through emit()"]
403#[derive(Clone, Copy, Debug)]
404pub struct Declaration<'a> {
405 capabilities: Capabilities<'a>,
406}
407
408pub const fn declare(uses: &[Use]) -> Declaration<'_> {
410 Declaration {
411 capabilities: Capabilities {
412 uses,
413 demands: &[],
414 opens: &[],
415 },
416 }
417}
418
419impl<'a> Declaration<'a> {
420 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
422 Declaration {
423 capabilities: Capabilities {
424 demands,
425 ..self.capabilities
426 },
427 }
428 }
429
430 pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
433 Declaration {
434 capabilities: Capabilities {
435 opens,
436 ..self.capabilities
437 },
438 }
439 }
440
441 pub fn emit(self) {
447 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
448 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
449 write_file(
450 &out.join("cranpose_capabilities.rs"),
451 &rust_source(&self.capabilities),
452 );
453
454 let crate_dir = PathBuf::from(
455 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
456 );
457 let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
458 fs::create_dir_all(&shared).expect("the shared capabilities directory");
459 let outputs = shared_outputs(&shared, &package);
460 let contents = [
461 json(&self.capabilities),
462 android_manifest(&self.capabilities),
463 apple_usage(&self.capabilities),
464 ];
465 for (path, text) in outputs.iter().zip(contents.iter()) {
466 write_file(path, text);
467 }
468 println!("cargo::rerun-if-changed=build.rs");
469 println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
470 for directive in rerun_directives(&outputs) {
471 println!("{directive}");
472 }
473 }
474}
475
476fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
481 [
482 shared.join(format!("{package}-capabilities.json")),
483 shared.join(format!("{package}-permissions.xml")),
484 shared.join(format!("{package}-usage.plist")),
485 ]
486}
487
488fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
499 outputs
500 .iter()
501 .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
502 .collect()
503}
504
505fn write_file(path: &Path, text: &str) {
506 if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
507 return;
508 }
509 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
510}
511
512const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
518
519fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
522 match named.filter(|value| !value.is_empty()) {
523 Some(value) => PathBuf::from(value),
524 None => workspace_root(crate_dir).join("target").join("cranpose"),
525 }
526}
527
528fn workspace_root(crate_dir: &Path) -> PathBuf {
535 found_workspace(crate_dir, &|path| {
536 fs::read_to_string(path.join("Cargo.toml"))
537 .is_ok_and(|manifest| manifest.contains("[workspace]"))
538 })
539}
540
541fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
542 crate_dir
545 .ancestors()
546 .filter(|path| holds_workspace(path))
547 .last()
548 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
549}
550
551fn rust_source(capabilities: &Capabilities<'_>) -> String {
554 let mut text = String::from(
555 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
556 cranpose_capabilities::Capabilities {\n uses: &[\n",
557 );
558 for entry in capabilities.uses {
559 let call = constructor(entry);
560 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
561 }
562 text.push_str(" ],\n demands: &[\n");
563 for demand in capabilities.demands {
564 let _ = writeln!(
565 text,
566 " cranpose_capabilities::Demand::{demand:?},"
567 );
568 }
569 text.push_str(" ],\n opens: &[\n");
570 for media_type in capabilities.opens {
571 let _ = writeln!(text, " \"{}\",", escape(media_type));
572 }
573 text.push_str(" ],\n };\n");
574 text
575}
576
577fn constructor(entry: &Use) -> String {
580 let name = entry.service.name().replace('-', "_");
581 match entry.reason {
582 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
583 None => format!("{name}()"),
584 }
585}
586
587fn escape(text: &str) -> String {
588 text.replace('\\', "\\\\").replace('"', "\\\"")
589}
590
591pub fn json(capabilities: &Capabilities<'_>) -> String {
593 let mut text = String::from("{\n \"services\": [\n");
594 for (at, entry) in capabilities.uses.iter().enumerate() {
595 let comma = if at + 1 == capabilities.uses.len() {
596 ""
597 } else {
598 ","
599 };
600 let reason = match entry.reason {
601 Some(reason) => format!("\"{}\"", escape_json(reason)),
602 None => String::from("null"),
603 };
604 let _ = writeln!(
605 text,
606 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
607 entry.service.name()
608 );
609 }
610 text.push_str(" ],\n \"permissions\": [\n");
611 let permissions = android_permissions(capabilities);
612 for (at, permission) in permissions.iter().enumerate() {
613 let comma = if at + 1 == permissions.len() { "" } else { "," };
614 let _ = writeln!(text, " \"{permission}\"{comma}");
615 }
616 text.push_str(" ],\n \"permissionsUpTo\": [\n");
617 let capped = android_permissions_up_to(capabilities);
618 for (at, (permission, level)) in capped.iter().enumerate() {
619 let comma = if at + 1 == capped.len() { "" } else { "," };
620 let _ = writeln!(
621 text,
622 " {{ \"name\": \"{permission}\", \"maxSdk\": {level} }}{comma}"
623 );
624 }
625 text.push_str(" ],\n \"demands\": [\n");
626 for (at, demand) in capabilities.demands.iter().enumerate() {
627 let comma = if at + 1 == capabilities.demands.len() {
628 ""
629 } else {
630 ","
631 };
632 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
633 }
634 text.push_str(" ],\n \"opens\": [\n");
635 for (at, media_type) in capabilities.opens.iter().enumerate() {
636 let comma = if at + 1 == capabilities.opens.len() {
637 ""
638 } else {
639 ","
640 };
641 let _ = writeln!(text, " \"{}\"{comma}", escape_json(media_type));
642 }
643 text.push_str(" ]\n}\n");
644 text
645}
646
647fn escape_json(text: &str) -> String {
648 escape(text).replace('\n', "\\n")
649}
650
651pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
654 let mut named = BTreeSet::new();
655 for entry in capabilities.uses {
656 for permission in entry.service.android_permissions() {
657 named.insert(*permission);
658 }
659 }
660 named.into_iter().collect()
661}
662
663pub fn android_permissions_up_to(capabilities: &Capabilities<'_>) -> Vec<(&'static str, u32)> {
667 let plain = android_permissions(capabilities);
668 let mut named = std::collections::BTreeMap::new();
669 for entry in capabilities.uses {
670 for (permission, level) in entry.service.android_permissions_up_to() {
671 if !plain.contains(permission) {
672 named.insert(*permission, *level);
673 }
674 }
675 }
676 named.into_iter().collect()
677}
678
679pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
681 let mut text = String::from(
682 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
683 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
684 );
685 for permission in android_permissions(capabilities) {
686 let _ = writeln!(
687 text,
688 " <uses-permission android:name=\"{permission}\" />"
689 );
690 }
691 for (permission, level) in android_permissions_up_to(capabilities) {
692 let _ = writeln!(
693 text,
694 " <uses-permission android:name=\"{permission}\" android:maxSdkVersion=\"{level}\" />"
695 );
696 }
697 for demand in capabilities.demands {
698 let _ = writeln!(
699 text,
700 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
701 demand.android_feature()
702 );
703 }
704 if !capabilities.opens.is_empty() {
705 text.push_str(&android_opening_activity(capabilities.opens));
706 }
707 text.push_str("</manifest>\n");
708 text
709}
710
711fn android_opening_activity(opens: &[&str]) -> String {
714 let mut text = String::from(
715 " <application>\n \
716 <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
717 );
718 for actions in [
719 &[
720 "android.intent.action.SEND",
721 "android.intent.action.SEND_MULTIPLE",
722 ][..],
723 &["android.intent.action.VIEW"][..],
724 ] {
725 text.push_str(" <intent-filter>\n");
726 for action in actions {
727 let _ = writeln!(text, " <action android:name=\"{action}\" />");
728 }
729 text.push_str(
730 " <category android:name=\"android.intent.category.DEFAULT\" />\n",
731 );
732 for media_type in opens {
733 let _ = writeln!(
734 text,
735 " <data android:mimeType=\"{}\" />",
736 escape_xml(media_type)
737 );
738 }
739 text.push_str(" </intent-filter>\n");
740 }
741 text.push_str(" </activity>\n </application>\n");
742 text
743}
744
745pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
756 let mut text = String::from(
757 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
758 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
759 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
760 <plist version=\"1.0\">\n<dict>\n",
761 );
762 let mut modes: BTreeSet<&str> = BTreeSet::new();
763 for entry in capabilities.uses {
764 modes.extend(entry.service.watch_background_modes());
765 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
766 continue;
767 };
768 for key in std::iter::once(key).chain(entry.service.apple_more_keys().iter().copied()) {
769 let _ = writeln!(text, "\t<key>{key}</key>");
770 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
771 }
772 }
773 if !modes.is_empty() {
774 text.push_str("\t<key>WKBackgroundModes</key>\n\t<array>\n");
775 for mode in modes {
776 let _ = writeln!(text, "\t\t<string>{mode}</string>");
777 }
778 text.push_str("\t</array>\n");
779 }
780 text.push_str("</dict>\n</plist>\n");
781 text
782}
783
784fn escape_xml(text: &str) -> String {
785 text.replace('&', "&")
786 .replace('<', "<")
787 .replace('>', ">")
788}
789
790#[cfg(test)]
791#[path = "tests/capabilities_tests.rs"]
792mod tests;