use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chromiumoxide::browser::Browser as CdpBrowser;
use chromiumoxide::cdp::browser_protocol::network::CookieParam;
use futures::StreamExt;
use serde_json::Value;
use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
use crate::browser::launcher::{Browser, LaunchResult};
use crate::browser::media::ColorScheme;
use crate::browser::node_bridge::NodeBridgePage;
use crate::browser::playwright_driver_page::{PlaywrightConnect, PlaywrightDriverPage};
use crate::browser::storage_state::{StorageState, StorageStateInput};
use crate::core::engine::{EngineAdapter, EngineType};
use crate::downloads::{attach_downloads, DownloadSetting};
use crate::fingerprint::apply::{apply_fingerprint, ApplyOptions};
use crate::fingerprint::profile::FingerprintProfile;
use crate::playwright::{DriverOptions, PlaywrightDriver};
#[derive(Debug, Clone)]
pub struct ConnectOptions {
pub engine: EngineType,
pub cdp_endpoint: Option<String>,
pub ws_endpoint: Option<String>,
pub slow_mo: u64,
pub timeout: Option<Duration>,
pub protocol_timeout: Option<Duration>,
pub seed_cookies: Vec<Value>,
pub storage_state: Option<StorageStateInput>,
pub verbose: bool,
pub node_executable: Option<PathBuf>,
pub node_working_dir: Option<PathBuf>,
pub downloads: DownloadSetting,
}
impl Default for ConnectOptions {
fn default() -> Self {
Self {
engine: EngineType::Chromiumoxide,
cdp_endpoint: None,
ws_endpoint: None,
slow_mo: 0,
timeout: None,
protocol_timeout: None,
seed_cookies: Vec::new(),
storage_state: None,
verbose: false,
node_executable: None,
node_working_dir: None,
downloads: DownloadSetting::Off,
}
}
}
impl ConnectOptions {
pub fn chromiumoxide() -> Self {
Self::default()
}
pub fn playwright() -> Self {
Self {
engine: EngineType::Playwright,
..Self::default()
}
}
pub fn puppeteer() -> Self {
Self {
engine: EngineType::Puppeteer,
..Self::default()
}
}
pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.cdp_endpoint = Some(endpoint.into());
self
}
pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.ws_endpoint = Some(endpoint.into());
self
}
pub fn slow_mo(mut self, milliseconds: u64) -> Self {
self.slow_mo = milliseconds;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
self.protocol_timeout = Some(timeout);
self
}
pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
self.seed_cookies = cookies;
self
}
pub fn storage_state(mut self, state: impl Into<StorageStateInput>) -> Self {
self.storage_state = Some(state.into());
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
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, directory: impl Into<PathBuf>) -> Self {
self.node_working_dir = Some(directory.into());
self
}
pub fn downloads(mut self, downloads: impl Into<DownloadSetting>) -> Self {
self.downloads = downloads.into();
self
}
pub(crate) fn endpoint(&self) -> Result<&str, anyhow::Error> {
match (&self.cdp_endpoint, &self.ws_endpoint) {
(Some(endpoint), None) | (None, Some(endpoint)) if !endpoint.is_empty() => Ok(endpoint),
_ => Err(anyhow::anyhow!(
"connect_browser requires exactly one of cdp_endpoint or ws_endpoint"
)),
}
}
}
pub async fn connect_browser(options: ConnectOptions) -> Result<LaunchResult, anyhow::Error> {
connect_browser_with(options, AttachSettings::default()).await
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct AttachSettings<'a> {
pub(crate) fingerprint: Option<&'a FingerprintProfile>,
pub(crate) color_scheme: Option<&'a ColorScheme>,
}
pub(crate) fn refuse_unappliable_fingerprint(
engine: EngineType,
fingerprint: Option<&FingerprintProfile>,
) -> Result<(), anyhow::Error> {
if fingerprint.is_some() && engine != EngineType::Chromiumoxide {
return Err(anyhow::anyhow!(
"the {engine} engine cannot apply a fingerprint profile yet; \
use EngineType::Chromiumoxide, or apply the profile from the \
JavaScript package, which drives Playwright and Puppeteer directly"
));
}
Ok(())
}
pub(crate) async fn connect_browser_with(
options: ConnectOptions,
settings: AttachSettings<'_>,
) -> Result<LaunchResult, anyhow::Error> {
let endpoint = options.endpoint()?.to_string();
let storage_state = options
.storage_state
.as_ref()
.map(StorageStateInput::load)
.transpose()?;
refuse_unappliable_fingerprint(options.engine, settings.fingerprint)?;
if options.verbose {
tracing::info!(engine = %options.engine, %endpoint, "connecting to browser");
}
match options.engine {
EngineType::Chromiumoxide => {
connect_chromiumoxide(options, endpoint, settings, storage_state).await
}
EngineType::Playwright | EngineType::Puppeteer => {
let engine = options.engine;
let timeout = options.timeout;
let seed_cookies = options.seed_cookies.clone();
crate::downloads::normalize_download_options(options.downloads.clone())
.map(|_| crate::downloads::supported_engine(engine))
.transpose()
.map_err(|error| anyhow::anyhow!("{error}"))?;
let connection = connect_node_engine(options, endpoint, settings.color_scheme);
let page = if let Some(timeout) = timeout {
tokio::time::timeout(timeout, connection)
.await
.map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
} else {
connection.await?
};
if let Some(mut state) = storage_state {
state.cookies.extend(seed_cookies);
let value = serde_json::to_value(state)?;
if let Err(error) = page.adapter().restore_storage_state(value).await {
page.close().await;
return Err(error.into());
}
}
Ok(LaunchResult::attached(
Browser {
engine,
user_data_dir: PathBuf::new(),
headless: false,
},
page.into_adapter(),
None,
))
}
EngineType::Fantoccini => Err(anyhow::anyhow!(
"fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
)),
}
}
enum NodeEngine {
Driver(PlaywrightDriverPage),
Bridge(NodeBridgePage),
}
impl NodeEngine {
fn adapter(&self) -> &dyn EngineAdapter {
match self {
Self::Driver(page) => page,
Self::Bridge(page) => page,
}
}
fn into_adapter(self) -> Arc<dyn EngineAdapter> {
match self {
Self::Driver(page) => Arc::new(page),
Self::Bridge(page) => Arc::new(page),
}
}
async fn close(&self) {
match self {
Self::Driver(page) => {
let _ = page.close().await;
}
Self::Bridge(page) => {
let _ = page.close().await;
}
}
}
}
async fn connect_node_engine(
options: ConnectOptions,
endpoint: String,
color_scheme: Option<&ColorScheme>,
) -> Result<NodeEngine, anyhow::Error> {
if options.engine == EngineType::Playwright {
let driver = PlaywrightDriver::launch(DriverOptions {
node: options.node_executable.clone(),
working_dir: options.node_working_dir.clone(),
verbose: options.verbose,
})
.await;
match driver {
Ok(driver) => {
let connect = PlaywrightConnect {
driver: DriverOptions::default(),
endpoint,
slow_mo: options.slow_mo,
timeout: options.timeout,
seed_cookies: options.seed_cookies.clone(),
color_scheme: color_scheme.map(|cs| cs.as_str().to_string()),
};
let page = PlaywrightDriverPage::connect_with(driver, connect).await?;
return Ok(NodeEngine::Driver(page));
}
Err(error) => {
tracing::info!(%error, "Playwright driver unavailable; using the Node bridge");
}
}
}
Ok(NodeEngine::Bridge(
NodeBridgePage::connect(options, color_scheme).await?,
))
}
async fn connect_chromiumoxide(
options: ConnectOptions,
endpoint: String,
settings: AttachSettings<'_>,
storage_state: Option<StorageState>,
) -> Result<LaunchResult, anyhow::Error> {
let connection = CdpBrowser::connect(endpoint);
let (browser, mut handler) = if let Some(timeout) = options.timeout {
tokio::time::timeout(timeout, connection)
.await
.map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
} else {
connection.await?
};
let handler_task = tokio::spawn(async move {
while let Some(event) = handler.next().await {
if let Err(error) = event {
tracing::debug!(%error, "chromiumoxide handler event error");
}
}
});
if !options.seed_cookies.is_empty() {
let cookies = options
.seed_cookies
.iter()
.cloned()
.map(serde_json::from_value::<CookieParam>)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| anyhow::anyhow!("invalid seed cookie: {error}"))?;
browser.set_cookies(cookies).await?;
}
let page = match pick_foreground_page(browser.pages().await?).await {
Some(page) => page,
None => browser.new_page("about:blank").await?,
};
let engine = options.engine;
let adapter = ChromiumoxidePage::new(page, browser, handler_task, PathBuf::new());
if let Some(mut state) = storage_state {
state.cookies.extend(options.seed_cookies.clone());
if let Err(error) = adapter
.restore_storage_state(serde_json::to_value(state)?)
.await
{
let _ = adapter.close().await;
return Err(error.into());
}
}
if let Some(profile) = settings.fingerprint {
apply_fingerprint(&adapter, profile, ApplyOptions::default()).await?;
if options.verbose {
tracing::info!("Fingerprint profile applied");
}
}
if let Some(color_scheme) = settings.color_scheme {
if let Err(error) = adapter.set_color_scheme(Some(color_scheme)).await {
if options.verbose {
tracing::warn!(%error, "could not set color scheme");
}
}
}
let downloads = attach_downloads(engine, &adapter, options.downloads.clone())
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
Ok(LaunchResult::attached(
Browser {
engine,
user_data_dir: PathBuf::new(),
headless: false,
},
Arc::new(adapter),
downloads,
))
}
async fn pick_foreground_page(pages: Vec<chromiumoxide::Page>) -> Option<chromiumoxide::Page> {
for page in &pages {
let state = page
.evaluate("document.visibilityState")
.await
.ok()
.and_then(|result| result.into_value::<String>().ok());
if state.as_deref() == Some("visible") {
return Some(page.clone());
}
}
pages.into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn connect_options_carry_a_download_setting() {
assert!(matches!(
ConnectOptions::default().downloads,
DownloadSetting::Off
));
assert!(matches!(
ConnectOptions::chromiumoxide().downloads(true).downloads,
DownloadSetting::On
));
}
#[tokio::test]
async fn connect_playwright_refuses_downloads_it_cannot_manage() {
let options = ConnectOptions::playwright()
.cdp_endpoint("http://127.0.0.1:9222")
.downloads(true);
let error = connect_browser(options).await.unwrap_err();
assert!(
error
.to_string()
.contains("managed downloads are not supported"),
"unexpected message: {error}"
);
}
#[test]
fn connect_options_builders_preserve_endpoints_and_cookies() {
let cookies = vec![json!({"name": "SID", "value": "saved", "domain": ".example.com"})];
let options = ConnectOptions::playwright()
.cdp_endpoint("http://127.0.0.1:9222")
.slow_mo(25)
.seed_cookies(cookies.clone())
.node_working_dir("../js");
assert_eq!(options.engine, EngineType::Playwright);
assert_eq!(options.endpoint().unwrap(), "http://127.0.0.1:9222");
assert_eq!(options.slow_mo, 25);
assert_eq!(options.seed_cookies, cookies);
assert_eq!(options.node_working_dir, Some(PathBuf::from("../js")));
}
#[test]
fn connect_options_require_exactly_one_endpoint() {
assert!(ConnectOptions::default().endpoint().is_err());
assert!(ConnectOptions::puppeteer()
.cdp_endpoint("http://127.0.0.1:9222")
.ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id")
.endpoint()
.is_err());
}
}