Skip to main content

nifty_cli/build/
macos.rs

1//! macOS apps are bundles: a folder ending in `.app` holding the executable and a
2//! description of the app.
3
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use crate::error::CliError;
9
10const EXECUTABLE_DIR: &str = "MacOS";
11/// Identifiers are reverse-DNS names; `local` marks them as not tied to a real domain.
12const BUNDLE_ID_PREFIX: &str = "local.nifty.";
13
14/// Writes `<output>/<name>.app` around `executable`, replacing an earlier build, and
15/// returns its path.
16pub(crate) fn write_app(output: &Path, name: &str, executable: &Path) -> Result<PathBuf, CliError> {
17    let app = output.join(format!("{name}.app"));
18    let failed =
19        |error: io::Error| CliError::new(format!("can't write {}: {error}", app.display()));
20    if app.exists() {
21        fs::remove_dir_all(&app).map_err(failed)?;
22    }
23    let contents = app.join("Contents");
24    let executables = contents.join(EXECUTABLE_DIR);
25    fs::create_dir_all(&executables).map_err(failed)?;
26    fs::write(contents.join("Info.plist"), info_plist(name)).map_err(failed)?;
27    fs::copy(executable, executables.join(name)).map_err(failed)?;
28    Ok(app)
29}
30
31fn info_plist(name: &str) -> String {
32    let display_name = escape_xml(name);
33    let bundle_id = bundle_id(name);
34    format!(
35        r#"<?xml version="1.0" encoding="UTF-8"?>
36<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37<plist version="1.0">
38<dict>
39  <key>CFBundleExecutable</key>
40  <string>{display_name}</string>
41  <key>CFBundleIdentifier</key>
42  <string>{bundle_id}</string>
43  <key>CFBundleInfoDictionaryVersion</key>
44  <string>6.0</string>
45  <key>CFBundleName</key>
46  <string>{display_name}</string>
47  <key>CFBundlePackageType</key>
48  <string>APPL</string>
49  <key>NSHighResolutionCapable</key>
50  <true/>
51</dict>
52</plist>
53"#
54    )
55}
56
57/// Bundle identifiers allow only letters, digits, `-` and `.`.
58fn bundle_id(name: &str) -> String {
59    let suffix: String = name
60        .chars()
61        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
62        .collect();
63    format!("{BUNDLE_ID_PREFIX}{suffix}")
64}
65
66fn escape_xml(text: &str) -> String {
67    let mut escaped = String::with_capacity(text.len());
68    for c in text.chars() {
69        match c {
70            '&' => escaped.push_str("&amp;"),
71            '<' => escaped.push_str("&lt;"),
72            '>' => escaped.push_str("&gt;"),
73            '"' => escaped.push_str("&quot;"),
74            '\'' => escaped.push_str("&apos;"),
75            other => escaped.push(other),
76        }
77    }
78    escaped
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::test_support::scratch_dir;
85
86    #[test]
87    fn writes_a_bundle_around_the_executable() {
88        let dir = scratch_dir("build-macos-bundle");
89        let executable = dir.join("app");
90        fs::write(&executable, "machine code").expect("write a stand-in executable");
91
92        let app =
93            write_app(&dir.join("build"), "todos", &executable).expect("the bundle is written");
94
95        assert_eq!(app, dir.join("build/todos.app"));
96        let bundled = app.join("Contents/MacOS/todos");
97        assert_eq!(
98            fs::read_to_string(bundled).expect("executable"),
99            "machine code"
100        );
101        let plist = fs::read_to_string(app.join("Contents/Info.plist")).expect("plist");
102        assert!(plist.contains("<string>local.nifty.todos</string>"));
103    }
104
105    #[test]
106    fn building_again_replaces_the_previous_bundle() {
107        let dir = scratch_dir("build-macos-rebuild");
108        let executable = dir.join("app");
109        fs::write(&executable, "machine code").expect("write a stand-in executable");
110        let app = write_app(&dir, "app", &executable).expect("first build");
111        fs::write(app.join("Contents/stale"), "").expect("write a stale file");
112
113        write_app(&dir, "app", &executable).expect("second build");
114
115        assert!(!app.join("Contents/stale").exists());
116    }
117
118    #[test]
119    fn names_are_escaped_and_identifiers_kept_to_allowed_characters() {
120        assert_eq!(bundle_id("my_app 2"), "local.nifty.my-app-2");
121        let plist = info_plist("Tom & Jerry");
122        assert!(plist.contains("<string>Tom &amp; Jerry</string>"));
123    }
124}