cortex_runtime/renderer/
chromium.rs1use super::{NavigationResult, RenderContext, Renderer};
4use anyhow::{bail, Context, Result};
5use async_trait::async_trait;
6use chromiumoxide::browser::{Browser, BrowserConfig};
7use chromiumoxide::page::Page;
8use futures::StreamExt;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::Arc;
12use std::time::Instant;
13
14pub fn find_chromium() -> Option<PathBuf> {
16 if let Ok(p) = std::env::var("CORTEX_CHROMIUM_PATH") {
18 let path = PathBuf::from(&p);
19 if path.exists() {
20 return Some(path);
21 }
22 }
23
24 if let Some(home) = dirs::home_dir() {
26 let candidates = if cfg!(target_os = "macos") {
27 vec![
28 home.join(".cortex/chromium/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
29 home.join(".cortex/chromium/chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
30 home.join(".cortex/chromium/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
31 home.join(".cortex/chromium/chrome"),
32 ]
33 } else {
34 vec![
35 home.join(".cortex/chromium/chrome-linux64/chrome"),
36 home.join(".cortex/chromium/chrome"),
37 ]
38 };
39 for c in candidates {
40 if c.exists() {
41 return Some(c);
42 }
43 }
44 }
45
46 if let Ok(path) = which::which("google-chrome") {
48 return Some(path);
49 }
50 if let Ok(path) = which::which("chromium") {
51 return Some(path);
52 }
53 if let Ok(path) = which::which("chromium-browser") {
54 return Some(path);
55 }
56
57 if cfg!(target_os = "macos") {
59 let common = PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
60 if common.exists() {
61 return Some(common);
62 }
63 }
64
65 None
66}
67
68pub struct ChromiumRenderer {
70 browser: Browser,
71 active_count: Arc<AtomicUsize>,
72}
73
74impl ChromiumRenderer {
75 pub async fn new() -> Result<Self> {
77 let chrome_path = find_chromium().context("Chromium not found. Run `cortex install`.")?;
78
79 tracing::info!("launching Chromium at {}", chrome_path.display());
80
81 let data_dir = std::env::temp_dir().join("cortex-chromium-profile");
84 std::fs::create_dir_all(&data_dir).ok();
85
86 for dir_name in ["cortex-chromium-profile", "chromiumoxide-runner"] {
90 let lock = std::env::temp_dir().join(dir_name).join("SingletonLock");
91 if lock.exists() {
92 tracing::info!("removing stale Chrome lock: {}", lock.display());
93 std::fs::remove_file(&lock).ok();
94 }
95 }
96
97 let config = BrowserConfig::builder()
98 .chrome_executable(chrome_path)
99 .new_headless_mode()
100 .arg("--disable-gpu")
101 .arg("--no-sandbox")
102 .arg("--disable-dev-shm-usage")
103 .arg("--disable-extensions")
104 .arg("--disable-background-networking")
105 .arg("--disable-features=TranslateUI")
106 .arg(format!("--user-data-dir={}", data_dir.display()))
107 .build()
108 .map_err(|e| anyhow::anyhow!("failed to build browser config: {e}"))?;
109
110 let (browser, mut handler) = Browser::launch(config)
111 .await
112 .context("failed to launch Chromium")?;
113
114 tokio::spawn(async move {
116 while let Some(event) = handler.next().await {
117 let _ = event;
118 }
119 });
120
121 Ok(Self {
122 browser,
123 active_count: Arc::new(AtomicUsize::new(0)),
124 })
125 }
126}
127
128#[async_trait]
129impl Renderer for ChromiumRenderer {
130 async fn new_context(&self) -> Result<Box<dyn RenderContext>> {
131 let page = self
132 .browser
133 .new_page("about:blank")
134 .await
135 .context("failed to create new page")?;
136
137 self.active_count.fetch_add(1, Ordering::Relaxed);
138
139 Ok(Box::new(ChromiumContext {
140 page,
141 active_count: Arc::clone(&self.active_count),
142 }))
143 }
144
145 async fn shutdown(&self) -> Result<()> {
146 Ok(())
148 }
149
150 fn active_contexts(&self) -> usize {
151 self.active_count.load(Ordering::Relaxed)
152 }
153}
154
155pub struct ChromiumContext {
157 page: Page,
158 active_count: Arc<AtomicUsize>,
159}
160
161#[async_trait]
162impl RenderContext for ChromiumContext {
163 async fn navigate(&mut self, url: &str, timeout_ms: u64) -> Result<NavigationResult> {
164 let start = Instant::now();
165
166 let result = tokio::time::timeout(
167 std::time::Duration::from_millis(timeout_ms),
168 self.page.goto(url),
169 )
170 .await;
171
172 let load_time_ms = start.elapsed().as_millis() as u64;
173
174 match result {
175 Ok(Ok(_response)) => {
176 let _ = tokio::time::timeout(
178 std::time::Duration::from_secs(10),
179 self.page.wait_for_navigation(),
180 )
181 .await;
182
183 let final_url = self
184 .page
185 .url()
186 .await
187 .unwrap_or_default()
188 .map(|u| u.to_string())
189 .unwrap_or_else(|| url.to_string());
190
191 Ok(NavigationResult {
192 final_url,
193 status: 200, redirect_chain: Vec::new(),
195 load_time_ms,
196 })
197 }
198 Ok(Err(e)) => bail!("navigation failed: {e}"),
199 Err(_) => bail!("navigation timed out after {timeout_ms}ms"),
200 }
201 }
202
203 async fn execute_js(&self, script: &str) -> Result<serde_json::Value> {
204 let result = self
205 .page
206 .evaluate(script)
207 .await
208 .context("JS execution failed")?;
209
210 result
211 .into_value()
212 .map_err(|e| anyhow::anyhow!("failed to convert JS result: {e:?}"))
213 }
214
215 async fn get_html(&self) -> Result<String> {
216 let result = self
217 .page
218 .evaluate("document.documentElement.outerHTML")
219 .await
220 .context("failed to get HTML")?;
221
222 let html: String = result
223 .into_value()
224 .map_err(|e| anyhow::anyhow!("failed to convert HTML result: {e:?}"))?;
225
226 Ok(html)
227 }
228
229 async fn get_url(&self) -> Result<String> {
230 let url = self
231 .page
232 .url()
233 .await
234 .context("failed to get URL")?
235 .map(|u| u.to_string())
236 .unwrap_or_default();
237 Ok(url)
238 }
239
240 async fn close(self: Box<Self>) -> Result<()> {
241 self.active_count.fetch_sub(1, Ordering::Relaxed);
242 let _ = self.page.close().await;
243 Ok(())
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[tokio::test]
252 #[ignore] async fn test_chromium_navigate_and_execute_js() {
254 let renderer = ChromiumRenderer::new()
255 .await
256 .expect("failed to create renderer");
257 let mut ctx = renderer
258 .new_context()
259 .await
260 .expect("failed to create context");
261
262 let nav = ctx
264 .navigate("data:text/html,<h1>Hello</h1><p>World</p>", 10000)
265 .await
266 .expect("navigation failed");
267
268 assert!(nav.load_time_ms < 10000);
269
270 let result = ctx
272 .execute_js("document.querySelector('h1').textContent")
273 .await
274 .expect("JS execution failed");
275
276 assert_eq!(result.as_str().unwrap(), "Hello");
277
278 let html = ctx.get_html().await.expect("get_html failed");
280 assert!(html.contains("<h1>Hello</h1>"));
281 assert!(html.contains("<p>World</p>"));
282
283 ctx.close().await.expect("close failed");
285 assert_eq!(renderer.active_contexts(), 0);
286
287 renderer.shutdown().await.expect("shutdown failed");
288 }
289}