Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 93 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,93 @@
RUST_LOG=info
BASE_URL=YOUR_BASE_URL
S3_ACCESS_TOKEN=null
S3_SECRET=null
S3_URL=null
S3_REGION=null
S3_BUCKET_NAME=null
BRAND_NAME=your-brand-name
SUPPORT_EMAIL=support-email
CDN_UPLOAD_DIR=./upload_cdn
SENTRY_DSN=null
# =============================================================================
# Daedalus Client Environment Configuration
# =============================================================================
# Copy this file to .env and fill in your actual values
# Required variables must be set for the application to run

# =============================================================================
# REQUIRED CONFIGURATION
# =============================================================================

# Base URL for CAS objects (public CDN URL where metadata will be served)
BASE_URL=https://cdn.example.com

# Sentry error tracking DSN (get from https://sentry.io)
SENTRY_DSN=https://key@sentry.io/project

# Brand name for metadata files
BRAND_NAME=MyLauncher

# Support email for metadata
SUPPORT_EMAIL=support@example.com

# =============================================================================
# S3 STORAGE CONFIGURATION
# =============================================================================

# S3 bucket name where metadata will be stored
S3_BUCKET_NAME=minecraft-metadata

# S3 region (use "r2" for Cloudflare R2)
# Examples: us-east-1, eu-west-1, ap-southeast-1, r2
S3_REGION=us-east-1

# S3 endpoint URL
# AWS S3: https://s3.amazonaws.com
# Cloudflare R2: https://<account-id>.r2.cloudflarestorage.com
S3_URL=https://s3.amazonaws.com

# S3 access key ID
S3_ACCESS_TOKEN=token

# S3 secret access key
S3_SECRET=secret

# =============================================================================
# OPTIONAL: LOGGING CONFIGURATION
# =============================================================================

# Log output format: "text" or "json"
# Default: text
# LOG_FORMAT=text

# Rust log level filter
# Options: trace, debug, info, warn, error
# Default: info
# RUST_LOG=info

# Betterstack logging token for centralized log management
# Get from https://betterstack.com
# BETTERSTACK_TOKEN=your-betterstack-token

# Betterstack ingestion endpoint URL
# Default: https://in.logs.betterstack.com
# BETTERSTACK_URL=https://in.logs.betterstack.com

# =============================================================================
# OPTIONAL: CLOUDFLARE INTEGRATION
# =============================================================================

# Enable Cloudflare cache purging on updates
# Default: false
# CLOUDFLARE_INTEGRATION=true

# Cloudflare API token (required if CLOUDFLARE_INTEGRATION=true)
# Create at: https://dash.cloudflare.com/profile/api-tokens
# Required permissions: Zone.Cache Purge
# CLOUDFLARE_TOKEN=your-cloudflare-token

# Cloudflare zone ID (required if CLOUDFLARE_INTEGRATION=true)
# Find in: Zone Overview > API section
# CLOUDFLARE_ZONE_ID=your-zone-id

# =============================================================================
# OPTIONAL: ADVANCED CONFIGURATION
# =============================================================================

# Local directory for CDN file uploads
# Default: ./upload_cdn
# CDN_UPLOAD_DIR=./upload_cdn

# Force reprocessing of all NeoForge versions (useful for debugging)
# Default: false
# FORCE_REPROCESS=false
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[workspace]
resolver = "2"

