Skip to main content

forge_browser/
host_bundle.rs

1//! Where the server's own CEF bundle lives.
2//!
3//! The bundle is staged per server version beside forge's machine-local state
4//! by `scripts/stage-browser-host.sh`; it is fixed to that directory, never
5//! to the launch directory (hard rule 14).
6
7use std::path::{Path, PathBuf};
8
9/// The bundle's directory name, as the staging script writes it.
10pub const APP: &str = "Forge Browser.app";
11
12/// The bundle's executable name, as its Info.plist names it.
13pub const EXECUTABLE_NAME: &str = "Forge Browser";
14
15/// The bundle's executable, as its Info.plist names it.
16const EXECUTABLE: &str = "Contents/MacOS/Forge Browser";
17
18/// The app-support base forge's machine-local state sits under.
19pub 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
24/// The directory every staged version's bundle sits under, so a reaper can
25/// match a host of ANY staged version rather than only this build's.
26pub fn install_root() -> Result<PathBuf, String> {
27    app_support().map(|dir| dir.join("browser-host"))
28}
29
30/// The installed `Forge Browser.app`, under this server's own version.
31///
32/// **One bundle per version, so an install cannot replace the one a running
33/// server uses.** Staging writes the new version's directory beside the old
34/// one, and a process always resolves its own - the ring layout and the
35/// launch arguments are a contract between the two halves.
36pub fn path() -> Result<PathBuf, String> {
37    app_support().map(|dir| dir.join("browser-host").join(env!("CARGO_PKG_VERSION")).join(APP))
38}
39
40/// Delete every staged bundle that is not this server's own version.
41///
42/// Staging never removes an older directory - the server still running on it
43/// needs it - so the boot of the new server is the one moment that knows the
44/// old tree is finished with. Each is a CEF tree, so leaving them would grow
45/// the app-support dir by hundreds of megabytes per release.
46///
47/// **`sole_instance` is asked again before every removal**, not once at the
48/// boot: a peer on another config dir that takes its lock after the first
49/// check makes the tree shared again, and deleting its bundle is the
50/// mid-task loss this prune exists beside. A machine-wide lock every boot
51/// holds would close the window fully; this narrows it to the gap between
52/// the check and one `remove_dir_all`.
53pub 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
91/// The executable inside the bundle that runs as a host.
92pub fn executable() -> Result<PathBuf, String> {
93    path().map(|app| app.join(EXECUTABLE))
94}
95
96/// The host executable, checked against this server's own version.
97///
98/// **A bundle another version staged is refused rather than run**: the ring
99/// layout and the launch arguments are a contract between the two, and an
100/// update that replaced the server but not the bundle would hand it a shape
101/// it does not know.
102pub 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
133/// The bundle's `CFBundleVersion`, read out of its `Info.plist`. The
134/// staging script writes the key's value as the next `<string>`.
135fn 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    /// **The install location is the app-support tree, not a launch-relative
149    /// path, and it is keyed by this server's version.** The bundle and the
150    /// profiles are machine-local state, and a checkout's cwd must not change
151    /// where the server looks.
152    #[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    /// **Every other version's staged bundle is pruned at boot.** Nothing
166    /// else would ever remove them and each is a CEF tree; the booting
167    /// server is the one process whose version must survive.
168    #[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    /// **The shared check is asked before EVERY removal**, not hoisted out
188    /// of the loop: a peer that appears between two removals keeps its
189    /// bundle. The closure answers true once, so a hoisted check would keep
190    /// removing and a per-removal one stops after the first.
191    #[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    /// **A shared machine prunes nothing.** The peer check is asked again
218    /// before each removal, so a peer that appeared after the boot's first
219    /// look keeps its bundle.
220    #[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    /// **A missing or stale bundle names what to do about it**, and a bundle
250    /// of this server's own version is accepted. The negative controls are
251    /// the point: each refusal must name its own remedy, not a bare path.
252    #[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}