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}