members = [
"daedalus",
Expand Down
5 changes: 2 additions & 3 deletions daedalus/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[package]
name = "daedalus"
version = "0.1.21"
version = "5.0.0"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2021"
edition = "2024"
license = "MIT"
description = "Utilities for querying and parsing Minecraft metadata"
repository = "https://github.com/modrinth/daedalus/"
Expand All @@ -26,7 +26,6 @@ bytes = "1"
thiserror = "1"
tokio = { version = "1", features = ["full"] }
sha1 = { version = "0.6.1", features = ["std"] }
bincode = { version = "2.0.0-rc.3", features = ["serde"], optional = true }
once_cell = "1"
url = "2"
lenient_semver = "0"
Expand Down
82 changes: 47 additions & 35 deletions daedalus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use std::{
cmp::Ordering, convert::TryFrom, fmt::Display, path::PathBuf, str::FromStr,
time::Duration,
sync::LazyLock, time::Duration,
};

use backon::{ExponentialBuilder, Retryable};
Expand All @@ -17,6 +17,18 @@ use serde::{Deserialize, Serialize};
pub mod minecraft;
/// Models and methods for fetching metadata for Minecraft mod loaders
pub mod modded;
/// Custom version comparison for Minecraft versions
pub mod version;

/// HTTP client configuration constants
/// TCP keepalive interval for persistent connections
const TCP_KEEPALIVE_SECS: u64 = 10;
/// Overall request timeout including reading response
const REQUEST_TIMEOUT_SECS: u64 = 120;
/// Connection establishment timeout
const CONNECT_TIMEOUT_SECS: u64 = 30;
/// Maximum idle connections per host in the pool
const MAX_IDLE_CONNECTIONS_PER_HOST: usize = 10;

/// Your branding, used for the user agent and similar
#[derive(Debug)]
Expand All @@ -30,6 +42,30 @@ pub struct Branding {
/// The branding of your application
pub static BRANDING: OnceCell<Branding> = OnceCell::new();

/// Global HTTP client with connection pooling and TCP keepalive
///
/// # Panics
/// Panics if the HTTP client fails to initialize. This is intentional as
/// the application cannot function without a working HTTP client (e.g., if
/// TLS initialization fails, which is extremely rare on modern systems).
static HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(header) = reqwest::header::HeaderValue::from_str(
&BRANDING.get_or_init(Branding::default).header_value,
) {
headers.insert(reqwest::header::USER_AGENT, header);
}

reqwest::Client::builder()
.tcp_keepalive(Some(Duration::from_secs(TCP_KEEPALIVE_SECS)))
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
.default_headers(headers)
.pool_max_idle_per_host(MAX_IDLE_CONNECTIONS_PER_HOST)
.build()
.expect("Failed to create HTTP client")
});

impl Branding {
/// Creates a new branding instance
pub fn new(name: String, email: String) -> Branding {
Expand Down Expand Up @@ -100,7 +136,6 @@ pub enum Error {
MirrorsFailed(String),
}

#[cfg_attr(feature = "bincode", derive(Encode, Decode))]
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Default)]
/// A specifier string for Gradle
pub struct GradleSpecifier {
Expand Down Expand Up @@ -166,12 +201,10 @@ impl GradleSpecifier {

/// Returns if specifier belongs to a lwjgl library
pub fn is_lwjgl(&self) -> bool {
vec![
"org.lwjgl",
["org.lwjgl",
"org.lwjgl.lwjgl",
"net.java.jinput",
"net.java.jutils",
]
"net.java.jutils"]
.contains(&self.package.as_str())
}

Expand All @@ -186,7 +219,7 @@ impl GradleSpecifier {
"{}:{}:{}",
self.package,
self.artifact,
self.identifier.clone().unwrap_or("".to_string())
self.identifier.as_deref().unwrap_or("")
)
}

Expand All @@ -195,17 +228,12 @@ impl GradleSpecifier {
/// Returns Ordering::Greater if self is greater than other
/// Returns Ordering::Less if self is less than other
pub fn compare_versions(&self, other: &Self) -> Result<Ordering, Error> {
let x = lenient_semver::parse(self.version.as_str());
let y = lenient_semver::parse(other.version.as_str());
let x = lenient_semver::parse(self.version.as_str())
.map_err(|_| Error::ParseError("Unable to parse version".to_string()))?;
let y = lenient_semver::parse(other.version.as_str())
.map_err(|_| Error::ParseError("Unable to parse version".to_string()))?;

if x.is_err() || y.is_err() {
return Err(Error::ParseError(
"Unable to parse version".to_string(),
));
}

// safe to unwrap because we already checked for errors
Ok(x.unwrap().cmp(&y.unwrap()))
Ok(x.cmp(&y))
}
}

Expand Down Expand Up @@ -358,32 +386,16 @@ pub async fn download_file_mirrors(
}
}

return Err(Error::MirrorsFailed("No mirrors succeeded!".to_string()));
Err(Error::MirrorsFailed("No mirrors succeeded!".to_string()))
}

/// Downloads a file with retry and checksum functionality
pub async fn download_file(
url: &str,
sha1: Option<&str>,
) -> Result<bytes::Bytes, Error> {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(header) = reqwest::header::HeaderValue::from_str(
&BRANDING.get_or_init(Branding::default).header_value,
) {
headers.insert(reqwest::header::USER_AGENT, header);
}
let client = reqwest::Client::builder()
.tcp_keepalive(Some(std::time::Duration::from_secs(10)))
.timeout(std::time::Duration::from_secs(15))
.default_headers(headers)
.build()
.map_err(|err| Error::FetchError {
inner: err,
item: url.to_string(),
})?;

(|| async {
let result = client.get(url).send().await;
let result = HTTP_CLIENT.get(url).send().await;

match result {
Ok(x) => {
Expand Down
Loading
Loading