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

commitf55e6bf6fc3151460badcaac0925aa8bf8874615
parent486c77423e
authorLucas Galante <[email protected]>
date2026-08-28 11:06
feat(wpe): downloads through WebKit's own pipeline

Shadow-verified against a server that serves no extension in the URL and
puts the name only in Content-Disposition:

  URL      http://127.0.0.1:8760/getfile
  landed   ~/Downloads/report.tar.gz   6291456 bytes

Both halves of that are cases the Servo path cannot reach. is_download_url
sniffs the URL's extension, so /getfile would have been navigated to
rather than downloaded, and even if it had matched, the file would have
been named "getfile" — the real name existed only in a header the sniff
never sees. WebKit decides by content type and hands us the server's
suggested filename, so the sniff, its blind spot, and the argv bug fixed
in 5a85c97 all stop being categories rather than being reimplemented.

The store and the cce://downloads page are untouched; only who moves the
bytes changed. Downloads::adopt registers an engine-driven entry and
set_progress / set_finished report against it, so the page renders
identically on both backends. destination_for is shared, so the configured
download directory and the name.1.ext de-duplication apply either way.

Per-download signal state is an Rc owned by that download's own closures
and released by their destroy-notify, the same discipline as the tab
signals: the closures outlive any borrow, and WebKit may emit after we
have stopped holding anything.

Servo's is_download_url and its request_navigation divert are still
compiled and still correct for that backend; under the wpe feature they
are simply never reached.

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

 src/downloads.rs |  59 ++++++++++++++++++++++++
 src/wpe/host.rs  | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 192 insertions(+)

diff --git a/src/downloads.rs b/src/downloads.rs
index 4ca4fb2..7d406a9 100644
--- a/src/downloads.rs
+++ b/src/downloads.rs
@@ -184,6 +184,65 @@ impl Downloads {
         });
     }
 
+    /// Register a download the *engine* is performing, rather than one of
+    /// our own reqwest workers.
+    ///
+    /// WebKit does its own fetching, and does it better: it decides by
+    /// content type and honours `Content-Disposition`, where
+    /// [`is_download_url`] can only guess from the extension. The store and
+    /// the `cce://downloads` page are unchanged — only who moves the bytes.
+    /// Returns the id to report progress against.
+    pub fn adopt(&self, url: String, path: PathBuf, total: Option<u64>) -> u64 {
+        let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+        let ts = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map(|d| d.as_secs())
+            .unwrap_or(0);
+        self.items.lock().unwrap().push(Download {
+            id,
+            ts,
+            url,
+            filename: path
+                .file_name()
+                .map(|n| n.to_string_lossy().into_owned())
+                .unwrap_or_else(|| "download".into()),
+            path,
+            received: 0,
+            total,
+            state: State::Active,
+        });
+        id
+    }
+
+    pub fn set_progress(&self, id: u64, received: u64, total: Option<u64>) {
+        self.with_item(id, |d| {
+            d.received = received;
+            if total.is_some() {
+                d.total = total;
+            }
+        });
+    }
+
+    pub fn set_finished(&self, id: u64, result: Result<(), String>) {
+        self.with_item(id, |d| {
+            d.state = match result {
+                Ok(()) => State::Done,
+                Err(e) => State::Failed(e),
+            };
+        });
+    }
+
+    /// Where a download should land, given the name the server suggested.
+    /// Shared with the engine-driven path so both honour the configured
+    /// directory and the `name.1.ext` de-duplication.
+    pub fn destination_for(suggested: &str) -> PathBuf {
+        let dir = download_dir();
+        let _ = std::fs::create_dir_all(&dir);
+        let name = suggested.replace(['/', '\0'], "_");
+        let name = if name.trim().is_empty() { "download" } else { name.trim() };
+        unique_path(&dir, name)
+    }
+
     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) {
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index c35b9f5..1aaf3c7 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -209,6 +209,27 @@ impl WebKitHost {
 
             let download_started = Rc::new(Cell::new(false));
 
+            // WebKit fetches downloads itself, and decides what *is* one by
+            // content type — so the extension sniff `is_download_url` exists
+            // for is simply not needed here, and neither is the argv/URL-bar
+            // blind spot it created.
+            let ctxs = Rc::new(DownloadCtx {
+                downloads: downloads.clone(),
+                started: download_started.clone(),
+            });
+            let sig = cstr("download-started");
+            g_signal_connect_data(
+                session as *mut _,
+                sig.as_ptr(),
+                Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
+                    on_download_started
+                        as unsafe extern "C" fn(*mut GObject, *mut WebKitDownload, gpointer),
+                )),
+                Rc::into_raw(ctxs) as gpointer,
+                None,
+                0,
+            );
+
             let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
             let sink = pending.clone();
             FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
