Skip to main content

zellij_utils/
downloader.rs

1use isahc::prelude::*;
2use isahc::{config::RedirectPolicy, HttpClient, Request};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use thiserror::Error;
7use tokio::{io::AsyncWriteExt as _, sync::Mutex};
8use tokio_stream::StreamExt as _;
9use tokio_util::compat::FuturesAsyncReadCompatExt as _;
10use tokio_util::io::ReaderStream;
11use url::Url;
12
13const STREAM_BUFFER_SIZE_BYTES: usize = 65535;
14
15#[derive(Error, Debug)]
16pub enum DownloaderError {
17    #[error("RequestError: {0}")]
18    Request(#[from] isahc::Error),
19    #[error("HttpError: {0}")]
20    HttpError(#[from] isahc::http::Error),
21    #[error("IoError: {0}")]
22    Io(#[source] std::io::Error),
23    #[error("StdIoError: {0}")]
24    StdIoError(#[from] std::io::Error),
25    #[error("File name cannot be found in URL: {0}")]
26    NotFoundFileName(String),
27    #[error("Failed to parse URL body: {0}")]
28    InvalidUrlBody(String),
29}
30
31#[derive(Debug, Clone)]
32pub struct Downloader {
33    client: Option<HttpClient>,
34    location: PathBuf,
35    // the whole thing is an Arc/Mutex so that Downloader is thread safe, and the individual values of
36    // the HashMap are Arc/Mutexes (Mutexi?) to represent that individual downloads should not
37    // happen concurrently
38    download_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
39}
40
41impl Default for Downloader {
42    fn default() -> Self {
43        Self {
44            client: HttpClient::builder()
45                // TODO: timeout?
46                .redirect_policy(RedirectPolicy::Follow)
47                .build()
48                .ok(),
49            location: PathBuf::from(""),
50            download_locks: Default::default(),
51        }
52    }
53}
54
55impl Downloader {
56    pub fn new(location: PathBuf) -> Self {
57        Self {
58            client: HttpClient::builder()
59                // TODO: timeout?
60                .redirect_policy(RedirectPolicy::Follow)
61                .build()
62                .ok(),
63            location,
64            download_locks: Default::default(),
65        }
66    }
67
68    pub async fn download(
69        &self,
70        url: &str,
71        file_name: Option<&str>,
72    ) -> Result<(), DownloaderError> {
73        let Some(client) = &self.client else {
74            log::error!("No Http client found, cannot perform requests - this is likely a misconfiguration of isahc::HttpClient");
75            return Ok(());
76        };
77        let file_name = match file_name {
78            Some(name) => name.to_string(),
79            None => self.parse_name(url)?,
80        };
81
82        // we do this to make sure only one download of a specific url is happening at a time
83        // otherwise the downloads corrupt each other (and we waste lots of system resources)
84        let download_lock = self.acquire_download_lock(&file_name).await;
85        // it's important that _lock remains in scope, otherwise it gets dropped and the lock is
86        // released before the download is complete
87        let _lock = download_lock.lock().await;
88
89        let file_path = self.location.join(file_name.as_str());
90        if file_path.exists() {
91            log::debug!("File already exists: {:?}", file_path);
92            return Ok(());
93        }
94        let file_part_path = self.location.join(format!("{}.part", file_name));
95        let (mut target, file_part_size) = {
96            if file_part_path.exists() {
97                let file_part = tokio::fs::OpenOptions::new()
98                    .append(true)
99                    .write(true)
100                    .open(&file_part_path)
101                    .await
102                    .map_err(|e| DownloaderError::Io(e))?;
103
104                let file_part_size = file_part
105                    .metadata()
106                    .await
107                    .map_err(|e| DownloaderError::Io(e))?
108                    .len();
109
110                log::debug!("Resuming download from {} bytes", file_part_size);
111
112                (file_part, file_part_size)
113            } else {
114                let file_part = tokio::fs::File::create(&file_part_path)
115                    .await
116                    .map_err(|e| DownloaderError::Io(e))?;
117
118                (file_part, 0)
119            }
120        };
121        let request = Request::get(url)
122            .header("Content-Type", "application/octet-stream")
123            .header("Range", format!("bytes={}-", file_part_size))
124            .body(())?;
125        let mut res = client.send_async(request).await?;
126        let body = res.body_mut();
127        let mut stream = ReaderStream::with_capacity(body.compat(), STREAM_BUFFER_SIZE_BYTES);
128        while let Some(chunk) = stream.next().await {
129            let chunk = chunk.map_err(DownloaderError::Io)?;
130            target
131                .write_all(&chunk)
132                .await
133                .map_err(DownloaderError::Io)?;
134        }
135
136        log::debug!("Download complete: {:?}", file_part_path);
137
138        tokio::fs::rename(file_part_path, file_path)
139            .await
140            .map_err(|e| DownloaderError::Io(e))?;
141
142        Ok(())
143    }
144    pub async fn download_without_cache(url: &str) -> Result<String, DownloaderError> {
145        let request = Request::get(url)
146            .header("Content-Type", "application/octet-stream")
147            .body(())?;
148        let client = HttpClient::builder()
149            // TODO: timeout?
150            .redirect_policy(RedirectPolicy::Follow)
151            .build()?;
152
153        let mut res = client.send_async(request).await?;
154
155        let mut downloaded_bytes: Vec<u8> = vec![];
156        let body = res.body_mut();
157        let mut stream = ReaderStream::with_capacity(body.compat(), STREAM_BUFFER_SIZE_BYTES);
158        while let Some(chunk) = stream.next().await {
159            let chunk = chunk.map_err(DownloaderError::Io)?;
160            downloaded_bytes.extend_from_slice(&*chunk);
161        }
162
163        log::debug!("Download complete");
164        let stringified = String::from_utf8(downloaded_bytes)
165            .map_err(|e| DownloaderError::InvalidUrlBody(format!("{}", e)))?;
166
167        Ok(stringified)
168    }
169
170    /// Download the content of a URL and block for the result.
171    ///
172    /// Wraps the `async` call to [`download_without_cache`] such that it can be used from sync
173    /// code. This is achieved by either:
174    ///
175    /// 1. Reusing an existing async runtime in case one is present in the current thread, or
176    /// 2. Spawning a new async runtime on the current thread
177    ///
178    /// If neither of these works, an error is returned instead.
179    ///
180    /// # Note
181    ///
182    /// At the moment, this function is only here to bridge the gap between the async
183    /// [`Downloader`] impl and the sync [`Layout`] code that ultimately calls this function. This
184    /// is needed since the Layout code can't trivially be turned `async` without a lot of
185    /// refactoring, while the Downloader is used in many other places with async code and can't
186    /// sensibly be sync. Maybe in the future, when more code around here is async, we can drop
187    /// this function.
188    pub fn download_without_cache_blocking(url: &str) -> Result<String, DownloaderError> {
189        let runtime_handle = match tokio::runtime::Handle::try_current() {
190            Ok(handle) => handle.clone(),
191            Err(e) if e.is_missing_context() => {
192                let runtime = tokio::runtime::Builder::new_current_thread()
193                    .thread_name("ephemeral runtime for downloader implementation")
194                    .build()
195                    .map_err(DownloaderError::Io)?;
196                runtime.handle().clone()
197            },
198            _ => {
199                return Err(DownloaderError::Io(std::io::Error::new(
200                    std::io::ErrorKind::Other,
201                    "failed to spawn runtime for download task",
202                )))
203            },
204        };
205        runtime_handle.block_on(async move { Downloader::download_without_cache(url).await })
206    }
207
208    fn parse_name(&self, url: &str) -> Result<String, DownloaderError> {
209        Url::parse(url)
210            .map_err(|_| DownloaderError::NotFoundFileName(url.to_string()))?
211            .path_segments()
212            .ok_or_else(|| DownloaderError::NotFoundFileName(url.to_string()))?
213            .last()
214            .ok_or_else(|| DownloaderError::NotFoundFileName(url.to_string()))
215            .map(|s| s.to_string())
216    }
217    async fn acquire_download_lock(&self, file_name: &String) -> Arc<Mutex<()>> {
218        let mut lock_dict = self.download_locks.lock().await;
219        let download_lock = lock_dict
220            .entry(file_name.clone())
221            .or_insert_with(|| Default::default());
222        download_lock.clone()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    use tempfile::tempdir;
231
232    #[ignore]
233    #[tokio::test]
234    async fn test_download_ok() {
235        let location = tempdir().expect("Failed to create temp directory");
236        let location_path = location.path();
237
238        let downloader = Downloader::new(location_path.to_path_buf());
239        let result = downloader
240            .download(
241                "https://github.com/imsnif/monocle/releases/download/0.39.0/monocle.wasm",
242                Some("monocle.wasm"),
243            )
244            .await
245            .is_ok();
246
247        assert!(result);
248        assert!(location_path.join("monocle.wasm").exists());
249
250        location.close().expect("Failed to close temp directory");
251    }
252
253    #[ignore]
254    #[tokio::test]
255    async fn test_download_without_file_name() {
256        let location = tempdir().expect("Failed to create temp directory");
257        let location_path = location.path();
258
259        let downloader = Downloader::new(location_path.to_path_buf());
260        let result = downloader
261            .download(
262                "https://github.com/imsnif/multitask/releases/download/0.38.2v2/multitask.wasm",
263                None,
264            )
265            .await
266            .is_ok();
267
268        assert!(result);
269        assert!(location_path.join("multitask.wasm").exists());
270
271        location.close().expect("Failed to close temp directory");
272    }
273}