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
140 .clone()
141 .unwrap_or_else(|| "Unknown download error".to_string()),
142 ));
143 }
144
145 // Wait for changes
146 if path_rx.changed().await.is_err() {
147 return Err(NetworkError::Aborted);
148 }
149 }
150 }
151
152 /// Save the downloaded file to a custom location.
153 ///
154 /// This method waits for the download to complete if it's still in progress,
155 /// then copies the file to the specified path.
156 ///
157 /// # Errors
158 ///
159 /// Returns an error if:
160 /// - The download fails or is canceled
161 /// - The file cannot be copied to the destination
162 #[instrument(level = "debug", skip(self), fields(guid = %self.guid, dest = %dest.as_ref().display()))]
163 pub async fn save_as(&mut self, dest: impl AsRef<Path>) -> Result<(), NetworkError> {
164 let source = self.path().await?;
165
166 debug!("Copying download to destination");
167 tokio::fs::copy(&source, dest.as_ref())
168 .await
169 .map_err(|e| NetworkError::IoError(e.to_string()))?;
170
171 Ok(())
172 }
173
174 /// Cancel the download.
175 ///
176 /// This method cancels an in-progress download. If the download has already
177 /// completed, this has no effect.
178 #[instrument(level = "debug", skip(self), fields(guid = %self.guid))]
179 pub async fn cancel(&mut self) -> Result<(), NetworkError> {
180 // For now, just mark it as canceled
181 // In a full implementation, we'd send a CDP command to cancel
182 self.state = DownloadState::Canceled;
183 self.failure = Some("canceled".to_string());
184 Ok(())
185 }
186
187 /// Get the failure reason if the download failed.
188 ///
189 /// Returns `None` if the download completed successfully or is still in progress.
190 pub fn failure(&self) -> Option<&str> {
191 self.failure.as_deref()
192 }
193
194 /// Update the download state.
195 pub(crate) fn update_state(&mut self, state: DownloadState, failure: Option<String>) {
196 self.state = state;
197 if let Some(f) = failure {
198 self.failure = Some(f);
199 }
200 }
201
202 /// Set the temporary path.
203 pub(crate) fn set_path(&mut self, path: PathBuf) {
204 self.temp_path = Some(path);
205 }
206}
207
208/// Manager for tracking downloads.
209#[derive(Debug)]
210pub(crate) struct DownloadManager {
211 /// Base download directory.
212 download_dir: PathBuf,
213}
214
215impl DownloadManager {
216 /// Create a new download manager.
217 pub fn new() -> Self {
218 // Use temp directory by default
219 let download_dir = std::env::temp_dir().join("viewpoint-downloads");
220 Self { download_dir }
221 }
222
223 /// Get the download directory.
224 pub fn download_dir(&self) -> &Path {
225 &self.download_dir
226 }
227}
228
229impl Default for DownloadManager {
230 fn default() -> Self {
231 Self::new()
232 }
233}