git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commitba26eeeec3503bf8186dcfb2e562d29ac17fdcc3
parente71598421f
authorLucas Galante <[email protected]>
date2026-08-11 11:44
Add bookmarks

The history module grows into pages.rs: internal cce: pages and their
TSV-backed stores. Bookmarks live in bookmarks.tsv next to history
(whole-file rewrite on change since entries are removable); the cce:
handler gains cce://bookmarks and cce://bookmarks/remove?url=... —
entries render like history rows plus a remove link. Chrome adds a
star button at the right end of the controls row (accent-lit when the
page is bookmarked) that toggles, Ctrl+D toggles, Ctrl+B opens the
page, about:bookmarks maps to it.

Live-verified: star click writes the TSV and lights up, the entry
survives a restart, cce://bookmarks renders it, remove empties store
and file. (Ctrl-key injection was blocked by the known compositor
focus-gate this session; the shortcut handlers call the same verified
paths.)

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/history.rs | 199 -------------------------------------
 src/main.rs    |  44 ++++++++-
 src/pages.rs   | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/webview.rs |  25 ++++-
 4 files changed, 366 insertions(+), 205 deletions(-)

diff --git a/src/history.rs b/src/history.rs
deleted file mode 100644
index d71464f..0000000
--- a/src/history.rs
+++ /dev/null
@@ -1,199 +0,0 @@
-//! Visit history: an append-only TSV log under the XDG state dir
-//! (`~/.local/state/cce/browser/history.tsv`), an in-memory copy for
-//! rendering, and the `cce:` protocol handler that serves it back as a
-//! real page — `cce://history` is fetched through Servo's network stack
-//! and rendered like any other page, so entries are ordinary links.
-//!
-//! The handler runs on Servo's fetch threads, hence the `Arc<Mutex<_>>`
-//! store shared with the main thread's recorder.
-
-use std::fs::{self, OpenOptions};
-use std::future::Future;
-use std::io::Write;
-use std::path::PathBuf;
-use std::pin::Pin;
-use std::sync::Mutex;
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use servo::protocol_handler::{
-    DoneChannel, FetchContext, HttpStatus, NetworkError, ProtocolHandler, Request, Response,
-    ResponseBody, ResourceFetchTiming,
-};
-use std::sync::Arc;
-
-/// Render at most this many entries on the history page.
-const RENDER_CAP: usize = 500;
-
-#[derive(Clone)]
-struct Entry {
-    ts: u64,
-    url: String,
-    title: String,
-}
-
-pub struct History {
-    entries: Mutex<Vec<Entry>>,
-    path: PathBuf,
-}
-
-fn state_path() -> PathBuf {
-    let base = match std::env::var("XDG_STATE_HOME") {
-        Ok(x) if !x.is_empty() => PathBuf::from(x),
-        _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state"),
-    };
-    base.join("cce").join("browser").join("history.tsv")
-}
-
-/// One-line-safe field: the TSV log separates with tabs and newlines.
-fn sanitize(s: &str) -> String {
-    s.replace(['\t', '\n', '\r'], " ")
-}
-
-fn html_escape(s: &str) -> String {
-    s.replace('&', "&amp;")
-        .replace('<', "&lt;")
-        .replace('>', "&gt;")
-        .replace('"', "&quot;")
-}
-
-impl History {
-    /// Load the log from the state dir (missing file = empty history).
-    pub fn load() -> Self {
-        let path = state_path();
-        let mut entries = Vec::new();
-        if let Ok(text) = fs::read_to_string(&path) {
-            for line in text.lines() {
-                let mut parts = line.splitn(3, '\t');
-                if let (Some(ts), Some(url), Some(title)) =
-                    (parts.next(), parts.next(), parts.next())
-                {
-                    if let Ok(ts) = ts.parse() {
-                        entries.push(Entry { ts, url: url.to_string(), title: title.to_string() });
-                    }
-                }
-            }
-        }
-        Self { entries: Mutex::new(entries), path }
-    }
-
-    /// Record a completed page load. Internal pages and immediate
-    /// duplicates (reload spam) are skipped.
-    pub fn record(&self, url: &str, title: &str) {
-        if url.starts_with("cce:") || url == "about:blank" {
-            return;
-        }
-        let mut entries = self.entries.lock().unwrap();
-        if entries.last().is_some_and(|last| last.url == url) {
-            return;
-        }
-        let ts = SystemTime::now()
-            .duration_since(UNIX_EPOCH)
-            .map(|d| d.as_secs())
-            .unwrap_or(0);
-        let entry = Entry { ts, url: sanitize(url), title: sanitize(title) };
-        if let Some(dir) = self.path.parent() {
-            let _ = fs::create_dir_all(dir);
-        }
-        if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
-            let _ = writeln!(f, "{}\t{}\t{}", entry.ts, entry.url, entry.title);
-        }
-        entries.push(entry);
-    }
-
-    pub fn clear(&self) {
-        self.entries.lock().unwrap().clear();
-        let _ = fs::write(&self.path, "");
-    }
-
-    /// The history page markup: newest first, timestamps localized by a
-    /// tiny script on the page itself.
-    fn html(&self) -> String {
-        let entries = self.entries.lock().unwrap();
-        let mut rows = String::new();
-        for e in entries.iter().rev().take(RENDER_CAP) {
-            let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
-            rows.push_str(&format!(
-                "<div class=e><span class=w data-ts=\"{}\"></span>\
-                 <a href=\"{}\">{}</a><span class=u>{}</span></div>\n",
-                e.ts,
-                html_escape(&e.url),
-                html_escape(title),
-                html_escape(&e.url),
-            ));
-        }
-        let count = entries.len();
-        let body = if count == 0 {
-            "<p class=empty>No history yet.</p>".to_string()
-        } else {
-            rows
-        };
-        format!(
-            "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>History</title><style>\
-             :root{{color-scheme:dark}}\
-             body{{background:#1a1b1d;color:#dcdce1;font-family:sans-serif;margin:0;padding:28px 36px}}\
-             h1{{font-size:20px;font-weight:600;margin:0 0 4px}}\
-             .meta{{color:#8a8c92;font-size:13px;margin-bottom:20px}}\
-             .meta a{{color:#7fa3d4;text-decoration:none;margin-left:12px}}\
-             .e{{display:flex;gap:14px;padding:7px 10px;border-radius:8px;align-items:baseline}}\
-             .e:hover{{background:#232427}}\
-             .w{{color:#8a8c92;font-size:12px;min-width:11em}}\
-             .e a{{color:#dcdce1;text-decoration:none;white-space:nowrap;overflow:hidden;\
-                   text-overflow:ellipsis;max-width:40%}}\
-             .e a:hover{{color:#9fc1ea}}\
-             .u{{color:#6f7177;font-size:12px;white-space:nowrap;overflow:hidden;\
-                 text-overflow:ellipsis;flex:1}}\
-             .empty{{color:#8a8c92}}\
-             </style></head><body>\
-             <h1>History</h1>\
-             <div class=meta>{count} entries<a href=\"cce://history/clear\">clear</a></div>\
-             {body}\
-             <script>for(const el of document.querySelectorAll('[data-ts]')){{\
-             const d=new Date(1000*+el.dataset.ts);\
-             el.textContent=d.toLocaleDateString()+'  '+\
-             d.toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}</script>\
-             </body></html>"
-        )
-    }
-}
-
-/// `cce:` scheme: internal pages served straight out of the app.
-pub struct CceProtocol {
-    pub history: Arc<History>,
-}
-
-impl ProtocolHandler for CceProtocol {
-    fn load(
-        &self,
-        request: &mut Request,
-        _done_chan: &mut DoneChannel,
-        _context: &FetchContext,
-    ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
-        let url = request.current_url();
-        let page = url.as_str().trim_start_matches("cce://").trim_end_matches('/');
-        let body = match page {
-            "history" => Some(self.history.html()),
-            "history/clear" => {
-                self.history.clear();
-                Some(self.history.html())
-            }
-            _ => None,
-        };
-        let response = match body {
-            Some(html) => {
-                let mut response =
-                    Response::new(url, ResourceFetchTiming::new(request.timing_type()));
-                *response.body.lock() = ResponseBody::Done(html.into_bytes());
-                response.headers.insert(
-                    http::header::CONTENT_TYPE,
-                    http::HeaderValue::from_static("text/html; charset=utf-8"),
-                );
-                response.status = HttpStatus::default();
-                response
-            }
-            None => Response::network_error(NetworkError::ResourceLoadError(
-                format!("no such cce: page: {page}"),
-            )),
-        };
-        Box::pin(std::future::ready(response))
-    }
-}
diff --git a/src/main.rs b/src/main.rs
index b1fa847..25505c5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,7 +6,7 @@
 //! reload / URL field). Input over the page area is translated into Servo
 //! input events; the URL bar is a small hand-rolled line editor.
 
