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

commit09551ae8adf290664f19124dbe1c20dcc7e2b7b7
parentba26eeeec3
authorLucas Galante <[email protected]>
date2026-08-11 12:36
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('&', "&amp;")
         .replace('<', "&lt;")
         .replace('>', "&gt;")
@@ -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()