use std::collections::HashMap;
use eoka::{Browser, Page, StealthConfig};
use eoka_server::{InteractiveElement, ObserveConfig};
use super::profile::clone_profile_dir;
use super::proxy_forward::ProxyForwarder;
fn tracked_tab_page(tabs: &HashMap<String, TabState>, tab_id: &str) -> eoka::Result<Page> {
tabs.get(tab_id)
.map(|tab| tab.page.clone())
.ok_or_else(|| eoka::Error::cdp_msg(format!("Tab {tab_id} not found")))
}
fn reconcile_closed_tab<T>(
tabs: &mut HashMap<String, T>,
current_tab_id: &mut Option<String>,
tab_id: &str,
) -> Option<String> {
tabs.remove(tab_id);
if current_tab_id.as_deref() == Some(tab_id) {
let next_tab_id = tabs.keys().next().cloned();
*current_tab_id = next_tab_id.clone();
next_tab_id
} else {
None
}
}
pub struct TabState {
pub page: Page,
pub elements: Vec<InteractiveElement>,
pub snapshot_refs: HashMap<String, i64>,
pub console_injected: bool,
}
impl TabState {
pub fn new(page: Page) -> Self {
Self {
page,
elements: Vec::new(),
snapshot_refs: HashMap::new(),
console_injected: false,
}
}
pub fn invalidate(&mut self) {
self.elements.clear();
self.snapshot_refs.clear();
}
}
pub struct BrowserState {
pub browser: Browser,
pub tabs: HashMap<String, TabState>,
pub current_tab_id: Option<String>,
pub config: ObserveConfig,
pub is_live: bool,
_proxy_forwarder: Option<ProxyForwarder>,
}
impl BrowserState {
pub async fn launched(
headless: bool,
copy_profile_from: Option<&std::path::Path>,
no_stealth: bool,
proxy: Option<String>,
persist_profile_dir: Option<&std::path::Path>,
geo_align: bool,
) -> eoka::Result<Self> {
let patch_binary = std::env::var("EOKA_PATCH_BINARY")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let proxy = proxy
.map(|value| {
eoka_proxy::parse(&value).map_err(|error| eoka::Error::Launch(error.to_string()))
})
.transpose()?;
let mut proxy_forwarder = None;
let (proxy, proxy_username, proxy_password) = match proxy {
Some(proxy) => match (proxy.username, proxy.password) {
(Some(username), Some(password)) if proxy.server.starts_with("http://") => {
let forwarder = ProxyForwarder::start(&proxy.server, &username, &password)
.await
.map_err(|error| eoka::Error::Launch(error.to_string()))?;
let server = forwarder.server().to_owned();
proxy_forwarder = Some(forwarder);
(Some(server), None, None)
}
(username, password) => (Some(proxy.server), username, password),
},
None => (None, None, None),
};
let cdp_timeout = if proxy.is_some() { 90 } else { 30 };
let extra_args: Vec<String> = std::env::var("EOKA_CHROME_ARGS")
.unwrap_or_default()
.split(':')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let mut user_data_dir = persist_profile_dir.map(|dir| dir.to_string_lossy().to_string());
if let Some(src) = copy_profile_from {
let dst = clone_profile_dir(src).map_err(eoka::Error::Io)?;
user_data_dir = Some(dst.to_string_lossy().to_string());
eprintln!(
"[eoka] cloned profile {} → {}",
src.display(),
dst.display()
);
}
if let Some(ref dir) = user_data_dir {
std::fs::create_dir_all(dir).map_err(eoka::Error::Io)?;
eprintln!("[eoka] durable profile dir {}", dir);
}
eprintln!(
"[eoka] launching browser (headless={}, stealth={}, cdp_timeout={}s, proxy={}, profile_clone={})",
headless,
!no_stealth,
cdp_timeout,
proxy.is_some(),
copy_profile_from.is_some()
);
let config = StealthConfig {
headless,
patch_binary: patch_binary && !no_stealth,
proxy,
proxy_username,
proxy_password,
cdp_timeout,
extra_args,
live_session: no_stealth,
filter_cdp: !no_stealth,
user_data_dir,
geo_align: geo_align && !no_stealth,
..Default::default()
};
let browser = Browser::launch_with_config(config).await?;
Ok(Self {
browser,
tabs: HashMap::new(),
current_tab_id: None,
config: ObserveConfig::default(),
is_live: false,
_proxy_forwarder: proxy_forwarder,
})
}
pub async fn connected(ws_url: &str) -> eoka::Result<Self> {
eprintln!("[eoka] connecting to {}", ws_url);
let browser = Browser::connect(ws_url).await?;
Ok(Self {
browser,
tabs: HashMap::new(),
current_tab_id: None,
config: ObserveConfig::default(),
is_live: true,
_proxy_forwarder: None,
})
}
pub async fn ensure_tab(&mut self, url: &str) -> eoka::Result<&mut TabState> {
if let Some(existing_id) = self.current_tab_id.clone() {
if let Some(tab) = self.tabs.get_mut(&existing_id) {
tab.invalidate();
tab.page.goto(url).await?;
}
return self
.tabs
.get_mut(&existing_id)
.ok_or_else(|| eoka::Error::cdp_msg("Current tab disappeared"));
}
let page = self.browser.new_page(url).await?;
let id = page.target_id().to_string();
self.tabs.insert(id.clone(), TabState::new(page));
self.current_tab_id = Some(id.clone());
Ok(self.tabs.get_mut(&id).expect("just inserted"))
}
pub fn current_tab(&self) -> Option<&TabState> {
self.current_tab_id
.as_ref()
.and_then(|id| self.tabs.get(id))
}
pub fn current_tab_mut(&mut self) -> Option<&mut TabState> {
self.current_tab_id
.as_ref()
.and_then(|id| self.tabs.get_mut(id))
}
pub async fn ensure_blank_tab(&mut self) -> eoka::Result<&mut TabState> {
let page = self.browser.new_blank_page().await?;
let id = page.target_id().to_string();
self.tabs.insert(id.clone(), TabState::new(page));
self.current_tab_id = Some(id.clone());
Ok(self.tabs.get_mut(&id).expect("just inserted"))
}
pub async fn new_tab(&mut self, url: Option<&str>) -> eoka::Result<(String, &mut TabState)> {
let page = match url {
Some(u) => self.browser.new_page(u).await?,
None => self.browser.new_blank_page().await?,
};
let tab_id = page.target_id().to_string();
self.tabs.insert(tab_id.clone(), TabState::new(page));
self.browser.activate_tab(&tab_id).await?;
self.current_tab_id = Some(tab_id.clone());
let tab = self.tabs.get_mut(&tab_id).expect("just inserted");
Ok((tab_id, tab))
}
pub async fn switch_tab(&mut self, tab_id: &str) -> eoka::Result<()> {
if !self.tabs.contains_key(tab_id) {
let page = self.browser.attach_page(tab_id).await?;
self.tabs.insert(tab_id.to_string(), TabState::new(page));
}
self.browser.activate_tab(tab_id).await?;
self.current_tab_id = Some(tab_id.to_string());
Ok(())
}
pub async fn attach_existing_tab(&mut self, tab_id: &str) -> eoka::Result<()> {
if !self.tabs.contains_key(tab_id) {
let page = self.browser.attach_page(tab_id).await?;
self.tabs.insert(tab_id.to_string(), TabState::new(page));
}
self.current_tab_id = Some(tab_id.to_string());
Ok(())
}
pub async fn close_tab(&mut self, tab_id: &str) -> eoka::Result<()> {
let page = tracked_tab_page(&self.tabs, tab_id)?;
if self.tabs.len() <= 1 {
return Err(eoka::Error::cdp_msg("Cannot close the last tab"));
}
let release_result = page.release_all_inputs().await;
self.browser.close_tab(tab_id).await?;
if let Some(next_tab_id) =
reconcile_closed_tab(&mut self.tabs, &mut self.current_tab_id, tab_id)
{
self.browser.activate_tab(&next_tab_id).await?;
}
release_result
}
pub async fn close(self) -> eoka::Result<()> {
let BrowserState { browser, tabs, .. } = self;
let mut release_error = None;
for tab in tabs.values() {
if let Err(error) = tab.page.release_all_inputs().await {
release_error.get_or_insert(error);
}
}
browser.close().await?;
match release_error {
Some(error) => Err(error),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn close_tab_rejects_unknown_untracked_tab_without_indexing() {
let tabs = HashMap::new();
let result = tracked_tab_page(&tabs, "unknown-tab");
match result {
Ok(_) => panic!("unknown tab was accepted"),
Err(error) => assert!(error.to_string().contains("Tab unknown-tab not found")),
}
}
#[test]
fn close_tab_reconciles_state_before_returning_cleanup_error() {
let mut tabs = HashMap::from([("closed".to_string(), ()), ("next".to_string(), ())]);
let mut current_tab_id = Some("closed".to_string());
let cleanup_result: Result<(), &str> = Err("release failed");
let next_tab_id = reconcile_closed_tab(&mut tabs, &mut current_tab_id, "closed");
assert_eq!(next_tab_id.as_deref(), Some("next"));
assert_eq!(current_tab_id.as_deref(), Some("next"));
assert!(!tabs.contains_key("closed"));
assert!(matches!(cleanup_result, Err("release failed")));
}
}