zellij_utils/
downloader.rs1use 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 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 .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 .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 let download_lock = self.acquire_download_lock(&file_name).await;
85 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 .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 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}