Skip to main content

cranpose_capabilities/
lib.rs

1//! What an application asks of the device it runs on, written once in Rust.
2//!
3//! An application declares this in its build script:
4//!
5//! ```no_run
6//! use cranpose_capabilities::{Use, declare};
7//!
8//! declare(&[
9//!     Use::camera("Reads a receipt with the camera. Nothing leaves this device."),
10//!     Use::notifications(),
11//! ])
12//! .emit();
13//! ```
14//!
15//! From that one list the build writes the Android permissions and feature
16//! declarations, the Apple usage descriptions, and a constant the application
17//! itself reads at run time. A service is a function, so a name cannot be
18//! misspelled, and a service Apple wants a sentence for takes that sentence as
19//! an argument, so it cannot be forgotten.
20
21use std::{
22    collections::BTreeSet,
23    env,
24    fmt::Write as _,
25    fs,
26    path::{Path, PathBuf},
27};
28
29/// Something an application uses that the device has to allow.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
31pub enum Service {
32    /// The camera.
33    Camera,
34    /// Reading the photo library.
35    PhotoLibrary,
36    /// Writing to the photo library.
37    PhotoLibraryAdd,
38    /// The microphone.
39    Microphone,
40    /// Where the device is.
41    Location,
42    /// Notifications the person sees.
43    Notifications,
44    /// Work that carries on with the application off screen.
45    Background,
46    /// Media playback that carries on with the application off screen.
47    Media,
48    /// Purchases through the platform store.
49    Billing,
50    /// A window drawn above other applications.
51    Overlay,
52    /// The vibrator.
53    Haptics,
54    /// Handing a downloaded package to the system installer.
55    Update,
56    /// The network, and whether the device is on one.
57    Network,
58}
59
60impl Service {
61    /// The name this service carries in the build's own files.
62    pub const fn name(self) -> &'static str {
63        match self {
64            Service::Camera => "camera",
65            Service::PhotoLibrary => "photo-library",
66            Service::PhotoLibraryAdd => "photo-library-add",
67            Service::Microphone => "microphone",
68            Service::Location => "location",
69            Service::Notifications => "notifications",
70            Service::Background => "background",
71            Service::Media => "media",
72            Service::Billing => "billing",
73            Service::Overlay => "overlay",
74            Service::Haptics => "haptics",
75            Service::Update => "update",
76            Service::Network => "network",
77        }
78    }
79
80    /// The Android permissions this service needs.
81    pub const fn android_permissions(self) -> &'static [&'static str] {
82        match self {
83            Service::Camera => &["android.permission.CAMERA"],
84            Service::PhotoLibrary => &["android.permission.READ_MEDIA_IMAGES"],
85            Service::PhotoLibraryAdd => &[],
86            Service::Microphone => &["android.permission.RECORD_AUDIO"],
87            Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
88            Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
89            Service::Background => &[
90                "android.permission.FOREGROUND_SERVICE",
91                "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
92            ],
93            Service::Media => &[
94                "android.permission.FOREGROUND_SERVICE",
95                "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
96            ],
97            Service::Billing => &["com.android.vending.BILLING"],
98            Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
99            Service::Haptics => &["android.permission.VIBRATE"],
100            Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
101            Service::Network => &[
102                "android.permission.INTERNET",
103                "android.permission.ACCESS_NETWORK_STATE",
104            ],
105        }
106    }
107
108    /// The key an Apple platform reads the sentence from.
109    pub const fn apple_key(self) -> Option<&'static str> {
110        match self {
111            Service::Camera => Some("NSCameraUsageDescription"),
112            Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
113            Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
114            Service::Microphone => Some("NSMicrophoneUsageDescription"),
115            Service::Location => Some("NSLocationWhenInUseUsageDescription"),
116            _ => None,
117        }
118    }
119
120    /// Whether this service takes a sentence to show the person.
121    pub const fn takes_reason(self) -> bool {
122        self.apple_key().is_some()
123    }
124}
125
126/// One service an application uses, with the sentence it shows if it needs one.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub struct Use {
129    service: Service,
130    reason: Option<&'static str>,
131}
132
133impl Use {
134    /// The camera, with what the person is told before it opens.
135    pub const fn camera(reason: &'static str) -> Self {
136        Self {
137            service: Service::Camera,
138            reason: Some(reason),
139        }
140    }
141
142    /// Reading the photo library, with what the person is told.
143    pub const fn photo_library(reason: &'static str) -> Self {
144        Self {
145            service: Service::PhotoLibrary,
146            reason: Some(reason),
147        }
148    }
149
150    /// Writing to the photo library, with what the person is told.
151    pub const fn photo_library_add(reason: &'static str) -> Self {
152        Self {
153            service: Service::PhotoLibraryAdd,
154            reason: Some(reason),
155        }
156    }
157
158    /// The microphone, with what the person is told.
159    pub const fn microphone(reason: &'static str) -> Self {
160        Self {
161            service: Service::Microphone,
162            reason: Some(reason),
163        }
164    }
165
166    /// Where the device is, with what the person is told.
167    pub const fn location(reason: &'static str) -> Self {
168        Self {
169            service: Service::Location,
170            reason: Some(reason),
171        }
172    }
173
174    /// Notifications the person sees.
175    pub const fn notifications() -> Self {
176        Self {
177            service: Service::Notifications,
178            reason: None,
179        }
180    }
181
182    /// Work that carries on with the application off screen.
183    pub const fn background() -> Self {
184        Self {
185            service: Service::Background,
186            reason: None,
187        }
188    }
189
190    /// Media playback that carries on with the application off screen.
191    pub const fn media() -> Self {
192        Self {
193            service: Service::Media,
194            reason: None,
195        }
196    }
197
198    /// Purchases through the platform store.
199    pub const fn billing() -> Self {
200        Self {
201            service: Service::Billing,
202            reason: None,
203        }
204    }
205
206    /// A window drawn above other applications.
207    pub const fn overlay() -> Self {
208        Self {
209            service: Service::Overlay,
210            reason: None,
211        }
212    }
213
214    /// The vibrator.
215    pub const fn haptics() -> Self {
216        Self {
217            service: Service::Haptics,
218            reason: None,
219        }
220    }
221
222    /// Handing a downloaded package to the system installer.
223    pub const fn update() -> Self {
224        Self {
225            service: Service::Update,
226            reason: None,
227        }
228    }
229
230    /// The network, and whether the device is on one.
231    pub const fn network() -> Self {
232        Self {
233            service: Service::Network,
234            reason: None,
235        }
236    }
237
238    /// Which service this is.
239    pub const fn service(self) -> Service {
240        self.service
241    }
242
243    /// The sentence the person is shown, for the services that have one.
244    pub const fn reason(self) -> Option<&'static str> {
245        self.reason
246    }
247}
248
249/// Hardware an application cannot run without.
250///
251/// Every other feature stays optional, so the application reaches devices
252/// that lack the hardware and asks the framework at run time.
253#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
254pub enum Demand {
255    /// A camera.
256    Camera,
257    /// A microphone.
258    Microphone,
259    /// A watch.
260    Watch,
261    /// Telephony.
262    Telephony,
263    /// Bluetooth.
264    Bluetooth,
265    /// Near-field communication.
266    Nfc,
267    /// Wi-Fi.
268    Wifi,
269    /// Location hardware.
270    Location,
271}
272
273impl Demand {
274    /// The Android feature name this demand becomes.
275    pub const fn android_feature(self) -> &'static str {
276        match self {
277            Demand::Camera => "android.hardware.camera",
278            Demand::Microphone => "android.hardware.microphone",
279            Demand::Watch => "android.hardware.type.watch",
280            Demand::Telephony => "android.hardware.telephony",
281            Demand::Bluetooth => "android.hardware.bluetooth",
282            Demand::Nfc => "android.hardware.nfc",
283            Demand::Wifi => "android.hardware.wifi",
284            Demand::Location => "android.hardware.location",
285        }
286    }
287
288    /// The name this demand carries in the build's own files.
289    pub const fn name(self) -> &'static str {
290        self.android_feature()
291    }
292}
293
294/// Everything one application asks of a device.
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub struct Capabilities<'a> {
297    /// The services the application uses.
298    pub uses: &'a [Use],
299    /// The hardware the application cannot run without.
300    pub demands: &'a [Demand],
301}
302
303impl Capabilities<'_> {
304    /// An application that asks for nothing.
305    pub const NONE: Capabilities<'static> = Capabilities {
306        uses: &[],
307        demands: &[],
308    };
309
310    /// Whether the application declared this service.
311    pub fn has(&self, service: Service) -> bool {
312        self.uses.iter().any(|entry| entry.service == service)
313    }
314
315    /// The sentence declared for a service, if it has one.
316    pub fn reason_for(&self, service: Service) -> Option<&'static str> {
317        self.uses
318            .iter()
319            .find(|entry| entry.service == service)
320            .and_then(|entry| entry.reason())
321    }
322}
323
324/// What a build script declares, before [`Declaration::emit`] writes it out.
325#[must_use = "a declaration reaches the platform builds only through emit()"]
326#[derive(Clone, Copy, Debug)]
327pub struct Declaration<'a> {
328    capabilities: Capabilities<'a>,
329}
330
331/// Starts a declaration with the services an application uses.
332pub const fn declare(uses: &[Use]) -> Declaration<'_> {
333    Declaration {
334        capabilities: Capabilities { uses, demands: &[] },
335    }
336}
337
338impl<'a> Declaration<'a> {
339    /// Adds the hardware the application cannot run without.
340    pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
341        Declaration {
342            capabilities: Capabilities {
343                uses: self.capabilities.uses,
344                demands,
345            },
346        }
347    }
348
349    /// Writes the declaration where the application and every platform build
350    /// reads it.
351    ///
352    /// Call this from a build script. It panics when the files cannot be
353    /// written, which is what a build script does with a failure.
354    pub fn emit(self) {
355        let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
356        let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
357        write_file(
358            &out.join("cranpose_capabilities.rs"),
359            &rust_source(&self.capabilities),
360        );
361
362        let crate_dir = PathBuf::from(
363            env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
364        );
365        let shared = workspace_root(&crate_dir).join("target").join("cranpose");
366        fs::create_dir_all(&shared).expect("the shared capabilities directory");
367        write_file(
368            &shared.join(format!("{package}-capabilities.json")),
369            &json(&self.capabilities),
370        );
371        write_file(
372            &shared.join(format!("{package}-permissions.xml")),
373            &android_manifest(&self.capabilities),
374        );
375        write_file(
376            &shared.join(format!("{package}-usage.plist")),
377            &apple_usage(&self.capabilities),
378        );
379        println!("cargo::rerun-if-changed=build.rs");
380    }
381}
382
383fn write_file(path: &Path, text: &str) {
384    fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
385}
386
387/// The workspace this crate belongs to, found from its own directory.
388///
389/// The platform builds read the declaration from `<workspace>/target/cranpose`,
390/// and they know the workspace because they already resolved this crate's
391/// source there. The directory Cargo happens to build into is not that place:
392/// it moves with `CARGO_TARGET_DIR`, and continuous integration sets it.
393fn workspace_root(crate_dir: &Path) -> PathBuf {
394    found_workspace(crate_dir, &|path| {
395        fs::read_to_string(path.join("Cargo.toml"))
396            .is_ok_and(|manifest| manifest.contains("[workspace]"))
397    })
398}
399
400fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
401    // The outermost workspace, so a crate inside a workspace that is itself
402    // vendored into another one still answers with the tree the build drives.
403    crate_dir
404        .ancestors()
405        .filter(|path| holds_workspace(path))
406        .last()
407        .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
408}
409
410/// The Rust the application includes, so it reads the same declaration the
411/// platform builds do.
412fn rust_source(capabilities: &Capabilities<'_>) -> String {
413    let mut text = String::from(
414        "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n    \
415         cranpose_capabilities::Capabilities {\n        uses: &[\n",
416    );
417    for entry in capabilities.uses {
418        let call = constructor(entry);
419        let _ = writeln!(text, "            cranpose_capabilities::Use::{call},");
420    }
421    text.push_str("        ],\n        demands: &[\n");
422    for demand in capabilities.demands {
423        let _ = writeln!(
424            text,
425            "            cranpose_capabilities::Demand::{demand:?},"
426        );
427    }
428    text.push_str("        ],\n    };\n");
429    text
430}
431
432/// The call that rebuilds one entry, which is the service's own name with the
433/// dashes a Rust function cannot carry turned back into underscores.
434fn constructor(entry: &Use) -> String {
435    let name = entry.service.name().replace('-', "_");
436    match entry.reason {
437        Some(reason) => format!("{name}(\"{}\")", escape(reason)),
438        None => format!("{name}()"),
439    }
440}
441
442fn escape(text: &str) -> String {
443    text.replace('\\', "\\\\").replace('"', "\\\"")
444}
445
446/// The declaration as the platform builds read it.
447pub fn json(capabilities: &Capabilities<'_>) -> String {
448    let mut text = String::from("{\n  \"services\": [\n");
449    for (at, entry) in capabilities.uses.iter().enumerate() {
450        let comma = if at + 1 == capabilities.uses.len() {
451            ""
452        } else {
453            ","
454        };
455        let reason = match entry.reason {
456            Some(reason) => format!("\"{}\"", escape_json(reason)),
457            None => String::from("null"),
458        };
459        let _ = writeln!(
460            text,
461            "    {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
462            entry.service.name()
463        );
464    }
465    text.push_str("  ],\n  \"permissions\": [\n");
466    let permissions = android_permissions(capabilities);
467    for (at, permission) in permissions.iter().enumerate() {
468        let comma = if at + 1 == permissions.len() { "" } else { "," };
469        let _ = writeln!(text, "    \"{permission}\"{comma}");
470    }
471    text.push_str("  ],\n  \"demands\": [\n");
472    for (at, demand) in capabilities.demands.iter().enumerate() {
473        let comma = if at + 1 == capabilities.demands.len() {
474            ""
475        } else {
476            ","
477        };
478        let _ = writeln!(text, "    \"{}\"{comma}", demand.android_feature());
479    }
480    text.push_str("  ]\n}\n");
481    text
482}
483
484fn escape_json(text: &str) -> String {
485    escape(text).replace('\n', "\\n")
486}
487
488/// Every Android permission the declared services need, in order and without
489/// repeats.
490pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
491    let mut named = BTreeSet::new();
492    for entry in capabilities.uses {
493        for permission in entry.service.android_permissions() {
494            named.insert(*permission);
495        }
496    }
497    named.into_iter().collect()
498}
499
500/// The Android manifest fragment the declaration becomes.
501pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
502    let mut text = String::from(
503        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
504         <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
505    );
506    for permission in android_permissions(capabilities) {
507        let _ = writeln!(
508            text,
509            "    <uses-permission android:name=\"{permission}\" />"
510        );
511    }
512    for demand in capabilities.demands {
513        let _ = writeln!(
514            text,
515            "    <uses-feature android:name=\"{}\" android:required=\"true\" />",
516            demand.android_feature()
517        );
518    }
519    text.push_str("</manifest>\n");
520    text
521}
522
523/// The Apple usage descriptions the declaration becomes, as property list
524/// entries to merge into an `Info.plist`.
525pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
526    let mut text = String::new();
527    for entry in capabilities.uses {
528        let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
529            continue;
530        };
531        let _ = writeln!(text, "\t<key>{key}</key>");
532        let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
533    }
534    text
535}
536
537fn escape_xml(text: &str) -> String {
538    text.replace('&', "&amp;")
539        .replace('<', "&lt;")
540        .replace('>', "&gt;")
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    const READS: &str = "Reads a receipt with the camera.";
548
549    const SCANNER: Capabilities<'static> = Capabilities {
550        uses: &[Use::camera(READS), Use::notifications(), Use::billing()],
551        demands: &[],
552    };
553
554    #[test]
555    fn a_service_with_a_sentence_carries_it() {
556        assert_eq!(SCANNER.reason_for(Service::Camera), Some(READS));
557        assert_eq!(SCANNER.reason_for(Service::Notifications), None);
558        assert!(SCANNER.has(Service::Billing));
559        assert!(!SCANNER.has(Service::Haptics));
560    }
561
562    const BOTH_SERVICES: [Use; 2] = [Use::background(), Use::media()];
563    const ONE_HAPTIC: [Use; 1] = [Use::haptics()];
564    const WATCH: [Demand; 1] = [Demand::Watch];
565    const ONE_CAMERA: [Use; 1] = [Use::camera(READS)];
566    const CAMERA_HARDWARE: [Demand; 1] = [Demand::Camera];
567
568    #[test]
569    fn android_permissions_come_from_the_services_without_repeats() {
570        let both = Capabilities {
571            uses: &BOTH_SERVICES,
572            demands: &[],
573        };
574        assert_eq!(
575            android_permissions(&both),
576            vec![
577                "android.permission.FOREGROUND_SERVICE",
578                "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
579                "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
580            ]
581        );
582    }
583
584    #[test]
585    fn the_manifest_names_the_permissions_and_the_demanded_hardware() {
586        let watch = Capabilities {
587            uses: &ONE_HAPTIC,
588            demands: &WATCH,
589        };
590        let text = android_manifest(&watch);
591        assert!(text.contains("<uses-permission android:name=\"android.permission.VIBRATE\" />"));
592        assert!(text.contains(
593            "<uses-feature android:name=\"android.hardware.type.watch\" android:required=\"true\" />"
594        ));
595    }
596
597    #[test]
598    fn apple_takes_only_the_services_it_shows_a_sentence_for() {
599        let text = apple_usage(&SCANNER);
600        assert!(text.contains("<key>NSCameraUsageDescription</key>"));
601        assert!(text.contains(READS));
602        assert!(!text.contains("Notification"));
603    }
604
605    #[test]
606    fn the_json_lists_services_permissions_and_demands() {
607        let text = json(&Capabilities {
608            uses: &ONE_CAMERA,
609            demands: &CAMERA_HARDWARE,
610        });
611        assert!(text.contains("\"name\": \"camera\""));
612        assert!(text.contains("\"android.permission.CAMERA\""));
613        assert!(text.contains("\"android.hardware.camera\""));
614    }
615
616    #[test]
617    fn the_generated_rust_rebuilds_the_same_declaration() {
618        let text = rust_source(&SCANNER);
619        assert!(text.contains("Use::camera(\"Reads a receipt with the camera.\")"));
620        assert!(text.contains("Use::notifications()"));
621        assert!(text.contains("demands: &[\n        ],"));
622    }
623
624    #[test]
625    fn a_quote_in_a_sentence_survives_the_generated_rust() {
626        let text = constructor(&Use::camera("Reads a \"receipt\"."));
627        assert_eq!(text, "camera(\"Reads a \\\"receipt\\\".\")");
628    }
629
630    #[test]
631    fn the_workspace_is_the_tree_the_platform_builds_know() {
632        let crate_dir = Path::new("/w/app");
633        let holds = |path: &Path| path == Path::new("/w");
634        assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
635    }
636
637    #[test]
638    fn a_workspace_inside_a_workspace_answers_with_the_outer_one() {
639        let crate_dir = Path::new("/w/vendor/thing/crates/one");
640        let holds = |path: &Path| path == Path::new("/w") || path == Path::new("/w/vendor/thing");
641        assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
642    }
643
644    #[test]
645    fn a_crate_in_no_workspace_answers_with_itself() {
646        let crate_dir = Path::new("/w/single");
647        assert_eq!(
648            found_workspace(crate_dir, &|_| false),
649            Path::new("/w/single")
650        );
651    }
652}