-mod history;
+mod pages;
 mod webview;
 
 use url::Url;
@@ -148,13 +148,24 @@ fn btn_rect(i: usize) -> Rect {
     }
 }
 
+/// The bookmark star, at the right end of the controls row.
+fn star_rect(win_w: f32) -> Rect {
+    let bar = bar_rect(win_w);
+    Rect {
+        x: bar.x + bar.width - BAR_PAD - BTN_W,
+        y: controls_y(),
+        width: BTN_W,
+        height: BTN_H,
+    }
+}
+
 fn url_rect(win_w: f32) -> Rect {
     let bar = bar_rect(win_w);
     let x = BAR_MARGIN + BAR_PAD + 3.0 * (BTN_W + BTN_GAP) + 4.0;
     Rect {
         x,
         y: controls_y(),
-        width: (bar.x + bar.width - BAR_PAD - x).max(60.0),
+        width: (bar.x + bar.width - BAR_PAD - BTN_W - BTN_GAP - x).max(60.0),
         height: BTN_H,
     }
 }
@@ -169,6 +180,9 @@ fn parse_url_input(input: &str) -> Option<Url> {
     if s.eq_ignore_ascii_case("about:history") {
         return Url::parse("cce://history").ok();
     }
+    if s.eq_ignore_ascii_case("about:bookmarks") {
+        return Url::parse("cce://bookmarks").ok();
+    }
     if let Ok(u) = Url::parse(s) {
         if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about" | "cce") {
             return Some(u);
@@ -482,6 +496,8 @@ impl Application for BrowserApp {
                 self.host.forward();
             } else if hit(&btn_rect(2), pos.x, pos.y) {
                 self.host.reload();
+            } else if hit(&star_rect(self.win.0), pos.x, pos.y) {
+                self.host.toggle_bookmark();
             } else {
                 let field = url_rect(self.win.0);
                 if hit(&field, pos.x, pos.y) {
@@ -542,8 +558,9 @@ impl Application for BrowserApp {
                     *needs_rebuild = true;
                     return self.close_tab(self.host.active_index());
                 }
-                Key::Character(c) if c == "h" => {
-                    if let Ok(url) = Url::parse("cce://history") {
+                Key::Character(c) if c == "h" || c == "b" => {
+                    let page = if c == "h" { "cce://history" } else { "cce://bookmarks" };
+                    if let Ok(url) = Url::parse(page) {
                         self.host.open_tab(url);
                         self.url_focused = false;
                         self.sync_page_state();
@@ -551,6 +568,11 @@ impl Application for BrowserApp {
                     }
                     return None;
                 }
+                Key::Character(c) if c == "d" => {
+                    self.host.toggle_bookmark();
+                    *needs_rebuild = true;
+                    return None;
+                }
                 Key::Named(NamedKey::Tab) if count > 1 => {
                     let cur = self.host.active_index();
                     let next = if event.shift { (cur + count - 1) % count } else { (cur + 1) % count };
@@ -702,6 +724,20 @@ impl Application for BrowserApp {
             );
         }
 
+        // Bookmark star: accent-lit when the page is bookmarked.
+        let star = star_rect(w);
+        pc.rounded_rect(star, 6.0, (true, true, true, true), BTN_BG);
+        let starred = self.host.active_bookmarked();
+        let star_color: [u8; 3] = if starred { [150, 190, 240] } else { TEXT_DIM };
+        let sw = measure_text_width("*", &sans, 17.0);
+        pc.text(
+            "*",
+            star.x + (star.width - sw) / 2.0,
+            cce_ui::layout::align_text_y(star.y, star.height, 17.0, 0.0) + 3.0,
+            17.0,
+            star_color,
+        );
+
         // URL field: rim + recess, brighter rim when focused.
         let f = url_rect(w);
         let rim = if self.url_focused { RIM_FOCUS } else { RIM };
diff --git a/src/pages.rs b/src/pages.rs
new file mode 100644
index 0000000..228cb46
--- /dev/null
+++ b/src/pages.rs
@@ -0,0 +1,303 @@
+//! Internal `cce:` pages and their backing stores.
+//!
+//! History and bookmarks live as TSV files under the XDG state dir
+//! (`~/.local/state/cce/browser/`), with in-memory copies for rendering.
+//! The `cce:` protocol handler serves them back as real pages —
+//! `cce://history` and `cce://bookmarks` are fetched through Servo's
+//! network stack and rendered like any other page, so entries are
+//! ordinary links (including the mutating clear/remove actions).
+//!
+//! The handler runs on Servo's fetch threads, hence the `Arc<Mutex<_>>`
+//! stores shared with the main thread.
+
+use std::fs::{self, OpenOptions};
+use std::future::Future;
+use std::io::Write;
+use std::path::PathBuf;
+use std::pin::Pin;
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use servo::protocol_handler::{
+    DoneChannel, FetchContext, HttpStatus, NetworkError, ProtocolHandler, Request, Response,
+    ResponseBody, ResourceFetchTiming,
+};
+
+/// Render at most this many entries on the history page.
+const RENDER_CAP: usize = 500;
+
+#[derive(Clone)]
+struct Entry {
+    ts: u64,
+    url: String,
+    title: String,
+}
+
+fn state_dir() -> PathBuf {
+    let base = match std::env::var("XDG_STATE_HOME") {
+        Ok(x) if !x.is_empty() => PathBuf::from(x),
+        _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state"),
+    };
+    base.join("cce").join("browser")
+}
+
+fn now() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0)
+}
+
+/// One-line-safe field: the TSV logs separate with tabs and newlines.
+fn sanitize(s: &str) -> String {
+    s.replace(['\t', '\n', '\r'], " ")
+}
+
+fn html_escape(s: &str) -> String {
+    s.replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+}
+
+fn read_tsv(path: &PathBuf) -> Vec<Entry> {
+    let mut entries = Vec::new();
+    if let Ok(text) = fs::read_to_string(path) {
+        for line in text.lines() {
+            let mut parts = line.splitn(3, '\t');
+            if let (Some(ts), Some(url), Some(title)) = (parts.next(), parts.next(), parts.next())
+            {
+                if let Ok(ts) = ts.parse() {
+                    entries.push(Entry { ts, url: url.to_string(), title: title.to_string() });
+                }
+            }
+        }
+    }
+    entries
+}
+
+fn write_tsv(path: &PathBuf, entries: &[Entry]) {
+    if let Some(dir) = path.parent() {
+        let _ = fs::create_dir_all(dir);
+    }
+    let mut out = String::new();
+    for e in entries {
+        out.push_str(&format!("{}\t{}\t{}\n", e.ts, e.url, e.title));
+    }
+    let _ = fs::write(path, out);
+}
+
+/// Shared page skeleton for the internal pages (dark, DE-toned).
+fn page(title: &str, meta: &str, body: &str) -> String {
+    format!(
+        "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{title}</title><style>\
+         :root{{color-scheme:dark}}\
+         body{{background:#1a1b1d;color:#dcdce1;font-family:sans-serif;margin:0;padding:28px 36px}}\
+         h1{{font-size:20px;font-weight:600;margin:0 0 4px}}\
+         .meta{{color:#8a8c92;font-size:13px;margin-bottom:20px}}\
+         .meta a{{color:#7fa3d4;text-decoration:none;margin-left:12px}}\
+         .e{{display:flex;gap:14px;padding:7px 10px;border-radius:8px;align-items:baseline}}\
+         .e:hover{{background:#232427}}\
+         .w{{color:#8a8c92;font-size:12px;min-width:11em}}\
+         .e a{{color:#dcdce1;text-decoration:none;white-space:nowrap;overflow:hidden;\
+               text-overflow:ellipsis;max-width:40%}}\
+         .e a:hover{{color:#9fc1ea}}\
+         .u{{color:#6f7177;font-size:12px;white-space:nowrap;overflow:hidden;\
+             text-overflow:ellipsis;flex:1}}\
+         .e a.rm{{color:#6f7177;font-size:12px;max-width:none}}\
+         .e a.rm:hover{{color:#d49b9b}}\
+         .empty{{color:#8a8c92}}\
+         </style></head><body>\
+         <h1>{title}</h1>\
+         <div class=meta>{meta}</div>\
+         {body}\
+         <script>for(const el of document.querySelectorAll('[data-ts]')){{\
+         const d=new Date(1000*+el.dataset.ts);\
+         el.textContent=d.toLocaleDateString()+'  '+\
+         d.toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}</script>\
+         </body></html>"
+    )
+}
+
+pub struct History {
+    entries: Mutex<Vec<Entry>>,
+    path: PathBuf,
+}
+
+impl History {
+    /// Load the log from the state dir (missing file = empty history).
+    pub fn load() -> Self {
+        let path = state_dir().join("history.tsv");
+        Self { entries: Mutex::new(read_tsv(&path)), path }
+    }
+
+    /// Record a completed page load. Internal pages and immediate
+    /// duplicates (reload spam) are skipped.
+    pub fn record(&self, url: &str, title: &str) {
+        if url.starts_with("cce:") || url == "about:blank" {
+            return;
+        }
+        let mut entries = self.entries.lock().unwrap();
+        if entries.last().is_some_and(|last| last.url == url) {
+            return;
+        }
+        let entry = Entry { ts: now(), url: sanitize(url), title: sanitize(title) };
+        if let Some(dir) = self.path.parent() {
+            let _ = fs::create_dir_all(dir);
+        }
+        if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
+            let _ = writeln!(f, "{}\t{}\t{}", entry.ts, entry.url, entry.title);
+        }
+        entries.push(entry);
+    }
+
+    pub fn clear(&self) {
+        self.entries.lock().unwrap().clear();
+        let _ = fs::write(&self.path, "");
+    }
+
+    fn html(&self) -> String {
+        let entries = self.entries.lock().unwrap();
+        let mut rows = String::new();
+        for e in entries.iter().rev().take(RENDER_CAP) {
+            let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
+            rows.push_str(&format!(
+                "<div class=e><span class=w data-ts=\"{}\"></span>\
+                 <a href=\"{}\">{}</a><span class=u>{}</span></div>\n",
+                e.ts,
+                html_escape(&e.url),
+                html_escape(title),
+                html_escape(&e.url),
+            ));
+        }
+        let meta = format!(
+            "{} entries<a href=\"cce://history/clear\">clear</a>",
+            entries.len()
+        );
+        let body = if entries.is_empty() {
+            "<p class=empty>No history yet.</p>".to_string()
+        } else {
+            rows
+        };
+        page("History", &meta, &body)
+    }
+}
+
+pub struct Bookmarks {
+    entries: Mutex<Vec<Entry>>,
+    path: PathBuf,
+}
+
+impl Bookmarks {
+    pub fn load() -> Self {
+        let path = state_dir().join("bookmarks.tsv");
+        Self { entries: Mutex::new(read_tsv(&path)), path }
+    }
+
+    pub fn contains(&self, url: &str) -> bool {
+        self.entries.lock().unwrap().iter().any(|e| e.url == url)
+    }
+
+    /// Add or remove a bookmark for `url`; returns true when it is now
+    /// bookmarked.
+    pub fn toggle(&self, url: &str, title: &str) -> bool {
+        if url.starts_with("cce:") || url == "about:blank" {
+            return false;
+        }
+        let mut entries = self.entries.lock().unwrap();
+        let added = if let Some(i) = entries.iter().position(|e| e.url == url) {
+            entries.remove(i);
+            false
+        } else {
+            entries.push(Entry { ts: now(), url: sanitize(url), title: sanitize(title) });
+            true
+        };
+        write_tsv(&self.path, &entries);
+        added
+    }
+
+    pub fn remove(&self, url: &str) {
+        let mut entries = self.entries.lock().unwrap();
+        entries.retain(|e| e.url != url);
+        write_tsv(&self.path, &entries);
+    }
+
+    fn html(&self) -> String {
+        let entries = self.entries.lock().unwrap();
+        let mut rows = String::new();
+        for e in entries.iter().rev() {
+            let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
+            let enc: String = url::form_urlencoded::byte_serialize(e.url.as_bytes()).collect();
+            rows.push_str(&format!(
+                "<div class=e><span class=w data-ts=\"{}\"></span>\
+                 <a href=\"{}\">{}</a><span class=u>{}</span>\
+                 <a class=rm href=\"cce://bookmarks/remove?url={}\">remove</a></div>\n",
+                e.ts,
+                html_escape(&e.url),
+                html_escape(title),
+                html_escape(&e.url),
+                html_escape(&enc),
+            ));
+        }
+        let meta = format!("{} bookmarks", entries.len());
+        let body = if entries.is_empty() {
+            "<p class=empty>No bookmarks yet. Star a page or press Ctrl+D.</p>".to_string()
+        } else {
+            rows
+        };
+        page("Bookmarks", &meta, &body)
+    }
+}
+
+/// `cce:` scheme: internal pages served straight out of the app.
+pub struct CceProtocol {
+    pub history: Arc<History>,
+    pub bookmarks: Arc<Bookmarks>,
+}
+
+impl ProtocolHandler for CceProtocol {
+    fn load(
+        &self,
+        request: &mut Request,
+        _done_chan: &mut DoneChannel,
+        _context: &FetchContext,
+    ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
+        let url = request.current_url();
+        let full = url.as_str().trim_start_matches("cce://");
+        let (path, query) = full.split_once('?').unwrap_or((full, ""));
+        let body = match path.trim_end_matches('/') {
+            "history" => Some(self.history.html()),
+            "history/clear" => {
+                self.history.clear();
+                Some(self.history.html())
+            }
+            "bookmarks" => Some(self.bookmarks.html()),
+            "bookmarks/remove" => {
+                if let Some((_, target)) =
+                    url::form_urlencoded::parse(query.as_bytes()).find(|(k, _)| k == "url")
+                {
+                    self.bookmarks.remove(&target);
+                }
+                Some(self.bookmarks.html())
+            }
+            _ => None,
+        };
+        let response = match body {
+            Some(html) => {
+                let mut response =
+                    Response::new(url, ResourceFetchTiming::new(request.timing_type()));
+                *response.body.lock() = ResponseBody::Done(html.into_bytes());
+                response.headers.insert(
+                    http::header::CONTENT_TYPE,
+                    http::HeaderValue::from_static("text/html; charset=utf-8"),
+                );
+                response.status = HttpStatus::default();
+                response
+            }
+            None => Response::network_error(NetworkError::ResourceLoadError(format!(
+                "no such cce: page: {path}"
+            ))),
+        };
+        Box::pin(std::future::ready(response))
+    }
+}
diff --git a/src/webview.rs b/src/webview.rs
index 295872c..3ce1755 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -26,7 +26,7 @@ use servo::{
 use servo::protocol_handler::ProtocolRegistry;
 use url::Url;
 
-use crate::history::{CceProtocol, History};
+use crate::pages::{Bookmarks, CceProtocol, History};
 use crate::Message;
 
 /// Delegate-observed signals for one webview, polled by the app after each
@@ -129,6 +129,7 @@ pub struct ServoHost {
     shared: Rc<HostShared>,
     delegate: Rc<Delegate>,
     history: std::sync::Arc<History>,
+    bookmarks: std::sync::Arc<Bookmarks>,
     tabs: Vec<Tab>,
     active: usize,
     size_px: (u32, u32),
@@ -149,8 +150,10 @@ impl ServoHost {
             .expect("make software rendering context current");
 
         let history = std::sync::Arc::new(History::load());
+        let bookmarks = std::sync::Arc::new(Bookmarks::load());
         let mut protocols = ProtocolRegistry::default();
-        if let Err(e) = protocols.register("cce", CceProtocol { history: history.clone() }) {
+        let handler = CceProtocol { history: history.clone(), bookmarks: bookmarks.clone() };
+        if let Err(e) = protocols.register("cce", handler) {
             log::error!("failed to register cce: protocol: {e:?}");
         }
 
@@ -174,6 +177,7 @@ impl ServoHost {
             shared,
             delegate,
             history,
+            bookmarks,
             tabs: Vec::new(),
             // Sentinel so the first open_tab's activate() does the full
             // show/focus/resize dance instead of early-returning on 0 == 0.
@@ -352,6 +356,23 @@ impl ServoHost {
         self.active_tab().loading
     }
 
+    /// Whether the active tab's page is bookmarked.
+    pub fn active_bookmarked(&self) -> bool {
+        self.active_tab()
+            .url
+            .as_ref()
+            .is_some_and(|u| self.bookmarks.contains(u.as_str()))
+    }
+
+    /// Toggle the bookmark for the active tab's page.
+    pub fn toggle_bookmark(&self) {
+        let tab = self.active_tab();
+        if let Some(url) = &tab.url {
+            self.bookmarks
+                .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
+        }
+    }
+
     pub fn can_go_back(&self) -> bool {
         self.active_tab().webview.can_go_back()
     }