use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chromiumoxide::browser::{Browser as CdpBrowser, BrowserConfig, HeadlessMode};
use futures::StreamExt;
use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
use crate::browser::media::ColorScheme;
use crate::browser::node_bridge::NodeBridgePage;
use crate::core::constants::CHROME_ARGS;
use crate::core::engine::{EngineAdapter, EngineType};
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub engine: EngineType,
pub user_data_dir: Option<PathBuf>,
pub headless: bool,
pub slow_mo: u64,
pub verbose: bool,
pub args: Vec<String>,
pub channel: Option<String>,
pub executable_path: Option<PathBuf>,
pub color_scheme: Option<ColorScheme>,
pub launch_timeout: Option<Duration>,
pub sandbox: bool,
pub node_executable: Option<PathBuf>,
pub node_working_dir: Option<PathBuf>,
}
impl Default for LaunchOptions {
fn default() -> Self {
Self {
engine: EngineType::Chromiumoxide,
user_data_dir: None,
headless: false,
slow_mo: 0,
verbose: false,
args: Vec::new(),
channel: None,
executable_path: None,
color_scheme: None,
launch_timeout: None,
sandbox: true,
node_executable: None,
node_working_dir: None,
}
}
}
impl LaunchOptions {
pub fn engine(mut self, engine: EngineType) -> Self {
self.engine = engine;
if engine == EngineType::Playwright && self.slow_mo == 0 {
self.slow_mo = 150;
}
self
}
pub fn chromiumoxide() -> Self {
Self {
engine: EngineType::Chromiumoxide,
..Default::default()
}
}
pub fn fantoccini() -> Self {
Self {
engine: EngineType::Fantoccini,
..Default::default()
}
}
pub fn playwright() -> Self {
Self {
engine: EngineType::Playwright,
slow_mo: 150,
..Default::default()
}
}
pub fn puppeteer() -> Self {
Self {
engine: EngineType::Puppeteer,
..Default::default()
}
}
pub fn headless(mut self, headless: bool) -> Self {
self.headless = headless;
self
}
pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.user_data_dir = Some(dir.into());
self
}
pub fn slow_mo(mut self, ms: u64) -> Self {
self.slow_mo = ms;
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
pub fn channel(mut self, channel: impl Into<String>) -> Self {
self.channel = Some(channel.into());
self
}
pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
self.executable_path = Some(executable_path.into());
self
}
pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
self.color_scheme = Some(color_scheme);
self
}
pub fn launch_timeout(mut self, timeout: Duration) -> Self {
self.launch_timeout = Some(timeout);
self
}
pub fn sandbox(mut self, sandbox: bool) -> Self {
self.sandbox = sandbox;
self
}
pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
self.node_executable = Some(executable.into());
self
}
pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.node_working_dir = Some(dir.into());
self
}
pub fn all_chrome_args(&self) -> Vec<String> {
let mut all_args: Vec<String> = CHROME_ARGS.iter().map(|s| s.to_string()).collect();
all_args.extend(self.args.clone());
all_args
}
pub fn get_user_data_dir(&self) -> PathBuf {
if let Some(ref dir) = self.user_data_dir {
dir.clone()
} else {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".browser-commander")
.join(format!("{}-data", self.engine))
}
}
}
#[derive(Debug, Clone)]
pub struct Browser {
pub engine: EngineType,
pub user_data_dir: PathBuf,
pub headless: bool,
}
pub struct LaunchResult {
pub browser: Browser,
pub page: Arc<dyn EngineAdapter>,
}
impl std::fmt::Debug for LaunchResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchResult")
.field("browser", &self.browser)
.field("page", &"<dyn EngineAdapter>")
.finish()
}
}
pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
if options.verbose {
tracing::info!("Launching browser with {} engine...", options.engine);
}
let user_data_dir = options.get_user_data_dir();
std::fs::create_dir_all(&user_data_dir)?;
match options.engine {
EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
EngineType::Playwright | EngineType::Puppeteer => {
launch_node_bridge(options, user_data_dir).await
}
EngineType::Fantoccini => Err(anyhow::anyhow!(
"fantoccini engine launch is not yet implemented; \
connect to an existing WebDriver session or use EngineType::Chromiumoxide"
)),
}
}
async fn launch_node_bridge(
options: LaunchOptions,
user_data_dir: PathBuf,
) -> Result<LaunchResult, anyhow::Error> {
let engine = options.engine;
let headless = options.headless;
let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
Ok(LaunchResult {
browser: Browser {
engine,
user_data_dir,
headless,
},
page: Arc::new(adapter),
})
}
async fn launch_chromiumoxide(
options: LaunchOptions,
user_data_dir: PathBuf,
) -> Result<LaunchResult, anyhow::Error> {
let headless_mode = if options.headless {
HeadlessMode::New
} else {
HeadlessMode::False
};
let mut builder = BrowserConfig::builder()
.user_data_dir(&user_data_dir)
.headless_mode(headless_mode)
.args(options.all_chrome_args());
if !options.sandbox {
builder = builder.no_sandbox();
}
if let Some(ref executable_path) = options.executable_path {
builder = builder.chrome_executable(executable_path);
}
if let Some(timeout) = options.launch_timeout {
builder = builder.launch_timeout(timeout);
}
let config = builder
.build()
.map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
let (browser, mut handler) = CdpBrowser::launch(config)
.await
.map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
let handler_task = tokio::spawn(async move {
while let Some(event) = handler.next().await {
if let Err(err) = event {
tracing::debug!(error = %err, "chromiumoxide handler event error");
}
}
});
let page = browser
.new_page("about:blank")
.await
.map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
let engine = options.engine;
let headless = options.headless;
let color_scheme = options.color_scheme.clone();
let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
if let Some(ref cs) = color_scheme {
if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
if options.verbose {
tracing::warn!(error = %err, "could not set color scheme");
}
}
}
if !headless {
if let Err(err) = adapter.bring_to_front().await {
if options.verbose {
tracing::debug!(error = %err, "bring_to_front failed");
}
}
}
if options.verbose {
tracing::info!("Browser launched with {} engine", engine);
}
Ok(LaunchResult {
browser: Browser {
engine,
user_data_dir,
headless,
},
page: Arc::new(adapter),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_options_default() {
let options = LaunchOptions::default();
assert_eq!(options.engine, EngineType::Chromiumoxide);
assert!(!options.headless);
assert_eq!(options.slow_mo, 0);
assert!(!options.verbose);
assert!(options.args.is_empty());
assert!(options.channel.is_none());
assert!(options.executable_path.is_none());
assert!(options.node_executable.is_none());
assert!(options.node_working_dir.is_none());
}
#[test]
fn launch_options_builder() {
let options = LaunchOptions::chromiumoxide()
.headless(true)
.slow_mo(100)
.verbose(true)
.with_args(vec!["--custom-arg".to_string()]);
assert_eq!(options.engine, EngineType::Chromiumoxide);
assert!(options.headless);
assert_eq!(options.slow_mo, 100);
assert!(options.verbose);
assert_eq!(options.args, vec!["--custom-arg"]);
}
#[test]
fn launch_options_fantoccini() {
let options = LaunchOptions::fantoccini();
assert_eq!(options.engine, EngineType::Fantoccini);
}
#[test]
fn launch_options_playwright() {
let options = LaunchOptions::playwright();
assert_eq!(options.engine, EngineType::Playwright);
assert_eq!(options.slow_mo, 150);
}
#[test]
fn launch_options_puppeteer() {
let options = LaunchOptions::puppeteer();
assert_eq!(options.engine, EngineType::Puppeteer);
}
#[test]
fn launch_options_node_bridge_configuration() {
let options = LaunchOptions::playwright()
.node_executable("/custom/node")
.node_working_dir("/project/js")
.channel("chrome-beta")
.executable_path("/opt/google/chrome-beta");
assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
assert_eq!(options.channel.as_deref(), Some("chrome-beta"));
assert_eq!(
options.executable_path,
Some(PathBuf::from("/opt/google/chrome-beta"))
);
}
#[test]
fn all_chrome_args_includes_defaults() {
let options = LaunchOptions::default();
let args = options.all_chrome_args();
assert!(args.contains(&"--disable-infobars".to_string()));
assert!(args.contains(&"--no-first-run".to_string()));
}
#[test]
fn all_chrome_args_includes_custom() {
let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
let args = options.all_chrome_args();
assert!(args.contains(&"--custom".to_string()));
}
#[test]
fn get_user_data_dir_uses_custom() {
let options = LaunchOptions::default().user_data_dir("/custom/path");
assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
}
#[test]
fn get_user_data_dir_creates_default() {
let options = LaunchOptions::default();
let dir = options.get_user_data_dir();
assert!(dir.to_string_lossy().contains("browser-commander"));
assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
}
#[tokio::test]
async fn launch_fantoccini_is_unimplemented() {
let options = LaunchOptions::fantoccini();
let err = launch_browser(options).await.unwrap_err();
assert!(err.to_string().contains("fantoccini"));
}
#[tokio::test]
async fn launch_playwright_reports_missing_node_executable() {
let options = LaunchOptions::playwright()
.headless(true)
.node_executable("browser-commander-missing-node");
let err = launch_browser(options).await.unwrap_err();
assert!(err.to_string().contains("failed to start Node.js bridge"));
}
}