Skip to main content

a3s_use_browser/
management.rs

1//! Lifecycle and diagnostics for Browser provider runtimes.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use a3s_use_core::UseResult;
8
9use crate::pool::browser_error;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum ManagedBrowser {
14    Chrome,
15    Lightpanda,
16}
17
18impl ManagedBrowser {
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::Chrome => "chrome",
22            Self::Lightpanda => "lightpanda",
23        }
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "kebab-case")]
29pub enum BrowserInstallSource {
30    Environment,
31    System,
32    ManagedCache,
33    Missing,
34    Unsupported,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct BrowserRuntimeStatus {
39    pub browser: ManagedBrowser,
40    pub available: bool,
41    pub source: BrowserInstallSource,
42    pub path: Option<PathBuf>,
43    pub version: Option<String>,
44    pub cache_dir: Option<PathBuf>,
45    pub detail: String,
46}
47
48pub fn browser_status(browser: ManagedBrowser) -> BrowserRuntimeStatus {
49    match browser {
50        ManagedBrowser::Chrome => chrome_status(),
51        ManagedBrowser::Lightpanda => lightpanda_status(),
52    }
53}
54
55pub fn browser_statuses() -> Vec<BrowserRuntimeStatus> {
56    [ManagedBrowser::Chrome, ManagedBrowser::Lightpanda]
57        .into_iter()
58        .map(browser_status)
59        .collect()
60}
61
62pub async fn install_browser(browser: ManagedBrowser) -> UseResult<BrowserRuntimeStatus> {
63    install_or_update_browser(browser, false).await
64}
65
66pub async fn update_browser(browser: ManagedBrowser) -> UseResult<BrowserRuntimeStatus> {
67    install_or_update_browser(browser, true).await
68}
69
70pub async fn repair_browser(browser: ManagedBrowser) -> UseResult<BrowserRuntimeStatus> {
71    let status = browser_status(browser);
72    if status.available {
73        Ok(status)
74    } else {
75        update_browser(browser).await
76    }
77}
78
79async fn install_or_update_browser(
80    browser: ManagedBrowser,
81    force_latest: bool,
82) -> UseResult<BrowserRuntimeStatus> {
83    match browser {
84        ManagedBrowser::Chrome => {
85            if force_latest {
86                crate::chrome::download_latest_chrome().await?;
87            } else {
88                crate::chrome::ensure_chrome().await?;
89            }
90        }
91        ManagedBrowser::Lightpanda => {
92            #[cfg(feature = "lightpanda")]
93            if force_latest {
94                crate::lightpanda::download_latest_lightpanda().await?;
95            } else {
96                crate::lightpanda::ensure_lightpanda().await?;
97            }
98            #[cfg(not(feature = "lightpanda"))]
99            return Err(browser_error(
100                "Lightpanda support is not compiled into this build".to_string(),
101            ));
102        }
103    }
104    let status = browser_status(browser);
105    status.available.then_some(status).ok_or_else(|| {
106        browser_error(format!(
107            "{} installation completed without a usable executable",
108            browser.as_str()
109        ))
110    })
111}
112
113fn chrome_status() -> BrowserRuntimeStatus {
114    let cache_dir = crate::chrome::managed_cache_dir().ok();
115    if let Some(path) =
116        env_executable("A3S_BROWSER_EXECUTABLE").or_else(|| env_executable("CHROME"))
117    {
118        return available_status(
119            ManagedBrowser::Chrome,
120            BrowserInstallSource::Environment,
121            path,
122            cache_dir,
123        );
124    }
125    if let Some(path) = crate::chrome::detect_chrome() {
126        return available_status(
127            ManagedBrowser::Chrome,
128            BrowserInstallSource::System,
129            path,
130            cache_dir,
131        );
132    }
133    if let Ok(path) = crate::chrome::find_managed_chrome() {
134        return available_status(
135            ManagedBrowser::Chrome,
136            BrowserInstallSource::ManagedCache,
137            path,
138            cache_dir,
139        );
140    }
141    missing_status(ManagedBrowser::Chrome, cache_dir, "not installed")
142}
143
144fn lightpanda_status() -> BrowserRuntimeStatus {
145    #[cfg(feature = "lightpanda")]
146    {
147        let cache_dir = crate::lightpanda::managed_cache_dir().ok();
148        if let Some(path) =
149            env_executable("A3S_LIGHTPANDA_EXECUTABLE").or_else(|| env_executable("LIGHTPANDA"))
150        {
151            return available_status(
152                ManagedBrowser::Lightpanda,
153                BrowserInstallSource::Environment,
154                path,
155                cache_dir,
156            );
157        }
158        if let Some(path) = crate::lightpanda::detect_lightpanda() {
159            return available_status(
160                ManagedBrowser::Lightpanda,
161                BrowserInstallSource::System,
162                path,
163                cache_dir,
164            );
165        }
166        if let Ok(path) = crate::lightpanda::find_managed_lightpanda() {
167            return available_status(
168                ManagedBrowser::Lightpanda,
169                BrowserInstallSource::ManagedCache,
170                path,
171                cache_dir,
172            );
173        }
174        missing_status(ManagedBrowser::Lightpanda, cache_dir, "not installed")
175    }
176    #[cfg(not(feature = "lightpanda"))]
177    {
178        BrowserRuntimeStatus {
179            browser: ManagedBrowser::Lightpanda,
180            available: false,
181            source: BrowserInstallSource::Unsupported,
182            path: None,
183            version: None,
184            cache_dir: None,
185            detail: "support is not compiled into this build".to_string(),
186        }
187    }
188}
189
190fn env_executable(name: &str) -> Option<PathBuf> {
191    std::env::var_os(name)
192        .map(PathBuf::from)
193        .filter(|path| path.is_file())
194}
195
196fn available_status(
197    browser: ManagedBrowser,
198    source: BrowserInstallSource,
199    path: PathBuf,
200    cache_dir: Option<PathBuf>,
201) -> BrowserRuntimeStatus {
202    if !is_usable_executable(&path) {
203        return BrowserRuntimeStatus {
204            browser,
205            available: false,
206            source,
207            path: Some(path),
208            version: None,
209            cache_dir,
210            detail: "executable is not runnable; use the repair operation".to_string(),
211        };
212    }
213    let version = (source == BrowserInstallSource::ManagedCache)
214        .then(|| {
215            let cache = cache_dir.as_deref()?;
216            path.strip_prefix(cache)
217                .ok()?
218                .components()
219                .next()?
220                .as_os_str()
221                .to_str()
222                .map(str::to_string)
223        })
224        .flatten();
225    BrowserRuntimeStatus {
226        browser,
227        available: true,
228        source,
229        detail: "ready".to_string(),
230        path: Some(path),
231        version,
232        cache_dir,
233    }
234}
235
236fn is_usable_executable(path: &std::path::Path) -> bool {
237    if !path.is_file() {
238        return false;
239    }
240    #[cfg(unix)]
241    {
242        use std::os::unix::fs::PermissionsExt;
243        std::fs::metadata(path)
244            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
245            .unwrap_or(false)
246    }
247    #[cfg(not(unix))]
248    {
249        true
250    }
251}
252
253fn missing_status(
254    browser: ManagedBrowser,
255    cache_dir: Option<PathBuf>,
256    detail: &str,
257) -> BrowserRuntimeStatus {
258    BrowserRuntimeStatus {
259        browser,
260        available: false,
261        source: BrowserInstallSource::Missing,
262        path: None,
263        version: None,
264        cache_dir,
265        detail: detail.to_string(),
266    }
267}
268
269/// Removes only Browser runtimes installed under the A3S Use data root.
270pub async fn uninstall_managed_browsers() -> UseResult<bool> {
271    let root = crate::chrome::browser_data_root()?;
272    uninstall_managed_browsers_at(&root).await
273}
274
275async fn uninstall_managed_browsers_at(root: &std::path::Path) -> UseResult<bool> {
276    let mut changed = false;
277    for directory in [root.join("chrome"), root.join("lightpanda")] {
278        match tokio::fs::remove_dir_all(&directory).await {
279            Ok(()) => changed = true,
280            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
281            Err(error) => {
282                return Err(browser_error(format!(
283                    "Failed to remove managed Browser runtime '{}': {error}",
284                    directory.display()
285                )))
286            }
287        }
288    }
289    Ok(changed)
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[tokio::test]
297    async fn uninstall_is_idempotent_and_preserves_unowned_files() {
298        let temp = tempfile::tempdir().unwrap();
299        tokio::fs::create_dir_all(temp.path().join("chrome/v1"))
300            .await
301            .unwrap();
302        tokio::fs::create_dir_all(temp.path().join("lightpanda/v1"))
303            .await
304            .unwrap();
305        tokio::fs::write(temp.path().join("keep"), b"unowned")
306            .await
307            .unwrap();
308
309        assert!(uninstall_managed_browsers_at(temp.path()).await.unwrap());
310        assert!(!uninstall_managed_browsers_at(temp.path()).await.unwrap());
311        assert!(tokio::fs::try_exists(temp.path().join("keep"))
312            .await
313            .unwrap());
314    }
315
316    #[cfg(not(feature = "lightpanda"))]
317    #[test]
318    fn unsupported_lightpanda_status_is_explicit_without_the_feature() {
319        let status = browser_status(ManagedBrowser::Lightpanda);
320        assert_eq!(status.source, BrowserInstallSource::Unsupported);
321        assert!(!status.available);
322    }
323}