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

commit0092f5ce563c4bdee801b0689e0abd8529bd5de5
parentd2c6b1a26e
authorLucas Galante <[email protected]>
date2026-09-01 22:19
feat: restore open tabs across restarts

The tab set persists to ~/.local/state/cce/browser/tabs.tsv (one
active-flag + URL line per tab, strip order). Saves are eager — every
open/close/switch and each navigation via the Spin-dirty path — with a
last-serialization check so load-time signal storms don't touch the
disk. Startup restores the set and re-activates the saved tab; a launch
argument opens on top of it. Closing the last tab saves the empty set,
so an emptied browser starts fresh instead of resurrecting itself.
about:blank tabs are not saved.

Chrome-side and engine-agnostic: persistence reads tabs through the
shared host surface, so the WPE and Servo backends both carry it.

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

 CLAUDE.md      |  29 ++++++++++++-
 src/main.rs    |  77 +++++++++++++++++++++++++++++-----
 src/session.rs | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 224 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ee7c849..5623a6b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,7 +13,7 @@ truth, there is no push remote). Read the workspace-level
 `../cce-compositor/WORKSPACE.md` first: workspace layout, the `cce-ui` toolkit, config
 conventions, and the multi-repo rules all live there.
 
-Seven files, ~2.9k lines:
+Eight files, ~3k lines:
 
 | file | what it owns |
 | --- | --- |
@@ -23,6 +23,7 @@ Seven files, ~2.9k lines:
 | `src/webview.rs` | `ServoHost` — Servo boot, the delegate, one `WebView` per tab, the frame pipeline |
 | `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks stores |
 | `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
+| `src/session.rs` | open-tab persistence: the tab set survives a restart |
 | `src/settings.rs` | the per-app KDL config |
 
 ## Build
@@ -134,6 +135,32 @@ frame, so switching shows content instantly while the resize refreshes it.
   `pump`. They are built before anyone told them the theme, so `pump` calls
   `notify_theme_change` on adoption.
 
+### Session restore
+
+The open-tab set persists across restarts: `src/session.rs` writes
+`~/.local/state/cce/browser/tabs.tsv` (one `<active-flag>\t<url>` line per tab)
+and startup restores it, engine-agnostically — the chrome reads tabs back
+through the shared host surface, so both backends get it for free. Points that
+are choices, not accidents:
+
+- **Saves are eager, not on-exit** — `persist_session()` fires on every tab
+  open/close/switch and on navigation (via the Spin-dirty path), so a crash or
+  a compositor-side window close loses nothing. `Session::save` compares
+  against the last serialization and skips no-op writes, which is what keeps
+  the loading-time signal storm off the disk.
+- **Closing the last tab saves the empty set** before `Message::Quit`, so a
+  deliberately emptied browser starts fresh on the homepage instead of
+  resurrecting what was just closed. Quitting via the window close keeps the
+  tabs (they were never closed).
+- **A launch argument opens as an extra tab on top of the restored set**; only
+  when there is nothing to restore does it become the single starting tab
+  (then falling back to the homepage, as before).
+- **`about:blank` tabs are skipped on save** — a "New Tab" is not worth
+  resurrecting.
+- Restore is **eager**: every saved tab starts loading at launch (one
+  WebProcess each on WPE). Fine at normal tab counts; lazy restore is the
+  upgrade path if someone lives with dozens.
+
 ## The chrome is hand-rolled
 
 There are **no `cce-ui` widgets in this app**. The whole utility bar is emitted as
diff --git a/src/main.rs b/src/main.rs
index 384e1e8..acef84c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,6 +10,7 @@ mod downloads;
 mod instance;
 mod lineedit;
 mod pages;
+mod session;
 mod settings;
 /// The retired Servo backend; compiled only under `--features servo`.
 #[cfg(feature = "servo")]
@@ -282,6 +283,8 @@ struct BrowserApp {
     /// with) for URL-bar caret/click metrics via `shaped_cluster_offsets` —
     /// `measure_text_width`'s inked-extent numbers drift off the drawn glyphs.
     font_system: cce_ui::cosmic_text::FontSystem,
+    /// The open-tab set, persisted across restarts (see `session.rs`).
+    session: session::Session,
 }
 
 fn hit(r: &Rect, x: f32, y: f32) -> bool {
@@ -669,6 +672,20 @@ impl BrowserApp {
         });
     }
 
