pub fn bundle_identifier(publisher: Option<&str>, product: &str) -> String {
let segment = |raw: &str| {
let mapped: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
joined(&mapped)
};
let product = segment(product);
match publisher.map(segment).filter(|p| !p.is_empty()) {
Some(publisher) => format!("shun.{publisher}.{product}"),
None => format!("shun.{product}"),
}
}
fn joined(mapped: &str) -> String {
mapped
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
}
pub fn render_info_plist(
product: &str,
version: &str,
publisher: Option<&str>,
executable_name: &str,
deep_link_schemes: &[String],
) -> String {
let entry = |key: &str, value: &str| {
format!(
" <key>{key}</key>\n <string>{}</string>\n",
xml_escape(value)
)
};
let mut doc = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n<dict>\n",
);
doc.push_str(" <key>CFBundlePackageType</key>\n <string>APPL</string>\n");
doc.push_str(&entry("CFBundleName", product));
doc.push_str(&entry("CFBundleDisplayName", product));
doc.push_str(&entry("CFBundleExecutable", executable_name));
doc.push_str(&entry(
"CFBundleIdentifier",
&bundle_identifier(publisher, product),
));
doc.push_str(&entry("CFBundleShortVersionString", version));
doc.push_str(&entry("CFBundleVersion", version));
if !deep_link_schemes.is_empty() {
doc.push_str(" <key>CFBundleURLTypes</key>\n <array>\n <dict>\n");
doc.push_str(&format!(
" <key>CFBundleURLName</key>\n <string>{}</string>\n",
xml_escape(&bundle_identifier(publisher, product))
));
doc.push_str(" <key>CFBundleURLSchemes</key>\n <array>\n");
for scheme in deep_link_schemes {
doc.push_str(&format!(
" <string>{}</string>\n",
xml_escape(scheme)
));
}
doc.push_str(" </array>\n </dict>\n </array>\n");
}
doc.push_str(" <key>LSMinimumSystemVersion</key>\n <string>10.13</string>\n");
doc.push_str("</dict>\n</plist>\n");
doc
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifier_segments_are_sanitized() {
assert_eq!(
bundle_identifier(Some("celestia-island"), "ShunDemo Test"),
"shun.celestia-island.ShunDemo-Test"
);
assert_eq!(bundle_identifier(None, "Widget"), "shun.Widget");
assert_eq!(bundle_identifier(Some("///"), "App"), "shun.App");
}
#[test]
fn plist_declares_launch_identity() {
let plist = render_info_plist(
"Shun & Demo",
"0.1.0",
Some("celestia-island"),
"shun-demo",
&["shundemo".to_string()],
);
assert!(plist.contains("<key>CFBundlePackageType</key>"));
assert!(plist.contains("<string>APPL</string>"));
assert!(plist.contains("<string>Shun & Demo</string>"));
assert!(plist.contains("<key>CFBundleExecutable</key>\n <string>shun-demo</string>"));
assert!(plist.contains(
"<key>CFBundleIdentifier</key>\n <string>shun.celestia-island.Shun-Demo</string>"
));
assert!(
plist.contains("<key>CFBundleShortVersionString</key>\n <string>0.1.0</string>")
);
assert!(plist.contains("<key>CFBundleURLTypes</key>"));
assert!(plist.contains("<string>shundemo</string>"));
assert!(plist.ends_with("</dict>\n</plist>\n"));
}
#[test]
fn plist_without_deep_links_omits_url_types() {
let plist = render_info_plist("Widget", "1.0", None, "widget", &[]);
assert!(!plist.contains("CFBundleURLTypes"));
}
}