You've already forked AstralRinth
* Make theseus capable of logging messages from the `log` crate * Move update checking entirely into JS and open a modal if an update is available * Fix formatjs on Windows and run formatjs * Add in the buttons and body * Fix lint * Show update size in modal * Fix update not being rechecked if the update modal was directly dismissed * Slight UI tweaks * Fix lint * Implement skipping the update * Implement the Update Now button * Implement updating at next exit * Turn download progress into an error bar on failure * Restore 5 minute update check instead of 30 seconds * Fix PendingUpdateData being seen as a unit struct * Fix lint * Make CI also lint updater code * feat: create AppearingProgressBar component * feat: polish update available modal * feat: add error handling * Open changelog with tauri-plugin-opener * Run intl:extract * Update completion toasts (#3978) * Use single LAUNCHER_USER_AGENT constant for all user agents * Fix build on Mac * Request the update size with HEAD instead of GET * UI tweaks * lint * Fix lint * fix: hide modal header & add "Hide update reminder" button w/ tooltip * Run intl:extract * fix: lint issues * fix: merge issues * notifications.js no longer exists * Add metered network checking * Add a timeout to macOS is_network_metered * Fix tauri.conf.json * vibe debugging * Set a dispatch queue * Have a popup that asks you if you'd like to disable automatic file downloads if you're on a metered network * Move UpdateModal to modal package * Fix lint * Add a toggle for automatic downloads * Fix type Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com> Signed-off-by: Josiah Glosson <soujournme@gmail.com> * Redo updating UI and experience * lint * fix unlistener issue * remove unneeded translation keys * Fix expose issue * temp disable cranelift, tweak some messages * change version back * Clean up App.vue * move toast to top right * update reload icon * Fixed the bug!!!!!!!!!!!! * improve messages * intl:extract * Add liquid glass icon file * not you! * use dependency injection * lint on apple icon * Fix imports, move download size to button * change update check back to 5 mins * lint + move to providers * intl:extract --------- Signed-off-by: Cal H. <hendersoncal117@gmail.com> Signed-off-by: Josiah Glosson <soujournme@gmail.com> Co-authored-by: Calum <calum@modrinth.com> Co-authored-by: Prospector <prospectordev@gmail.com> Co-authored-by: Cal H. <hendersoncal117@gmail.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
328 lines
9.6 KiB
Rust
328 lines
9.6 KiB
Rust
//! Functions for fetching information from the Internet
|
|
use super::io::{self, IOError};
|
|
use crate::ErrorKind;
|
|
use crate::LAUNCHER_USER_AGENT;
|
|
use crate::event::LoadingBarId;
|
|
use crate::event::emit::emit_loading;
|
|
use bytes::Bytes;
|
|
use reqwest::Method;
|
|
use serde::de::DeserializeOwned;
|
|
use std::ffi::OsStr;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::LazyLock;
|
|
use std::time::{self};
|
|
use tokio::sync::Semaphore;
|
|
use tokio::{fs::File, io::AsyncWriteExt};
|
|
|
|
#[derive(Debug)]
|
|
pub struct IoSemaphore(pub Semaphore);
|
|
#[derive(Debug)]
|
|
pub struct FetchSemaphore(pub Semaphore);
|
|
|
|
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
let header =
|
|
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
|
|
headers.insert(reqwest::header::USER_AGENT, header);
|
|
reqwest::Client::builder()
|
|
.tcp_keepalive(Some(time::Duration::from_secs(10)))
|
|
.default_headers(headers)
|
|
.build()
|
|
.expect("Reqwest Client Building Failed")
|
|
});
|
|
const FETCH_ATTEMPTS: usize = 3;
|
|
|
|
#[tracing::instrument(skip(semaphore))]
|
|
pub async fn fetch(
|
|
url: &str,
|
|
sha1: Option<&str>,
|
|
semaphore: &FetchSemaphore,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
|
) -> crate::Result<Bytes> {
|
|
fetch_advanced(Method::GET, url, sha1, None, None, None, semaphore, exec)
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument(skip(json_body, semaphore))]
|
|
pub async fn fetch_json<T>(
|
|
method: Method,
|
|
url: &str,
|
|
sha1: Option<&str>,
|
|
json_body: Option<serde_json::Value>,
|
|
semaphore: &FetchSemaphore,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
|
) -> crate::Result<T>
|
|
where
|
|
T: DeserializeOwned,
|
|
{
|
|
let result = fetch_advanced(
|
|
method, url, sha1, json_body, None, None, semaphore, exec,
|
|
)
|
|
.await?;
|
|
let value = serde_json::from_slice(&result)?;
|
|
Ok(value)
|
|
}
|
|
|
|
/// Downloads a file with retry and checksum functionality
|
|
#[tracing::instrument(skip(json_body, semaphore))]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn fetch_advanced(
|
|
method: Method,
|
|
url: &str,
|
|
sha1: Option<&str>,
|
|
json_body: Option<serde_json::Value>,
|
|
header: Option<(&str, &str)>,
|
|
loading_bar: Option<(&LoadingBarId, f64)>,
|
|
semaphore: &FetchSemaphore,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
|
) -> crate::Result<Bytes> {
|
|
let _permit = semaphore.0.acquire().await?;
|
|
|
|
let creds = if header
|
|
.as_ref()
|
|
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
|
|
&& (url.starts_with("https://cdn.modrinth.com")
|
|
|| url.starts_with(env!("MODRINTH_API_URL"))
|
|
|| url.starts_with(env!("MODRINTH_API_URL_V3")))
|
|
{
|
|
crate::state::ModrinthCredentials::get_active(exec).await?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
|
|
let mut req = REQWEST_CLIENT.request(method.clone(), url);
|
|
|
|
if let Some(body) = json_body.clone() {
|
|
req = req.json(&body);
|
|
}
|
|
|
|
if let Some(header) = header {
|
|
req = req.header(header.0, header.1);
|
|
}
|
|
|
|
if let Some(ref creds) = creds {
|
|
req = req.header("Authorization", &creds.session);
|
|
}
|
|
|
|
let result = req.send().await;
|
|
match result {
|
|
Ok(resp) => {
|
|
if resp.status().is_server_error() && attempt <= FETCH_ATTEMPTS
|
|
{
|
|
continue;
|
|
}
|
|
if resp.status().is_client_error()
|
|
|| resp.status().is_server_error()
|
|
{
|
|
let backup_error = resp.error_for_status_ref().unwrap_err();
|
|
if let Ok(error) = resp.json().await {
|
|
return Err(ErrorKind::LabrinthError(error).into());
|
|
}
|
|
return Err(backup_error.into());
|
|
}
|
|
|
|
let bytes = if let Some((bar, total)) = &loading_bar {
|
|
let length = resp.content_length();
|
|
if let Some(total_size) = length {
|
|
use futures::StreamExt;
|
|
let mut stream = resp.bytes_stream();
|
|
let mut bytes = Vec::new();
|
|
while let Some(item) = stream.next().await {
|
|
let chunk = item.or(Err(ErrorKind::NoValueFor(
|
|
"fetch bytes".to_string(),
|
|
)))?;
|
|
bytes.append(&mut chunk.to_vec());
|
|
emit_loading(
|
|
bar,
|
|
(chunk.len() as f64 / total_size as f64)
|
|
* total,
|
|
None,
|
|
)?;
|
|
}
|
|
|
|
Ok(bytes::Bytes::from(bytes))
|
|
} else {
|
|
resp.bytes().await
|
|
}
|
|
} else {
|
|
resp.bytes().await
|
|
};
|
|
|
|
if let Ok(bytes) = bytes {
|
|
if let Some(sha1) = sha1 {
|
|
let hash = sha1_async(bytes.clone()).await?;
|
|
if &*hash != sha1 {
|
|
if attempt <= FETCH_ATTEMPTS {
|
|
continue;
|
|
} else {
|
|
return Err(ErrorKind::HashError(
|
|
sha1.to_string(),
|
|
hash,
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::trace!("Done downloading URL {url}");
|
|
return Ok(bytes);
|
|
} else if attempt <= FETCH_ATTEMPTS {
|
|
continue;
|
|
} else if let Err(err) = bytes {
|
|
return Err(err.into());
|
|
}
|
|
}
|
|
Err(_) if attempt <= FETCH_ATTEMPTS => continue,
|
|
Err(err) => {
|
|
return Err(err.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
unreachable!()
|
|
}
|
|
|
|
/// Downloads a file from specified mirrors
|
|
#[tracing::instrument(skip(semaphore))]
|
|
pub async fn fetch_mirrors(
|
|
mirrors: &[&str],
|
|
sha1: Option<&str>,
|
|
semaphore: &FetchSemaphore,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
|
) -> crate::Result<Bytes> {
|
|
if mirrors.is_empty() {
|
|
return Err(
|
|
ErrorKind::InputError("No mirrors provided!".to_string()).into()
|
|
);
|
|
}
|
|
|
|
for (index, mirror) in mirrors.iter().enumerate() {
|
|
let result = fetch(mirror, sha1, semaphore, exec).await;
|
|
|
|
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
unreachable!()
|
|
}
|
|
|
|
/// Posts a JSON to a URL
|
|
#[tracing::instrument(skip(json_body, semaphore))]
|
|
pub async fn post_json<T>(
|
|
url: &str,
|
|
json_body: serde_json::Value,
|
|
semaphore: &FetchSemaphore,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
|
) -> crate::Result<T>
|
|
where
|
|
T: DeserializeOwned,
|
|
{
|
|
let _permit = semaphore.0.acquire().await?;
|
|
|
|
let mut req = REQWEST_CLIENT.post(url).json(&json_body);
|
|
|
|
if let Some(creds) =
|
|
crate::state::ModrinthCredentials::get_active(exec).await?
|
|
{
|
|
req = req.header("Authorization", &creds.session);
|
|
}
|
|
|
|
let result = req.send().await?.error_for_status()?;
|
|
|
|
let value = result.json().await?;
|
|
Ok(value)
|
|
}
|
|
|
|
pub async fn read_json<T>(
|
|
path: &Path,
|
|
semaphore: &IoSemaphore,
|
|
) -> crate::Result<T>
|
|
where
|
|
T: DeserializeOwned,
|
|
{
|
|
let _permit = semaphore.0.acquire().await?;
|
|
|
|
let json = io::read(path).await?;
|
|
let json = serde_json::from_slice::<T>(&json)?;
|
|
|
|
Ok(json)
|
|
}
|
|
|
|
#[tracing::instrument(skip(bytes, semaphore))]
|
|
pub async fn write(
|
|
path: &Path,
|
|
bytes: &[u8],
|
|
semaphore: &IoSemaphore,
|
|
) -> crate::Result<()> {
|
|
let _permit = semaphore.0.acquire().await?;
|
|
|
|
if let Some(parent) = path.parent() {
|
|
io::create_dir_all(parent).await?;
|
|
}
|
|
|
|
let mut file = File::create(path)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, path))?;
|
|
file.write_all(bytes)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, path))?;
|
|
tracing::trace!("Done writing file {}", path.display());
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn copy(
|
|
src: impl AsRef<Path>,
|
|
dest: impl AsRef<Path>,
|
|
semaphore: &IoSemaphore,
|
|
) -> crate::Result<()> {
|
|
let src: &Path = src.as_ref();
|
|
let dest = dest.as_ref();
|
|
|
|
let _permit = semaphore.0.acquire().await?;
|
|
|
|
if let Some(parent) = dest.parent() {
|
|
io::create_dir_all(parent).await?;
|
|
}
|
|
|
|
io::copy(src, dest).await?;
|
|
tracing::trace!(
|
|
"Done copying file {} to {}",
|
|
src.display(),
|
|
dest.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// Writes a icon to the cache and returns the absolute path of the icon within the cache directory
|
|
#[tracing::instrument(skip(bytes, semaphore))]
|
|
pub async fn write_cached_icon(
|
|
icon_path: &str,
|
|
cache_dir: &Path,
|
|
bytes: Bytes,
|
|
semaphore: &IoSemaphore,
|
|
) -> crate::Result<PathBuf> {
|
|
let extension = Path::new(&icon_path).extension().and_then(OsStr::to_str);
|
|
let hash = sha1_async(bytes.clone()).await?;
|
|
let path = cache_dir.join("icons").join(if let Some(ext) = extension {
|
|
format!("{hash}.{ext}")
|
|
} else {
|
|
hash
|
|
});
|
|
|
|
write(&path, &bytes, semaphore).await?;
|
|
|
|
let path = io::canonicalize(path)?;
|
|
Ok(path)
|
|
}
|
|
|
|
pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
|
|
let hash = tokio::task::spawn_blocking(move || {
|
|
sha1_smol::Sha1::from(bytes).hexdigest()
|
|
})
|
|
.await?;
|
|
|
|
Ok(hash)
|
|
}
|