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}