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