forge_browser/
host_bundle.rs1use std::path::{Path, PathBuf};
8
9pub const APP: &str = "Forge Browser.app";
11
12pub const EXECUTABLE_NAME: &str = "Forge Browser";
14
15const EXECUTABLE: &str = "Contents/MacOS/Forge Browser";
17
18pub fn app_support() -> Result<PathBuf, String> {
20 forge_sdk::app_support_dir()
21 .map_err(|why| format!("the app-support directory cannot be resolved: {why}"))
22}
23
24pub fn install_root() -> Result<PathBuf, String> {
27 app_support().map(|dir| dir.join("browser-host"))
28}
29
30pub fn path() -> Result<PathBuf, String> {
37 app_support().map(|dir| dir.join("browser-host").join(env!("CARGO_PKG_VERSION")).join(APP))
38}
39
40pub fn prune_other_versions(sole_instance: &(dyn Fn() -> bool + Sync)) {
54 let Ok(dir) = app_support() else { return };
55 let removed = prune_other_versions_in(
56 &dir.join("browser-host"),
57 env!("CARGO_PKG_VERSION"),
58 sole_instance,
59 );
60 if !removed.is_empty() {
61 tracing::info!(
62 event_name = "browser_host_versions_pruned",
63 "the browser host bundles of other versions are removed: {:?}",
64 removed,
65 );
66 }
67}
68
69fn prune_other_versions_in(
70 root: &Path,
71 keep: &str,
72 sole_instance: &(dyn Fn() -> bool + Sync),
73) -> Vec<PathBuf> {
74 let Ok(entries) = std::fs::read_dir(root) else { return Vec::new() };
75 let mut removed = Vec::new();
76 for entry in entries.flatten() {
77 let path = entry.path();
78 if !path.is_dir() || path.file_name().and_then(|name| name.to_str()) == Some(keep) {
79 continue;
80 }
81 if !sole_instance() {
82 break;
83 }
84 if std::fs::remove_dir_all(&path).is_ok() {
85 removed.push(path);
86 }
87 }
88 removed
89}
90
91pub fn executable() -> Result<PathBuf, String> {
93 path().map(|app| app.join(EXECUTABLE))
94}
95
96pub fn checked_executable() -> Result<PathBuf, String> {
103 check(&path()?)
104}
105
106fn check(app: &Path) -> Result<PathBuf, String> {
107 let exe = app.join(EXECUTABLE);
108 if !exe.is_file() {
109 return Err(format!(
110 "the browser host bundle is not installed at {}; reinstall forge (`just install` from \
111 a checkout, or the release installer)",
112 app.display()
113 ));
114 }
115 let ours = env!("CARGO_PKG_VERSION");
116 let found = version(app).ok_or_else(|| {
117 format!(
118 "the browser host bundle at {} carries no CFBundleVersion, so it cannot be shown to be \
119 this server's; reinstall forge",
120 app.display()
121 )
122 })?;
123 if found != ours {
124 return Err(format!(
125 "the browser host bundle at {} is version {found} and this server is {ours}; restart \
126 forge so both halves run the version they were installed as",
127 app.display()
128 ));
129 }
130 Ok(exe)
131}
132
133fn version(app: &Path) -> Option<String> {
136 let plist = std::fs::read_to_string(app.join("Contents/Info.plist")).ok()?;
137 let after = plist.split("<key>CFBundleVersion</key>").nth(1)?;
138 let value = after.split("<string>").nth(1)?;
139 Some(value.split("</string>").next()?.trim().to_owned())
140}
141
142#[cfg(test)]
143mod tests {
144 use std::path::Path;
145
146 use super::{APP, EXECUTABLE, check, path, prune_other_versions_in};
147
148 #[test]
153 fn the_bundle_sits_beside_forges_own_state_under_its_version() {
154 let path = path().expect("resolves on a normal dev machine");
155 assert!(
156 path.ends_with(format!("browser-host/{}/{APP}", env!("CARGO_PKG_VERSION"))),
157 "a version-keyed bundle, so an install cannot replace the one this server runs: {}",
158 path.display(),
159 );
160 assert!(path.is_absolute(), "an absolute install path: {}", path.display());
161 let exe = path.join(EXECUTABLE);
162 assert_eq!(exe.file_name().and_then(|name| name.to_str()), Some("Forge Browser"));
163 }
164
165 #[test]
169 fn a_bundle_staged_under_another_version_is_pruned() {
170 let dir = tempfile::tempdir().expect("a temp dir");
171 let root = dir.path().join("browser-host");
172 let ours = root.join(env!("CARGO_PKG_VERSION"));
173 let theirs = root.join("0.0.1");
174 staged(&ours, Some(env!("CARGO_PKG_VERSION")));
175 staged(&theirs, Some("0.0.1"));
176 let stray = root.join("README");
177 std::fs::write(&stray, b"not a bundle").expect("the stray file");
178
179 let removed = prune_other_versions_in(&root, env!("CARGO_PKG_VERSION"), &|| true);
180
181 assert_eq!(removed, vec![theirs.clone()], "the other version's bundle is what went");
182 assert!(ours.is_dir(), "this server's own bundle survives");
183 assert!(!theirs.exists(), "and the other version's is gone");
184 assert!(stray.is_file(), "a non-directory entry is not a staged bundle and is left alone");
185 }
186
187 #[test]
192 fn the_shared_check_is_asked_before_each_removal() {
193 let dir = tempfile::tempdir().expect("a temp dir");
194 let root = dir.path().join("browser-host");
195 staged(&root.join("0.0.1"), Some("0.0.1"));
196 staged(&root.join("0.0.2"), Some("0.0.2"));
197 let calls = std::sync::atomic::AtomicUsize::new(0);
198
199 let removed = prune_other_versions_in(&root, env!("CARGO_PKG_VERSION"), &|| {
200 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0
201 });
202
203 assert_eq!(removed.len(), 1, "the first removal ran and the second was refused");
204 assert_eq!(
205 calls.load(std::sync::atomic::Ordering::SeqCst),
206 2,
207 "the check ran once per other-version directory",
208 );
209 let left = std::fs::read_dir(&root)
210 .expect("the root")
211 .flatten()
212 .filter(|entry| entry.path().is_dir())
213 .count();
214 assert_eq!(left, 1, "exactly one other version's bundle survives");
215 }
216
217 #[test]
221 fn a_shared_machine_prunes_nothing() {
222 let dir = tempfile::tempdir().expect("a temp dir");
223 let root = dir.path().join("browser-host");
224 let theirs = root.join("0.0.1");
225 staged(&theirs, Some("0.0.1"));
226
227 let removed = prune_other_versions_in(&root, env!("CARGO_PKG_VERSION"), &|| false);
228
229 assert!(removed.is_empty(), "nothing is removed while a peer is live");
230 assert!(theirs.is_dir(), "the other version's bundle stays");
231 }
232
233 fn staged(app: &Path, version: Option<&str>) {
234 let exe = app.join(EXECUTABLE);
235 std::fs::create_dir_all(exe.parent().expect("the executable's dir")).expect("the dirs");
236 std::fs::write(&exe, b"host").expect("the executable");
237 if let Some(version) = version {
238 std::fs::write(
239 app.join("Contents/Info.plist"),
240 format!(
241 "<key>CFBundleVersion</key>\n\t<string>{version}</string>\n\
242 <key>CFBundleShortVersionString</key>\n\t<string>{version}</string>\n"
243 ),
244 )
245 .expect("the plist");
246 }
247 }
248
249 #[test]
253 fn a_bundle_is_checked_for_presence_and_version() {
254 let dir = tempfile::tempdir().expect("a temp dir");
255 let absent = dir.path().join("absent");
256 let why = check(&absent).expect_err("no bundle");
257 assert!(why.contains("not installed") && why.contains("reinstall"), "{why}");
258
259 let stale = dir.path().join("stale");
260 staged(&stale, Some("0.0.1"));
261 let why = check(&stale).expect_err("another version's bundle");
262 assert!(why.contains("0.0.1") && why.contains(env!("CARGO_PKG_VERSION")), "{why}");
263 assert!(
264 why.contains("restart forge"),
265 "a server that meets another version's bundle is the old half; the remedy is a \
266 restart, not a reinstall: {why}",
267 );
268
269 let unversioned = dir.path().join("unversioned");
270 staged(&unversioned, None);
271 let why = check(&unversioned).expect_err("no version to check");
272 assert!(why.contains("CFBundleVersion") && why.contains("reinstall"), "{why}");
273
274 let ours = dir.path().join("ours");
275 staged(&ours, Some(env!("CARGO_PKG_VERSION")));
276 assert_eq!(
277 check(&ours).expect("this server's own bundle"),
278 ours.join(EXECUTABLE),
279 "the executable inside the bundle is what runs"
280 );
281 }
282}