Skip to main content

browser_commander/downloads/
attach.rs

1//! Attaching the download manager to a browser (issue #88).
2//!
3//! Every entry point — [`launch_browser`](crate::browser::launch_browser),
4//! [`connect_browser`](crate::browser::connect_browser) and
5//! [`launch_real_browser`](crate::browser::launch_real_browser) — goes through
6//! this one function, so the managed lifecycle is the same however the browser
7//! was obtained. The manager is built from the live CDP connection, never from
8//! the way the browser was created.
9//!
10//! # Example
11//!
12//! ```rust
13//! use browser_commander::core::EngineType;
14//! use browser_commander::downloads::{normalize_download_options, DownloadSetting};
15//!
16//! assert!(normalize_download_options(DownloadSetting::Off).is_none());
17//! assert!(normalize_download_options(DownloadSetting::On).is_some());
18//!
19//! // An engine that cannot manage downloads says so rather than quietly
20//! // handing back a manager that would never see a file.
21//! use browser_commander::downloads::supported_engine;
22//! assert!(supported_engine(EngineType::Chromiumoxide).is_ok());
23//! assert!(supported_engine(EngineType::Fantoccini).is_err());
24//! ```
25
26use std::sync::Arc;
27
28use crate::core::EngineType;
29use crate::downloads::manager::DownloadManager;
30use crate::downloads::options::DownloadOptions;
31use crate::downloads::DownloadError;
32use crate::fingerprint::CdpTransport;
33
34/// The caller's `downloads` option.
35///
36/// The three-way shape mirrors the `true | false | {…}` the JavaScript and
37/// Python packages accept, so the same configuration reads the same way in all
38/// three languages.
39#[derive(Debug, Clone, Default)]
40pub enum DownloadSetting {
41    /// Downloads are not managed. The browser's own behavior is untouched.
42    #[default]
43    Off,
44    /// Manage downloads with the defaults.
45    On,
46    /// Manage downloads with these options.
47    Options(Box<DownloadOptions>),
48}
49
50impl From<bool> for DownloadSetting {
51    fn from(enabled: bool) -> Self {
52        if enabled {
53            Self::On
54        } else {
55            Self::Off
56        }
57    }
58}
59
60impl From<DownloadOptions> for DownloadSetting {
61    fn from(options: DownloadOptions) -> Self {
62        Self::Options(Box::new(options))
63    }
64}
65
66/// Turn the caller's setting into manager options.
67///
68/// # Arguments
69///
70/// * `setting` - What the caller asked for
71///
72/// # Returns
73///
74/// Manager options, or `None` when downloads are not managed.
75pub fn normalize_download_options(setting: DownloadSetting) -> Option<DownloadOptions> {
76    match setting {
77        DownloadSetting::Off => None,
78        DownloadSetting::On => Some(DownloadOptions::default()),
79        DownloadSetting::Options(options) => Some(*options),
80    }
81}
82
83/// Check that an engine can manage downloads at all.
84///
85/// Refusing here is the honest answer, and it is what issue #88 asks for:
86/// "unsupported engine limitations are explicit rather than silently ignored".
87/// A manager attached to an engine with no route to
88/// `Browser.setDownloadBehavior` would sit watching an empty directory and
89/// report every capture as a timeout.
90///
91/// # Arguments
92///
93/// * `engine` - The engine driving the browser
94///
95/// # Errors
96///
97/// Returns [`DownloadError::Unsupported`] for every engine but
98/// [`EngineType::Chromiumoxide`], naming the engine and what to use instead.
99pub fn supported_engine(engine: EngineType) -> Result<(), DownloadError> {
100    match engine {
101        EngineType::Chromiumoxide => Ok(()),
102        EngineType::Fantoccini => Err(DownloadError::Unsupported {
103            engine: engine.to_string(),
104            reason: "WebDriver has neither download events nor a way to \
105                     redirect downloads; use EngineType::Chromiumoxide"
106                .to_string(),
107        }),
108        EngineType::Playwright | EngineType::Puppeteer => Err(DownloadError::Unsupported {
109            engine: engine.to_string(),
110            reason: "the node bridge speaks its own command protocol rather \
111                     than CDP; use EngineType::Chromiumoxide, or manage \
112                     downloads from the JavaScript package, which drives \
113                     Playwright and Puppeteer directly"
114                .to_string(),
115        }),
116    }
117}
118
119/// Build and attach a download manager, if the caller asked for one.
120///
121/// # Arguments
122///
123/// * `engine` - The engine driving the browser
124/// * `transport` - A CDP connection with Browser-domain access
125/// * `setting` - The caller's `downloads` option
126///
127/// # Returns
128///
129/// An attached manager, or `None` when downloads are not managed.
130///
131/// # Errors
132///
133/// Returns [`DownloadError::Unsupported`] when the engine cannot manage
134/// downloads, and whatever [`DownloadManager::create`] or
135/// [`DownloadManager::attach`] reports otherwise.
136pub async fn attach_downloads(
137    engine: EngineType,
138    transport: &dyn CdpTransport,
139    setting: DownloadSetting,
140) -> Result<Option<Arc<DownloadManager>>, DownloadError> {
141    let Some(options) = normalize_download_options(setting) else {
142        return Ok(None);
143    };
144    supported_engine(engine)?;
145
146    let manager = DownloadManager::create(options)?;
147    manager.attach(transport).await?;
148    Ok(Some(manager))
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::downloads::test_support::{RecordingTransport, TempDir};
155
156    /// Options writing into a directory of this test's own.
157    fn options(temp: &TempDir) -> DownloadOptions {
158        DownloadOptions::default().directory(temp.path().join("downloads").to_string_lossy())
159    }
160
161    #[test]
162    fn reads_the_three_shapes_the_other_languages_accept() {
163        assert!(normalize_download_options(DownloadSetting::Off).is_none());
164        assert!(normalize_download_options(false.into()).is_none());
165        assert!(normalize_download_options(DownloadSetting::On).is_some());
166        assert!(normalize_download_options(true.into()).is_some());
167
168        let configured = normalize_download_options(
169            DownloadOptions::default()
170                .directory("/tmp/bc-downloads")
171                .into(),
172        )
173        .expect("configured downloads");
174        assert_eq!(configured.directory.as_deref(), Some("/tmp/bc-downloads"));
175    }
176
177    #[tokio::test]
178    async fn points_the_browser_at_the_staging_directory() {
179        let temp = TempDir::new("bc-attach");
180        let transport = RecordingTransport::default();
181
182        let manager =
183            attach_downloads(EngineType::Chromiumoxide, &transport, options(&temp).into())
184                .await
185                .expect("attach")
186                .expect("a manager");
187
188        let sent = transport.sent();
189        assert_eq!(sent.len(), 1);
190        assert_eq!(sent[0].0, "Browser.setDownloadBehavior");
191        assert_eq!(
192            sent[0].1["downloadPath"],
193            manager
194                .directory
195                .join(crate::downloads::STAGING_DIRECTORY)
196                .to_string_lossy()
197                .as_ref()
198        );
199        manager.dispose().await;
200    }
201
202    #[tokio::test]
203    async fn leaves_the_browser_alone_when_downloads_are_off() {
204        let transport = RecordingTransport::default();
205
206        let manager = attach_downloads(EngineType::Chromiumoxide, &transport, DownloadSetting::Off)
207            .await
208            .expect("attach");
209
210        assert!(manager.is_none());
211        assert!(
212            transport.sent().is_empty(),
213            "a browser with downloads off had its download behavior changed"
214        );
215    }
216
217    #[tokio::test]
218    async fn refuses_an_engine_that_cannot_manage_downloads() {
219        let temp = TempDir::new("bc-attach-unsupported");
220        let transport = RecordingTransport::default();
221
222        for engine in [
223            EngineType::Fantoccini,
224            EngineType::Playwright,
225            EngineType::Puppeteer,
226        ] {
227            let error = attach_downloads(engine, &transport, options(&temp).into())
228                .await
229                .unwrap_err();
230
231            assert!(
232                error
233                    .to_string()
234                    .contains("managed downloads are not supported"),
235                "unexpected message: {error}"
236            );
237            assert!(
238                error.to_string().contains(&engine.to_string()),
239                "the message does not name the engine: {error}"
240            );
241        }
242    }
243
244    #[tokio::test]
245    async fn reports_a_browser_that_refuses_to_redirect_its_downloads() {
246        let temp = TempDir::new("bc-attach-refused");
247        let transport = RecordingTransport::refusing("Browser domain is not available");
248
249        let error = attach_downloads(EngineType::Chromiumoxide, &transport, options(&temp).into())
250            .await
251            .unwrap_err();
252
253        assert!(
254            error
255                .to_string()
256                .contains("the browser refused to redirect its downloads"),
257            "unexpected message: {error}"
258        );
259    }
260}