use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::element_ref::ElementRef;
pub const SESSION_FILE: &str = "sessions.json";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SessionStore {
#[serde(default)]
pub browsers: HashMap<String, BrowserSession>,
#[serde(skip)]
pub(crate) loaded_names: HashSet<String>,
#[serde(skip)]
pub(crate) loaded_entries: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserSession {
pub ws_endpoint: String,
pub pid: Option<u32>,
#[serde(default)]
pub headless: bool,
#[serde(default)]
pub proxy_server: Option<String>,
#[serde(default)]
pub chrome_args: Vec<String>,
#[serde(default)]
pub daemon_pid: Option<u32>,
#[serde(default)]
pub pages: HashMap<String, PageSession>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageSession {
pub target_id: String,
#[serde(default)]
pub uid_map: HashMap<String, ElementRef>,
#[serde(default)]
pub last_snapshot: Option<String>,
#[serde(default)]
pub last_snapshot_frame: Option<String>,
#[serde(default)]
pub last_snapshot_loader: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_emulation: Option<crate::emulation::DeviceEmulation>,
}
impl PageSession {
pub fn store_snapshot(&mut self, snapshot: crate::snapshot::Snapshot) {
self.uid_map = snapshot.uid_map;
self.last_snapshot = Some(snapshot.text);
let (frame, loader) = snapshot
.identity
.map_or((None, None), |(f, l)| (Some(f), Some(l)));
self.last_snapshot_frame = frame;
self.last_snapshot_loader = loader;
}
}
pub use crate::session_save::{FileLock, Liveness, cleanup_stale, liveness, save_to};
pub fn load_session() -> Result<SessionStore, SessionError> {
crate::session_load::load_from(&session_path()?)
}
pub fn save_session(store: &mut SessionStore) -> Result<(), SessionError> {
let result = save_to(&session_path()?, store);
if result.is_ok() {
for pid in store.browsers.values().filter_map(|b| b.pid) {
crate::kill::disarm(pid);
}
}
result
}
impl SessionStore {
pub fn take_baseline(&mut self) {
self.loaded_names = self.browsers.keys().cloned().collect();
self.loaded_entries = self
.browsers
.iter()
.filter_map(|(name, entry)| {
serde_json::to_string(entry)
.ok()
.map(|json| (name.clone(), json))
})
.collect();
}
}
pub fn browsers_dir() -> Result<PathBuf, SessionError> {
Ok(dev_browser_dir()?.join("browsers"))
}
pub fn ensure_browser<'a>(
store: &'a mut SessionStore,
name: &str,
ws_endpoint: &str,
pid: Option<u32>,
headless: bool,
proxy_server: Option<String>,
chrome_args: Vec<String>,
) -> &'a mut BrowserSession {
store
.browsers
.entry(name.to_string())
.or_insert_with(|| BrowserSession {
ws_endpoint: ws_endpoint.to_string(),
pid,
headless,
proxy_server,
chrome_args,
daemon_pid: None,
pages: HashMap::new(),
})
}
pub fn ensure_proxy_compatible(
browser: &BrowserSession,
requested_proxy: Option<&str>,
) -> Result<(), SessionError> {
let Some(requested) = requested_proxy else {
return Ok(());
};
if browser.proxy_server.as_deref() == Some(requested) {
return Ok(());
}
Err(SessionError(
"named browser is already running with a different proxy; close or purge it (chrome-agent --browser <name> close --purge), or select another browser name"
.into(),
))
}
pub use crate::chrome_args::ensure_chrome_args_compatible;
pub fn ensure_page<'a>(
browser: &'a mut BrowserSession,
page_name: &str,
target_id: &str,
) -> &'a mut PageSession {
browser
.pages
.entry(page_name.to_string())
.or_insert_with(|| PageSession {
target_id: target_id.to_string(),
uid_map: HashMap::new(),
last_snapshot: None,
last_snapshot_frame: None,
last_snapshot_loader: None,
device_emulation: None,
})
}
pub fn daemon_socket_exists() -> bool {
daemon_socket_path().is_ok_and(|p| p.exists())
}
pub fn daemon_socket_path() -> Result<PathBuf, SessionError> {
Ok(dev_browser_dir()?.join("daemon.sock"))
}
pub fn daemon_pid_path() -> Result<PathBuf, SessionError> {
Ok(dev_browser_dir()?.join("daemon.pid"))
}
fn session_path() -> Result<PathBuf, SessionError> {
Ok(dev_browser_dir()?.join(SESSION_FILE))
}
fn dev_browser_dir() -> Result<PathBuf, SessionError> {
dirs::home_dir()
.map(|h| h.join(".chrome-agent"))
.ok_or_else(|| SessionError("Could not determine home directory".into()))
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct SessionError(pub String);
#[cfg(test)]
pub fn browser_fixture(ws: &str) -> BrowserSession {
BrowserSession {
ws_endpoint: ws.to_string(),
pid: Some(1),
headless: true,
proxy_server: None,
chrome_args: Vec::new(),
daemon_pid: None,
pages: HashMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::browser_fixture as browser;
use super::*;
#[test]
fn session_roundtrip() {
let mut store = SessionStore::default();
let browser = ensure_browser(
&mut store,
"test",
"ws://localhost:9222",
Some(1234),
true,
Some("http://127.0.0.1:8080".into()),
vec!["--enable-features=WebMCP,WebMCPTesting".into()],
);
ensure_page(browser, "main", "target-abc");
let json = serde_json::to_string(&store).unwrap();
let loaded: SessionStore = serde_json::from_str(&json).unwrap();
assert!(loaded.browsers.contains_key("test"));
let b = &loaded.browsers["test"];
assert_eq!(b.ws_endpoint, "ws://localhost:9222");
assert_eq!(b.pid, Some(1234));
assert!(b.headless);
assert_eq!(b.proxy_server.as_deref(), Some("http://127.0.0.1:8080"));
assert_eq!(
b.chrome_args,
vec!["--enable-features=WebMCP,WebMCPTesting".to_string()]
);
assert!(b.pages.contains_key("main"));
assert_eq!(b.pages["main"].target_id, "target-abc");
}
#[test]
fn named_browser_proxy_must_match_before_reuse() {
let existing = browser("ws://localhost:9222");
assert!(ensure_proxy_compatible(&existing, None).is_ok());
assert!(
ensure_proxy_compatible(&existing, Some("http://127.0.0.1:8080"))
.unwrap_err()
.to_string()
.contains("different proxy")
);
}
#[test]
fn proxied_browser_inherits_proxy_when_flag_omitted() {
let mut existing = browser("ws://localhost:9222");
existing.proxy_server = Some("http://127.0.0.1:8080".into());
assert!(ensure_proxy_compatible(&existing, None).is_ok());
assert!(ensure_proxy_compatible(&existing, Some("http://127.0.0.1:8080")).is_ok());
assert!(ensure_proxy_compatible(&existing, Some("http://127.0.0.1:9090")).is_err());
}
#[test]
fn bug_element_ref_unknown_type() {
let json = r#"{"type":"futureType","data":"unknown"}"#;
let result: Result<crate::element_ref::ElementRef, _> = serde_json::from_str(json);
assert!(result.is_err());
}
}