1use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use crate::error::CliError;
9
10const EXECUTABLE_DIR: &str = "MacOS";
11const BUNDLE_ID_PREFIX: &str = "local.nifty.";
13
14pub(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
57fn 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("&"),
71 '<' => escaped.push_str("<"),
72 '>' => escaped.push_str(">"),
73 '"' => escaped.push_str("""),
74 '\'' => escaped.push_str("'"),
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 & Jerry</string>"));
123 }
124}