rmcl 0.6.0

A fully featured Minecraft TUI launcher
// SPDX-FileCopyrightText: 2026 Constantin Bauer
// SPDX-License-Identifier: GPL-3.0-only

pub mod curseforge;
pub mod fabric;
pub mod forge;
pub mod modrinth;
pub mod mojang;
pub mod neoforge;
pub mod quilt;
pub(crate) mod versions;

use reqwest::Client;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::path::Path;
use thiserror::Error;

pub const MAX_PROVIDER_ASSET_BYTES: usize = 16 * 1024 * 1024;

#[derive(Debug, Error)]
pub enum NetError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Parse error: {0}")]
    Parse(String),
    #[error("Server returned error status {status}: {url}")]
    StatusError { status: u16, url: String },
    #[error("Task failed: {0}")]
    TaskFailed(String),
}

#[derive(Clone)]
pub struct HttpClient {
    inner: Client,
}

impl Default for HttpClient {
    fn default() -> Self {
        Self::new()
    }
}

impl From<Client> for HttpClient {
    fn from(inner: Client) -> Self {
        Self { inner }
    }
}

impl NetError {
    pub(crate) fn is_retryable(&self) -> bool {
        matches!(self, Self::StatusError { status: 429, .. }) || is_retryable(self)
    }
}

impl HttpClient {
    pub fn new() -> Self {
        let user_agent = format!("rmcl/{} (Minecraft Launcher)", env!("CARGO_PKG_VERSION"));
        let client = Client::builder()
            .user_agent(user_agent.clone())
            .connect_timeout(std::time::Duration::from_secs(30))
            .read_timeout(std::time::Duration::from_secs(30))
            .build()
            .unwrap_or_else(|e| {
                tracing::warn!(
                    "Failed to build configured HTTP client, falling back to reqwest default: {}",
                    e
                );
                Client::new()
            });
        tracing::trace!("Created HTTP client with user-agent '{}'", user_agent);
        Self { inner: client }
    }

    pub fn inner(&self) -> &Client {
        &self.inner
    }

    pub async fn get(&self, url: &str) -> Result<reqwest::Response, NetError> {
        tracing::trace!("HTTP GET {}", url);
        let response = self.inner.get(url).send().await?;
        if !response.status().is_success() {
            tracing::debug!(
                "HTTP GET {} returned non-success status {}",
                url,
                response.status()
            );
            return Err(NetError::StatusError {
                status: response.status().as_u16(),
                url: url.to_string(),
            });
        }
        tracing::trace!("HTTP GET {} succeeded with {}", url, response.status());
        Ok(response)
    }

    pub async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, NetError> {
        get_with_retry(self, url, |resp| async move { Ok(resp.json().await?) }).await
    }

    pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, NetError> {
        get_with_retry(
            self,
            url,
            |resp| async move { Ok(resp.bytes().await?.to_vec()) },
        )
        .await
    }

    pub async fn get_bytes_limited(&self, url: &str, limit: usize) -> Result<Vec<u8>, NetError> {
        get_with_retry(self, url, move |mut response| async move {
            if response
                .content_length()
                .is_some_and(|length| length > limit as u64)
            {
                return Err(NetError::Parse(format!(
                    "Response exceeds the {limit}-byte limit"
                )));
            }
            let mut bytes = Vec::new();
            while let Some(chunk) = response.chunk().await? {
                if bytes.len().saturating_add(chunk.len()) > limit {
                    return Err(NetError::Parse(format!(
                        "Response exceeds the {limit}-byte limit"
                    )));
                }
                bytes.extend_from_slice(&chunk);
            }
            Ok(bytes)
        })
        .await
    }

    pub async fn post_json<B, T>(&self, url: &str, body: &B) -> Result<T, NetError>
    where
        B: Serialize + ?Sized,
        T: DeserializeOwned,
    {
        request_json_with_retry(url, || self.inner.post(url).json(body)).await
    }

    // Cache upstream bytes so fields outside our deserialized types survive.
    pub async fn get_json_with_raw<T: DeserializeOwned>(
        &self,
        url: &str,
        label: &str,
    ) -> Result<(T, Vec<u8>), NetError> {
        tracing::debug!("Fetching {} JSON from {}", label, url);
        let raw = self.get_bytes(url).await?;
        tracing::trace!("Fetched {} byte(s) for {}", raw.len(), label);
        let parsed: T = serde_json::from_slice(&raw)
            .map_err(|e| NetError::Parse(format!("Failed to parse {label}: {e}")))?;
        Ok((parsed, raw))
    }
}

async fn request_json_with_retry<T: DeserializeOwned>(
    url: &str,
    build: impl Fn() -> reqwest::RequestBuilder,
) -> Result<T, NetError> {
    request_with_retry(
        url,
        build,
        |response| async move { Ok(response.json().await?) },
    )
    .await
}

