Skip to main content

browser_automation_cli/native/cdp/
oxide.rs

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