use crate::core::{TabId, WindowId};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use url::Url;
pub const MAX_SESSION_WINDOWS: usize = 4;
pub const MAX_SESSION_TABS_PER_WINDOW: usize = 100;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BrowserSession {
pub windows: Vec<SessionWindow>,
}
impl BrowserSession {
pub fn enforce_limits(&mut self) {
self.windows.truncate(MAX_SESSION_WINDOWS);
for window in &mut self.windows {
window.tabs.retain(|tab| !tab.private);
window.tabs.truncate(MAX_SESSION_TABS_PER_WINDOW);
for tab in &mut window.tabs {
if tab.title.trim().is_empty() {
tab.title = default_tab_title();
}
tab.zoom = if tab.zoom.is_finite() {
tab.zoom.clamp(0.5, 2.5)
} else {
default_tab_zoom()
};
}
if let Some(active_tab) = window.active_tab
&& !window.tabs.iter().any(|tab| tab.id == active_tab)
{
window.active_tab = window.tabs.first().map(|tab| tab.id);
}
}
self.windows.retain(|window| !window.tabs.is_empty());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionWindow {
pub id: WindowId,
#[serde(default)]
pub active_tab: Option<TabId>,
#[serde(default)]
pub tabs: Vec<SessionTab>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionTab {
pub id: TabId,
#[serde(default)]
pub url: Option<Url>,
#[serde(default = "default_tab_title")]
pub title: String,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub muted: bool,
#[serde(default)]
pub private: bool,
#[serde(default = "default_tab_zoom")]
pub zoom: f64,
}
fn default_tab_title() -> String {
"Start".to_string()
}
fn default_tab_zoom() -> f64 {
1.0
}
#[derive(Debug, Clone)]
pub struct SessionStore {
path: PathBuf,
}
impl SessionStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> anyhow::Result<BrowserSession> {
if !self.path.exists() {
return Ok(BrowserSession::default());
}
let data = fs::read_to_string(&self.path)?;
let mut session: BrowserSession = serde_json::from_str(&data)?;
session.enforce_limits();
Ok(session)
}
pub fn save(&self, session: &BrowserSession) -> anyhow::Result<()> {
let mut session = session.clone();
session.enforce_limits();
atomic_write_json(&self.path, &session)
}
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, serde_json::to_vec(value)?)?;
fs::rename(tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_round_trips() {
let dir = tempfile::tempdir().unwrap();
let store = SessionStore::new(dir.path().join("session.json"));
let session = BrowserSession {
windows: vec![SessionWindow {
id: WindowId(1),
active_tab: Some(TabId(7)),
tabs: vec![SessionTab {
id: TabId(7),
url: Some(Url::parse("https://example.com/").unwrap()),
title: "Example".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 1.1,
}],
}],
};
store.save(&session).unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded.windows[0].active_tab, Some(TabId(7)));
assert_eq!(loaded.windows[0].tabs[0].zoom, 1.1);
}
#[test]
fn session_limits_windows_tabs_and_private_entries() {
let mut session = BrowserSession {
windows: (0..8)
.map(|window| SessionWindow {
id: WindowId(window),
active_tab: Some(TabId(1)),
tabs: (0..150)
.map(|tab| SessionTab {
id: TabId(tab),
url: Some(Url::parse("https://example.com/").unwrap()),
title: format!("Tab {tab}"),
pinned: false,
muted: false,
private: tab % 2 == 0,
zoom: 1.0,
})
.collect(),
})
.collect(),
};
session.enforce_limits();
assert_eq!(session.windows.len(), MAX_SESSION_WINDOWS);
assert!(
session
.windows
.iter()
.all(|window| window.tabs.len() <= MAX_SESSION_TABS_PER_WINDOW)
);
assert!(
session
.windows
.iter()
.flat_map(|window| &window.tabs)
.all(|tab| !tab.private)
);
}
#[test]
fn session_loads_tabs_when_safe_fields_are_missing() {
let mut session: BrowserSession = serde_json::from_value(serde_json::json!({
"windows": [{
"id": 1,
"tabs": [{ "id": 7 }]
}]
}))
.unwrap();
session.enforce_limits();
let tab = &session.windows[0].tabs[0];
assert_eq!(tab.id, TabId(7));
assert_eq!(tab.title, "Start");
assert_eq!(tab.zoom, 1.0);
assert!(!tab.pinned);
assert!(!tab.private);
session.windows[0].tabs[0].zoom = f64::INFINITY;
session.enforce_limits();
assert_eq!(session.windows[0].tabs[0].zoom, 1.0);
}
}