Skip to main content

browser_automation_cli/native/cdp/
oxide.rs

1//! Chromiumoxide launch + FINALIZE helpers (PRD: Browser::launch only, no connect).
2#![allow(missing_docs)]
3//!
4//! System Chrome/Chromium only — no BrowserFetcher embutido (PRD L56 / L387).
5//! Launch flags come from `build_chrome_args` so proxy/webgpu/extensions are live.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use chromiumoxide::browser::{Browser, BrowserConfig};
12use chromiumoxide::Handler as OxideHandler;
13use tokio::sync::Mutex;
14
15use super::chrome::{build_chrome_args, find_chrome, LaunchOptions};
16
17/// Launch result before the CDP client takes ownership of the handler task.
18pub struct OxideLaunch {
19    pub browser: Browser,
20    pub handler: OxideHandler,
21    pub executable: Option<PathBuf>,
22    pub ws_url: String,
23    /// Temp user-data-dir created for this one-shot (cleanup after FINALIZE).
24    pub temp_user_data_dir: Option<PathBuf>,
25}
26
27/// Launch headless Chrome via chromiumoxide using system or explicit executable.
28///
29/// PROIBIDO: attach/connect to external CDP in MVP. PROIBIDO: BrowserFetcher download automático.
30/// Handler must be polled (via CdpClient::from_browser) for commands to complete.
31pub async fn launch_with_oxide(options: &LaunchOptions) -> Result<OxideLaunch, String> {
32    let chrome_args = build_chrome_args(options)?;
33
34    let mut builder = BrowserConfig::builder();
35
36    let executable = options
37        .executable_path
38        .as_ref()
39        .map(PathBuf::from)
40        .or_else(find_chrome);
41
42    if let Some(ref exe) = executable {
43        builder = builder.chrome_executable(exe);
44    }
45
46    // Headed vs headless: builder tracks product mode; flags also carry --headless=new.
47    // Chromiumoxide refuses extensions in headless product mode — force with_head when loading.
48    let has_extensions = options.extensions.as_ref().is_some_and(|e| !e.is_empty());
49    if !options.headless || has_extensions {
50        builder = builder.with_head();
51    }
52
53    // Register extensions on BrowserConfig so it does NOT inject --disable-extensions.
54    if let Some(ref exts) = options.extensions {
55        if !exts.is_empty() {
56            builder = builder.extensions(exts.clone());
57        }
58    }
59
60    builder = builder.user_data_dir(chrome_args.user_data_dir.clone());
61
62    // Apply full flag set (proxy, webgpu, extensions, sandbox, window-size, …).
63    // Skip flags already owned by BrowserConfig to avoid duplicate conflicts.
64    for a in &chrome_args.args {
65        if a.starts_with("--user-data-dir=") {
66            continue;
67        }
68        if a == "--headless=new" || a == "--headless" || a.starts_with("--headless=") {
69            // BrowserConfig owns headless mode via with_head / default.
70            continue;
71        }
72        // Extension load paths are owned by BrowserConfig.extensions — avoid double --load-extension.
73        if a.starts_with("--load-extension=") || a.starts_with("--disable-extensions-except=") {
74            continue;
75        }
76        builder = builder.arg(a.as_str());
77    }
78
79    let config = builder
80        .build()
81        .map_err(|e| format!("chromiumoxide BrowserConfig: {e}"))?;
82
83    let (browser, handler) = Browser::launch(config)
84        .await
85        .map_err(|e| format!("chromiumoxide Browser::launch: {e}"))?;
86
87    let ws_url = browser.websocket_address().clone();
88
89    Ok(OxideLaunch {
90        browser,
91        handler,
92        executable,
93        ws_url,
94        temp_user_data_dir: chrome_args.temp_user_data_dir,
95    })
96}
97
98/// FINALIZE: close + wait + kill fallback on a shared browser mutex.
99pub async fn finalize_browser(browser: Arc<Mutex<Browser>>) -> Result<(), String> {
100    let mut browser = match Arc::try_unwrap(browser) {
101        Ok(m) => m.into_inner(),
102        Err(shared) => {
103            let mut guard = shared.lock().await;
104            if let Err(e) = guard.close().await {
105                let _ = guard.kill().await;
106                return Err(format!("chromiumoxide close: {e}"));
107            }
108            match tokio::time::timeout(Duration::from_secs(5), guard.wait()).await {
109                Ok(Ok(_)) => {}
110                Ok(Err(e)) => {
111                    let _ = guard.kill().await;
112                    return Err(format!("chromiumoxide wait: {e}"));
113                }
114                Err(_) => {
115                    let _ = guard.kill().await;
116                }
117            }
118            return Ok(());
119        }
120    };
121
122    if let Err(e) = browser.close().await {
123        let _ = browser.kill().await;
124        return Err(format!("chromiumoxide close: {e}"));
125    }
126
127    match tokio::time::timeout(Duration::from_secs(5), browser.wait()).await {
128        Ok(Ok(_)) => {}
129        Ok(Err(e)) => {
130            let _ = browser.kill().await;
131            return Err(format!("chromiumoxide wait: {e}"));
132        }
133        Err(_) => {
134            let _ = browser.kill().await;
135        }
136    }
137
138    Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn launch_options_default_is_headless_path() {
147        let o = LaunchOptions::default();
148        assert!(o.headless);
149    }
150
151    #[test]
152    fn build_args_feed_proxy_into_oxide_flag_list() {
153        let o = LaunchOptions {
154            proxy: Some("http://127.0.0.1:8080".to_string()),
155            ..Default::default()
156        };
157        let args = build_chrome_args(&o).unwrap();
158        assert!(args
159            .args
160            .iter()
161            .any(|a| a == "--proxy-server=http://127.0.0.1:8080"));
162        if let Some(ref dir) = args.temp_user_data_dir {
163            let _ = std::fs::remove_dir_all(dir);
164        }
165    }
166}