use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::Instant;
use super::backend::{BrowserBackend, PageHandle};
#[derive(Clone)]
pub struct TabEntry {
pub tab_id: String,
pub target_id: String,
pub page: Arc<dyn PageHandle>,
pub last_url: Option<String>,
pub title: Option<String>,
pub context_id: Option<String>,
}
pub struct BrowserSessionState {
pub tabs: Vec<TabEntry>,
pub active_target_id: String,
pub last_used_at: Instant,
pub action_lock: Arc<Mutex<()>>,
}
impl BrowserSessionState {
fn active_tab(&self) -> Option<&TabEntry> {
self.tabs
.iter()
.find(|t| t.target_id == self.active_target_id)
}
}
#[derive(Default)]
pub struct BrowserSessionRegistry {
sessions: Mutex<HashMap<String, BrowserSessionState>>,
approvals: Mutex<HashMap<String, bool>>,
}
#[derive(Debug, Clone)]
pub struct TabView {
pub tab_id: String,
pub title: Option<String>,
pub url: Option<String>,
pub active: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EvictedSession {
pub session_id: String,
pub tab_target_ids: Vec<String>,
pub context_ids: Vec<String>,
}
pub const DEFAULT_SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
impl BrowserSessionRegistry {
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
approvals: Mutex::new(HashMap::new()),
}
}
pub async fn is_session_approved(&self, session_id: &str) -> bool {
*self
.approvals
.lock()
.await
.get(session_id)
.unwrap_or(&false)
}
pub async fn mark_session_approved(&self, session_id: &str) {
self.approvals
.lock()
.await
.insert(session_id.to_string(), true);
}
pub async fn get_or_create_page(
&self,
session_id: &str,
backend: &dyn BrowserBackend,
) -> Result<(Arc<dyn PageHandle>, Arc<Mutex<()>>), String> {
if session_id.is_empty() {
return Err("browser actions require a session id".to_string());
}
{
let mut sessions = self.sessions.lock().await;
if let Some(state) = sessions.get_mut(session_id) {
state.last_used_at = Instant::now();
if let Some(tab) = state.active_tab() {
return Ok((Arc::clone(&tab.page), Arc::clone(&state.action_lock)));
}
}
}
let evicted = self
.evict_idle(Instant::now(), DEFAULT_SESSION_IDLE_TIMEOUT)
.await;
for ev in &evicted {
backend
.dispose_session(&ev.tab_target_ids, &ev.context_ids)
.await;
}
let (page_id, context_id, page) = backend.create_page().await?;
let mut sessions = self.sessions.lock().await;
if let Some(state) = sessions.get_mut(session_id) {
state.last_used_at = Instant::now();
if let Some(tab) = state.active_tab() {
return Ok((Arc::clone(&tab.page), Arc::clone(&state.action_lock)));
}
let tab = TabEntry {
tab_id: page_id.clone(),
target_id: page_id.clone(),
page: Arc::clone(&page),
last_url: None,
title: None,
context_id,
};
state.active_target_id = page_id;
state.tabs.push(tab);
return Ok((page, Arc::clone(&state.action_lock)));
}
let action_lock = Arc::new(Mutex::new(()));
let tab = TabEntry {
tab_id: page_id.clone(),
target_id: page_id.clone(),
page: Arc::clone(&page),
last_url: None,
title: None,
context_id,
};
let state = BrowserSessionState {
tabs: vec![tab],
active_target_id: page_id,
last_used_at: Instant::now(),
action_lock: Arc::clone(&action_lock),
};
sessions.insert(session_id.to_string(), state);
Ok((page, action_lock))
}
#[allow(clippy::too_many_arguments)]
pub async fn add_tab(
&self,
session_id: &str,
target_id: &str,
page: Arc<dyn PageHandle>,
url: Option<String>,
title: Option<String>,
context_id: Option<String>,
make_active: bool,
) -> Option<String> {
let mut sessions = self.sessions.lock().await;
let state = sessions.get_mut(session_id)?;
if let Some(existing) = state.tabs.iter().find(|t| t.target_id == target_id) {
let id = existing.tab_id.clone();
if make_active {
state.active_target_id = target_id.to_string();
}
return Some(id);
}
let tab = TabEntry {
tab_id: target_id.to_string(),
target_id: target_id.to_string(),
page,
last_url: url,
title,
context_id,
};
let id = tab.tab_id.clone();
state.tabs.push(tab);
if make_active {
state.active_target_id = target_id.to_string();
}
Some(id)
}
pub async fn switch_tab(&self, session_id: &str, tab_id: &str) -> Result<TabView, String> {
let mut sessions = self.sessions.lock().await;
let state = sessions
.get_mut(session_id)
.ok_or_else(|| "no browser tabs for this session".to_string())?;
let owned = state.tabs.iter().any(|t| t.tab_id == tab_id);
if !owned {
return Err(format!(
"Unknown tab '{}'. It does not belong to this session. Use list_tabs to see open tabs.",
tab_id
));
}
state.active_target_id = tab_id.to_string();
let tab = state
.tabs
.iter()
.find(|t| t.tab_id == tab_id)
.expect("ownership just validated");
Ok(TabView {
tab_id: tab.tab_id.clone(),
title: tab.title.clone(),
url: tab.last_url.clone(),
active: true,
})
}
pub async fn close_tab(
&self,
session_id: &str,
tab_id: &str,
) -> Result<(String, Option<String>), String> {
let mut sessions = self.sessions.lock().await;
let state = sessions
.get_mut(session_id)
.ok_or_else(|| "no browser tabs for this session".to_string())?;
let pos = state.tabs.iter().position(|t| t.tab_id == tab_id);
let Some(pos) = pos else {
return Err(format!(
"Unknown tab '{}'. It does not belong to this session. Use list_tabs to see open tabs.",
tab_id
));
};
let removed = state.tabs.remove(pos);
let was_active = state.active_target_id == removed.target_id;
let new_active = if was_active {
if let Some(last) = state.tabs.last() {
state.active_target_id = last.target_id.clone();
Some(last.tab_id.clone())
} else {
state.active_target_id.clear();
None
}
} else {
state
.tabs
.iter()
.find(|t| t.target_id == state.active_target_id)
.map(|t| t.tab_id.clone())
};
Ok((removed.target_id, new_active))
}
pub async fn active_target_id(&self, session_id: &str) -> Option<String> {
let sessions = self.sessions.lock().await;
let state = sessions.get(session_id)?;
let active = &state.active_target_id;
if active.is_empty() {
return None;
}
state
.tabs
.iter()
.find(|t| &t.target_id == active)
.map(|t| t.target_id.clone())
}
pub async fn invalidate_all_pages(&self) {
let mut sessions = self.sessions.lock().await;
for state in sessions.values_mut() {
state.tabs.clear();
state.active_target_id.clear();
}
}
pub async fn evict_idle(&self, now: Instant, max_idle: Duration) -> Vec<EvictedSession> {
let mut sessions = self.sessions.lock().await;
let idle_ids: Vec<String> = sessions
.iter()
.filter(|(_, state)| now.duration_since(state.last_used_at) >= max_idle)
.filter(|(_, state)| state.action_lock.try_lock().is_ok())
.map(|(id, _)| id.clone())
.collect();
let mut evicted = Vec::with_capacity(idle_ids.len());
for id in idle_ids {
let Some(state) = sessions.remove(&id) else {
continue;
};
let tab_target_ids: Vec<String> =
state.tabs.iter().map(|t| t.target_id.clone()).collect();
let mut context_ids: Vec<String> = Vec::new();
for ctx in state.tabs.iter().filter_map(|t| t.context_id.clone()) {
if !context_ids.contains(&ctx) {
context_ids.push(ctx);
}
}
evicted.push(EvictedSession {
session_id: id,
tab_target_ids,
context_ids,
});
}
drop(sessions);
if !evicted.is_empty() {
let mut approvals = self.approvals.lock().await;
for ev in &evicted {
approvals.remove(&ev.session_id);
}
}
evicted
}
#[cfg(test)]
pub async fn seed_session_for_test(
&self,
session_id: &str,
last_used_at: Instant,
tabs: Vec<(String, Option<String>)>,
page: Arc<dyn PageHandle>,
approved: bool,
) {
let tab_entries: Vec<TabEntry> = tabs
.into_iter()
.map(|(target_id, context_id)| TabEntry {
tab_id: target_id.clone(),
target_id,
page: Arc::clone(&page),
last_url: None,
title: None,
context_id,
})
.collect();
let active_target_id = tab_entries
.first()
.map(|t| t.target_id.clone())
.unwrap_or_default();
let state = BrowserSessionState {
tabs: tab_entries,
active_target_id,
last_used_at,
action_lock: Arc::new(Mutex::new(())),
};
self.sessions
.lock()
.await
.insert(session_id.to_string(), state);
if approved {
self.approvals
.lock()
.await
.insert(session_id.to_string(), true);
}
}
#[cfg(test)]
pub async fn has_session_for_test(&self, session_id: &str) -> bool {
self.sessions.lock().await.contains_key(session_id)
}
#[cfg(test)]
pub async fn action_lock_for_test(&self, session_id: &str) -> Option<Arc<Mutex<()>>> {
self.sessions
.lock()
.await
.get(session_id)
.map(|state| Arc::clone(&state.action_lock))
}
pub async fn list_tabs(&self, session_id: &str) -> Vec<TabView> {
let sessions = self.sessions.lock().await;
let Some(state) = sessions.get(session_id) else {
return Vec::new();
};
state
.tabs
.iter()
.map(|t| TabView {
tab_id: t.tab_id.clone(),
title: t.title.clone(),
url: t.last_url.clone(),
active: t.target_id == state.active_target_id,
})
.collect()
}
}