Skip to main content

cranpose_capabilities/
lib.rs

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/// Something an application uses that the device has to allow.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub enum Service {
15    /// The camera.
16    Camera,
17    /// Reading the photo library.
18    PhotoLibrary,
19    /// Writing to the photo library.
20    PhotoLibraryAdd,
21    /// The microphone.
22    Microphone,
23    /// Where the device is.
24    Location,
25    /// Notifications the person sees.
26    Notifications,
27    /// Work that carries on with the application off screen.
28    Background,
29    /// Media playback that carries on with the application off screen.
30    Media,
31    /// Purchases through the platform store.
32    Billing,
33    /// A window drawn above other applications.
34    Overlay,
35    /// The vibrator.
36    Haptics,
37    /// Handing a downloaded package to the system installer.
38    Update,
39    /// The network, and whether the device is on one.
40    Network,
41}
42
43impl Service {
44    /// The name this service carries in the build's own files.
45    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    /// The Android permissions this service needs.
64    ///
65    /// Both photo library services are empty here: Android reads and writes
66    /// photos through the system picker, which asks the person for one file
67    /// and needs no permission from the application.
68    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    /// The key an Apple platform reads the sentence from.
96    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    /// Whether this service takes a sentence to show the person.
108    pub const fn takes_reason(self) -> bool {
109        self.apple_key().is_some()
110    }
111}
112
113/// One service an application uses, with the sentence it shows if it needs one.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub struct Use {
116    service: Service,
117    reason: Option<&'static str>,
118}
119
120impl Use {
121    /// The camera, with what the person is told before it opens.
122    pub const fn camera(reason: &'static str) -> Self {
123        Self {
124            service: Service::Camera,
125            reason: Some(reason),
126        }
127    }
128
129    /// Reading the photo library, with what the person is told.
130    pub const fn photo_library(reason: &'static str) -> Self {
131        Self {
132            service: Service::PhotoLibrary,
133            reason: Some(reason),
134        }
135    }
136
137    /// Writing to the photo library, with what the person is told.
138    pub const fn photo_library_add(reason: &'static str) -> Self {
139        Self {
140            service: Service::PhotoLibraryAdd,
141            reason: Some(reason),
142        }
143    }
144
145    /// The microphone, with what the person is told.
146    pub const fn microphone(reason: &'static str) -> Self {
147        Self {
148            service: Service::Microphone,
149            reason: Some(reason),
150        }
151    }
152
153    /// Where the device is, with what the person is told.
154    pub const fn location(reason: &'static str) -> Self {
155        Self {
156            service: Service::Location,
157            reason: Some(reason),
158        }
159    }
160
161    /// Notifications the person sees.
162    pub const fn notifications() -> Self {
163        Self {
164            service: Service::Notifications,
165            reason: None,
166        }
167    }
168
169    /// Work that carries on with the application off screen.
170    pub const fn background() -> Self {
171        Self {
172            service: Service::Background,
173            reason: None,
174        }
175    }
176
177    /// Media playback that carries on with the application off screen.
178    pub const fn media() -> Self {
179        Self {
180            service: Service::Media,
181            reason: None,
182        }
183    }
184
185    /// Purchases through the platform store.
186    pub const fn billing() -> Self {
187        Self {
188            service: Service::Billing,
189            reason: None,
190        }
191    }
192
193    /// A window drawn above other applications.
194    pub const fn overlay() -> Self {
195        Self {
196            service: Service::Overlay,
197            reason: None,
198        }
199    }
200
201    /// The vibrator.
202    pub const fn haptics() -> Self {
203        Self {
204            service: Service::Haptics,
205            reason: None,
206        }
207    }
208
209    /// Handing a downloaded package to the system installer.
210    pub const fn update() -> Self {
211        Self {
212            service: Service::Update,
213            reason: None,
214        }
215    }
216
217    /// The network, and whether the device is on one.
218    pub const fn network() -> Self {
219        Self {
220            service: Service::Network,
221            reason: None,
222        }
223    }
224
225    /// Which service this is.
226    pub const fn service(self) -> Service {
227        self.service
228    }
229
230    /// The sentence the person is shown, for the services that have one.
231    pub const fn reason(self) -> Option<&'static str> {
232        self.reason
233    }
234}
235
236/// Hardware an application cannot run without.
237///
238/// Every other feature stays optional, so the application reaches devices
239/// that lack the hardware and asks the framework at run time.
240#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
241pub enum Demand {
242    /// A camera.
243    Camera,
244    /// A microphone.
245    Microphone,
246    /// A watch.
247    Watch,
248    /// Telephony.
249    Telephony,
250    /// Bluetooth.
251    Bluetooth,
252    /// Near-field communication.
253    Nfc,
254    /// Wi-Fi.
255    Wifi,
256    /// Location hardware.
257    Location,
258}
259
260impl Demand {
261    /// The Android feature name this demand becomes.
262    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    /// The name this demand carries in the build's own files.
276    pub const fn name(self) -> &'static str {
277        self.android_feature()
278    }
279}
280
281/// Everything one application asks of a device.
282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283pub struct Capabilities<'a> {
284    /// The services the application uses.
285    pub uses: &'a [Use],
286    /// The hardware the application cannot run without.
287    pub demands: &'a [Demand],
288    /// The media types of the files the application opens, such as
289    /// `"audio/*"` or `"application/pdf"`: the platform then offers it for
290    /// those files, in a share sheet and in "Open with", and hands what the
291    /// person picks to `cranpose_services::incoming_share`.
292    pub opens: &'a [&'a str],
293}
294
295impl Capabilities<'_> {
296    /// An application that asks for nothing.
297    pub const NONE: Capabilities<'static> = Capabilities {
298        uses: &[],
299        demands: &[],
300        opens: &[],
301    };
302
303    /// Whether the application declared this service.
304    pub fn has(&self, service: Service) -> bool {
305        self.uses.iter().any(|entry| entry.service == service)
306    }
307
308    /// The sentence declared for a service, if it has one.
309    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/// What a build script declares, before [`Declaration::emit`] writes it out.
318#[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
324/// Starts a declaration with the services an application uses.
325pub 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    /// Adds the hardware the application cannot run without.
337    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    /// Adds the media types of the files the application opens, such as
347    /// `"audio/*"`, so the platform offers it for them.
348    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    /// Writes the declaration where the application and every platform build
358    /// reads it.
359    ///
360    /// Call this from a build script. It panics when the files cannot be
361    /// written, which is what a build script does with a failure.
362    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
392/// The files [`Declaration::emit`] writes outside `OUT_DIR`.
393///
394/// They go where every platform build reads them, which is also where anything
395/// that reclaims build artifacts can remove them.
396fn 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
404/// Tells cargo that these files are this build script's outputs.
405///
406/// Cargo does not know what a build script writes outside `OUT_DIR`, and a
407/// path named to `rerun-if-changed` counts as changed when it is missing. So
408/// naming them is what makes a deleted declaration come back: without it,
409/// cargo reads an unchanged `build.rs`, skips the script, and the tree keeps
410/// building without the permissions XML that carries SYSTEM_ALERT_WINDOW and
411/// VIBRATE into the merged Android manifest. The Android release APK then
412/// fails `cranposeReleaseManifestCheck` on every run in that workspace,
413/// because nothing will ever write the file again.
414fn 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
428/// Names the directory the declaration is written into.
429///
430/// A platform build sets it, because the build knows the tree it drives. A
431/// plain `cargo build` does not, and the declaration then goes to
432/// `<workspace>/target/cranpose`.
433const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
434
435/// Where the declaration goes: the directory the build named, or the one under
436/// the workspace this crate belongs to.
437fn 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
444/// The workspace this crate belongs to, found from its own directory.
445///
446/// The platform builds read the declaration from `<workspace>/target/cranpose`,
447/// and they know the workspace because they already resolved this crate's
448/// source there. The directory Cargo happens to build into is not that place:
449/// it moves with `CARGO_TARGET_DIR`, and continuous integration sets it.
450fn 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    // The outermost workspace, so a crate inside a workspace that is itself
459    // vendored into another one still answers with the tree the build drives.
460    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
467/// The Rust the application includes, so it reads the same declaration the
468/// platform builds do.
469fn 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
493/// The call that rebuilds one entry, which is the service's own name with the
494/// dashes a Rust function cannot carry turned back into underscores.
495fn 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
507/// The declaration as the platform builds read it.
508pub 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
558/// Every Android permission the declared services need, in order and without
559/// repeats.
560pub 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
570/// The Android manifest fragment the declaration becomes.
571pub 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
596/// The activity entry that offers the application for `opens`: sending a file
597/// to it and opening one with it.
598fn 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
630/// The Apple usage descriptions the declaration becomes, as a property list
631/// to merge into an `Info.plist`.
632///
633/// It is a property list of its own, so an Apple build merges it with one
634/// line and no tool beyond the ones macOS ships:
635///
636/// ```text
637/// /usr/libexec/PlistBuddy -c "Merge target/cranpose/my-app-usage.plist" MyApp.app/Info.plist
638/// ```
639pub 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('&', "&amp;")
659        .replace('<', "&lt;")
660        .replace('>', "&gt;")
661}
662
663#[cfg(test)]
664#[path = "tests/capabilities_tests.rs"]
665mod tests;