+    /// Write the open-tab set to the session store (a no-op when nothing
+    /// changed). Blank tabs are not worth resurrecting, so they are skipped —
+    /// which also means a browser left on nothing but "New Tab" starts fresh.
+    fn persist_session(&mut self) {
+        let active = self.host.active_index();
+        let tabs: Vec<(String, bool)> = (0..self.host.tab_count())
+            .filter_map(|i| {
+                let url = self.host.tab(i)?.url.as_ref()?.to_string();
+                (url != "about:blank").then_some((url, i == active))
+            })
+            .collect();
+        self.session.save(&tabs);
+    }
+
     /// New blank tab with the URL bar focused for typing.
     fn new_tab(&mut self) {
         let url = Url::parse("about:blank").expect("about:blank");
@@ -676,15 +693,20 @@ impl BrowserApp {
         self.url = lineedit::LineEdit::default();
         self.url_focused = true;
         self.sync_page_state();
+        self.persist_session();
     }
 
     /// Close a tab; returns `Message::Quit` when it was the last one.
     fn close_tab(&mut self, index: usize) -> Option<Message> {
         if !self.host.close_tab(index) {
+            // Deliberately emptied: save the empty set so the next launch
+            // starts on the homepage instead of restoring what was closed.
+            self.persist_session();
             return Some(Message::Quit);
         }
         self.url_focused = false;
         self.sync_page_state();
+        self.persist_session();
         None
     }
 
@@ -692,6 +714,7 @@ impl BrowserApp {
         self.host.activate(index);
         self.url_focused = false;
         self.sync_page_state();
+        self.persist_session();
     }
 
     /// Show an internal page: reuse a tab already on it (reloading, so
@@ -713,6 +736,7 @@ impl BrowserApp {
         self.host.open_tab(url);
         self.url_focused = false;
         self.sync_page_state();
+        self.persist_session();
     }
 
     /// Widest prefix of `text` fitting `avail`, with a "…"-style tail cut.
@@ -899,23 +923,51 @@ impl Application for BrowserApp {
         instance::spawn_listener(sender.clone());
         let settings = settings::load();
         downloads::set_download_dir(settings.download_dir.clone());
-        // Optional CLI arg: the start URL (same parsing as the URL bar);
-        // otherwise the configured homepage.
-        let url = std::env::args()
+        // Optional CLI arg: the start URL (same parsing as the URL bar).
+        let arg = std::env::args()
             .nth(1)
-            .and_then(|arg| parse_startup_arg(&arg, &settings.search_prefix))
-            .or_else(|| parse_url_input(&settings.homepage, &settings.search_prefix))
-            .unwrap_or_else(|| Url::parse(settings::DEFAULT_HOMEPAGE).expect("home url"));
-        // Taken before `url` moves into the host.
-        let url_text = url.to_string();
+            .and_then(|arg| parse_startup_arg(&arg, &settings.search_prefix));
+        // The previous run's tabs. When there are some, they come back in
+        // order and an argv URL opens as an extra tab on top of them —
+        // otherwise the argument (or the configured homepage) is the one
+        // starting tab, as before session restore existed.
+        let mut session = session::Session::new();
+        let (saved, saved_active) = session.load();
+        let restored = !saved.is_empty();
+        let mut queue = saved;
+        if queue.is_empty() {
+            queue.push(
+                arg.clone()
+                    .or_else(|| parse_url_input(&settings.homepage, &settings.search_prefix))
+                    .unwrap_or_else(|| {
+                        Url::parse(settings::DEFAULT_HOMEPAGE).expect("home url")
+                    }),
+            );
+        }
+        let first = queue.remove(0);
 
         #[cfg(all(not(feature = "wpe"), feature = "servo"))]
-        let mut host = Host::new(sender, url, (1200, 800), settings.color_scheme.forces_dark());
+        let mut host = Host::new(sender, first, (1200, 800), settings.color_scheme.forces_dark());
         #[cfg(feature = "wpe")]
         let mut host = {
             let _ = &sender; // WPE wakes through register_sources, not a waker
-            Host::new(url, (1200, 800))
+            Host::new(first, (1200, 800))
         };
+        for url in queue {
+            host.open_tab(url);
+        }
+        if restored {
+            host.activate(saved_active.min(host.tab_count() - 1));
+            if let Some(url) = arg {
+                host.open_tab(url);
+            }
+        }
+        // The bar mirrors whichever tab ended up active.
+        let url_text = host
+            .url()
+            .map(|u| u.to_string())
+            .filter(|s| s != "about:blank")
+            .unwrap_or_default();
         host.set_history_enabled(settings.history);
         host.set_color_scheme_dark(settings.color_scheme.is_dark());
         #[cfg(feature = "wpe")]
@@ -937,6 +989,7 @@ impl Application for BrowserApp {
             #[cfg(feature = "wpe")]
             sender,
             font_system: cce_ui::create_font_system(),
+            session,
         }
     }
 
@@ -1015,6 +1068,9 @@ impl Application for BrowserApp {
                 }
                 if dirty {
                     self.sync_page_state();
+                    // Navigation reaches the tab set through these signals,
+                    // so this is where an address change gets persisted.
+                    self.persist_session();
                 }
                 if new_frame || dirty {
                     *needs_rebuild = true;
@@ -1030,6 +1086,7 @@ impl Application for BrowserApp {
                             self.host.open_tab(url);
                             self.url_focused = false;
                             self.sync_page_state();
+                            self.persist_session();
                         }
                     }
                     None => self.new_tab(),
diff --git a/src/session.rs b/src/session.rs
new file mode 100644
index 0000000..c41bf2f
--- /dev/null
+++ b/src/session.rs
@@ -0,0 +1,129 @@
+//! Open-tab persistence: the tab set survives a restart.
+//!
+//! `~/.local/state/cce/browser/tabs.tsv` (the same state dir as history and
+//! bookmarks), one line per tab in strip order: `<1|0>\t<url>`, the flag
+//! marking the active tab. It is written eagerly on every tab-set change —
+//! open, close, switch, navigation — rather than on exit, so a crash or a
+//! compositor-side window close loses nothing; `save` skips the write when
+//! the serialization is unchanged, which keeps the loading-time signal storm
+//! from touching the disk more than once. Closing the last tab saves an
+//! empty set, so a deliberately emptied browser starts fresh on the
+//! homepage rather than resurrecting what was just closed.
+
+use std::path::PathBuf;
+
+use url::Url;
+
+pub struct Session {
+    path: PathBuf,
+    /// Last serialization written (or loaded), to skip no-op writes.
+    last: Option<String>,
+}
+
+impl Session {
+    pub fn new() -> Self {
+        Self::at(crate::pages::state_dir().join("tabs.tsv"))
+    }
+
+    fn at(path: PathBuf) -> Self {
+        Self { path, last: None }
+    }
+
+    /// Tabs saved by the previous run, in strip order, plus the active
+    /// index. Missing file or unparseable lines mean fewer tabs, never an
+    /// error; an empty result is "nothing to restore".
+    pub fn load(&mut self) -> (Vec<Url>, usize) {
+        let text = std::fs::read_to_string(&self.path).unwrap_or_default();
+        let mut tabs = Vec::new();
+        let mut active = 0;
+        for line in text.lines() {
+            let mut parts = line.splitn(2, '\t');
+            if let (Some(flag), Some(url)) = (parts.next(), parts.next()) {
+                if let Ok(u) = Url::parse(url) {
+                    if flag == "1" {
+                        active = tabs.len();
+                    }
+                    tabs.push(u);
+                }
+            }
+        }
+        self.last = Some(text);
+        (tabs, active)
+    }
+
+    /// Persist the open tabs; a no-op when nothing changed since the last
+    /// write.
+    pub fn save(&mut self, tabs: &[(String, bool)]) {
+        let mut out = String::new();
+        for (url, active) in tabs {
+            out.push_str(if *active { "1\t" } else { "0\t" });
+            out.push_str(url);
+            out.push('\n');
+        }
+        if self.last.as_deref() == Some(&out) {
+            return;
+        }
+        if let Some(dir) = self.path.parent() {
+            let _ = std::fs::create_dir_all(dir);
+        }
+        if std::fs::write(&self.path, &out).is_ok() {
+            self.last = Some(out);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn temp_session(name: &str) -> Session {
+        let dir = std::env::temp_dir().join("cce-browser-session-test");
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join(name);
+        let _ = std::fs::remove_file(&path);
+        Session::at(path)
+    }
+
+    #[test]
+    fn round_trips_tabs_and_active_index() {
+        let mut s = temp_session("round-trip.tsv");
+        s.save(&[
+            ("https://example.com/".to_string(), false),
+            ("https://example.org/".to_string(), true),
+        ]);
+
+        let mut fresh = Session::at(s.path.clone());
+        let (tabs, active) = fresh.load();
+        assert_eq!(
+            tabs.iter().map(Url::as_str).collect::<Vec<_>>(),
+            ["https://example.com/", "https://example.org/"]
+        );
+        assert_eq!(active, 1);
+    }
+
+    #[test]
+    fn empty_save_clears_and_loads_as_nothing() {
+        let mut s = temp_session("empty.tsv");
+        s.save(&[("https://example.com/".to_string(), true)]);
+        s.save(&[]);
+
+        let (tabs, active) = Session::at(s.path.clone()).load();
+        assert!(tabs.is_empty());
+        assert_eq!(active, 0);
+    }
+
+    #[test]
+    fn missing_file_and_junk_lines_load_as_fewer_tabs() {
+        let mut s = temp_session("missing.tsv");
+        assert!(s.load().0.is_empty());
+
+        std::fs::write(
+            &s.path,
+            "no-tab-here\n1\tnot a url\n0\thttps://example.com/\n",
+        )
+        .unwrap();
+        let (tabs, active) = Session::at(s.path.clone()).load();
+        assert_eq!(tabs.len(), 1);
+        assert_eq!(active, 0);
+    }
+}