Skip to main content

browser_control/sidecar/
assets.rs

1//! Cache-dir management for the embedded sidecar assets.
2//!
3//! The sidecar JS + `package.json` are bundled into the Rust binary via
4//! `include_str!` at compile time. At runtime we materialize them into a
5//! cache directory under the user's data dir (e.g.
6//! `~/.cache/browser-control/playwright-sidecar-<version>/`) and run
7//! `bun install` / `npm install` there once. Subsequent spawns reuse the
8//! cache.
9//!
10//! The cache dir name encodes the `playwright-core` version so different
11//! versions don't share `node_modules`.
12
13use anyhow::{Context, Result};
14use std::path::PathBuf;
15
16/// Bundled sidecar JS source.
17const SIDECAR_JS: &str = include_str!("../../assets/playwright-sidecar/sidecar.mjs");
18
19/// Bundled `package.json` template. The `{version}` placeholder is
20/// replaced at runtime with the requested `playwright-core` version.
21const PACKAGE_JSON: &str = include_str!("../../assets/playwright-sidecar/package.json");
22
23/// Compute the per-version cache directory and ensure it exists with the
24/// current sidecar assets written into it.
25pub async fn ensure_sidecar_dir(playwright_version: &str) -> Result<PathBuf> {
26    let dir = cache_dir(playwright_version)?;
27    tokio::fs::create_dir_all(&dir)
28        .await
29        .with_context(|| format!("creating sidecar cache directory at {dir:?}"))?;
30
31    // Write the JS verbatim each time so a `cargo install` upgrade picks
32    // up changes to the bundled script automatically.
33    let js_path = dir.join("sidecar.mjs");
34    tokio::fs::write(&js_path, SIDECAR_JS)
35        .await
36        .with_context(|| format!("writing {js_path:?}"))?;
37
38    // Substitute the requested Playwright version into the package.json
39    // template before writing. The template ships with the default
40    // version baked in.
41    let pkg_json = patch_version(PACKAGE_JSON, playwright_version);
42    let pkg_path = dir.join("package.json");
43    tokio::fs::write(&pkg_path, pkg_json)
44        .await
45        .with_context(|| format!("writing {pkg_path:?}"))?;
46
47    Ok(dir)
48}
49
50fn cache_dir(playwright_version: &str) -> Result<PathBuf> {
51    let project = directories::ProjectDirs::from("dev", "browser-control", "browser-control")
52        .ok_or_else(|| anyhow::anyhow!("could not determine user cache directory"))?;
53    let base = project.cache_dir().to_path_buf();
54    Ok(base.join(format!("playwright-sidecar-{playwright_version}")))
55}
56
57/// Replace `"playwright-core": "<X.Y.Z>"` in the template with the
58/// requested version. The template ships with a known default; this is a
59/// targeted substitution so an unrelated version-shaped string elsewhere
60/// in the file isn't accidentally rewritten.
61fn patch_version(template: &str, version: &str) -> String {
62    // Find the `"playwright-core":` key and rewrite the value string.
63    let key = "\"playwright-core\":";
64    let Some(idx) = template.find(key) else {
65        return template.to_string();
66    };
67    // Locate the opening quote of the value after the key.
68    let after_key = &template[idx + key.len()..];
69    let Some(open_off) = after_key.find('"') else {
70        return template.to_string();
71    };
72    let after_open = &after_key[open_off + 1..];
73    let Some(close_off) = after_open.find('"') else {
74        return template.to_string();
75    };
76    let mut out = String::with_capacity(template.len() + version.len());
77    out.push_str(&template[..idx + key.len() + open_off + 1]);
78    out.push_str(version);
79    out.push_str(&after_open[close_off..]);
80    out
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn patch_version_rewrites_known_template() {
89        let t = r#"{
90  "name": "x",
91  "dependencies": {
92    "playwright-core": "1.49.1"
93  }
94}"#;
95        let out = patch_version(t, "1.55.0");
96        assert!(out.contains("\"playwright-core\": \"1.55.0\""));
97        assert!(!out.contains("1.49.1"));
98    }
99
100    #[test]
101    fn patch_version_keeps_input_when_key_missing() {
102        let t = r#"{ "name": "x" }"#;
103        let out = patch_version(t, "1.55.0");
104        assert_eq!(out, t);
105    }
106}