use {
crate::BundlePackageType,
anyhow::{anyhow, Context, Result},
std::path::{Path, PathBuf},
tugger_file_manifest::{FileData, FileEntry, FileManifest, FileManifestError},
};
#[derive(Clone, Debug)]
pub struct MacOsApplicationBundleBuilder {
files: FileManifest,
}
impl MacOsApplicationBundleBuilder {
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")?;
instance
.set_info_plist_key("CFBundlePackageType", BundlePackageType::App.to_string())
.context("setting CFBundlePackageType")?;
Ok(instance)
}
pub fn files(&self) -> &FileManifest {
&self.files
}
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())
}
pub fn info_plist(&self) -> Result<Option<plist::Dictionary>> {
if let Some(entry) = self.files.get("Contents/Info.plist") {
let data = entry.data.resolve().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)
}
}
pub fn add_file(
&mut self,
path: impl AsRef<Path>,
entry: impl Into<FileEntry>,
) -> Result<(), FileManifestError> {
self.files.add_file_entry(path, entry)
}
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",
FileEntry {
data: data.into(),
executable: false,
},
)?)
}
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
},
)
}
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)
}
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(())
}
pub fn add_icon(&mut self, data: impl Into<FileData>) -> Result<()> {
Ok(self.add_file_resources(
format!(
"{}.icns",
self.bundle_name().context("resolving bundle name")?
),
FileEntry {
data: data.into(),
executable: false,
},
)?)
}
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)
}
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)
}
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,
)
}
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)
}
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)
}
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)
}
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!("{}.app", bundle_name));
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.data.resolve()?)?
.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 {
data: vec![42].into(),
executable: 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",
FileEntry {
data: vec![42].into(),
executable: false,
},
)?;
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(())
}
}