1use 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
269pub 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}