use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use futures::StreamExt;
use serde::Deserialize;
use serde_json::json;
use tempfile::TempDir;
use tokio::sync::oneshot;
use tokio::time::Sleep;
use zendriver_transport::SessionHandle;
use crate::error::{Result, ZendriverError};
const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadProgressState {
InProgress,
Completed,
Canceled,
}
impl DownloadProgressState {
fn from_cdp(s: &str) -> Self {
match s {
"completed" => Self::Completed,
"canceled" => Self::Canceled,
_ => Self::InProgress,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DownloadState {
pub received_bytes: u64,
pub total_bytes: u64,
pub state: DownloadProgressState,
}
impl Default for DownloadState {
fn default() -> Self {
Self {
received_bytes: 0,
total_bytes: 0,
state: DownloadProgressState::InProgress,
}
}
}
pub(crate) type SharedDownloadState = Arc<tokio::sync::Mutex<DownloadState>>;
#[derive(Debug)]
pub(crate) struct DownloadCoordinator {
_tempdir: TempDir,
download_dir: PathBuf,
states: Arc<tokio::sync::Mutex<HashMap<String, SharedDownloadState>>>,
}
impl DownloadCoordinator {
fn download_dir(&self) -> &PathBuf {
&self.download_dir
}
}
pub(crate) async fn ensure_download_setup(
cell: &tokio::sync::OnceCell<Arc<DownloadCoordinator>>,
session: &SessionHandle,
) -> Result<Arc<DownloadCoordinator>> {
let coord = cell
.get_or_try_init(|| async {
let tempdir = tempfile::Builder::new()
.prefix("zendriver-downloads-")
.tempdir()
.map_err(ZendriverError::Io)?;
let download_dir = tempdir.path().to_path_buf();
let _ = session
.connection()
.call_raw(
"Browser.setDownloadBehavior",
json!({
"behavior": "allowAndName",
"downloadPath": download_dir.to_string_lossy().to_string(),
}),
None,
)
.await?;
let states: Arc<tokio::sync::Mutex<HashMap<String, SharedDownloadState>>> =
Arc::new(tokio::sync::Mutex::new(HashMap::new()));
let states_for_task = states.clone();
let mut progress_stream =
session.subscribe::<DownloadProgressEvent>("Page.downloadProgress");
tokio::spawn(async move {
while let Some(ev) = progress_stream.next().await {
let map = states_for_task.lock().await;
if let Some(cell) = map.get(&ev.guid) {
let mut s = cell.lock().await;
s.received_bytes = ev.received_bytes;
s.total_bytes = ev.total_bytes;
s.state = DownloadProgressState::from_cdp(&ev.state);
}
}
});
Ok::<_, ZendriverError>(Arc::new(DownloadCoordinator {
_tempdir: tempdir,
download_dir,
states,
}))
})
.await?;
Ok(coord.clone())
}
#[derive(Clone)]
pub struct MatchedDownload {
pub url: String,
pub suggested_filename: String,
pub guid: String,
pub state: SharedDownloadState,
pub session: SessionHandle,
pub download_dir: PathBuf,
}
impl std::fmt::Debug for MatchedDownload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MatchedDownload")
.field("url", &self.url)
.field("suggested_filename", &self.suggested_filename)
.field("guid", &self.guid)
.field("download_dir", &self.download_dir)
.field("state", &"<Mutex<DownloadState>>")
.field("session", &"<SessionHandle>")
.finish()
}
}
impl MatchedDownload {
pub async fn path(&self) -> Option<PathBuf> {
let s = self.state.lock().await;
if s.state == DownloadProgressState::Completed {
Some(self.download_dir.join(&self.guid))
} else {
None
}
}
pub async fn save_to(self, dest: PathBuf) -> Result<()> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
loop {
let snapshot = *self.state.lock().await;
match snapshot.state {
DownloadProgressState::Completed => break,
DownloadProgressState::Canceled => {
return Err(ZendriverError::Navigation(format!(
"download {} canceled before save_to completed",
self.guid
)));
}
DownloadProgressState::InProgress => {
if tokio::time::Instant::now() >= deadline {
return Err(ZendriverError::Timeout(Duration::from_secs(30)));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
let src = self.download_dir.join(&self.guid);
tokio::fs::copy(&src, &dest)
.await
.map_err(ZendriverError::Io)?;
Ok(())
}
}
#[derive(Debug)]
pub struct DownloadExpectation {
rx: oneshot::Receiver<MatchedDownload>,
timeout: Duration,
sleep: Option<Pin<Box<Sleep>>>,
}
impl DownloadExpectation {
#[must_use]
pub fn timeout(mut self, dur: Duration) -> Self {
self.timeout = dur;
self.sleep = None;
self
}
pub async fn matched(self) -> Result<MatchedDownload> {
self.await
}
}
impl Future for DownloadExpectation {
type Output = Result<MatchedDownload>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.rx).poll(cx) {
Poll::Ready(Ok(d)) => return Poll::Ready(Ok(d)),
Poll::Ready(Err(_)) => {
return Poll::Ready(Err(ZendriverError::Timeout(self.timeout)));
}
Poll::Pending => {}
}
let timeout = self.timeout;
let sleep = self
.sleep
.get_or_insert_with(|| Box::pin(tokio::time::sleep(timeout)));
match sleep.as_mut().poll(cx) {
Poll::Ready(()) => Poll::Ready(Err(ZendriverError::Timeout(timeout))),
Poll::Pending => Poll::Pending,
}
}
}
#[derive(Debug, Deserialize)]
struct DownloadWillBeginEvent {
guid: String,
url: String,
#[serde(rename = "suggestedFilename", default)]
suggested_filename: String,
}
#[derive(Debug, Deserialize)]
struct DownloadProgressEvent {
guid: String,
#[serde(rename = "receivedBytes", default)]
received_bytes: u64,
#[serde(rename = "totalBytes", default)]
total_bytes: u64,
state: String,
}
pub(crate) fn register(
session: &SessionHandle,
coordinator: Arc<DownloadCoordinator>,
) -> DownloadExpectation {
let (tx, rx) = oneshot::channel();
let mut stream = session.subscribe::<DownloadWillBeginEvent>("Page.downloadWillBegin");
let session_for_match = session.clone();
tokio::spawn(async move {
if let Some(ev) = stream.next().await {
let state: SharedDownloadState =
Arc::new(tokio::sync::Mutex::new(DownloadState::default()));
coordinator
.states
.lock()
.await
.insert(ev.guid.clone(), state.clone());
let matched = MatchedDownload {
url: ev.url,
suggested_filename: ev.suggested_filename,
guid: ev.guid,
state,
session: session_for_match,
download_dir: coordinator.download_dir().clone(),
};
let _ = tx.send(matched);
}
});
DownloadExpectation {
rx,
timeout: DEFAULT_EXPECT_TIMEOUT,
sleep: None,
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn expect_download_resolves_on_download_will_begin_and_path_is_none_until_completed() {
let (mock, conn) = MockConnection::pair();
let session = SessionHandle::new(conn.clone(), "S1");
let tempdir = tempfile::tempdir().unwrap();
let download_dir = tempdir.path().to_path_buf();
let coord = Arc::new(DownloadCoordinator {
_tempdir: tempdir,
download_dir: download_dir.clone(),
states: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
});
let expectation = register(&session, coord);
mock.emit_event_for_session(
"Page.downloadWillBegin",
json!({
"frameId": "F0",
"guid": "GUID-123",
"url": "https://example.com/report.pdf",
"suggestedFilename": "report.pdf",
}),
"S1",
)
.await;
let matched = tokio::time::timeout(Duration::from_secs(2), expectation)
.await
.expect("expectation did not resolve within 2s")
.expect("expectation returned Err");
assert_eq!(matched.guid, "GUID-123");
assert_eq!(matched.url, "https://example.com/report.pdf");
assert_eq!(matched.suggested_filename, "report.pdf");
assert_eq!(matched.download_dir, download_dir);
assert!(
matched.path().await.is_none(),
"path() must be None until state == Completed",
);
conn.shutdown();
}
}