use crate::{
cdp::{transport::CdpMessage, Transport},
Error, Result,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{collections::HashMap, path::Path, sync::Arc, time::Duration};
use tokio::sync::{broadcast, OwnedSemaphorePermit, Semaphore};
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadBeginning {
pub frame_id: String,
pub guid: String,
pub url: String,
pub suggested_filename: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadProgress {
pub guid: String,
pub received_bytes: f64,
pub total_bytes: f64,
pub state: NativeDownloadState,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NativeDownloadState {
InProgress,
Completed,
Canceled,
}
#[derive(Clone)]
pub enum DownloadEvent {
Beginning(DownloadBeginning),
Progress(DownloadProgress),
}
pub struct DownloadWatch {
transport: Arc<Transport>,
events: broadcast::Receiver<CdpMessage>,
permit: Option<OwnedSemaphorePermit>,
openers: HashMap<String, String>,
}
impl DownloadWatch {
pub(crate) async fn begin(
transport: Arc<Transport>,
slot: Arc<Semaphore>,
directory: &Path,
) -> Result<Self> {
let permit = slot
.try_acquire_owned()
.map_err(|_| Error::cdp_msg("download_busy: download configuration unavailable"))?;
let directory = std::fs::canonicalize(directory)?;
if !directory.is_dir() {
return Err(Error::cdp_msg("Download directory is not a directory"));
}
let watch = Self {
events: transport.subscribe(),
transport,
permit: Some(permit),
openers: HashMap::new(),
};
watch
.transport
.send::<_, Value>("Target.setDiscoverTargets", &json!({"discover": true}))
.await?;
watch
.transport
.send::<_, Value>(
"Browser.setDownloadBehavior",
&json!({
"behavior": "allowAndName", "downloadPath": directory, "eventsEnabled": true,
}),
)
.await?;
Ok(watch)
}
pub async fn next_event(&mut self) -> Result<DownloadEvent> {
loop {
if self.transport.is_closed() {
return Err(Error::transport("Download event transport disconnected"));
}
let message =
match tokio::time::timeout(Duration::from_secs(1), self.events.recv()).await {
Err(_) => continue,
Ok(Err(error)) => {
return Err(Error::transport(format!("Download event loss: {error}")))
}
Ok(Ok(message)) => message,
};
if let CdpMessage::Event { method, params, .. } = message {
match method.as_str() {
"Target.targetCreated" | "Target.targetInfoChanged" => {
if let (Some(target), Some(opener)) = (
params["targetInfo"]["targetId"].as_str(),
params["targetInfo"]["openerId"].as_str(),
) {
if self.openers.len() >= 256 && !self.openers.contains_key(target) {
return Err(Error::transport(
"Download target ancestry limit exceeded",
));
}
self.openers.insert(target.into(), opener.into());
}
}
"Browser.downloadWillBegin" => {
return Ok(DownloadEvent::Beginning(serde_json::from_value(params)?))
}
"Browser.downloadProgress" => {
return Ok(DownloadEvent::Progress(serde_json::from_value(params)?))
}
_ => {}
}
}
}
}
pub fn has_opener_ancestor(&self, target_id: &str, ancestor_id: &str) -> bool {
let mut target = target_id;
for _ in 0..16 {
let Some(opener) = self.openers.get(target) else {
return false;
};
if opener == ancestor_id {
return true;
}
target = opener;
}
false
}
pub async fn cancel(&self, guid: &str) -> Result<()> {
tokio::time::timeout(
Duration::from_secs(5),
self.transport
.send::<_, Value>("Browser.cancelDownload", &json!({"guid": guid})),
)
.await
.map_err(|_| Error::Timeout("Download cancellation timed out".into()))??;
Ok(())
}
pub async fn stop(mut self) -> Result<()> {
let result = restore(&self.transport).await;
if let Some(permit) = self.permit.take() {
if result.is_err() {
permit.semaphore().close();
}
}
result
}
}
async fn restore(transport: &Transport) -> Result<()> {
tokio::time::timeout(Duration::from_secs(5), async {
let behavior = transport
.send::<_, Value>(
"Browser.setDownloadBehavior",
&json!({"behavior": "default", "eventsEnabled": false}),
)
.await;
let discovery = transport
.send::<_, Value>("Target.setDiscoverTargets", &json!({"discover": false}))
.await;
behavior?;
discovery?;
Ok(())
})
.await
.map_err(|_| Error::Timeout("Download configuration restoration timed out".into()))?
}
impl Drop for DownloadWatch {
fn drop(&mut self) {
let Some(permit) = self.permit.take() else {
return;
};
let transport = self.transport.clone();
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
runtime.spawn(async move {
if restore(&transport).await.is_err() {
permit.semaphore().close();
}
drop(permit);
});
} else {
permit.semaphore().close();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
async fn peer(reject_restore: bool) -> (Arc<Transport>, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("ws://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = tokio_tungstenite::accept_async(socket).await.unwrap();
while let Some(Ok(Message::Text(text))) = stream.next().await {
let command: Value = serde_json::from_str(&text).unwrap();
if command["params"]["behavior"] == "allowAndName" {
stream.send(Message::Text(json!({"method":"Browser.downloadWillBegin","params":{"frameId":"frame","guid":"guid","url":"https://example.test/file","suggestedFilename":"../../unsafe"}}).to_string().into())).await.unwrap();
}
if command["params"]["guid"] == "flood" {
for _ in 0..2048 {
stream.send(Message::Text(json!({"method":"Browser.downloadProgress","params":{"guid":"guid","receivedBytes":1,"totalBytes":2,"state":"inProgress"}}).to_string().into())).await.unwrap();
}
}
let response = if reject_restore && command["params"]["behavior"] == "default" {
json!({"id":command["id"],"error":{"code":-32000,"message":"restore failed"}})
} else {
json!({"id":command["id"],"result":{}})
};
stream
.send(Message::Text(response.to_string().into()))
.await
.unwrap();
}
});
(
Arc::new(Transport::connect(&address, 2).await.unwrap()),
task,
)
}
#[tokio::test]
async fn subscribes_before_configuration_and_excludes_competing_watchers() {
let (transport, task) = peer(false).await;
let slot = Arc::new(Semaphore::new(1));
let mut watch =
DownloadWatch::begin(transport.clone(), slot.clone(), &std::env::temp_dir())
.await
.unwrap();
let event = watch.next_event().await.unwrap();
assert!(matches!(event, DownloadEvent::Beginning(begin) if begin.guid == "guid"));
assert!(
DownloadWatch::begin(transport.clone(), slot.clone(), &std::env::temp_dir())
.await
.is_err()
);
watch.cancel("flood").await.unwrap();
assert!(watch.next_event().await.is_err());
watch.stop().await.unwrap();
let watch = DownloadWatch::begin(transport, slot.clone(), &std::env::temp_dir())
.await
.unwrap();
drop(watch);
let _permit = tokio::time::timeout(Duration::from_secs(2), slot.acquire())
.await
.unwrap()
.unwrap();
task.abort();
}
#[tokio::test]
async fn restoration_failure_closes_slot_and_disconnect_is_reported() {
let (transport, task) = peer(true).await;
let slot = Arc::new(Semaphore::new(1));
let mut watch =
DownloadWatch::begin(transport.clone(), slot.clone(), &std::env::temp_dir())
.await
.unwrap();
watch.next_event().await.unwrap();
assert!(watch.stop().await.is_err());
assert!(slot.is_closed());
let slot = Arc::new(Semaphore::new(1));
let mut watch = DownloadWatch::begin(transport, slot, &std::env::temp_dir())
.await
.unwrap();
watch.next_event().await.unwrap();
task.abort();
assert!(
tokio::time::timeout(Duration::from_secs(3), watch.next_event())
.await
.unwrap()
.is_err()
);
}
}