Skip to main content

a3s_use_browser/
lib.rs

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