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}
42
43impl Service {
44 pub const fn name(self) -> &'static str {
46 match self {
47 Service::Camera => "camera",
48 Service::PhotoLibrary => "photo-library",
49 Service::PhotoLibraryAdd => "photo-library-add",
50 Service::Microphone => "microphone",
51 Service::Location => "location",
52 Service::Notifications => "notifications",
53 Service::Background => "background",
54 Service::Media => "media",
55 Service::Billing => "billing",
56 Service::Overlay => "overlay",
57 Service::Haptics => "haptics",
58 Service::Update => "update",
59 Service::Network => "network",
60 }
61 }
62
63 pub const fn android_permissions(self) -> &'static [&'static str] {
69 match self {
70 Service::Camera => &["android.permission.CAMERA"],
71 Service::PhotoLibrary => &[],
72 Service::PhotoLibraryAdd => &[],
73 Service::Microphone => &["android.permission.RECORD_AUDIO"],
74 Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
75 Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
76 Service::Background => &[
77 "android.permission.FOREGROUND_SERVICE",
78 "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
79 ],
80 Service::Media => &[
81 "android.permission.FOREGROUND_SERVICE",
82 "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
83 ],
84 Service::Billing => &["com.android.vending.BILLING"],
85 Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
86 Service::Haptics => &["android.permission.VIBRATE"],
87 Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
88 Service::Network => &[
89 "android.permission.INTERNET",
90 "android.permission.ACCESS_NETWORK_STATE",
91 ],
92 }
93 }
94
95 pub const fn apple_key(self) -> Option<&'static str> {
97 match self {
98 Service::Camera => Some("NSCameraUsageDescription"),
99 Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
100 Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
101 Service::Microphone => Some("NSMicrophoneUsageDescription"),
102 Service::Location => Some("NSLocationWhenInUseUsageDescription"),
103 _ => None,
104 }
105 }
106
107 pub const fn takes_reason(self) -> bool {
109 self.apple_key().is_some()
110 }
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub struct Use {
116 service: Service,
117 reason: Option<&'static str>,
118}
119
120impl Use {
121 pub const fn camera(reason: &'static str) -> Self {
123 Self {
124 service: Service::Camera,
125 reason: Some(reason),
126 }
127 }
128
129 pub const fn photo_library(reason: &'static str) -> Self {
131 Self {
132 service: Service::PhotoLibrary,
133 reason: Some(reason),
134 }
135 }
136
137 pub const fn photo_library_add(reason: &'static str) -> Self {
139 Self {
140 service: Service::PhotoLibraryAdd,
141 reason: Some(reason),
142 }
143 }
144
145 pub const fn microphone(reason: &'static str) -> Self {
147 Self {
148 service: Service::Microphone,
149 reason: Some(reason),
150 }
151 }
152
153 pub const fn location(reason: &'static str) -> Self {
155 Self {
156 service: Service::Location,
157 reason: Some(reason),
158 }
159 }
160
161 pub const fn notifications() -> Self {
163 Self {
164 service: Service::Notifications,
165 reason: None,
166 }
167 }
168
169 pub const fn background() -> Self {
171 Self {
172 service: Service::Background,
173 reason: None,
174 }
175 }
176
177 pub const fn media() -> Self {
179 Self {
180 service: Service::Media,
181 reason: None,
182 }
183 }
184
185 pub const fn billing() -> Self {
187 Self {
188 service: Service::Billing,
189 reason: None,
190 }
191 }
192
193 pub const fn overlay() -> Self {
195 Self {
196 service: Service::Overlay,
197 reason: None,
198 }
199 }
200
201 pub const fn haptics() -> Self {
203 Self {
204 service: Service::Haptics,
205 reason: None,
206 }
207 }
208
209 pub const fn update() -> Self {
211 Self {
212 service: Service::Update,
213 reason: None,
214 }
215 }
216
217 pub const fn network() -> Self {
219 Self {
220 service: Service::Network,
221 reason: None,
222 }
223 }
224
225 pub const fn service(self) -> Service {
227 self.service
228 }
229
230 pub const fn reason(self) -> Option<&'static str> {
232 self.reason
233 }
234}
235
236#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
241pub enum Demand {
242 Camera,
244 Microphone,
246 Watch,
248 Telephony,
250 Bluetooth,
252 Nfc,
254 Wifi,
256 Location,
258}
259
260impl Demand {
261 pub const fn android_feature(self) -> &'static str {
263 match self {
264 Demand::Camera => "android.hardware.camera",
265 Demand::Microphone => "android.hardware.microphone",
266 Demand::Watch => "android.hardware.type.watch",
267 Demand::Telephony => "android.hardware.telephony",
268 Demand::Bluetooth => "android.hardware.bluetooth",
269 Demand::Nfc => "android.hardware.nfc",
270 Demand::Wifi => "android.hardware.wifi",
271 Demand::Location => "android.hardware.location",
272 }
273 }
274
275 pub const fn name(self) -> &'static str {
277 self.android_feature()
278 }
279}
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283pub struct Capabilities<'a> {
284 pub uses: &'a [Use],
286 pub demands: &'a [Demand],
288 pub opens: &'a [&'a str],
293}
294
295impl Capabilities<'_> {
296 pub const NONE: Capabilities<'static> = Capabilities {
298 uses: &[],
299 demands: &[],
300 opens: &[],
301 };
302
303 pub fn has(&self, service: Service) -> bool {
305 self.uses.iter().any(|entry| entry.service == service)
306 }
307
308 pub fn reason_for(&self, service: Service) -> Option<&'static str> {
310 self.uses
311 .iter()
312 .find(|entry| entry.service == service)
313 .and_then(|entry| entry.reason())
314 }
315}
316
317#[must_use = "a declaration reaches the platform builds only through emit()"]
319#[derive(Clone, Copy, Debug)]
320pub struct Declaration<'a> {
321 capabilities: Capabilities<'a>,
322}
323
324pub const fn declare(uses: &[Use]) -> Declaration<'_> {
326 Declaration {
327 capabilities: Capabilities {
328 uses,
329 demands: &[],
330 opens: &[],
331 },
332 }
333}
334
335impl<'a> Declaration<'a> {
336 pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
338 Declaration {
339 capabilities: Capabilities {
340 demands,
341 ..self.capabilities
342 },
343 }
344 }
345
346 pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
349 Declaration {
350 capabilities: Capabilities {
351 opens,
352 ..self.capabilities
353 },
354 }
355 }
356
357 pub fn emit(self) {
363 let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
364 let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
365 write_file(
366 &out.join("cranpose_capabilities.rs"),
367 &rust_source(&self.capabilities),
368 );
369
370 let crate_dir = PathBuf::from(
371 env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
372 );
373 let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
374 fs::create_dir_all(&shared).expect("the shared capabilities directory");
375 let outputs = shared_outputs(&shared, &package);
376 let contents = [
377 json(&self.capabilities),
378 android_manifest(&self.capabilities),
379 apple_usage(&self.capabilities),
380 ];
381 for (path, text) in outputs.iter().zip(contents.iter()) {
382 write_file(path, text);
383 }
384 println!("cargo::rerun-if-changed=build.rs");
385 println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
386 for directive in rerun_directives(&outputs) {
387 println!("{directive}");
388 }
389 }
390}
391
392fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
397 [
398 shared.join(format!("{package}-capabilities.json")),
399 shared.join(format!("{package}-permissions.xml")),
400 shared.join(format!("{package}-usage.plist")),
401 ]
402}
403
404fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
415 outputs
416 .iter()
417 .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
418 .collect()
419}
420
421fn write_file(path: &Path, text: &str) {
422 if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
423 return;
424 }
425 fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
426}
427
428const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
434
435fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
438 match named.filter(|value| !value.is_empty()) {
439 Some(value) => PathBuf::from(value),
440 None => workspace_root(crate_dir).join("target").join("cranpose"),
441 }
442}
443
444fn workspace_root(crate_dir: &Path) -> PathBuf {
451 found_workspace(crate_dir, &|path| {
452 fs::read_to_string(path.join("Cargo.toml"))
453 .is_ok_and(|manifest| manifest.contains("[workspace]"))
454 })
455}
456
457fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
458 crate_dir
461 .ancestors()
462 .filter(|path| holds_workspace(path))
463 .last()
464 .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
465}
466
467fn rust_source(capabilities: &Capabilities<'_>) -> String {
470 let mut text = String::from(
471 "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n \
472 cranpose_capabilities::Capabilities {\n uses: &[\n",
473 );
474 for entry in capabilities.uses {
475 let call = constructor(entry);
476 let _ = writeln!(text, " cranpose_capabilities::Use::{call},");
477 }
478 text.push_str(" ],\n demands: &[\n");
479 for demand in capabilities.demands {
480 let _ = writeln!(
481 text,
482 " cranpose_capabilities::Demand::{demand:?},"
483 );
484 }
485 text.push_str(" ],\n opens: &[\n");
486 for media_type in capabilities.opens {
487 let _ = writeln!(text, " \"{}\",", escape(media_type));
488 }
489 text.push_str(" ],\n };\n");
490 text
491}
492
493fn constructor(entry: &Use) -> String {
496 let name = entry.service.name().replace('-', "_");
497 match entry.reason {
498 Some(reason) => format!("{name}(\"{}\")", escape(reason)),
499 None => format!("{name}()"),
500 }
501}
502
503fn escape(text: &str) -> String {
504 text.replace('\\', "\\\\").replace('"', "\\\"")
505}
506
507pub fn json(capabilities: &Capabilities<'_>) -> String {
509 let mut text = String::from("{\n \"services\": [\n");
510 for (at, entry) in capabilities.uses.iter().enumerate() {
511 let comma = if at + 1 == capabilities.uses.len() {
512 ""
513 } else {
514 ","
515 };
516 let reason = match entry.reason {
517 Some(reason) => format!("\"{}\"", escape_json(reason)),
518 None => String::from("null"),
519 };
520 let _ = writeln!(
521 text,
522 " {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
523 entry.service.name()
524 );
525 }
526 text.push_str(" ],\n \"permissions\": [\n");
527 let permissions = android_permissions(capabilities);
528 for (at, permission) in permissions.iter().enumerate() {
529 let comma = if at + 1 == permissions.len() { "" } else { "," };
530 let _ = writeln!(text, " \"{permission}\"{comma}");
531 }
532 text.push_str(" ],\n \"demands\": [\n");
533 for (at, demand) in capabilities.demands.iter().enumerate() {
534 let comma = if at + 1 == capabilities.demands.len() {
535 ""
536 } else {
537 ","
538 };
539 let _ = writeln!(text, " \"{}\"{comma}", demand.android_feature());
540 }
541 text.push_str(" ],\n \"opens\": [\n");
542 for (at, media_type) in capabilities.opens.iter().enumerate() {
543 let comma = if at + 1 == capabilities.opens.len() {
544 ""
545 } else {
546 ","
547 };
548 let _ = writeln!(text, " \"{}\"{comma}", escape_json(media_type));
549 }
550 text.push_str(" ]\n}\n");
551 text
552}
553
554fn escape_json(text: &str) -> String {
555 escape(text).replace('\n', "\\n")
556}
557
558pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
561 let mut named = BTreeSet::new();
562 for entry in capabilities.uses {
563 for permission in entry.service.android_permissions() {
564 named.insert(*permission);
565 }
566 }
567 named.into_iter().collect()
568}
569
570pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
572 let mut text = String::from(
573 "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
574 <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
575 );
576 for permission in android_permissions(capabilities) {
577 let _ = writeln!(
578 text,
579 " <uses-permission android:name=\"{permission}\" />"
580 );
581 }
582 for demand in capabilities.demands {
583 let _ = writeln!(
584 text,
585 " <uses-feature android:name=\"{}\" android:required=\"true\" />",
586 demand.android_feature()
587 );
588 }
589 if !capabilities.opens.is_empty() {
590 text.push_str(&android_opening_activity(capabilities.opens));
591 }
592 text.push_str("</manifest>\n");
593 text
594}
595
596fn android_opening_activity(opens: &[&str]) -> String {
599 let mut text = String::from(
600 " <application>\n \
601 <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
602 );
603 for actions in [
604 &[
605 "android.intent.action.SEND",
606 "android.intent.action.SEND_MULTIPLE",
607 ][..],
608 &["android.intent.action.VIEW"][..],
609 ] {
610 text.push_str(" <intent-filter>\n");
611 for action in actions {
612 let _ = writeln!(text, " <action android:name=\"{action}\" />");
613 }
614 text.push_str(
615 " <category android:name=\"android.intent.category.DEFAULT\" />\n",
616 );
617 for media_type in opens {
618 let _ = writeln!(
619 text,
620 " <data android:mimeType=\"{}\" />",
621 escape_xml(media_type)
622 );
623 }
624 text.push_str(" </intent-filter>\n");
625 }
626 text.push_str(" </activity>\n </application>\n");
627 text
628}
629
630pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
640 let mut text = String::from(
641 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
642 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
643 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
644 <plist version=\"1.0\">\n<dict>\n",
645 );
646 for entry in capabilities.uses {
647 let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
648 continue;
649 };
650 let _ = writeln!(text, "\t<key>{key}</key>");
651 let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
652 }
653 text.push_str("</dict>\n</plist>\n");
654 text
655}
656
657fn escape_xml(text: &str) -> String {
658 text.replace('&', "&")
659 .replace('<', "<")
660 .replace('>', ">")
661}
662
663#[cfg(test)]
664#[path = "tests/capabilities_tests.rs"]
665mod tests;