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
12pub 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
145pub 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}