web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
Add a downloads manager (cce://downloads)
Servo has no download pipeline, so the chrome owns one: the delegate's
request_navigation denies navigations to downloadable file types
(archives, packages, pdf, ...) and hands the URL to a reqwest worker
that streams into the XDG download dir (user-dirs.dirs honored, unique
filenames). cce://downloads renders the store — filename links to the
file, size/progress/state per entry, clear-finished action — and
self-refreshes via a 1s meta tag while transfers are active, so
progress needs zero chrome plumbing. Starting a download surfaces the
downloads page (reusing an existing tab when one is on it); Ctrl+J /
about:downloads open it. Workers address entries by stable id, not Vec
index — clear-finished shifts positions.
Live-verified: clicking a .pdf link on a test page diverted to a
download, the Downloads tab auto-opened showing dummy.pdf at 13.0 KB
done, and the file landed intact in ~/Downloads (valid PDF).
Co-Authored-By: Claude Fable 5 <[email protected]>
Cargo.toml | 1 +
src/downloads.rs | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 44 ++++++++--
src/pages.rs | 17 ++--
src/webview.rs | 39 ++++++++-
5 files changed, 339 insertions(+), 17 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 4a6d013..b98a8d1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ wayland-client = { version = "0.31", features = ["system"] }
servo = "0.4"
url = "2"
http = "1"
+reqwest = { version = "0.12", features = ["blocking"] }
dpi = "0.1"
euclid = "0.22"
rustls = { version = "0.23", features = ["aws-lc-rs"] }
diff --git a/src/downloads.rs b/src/downloads.rs
new file mode 100644
index 0000000..08d336e
--- /dev/null
+++ b/src/downloads.rs
@@ -0,0 +1,255 @@
+//! Chrome-side downloads. Servo has no download pipeline, so navigations
+//! that target obviously-downloadable files are denied in the delegate and
+//! fetched here instead: reqwest workers stream into the user's Downloads
+//! directory, and `cce://downloads` renders the store — with a 1s
+//! meta-refresh while anything is active, so progress needs no chrome
+//! plumbing at all.
+
+use std::io::{Read, Write};
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use url::Url;
+
+use crate::pages::{html_escape, page};
+
+/// Extensions that download instead of navigating. Servo renders none of
+/// these; the common "click a release artifact" cases.
+const DOWNLOAD_EXTENSIONS: &[&str] = &[
+ "zip", "tar", "gz", "tgz", "xz", "bz2", "7z", "rar", "pdf", "iso", "img", "deb", "rpm",
+ "exe", "msi", "dmg", "appimage", "bin", "apk", "jar", "flatpak",
+];
+
+pub fn is_download_url(url: &Url) -> bool {
+ if !matches!(url.scheme(), "http" | "https") {
+ return false;
+ }
+ let path = url.path().to_ascii_lowercase();
+ DOWNLOAD_EXTENSIONS
+ .iter()
+ .any(|ext| path.ends_with(&format!(".{ext}")))
+}
+
+#[derive(Clone, PartialEq)]
+pub enum State {
+ Active,
+ Done,
+ Failed(String),
+}
+
+pub struct Download {
+ /// Stable handle for worker updates — `clear_finished` shifts Vec
+ /// positions, so indices must never cross a lock boundary.
+ id: u64,
+ pub ts: u64,
+ pub url: String,
+ pub filename: String,
+ pub path: PathBuf,
+ pub received: u64,
+ pub total: Option<u64>,
+ pub state: State,
+}
+
+#[derive(Default)]
+pub struct Downloads {
+ items: Mutex<Vec<Download>>,
+ next_id: std::sync::atomic::AtomicU64,
+}
+
+/// The user's download directory: XDG_DOWNLOAD_DIR from user-dirs.dirs
+/// when configured, else ~/Downloads.
+fn download_dir() -> PathBuf {
+ let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
+ let conf = home.join(".config/user-dirs.dirs");
+ if let Ok(text) = std::fs::read_to_string(conf) {
+ for line in text.lines() {
+ if let Some(rest) = line.trim().strip_prefix("XDG_DOWNLOAD_DIR=") {
+ let value = rest.trim_matches('"').replace("$HOME", &home.to_string_lossy());
+ if !value.is_empty() {
+ return PathBuf::from(value);
+ }
+ }
+ }
+ }
+ home.join("Downloads")
+}
+
+/// Minimal percent-decode for display filenames; anything path-hostile
+/// falls back untouched.
+fn percent_decode(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
+ if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
+ out.push(v);
+ i += 3;
+ continue;
+ }
+ }
+ out.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8(out).unwrap_or_else(|_| s.to_string())
+}
+
+fn filename_for(url: &Url) -> String {
+ let name = url
+ .path_segments()
+ .and_then(|mut s| s.next_back().map(str::to_string))
+ .map(|s| percent_decode(&s))
+ .unwrap_or_default();
+ let name = name.replace(['/', '\0'], "_");
+ if name.is_empty() { "download".to_string() } else { name }
+}
+
+/// `name.ext` → `name.1.ext` … until the path is free.
+fn unique_path(dir: &PathBuf, filename: &str) -> PathBuf {
+ let candidate = dir.join(filename);
+ if !candidate.exists() {
+ return candidate;
+ }
+ let (stem, ext) = match filename.rsplit_once('.') {
+ Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
+ _ => (filename.to_string(), String::new()),
+ };
+ for n in 1.. {
+ let candidate = dir.join(format!("{stem}.{n}{ext}"));
+ if !candidate.exists() {
+ return candidate;
+ }
+ }
+ unreachable!()
+}
+
+fn human_size(bytes: u64) -> String {
+ const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
+ let mut v = bytes as f64;
+ let mut unit = 0;
+ while v >= 1024.0 && unit < UNITS.len() - 1 {
+ v /= 1024.0;
+ unit += 1;
+ }
+ if unit == 0 { format!("{bytes} B") } else { format!("{v:.1} {}", UNITS[unit]) }
+}
+
+impl Downloads {
+ /// Start fetching `url` on a worker thread.
+ pub fn start(self: &Arc<Self>, url: Url) {
+ let dir = download_dir();
+ let _ = std::fs::create_dir_all(&dir);
+ let filename = filename_for(&url);
+ let path = unique_path(&dir, &filename);
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+ let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ self.items.lock().unwrap().push(Download {
+ id,
+ ts,
+ url: url.to_string(),
+ filename: path
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or(filename),
+ path: path.clone(),
+ received: 0,
+ total: None,
+ state: State::Active,
+ });
+
+ let store = self.clone();
+ std::thread::spawn(move || {
+ let result = store.fetch(id, url, path);
+ store.with_item(id, |item| {
+ item.state = match result {
+ Ok(()) => State::Done,
+ Err(e) => State::Failed(e),
+ };
+ });
+ });
+ }
+
+ fn with_item(&self, id: u64, f: impl FnOnce(&mut Download)) {
+ let mut items = self.items.lock().unwrap();
+ if let Some(item) = items.iter_mut().find(|d| d.id == id) {
+ f(item);
+ }
+ }
+
+ fn fetch(&self, id: u64, url: Url, path: PathBuf) -> Result<(), String> {
+ let client = reqwest::blocking::Client::builder()
+ .user_agent(concat!("cce-browser/", env!("CARGO_PKG_VERSION")))
+ .build()
+ .map_err(|e| e.to_string())?;
+ let mut resp = client.get(url).send().map_err(|e| e.to_string())?;
+ if !resp.status().is_success() {
+ return Err(format!("HTTP {}", resp.status()));
+ }
+ let total = resp.content_length();
+ self.with_item(id, |item| item.total = total);
+ let mut file = std::fs::File::create(&path).map_err(|e| e.to_string())?;
+ let mut buf = [0u8; 64 * 1024];
+ let mut received: u64 = 0;
+ loop {
+ let n = resp.read(&mut buf).map_err(|e| e.to_string())?;
+ if n == 0 {
+ break;
+ }
+ file.write_all(&buf[..n]).map_err(|e| e.to_string())?;
+ received += n as u64;
+ self.with_item(id, |item| item.received = received);
+ }
+ Ok(())
+ }
+
+ /// Drop finished/failed entries (files stay on disk).
+ pub fn clear_finished(&self) {
+ self.items.lock().unwrap().retain(|d| d.state == State::Active);
+ }
+
+ pub fn html(&self) -> String {
+ let items = self.items.lock().unwrap();
+ let any_active = items.iter().any(|d| d.state == State::Active);
+ let mut rows = String::new();
+ for d in items.iter().rev() {
+ let progress = match (&d.state, d.total) {
+ (State::Active, Some(total)) if total > 0 => format!(
+ "{} / {} ({}%)",
+ human_size(d.received),
+ human_size(total),
+ d.received * 100 / total
+ ),
+ (State::Active, _) => format!("{}...", human_size(d.received)),
+ (State::Done, _) => human_size(d.received),
+ (State::Failed(e), _) => format!("failed: {}", html_escape(e)),
+ };
+ rows.push_str(&format!(
+ "<div class=e><span class=w data-ts=\"{}\"></span>\
+ <a href=\"file://{}\">{}</a><span class=u>{}</span>\
+ <span class=w style=\"min-width:0\">{}</span></div>\n",
+ d.ts,
+ html_escape(&d.path.to_string_lossy()),
+ html_escape(&d.filename),
+ html_escape(&d.url),
+ progress,
+ ));
+ }
+ let meta = format!(
+ "{} downloads<a href=\"cce://downloads/clear\">clear finished</a>",
+ items.len()
+ );
+ let body = if items.is_empty() {
+ "<p class=empty>No downloads yet. Links to archives and binaries download here.</p>"
+ .to_string()
+ } else {
+ rows
+ };
+ // Self-refresh while transfers run; static once everything settled.
+ let head = if any_active { "<meta http-equiv=\"refresh\" content=\"1\">" } else { "" };
+ page("Downloads", &meta, &body, head)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 25505c5..5caf6b0 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 downloads;
mod pages;
mod webview;
@@ -183,6 +184,9 @@ fn parse_url_input(input: &str) -> Option<Url> {
if s.eq_ignore_ascii_case("about:bookmarks") {
return Url::parse("cce://bookmarks").ok();
}
+ if s.eq_ignore_ascii_case("about:downloads") {
+ return Url::parse("cce://downloads").ok();
+ }
if let Ok(u) = Url::parse(s) {
if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about" | "cce") {
return Some(u);
@@ -312,6 +316,27 @@ impl BrowserApp {
self.sync_page_state();
}
+ /// Show an internal page: reuse a tab already on it (reloading, so
+ /// live pages like downloads refresh), otherwise open a new one.
+ fn open_internal_page(&mut self, page: &str) {
+ let Ok(url) = Url::parse(page) else { return };
+ for i in 0..self.host.tab_count() {
+ let on_page = self
+ .host
+ .tab(i)
+ .and_then(|t| t.url.as_ref().map(|u| u.as_str().starts_with(page)))
+ .unwrap_or(false);
+ if on_page {
+ self.switch_tab(i);
+ self.host.reload();
+ return;
+ }
+ }
+ self.host.open_tab(url);
+ self.url_focused = false;
+ self.sync_page_state();
+ }
+
/// Widest prefix of `text` fitting `avail`, with a "…"-style tail cut.
fn fit_text(text: &str, sans: &str, size: f32, avail: f32) -> String {
if measure_text_width(text, sans, size) <= avail {
@@ -428,6 +453,9 @@ impl Application for BrowserApp {
match msg {
Message::Spin => {
let (new_frame, dirty) = self.host.pump();
+ if self.host.take_download_started() {
+ self.open_internal_page("cce://downloads");
+ }
if dirty {
self.sync_page_state();
}
@@ -558,14 +586,14 @@ impl Application for BrowserApp {
*needs_rebuild = true;
return self.close_tab(self.host.active_index());
}
- 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();
- *needs_rebuild = true;
- }
+ Key::Character(c) if c == "h" || c == "b" || c == "j" => {
+ let page = match c.as_str() {
+ "h" => "cce://history",
+ "b" => "cce://bookmarks",
+ _ => "cce://downloads",
+ };
+ self.open_internal_page(page);
+ *needs_rebuild = true;
return None;
}
Key::Character(c) if c == "d" => {
diff --git a/src/pages.rs b/src/pages.rs
index 228cb46..386034a 100644
--- a/src/pages.rs
+++ b/src/pages.rs
@@ -53,7 +53,7 @@ fn sanitize(s: &str) -> String {
s.replace(['\t', '\n', '\r'], " ")
}
-fn html_escape(s: &str) -> String {
+pub(crate) fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
@@ -88,9 +88,10 @@ fn write_tsv(path: &PathBuf, entries: &[Entry]) {
}
/// Shared page skeleton for the internal pages (dark, DE-toned).
-fn page(title: &str, meta: &str, body: &str) -> String {
+/// `head_extra` lands in <head> (e.g. a refresh tag for live pages).
+pub(crate) fn page(title: &str, meta: &str, body: &str, head_extra: &str) -> String {
format!(
- "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{title}</title><style>\
+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>{title}</title>{head_extra}<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}}\
@@ -179,7 +180,7 @@ impl History {
} else {
rows
};
- page("History", &meta, &body)
+ page("History", &meta, &body, "")
}
}
@@ -245,7 +246,7 @@ impl Bookmarks {
} else {
rows
};
- page("Bookmarks", &meta, &body)
+ page("Bookmarks", &meta, &body, "")
}
}
@@ -253,6 +254,7 @@ impl Bookmarks {
pub struct CceProtocol {
pub history: Arc<History>,
pub bookmarks: Arc<Bookmarks>,
+ pub downloads: Arc<crate::downloads::Downloads>,
}
impl ProtocolHandler for CceProtocol {
@@ -280,6 +282,11 @@ impl ProtocolHandler for CceProtocol {
}
Some(self.bookmarks.html())
}
+ "downloads" => Some(self.downloads.html()),
+ "downloads/clear" => {
+ self.downloads.clear_finished();
+ Some(self.downloads.html())
+ }
_ => None,
};
let response = match body {
diff --git a/src/webview.rs b/src/webview.rs
index 3ce1755..4031f99 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -19,13 +19,14 @@ use euclid::Scale;
use servo::{
CreateNewWebViewRequest, DeviceIntRect, DevicePoint, EventLoopWaker, InputEvent,
Key as DomKey, KeyState, KeyboardEvent, LoadStatus, MouseButton as DomMouseButton,
- MouseButtonAction, MouseButtonEvent, MouseMoveEvent, RenderingContext, Servo, ServoBuilder,
- SoftwareRenderingContext, WebView, WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta,
- WheelEvent, WheelMode,
+ MouseButtonAction, MouseButtonEvent, MouseMoveEvent, NavigationRequest, RenderingContext,
+ Servo, ServoBuilder, SoftwareRenderingContext, WebView, WebViewBuilder, WebViewDelegate,
+ WebViewId, WheelDelta, WheelEvent, WheelMode,
};
use servo::protocol_handler::ProtocolRegistry;
use url::Url;
+use crate::downloads::{is_download_url, Downloads};
use crate::pages::{Bookmarks, CceProtocol, History};
use crate::Message;
@@ -49,12 +50,16 @@ struct HostShared {
/// WebViews created by pages (window.open / target=_blank), built in the
/// delegate and adopted as tabs by the next `pump`.
pending_new: RefCell<Vec<WebView>>,
+ /// A navigation was diverted into a download; the app surfaces the
+ /// downloads page.
+ download_started: Cell<bool>,
}
struct Delegate {
shared: Rc<HostShared>,
wake: calloop::channel::Sender<Message>,
context: Rc<SoftwareRenderingContext>,
+ downloads: std::sync::Arc<Downloads>,
/// Handle to this same Rc'd delegate, so page-opened webviews can be
/// delegated back here; filled right after construction.
self_rc: RefCell<std::rc::Weak<Delegate>>,
@@ -85,6 +90,21 @@ impl WebViewDelegate for Delegate {
self.with_tab(&webview, |t| t.loading = Some(status != LoadStatus::Complete));
}
+ fn request_navigation(&self, _webview: WebView, request: NavigationRequest) {
+ // Navigations to downloadable files become chrome downloads —
+ // Servo has no download path of its own.
+ if is_download_url(&request.url) {
+ let url = request.url.clone();
+ request.deny();
+ self.downloads.start(url);
+ self.shared.download_started.set(true);
+ self.shared.dirty.set(true);
+ let _ = self.wake.send(Message::Spin);
+ } else {
+ request.allow();
+ }
+ }
+
fn request_create_new(&self, _parent_webview: WebView, request: CreateNewWebViewRequest) {
let Some(delegate) = self.self_rc.borrow().upgrade() else {
return; // dropping the request denies it
@@ -151,8 +171,13 @@ impl ServoHost {
let history = std::sync::Arc::new(History::load());
let bookmarks = std::sync::Arc::new(Bookmarks::load());
+ let downloads = std::sync::Arc::new(Downloads::default());
let mut protocols = ProtocolRegistry::default();
- let handler = CceProtocol { history: history.clone(), bookmarks: bookmarks.clone() };
+ let handler = CceProtocol {
+ history: history.clone(),
+ bookmarks: bookmarks.clone(),
+ downloads: downloads.clone(),
+ };
if let Err(e) = protocols.register("cce", handler) {
log::error!("failed to register cce: protocol: {e:?}");
}
@@ -167,6 +192,7 @@ impl ServoHost {
shared: shared.clone(),
wake,
context: context.clone(),
+ downloads: downloads.clone(),
self_rc: RefCell::new(std::rc::Weak::new()),
});
*delegate.self_rc.borrow_mut() = Rc::downgrade(&delegate);
@@ -356,6 +382,11 @@ impl ServoHost {
self.active_tab().loading
}
+ /// A navigation became a download since the last check.
+ pub fn take_download_started(&self) -> bool {
+ self.shared.download_started.take()
+ }
+
/// Whether the active tab's page is bookmarked.
pub fn active_bookmarked(&self) -> bool {
self.active_tab()