web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
Add a history page (cce://history)
Completed page loads append to ~/.local/state/cce/browser/history.tsv
(XDG state dir; tab-separated ts/url/title, fields sanitized) and to an
in-memory store. A custom cce: protocol registered on ServoBuilder
serves cce://history as a real page rendered by Servo — dark-themed,
newest-first, entries are plain links, timestamps localized by a tiny
on-page script, plus a clear action (cce://history/clear). Ctrl+H opens
it in a new tab; the URL bar accepts cce:// URLs and maps about:history.
Recording rides the load-complete transition; TabSignals.loading became
Option<bool> because syncing the bool default swallowed the transition
when URL/title signals pumped before the first load-status callback.
Live-verified: visit recorded to the TSV, Ctrl+H opens History, the
entry renders with a localized timestamp, and clicking it navigates.
Co-Authored-By: Claude Fable 5 <[email protected]>
Cargo.toml | 1 +
src/history.rs | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 15 ++++-
src/webview.rs | 30 ++++++++-
4 files changed, 241 insertions(+), 4 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 36c409f..4a6d013 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ calloop = "0.13.0"
wayland-client = { version = "0.31", features = ["system"] }
servo = "0.4"
url = "2"
+http = "1"
dpi = "0.1"
euclid = "0.22"
rustls = { version = "0.23", features = ["aws-lc-rs"] }
diff --git a/src/history.rs b/src/history.rs
new file mode 100644
index 0000000..d71464f
--- /dev/null
+++ b/src/history.rs
@@ -0,0 +1,199 @@
+//! 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('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+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 0172bd2..b1fa847 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,6 +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 webview;
use url::Url;
@@ -165,8 +166,11 @@ fn parse_url_input(input: &str) -> Option<Url> {
if s.is_empty() {
return None;
}
+ if s.eq_ignore_ascii_case("about:history") {
+ return Url::parse("cce://history").ok();
+ }
if let Ok(u) = Url::parse(s) {
- if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about") {
+ if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about" | "cce") {
return Some(u);
}
}
@@ -538,6 +542,15 @@ 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") {
+ self.host.open_tab(url);
+ self.url_focused = false;
+ self.sync_page_state();
+ *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 };
diff --git a/src/webview.rs b/src/webview.rs
index bd11ac4..295872c 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -23,8 +23,10 @@ use servo::{
SoftwareRenderingContext, WebView, WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta,
WheelEvent, WheelMode,
};
+use servo::protocol_handler::ProtocolRegistry;
use url::Url;
+use crate::history::{CceProtocol, History};
use crate::Message;
/// Delegate-observed signals for one webview, polled by the app after each
@@ -34,7 +36,10 @@ struct TabSignals {
frame_ready: bool,
title: Option<String>,
url: Option<Url>,
- loading: bool,
+ /// None until Servo reports a load status — the sync must not mistake
+ /// the default for "finished loading" (that swallows the completion
+ /// transition history recording depends on).
+ loading: Option<bool>,
}
#[derive(Default)]
@@ -77,7 +82,7 @@ impl WebViewDelegate for Delegate {
}
fn notify_load_status_changed(&self, webview: WebView, status: LoadStatus) {
- self.with_tab(&webview, |t| t.loading = status != LoadStatus::Complete);
+ self.with_tab(&webview, |t| t.loading = Some(status != LoadStatus::Complete));
}
fn request_create_new(&self, _parent_webview: WebView, request: CreateNewWebViewRequest) {
@@ -123,6 +128,7 @@ pub struct ServoHost {
context: Rc<SoftwareRenderingContext>,
shared: Rc<HostShared>,
delegate: Rc<Delegate>,
+ history: std::sync::Arc<History>,
tabs: Vec<Tab>,
active: usize,
size_px: (u32, u32),
@@ -142,8 +148,15 @@ impl ServoHost {
.make_current()
.expect("make software rendering context current");
+ let history = std::sync::Arc::new(History::load());
+ let mut protocols = ProtocolRegistry::default();
+ if let Err(e) = protocols.register("cce", CceProtocol { history: history.clone() }) {
+ log::error!("failed to register cce: protocol: {e:?}");
+ }
+
let servo = ServoBuilder::default()
.event_loop_waker(Box::new(Waker(wake.clone())))
+ .protocol_registry(protocols)
.build();
let shared = Rc::new(HostShared::default());
@@ -160,6 +173,7 @@ impl ServoHost {
context,
shared,
delegate,
+ history,
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.
@@ -299,7 +313,17 @@ impl ServoHost {
if let Some(sig) = per.get_mut(&tab.webview.id()) {
tab.title = sig.title.clone();
tab.url = sig.url.clone();
- tab.loading = sig.loading;
+ if let Some(loading) = sig.loading.take() {
+ let was_loading = tab.loading;
+ tab.loading = loading;
+ // Load-complete transition: log the visit.
+ if was_loading && !loading {
+ if let Some(url) = &tab.url {
+ self.history
+ .record(url.as_str(), tab.title.as_deref().unwrap_or(""));
+ }
+ }
+ }
if std::mem::take(&mut sig.frame_ready) && i == self.active {
active_frame = true;
}