Skip to main content

playwright_cdp/
download.rs

1//! `Download` — file-download capture via the CDP `Page` download events.
2//!
3//! `Page::on_download(handler)` enables downloads to a temp directory
4//! (`Page.setDownloadBehavior` `allow`) and dispatches a [`Download`] for every
5//! `Page.downloadWillBegin` event. Progress (`Page.downloadProgress`) updates
6//! the per-guid shared state so `path()`/`save_as()` can await completion.
7
8use crate::error::{Error, Result};
9use crate::page::Page;
10use parking_lot::Mutex;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::Duration;
14
15/// Internal lifecycle of a download, shared between the listener task and the
16/// [`Download`] handle so async progress events are visible to the caller.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub(crate) enum DownloadState {
19    InProgress,
20    Completed,
21    Canceled,
22}
23
24#[derive(Clone)]
25pub(crate) struct DownloadStateCell {
26    pub state: Arc<Mutex<DownloadState>>,
27}
28
29impl DownloadStateCell {
30    pub(crate) fn new() -> Self {
31        Self {
32            state: Arc::new(Mutex::new(DownloadState::InProgress)),
33        }
34    }
35}
36
37impl Default for DownloadStateCell {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43/// A file download initiated by the page.
44///
45/// Produced by [`Page::on_download`](Page::on_download). The download lands in
46/// a per-page temp directory; call [`path`](Download::path) or
47/// [`save_as`](Download::save_as) to retrieve it.
48#[derive(Clone)]
49pub struct Download {
50    inner: Arc<DownloadInner>,
51}
52
53struct DownloadInner {
54    url: String,
55    suggested_filename: String,
56    #[allow(dead_code)]
57    guid: String,
58    state: DownloadStateCell,
59    /// The temp directory Chrome writes downloads into.
60    download_path: PathBuf,
61    page: Page,
62}
63
64impl Download {
65    pub(crate) fn new(
66        url: String,
67        suggested_filename: String,
68        guid: String,
69        state: DownloadStateCell,
70        download_path: PathBuf,
71        page: Page,
72    ) -> Self {
73        Self {
74            inner: Arc::new(DownloadInner {
75                url,
76                suggested_filename,
77                guid,
78                state,
79                download_path,
80                page,
81            }),
82        }
83    }
84
85    /// The URL being downloaded.
86    pub fn url(&self) -> &str {
87        &self.inner.url
88    }
89
90    /// The page that owns this download.
91    ///
92    /// Cheap clone — [`Page`] is `Arc`-backed, so the returned handle shares the
93    /// page's state with the caller.
94    pub fn page(&self) -> Page {
95        self.inner.page.clone()
96    }
97
98    /// The filename suggested by the server (`suggestedFilename`).
99    pub fn suggested_filename(&self) -> &str {
100        &self.inner.suggested_filename
101    }
102
103    /// A failure reason, if the download was canceled. `None` otherwise.
104    pub fn failure(&self) -> Option<&str> {
105        if *self.inner.state.state.lock() == DownloadState::Canceled {
106            Some("download canceled")
107        } else {
108            None
109        }
110    }
111
112    /// Wait (up to ~30s) for the download to reach a terminal state, then
113    /// return the on-disk path if completed. Falls back to the filesystem: some
114    /// Chrome builds omit the `Completed` `downloadProgress` event, so if the
115    /// final file (or its `.crdownload` temp) appears on disk and its size
116    /// stabilizes, we treat the download as done.
117    async fn await_completion(&self) -> Result<()> {
118        let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
119        loop {
120            match *self.inner.state.state.lock() {
121                DownloadState::Completed => return Ok(()),
122                DownloadState::Canceled => {
123                    return Err(Error::ProtocolError("download was canceled".into()))
124                }
125                DownloadState::InProgress => {}
126            }
127            // Filesystem fallback: a file landed on disk even without an event.
128            if let Some(p) = self.find_stable_file().await {
129                // If Chrome left it as `*.crdownload`, rename to the final name
130                // so subsequent path()/save_as() reads resolve it.
131                if p.extension().and_then(|e| e.to_str()) == Some("crdownload") {
132                    let final_path = self
133                        .inner
134                        .download_path
135                        .join(&self.inner.suggested_filename);
136                    let _ = tokio::fs::rename(&p, &final_path).await;
137                }
138                return Ok(());
139            }
140            if tokio::time::Instant::now() >= deadline {
141                return Err(Error::Timeout(
142                    "timed out waiting for download to complete".into(),
143                ));
144            }
145            tokio::time::sleep(Duration::from_millis(50)).await;
146        }
147    }
148
149    /// Look in the download dir for the final file or an in-progress
150    /// `.crdownload` temp whose size has stopped growing.
151    async fn find_stable_file(&self) -> Option<PathBuf> {
152        let final_path = self.inner.download_path.join(&self.inner.suggested_filename);
153        if self.size_stable(&final_path).await {
154            return Some(final_path);
155        }
156        // Scan for `*.crdownload` files (Chrome's in-progress temp name).
157        let mut entries = tokio::fs::read_dir(&self.inner.download_path).await.ok()?;
158        while let Ok(Some(e)) = entries.next_entry().await {
159            let p = e.path();
160            if p.extension().and_then(|x| x.to_str()) == Some("crdownload")
161                && self.size_stable(&p).await
162            {
163                return Some(p);
164            }
165        }
166        None
167    }
168
169    /// True if the file size stops changing across two reads ~150ms apart.
170    async fn size_stable(&self, path: &Path) -> bool {
171        let s1 = tokio::fs::metadata(path).await.map(|m| m.len()).ok();
172        tokio::time::sleep(Duration::from_millis(150)).await;
173        let s2 = tokio::fs::metadata(path).await.map(|m| m.len()).ok();
174        s1.is_some() && s1 == s2
175    }
176
177    /// The on-disk path of the completed download, or `None` if it failed.
178    ///
179    /// Waits for the download to finish; once `Completed`, polls briefly for
180    /// the file to appear (Chrome renames the `.crdownload` temp on finish).
181    pub async fn path(&self) -> Result<Option<PathBuf>> {
182        if self.await_completion().await.is_err() {
183            return Ok(None);
184        }
185        let target = self.inner.download_path.join(&self.inner.suggested_filename);
186        // Chrome renames the in-progress .crdownload file on completion; allow
187        // a brief window for the rename to land on disk.
188        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
189        loop {
190            if tokio::fs::metadata(&target).await.is_ok() {
191                return Ok(Some(target));
192            }
193            if tokio::time::Instant::now() >= deadline {
194                return Ok(Some(target)); // best-effort; caller may still read it
195            }
196            tokio::time::sleep(Duration::from_millis(50)).await;
197        }
198    }
199
200    /// Copy the downloaded file to `to` and return the destination path.
201    pub async fn save_as(&self, to: impl AsRef<Path>) -> Result<PathBuf> {
202        self.await_completion().await?;
203        let from = self.path().await?.ok_or_else(|| {
204            Error::ProtocolError("download has no file to save (failed)".into())
205        })?;
206        let to = to.as_ref().to_path_buf();
207        if let Some(parent) = to.parent() {
208            if !parent.as_os_str().is_empty() {
209                tokio::fs::create_dir_all(parent).await?;
210            }
211        }
212        tokio::fs::copy(&from, &to).await?;
213        Ok(to)
214    }
215
216    /// Best-effort remove the downloaded file from the temp directory.
217    pub async fn delete(&self) -> Result<()> {
218        let target = self.inner.download_path.join(&self.inner.suggested_filename);
219        match tokio::fs::remove_file(&target).await {
220            Ok(()) => Ok(()),
221            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
222            Err(e) => Err(Error::from(e)),
223        }
224    }
225
226    /// Open the downloaded file as a read stream.
227    pub async fn create_read_stream(&self) -> Result<tokio::fs::File> {
228        self.await_completion().await?;
229        let from = self.path().await?.ok_or_else(|| {
230            Error::ProtocolError("download has no file to read (failed)".into())
231        })?;
232        Ok(tokio::fs::File::open(from).await?)
233    }
234}