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 takes_reason(self) -> bool {
143 self.apple_key().is_some()
144 }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub struct Use {
150 service: Service,
151 reason: Option<&'static str>,
152}
153
154impl Use {
155 pub const fn camera(reason: &'static str) -> Self {
157 Self {
158 service: Service::Camera,
159 reason: Some(reason),
160 }
161 }
162
163 pub const fn photo_library(reason: &'static str) -> Self {
165 Self {
166 service: Service::PhotoLibrary,
167 reason: Some(reason),
168 }
169 }
170
171 pub const fn photo_library_add(reason: &'static str) -> Self {
173 Self {
174 service: Service::PhotoLibraryAdd,
175 reason: Some(reason),
176 }
177 }
178
179 pub const fn microphone(reason: &'static str) -> Self {
181 Self {
182 service: Service::Microphone,
183 reason: Some(reason),
184 }
185 }
186
187 pub const fn location(reason: &'static str) -> Self {
189 Self {
190 service: Service::Location,
191 reason: Some(reason),
192 }
193 }
194
195 pub const fn notifications() -> Self {
197 Self {
198 service: Service::Notifications,
199 reason: None,
200 }
201 }
202
203 pub const fn background() -> Self {
205 Self {
206 service: Service::Background,
207 reason: None,
208 }
209 }
210
211 pub const fn media() -> Self {
213 Self {
214 service: Service::Media,
215 reason: None,
216 }
217 }
218
219 pub const fn billing() -> Self {
221 Self {
222 service: Service::Billing,
223 reason: None,
224 }
225 }
226
227 pub const fn overlay() -> Self {
229 Self {
230 service: Service::Overlay,
231 reason: None,
232 }
233 }
234
235 pub const fn haptics() -> Self {
237 Self {
238 service: Service::Haptics,
239 reason: None,
240 }
241 }
242
243 pub const fn microphone_standby() -> Self {
246 Self {
247 service: Service::MicrophoneStandby,
248 reason: None,
249 }
250 }
251
252 pub const fn wearable() -> Self {
255 Self {
256 service: Service::Wearable,
257 reason: None,
258 }
259 }
260
261 pub const fn update() -> Self {
263 Self {
264 service: Service::Update,
265 reason: None,
266 }
267 }
268
269 pub const fn network() -> Self {
271 Self {
272 service: Service::Network,
273 reason: None,
274 }
275 }
276
277 pub const fn heart_rate(reason: &'static str) -> Self {
282 Self {
283 service: Service::HeartRate,
284 reason: Some(reason),
285 }
286 }
287
288 pub const fn service(self) -> Service {
290 self.service
291 }
292
293 pub const fn reason(self) -> Option<&'static str> {
295 self.reason
296 }
297}
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
304pub enum Demand {
305 Camera,
307 Microphone,
309 Watch,
311 Telephony,
313 Bluetooth,
315 Nfc,
317 Wifi,
319 Location,
321}
322
323impl Demand {
324 pub const fn android_feature(self) -> &'static str {
326 match self {
327 Demand::Camera => "android.hardware.camera",
328 Demand::Microphone => "android.hardware.microphone",
329 Demand::Watch => "android.hardware.type.watch",
330 Demand::Telephony => "android.hardware.telephony",
331 Demand::Bluetooth => "android.hardware.bluetooth",
332 Demand::Nfc => "android.hardware.nfc",
333 Demand::Wifi => "android.hardware.wifi",
334 Demand::Location => "android.hardware.location",
335 }
336 }
337
338 pub const fn name(self) -> &'static str {
340 self.android_feature()
341 }
342}
343
344#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub struct Capabilities<'a> {
347 pub uses: &'a [Use],
349 pub demands: &'a [Demand],
351 pub opens: &'a [&'a str],
356}
357
358impl Capabilities<'_> {
359 pub const NONE: Capabilities<'static> = Capabilities {
361 uses: &[],
362 demands: &[],
363 opens: &[],
364 };
365
366 pub fn has(&self, service: Service) -> bool {
368 self.uses.iter().any(|entry| entry.service == service)
369 }
370
371 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
373 self.uses
374 .iter()
375 .find(|entry| entry.service == service)
376 .and_then(|entry| entry.reason())
377 }
378}
379
380#[must_use = "a declaration reaches the platform builds only through emit()"]
382#[derive(Clone, Copy, Debug)]
383pub struct Declaration<'a> {
384 capabilities: Capabilities<'a>,
385}
386
387pub const fn declare(uses: &[Use]) -> Declaration<'_> {
389 Declaration {
390 capabilities: Capabilities {
391 uses,
392 demands: &[],
393 opens: &[],
394 },
395 }
396}
397
398impl<'a> Declaration<'a> {
399 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
401 Declaration {
402 capabilities: Capabilities {
403 demands,
404 ..self.capabilities
405 },
406 }
407 }
408
409 pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
412 Declaration {
413 capabilities: Capabilities {
414 opens,
415 ..self.capabilities
416 },
417 }
418 }
419
420 pub fn emit(self) {
426 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
427 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
428 write_file(
429 &out.join("cranpose_capabilities.rs"),
430 &rust_source(&self.capabilities),
431 );
432
433 let crate_dir = PathBuf::from(
434 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
435 );
436 let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
437 fs::create_dir_all(&shared).expect("the shared capabilities directory");
438 let outputs = shared_outputs(&shared, &package);
439 let contents = [
440 json(&self.capabilities),
441 android_manifest(&self.capabilities),
442 apple_usage(&self.capabilities),
443 ];
444 for (path, text) in outputs.iter().zip(contents.iter()) {
445 write_file(path, text);
446 }
447 println!("cargo::rerun-if-changed=build.rs");
448 println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
449 for directive in rerun_directives(&outputs) {
450 println!("{directive}");
451 }
452 }
453}
454
455fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
460 [
461 shared.join(format!("{package}-capabilities.json")),
462 shared.join(format!("{package}-permissions.xml")),
463 shared.join(format!("{package}-usage.plist")),
464 ]
465}
466
467fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
478 outputs
479 .iter()
480 .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
481 .collect()
482}
483
484fn write_file(path: &Path, text: &str) {
485 if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
486 return;
487 }
488 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
489}
490
491const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
497
498fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
501 match named.filter(|value| !value.is_empty()) {
502 Some(value) => PathBuf::from(value),
503 None => workspace_root(crate_dir).join("target").join("cranpose"),
504 }
505}
506
507fn workspace_root(crate_dir: &Path) -> PathBuf {
514 found_workspace(crate_dir, &|path| {
515 fs::read_to_string(path.join("Cargo.toml"))
516 .is_ok_and(|manifest| manifest.contains("[workspace]"))
517 })
518}
519
520fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
521 crate_dir
524 .ancestors()
525 .filter(|path| holds_workspace(path))
526 .last()
527 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
528}
529
530fn rust_source(capabilities: &Capabilities<'_>) -> String {
533 let mut text = String::from(
534 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
535 cranpose_capabilities::Capabilities {\n uses: &[\n",
536 );
537 for entry in capabilities.uses {
538 let call = constructor(entry);
539 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
540 }
541 text.push_str(" ],\n demands: &[\n");
542 for demand in capabilities.demands {
543 let _ = writeln!(
544 text,
545 " cranpose_capabilities::Demand::{demand:?},"
546 );
547 }
548 text.push_str(" ],\n opens: &[\n");
549 for media_type in capabilities.opens {
550 let _ = writeln!(text, " \"{}\",", escape(media_type));
551 }
552 text.push_str(" ],\n };\n");
553 text
554}
555
556fn constructor(entry: &Use) -> String {
559 let name = entry.service.name().replace('-', "_");
560 match entry.reason {
561 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
562 None => format!("{name}()"),
563 }
564}
565
566fn escape(text: &str) -> String {
567 text.replace('\\', "\\\\").replace('"', "\\\"")
568}
569
570pub fn json(capabilities: &Capabilities<'_>) -> String {
572 let mut text = String::from("{\n \"services\": [\n");
573 for (at, entry) in capabilities.uses.iter().enumerate() {
574 let comma = if at + 1 == capabilities.uses.len() {
575 ""
576 } else {
577 ","
578 };
579 let reason = match entry.reason {
580 Some(reason) => format!("\"{}\"", escape_json(reason)),
581 None => String::from("null"),
582 };
583 let _ = writeln!(
584 text,
585 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
586 entry.service.name()
587 );
588 }
589 text.push_str(" ],\n \"permissions\": [\n");
590 let permissions = android_permissions(capabilities);
591 for (at, permission) in permissions.iter().enumerate() {
592 let comma = if at + 1 == permissions.len() { "" } else { "," };
593 let _ = writeln!(text, " \"{permission}\"{comma}");
594 }
595 text.push_str(" ],\n \"permissionsUpTo\": [\n");
596 let capped = android_permissions_up_to(capabilities);
597 for (at, (permission, level)) in capped.iter().enumerate() {
598 let comma = if at + 1 == capped.len() { "" } else { "," };
599 let _ = writeln!(
600 text,
601 " {{ \"name\": \"{permission}\", \"maxSdk\": {level} }}{comma}"
602 );
603 }
604 text.push_str(" ],\n \"demands\": [\n");
605 for (at, demand) in capabilities.demands.iter().enumerate() {
606 let comma = if at + 1 == capabilities.demands.len() {
607 ""
608 } else {
609 ","
610 };
611 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
612 }
613 text.push_str(" ],\n \"opens\": [\n");
614 for (at, media_type) in capabilities.opens.iter().enumerate() {
615 let comma = if at + 1 == capabilities.opens.len() {
616 ""
617 } else {
618 ","
619 };
620 let _ = writeln!(text, " \"{}\"{comma}", escape_json(media_type));
621 }
622 text.push_str(" ]\n}\n");
623 text
624}
625
626fn escape_json(text: &str) -> String {
627 escape(text).replace('\n', "\\n")
628}
629
630pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
633 let mut named = BTreeSet::new();
634 for entry in capabilities.uses {
635 for permission in entry.service.android_permissions() {
636 named.insert(*permission);
637 }
638 }
639 named.into_iter().collect()
640}
641
642pub fn android_permissions_up_to(capabilities: &Capabilities<'_>) -> Vec<(&'static str, u32)> {
646 let plain = android_permissions(capabilities);
647 let mut named = std::collections::BTreeMap::new();
648 for entry in capabilities.uses {
649 for (permission, level) in entry.service.android_permissions_up_to() {
650 if !plain.contains(permission) {
651 named.insert(*permission, *level);
652 }
653 }
654 }
655 named.into_iter().collect()
656}
657
658pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
660 let mut text = String::from(
661 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
662 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
663 );
664 for permission in android_permissions(capabilities) {
665 let _ = writeln!(
666 text,
667 " <uses-permission android:name=\"{permission}\" />"
668 );
669 }
670 for (permission, level) in android_permissions_up_to(capabilities) {
671 let _ = writeln!(
672 text,
673 " <uses-permission android:name=\"{permission}\" android:maxSdkVersion=\"{level}\" />"
674 );
675 }
676 for demand in capabilities.demands {
677 let _ = writeln!(
678 text,
679 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
680 demand.android_feature()
681 );
682 }
683 if !capabilities.opens.is_empty() {
684 text.push_str(&android_opening_activity(capabilities.opens));
685 }
686 text.push_str("</manifest>\n");
687 text
688}
689
690fn android_opening_activity(opens: &[&str]) -> String {
693 let mut text = String::from(
694 " <application>\n \
695 <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
696 );
697 for actions in [
698 &[
699 "android.intent.action.SEND",
700 "android.intent.action.SEND_MULTIPLE",
701 ][..],
702 &["android.intent.action.VIEW"][..],
703 ] {
704 text.push_str(" <intent-filter>\n");
705 for action in actions {
706 let _ = writeln!(text, " <action android:name=\"{action}\" />");
707 }
708 text.push_str(
709 " <category android:name=\"android.intent.category.DEFAULT\" />\n",
710 );
711 for media_type in opens {
712 let _ = writeln!(
713 text,
714 " <data android:mimeType=\"{}\" />",
715 escape_xml(media_type)
716 );
717 }
718 text.push_str(" </intent-filter>\n");
719 }
720 text.push_str(" </activity>\n </application>\n");
721 text
722}
723
724pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
734 let mut text = String::from(
735 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
736 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
737 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
738 <plist version=\"1.0\">\n<dict>\n",
739 );
740 for entry in capabilities.uses {
741 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
742 continue;
743 };
744 let _ = writeln!(text, "\t<key>{key}</key>");
745 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
746 }
747 text.push_str("</dict>\n</plist>\n");
748 text
749}
750
751fn escape_xml(text: &str) -> String {
752 text.replace('&', "&")
753 .replace('<', "<")
754 .replace('>', ">")
755}
756
757#[cfg(test)]
758#[path = "tests/capabilities_tests.rs"]
759mod tests;