Skip to main content

a3s_use_browser/
lib.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Duration;
4
5use a3s_use_core::{Artifact, DomainDiagnostic, Readiness};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10pub use a3s_use_core::{UseError, UseResult};
11
12/// Source-tree root of the Browser driver and its packaged assets.
13///
14/// Installed products should use release-packaged paths. This is a development
15/// fallback for hosts consuming the complete Browser repository through Git.
16pub fn source_driver_root() -> PathBuf {
17    Path::new(env!("CARGO_MANIFEST_DIR")).join("../browser-driver")
18}
19
20#[cfg(feature = "chrome")]
21mod chrome;
22#[cfg(feature = "chrome")]
23mod chrome_install;
24#[cfg(feature = "chrome")]
25mod cleanup;
26#[cfg(feature = "chrome")]
27mod install;
28#[cfg(feature = "chrome")]
29mod management;
30#[cfg(feature = "chrome")]
31mod pool;
32#[cfg(feature = "chrome")]
33mod renderer;
34#[cfg(feature = "chrome")]
35mod session;
36
37#[cfg(all(test, feature = "chrome", not(windows)))]
38mod test_support {
39    use std::sync::OnceLock;
40
41    use tokio::sync::{Mutex, MutexGuard};
42
43    pub(crate) const CHROME_OPERATION_TIMEOUT_MS: u64 = 30_000;
44
45    pub(crate) async fn lock_chrome_integration_test() -> MutexGuard<'static, ()> {
46        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
47        LOCK.get_or_init(|| Mutex::new(())).lock().await
48    }
49}
50
51#[cfg(feature = "lightpanda")]
52mod lightpanda;
53#[cfg(feature = "lightpanda")]
54mod lightpanda_renderer;
55
56#[cfg(feature = "chrome")]
57pub use chrome::{detect_chrome, ensure_chrome};
58#[cfg(feature = "chrome")]
59pub use management::{
60    browser_status, browser_statuses, install_browser, repair_browser, uninstall_managed_browsers,
61    update_browser, BrowserInstallSource, BrowserRuntimeStatus, ManagedBrowser,
62};
63#[cfg(feature = "chrome")]
64pub use pool::{BrowserBackend, BrowserPool, BrowserPoolConfig, BrowserProvider};
65#[cfg(feature = "chrome")]
66pub use session::{
67    BrowserActionResult, BrowserSessionInfo, BrowserSessions, BrowserSnapshot, OpenSessionRequest,
68    SnapshotElement,
69};
70
71#[cfg(feature = "lightpanda")]
72pub use lightpanda::{detect_lightpanda, ensure_lightpanda};
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "kebab-case")]
76pub enum WaitCondition {
77    Load,
78    DomContentLoaded,
79    NetworkIdle { idle_ms: u64 },
80    Selector { css: String, timeout_ms: u64 },
81    Delay { ms: u64 },
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct RenderRequest {
87    pub url: Url,
88    pub timeout_ms: u64,
89    pub wait: WaitCondition,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub user_agent: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub screenshot_path: Option<PathBuf>,
94}
95
96impl RenderRequest {
97    pub fn new(url: Url) -> Self {
98        Self {
99            url,
100            timeout_ms: 30_000,
101            wait: WaitCondition::DomContentLoaded,
102            user_agent: None,
103            screenshot_path: None,
104        }
105    }
106
107    pub fn timeout(&self) -> Duration {
108        Duration::from_millis(self.timeout_ms)
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct RenderedPage {
115    pub requested_url: Url,
116    pub final_url: Url,
117    pub status: Option<u16>,
118    pub content_type: Option<String>,
119    pub html: String,
120    pub elapsed_ms: u64,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub artifacts: Vec<Artifact>,
123}
124
125#[async_trait]
126pub trait PageRenderer: Send + Sync {
127    async fn render(&self, request: RenderRequest) -> UseResult<RenderedPage>;
128}
129
130#[derive(Clone)]
131pub struct BrowserRuntime {
132    renderer: Arc<dyn PageRenderer>,
133}
134
135impl BrowserRuntime {
136    pub fn new(renderer: Arc<dyn PageRenderer>) -> Self {
137        Self { renderer }
138    }
139
140    pub async fn render(&self, request: RenderRequest) -> UseResult<RenderedPage> {
141        self.renderer.render(request).await
142    }
143}
144
145/// Initial provider used until the proven Chrome implementation is extracted
146/// from A3S Search.
147pub struct UnavailableRenderer;
148
149#[async_trait]
150impl PageRenderer for UnavailableRenderer {
151    async fn render(&self, _request: RenderRequest) -> UseResult<RenderedPage> {
152        Err(UseError::new(
153            "use.browser.runtime_missing",
154            "No compatible browser provider is configured.",
155        )
156        .with_suggestion("Run 'a3s install use/browser' or configure a system browser."))
157    }
158}
159
160pub fn doctor() -> DomainDiagnostic {
161    #[cfg(feature = "chrome")]
162    {
163        let statuses = browser_statuses();
164        if let Some(status) = statuses.iter().find(|status| status.available) {
165            return DomainDiagnostic {
166                domain: "browser".to_string(),
167                readiness: Readiness::Ready,
168                provider: Some(status.browser.as_str().to_string()),
169                version: status.version.clone(),
170                path: status.path.clone(),
171                message: format!("The {} browser provider is ready.", status.browser.as_str()),
172                suggestions: Vec::new(),
173            };
174        }
175        DomainDiagnostic {
176            domain: "browser".to_string(),
177            readiness: Readiness::Missing,
178            provider: None,
179            version: None,
180            path: None,
181            message: "No compatible browser provider was found.".to_string(),
182            suggestions: vec!["Run 'a3s install use/browser'.".to_string()],
183        }
184    }
185    #[cfg(not(feature = "chrome"))]
186    match discover_system_browser() {
187        Some(path) => DomainDiagnostic {
188            domain: "browser".to_string(),
189            readiness: Readiness::Ready,
190            provider: Some("system".to_string()),
191            version: None,
192            path: Some(path),
193            message: "A compatible system browser is available.".to_string(),
194            suggestions: Vec::new(),
195        },
196        None => DomainDiagnostic {
197            domain: "browser".to_string(),
198            readiness: Readiness::Missing,
199            provider: None,
200            version: None,
201            path: None,
202            message: "No compatible system browser was found.".to_string(),
203            suggestions: vec!["Run 'a3s install use/browser'.".to_string()],
204        },
205    }
206}
207
208pub fn discover_system_browser() -> Option<PathBuf> {
209    #[cfg(feature = "chrome")]
210    {
211        detect_chrome()
212    }
213    #[cfg(not(feature = "chrome"))]
214    {
215        if let Some(path) = std::env::var_os("A3S_BROWSER_EXECUTABLE").map(PathBuf::from) {
216            if executable(&path) {
217                return Some(path);
218            }
219        }
220        let candidates = if cfg!(target_os = "macos") {
221            vec![
222                PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
223                PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
224            ]
225        } else {
226            Vec::new()
227        };
228        if let Some(path) = candidates.into_iter().find(|path| executable(path)) {
229            return Some(path);
230        }
231        let path = std::env::var_os("PATH")?;
232        for directory in std::env::split_paths(&path) {
233            for name in ["google-chrome", "chromium", "chromium-browser"] {
234                let candidate = directory.join(name);
235                if executable(&candidate) {
236                    return Some(candidate);
237                }
238            }
239        }
240        None
241    }
242}
243
244#[cfg(not(feature = "chrome"))]
245fn executable(path: &Path) -> bool {
246    if !path.is_file() {
247        return false;
248    }
249    #[cfg(unix)]
250    {
251        use std::os::unix::fs::PermissionsExt;
252        std::fs::metadata(path)
253            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
254            .unwrap_or(false)
255    }
256    #[cfg(not(unix))]
257    {
258        true
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    struct FakeRenderer;
267
268    #[async_trait]
269    impl PageRenderer for FakeRenderer {
270        async fn render(&self, request: RenderRequest) -> UseResult<RenderedPage> {
271            Ok(RenderedPage {
272                requested_url: request.url.clone(),
273                final_url: request.url,
274                status: Some(200),
275                content_type: Some("text/html".to_string()),
276                html: "<main>fixture</main>".to_string(),
277                elapsed_ms: 1,
278                artifacts: Vec::new(),
279            })
280        }
281    }
282
283    #[tokio::test]
284    async fn renderer_is_injectable_without_a_cli_or_service() {
285        let runtime = BrowserRuntime::new(Arc::new(FakeRenderer));
286        let page = runtime
287            .render(RenderRequest::new(
288                Url::parse("https://example.com").unwrap(),
289            ))
290            .await
291            .unwrap();
292        assert_eq!(page.status, Some(200));
293        assert!(page.html.contains("fixture"));
294    }
295
296    #[test]
297    fn public_runtime_is_send_and_sync() {
298        fn assert_send_sync<T: Send + Sync>() {}
299        assert_send_sync::<BrowserRuntime>();
300    }
301}