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

src/downloads.rs (12.5K)

  1 //! Chrome-side downloads. Servo has no download pipeline, so navigations
  2 //! that target obviously-downloadable files are denied in the delegate and
  3 //! fetched here instead: reqwest workers stream into the user's Downloads
  4 //! directory, and `cce://downloads` renders the store — with a 1s
  5 //! meta-refresh while anything is active, so progress needs no chrome
  6 //! plumbing at all.
  7 
  8 use std::io::{Read, Write};
  9 use std::path::PathBuf;
 10 use std::sync::{Arc, Mutex};
 11 use std::time::{SystemTime, UNIX_EPOCH};
 12 
 13 use url::Url;
 14 
 15 use crate::pages::{html_escape, page};
 16 
 17 /// Extensions that download instead of navigating. Servo renders none of
 18 /// these; the common "click a release artifact" cases.
 19 const DOWNLOAD_EXTENSIONS: &[&str] = &[
 20     "zip", "tar", "gz", "tgz", "xz", "bz2", "7z", "rar", "pdf", "iso", "img", "deb", "rpm",
 21     "exe", "msi", "dmg", "appimage", "bin", "apk", "jar", "flatpak",
 22 ];
 23 
 24 pub fn is_download_url(url: &Url) -> bool {
 25     if !matches!(url.scheme(), "http" | "https") {
 26         return false;
 27     }
 28     let path = url.path().to_ascii_lowercase();
 29     DOWNLOAD_EXTENSIONS
 30         .iter()
 31         .any(|ext| path.ends_with(&format!(".{ext}")))
 32 }
 33 
 34 #[derive(Clone, PartialEq)]
 35 pub enum State {
 36     Active,
 37     Done,
 38     Failed(String),
 39 }
 40 
 41 pub struct Download {
 42     /// Stable handle for worker updates — `clear_finished` shifts Vec
 43     /// positions, so indices must never cross a lock boundary.
 44     id: u64,
 45     pub ts: u64,
 46     pub url: String,
 47     pub filename: String,
 48     pub path: PathBuf,
 49     pub received: u64,
 50     pub total: Option<u64>,
 51     pub state: State,
 52 }
 53 
 54 #[derive(Default)]
 55 pub struct Downloads {
 56     items: Mutex<Vec<Download>>,
 57     next_id: std::sync::atomic::AtomicU64,
 58 }
 59 
 60 /// Settings override for the download directory (None = XDG default).
 61 /// A global because downloads run on worker threads.
 62 static DIR_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
 63 
 64 pub fn set_download_dir(dir: Option<PathBuf>) {
 65     *DIR_OVERRIDE.lock().unwrap() = dir;
 66 }
 67 
 68 /// The user's download directory: the settings override when set, else
 69 /// XDG_DOWNLOAD_DIR from user-dirs.dirs, else ~/Downloads.
 70 fn download_dir() -> PathBuf {
 71     if let Some(dir) = DIR_OVERRIDE.lock().unwrap().clone() {
 72         return dir;
 73     }
 74     let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
 75     let conf = home.join(".config/user-dirs.dirs");
 76     if let Ok(text) = std::fs::read_to_string(conf) {
 77         for line in text.lines() {
 78             if let Some(rest) = line.trim().strip_prefix("XDG_DOWNLOAD_DIR=") {
 79                 let value = rest.trim_matches('"').replace("$HOME", &home.to_string_lossy());
 80                 if !value.is_empty() {
 81                     return PathBuf::from(value);
 82                 }
 83             }
 84         }
 85     }
 86     home.join("Downloads")
 87 }
 88 
 89 /// Minimal percent-decode for display filenames; anything path-hostile
 90 /// falls back untouched.
 91 fn percent_decode(s: &str) -> String {
 92     let bytes = s.as_bytes();
 93     let mut out = Vec::with_capacity(bytes.len());
 94     let mut i = 0;
 95     while i < bytes.len() {
 96         if bytes[i] == b'%' && i + 2 < bytes.len() {
 97             if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
 98                 out.push(v);
 99                 i += 3;
100                 continue;
101             }
102         }
103         out.push(bytes[i]);
104         i += 1;
105     }
106     String::from_utf8(out).unwrap_or_else(|_| s.to_string())
107 }
108 
109 fn filename_for(url: &Url) -> String {
110     let name = url
111         .path_segments()
112         .and_then(|mut s| s.next_back().map(str::to_string))
113         .map(|s| percent_decode(&s))
114         .unwrap_or_default();
115     let name = name.replace(['/', '\0'], "_");
116     if name.is_empty() { "download".to_string() } else { name }
117 }
118 
119 /// `name.ext` → `name.1.ext` … until the path is free.
120 fn unique_path(dir: &PathBuf, filename: &str) -> PathBuf {
121     let candidate = dir.join(filename);
122     if !candidate.exists() {
123         return candidate;
124     }
125     let (stem, ext) = match filename.rsplit_once('.') {
126         Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
127         _ => (filename.to_string(), String::new()),
128     };
129     for n in 1.. {
130         let candidate = dir.join(format!("{stem}.{n}{ext}"));
131         if !candidate.exists() {
132             return candidate;
133         }
134     }
135     unreachable!()
136 }
137 
138 fn human_size(bytes: u64) -> String {
139     const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
140     let mut v = bytes as f64;
141     let mut unit = 0;
142     while v >= 1024.0 && unit < UNITS.len() - 1 {
143         v /= 1024.0;
144         unit += 1;
145     }
146     if unit == 0 { format!("{bytes} B") } else { format!("{v:.1} {}", UNITS[unit]) }
147 }
148 
149 impl Downloads {
150     /// Start fetching `url` on a worker thread. Servo path only: WebKit does
151     /// its own fetching and enters the store through [`Downloads::adopt`].
152     #[cfg(feature = "servo")]
153     pub fn start(self: &Arc<Self>, url: Url) {
154         let dir = download_dir();
155         let _ = std::fs::create_dir_all(&dir);
156         let filename = filename_for(&url);
157         let path = unique_path(&dir, &filename);
158         let ts = SystemTime::now()
159             .duration_since(UNIX_EPOCH)
160             .map(|d| d.as_secs())
161             .unwrap_or(0);
162         let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
163         self.items.lock().unwrap().push(Download {
164             id,
165             ts,
166             url: url.to_string(),
167             filename: path
168                 .file_name()
169                 .map(|n| n.to_string_lossy().into_owned())
170                 .unwrap_or(filename),
171             path: path.clone(),
172             received: 0,
173             total: None,
174             state: State::Active,
175         });
176 
177         let store = self.clone();
178         std::thread::spawn(move || {
179             let result = store.fetch(id, url, path);
180             store.with_item(id, |item| {
181                 item.state = match result {
182                     Ok(()) => State::Done,
183                     Err(e) => State::Failed(e),
184                 };
185             });
186         });
187     }
188 
189     /// Register a download the *engine* is performing, rather than one of
190     /// our own reqwest workers.
191     ///
192     /// WebKit does its own fetching, and does it better: it decides by
193     /// content type and honours `Content-Disposition`, where
194     /// [`is_download_url`] can only guess from the extension. The store and
195     /// the `cce://downloads` page are unchanged — only who moves the bytes.
196     /// Returns the id to report progress against.
197     pub fn adopt(&self, url: String, path: PathBuf, total: Option<u64>) -> u64 {
198         let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
199         let ts = SystemTime::now()
200             .duration_since(UNIX_EPOCH)
201             .map(|d| d.as_secs())
202             .unwrap_or(0);
203         self.items.lock().unwrap().push(Download {
204             id,
205             ts,
206             url,
207             filename: path
208                 .file_name()
209                 .map(|n| n.to_string_lossy().into_owned())
210                 .unwrap_or_else(|| "download".into()),
211             path,
212             received: 0,
213             total,
214             state: State::Active,
215         });
216         id
217     }
218 
219     pub fn set_progress(&self, id: u64, received: u64, total: Option<u64>) {
220         self.with_item(id, |d| {
221             d.received = received;
222             if total.is_some() {
223                 d.total = total;
224             }
225         });
226     }
227 
228     pub fn set_finished(&self, id: u64, result: Result<(), String>) {
229         self.with_item(id, |d| {
230             d.state = match result {
231                 Ok(()) => State::Done,
232                 Err(e) => State::Failed(e),
233             };
234         });
235     }
236 
237     /// Where a download should land, given the name the server suggested.
238     /// Shared with the engine-driven path so both honour the configured
239     /// directory and the `name.1.ext` de-duplication.
240     pub fn destination_for(suggested: &str) -> PathBuf {
241         let dir = download_dir();
242         let _ = std::fs::create_dir_all(&dir);
243         let name = suggested.replace(['/', '\0'], "_");
244         let name = if name.trim().is_empty() { "download" } else { name.trim() };
245         unique_path(&dir, name)
246     }
247 
248     fn with_item(&self, id: u64, f: impl FnOnce(&mut Download)) {
249         let mut items = self.items.lock().unwrap();
250         if let Some(item) = items.iter_mut().find(|d| d.id == id) {
251             f(item);
252         }
253     }
254 
255     #[cfg(feature = "servo")]
256     fn fetch(&self, id: u64, url: Url, path: PathBuf) -> Result<(), String> {
257         let client = reqwest::blocking::Client::builder()
258             .user_agent(concat!("cce-browser/", env!("CARGO_PKG_VERSION")))
259             .build()
260             .map_err(|e| e.to_string())?;
261         let mut resp = client.get(url).send().map_err(|e| e.to_string())?;
262         if !resp.status().is_success() {
263             return Err(format!("HTTP {}", resp.status()));
264         }
265         let total = resp.content_length();
266         self.with_item(id, |item| item.total = total);
267         let mut file = std::fs::File::create(&path).map_err(|e| e.to_string())?;
268         let mut buf = [0u8; 64 * 1024];
269         let mut received: u64 = 0;
270         loop {
271             let n = resp.read(&mut buf).map_err(|e| e.to_string())?;
272             if n == 0 {
273                 break;
274             }
275             file.write_all(&buf[..n]).map_err(|e| e.to_string())?;
276             received += n as u64;
277             self.with_item(id, |item| item.received = received);
278         }
279         Ok(())
280     }
281 
282     /// Drop finished/failed entries (files stay on disk).
283     pub fn clear_finished(&self) {
284         self.items.lock().unwrap().retain(|d| d.state == State::Active);
285     }
286 
287     pub fn html(&self) -> String {
288         let items = self.items.lock().unwrap();
289         let any_active = items.iter().any(|d| d.state == State::Active);
290         let mut rows = String::new();
291         for d in items.iter().rev() {
292             let progress = match (&d.state, d.total) {
293                 (State::Active, Some(total)) if total > 0 => format!(
294                     "{} / {} ({}%)",
295                     human_size(d.received),
296                     human_size(total),
297                     d.received * 100 / total
298                 ),
299                 (State::Active, _) => format!("{}...", human_size(d.received)),
300                 (State::Done, _) => human_size(d.received),
301                 (State::Failed(e), _) => format!("failed: {}", html_escape(e)),
302             };
303             rows.push_str(&format!(
304                 "<div class=e><span class=w data-ts=\"{}\"></span>\
305                  <a href=\"file://{}\">{}</a><span class=u>{}</span>\
306                  <span class=w style=\"min-width:0\">{}</span></div>\n",
307                 d.ts,
308                 html_escape(&d.path.to_string_lossy()),
309                 html_escape(&d.filename),
310                 html_escape(&d.url),
311                 progress,
312             ));
313         }
314         let meta = format!(
315             "{} downloads<a href=\"cce://downloads/clear\">clear finished</a>",
316             items.len()
317         );
318         let body = if items.is_empty() {
319             "<p class=empty>No downloads yet. Links to archives and binaries download here.</p>"
320                 .to_string()
321         } else {
322             rows
323         };
324         // Self-refresh while transfers run; static once everything settled.
325         let head = if any_active { "<meta http-equiv=\"refresh\" content=\"1\">" } else { "" };
326         page("Downloads", &meta, &body, head)
327     }
328 }
329 
330 #[cfg(test)]
331 mod tests {
332     use super::*;
333 
334     /// The argv case: a release-artifact URL must be recognized before it is
335     /// ever handed to Servo. `request_navigation` does not fire for a URL the
336     /// embedder supplies, so `ServoHost::take_as_download` is the only thing
337     /// standing between this and Servo's "Unknown content type" page.
338     #[test]
339     fn download_urls_are_recognized_by_extension() {
340         for u in [
341             "https://example.com/rel/app-1.2.3.tar.gz",
342             "http://127.0.0.1:8740/big.bin",
343             "https://example.com/Installer.EXE",
344             "https://example.com/x.zip?token=abc",
345         ] {
346             assert!(is_download_url(&Url::parse(u).unwrap()), "should download: {u}");
347         }
348     }
349 
350     #[test]
351     fn ordinary_pages_and_non_http_schemes_are_not_downloads() {
352         for u in [
353             "https://www.cloudflare.com/",
354             "https://example.com/page.html",
355             "https://example.com/binary",       // no extension: navigates
356             "cce://downloads",
357             "file:///home/me/x.zip",            // only http(s) is fetched here
358         ] {
359             assert!(!is_download_url(&Url::parse(u).unwrap()), "should not download: {u}");
360         }
361     }
362 }