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