@@ -791,3 +812,115 @@ unsafe extern "C" fn on_cce_request(request: *mut WebKitURISchemeRequest, data:
 unsafe extern "C" fn free_boxed(p: gpointer) {
     drop(Box::from_raw(p as *mut u8));
 }
+
+/// Shared with WebKit's download signals for the life of the process.
+struct DownloadCtx {
+    downloads: std::sync::Arc<crate::downloads::Downloads>,
+    started: Rc<Cell<bool>>,
+}
+
+/// Per-download state, owned by that download's own signal closures.
+struct OneDownload {
+    ctx: Rc<DownloadCtx>,
+    id: Cell<u64>,
+}
+
+unsafe extern "C" fn on_download_started(
+    _session: *mut GObject,
+    download: *mut WebKitDownload,
+    data: gpointer,
+) {
+    let ctx = &*(data as *const DownloadCtx);
+    let one = Rc::new(OneDownload {
+        ctx: Rc::new(DownloadCtx {
+            downloads: ctx.downloads.clone(),
+            started: ctx.started.clone(),
+        }),
+        id: Cell::new(u64::MAX),
+    });
+    ctx.started.set(true);
+
+    for (sig, cb) in [
+        (
+            "decide-destination",
+            on_decide_destination as *const () as usize,
+        ),
+        ("received-data", on_received_data as *const () as usize),
+        ("finished", on_finished as *const () as usize),
+        ("failed", on_failed as *const () as usize),
+    ] {
+        let name = cstr(sig);
+        g_signal_connect_data(
+            download as *mut _,
+            name.as_ptr(),
+            Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
+            Rc::into_raw(one.clone()) as gpointer,
+            Some(drop_one_download),
+            0,
+        );
+    }
+}
+
+unsafe extern "C" fn drop_one_download(data: gpointer, _c: *mut GClosure) {
+    drop(Rc::from_raw(data as *const OneDownload));
+}
+
+/// WebKit asks where to put it, passing the name the *server* suggested —
+/// `Content-Disposition` when present, which the extension sniff could never
+/// see. Returning TRUE means we handled it.
+unsafe extern "C" fn on_decide_destination(
+    download: *mut WebKitDownload,
+    suggested: *const c_char,
+    data: gpointer,
+) -> gboolean {
+    let one = &*(data as *const OneDownload);
+    let name = from_cstr(suggested).unwrap_or_else(|| "download".into());
+    let path = crate::downloads::Downloads::destination_for(&name);
+
+    let total = {
+        let response = webkit_download_get_response(download);
+        (!response.is_null())
+            .then(|| webkit_uri_response_get_content_length(response))
+            .filter(|n| *n > 0)
+    };
+    let uri = from_cstr(webkit_download_get_destination(download)).unwrap_or_default();
+    one.id
+        .set(one.ctx.downloads.adopt(uri, path.clone(), total));
+
+    let dest = cstr(&path.to_string_lossy());
+    webkit_download_set_destination(download, dest.as_ptr());
+    1
+}
+
+unsafe extern "C" fn on_received_data(
+    download: *mut WebKitDownload,
+    _len: u64,
+    data: gpointer,
+) {
+    let one = &*(data as *const OneDownload);
+    if one.id.get() != u64::MAX {
+        one.ctx.downloads.set_progress(
+            one.id.get(),
+            webkit_download_get_received_data_length(download),
+            None,
+        );
+    }
+}
+
+unsafe extern "C" fn on_finished(_d: *mut WebKitDownload, data: gpointer) {
+    let one = &*(data as *const OneDownload);
+    if one.id.get() != u64::MAX {
+        one.ctx.downloads.set_finished(one.id.get(), Ok(()));
+    }
+}
+
+unsafe extern "C" fn on_failed(_d: *mut WebKitDownload, error: *mut GError, data: gpointer) {
+    let one = &*(data as *const OneDownload);
+    let msg = (!error.is_null())
+        .then(|| from_cstr((*error).message))
+        .flatten()
+        .unwrap_or_else(|| "download failed".into());
+    if one.id.get() != u64::MAX {
+        one.ctx.downloads.set_finished(one.id.get(), Err(msg));
+    }
+}