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