async fn request_with_retry<T, Fut>(
    url: &str,
    build: impl Fn() -> reqwest::RequestBuilder,
    decode: impl Fn(reqwest::Response) -> Fut,
) -> Result<T, NetError>
where
    Fut: std::future::Future<Output = Result<T, NetError>>,
{
    for attempt in 0..=MAX_RETRIES {
        let result = async {
            let response = build().send().await?;
            if !response.status().is_success() {
                return Err(NetError::StatusError {
                    status: response.status().as_u16(),
                    url: url.to_owned(),
                });
            }
            decode(response).await
        }
        .await;
        match result {
            Ok(value) => return Ok(value),
            Err(error) if error.is_retryable() && attempt < MAX_RETRIES => {
                sleep_before_retry("request", url, attempt, &error).await
            }
            Err(error) => return Err(error),
        }
    }
    unreachable!("retry loop returns on success or final error")
}

async fn get_with_retry<T, F, Fut>(client: &HttpClient, url: &str, decode: F) -> Result<T, NetError>
where
    F: Fn(reqwest::Response) -> Fut,
    Fut: std::future::Future<Output = Result<T, NetError>>,
{
    request_with_retry(url, || client.inner.get(url), decode).await
}

const MAX_RETRIES: u32 = 3;
const RETRY_BASE_DELAY_MS: u64 = 500;

async fn sleep_before_retry(kind: &str, url: &str, attempt: u32, err: &NetError) {
    let delay = RETRY_BASE_DELAY_MS * 2u64.pow(attempt);
    tracing::debug!(
        "{} failed, retrying after {}ms (attempt {}/{}): {}: {}",
        kind,
        delay,
        attempt + 2,
        MAX_RETRIES + 1,
        url,
        err
    );
    tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}

// total is 0 without a Content-Length; callers must handle unknown sizes.
pub async fn download_file(
    client: &HttpClient,
    url: &str,
    dest: &Path,
    progress_cb: impl Fn(u64, u64),
) -> Result<(), NetError> {
    tracing::debug!("Downloading {} to {}", url, dest.display());

    'download: {
        for attempt in 0..=MAX_RETRIES {
            match download_file_once(client, url, dest, &progress_cb).await {
                Ok(()) => {
                    tracing::debug!("Downloaded {} to {}", url, dest.display());
                    break 'download Ok(());
                }
                Err(e) if e.is_retryable() => {
                    if attempt == MAX_RETRIES {
                        break 'download Err(e);
                    }
                    sleep_before_retry("download", url, attempt, &e).await;
                }
                Err(e) => break 'download Err(e),
            }
        }
        unreachable!("retry loop returns on success or final error")
    }
}

pub(crate) async fn download_loader_libraries<'a>(
    client: &HttpClient,
    libraries: impl IntoIterator<Item = (&'a str, &'a str)>,
    meta_dir: &Path,
    loader: &str,
) -> Result<(), NetError> {
    let libraries_dir = crate::storage::MetadataPaths::new(meta_dir).libraries();
    for (name, url) in libraries {
        let maven_path = crate::instance::loader::maven::maven_coord_to_path(name)
            .ok_or_else(|| NetError::Parse(format!("Invalid Maven coordinate: {name}")))?;
        let dest = libraries_dir.join(&maven_path);
        if valid_jar(&dest) {
            tracing::debug!("{loader} library already exists: {name}");
            continue;
        }

        let download_url = format!("{}/{}", url.trim_end_matches('/'), maven_path);
        crate::feedback::progress::set_sub_action(name);
        tracing::info!("Downloading {loader} library: {name}");
        tracing::trace!("{loader} library destination: {}", dest.display());
        download_file(client, &download_url, &dest, |_, _| {}).await?;
        if !valid_jar(&dest) {
            let _ = tokio::fs::remove_file(&dest).await;
            return Err(NetError::Parse(format!(
                "Downloaded {loader} library '{name}' is not a valid JAR"
            )));
        }
    }
    Ok(())
}

fn valid_jar(path: &Path) -> bool {
    std::fs::File::open(path)
        .ok()
        .and_then(|file| zip::ZipArchive::new(file).ok())
        .is_some()
}

async fn download_file_once(
    client: &HttpClient,
    url: &str,
    dest: &Path,
    progress_cb: &impl Fn(u64, u64),
) -> Result<(), NetError> {
    use tokio::io::AsyncWriteExt;

    let response = client.get(url).await?;
    let total = response.content_length().unwrap_or(0);
    tracing::trace!("Download content length for {}: {}", url, total);

    let parent = dest
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or(Path::new("."));
    tokio::fs::create_dir_all(parent).await?;
    let temporary = tempfile::Builder::new()
        .prefix(".rmcl-download-")
        .tempfile_in(parent)?;
    let mut file = tokio::fs::File::from_std(temporary.reopen()?);
    let mut downloaded: u64 = 0;
    let mut stream = response;

    while let Some(chunk) = stream.chunk().await? {
        file.write_all(&chunk).await?;
        downloaded += chunk.len() as u64;
        progress_cb(downloaded, total);
    }
    file.flush().await?;
    drop(file);
    temporary.persist(dest).map_err(|error| error.error)?;
    Ok(())
}

// body decode errors and timeouts are worth retrying, but a 404 or disk
// error isn't. Parse errors stay non-retryable: by the time we hit one
// the response body has fully arrived, so the failure means the upstream
// returned malformed JSON - retrying won't fix that.
fn is_retryable(err: &NetError) -> bool {
    match err {
        NetError::Http(e) => e.is_timeout() || e.is_body() || e.is_connect(),
        NetError::StatusError { status, .. } => *status >= 500,
        _ => false,
    }
}