Skip to main content

oxide_browser/
history.rs

1//! Persistent browsing history for the Oxide browser.
2//!
3//! Entries are stored in a [`sled`] embedded database under a [`sled::Tree`]
4//! named `"history"`. Each record is keyed by a unique timestamp+URL composite
5//! key so the same URL visited multiple times produces separate entries.
6//! For thread-safe access use [`SharedHistoryStore`].
7
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::{Context, Result};
13
14static HISTORY_SEQ: AtomicU64 = AtomicU64::new(0);
15
16/// A single browsing history entry.
17#[derive(Clone, Debug)]
18pub struct HistoryItem {
19    pub url: String,
20    pub title: String,
21    pub visited_at_ms: u64,
22}
23
24impl HistoryItem {
25    fn to_bytes(&self) -> Vec<u8> {
26        let ts_bytes = self.visited_at_ms.to_le_bytes();
27        let title_bytes = self.title.as_bytes();
28        let title_len = (title_bytes.len() as u32).to_le_bytes();
29        let url_bytes = self.url.as_bytes();
30        let url_len = (url_bytes.len() as u32).to_le_bytes();
31
32        let mut buf = Vec::with_capacity(8 + 4 + title_bytes.len() + 4 + url_bytes.len());
33        buf.extend_from_slice(&ts_bytes);
34        buf.extend_from_slice(&title_len);
35        buf.extend_from_slice(title_bytes);
36        buf.extend_from_slice(&url_len);
37        buf.extend_from_slice(url_bytes);
38        buf
39    }
40
41    fn from_bytes(data: &[u8]) -> Option<Self> {
42        if data.len() < 16 {
43            return None;
44        }
45        let visited_at_ms = u64::from_le_bytes(data[0..8].try_into().ok()?);
46        let title_len = u32::from_le_bytes(data[8..12].try_into().ok()?) as usize;
47        if data.len() < 12 + title_len + 4 {
48            return None;
49        }
50        let title = String::from_utf8(data[12..12 + title_len].to_vec()).ok()?;
51        let url_off = 12 + title_len;
52        let url_len = u32::from_le_bytes(data[url_off..url_off + 4].try_into().ok()?) as usize;
53        if data.len() < url_off + 4 + url_len {
54            return None;
55        }
56        let url = String::from_utf8(data[url_off + 4..url_off + 4 + url_len].to_vec()).ok()?;
57        Some(Self {
58            url,
59            title,
60            visited_at_ms,
61        })
62    }
63}
64
65/// Persistent history storage backed by a [`sled::Tree`].
66#[derive(Clone)]
67pub struct HistoryStore {
68    tree: sled::Tree,
69}
70
71impl HistoryStore {
72    pub fn open(db: &sled::Db) -> Result<Self> {
73        let tree = db
74            .open_tree("history")
75            .context("failed to open history tree")?;
76        Ok(Self { tree })
77    }
78
79    /// Record a visited page. Uses timestamp + sequence number as key for uniqueness.
80    pub fn record(&self, url: &str, title: &str) -> Result<()> {
81        let ts = now_ms();
82        let seq = HISTORY_SEQ.fetch_add(1, Ordering::Relaxed);
83        let item = HistoryItem {
84            url: url.to_string(),
85            title: title.to_string(),
86            visited_at_ms: ts,
87        };
88        let mut key = Vec::with_capacity(16);
89        key.extend_from_slice(&ts.to_be_bytes());
90        key.extend_from_slice(&seq.to_be_bytes());
91        self.tree
92            .insert(key, item.to_bytes())
93            .context("failed to insert history entry")?;
94        Ok(())
95    }
96
97    /// Returns all history entries, newest first. Each entry includes its
98    /// sled key for targeted deletion via [`Self::remove_by_key`].
99    pub fn list_all(&self) -> Vec<(Vec<u8>, HistoryItem)> {
100        let mut items = Vec::new();
101        for entry in self.tree.iter().flatten() {
102            let (key, val) = entry;
103            if let Some(item) = HistoryItem::from_bytes(&val) {
104                items.push((key.to_vec(), item));
105            }
106        }
107        items.reverse();
108        items
109    }
110
111    /// Remove a single history entry by its sled key.
112    pub fn remove_by_key(&self, key: &[u8]) -> Result<()> {
113        self.tree
114            .remove(key)
115            .context("failed to remove history entry")?;
116        Ok(())
117    }
118
119    /// Delete every entry in the history.
120    pub fn clear(&self) -> Result<()> {
121        self.tree.clear().context("failed to clear history")?;
122        Ok(())
123    }
124}
125
126pub type SharedHistoryStore = Arc<Mutex<Option<HistoryStore>>>;
127
128fn now_ms() -> u64 {
129    SystemTime::now()
130        .duration_since(UNIX_EPOCH)
131        .unwrap_or_default()
132        .as_millis() as u64
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use tempfile::tempdir;
139
140    fn test_store() -> HistoryStore {
141        let dir = tempdir().unwrap();
142        let db = sled::open(dir.path()).unwrap();
143        HistoryStore::open(&db).unwrap()
144    }
145
146    #[test]
147    fn record_and_list() {
148        let store = test_store();
149        store.record("https://a.com/app.wasm", "App A").unwrap();
150        store.record("https://b.com/app.wasm", "App B").unwrap();
151        let all = store.list_all();
152        assert_eq!(all.len(), 2);
153        assert_eq!(all[0].1.url, "https://b.com/app.wasm");
154        assert_eq!(all[1].1.url, "https://a.com/app.wasm");
155    }
156
157    #[test]
158    fn duplicate_urls_create_separate_entries() {
159        let store = test_store();
160        store.record("https://a.com/app.wasm", "A").unwrap();
161        store.record("https://a.com/app.wasm", "A").unwrap();
162        let all = store.list_all();
163        assert_eq!(all.len(), 2);
164    }
165
166    #[test]
167    fn remove_single_entry() {
168        let store = test_store();
169        store.record("https://a.com/app.wasm", "A").unwrap();
170        store.record("https://b.com/app.wasm", "B").unwrap();
171        let all = store.list_all();
172        assert_eq!(all.len(), 2);
173        store.remove_by_key(&all[0].0).unwrap();
174        let remaining = store.list_all();
175        assert_eq!(remaining.len(), 1);
176        assert_eq!(remaining[0].1.url, "https://a.com/app.wasm");
177    }
178
179    #[test]
180    fn clear_all() {
181        let store = test_store();
182        store.record("https://a.com/app.wasm", "A").unwrap();
183        store.record("https://b.com/app.wasm", "B").unwrap();
184        store.clear().unwrap();
185        assert_eq!(store.list_all().len(), 0);
186    }
187}