use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::element_ref::ElementRef;
const SESSION_FILE: &str = "sessions.json";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SessionStore {
#[serde(default)]
pub browsers: HashMap<String, BrowserSession>,
#[serde(skip)]
pub loaded_mtime: Option<std::time::SystemTime>,
}
#[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 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>,
}
pub fn load_session() -> Result<SessionStore, SessionError> {
let path = session_path()?;
if !path.exists() {
return Ok(SessionStore::default());
}
let mtime = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok());
let contents = std::fs::read_to_string(&path)
.map_err(|e| SessionError(format!("Failed to read {}: {e}", path.display())))?;
let mut store: SessionStore = serde_json::from_str(&contents)
.map_err(|e| SessionError(format!("Failed to parse {}: {e}", path.display())))?;
store.loaded_mtime = mtime;
Ok(store)
}
pub fn save_session(store: &mut SessionStore) -> Result<(), SessionError> {
let path = session_path()?;
if let Some(loaded_mtime) = store.loaded_mtime
&& let Ok(current_mtime) = std::fs::metadata(&path).and_then(|m| m.modified())
&& current_mtime != loaded_mtime {
eprintln!("warning: session file was modified by another process. Use --browser <name> to isolate parallel agents.");
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| SessionError(format!("Failed to create dir: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
let json = serde_json::to_string_pretty(store)
.map_err(|e| SessionError(format!("Failed to serialize session: {e}")))?;
let tmp_path = path.with_extension("json.tmp");
std::fs::write(&tmp_path, &json)
.map_err(|e| SessionError(format!("Failed to write {}: {e}", tmp_path.display())))?;
std::fs::rename(&tmp_path, &path)
.map_err(|e| SessionError(format!("Failed to rename session file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
store.loaded_mtime = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok());
Ok(())
}
pub fn cleanup_stale(store: &mut SessionStore) {
store.browsers.retain(|_name, session| {
if let Some(pid) = session.pid {
is_process_alive(pid)
} else {
is_ws_reachable(&session.ws_endpoint)
}
});
}
fn is_ws_reachable(ws_url: &str) -> bool {
let http_url = crate::browser::extract_http_from_ws(ws_url);
let version_url = format!("{http_url}/json/version");
let agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_millis(500)))
.build()
.new_agent();
agent.get(&version_url).call().is_ok()
}
pub fn ensure_browser<'a>(
store: &'a mut SessionStore,
name: &str,
ws_endpoint: &str,
pid: Option<u32>,
headless: bool,
) -> &'a mut BrowserSession {
store
.browsers
.entry(name.to_string())
.or_insert_with(|| BrowserSession {
ws_endpoint: ws_endpoint.to_string(),
pid,
headless,
daemon_pid: None,
pages: HashMap::new(),
})
}
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,
})
}
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()))
}
fn is_process_alive(pid: u32) -> bool {
#[cfg(unix)]
{
#[allow(unsafe_code)]
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0
}
#[cfg(not(unix))]
{
let _ = pid;
true
}
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct SessionError(pub String);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_roundtrip() {
let mut store = SessionStore::default();
let browser =
ensure_browser(&mut store, "test", "ws://localhost:9222", Some(1234), true);
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!(b.pages.contains_key("main"));
assert_eq!(b.pages["main"].target_id, "target-abc");
}
#[test]
fn bug_session_corrupt_json() {
let dir = std::env::temp_dir().join("chrome-agent_test_corrupt");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sessions.json");
std::fs::write(&path, "NOT VALID JSON {{{").unwrap();
let result: Result<SessionStore, _> = serde_json::from_str("NOT VALID JSON {{{");
assert!(result.is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bug_session_empty_file() {
let result: Result<SessionStore, _> = serde_json::from_str("");
assert!(result.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());
}
}