1use std::collections::{HashMap, HashSet, VecDeque};
31use std::path::{Path, PathBuf};
32use std::sync::{Arc, Mutex, Weak};
33use std::time::{Duration, Instant, SystemTime};
34
35use anyhow::{anyhow, bail, Context, Result};
36use serde::Serialize;
37use serde_json::json;
38use tokio::sync::{broadcast, Notify};
39
40use crate::bidi::BidiEvent;
41use crate::cdp::CdpEvent;
42use crate::session::backend::TabBackend;
43
44pub const RECORD_CAP: usize = 200;
46const REAPPLY_DEBOUNCE: Duration = Duration::from_millis(250);
48const APPLY_TIMEOUT: Duration = Duration::from_secs(2);
50const SWEEP_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
53pub const DEFAULT_BEGIN_TIMEOUT: Duration = Duration::from_secs(5);
55pub const DEFAULT_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);
57pub const MAX_WAIT: Duration = Duration::from_secs(120);
59
60pub const DOWNLOAD_DIR_ENV: &str = "BROWSER_CONTROL_DOWNLOAD_DIR";
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Mode {
67 Managed { staging: PathBuf },
68 Unmanaged { reason: &'static str },
69}
70
71pub const UNMANAGED_ATTACHED: &str = "this browser was not launched by browser-control (or is an external endpoint), so its download settings are left alone and downloads go wherever the browser's own settings put them (possibly a Save dialog). Start a managed browser with browser_start to get silent downloads into a staging folder";
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum DlState {
76 InProgress,
77 Completed,
78 Canceled,
79}
80
81impl DlState {
82 fn label(self) -> &'static str {
83 match self {
84 DlState::InProgress => "in_progress",
85 DlState::Completed => "completed",
86 DlState::Canceled => "canceled",
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize)]
93pub struct DownloadRecord {
94 #[serde(skip)]
96 pub seq: u64,
97 pub id: String,
99 pub url: String,
100 pub suggested_filename: String,
101 pub state: DlState,
102 pub received_bytes: Option<u64>,
103 pub total_bytes: Option<u64>,
104 pub path: Option<PathBuf>,
106 pub note: Option<String>,
108 pub owned: bool,
113 #[serde(skip)]
116 pub events_lost: bool,
117 #[serde(skip)]
119 pub finalizing: bool,
120 #[serde(skip)]
122 pub frame: Option<String>,
123 #[serde(skip)]
124 pub began: Option<SystemTime>,
125}
126
127impl DownloadRecord {
128 fn new(id: String, url: &str, suggested: &str, frame: Option<String>, owned: bool) -> Self {
129 Self {
130 seq: 0,
131 id,
132 url: url.to_string(),
133 suggested_filename: suggested.to_string(),
134 state: DlState::InProgress,
135 received_bytes: None,
136 total_bytes: None,
137 path: None,
138 note: None,
139 owned,
140 events_lost: false,
141 finalizing: false,
142 frame,
143 began: Some(SystemTime::now()),
144 }
145 }
146}
147
148const OPENER_CAP: usize = 2000;
150
151#[derive(Default)]
152struct Inner {
153 records: VecDeque<DownloadRecord>,
154 next_seq: u64,
155 last_action_mark: u64,
157 last_returned_seq: u64,
160 in_flight: u32,
162 touched: HashSet<String>,
164 openers: HashMap<String, String>,
168 known: HashSet<String>,
170 backend_key: Option<usize>,
172 mode: Option<Mode>,
173 router: Option<tokio::task::JoinHandle<()>>,
174 last_applied: Option<Instant>,
175 bidi_subscribed: bool,
177 bidi_behavior_unsupported: bool,
179 bidi_events_unavailable: bool,
181 cdp_discovering: bool,
183 swept: bool,
184}
185
186impl Inner {
187 fn find_mut(&mut self, id: &str) -> Option<&mut DownloadRecord> {
188 self.records.iter_mut().rev().find(|r| r.id == id)
189 }
190
191 fn push(&mut self, mut r: DownloadRecord) {
192 self.next_seq += 1;
193 r.seq = self.next_seq;
194 if self.records.len() >= RECORD_CAP {
195 self.records.pop_front();
196 }
197 self.records.push_back(r);
198 }
199
200 fn staging(&self) -> Option<PathBuf> {
201 match &self.mode {
202 Some(Mode::Managed { staging }) => Some(staging.clone()),
203 _ => None,
204 }
205 }
206
207 fn owns(&self, frame: Option<&str>) -> bool {
213 let Some(mut cur) = frame else {
214 return self.in_flight > 0;
215 };
216 for _ in 0..16 {
217 if self.touched.contains(cur) {
218 return true;
219 }
220 match self.openers.get(cur) {
221 Some(o) => cur = o,
222 None => break,
223 }
224 }
225 if self.known.contains(cur) {
226 false
227 } else {
228 self.in_flight > 0
229 }
230 }
231
232 fn note_opener(&mut self, child: &str, opener: &str) {
233 if self.openers.len() >= OPENER_CAP {
234 self.openers.clear();
235 }
236 self.openers.insert(child.to_string(), opener.to_string());
237 }
238}
239
240struct FinalizeJob {
242 id: String,
243 staging: PathBuf,
244 suggested: String,
245 received: Option<u64>,
246 began: Option<SystemTime>,
247}
248
249pub struct DownloadHub {
250 inner: Arc<Mutex<Inner>>,
251 notify: Arc<Notify>,
252}
253
254impl Default for DownloadHub {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260impl Drop for DownloadHub {
261 fn drop(&mut self) {
262 if let Ok(mut g) = self.inner.lock() {
263 if let Some(h) = g.router.take() {
264 h.abort();
265 }
266 }
267 }
268}
269
270fn backend_key(b: &TabBackend) -> usize {
271 match b {
272 TabBackend::Cdp(c) => Arc::as_ptr(c) as *const () as usize,
273 TabBackend::Bidi(c) => Arc::as_ptr(c) as *const () as usize,
274 }
275}
276
277impl DownloadHub {
278 pub fn new() -> Self {
279 Self {
280 inner: Arc::new(Mutex::new(Inner::default())),
281 notify: Arc::new(Notify::new()),
282 }
283 }
284
285 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
286 self.inner.lock().unwrap_or_else(|p| p.into_inner())
287 }
288
289 pub fn mode_for(&self, backend: &TabBackend) -> Option<Mode> {
291 let g = self.lock();
292 (g.backend_key == Some(backend_key(backend)))
293 .then(|| g.mode.clone())
294 .flatten()
295 }
296
297 pub fn mode(&self) -> Option<Mode> {
299 self.lock().mode.clone()
300 }
301
302 pub fn set_mode(&self, backend: &TabBackend, mode: Mode) {
305 let mut g = self.lock();
306 let key = backend_key(backend);
307 if g.backend_key != Some(key) {
308 if let Some(h) = g.router.take() {
309 h.abort();
310 }
311 g.backend_key = Some(key);
312 g.last_applied = None;
313 g.bidi_subscribed = false;
314 g.bidi_behavior_unsupported = false;
315 g.bidi_events_unavailable = false;
316 g.cdp_discovering = false;
317 g.known.clear();
318 g.swept = false;
319 }
320 g.mode = Some(mode);
321 }
322
323 pub fn reset(&self) {
325 let mut g = self.lock();
326 if let Some(h) = g.router.take() {
327 h.abort();
328 }
329 *g = Inner {
330 next_seq: g.next_seq,
331 last_action_mark: g.next_seq,
332 last_returned_seq: g.next_seq,
333 in_flight: g.in_flight,
334 ..Default::default()
335 };
336 }
337
338 pub fn begin_action(&self) -> u64 {
344 let mut g = self.lock();
345 g.last_action_mark = g.next_seq;
346 g.last_applied = None;
347 g.in_flight += 1;
348 g.next_seq
349 }
350
351 pub fn end_action(&self) {
353 let mut g = self.lock();
354 g.in_flight = g.in_flight.saturating_sub(1);
355 }
356
357 pub fn touch(&self, target_id: &str) {
359 let mut g = self.lock();
360 if !g.touched.contains(target_id) {
361 g.touched.insert(target_id.to_string());
362 }
363 }
364
365 pub fn invalidate(&self) {
368 self.lock().last_applied = None;
369 }
370
371 pub fn since(&self, mark: u64) -> Vec<DownloadRecord> {
373 self.lock()
374 .records
375 .iter()
376 .filter(|r| r.seq > mark && r.owned)
377 .cloned()
378 .collect()
379 }
380
381 pub fn list(&self) -> Vec<DownloadRecord> {
382 let mut out: Vec<_> = self.lock().records.iter().cloned().collect();
383 for r in &mut out {
384 resolve_lazily(r, &self.inner);
385 }
386 out
387 }
388
389 pub async fn apply(&self, backend: &TabBackend, force: bool) {
393 let staging = {
394 let mut g = self.lock();
395 if g.backend_key != Some(backend_key(backend)) {
396 return;
397 }
398 let Some(staging) = g.staging() else {
399 return;
400 };
401 if !force
402 && g.last_applied
403 .is_some_and(|t| t.elapsed() < REAPPLY_DEBOUNCE)
404 {
405 return;
406 }
407 g.last_applied = Some(Instant::now());
408 if g.router.is_none() {
409 let weak = Arc::downgrade(&self.inner);
410 let notify = self.notify.clone();
411 g.router = Some(match backend {
412 TabBackend::Cdp(c) => tokio::spawn(run_cdp_router(c.subscribe(), weak, notify)),
413 TabBackend::Bidi(c) => {
414 tokio::spawn(run_bidi_router(c.subscribe(), weak, notify))
415 }
416 });
417 }
418 if !g.swept {
419 g.swept = true;
420 let dir = staging.clone();
421 tokio::task::spawn_blocking(move || sweep_staging(&dir));
422 }
423 staging
424 };
425 let _ = std::fs::create_dir_all(&staging);
426 let dir = staging.to_string_lossy().to_string();
427 match backend {
428 TabBackend::Cdp(c) => {
429 if !self.lock().cdp_discovering {
430 let fut = c.send("Target.setDiscoverTargets", json!({ "discover": true }));
433 if matches!(tokio::time::timeout(APPLY_TIMEOUT, fut).await, Ok(Ok(_))) {
434 self.lock().cdp_discovering = true;
435 }
436 }
437 let fut = c.send(
438 "Browser.setDownloadBehavior",
439 json!({
440 "behavior": "allowAndName",
441 "downloadPath": dir,
442 "eventsEnabled": true,
443 }),
444 );
445 match tokio::time::timeout(APPLY_TIMEOUT, fut).await {
446 Ok(Ok(_)) => {}
447 Ok(Err(e)) => tracing::debug!(error = %e, "setDownloadBehavior failed"),
448 Err(_) => tracing::debug!("setDownloadBehavior timed out"),
449 }
450 }
451 TabBackend::Bidi(c) => {
452 let (subscribe, behavior) = {
453 let g = self.lock();
454 (!g.bidi_subscribed, !g.bidi_behavior_unsupported)
455 };
456 if subscribe {
457 let fut = c.send(
458 "session.subscribe",
459 json!({ "events": [
460 "browsingContext.downloadWillBegin",
461 "browsingContext.downloadEnd",
462 "browsingContext.contextCreated",
463 ] }),
464 );
465 match tokio::time::timeout(APPLY_TIMEOUT, fut).await {
466 Ok(Ok(_)) => self.lock().bidi_subscribed = true,
467 Ok(Err(e)) => {
470 tracing::debug!(error = %e, "BiDi download events unavailable");
471 let mut g = self.lock();
472 g.bidi_subscribed = true;
473 g.bidi_events_unavailable = true;
474 }
475 Err(_) => tracing::debug!("BiDi download subscribe timed out"),
476 }
477 }
478 if behavior {
479 let fut = c.send(
480 "browser.setDownloadBehavior",
481 json!({ "downloadBehavior": {
482 "type": "allowed",
483 "destinationFolder": dir,
484 } }),
485 );
486 match tokio::time::timeout(APPLY_TIMEOUT, fut).await {
487 Ok(Ok(_)) => {}
488 Ok(Err(e)) => {
489 let msg = format!("{e:#}");
490 tracing::debug!(error = %msg, "BiDi setDownloadBehavior failed");
491 if msg.contains("unknown command") || msg.contains("unknown method") {
492 self.lock().bidi_behavior_unsupported = true;
493 }
494 }
495 Err(_) => tracing::debug!("BiDi setDownloadBehavior timed out"),
496 }
497 }
498 }
499 }
500 }
501
502 pub fn events_available(&self) -> bool {
504 let g = self.lock();
505 !(g.bidi_subscribed && g.bidi_events_unavailable)
506 }
507
508 pub async fn wait(
514 &self,
515 id: Option<&str>,
516 begin_timeout: Duration,
517 complete_timeout: Duration,
518 ) -> Result<Option<DownloadRecord>> {
519 let begin_deadline = tokio::time::Instant::now() + begin_timeout;
520 let target_id = loop {
521 let notified = self.notify.notified();
522 tokio::pin!(notified);
523 notified.as_mut().enable();
524 {
525 let g = self.lock();
526 let found = match id {
532 Some(id) => g.records.iter().rev().find(|r| r.id == id),
533 None => g
534 .records
535 .iter()
536 .find(|r| r.owned && r.seq > g.last_returned_seq),
537 };
538 if let Some(r) = found {
539 break r.id.clone();
540 }
541 if id.is_some() {
542 bail!(
543 "no download with id `{}` is known to this MCP server; call browser_downloads with action `list`",
544 id.unwrap_or_default()
545 );
546 }
547 }
548 if tokio::time::timeout_at(begin_deadline, notified)
549 .await
550 .is_err()
551 {
552 return Ok(None);
553 }
554 };
555 let done_deadline = tokio::time::Instant::now() + complete_timeout;
556 loop {
557 let notified = self.notify.notified();
558 tokio::pin!(notified);
559 notified.as_mut().enable();
560 let snapshot = {
561 let g = self.lock();
562 g.records.iter().rev().find(|r| r.id == target_id).cloned()
563 };
564 let Some(mut r) = snapshot else {
565 bail!(
566 "download `{target_id}` is no longer known to this MCP server (the browser was switched, or more than {RECORD_CAP} downloads followed); check the staging folder"
567 );
568 };
569 let settled = match r.state {
570 DlState::InProgress => r.events_lost,
573 DlState::Canceled => true,
574 DlState::Completed => {
575 r.path.is_some()
576 || (!r.finalizing
577 && (r.note.is_some() || {
578 resolve_lazily(&mut r, &self.inner);
579 r.path.is_some()
580 }))
581 }
582 };
583 if settled {
584 if id.is_none() {
585 let mut g = self.lock();
586 g.last_returned_seq = g.last_returned_seq.max(r.seq);
587 }
588 return Ok(Some(r));
589 }
590 if tokio::time::timeout_at(done_deadline, notified)
591 .await
592 .is_err()
593 {
594 resolve_lazily(&mut r, &self.inner);
595 return Ok(Some(r));
596 }
597 }
598 }
599
600 pub async fn save_to(&self, id: &str, dest_dir: &Path) -> Result<DownloadRecord> {
603 if !dest_dir.is_absolute() {
604 bail!(
605 "`save_to` must be an absolute directory path (got {})",
606 dest_dir.display()
607 );
608 }
609 let mut r = self
610 .lock()
611 .records
612 .iter()
613 .rev()
614 .find(|r| r.id == id)
615 .cloned()
616 .ok_or_else(|| anyhow!("no download with id `{id}` is known to this MCP server"))?;
617 resolve_lazily(&mut r, &self.inner);
618 if r.state != DlState::Completed {
619 bail!(
620 "download `{id}` is {}; wait for it to complete first",
621 r.state.label()
622 );
623 }
624 let src = r
625 .path
626 .clone()
627 .ok_or_else(|| anyhow!("download `{id}` has no file path yet"))?;
628 if src.parent() == Some(dest_dir) {
629 return Ok(r);
630 }
631 let dest_dir = dest_dir.to_path_buf();
632 let name = src
633 .file_name()
634 .map(|n| n.to_string_lossy().to_string())
635 .unwrap_or_else(|| "download".into());
636 let staging = self.lock().staging();
637 let rid = id.to_string();
638 let moved = tokio::task::spawn_blocking(move || -> Result<PathBuf> {
639 let moved = move_unique(&src, &dest_dir, &name)?;
640 if let Some(staging) = staging {
642 if is_safe_id(&rid) && done_marker(&staging, &rid).exists() {
643 let _ = std::fs::write(
644 done_marker(&staging, &rid),
645 moved.to_string_lossy().as_bytes(),
646 );
647 }
648 }
649 Ok(moved)
650 })
651 .await??;
652 let mut g = self.lock();
653 if let Some(rec) = g.find_mut(id) {
654 rec.path = Some(moved.clone());
655 }
656 r.path = Some(moved);
657 Ok(r)
658 }
659}
660
661fn resolve_lazily(r: &mut DownloadRecord, inner: &Arc<Mutex<Inner>>) {
664 if r.state != DlState::Completed || r.path.as_ref().is_some_and(|p| p.exists()) {
667 return;
668 }
669 let staging = inner.lock().unwrap_or_else(|p| p.into_inner()).staging();
670 let Some(staging) = staging else {
671 return;
672 };
673 if let Some(p) = read_done_marker(&staging, &r.id).filter(|p| Some(p) != r.path.as_ref()) {
674 r.path = Some(p.clone());
675 let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
676 if let Some(rec) = g.find_mut(&r.id) {
677 rec.path = Some(p);
678 }
679 }
680}
681
682impl Inner {
687 fn route_cdp(&mut self, ev: &CdpEvent) -> Option<FinalizeJob> {
690 if ev.session_id.is_some() {
691 return None;
692 }
693 let p = &ev.params;
694 match ev.method.as_str() {
695 "Target.targetCreated" | "Target.targetInfoChanged" => {
696 let info = &p["targetInfo"];
697 let tid = info["targetId"].as_str()?;
698 if info["type"].as_str() == Some("page") {
699 self.known.insert(tid.to_string());
700 }
701 if let Some(op) = info["openerId"].as_str() {
702 self.note_opener(tid, op);
703 }
704 None
705 }
706 "Target.targetDestroyed" => {
707 self.known.remove(p["targetId"].as_str()?);
710 None
711 }
712 "Browser.downloadWillBegin" => {
713 let id = p["guid"].as_str()?.to_string();
714 if self.find_mut(&id).is_some() {
715 return None;
716 }
717 let frame = p["frameId"].as_str().map(String::from);
718 let owned = self.owns(frame.as_deref());
719 self.push(DownloadRecord::new(
720 id,
721 p["url"].as_str().unwrap_or_default(),
722 p["suggestedFilename"].as_str().unwrap_or_default(),
723 frame,
724 owned,
725 ));
726 None
727 }
728 "Browser.downloadProgress" => {
729 let id = p["guid"].as_str()?;
730 let staging = self.staging();
731 if self.find_mut(id).is_none() {
732 if p["state"].as_str() != Some("completed") {
736 return None;
737 }
738 let short: String = id.chars().take(8).collect();
739 let mut r = DownloadRecord::new(
740 id.to_string(),
741 "",
742 &format!("download-{short}"),
743 None,
744 false,
745 );
746 r.began = None;
747 self.push(r);
748 }
749 let r = self.find_mut(id)?;
750 r.events_lost = false;
751 r.received_bytes = p["receivedBytes"].as_f64().map(|v| v as u64);
752 r.total_bytes = p["totalBytes"]
753 .as_f64()
754 .filter(|v| *v > 0.0)
755 .map(|v| v as u64);
756 match p["state"].as_str() {
757 Some("completed") if r.state != DlState::Completed => {
758 r.state = DlState::Completed;
759 let staging = staging?;
760 r.finalizing = true;
761 Some(FinalizeJob {
762 id: r.id.clone(),
763 staging,
764 suggested: r.suggested_filename.clone(),
765 received: r.received_bytes,
766 began: r.began,
767 })
768 }
769 Some("canceled") => {
770 r.state = DlState::Canceled;
771 r.note = Some("canceled or interrupted by the browser".into());
772 None
773 }
774 _ => None,
775 }
776 }
777 _ => None,
778 }
779 }
780
781 fn route_bidi(&mut self, ev: &BidiEvent) {
783 let p = &ev.params;
784 let explicit = p["download"]
788 .as_str()
789 .or_else(|| p["navigation"].as_str())
790 .map(String::from);
791 let url = p["url"].as_str().unwrap_or_default();
792 match ev.method.as_str() {
793 "browsingContext.contextCreated" => {
794 let Some(ctx) = p["context"].as_str() else {
795 return;
796 };
797 let link = p["parent"]
798 .as_str()
799 .or_else(|| p["originalOpener"].as_str());
800 match link {
801 Some(l) => self.note_opener(ctx, l),
802 None => {
803 self.known.insert(ctx.to_string());
804 }
805 }
806 }
807 "browsingContext.downloadWillBegin" => {
808 let id = match explicit {
809 Some(id) if self.find_mut(&id).is_some() => return,
810 Some(id) => id,
811 None => format!("bidi-{}", self.next_seq + 1),
812 };
813 let frame = p["context"].as_str().map(String::from);
814 let owned = self.owns(frame.as_deref());
815 self.push(DownloadRecord::new(
816 id,
817 url,
818 p["suggestedFilename"].as_str().unwrap_or_default(),
819 frame,
820 owned,
821 ));
822 }
823 "browsingContext.downloadEnd" => {
824 let r = match explicit {
825 Some(id) => self.find_mut(&id),
826 None => self
827 .records
828 .iter_mut()
829 .rev()
830 .find(|r| r.state == DlState::InProgress && r.url == url),
831 };
832 let Some(r) = r else {
833 return;
834 };
835 r.events_lost = false;
836 if p["status"].as_str() == Some("complete") {
837 r.state = DlState::Completed;
838 r.path = p["filepath"].as_str().map(PathBuf::from);
839 if r.path.is_none() {
840 r.note = Some("the browser did not report a file path".into());
841 }
842 } else {
843 r.state = DlState::Canceled;
844 r.note = Some("canceled by the browser".into());
845 }
846 }
847 _ => {}
848 }
849 }
850}
851
852async fn run_cdp_router(
853 mut rx: broadcast::Receiver<CdpEvent>,
854 weak: Weak<Mutex<Inner>>,
855 notify: Arc<Notify>,
856) {
857 loop {
858 match rx.recv().await {
859 Ok(ev) => {
860 if !(ev.method.starts_with("Browser.download")
861 || (ev.session_id.is_none()
862 && matches!(
863 ev.method.as_str(),
864 "Target.targetCreated"
865 | "Target.targetInfoChanged"
866 | "Target.targetDestroyed"
867 )))
868 {
869 continue;
870 }
871 let Some(inner) = weak.upgrade() else {
872 return;
873 };
874 let job = inner
875 .lock()
876 .unwrap_or_else(|p| p.into_inner())
877 .route_cdp(&ev);
878 notify.notify_waiters();
879 if let Some(job) = job {
880 let weak = weak.clone();
881 let notify = notify.clone();
882 tokio::spawn(async move {
883 let id = job.id.clone();
884 let res = tokio::task::spawn_blocking(move || {
885 match finalize(
886 &job.staging,
887 &job.id,
888 &job.suggested,
889 job.received,
890 job.began,
891 )? {
892 Some(p) => Ok(Some(p)),
893 None => {
896 for _ in 0..40 {
897 if let Some(p) = read_done_marker(&job.staging, &job.id) {
898 return Ok(Some(p));
899 }
900 std::thread::sleep(Duration::from_millis(50));
901 }
902 Ok(None)
903 }
904 }
905 })
906 .await
907 .map_err(|e| anyhow!("finalize task failed: {e}"))
908 .and_then(|r| r);
909 if let Some(inner) = weak.upgrade() {
910 let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
911 if let Some(r) = g.find_mut(&id) {
912 r.finalizing = false;
913 match res {
914 Ok(Some(path)) => r.path = Some(path),
915 Ok(None) => {
918 r.note = Some(
919 "renamed by another MCP server sharing this browser, which did not report the path in time; call browser_downloads list".into(),
920 )
921 }
922 Err(e) => r.note = Some(format!("{e:#}")),
923 }
924 }
925 }
926 notify.notify_waiters();
927 });
928 }
929 }
930 Err(broadcast::error::RecvError::Lagged(n)) => {
931 tracing::debug!(lost = n, "download router lagged");
932 mark_events_lost(&weak, ¬ify);
933 }
934 Err(broadcast::error::RecvError::Closed) => return,
935 }
936 }
937}
938
939fn mark_events_lost(weak: &Weak<Mutex<Inner>>, notify: &Notify) {
942 if let Some(inner) = weak.upgrade() {
943 let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
944 for r in g
945 .records
946 .iter_mut()
947 .filter(|r| r.state == DlState::InProgress)
948 {
949 r.events_lost = true;
950 }
951 }
952 notify.notify_waiters();
953}
954
955async fn run_bidi_router(
956 mut rx: broadcast::Receiver<BidiEvent>,
957 weak: Weak<Mutex<Inner>>,
958 notify: Arc<Notify>,
959) {
960 loop {
961 match rx.recv().await {
962 Ok(ev) => {
963 if !(ev.method.starts_with("browsingContext.download")
964 || ev.method == "browsingContext.contextCreated")
965 {
966 continue;
967 }
968 let Some(inner) = weak.upgrade() else {
969 return;
970 };
971 inner
972 .lock()
973 .unwrap_or_else(|p| p.into_inner())
974 .route_bidi(&ev);
975 notify.notify_waiters();
976 }
977 Err(broadcast::error::RecvError::Lagged(n)) => {
978 tracing::debug!(lost = n, "download router lagged");
979 mark_events_lost(&weak, ¬ify);
980 }
981 Err(broadcast::error::RecvError::Closed) => return,
982 }
983 }
984}
985
986fn claim_marker(staging: &Path, id: &str) -> PathBuf {
991 staging.join(format!(".{id}.claim"))
992}
993
994fn done_marker(staging: &Path, id: &str) -> PathBuf {
995 staging.join(format!(".{id}.done"))
996}
997
998fn read_done_marker(staging: &Path, id: &str) -> Option<PathBuf> {
999 let s = std::fs::read_to_string(done_marker(staging, id)).ok()?;
1000 let s = s.trim();
1001 (!s.is_empty()).then(|| PathBuf::from(s))
1002}
1003
1004fn finalize(
1007 staging: &Path,
1008 id: &str,
1009 suggested: &str,
1010 received: Option<u64>,
1011 began: Option<SystemTime>,
1012) -> Result<Option<PathBuf>> {
1013 if !is_safe_id(id) {
1014 bail!("refusing unexpected download id `{id}`");
1015 }
1016 let claim = claim_marker(staging, id);
1017 match std::fs::OpenOptions::new()
1018 .write(true)
1019 .create_new(true)
1020 .open(&claim)
1021 {
1022 Ok(_) => {}
1023 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
1024 Err(e) => return Err(e).with_context(|| format!("claiming {}", claim.display())),
1025 }
1026 let name = sanitize_filename(suggested);
1027 let src = staging.join(id);
1028 if !src.exists() {
1029 if let Some(found) = find_browser_named(staging, &name, received, began) {
1032 std::fs::write(done_marker(staging, id), found.to_string_lossy().as_bytes())
1033 .context("writing download marker")?;
1034 return Ok(Some(found));
1035 }
1036 bail!(
1037 "the browser reported the download complete but {} is missing and no matching {name} was found; check {}",
1038 src.display(),
1039 staging.display()
1040 );
1041 }
1042 let path = move_unique(&src, staging, &name)?;
1043 std::fs::write(done_marker(staging, id), path.to_string_lossy().as_bytes())
1044 .with_context(|| "writing download marker".to_string())?;
1045 Ok(Some(path))
1046}
1047
1048fn find_browser_named(
1052 staging: &Path,
1053 name: &str,
1054 received: Option<u64>,
1055 began: Option<SystemTime>,
1056) -> Option<PathBuf> {
1057 let (stem, ext) = split_ext(name);
1058 let since = began?.checked_sub(Duration::from_secs(2))?;
1059 let mut best: Option<(SystemTime, PathBuf)> = None;
1060 for e in std::fs::read_dir(staging).ok()?.flatten() {
1061 let fname = e.file_name().to_string_lossy().to_string();
1062 let matches_name = fname == name
1063 || fname
1064 .strip_prefix(&format!("{stem} ("))
1065 .and_then(|rest| rest.strip_suffix(ext))
1066 .and_then(|rest| rest.strip_suffix(')'))
1067 .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
1068 if !matches_name {
1069 continue;
1070 }
1071 let Ok(meta) = e.metadata() else { continue };
1072 let Ok(mtime) = meta.modified() else { continue };
1073 if mtime < since || received.is_some_and(|n| n != meta.len()) {
1074 continue;
1075 }
1076 if best.as_ref().map_or(true, |(t, _)| mtime > *t) {
1077 best = Some((mtime, e.path()));
1078 }
1079 }
1080 best.map(|(_, p)| p)
1081}
1082
1083fn is_safe_id(id: &str) -> bool {
1085 !id.is_empty()
1086 && id.len() <= 128
1087 && id
1088 .chars()
1089 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1090}
1091
1092pub fn move_unique(src: &Path, dir: &Path, name: &str) -> Result<PathBuf> {
1097 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
1098 let (stem, ext) = split_ext(name);
1099 for n in 0..1000u32 {
1100 let candidate = if n == 0 {
1101 dir.join(name)
1102 } else {
1103 dir.join(format!("{stem} ({n}){ext}"))
1104 };
1105 match std::fs::OpenOptions::new()
1106 .write(true)
1107 .create_new(true)
1108 .open(&candidate)
1109 {
1110 Ok(_) => {}
1111 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1112 Err(e) => return Err(e).with_context(|| format!("creating {}", candidate.display())),
1113 }
1114 if std::fs::rename(src, &candidate).is_err() {
1115 if let Err(e) = std::fs::copy(src, &candidate) {
1118 let _ = std::fs::remove_file(&candidate);
1119 return Err(e).with_context(|| {
1120 format!("moving {} to {}", src.display(), candidate.display())
1121 });
1122 }
1123 let _ = std::fs::remove_file(src);
1124 }
1125 return Ok(candidate);
1126 }
1127 bail!("no free file name for {name} in {}", dir.display())
1128}
1129
1130fn split_ext(name: &str) -> (&str, &str) {
1131 match name.rfind('.') {
1132 Some(i) if i > 0 => (&name[..i], &name[i..]),
1133 _ => (name, ""),
1134 }
1135}
1136
1137pub fn sanitize_filename(raw: &str) -> String {
1142 let base = raw.rsplit(['/', '\\']).next().unwrap_or_default();
1143 let mut s: String = base
1144 .chars()
1145 .map(|c| match c {
1146 '<' | '>' | ':' | '"' | '|' | '?' | '*' => '_',
1147 c if c.is_control() => '_',
1148 c => c,
1149 })
1150 .collect();
1151 s = s
1152 .trim()
1153 .trim_start_matches('.')
1154 .trim_end_matches(['.', ' '])
1155 .to_string();
1156 let stem = s.split('.').next().unwrap_or_default().trim_end();
1158 const RESERVED: &[&str] = &[
1159 "CON", "PRN", "AUX", "NUL", "COM0", "LPT0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
1160 "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
1161 "LPT9",
1162 ];
1163 if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(stem)) {
1164 s = format!("_{s}");
1165 }
1166 if s.is_empty() {
1167 s = "download".into();
1168 }
1169 if s.len() > 200 {
1170 let (stem, ext) = split_ext(&s);
1171 let ext = if ext.len() <= 16 { ext } else { "" };
1172 let mut cut = 200 - ext.len();
1173 while !stem.is_char_boundary(cut.min(stem.len())) {
1174 cut -= 1;
1175 }
1176 s = format!("{}{ext}", &stem[..cut.min(stem.len())]);
1177 }
1178 s
1179}
1180
1181fn sweep_staging(dir: &Path) {
1185 let Ok(entries) = std::fs::read_dir(dir) else {
1186 return;
1187 };
1188 for e in entries.flatten() {
1189 let name = e.file_name().to_string_lossy().to_string();
1190 let marker = name.starts_with('.') && (name.ends_with(".claim") || name.ends_with(".done"));
1191 if !marker {
1192 continue;
1193 }
1194 let old = e
1195 .metadata()
1196 .and_then(|m| m.modified())
1197 .ok()
1198 .and_then(|t| t.elapsed().ok())
1199 .is_some_and(|age| age > SWEEP_AGE);
1200 if old {
1201 let _ = std::fs::remove_file(e.path());
1202 }
1203 }
1204}
1205
1206fn fmt_bytes(n: u64) -> String {
1211 if n >= 1024 * 1024 {
1212 format!("{:.1} MB", n as f64 / (1024.0 * 1024.0))
1213 } else if n >= 1024 {
1214 format!("{:.1} KB", n as f64 / 1024.0)
1215 } else {
1216 format!("{n} B")
1217 }
1218}
1219
1220pub fn format_record(r: &DownloadRecord) -> String {
1221 let mut s = format!("{} {}", r.id, r.state.label());
1222 match (r.received_bytes, r.total_bytes, r.state) {
1223 (Some(rx), Some(t), DlState::InProgress) => {
1224 s.push_str(&format!(" {}/{}", fmt_bytes(rx), fmt_bytes(t)))
1225 }
1226 (Some(rx), _, _) if rx > 0 => s.push_str(&format!(" {}", fmt_bytes(rx))),
1227 _ => {}
1228 }
1229 if !r.suggested_filename.is_empty() {
1230 s.push_str(&format!(" {:?}", r.suggested_filename));
1231 }
1232 if let Some(p) = &r.path {
1233 s.push_str(&format!(" -> {}", p.display()));
1234 }
1235 if let Some(n) = &r.note {
1236 s.push_str(&format!(" ({n})"));
1237 }
1238 if r.events_lost && r.state == DlState::InProgress {
1239 s.push_str(&format!(
1240 " (browser events were lost while it ran; check the staging folder, where a Chromium file may still be named {})",
1241 r.id
1242 ));
1243 }
1244 if !r.owned {
1245 s.push_str(" [another tab]");
1246 }
1247 if !r.url.is_empty() && !r.url.starts_with("data:") {
1248 let url: String = r.url.chars().take(200).collect();
1249 s.push_str(&format!(" from {url}"));
1250 }
1251 s
1252}
1253
1254pub fn action_note(records: &[DownloadRecord]) -> Option<String> {
1257 if records.is_empty() {
1258 return None;
1259 }
1260 let mut s = String::from("download started:\n");
1261 for r in records {
1262 s.push_str(" ");
1263 s.push_str(&format_record(r));
1264 s.push('\n');
1265 }
1266 s.push_str("call browser_downloads with action \"wait\" for the saved path (add save_to to move it into a specific folder)");
1267 Some(s)
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273 use serde_json::Value;
1274
1275 fn cev(method: &str, params: Value) -> CdpEvent {
1276 CdpEvent {
1277 method: method.into(),
1278 params,
1279 session_id: None,
1280 }
1281 }
1282
1283 fn managed(dir: &Path) -> Inner {
1284 Inner {
1285 mode: Some(Mode::Managed {
1286 staging: dir.to_path_buf(),
1287 }),
1288 ..Default::default()
1289 }
1290 }
1291
1292 #[test]
1293 fn sanitize_strips_paths_controls_and_reserved_names() {
1294 assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
1295 assert_eq!(sanitize_filename("..\\..\\Cargo.toml"), "Cargo.toml");
1296 assert_eq!(sanitize_filename(".bashrc"), "bashrc");
1297 assert_eq!(sanitize_filename("a\u{0}b\nc.txt"), "a_b_c.txt");
1298 assert_eq!(sanitize_filename("CON.txt"), "_CON.txt");
1299 assert_eq!(sanitize_filename("con.tar.gz"), "_con.tar.gz");
1300 assert_eq!(sanitize_filename("CONSOLE.txt"), "CONSOLE.txt");
1301 assert_eq!(sanitize_filename(""), "download");
1302 assert_eq!(sanitize_filename("..."), "download");
1303 assert_eq!(sanitize_filename("re:port?.csv"), "re_port_.csv");
1304 let long = format!("{}.pdf", "é".repeat(300));
1305 let s = sanitize_filename(&long);
1306 assert!(s.len() <= 200 && s.ends_with(".pdf"), "{s}");
1307 }
1308
1309 #[test]
1310 fn move_unique_never_overwrites() {
1311 let tmp = tempfile::TempDir::new().unwrap();
1312 let dir = tmp.path();
1313 std::fs::write(dir.join("report.csv"), "existing").unwrap();
1314 let a = dir.join("a");
1315 std::fs::write(&a, "new").unwrap();
1316 let p = move_unique(&a, dir, "report.csv").unwrap();
1317 assert_eq!(p, dir.join("report (1).csv"));
1318 assert_eq!(
1319 std::fs::read_to_string(dir.join("report.csv")).unwrap(),
1320 "existing"
1321 );
1322 assert_eq!(std::fs::read_to_string(&p).unwrap(), "new");
1323 assert!(!a.exists());
1324 }
1325
1326 #[test]
1327 fn finalize_is_claimed_once() {
1328 let tmp = tempfile::TempDir::new().unwrap();
1329 let dir = tmp.path();
1330 let id = "0b9a3c1e-1111-2222-3333-444455556666";
1331 std::fs::write(dir.join(id), "x").unwrap();
1332 let first = finalize(dir, id, "../evil.txt", None, None).unwrap();
1333 assert_eq!(first, Some(dir.join("evil.txt")));
1334 assert_eq!(finalize(dir, id, "evil.txt", None, None).unwrap(), None);
1335 assert_eq!(read_done_marker(dir, id), Some(dir.join("evil.txt")));
1336 assert!(finalize(dir, "../x", "y", None, None).is_err());
1337 }
1338
1339 #[test]
1340 fn cdp_events_track_lifecycle_and_emit_finalize_once() {
1341 let tmp = tempfile::TempDir::new().unwrap();
1342 let mut h = managed(tmp.path());
1343 let mut scoped = cev("Browser.downloadWillBegin", json!({"guid": "g0"}));
1345 scoped.session_id = Some("S".into());
1346 assert!(h.route_cdp(&scoped).is_none());
1347 assert!(h.records.is_empty());
1348
1349 h.route_cdp(&cev(
1350 "Browser.downloadWillBegin",
1351 json!({"guid": "g1", "url": "https://x.test/r.csv", "suggestedFilename": "r.csv", "frameId": "F"}),
1352 ));
1353 assert_eq!(h.records.len(), 1);
1354 assert!(h
1355 .route_cdp(&cev(
1356 "Browser.downloadProgress",
1357 json!({"guid": "g1", "receivedBytes": 5, "totalBytes": 10, "state": "inProgress"}),
1358 ))
1359 .is_none());
1360 let job = h
1361 .route_cdp(&cev(
1362 "Browser.downloadProgress",
1363 json!({"guid": "g1", "receivedBytes": 10, "totalBytes": 10, "state": "completed"}),
1364 ))
1365 .expect("finalize job");
1366 assert_eq!(job.suggested, "r.csv");
1367 assert!(h
1369 .route_cdp(&cev(
1370 "Browser.downloadProgress",
1371 json!({"guid": "g1", "state": "completed"}),
1372 ))
1373 .is_none());
1374 h.route_cdp(&cev(
1375 "Browser.downloadWillBegin",
1376 json!({"guid": "g2", "suggestedFilename": "b.bin"}),
1377 ));
1378 h.route_cdp(&cev(
1379 "Browser.downloadProgress",
1380 json!({"guid": "g2", "state": "canceled"}),
1381 ));
1382 assert_eq!(h.records[1].state, DlState::Canceled);
1383 }
1384
1385 #[test]
1386 fn bidi_events_use_reported_filepath() {
1387 let mut h = Inner::default();
1388 h.route_bidi(&BidiEvent {
1389 method: "browsingContext.downloadWillBegin".into(),
1390 params: json!({"context": "C", "navigation": "N1", "url": "https://x/a.pdf", "suggestedFilename": "a.pdf"}),
1391 });
1392 h.route_bidi(&BidiEvent {
1393 method: "browsingContext.downloadEnd".into(),
1394 params: json!({"context": "C", "navigation": "N1", "status": "complete", "filepath": "/s/a.pdf"}),
1395 });
1396 assert_eq!(h.records[0].state, DlState::Completed);
1397 assert_eq!(h.records[0].path.as_deref(), Some(Path::new("/s/a.pdf")));
1398 }
1399
1400 fn rec(id: &str, state: DlState) -> DownloadRecord {
1401 let mut r = DownloadRecord::new(id.into(), "https://x/f", "f", None, true);
1402 r.state = state;
1403 r.path = Some(PathBuf::from(format!("/s/{id}")));
1404 r
1405 }
1406
1407 #[tokio::test]
1408 async fn wait_finds_download_despite_intervening_action_and_moves_on_after_return() {
1409 let hub = DownloadHub::new();
1410 hub.begin_action(); hub.lock().push(rec("g1", DlState::Completed)); hub.begin_action(); let short = Duration::from_millis(20);
1414 let r = hub.wait(None, short, short).await.unwrap().unwrap();
1415 assert_eq!(r.id, "g1");
1416 assert!(hub.wait(None, short, short).await.unwrap().is_none());
1418 assert_eq!(
1420 hub.wait(Some("g1"), short, short)
1421 .await
1422 .unwrap()
1423 .unwrap()
1424 .id,
1425 "g1"
1426 );
1427 }
1428
1429 #[tokio::test]
1430 async fn lost_events_settle_in_progress_wait() {
1431 let hub = DownloadHub::new();
1432 let mut r = rec("g1", DlState::InProgress);
1433 r.path = None;
1434 hub.lock().push(r);
1435 mark_events_lost(&Arc::downgrade(&hub.inner), &hub.notify);
1436 let got = hub
1437 .wait(None, Duration::from_millis(20), Duration::from_secs(5))
1438 .await
1439 .unwrap()
1440 .unwrap();
1441 assert_eq!(got.state, DlState::InProgress);
1442 assert!(got.events_lost);
1443 assert!(format_record(&got).contains("lost"));
1444 }
1445
1446 #[test]
1447 fn lost_flag_clears_on_later_event_and_completion_still_finalizes() {
1448 let tmp = tempfile::TempDir::new().unwrap();
1449 let mut h = managed(tmp.path());
1450 h.route_cdp(&cev(
1451 "Browser.downloadWillBegin",
1452 json!({"guid": "g1", "suggestedFilename": "r.csv"}),
1453 ));
1454 h.records[0].events_lost = true;
1455 let job = h.route_cdp(&cev(
1456 "Browser.downloadProgress",
1457 json!({"guid": "g1", "receivedBytes": 3, "state": "completed"}),
1458 ));
1459 assert!(job.is_some());
1460 assert!(!h.records[0].events_lost);
1461 assert!(h.records[0].finalizing);
1463 }
1464
1465 #[tokio::test]
1466 async fn completed_but_finalizing_is_not_settled() {
1467 let hub = DownloadHub::new();
1468 let mut r = rec("g1", DlState::Completed);
1469 r.path = None;
1470 r.finalizing = true;
1471 hub.lock().push(r);
1472 let got = hub
1473 .wait(
1474 Some("g1"),
1475 Duration::from_millis(10),
1476 Duration::from_millis(50),
1477 )
1478 .await
1479 .unwrap()
1480 .unwrap();
1481 assert!(got.path.is_none());
1483 assert_eq!(hub.lock().last_returned_seq, 0);
1484 }
1485
1486 #[tokio::test]
1487 async fn idless_wait_hands_out_oldest_first_and_skips_other_tabs() {
1488 let hub = DownloadHub::new();
1489 {
1490 let mut g = hub.lock();
1491 g.push(rec("a", DlState::Completed));
1492 let mut other = rec("x", DlState::Completed);
1493 other.owned = false;
1494 g.push(other);
1495 g.push(rec("b", DlState::Completed));
1496 }
1497 let short = Duration::from_millis(10);
1498 assert_eq!(hub.wait(None, short, short).await.unwrap().unwrap().id, "a");
1499 assert_eq!(hub.wait(None, short, short).await.unwrap().unwrap().id, "b");
1500 assert!(hub.wait(None, short, short).await.unwrap().is_none());
1501 assert_eq!(
1503 hub.wait(Some("x"), short, short).await.unwrap().unwrap().id,
1504 "x"
1505 );
1506 }
1507
1508 #[test]
1509 fn ownership_follows_touched_tabs_and_openers() {
1510 let mut h = Inner::default();
1511 h.touched.insert("T1".into());
1512 h.route_cdp(&cev(
1513 "Target.targetCreated",
1514 json!({"targetInfo": {"targetId": "T1", "type": "page"}}),
1515 ));
1516 h.route_cdp(&cev(
1517 "Target.targetCreated",
1518 json!({"targetInfo": {"targetId": "T2", "type": "page"}}),
1519 ));
1520 h.route_cdp(&cev(
1521 "Target.targetCreated",
1522 json!({"targetInfo": {"targetId": "P", "type": "page", "openerId": "T1"}}),
1523 ));
1524 assert!(h.owns(Some("T1")));
1525 assert!(h.owns(Some("P")), "popup opened by our tab");
1526 assert!(!h.owns(Some("T2")), "another agent's tab");
1527 assert!(!h.owns(Some("iframe-1")));
1529 h.in_flight = 1;
1530 assert!(h.owns(Some("iframe-1")));
1531 assert!(!h.owns(Some("T2")));
1532 }
1533
1534 #[test]
1535 fn completion_of_unseen_download_is_still_renamed() {
1536 let tmp = tempfile::TempDir::new().unwrap();
1537 let mut h = managed(tmp.path());
1538 let job = h
1539 .route_cdp(&cev(
1540 "Browser.downloadProgress",
1541 json!({"guid": "0b9a3c1e-1111-2222-3333-444455556666", "state": "completed"}),
1542 ))
1543 .expect("job for unseen guid");
1544 assert_eq!(job.suggested, "download-0b9a3c1e");
1545 assert!(!h.records[0].owned);
1546 assert!(h
1548 .route_cdp(&cev(
1549 "Browser.downloadProgress",
1550 json!({"guid": "other", "state": "inProgress"}),
1551 ))
1552 .is_none());
1553 }
1554
1555 #[test]
1556 fn bidi_without_navigation_tracks_repeat_downloads_separately() {
1557 let mut h = Inner::default();
1558 for _ in 0..2 {
1559 h.route_bidi(&BidiEvent {
1560 method: "browsingContext.downloadWillBegin".into(),
1561 params: json!({"context": "C", "navigation": null, "url": "https://x/a", "suggestedFilename": "a"}),
1562 });
1563 }
1564 assert_eq!(h.records.len(), 2);
1565 h.route_bidi(&BidiEvent {
1566 method: "browsingContext.downloadEnd".into(),
1567 params: json!({"context": "C", "navigation": null, "url": "https://x/a", "status": "complete", "filepath": "/s/a (1)"}),
1568 });
1569 assert_eq!(h.records[1].state, DlState::Completed);
1570 assert_eq!(h.records[0].state, DlState::InProgress);
1571 }
1572
1573 #[test]
1574 fn sweep_removes_only_old_markers_never_downloads() {
1575 let tmp = tempfile::TempDir::new().unwrap();
1576 let dir = tmp.path();
1577 let old = SystemTime::now() - Duration::from_secs(30 * 24 * 3600);
1578 for name in [
1579 "0b9a3c1e-1111-2222-3333-444455556666",
1580 "report.csv",
1581 ".g1.claim",
1582 ".g1.done",
1583 ] {
1584 let f = std::fs::File::create(dir.join(name)).unwrap();
1585 f.set_modified(old).unwrap();
1586 }
1587 std::fs::write(dir.join(".fresh.done"), "x").unwrap();
1588 sweep_staging(dir);
1589 assert!(dir.join("0b9a3c1e-1111-2222-3333-444455556666").exists());
1590 assert!(dir.join("report.csv").exists());
1591 assert!(!dir.join(".g1.claim").exists());
1592 assert!(!dir.join(".g1.done").exists());
1593 assert!(dir.join(".fresh.done").exists());
1594 }
1595
1596 #[test]
1597 fn finalize_fallback_picks_the_matching_browser_named_file() {
1598 let tmp = tempfile::TempDir::new().unwrap();
1599 let dir = tmp.path();
1600 let old = SystemTime::now() - Duration::from_secs(60);
1601 let f = std::fs::File::create(dir.join("r.csv")).unwrap();
1603 f.set_modified(old).unwrap();
1604 let began = SystemTime::now() - Duration::from_secs(1);
1606 std::fs::write(dir.join("r (1).csv"), "hello").unwrap();
1607 let p = finalize(dir, "abcd-1", "r.csv", Some(5), Some(began))
1608 .unwrap()
1609 .unwrap();
1610 assert_eq!(p, dir.join("r (1).csv"));
1611 assert!(finalize(dir, "abcd-2", "r.csv", Some(999), Some(began)).is_err());
1613 assert!(finalize(dir, "abcd-3", "missing.csv", None, Some(began)).is_err());
1614 }
1615
1616 #[tokio::test]
1617 async fn save_to_by_another_server_is_picked_up_from_the_marker() {
1618 let tmp = tempfile::TempDir::new().unwrap();
1619 let staging = tmp.path().join("staging");
1620 let dest = tmp.path().join("dest");
1621 std::fs::create_dir_all(&staging).unwrap();
1622 std::fs::write(staging.join("r.csv"), "x").unwrap();
1623 let mk = || {
1624 let hub = DownloadHub::new();
1625 {
1626 let mut g = hub.lock();
1627 g.mode = Some(Mode::Managed {
1628 staging: staging.clone(),
1629 });
1630 let mut r = rec("abcd-1", DlState::Completed);
1631 r.path = Some(staging.join("r.csv"));
1632 g.push(r);
1633 }
1634 hub
1635 };
1636 let (a, b) = (mk(), mk());
1637 std::fs::write(
1638 done_marker(&staging, "abcd-1"),
1639 staging.join("r.csv").to_string_lossy().as_bytes(),
1640 )
1641 .unwrap();
1642 let moved = a.save_to("abcd-1", &dest).await.unwrap();
1643 assert_eq!(moved.path, Some(dest.join("r.csv")));
1644 assert_eq!(b.list()[0].path, Some(dest.join("r.csv")));
1646 }
1647
1648 #[tokio::test]
1649 async fn wait_returns_none_when_nothing_begins_and_finds_new_download() {
1650 let hub = DownloadHub::new();
1651 let r = hub
1652 .wait(None, Duration::from_millis(20), Duration::from_millis(20))
1653 .await
1654 .unwrap();
1655 assert!(r.is_none());
1656
1657 let mark = hub.begin_action();
1658 {
1659 let mut g = hub.lock();
1660 g.push(DownloadRecord::new("g1".into(), "", "a", None, true));
1661 }
1662 assert_eq!(hub.since(mark).len(), 1);
1663 let inner = hub.inner.clone();
1664 let notify = hub.notify.clone();
1665 tokio::spawn(async move {
1666 tokio::time::sleep(Duration::from_millis(20)).await;
1667 let mut g = inner.lock().unwrap();
1668 let r = g.find_mut("g1").unwrap();
1669 r.state = DlState::Completed;
1670 r.path = Some(PathBuf::from("/s/a"));
1671 drop(g);
1672 notify.notify_waiters();
1673 });
1674 let r = hub
1675 .wait(None, Duration::from_millis(500), Duration::from_secs(2))
1676 .await
1677 .unwrap()
1678 .unwrap();
1679 assert_eq!(r.state, DlState::Completed);
1680 assert_eq!(r.path.as_deref(), Some(Path::new("/s/a")));
1681 }
1682}