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}
44
45impl Service {
46 pub const fn name(self) -> &'static str {
48 match self {
49 Service::Camera => "camera",
50 Service::PhotoLibrary => "photo-library",
51 Service::PhotoLibraryAdd => "photo-library-add",
52 Service::Microphone => "microphone",
53 Service::Location => "location",
54 Service::Notifications => "notifications",
55 Service::Background => "background",
56 Service::Media => "media",
57 Service::Billing => "billing",
58 Service::Overlay => "overlay",
59 Service::Haptics => "haptics",
60 Service::Update => "update",
61 Service::Network => "network",
62 Service::HeartRate => "heart-rate",
63 }
64 }
65
66 pub const fn android_permissions(self) -> &'static [&'static str] {
72 match self {
73 Service::Camera => &["android.permission.CAMERA"],
74 Service::PhotoLibrary => &[],
75 Service::PhotoLibraryAdd => &[],
76 Service::Microphone => &["android.permission.RECORD_AUDIO"],
77 Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
78 Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
79 Service::Background => &[
80 "android.permission.FOREGROUND_SERVICE",
81 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
82 ],
83 Service::Media => &[
84 "android.permission.FOREGROUND_SERVICE",
85 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
86 ],
87 Service::Billing => &["com.android.vending.BILLING"],
88 Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
89 Service::Haptics => &["android.permission.VIBRATE"],
90 Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
91 Service::Network => &[
92 "android.permission.INTERNET",
93 "android.permission.ACCESS_NETWORK_STATE",
94 ],
95 Service::HeartRate => &["android.permission.health.READ_HEART_RATE"],
99 }
100 }
101
102 pub const fn android_permissions_up_to(self) -> &'static [(&'static str, u32)] {
106 match self {
107 Service::HeartRate => &[("android.permission.BODY_SENSORS", 35)],
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 Service::HeartRate => Some("NSHealthShareUsageDescription"),
121 _ => None,
122 }
123 }
124
125 pub const fn takes_reason(self) -> bool {
127 self.apple_key().is_some()
128 }
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub struct Use {
134 service: Service,
135 reason: Option<&'static str>,
136}
137
138impl Use {
139 pub const fn camera(reason: &'static str) -> Self {
141 Self {
142 service: Service::Camera,
143 reason: Some(reason),
144 }
145 }
146
147 pub const fn photo_library(reason: &'static str) -> Self {
149 Self {
150 service: Service::PhotoLibrary,
151 reason: Some(reason),
152 }
153 }
154
155 pub const fn photo_library_add(reason: &'static str) -> Self {
157 Self {
158 service: Service::PhotoLibraryAdd,
159 reason: Some(reason),
160 }
161 }
162
163 pub const fn microphone(reason: &'static str) -> Self {
165 Self {
166 service: Service::Microphone,
167 reason: Some(reason),
168 }
169 }
170
171 pub const fn location(reason: &'static str) -> Self {
173 Self {
174 service: Service::Location,
175 reason: Some(reason),
176 }
177 }
178
179 pub const fn notifications() -> Self {
181 Self {
182 service: Service::Notifications,
183 reason: None,
184 }
185 }
186
187 pub const fn background() -> Self {
189 Self {
190 service: Service::Background,
191 reason: None,
192 }
193 }
194
195 pub const fn media() -> Self {
197 Self {
198 service: Service::Media,
199 reason: None,
200 }
201 }
202
203 pub const fn billing() -> Self {
205 Self {
206 service: Service::Billing,
207 reason: None,
208 }
209 }
210
211 pub const fn overlay() -> Self {
213 Self {
214 service: Service::Overlay,
215 reason: None,
216 }
217 }
218
219 pub const fn haptics() -> Self {
221 Self {
222 service: Service::Haptics,
223 reason: None,
224 }
225 }
226
227 pub const fn update() -> Self {
229 Self {
230 service: Service::Update,
231 reason: None,
232 }
233 }
234
235 pub const fn network() -> Self {
237 Self {
238 service: Service::Network,
239 reason: None,
240 }
241 }
242
243 pub const fn heart_rate(reason: &'static str) -> Self {
248 Self {
249 service: Service::HeartRate,
250 reason: Some(reason),
251 }
252 }
253
254 pub const fn service(self) -> Service {
256 self.service
257 }
258
259 pub const fn reason(self) -> Option<&'static str> {
261 self.reason
262 }
263}
264
265#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
270pub enum Demand {
271 Camera,
273 Microphone,
275 Watch,
277 Telephony,
279 Bluetooth,
281 Nfc,
283 Wifi,
285 Location,
287}
288
289impl Demand {
290 pub const fn android_feature(self) -> &'static str {
292 match self {
293 Demand::Camera => "android.hardware.camera",
294 Demand::Microphone => "android.hardware.microphone",
295 Demand::Watch => "android.hardware.type.watch",
296 Demand::Telephony => "android.hardware.telephony",
297 Demand::Bluetooth => "android.hardware.bluetooth",
298 Demand::Nfc => "android.hardware.nfc",
299 Demand::Wifi => "android.hardware.wifi",
300 Demand::Location => "android.hardware.location",
301 }
302 }
303
304 pub const fn name(self) -> &'static str {
306 self.android_feature()
307 }
308}
309
310#[derive(Clone, Copy, Debug, PartialEq, Eq)]
312pub struct Capabilities<'a> {
313 pub uses: &'a [Use],
315 pub demands: &'a [Demand],
317 pub opens: &'a [&'a str],
322}
323
324impl Capabilities<'_> {
325 pub const NONE: Capabilities<'static> = Capabilities {
327 uses: &[],
328 demands: &[],
329 opens: &[],
330 };
331
332 pub fn has(&self, service: Service) -> bool {
334 self.uses.iter().any(|entry| entry.service == service)
335 }
336
337 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
339 self.uses
340 .iter()
341 .find(|entry| entry.service == service)
342 .and_then(|entry| entry.reason())
343 }
344}
345
346#[must_use = "a declaration reaches the platform builds only through emit()"]
348#[derive(Clone, Copy, Debug)]
349pub struct Declaration<'a> {
350 capabilities: Capabilities<'a>,
351}
352
353pub const fn declare(uses: &[Use]) -> Declaration<'_> {
355 Declaration {
356 capabilities: Capabilities {
357 uses,
358 demands: &[],
359 opens: &[],
360 },
361 }
362}
363
364impl<'a> Declaration<'a> {
365 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
367 Declaration {
368 capabilities: Capabilities {
369 demands,
370 ..self.capabilities
371 },
372 }
373 }
374
375 pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
378 Declaration {
379 capabilities: Capabilities {
380 opens,
381 ..self.capabilities
382 },
383 }
384 }
385
386 pub fn emit(self) {
392 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
393 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
394 write_file(
395 &out.join("cranpose_capabilities.rs"),
396 &rust_source(&self.capabilities),
397 );
398
399 let crate_dir = PathBuf::from(
400 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
401 );
402 let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
403 fs::create_dir_all(&shared).expect("the shared capabilities directory");
404 let outputs = shared_outputs(&shared, &package);
405 let contents = [
406 json(&self.capabilities),
407 android_manifest(&self.capabilities),
408 apple_usage(&self.capabilities),
409 ];
410 for (path, text) in outputs.iter().zip(contents.iter()) {
411 write_file(path, text);
412 }
413 println!("cargo::rerun-if-changed=build.rs");
414 println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
415 for directive in rerun_directives(&outputs) {
416 println!("{directive}");
417 }
418 }
419}
420
421fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
426 [
427 shared.join(format!("{package}-capabilities.json")),
428 shared.join(format!("{package}-permissions.xml")),
429 shared.join(format!("{package}-usage.plist")),
430 ]
431}
432
433fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
444 outputs
445 .iter()
446 .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
447 .collect()
448}
449
450fn write_file(path: &Path, text: &str) {
451 if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
452 return;
453 }
454 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
455}
456
457const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
463
464fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
467 match named.filter(|value| !value.is_empty()) {
468 Some(value) => PathBuf::from(value),
469 None => workspace_root(crate_dir).join("target").join("cranpose"),
470 }
471}
472
473fn workspace_root(crate_dir: &Path) -> PathBuf {
480 found_workspace(crate_dir, &|path| {
481 fs::read_to_string(path.join("Cargo.toml"))
482 .is_ok_and(|manifest| manifest.contains("[workspace]"))
483 })
484}
485
486fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
487 crate_dir
490 .ancestors()
491 .filter(|path| holds_workspace(path))
492 .last()
493 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
494}
495
496fn rust_source(capabilities: &Capabilities<'_>) -> String {
499 let mut text = String::from(
500 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
501 cranpose_capabilities::Capabilities {\n uses: &[\n",
502 );
503 for entry in capabilities.uses {
504 let call = constructor(entry);
505 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
506 }
507 text.push_str(" ],\n demands: &[\n");
508 for demand in capabilities.demands {
509 let _ = writeln!(
510 text,
511 " cranpose_capabilities::Demand::{demand:?},"
512 );
513 }
514 text.push_str(" ],\n opens: &[\n");
515 for media_type in capabilities.opens {
516 let _ = writeln!(text, " \"{}\",", escape(media_type));
517 }
518 text.push_str(" ],\n };\n");
519 text
520}
521
522fn constructor(entry: &Use) -> String {
525 let name = entry.service.name().replace('-', "_");
526 match entry.reason {
527 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
528 None => format!("{name}()"),
529 }
530}
531
532fn escape(text: &str) -> String {
533 text.replace('\\', "\\\\").replace('"', "\\\"")
534}
535
536pub fn json(capabilities: &Capabilities<'_>) -> String {
538 let mut text = String::from("{\n \"services\": [\n");
539 for (at, entry) in capabilities.uses.iter().enumerate() {
540 let comma = if at + 1 == capabilities.uses.len() {
541 ""
542 } else {
543 ","
544 };
545 let reason = match entry.reason {
546 Some(reason) => format!("\"{}\"", escape_json(reason)),
547 None => String::from("null"),
548 };
549 let _ = writeln!(
550 text,
551 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
552 entry.service.name()
553 );
554 }
555 text.push_str(" ],\n \"permissions\": [\n");
556 let permissions = android_permissions(capabilities);
557 for (at, permission) in permissions.iter().enumerate() {
558 let comma = if at + 1 == permissions.len() { "" } else { "," };
559 let _ = writeln!(text, " \"{permission}\"{comma}");
560 }
561 text.push_str(" ],\n \"permissionsUpTo\": [\n");
562 let capped = android_permissions_up_to(capabilities);
563 for (at, (permission, level)) in capped.iter().enumerate() {
564 let comma = if at + 1 == capped.len() { "" } else { "," };
565 let _ = writeln!(
566 text,
567 " {{ \"name\": \"{permission}\", \"maxSdk\": {level} }}{comma}"
568 );
569 }
570 text.push_str(" ],\n \"demands\": [\n");
571 for (at, demand) in capabilities.demands.iter().enumerate() {
572 let comma = if at + 1 == capabilities.demands.len() {
573 ""
574 } else {
575 ","
576 };
577 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
578 }
579 text.push_str(" ],\n \"opens\": [\n");
580 for (at, media_type) in capabilities.opens.iter().enumerate() {
581 let comma = if at + 1 == capabilities.opens.len() {
582 ""
583 } else {
584 ","
585 };
586 let _ = writeln!(text, " \"{}\"{comma}", escape_json(media_type));
587 }
588 text.push_str(" ]\n}\n");
589 text
590}
591
592fn escape_json(text: &str) -> String {
593 escape(text).replace('\n', "\\n")
594}
595
596pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
599 let mut named = BTreeSet::new();
600 for entry in capabilities.uses {
601 for permission in entry.service.android_permissions() {
602 named.insert(*permission);
603 }
604 }
605 named.into_iter().collect()
606}
607
608pub fn android_permissions_up_to(capabilities: &Capabilities<'_>) -> Vec<(&'static str, u32)> {
612 let plain = android_permissions(capabilities);
613 let mut named = std::collections::BTreeMap::new();
614 for entry in capabilities.uses {
615 for (permission, level) in entry.service.android_permissions_up_to() {
616 if !plain.contains(permission) {
617 named.insert(*permission, *level);
618 }
619 }
620 }
621 named.into_iter().collect()
622}
623
624pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
626 let mut text = String::from(
627 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
628 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
629 );
630 for permission in android_permissions(capabilities) {
631 let _ = writeln!(
632 text,
633 " <uses-permission android:name=\"{permission}\" />"
634 );
635 }
636 for (permission, level) in android_permissions_up_to(capabilities) {
637 let _ = writeln!(
638 text,
639 " <uses-permission android:name=\"{permission}\" android:maxSdkVersion=\"{level}\" />"
640 );
641 }
642 for demand in capabilities.demands {
643 let _ = writeln!(
644 text,
645 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
646 demand.android_feature()
647 );
648 }
649 if !capabilities.opens.is_empty() {
650 text.push_str(&android_opening_activity(capabilities.opens));
651 }
652 text.push_str("</manifest>\n");
653 text
654}
655
656fn android_opening_activity(opens: &[&str]) -> String {
659 let mut text = String::from(
660 " <application>\n \
661 <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
662 );
663 for actions in [
664 &[
665 "android.intent.action.SEND",
666 "android.intent.action.SEND_MULTIPLE",
667 ][..],
668 &["android.intent.action.VIEW"][..],
669 ] {
670 text.push_str(" <intent-filter>\n");
671 for action in actions {
672 let _ = writeln!(text, " <action android:name=\"{action}\" />");
673 }
674 text.push_str(
675 " <category android:name=\"android.intent.category.DEFAULT\" />\n",
676 );
677 for media_type in opens {
678 let _ = writeln!(
679 text,
680 " <data android:mimeType=\"{}\" />",
681 escape_xml(media_type)
682 );
683 }
684 text.push_str(" </intent-filter>\n");
685 }
686 text.push_str(" </activity>\n </application>\n");
687 text
688}
689
690pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
700 let mut text = String::from(
701 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
702 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
703 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
704 <plist version=\"1.0\">\n<dict>\n",
705 );
706 for entry in capabilities.uses {
707 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
708 continue;
709 };
710 let _ = writeln!(text, "\t<key>{key}</key>");
711 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
712 }
713 text.push_str("</dict>\n</plist>\n");
714 text
715}
716
717fn escape_xml(text: &str) -> String {
718 text.replace('&', "&")
719 .replace('<', "<")
720 .replace('>', ">")
721}
722
723#[cfg(test)]
724#[path = "tests/capabilities_tests.rs"]
725mod tests;