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 }