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}