use std::sync::Arc;
use crate::backend::{AnyBrowser, AnyPage, BackendKind};
use crate::context::BrowserContext;
use crate::error::{FerriError, Result};
use rustc_hash::FxHashMap as HashMap;
pub const DEFAULT_VIEWPORT_WIDTH: i64 = 1280;
pub const DEFAULT_VIEWPORT_HEIGHT: i64 = 720;
pub use crate::console_message::ConsoleMessage;
pub use crate::context::DialogEvent;
pub use crate::network::Request;
#[derive(Clone)]
pub struct ContextLogHandles {
pub console: std::sync::Arc<tokio::sync::RwLock<Vec<ConsoleMessage>>>,
pub network: std::sync::Arc<tokio::sync::RwLock<Vec<Request>>>,
pub dialog: std::sync::Arc<tokio::sync::RwLock<Vec<DialogEvent>>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionKey {
pub instance: Arc<str>,
pub context: Arc<str>,
}
impl SessionKey {
#[must_use]
pub fn parse(raw: &str) -> Self {
if let Some((inst, ctx)) = raw.split_once(':') {
SessionKey {
instance: Arc::from(inst),
context: Arc::from(ctx),
}
} else if raw == "default" {
SessionKey {
instance: Arc::from("default"),
context: Arc::from("default"),
}
} else {
SessionKey {
instance: Arc::from("default"),
context: Arc::from(raw),
}
}
}
#[must_use]
pub fn to_composite(&self) -> String {
format!("{}:{}", self.instance, self.context)
}
}
struct BrowserInstance {
browser: AnyBrowser,
contexts: HashMap<String, BrowserContext>,
generation: u64,
}
#[derive(Clone)]
pub struct PageOpenPlan {
pub browser: AnyBrowser,
pub viewport: Option<crate::options::ViewportConfig>,
pub browser_context_id: Option<String>,
}
impl BrowserInstance {
fn context(&self, name: &str) -> Result<&BrowserContext> {
self
.contexts
.get(name)
.ok_or_else(|| FerriError::invalid_argument("context", format!("'{name}' not found in this instance")))
}
fn context_mut(&mut self, name: &str) -> &mut BrowserContext {
self
.contexts
.entry(name.to_string())
.or_insert_with(|| BrowserContext::new(name.to_string()))
}
fn context_mut_checked(&mut self, name: &str) -> Result<&mut BrowserContext> {
self
.contexts
.get_mut(name)
.ok_or_else(|| FerriError::invalid_argument("context", format!("'{name}' not found")))
}
fn remove_context(&mut self, name: &str) {
self.contexts.remove(name);
}
}
pub type InstanceArgsFn = Box<dyn Fn(&str) -> Vec<String> + Send + Sync>;
pub type InstanceResolverFn = Box<dyn Fn(&str) -> Option<ConnectMode> + Send + Sync>;
pub struct BrowserState {
instances: HashMap<String, BrowserInstance>,
instance_generation_counter: u64,
chromium_path: String,
connect_mode: ConnectMode,
backend_kind: BackendKind,
pub extra_args: Vec<String>,
instance_args_fn: Option<InstanceArgsFn>,
instance_resolver_fn: Option<InstanceResolverFn>,
pub headless: bool,
pub user_data_dir: Option<String>,
pub default_viewport: Option<crate::options::ViewportConfig>,
close_reason: Option<String>,
pub context_events: Arc<std::sync::Mutex<HashMap<String, crate::events::ContextEventEmitter>>>,
pub record_video: Arc<std::sync::Mutex<HashMap<String, crate::options::RecordVideoOptions>>>,
pub context_options: Arc<std::sync::Mutex<HashMap<String, crate::options::BrowserContextOptions>>>,
pub connected: Arc<std::sync::atomic::AtomicBool>,
pub storage_state_hydrated: Arc<std::sync::Mutex<rustc_hash::FxHashSet<String>>>,
pub persistent_context: bool,
}
#[derive(Clone)]
pub enum ConnectMode {
Launch,
ConnectUrl(String),
AutoConnect {
channel: String,
user_data_dir: Option<String>,
},
}
impl BrowserState {
#[must_use]
pub fn with_plan(connect_mode: ConnectMode, plan: crate::options::LaunchPlan) -> Self {
let chromium_path = if let Some(path) = plan.executable_path {
path
} else {
match plan.kind {
crate::options::BrowserKind::Firefox => std::env::var("FIREFOX_PATH")
.or_else(|_| detect_firefox().map_err(|_| std::env::VarError::NotPresent))
.unwrap_or_else(|_| resolve_chromium(plan.headless)),
_ => resolve_chromium(plan.headless),
}
};
Self {
instances: HashMap::default(),
instance_generation_counter: 0,
chromium_path,
connect_mode,
backend_kind: plan.backend,
extra_args: plan.args,
instance_args_fn: None,
instance_resolver_fn: None,
headless: plan.headless,
user_data_dir: plan.user_data_dir,
default_viewport: plan.default_viewport,
close_reason: None,
context_events: Arc::new(std::sync::Mutex::new(HashMap::default())),
record_video: Arc::new(std::sync::Mutex::new(HashMap::default())),
context_options: Arc::new(std::sync::Mutex::new(HashMap::default())),
connected: Arc::new(std::sync::atomic::AtomicBool::new(false)),
storage_state_hydrated: Arc::new(std::sync::Mutex::new(rustc_hash::FxHashSet::default())),
persistent_context: false,
}
}
#[must_use]
pub fn backend_kind(&self) -> BackendKind {
self.backend_kind
}
#[must_use]
pub fn claim_storage_state_hydration(&self, composite_key: &str) -> bool {
let mut set = match self.storage_state_hydrated.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
set.insert(composite_key.to_string())
}
pub fn set_context_options(&self, composite_key: &str, opts: crate::options::BrowserContextOptions) {
if let Some(ref rv) = opts.record_video {
self.set_record_video(composite_key, rv.clone());
}
let mut map = match self.context_options.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
map.insert(composite_key.to_string(), opts);
}
#[must_use]
pub fn get_context_options(&self, composite_key: &str) -> Option<crate::options::BrowserContextOptions> {
let map = self.context_options.lock().ok()?;
map.get(composite_key).cloned()
}
pub fn set_record_video(&self, composite_key: &str, opts: crate::options::RecordVideoOptions) {
let mut map = match self.record_video.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
map.insert(composite_key.to_string(), opts);
}
#[must_use]
pub fn get_record_video(&self, composite_key: &str) -> Option<crate::options::RecordVideoOptions> {
let map = self.record_video.lock().ok()?;
map.get(composite_key).cloned()
}
#[must_use]
pub fn get_or_create_context_events(&self, key: &str) -> crate::events::ContextEventEmitter {
let mut map = match self.context_events.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
map
.entry(key.to_string())
.or_insert_with(crate::events::ContextEventEmitter::new)
.clone()
}
pub fn set_close_reason(&mut self, reason: String) {
self.close_reason = Some(reason);
}
#[must_use]
pub fn close_reason(&self) -> Option<&str> {
self.close_reason.as_deref()
}
pub fn set_instance_args_fn(&mut self, f: InstanceArgsFn) {
self.instance_args_fn = Some(f);
}
pub fn set_instance_resolver_fn(&mut self, f: InstanceResolverFn) {
self.instance_resolver_fn = Some(f);
}
async fn launch_browser(&self, all_extra: &[String]) -> Result<AnyBrowser> {
Ok(match self.backend_kind {
BackendKind::CdpPipe => {
use crate::backend::cdp::{CdpBrowser, pipe::PipeTransport};
let flags = chrome_flags(self.headless, all_extra);
let browser = match &self.user_data_dir {
Some(dir) => {
CdpBrowser::<PipeTransport>::launch_with_flags_in_dir(
&self.chromium_path,
&flags,
std::path::Path::new(dir),
)
.await?
},
None => CdpBrowser::<PipeTransport>::launch_with_flags(&self.chromium_path, &flags).await?,
};
AnyBrowser::CdpPipe(browser)
},
BackendKind::CdpRaw => {
use crate::backend::cdp::{CdpBrowser, ws::WsTransport};
let flags = chrome_flags(self.headless, all_extra);
let browser = match &self.user_data_dir {
Some(dir) => {
Box::pin(CdpBrowser::<WsTransport>::launch_with_flags_in_dir(
&self.chromium_path,
&flags,
std::path::Path::new(dir),
))
.await?
},
None => CdpBrowser::<WsTransport>::launch_with_flags(&self.chromium_path, &flags).await?,
};
AnyBrowser::CdpRaw(browser)
},
BackendKind::WebKit => {
use crate::backend::webkit::{LaunchConfig, WebKitBrowser};
let config = LaunchConfig {
headless: self.headless,
..LaunchConfig::default()
};
AnyBrowser::WebKit(Box::pin(WebKitBrowser::launch(&config)).await?)
},
BackendKind::Bidi => {
use crate::backend::bidi::BidiBrowser;
let mut flags = all_extra.to_vec();
if self.headless {
flags.push("--headless".into());
}
AnyBrowser::Bidi(Box::pin(BidiBrowser::launch_with_flags(&self.chromium_path, &flags)).await?)
},
})
}
pub async fn ensure_instance(&mut self, instance_name: &str) -> Result<()> {
if self.instances.contains_key(instance_name) {
return Ok(());
}
let resolved_mode = self.instance_resolver_fn.as_ref().and_then(|f| f(instance_name));
let mut all_extra = self.extra_args.clone();
if let Some(ref f) = self.instance_args_fn {
all_extra.extend(f(instance_name));
}
if !all_extra.iter().any(|a| a.starts_with("--window-size")) {
if let Some(ref vp) = self.default_viewport {
all_extra.push(format!("--window-size={},{}", vp.width, vp.height));
}
}
let effective_mode = resolved_mode.as_ref().unwrap_or(&self.connect_mode);
let browser = match effective_mode {
ConnectMode::ConnectUrl(url) => {
use crate::backend::cdp::{CdpBrowser, ws::WsTransport};
let ws_url = if url.starts_with("ws://") || url.starts_with("wss://") {
url.clone()
} else {
discover_ws_from_http(url).await?
};
AnyBrowser::CdpRaw(Box::pin(CdpBrowser::<WsTransport>::connect(&ws_url)).await?)
},
ConnectMode::AutoConnect { channel, user_data_dir } => {
use crate::backend::cdp::{CdpBrowser, ws::WsTransport};
let ws_url = discover_chrome_ws(channel, user_data_dir.as_deref())?;
AnyBrowser::CdpRaw(Box::pin(CdpBrowser::<WsTransport>::connect(&ws_url)).await?)
},
ConnectMode::Launch => self.launch_browser(&all_extra).await?,
};
let mut inst = BrowserInstance {
browser,
contexts: HashMap::default(),
generation: 0,
};
let is_connect = matches!(
effective_mode,
ConnectMode::ConnectUrl(_) | ConnectMode::AutoConnect { .. }
);
if is_connect {
let existing_pages = Box::pin(inst.browser.pages()).await.unwrap_or_default();
let ctx = inst.context_mut("default");
for page in existing_pages {
page.attach_listeners(ctx.console_log.clone(), ctx.network_log.clone(), ctx.dialog_log.clone());
ctx.pages.push(page);
}
}
inst.generation = self.next_instance_generation();
self.instances.insert(instance_name.to_string(), inst);
self.connected.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
pub async fn ensure_browser(&mut self) -> Result<()> {
Box::pin(self.ensure_instance("default")).await
}
pub async fn connect_to_url(&mut self, instance_name: &str, url: &str) -> Result<usize> {
use crate::backend::cdp::{CdpBrowser, ws::WsTransport};
self.instances.remove(instance_name);
let ws_url = if url.starts_with("ws://") || url.starts_with("wss://") {
url.to_string()
} else {
discover_ws_from_http(url).await?
};
let browser = AnyBrowser::CdpRaw(Box::pin(CdpBrowser::<WsTransport>::connect(&ws_url)).await?);
let mut inst = BrowserInstance {
browser,
contexts: HashMap::default(),
generation: 0,
};
let existing_pages = Box::pin(inst.browser.pages()).await.unwrap_or_default();
let ctx = inst.context_mut("default");
let page_count = existing_pages.len();
for page in existing_pages {
page.attach_listeners(ctx.console_log.clone(), ctx.network_log.clone(), ctx.dialog_log.clone());
ctx.pages.push(page);
}
inst.generation = self.next_instance_generation();
self.instances.insert(instance_name.to_string(), inst);
self.connected.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(page_count)
}
pub async fn connect_auto(
&mut self,
instance_name: &str,
channel: &str,
user_data_dir: Option<&str>,
) -> Result<usize> {
if let Some(resolved) = self.resolve_via_instance_fn(instance_name) {
return self.connect_with_resolved_mode(instance_name, resolved).await;
}
let ws_url = discover_chrome_ws(channel, user_data_dir)?;
Box::pin(self.connect_to_url(instance_name, &ws_url)).await
}
fn resolve_via_instance_fn(&self, instance_name: &str) -> Option<ConnectMode> {
let resolver = self.instance_resolver_fn.as_ref()?;
if let Some(mode) = resolver(instance_name) {
return Some(mode);
}
if let Some(prefix) = instance_name.split(':').next()
&& prefix != instance_name
{
return resolver(prefix);
}
None
}
async fn connect_with_resolved_mode(&mut self, instance_name: &str, mode: ConnectMode) -> Result<usize> {
match mode {
ConnectMode::ConnectUrl(url) => Box::pin(self.connect_to_url(instance_name, &url)).await,
ConnectMode::AutoConnect { channel, user_data_dir } => {
let ws_url = discover_chrome_ws(&channel, user_data_dir.as_deref())?;
Box::pin(self.connect_to_url(instance_name, &ws_url)).await
},
ConnectMode::Launch => Err(FerriError::Backend(format!(
"Instance resolver returned Launch mode for '{instance_name}', expected ConnectUrl"
))),
}
}
fn instance(&self, name: &str) -> Result<&BrowserInstance> {
self.instances.get(name).ok_or_else(|| {
FerriError::invalid_argument(
"instance",
format!("'{name}' not found. It will be created on first use."),
)
})
}
pub(crate) fn default_browser(&self) -> Option<&AnyBrowser> {
self.instances.get("default").map(|i| &i.browser)
}
fn instance_mut(&mut self, name: &str) -> Result<&mut BrowserInstance> {
self
.instances
.get_mut(name)
.ok_or_else(|| FerriError::invalid_argument("instance", format!("'{name}' not found")))
}
pub async fn open_page(&mut self, context: &str, url: &str) -> Result<AnyPage> {
let key = SessionKey::parse(context);
Box::pin(self.open_page_keyed(&key, url)).await
}
pub fn page_open_plan(&self, key: &SessionKey) -> Result<PageOpenPlan> {
let inst = self.instance(&key.instance)?;
let browser_context_id = if &*key.context == "default" {
None
} else {
inst
.contexts
.get(&*key.context)
.and_then(|ctx| ctx.cdp_context_id.clone())
};
Ok(PageOpenPlan {
browser: inst.browser.clone(),
viewport: self.default_viewport.clone(),
browser_context_id,
})
}
pub fn register_opened_page(
&mut self,
key: &SessionKey,
page: AnyPage,
browser_context_id: Option<String>,
) -> Result<()> {
let composite = key.to_composite();
let context_events = self.get_or_create_context_events(&composite);
let inst = self.instance_mut(&key.instance)?;
let ctx = inst.context_mut(&key.context);
if let Some(id) = browser_context_id {
ctx.cdp_context_id = Some(id);
}
page.attach_listeners(ctx.console_log.clone(), ctx.network_log.clone(), ctx.dialog_log.clone());
let mut rx = page.events().subscribe();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
if let crate::events::PageEvent::PageError(err) = event {
context_events.emit(crate::events::ContextEvent::WebError(err));
}
}
});
ctx.pages.push(page);
ctx.active_page_idx = ctx.pages.len() - 1;
Ok(())
}
pub async fn open_page_keyed(&mut self, key: &SessionKey, url: &str) -> Result<AnyPage> {
if !self.instances.contains_key(&*key.instance) {
Box::pin(self.ensure_instance(&key.instance)).await?;
}
let plan = self.page_open_plan(key)?;
let (page, cdp_ctx_id) = if &*key.context == "default" {
(
Box::pin(
plan
.browser
.new_page(url, plan.browser_context_id.as_deref(), plan.viewport.as_ref()),
)
.await?,
None,
)
} else if let Some(existing_ctx_id) = plan.browser_context_id.clone() {
(
Box::pin(
plan
.browser
.new_page(url, Some(&existing_ctx_id), plan.viewport.as_ref()),
)
.await?,
Some(existing_ctx_id),
)
} else {
let ctx_id = plan.browser.new_context(None).await?;
let p = Box::pin(plan.browser.new_page(url, Some(&ctx_id), plan.viewport.as_ref())).await?;
(p, Some(ctx_id))
};
self.register_opened_page(key, page.clone(), cdp_ctx_id)?;
Ok(page)
}
pub fn active_page(&self, context: &str) -> Result<&AnyPage> {
let key = SessionKey::parse(context);
let inst = self.instance(&key.instance)?;
let ctx = inst.context(&key.context)?;
ctx
.active_page()
.ok_or_else(|| FerriError::invalid_argument("context", format!("no pages in context '{context}'")))
}
pub fn context(&self, context: &str) -> Result<&BrowserContext> {
let key = SessionKey::parse(context);
let inst = self.instance(&key.instance)?;
inst.context(&key.context)
}
pub fn context_mut_checked(&mut self, context: &str) -> Result<&mut BrowserContext> {
let key = SessionKey::parse(context);
let inst = self.instance_mut(&key.instance)?;
inst.context_mut_checked(&key.context)
}
pub async fn remove_context(&mut self, context: &str) {
let key = SessionKey::parse(context);
if let Some(inst) = self.instances.get_mut(&*key.instance) {
if let Ok(ctx) = inst.context(&key.context) {
if let Some(ref ctx_id) = ctx.cdp_context_id {
let _ = inst.browser.dispose_context(ctx_id).await;
}
}
inst.remove_context(&key.context);
}
}
pub fn select_page(&mut self, context: &str, page_idx: usize) -> Result<()> {
let key = SessionKey::parse(context);
let inst = self.instance_mut(&key.instance)?;
let ctx = inst.context_mut_checked(&key.context)?;
if page_idx >= ctx.pages.len() {
return Err(FerriError::Backend(format!(
"Page index {page_idx} out of range (context '{context}' has {} pages)",
ctx.pages.len()
)));
}
ctx.active_page_idx = page_idx;
Ok(())
}
pub fn close_page(&mut self, context: &str, page_idx: usize) -> Result<()> {
let key = SessionKey::parse(context);
let inst = self.instance_mut(&key.instance)?;
let ctx = inst.context_mut_checked(&key.context)?;
if ctx.pages.len() <= 1 {
return Err(FerriError::invalid_argument(
"page",
"Cannot close the last page in a context",
));
}
if page_idx >= ctx.pages.len() {
return Err(FerriError::Backend(format!("Page index {page_idx} out of range")));
}
ctx.pages.remove(page_idx);
if ctx.active_page_idx >= ctx.pages.len() {
ctx.active_page_idx = ctx.pages.len() - 1;
}
Ok(())
}
pub async fn list_contexts(&self) -> Vec<ContextInfo> {
let mut result = Vec::new();
for (inst_name, inst) in &self.instances {
for (ctx_name, ctx) in &inst.contexts {
let mut pages = Vec::new();
for (i, page) in ctx.pages.iter().enumerate() {
let url = page.url().await.ok().flatten().unwrap_or_default();
let title = page.title().await.ok().flatten().unwrap_or_default();
pages.push(PageInfo {
index: i,
url,
title,
active: i == ctx.active_page_idx,
});
}
let name = if inst_name == "default" {
ctx_name.clone()
} else {
format!("{inst_name}:{ctx_name}")
};
result.push(ContextInfo {
name,
instance: inst_name.clone(),
context: ctx_name.clone(),
pages,
});
}
}
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub fn set_ref_map(&self, context: &str, ref_map: HashMap<String, i64>) {
let key = SessionKey::parse(context);
if let Some(inst) = self.instances.get(&*key.instance) {
if let Some(ctx) = inst.contexts.get(&*key.context) {
ctx.ref_map.store(std::sync::Arc::new(ref_map));
}
}
}
#[must_use]
pub fn ref_map(&self, context: &str) -> HashMap<String, i64> {
let key = SessionKey::parse(context);
self
.instances
.get(&*key.instance)
.and_then(|inst| inst.contexts.get(&*key.context))
.map(|c| (**c.ref_map.load()).clone())
.unwrap_or_default()
}
#[must_use]
pub fn ref_map_handle(&self, context: &str) -> Option<std::sync::Arc<arc_swap::ArcSwap<HashMap<String, i64>>>> {
let key = SessionKey::parse(context);
self
.instances
.get(&*key.instance)
.and_then(|inst| inst.contexts.get(&*key.context))
.map(|c| std::sync::Arc::clone(&c.ref_map))
}
#[must_use]
pub fn log_handles(&self, context: &str) -> Option<ContextLogHandles> {
let key = SessionKey::parse(context);
self
.instances
.get(&*key.instance)
.and_then(|inst| inst.contexts.get(&*key.context))
.map(|ctx| ContextLogHandles {
console: std::sync::Arc::clone(&ctx.console_log),
network: std::sync::Arc::clone(&ctx.network_log),
dialog: std::sync::Arc::clone(&ctx.dialog_log),
})
}
pub async fn console_messages(
&self,
context: &str,
level: Option<&str>,
limit: usize,
) -> Result<Vec<ConsoleMessage>> {
let key = SessionKey::parse(context);
let inst = self.instance(&key.instance)?;
let ctx = inst.context(&key.context)?;
Ok(ctx.console_messages(level, limit).await)
}
pub async fn network_requests(&self, context: &str, limit: usize) -> Result<Vec<Request>> {
let key = SessionKey::parse(context);
let inst = self.instance(&key.instance)?;
let ctx = inst.context(&key.context)?;
Ok(ctx.network_requests(limit).await)
}
pub async fn refresh_pages(&mut self, context: &str) -> Result<usize> {
let key = SessionKey::parse(context);
let inst = self.instance_mut(&key.instance)?;
let current_pages = Box::pin(inst.browser.pages()).await?;
let ctx = inst.context_mut_checked(&key.context)?;
let existing_count = ctx.pages.len();
if current_pages.len() > existing_count {
for page in current_pages.into_iter().skip(existing_count) {
page.attach_listeners(ctx.console_log.clone(), ctx.network_log.clone(), ctx.dialog_log.clone());
ctx.pages.push(page);
}
}
Ok(ctx.pages.len())
}
pub async fn dialog_messages(&self, context: &str, limit: usize) -> Result<Vec<DialogEvent>> {
let key = SessionKey::parse(context);
let inst = self.instance(&key.instance)?;
let ctx = inst.context(&key.context)?;
Ok(ctx.dialog_messages(limit).await)
}
pub async fn shutdown(&mut self) {
self.connected.store(false, std::sync::atomic::Ordering::Relaxed);
for (_, mut inst) in self.instances.drain() {
inst.contexts.clear();
let _ = inst.browser.close().await;
}
}
#[must_use]
pub fn is_connected(&self) -> bool {
!self.instances.is_empty()
}
fn next_instance_generation(&mut self) -> u64 {
self.instance_generation_counter += 1;
self.instance_generation_counter
}
#[must_use]
pub fn instance_generation(&self, instance: &str) -> Option<u64> {
self.instances.get(instance).map(|i| i.generation)
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ContextInfo {
pub name: String,
pub instance: String,
pub context: String,
pub pages: Vec<PageInfo>,
}
pub type SessionInfo = ContextInfo;
#[derive(Debug, Clone, serde::Serialize)]
pub struct PageInfo {
pub index: usize,
pub url: String,
pub title: String,
pub active: bool,
}
async fn discover_ws_from_http(http_url: &str) -> Result<String> {
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
let url = http_url.trim_end_matches('/');
let host_port = url
.strip_prefix("http://")
.ok_or_else(|| FerriError::invalid_argument("url", format!("Expected http:// URL, got {http_url}")))?;
let stream = tokio::net::TcpStream::connect(host_port)
.await
.map_err(|e| FerriError::backend(format!("Cannot connect to {host_port}: {e}")))?;
let (reader, mut writer) = stream.into_split();
let req = format!("GET /json/version HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n");
writer
.write_all(req.as_bytes())
.await
.map_err(|e| FerriError::backend(format!("Write: {e}")))?;
let mut buf_reader = BufReader::new(reader);
let mut content_length: usize = 0;
loop {
let mut line = String::new();
buf_reader
.read_line(&mut line)
.await
.map_err(|e| FerriError::backend(format!("Read header: {e}")))?;
let trimmed = line.trim();
if trimmed.is_empty() {
break;
}
if let Some(val) = trimmed.strip_prefix("Content-Length:") {
content_length = val.trim().parse().unwrap_or(0);
}
if let Some(val) = trimmed.strip_prefix("content-length:") {
content_length = val.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; content_length.max(4096)];
let n = buf_reader
.read(&mut body)
.await
.map_err(|e| FerriError::backend(format!("Read body: {e}")))?;
let body_str = String::from_utf8_lossy(&body[..n]);
let json: serde_json::Value =
serde_json::from_str(&body_str).map_err(|e| FerriError::Backend(format!("Parse /json/version: {e}")))?;
json
.get("webSocketDebuggerUrl")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
.ok_or_else(|| FerriError::backend("No webSocketDebuggerUrl in /json/version"))
}
fn discover_chrome_ws(channel: &str, explicit_user_data_dir: Option<&str>) -> Result<String> {
let user_data_dir = if let Some(dir) = explicit_user_data_dir {
std::path::PathBuf::from(dir)
} else {
chrome_default_user_data_dir(channel)?
};
let port_file = user_data_dir.join("DevToolsActivePort");
let content = std::fs::read_to_string(&port_file).map_err(|e| {
format!(
"Cannot read {}: {e}. Ensure Chrome ({channel}) is running and \
remote debugging is enabled at chrome://inspect/#remote-debugging",
port_file.display()
)
})?;
let lines: Vec<&str> = content.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
if lines.len() < 2 {
return Err(FerriError::Backend(format!(
"Invalid DevToolsActivePort content: {content:?}"
)));
}
let port: u16 = lines[0]
.parse()
.map_err(|_| FerriError::Backend(format!("Invalid port '{}' in DevToolsActivePort", lines[0])))?;
let path = lines[1];
Ok(format!("ws://127.0.0.1:{port}{path}"))
}
fn chrome_default_user_data_dir(channel: &str) -> Result<std::path::PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| FerriError::backend("Cannot determine home directory"))?;
let os = std::env::consts::OS;
let suffix = match channel {
"stable" | "chrome" => "",
"beta" => " Beta",
"dev" => " Dev",
"canary" => " Canary",
other => {
return Err(FerriError::invalid_argument(
"channel",
format!("unknown Chrome channel: {other}"),
));
},
};
let path = match os {
"linux" => {
let dir_name = if suffix.is_empty() {
"google-chrome".to_string()
} else {
format!("google-chrome{}", suffix.to_lowercase().replace(' ', "-"))
};
std::path::PathBuf::from(&home).join(".config").join(dir_name)
},
"macos" => std::path::PathBuf::from(&home)
.join("Library/Application Support")
.join(format!("Google/Chrome{suffix}")),
"windows" => {
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| format!("{home}/AppData/Local"));
std::path::PathBuf::from(local_app_data).join(format!("Google/Chrome{suffix}/User Data"))
},
_ => {
return Err(FerriError::unsupported(format!("OS: {os}")));
},
};
if !path.exists() {
let chromium_path = match os {
"linux" => std::path::PathBuf::from(&home).join(".config/chromium"),
"macos" => std::path::PathBuf::from(&home).join("Library/Application Support/Chromium"),
_ => {
return Err(FerriError::Backend(format!(
"Chrome user data dir not found: {}",
path.display()
)));
},
};
if chromium_path.exists() {
return Ok(chromium_path);
}
return Err(FerriError::Backend(format!(
"Chrome user data dir not found at {} or {}",
path.display(),
chromium_path.display()
)));
}
Ok(path)
}
#[must_use]
pub fn chrome_flags(headless: bool, extra_args: &[String]) -> Vec<String> {
let mut flags: Vec<String> = Vec::with_capacity(40 + extra_args.len());
for f in CHROMIUM_SWITCHES {
flags.push((*f).into());
}
flags.push("--enable-unsafe-swiftshader".into());
if headless {
flags.push("--headless".into());
flags.push("--hide-scrollbars".into());
flags.push("--mute-audio".into());
flags.push(
"--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4,preferredColorScheme=1".into(),
);
}
flags.push("--no-sandbox".into());
for arg in extra_args {
flags.push(arg.clone());
}
flags
}
const CHROMIUM_SWITCHES: &[&str] = &[
"--disable-field-trial-config",
"--disable-background-networking",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-back-forward-cache",
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-component-extensions-with-background-pages",
"--disable-component-update",
"--no-default-browser-check",
"--disable-default-apps",
"--disable-dev-shm-usage",
"--disable-edgeupdater",
"--disable-extensions",
"--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BlockInsecurePrivateNetworkRequests,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,PrivateNetworkAccessSendPreflights,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion",
"--enable-features=CDPScreenshotNewSurface",
"--allow-pre-commit-input",
"--disable-hang-monitor",
"--disable-ipc-flooding-protection",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-renderer-backgrounding",
"--force-color-profile=srgb",
"--metrics-recording-only",
"--no-first-run",
"--password-store=basic",
"--use-mock-keychain",
"--no-service-autorun",
"--export-tagged-pdf",
"--disable-search-engine-choice-screen",
"--unsafely-disable-devtools-self-xss-warnings",
"--edge-skip-compat-layer-relaunch",
"--enable-automation",
"--disable-infobars",
"--disable-sync",
];
#[must_use]
pub fn resolve_chromium(headless: bool) -> String {
if headless {
if let Ok(p) = std::env::var("CHROMIUM_HEADLESS_SHELL_PATH") {
if std::path::Path::new(&p).exists() {
return p;
}
}
if let Ok(p) = std::env::var("CHROMIUM_PATH") {
if std::path::Path::new(&p).exists() {
return p;
}
}
if let Some(p) = detect_chromium_headless_shell() {
return p;
}
}
detect_chromium()
}
#[must_use]
pub fn detect_chromium_headless_shell() -> Option<String> {
if let Some(p) = find_playwright_headless_shell() {
return Some(p);
}
if let Some(p) = crate::install::BrowserInstaller::new().find_installed_headless_shell() {
return Some(p);
}
None
}
#[must_use]
pub fn detect_chromium() -> String {
if let Ok(p) = std::env::var("CHROMIUM_PATH") {
if std::path::Path::new(&p).exists() {
return p;
}
}
let pw_cache = if let Ok(p) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
Some(std::path::PathBuf::from(p))
} else {
std::env::var("XDG_CACHE_HOME")
.ok()
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.cache")))
.map(|c| std::path::PathBuf::from(c).join("ms-playwright"))
};
if let Some(pw_cache) = pw_cache {
if pw_cache.is_dir() {
if let Ok(entries) = std::fs::read_dir(&pw_cache) {
let mut candidates: Vec<_> = entries
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_string_lossy().starts_with("chromium-"))
.collect();
candidates.sort_by_key(|b| std::cmp::Reverse(b.file_name())); for entry in candidates {
let chrome = entry.path().join("chrome-linux64/chrome");
if chrome.exists() {
return chrome.to_string_lossy().to_string();
}
let chrome_mac = entry.path().join("chrome-mac/Chromium.app/Contents/MacOS/Chromium");
if chrome_mac.exists() {
return chrome_mac.to_string_lossy().to_string();
}
}
}
}
}
if let Ok(path_var) = std::env::var("PATH") {
let names = [
"google-chrome-stable",
"google-chrome",
"chromium-browser",
"chromium",
"microsoft-edge",
"chrome",
];
for name in &names {
for dir in path_var.split(':') {
let candidate = std::path::PathBuf::from(dir).join(name);
if candidate.exists() {
return candidate.to_string_lossy().to_string();
}
}
}
}
#[cfg(target_os = "macos")]
{
let bundles = [
"Google Chrome.app/Contents/MacOS/Google Chrome",
"Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"Chromium.app/Contents/MacOS/Chromium",
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
];
for bundle in &bundles {
let sys = std::path::PathBuf::from("/Applications").join(bundle);
if sys.exists() {
return sys.to_string_lossy().to_string();
}
if let Ok(home) = std::env::var("HOME") {
let user = std::path::PathBuf::from(&home).join("Applications").join(bundle);
if user.exists() {
return user.to_string_lossy().to_string();
}
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge",
];
for path in &paths {
if std::path::Path::new(path).exists() {
return path.to_string();
}
}
}
if let Some(p) = crate::install::BrowserInstaller::new().find_installed_chromium() {
return p;
}
if let Some(p) = find_playwright_chrome() {
return p;
}
"chromium".to_string()
}
pub fn detect_firefox() -> Result<String> {
if let Ok(p) = std::env::var("FIREFOX_PATH") {
if std::path::Path::new(&p).exists() {
return Ok(p);
}
}
if let Some(p) = crate::install::BrowserInstaller::new().find_installed_firefox() {
return Ok(p);
}
if let Some(p) = find_playwright_firefox() {
return Ok(p);
}
#[cfg(target_os = "macos")]
{
let paths = [
"/Applications/Firefox.app/Contents/MacOS/firefox",
"/Applications/Firefox Nightly.app/Contents/MacOS/firefox",
"/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox",
];
for path in &paths {
if std::path::Path::new(path).exists() {
return Ok(path.to_string());
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
"/usr/bin/firefox",
"/usr/bin/firefox-esr",
"/snap/bin/firefox",
"/usr/lib/firefox/firefox",
"/usr/lib64/firefox/firefox",
];
for path in &paths {
let p = std::path::Path::new(path);
if !p.exists() {
continue;
}
let resolved = std::fs::canonicalize(p).map_or_else(|_| path.to_string(), |c| c.to_string_lossy().to_string());
if resolved.contains("/snap/") {
continue;
}
return Ok(path.to_string());
}
}
#[cfg(target_os = "windows")]
{
let paths = [
r"C:\Program Files\Mozilla Firefox\firefox.exe",
r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe",
];
for path in &paths {
if std::path::Path::new(path).exists() {
return Ok(path.to_string());
}
}
}
let cmd = if cfg!(windows) { "where" } else { "which" };
if let Ok(output) = std::process::Command::new(cmd).arg("firefox").output() {
if output.status.success() {
let p = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !p.is_empty() && std::path::Path::new(&p).exists() {
return Ok(p);
}
}
}
Err(FerriError::backend(
"Firefox not found. Install with `ferridriver install firefox` or set FIREFOX_PATH.",
))
}
fn find_playwright_firefox() -> Option<String> {
let home = std::env::var("HOME").ok()?;
#[cfg(target_os = "macos")]
let cache_base = std::path::PathBuf::from(&home).join("Library/Caches/ms-playwright");
#[cfg(target_os = "linux")]
let cache_base = std::env::var("XDG_CACHE_HOME")
.map_or_else(
|_| std::path::PathBuf::from(&home).join(".cache"),
std::path::PathBuf::from,
)
.join("ms-playwright");
#[cfg(target_os = "windows")]
let cache_base = std::env::var("LOCALAPPDATA")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from(&home))
.join("ms-playwright");
let entries = std::fs::read_dir(&cache_base).ok()?;
let mut firefox_dirs: Vec<_> = entries
.filter_map(std::result::Result::ok)
.filter(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with("firefox-")
})
.collect();
firefox_dirs.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for dir in firefox_dirs {
let path = dir.path();
#[cfg(target_os = "macos")]
let exe = path.join("Firefox.app/Contents/MacOS/firefox");
#[cfg(target_os = "linux")]
let exe = path.join("firefox/firefox");
#[cfg(target_os = "windows")]
let exe = path.join("firefox/firefox.exe");
if exe.exists() {
return Some(exe.to_string_lossy().to_string());
}
}
None
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn find_playwright_headless_shell() -> Option<String> {
let home = std::env::var("HOME").ok()?;
#[cfg(target_os = "macos")]
let cache_dir = std::path::PathBuf::from(&home).join("Library/Caches/ms-playwright");
#[cfg(target_os = "linux")]
let cache_dir = std::path::PathBuf::from(&home).join(".cache/ms-playwright");
if !cache_dir.exists() {
return None;
}
let mut best_rev: u32 = 0;
let mut best_name = String::new();
let prefix = "chromium_headless_shell-";
if let Ok(entries) = std::fs::read_dir(&cache_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(rev_str) = name.strip_prefix(prefix) {
if let Ok(rev) = rev_str.parse::<u32>() {
if rev > best_rev {
best_rev = rev;
best_name = name;
}
}
}
}
}
if best_rev == 0 {
return None;
}
#[cfg(target_os = "macos")]
let arch = if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" };
#[cfg(target_os = "linux")]
let arch = if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" };
#[cfg(target_os = "macos")]
let plat = "mac";
#[cfg(target_os = "linux")]
let plat = "linux";
let cft_binary = cache_dir
.join(&best_name)
.join(format!("chrome-headless-shell-{plat}-{arch}"))
.join("chrome-headless-shell");
if cft_binary.exists() {
return Some(cft_binary.to_string_lossy().to_string());
}
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn find_playwright_headless_shell() -> Option<String> {
None
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn find_playwright_chrome() -> Option<String> {
let home = std::env::var("HOME").ok()?;
#[cfg(target_os = "macos")]
let cache_dir = std::path::PathBuf::from(&home).join("Library/Caches/ms-playwright");
#[cfg(target_os = "linux")]
let cache_dir = std::path::PathBuf::from(&home).join(".cache/ms-playwright");
if !cache_dir.exists() {
return None;
}
let mut best_rev: u32 = 0;
let mut best_name = String::new();
let prefix = "chromium_headless_shell-";
if let Ok(entries) = std::fs::read_dir(&cache_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(rev_str) = name.strip_prefix(prefix) {
if let Ok(rev) = rev_str.parse::<u32>() {
if rev > best_rev {
best_rev = rev;
best_name = name;
}
}
}
}
}
if best_rev == 0 {
return None;
}
#[cfg(target_os = "macos")]
let arch = if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" };
#[cfg(target_os = "linux")]
let arch = if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" };
#[cfg(target_os = "macos")]
let plat = "mac";
#[cfg(target_os = "linux")]
let plat = "linux";
let cft_binary = cache_dir
.join(&best_name)
.join(format!("chrome-headless-shell-{plat}-{arch}"))
.join("chrome-headless-shell");
if cft_binary.exists() {
return Some(cft_binary.to_string_lossy().to_string());
}
#[cfg(target_os = "linux")]
{
let alt_binary = cache_dir.join(&best_name).join("chrome-linux").join("headless_shell");
if alt_binary.exists() {
return Some(alt_binary.to_string_lossy().to_string());
}
}
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn find_playwright_chrome() -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::backend::BackendKind;
fn test_state(backend: BackendKind) -> BrowserState {
let kind = match backend {
BackendKind::Bidi => crate::options::BrowserKind::Firefox,
_ => crate::options::BrowserKind::Chromium,
};
BrowserState::with_plan(
ConnectMode::Launch,
crate::options::LaunchPlan {
backend,
kind,
headless: false,
..Default::default()
},
)
}
#[test]
fn test_instance_resolver_none_by_default() {
let state = test_state(BackendKind::CdpPipe);
assert!(state.instance_resolver_fn.is_none());
}
#[test]
fn test_instance_resolver_returns_connect_url() {
let mut state = test_state(BackendKind::CdpPipe);
state.set_instance_resolver_fn(Box::new(|instance| match instance {
"staging" => Some(ConnectMode::ConnectUrl(
"ws://127.0.0.1:9222/devtools/browser/abc".to_owned(),
)),
_ => None,
}));
let resolved = state.instance_resolver_fn.as_ref().unwrap()("staging");
assert!(matches!(resolved, Some(ConnectMode::ConnectUrl(url)) if url.contains("9222")));
let resolved = state.instance_resolver_fn.as_ref().unwrap()("unknown");
assert!(resolved.is_none());
}
#[test]
fn test_instance_args_fn_independent_of_resolver() {
let mut state = test_state(BackendKind::CdpPipe);
state.set_instance_args_fn(Box::new(|instance| vec![format!("--window-name={instance}")]));
state.set_instance_resolver_fn(Box::new(|_| None));
let args = state.instance_args_fn.as_ref().unwrap()("dev");
assert_eq!(args, vec!["--window-name=dev"]);
let resolved = state.instance_resolver_fn.as_ref().unwrap()("dev");
assert!(resolved.is_none());
}
#[tokio::test]
async fn test_ensure_instance_uses_resolver_for_connect() {
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let mut state = test_state(BackendKind::CdpRaw);
state.set_instance_resolver_fn(Box::new(move |instance| {
if instance == "test-resolved" {
Some(ConnectMode::ConnectUrl(format!(
"ws://127.0.0.1:{port}/devtools/browser/test"
)))
} else {
None
}
}));
let result = Box::pin(state.ensure_instance("test-resolved")).await;
assert!(
result.is_err(),
"Should fail with connection refused, proving resolver was invoked"
);
let err = result.unwrap_err().to_string();
assert!(
!err.contains("not found") && !err.contains("No such file"),
"Error should be connection-related, not binary-not-found: {err}"
);
}
#[tokio::test]
async fn test_ensure_instance_skips_resolver_when_exists() {
use std::sync::atomic::{AtomicU32, Ordering};
let call_count = Arc::new(AtomicU32::new(0));
let counter = Arc::clone(&call_count);
let mut state = test_state(BackendKind::CdpPipe);
state.set_instance_resolver_fn(Box::new(move |_| {
counter.fetch_add(1, Ordering::Relaxed);
None }));
let _ = Box::pin(state.ensure_instance("test")).await;
assert_eq!(call_count.load(Ordering::Relaxed), 1);
}
}