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    /// The wearer's heart rate, from the device's own sensor.
42    HeartRate,
43    /// Recording that can start from the background, kept possible by a
44    /// standby the application starts from its screen.
45    MicrophoneStandby,
46    /// Messages and streams to the same application on the paired phone or
47    /// watch.
48    Wearable,
49}
50
51impl Service {
52    /// The name this service carries in the build's own files.
53    pub const fn name(self) -> &'static str {
54        match self {
55            Service::Camera => "camera",
56            Service::PhotoLibrary => "photo-library",
57            Service::PhotoLibraryAdd => "photo-library-add",
58            Service::Microphone => "microphone",
59            Service::Location => "location",
60            Service::Notifications => "notifications",
61            Service::Background => "background",
62            Service::Media => "media",
63            Service::Billing => "billing",
64            Service::Overlay => "overlay",
65            Service::Haptics => "haptics",
66            Service::Update => "update",
67            Service::Network => "network",
68            Service::HeartRate => "heart-rate",
69            Service::MicrophoneStandby => "microphone-standby",
70            Service::Wearable => "wearable",
71        }
72    }
73
74    /// The Android permissions this service needs.
75    ///
76    /// Both photo library services are empty here: Android reads and writes
77    /// photos through the system picker, which asks the person for one file
78    /// and needs no permission from the application.
79    pub const fn android_permissions(self) -> &'static [&'static str] {
80        match self {
81            Service::Camera => &["android.permission.CAMERA"],
82            Service::PhotoLibrary => &[],
83            Service::PhotoLibraryAdd => &[],
84            Service::Microphone => &["android.permission.RECORD_AUDIO"],
85            Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
86            Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
87            Service::Background => &[
88                "android.permission.FOREGROUND_SERVICE",
89                "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
90            ],
91            Service::Media => &[
92                "android.permission.FOREGROUND_SERVICE",
93                "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
94            ],
95            Service::Billing => &["com.android.vending.BILLING"],
96            Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
97            Service::Haptics => &["android.permission.VIBRATE"],
98            Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
99            Service::Network => &[
100                "android.permission.INTERNET",
101                "android.permission.ACCESS_NETWORK_STATE",
102            ],
103            // Android 16 split the body sensors into the health
104            // permissions; older releases read the same sensor under
105            // BODY_SENSORS, which `android_permissions_up_to` carries.
106            Service::HeartRate => &["android.permission.health.READ_HEART_RATE"],
107            Service::MicrophoneStandby => &[
108                "android.permission.FOREGROUND_SERVICE",
109                "android.permission.FOREGROUND_SERVICE_MICROPHONE",
110                "android.permission.RECORD_AUDIO",
111            ],
112            // The Data Layer runs through Google Play services and asks for
113            // nothing.
114            Service::Wearable => &[],
115        }
116    }
117
118    /// The Android permissions this service needs only up to an API level,
119    /// with that level: what a platform release replaced, declared with
120    /// `android:maxSdkVersion` so a newer device is never asked for it.
121    pub const fn android_permissions_up_to(self) -> &'static [(&'static str, u32)] {
122        match self {
123            Service::HeartRate => &[("android.permission.BODY_SENSORS", 35)],
124            _ => &[],
125        }
126    }
127
128    /// The key an Apple platform reads the sentence from.
129    pub const fn apple_key(self) -> Option<&'static str> {
130        match self {
131            Service::Camera => Some("NSCameraUsageDescription"),
132            Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
133            Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
134            Service::Microphone => Some("NSMicrophoneUsageDescription"),
135            Service::Location => Some("NSLocationWhenInUseUsageDescription"),
136            Service::HeartRate => Some("NSHealthShareUsageDescription"),
137            _ => None,
138        }
139    }
140
141    /// Whether this service takes a sentence to show the person.
142    pub const fn takes_reason(self) -> bool {
143        self.apple_key().is_some()
144    }
145}
146
147/// One service an application uses, with the sentence it shows if it needs one.
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub struct Use {
150    service: Service,
151    reason: Option<&'static str>,
152}
153
154impl Use {
155    /// The camera, with what the person is told before it opens.
156    pub const fn camera(reason: &'static str) -> Self {
157        Self {
158            service: Service::Camera,
159            reason: Some(reason),
160        }
161    }
162
163    /// Reading the photo library, with what the person is told.
164    pub const fn photo_library(reason: &'static str) -> Self {
165        Self {
166            service: Service::PhotoLibrary,
167            reason: Some(reason),
168        }
169    }
170
171    /// Writing to the photo library, with what the person is told.
172    pub const fn photo_library_add(reason: &'static str) -> Self {
173        Self {
174            service: Service::PhotoLibraryAdd,
175            reason: Some(reason),
176        }
177    }
178
179    /// The microphone, with what the person is told.
180    pub const fn microphone(reason: &'static str) -> Self {
181        Self {
182            service: Service::Microphone,
183            reason: Some(reason),
184        }
185    }
186
187    /// Where the device is, with what the person is told.
188    pub const fn location(reason: &'static str) -> Self {
189        Self {
190            service: Service::Location,
191            reason: Some(reason),
192        }
193    }
194
195    /// Notifications the person sees.
196    pub const fn notifications() -> Self {
197        Self {
198            service: Service::Notifications,
199            reason: None,
200        }
201    }
202
203    /// Work that carries on with the application off screen.
204    pub const fn background() -> Self {
205        Self {
206            service: Service::Background,
207            reason: None,
208        }
209    }
210
211    /// Media playback that carries on with the application off screen.
212    pub const fn media() -> Self {
213        Self {
214            service: Service::Media,
215            reason: None,
216        }
217    }
218
219    /// Purchases through the platform store.
220    pub const fn billing() -> Self {
221        Self {
222            service: Service::Billing,
223            reason: None,
224        }
225    }
226
227    /// A window drawn above other applications.
228    pub const fn overlay() -> Self {
229        Self {
230            service: Service::Overlay,
231            reason: None,
232        }
233    }
234
235    /// The vibrator.
236    pub const fn haptics() -> Self {
237        Self {
238            service: Service::Haptics,
239            reason: None,
240        }
241    }
242
243    /// Recording that can start from the background; see
244    /// `cranpose_services::hold_microphone_standby`.
245    pub const fn microphone_standby() -> Self {
246        Self {
247            service: Service::MicrophoneStandby,
248            reason: None,
249        }
250    }
251
252    /// Messages and streams to the same application on the paired phone or
253    /// watch; see `cranpose_services::wearable`.
254    pub const fn wearable() -> Self {
255        Self {
256            service: Service::Wearable,
257            reason: None,
258        }
259    }
260
261    /// Handing a downloaded package to the system installer.
262    pub const fn update() -> Self {
263        Self {
264            service: Service::Update,
265            reason: None,
266        }
267    }
268
269    /// The network, and whether the device is on one.
270    pub const fn network() -> Self {
271        Self {
272            service: Service::Network,
273            reason: None,
274        }
275    }
276
277    /// The wearer's heart rate, with what the person is told before the
278    /// sensor is read. Declaring it is the only way an application's build
279    /// asks for the permission; reading it still takes an explicit request
280    /// at run time.
281    pub const fn heart_rate(reason: &'static str) -> Self {
282        Self {
283            service: Service::HeartRate,
284            reason: Some(reason),
285        }
286    }
287
288    /// Which service this is.
289    pub const fn service(self) -> Service {
290        self.service
291    }
292
293    /// The sentence the person is shown, for the services that have one.
294    pub const fn reason(self) -> Option<&'static str> {
295        self.reason
296    }
297}
298
299/// Hardware an application cannot run without.
300///
301/// Every other feature stays optional, so the application reaches devices
302/// that lack the hardware and asks the framework at run time.
303#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
304pub enum Demand {
305    /// A camera.
306    Camera,
307    /// A microphone.
308    Microphone,
309    /// A watch.
310    Watch,
311    /// Telephony.
312    Telephony,
313    /// Bluetooth.
314    Bluetooth,
315    /// Near-field communication.
316    Nfc,
317    /// Wi-Fi.
318    Wifi,
319    /// Location hardware.
320    Location,
321}
322
323impl Demand {
324    /// The Android feature name this demand becomes.
325    pub const fn android_feature(self) -> &'static str {
326        match self {
327            Demand::Camera => "android.hardware.camera",
328            Demand::Microphone => "android.hardware.microphone",
329            Demand::Watch => "android.hardware.type.watch",
330            Demand::Telephony => "android.hardware.telephony",
331            Demand::Bluetooth => "android.hardware.bluetooth",
332            Demand::Nfc => "android.hardware.nfc",
333            Demand::Wifi => "android.hardware.wifi",
334            Demand::Location => "android.hardware.location",
335        }
336    }
337
338    /// The name this demand carries in the build's own files.
339    pub const fn name(self) -> &'static str {
340        self.android_feature()
341    }
342}
343
344/// Everything one application asks of a device.
345#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub struct Capabilities<'a> {
347    /// The services the application uses.
348    pub uses: &'a [Use],
349    /// The hardware the application cannot run without.
350    pub demands: &'a [Demand],
351    /// The media types of the files the application opens, such as
352    /// `"audio/*"` or `"application/pdf"`: the platform then offers it for
353    /// those files, in a share sheet and in "Open with", and hands what the
354    /// person picks to `cranpose_services::incoming_share`.
355    pub opens: &'a [&'a str],
356}
357
358impl Capabilities<'_> {
359    /// An application that asks for nothing.
360    pub const NONE: Capabilities<'static> = Capabilities {
361        uses: &[],
362        demands: &[],
363        opens: &[],
364    };
365
366    /// Whether the application declared this service.
367    pub fn has(&self, service: Service) -> bool {
368        self.uses.iter().any(|entry| entry.service == service)
369    }
370
371    /// The sentence declared for a service, if it has one.
372    pub fn reason_for(&self, service: Service) -> Option<&'static str> {
373        self.uses
374            .iter()
375            .find(|entry| entry.service == service)
376            .and_then(|entry| entry.reason())
377    }
378}
379
380/// What a build script declares, before [`Declaration::emit`] writes it out.
381#[must_use = "a declaration reaches the platform builds only through emit()"]
382#[derive(Clone, Copy, Debug)]
383pub struct Declaration<'a> {
384    capabilities: Capabilities<'a>,
385}
386
387/// Starts a declaration with the services an application uses.
388pub const fn declare(uses: &[Use]) -> Declaration<'_> {
389    Declaration {
390        capabilities: Capabilities {
391            uses,
392            demands: &[],
393            opens: &[],
394        },
395    }
396}
397
398impl<'a> Declaration<'a> {
399    /// Adds the hardware the application cannot run without.
400    pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
401        Declaration {
402            capabilities: Capabilities {
403                demands,
404                ..self.capabilities
405            },
406        }
407    }
408
409    /// Adds the media types of the files the application opens, such as
410    /// `"audio/*"`, so the platform offers it for them.
411    pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
412        Declaration {
413            capabilities: Capabilities {
414                opens,
415                ..self.capabilities
416            },
417        }
418    }
419
420    /// Writes the declaration where the application and every platform build
421    /// reads it.
422    ///
423    /// Call this from a build script. It panics when the files cannot be
424    /// written, which is what a build script does with a failure.
425    pub fn emit(self) {
426        let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
427        let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
428        write_file(
429            &out.join("cranpose_capabilities.rs"),
430            &rust_source(&self.capabilities),
431        );
432
433        let crate_dir = PathBuf::from(
434            env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
435        );
436        let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
437        fs::create_dir_all(&shared).expect("the shared capabilities directory");
438        let outputs = shared_outputs(&shared, &package);
439        let contents = [
440            json(&self.capabilities),
441            android_manifest(&self.capabilities),
442            apple_usage(&self.capabilities),
443        ];
444        for (path, text) in outputs.iter().zip(contents.iter()) {
445            write_file(path, text);
446        }
447        println!("cargo::rerun-if-changed=build.rs");
448        println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
449        for directive in rerun_directives(&outputs) {
450            println!("{directive}");
451        }
452    }
453}
454
455/// The files [`Declaration::emit`] writes outside `OUT_DIR`.
456///
457/// They go where every platform build reads them, which is also where anything
458/// that reclaims build artifacts can remove them.
459fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
460    [
461        shared.join(format!("{package}-capabilities.json")),
462        shared.join(format!("{package}-permissions.xml")),
463        shared.join(format!("{package}-usage.plist")),
464    ]
465}
466
467/// Tells cargo that these files are this build script's outputs.
468///
469/// Cargo does not know what a build script writes outside `OUT_DIR`, and a
470/// path named to `rerun-if-changed` counts as changed when it is missing. So
471/// naming them is what makes a deleted declaration come back: without it,
472/// cargo reads an unchanged `build.rs`, skips the script, and the tree keeps
473/// building without the permissions XML that carries SYSTEM_ALERT_WINDOW and
474/// VIBRATE into the merged Android manifest. The Android release APK then
475/// fails `cranposeReleaseManifestCheck` on every run in that workspace,
476/// because nothing will ever write the file again.
477fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
478    outputs
479        .iter()
480        .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
481        .collect()
482}
483
484fn write_file(path: &Path, text: &str) {
485    if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
486        return;
487    }
488    fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
489}
490
491/// Names the directory the declaration is written into.
492///
493/// A platform build sets it, because the build knows the tree it drives. A
494/// plain `cargo build` does not, and the declaration then goes to
495/// `<workspace>/target/cranpose`.
496const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
497
498/// Where the declaration goes: the directory the build named, or the one under
499/// the workspace this crate belongs to.
500fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
501    match named.filter(|value| !value.is_empty()) {
502        Some(value) => PathBuf::from(value),
503        None => workspace_root(crate_dir).join("target").join("cranpose"),
504    }
505}
506
507/// The workspace this crate belongs to, found from its own directory.
508///
509/// The platform builds read the declaration from `<workspace>/target/cranpose`,
510/// and they know the workspace because they already resolved this crate's
511/// source there. The directory Cargo happens to build into is not that place:
512/// it moves with `CARGO_TARGET_DIR`, and continuous integration sets it.
513fn workspace_root(crate_dir: &Path) -> PathBuf {
514    found_workspace(crate_dir, &|path| {
515        fs::read_to_string(path.join("Cargo.toml"))
516            .is_ok_and(|manifest| manifest.contains("[workspace]"))
517    })
518}
519
520fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
521    // The outermost workspace, so a crate inside a workspace that is itself
522    // vendored into another one still answers with the tree the build drives.
523    crate_dir
524        .ancestors()
525        .filter(|path| holds_workspace(path))
526        .last()
527        .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
528}
529
530/// The Rust the application includes, so it reads the same declaration the
531/// platform builds do.
532fn rust_source(capabilities: &Capabilities<'_>) -> String {
533    let mut text = String::from(
534        "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n    \
535         cranpose_capabilities::Capabilities {\n        uses: &[\n",
536    );
537    for entry in capabilities.uses {
538        let call = constructor(entry);
539        let _ = writeln!(text, "            cranpose_capabilities::Use::{call},");
540    }
541    text.push_str("        ],\n        demands: &[\n");
542    for demand in capabilities.demands {
543        let _ = writeln!(
544            text,
545            "            cranpose_capabilities::Demand::{demand:?},"
546        );
547    }
548    text.push_str("        ],\n        opens: &[\n");
549    for media_type in capabilities.opens {
550        let _ = writeln!(text, "            \"{}\",", escape(media_type));
551    }
552    text.push_str("        ],\n    };\n");
553    text
554}
555
556/// The call that rebuilds one entry, which is the service's own name with the
557/// dashes a Rust function cannot carry turned back into underscores.
558fn constructor(entry: &Use) -> String {
559    let name = entry.service.name().replace('-', "_");
560    match entry.reason {
561        Some(reason) => format!("{name}(\"{}\")", escape(reason)),
562        None => format!("{name}()"),
563    }
564}
565
566fn escape(text: &str) -> String {
567    text.replace('\\', "\\\\").replace('"', "\\\"")
568}
569
570/// The declaration as the platform builds read it.
571pub fn json(capabilities: &Capabilities<'_>) -> String {
572    let mut text = String::from("{\n  \"services\": [\n");
573    for (at, entry) in capabilities.uses.iter().enumerate() {
574        let comma = if at + 1 == capabilities.uses.len() {
575            ""
576        } else {
577            ","
578        };
579        let reason = match entry.reason {
580            Some(reason) => format!("\"{}\"", escape_json(reason)),
581            None => String::from("null"),
582        };
583        let _ = writeln!(
584            text,
585            "    {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
586            entry.service.name()
587        );
588    }
589    text.push_str("  ],\n  \"permissions\": [\n");
590    let permissions = android_permissions(capabilities);
591    for (at, permission) in permissions.iter().enumerate() {
592        let comma = if at + 1 == permissions.len() { "" } else { "," };
593        let _ = writeln!(text, "    \"{permission}\"{comma}");
594    }
595    text.push_str("  ],\n  \"permissionsUpTo\": [\n");
596    let capped = android_permissions_up_to(capabilities);
597    for (at, (permission, level)) in capped.iter().enumerate() {
598        let comma = if at + 1 == capped.len() { "" } else { "," };
599        let _ = writeln!(
600            text,
601            "    {{ \"name\": \"{permission}\", \"maxSdk\": {level} }}{comma}"
602        );
603    }
604    text.push_str("  ],\n  \"demands\": [\n");
605    for (at, demand) in capabilities.demands.iter().enumerate() {
606        let comma = if at + 1 == capabilities.demands.len() {
607            ""
608        } else {
609            ","
610        };
611        let _ = writeln!(text, "    \"{}\"{comma}", demand.android_feature());
612    }
613    text.push_str("  ],\n  \"opens\": [\n");
614    for (at, media_type) in capabilities.opens.iter().enumerate() {
615        let comma = if at + 1 == capabilities.opens.len() {
616            ""
617        } else {
618            ","
619        };
620        let _ = writeln!(text, "    \"{}\"{comma}", escape_json(media_type));
621    }
622    text.push_str("  ]\n}\n");
623    text
624}
625
626fn escape_json(text: &str) -> String {
627    escape(text).replace('\n', "\\n")
628}
629
630/// Every Android permission the declared services need, in order and without
631/// repeats.
632pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
633    let mut named = BTreeSet::new();
634    for entry in capabilities.uses {
635        for permission in entry.service.android_permissions() {
636            named.insert(*permission);
637        }
638    }
639    named.into_iter().collect()
640}
641
642/// The permissions the declaration needs only up to an API level, each with
643/// that level, without repeats and leaving out any the newer list already
644/// names.
645pub fn android_permissions_up_to(capabilities: &Capabilities<'_>) -> Vec<(&'static str, u32)> {
646    let plain = android_permissions(capabilities);
647    let mut named = std::collections::BTreeMap::new();
648    for entry in capabilities.uses {
649        for (permission, level) in entry.service.android_permissions_up_to() {
650            if !plain.contains(permission) {
651                named.insert(*permission, *level);
652            }
653        }
654    }
655    named.into_iter().collect()
656}
657
658/// The Android manifest fragment the declaration becomes.
659pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
660    let mut text = String::from(
661        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
662         <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
663    );
664    for permission in android_permissions(capabilities) {
665        let _ = writeln!(
666            text,
667            "    <uses-permission android:name=\"{permission}\" />"
668        );
669    }
670    for (permission, level) in android_permissions_up_to(capabilities) {
671        let _ = writeln!(
672            text,
673            "    <uses-permission android:name=\"{permission}\" android:maxSdkVersion=\"{level}\" />"
674        );
675    }
676    for demand in capabilities.demands {
677        let _ = writeln!(
678            text,
679            "    <uses-feature android:name=\"{}\" android:required=\"true\" />",
680            demand.android_feature()
681        );
682    }
683    if !capabilities.opens.is_empty() {
684        text.push_str(&android_opening_activity(capabilities.opens));
685    }
686    text.push_str("</manifest>\n");
687    text
688}
689
690/// The activity entry that offers the application for `opens`: sending a file
691/// to it and opening one with it.
692fn android_opening_activity(opens: &[&str]) -> String {
693    let mut text = String::from(
694        "    <application>\n        \
695         <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
696    );
697    for actions in [
698        &[
699            "android.intent.action.SEND",
700            "android.intent.action.SEND_MULTIPLE",
701        ][..],
702        &["android.intent.action.VIEW"][..],
703    ] {
704        text.push_str("            <intent-filter>\n");
705        for action in actions {
706            let _ = writeln!(text, "                <action android:name=\"{action}\" />");
707        }
708        text.push_str(
709            "                <category android:name=\"android.intent.category.DEFAULT\" />\n",
710        );
711        for media_type in opens {
712            let _ = writeln!(
713                text,
714                "                <data android:mimeType=\"{}\" />",
715                escape_xml(media_type)
716            );
717        }
718        text.push_str("            </intent-filter>\n");
719    }
720    text.push_str("        </activity>\n    </application>\n");
721    text
722}
723
724/// The Apple usage descriptions the declaration becomes, as a property list
725/// to merge into an `Info.plist`.
726///
727/// It is a property list of its own, so an Apple build merges it with one
728/// line and no tool beyond the ones macOS ships:
729///
730/// ```text
731/// /usr/libexec/PlistBuddy -c "Merge target/cranpose/my-app-usage.plist" MyApp.app/Info.plist
732/// ```
733pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
734    let mut text = String::from(
735        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
736         <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
737         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
738         <plist version=\"1.0\">\n<dict>\n",
739    );
740    for entry in capabilities.uses {
741        let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
742            continue;
743        };
744        let _ = writeln!(text, "\t<key>{key}</key>");
745        let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
746    }
747    text.push_str("</dict>\n</plist>\n");
748    text
749}
750
751fn escape_xml(text: &str) -> String {
752    text.replace('&', "&amp;")
753        .replace('<', "&lt;")
754        .replace('>', "&gt;")
755}
756
757#[cfg(test)]
758#[path = "tests/capabilities_tests.rs"]
759mod tests;