web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
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);
+ }
+}