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//! .opening(&["image/*", "application/pdf"])
13//! .emit();
14//! ```
15//!
16//! From that one list the build writes the Android permissions and feature
17//! declarations, the Android intent filters that offer the application for
18//! the files it opens, the Apple usage descriptions, and a constant the
19//! application itself reads at run time. A service is a function, so a name cannot be
20//! misspelled, and a service Apple wants a sentence for takes that sentence as
21//! an argument, so it cannot be forgotten.
22
23use std::{
24    collections::BTreeSet,
25    env,
26    ffi::OsString,
27    fmt::Write as _,
28    fs,
29    path::{Path, PathBuf},
30};
31
32/// Something an application uses that the device has to allow.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub enum Service {
35    /// The camera.
36    Camera,
37    /// Reading the photo library.
38    PhotoLibrary,
39    /// Writing to the photo library.
40    PhotoLibraryAdd,
41    /// The microphone.
42    Microphone,
43    /// Where the device is.
44    Location,
45    /// Notifications the person sees.
46    Notifications,
47    /// Work that carries on with the application off screen.
48    Background,
49    /// Media playback that carries on with the application off screen.
50    Media,
51    /// Purchases through the platform store.
52    Billing,
53    /// A window drawn above other applications.
54    Overlay,
55    /// The vibrator.
56    Haptics,
57    /// Handing a downloaded package to the system installer.
58    Update,
59    /// The network, and whether the device is on one.
60    Network,
61}
62
63impl Service {
64    /// The name this service carries in the build's own files.
65    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    /// The Android permissions this service needs.
84    ///
85    /// Both photo library services are empty here: Android reads and writes
86    /// photos through the system picker, which asks the person for one file
87    /// and needs no permission from the application.
88    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    /// The key an Apple platform reads the sentence from.
116    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    /// Whether this service takes a sentence to show the person.
128    pub const fn takes_reason(self) -> bool {
129        self.apple_key().is_some()
130    }
131}
132
133/// One service an application uses, with the sentence it shows if it needs one.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct Use {
136    service: Service,
137    reason: Option<&'static str>,
138}
139
140impl Use {
141    /// The camera, with what the person is told before it opens.
142    pub const fn camera(reason: &'static str) -> Self {
143        Self {
144            service: Service::Camera,
145            reason: Some(reason),
146        }
147    }
148
149    /// Reading the photo library, with what the person is told.
150    pub const fn photo_library(reason: &'static str) -> Self {
151        Self {
152            service: Service::PhotoLibrary,
153            reason: Some(reason),
154        }
155    }
156
157    /// Writing to the photo library, with what the person is told.
158    pub const fn photo_library_add(reason: &'static str) -> Self {
159        Self {
160            service: Service::PhotoLibraryAdd,
161            reason: Some(reason),
162        }
163    }
164
165    /// The microphone, with what the person is told.
166    pub const fn microphone(reason: &'static str) -> Self {
167        Self {
168            service: Service::Microphone,
169            reason: Some(reason),
170        }
171    }
172
173    /// Where the device is, with what the person is told.
174    pub const fn location(reason: &'static str) -> Self {
175        Self {
176            service: Service::Location,
177            reason: Some(reason),
178        }
179    }
180
181    /// Notifications the person sees.
182    pub const fn notifications() -> Self {
183        Self {
184            service: Service::Notifications,
185            reason: None,
186        }
187    }
188
189    /// Work that carries on with the application off screen.
190    pub const fn background() -> Self {
191        Self {
192            service: Service::Background,
193            reason: None,
194        }
195    }
196
197    /// Media playback that carries on with the application off screen.
198    pub const fn media() -> Self {
199        Self {
200            service: Service::Media,
201            reason: None,
202        }
203    }
204
205    /// Purchases through the platform store.
206    pub const fn billing() -> Self {
207        Self {
208            service: Service::Billing,
209            reason: None,
210        }
211    }
212
213    /// A window drawn above other applications.
214    pub const fn overlay() -> Self {
215        Self {
216            service: Service::Overlay,
217            reason: None,
218        }
219    }
220
221    /// The vibrator.
222    pub const fn haptics() -> Self {
223        Self {
224            service: Service::Haptics,
225            reason: None,
226        }
227    }
228
229    /// Handing a downloaded package to the system installer.
230    pub const fn update() -> Self {
231        Self {
232            service: Service::Update,
233            reason: None,
234        }
235    }
236
237    /// The network, and whether the device is on one.
238    pub const fn network() -> Self {
239        Self {
240            service: Service::Network,
241            reason: None,
242        }
243    }
244
245    /// Which service this is.
246    pub const fn service(self) -> Service {
247        self.service
248    }
249
250    /// The sentence the person is shown, for the services that have one.
251    pub const fn reason(self) -> Option<&'static str> {
252        self.reason
253    }
254}
255
256/// Hardware an application cannot run without.
257///
258/// Every other feature stays optional, so the application reaches devices
259/// that lack the hardware and asks the framework at run time.
260#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
261pub enum Demand {
262    /// A camera.
263    Camera,
264    /// A microphone.
265    Microphone,
266    /// A watch.
267    Watch,
268    /// Telephony.
269    Telephony,
270    /// Bluetooth.
271    Bluetooth,
272    /// Near-field communication.
273    Nfc,
274    /// Wi-Fi.
275    Wifi,
276    /// Location hardware.
277    Location,
278}
279
280impl Demand {
281    /// The Android feature name this demand becomes.
282    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    /// The name this demand carries in the build's own files.
296    pub const fn name(self) -> &'static str {
297        self.android_feature()
298    }
299}
300
301/// Everything one application asks of a device.
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub struct Capabilities<'a> {
304    /// The services the application uses.
305    pub uses: &'a [Use],
306    /// The hardware the application cannot run without.
307    pub demands: &'a [Demand],
308    /// The media types of the files the application opens, such as
309    /// `"audio/*"` or `"application/pdf"`: the platform then offers it for
310    /// those files, in a share sheet and in "Open with", and hands what the
311    /// person picks to `cranpose_services::incoming_share`.
312    pub opens: &'a [&'a str],
313}
314
315impl Capabilities<'_> {
316    /// An application that asks for nothing.
317    pub const NONE: Capabilities<'static> = Capabilities {
318        uses: &[],
319        demands: &[],
320        opens: &[],
321    };
322
323    /// Whether the application declared this service.
324    pub fn has(&self, service: Service) -> bool {
325        self.uses.iter().any(|entry| entry.service == service)
326    }
327
328    /// The sentence declared for a service, if it has one.
329    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/// What a build script declares, before [`Declaration::emit`] writes it out.
338#[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
344/// Starts a declaration with the services an application uses.
345pub 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    /// Adds the hardware the application cannot run without.
357    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    /// Adds the media types of the files the application opens, such as
367    /// `"audio/*"`, so the platform offers it for them.
368    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    /// Writes the declaration where the application and every platform build
378    /// reads it.
379    ///
380    /// Call this from a build script. It panics when the files cannot be
381    /// written, which is what a build script does with a failure.
382    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
412/// The files [`Declaration::emit`] writes outside `OUT_DIR`.
413///
414/// They go where every platform build reads them, which is also where anything
415/// that reclaims build artifacts can remove them.
416fn 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
424/// Tells cargo that these files are this build script's outputs.
425///
426/// Cargo does not know what a build script writes outside `OUT_DIR`, and a
427/// path named to `rerun-if-changed` counts as changed when it is missing. So
428/// naming them is what makes a deleted declaration come back: without it,
429/// cargo reads an unchanged `build.rs`, skips the script, and the tree keeps
430/// building without the permissions XML that carries SYSTEM_ALERT_WINDOW and
431/// VIBRATE into the merged Android manifest. The Android release APK then
432/// fails `cranposeReleaseManifestCheck` on every run in that workspace,
433/// because nothing will ever write the file again.
434fn 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
448/// Names the directory the declaration is written into.
449///
450/// A platform build sets it, because the build knows the tree it drives. A
451/// plain `cargo build` does not, and the declaration then goes to
452/// `<workspace>/target/cranpose`.
453const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
454
455/// Where the declaration goes: the directory the build named, or the one under
456/// the workspace this crate belongs to.
457fn 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
464/// The workspace this crate belongs to, found from its own directory.
465///
466/// The platform builds read the declaration from `<workspace>/target/cranpose`,
467/// and they know the workspace because they already resolved this crate's
468/// source there. The directory Cargo happens to build into is not that place:
469/// it moves with `CARGO_TARGET_DIR`, and continuous integration sets it.
470fn 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    // The outermost workspace, so a crate inside a workspace that is itself
479    // vendored into another one still answers with the tree the build drives.
480    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
487/// The Rust the application includes, so it reads the same declaration the
488/// platform builds do.
489fn 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
513/// The call that rebuilds one entry, which is the service's own name with the
514/// dashes a Rust function cannot carry turned back into underscores.
515fn 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
527/// The declaration as the platform builds read it.
528pub 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
578/// Every Android permission the declared services need, in order and without
579/// repeats.
580pub 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
590/// The Android manifest fragment the declaration becomes.
591pub 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
616/// The activity entry that offers the application for `opens`: sending a file
617/// to it and opening one with it.
618fn 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
650/// The Apple usage descriptions the declaration becomes, as a property list
651/// to merge into an `Info.plist`.
652///
653/// It is a property list of its own, so an Apple build merges it with one
654/// line and no tool beyond the ones macOS ships:
655///
656/// ```text
657/// /usr/libexec/PlistBuddy -c "Merge target/cranpose/my-app-usage.plist" MyApp.app/Info.plist
658/// ```
659pub 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('&', "&amp;")
679        .replace('<', "&lt;")
680        .replace('>', "&gt;")
681}
682
683#[cfg(test)]
684#[path = "tests/capabilities_tests.rs"]
685mod tests;