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