1use std::{
24 collections::BTreeSet,
25 env,
26 ffi::OsString,
27 fmt::Write as _,
28 fs,
29 path::{Path, PathBuf},
30};
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub enum Service {
35 Camera,
37 PhotoLibrary,
39 PhotoLibraryAdd,
41 Microphone,
43 Location,
45 Notifications,
47 Background,
49 Media,
51 Billing,
53 Overlay,
55 Haptics,
57 Update,
59 Network,
61}
62
63impl Service {
64 pub const fn name(self) -> &'static str {
66 match self {
67 Service::Camera => "camera",
68 Service::PhotoLibrary => "photo-library",
69 Service::PhotoLibraryAdd => "photo-library-add",
70 Service::Microphone => "microphone",
71 Service::Location => "location",
72 Service::Notifications => "notifications",
73 Service::Background => "background",
74 Service::Media => "media",
75 Service::Billing => "billing",
76 Service::Overlay => "overlay",
77 Service::Haptics => "haptics",
78 Service::Update => "update",
79 Service::Network => "network",
80 }
81 }
82
83 pub const fn android_permissions(self) -> &'static [&'static str] {
89 match self {
90 Service::Camera => &["android.permission.CAMERA"],
91 Service::PhotoLibrary => &[],
92 Service::PhotoLibraryAdd => &[],
93 Service::Microphone => &["android.permission.RECORD_AUDIO"],
94 Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
95 Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
96 Service::Background => &[
97 "android.permission.FOREGROUND_SERVICE",
98 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
99 ],
100 Service::Media => &[
101 "android.permission.FOREGROUND_SERVICE",
102 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
103 ],
104 Service::Billing => &["com.android.vending.BILLING"],
105 Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
106 Service::Haptics => &["android.permission.VIBRATE"],
107 Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
108 Service::Network => &[
109 "android.permission.INTERNET",
110 "android.permission.ACCESS_NETWORK_STATE",
111 ],
112 }
113 }
114
115 pub const fn apple_key(self) -> Option<&'static str> {
117 match self {
118 Service::Camera => Some("NSCameraUsageDescription"),
119 Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
120 Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
121 Service::Microphone => Some("NSMicrophoneUsageDescription"),
122 Service::Location => Some("NSLocationWhenInUseUsageDescription"),
123 _ => None,
124 }
125 }
126
127 pub const fn takes_reason(self) -> bool {
129 self.apple_key().is_some()
130 }
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct Use {
136 service: Service,
137 reason: Option<&'static str>,
138}
139
140impl Use {
141 pub const fn camera(reason: &'static str) -> Self {
143 Self {
144 service: Service::Camera,
145 reason: Some(reason),
146 }
147 }
148
149 pub const fn photo_library(reason: &'static str) -> Self {
151 Self {
152 service: Service::PhotoLibrary,
153 reason: Some(reason),
154 }
155 }
156
157 pub const fn photo_library_add(reason: &'static str) -> Self {
159 Self {
160 service: Service::PhotoLibraryAdd,
161 reason: Some(reason),
162 }
163 }
164
165 pub const fn microphone(reason: &'static str) -> Self {
167 Self {
168 service: Service::Microphone,
169 reason: Some(reason),
170 }
171 }
172
173 pub const fn location(reason: &'static str) -> Self {
175 Self {
176 service: Service::Location,
177 reason: Some(reason),
178 }
179 }
180
181 pub const fn notifications() -> Self {
183 Self {
184 service: Service::Notifications,
185 reason: None,
186 }
187 }
188
189 pub const fn background() -> Self {
191 Self {
192 service: Service::Background,
193 reason: None,
194 }
195 }
196
197 pub const fn media() -> Self {
199 Self {
200 service: Service::Media,
201 reason: None,
202 }
203 }
204
205 pub const fn billing() -> Self {
207 Self {
208 service: Service::Billing,
209 reason: None,
210 }
211 }
212
213 pub const fn overlay() -> Self {
215 Self {
216 service: Service::Overlay,
217 reason: None,
218 }
219 }
220
221 pub const fn haptics() -> Self {
223 Self {
224 service: Service::Haptics,
225 reason: None,
226 }
227 }
228
229 pub const fn update() -> Self {
231 Self {
232 service: Service::Update,
233 reason: None,
234 }
235 }
236
237 pub const fn network() -> Self {
239 Self {
240 service: Service::Network,
241 reason: None,
242 }
243 }
244
245 pub const fn service(self) -> Service {
247 self.service
248 }
249
250 pub const fn reason(self) -> Option<&'static str> {
252 self.reason
253 }
254}
255
256#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
261pub enum Demand {
262 Camera,
264 Microphone,
266 Watch,
268 Telephony,
270 Bluetooth,
272 Nfc,
274 Wifi,
276 Location,
278}
279
280impl Demand {
281 pub const fn android_feature(self) -> &'static str {
283 match self {
284 Demand::Camera => "android.hardware.camera",
285 Demand::Microphone => "android.hardware.microphone",
286 Demand::Watch => "android.hardware.type.watch",
287 Demand::Telephony => "android.hardware.telephony",
288 Demand::Bluetooth => "android.hardware.bluetooth",
289 Demand::Nfc => "android.hardware.nfc",
290 Demand::Wifi => "android.hardware.wifi",
291 Demand::Location => "android.hardware.location",
292 }
293 }
294
295 pub const fn name(self) -> &'static str {
297 self.android_feature()
298 }
299}
300
301#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub struct Capabilities<'a> {
304 pub uses: &'a [Use],
306 pub demands: &'a [Demand],
308 pub opens: &'a [&'a str],
313}
314
315impl Capabilities<'_> {
316 pub const NONE: Capabilities<'static> = Capabilities {
318 uses: &[],
319 demands: &[],
320 opens: &[],
321 };
322
323 pub fn has(&self, service: Service) -> bool {
325 self.uses.iter().any(|entry| entry.service == service)
326 }
327
328 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
330 self.uses
331 .iter()
332 .find(|entry| entry.service == service)
333 .and_then(|entry| entry.reason())
334 }
335}
336
337#[must_use = "a declaration reaches the platform builds only through emit()"]
339#[derive(Clone, Copy, Debug)]
340pub struct Declaration<'a> {
341 capabilities: Capabilities<'a>,
342}
343
344pub const fn declare(uses: &[Use]) -> Declaration<'_> {
346 Declaration {
347 capabilities: Capabilities {
348 uses,
349 demands: &[],
350 opens: &[],
351 },
352 }
353}
354
355impl<'a> Declaration<'a> {
356 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
358 Declaration {
359 capabilities: Capabilities {
360 demands,
361 ..self.capabilities
362 },
363 }
364 }
365
366 pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
369 Declaration {
370 capabilities: Capabilities {
371 opens,
372 ..self.capabilities
373 },
374 }
375 }
376
377 pub fn emit(self) {
383 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
384 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
385 write_file(
386 &out.join("cranpose_capabilities.rs"),
387 &rust_source(&self.capabilities),
388 );
389
390 let crate_dir = PathBuf::from(
391 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
392 );
393 let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
394 fs::create_dir_all(&shared).expect("the shared capabilities directory");
395 let outputs = shared_outputs(&shared, &package);
396 let contents = [
397 json(&self.capabilities),
398 android_manifest(&self.capabilities),
399 apple_usage(&self.capabilities),
400 ];
401 for (path, text) in outputs.iter().zip(contents.iter()) {
402 write_file(path, text);
403 }
404 println!("cargo::rerun-if-changed=build.rs");
405 println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
406 for directive in rerun_directives(&outputs) {
407 println!("{directive}");
408 }
409 }
410}
411
412fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
417 [
418 shared.join(format!("{package}-capabilities.json")),
419 shared.join(format!("{package}-permissions.xml")),
420 shared.join(format!("{package}-usage.plist")),
421 ]
422}
423
424fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
435 outputs
436 .iter()
437 .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
438 .collect()
439}
440
441fn write_file(path: &Path, text: &str) {
442 if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
443 return;
444 }
445 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
446}
447
448const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
454
455fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
458 match named.filter(|value| !value.is_empty()) {
459 Some(value) => PathBuf::from(value),
460 None => workspace_root(crate_dir).join("target").join("cranpose"),
461 }
462}
463
464fn workspace_root(crate_dir: &Path) -> PathBuf {
471 found_workspace(crate_dir, &|path| {
472 fs::read_to_string(path.join("Cargo.toml"))
473 .is_ok_and(|manifest| manifest.contains("[workspace]"))
474 })
475}
476
477fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
478 crate_dir
481 .ancestors()
482 .filter(|path| holds_workspace(path))
483 .last()
484 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
485}
486
487fn rust_source(capabilities: &Capabilities<'_>) -> String {
490 let mut text = String::from(
491 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
492 cranpose_capabilities::Capabilities {\n uses: &[\n",
493 );
494 for entry in capabilities.uses {
495 let call = constructor(entry);
496 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
497 }
498 text.push_str(" ],\n demands: &[\n");
499 for demand in capabilities.demands {
500 let _ = writeln!(
501 text,
502 " cranpose_capabilities::Demand::{demand:?},"
503 );
504 }
505 text.push_str(" ],\n opens: &[\n");
506 for media_type in capabilities.opens {
507 let _ = writeln!(text, " \"{}\",", escape(media_type));
508 }
509 text.push_str(" ],\n };\n");
510 text
511}
512
513fn constructor(entry: &Use) -> String {
516 let name = entry.service.name().replace('-', "_");
517 match entry.reason {
518 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
519 None => format!("{name}()"),
520 }
521}
522
523fn escape(text: &str) -> String {
524 text.replace('\\', "\\\\").replace('"', "\\\"")
525}
526
527pub fn json(capabilities: &Capabilities<'_>) -> String {
529 let mut text = String::from("{\n \"services\": [\n");
530 for (at, entry) in capabilities.uses.iter().enumerate() {
531 let comma = if at + 1 == capabilities.uses.len() {
532 ""
533 } else {
534 ","
535 };
536 let reason = match entry.reason {
537 Some(reason) => format!("\"{}\"", escape_json(reason)),
538 None => String::from("null"),
539 };
540 let _ = writeln!(
541 text,
542 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
543 entry.service.name()
544 );
545 }
546 text.push_str(" ],\n \"permissions\": [\n");
547 let permissions = android_permissions(capabilities);
548 for (at, permission) in permissions.iter().enumerate() {
549 let comma = if at + 1 == permissions.len() { "" } else { "," };
550 let _ = writeln!(text, " \"{permission}\"{comma}");
551 }
552 text.push_str(" ],\n \"demands\": [\n");
553 for (at, demand) in capabilities.demands.iter().enumerate() {
554 let comma = if at + 1 == capabilities.demands.len() {
555 ""
556 } else {
557 ","
558 };
559 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
560 }
561 text.push_str(" ],\n \"opens\": [\n");
562 for (at, media_type) in capabilities.opens.iter().enumerate() {
563 let comma = if at + 1 == capabilities.opens.len() {
564 ""
565 } else {
566 ","
567 };
568 let _ = writeln!(text, " \"{}\"{comma}", escape_json(media_type));
569 }
570 text.push_str(" ]\n}\n");
571 text
572}
573
574fn escape_json(text: &str) -> String {
575 escape(text).replace('\n', "\\n")
576}
577
578pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
581 let mut named = BTreeSet::new();
582 for entry in capabilities.uses {
583 for permission in entry.service.android_permissions() {
584 named.insert(*permission);
585 }
586 }
587 named.into_iter().collect()
588}
589
590pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
592 let mut text = String::from(
593 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
594 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
595 );
596 for permission in android_permissions(capabilities) {
597 let _ = writeln!(
598 text,
599 " <uses-permission android:name=\"{permission}\" />"
600 );
601 }
602 for demand in capabilities.demands {
603 let _ = writeln!(
604 text,
605 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
606 demand.android_feature()
607 );
608 }
609 if !capabilities.opens.is_empty() {
610 text.push_str(&android_opening_activity(capabilities.opens));
611 }
612 text.push_str("</manifest>\n");
613 text
614}
615
616fn android_opening_activity(opens: &[&str]) -> String {
619 let mut text = String::from(
620 " <application>\n \
621 <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
622 );
623 for actions in [
624 &[
625 "android.intent.action.SEND",
626 "android.intent.action.SEND_MULTIPLE",
627 ][..],
628 &["android.intent.action.VIEW"][..],
629 ] {
630 text.push_str(" <intent-filter>\n");
631 for action in actions {
632 let _ = writeln!(text, " <action android:name=\"{action}\" />");
633 }
634 text.push_str(
635 " <category android:name=\"android.intent.category.DEFAULT\" />\n",
636 );
637 for media_type in opens {
638 let _ = writeln!(
639 text,
640 " <data android:mimeType=\"{}\" />",
641 escape_xml(media_type)
642 );
643 }
644 text.push_str(" </intent-filter>\n");
645 }
646 text.push_str(" </activity>\n </application>\n");
647 text
648}
649
650pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
660 let mut text = String::from(
661 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
662 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
663 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
664 <plist version=\"1.0\">\n<dict>\n",
665 );
666 for entry in capabilities.uses {
667 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
668 continue;
669 };
670 let _ = writeln!(text, "\t<key>{key}</key>");
671 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
672 }
673 text.push_str("</dict>\n</plist>\n");
674 text
675}
676
677fn escape_xml(text: &str) -> String {
678 text.replace('&', "&")
679 .replace('<', "<")
680 .replace('>', ">")
681}
682
683#[cfg(test)]
684#[path = "tests/capabilities_tests.rs"]
685mod tests;