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
123pub 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}