Skip to main content

cortex_runtime/renderer/
chromium.rs

1//! Chromium-based renderer using chromiumoxide.
2
3use 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
14/// Find the Chromium binary path.
15pub fn find_chromium() -> Option<PathBuf> {
16    // 1. CORTEX_CHROMIUM_PATH env
17    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    // 2. ~/.cortex/chromium/
25    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    // 3. System PATH
47    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    // 4. Common macOS locations
58    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
68/// Chromium-based renderer.
69pub struct ChromiumRenderer {
70    browser: Browser,
71    active_count: Arc<AtomicUsize>,
72}
73
74impl ChromiumRenderer {
75    /// Create a new ChromiumRenderer, launching a headless Chromium instance.
76    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        // Use a separate user data dir so headless Chrome doesn't conflict
82        // with any running Chrome instance.
83        let data_dir = std::env::temp_dir().join("cortex-chromium-profile");
84        std::fs::create_dir_all(&data_dir).ok();
85
86        // Clean up stale lock files from previous Chrome crashes.
87        // chromiumoxide uses its own runner dir; our --user-data-dir may be
88        // overridden, so clean both locations.
89        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        // Spawn the handler task
115        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        // Browser is dropped when ChromiumRenderer is dropped
147        Ok(())
148    }
149
150    fn active_contexts(&self) -> usize {
151        self.active_count.load(Ordering::Relaxed)
152    }
153}
154
155/// A single Chromium page context.
156pub 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                // Wait for page to be loaded (with timeout to prevent hangs)
177                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, // chromiumoxide doesn't easily expose status
194                    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] // Requires Chromium to be installed
253    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        // Navigate to a data URL
263        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        // Execute JS to extract heading text
271        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        // Get HTML
279        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        // Close context
284        ctx.close().await.expect("close failed");
285        assert_eq!(renderer.active_contexts(), 0);
286
287        renderer.shutdown().await.expect("shutdown failed");
288    }
289}