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_ok());
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/// Chromiumoxide redirects over CDP; native Fantoccini sets browser preferences
98/// before launch and uses the same filesystem watcher. Other engines must use
99/// their corresponding native download APIs.
100pub fn supported_engine(engine: EngineType) -> Result<(), DownloadError> {
101    match engine {
102        EngineType::Chromiumoxide | EngineType::Fantoccini => Ok(()),
103        EngineType::Playwright | EngineType::Puppeteer => Err(DownloadError::Unsupported {
104            engine: engine.to_string(),
105            reason: "the node bridge speaks its own command protocol rather \
106                     than CDP; use EngineType::Chromiumoxide, or manage \
107                     downloads from the JavaScript package, which drives \
108                     Playwright and Puppeteer directly"
109                .to_string(),
110        }),
111    }
112}
113
114/// Build and attach a download manager, if the caller asked for one.
115///
116/// # Arguments
117///
118/// * `engine` - The engine driving the browser
119/// * `transport` - A CDP connection with Browser-domain access
120/// * `setting` - The caller's `downloads` option
121///
122/// # Returns
123///
124/// An attached manager, or `None` when downloads are not managed.
125///
126/// # Errors
127///
128/// Returns [`DownloadError::Unsupported`] when the engine cannot manage
129/// downloads, and whatever [`DownloadManager::create`] or
130/// [`DownloadManager::attach`] reports otherwise.
131pub async fn attach_downloads(
132    engine: EngineType,
133    transport: &dyn CdpTransport,
134    setting: DownloadSetting,
135) -> Result<Option<Arc<DownloadManager>>, DownloadError> {
136    let Some(options) = normalize_download_options(setting) else {
137        return Ok(None);
138    };
139    supported_engine(engine)?;
140    if engine == EngineType::Fantoccini {
141        return Err(DownloadError::Unsupported {
142            engine: engine.to_string(),
143            reason: "configure WebDriver download preferences before launch using launch_webdriver or LaunchOptions::fantoccini".into(),
144        });
145    }
146
147    let manager = DownloadManager::create(options)?;
148    manager.attach(transport).await?;
149    Ok(Some(manager))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::downloads::test_support::{RecordingTransport, TempDir};
156
157    /// Options writing into a directory of this test's own.
158    fn options(temp: &TempDir) -> DownloadOptions {
159        DownloadOptions::default().directory(temp.path().join("downloads").to_string_lossy())
160    }
161
162    #[test]
163    fn reads_the_three_shapes_the_other_languages_accept() {
164        assert!(normalize_download_options(DownloadSetting::Off).is_none());
165        assert!(normalize_download_options(false.into()).is_none());
166        assert!(normalize_download_options(DownloadSetting::On).is_some());
167        assert!(normalize_download_options(true.into()).is_some());
168
169        let configured = normalize_download_options(
170            DownloadOptions::default()
171                .directory("/tmp/bc-downloads")
172                .into(),
173        )
174        .expect("configured downloads");
175        assert_eq!(configured.directory.as_deref(), Some("/tmp/bc-downloads"));
176    }
177
178    #[tokio::test]
179    async fn points_the_browser_at_the_staging_directory() {
180        let temp = TempDir::new("bc-attach");
181        let transport = RecordingTransport::default();
182
183        let manager =
184            attach_downloads(EngineType::Chromiumoxide, &transport, options(&temp).into())
185                .await
186                .expect("attach")
187                .expect("a manager");
188
189        let sent = transport.sent();
190        assert_eq!(sent.len(), 1);
191        assert_eq!(sent[0].0, "Browser.setDownloadBehavior");
192        assert_eq!(
193            sent[0].1["downloadPath"],
194            manager
195                .directory
196                .join(crate::downloads::STAGING_DIRECTORY)
197                .to_string_lossy()
198                .as_ref()
199        );
200        manager.dispose().await;
201    }
202
203    #[tokio::test]
204    async fn leaves_the_browser_alone_when_downloads_are_off() {
205        let transport = RecordingTransport::default();
206
207        let manager = attach_downloads(EngineType::Chromiumoxide, &transport, DownloadSetting::Off)
208            .await
209            .expect("attach");
210
211        assert!(manager.is_none());
212        assert!(
213            transport.sent().is_empty(),
214            "a browser with downloads off had its download behavior changed"
215        );
216    }
217
218    #[tokio::test]
219    async fn refuses_an_engine_that_cannot_manage_downloads() {
220        let temp = TempDir::new("bc-attach-unsupported");
221        let transport = RecordingTransport::default();
222
223        for engine in [
224            EngineType::Fantoccini,
225            EngineType::Playwright,
226            EngineType::Puppeteer,
227        ] {
228            let error = attach_downloads(engine, &transport, options(&temp).into())
229                .await
230                .unwrap_err();
231
232            assert!(
233                error
234                    .to_string()
235                    .contains("managed downloads are not supported"),
236                "unexpected message: {error}"
237            );
238            assert!(
239                error.to_string().contains(&engine.to_string()),
240                "the message does not name the engine: {error}"
241            );
242        }
243    }
244
245    #[tokio::test]
246    async fn reports_a_browser_that_refuses_to_redirect_its_downloads() {
247        let temp = TempDir::new("bc-attach-refused");
248        let transport = RecordingTransport::refusing("Browser domain is not available");
249
250        let error = attach_downloads(EngineType::Chromiumoxide, &transport, options(&temp).into())
251            .await
252            .unwrap_err();
253
254        assert!(
255            error
256                .to_string()
257                .contains("the browser refused to redirect its downloads"),
258            "unexpected message: {error}"
259        );
260    }
261}