apple_bundles/
macos_application_bundle.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5/*! macOS Application Bundles
6
7See https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1
8for documentation of the macOS Application Bundle format.
9*/
10
11use {
12    crate::BundlePackageType,
13    anyhow::{anyhow, Context, Result},
14    simple_file_manifest::{FileEntry, FileManifest, FileManifestError},
15    std::path::{Path, PathBuf},
16};
17
18/// Primitive used to iteratively construct a macOS Application Bundle.
19///
20/// Under the hood, the builder maintains a list of files that will constitute
21/// the final, materialized bundle. There is a low-level `add_file()` API for
22/// adding a file at an explicit path within the bundle. This gives you full
23/// control over the content of the bundle.
24///
25/// There are also a number of high-level APIs for performing common tasks, such
26/// as defining required bundle metadata for the `Contents/Info.plist` file and
27/// adding files to specific locations. There are even APIs for performing
28/// lower-level manipulation of certain files, such as adding keys to the
29/// `Content/Info.plist` file.
30///
31/// Apple's documentation on the
32/// [bundle format](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1)
33/// is very comprehensive and can answer many questions. The most important
34/// takeaways are:
35///
36/// 1. The `Contents/Info.plist` must contain some required keys defining the
37///    bundle. Call `set_info_plist_required_keys()` to ensure these are
38///    defined.
39/// 2. There must be an executable file in the `Contents/MacOS` directory. Add
40///    one via `add_file_macos()`.
41///
42/// This type attempts to prevent some misuse (such as validating `Info.plist`
43/// content) but it cannot prevent all misconfigurations.
44///
45/// # Examples
46///
47/// ```
48/// use apple_bundles::MacOsApplicationBundleBuilder;
49/// use simple_file_manifest::FileEntry;
50///
51/// # fn main() -> anyhow::Result<()> {
52/// let mut builder = MacOsApplicationBundleBuilder::new("MyProgram")?;
53///
54/// // Populate some required keys in Contents/Info.plist.
55/// builder.set_info_plist_required_keys("My Program", "com.example.my_program", "0.1", "mypg", "MyProgram")?;
56///
57/// // Add an executable file providing our main application.
58/// builder.add_file_macos("MyProgram", FileEntry::new_from_data(b"#!/bin/sh\necho 'hello world'\n".to_vec(), true))?;
59/// # Ok(())
60/// # }
61/// ```
62#[derive(Clone, Debug)]
63pub struct MacOsApplicationBundleBuilder {
64    /// Files constituting the application bundle.
65    files: FileManifest,
66}
67
68impl MacOsApplicationBundleBuilder {
69    /// Create a new macOS Application Bundle builder.
70    ///
71    /// The bundle will be populated with a skeleton `Contents/Info.plist` file
72    /// defining the bundle name passed.
73    pub fn new(bundle_name: impl ToString) -> Result<Self> {
74        let mut instance = Self {
75            files: FileManifest::default(),
76        };
77
78        instance
79            .set_info_plist_key("CFBundleName", bundle_name.to_string())
80            .context("setting CFBundleName")?;
81
82        // This is an application bundle, so CFBundlePackageType is constant.
83        instance
84            .set_info_plist_key("CFBundlePackageType", BundlePackageType::App.to_string())
85            .context("setting CFBundlePackageType")?;
86
87        Ok(instance)
88    }
89
90    /// Obtain the raw FileManifest backing this builder.
91    pub fn files(&self) -> &FileManifest {
92        &self.files
93    }
94
95    /// Obtain the name of the bundle.
96    ///
97    /// This will parse the stored `Contents/Info.plist` and return the
98    /// value of the `CFBundleName` key.
99    ///
100    /// This will error if the stored `Info.plist` is malformed, is missing
101    /// a key, or the key has the wrong type. Errors should only happen if
102    /// the file was explicitly stored or the value of this key was explicitly
103    /// defined to the wrong type.
104    pub fn bundle_name(&self) -> Result<String> {
105        Ok(self
106            .get_info_plist_key("CFBundleName")
107            .context("resolving CFBundleName")?
108            .ok_or_else(|| anyhow!("CFBundleName key not defined"))?
109            .as_string()
110            .ok_or_else(|| anyhow!("CFBundleName is not a string"))?
111            .to_string())
112    }
113
114    /// Obtain the parsed content of the `Contents/Info.plist` file.
115    ///
116    /// Returns `Some(T)` if a `Contents/Info.plist` is defined or `None` if
117    /// not.
118    ///
119    /// Returns `Err` if the file content could not be resolved or fails to parse
120    /// as a plist dictionary.
121    pub fn info_plist(&self) -> Result<Option<plist::Dictionary>> {
122        if let Some(entry) = self.files.get("Contents/Info.plist") {
123            let data = entry.resolve_content().context("resolving file content")?;
124            let cursor = std::io::Cursor::new(data);
125
126            let value = plist::Value::from_reader_xml(cursor).context("parsing plist")?;
127
128            if let Some(dict) = value.into_dictionary() {
129                Ok(Some(dict))
130            } else {
131                Err(anyhow!("parsed plist is not a dictionary"))
132            }
133        } else {
134            Ok(None)
135        }
136    }
137
138    /// Add a file to this application bundle.
139    ///
140    /// The path specified will be added without any checking, replacing
141    /// an existing file at that path, if present.
142    pub fn add_file(
143        &mut self,
144        path: impl AsRef<Path>,
145        entry: impl Into<FileEntry>,
146    ) -> Result<(), FileManifestError> {
147        self.files.add_file_entry(path, entry)
148    }
149
150    /// Set the content of `Contents/Info.plist` using a `plist::Dictionary`.
151    ///
152    /// This allows you to define the `Info.plist` file with some validation
153    /// since it goes through a plist serialization API, which should produce a
154    /// valid plist file (although the contents of the plist may be invalid
155    /// for an application bundle).
156    pub fn set_info_plist_from_dictionary(&mut self, value: plist::Dictionary) -> Result<()> {
157        let mut data: Vec<u8> = vec![];
158
159        let value = plist::Value::from(value);
160
161        value
162            .to_writer_xml(&mut data)
163            .context("serializing plist dictionary to XML")?;
164
165        Ok(self.add_file("Contents/Info.plist", data)?)
166    }
167
168    /// Obtain the value of a key in the `Contents/Info.plist` file.
169    ///
170    /// Returns `Some(Value)` if the key exists, `None` otherwise.
171    ///
172    /// May error if the stored `Contents/Info.plist` file is malformed.
173    pub fn get_info_plist_key(&self, key: &str) -> Result<Option<plist::Value>> {
174        Ok(
175            if let Some(dict) = self.info_plist().context("parsing Info.plist")? {
176                dict.get(key).cloned()
177            } else {
178                None
179            },
180        )
181    }
182
183    /// Set the value of a key in the `Contents/Info.plist` file.
184    ///
185    /// This API can be used to iteratively build up the `Info.plist` file by
186    /// setting keys in it.
187    ///
188    /// If an existing key is replaced, `Some(Value)` will be returned.
189    pub fn set_info_plist_key(
190        &mut self,
191        key: impl ToString,
192        value: impl Into<plist::Value>,
193    ) -> Result<Option<plist::Value>> {
194        let mut dict = if let Some(dict) = self.info_plist().context("retrieving Info.plist")? {
195            dict
196        } else {
197            plist::Dictionary::new()
198        };
199
200        let old = dict.insert(key.to_string(), value.into());
201
202        self.set_info_plist_from_dictionary(dict)
203            .context("replacing Info.plist dictionary")?;
204
205        Ok(old)
206    }
207
208    /// Defines required keys in the `Contents/Info.plist` file.
209    ///
210    /// The following keys are set:
211    ///
212    /// `display_name` sets `CFBundleDisplayName`, the bundle display name.
213    /// `identifier` sets `CFBundleIdentifier`, the bundle identifier.
214    /// `version` sets `CFBundleVersion`, the bundle version string.
215    /// `signature` sets `CFBundleSignature`, the bundle creator OS type code.
216    /// `executable` sets `CFBundleExecutable`, the name of the main executable file.
217    pub fn set_info_plist_required_keys(
218        &mut self,
219        display_name: impl ToString,
220        identifier: impl ToString,
221        version: impl ToString,
222        signature: impl ToString,
223        executable: impl ToString,
224    ) -> Result<()> {
225        let signature = signature.to_string();
226
227        if signature.len() != 4 {
228            return Err(anyhow!(
229                "signature must be exactly 4 characters; got {}",
230                signature
231            ));
232        }
233
234        self.set_info_plist_key("CFBundleDisplayName", display_name.to_string())
235            .context("setting CFBundleDisplayName")?;
236        self.set_info_plist_key("CFBundleIdentifier", identifier.to_string())
237            .context("setting CFBundleIdentifier")?;
238        self.set_info_plist_key("CFBundleVersion", version.to_string())
239            .context("setting CFBundleVersion")?;
240        self.set_info_plist_key("CFBundleSignature", signature)
241            .context("setting CFBundleSignature")?;
242        self.set_info_plist_key("CFBundleExecutable", executable.to_string())
243            .context("setting CFBundleExecutable")?;
244
245        Ok(())
246    }
247
248    /// Add the icon for the bundle.
249    ///
250    /// This will materialize the passed raw image data (can be multiple formats)
251    /// into the `Contents/Resources/<BundleName>.icns` file.
252    pub fn add_icon(&mut self, data: impl Into<FileEntry>) -> Result<()> {
253        Ok(self.add_file_resources(
254            format!(
255                "{}.icns",
256                self.bundle_name().context("resolving bundle name")?
257            ),
258            data,
259        )?)
260    }
261
262    /// Add a file to the `Contents/MacOS/` directory.
263    ///
264    /// The passed path will be prefixed with `Contents/MacOS/`.
265    pub fn add_file_macos(
266        &mut self,
267        path: impl AsRef<Path>,
268        entry: impl Into<FileEntry>,
269    ) -> Result<(), FileManifestError> {
270        self.add_file(PathBuf::from("Contents/MacOS").join(path), entry)
271    }
272
273    /// Add a file to the `Contents/Resources/` directory.
274    ///
275    /// The passed path will be prefixed with `Contents/Resources/`
276    pub fn add_file_resources(
277        &mut self,
278        path: impl AsRef<Path>,
279        entry: impl Into<FileEntry>,
280    ) -> Result<(), FileManifestError> {
281        self.add_file(PathBuf::from("Contents/Resources").join(path), entry)
282    }
283
284    /// Add a localized resources file.
285    ///
286    /// This is a convenience wrapper to `add_file_resources()` which automatically
287    /// places the file in the appropriate directory given the name of a locale.
288    pub fn add_localized_resources_file(
289        &mut self,
290        locale: impl ToString,
291        path: impl AsRef<Path>,
292        entry: impl Into<FileEntry>,
293    ) -> Result<(), FileManifestError> {
294        self.add_file_resources(
295            PathBuf::from(format!("{}.lproj", locale.to_string())).join(path),
296            entry,
297        )
298    }
299
300    /// Add a file to the `Contents/Frameworks/` directory.
301    ///
302    /// The passed path will be prefixed with `Contents/Frameworks/`.
303    pub fn add_file_frameworks(
304        &mut self,
305        path: impl AsRef<Path>,
306        entry: impl Into<FileEntry>,
307    ) -> Result<(), FileManifestError> {
308        self.add_file(PathBuf::from("Contents/Frameworks").join(path), entry)
309    }
310
311    /// Add a file to the `Contents/Plugins/` directory.
312    ///
313    /// The passed path will be prefixed with `Contents/Plugins/`.
314    pub fn add_file_plugins(
315        &mut self,
316        path: impl AsRef<Path>,
317        entry: impl Into<FileEntry>,
318    ) -> Result<(), FileManifestError> {
319        self.add_file(PathBuf::from("Contents/Plugins").join(path), entry)
320    }
321
322    /// Add a file to the `Contents/SharedSupport/` directory.
323    ///
324    /// The passed path will be prefixed with `Contents/SharedSupport/`.
325    pub fn add_file_shared_support(
326        &mut self,
327        path: impl AsRef<Path>,
328        entry: impl Into<FileEntry>,
329    ) -> Result<(), FileManifestError> {
330        self.add_file(PathBuf::from("Contents/SharedSupport").join(path), entry)
331    }
332
333    /// Materialize this bundle to the specified directory.
334    ///
335    /// All files comprising this bundle will be written to a directory named
336    /// `<bundle_name>.app` in the directory specified. The path of this directory
337    /// will be returned.
338    ///
339    /// If the destination bundle directory exists, existing files will be
340    /// overwritten. Files already in the destination not defined in this
341    /// builder will not be touched.
342    pub fn materialize_bundle(&self, dest_dir: impl AsRef<Path>) -> Result<PathBuf> {
343        let bundle_name = self.bundle_name().context("resolving bundle name")?;
344        let bundle_dir = dest_dir.as_ref().join(format!("{bundle_name}.app"));
345
346        self.files
347            .materialize_files(&bundle_dir)
348            .context("materializing FileManifest")?;
349
350        Ok(bundle_dir)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn new_plist() -> Result<()> {
360        let builder = MacOsApplicationBundleBuilder::new("MyProgram")?;
361
362        let entries = builder.files().iter_entries().collect::<Vec<_>>();
363        assert_eq!(entries.len(), 1);
364        assert_eq!(entries[0].0, &PathBuf::from("Contents/Info.plist"));
365
366        let mut dict = plist::Dictionary::new();
367        dict.insert("CFBundleName".to_string(), "MyProgram".to_string().into());
368        dict.insert("CFBundlePackageType".to_string(), "APPL".to_string().into());
369
370        assert_eq!(builder.info_plist()?, Some(dict));
371        assert!(String::from_utf8(entries[0].1.resolve_content()?)?
372            .starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
373
374        Ok(())
375    }
376
377    #[test]
378    fn plist_set() -> Result<()> {
379        let mut builder = MacOsApplicationBundleBuilder::new("MyProgram")?;
380
381        builder.set_info_plist_required_keys(
382            "My Program",
383            "com.example.my_program",
384            "0.1",
385            "mypg",
386            "MyProgram",
387        )?;
388
389        let dict = builder.info_plist()?.unwrap();
390        assert_eq!(
391            dict.get("CFBundleDisplayName"),
392            Some(&plist::Value::from("My Program"))
393        );
394        assert_eq!(
395            dict.get("CFBundleIdentifier"),
396            Some(&plist::Value::from("com.example.my_program"))
397        );
398        assert_eq!(
399            dict.get("CFBundleVersion"),
400            Some(&plist::Value::from("0.1"))
401        );
402        assert_eq!(
403            dict.get("CFBundleSignature"),
404            Some(&plist::Value::from("mypg"))
405        );
406        assert_eq!(
407            dict.get("CFBundleExecutable"),
408            Some(&plist::Value::from("MyProgram"))
409        );
410
411        Ok(())
412    }
413
414    #[test]
415    fn add_icon() -> Result<()> {
416        let mut builder = MacOsApplicationBundleBuilder::new("MyProgram")?;
417
418        builder.add_icon(vec![42])?;
419
420        let entries = builder.files.iter_entries().collect::<Vec<_>>();
421        assert_eq!(entries.len(), 2);
422        assert_eq!(
423            entries[1].0,
424            &PathBuf::from("Contents/Resources/MyProgram.icns")
425        );
426
427        Ok(())
428    }
429
430    #[test]
431    fn add_file_macos() -> Result<()> {
432        let mut builder = MacOsApplicationBundleBuilder::new("MyProgram")?;
433
434        builder.add_file_macos("MyProgram", FileEntry::new_from_data(vec![42], true))?;
435
436        let entries = builder.files.iter_entries().collect::<Vec<_>>();
437        assert_eq!(entries.len(), 2);
438        assert_eq!(entries[1].0, &PathBuf::from("Contents/MacOS/MyProgram"));
439
440        Ok(())
441    }
442
443    #[test]
444    fn add_localized_resources_file() -> Result<()> {
445        let mut builder = MacOsApplicationBundleBuilder::new("MyProgram")?;
446
447        builder.add_localized_resources_file("it", "resource", vec![42])?;
448
449        let entries = builder.files.iter_entries().collect::<Vec<_>>();
450        assert_eq!(entries.len(), 2);
451        assert_eq!(
452            entries[1].0,
453            &PathBuf::from("Contents/Resources/it.lproj/resource")
454        );
455
456        Ok(())
457    }
458}