Skip to main content

browser_control/session/
downloads.rs

1//! Download management for the MCP server (ADR-005).
2//!
3//! Goal: a download triggered by an agent never opens a native Save dialog
4//! and always ends up at a path the agent can be told about.
5//!
6//! - **Managed browsers** (launched by browser-control into an owned
7//!   profile): downloads land in the per-kind staging directory
8//!   (`paths::downloads_staging_dir`). Launch-time prefs cover every path
9//!   without a live connection; on top of that, the server re-sends the
10//!   runtime override before tool calls (debounced) because Chromium resets
11//!   it to the profile default whenever *another* connection that set it
12//!   detaches (the Playwright sidecar, another MCP server) and nothing can
13//!   observe that without polling.
14//!   - Chromium: `Browser.setDownloadBehavior {allowAndName}` so files are
15//!     written as `<staging>/<guid>`, then renamed here to a sanitized,
16//!     never-overwriting name when `downloadProgress` reports completion.
17//!     Every server sharing the browser sends identical values, so the
18//!     browser-global setting has no last-writer problem; the rename is
19//!     claimed with an `O_EXCL` marker so exactly one server performs it.
20//!   - Firefox 149+: BiDi `browser.setDownloadBehavior {allowed}`; Firefox
21//!     names files uniquely itself and reports the path in `downloadEnd`.
22//! - **Unmanaged browsers** (a user's own browser, external endpoints):
23//!   nothing is changed. Setting a download override in Chromium swaps in a
24//!   devtools download delegate for the profile until restart, which
25//!   degrades Save Page As and Safe Browsing for the human user.
26//!
27//! Push-only, like the capture hub: one router task blocks on the event
28//! channel; nothing polls or wakes on a timer.
29
30use 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
44/// Downloads remembered per server.
45pub const RECORD_CAP: usize = 200;
46/// Minimum spacing between runtime-override re-sends.
47const REAPPLY_DEBOUNCE: Duration = Duration::from_millis(250);
48/// Bound on one override RPC.
49const APPLY_TIMEOUT: Duration = Duration::from_secs(2);
50/// Staging bookkeeping files older than this are removed on the first
51/// prepare of a backend (on invocation, never on a timer).
52const SWEEP_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
53/// Default wait for a download to begin.
54pub const DEFAULT_BEGIN_TIMEOUT: Duration = Duration::from_secs(5);
55/// Default wait for a begun download to complete.
56pub const DEFAULT_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);
57/// Upper bound for either wait, below common MCP client call timeouts.
58pub const MAX_WAIT: Duration = Duration::from_secs(120);
59
60/// Env var naming the default destination for `browser_downloads`
61/// `wait` / `save` (equivalent to `browser-control mcp --download-dir`).
62pub const DOWNLOAD_DIR_ENV: &str = "BROWSER_CONTROL_DOWNLOAD_DIR";
63
64/// Whether this server manages downloads for its current browser.
65#[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/// One download this server observed.
92#[derive(Debug, Clone, Serialize)]
93pub struct DownloadRecord {
94    /// Server-local ordering; compared against action marks.
95    #[serde(skip)]
96    pub seq: u64,
97    /// Agent-facing id: the CDP guid, or the BiDi download/navigation id.
98    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    /// Final absolute path once known.
105    pub path: Option<PathBuf>,
106    /// Why no path is available (finalize failure, canceled, …).
107    pub note: Option<String>,
108    /// Started from a tab this server works in (see `Inner::owns`).
109    /// Only owned downloads are reported in action results and picked by
110    /// an id-less `wait`, so agents sharing a browser do not take each
111    /// other's files.
112    pub owned: bool,
113    /// The event channel overflowed while this download ran; events for
114    /// it may be missing. Cleared by any later event for it.
115    #[serde(skip)]
116    pub events_lost: bool,
117    /// A CDP rename is running for it.
118    #[serde(skip)]
119    pub finalizing: bool,
120    /// CDP frame id or BiDi context that started it.
121    #[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
148/// Bound on the target→opener map.
149const OPENER_CAP: usize = 2000;
150
151#[derive(Default)]
152struct Inner {
153    records: VecDeque<DownloadRecord>,
154    next_seq: u64,
155    /// `next_seq` value when the most recent action tool call began.
156    last_action_mark: u64,
157    /// Highest seq a `wait` without `id` has returned as settled; the next
158    /// such `wait` takes the oldest owned download after it.
159    last_returned_seq: u64,
160    /// Action tool calls currently running on this server.
161    in_flight: u32,
162    /// Tabs (CDP target ids / BiDi contexts) this server's tools used.
163    touched: HashSet<String>,
164    /// Child → opener/parent (CDP `openerId`, BiDi `originalOpener` or
165    /// `parent`), so a download from a popup or iframe is attributed to
166    /// the tab that opened it.
167    openers: HashMap<String, String>,
168    /// Top-level targets the browser reported (CDP discovery / BiDi).
169    known: HashSet<String>,
170    /// Identity of the backend the router/mode belong to.
171    backend_key: Option<usize>,
172    mode: Option<Mode>,
173    router: Option<tokio::task::JoinHandle<()>>,
174    last_applied: Option<Instant>,
175    /// BiDi: download events subscribed (once per backend).
176    bidi_subscribed: bool,
177    /// BiDi: `browser.setDownloadBehavior` unsupported (Firefox < 149).
178    bidi_behavior_unsupported: bool,
179    /// BiDi: download events unavailable (Firefox < 144/145).
180    bidi_events_unavailable: bool,
181    /// CDP: `Target.setDiscoverTargets` sent on this backend.
182    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    /// Whether a download started by `frame` belongs to this server: the
208    /// frame, or a tab up its opener/parent chain, is one this server's
209    /// tools used. A frame the browser never announced (a CDP iframe) is
210    /// attributed to whichever server has an action running at that
211    /// moment; a known tab this server never used is someone else's.
212    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
240/// Finalize job produced by routing a CDP `completed` progress event.
241struct 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    /// Mode for `backend`, if already resolved for it.
290    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    /// Current mode regardless of backend (tool output).
298    pub fn mode(&self) -> Option<Mode> {
299        self.lock().mode.clone()
300    }
301
302    /// Install the mode for a (new) backend. Drops router state that
303    /// belonged to a previous backend.
304    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    /// Forget everything (browser switch).
324    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    /// Record the start of an action tool call; returns the mark used to
339    /// report downloads that began during it.
340    ///
341    /// Also clears the re-apply debounce: an action is exactly when a
342    /// download can start, so the override is always re-sent for it.
343    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    /// Pair of [`Self::begin_action`].
352    pub fn end_action(&self) {
353        let mut g = self.lock();
354        g.in_flight = g.in_flight.saturating_sub(1);
355    }
356
357    /// Record that this server's tools work in `target_id`.
358    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    /// Force the next `apply` to re-send the override (another connection
366    /// that set it, such as the sidecar, just went away).
367    pub fn invalidate(&self) {
368        self.lock().last_applied = None;
369    }
370
371    /// Downloads from this server's tabs that began after `mark`.
372    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    /// Make sure the runtime override and event routing are in place for
390    /// `backend`. Debounced unless `force`. Never errors: failures are
391    /// logged and the launch-time prefs remain the safety net.
392    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                    // Push-only target events: openers for popup
431                    // attribution and the set of known tabs.
432                    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                        // A protocol error means this Firefox lacks the
468                        // events; a timeout is retried on the next call.
469                        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    /// Whether download events can be observed on the current backend.
503    pub fn events_available(&self) -> bool {
504        let g = self.lock();
505        !(g.bidi_subscribed && g.bidi_events_unavailable)
506    }
507
508    /// Wait for a download. `id`: that download; otherwise the newest one
509    /// no earlier id-less `wait` has returned (waiting up to
510    /// `begin_timeout` for one to begin). Then waits up to
511    /// `complete_timeout` for it to finish. Returns `None` when nothing
512    /// began; an unfinished download is returned in its current state.
513    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                // Without an id: the oldest download from this server's tabs
527                // that no earlier `wait` returned. Not tied to the last
528                // action, because a download often begins after the click
529                // returns and the agent may act again before waiting; oldest
530                // first so a burst ("download all") is handed out in order.
531                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                // Events were lost: more may never arrive, so report now
571                // rather than block. Cleared if a later event shows up.
572                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    /// Move a completed download into `dest_dir` (absolute; created if
601    /// missing) without overwriting. Updates the record.
602    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            // Keep other servers' lazy resolution pointing at the file.
641            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
661/// Fill `path` for a completed CDP download finalized by another server
662/// (or by a finalize that raced this read) from the staging marker.
663fn resolve_lazily(r: &mut DownloadRecord, inner: &Arc<Mutex<Inner>>) {
664    // A recorded path that no longer exists was moved by another server
665    // (`save_to`), which rewrote the marker.
666    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
682// ---------------------------------------------------------------------------
683// Routing.
684// ---------------------------------------------------------------------------
685
686impl Inner {
687    /// Route one CDP event. Pure and synchronous (unit-testable); a
688    /// completion that needs a rename is returned as a job.
689    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                // Openers stay: a popup that became a download closes
708                // around the time its download begins.
709                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                    // Began before this server connected (or its begin
733                    // event was lost). Still rename it when it completes so
734                    // no GUID-named file is left behind.
735                    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    /// Route one BiDi event. Firefox names and places the file itself.
782    fn route_bidi(&mut self, ev: &BidiEvent) {
783        let p = &ev.params;
784        // `download` (Firefox 154+) or `navigation` correlate begin and end.
785        // Without either, synthesize a unique id at begin and match the end
786        // to the newest in-progress record for the same URL.
787        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                                // Another server holds the claim: give it a
894                                // bounded moment to record the final path.
895                                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                                    // Another server claimed it; path is
916                                    // resolved lazily from its marker.
917                                    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, &notify);
933            }
934            Err(broadcast::error::RecvError::Closed) => return,
935        }
936    }
937}
938
939/// The event channel overflowed: a `completed` for any in-progress
940/// download may be gone. Say so instead of letting `wait` hang.
941fn 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, &notify);
980            }
981            Err(broadcast::error::RecvError::Closed) => return,
982        }
983    }
984}
985
986// ---------------------------------------------------------------------------
987// Files.
988// ---------------------------------------------------------------------------
989
990fn 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
1004/// Rename `<staging>/<id>` to a sanitized, unique name in `staging`.
1005/// Returns `Ok(None)` when another server already claimed it.
1006fn 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        // The override was reset (another connection detached) before this
1030        // download began, so the browser saved it under its own name.
1031        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
1048/// The file the browser itself named `name` or `name (N)` for this
1049/// download: written after it began and, when known, of the reported size.
1050/// The newest match wins; `None` rather than a guess.
1051fn 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
1083/// Chromium guids are hex + dashes; never let an id escape the directory.
1084fn 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
1092/// Move `src` into `dir` as `name`, or `name (1)`, `name (2)`, … — never
1093/// overwriting. The destination is reserved with `create_new` first so
1094/// concurrent movers cannot pick the same name; cross-device moves fall
1095/// back to copy + remove.
1096pub 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            // Cross-device (EXDEV) or Windows sharing quirks: copy over the
1116            // reserved file, then remove the source.
1117            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
1137/// Make a page-supplied filename safe to create: no path components, no
1138/// control characters, no leading dots, no Windows reserved names, bounded
1139/// length. `allowAndName` bypasses Chromium's own name generation, so this
1140/// is the only gate between `Content-Disposition` and the filesystem.
1141pub 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    // Windows reserves the device name before the *first* dot.
1157    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
1181/// Remove stale rename bookkeeping (`.<id>.claim` / `.<id>.done`) from
1182/// staging. Downloads themselves are never deleted, whatever their name:
1183/// a GUID-named file is usually a finished download nobody renamed.
1184fn 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
1206// ---------------------------------------------------------------------------
1207// Formatting.
1208// ---------------------------------------------------------------------------
1209
1210fn 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
1254/// Text appended to an action tool's result when downloads began during
1255/// the call.
1256pub 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        // Session-scoped events (Page.* on a tab session) are ignored.
1344        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        // A repeated completed event does not finalize twice.
1368        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(); // click
1411        hub.lock().push(rec("g1", DlState::Completed)); // began after click returned
1412        hub.begin_action(); // agent runs another action before waiting
1413        let short = Duration::from_millis(20);
1414        let r = hub.wait(None, short, short).await.unwrap().unwrap();
1415        assert_eq!(r.id, "g1");
1416        // Already returned: the next wait does not hand it out again.
1417        assert!(hub.wait(None, short, short).await.unwrap().is_none());
1418        // But an explicit id still works.
1419        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        // Completed but still renaming: not settled until the job reports.
1462        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        // Returned on timeout, not as settled: the id-less cursor did not move.
1482        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        // Another tab's download is still reachable by id.
1502        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        // Unknown frame (an iframe): owned only while an action runs here.
1528        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        // Progress for an unseen guid that is not complete is ignored.
1547        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        // An older report.csv that predates this download.
1602        let f = std::fs::File::create(dir.join("r.csv")).unwrap();
1603        f.set_modified(old).unwrap();
1604        // The browser saved this download as "r (1).csv" (5 bytes).
1605        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        // Size mismatch or nothing written since it began: no guess.
1612        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        // Server B still has the old staging path; list re-reads the marker.
1645        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}