Skip to main content

boost/
browser.rs

1//! Lazy shared Chromium instance used by the browser MCP tools.
2//!
3//! The browser is launched on first use and kept alive for the lifetime of the
4//! Boost server process. We hold its `Handler` future in a detached background
5//! task so DevTools events keep flowing while we drive pages from the tools.
6//!
7//! Failure mode: if Chromium isn't installed and `chromiumoxide` can't find one
8//! on PATH, every call returns a clean MCP error explaining how to fix it.
9//! Tools never panic.
10
11use std::sync::Arc;
12
13use chromiumoxide::browser::{Browser, BrowserConfig};
14use chromiumoxide::page::Page;
15use futures::StreamExt;
16use once_cell::sync::OnceCell;
17use tokio::sync::Mutex;
18
19#[derive(Clone)]
20pub struct BrowserManager {
21    inner: Arc<Inner>,
22}
23
24struct Inner {
25    cell: OnceCell<Arc<Mutex<Browser>>>,
26    init: Mutex<()>,
27}
28
29impl Default for BrowserManager {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl BrowserManager {
36    pub fn new() -> Self {
37        Self {
38            inner: Arc::new(Inner {
39                cell: OnceCell::new(),
40                init: Mutex::new(()),
41            }),
42        }
43    }
44
45    /// Return a handle to the singleton browser, launching it on first call.
46    pub async fn browser(&self) -> Result<Arc<Mutex<Browser>>, String> {
47        if let Some(b) = self.inner.cell.get() {
48            return Ok(b.clone());
49        }
50        let _g = self.inner.init.lock().await;
51        if let Some(b) = self.inner.cell.get() {
52            return Ok(b.clone());
53        }
54        let config = BrowserConfig::builder()
55            .build()
56            .map_err(|e| format!("browser config: {e}"))?;
57        let (browser, mut handler) = Browser::launch(config).await.map_err(|e| {
58            format!(
59                "launch chromium: {e}. Install Chrome/Chromium or set CHROME env var to its path."
60            )
61        })?;
62
63        // Drain DevTools events forever so pages remain responsive.
64        tokio::spawn(async move {
65            while let Some(_evt) = handler.next().await {
66                // Ignore — keeping the channel alive is enough.
67            }
68        });
69
70        let arc = Arc::new(Mutex::new(browser));
71        let _ = self.inner.cell.set(arc.clone());
72        Ok(arc)
73    }
74
75    /// Open `url`, wait for `load`, and return the live page.
76    pub async fn open(&self, url: &str) -> Result<Page, String> {
77        let browser_arc = self.browser().await?;
78        let browser = browser_arc.lock().await;
79        let page = browser
80            .new_page(url)
81            .await
82            .map_err(|e| format!("new_page({url}): {e}"))?;
83        page.wait_for_navigation()
84            .await
85            .map_err(|e| format!("wait_for_navigation: {e}"))?;
86        Ok(page)
87    }
88}