viewpoint_core/page/download/mod.rs
1//! Download handling for browser downloads.
2//!
3//! This module provides functionality for handling file downloads.
4
5// Allow dead code for scaffolding that will be wired up in future
6
7use std::path::{Path, PathBuf};
8
9use tokio::sync::watch;
10use tracing::{debug, instrument};
11
12use crate::error::NetworkError;
13
14/// Download progress state.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum DownloadState {
17 /// Download is in progress.
18 InProgress,
19 /// Download completed successfully.
20 Completed,
21 /// Download was canceled.
22 Canceled,
23}
24
25/// A file download.
26///
27/// Downloads are emitted via the `page.on_download()` callback or can be
28/// obtained using `page.wait_for_download()`.
29///
30/// # Example
31///
32/// ```
33/// # #[cfg(feature = "integration")]
34/// # tokio_test::block_on(async {
35/// # use viewpoint_core::Browser;
36/// # let browser = Browser::launch().headless(true).launch().await.unwrap();
37/// # let context = browser.new_context().await.unwrap();
38/// # let page = context.new_page().await.unwrap();
39/// # page.goto("about:blank").goto().await.unwrap();
40///
41/// // Downloads are obtained like this:
42/// // let download = page.wait_for_download(async {
43/// // page.locator("a.download").click().await?;
44/// // Ok(())
45/// // }).await?;
46/// //
47/// // // Get the downloaded file path
48/// // let path = download.path().await?;
49/// // println!("Downloaded to: {}", path.display());
50/// //
51/// // // Or save to a custom location
52/// // download.save_as("./downloads/my-file.pdf").await?;
53/// # });
54/// ```
55#[derive(Debug)]
56pub struct Download {
57 /// Global unique identifier.
58 guid: String,
59 /// Download URL.
60 url: String,
61 /// Suggested filename from the browser.
62 suggested_filename: String,
63 /// Temporary file path where the download is saved.
64 temp_path: Option<PathBuf>,
65 /// State of the download.
66 state: DownloadState,
67 /// Failure reason if any.
68 failure: Option<String>,
69 /// Receiver for state updates.
70 state_rx: watch::Receiver<DownloadState>,
71 /// Receiver for path updates.
72 path_rx: watch::Receiver<Option<PathBuf>>,
73}
74
75impl Download {
76 /// Create a new Download.
77 pub(crate) fn new(
78 guid: String,
79 url: String,
80 suggested_filename: String,
81 state_rx: watch::Receiver<DownloadState>,
82 path_rx: watch::Receiver<Option<PathBuf>>,
83 ) -> Self {
84 Self {
85 guid,
86 url,
87 suggested_filename,
88 temp_path: None,
89 state: DownloadState::InProgress,
90 failure: None,
91 state_rx,
92 path_rx,
93 }
94 }
95
96 /// Get the download URL.
97 pub fn url(&self) -> &str {
98 &self.url
99 }
100
101 /// Get the suggested filename from the browser.
102 ///
103 /// This is the filename that the browser suggested based on the
104 /// Content-Disposition header or URL.
105 pub fn suggested_filename(&self) -> &str {
106 &self.suggested_filename
107 }
108
109 /// Get the global unique identifier of this download.
110 pub fn guid(&self) -> &str {
111 &self.guid
112 }
113
114 /// Get the path to the downloaded file.
115 ///
116 /// This method waits for the download to complete if it's still in progress.
117 /// The file is saved to a temporary location by default.
118 ///
119 /// # Errors
120 ///
121 /// Returns an error if the download fails or is canceled.
122 #[instrument(level = "debug", skip(self), fields(guid = %self.guid))]
123 pub async fn path(&mut self) -> Result<PathBuf, NetworkError> {
124 // Wait for the path to be available
125 let mut path_rx = self.path_rx.clone();
126
127 loop {
128 {
129 let path = path_rx.borrow();
130 if let Some(ref p) = *path {
131 self.temp_path = Some(p.clone());
132 return Ok(p.clone());
133 }
134 }
135
136 // Check if we've failed
137 if self.failure.is_some() {
138 return Err(NetworkError::IoError(
139 self.failure.clone().unwrap_or_else(|| "Unknown download error".to_string()),
140 ));
141 }
142
143 // Wait for changes
144 if path_rx.changed().await.is_err() {
145 return Err(NetworkError::Aborted);
146 }
147 }
148 }
149
150 /// Save the downloaded file to a custom location.
151 ///
152 /// This method waits for the download to complete if it's still in progress,
153 /// then copies the file to the specified path.
154 ///
155 /// # Errors
156 ///
157 /// Returns an error if:
158 /// - The download fails or is canceled
159 /// - The file cannot be copied to the destination
160 #[instrument(level = "debug", skip(self), fields(guid = %self.guid, dest = %dest.as_ref().display()))]
161 pub async fn save_as(&mut self, dest: impl AsRef<Path>) -> Result<(), NetworkError> {
162 let source = self.path().await?;
163
164 debug!("Copying download to destination");
165 tokio::fs::copy(&source, dest.as_ref())
166 .await
167 .map_err(|e| NetworkError::IoError(e.to_string()))?;
168
169 Ok(())
170 }
171
172 /// Cancel the download.
173 ///
174 /// This method cancels an in-progress download. If the download has already
175 /// completed, this has no effect.
176 #[instrument(level = "debug", skip(self), fields(guid = %self.guid))]
177 pub async fn cancel(&mut self) -> Result<(), NetworkError> {
178 // For now, just mark it as canceled
179 // In a full implementation, we'd send a CDP command to cancel
180 self.state = DownloadState::Canceled;
181 self.failure = Some("canceled".to_string());
182 Ok(())
183 }
184
185 /// Get the failure reason if the download failed.
186 ///
187 /// Returns `None` if the download completed successfully or is still in progress.
188 pub fn failure(&self) -> Option<&str> {
189 self.failure.as_deref()
190 }
191
192 /// Update the download state.
193 pub(crate) fn update_state(&mut self, state: DownloadState, failure: Option<String>) {
194 self.state = state;
195 if let Some(f) = failure {
196 self.failure = Some(f);
197 }
198 }
199
200 /// Set the temporary path.
201 pub(crate) fn set_path(&mut self, path: PathBuf) {
202 self.temp_path = Some(path);
203 }
204}
205
206/// Manager for tracking downloads.
207#[derive(Debug)]
208pub(crate) struct DownloadManager {
209 /// Base download directory.
210 download_dir: PathBuf,
211}
212
213impl DownloadManager {
214 /// Create a new download manager.
215 pub fn new() -> Self {
216 // Use temp directory by default
217 let download_dir = std::env::temp_dir().join("viewpoint-downloads");
218 Self { download_dir }
219 }
220
221 /// Get the download directory.
222 pub fn download_dir(&self) -> &Path {
223 &self.download_dir
224 }
225}
226
227impl Default for DownloadManager {
228 fn default() -> Self {
229 Self::new()
230 }
231}