feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,177 @@
use crate::state::{
CacheBehaviour, CacheValueType, CachedEntry, ModrinthProjectId,
ModrinthVersionId, Organization, Project, ProjectV3, SearchResults,
SearchResultsV3, TeamMember, User, Version, VersionV3,
};
macro_rules! impl_cache_methods {
($(($variant:ident, $type:ty)),*) => {
$(
paste::paste! {
#[tracing::instrument]
pub async fn [<get_ $variant:snake>](
id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<$type>>
{
let state = crate::State::get().await?;
Ok(CachedEntry::[<get_ $variant:snake _many>](&[id], cache_behaviour, &state.pool, &state.api_semaphore).await?.into_iter().next())
}
#[tracing::instrument]
pub async fn [<get_ $variant:snake _many>](
ids: &[&str],
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<$type>>
{
let state = crate::State::get().await?;
let entries =
CachedEntry::[<get_ $variant:snake _many>](ids, None, &state.pool, &state.api_semaphore).await?;
Ok(entries)
}
}
)*
}
}
impl_cache_methods!(
(ProjectV3, ProjectV3),
(User, User),
(Team, Vec<TeamMember>),
(Organization, Organization),
(SearchResults, SearchResults),
(SearchResultsV3, SearchResultsV3)
);
#[tracing::instrument]
pub async fn get_project(
id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<Project>> {
let id = ModrinthProjectId::new(id.to_string())?;
let state = crate::State::get().await?;
CachedEntry::get_project(
&id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
#[tracing::instrument]
pub async fn get_project_many(
ids: &[&str],
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<Project>> {
let ids = ids
.iter()
.map(|id| ModrinthProjectId::new((*id).to_string()))
.collect::<crate::Result<Vec<_>>>()?;
let state = crate::State::get().await?;
CachedEntry::get_project_many(
&ids,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
#[tracing::instrument]
pub async fn get_version(
id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<Version>> {
let id = ModrinthVersionId::new(id.to_string())?;
let state = crate::State::get().await?;
CachedEntry::get_version(
&id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
#[tracing::instrument]
pub async fn get_version_many(
ids: &[&str],
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<Version>> {
let ids = ids
.iter()
.map(|id| ModrinthVersionId::new((*id).to_string()))
.collect::<crate::Result<Vec<_>>>()?;
let state = crate::State::get().await?;
CachedEntry::get_version_many(
&ids,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
#[tracing::instrument]
pub async fn get_version_v3(
id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<VersionV3>> {
let id = ModrinthVersionId::new(id.to_string())?;
let state = crate::State::get().await?;
CachedEntry::get_version_v3(
&id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
#[tracing::instrument]
pub async fn get_version_v3_many(
ids: &[&str],
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<VersionV3>> {
let ids = ids
.iter()
.map(|id| ModrinthVersionId::new((*id).to_string()))
.collect::<crate::Result<Vec<_>>>()?;
let state = crate::State::get().await?;
CachedEntry::get_version_v3_many(
&ids,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
pub async fn purge_cache_types(
cache_types: &[CacheValueType],
) -> crate::Result<()> {
let state = crate::State::get().await?;
CachedEntry::purge_cache_types(cache_types, &state.pool).await?;
Ok(())
}
/// Get versions for a project (without changelogs for fast loading).
/// Uses the cache system with the ProjectVersions cache type.
#[tracing::instrument]
pub async fn get_project_versions(
project_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<Vec<Version>>> {
let project_id = ModrinthProjectId::new(project_id.to_string())?;
let state = crate::State::get().await?;
CachedEntry::get_project_versions(
&project_id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}

View File

@ -0,0 +1,28 @@
pub use crate::state::content_favorites::{
ContentFavorite, ContentFavoriteInput, ContentFavoriteProvider,
ContentFavoriteType,
};
#[tracing::instrument]
pub async fn list() -> crate::Result<Vec<ContentFavorite>> {
let state = crate::State::get().await?;
crate::state::content_favorites::list(&state.pool).await
}
#[tracing::instrument]
pub async fn add(
favorite: ContentFavoriteInput,
) -> crate::Result<ContentFavorite> {
let state = crate::State::get().await?;
crate::state::content_favorites::add(favorite, &state.pool).await
}
#[tracing::instrument]
pub async fn remove(
provider: ContentFavoriteProvider,
project_id: &str,
) -> crate::Result<()> {
let state = crate::State::get().await?;
crate::state::content_favorites::remove(provider, project_id, &state.pool)
.await
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
use crate::state::{FriendsSocket, UserFriend};
use ariadne::users::UserStatus;
#[tracing::instrument]
pub async fn friends() -> crate::Result<Vec<UserFriend>> {
let state = crate::State::get().await?;
let friends =
FriendsSocket::friends(&state.pool, &state.api_semaphore).await?;
Ok(friends)
}
pub async fn friend_statuses() -> crate::Result<Vec<UserStatus>> {
let state = crate::State::get().await?;
let statuses = state.friends_socket.friend_statuses();
Ok(statuses)
}
#[tracing::instrument]
pub async fn add_friend(user_id: &str) -> crate::Result<()> {
let state = crate::State::get().await?;
FriendsSocket::add_friend(user_id, &state.pool, &state.api_semaphore)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn remove_friend(user_id: &str) -> crate::Result<()> {
let state = crate::State::get().await?;
FriendsSocket::remove_friend(user_id, &state.pool, &state.api_semaphore)
.await?;
Ok(())
}

View File

@ -0,0 +1,831 @@
//! Direct-connect IP selection for Google Translate.
//!
//! `translate-pa.googleapis.com` is pinned in memory to a probed IPv4 so the
//! HTTPS URL, Host header, and TLS SNI stay unchanged while TCP connects to
//! the selected IP. The system hosts file, IPv6, and TLS certificate
//! verification are never used or modified.
use std::collections::HashSet;
use std::net::{IpAddr, SocketAddr};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use futures::{StreamExt, stream};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use tokio::sync::Mutex;
use crate::util::proxy::ProxyConfig;
use crate::{ErrorKind, State};
// IP list source: Ponderfly/GoogleTranslateIpCheck, MIT License.
// https://github.com/Ponderfly/GoogleTranslateIpCheck
const IP_LIST_URL: &str = "https://ghfast.top/https://raw.githubusercontent.com/Ponderfly/GoogleTranslateIpCheck/refs/heads/master/src/GoogleTranslateIpCheck/GoogleTranslateIpCheck/ip.txt";
const GOOGLE_TRANSLATE_HOST: &str = "translate-pa.googleapis.com";
const TOP_IPS: usize = 20;
const SCAN_BATCH_SIZE: usize = 1000;
const PROBE_CONCURRENCY: usize = 32;
const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(20);
const CACHE_REFRESH_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct GoogleTranslateIp {
pub ip: String,
pub latency_ms: u64,
}
#[derive(Debug)]
struct GoogleIpRuntime {
candidates: Vec<GoogleTranslateIp>,
current: usize,
client: Option<Client>,
pinned_ip: Option<String>,
}
impl GoogleIpRuntime {
fn current_ip(&self) -> Option<IpAddr> {
self.candidates
.get(self.current)
.and_then(|candidate| parse_ipv4(&candidate.ip))
}
}
struct RefreshHandle {
task: tokio::task::JoinHandle<()>,
done: tokio::sync::watch::Receiver<bool>,
}
static RUNTIME: LazyLock<Mutex<Option<GoogleIpRuntime>>> =
LazyLock::new(|| Mutex::new(None));
static REFRESH_TASK: LazyLock<Mutex<Option<Arc<RefreshHandle>>>> =
LazyLock::new(|| Mutex::new(None));
/// Returns a reqwest client pinned to the currently selected Google Translate
/// IPv4 address.
pub async fn google_translation_client() -> crate::Result<Client> {
let proxy = State::get().await?.proxy_config().await.unwrap_or_default();
loop {
let mut runtime = RUNTIME.lock().await;
match runtime.as_mut() {
Some(runtime) if runtime.current < runtime.candidates.len() => {
let pinned_matches = runtime
.candidates
.get(runtime.current)
.is_some_and(|candidate| {
runtime.pinned_ip.as_deref()
== Some(candidate.ip.as_str())
});
if let Some(client) = runtime.client.as_ref()
&& pinned_matches
{
return Ok(client.clone());
}
let Some(ip) = runtime.current_ip() else {
runtime.current += 1;
runtime.client = None;
runtime.pinned_ip = None;
continue;
};
let client = client_for(ip, &proxy);
runtime.client = Some(client.clone());
runtime.pinned_ip = Some(ip.to_string());
return Ok(client);
}
Some(_) => {
drop(runtime);
let task = start_refresh().await;
wait_for_refresh(&task).await;
return refreshed_client().await;
}
None => {
drop(runtime);
return initialize().await;
}
}
}
}
/// Marks the current pinned IP as failed so the next call moves to a cached
/// backup or triggers a background rescan.
pub async fn mark_current_failed() {
let mut runtime = RUNTIME.lock().await;
if let Some(runtime) = runtime.as_mut() {
let failed_ip = runtime.current_ip();
runtime.current = runtime.current.saturating_add(1);
runtime.client = None;
runtime.pinned_ip = None;
tracing::warn!(
ip = ?failed_ip.map(|ip| ip.to_string()),
next_index = runtime.current,
pool_size = runtime.candidates.len(),
"Google Translate IP marked failed; switching to next cached IP"
);
}
}
/// Returns the number of cached Google Translate IPs currently available.
pub async fn ip_pool_size() -> usize {
{
let runtime = RUNTIME.lock().await;
if let Some(runtime) = runtime.as_ref() {
return runtime.candidates.len();
}
}
let Ok(state) = State::get().await else {
tracing::warn!(
"Unable to resolve launcher state for Google Translate IP cache"
);
return 0;
};
let pool = &state.pool;
let stale = cache_is_stale(pool).await;
let cached = load_cache(pool).await;
let size = cached.len();
if cached.is_empty() {
tracing::warn!(
"Google Translate IP cache is missing or empty; starting background refresh"
);
let _ = start_refresh().await;
} else if stale {
tracing::info!(
size,
"Google Translate IP cache is stale; starting background refresh"
);
let _ = start_refresh().await;
} else {
tracing::info!(size, "Google Translate IP cache loaded from database");
}
size
}
async fn initialize() -> crate::Result<Client> {
let proxy = State::get().await?.proxy_config().await.unwrap_or_default();
preload().await;
{
let mut runtime = RUNTIME.lock().await;
if let Some(runtime) = runtime.as_mut()
&& runtime.current < runtime.candidates.len()
{
if let Some(client) = runtime.client.as_ref() {
return Ok(client.clone());
}
if let Some(ip) = runtime.current_ip() {
let client = client_for(ip, &proxy);
runtime.client = Some(client.clone());
runtime.pinned_ip = Some(ip.to_string());
return Ok(client);
}
}
}
let task = start_refresh().await;
wait_for_refresh(&task).await;
refreshed_client().await
}
/// Warms the in-memory Google Translate IP pool without blocking startup.
///
/// Loads the cached Top 20 and verifies the first usable IP. A missing, empty,
/// stale, or fully failed cache only starts a background refresh and returns
/// immediately.
pub async fn preload() {
let proxy = match State::get().await {
Ok(state) => state.proxy_config().await.ok().unwrap_or_default(),
Err(_) => ProxyConfig::default(),
};
{
let runtime = RUNTIME.lock().await;
if runtime
.as_ref()
.is_some_and(|runtime| runtime.current < runtime.candidates.len())
{
tracing::debug!("Google Translate IP pool already initialized");
return;
}
}
let Ok(state) = State::get().await else {
tracing::warn!(
"Unable to resolve launcher state during Google Translate IP preload"
);
return;
};
let pool = &state.pool;
let candidates = load_cache(pool).await;
let stale = cache_is_stale(pool).await;
tracing::info!(
cached = candidates.len(),
"Preloading Google Translate IP pool"
);
if candidates.is_empty() {
tracing::warn!(
"Google Translate IP cache missing or empty during preload; starting background refresh"
);
let _ = start_refresh().await;
return;
}
if stale {
tracing::info!(
"Google Translate IP cache stale during preload; starting background refresh"
);
let _ = start_refresh().await;
}
for (index, candidate) in candidates.iter().enumerate() {
let Some(ip) = parse_ipv4(&candidate.ip) else {
continue;
};
let Some(latency_ms) = probe(ip).await else {
tracing::debug!(ip = %ip, "Cached Google Translate IP failed preload verification");
continue;
};
tracing::info!(
ip = %ip,
latency_ms,
"Cached Google Translate IP verified during preload"
);
let client = client_for(ip, &proxy);
let mut runtime = RUNTIME.lock().await;
*runtime = Some(GoogleIpRuntime {
candidates,
current: index,
client: Some(client.clone()),
pinned_ip: Some(ip.to_string()),
});
return;
}
tracing::warn!(
"All cached Google Translate IPs failed preload verification; starting background refresh"
);
let _ = start_refresh().await;
}
async fn refreshed_client() -> crate::Result<Client> {
let proxy = State::get().await?.proxy_config().await.unwrap_or_default();
let mut runtime = RUNTIME.lock().await;
if let Some(runtime) = runtime.as_mut()
&& let Some(ip) = runtime
.candidates
.first()
.and_then(|candidate| parse_ipv4(&candidate.ip))
{
runtime.current = 0;
let client = client_for(ip, &proxy);
runtime.client = Some(client.clone());
runtime.pinned_ip = Some(ip.to_string());
return Ok(client);
}
Err(ErrorKind::OtherError(
"GOOGLE_IP_UNAVAILABLE: no usable Google Translate IP found"
.to_string(),
)
.into())
}
fn client_for(ip: IpAddr, proxy: &ProxyConfig) -> Client {
tracing::info!(ip = %ip, "Pinning Google Translate requests to IP");
let builder = Client::builder()
.resolve(GOOGLE_TRANSLATE_HOST, SocketAddr::new(ip, 443))
.timeout(Duration::from_secs(20))
.user_agent(crate::launcher_user_agent());
proxy
.apply(builder)
.expect("google translate proxy configuration should be valid")
.build()
.expect("google translate client configuration should be valid")
}
fn parse_ipv4(value: &str) -> Option<IpAddr> {
value.trim().parse::<IpAddr>().ok().filter(IpAddr::is_ipv4)
}
fn parse_ip_list(content: &str) -> Vec<IpAddr> {
let mut seen = HashSet::new();
content
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let ip = line.parse::<IpAddr>().ok()?;
if !ip.is_ipv4() || !seen.insert(ip) {
return None;
}
Some(ip)
})
.collect()
}
fn rank_candidates(
mut candidates: Vec<GoogleTranslateIp>,
) -> Vec<GoogleTranslateIp> {
let mut seen = HashSet::new();
candidates.retain(|candidate| {
parse_ipv4(&candidate.ip).is_some() && seen.insert(candidate.ip.clone())
});
candidates.sort_by_key(|candidate| candidate.latency_ms);
candidates.truncate(TOP_IPS);
candidates
}
async fn scan_batched(ips: &[IpAddr]) -> Vec<GoogleTranslateIp> {
let mut offset = 0;
while offset < ips.len() {
let end = (offset + SCAN_BATCH_SIZE).min(ips.len());
let batch = &ips[offset..end];
let found = current_pool_size().await;
tracing::info!(
start = offset + 1,
end,
total = ips.len(),
found,
"Probing Google Translate IP batch"
);
let mut stream = stream::iter(batch.iter().copied())
.map(|ip| async move { (ip, probe(ip).await) })
.buffer_unordered(PROBE_CONCURRENCY);
let mut full = false;
while let Some((ip, latency)) = stream.next().await {
let Some(latency_ms) = latency else {
continue;
};
let size = insert_candidate(ip, latency_ms).await;
tracing::info!(
ip = %ip,
latency_ms,
size,
"Google Translate IP added to pool"
);
if size >= TOP_IPS {
full = true;
break;
}
}
drop(stream);
if full || current_pool_size().await >= TOP_IPS {
break;
}
offset = end;
}
let candidates = current_candidates().await;
tracing::info!(
candidates = candidates.len(),
"Google Translate IP scan complete"
);
candidates
}
async fn insert_candidate(ip: IpAddr, latency_ms: u64) -> usize {
let mut runtime = RUNTIME.lock().await;
let runtime = runtime.get_or_insert_with(|| GoogleIpRuntime {
candidates: Vec::new(),
current: 0,
client: None,
pinned_ip: None,
});
let candidate = GoogleTranslateIp {
ip: ip.to_string(),
latency_ms,
};
if runtime
.candidates
.iter()
.any(|existing| existing.ip == candidate.ip)
{
return runtime.candidates.len();
}
let index = runtime
.candidates
.binary_search_by(|existing| {
existing.latency_ms.cmp(&candidate.latency_ms)
})
.unwrap_or_else(|index| index);
runtime.candidates.insert(index, candidate);
let became_best = index == 0;
runtime.candidates.truncate(TOP_IPS);
if became_best {
runtime.current = 0;
runtime.client = None;
runtime.pinned_ip = None;
}
runtime.candidates.len()
}
async fn current_pool_size() -> usize {
RUNTIME
.lock()
.await
.as_ref()
.map_or(0, |runtime| runtime.candidates.len())
}
async fn current_candidates() -> Vec<GoogleTranslateIp> {
RUNTIME
.lock()
.await
.as_ref()
.map(|runtime| runtime.candidates.clone())
.unwrap_or_default()
}
#[cfg(test)]
async fn scan_batched_with<F, Fut>(
ips: &[IpAddr],
mut probe: F,
) -> Vec<GoogleTranslateIp>
where
F: FnMut(IpAddr) -> Fut,
Fut: std::future::Future<Output = Option<u64>>,
{
let mut candidates = Vec::new();
let mut offset = 0;
while offset < ips.len() && candidates.len() < TOP_IPS {
let end = (offset + SCAN_BATCH_SIZE).min(ips.len());
let batch = &ips[offset..end];
tracing::info!(
start = offset + 1,
end,
total = ips.len(),
found = candidates.len(),
"Probing Google Translate IP batch"
);
let probed = stream::iter(batch.iter().copied())
.map(|ip| {
let future = probe(ip);
async move { (ip, future.await) }
})
.buffer_unordered(PROBE_CONCURRENCY)
.collect::<Vec<_>>()
.await;
candidates.extend(probed.into_iter().filter_map(|(ip, latency)| {
latency.map(|latency_ms| GoogleTranslateIp {
ip: ip.to_string(),
latency_ms,
})
}));
candidates = rank_candidates(candidates);
offset = end;
}
tracing::info!(
candidates = candidates.len(),
"Google Translate IP scan complete"
);
candidates
}
async fn probe(ip: IpAddr) -> Option<u64> {
let proxy = match State::get().await {
Ok(state) => state.proxy_config().await.ok().unwrap_or_default(),
Err(_) => ProxyConfig::default(),
};
let builder = Client::builder()
.resolve(GOOGLE_TRANSLATE_HOST, SocketAddr::new(ip, 443))
.connect_timeout(PROBE_CONNECT_TIMEOUT)
.timeout(PROBE_TIMEOUT)
.user_agent(crate::launcher_user_agent());
let client = match proxy.apply(builder) {
Ok(builder) => builder.build().ok()?,
Err(_) => {
tracing::warn!(
"Failed to apply proxy config for probe, using direct"
);
Client::builder()
.resolve(GOOGLE_TRANSLATE_HOST, SocketAddr::new(ip, 443))
.connect_timeout(PROBE_CONNECT_TIMEOUT)
.timeout(PROBE_TIMEOUT)
.user_agent(crate::launcher_user_agent())
.no_proxy()
.build()
.ok()?
}
};
let started = Instant::now();
let response = client
.get(format!("https://{GOOGLE_TRANSLATE_HOST}/"))
.send()
.await;
let Ok(response) = response else {
tracing::debug!(ip = %ip, "Google Translate IP probe failed");
return None;
};
drop(response);
let latency_ms = started.elapsed().as_millis() as u64;
tracing::debug!(ip = %ip, latency_ms, "Google Translate IP probe succeeded");
Some(latency_ms)
}
async fn download_ip_list() -> crate::Result<Vec<IpAddr>> {
let proxy = State::get().await?.proxy_config().await.unwrap_or_default();
tracing::info!(url = IP_LIST_URL, "Downloading Google Translate IP list");
let builder = Client::builder()
.timeout(DOWNLOAD_TIMEOUT)
.user_agent(crate::launcher_user_agent());
let client = match proxy.apply(builder) {
Ok(builder) => builder.build()?,
Err(e) => {
tracing::warn!(%e, "Failed to apply proxy config for IP list download, using direct");
Client::builder()
.timeout(DOWNLOAD_TIMEOUT)
.user_agent(crate::launcher_user_agent())
.no_proxy()
.build()?
}
};
let response = client.get(IP_LIST_URL).send().await?;
if !response.status().is_success() {
tracing::warn!(
status = %response.status(),
"Google Translate IP list download failed"
);
return Err(ErrorKind::OtherError(format!(
"GOOGLE_IP_LIST_FAILED: Ponderfly IP list returned HTTP {}",
response.status()
))
.into());
}
let text = response.text().await?;
let ips = parse_ip_list(&text);
tracing::info!(
bytes = text.len(),
ips = ips.len(),
"Google Translate IP list downloaded"
);
if ips.is_empty() {
return Err(ErrorKind::OtherError(
"GOOGLE_IP_LIST_FAILED: Ponderfly IP list contained no IPv4 addresses"
.to_string(),
)
.into());
}
Ok(ips)
}
async fn refresh_in_background() {
tracing::info!("Starting background Google Translate IP refresh");
let result: crate::Result<()> = async {
let ips = download_ip_list().await?;
let candidates = scan_batched(&ips).await;
if candidates.is_empty() {
return Err(ErrorKind::OtherError(
"GOOGLE_IP_UNAVAILABLE: no usable Google Translate IP found"
.to_string(),
)
.into());
}
let state = State::get().await?;
if let Err(error) = save_cache(&state.pool, &candidates).await {
tracing::warn!(%error, "Unable to persist Google Translate IP cache");
}
Ok(())
}
.await;
if let Err(error) = result {
tracing::warn!(%error, "Background Google Translate IP refresh failed");
} else {
tracing::info!("Google Translate IP cache refreshed");
}
}
async fn start_refresh() -> Arc<RefreshHandle> {
let mut guard = REFRESH_TASK.lock().await;
if let Some(handle) = guard.as_ref()
&& !handle.task.is_finished()
{
return handle.clone();
}
let (done_tx, done_rx) = tokio::sync::watch::channel(false);
let task = tokio::spawn(async move {
refresh_in_background().await;
let _ = done_tx.send(true);
});
let handle = Arc::new(RefreshHandle {
task,
done: done_rx,
});
*guard = Some(handle.clone());
handle
}
async fn wait_for_refresh(handle: &RefreshHandle) {
if handle.task.is_finished() {
return;
}
let mut done = handle.done.clone();
let _ = done.wait_for(|done| *done).await;
}
async fn load_cache(pool: &SqlitePool) -> Vec<GoogleTranslateIp> {
let rows = match sqlx::query(
"SELECT ip, latency_ms FROM google_translate_ip_cache \
ORDER BY latency_ms ASC, ip ASC LIMIT ?",
)
.bind(TOP_IPS as i32)
.fetch_all(pool)
.await
{
Ok(rows) => rows,
Err(error) => {
tracing::warn!(
%error,
"Unable to read Google Translate IP cache from database; treating it as empty"
);
return Vec::new();
}
};
let candidates = rows
.into_iter()
.filter_map(|row| {
let ip: String = row.try_get("ip").ok()?;
let latency_ms: i64 = row.try_get("latency_ms").ok()?;
Some(GoogleTranslateIp {
ip,
latency_ms: latency_ms.max(0) as u64,
})
})
.collect();
rank_candidates(candidates)
}
async fn save_cache(
pool: &SqlitePool,
candidates: &[GoogleTranslateIp],
) -> crate::Result<()> {
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0);
let mut transaction = pool.begin().await?;
sqlx::query("DELETE FROM google_translate_ip_cache")
.execute(&mut *transaction)
.await?;
for candidate in candidates {
sqlx::query(
"INSERT INTO google_translate_ip_cache (ip, latency_ms, created_at) \
VALUES (?, ?, ?)",
)
.bind(&candidate.ip)
.bind(candidate.latency_ms as i64)
.bind(created_at)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
tracing::info!(
count = candidates.len(),
"Google Translate IP cache saved to database"
);
Ok(())
}
async fn cache_is_stale(pool: &SqlitePool) -> bool {
let Ok(created_at) = sqlx::query_scalar::<_, Option<i64>>(
"SELECT MAX(created_at) FROM google_translate_ip_cache",
)
.fetch_one(pool)
.await
else {
return true;
};
let Some(created_at) = created_at else {
return true;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0);
now.saturating_sub(created_at) >= CACHE_REFRESH_AGE.as_secs() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_only_unique_ipv4_addresses() {
let content = "# comment\n\n1.2.3.4\r\n2001:db8::1\n1.2.3.4\n8.8.8.8\n";
assert_eq!(
parse_ip_list(content),
vec![
"1.2.3.4".parse::<IpAddr>().unwrap(),
"8.8.8.8".parse::<IpAddr>().unwrap(),
]
);
}
#[test]
fn ranks_and_truncates_candidates() {
let candidates = (0..25)
.map(|index| GoogleTranslateIp {
ip: format!("10.0.{}.1", index),
latency_ms: 1000 - index,
})
.collect();
let ranked = rank_candidates(candidates);
assert_eq!(ranked.len(), TOP_IPS);
assert_eq!(ranked.first().unwrap().latency_ms, 976);
assert!(
ranked
.windows(2)
.all(|window| window[0].latency_ms <= window[1].latency_ms)
);
}
#[tokio::test]
async fn stops_scanning_after_first_batch_reaches_top() {
use std::sync::atomic::{AtomicUsize, Ordering};
let ips = (0..2500)
.map(|index| {
format!("10.{}.{}.1", index / 256, index % 256)
.parse::<IpAddr>()
.unwrap()
})
.collect::<Vec<_>>();
let probed = Arc::new(AtomicUsize::new(0));
let probe_count = probed.clone();
let candidates = scan_batched_with(&ips, move |_ip| {
let probe_count = probe_count.clone();
async move {
probe_count.fetch_add(1, Ordering::Relaxed);
Some(10)
}
})
.await;
assert_eq!(candidates.len(), TOP_IPS);
assert_eq!(probed.load(Ordering::Relaxed), SCAN_BATCH_SIZE);
}
#[tokio::test]
async fn continues_to_next_batch_when_first_is_insufficient() {
use std::sync::atomic::{AtomicUsize, Ordering};
let ips = (0..2000)
.map(|index| {
let prefix = if index < SCAN_BATCH_SIZE { 10 } else { 11 };
let octet = index % SCAN_BATCH_SIZE;
format!("{prefix}.{}.{}.1", octet / 256, octet % 256,)
.parse::<IpAddr>()
.unwrap()
})
.collect::<Vec<_>>();
let probed = Arc::new(AtomicUsize::new(0));
let probe_count = probed.clone();
let candidates = scan_batched_with(&ips, move |ip| {
let probe_count = probe_count.clone();
async move {
probe_count.fetch_add(1, Ordering::Relaxed);
let IpAddr::V4(ipv4) = ip else {
return None;
};
if ipv4.octets()[0] == 11 {
Some(5)
} else {
None
}
}
})
.await;
assert_eq!(probed.load(Ordering::Relaxed), 2000);
assert_eq!(candidates.len(), TOP_IPS);
}
#[tokio::test]
async fn cache_round_trip() {
use sqlx::sqlite::SqlitePoolOptions;
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE google_translate_ip_cache (
ip TEXT NOT NULL PRIMARY KEY,
latency_ms INTEGER NOT NULL,
created_at INTEGER NOT NULL
)",
)
.execute(&pool)
.await
.unwrap();
let candidates = vec![
GoogleTranslateIp {
ip: "1.1.1.1".to_string(),
latency_ms: 10,
},
GoogleTranslateIp {
ip: "8.8.8.8".to_string(),
latency_ms: 20,
},
];
save_cache(&pool, &candidates).await.unwrap();
assert_eq!(load_cache(&pool).await, candidates);
}
}

View File

@ -0,0 +1,138 @@
use std::path::PathBuf;
use crate::{
event::{
CommandPayload,
emit::{emit_command, emit_warning},
},
util::io,
};
use url::form_urlencoded;
use urlencoding::decode;
/// Handles external functions (such as through URL deep linkage)
/// Link is extracted value (link) in somewhat URL format, such as
/// subdomain1/subdomain2
/// (Does not include axolotl://)
pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
// /seed-map?{query} - Opens the Lab seed map with a shared state
if let Some(rest) = sublink.strip_prefix("seed-map")
&& (rest.is_empty() || rest.starts_with('?') || rest.starts_with('/'))
{
let query = rest.trim_start_matches('/').trim_start_matches('?');
return Ok(CommandPayload::OpenSeedMap {
query: query.to_string(),
});
}
Ok(match sublink.split_once('/') {
// /mod/{id} - Installs a mod of mod id
Some(("mod", id)) => CommandPayload::InstallMod { id: id.to_string() },
// /version/{id} - Installs a specific version of id
Some(("version", id)) => {
CommandPayload::InstallVersion { id: id.to_string() }
}
// /modpack/{id} - Installs a modpack of modpack id
Some(("modpack", id)) => {
CommandPayload::InstallModpack { id: id.to_string() }
}
// /server/{id} - Opens a server project page and triggers play flow
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
// /launch/instance/{id} - Launches an instance
Some(("launch", rest)) if rest.starts_with("instance/") => {
let raw = rest.trim_start_matches("instance/");
let (raw, query) = raw.split_once('?').unwrap_or((raw, ""));
let mut server = None;
let mut singleplayer_world = None;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match &*key {
"server" => server = Some(value.into_owned()),
"singleplayer_world" => {
singleplayer_world = Some(value.into_owned());
}
_ => {}
}
}
if server.is_some() && singleplayer_world.is_some() {
emit_warning(
"Invalid command, cannot launch both a server and a singleplayer world",
)
.await?;
return Err(crate::ErrorKind::InputError(
"Cannot launch both a server and a singleplayer world"
.to_string(),
)
.into());
}
match decode(raw) {
Ok(decoded) => CommandPayload::LaunchInstance {
id: decoded.to_string(),
server,
singleplayer_world,
},
Err(e) => {
emit_warning(&format!(
"Invalid UTF-8 in instance path: {e}"
))
.await?;
return Err(crate::ErrorKind::InputError(format!(
"Invalid UTF-8 in instance path: {e}"
))
.into());
}
}
}
_ => {
emit_warning(&format!(
"Invalid command, unrecognized path: {sublink}"
))
.await?;
return Err(crate::ErrorKind::InputError(format!(
"Invalid command, unrecognized path: {sublink}"
))
.into());
}
})
}
pub async fn parse_command(
command_string: &str,
) -> crate::Result<CommandPayload> {
tracing::debug!("Parsing command: {}", &command_string);
// axolotl://some-command
// This occurs when following a web redirect link
if let Some(sublink) = command_string.strip_prefix("axolotl://") {
Ok(handle_url(sublink).await?)
} else {
// We assume anything else is a filepath to a modpack file; zip
// archives are format-sniffed by the pack installer.
let path = PathBuf::from(command_string);
let path = io::canonicalize(path)?;
if let Some(ext) = path.extension()
&& (ext == "mrpack" || ext == "zip")
{
return Ok(CommandPayload::RunMRPack { path });
}
emit_warning(&format!(
"Invalid command, unrecognized filetype: {}",
path.display()
))
.await?;
Err(crate::ErrorKind::InputError(format!(
"Invalid command, unrecognized filetype: {}",
path.display()
))
.into())
}
}
pub async fn parse_and_emit_command(command_string: &str) -> crate::Result<()> {
let command = parse_command(command_string).await?;
emit_command(command).await?;
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,81 @@
//! Theseus instance management interface
mod content;
mod core_components;
mod export_mrpack;
mod get;
mod home;
mod install;
mod lifecycle;
mod mcarchive;
mod paths;
mod planet_minecraft;
mod projects;
mod run;
mod upgrade;
pub use self::content::{
apply_content_update_plan, get_content_items, get_content_items_by_paths,
get_content_snapshot, get_dependencies_as_content_items,
get_install_candidates, get_installed_project_ids,
get_linked_modpack_content, get_linked_modpack_info, get_projects,
list_content_sets, plan_content_updates, refresh_content,
sync_content_files,
};
pub(crate) use self::core_components::assemble_for_launch;
pub use self::core_components::{
McArchiveCoreInstallResult, add_core_jar_mod, import_mcarchive_modloader,
install_mcarchive_modloader, list_core_components, move_core_component,
preview_core_jar, remove_core_component, replace_core_jar,
restore_core_component, set_core_component_enabled,
};
pub use self::export_mrpack::{
create_mrpack_json, export_mrpack, get_pack_export_candidates,
};
pub use self::get::{get, get_many, list};
pub use self::home::{
get_daily_playtime, get_daily_playtime_details, set_pinned,
};
pub use self::install::get_optimal_jre_key;
pub(crate) use self::lifecycle::create;
pub use self::lifecycle::{
cache_icon, create_with_direct_link, edit, edit_icon, remove,
sync_direct_links,
};
pub use self::mcarchive::{
McArchiveContentInstallRequest, McArchiveContentInstallResult,
import_mcarchive_content, install_mcarchive_content,
};
pub use self::paths::{get_full_path, get_mod_full_path};
pub use self::planet_minecraft::{
PlanetMinecraftContentInstallRequest, PlanetMinecraftContentInstallResult,
import_planet_minecraft_content, install_planet_minecraft_content,
};
pub(crate) use self::projects::emit_content_changed;
pub use self::projects::{
ContentToggleResult, InstallProjectWithDependenciesRequest,
add_project_from_path, add_project_from_version, import_world_save,
install_datapack_bytes_to_world, install_datapack_to_world,
install_project_with_dependencies, preview_project_with_dependencies,
preview_project_with_dependencies_for_target, queue_curseforge_content,
queue_curseforge_world, queue_project_with_dependencies,
remove_content_entry, remove_project, repair_managed_modrinth,
restore_pack_member_default, rollback_project,
switch_content_entry_version, switch_project_version_with_dependencies,
toggle_content_entries, toggle_content_entry, toggle_disable_project,
update_all_projects, update_content_entry, update_managed_modrinth_version,
update_project,
};
pub use self::run::{
GcLaunchIntent, GcLaunchReport, QuickPlayType, kill, run,
run_with_extra_launch_args, run_with_extra_launch_args_with_gc,
try_update_playtime_by_instance_id,
};
pub use self::upgrade::{
dismiss_instance_post_upgrade_notice, execute_instance_upgrade,
get_instance_post_upgrade_notice, get_instance_upgrade_plan,
plan_instance_upgrade, reset_instance_upgrade_resolution,
resolve_custom_instance_upgrade_solution, select_instance_upgrade_solution,
update_instance_upgrade_resolution, update_instance_upgrade_resolutions,
};
pub use crate::state::{DailyPlaytime, DailyPlaytimeEntry};

View File

@ -0,0 +1,530 @@
use crate::state::{
CacheBehaviour, ContentFile, ContentItem, ContentItemUpdate,
ContentOwnershipKind, ContentProvider, ContentSet, ContentUpdatePlan,
ContentUpdatePlanAction, ContentUpdateResolution,
ContentUpdateResolutionChoice, ContentUpdateScope, Dependency,
InstanceContentSnapshot, InstanceInstallCandidate, InstanceInstallTarget,
LinkedModpackInfo, ProjectType, State,
};
use dashmap::DashMap;
use std::sync::LazyLock;
static CONTENT_UPDATE_PLANS: LazyLock<DashMap<String, ContentUpdatePlan>> =
LazyLock::new(DashMap::new);
#[tracing::instrument]
pub async fn sync_content_files(
instance_id: &str,
) -> crate::Result<Vec<crate::state::instances::InstanceFile>> {
let state = State::get().await?;
crate::state::sync_content_files(instance_id, &state).await
}
#[tracing::instrument]
pub async fn list_content_sets(
instance_id: &str,
) -> crate::Result<Vec<ContentSet>> {
let state = State::get().await?;
crate::state::list_content_sets(instance_id, &state.pool).await
}
#[tracing::instrument]
pub async fn get_projects(
instance_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<DashMap<String, ContentFile>> {
let state = State::get().await?;
crate::state::get_content_projects(
instance_id,
None,
cache_behaviour,
&state,
)
.await
}
#[tracing::instrument]
pub async fn get_installed_project_ids(
instance_id: &str,
) -> crate::Result<Vec<String>> {
let state = State::get().await?;
crate::state::get_installed_project_ids_for_instance(
instance_id,
None,
&state,
)
.await
}
#[tracing::instrument]
pub async fn get_install_candidates(
project_id: &str,
project_type: ProjectType,
targets: Vec<InstanceInstallTarget>,
) -> crate::Result<Vec<InstanceInstallCandidate>> {
let state = State::get().await?;
crate::state::get_instance_install_candidates(
project_id,
project_type,
&targets,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_content_items(
instance_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
crate::state::list_content(instance_id, None, cache_behaviour, &state).await
}
#[tracing::instrument]
pub async fn get_content_items_by_paths(
instance_id: &str,
paths: Vec<String>,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
crate::state::list_content_by_paths(
instance_id,
&paths,
cache_behaviour,
&state,
)
.await
}
#[tracing::instrument]
pub async fn get_content_snapshot(
instance_id: &str,
) -> crate::Result<InstanceContentSnapshot> {
let state = State::get().await?;
crate::state::get_content_snapshot(instance_id, false, &state).await
}
#[tracing::instrument]
pub async fn refresh_content(
instance_id: &str,
) -> crate::Result<InstanceContentSnapshot> {
let state = State::get().await?;
require_installed_instance(instance_id, &state).await?;
crate::state::get_content_snapshot(instance_id, true, &state).await
}
#[tracing::instrument]
pub async fn plan_content_updates(
instance_id: &str,
scope: ContentUpdateScope,
target: Option<&str>,
) -> crate::Result<ContentUpdatePlan> {
let state = State::get().await?;
let snapshot =
crate::state::get_content_snapshot(instance_id, true, &state).await?;
let mut actions = Vec::new();
match scope {
ContentUpdateScope::UserAdded | ContentUpdateScope::Item => {
if scope == ContentUpdateScope::Item && target.is_none() {
return Err(crate::ErrorKind::InputError(
"An item update plan requires a stable content ID"
.to_string(),
)
.into());
}
for item in &snapshot.items {
let content_id = item
.entry_id
.as_deref()
.or(item.member_id.as_deref())
.or(item.file_id.as_deref());
let selected =
update_scope_selects_item(scope, target, item, content_id);
if !selected {
continue;
}
let Some(content) = item.content.as_ref() else {
continue;
};
let Some(update) = content.update.as_ref() else {
continue;
};
let Some(content_id) = content_id else {
continue;
};
actions.push(update_action(
content_id.to_string(),
Some(item.expected_relative_path.clone()),
item.ownership_kind,
update,
));
}
}
ContentUpdateScope::Pack => {
let pack = snapshot.pack.as_ref().ok_or_else(|| {
crate::ErrorKind::InputError(
"This instance is not linked to a managed pack".to_string(),
)
})?;
if !pack.reconciled {
return Err(crate::ErrorKind::InputError(
"Pack membership is not calibrated yet; refresh while online before updating the pack"
.to_string(),
)
.into());
}
if let Some(target_release_id) = target {
let provider = pack.provider.ok_or_else(|| {
crate::ErrorKind::InputError(
"This pack has no managed provider".to_string(),
)
})?;
actions.push(ContentUpdatePlanAction {
content_id: "pack".to_string(),
relative_path: None,
ownership_kind: ContentOwnershipKind::PackManaged,
provider,
current_release_id: pack.version_id.clone(),
target_release_id: target_release_id.to_string(),
});
} else if let Some(update) = pack
.metadata
.as_ref()
.and_then(|metadata| metadata.update.as_ref())
{
actions.push(update_action(
"pack".to_string(),
None,
ContentOwnershipKind::PackManaged,
update,
));
}
}
}
let plan = ContentUpdatePlan {
id: format!("content-update-plan:{}", uuid::Uuid::new_v4()),
instance_id: instance_id.to_string(),
revision: snapshot.revision,
scope,
actions,
warnings: snapshot
.warnings
.into_iter()
.map(|warning| warning.message)
.collect(),
};
CONTENT_UPDATE_PLANS.insert(plan.id.clone(), plan.clone());
Ok(plan)
}
#[tracing::instrument]
pub async fn apply_content_update_plan(
plan_id: &str,
resolutions: Vec<ContentUpdateResolution>,
) -> crate::Result<InstanceContentSnapshot> {
let plan = CONTENT_UPDATE_PLANS
.get(plan_id)
.map(|entry| entry.clone())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The content update plan has expired".to_string(),
)
})?;
let state = State::get().await?;
let current_revision = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
&plan.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?
.revision;
if let Err(error) =
ensure_update_plan_revision(plan.revision, current_revision)
{
CONTENT_UPDATE_PLANS.remove(plan_id);
return Err(error);
}
for resolution in resolutions {
if resolution.choice
== ContentUpdateResolutionChoice::RestorePackDefault
{
super::projects::restore_pack_member_default(
&plan.instance_id,
&resolution.content_id,
)
.await?;
}
}
if plan.scope == ContentUpdateScope::Pack {
if let Some(action) = plan.actions.first() {
match action.provider {
ContentProvider::Modrinth => {
super::projects::update_managed_modrinth_version(
&plan.instance_id,
&action.target_release_id,
)
.await?;
}
ContentProvider::CurseForge => {
let file_id = action
.target_release_id
.parse::<u32>()
.map_err(|_| {
crate::ErrorKind::InputError(
"The planned CurseForge file ID is invalid"
.to_string(),
)
})?;
crate::install::runner::update_managed_curseforge_modpack(
plan.instance_id.clone(),
file_id,
)
.await?;
}
ContentProvider::McArchive => {
return Err(crate::ErrorKind::InputError(
"MCArchive does not support automatic pack updates"
.to_string(),
)
.into());
}
ContentProvider::Local => {}
}
}
} else {
for action in &plan.actions {
super::projects::switch_content_entry_version(
&plan.instance_id,
&action.content_id,
&action.target_release_id,
)
.await?;
}
}
CONTENT_UPDATE_PLANS.remove(plan_id);
crate::state::get_content_snapshot(&plan.instance_id, false, &state).await
}
fn update_action(
content_id: String,
relative_path: Option<String>,
ownership_kind: ContentOwnershipKind,
update: &ContentItemUpdate,
) -> ContentUpdatePlanAction {
match update {
ContentItemUpdate::Modrinth {
current_version_id,
target_version_id,
..
} => ContentUpdatePlanAction {
content_id,
relative_path,
ownership_kind,
provider: ContentProvider::Modrinth,
current_release_id: Some(current_version_id.to_string()),
target_release_id: target_version_id.to_string(),
},
ContentItemUpdate::CurseForge {
current_file_id,
target_file_id,
..
} => ContentUpdatePlanAction {
content_id,
relative_path,
ownership_kind,
provider: ContentProvider::CurseForge,
current_release_id: Some(current_file_id.get().to_string()),
target_release_id: target_file_id.get().to_string(),
},
}
}
fn update_scope_selects_item(
scope: ContentUpdateScope,
target: Option<&str>,
item: &crate::state::InstanceContentSnapshotItem,
content_id: Option<&str>,
) -> bool {
match scope {
ContentUpdateScope::UserAdded => {
item.ownership_kind == ContentOwnershipKind::UserAdded
}
ContentUpdateScope::Item => content_id == target,
ContentUpdateScope::Pack => false,
}
}
fn ensure_update_plan_revision(
planned_revision: u64,
current_revision: u64,
) -> crate::Result<()> {
if planned_revision == current_revision {
return Ok(());
}
Err(crate::ErrorKind::InputError(format!(
"The content update plan is stale (planned revision {planned_revision}, current revision {current_revision})"
))
.into())
}
#[tracing::instrument]
pub async fn get_linked_modpack_content(
instance_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
crate::state::list_linked_modpack_content(
instance_id,
None,
cache_behaviour,
&state,
)
.await
}
#[tracing::instrument]
pub async fn get_dependencies_as_content_items(
dependencies: Vec<Dependency>,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
crate::state::dependencies_to_content_items(
&dependencies,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
#[tracing::instrument]
pub async fn get_linked_modpack_info(
instance_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<LinkedModpackInfo>> {
let state = State::get().await?;
require_installed_instance(instance_id, &state).await?;
crate::state::get_linked_modpack_info(
instance_id,
None,
cache_behaviour,
&state,
)
.await
}
async fn require_installed_instance(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
let metadata = crate::state::instances::commands::get_instance_metadata(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::UnmanagedInstanceError(instance_id.to_string())
})?;
let stage = metadata.instance.install_stage;
if stage != crate::state::InstanceInstallStage::Installed {
return Err(crate::ErrorKind::InstanceNotReady {
instance_id: instance_id.to_string(),
stage: stage.as_str().to_string(),
}
.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::instances::{
ContentItemCapabilities, InstanceContentSnapshotItem,
PackMemberMaterializationState, PackMemberOverrideKind,
};
fn snapshot_item(
ownership_kind: ContentOwnershipKind,
entry_id: Option<&str>,
member_id: Option<&str>,
file_id: Option<&str>,
) -> InstanceContentSnapshotItem {
InstanceContentSnapshotItem {
file_id: file_id.map(str::to_string),
entry_id: entry_id.map(str::to_string),
member_id: member_id.map(str::to_string),
ownership_kind,
materialization_state: PackMemberMaterializationState::Present,
override_kind: PackMemberOverrideKind::None,
expected_relative_path: "mods/test.jar".to_string(),
required: true,
project_type: ProjectType::Mod,
provider: None,
provider_project_id: None,
provider_release_id: None,
content: None,
capabilities: ContentItemCapabilities::default(),
dependency: None,
}
}
#[test]
fn user_added_update_scope_excludes_pack_and_discovered_content() {
let user_added = snapshot_item(
ContentOwnershipKind::UserAdded,
Some("user-entry"),
None,
Some("user-file"),
);
let pack_managed = snapshot_item(
ContentOwnershipKind::PackManaged,
Some("pack-entry"),
Some("pack-member"),
Some("pack-file"),
);
let discovered = snapshot_item(
ContentOwnershipKind::LocalDiscovered,
None,
None,
Some("discovered-file"),
);
assert!(update_scope_selects_item(
ContentUpdateScope::UserAdded,
None,
&user_added,
user_added.entry_id.as_deref(),
));
assert!(!update_scope_selects_item(
ContentUpdateScope::UserAdded,
None,
&pack_managed,
pack_managed.entry_id.as_deref(),
));
assert!(!update_scope_selects_item(
ContentUpdateScope::UserAdded,
None,
&discovered,
discovered.file_id.as_deref(),
));
}
#[test]
fn update_plan_revision_rejects_stale_plans() {
assert!(ensure_update_plan_revision(7, 7).is_ok());
let error = ensure_update_plan_revision(7, 8).unwrap_err();
assert!(error.to_string().contains("planned revision 7"));
assert!(error.to_string().contains("current revision 8"));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,722 @@
use super::content::get_projects;
use super::get::get;
use super::paths::get_full_path;
use crate::api::content_search::original_content_relative_path;
use crate::event::LoadingBarType;
use crate::event::emit::{emit_loading, init_loading};
use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
use crate::state::{
CacheBehaviour, CachedEntry, ContentProviderRef, InstanceMetadata,
ModLoader, ModrinthVersionId, SideType, State,
};
use crate::util::io::{self, IOError};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, DeflateOption, ZipEntryBuilder};
use futures::io::AsyncWriteExt;
use path_util::SafeRelativeUtf8UnixPathBuf;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
const METADATA_PROGRESS_WEIGHT: f64 = 15.0;
const WRITE_PROGRESS_WEIGHT: f64 = 80.0;
/// Minimum bar movement (in progress points) before another loading event is
/// emitted, so small packs do not spam the UI with per-chunk updates.
const PROGRESS_EMIT_THRESHOLD: f64 = 0.1;
/// Hard floor on how many copy chunks may pass between loading events, so very
/// large files still move the bar while they are being packed.
const PROGRESS_EMIT_CHUNKS: u32 = 64;
/// Deflate is only applied to compressible text/config files; a low level
/// keeps packing fast while still shrinking those files.
const DEFLATE_OPTION: DeflateOption = DeflateOption::Other(1);
const COPY_BUFFER_SIZE: usize = 256 * 1024;
#[tracing::instrument(skip_all)]
pub async fn export_mrpack(
instance_id: &str,
export_path: PathBuf,
included_export_candidates: Vec<String>,
version_id: Option<String>,
description: Option<String>,
_name: Option<String>,
) -> crate::Result<()> {
let state = State::get().await?;
let _permit: tokio::sync::SemaphorePermit =
state.io_semaphore.0.acquire().await?;
let metadata = get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to export a nonexistent instance {instance_id}!"
))
})?;
// Directly associated instances own no files: everything lives in the
// linked launcher's `.minecraft`, so there is nothing exportable here.
if metadata.instance.is_direct_linked() {
return Err(crate::ErrorKind::InputError(format!(
"\"{}\" is directly associated with an external launcher; its \
files are managed by that launcher and cannot be exported",
metadata.instance.name
))
.into());
}
let included_export_candidates = included_export_candidates
.into_iter()
.map(|candidate| candidate.replace('\\', "/"))
.filter(|x| {
if let Some(f) = PathBuf::from(x).file_name()
&& f.to_string_lossy().starts_with(".DS_Store")
{
return false;
}
true
})
.collect::<Vec<_>>();
let instance_base_path = get_full_path(instance_id).await?;
let mut file = File::create(&export_path)
.await
.map_err(|e| IOError::with_path(e, &export_path))?;
let mut writer = ZipFileWriter::with_tokio(&mut file);
let version_id = version_id.unwrap_or("1.0.0".to_string());
let loading_bar = init_loading(
LoadingBarType::ZipExtract {
instance_id: metadata.instance.id.clone(),
instance_name: metadata.instance.name.clone(),
},
100.0,
"Exporting instance to .mrpack",
)
.await?;
let (mut progress, mut packfile) = {
let mut metadata_progress = 0.0;
let mut on_metadata_progress = |target: f64| {
let target = METADATA_PROGRESS_WEIGHT * target.clamp(0.0, 1.0);
let delta = target - metadata_progress;
metadata_progress = target;
let _ = emit_loading(
&loading_bar,
delta,
Some("Preparing modpack metadata"),
);
};
let packfile = create_mrpack_json_inner(
&metadata,
version_id,
description,
&mut on_metadata_progress,
)
.await?;
drop(on_metadata_progress);
(metadata_progress, packfile)
};
packfile.files.retain(|f| {
is_export_candidate_included(
f.path.as_str(),
&included_export_candidates,
)
});
strip_localized_pack_file_paths(&mut packfile.files);
let mut path_list = Vec::new();
add_all_recursive_folder_paths(&instance_base_path, &mut path_list).await?;
let disk_paths = path_list
.iter()
.filter(|path| path.is_file())
.filter_map(|path| {
pack_get_relative_path(&instance_base_path, path)
.ok()
.map(|relative_path| relative_path.as_str().to_string())
})
.collect::<HashSet<_>>();
let mut write_list: Vec<(PathBuf, String)> = Vec::new();
let mut write_total_bytes: u64 = 0;
for path in &path_list {
let Ok(relative_path) =
pack_get_relative_path(&instance_base_path, path)
else {
continue;
};
let exported_path =
original_content_relative_path(relative_path.as_str());
let Ok(metadata) = path.metadata() else {
continue;
};
if !metadata.is_file()
|| packfile
.files
.iter()
.any(|f| f.path.as_str() == exported_path)
|| !is_export_candidate_included(
relative_path.as_str(),
&included_export_candidates,
)
{
continue;
}
write_total_bytes += metadata.len();
write_list.push((path.clone(), relative_path.as_str().to_string()));
}
let mut written_override_paths = HashSet::new();
let mut buffer = vec![0u8; COPY_BUFFER_SIZE];
let mut bytes_written = 0u64;
let mut write_progress = 0.0_f64;
for (path, relative_path) in write_list {
let exported_path =
original_content_relative_path(relative_path.as_str());
let entry_path = if exported_path != relative_path.as_str()
&& !disk_paths.contains(&exported_path)
&& written_override_paths.insert(exported_path.clone())
{
exported_path
} else {
relative_path.as_str().to_string()
};
let compression = if is_already_compressed(&entry_path) {
Compression::Stored
} else {
Compression::Deflate
};
let builder = ZipEntryBuilder::new(
format!("overrides/{entry_path}").into(),
compression,
)
.deflate_option(DEFLATE_OPTION);
let mut file = File::open(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
let mut entry = writer.write_entry_stream(builder).await?;
let mut chunks_since_emit = 0u32;
loop {
let read = file
.read(&mut buffer)
.await
.map_err(|e| IOError::with_path(e, &path))?;
if read == 0 {
break;
}
entry
.write_all(&buffer[..read])
.await
.map_err(|e| IOError::with_path(e, &path))?;
bytes_written += read as u64;
chunks_since_emit += 1;
let target = WRITE_PROGRESS_WEIGHT
* (bytes_written as f64 / write_total_bytes.max(1) as f64)
.min(1.0);
let delta = target - write_progress;
if delta >= PROGRESS_EMIT_THRESHOLD
|| chunks_since_emit >= PROGRESS_EMIT_CHUNKS
{
write_progress = target;
progress += delta;
let _ = emit_loading(
&loading_bar,
delta,
Some(relative_path.as_str()),
);
chunks_since_emit = 0;
}
}
entry.close().await?;
let target = WRITE_PROGRESS_WEIGHT
* (bytes_written as f64 / write_total_bytes.max(1) as f64).min(1.0);
let delta = target - write_progress;
if delta > 0.0 {
write_progress = target;
progress += delta;
let _ =
emit_loading(&loading_bar, delta, Some(relative_path.as_str()));
}
}
let data = serde_json::to_vec_pretty(&packfile)?;
let builder = ZipEntryBuilder::new(
"modrinth.index.json".to_string().into(),
Compression::Deflate,
);
writer.write_entry_whole(builder, &data).await?;
writer.close().await?;
let _ =
emit_loading(&loading_bar, 100.0 - progress, Some("Finalizing export"));
Ok(())
}
/// Files in these formats are already compressed, so re-compressing them
/// with Deflate wastes CPU for little size gain; store them as-is instead.
fn is_already_compressed(path: &str) -> bool {
let Some(extension) = path.rsplit('.').next() else {
return false;
};
matches!(
extension.to_ascii_lowercase().as_str(),
"7z" | "aac"
| "apk"
| "avif"
| "bz2"
| "flac"
| "gif"
| "gz"
| "heic"
| "jar"
| "jpeg"
| "jpg"
| "lz4"
| "lzma"
| "m4a"
| "mkv"
| "mov"
| "mp3"
| "mp4"
| "ogg"
| "oga"
| "opus"
| "png"
| "rar"
| "webm"
| "webp"
| "woff"
| "woff2"
| "xz"
| "zip"
| "zst"
)
}
fn is_export_candidate_included(
path: &str,
included_export_candidates: &[String],
) -> bool {
included_export_candidates.iter().any(|candidate| {
path == candidate
|| path
.strip_prefix(candidate)
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
/// Rewrites `[中文名]`-prefixed install paths back to their original names so
/// exported packs stay free of localized file names. Entries whose stripped
/// path would collide with another entry keep their on-disk name.
fn strip_localized_pack_file_paths(files: &mut [PackFile]) {
let mut used = files
.iter()
.map(|file| file.path.as_str().to_string())
.collect::<HashSet<_>>();
for file in files {
let stripped = original_content_relative_path(file.path.as_str());
if stripped == file.path.as_str() || used.contains(&stripped) {
continue;
}
let Ok(path) = SafeRelativeUtf8UnixPathBuf::try_from(stripped.clone())
else {
continue;
};
used.insert(stripped);
file.path = path;
}
}
#[tracing::instrument]
pub async fn get_pack_export_candidates(
instance_id: &str,
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
let mut path_list = Vec::new();
let instance_base_dir = get_full_path(instance_id).await?;
let mut read_dir = io::read_dir(&instance_base_dir).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
{
let path = entry.path();
if path.is_dir() {
let mut read_dir = io::read_dir(&path).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &instance_base_dir))?
{
path_list.push(pack_get_relative_path(
&instance_base_dir,
&entry.path(),
)?);
}
} else {
path_list.push(pack_get_relative_path(&instance_base_dir, &path)?);
}
}
Ok(path_list)
}
fn pack_get_relative_path(
instance_path: &PathBuf,
path: &PathBuf,
) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
Ok(SafeRelativeUtf8UnixPathBuf::try_from(
path.strip_prefix(instance_path)
.map_err(|_| {
crate::ErrorKind::FSError(format!(
"Path {path:?} does not correspond to an instance"
))
})?
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)?)
}
/// The `.mrpack` specification stores archive paths as Unix-style relative
/// paths, even when the source instance is on Windows.
fn mrpack_relative_path(
relative_path: &str,
) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
Ok(SafeRelativeUtf8UnixPathBuf::try_from(
relative_path.replace('\\', "/"),
)?)
}
#[tracing::instrument(skip_all)]
pub async fn create_mrpack_json(
metadata: &InstanceMetadata,
version_id: String,
description: Option<String>,
) -> crate::Result<PackFormat> {
create_mrpack_json_inner(metadata, version_id, description, &mut |_| {})
.await
}
#[tracing::instrument(skip_all)]
async fn create_mrpack_json_inner(
metadata: &InstanceMetadata,
version_id: String,
description: Option<String>,
on_progress: &mut impl FnMut(f64),
) -> crate::Result<PackFormat> {
let mut dependencies = HashMap::new();
match (
metadata.applied_content_set.loader,
metadata.applied_content_set.loader_version.clone(),
) {
(ModLoader::Forge, Some(v)) => {
dependencies.insert(PackDependency::Forge, v)
}
(ModLoader::NeoForge, Some(v)) => {
dependencies.insert(PackDependency::NeoForge, v)
}
(ModLoader::Fabric, Some(v)) => {
dependencies.insert(PackDependency::FabricLoader, v)
}
(ModLoader::Quilt, Some(v)) => {
dependencies.insert(PackDependency::QuiltLoader, v)
}
(ModLoader::Babric, _) => {
return Err(crate::ErrorKind::OtherError(
"Babric instances cannot be exported to mrpack, as the format has no Babric dependency type".to_string(),
).into())
}
(ModLoader::Vanilla, _) => None,
(ModLoader::OptiFine, _) => {
return Err(crate::ErrorKind::OtherError(
"OptiFine instances cannot be exported to mrpack, as the format has no OptiFine dependency type".to_string(),
)
.into());
}
_ => {
return Err(crate::ErrorKind::OtherError(
"Loader version mismatch".to_string(),
)
.into());
}
};
dependencies.insert(
PackDependency::Minecraft,
metadata.applied_content_set.game_version.clone(),
);
let state = State::get().await?;
let projects = get_projects(
&metadata.instance.id,
Some(CacheBehaviour::MustRevalidate),
)
.await?
.into_iter()
.collect::<Vec<_>>();
on_progress(0.2);
let instance_path = get_full_path(&metadata.instance.id).await?;
let mut modrinth_version_ids = projects
.iter()
.flat_map(|(_, file)| file.provider_refs.iter())
.filter_map(|reference| match reference {
ContentProviderRef::Modrinth {
version_id: Some(version_id),
..
} => Some(version_id.to_string()),
_ => None,
})
.collect::<HashSet<_>>();
for file in projects.iter().map(|(_, file)| file) {
if let Some(metadata) = &file.modrinth {
modrinth_version_ids.insert(metadata.version_id.to_string());
}
}
let modrinth_version_id_refs = modrinth_version_ids
.iter()
.map(|id| ModrinthVersionId::new(id.clone()))
.collect::<crate::Result<Vec<_>>>()?;
let versions = CachedEntry::get_version_many(
&modrinth_version_id_refs,
None,
&state.pool,
&state.api_semaphore,
)
.await?;
on_progress(0.4);
let versions_by_id = versions
.into_iter()
.map(|version| (version.id.clone(), version))
.collect::<HashMap<_, _>>();
let mut files = Vec::new();
let mut remote_paths = HashSet::new();
let project_total = projects.len();
for (index, (path, content_file)) in projects.into_iter().enumerate() {
on_progress(
0.4 + 0.6 * ((index + 1) as f64 / project_total.max(1) as f64),
);
let disk_path = instance_path.join(path.as_str());
let Ok((disk_size, local_sha1)) =
crate::util::fetch::sha1_file_async(&disk_path).await
else {
continue;
};
let Some(file_size) = u32::try_from(disk_size).ok() else {
continue;
};
let mut remote: Option<(HashMap<PackFileHash, String>, String)> = None;
for reference in &content_file.provider_refs {
match reference {
ContentProviderRef::Modrinth {
version_id: Some(version_id),
..
} => {
let Some(version) = versions_by_id.get(version_id.as_str())
else {
continue;
};
if let Some(version_file) =
version.files.iter().find(|file| {
file.size == file_size
&& file.hashes.get("sha1").is_some_and(|hash| {
hash.eq_ignore_ascii_case(&local_sha1)
})
&& !file.url.trim().is_empty()
})
{
remote = Some((
version_file
.hashes
.clone()
.into_iter()
.map(|(kind, hash)| {
(PackFileHash::from(kind), hash)
})
.collect(),
version_file.url.clone(),
));
break;
}
}
ContentProviderRef::CurseForge {
project_id,
file_id: Some(file_id),
} => {
let Ok(file) = crate::api::curseforge::get_file(
project_id.get(),
file_id.get(),
)
.await
else {
continue;
};
let allowed =
crate::api::curseforge::get_project(project_id.get())
.await
.ok()
.and_then(|project| project.allow_mod_distribution)
.unwrap_or(true);
if !allowed {
continue;
}
let hash = file
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.as_str());
if file.file_length == u64::from(file_size)
&& hash.is_some_and(|hash| {
hash.eq_ignore_ascii_case(&local_sha1)
})
&& file
.download_url
.as_deref()
.is_some_and(|url| !url.trim().is_empty())
{
let mut hashes = HashMap::new();
if let Some(hash) = hash {
hashes.insert(PackFileHash::Sha1, hash.to_string());
}
remote = Some((
hashes,
file.download_url.unwrap_or_default(),
));
break;
}
}
_ => {}
}
}
let Some((hashes, download)) = remote else {
continue;
};
let relative_path = path.as_str().replace('\\', "/");
let Ok(path) = mrpack_relative_path(&original_content_relative_path(
&relative_path,
)) else {
continue;
};
if !remote_paths.insert(path.as_str().to_string()) {
continue;
}
let mut env = HashMap::new();
env.insert(EnvType::Client, SideType::Required);
env.insert(EnvType::Server, SideType::Required);
files.push(PackFile {
path,
hashes,
env: Some(env),
downloads: vec![download],
file_size,
});
}
on_progress(1.0);
Ok(PackFormat {
game: "minecraft".to_string(),
format_version: 1,
version_id,
name: metadata.instance.name.clone(),
summary: description,
files,
dependencies,
})
}
#[async_recursion::async_recursion]
async fn add_all_recursive_folder_paths(
folder: &PathBuf,
output: &mut Vec<PathBuf>,
) -> crate::Result<()> {
let mut read_dir = io::read_dir(folder).await?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, folder))?
{
let path = entry.path();
if path.is_dir() {
add_all_recursive_folder_paths(&path, output).await?;
} else {
output.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state::{CreateDirectLinkInstance, State};
use std::sync::Arc;
use tempfile::TempDir;
#[test]
fn mrpack_paths_normalize_windows_separators() {
let path =
super::mrpack_relative_path(r"config\subdir\options.txt").unwrap();
assert_eq!(path.as_str(), "config/subdir/options.txt");
}
/// The launcher state is a process-wide singleton; initialize it once and
/// reuse it so `State::get()` resolves inside these APIs. The state root
/// is intentionally leaked (`.keep()`) because the shared state outlives
/// this function.
async fn global_state() -> Arc<State> {
if !State::initialized() {
let root = TempDir::new().unwrap().keep();
let _ =
State::init_for_test(root.to_string_lossy().to_string()).await;
}
State::get().await.unwrap()
}
#[tokio::test]
async fn direct_link_instances_cannot_be_exported() {
let state = global_state().await;
let minecraft = TempDir::new().unwrap();
let version_dir = minecraft.path().join("versions/export-demo");
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::write(
version_dir.join("export-demo.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"id": "export-demo",
"inheritsFrom": "1.20.1",
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
let instance = crate::state::create_direct_link_instance(
CreateDirectLinkInstance {
name: None,
launcher_type:
crate::api::pack::import::ImportLauncherType::Generic,
base_path: minecraft.path().to_path_buf(),
instance_folder: "versions/export-demo".to_string(),
instance_path: None,
game_dir_mode: None,
},
&state,
)
.await
.unwrap();
let export_target = minecraft.path().join("out.mrpack");
let error = super::export_mrpack(
&instance.id,
export_target,
Vec::new(),
None,
None,
None,
)
.await
.unwrap_err();
let message = error.to_string();
assert!(
message.contains("directly associated"),
"expected a friendly rejection, got: {message}"
);
assert!(
!minecraft.path().join("out.mrpack").exists(),
"no archive may be written"
);
}
}

View File

@ -0,0 +1,21 @@
use crate::state::{InstanceMetadata, State};
#[tracing::instrument]
pub async fn get(instance_id: &str) -> crate::Result<Option<InstanceMetadata>> {
let state = State::get().await?;
crate::state::get_instance(instance_id, &state.pool).await
}
#[tracing::instrument]
pub async fn get_many(
instance_ids: &[&str],
) -> crate::Result<Vec<InstanceMetadata>> {
let state = State::get().await?;
crate::state::get_instances_metadata(instance_ids, &state.pool).await
}
#[tracing::instrument]
pub async fn list() -> crate::Result<Vec<InstanceMetadata>> {
let state = State::get().await?;
crate::state::list_instances(&state.pool).await
}

View File

@ -0,0 +1,53 @@
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::{
DailyPlaytime, DailyPlaytimeEntry, InstanceMetadata, State,
};
pub async fn set_pinned(
instance_id: &str,
pinned: bool,
) -> crate::Result<InstanceMetadata> {
let state = State::get().await?;
crate::state::instances::commands::set_instance_pinned(
instance_id,
pinned,
&state.pool,
)
.await?;
let instance = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
.as_error()
})?;
emit_instance(&instance.instance.id, InstancePayloadType::Edited).await?;
Ok(instance)
}
pub async fn get_daily_playtime(
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> crate::Result<Vec<DailyPlaytime>> {
let state = State::get().await?;
crate::state::instances::commands::get_daily_playtime(
start_date,
end_date,
&state.pool,
)
.await
}
pub async fn get_daily_playtime_details(
date: chrono::NaiveDate,
) -> crate::Result<Vec<DailyPlaytimeEntry>> {
let state = State::get().await?;
crate::state::instances::commands::get_daily_playtime_details(
date,
&state.pool,
)
.await
}

View File

@ -0,0 +1,48 @@
use crate::state::{JavaVersion, State};
pub async fn get_optimal_jre_key(
instance_id: &str,
) -> crate::Result<Option<JavaVersion>> {
let state = State::get().await?;
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to resolve a nonexistent instance {instance_id}!"
))
})?;
let (minecraft, version_index) =
crate::launcher::resolve_minecraft_manifest(
&context.applied_content_set.game_version,
&state,
)
.await?;
let version = &minecraft.versions[version_index];
let loader_version = crate::launcher::get_loader_version_from_profile(
&context.applied_content_set.game_version,
context.applied_content_set.loader,
context.applied_content_set.loader_version.as_deref(),
)
.await?;
let version_info = crate::launcher::download::download_version_info(
&state,
version,
context.applied_content_set.loader,
loader_version.as_ref(),
None,
None,
None,
)
.await?;
let major_version = version_info
.java_version
.as_ref()
.map_or(8, |java| java.major_version);
crate::api::jre::find_java_for_version(major_version).await
}

View File

@ -0,0 +1,189 @@
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{
CreateDirectLinkInstance, CreateInstance, EditInstance, InstanceLink,
InstanceMetadata, ModLoader, State,
};
use crate::util::{fetch::write_cached_icon, io};
use std::path::Path;
use std::path::PathBuf;
#[tracing::instrument]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create(
name: String,
game_version: String,
modloader: ModLoader,
loader_version: Option<String>,
icon_path: Option<String>,
link: InstanceLink,
symlink_target: Option<String>,
game_dir_override: Option<String>,
) -> crate::Result<InstanceMetadata> {
let state = State::get().await?;
let instance = crate::state::create_instance(
CreateInstance {
name,
path: None,
game_version,
loader: modloader,
loader_version,
icon_path,
link,
symlink_target,
game_dir_override,
},
&state,
)
.await?;
let result = async {
emit_instance(&instance.id, InstancePayloadType::Created).await?;
crate::state::get_instance(&instance.id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Created instance could not be loaded".to_string(),
)
.into()
})
}
.await;
if result.is_err() {
let _ = crate::state::remove_instance(&instance.id, &state).await;
}
result
}
/// Creates a "directly associated" instance that launches an externally
/// managed (HMCL/PCL) local version in place: nothing is copied, symlinked,
/// or reinstalled, and the game directory points at the linked `.minecraft`.
#[tracing::instrument]
pub async fn create_with_direct_link(
input: CreateDirectLinkInstance,
) -> crate::Result<InstanceMetadata> {
let state = State::get().await?;
let instance =
crate::state::create_direct_link_instance(input, &state).await?;
let result = async {
emit_instance(&instance.id, InstancePayloadType::Created).await?;
crate::state::get_instance(&instance.id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Created instance could not be loaded".to_string(),
)
.into()
})
}
.await;
if result.is_err() {
let _ = crate::state::remove_instance(&instance.id, &state).await;
}
result
}
/// Reconcile configured external Minecraft roots with direct-link records.
pub async fn sync_direct_links(
roots: Vec<crate::state::ExternalMinecraftRoot>,
) -> crate::Result<crate::state::DirectLinkSyncReport> {
let state = State::get().await?;
crate::state::sync_direct_link_instances(roots, &state).await
}
pub async fn edit(
instance_id: &str,
patch: EditInstance,
) -> crate::Result<InstanceMetadata> {
let state = State::get().await?;
crate::state::edit_instance(instance_id, patch, &state.pool).await?;
let instance = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
.as_error()
})?;
emit_instance(&instance.instance.id, InstancePayloadType::Edited).await?;
Ok(instance)
}
pub async fn cache_icon(
icon_name: &str,
bytes: Vec<u8>,
) -> crate::Result<String> {
let state = State::get().await?;
let path = write_cached_icon(
icon_name,
&state.directories.caches_dir(),
bytes::Bytes::from(bytes),
&state.io_semaphore,
)
.await?;
Ok(path.to_string_lossy().to_string())
}
pub async fn edit_icon(
instance_id: &str,
icon_path: Option<&Path>,
) -> crate::Result<()> {
let state = State::get().await?;
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let icon_path = if let Some(icon) = icon_path {
let bytes = io::read(icon).await?;
let file = crate::util::fetch::write_cached_icon(
&icon.to_string_lossy(),
&state.directories.caches_dir(),
bytes::Bytes::from(bytes),
&state.io_semaphore,
)
.await?;
Some(file.to_string_lossy().to_string())
} else {
None
};
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path),
..EditInstance::default()
},
&state.pool,
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
}
#[tracing::instrument]
pub async fn remove(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?;
crate::state::remove_instance(instance_id, &state).await?;
if let Some(instance) = instance {
emit_instance(&instance.id, InstancePayloadType::Removed).await?;
}
Ok(())
}

View File

@ -0,0 +1,247 @@
use crate::state::instances::commands::add_project_bytes_from_provider;
use crate::state::{
ContentProviderRef, ContentSourceKind, McArchiveFileId, McArchiveProjectId,
McArchiveVersionId, ProjectType, State,
};
use bytes::Bytes;
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McArchiveContentInstallRequest {
pub project_id: String,
pub project_slug: String,
pub version_id: String,
pub file_id: String,
pub project_type: ProjectType,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum McArchiveContentInstallResult {
Installed {
relative_path: String,
},
ManualDownload {
file_name: String,
page_url: Option<String>,
expected_sha256: Option<String>,
},
}
#[tracing::instrument(skip(request))]
pub async fn install_mcarchive_content(
instance_id: &str,
request: McArchiveContentInstallRequest,
) -> crate::Result<McArchiveContentInstallResult> {
let (project, version, file) = resolve_requested_file(&request).await?;
if file.needs_manual_download() {
return Ok(manual_download_result(&project, &file));
}
let bytes = crate::api::mcarchive::download_file(&file).await?;
let relative_path = record_verified_file(
instance_id,
&request,
project,
version,
file,
bytes.into(),
)
.await?;
Ok(McArchiveContentInstallResult::Installed { relative_path })
}
#[tracing::instrument(skip(request, source_path))]
pub async fn import_mcarchive_content(
instance_id: &str,
request: McArchiveContentInstallRequest,
source_path: PathBuf,
) -> crate::Result<McArchiveContentInstallResult> {
let (project, version, file) = resolve_requested_file(&request).await?;
let expected_sha256 = file
.sha256
.as_deref()
.filter(|hash| !hash.trim().is_empty());
let Some(expected_sha256) = expected_sha256 else {
return Ok(manual_download_result(&project, &file));
};
let metadata = tokio::fs::metadata(&source_path).await?;
if !metadata.is_file() {
return Err(crate::ErrorKind::InputError(
"The selected MCArchive import must be a regular file".to_string(),
)
.into());
}
let source_path = crate::util::io::canonicalize(&source_path)?;
let actual_sha256 = sha256_file(&source_path).await?;
if !actual_sha256.eq_ignore_ascii_case(expected_sha256) {
return Err(crate::ErrorKind::InputError(format!(
"The selected file does not match MCArchive SHA-256 for {}",
file.name
))
.into());
}
let bytes = tokio::fs::read(&source_path).await?;
let relative_path = record_verified_file(
instance_id,
&request,
project,
version,
file,
Bytes::from(bytes),
)
.await?;
Ok(McArchiveContentInstallResult::Installed { relative_path })
}
async fn resolve_requested_file(
request: &McArchiveContentInstallRequest,
) -> crate::Result<(
crate::mcarchive::McArchiveMod,
crate::mcarchive::McArchiveModVersion,
crate::mcarchive::McArchiveFile,
)> {
if request.project_slug.trim().is_empty()
|| request.project_id.trim().is_empty()
|| request.version_id.trim().is_empty()
|| request.file_id.trim().is_empty()
{
return Err(crate::ErrorKind::InputError(
"MCArchive project, version, and file identifiers are required"
.to_string(),
)
.into());
}
let project =
crate::api::mcarchive::get_mod_by_slug(&request.project_slug).await?;
if project.uuid != request.project_id {
return Err(crate::ErrorKind::InputError(
"The selected MCArchive project no longer matches its identifier"
.to_string(),
)
.into());
}
let version = project
.mod_versions
.iter()
.find(|version| version.uuid == request.version_id)
.cloned()
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected MCArchive version is no longer available"
.to_string(),
)
})?;
let file = version
.files
.iter()
.find(|file| file.uuid == request.file_id)
.cloned()
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected MCArchive file is no longer available"
.to_string(),
)
})?;
Ok((project, version, file))
}
async fn record_verified_file(
instance_id: &str,
request: &McArchiveContentInstallRequest,
project: crate::mcarchive::McArchiveMod,
version: crate::mcarchive::McArchiveModVersion,
file: crate::mcarchive::McArchiveFile,
bytes: Bytes,
) -> crate::Result<String> {
let provider_ref = ContentProviderRef::McArchive {
project_id: McArchiveProjectId::new(project.uuid)?,
version_id: Some(McArchiveVersionId::new(version.uuid)?),
file_id: Some(McArchiveFileId::new(file.uuid)?),
};
let state = State::get().await?;
let relative_path = add_project_bytes_from_provider(
instance_id,
&file.name,
bytes,
None,
request.project_type,
ContentSourceKind::McArchive,
&provider_ref,
&state,
)
.await?;
super::emit_content_changed(instance_id).await?;
Ok(relative_path)
}
fn manual_download_result(
project: &crate::mcarchive::McArchiveMod,
file: &crate::mcarchive::McArchiveFile,
) -> McArchiveContentInstallResult {
McArchiveContentInstallResult::ManualDownload {
file_name: file.name.clone(),
page_url: file
.manual_download_url()
.map(ToString::to_string)
.or_else(|| project.page_url.clone()),
expected_sha256: file.sha256.clone(),
}
}
async fn sha256_file(path: &Path) -> crate::Result<String> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut file = std::fs::File::open(&path).map_err(|error| {
crate::util::io::IOError::with_path(error, &path)
})?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 262_144];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
})
.await?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manual_route_preserves_the_provider_download_page() {
let project = crate::mcarchive::McArchiveMod {
uuid: "project".to_string(),
slug: "project".to_string(),
name: "Project".to_string(),
summary: None,
description: None,
page_url: Some("https://mcarchive.net/mod/project".to_string()),
mod_versions: Vec::new(),
};
let file = crate::mcarchive::McArchiveFile {
uuid: "file".to_string(),
name: "project.jar".to_string(),
sha256: None,
archive_url: None,
direct_url: None,
redirect_url: None,
page_url: None,
};
assert!(matches!(
manual_download_result(&project, &file),
McArchiveContentInstallResult::ManualDownload {
page_url: Some(page_url),
..
} if page_url == "https://mcarchive.net/mod/project"
));
}
}

View File

@ -0,0 +1,224 @@
use crate::launcher::instance_runtime::InstanceRuntimeAdapter;
use crate::state::State;
use crate::util::io;
use std::path::PathBuf;
#[tracing::instrument]
pub async fn get_full_path(instance_id: &str) -> crate::Result<PathBuf> {
let state = State::get().await?;
let instance = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
// Directly associated instances never have a profile directory under
// `profiles`; interactive APIs (open folder, worlds, servers, ...) must
// operate on the linked `.minecraft` instead.
let adapter = InstanceRuntimeAdapter::for_instance(
&instance.instance,
&state.directories,
)?;
Ok(io::canonicalize(adapter.game_dir())?)
// `instance_game_dir` honours a per-instance `game_dir_override` before
// falling back to the profile directory.
}
#[tracing::instrument]
pub async fn get_mod_full_path(
instance_id: &str,
project_path: &str,
) -> crate::Result<PathBuf> {
Ok(get_full_path(instance_id).await?.join(project_path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::CreateDirectLinkInstance;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
/// The launcher state is a process-wide singleton; initialize it once and
/// reuse it so `State::get()` resolves inside these APIs. The state root
/// is intentionally leaked (`.keep()`) because the shared state outlives
/// this function.
async fn global_state() -> Arc<State> {
if !State::initialized() {
let root = TempDir::new().unwrap().keep();
let _ =
State::init_for_test(root.to_string_lossy().to_string()).await;
}
State::get().await.unwrap()
}
async fn create_direct_link_fixture(
label: &str,
) -> (TempDir, crate::state::InstanceMetadata) {
let state = global_state().await;
let minecraft = TempDir::new().unwrap();
let version_dir = minecraft.path().join("versions").join(label);
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::write(
version_dir.join(format!("{label}.json")),
serde_json::to_vec_pretty(&serde_json::json!({
"id": label,
"inheritsFrom": "1.20.1",
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
let instance = crate::state::create_direct_link_instance(
CreateDirectLinkInstance {
name: None,
launcher_type:
crate::api::pack::import::ImportLauncherType::Generic,
base_path: minecraft.path().to_path_buf(),
instance_folder: format!("versions/{label}"),
instance_path: None,
game_dir_mode: None,
},
&state,
)
.await
.unwrap();
let metadata = crate::state::get_instance(&instance.id, &state.pool)
.await
.unwrap()
.expect("metadata for created fixture");
(minecraft, metadata)
}
#[tokio::test]
async fn direct_link_instance_resolves_to_isolated_version_directory() {
let state = global_state().await;
let (_minecraft, metadata) =
create_direct_link_fixture("paths-demo").await;
let resolved = get_full_path(&metadata.instance.id).await.unwrap();
assert!(resolved.ends_with(Path::new("versions").join("paths-demo")));
assert!(
!state
.directories
.instances_dir()
.join(&metadata.instance.path)
.exists(),
"no profile directory may be created for a direct-link instance"
);
}
#[tokio::test]
async fn shared_direct_link_instance_resolves_to_minecraft_root() {
let state = global_state().await;
let minecraft = TempDir::new().unwrap();
let version_name = "paths-shared";
let version_dir = minecraft.path().join("versions").join(version_name);
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::write(
version_dir.join(format!("{version_name}.json")),
serde_json::to_vec_pretty(&serde_json::json!({
"id": version_name,
"inheritsFrom": "1.20.1",
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
let instance = crate::state::create_direct_link_instance(
CreateDirectLinkInstance {
name: None,
launcher_type:
crate::api::pack::import::ImportLauncherType::Generic,
base_path: minecraft.path().to_path_buf(),
instance_folder: Path::new("versions")
.join(version_name)
.to_string_lossy()
.to_string(),
instance_path: None,
game_dir_mode: Some(
crate::launcher::ExternalGameDirMode::Shared,
),
},
&state,
)
.await
.unwrap();
let resolved = get_full_path(&instance.id).await.unwrap();
assert_eq!(resolved, io::canonicalize(minecraft.path()).unwrap());
}
#[tokio::test]
async fn direct_link_file_browser_lists_linked_content() {
let _state = global_state().await;
let (minecraft, metadata) =
create_direct_link_fixture("paths-browse").await;
// The file browser lists directories relative to the resolved root.
let version_dir =
minecraft.path().join("versions").join("paths-browse");
std::fs::create_dir_all(version_dir.join("mods")).unwrap();
std::fs::write(
version_dir.join("mods").join("browse-fixture.jar"),
b"browse fixture",
)
.unwrap();
let root = get_full_path(&metadata.instance.id).await.unwrap();
let listed = root.join("mods").read_dir().unwrap();
let names = listed
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["browse-fixture.jar".to_string()],
"the browser root must expose the linked installation's content"
);
}
#[tokio::test]
async fn ordinary_instance_still_resolves_to_its_profile_directory() {
let state = global_state().await;
let metadata = crate::api::instance::create(
format!("paths-normal {}", uuid::Uuid::new_v4()),
"1.20.1".to_string(),
crate::state::ModLoader::Vanilla,
None,
None,
crate::state::InstanceLink::Unmanaged,
None,
None,
)
.await
.unwrap();
std::fs::create_dir_all(
state
.directories
.instances_dir()
.join(&metadata.instance.path),
)
.unwrap();
let resolved = get_full_path(&metadata.instance.id).await.unwrap();
assert_eq!(
resolved,
io::canonicalize(
state
.directories
.instances_dir()
.join(&metadata.instance.path)
)
.unwrap()
);
}
}

View File

@ -0,0 +1,134 @@
use crate::api::planet_minecraft::PlanetMinecraftInstallRoute;
use crate::state::instances::commands::add_project_bytes;
use crate::state::{ContentSourceKind, ProjectType, State};
use bytes::Bytes;
use std::path::PathBuf;
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanetMinecraftContentInstallRequest {
pub project_id: String,
pub version_id: String,
pub project_type: ProjectType,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum PlanetMinecraftContentInstallResult {
Installed {
relative_path: String,
},
ManualDownload {
page_url: String,
file_name: Option<String>,
},
}
pub async fn install_planet_minecraft_content(
instance_id: &str,
request: PlanetMinecraftContentInstallRequest,
) -> crate::Result<PlanetMinecraftContentInstallResult> {
let project =
crate::api::planet_minecraft::get_project(&request.project_id).await?;
let version = project
.versions
.iter()
.find(|version| version.id == request.version_id)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected Planet Minecraft release is no longer available"
.to_string(),
)
})?;
match version.download.install_route() {
PlanetMinecraftInstallRoute::Manual {
page_url,
file_name,
} => Ok(PlanetMinecraftContentInstallResult::ManualDownload {
page_url,
file_name,
}),
PlanetMinecraftInstallRoute::Automatic {
direct_url,
sha256,
file_name,
} => {
let bytes = crate::api::planet_minecraft::download_verified_file(
&direct_url,
&sha256,
)
.await?;
let name =
file_name.unwrap_or_else(|| format!("{}.jar", version.name));
let state = State::get().await?;
let relative_path = add_project_bytes(
instance_id,
&name,
Bytes::from(bytes),
None,
Some(request.project_type),
ContentSourceKind::Local,
&state,
)
.await?;
super::emit_content_changed(instance_id).await?;
Ok(
PlanetMinecraftContentInstallResult::Installed {
relative_path,
},
)
}
}
}
pub async fn import_planet_minecraft_content(
instance_id: &str,
request: PlanetMinecraftContentInstallRequest,
source_path: PathBuf,
) -> crate::Result<PlanetMinecraftContentInstallResult> {
let project =
crate::api::planet_minecraft::get_project(&request.project_id).await?;
let version = project
.versions
.iter()
.find(|version| version.id == request.version_id)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected Planet Minecraft release is no longer available"
.to_string(),
)
})?;
let PlanetMinecraftInstallRoute::Manual {
file_name,
page_url: _,
} = version.download.install_route()
else {
return Err(crate::ErrorKind::InputError(
"This Planet Minecraft release has a verified direct download; use automatic installation"
.to_string(),
)
.into());
};
let metadata = tokio::fs::metadata(&source_path).await?;
if !metadata.is_file() {
return Err(crate::ErrorKind::InputError(
"The selected Planet Minecraft import must be a regular file"
.to_string(),
)
.into());
}
let bytes = tokio::fs::read(&source_path).await?;
let state = State::get().await?;
let relative_path = add_project_bytes(
instance_id,
file_name.as_deref().unwrap_or("planet-minecraft.jar"),
Bytes::from(bytes),
None,
Some(request.project_type),
ContentSourceKind::Local,
&state,
)
.await?;
super::emit_content_changed(instance_id).await?;
Ok(PlanetMinecraftContentInstallResult::Installed { relative_path })
}

View File

@ -0,0 +1,995 @@
use crate::event::emit::{emit_instance, emit_loading, init_loading};
use crate::event::{InstancePayloadType, LoadingBarType};
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
use crate::state::instances::{
ContentOwnershipKind, PackMemberMaterializationState,
PackMemberOverrideKind,
};
use crate::state::{ContentProvider, ContentSourceKind, ProjectType, State};
use crate::util::fetch;
use modrinth_content_management::{
ContentType, ResolutionPreferences, ResolveContentPlan,
};
use std::collections::HashMap;
use std::path::Path;
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct InstallProjectWithDependenciesRequest {
pub project_id: String,
pub version_id: Option<String>,
pub content_type: ContentType,
#[serde(default)]
pub selected: ResolutionPreferences,
#[serde(default)]
pub excluded_project_ids: Vec<String>,
#[serde(default)]
pub force_project_ids: Vec<String>,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentToggleResult {
pub content_id: String,
pub path: String,
pub enabled: bool,
}
#[tracing::instrument]
pub async fn update_all_projects(
instance_id: &str,
) -> crate::Result<HashMap<String, String>> {
let state = State::get().await?;
let instance = get_instance_display_info(instance_id, &state).await?;
let loading_bar = init_loading(
LoadingBarType::InstanceUpdate {
instance_id: instance.id.clone(),
instance_name: instance.name.clone(),
},
100.0,
"Updating instance",
)
.await?;
let map = crate::state::instances::commands::update_all_projects(
instance_id,
&state,
)
.await?;
emit_loading(&loading_bar, 100.0, Some("Updated instance"))?;
emit_content_changed(&instance.id).await?;
Ok(map)
}
#[tracing::instrument]
pub async fn update_project(
instance_id: &str,
project_path: &str,
skip_send_event: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
let path = crate::state::instances::commands::update_project(
instance_id,
project_path,
&state,
)
.await?;
if !skip_send_event.unwrap_or(false) {
emit_content_changed(instance_id).await?;
}
Ok(path)
}
#[tracing::instrument]
pub async fn add_project_from_version(
instance_id: &str,
version_id: &str,
reason: fetch::DownloadReason,
dependent_on_version_id: Option<String>,
) -> crate::Result<String> {
let state = State::get().await?;
let project_path =
crate::state::instances::commands::add_project_from_version(
instance_id,
version_id,
reason,
dependent_on_version_id,
crate::state::ContentSourceKind::Local,
crate::state::instances::ContentOwnershipKind::UserAdded,
&state,
)
.await?;
emit_content_changed(instance_id).await?;
Ok(project_path)
}
#[tracing::instrument]
pub async fn install_project_with_dependencies(
instance_id: &str,
request: InstallProjectWithDependenciesRequest,
) -> crate::Result<ResolveContentPlan> {
let state = State::get().await?;
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let plan = crate::state::instances::commands::resolve_install_plan(
instance_id,
crate::state::instances::commands::InstanceInstallProjectRequest {
project_id: request.project_id,
version_id: request.version_id,
content_type: request.content_type,
selected: request.selected,
excluded_project_ids: request.excluded_project_ids,
force_project_ids: request.force_project_ids,
},
&state,
)
.await?;
let instance_id = metadata.instance.id;
let project_ids = plan_project_ids(&plan);
let install_plan = plan.clone();
tokio::spawn(async move {
match crate::state::instances::commands::install_resolved_content_plan(
&instance_id,
&install_plan,
&state,
)
.await
{
Ok(_paths) => {
if let Err(error) = emit_instance(
&instance_id,
InstancePayloadType::ContentInstallFinished {
project_ids: project_ids.clone(),
dependency_project_ids: plan_project_ids(&install_plan)
.into_iter()
.skip(1)
.collect(),
},
)
.await
{
tracing::error!(
"Failed to emit content install finished event: {error}"
);
}
if let Err(error) = emit_content_changed(&instance_id).await {
tracing::error!(
"Failed to emit instance edited event after content install: {error}"
);
}
}
Err(error) => {
if let Err(emit_error) = emit_instance(
&instance_id,
InstancePayloadType::ContentInstallFailed {
project_ids,
message: error.to_string(),
},
)
.await
{
tracing::error!(
"Failed to emit content install failed event: {emit_error}"
);
}
}
}
});
Ok(plan)
}
#[tracing::instrument]
pub async fn preview_project_with_dependencies(
instance_id: &str,
request: InstallProjectWithDependenciesRequest,
) -> crate::Result<ResolveContentPlan> {
let state = State::get().await?;
super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
crate::state::instances::commands::resolve_install_plan(
instance_id,
crate::state::instances::commands::InstanceInstallProjectRequest {
project_id: request.project_id,
version_id: request.version_id,
content_type: request.content_type,
selected: request.selected,
excluded_project_ids: request.excluded_project_ids,
force_project_ids: request.force_project_ids,
},
&state,
)
.await
}
#[tracing::instrument]
pub async fn preview_project_with_dependencies_for_target(
request: InstallProjectWithDependenciesRequest,
game_version: String,
loader: crate::state::ModLoader,
) -> crate::Result<ResolveContentPlan> {
let state = State::get().await?;
crate::state::instances::commands::resolve_install_plan_for_target(
crate::state::instances::commands::InstanceInstallProjectRequest {
project_id: request.project_id,
version_id: request.version_id,
content_type: request.content_type,
selected: request.selected,
excluded_project_ids: request.excluded_project_ids,
force_project_ids: request.force_project_ids,
},
game_version,
loader,
&state,
)
.await
}
#[tracing::instrument]
pub async fn queue_project_with_dependencies(
instance_id: &str,
request: InstallProjectWithDependenciesRequest,
display_title: String,
display_icon: Option<String>,
) -> crate::Result<crate::install::InstallJobSnapshot> {
crate::install::install_content(
instance_id.to_string(),
request.project_id,
request.version_id,
request.content_type,
request.selected,
request.excluded_project_ids,
display_title,
display_icon,
)
.await
}
#[tracing::instrument]
pub async fn queue_curseforge_content(
request: crate::api::curseforge::CurseForgeInstallRequest,
display_title: String,
display_icon: Option<String>,
) -> crate::Result<crate::install::InstallJobSnapshot> {
crate::install::install_curseforge_content(
request,
display_title,
display_icon,
)
.await
}
#[tracing::instrument]
pub async fn queue_curseforge_world(
request: crate::api::curseforge::CurseForgeWorldInstallRequest,
display_title: String,
display_icon: Option<String>,
) -> crate::Result<crate::install::InstallJobSnapshot> {
crate::install::install_curseforge_world(
request,
display_title,
display_icon,
)
.await
}
fn plan_project_ids(plan: &ResolveContentPlan) -> Vec<String> {
let mut project_ids = Vec::with_capacity(plan.dependencies.len() + 1);
project_ids.push(plan.primary.project_id.clone());
project_ids.extend(
plan.dependencies
.iter()
.map(|dependency| dependency.project_id.clone()),
);
project_ids
}
#[tracing::instrument]
pub async fn switch_project_version_with_dependencies(
instance_id: &str,
project_path: &str,
version_id: &str,
) -> crate::Result<String> {
let state = State::get().await?;
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let path =
crate::state::instances::commands::switch_project_version_with_dependencies(
instance_id,
project_path,
version_id,
&state,
)
.await?;
emit_content_changed(&metadata.instance.id).await?;
Ok(path)
}
#[tracing::instrument]
pub async fn add_project_from_path(
instance_id: &str,
path: &Path,
project_type: Option<ProjectType>,
inner_base: Option<&str>,
) -> crate::Result<String> {
if let Some(relative_path) =
crate::api::curseforge::import_pending_manual_download_from_path(
instance_id,
path,
)
.await?
{
emit_content_changed(instance_id).await?;
return Ok(relative_path);
}
let state = State::get().await?;
let result = crate::state::instances::commands::add_project_from_path(
instance_id,
path,
project_type,
inner_base,
&state,
)
.await;
if result.is_ok() {
emit_instance(instance_id, InstancePayloadType::Synced).await?;
}
result
}
#[tracing::instrument]
pub async fn import_world_save(
instance_id: &str,
source_path: &Path,
inner_base: Option<&str>,
) -> crate::Result<String> {
let state = State::get().await?;
crate::state::instances::commands::import_world_save(
&state,
instance_id,
source_path,
inner_base,
)
.await
}
pub async fn install_datapack_to_world(
instance_id: &str,
world_path: &str,
source_path: &Path,
inner_base: Option<&str>,
) -> crate::Result<String> {
let state = State::get().await?;
crate::state::instances::commands::install_datapack_to_world(
instance_id,
world_path,
source_path,
inner_base,
&state,
)
.await
}
pub async fn install_datapack_bytes_to_world(
instance_id: &str,
world_path: &str,
file_name: &str,
bytes: Vec<u8>,
) -> crate::Result<String> {
let state = State::get().await?;
crate::state::instances::commands::install_datapack_bytes_to_world(
instance_id,
world_path,
file_name,
&bytes,
&state,
)
.await
}
#[tracing::instrument]
pub async fn toggle_disable_project(
instance_id: &str,
project: &str,
desired_enabled: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
let res = crate::state::instances::commands::toggle_disable_project(
instance_id,
project,
desired_enabled,
&state,
)
.await?;
emit_content_changed(instance_id).await?;
Ok(res)
}
#[tracing::instrument]
pub async fn rollback_project(
instance_id: &str,
project_path: &str,
) -> crate::Result<String> {
let state = State::get().await?;
let res = crate::state::instances::commands::rollback_project(
instance_id,
project_path,
&state,
)
.await?;
emit_content_changed(instance_id).await?;
Ok(res)
}
#[tracing::instrument]
pub async fn remove_project(
instance_id: &str,
project: &str,
) -> crate::Result<()> {
let state = State::get().await?;
crate::state::instances::commands::remove_project(
instance_id,
project,
&state,
)
.await?;
emit_content_changed(instance_id).await?;
Ok(())
}
#[tracing::instrument]
pub async fn toggle_content_entry(
instance_id: &str,
content_id: &str,
desired_enabled: Option<bool>,
) -> crate::Result<String> {
let mut results = toggle_content_entries(
instance_id,
vec![content_id.to_string()],
desired_enabled,
)
.await?;
results.pop().map(|result| result.path).ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected content no longer exists".to_string(),
)
.into()
})
}
#[tracing::instrument]
pub async fn toggle_content_entries(
instance_id: &str,
content_ids: Vec<String>,
desired_enabled: Option<bool>,
) -> crate::Result<Vec<ContentToggleResult>> {
let state = State::get().await?;
let results = crate::state::instances::commands::toggle_content_entries(
instance_id,
&content_ids,
desired_enabled,
&state,
)
.await?
.into_iter()
.map(|result| ContentToggleResult {
content_id: result.content_id,
path: result.path,
enabled: result.enabled,
})
.collect::<Vec<_>>();
if !results.is_empty() {
emit_content_changed(instance_id).await?;
}
Ok(results)
}
#[tracing::instrument]
pub async fn remove_content_entry(
instance_id: &str,
content_id: &str,
) -> crate::Result<()> {
let target = content_mutation_target(instance_id, content_id).await?;
let path = target.relative_path.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected content is not present on disk".to_string(),
)
})?;
remove_project(instance_id, &path).await
}
#[tracing::instrument]
pub async fn update_content_entry(
instance_id: &str,
content_id: &str,
) -> crate::Result<String> {
let target = content_mutation_target(instance_id, content_id).await?;
let path = target.relative_path.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected content is not present on disk".to_string(),
)
})?;
match target.provider {
Some(ContentProvider::CurseForge) => {
let result = crate::api::curseforge::update_installed_file(
instance_id,
&path,
)
.await?;
let updated_path = result
.installed
.iter()
.find(|file| !file.dependency)
.map_or(path, |file| file.relative_path.clone());
emit_content_changed(instance_id).await?;
Ok(updated_path)
}
Some(ContentProvider::McArchive) => Err(crate::ErrorKind::InputError(
"MCArchive content updates require selecting a file manually"
.to_string(),
)
.into()),
_ => update_project(instance_id, &path, None).await,
}
}
#[tracing::instrument]
pub async fn switch_content_entry_version(
instance_id: &str,
content_id: &str,
version_id: &str,
) -> crate::Result<String> {
let target = content_mutation_target(instance_id, content_id).await?;
let path = target.relative_path.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected content is not present on disk".to_string(),
)
})?;
match target.provider {
Some(ContentProvider::CurseForge) => {
let file_id = version_id.parse::<u32>().map_err(|_| {
crate::ErrorKind::InputError(
"The selected CurseForge file ID is invalid".to_string(),
)
})?;
let result = crate::api::curseforge::switch_installed_file_version(
instance_id,
&path,
file_id,
)
.await?;
let updated_path = result
.installed
.iter()
.find(|file| !file.dependency)
.map_or(path, |file| file.relative_path.clone());
emit_content_changed(instance_id).await?;
Ok(updated_path)
}
Some(ContentProvider::McArchive) => Err(crate::ErrorKind::InputError(
"MCArchive content version changes require selecting a file manually"
.to_string(),
)
.into()),
_ => {
switch_project_version_with_dependencies(
instance_id,
&path,
version_id,
)
.await
}
}
}
#[tracing::instrument]
pub async fn restore_pack_member_default(
instance_id: &str,
member_id: &str,
) -> crate::Result<Option<String>> {
let state = State::get().await?;
let target = content_rows::get_content_mutation_target(
instance_id,
member_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected pack member no longer exists".to_string(),
)
})?;
if target.member_id.as_deref() != Some(member_id) {
return Err(crate::ErrorKind::InputError(
"Restore requires a pack member ID".to_string(),
)
.into());
}
let member = content_rows::get_pack_members(
&content_rows::get_applied_content_set(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?
.id,
&state.pool,
)
.await?
.into_iter()
.find(|member| member.id == member_id)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected pack member no longer exists".to_string(),
)
})?;
if member.override_kind == PackMemberOverrideKind::None
&& member.materialization_state
== PackMemberMaterializationState::Present
{
return Ok(target.relative_path);
}
if member.override_kind == PackMemberOverrideKind::Disabled
&& let Some(path) = target.relative_path
{
return toggle_disable_project(instance_id, &path, Some(true))
.await
.map(Some);
}
let project_id =
member.provider_project_id.as_deref().ok_or_else(|| {
crate::ErrorKind::InputError(
"This pack member has no provider project to restore from"
.to_string(),
)
})?;
let release_id =
member.provider_release_id.as_deref().ok_or_else(|| {
crate::ErrorKind::InputError(
"This pack member has no original version to restore"
.to_string(),
)
})?;
let old_path = target.relative_path;
let (restored_path, pending_manual) = match member.provider {
Some(ContentProvider::Modrinth) => (
Some(
crate::state::instances::commands::add_project_from_version(
instance_id,
release_id,
fetch::DownloadReason::Update,
None,
ContentSourceKind::ModrinthModpack,
ContentOwnershipKind::PackManaged,
&state,
)
.await?,
),
false,
),
Some(ContentProvider::CurseForge) => {
let content_set =
content_rows::get_applied_content_set(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let result = crate::api::curseforge::install_file(
crate::api::curseforge::CurseForgeInstallRequest {
instance_id: instance_id.to_string(),
project_id: project_id.parse().map_err(|_| {
crate::ErrorKind::InputError(
"Stored CurseForge project ID is invalid".to_string(),
)
})?,
file_id: release_id.parse().map_err(|_| {
crate::ErrorKind::InputError(
"Stored CurseForge file ID is invalid".to_string(),
)
})?,
project_type: member.project_type.get_name().to_string(),
ownership_kind: ContentOwnershipKind::PackManaged,
manual_operation_kind: crate::state::instances::ManualDownloadOperationKind::ContentUpdate,
game_version: Some(content_set.game_version),
mod_loader_type: curseforge_loader_type(content_set.loader),
world_name: None,
install_dependencies: false,
excluded_dependency_project_ids: Vec::new(),
force_dependency_project_ids: Vec::new(),
dependency_plan_id: None,
},
)
.await?;
let pending_manual = !result.manual_downloads.is_empty();
let failure_reason = result
.failed_downloads
.first()
.map(|failure| failure.reason.clone());
let restored_path = result
.installed
.into_iter()
.find(|file| !file.dependency)
.map(|file| file.relative_path);
if restored_path.is_none() && !pending_manual {
return Err(crate::ErrorKind::OtherError(
failure_reason.unwrap_or_else(|| {
"CurseForge did not return the restored file"
.to_string()
}),
)
.into());
}
(restored_path, pending_manual)
}
Some(ContentProvider::Local) => {
return Err(crate::ErrorKind::InputError(
"This pack member has no managed provider".to_string(),
)
.into());
}
Some(ContentProvider::McArchive) => {
return Err(crate::ErrorKind::InputError(
"MCArchive pack members must be restored from an imported file"
.to_string(),
)
.into());
}
None => {
return Err(crate::ErrorKind::InputError(
"This pack member has no managed provider".to_string(),
)
.into());
}
};
if let (Some(old_path), Some(new_path)) =
(old_path.as_deref(), restored_path.as_deref())
&& old_path != new_path
&& crate::state::instances::commands::archive_project_file(
instance_id,
old_path,
new_path,
&state,
)
.await?
.is_none()
{
crate::state::instances::commands::remove_project(
instance_id,
old_path,
&state,
)
.await?;
}
let content_set =
content_rows::get_applied_content_set(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
content_rows::set_pack_member_override_in_transaction(
member_id,
if pending_manual {
PackMemberMaterializationState::PendingManual
} else {
PackMemberMaterializationState::Present
},
PackMemberOverrideKind::None,
&mut tx,
)
.await?;
content_rows::bump_content_set_revision_in_transaction(
&content_set.id,
&mut tx,
)
.await?;
tx.commit().await?;
emit_content_changed(instance_id).await?;
Ok(restored_path)
}
pub(crate) async fn emit_content_changed(
instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let content_set =
content_rows::get_applied_content_set(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
emit_instance(
instance_id,
InstancePayloadType::ContentChanged {
revision: content_set.revision,
},
)
.await
}
async fn content_mutation_target(
instance_id: &str,
content_id: &str,
) -> crate::Result<content_rows::ContentMutationTarget> {
let state = State::get().await?;
content_rows::get_content_mutation_target(
instance_id,
content_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The selected content no longer exists".to_string(),
)
.into()
})
}
fn curseforge_loader_type(loader: crate::state::ModLoader) -> Option<u32> {
match loader {
crate::state::ModLoader::Forge | crate::state::ModLoader::Cleanroom => {
Some(1)
}
crate::state::ModLoader::LiteLoader => Some(3),
crate::state::ModLoader::Fabric
| crate::state::ModLoader::LegacyFabric => Some(4),
crate::state::ModLoader::Quilt => Some(5),
crate::state::ModLoader::NeoForge => Some(6),
_ => None,
}
}
#[tracing::instrument]
pub async fn update_managed_modrinth_version(
instance_id: &str,
version_id: &str,
) -> crate::Result<crate::install::InstallJobSnapshot> {
let state = State::get().await?;
let metadata = crate::state::instances::commands::get_instance_metadata(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let post_install_edit = match &metadata.link {
crate::state::InstanceLink::ServerProjectModpack {
server_project_id,
content_project_id,
..
} => Some(crate::install::InstallPostInstallEdit {
name: Some(metadata.instance.name.clone()),
icon_path: Some(metadata.instance.icon_path.clone()),
link: Some(crate::state::InstanceLink::ServerProjectModpack {
server_project_id: server_project_id.clone(),
content_project_id: content_project_id.clone(),
content_version_id: version_id.to_string(),
}),
}),
_ => None,
};
let project_id = match &metadata.link {
crate::state::InstanceLink::ModrinthModpack { project_id, .. } => {
project_id.clone()
}
crate::state::InstanceLink::ServerProjectModpack {
content_project_id,
..
} => content_project_id.clone(),
_ => {
return Err(unmanaged_pack_error(&metadata.instance.id).into());
}
};
crate::install::install_pack_to_existing_instance(
metadata.instance.id,
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
project_id,
version_id: version_id.to_string(),
title: metadata.instance.name.clone(),
icon_url: None,
},
post_install_edit,
)
.await
}
#[tracing::instrument]
pub async fn repair_managed_modrinth(
instance_id: &str,
) -> crate::Result<crate::install::InstallJobSnapshot> {
let state = State::get().await?;
let metadata = crate::state::instances::commands::get_instance_metadata(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let post_install_edit = match &metadata.link {
crate::state::InstanceLink::ServerProjectModpack { .. } => {
Some(crate::install::InstallPostInstallEdit {
name: Some(metadata.instance.name.clone()),
icon_path: Some(metadata.instance.icon_path.clone()),
link: Some(metadata.link.clone()),
})
}
_ => None,
};
let (project_id, version_id) = match &metadata.link {
crate::state::InstanceLink::ModrinthModpack {
project_id,
version_id,
} => (project_id.clone(), version_id.clone()),
crate::state::InstanceLink::ServerProjectModpack {
content_project_id,
content_version_id,
..
} => (content_project_id.clone(), content_version_id.clone()),
_ => {
return Err(unmanaged_pack_error(&metadata.instance.id).into());
}
};
crate::install::install_pack_to_existing_instance(
metadata.instance.id,
crate::api::pack::install_from::CreatePackLocation::FromVersionId {
project_id,
version_id,
title: metadata.instance.name.clone(),
icon_url: None,
},
post_install_edit,
)
.await
}
fn unmanaged_pack_error(instance_id: &str) -> crate::ErrorKind {
crate::ErrorKind::InputError(format!(
"Instance {instance_id} is not a managed Modrinth pack, or has been disconnected."
))
}
async fn get_instance_display_info(
instance_id: &str,
state: &State,
) -> crate::Result<instance_rows::InstanceDisplayInfo> {
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string()).into()
})
}

View File

@ -0,0 +1,516 @@
use super::content::get_projects;
use crate::server_address::ServerAddress;
use crate::state::{
Credentials, InstanceInstallStage, InstanceLink, ProcessMetadata, Settings,
State,
};
use crate::util::fetch;
use crate::util::io::IOError;
use crate::util::mojang::mojang_service_url;
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
use tokio::process::Command;
use tracing::{info, warn};
pub use crate::launcher::jvm_args::{GcLaunchIntent, GcLaunchReport};
const DEFAULT_LAUNCH_PREPARATION_TIMEOUT: u64 = 60;
const MIN_LAUNCH_PREPARATION_TIMEOUT: u64 = 30;
const MAX_LAUNCH_PREPARATION_TIMEOUT: u64 = 600;
#[derive(Debug, Clone)]
pub enum QuickPlayType {
None,
Singleplayer(String),
Server(ServerAddress),
}
#[tracing::instrument]
pub async fn run(
instance_id: &str,
quick_play_type: QuickPlayType,
offline_mode: bool,
) -> crate::Result<ProcessMetadata> {
run_with_extra_launch_args(instance_id, quick_play_type, offline_mode, None)
.await
}
#[tracing::instrument]
pub async fn run_with_extra_launch_args(
instance_id: &str,
quick_play_type: QuickPlayType,
offline_mode: bool,
extra_launch_args: Option<Vec<String>>,
) -> crate::Result<ProcessMetadata> {
Ok(run_with_extra_launch_args_inner(
instance_id,
quick_play_type,
offline_mode,
extra_launch_args,
None,
)
.await?
.0)
}
/// Like [`run_with_extra_launch_args`], but additionally resolves a GC-args
/// intent against the actual JVM and reports what was actually used (useful
/// for surfacing strategy fallback / flag pruning to the user).
#[tracing::instrument]
pub async fn run_with_extra_launch_args_with_gc(
instance_id: &str,
quick_play_type: QuickPlayType,
offline_mode: bool,
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
run_with_extra_launch_args_inner(
instance_id,
quick_play_type,
offline_mode,
extra_launch_args,
gc_intent,
)
.await
}
async fn run_with_extra_launch_args_inner(
instance_id: &str,
quick_play_type: QuickPlayType,
offline_mode: bool,
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
let state = State::get().await?;
let launch_preparation_timeout =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.and_then(|context| context.launch_overrides.launch_preparation_timeout)
.unwrap_or(DEFAULT_LAUNCH_PREPARATION_TIMEOUT)
.clamp(
MIN_LAUNCH_PREPARATION_TIMEOUT,
MAX_LAUNCH_PREPARATION_TIMEOUT,
);
let default_account = if offline_mode {
Credentials::get_offline_credential(&state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(
"Offline mode requires an offline Minecraft account"
.to_string(),
)
.as_error()
})?
} else {
Credentials::get_default_credential(&state.pool)
.await?
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?
};
tokio::time::timeout(
Duration::from_secs(launch_preparation_timeout),
run_credentials(
instance_id,
&default_account,
quick_play_type,
offline_mode,
extra_launch_args,
gc_intent,
),
)
.await
.map_err(|_| {
crate::ErrorKind::LauncherError(
format!(
"Minecraft launch preparation timed out after {launch_preparation_timeout} seconds"
),
)
.as_error()
})?
}
#[tracing::instrument(skip(credentials))]
async fn run_credentials(
instance_id: &str,
credentials: &Credentials,
quick_play_type: QuickPlayType,
offline_mode: bool,
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
let state = State::get().await?;
let settings = Settings::get(&state.pool).await?;
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to run a nonexistent instance {instance_id}!"
))
})?;
if offline_mode
&& context.instance.install_stage != InstanceInstallStage::Installed
{
return Err(crate::ErrorKind::LauncherError(
"Offline mode can only launch fully downloaded instances"
.to_string(),
)
.as_error());
}
let pre_launch_hooks = context
.launch_overrides
.hooks
.pre_launch
.as_ref()
.or(settings.hooks.pre_launch.as_ref())
.filter(|hook_command| !hook_command.is_empty());
if let Some(hook) = pre_launch_hooks {
let mut cmd = shlex::split(hook)
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Invalid pre-launch command: {hook}",
))
})?
.into_iter();
if let Some(command) = cmd.next() {
let full_path = crate::util::io::canonicalize(
state.directories.resolve_game_dir(
&context.instance.path,
context.instance.game_dir_override.as_deref(),
),
)?;
let mut command = Command::new(command);
command.args(cmd).current_dir(&full_path).kill_on_drop(true);
let result = command
.spawn()
.map_err(|e| IOError::with_path(e, &full_path))?
.wait()
.await
.map_err(IOError::from)?;
if !result.success() {
return Err(crate::ErrorKind::LauncherError(format!(
"Non-zero exit code for pre-launch hook: {}",
result.code().unwrap_or(-1)
))
.as_error());
}
}
}
let java_args = if let Some(extra_launch_args) = extra_launch_args {
extra_launch_args
} else {
context
.launch_overrides
.extra_launch_args
.clone()
.unwrap_or(settings.extra_launch_args)
};
let wrapper = context
.launch_overrides
.hooks
.wrapper
.clone()
.or(settings.hooks.wrapper)
.filter(|hook_command| !hook_command.is_empty());
let mut memory = context.launch_overrides.memory.unwrap_or(settings.memory);
let resolution = context
.launch_overrides
.game_resolution
.unwrap_or(settings.game_resolution);
let maximize_window = context
.launch_overrides
.maximize_window
.unwrap_or(settings.maximize_window);
let env_args = context
.launch_overrides
.custom_env_vars
.clone()
.unwrap_or(settings.custom_env_vars);
let post_exit_hook = context
.launch_overrides
.hooks
.post_exit
.clone()
.or(settings.hooks.post_exit)
.filter(|hook_command| !hook_command.is_empty());
let mut mc_set_options: Vec<(String, String)> = vec![];
if let Some(fullscreen) = context.launch_overrides.force_fullscreen {
mc_set_options.push(("fullscreen".to_string(), fullscreen.to_string()));
} else if settings.force_fullscreen {
mc_set_options.push(("fullscreen".to_string(), "true".to_string()));
}
if credentials.is_microsoft()
&& let Some(project_id) = server_play_project_id(&context.link)
&& !project_id.trim().is_empty()
{
let server_id = uuid::Uuid::new_v4().to_string();
let join_url = mojang_service_url(
"https://sessionserver.mojang.com/session/minecraft/join",
state.mojang_auth_use_mirror(),
);
let join_result = fetch::INSECURE_REQWEST_CLIENT
.post(join_url.as_ref())
.json(&json!({
"accessToken": &credentials.access_token,
"selectedProfile": credentials.offline_profile.id.simple().to_string(),
"serverId": &server_id,
}))
.timeout(Duration::from_secs(5))
.send()
.await;
match join_result {
Ok(resp) if resp.status().is_success() => {
let result = fetch::post_json(
concat!(
env!("MODRINTH_API_BASE_URL"),
"analytics/minecraft-server-play"
),
json!({
"project_id": project_id,
"username": &credentials.offline_profile.name,
"server_id": &server_id,
}),
&state.api_semaphore,
&state.pool,
)
.await;
match result {
Ok(()) => {
info!(
"Tracked server play for '{project_id}' in analytics"
)
}
Err(err) => warn!("Failed to report server play: {err:?}"),
}
}
Ok(resp) => warn!(
"Failed to join Mojang session server: HTTP {}",
resp.status()
),
Err(err) => warn!("Failed to join Mojang session server: {err:?}"),
}
}
if offline_mode {
crate::minecraft_skins::flush_pending_skin_change_for_profile(
credentials.offline_profile.id,
)
.await?;
} else {
crate::minecraft_skins::flush_pending_skin_change().await?;
}
if memory.optimize_before_launch
&& crate::api::memory::optimization_supported()
{
tracing::info!("Optimizing memory before launching Minecraft");
crate::api::memory::optimize().await?;
}
if memory.automatic {
let instance_path = state.directories.resolve_game_dir(
&context.instance.path,
context.instance.game_dir_override.as_deref(),
);
memory.maximum = crate::api::jre::automatic_memory_max_mb_for_instance(
&instance_path,
matches!(
context.applied_content_set.loader,
crate::state::ModLoader::Forge
| crate::state::ModLoader::Fabric
| crate::state::ModLoader::Quilt
| crate::state::ModLoader::NeoForge
| crate::state::ModLoader::Cleanroom
| crate::state::ModLoader::LiteLoader
| crate::state::ModLoader::LegacyFabric
| crate::state::ModLoader::Babric
),
);
tracing::info!(
"Automatically allocated {} MiB of memory",
memory.maximum
);
}
let mut gc_report: Option<GcLaunchReport> = None;
let process = crate::launcher::launch_minecraft(
&java_args,
&env_args,
&mc_set_options,
&wrapper,
&memory,
&resolution,
maximize_window,
credentials,
post_exit_hook,
&context,
gc_intent,
&mut gc_report,
quick_play_type,
offline_mode,
)
.await?;
Ok((process, gc_report))
}
fn server_play_project_id(link: &InstanceLink) -> Option<&String> {
match link {
InstanceLink::ServerProject { project_id }
| InstanceLink::ServerProjectModpack {
server_project_id: project_id,
..
} => Some(project_id),
InstanceLink::Unmanaged
| InstanceLink::ModrinthModpack { .. }
| InstanceLink::CurseForgeModpack { .. }
| InstanceLink::ImportedModpack { .. }
| InstanceLink::SharedInstance { .. } => None,
}
}
fn modrinth_pack_version_id(link: &InstanceLink) -> Option<&str> {
match link {
InstanceLink::ModrinthModpack { version_id, .. }
| InstanceLink::ServerProjectModpack {
content_version_id: version_id,
..
} => Some(version_id),
InstanceLink::Unmanaged
| InstanceLink::ServerProject { .. }
| InstanceLink::CurseForgeModpack { .. }
| InstanceLink::ImportedModpack { .. }
| InstanceLink::SharedInstance { .. } => None,
}
}
fn playtime_api_url(base_url: &str) -> String {
format!("{}/analytics/playtime", base_url.trim_end_matches('/'))
}
pub async fn kill(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let processes =
crate::api::process::get_by_instance_id(instance_id).await?;
for process in processes {
state.process_manager.kill(process.uuid).await?;
}
Ok(())
}
#[tracing::instrument]
pub async fn try_update_playtime_by_instance_id(
instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let context =
crate::state::instances::commands::get_instance_launch_context(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to update playtime for nonexistent instance {instance_id}!"
))
})?;
let updated_recent_playtime = context.instance.recent_time_played;
let res = if updated_recent_playtime > 0 {
let modrinth_pack_version_id = modrinth_pack_version_id(&context.link);
let playtime_update_json = json!({
"seconds": updated_recent_playtime,
"loader": context.applied_content_set.loader.as_str(),
"game_version": &context.applied_content_set.game_version,
"parent": modrinth_pack_version_id,
});
let mut hashmap: HashMap<String, serde_json::Value> = HashMap::new();
for (_, project) in get_projects(instance_id, None).await? {
if let Some(metadata) = project.modrinth {
hashmap.insert(
metadata.version_id.to_string(),
playtime_update_json.clone(),
);
}
}
let playtime_url = playtime_api_url(env!("MODRINTH_API_BASE_URL"));
fetch::post_json(
&playtime_url,
serde_json::to_value(hashmap)?,
&state.api_semaphore,
&state.pool,
)
.await
} else {
Ok(())
};
if res.is_ok() {
crate::state::instances::commands::mark_instance_playtime_submitted(
&context.instance.id,
updated_recent_playtime,
&state.pool,
)
.await?;
}
res
}
#[cfg(test)]
mod tests {
use super::{modrinth_pack_version_id, playtime_api_url};
use crate::state::InstanceLink;
#[test]
fn playtime_parent_requires_an_explicit_modrinth_link() {
let modrinth = InstanceLink::ModrinthModpack {
project_id: "project".to_string(),
version_id: "version".to_string(),
};
let curseforge = InstanceLink::CurseForgeModpack {
project_id: "123".to_string(),
version_id: "456".to_string(),
};
let imported = InstanceLink::ImportedModpack {
project_id: Some("legacy-project".to_string()),
version_id: Some("legacy-version".to_string()),
name: None,
version_number: None,
filename: None,
};
assert_eq!(modrinth_pack_version_id(&modrinth), Some("version"));
assert_eq!(modrinth_pack_version_id(&curseforge), None);
assert_eq!(modrinth_pack_version_id(&imported), None);
}
#[test]
fn playtime_url_has_a_single_path_separator() {
assert_eq!(
playtime_api_url("https://api.modrinth.com"),
"https://api.modrinth.com/analytics/playtime"
);
assert_eq!(
playtime_api_url("https://api.modrinth.com/"),
"https://api.modrinth.com/analytics/playtime"
);
}
}

View File

@ -0,0 +1,966 @@
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use dashmap::DashMap;
use tokio::sync::Mutex;
use crate::state::instances::adapters::sqlite::content_rows;
use crate::state::{
ContentSourceKind, InstanceLink, InstanceUpgradeAction,
InstanceUpgradeFixedConstraint, InstanceUpgradePlan,
InstanceUpgradeResolution, InstanceUpgradeResolutionBatchResult,
InstanceUpgradeResolutionResult, InstanceUpgradeSolutionChoice,
InstanceUpgradeSolutionKind, State,
};
use crate::install::{
InstallJobSnapshot, InstanceUpgradeExecution, InstanceUpgradeWatchBaseline,
SharedUpgradeMode,
};
struct StoredUpgradePlanState {
plan: InstanceUpgradePlan,
validation: crate::state::instances::commands::UpgradePlanRuntimeValidation,
execution_started: bool,
}
type StoredUpgradePlan = Arc<Mutex<StoredUpgradePlanState>>;
static INSTANCE_UPGRADE_PLANS: LazyLock<DashMap<String, StoredUpgradePlan>> =
LazyLock::new(DashMap::new);
#[tracing::instrument]
pub async fn plan_instance_upgrade(
instance_id: &str,
target_environment: crate::state::InstanceUpgradeEnvironment,
) -> crate::Result<InstanceUpgradePlan> {
let state = State::get().await?;
let creation_watch =
state.file_watcher.content_watch_snapshot(instance_id).await;
let (plan, source) = crate::state::instances::commands::create_instance_upgrade_plan_with_source(
instance_id,
target_environment,
&state,
)
.await?;
let mut validation =
crate::state::instances::commands::UpgradePlanRuntimeValidation::new(
source,
instance_id,
creation_watch,
&state,
)
.await;
validation.validate(&plan, &state).await?;
INSTANCE_UPGRADE_PLANS.insert(
plan.id.clone(),
Arc::new(Mutex::new(StoredUpgradePlanState {
plan: plan.clone(),
validation,
execution_started: false,
})),
);
Ok(plan)
}
#[tracing::instrument]
pub async fn get_instance_upgrade_plan(
plan_id: &str,
) -> crate::Result<InstanceUpgradePlan> {
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
if let Err(error) = ensure_current_revision(&mut stored, &state).await {
drop(stored);
INSTANCE_UPGRADE_PLANS.remove(plan_id);
return Err(error);
}
Ok(stored.plan.clone())
}
#[tracing::instrument]
pub async fn update_instance_upgrade_resolution(
plan_id: &str,
resolution: InstanceUpgradeResolution,
) -> crate::Result<InstanceUpgradePlan> {
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
let source = ensure_current_revision(&mut stored, &state).await?;
let mut plan = stored.plan.clone();
let item = plan
.items
.iter_mut()
.find(|item| item.content_id == resolution.content_id)
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Upgrade plan has no content item {}",
resolution.content_id
))
})?;
item.resolution = resolution;
let (kind, constraints) = selected_kind_and_constraints(&plan);
crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
&mut plan,
&constraints,
kind,
source,
&state,
)
.await?;
stored.plan = plan.clone();
Ok(plan)
}
#[tracing::instrument(skip(resolutions))]
pub async fn update_instance_upgrade_resolutions(
plan_id: &str,
resolutions: Vec<InstanceUpgradeResolution>,
) -> crate::Result<InstanceUpgradeResolutionBatchResult> {
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
let source = ensure_current_revision(&mut stored, &state).await?;
let requested_count = resolutions.len();
let (requests, mut skipped) = normalize_batch_resolutions(resolutions);
let mut working_plan = stored.plan.clone();
let mut applied = Vec::new();
let mut failed = Vec::new();
let mut pending = vec![requests];
while let Some(chunk) = pending.pop() {
let mut applicable = Vec::new();
for resolution in chunk {
if !resolution_is_applicable(&working_plan, &resolution) {
skipped.push(InstanceUpgradeResolutionResult {
content_id: resolution.content_id,
code: Some("no_longer_applicable".to_string()),
message: Some(
"Resolution is no longer applicable".to_string(),
),
});
} else {
applicable.push(resolution);
}
}
if applicable.is_empty() {
continue;
}
let mut trial = working_plan.clone();
apply_resolutions(&mut trial, &applicable)?;
let (kind, constraints) = selected_kind_and_constraints(&trial);
match crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
&mut trial,
&constraints,
kind,
source.clone(),
&state,
)
.await
{
Ok(()) => {
applied.extend(applicable.iter().map(|resolution| {
InstanceUpgradeResolutionResult {
content_id: resolution.content_id.clone(),
code: None,
message: None,
}
}));
working_plan = trial;
}
Err(error) if applicable.len() == 1 => {
failed.push(InstanceUpgradeResolutionResult {
content_id: applicable[0].content_id.clone(),
code: Some("resolution_failed".to_string()),
message: Some(error.to_string()),
});
}
Err(_) => {
let midpoint = applicable.len() / 2;
let right = applicable[midpoint..].to_vec();
let left = applicable[..midpoint].to_vec();
pending.push(right);
pending.push(left);
}
}
}
if !applied.is_empty() {
stored.plan = working_plan.clone();
}
Ok(InstanceUpgradeResolutionBatchResult {
plan: working_plan,
requested_count,
applied,
skipped,
failed,
})
}
#[tracing::instrument]
pub async fn reset_instance_upgrade_resolution(
plan_id: &str,
content_id: &str,
) -> crate::Result<InstanceUpgradePlan> {
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
let source = ensure_current_revision(&mut stored, &state).await?;
let mut plan = stored.plan.clone();
let item = plan
.items
.iter_mut()
.find(|item| item.content_id == content_id)
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Upgrade plan has no content item {content_id}"
))
})?;
item.resolution = automatic_resolution(item);
let (kind, constraints) = selected_kind_and_constraints(&plan);
crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
&mut plan,
&constraints,
kind,
source,
&state,
)
.await?;
stored.plan = plan.clone();
Ok(plan)
}
fn normalize_batch_resolutions(
resolutions: Vec<InstanceUpgradeResolution>,
) -> (
Vec<InstanceUpgradeResolution>,
Vec<InstanceUpgradeResolutionResult>,
) {
let mut by_content_id = HashMap::new();
let mut skipped = Vec::new();
for resolution in resolutions {
if by_content_id
.insert(resolution.content_id.clone(), resolution.clone())
.is_some()
{
skipped.push(InstanceUpgradeResolutionResult {
content_id: resolution.content_id,
code: Some("duplicate_request".to_string()),
message: Some(
"Duplicate request replaced by its last value".to_string(),
),
});
}
}
let mut normalized = by_content_id.into_values().collect::<Vec<_>>();
normalized.sort_by(|left, right| left.content_id.cmp(&right.content_id));
(normalized, skipped)
}
fn resolution_is_applicable(
plan: &InstanceUpgradePlan,
resolution: &InstanceUpgradeResolution,
) -> bool {
plan.items.iter().any(|item| {
item.content_id == resolution.content_id
&& item.resolution != *resolution
})
}
fn apply_resolutions(
plan: &mut InstanceUpgradePlan,
resolutions: &[InstanceUpgradeResolution],
) -> crate::Result<()> {
for resolution in resolutions {
let item = plan
.items
.iter_mut()
.find(|item| item.content_id == resolution.content_id)
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Upgrade plan has no content item {}",
resolution.content_id
))
})?;
item.resolution = resolution.clone();
}
Ok(())
}
fn automatic_resolution(
item: &crate::state::InstanceUpgradeItem,
) -> InstanceUpgradeResolution {
let action = if matches!(
item.status,
crate::state::InstanceUpgradeItemStatus::Unidentified
| crate::state::InstanceUpgradeItemStatus::UnsupportedContentType
) {
InstanceUpgradeAction::Keep
} else {
InstanceUpgradeAction::Upgrade
};
InstanceUpgradeResolution {
content_id: item.content_id.clone(),
action,
allow_prerelease: false,
confirmed_prerelease_dependencies: Vec::new(),
}
}
#[tracing::instrument]
pub async fn select_instance_upgrade_solution(
plan_id: &str,
choice: InstanceUpgradeSolutionChoice,
) -> crate::Result<InstanceUpgradePlan> {
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
ensure_current_revision(&mut stored, &state).await?;
let mut plan = stored.plan.clone();
plan.selected_solution = match choice {
InstanceUpgradeSolutionChoice::Newest => plan.newest_solution.clone(),
InstanceUpgradeSolutionChoice::MinimalChange => {
plan.minimal_change_solution.clone()
}
InstanceUpgradeSolutionChoice::Custom => Some(
plan.selected_solution
.clone()
.filter(|solution| {
solution.kind == InstanceUpgradeSolutionKind::Custom
})
.ok_or_else(|| {
crate::ErrorKind::InputError(
"No custom upgrade solution has been resolved"
.to_string(),
)
})?,
),
};
plan.dependency_changes = plan
.selected_solution
.as_ref()
.map(|solution| solution.dependency_changes.clone())
.unwrap_or_default();
stored.plan = plan.clone();
Ok(plan)
}
#[tracing::instrument]
pub async fn resolve_custom_instance_upgrade_solution(
plan_id: &str,
fixed_constraints: Vec<InstanceUpgradeFixedConstraint>,
) -> crate::Result<InstanceUpgradePlan> {
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
let source = ensure_current_revision(&mut stored, &state).await?;
let mut plan = stored.plan.clone();
validate_fixed_constraints(&plan, &fixed_constraints)?;
crate::state::instances::commands::recompute_instance_upgrade_plan_from_source(
&mut plan,
&fixed_constraints,
InstanceUpgradeSolutionKind::Custom,
source,
&state,
)
.await?;
plan.custom_constraints = fixed_constraints;
stored.plan = plan.clone();
Ok(plan)
}
#[tracing::instrument]
pub async fn execute_instance_upgrade(
plan_id: &str,
create_full_backup: bool,
shared_upgrade_mode: SharedUpgradeMode,
display_names: crate::install::InstanceUpgradeDisplayNames,
) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
if stored.execution_started {
return Err(crate::ErrorKind::InputError(
"Upgrade plan execution has already started".to_string(),
)
.into());
}
let _execution_guard =
state.lock_instance_content(&stored.plan.instance_id).await;
let current_revision = content_rows::get_applied_content_set(
&stored.plan.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?
.revision;
ensure_instance_upgrade_revision(
stored.plan.source_revision,
current_revision,
)?;
if !stored.plan.blocking_issues.is_empty() {
return Err(crate::ErrorKind::InputError(
"Upgrade plan still has blocking issues".to_string(),
)
.into());
}
let solution = stored.plan.selected_solution.clone().ok_or_else(|| {
crate::ErrorKind::InputError(
"Upgrade plan has no selected solution".to_string(),
)
})?;
let metadata = crate::state::instances::commands::get_instance_metadata(
&stored.plan.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
if !matches!(metadata.link, InstanceLink::Unmanaged)
|| metadata.applied_content_set.source_kind != ContentSourceKind::Local
{
return Err(crate::ErrorKind::InputError(
"Only Local unmanaged instances can use upgrade execution"
.to_string(),
)
.into());
}
if state
.process_manager
.get_all()
.iter()
.any(|process| process.instance_id == stored.plan.instance_id)
{
return Err(crate::ErrorKind::InputError(
"Instance is currently running".to_string(),
)
.into());
}
let resolved_target_loader =
crate::launcher::get_loader_version_from_profile(
&stored.plan.target_environment.game_version,
stored.plan.target_environment.mod_loader,
stored.plan.target_environment.mod_loader_version.as_deref(),
)
.await?;
if stored.plan.target_environment.mod_loader
!= crate::state::ModLoader::Vanilla
&& resolved_target_loader.is_none()
{
return Err(crate::ErrorKind::InputError(
"Target loader version is no longer available".to_string(),
)
.into());
}
ensure_upgrade_disk_space(
&metadata,
&stored.plan.source_files,
create_full_backup,
shared_upgrade_mode,
&state,
)?;
ensure_upgrade_target_writable(
&state
.directories
.instances_dir()
.join(&metadata.instance.path),
)
.await?;
crate::state::instances::commands::validate_instance_upgrade_plan_source(
&stored.plan,
&state,
)
.await?;
let source_watch = state
.file_watcher
.content_watch_snapshot(&stored.plan.instance_id)
.await
.map(|snapshot| InstanceUpgradeWatchBaseline {
epoch: snapshot.epoch,
generation: snapshot.generation,
dirty_paths: snapshot.dirty_paths.into_iter().collect(),
});
if crate::install::store::list(false, &state)
.await?
.into_iter()
.any(|job| {
!job.status.is_finished()
&& job.instance_id.as_deref()
== Some(stored.plan.instance_id.as_str())
})
{
return Err(crate::ErrorKind::InputError(
"Instance already has an active install job".to_string(),
)
.into());
}
let instance_id = stored.plan.instance_id.clone();
let mut target_environment = stored.plan.target_environment.clone();
if let Some(loader) = resolved_target_loader {
target_environment.mod_loader_version = Some(loader.id);
}
let mut warnings = stored.plan.warnings.clone();
for warning in &solution.warnings {
if !warnings.contains(warning) {
warnings.push(warning.clone());
}
}
let execution = InstanceUpgradeExecution {
source_revision: stored.plan.source_revision,
source_files: stored.plan.source_files.clone(),
source_environment: stored.plan.source_environment.clone(),
target_environment,
items: stored.plan.items.clone(),
solution,
warnings,
source_watch,
};
stored.execution_started = true;
let result = crate::install::upgrade_unmanaged_instance(
instance_id,
plan_id.to_string(),
execution,
create_full_backup,
shared_upgrade_mode,
display_names,
)
.await;
if result.is_err() {
stored.execution_started = false;
}
result
}
pub async fn get_instance_post_upgrade_notice(
instance_id: &str,
) -> crate::Result<Option<crate::state::InstancePostUpgradeNotice>> {
let state = State::get().await?;
crate::state::instances::commands::get_instance_post_upgrade_notice(
instance_id,
&state.pool,
)
.await
}
pub async fn dismiss_instance_post_upgrade_notice(
instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
crate::state::instances::commands::dismiss_instance_post_upgrade_notice(
instance_id,
&state.pool,
)
.await
}
fn ensure_upgrade_disk_space(
metadata: &crate::state::InstanceMetadata,
source_files: &[crate::state::InstanceUpgradeSourceFile],
create_full_backup: bool,
shared_upgrade_mode: SharedUpgradeMode,
state: &State,
) -> crate::Result<()> {
let instance_path = state
.directories
.instances_dir()
.join(&metadata.instance.path);
let canonical = crate::util::io::canonicalize(&instance_path)?;
let disks = sysinfo::Disks::new_with_refreshed_list();
let available = disks
.iter()
.filter(|disk| canonical.starts_with(disk.mount_point()))
.max_by_key(|disk| disk.mount_point().as_os_str().len())
.map(sysinfo::Disk::available_space);
let Some(available) = available else {
return Ok(());
};
let source_size = source_files
.iter()
.fold(0_u64, |total, file| total.saturating_add(file.size));
let copies = 1_u64
+ u64::from(
create_full_backup
&& shared_upgrade_mode == SharedUpgradeMode::Direct,
);
let required = source_size
.saturating_mul(copies)
.saturating_add(source_size / 10);
if available < required {
return Err(crate::ErrorKind::FSError(format!(
"Not enough free disk space for upgrade staging: need {required} bytes, have {available} bytes"
))
.into());
}
Ok(())
}
async fn ensure_upgrade_target_writable(
path: &std::path::Path,
) -> crate::Result<()> {
let probe = path.join(format!(
".instance-upgrade-write-test-{}",
uuid::Uuid::new_v4()
));
let file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
.await
.map_err(|error| {
crate::ErrorKind::FSError(format!(
"Upgrade target is not writable: {error}"
))
})?;
drop(file);
tokio::fs::remove_file(&probe).await.map_err(|error| {
crate::ErrorKind::FSError(format!(
"Upgrade write probe could not be removed: {error}"
))
.into()
})
}
fn stored_plan_handle(plan_id: &str) -> crate::Result<StoredUpgradePlan> {
INSTANCE_UPGRADE_PLANS
.get(plan_id)
.map(|entry| Arc::clone(entry.value()))
.ok_or_else(|| {
crate::ErrorKind::InputError(
"The instance upgrade plan has expired".to_string(),
)
.into()
})
}
async fn ensure_current_revision(
stored: &mut StoredUpgradePlanState,
state: &State,
) -> crate::Result<crate::state::instances::commands::ReadOnlyUpgradeSource> {
let current_revision = content_rows::get_applied_content_set(
&stored.plan.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?
.revision;
if let Err(error) = ensure_instance_upgrade_revision(
stored.plan.source_revision,
current_revision,
) {
return Err(error);
}
stored.validation.validate(&stored.plan, state).await
}
fn ensure_instance_upgrade_revision(
planned_revision: u64,
current_revision: u64,
) -> crate::Result<()> {
if planned_revision == current_revision {
return Ok(());
}
Err(crate::ErrorKind::StaleInstanceUpgradePlan {
planned_revision,
current_revision,
}
.into())
}
fn selected_kind_and_constraints(
plan: &InstanceUpgradePlan,
) -> (
InstanceUpgradeSolutionKind,
Vec<InstanceUpgradeFixedConstraint>,
) {
let Some(solution) = plan.selected_solution.as_ref() else {
return (InstanceUpgradeSolutionKind::Newest, Vec::new());
};
if solution.kind != InstanceUpgradeSolutionKind::Custom {
return (solution.kind, Vec::new());
}
(
InstanceUpgradeSolutionKind::Custom,
plan.custom_constraints.clone(),
)
}
fn validate_fixed_constraints(
plan: &InstanceUpgradePlan,
constraints: &[InstanceUpgradeFixedConstraint],
) -> crate::Result<()> {
let mut seen = HashMap::new();
for constraint in constraints {
if let Some(previous) = seen.insert(
constraint.content_id.as_str(),
constraint.version_id.as_str(),
) && previous != constraint.version_id.as_str()
{
return Err(crate::ErrorKind::InputError(format!(
"Custom upgrade constraints select multiple versions for content {}",
constraint.content_id
))
.into());
}
let root_exists = plan.items.iter().any(|item| {
!item.auto_dependency
&& item.content_id == constraint.content_id
&& item.provider == Some(constraint.provider)
&& item.project_id.as_deref()
== Some(constraint.project_id.as_str())
});
if !root_exists {
return Err(crate::ErrorKind::InputError(format!(
"Custom upgrade constraint does not match root content {} at {}:{}",
constraint.content_id,
constraint.provider.as_str(),
constraint.project_id
))
.into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_fixed_constraint_preserves_content_id_through_serde() {
let input = serde_json::json!({
"contentId": "physical-iris",
"provider": "modrinth",
"projectId": "YL57xq9U",
"versionId": "Rhzf61g1"
});
let constraint: InstanceUpgradeFixedConstraint =
serde_json::from_value(input).unwrap();
let output = serde_json::to_value(constraint).unwrap();
assert_eq!(output["contentId"], "physical-iris");
}
#[test]
fn duplicate_custom_constraints_are_rejected_before_provider_work() {
let mut plan = empty_plan();
plan.items.push(crate::state::InstanceUpgradeItem {
content_id: "root".to_string(),
relative_path: "mods/root.jar".to_string(),
project_type: crate::state::ProjectType::Mod,
provider: Some(crate::state::ContentProvider::Modrinth),
project_id: Some("root".to_string()),
current_release_id: Some("old".to_string()),
current_enabled: true,
auto_dependency: false,
status: crate::state::InstanceUpgradeItemStatus::UpgradeAvailable,
resolution: crate::state::InstanceUpgradeResolution {
content_id: "root".to_string(),
action: crate::state::InstanceUpgradeAction::Upgrade,
allow_prerelease: false,
confirmed_prerelease_dependencies: Vec::new(),
},
candidate_release_ids: vec!["one".to_string(), "two".to_string()],
});
let constraints = vec![
InstanceUpgradeFixedConstraint {
content_id: "root".to_string(),
provider: crate::state::ContentProvider::Modrinth,
project_id: "root".to_string(),
version_id: "one".to_string(),
},
InstanceUpgradeFixedConstraint {
content_id: "root".to_string(),
provider: crate::state::ContentProvider::Modrinth,
project_id: "root".to_string(),
version_id: "two".to_string(),
},
];
assert!(validate_fixed_constraints(&plan, &constraints).is_err());
let wrong_physical_root = vec![InstanceUpgradeFixedConstraint {
content_id: "different-root".to_string(),
provider: crate::state::ContentProvider::Modrinth,
project_id: "root".to_string(),
version_id: "one".to_string(),
}];
assert!(
validate_fixed_constraints(&plan, &wrong_physical_root).is_err()
);
}
#[test]
fn stale_upgrade_plan_revision_is_rejected() {
assert!(ensure_instance_upgrade_revision(4, 4).is_ok());
let error = ensure_instance_upgrade_revision(4, 5).unwrap_err();
assert!(error.to_string().contains("planned revision 4"));
assert!(error.to_string().contains("current revision 5"));
}
#[test]
fn custom_recompute_uses_only_explicitly_stored_constraints() {
let mut plan = empty_plan();
plan.custom_constraints = vec![InstanceUpgradeFixedConstraint {
content_id: "a".to_string(),
provider: crate::state::ContentProvider::Modrinth,
project_id: "a".to_string(),
version_id: "a-fixed".to_string(),
}];
plan.selected_solution = Some(crate::state::InstanceUpgradeSolution {
kind: InstanceUpgradeSolutionKind::Custom,
selections: vec![
crate::state::InstanceUpgradeSelection {
content_id: "a".to_string(),
provider: Some(crate::state::ContentProvider::Modrinth),
project_id: Some("a".to_string()),
current_release_id: Some("a-old".to_string()),
target_release_id: Some("a-fixed".to_string()),
action: crate::state::InstanceUpgradeAction::Upgrade,
enabled: true,
},
crate::state::InstanceUpgradeSelection {
content_id: "b".to_string(),
provider: Some(crate::state::ContentProvider::Modrinth),
project_id: Some("b".to_string()),
current_release_id: Some("b-old".to_string()),
target_release_id: Some("b-auto".to_string()),
action: crate::state::InstanceUpgradeAction::Upgrade,
enabled: true,
},
],
dependency_changes: Vec::new(),
warnings: Vec::new(),
});
let (kind, constraints) = selected_kind_and_constraints(&plan);
assert_eq!(kind, InstanceUpgradeSolutionKind::Custom);
assert_eq!(constraints, plan.custom_constraints);
assert_eq!(constraints.len(), 1);
assert_eq!(constraints[0].project_id, "a");
}
#[test]
fn batch_requests_dedupe_by_content_with_last_value_and_stable_order() {
let request = |content_id: &str, action| InstanceUpgradeResolution {
content_id: content_id.to_string(),
action,
allow_prerelease: false,
confirmed_prerelease_dependencies: Vec::new(),
};
let (normalized, skipped) = normalize_batch_resolutions(vec![
request("b", InstanceUpgradeAction::Keep),
request("a", InstanceUpgradeAction::Disable),
request("b", InstanceUpgradeAction::Disable),
]);
assert_eq!(
normalized
.iter()
.map(|item| item.content_id.as_str())
.collect::<Vec<_>>(),
["a", "b"]
);
assert_eq!(normalized[1].action, InstanceUpgradeAction::Disable);
assert_eq!(skipped.len(), 1);
}
#[test]
fn batch_resolution_is_skipped_when_plan_already_has_requested_value() {
let mut plan = empty_plan();
plan.items.push(crate::state::InstanceUpgradeItem {
content_id: "root".to_string(),
relative_path: "mods/root.jar".to_string(),
project_type: crate::state::ProjectType::Mod,
provider: None,
project_id: None,
current_release_id: None,
current_enabled: true,
auto_dependency: false,
status: crate::state::InstanceUpgradeItemStatus::Unidentified,
resolution: InstanceUpgradeResolution {
content_id: "root".to_string(),
action: InstanceUpgradeAction::Keep,
allow_prerelease: false,
confirmed_prerelease_dependencies: Vec::new(),
},
candidate_release_ids: Vec::new(),
});
let request = plan.items[0].resolution.clone();
assert!(!resolution_is_applicable(&plan, &request));
}
#[tokio::test]
async fn per_plan_mutex_serializes_mutations_without_lost_update() {
let plan = Arc::new(Mutex::new(empty_plan()));
let acquired = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let first_plan = Arc::clone(&plan);
let first_acquired = Arc::clone(&acquired);
let first_release = Arc::clone(&release);
let first = tokio::spawn(async move {
let mut stored = first_plan.lock().await;
first_acquired.notify_one();
first_release.notified().await;
stored
.custom_constraints
.push(InstanceUpgradeFixedConstraint {
content_id: "a".to_string(),
provider: crate::state::ContentProvider::Modrinth,
project_id: "a".to_string(),
version_id: "a-one".to_string(),
});
});
acquired.notified().await;
let second_plan = Arc::clone(&plan);
let second = tokio::spawn(async move {
let mut stored = second_plan.lock().await;
stored
.custom_constraints
.push(InstanceUpgradeFixedConstraint {
content_id: "b".to_string(),
provider: crate::state::ContentProvider::Modrinth,
project_id: "b".to_string(),
version_id: "b-one".to_string(),
});
});
tokio::task::yield_now().await;
release.notify_one();
first.await.unwrap();
second.await.unwrap();
let stored = plan.lock().await;
assert_eq!(stored.custom_constraints.len(), 2);
}
fn empty_plan() -> InstanceUpgradePlan {
let environment = crate::state::InstanceUpgradeEnvironment {
game_version: "1.21.1".to_string(),
mod_loader: crate::state::ModLoader::Fabric,
mod_loader_version: None,
shader_runtime: crate::state::ShaderRuntime::Iris,
};
InstanceUpgradePlan {
id: "plan".to_string(),
instance_id: "instance".to_string(),
source_revision: 1,
source_files: Vec::new(),
source_environment: environment.clone(),
target_environment: environment,
items: Vec::new(),
dependency_changes: Vec::new(),
warnings: Vec::new(),
blocking_issues: Vec::new(),
newest_solution: None,
minimal_change_solution: None,
selected_solution: None,
custom_constraints: Vec::new(),
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,447 @@
use crate::State;
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
use serde::{Deserialize, Serialize};
use sha2::Digest;
const API_BASE_URL: &str = "https://mcarchive.net/api/v1";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McArchiveGameVersion {
pub id: i64,
pub name: String,
#[serde(default, alias = "version_type")]
pub version_type: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McArchiveFile {
pub uuid: String,
pub name: String,
#[serde(default)]
pub sha256: Option<String>,
#[serde(default, alias = "archive_url")]
pub archive_url: Option<String>,
#[serde(default, alias = "direct_url")]
pub direct_url: Option<String>,
#[serde(default, alias = "redirect_url")]
pub redirect_url: Option<String>,
#[serde(default, alias = "page_url")]
pub page_url: Option<String>,
}
impl McArchiveFile {
pub fn download_url(&self) -> Option<&str> {
self.archive_url
.as_deref()
.filter(|url| !url.trim().is_empty())
.or_else(|| {
self.direct_url
.as_deref()
.filter(|url| !url.trim().is_empty())
})
}
pub fn needs_manual_download(&self) -> bool {
!self.is_automatically_installable()
}
pub fn is_automatically_installable(&self) -> bool {
self.download_url().is_some()
&& self
.sha256
.as_deref()
.is_some_and(|hash| !hash.trim().is_empty())
}
pub fn manual_download_url(&self) -> Option<&str> {
self.page_url
.as_deref()
.filter(|url| !url.trim().is_empty())
.or_else(|| {
self.redirect_url
.as_deref()
.filter(|url| !url.trim().is_empty())
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McArchiveModVersion {
pub uuid: String,
pub name: String,
#[serde(default, alias = "game_versions")]
pub game_versions: Vec<McArchiveGameVersion>,
#[serde(default)]
pub files: Vec<McArchiveFile>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McArchiveMod {
#[serde(default)]
pub uuid: String,
pub slug: String,
pub name: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default, alias = "website", alias = "page_url")]
pub page_url: Option<String>,
#[serde(default, alias = "mod_versions")]
pub mod_versions: Vec<McArchiveModVersion>,
}
const SEARCH_FIELDS: &str = "{uuid,slug,name,description,website}";
const MOD_FIELDS: &str = "{uuid,slug,name,description,website,mod_versions{uuid,name,page_url,description,game_versions{id,name},files{uuid,name,sha256,description,page_url,redirect_url,direct_url,archive_url}}}";
const FILE_FIELDS: &str = "{uuid,name,sha256,description,page_url,redirect_url,direct_url,archive_url}";
fn normalize_mod_identity(item: &mut McArchiveMod) {
if item.uuid.trim().is_empty() {
item.uuid = item.slug.clone();
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum Collection<T> {
Items(Vec<T>),
Wrapped { results: Vec<T> },
Data { data: Vec<T> },
}
impl<T> Collection<T> {
fn into_inner(self) -> Vec<T> {
match self {
Self::Items(items) => items,
Self::Wrapped { results } => results,
Self::Data { data } => data,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum OneOrCollection<T> {
One(T),
Collection(Collection<T>),
}
impl<T> OneOrCollection<T> {
fn into_first(self) -> Option<T> {
match self {
Self::One(item) => Some(item),
Self::Collection(items) => items.into_inner().into_iter().next(),
}
}
}
pub async fn get_game_versions() -> crate::Result<Vec<McArchiveGameVersion>> {
get_collection("/game_versions", "{id,name}").await
}
pub async fn search_mods(
keyword: &str,
game_version: Option<&str>,
) -> crate::Result<Vec<McArchiveMod>> {
let keyword = keyword.trim();
let mut url = format!(
"{API_BASE_URL}/mods/?keyword={}",
urlencoding::encode(keyword)
);
if let Some(game_version) = game_version
.map(str::trim)
.filter(|value| !value.is_empty())
{
url.push_str("&game_version=");
url.push_str(&urlencoding::encode(game_version));
}
let mut mods = get_json::<Collection<McArchiveMod>>(&url, SEARCH_FIELDS)
.await
.map(Collection::into_inner)?;
for item in &mut mods {
normalize_mod_identity(item);
}
Ok(mods)
}
pub async fn get_mod_by_slug(slug: &str) -> crate::Result<McArchiveMod> {
get_json(
&format!("{API_BASE_URL}/mods/by_slug/{}", urlencoding::encode(slug)),
MOD_FIELDS,
)
.await
.map(|mut item| {
normalize_mod_identity(&mut item);
item
})
}
pub async fn get_file_by_filename(
filename: &str,
) -> crate::Result<Option<McArchiveFile>> {
get_optional(
&format!(
"{API_BASE_URL}/files/by_filename/{}",
urlencoding::encode(filename)
),
FILE_FIELDS,
)
.await
}
pub async fn get_file_by_sha256(
sha256: &str,
) -> crate::Result<Option<McArchiveFile>> {
get_optional(
&format!(
"{API_BASE_URL}/files/by_hash/sha256/{}",
urlencoding::encode(sha256)
),
FILE_FIELDS,
)
.await
}
pub async fn download_file(file: &McArchiveFile) -> crate::Result<Vec<u8>> {
let url = file.download_url().ok_or_else(|| {
crate::ErrorKind::InputError(
"MCArchive does not expose a verifiable direct archive URL for this file"
.to_string(),
)
})?;
let state = State::get().await?;
let _permit = state.download_semaphore.0.acquire().await?;
let response = INSECURE_REQWEST_CLIENT
.get(url)
.send()
.await?
.error_for_status()?;
let bytes = response.bytes().await?.to_vec();
if bytes.is_empty() {
return Err(crate::ErrorKind::OtherError(
"MCArchive returned an empty file".to_string(),
)
.into());
}
let expected = file
.sha256
.as_deref()
.filter(|hash| !hash.is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"MCArchive does not publish a SHA-256 for {}",
file.name
))
})?;
let actual = format!("{:x}", sha2::Sha256::digest(&bytes));
if !actual.eq_ignore_ascii_case(expected) {
return Err(crate::ErrorKind::OtherError(format!(
"MCArchive SHA-256 mismatch for {}",
file.name
))
.into());
}
Ok(bytes)
}
async fn get_collection<T>(path: &str, fields: &str) -> crate::Result<Vec<T>>
where
T: for<'de> Deserialize<'de>,
{
get_json::<Collection<T>>(&format!("{API_BASE_URL}{path}"), fields)
.await
.map(Collection::into_inner)
}
async fn get_optional<T>(url: &str, fields: &str) -> crate::Result<Option<T>>
where
T: for<'de> Deserialize<'de>,
{
let state = State::get().await?;
let _permit = state.api_semaphore.0.acquire().await?;
let response = mcarchive_get(url).header("X-Fields", fields).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response.error_for_status()?;
let value = response.json::<Option<OneOrCollection<T>>>().await?;
Ok(value.and_then(OneOrCollection::into_first))
}
async fn get_json<T>(url: &str, fields: &str) -> crate::Result<T>
where
T: for<'de> Deserialize<'de>,
{
let state = State::get().await?;
let _permit = state.api_semaphore.0.acquire().await?;
Ok(mcarchive_get(url)
.header("X-Fields", fields)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
fn mcarchive_get(url: &str) -> reqwest::RequestBuilder {
INSECURE_REQWEST_CLIENT
.get(url)
.version(reqwest::Version::HTTP_11)
.header(reqwest::header::ACCEPT, "application/json")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcarchive_uses_string_uuid_identifiers_and_archive_url() {
let file: McArchiveFile = serde_json::from_value(serde_json::json!({
"uuid": "46d5c61e-02b4-4ca0-8b4b-7c965d2931bc",
"name": "ModLoader 1.6.2.zip",
"sha256": "abc",
"archive_url": "https://b2.mcarchive.net/file/modloader.zip",
"direct_url": "",
"redirect_url": "",
"page_url": ""
}))
.unwrap();
assert_eq!(file.uuid, "46d5c61e-02b4-4ca0-8b4b-7c965d2931bc");
assert_eq!(
file.download_url(),
Some("https://b2.mcarchive.net/file/modloader.zip")
);
}
#[test]
fn missing_urls_require_manual_download() {
let file = McArchiveFile {
uuid: "file".to_string(),
name: "mod.zip".to_string(),
sha256: None,
archive_url: Some(String::new()),
direct_url: None,
redirect_url: Some("https://example.invalid/download".to_string()),
page_url: Some("https://mcarchive.net/mod".to_string()),
};
assert!(file.needs_manual_download());
assert_eq!(
file.manual_download_url(),
Some("https://mcarchive.net/mod")
);
}
#[test]
fn hash_lookup_collections_return_the_first_matching_file() {
let response: Option<OneOrCollection<McArchiveFile>> =
serde_json::from_value(serde_json::json!([
{
"uuid": "file-uuid",
"name": "mod.jar",
"sha256": "abc"
}
]))
.unwrap();
assert_eq!(
response
.and_then(OneOrCollection::into_first)
.map(|file| file.uuid),
Some("file-uuid".to_string())
);
}
#[test]
fn mod_search_summaries_do_not_claim_to_include_versions() {
let mod_: McArchiveMod = serde_json::from_value(serde_json::json!({
"uuid": "mod-uuid",
"slug": "modloader",
"name": "ModLoader"
}))
.unwrap();
assert_eq!(mod_.slug, "modloader");
assert!(mod_.mod_versions.is_empty());
}
#[test]
fn real_mcarchive_search_payload_deserializes_without_versions() {
let payload = serde_json::json!([
{
"uuid": "ff38ccfe-24ca-4bc6-97ba-59d6b37b147a",
"slug": "modloader",
"name": "Modloader",
"description": "",
"website": ""
},
{
"uuid": "7b2abb02-ba79-4363-abbf-268b2024488d",
"slug": "modloadermp",
"name": "ModLoaderMP"
}
]);
let mut mods: Vec<McArchiveMod> =
serde_json::from_value::<Collection<McArchiveMod>>(payload)
.unwrap()
.into_inner();
for item in &mut mods {
normalize_mod_identity(item);
}
assert_eq!(mods.len(), 2);
assert_eq!(mods[0].slug, "modloader");
assert_eq!(mods[0].uuid, "ff38ccfe-24ca-4bc6-97ba-59d6b37b147a");
assert!(mods[0].mod_versions.is_empty());
assert_eq!(mods[1].name, "ModLoaderMP");
}
#[test]
fn real_mcarchive_mod_payload_deserializes_nested_versions_and_files() {
let payload = serde_json::json!({
"uuid": "ff38ccfe-24ca-4bc6-97ba-59d6b37b147a",
"slug": "modloader",
"name": "Modloader",
"description": "",
"website": "",
"mod_versions": [{
"uuid": "52fbdb8f-6ae7-4dd6-8b02-cbfc54b5ce3e",
"name": "1.6.2",
"page_url": "",
"description": "",
"game_versions": [{"id": 9, "name": "1.6.2"}],
"files": [{
"uuid": "e7830701-aca7-4e9b-a5b2-94aacbeef293",
"name": "ModLoader 1.6.2.zip",
"sha256": "0b14f5e261c9862989aa74313b59188cce10bea6724bae31130ce1e8e6a1c060",
"description": "",
"page_url": "",
"redirect_url": "",
"direct_url": "",
"archive_url": "https://b2.mcarchive.net/file/mcarchive/example.zip"
}]
}]
});
let mut item: McArchiveMod = serde_json::from_value(payload).unwrap();
normalize_mod_identity(&mut item);
assert_eq!(item.mod_versions.len(), 1);
assert_eq!(item.mod_versions[0].game_versions[0].name, "1.6.2");
assert_eq!(
item.mod_versions[0].files[0].download_url(),
Some("https://b2.mcarchive.net/file/mcarchive/example.zip")
);
}
#[test]
fn missing_uuid_uses_slug_for_stable_search_identity() {
let mut item: McArchiveMod =
serde_json::from_value(serde_json::json!({
"slug": "modloader",
"name": "Modloader"
}))
.unwrap();
normalize_mod_identity(&mut item);
assert_eq!(item.uuid, "modloader");
}
}

View File

@ -0,0 +1,320 @@
use serde::Serialize;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct MemoryOptimizationResult {
pub before_available_bytes: u64,
pub after_available_bytes: u64,
pub reclaimed_bytes: u64,
pub supported: bool,
}
pub fn optimization_supported() -> bool {
cfg!(target_os = "windows")
}
pub async fn optimize() -> crate::Result<MemoryOptimizationResult> {
tokio::task::spawn_blocking(optimize_blocking)
.await
.map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Memory optimization task failed: {error}"
))
.as_error()
})?
}
#[cfg(not(target_os = "windows"))]
fn optimize_blocking() -> crate::Result<MemoryOptimizationResult> {
Ok(MemoryOptimizationResult {
before_available_bytes: 0,
after_available_bytes: 0,
reclaimed_bytes: 0,
supported: false,
})
}
#[cfg(target_os = "windows")]
fn optimize_blocking() -> crate::Result<MemoryOptimizationResult> {
let before_available_bytes = super::jre::system_available_memory_bytes();
let direct_result = optimize_windows_memory();
let result = match direct_result {
Ok(()) => Ok(()),
Err(_error) => run_elevated_helper().map(|_| ()),
};
result.map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Memory optimization was not completed: {error}"
))
.as_error()
})?;
let after_available_bytes = super::jre::system_available_memory_bytes();
Ok(MemoryOptimizationResult {
before_available_bytes,
after_available_bytes,
reclaimed_bytes: after_available_bytes
.saturating_sub(before_available_bytes),
supported: true,
})
}
#[cfg(target_os = "windows")]
fn run_elevated_helper() -> Result<(), String> {
use std::mem::size_of;
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{
GetExitCodeProcess, INFINITE, WaitForSingleObject,
};
use windows::Win32::UI::Shell::{
SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
};
use windows::core::{PCWSTR, w};
let mut executable = std::env::current_exe()
.map_err(|error| {
format!("Could not locate launcher executable: {error}")
})?
.into_os_string()
.encode_wide()
.collect::<Vec<_>>();
executable.push(0);
let arguments = "--memory-optimize\0".encode_utf16().collect::<Vec<_>>();
let mut execute_info = SHELLEXECUTEINFOW {
cbSize: size_of::<SHELLEXECUTEINFOW>() as u32,
fMask: SEE_MASK_NOCLOSEPROCESS,
lpVerb: w!("runas"),
lpFile: PCWSTR::from_raw(executable.as_ptr()),
lpParameters: PCWSTR::from_raw(arguments.as_ptr()),
// Run the elevated helper without a visible console window.
nShow: 0,
..Default::default()
};
unsafe { ShellExecuteExW(&raw mut execute_info) }.map_err(|error| {
format!("Could not request administrator permission: {error}")
})?;
let process = execute_info.hProcess;
let result = (|| {
if unsafe { WaitForSingleObject(process, INFINITE) } != WAIT_OBJECT_0 {
return Err(
"Elevated memory optimization did not finish".to_string()
);
}
let mut exit_code = 0;
unsafe { GetExitCodeProcess(process, &mut exit_code) }.map_err(
|error| format!("Could not read elevated process status: {error}"),
)?;
if exit_code == 0 {
Ok(())
} else {
Err("Administrator permission was denied".to_string())
}
})();
unsafe { CloseHandle(process) }.map_err(|error| {
format!("Could not close elevated process handle: {error}")
})?;
result
}
#[cfg(target_os = "windows")]
pub fn optimize_current_process_context() -> i32 {
match optimize_windows_memory() {
Ok(()) => 0,
Err(error) => {
tracing::error!("Elevated memory optimization failed: {error}");
1
}
}
}
#[cfg(target_os = "windows")]
fn optimize_windows_memory() -> Result<(), String> {
use std::ffi::c_void;
use std::mem::size_of;
use std::ptr::null_mut;
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct Luid {
low_part: u32,
high_part: i32,
}
#[repr(C)]
struct LuidAndAttributes {
luid: Luid,
attributes: u32,
}
#[repr(C)]
struct TokenPrivileges {
privilege_count: u32,
privileges: [LuidAndAttributes; 1],
}
#[repr(C)]
#[derive(Default)]
struct SystemFileCacheInformation {
current_size: usize,
peak_size: usize,
page_fault_count: u32,
minimum_working_set: usize,
maximum_working_set: usize,
current_size_including_transition_in_pages: usize,
peak_size_including_transition_in_pages: usize,
transition_repurpose_count: u32,
flags: u32,
}
#[repr(C)]
#[derive(Default)]
struct MemoryCombineInformationEx {
handle: isize,
pages_combined: usize,
flags: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentProcess() -> *mut c_void;
fn CloseHandle(object: *mut c_void) -> i32;
}
#[link(name = "advapi32")]
unsafe extern "system" {
fn OpenProcessToken(
process_handle: *mut c_void,
desired_access: u32,
token_handle: *mut *mut c_void,
) -> i32;
fn LookupPrivilegeValueW(
system_name: *const u16,
name: *const u16,
luid: *mut Luid,
) -> i32;
fn AdjustTokenPrivileges(
token_handle: *mut c_void,
disable_all_privileges: i32,
new_state: *const TokenPrivileges,
buffer_length: u32,
previous_state: *mut TokenPrivileges,
return_length: *mut u32,
) -> i32;
}
#[link(name = "ntdll")]
unsafe extern "system" {
fn NtSetSystemInformation(
system_information_class: u32,
system_information: *mut c_void,
system_information_length: u32,
) -> i32;
}
const TOKEN_ADJUST_PRIVILEGES: u32 = 0x20;
const TOKEN_QUERY: u32 = 0x8;
const SE_PRIVILEGE_ENABLED: u32 = 0x2;
let process = unsafe { GetCurrentProcess() };
let mut token = null_mut();
if unsafe {
OpenProcessToken(
process,
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&raw mut token,
)
} == 0
{
return Err(std::io::Error::last_os_error().to_string());
}
let result = (|| {
for privilege in [
"SeProfileSingleProcessPrivilege",
"SeIncreaseQuotaPrivilege",
] {
let mut wide = privilege.encode_utf16().collect::<Vec<_>>();
wide.push(0);
let mut luid = Luid::default();
if unsafe {
LookupPrivilegeValueW(null_mut(), wide.as_ptr(), &raw mut luid)
} == 0
{
return Err(std::io::Error::last_os_error().to_string());
}
let privileges = TokenPrivileges {
privilege_count: 1,
privileges: [LuidAndAttributes {
luid,
attributes: SE_PRIVILEGE_ENABLED,
}],
};
if unsafe {
AdjustTokenPrivileges(
token,
0,
&raw const privileges,
0,
null_mut(),
null_mut(),
)
} == 0
{
return Err(std::io::Error::last_os_error().to_string());
}
}
let mut statuses = Vec::with_capacity(7);
let mut info = 2_i32;
statuses.push(unsafe {
NtSetSystemInformation(
80,
(&raw mut info).cast(),
size_of::<i32>() as u32,
)
});
let mut cache = SystemFileCacheInformation {
minimum_working_set: usize::MAX,
maximum_working_set: usize::MAX,
..Default::default()
};
statuses.push(unsafe {
NtSetSystemInformation(
81,
(&raw mut cache).cast(),
size_of::<SystemFileCacheInformation>() as u32,
)
});
for value in [3_i32, 4, 5] {
info = value;
statuses.push(unsafe {
NtSetSystemInformation(
80,
(&raw mut info).cast(),
size_of::<i32>() as u32,
)
});
}
statuses.push(unsafe { NtSetSystemInformation(155, null_mut(), 0) });
let mut combine = MemoryCombineInformationEx::default();
statuses.push(unsafe {
NtSetSystemInformation(
130,
(&raw mut combine).cast(),
size_of::<MemoryCombineInformationEx>() as u32,
)
});
if statuses[0] < 0 && statuses[1] < 0 {
return Err("Administrator privileges are required".to_string());
}
Ok(())
})();
unsafe { CloseHandle(token) };
result
}

View File

@ -0,0 +1,139 @@
use crate::State;
use crate::state::{CacheBehaviour, CachedEntry};
pub use daedalus::minecraft::VersionManifest;
pub use daedalus::modded::Manifest;
#[tracing::instrument]
pub async fn get_minecraft_versions() -> crate::Result<VersionManifest> {
get_minecraft_versions_with_cache(None).await
}
pub async fn get_minecraft_versions_with_cache(
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<VersionManifest> {
let state = State::get().await?;
let minecraft_versions = CachedEntry::get_minecraft_manifest(
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor("minecraft versions".to_string())
})?;
Ok(minecraft_versions)
}
// #[tracing::instrument]
pub async fn get_loader_versions(loader: &str) -> crate::Result<Manifest> {
match get_loader_versions_with_cache(
loader,
Some(CacheBehaviour::MustRevalidate),
)
.await
{
Ok(manifest) => Ok(manifest),
Err(refresh_error) => {
match get_loader_versions_with_cache(
loader,
Some(CacheBehaviour::CacheOnly),
)
.await
{
Ok(manifest) => {
tracing::warn!(
loader,
error = %refresh_error,
"Loader manifest refresh failed; serving cached data"
);
Ok(manifest)
}
Err(_) => Err(refresh_error),
}
}
}
}
pub async fn get_loader_versions_with_cache(
loader: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Manifest> {
let state = State::get().await?;
let cache_key =
daedalus::modded::loader_manifest_metadata(loader).cache_key;
let loaders = CachedEntry::get_loader_manifest(
&cache_key,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor(format!("{loader} loader versions"))
})?;
Ok(loaders.manifest)
}
pub async fn get_loader_versions_for_game(
loader: &str,
game_version: &str,
) -> crate::Result<Manifest> {
match get_loader_versions_for_game_with_cache(
loader,
game_version,
Some(CacheBehaviour::MustRevalidate),
)
.await
{
Ok(manifest) => Ok(manifest),
Err(refresh_error) => {
match get_loader_versions_for_game_with_cache(
loader,
game_version,
Some(CacheBehaviour::CacheOnly),
)
.await
{
Ok(manifest) => {
tracing::warn!(
loader,
game_version,
error = %refresh_error,
"Scoped loader metadata refresh failed; serving cached data"
);
Ok(manifest)
}
Err(_) => Err(refresh_error),
}
}
}
}
pub async fn get_loader_versions_for_game_with_cache(
loader: &str,
game_version: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Manifest> {
let state = State::get().await?;
let cache_key = daedalus::modded::loader_manifest_metadata_for_game(
loader,
game_version,
)
.cache_key;
let loaders = CachedEntry::get_loader_manifest(
&cache_key,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor(format!(
"{loader} loader versions for Minecraft {game_version}"
))
})?;
Ok(loaders.manifest)
}

View File

@ -0,0 +1,274 @@
//! Authentication flow interface
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use serde::Serialize;
use std::time::Duration;
use uuid::Uuid;
use crate::State;
pub use crate::state::YggdrasilLoginResult;
use crate::state::{
Credentials, MinecraftAccountType, MinecraftLoginFlow, MinecraftProfile,
YggdrasilAccount,
};
pub use crate::state::{MinecraftDeviceLoginFlow, MinecraftDeviceLoginPoll};
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
use crate::util::mojang::{mojang_service_url, should_use_mojang_mirror};
#[tracing::instrument]
pub async fn check_reachable() -> crate::Result<()> {
let url = mojang_service_url(
"https://sessionserver.mojang.com/session/minecraft/hasJoined",
should_use_mojang_mirror(),
);
let resp = INSECURE_REQWEST_CLIENT
.get(url.as_ref())
.timeout(Duration::from_secs(5))
.send()
.await?;
if resp.status() == StatusCode::NO_CONTENT {
return Ok(());
}
resp.error_for_status()?;
Ok(())
}
pub async fn set_mojang_auth_use_mirror(
use_mirror: bool,
automatic: bool,
) -> crate::Result<()> {
let state = State::get().await?;
state.set_mojang_auth_use_mirror(use_mirror);
if use_mirror && automatic {
tracing::info!(
"Mojang services are unreachable; routing Mojang service requests through the Fallen proxy"
);
}
Ok(())
}
#[derive(Clone, Debug, Serialize)]
pub struct MojangServiceStatus {
pub service: &'static str,
pub url: &'static str,
pub reachable: bool,
}
const MOJANG_SERVICES: [(&str, &str); 5] = [
("auth", "https://authserver.mojang.com/"),
("account", "https://api.mojang.com/"),
(
"session",
"https://sessionserver.mojang.com/session/minecraft/hasJoined",
),
("services", "https://api.minecraftservices.com/"),
(
"profiles",
"https://api.mojang.com/users/profiles/minecraft/",
),
];
#[tracing::instrument]
pub async fn check_mojang_services() -> Vec<MojangServiceStatus> {
futures::future::join_all(MOJANG_SERVICES.map(
|(service, url)| async move {
let reachable = INSECURE_REQWEST_CLIENT
.get(url)
.timeout(Duration::from_secs(5))
.send()
.await
.is_ok();
MojangServiceStatus {
service,
url,
reachable,
}
},
))
.await
}
#[tracing::instrument]
pub async fn begin_login() -> crate::Result<MinecraftLoginFlow> {
let state = State::get().await?;
crate::state::login_begin(&state.pool).await
}
#[tracing::instrument]
pub async fn begin_browser_login() -> crate::Result<MinecraftLoginFlow> {
let state = State::get().await?;
crate::state::browser_login_begin(&state.pool).await
}
#[tracing::instrument]
pub async fn begin_device_login() -> crate::Result<MinecraftDeviceLoginFlow> {
crate::state::device_login_begin().await
}
#[tracing::instrument]
pub async fn poll_device_login(
device_code: &str,
) -> crate::Result<MinecraftDeviceLoginPoll> {
let state = State::get().await?;
crate::state::device_login_poll(device_code, &state.pool).await
}
#[tracing::instrument]
pub async fn finish_login(
code: &str,
state: &str,
flow: MinecraftLoginFlow,
) -> crate::Result<Credentials> {
let app_state = State::get().await?;
crate::state::login_finish(code, state, flow, &app_state.pool).await
}
#[tracing::instrument]
pub async fn add_offline_user(
username: &str,
uuid: Option<Uuid>,
) -> crate::Result<Credentials> {
let state = State::get().await?;
let credentials = match uuid {
Some(uuid) => Credentials::offline_with_uuid(username, uuid)?,
None => Credentials::offline(username)?,
};
if uuid.is_some() {
let users = Credentials::get_all_without_refresh(&state.pool).await?;
if users.contains_key(&credentials.offline_profile.id) {
return Err(crate::ErrorKind::InputError(
"An account with this UUID already exists".to_string(),
)
.as_error());
}
}
credentials.upsert(&state.pool).await?;
Ok(credentials)
}
#[tracing::instrument(skip(password))]
pub async fn begin_yggdrasil_login(
api_root: &str,
login: &str,
password: &str,
) -> crate::Result<YggdrasilLoginResult> {
let state = State::get().await?;
crate::state::begin_yggdrasil_login(api_root, login, password, &state.pool)
.await
}
#[tracing::instrument]
pub async fn finish_yggdrasil_login(
flow_id: uuid::Uuid,
profile_id: uuid::Uuid,
) -> crate::Result<Credentials> {
let state = State::get().await?;
crate::state::finish_yggdrasil_login(flow_id, profile_id, &state.pool).await
}
pub fn normalize_yggdrasil_api_root(api_root: &str) -> crate::Result<String> {
crate::state::normalize_api_root(api_root)
}
#[tracing::instrument]
pub async fn get_default_user(
offline_mode: bool,
) -> crate::Result<Option<uuid::Uuid>> {
let state = State::get().await?;
let user = if offline_mode {
Credentials::get_offline_credential(&state.pool).await?
} else {
Credentials::get_active(&state.pool).await?
};
Ok(user.map(|user| user.offline_profile.id))
}
#[tracing::instrument]
pub async fn set_default_user(user: uuid::Uuid) -> crate::Result<()> {
let state = State::get().await?;
let users = Credentials::get_all_without_refresh(&state.pool).await?;
let (_, mut user) = users.remove(&user).ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to get nonexistent user with ID {user}"
))
.as_error()
})?;
user.active = true;
user.upsert(&state.pool).await?;
Ok(())
}
/// Remove a user account from the database
#[tracing::instrument]
pub async fn remove_user(uuid: uuid::Uuid) -> crate::Result<()> {
let state = State::get().await?;
let users = Credentials::get_all_without_refresh(&state.pool).await?;
if let Some((uuid, user)) = users.remove(&uuid) {
Credentials::remove(uuid, &state.pool).await?;
if user.active
&& let Some((_, mut user)) = users.into_iter().next()
{
user.active = true;
user.upsert(&state.pool).await?;
}
}
Ok(())
}
#[derive(Serialize)]
pub struct MinecraftUser {
pub profile: MinecraftProfile,
pub account_type: MinecraftAccountType,
pub access_token: String,
pub refresh_token: String,
pub expires: DateTime<Utc>,
pub active: bool,
pub yggdrasil: Option<YggdrasilAccount>,
}
impl MinecraftUser {
async fn from_credentials(credentials: Credentials) -> Self {
let profile = (*credentials.maybe_online_profile().await).clone();
Self {
profile,
account_type: credentials.account_type,
access_token: credentials.access_token,
refresh_token: credentials.refresh_token,
expires: credentials.expires,
active: credentials.active,
yggdrasil: credentials.yggdrasil,
}
}
}
/// Get a copy of the list of all user credentials with profile data ready for
/// serialization.
#[tracing::instrument]
pub async fn users(offline_mode: bool) -> crate::Result<Vec<MinecraftUser>> {
let state = State::get().await?;
let users = if offline_mode {
Credentials::get_all_without_refresh(&state.pool).await?
} else {
Credentials::get_all(&state.pool).await?
};
let credentials = users
.into_iter()
.map(|x| x.1)
.filter(|credentials| !offline_mode || credentials.is_offline());
let mut hydrated_users = Vec::new();
for credentials in credentials {
hydrated_users.push(MinecraftUser::from_credentials(credentials).await);
}
Ok(hydrated_users)
}

View File

@ -0,0 +1,121 @@
//! Minecraft official news from the Minecraft website search API.
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::State;
use crate::util::fetch::fetch_json;
const MINECRAFT_NEWS_SEARCH_URL: &str = "https://net-secondary.web.minecraft-services.net/api/v1.0/en-us/search?pageSize=24&sortType=Recent&category=News&newsOnly=true&geography=USA";
const MINECRAFT_ARTICLE_BASE: &str = "https://www.minecraft.net/en-us/article/";
#[derive(Deserialize)]
struct NewsSearchResponse {
result: NewsSearchResult,
}
#[derive(Deserialize)]
struct NewsSearchResult {
results: Vec<NewsEntry>,
}
#[derive(Deserialize)]
struct NewsEntry {
title: String,
#[serde(default)]
url: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(default)]
time: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MinecraftNewsItem {
pub title: String,
pub category: Option<String>,
pub tag: Option<String>,
pub date: Option<String>,
pub image_url: Option<String>,
pub read_more_url: String,
}
pub async fn get_minecraft_news(
limit: usize,
) -> crate::Result<Vec<MinecraftNewsItem>> {
let state = State::get().await?;
let news = fetch_json::<NewsSearchResponse>(
Method::GET,
MINECRAFT_NEWS_SEARCH_URL,
None,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await?;
let mut items: Vec<MinecraftNewsItem> = news
.result
.results
.into_iter()
.filter_map(|entry| {
let read_more_url = article_url(entry.url.as_deref()?)?;
Some(MinecraftNewsItem {
title: entry.title,
category: None,
tag: None,
date: entry
.time
.and_then(|timestamp| {
chrono::DateTime::from_timestamp(timestamp, 0)
})
.map(|timestamp| timestamp.to_rfc3339()),
image_url: entry.image,
read_more_url,
})
})
.collect();
items.sort_by(|a, b| b.date.cmp(&a.date));
items.truncate(limit);
Ok(items)
}
fn article_url(url: &str) -> Option<String> {
let parsed = url::Url::parse(url.trim()).ok()?;
if parsed.scheme() != "https"
|| parsed.host_str() != Some("www.minecraft.net")
{
return None;
}
let article_id = parsed
.path_segments()?
.filter(|segment| !segment.is_empty())
.last()?;
url::Url::parse(MINECRAFT_ARTICLE_BASE)
.ok()?
.join(article_id)
.ok()
.map(|url| url.to_string())
}
#[cfg(test)]
mod tests {
use super::article_url;
#[test]
fn derives_article_urls_from_search_results() {
assert_eq!(
article_url("https://www.minecraft.net/en-us/article/example"),
Some("https://www.minecraft.net/en-us/article/example".to_string())
);
assert_eq!(
article_url("https://www.minecraft.net/fr-fr/article/example"),
Some("https://www.minecraft.net/en-us/article/example".to_string())
);
assert_eq!(article_url("https://example.com/article/example"), None);
assert_eq!(article_url("javascript:alert(1)"), None);
assert_eq!(article_url("https://"), None);
assert_eq!(article_url(" "), None);
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,298 @@
use std::{
io::{Cursor, Write},
path::Path,
};
use serde_json::json;
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
use crate::{
ErrorKind, State,
state::minecraft_skins::{CustomMinecraftSkin, OfflineMinecraftSkin},
};
use super::{Credentials, png_util};
pub(crate) const OFFLINE_SKIN_PACK_FILE_NAME: &str = "Axolotl Offline Skin.zip";
pub(crate) const OFFLINE_SKIN_PACK_LEGACY_ID: &str = "Axolotl Offline Skin.zip";
pub(crate) const OFFLINE_SKIN_PACK_MODERN_ID: &str =
"file/Axolotl Offline Skin.zip";
#[derive(Debug, Clone, Copy)]
pub(crate) struct OfflineSkinPackOptions {
pub enabled_pack_id: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MinecraftReleaseVersion {
major: u32,
minor: u32,
patch: u32,
}
impl MinecraftReleaseVersion {
fn parse(version: &str) -> Option<Self> {
let mut parts = version.split('.');
let major = leading_number(parts.next()?)?;
let minor = leading_number(parts.next()?)?;
let patch = parts.next().and_then(leading_number).unwrap_or(0);
Some(Self {
major,
minor,
patch,
})
}
fn supports_resource_packs(self) -> bool {
self.major > 1 || self.minor >= 6
}
fn uses_modern_pack_id(self) -> bool {
self.major > 1 || self.minor >= 13
}
fn uses_modern_player_texture_paths(self) -> bool {
self.major > 1
|| self.minor > 19
|| (self.minor == 19 && self.patch >= 3)
}
fn needs_legacy_skin_height(self) -> bool {
self.major == 1 && matches!(self.minor, 6 | 7)
}
fn resource_pack_format(self) -> u32 {
if self.major > 1 {
return 75;
}
match self.minor {
0..=8 => 1,
9..=10 => 2,
11..=12 => 3,
13..=14 => 4,
15 => 5,
16 if self.patch <= 1 => 5,
16 => 6,
17 => 7,
18 => 8,
19 if self.patch <= 2 => 9,
19 if self.patch == 3 => 12,
19 => 13,
20 if self.patch <= 1 => 15,
20 if self.patch == 2 => 18,
20 if self.patch <= 4 => 22,
20 => 32,
21 if self.patch <= 1 => 34,
21 if self.patch <= 3 => 42,
21 if self.patch == 4 => 46,
21 if self.patch == 5 => 55,
21 if self.patch == 6 => 63,
21 if self.patch <= 8 => 64,
21 if self.patch <= 10 => 69,
21 => 75,
_ => 75,
}
}
}
fn leading_number(value: &str) -> Option<u32> {
let digits = value
.chars()
.take_while(|character| character.is_ascii_digit())
.collect::<String>();
(!digits.is_empty()).then(|| digits.parse().ok()).flatten()
}
/// Builds (or removes) the resource pack that replaces vanilla's local default
/// player texture with the selected offline skin.
pub(crate) async fn prepare_offline_skin_resource_pack(
credentials: &Credentials,
instance_path: &Path,
game_version: &str,
) -> crate::Result<OfflineSkinPackOptions> {
let resource_pack_dir = instance_path.join("resourcepacks");
let resource_pack_path =
resource_pack_dir.join(OFFLINE_SKIN_PACK_FILE_NAME);
let Some(version) = MinecraftReleaseVersion::parse(game_version) else {
remove_pack_if_present(&resource_pack_path).await?;
return Ok(OfflineSkinPackOptions {
enabled_pack_id: None,
});
};
if !credentials.is_offline() || !version.supports_resource_packs() {
remove_pack_if_present(&resource_pack_path).await?;
return Ok(OfflineSkinPackOptions {
enabled_pack_id: None,
});
}
let state = State::get().await?;
let Some(offline_skin) =
OfflineMinecraftSkin::get(credentials.offline_profile.id, &state.pool)
.await?
else {
remove_pack_if_present(&resource_pack_path).await?;
return Ok(OfflineSkinPackOptions {
enabled_pack_id: None,
});
};
let Some(saved_skin) = CustomMinecraftSkin::get_by_texture(
credentials.offline_profile.id,
&offline_skin.texture_key,
&state.pool,
)
.await?
else {
OfflineMinecraftSkin::clear(
credentials.offline_profile.id,
&state.pool,
)
.await?;
remove_pack_if_present(&resource_pack_path).await?;
return Ok(OfflineSkinPackOptions {
enabled_pack_id: None,
});
};
let mut texture = saved_skin.texture_blob(&state.pool).await?;
if version.needs_legacy_skin_height()
&& png_util::dimensions(&texture)?.1 == 64
{
texture = png_util::to_legacy_client_texture(&texture)?.to_vec();
}
let zip_data = build_resource_pack(&texture, version)?;
tokio::fs::create_dir_all(&resource_pack_dir).await?;
tokio::fs::write(&resource_pack_path, zip_data).await?;
Ok(OfflineSkinPackOptions {
enabled_pack_id: Some(if version.uses_modern_pack_id() {
OFFLINE_SKIN_PACK_MODERN_ID
} else {
OFFLINE_SKIN_PACK_LEGACY_ID
}),
})
}
async fn remove_pack_if_present(path: &Path) -> crate::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn build_resource_pack(
texture: &[u8],
version: MinecraftReleaseVersion,
) -> crate::Result<Vec<u8>> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated);
let pack_format = version.resource_pack_format();
let metadata = json!({
"pack": {
"pack_format": pack_format,
"description": "Axolotl Launcher offline skin"
}
});
start_zip_file(&mut zip, "pack.mcmeta", options)?;
zip.write_all(&serde_json::to_vec(&metadata)?)?;
if version.uses_modern_player_texture_paths() {
for model in ["slim", "wide"] {
for skin_name in [
"alex", "ari", "efe", "kai", "makena", "noor", "steve",
"sunny", "zuri",
] {
let path = format!(
"assets/minecraft/textures/entity/player/{model}/{skin_name}.png"
);
start_zip_file(&mut zip, &path, options)?;
zip.write_all(texture)?;
}
}
} else {
// Replace both models. This keeps the standard offline UUID stable, so
// changing a skin cannot split single-player saves into a new player.
for path in [
"assets/minecraft/textures/entity/steve.png",
"assets/minecraft/textures/entity/alex.png",
] {
start_zip_file(&mut zip, path, options)?;
zip.write_all(texture)?;
}
}
zip.finish().map(Cursor::into_inner).map_err(zip_error)
}
fn start_zip_file(
zip: &mut ZipWriter<Cursor<Vec<u8>>>,
path: &str,
options: SimpleFileOptions,
) -> crate::Result<()> {
zip.start_file(path, options).map_err(zip_error)
}
fn zip_error(error: zip::result::ZipError) -> crate::Error {
ErrorKind::OtherError(format!(
"Failed to build offline skin resource pack: {error}"
))
.as_error()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_release_versions_and_selects_pack_formats() {
let version = MinecraftReleaseVersion::parse("1.19.2").unwrap();
assert_eq!(version.resource_pack_format(), 9);
assert!(!version.uses_modern_player_texture_paths());
let version = MinecraftReleaseVersion::parse("1.19.3").unwrap();
assert_eq!(version.resource_pack_format(), 12);
assert!(version.uses_modern_player_texture_paths());
let version = MinecraftReleaseVersion::parse("1.21.8").unwrap();
assert_eq!(version.resource_pack_format(), 64);
assert!(version.uses_modern_pack_id());
}
#[test]
fn rejects_snapshot_versions_without_a_release_shape() {
assert_eq!(MinecraftReleaseVersion::parse("25w31a"), None);
}
#[test]
fn generated_pack_replaces_all_modern_default_player_textures() {
let texture = include_bytes!("assets/default/MissingNo.png");
let version = MinecraftReleaseVersion::parse("1.19.3").unwrap();
let pack = build_resource_pack(texture, version).unwrap();
let mut archive = zip::ZipArchive::new(Cursor::new(pack)).unwrap();
assert!(archive.by_name("pack.mcmeta").is_ok());
assert!(
archive
.by_name(
"assets/minecraft/textures/entity/player/slim/alex.png"
)
.is_ok()
);
assert!(
archive
.by_name(
"assets/minecraft/textures/entity/player/wide/zuri.png"
)
.is_ok()
);
assert_eq!(archive.len(), 19);
}
}

View File

@ -0,0 +1,482 @@
//! Miscellaneous PNG utilities for Minecraft skins.
use std::io::{BufRead, Cursor, Seek};
use std::sync::Arc;
use base64::Engine;
use bytes::Bytes;
use data_url::DataUrl;
use futures::{Stream, TryStreamExt, future::Either, stream};
use itertools::Itertools;
use rgb::Rgba;
use tokio::io::AsyncReadExt;
use tokio_util::compat::FuturesAsyncReadCompatExt;
use url::Url;
use crate::{
ErrorKind, minecraft_skins::UrlOrBlob, util::fetch::INSECURE_REQWEST_CLIENT,
};
pub async fn url_to_data_stream(
url: &Url,
) -> crate::Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>> {
if url.scheme() == "data" {
let data = DataUrl::process(url.as_str())?.decode_to_vec()?.0.into();
Ok(Either::Left(stream::once(async { Ok(data) })))
} else {
let response = INSECURE_REQWEST_CLIENT
.get(url.as_str())
.header("Accept", "image/png")
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.and_then(|response| response.error_for_status())?;
Ok(Either::Right(response.bytes_stream()))
}
}
pub fn blob_to_data_url(png_data: impl AsRef<[u8]>) -> Option<Arc<Url>> {
let png_data = png_data.as_ref();
is_png(png_data).then(|| {
Url::parse(&format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(png_data)
))
.unwrap()
.into()
})
}
pub fn is_png(png_data: &[u8]) -> bool {
/// The initial 8 bytes of a PNG file, used to identify it as such.
///
/// Reference: <https://www.w3.org/TR/png-3/#3PNGsignature>
const PNG_SIGNATURE: &[u8] =
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
png_data.starts_with(PNG_SIGNATURE)
}
pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> {
if !is_png(png_data) {
Err(ErrorKind::InvalidPng)?;
}
// Read the width and height fields from the IHDR chunk, which the
// PNG specification mandates to be the first in the file, just after
// the 8 signature bytes. See:
// https://www.w3.org/TR/png-3/#5DataRep
// https://www.w3.org/TR/png-3/#11IHDR
let width = u32::from_be_bytes(
png_data
.get(16..20)
.ok_or(ErrorKind::InvalidPng)?
.try_into()
.unwrap(),
);
let height = u32::from_be_bytes(
png_data
.get(20..24)
.ok_or(ErrorKind::InvalidPng)?
.try_into()
.unwrap(),
);
Ok((width, height))
}
/// Crops a modern 64x64 skin to the 64x32 layout understood by Minecraft
/// 1.6 and 1.7 clients.
pub(super) fn to_legacy_client_texture(
png_data: &[u8],
) -> crate::Result<Bytes> {
let mut decoder = png::Decoder::new(Cursor::new(png_data));
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut png_reader = decoder.read_info()?;
if png_reader.info().width != 64 || png_reader.info().height != 64 {
Err(ErrorKind::InvalidSkinTexture)?;
}
let texture_buf = get_skin_texture_buffer(&mut png_reader, false)?;
let legacy_texture =
texture_buf.get(..64 * 32).ok_or(ErrorKind::InvalidPng)?;
let mut encoded_png = Vec::new();
let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 32);
png_encoder.set_color(png::ColorType::Rgba);
png_encoder.set_depth(png::BitDepth::Eight);
png_encoder.set_filter(png::Filter::NoFilter);
png_encoder.set_compression(png::Compression::Fast);
let png_buf = bytemuck::try_cast_slice(legacy_texture)
.map_err(|_| ErrorKind::InvalidPng)?;
let mut png_writer = png_encoder.write_header()?;
png_writer.write_image_data(png_buf)?;
png_writer.finish()?;
Ok(encoded_png.into())
}
/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling legacy 64x32
/// skins, doing "Notch transparency hack" and making inner parts opaque as the vanilla game client
/// does. This function prioritizes PNG encoding speed over compression density, so the resulting
/// textures are better suited for display purposes, not persistent storage or transmission.
///
/// The normalized, processed is returned texture as a byte array in PNG format.
pub async fn normalize_skin_texture(
texture: &UrlOrBlob,
) -> crate::Result<Bytes> {
let mut texture_data = Vec::with_capacity(8192);
Box::pin(
match texture {
UrlOrBlob::Url(url) => Either::Left(
url_to_data_stream(url)
.await?
.map_err(std::io::Error::other)
.into_async_read(),
),
UrlOrBlob::Blob(blob) => Either::Right(
stream::once({
let blob = Bytes::clone(blob);
async { Ok(blob) }
})
.into_async_read(),
),
}
.compat(),
)
.read_to_end(&mut texture_data)
.await?;
let mut png_reader = {
let mut decoder = png::Decoder::new(Cursor::new(texture_data));
decoder
.set_transformations(png::Transformations::normalize_to_color8());
decoder.read_info()
}?;
// The code below assumes that the skin texture has valid dimensions.
// This also serves as a way to bail out early for obviously invalid or
// adversarial textures
if png_reader.info().width != 64
|| ![64, 32].contains(&png_reader.info().height)
{
Err(ErrorKind::InvalidSkinTexture)?;
}
let is_legacy_skin = png_reader.info().height == 32;
let mut texture_buf =
get_skin_texture_buffer(&mut png_reader, is_legacy_skin)?;
if is_legacy_skin {
convert_legacy_skin_texture(&mut texture_buf, png_reader.info());
do_notch_transparency_hack(&mut texture_buf, png_reader.info());
}
make_inner_parts_opaque(&mut texture_buf, png_reader.info());
let mut encoded_png = vec![];
let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64);
png_encoder.set_color(png::ColorType::Rgba);
png_encoder.set_depth(png::BitDepth::Eight);
png_encoder.set_filter(png::Filter::NoFilter);
png_encoder.set_compression(png::Compression::Fast);
// Keeping color space information properly set, to handle the occasional
// strange PNG with non-sRGB chromaticities and/or different grayscale spaces
// that keeps most people wondering, is what sets a carefully crafted image
// manipulation routine apart :)
if let Some(source_chromaticities) =
png_reader.info().source_chromaticities.as_ref().copied()
{
png_encoder.set_source_chromaticities(source_chromaticities);
}
if let Some(source_gamma) = png_reader.info().source_gamma.as_ref().copied()
{
png_encoder.set_source_gamma(source_gamma);
}
if let Some(source_srgb) = png_reader.info().srgb.as_ref().copied() {
png_encoder.set_source_srgb(source_srgb);
}
let png_buf = bytemuck::try_cast_slice(&texture_buf)
.map_err(|_| ErrorKind::InvalidPng)?;
let mut png_writer = png_encoder.write_header()?;
png_writer.write_image_data(png_buf)?;
png_writer.finish()?;
Ok(encoded_png.into())
}
/// Reads a skin texture and returns a 64x64 buffer in RGBA format.
fn get_skin_texture_buffer<R: BufRead + Seek>(
png_reader: &mut png::Reader<R>,
is_legacy_skin: bool,
) -> crate::Result<Vec<Rgba<u8>>> {
let output_buffer_size = png_reader
.output_buffer_size()
.expect("Reasonable skin texture size verified already");
let mut png_buf = if is_legacy_skin {
// Legacy skins have half the height, so duplicate the rows to
// turn them into a 64x64 texture
vec![0; output_buffer_size * 2]
} else {
// Modern skins are left as-is
vec![0; output_buffer_size]
};
png_reader.next_frame(&mut png_buf)?;
let mut texture_buf = match png_reader.output_color_type().0 {
png::ColorType::Grayscale => png_buf
.iter()
.map(|&value| Rgba {
r: value,
g: value,
b: value,
a: 255,
})
.collect_vec(),
png::ColorType::GrayscaleAlpha => png_buf
.chunks_exact(2)
.map(|chunk| Rgba {
r: chunk[0],
g: chunk[0],
b: chunk[0],
a: chunk[1],
})
.collect_vec(),
png::ColorType::Rgb => png_buf
.chunks_exact(3)
.map(|chunk| Rgba {
r: chunk[0],
g: chunk[1],
b: chunk[2],
a: 255,
})
.collect_vec(),
png::ColorType::Rgba => bytemuck::try_cast_vec(png_buf)
.map_err(|_| ErrorKind::InvalidPng)?,
_ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations
};
// Make the added bottom half of the expanded legacy skin buffer transparent
if is_legacy_skin {
set_alpha(&mut texture_buf, png_reader.info(), 0, 32, 64, 64, 0);
}
Ok(texture_buf)
}
/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the
/// native 64x64 format used by modern Minecraft clients.
///
/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method.
#[inline]
fn convert_legacy_skin_texture(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
) {
/// The skin faces the game client copies around, in order, when converting a
/// legacy skin to the native 64x64 format.
const FACE_COPY_PARAMETERS: &[(
usize,
usize,
isize,
isize,
usize,
usize,
)] = &[
(4, 16, 16, 32, 4, 4),
(8, 16, 16, 32, 4, 4),
(0, 20, 24, 32, 4, 12),
(4, 20, 16, 32, 4, 12),
(8, 20, 8, 32, 4, 12),
(12, 20, 16, 32, 4, 12),
(44, 16, -8, 32, 4, 4),
(48, 16, -8, 32, 4, 4),
(40, 20, 0, 32, 4, 12),
(44, 20, -8, 32, 4, 12),
(48, 20, -16, 32, 4, 12),
(52, 20, -8, 32, 4, 12),
];
for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS {
copy_rect_mirror_horizontally(
texture_buf,
texture_info,
*x,
*y,
*off_x,
*off_y,
*width,
*height,
)
}
}
/// Makes outer head layer transparent if every pixel has alpha greater or equal to 128.
///
/// See also 25w16a's `SkinTextureDownloader#doNotchTransparencyHack` method.
fn do_notch_transparency_hack(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
) {
// The skin part the game client makes transparent
let (x1, y1, x2, y2) = (32, 0, 64, 32);
for y in y1..y2 {
for x in x1..x2 {
if texture_buf[x + y * texture_info.width as usize].a < 128 {
return;
}
}
}
set_alpha(texture_buf, texture_info, x1, y1, x2, y2, 0);
}
/// Makes inner parts of a skin texture opaque.
///
/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method.
#[inline]
fn make_inner_parts_opaque(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
) {
/// The skin parts the game client makes opaque.
const OPAQUE_PART_PARAMETERS: &[(usize, usize, usize, usize)] =
&[(0, 0, 32, 16), (0, 16, 64, 32), (16, 48, 48, 64)];
for (x1, y1, x2, y2) in OPAQUE_PART_PARAMETERS {
set_alpha(texture_buf, texture_info, *x1, *y1, *x2, *y2, 255);
}
}
/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf`
/// whose top-left corner is at coordinates `(x, y)` to a destination rectangle whose top-left
/// corner is at coordinates `(x + off_x, y + off_y)`, while mirroring (i.e., flipping) the
/// pixels horizontally.
///
/// Equivalent to Mojang's Blaze3D `NativeImage#copyRect(int, int, int, int, int, int,
/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`,
/// respectively.
#[allow(clippy::too_many_arguments)]
fn copy_rect_mirror_horizontally(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
x: usize,
y: usize,
off_x: isize,
off_y: isize,
width: usize,
height: usize,
) {
for row in 0..height {
for col in 0..width {
let src_x = x + col;
let src_y = y + row;
let dst_x = (x as isize + off_x) as usize + (width - 1 - col);
let dst_y = (y as isize + off_y) as usize + row;
texture_buf[dst_x + dst_y * texture_info.width as usize] =
texture_buf[src_x + src_y * texture_info.width as usize];
}
}
}
/// Sets alpha for every pixel of a rectangle within `texture_buf`
/// whose top-left corner is at `(x1, y1)` and bottom-right corner is at `(x2 - 1, y2 - 1)`.
fn set_alpha(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
x1: usize,
y1: usize,
x2: usize,
y2: usize,
alpha: u8,
) {
for y in y1..y2 {
for x in x1..x2 {
texture_buf[x + y * texture_info.width as usize].a = alpha;
}
}
}
#[cfg(test)]
#[tokio::test]
async fn normalize_skin_texture_works() {
let decode_to_pixels = |png_data: &[u8]| {
let decoder = png::Decoder::new(Cursor::new(png_data));
let mut reader = decoder.read_info().expect("Failed to read PNG info");
let mut buffer =
vec![0; reader.output_buffer_size().expect("Skin size too large")];
reader
.next_frame(&mut buffer)
.expect("Failed to decode PNG");
(buffer, reader.info().clone())
};
let test_data = [
(
"legacy",
&include_bytes!("assets/test/legacy.png")[..],
&include_bytes!("assets/test/legacy_normalized.png")[..],
),
(
"notch",
&include_bytes!("assets/test/notch.png")[..],
&include_bytes!("assets/test/notch_normalized.png")[..],
),
(
"transparent",
&include_bytes!("assets/test/transparent.png")[..],
&include_bytes!("assets/test/transparent_normalized.png")[..],
),
];
for (skin_name, original_png_data, expected_normalized_png_data) in
test_data
{
let normalized_png_data =
normalize_skin_texture(&UrlOrBlob::Blob(original_png_data.into()))
.await
.expect("Failed to normalize skin texture");
let (normalized_pixels, normalized_info) =
decode_to_pixels(&normalized_png_data);
let (expected_pixels, expected_info) =
decode_to_pixels(expected_normalized_png_data);
// Check that dimensions match
assert_eq!(
normalized_info.width, expected_info.width,
"Widths don't match for {skin_name}"
);
assert_eq!(
normalized_info.height, expected_info.height,
"Heights don't match for {skin_name}"
);
assert_eq!(
normalized_info.color_type, expected_info.color_type,
"Color types don't match for {skin_name}"
);
// Check that pixel data matches
assert_eq!(
normalized_pixels, expected_pixels,
"Pixel data doesn't match for {skin_name}"
);
}
}
#[cfg(test)]
#[test]
fn creates_legacy_client_skin_texture() {
let texture = include_bytes!("assets/test/notch_normalized.png");
let legacy_texture =
to_legacy_client_texture(texture).expect("Failed to crop skin");
assert_eq!(dimensions(&legacy_texture).unwrap(), (64, 32));
}

View File

@ -0,0 +1,96 @@
//! API for interacting with Theseus
pub mod ai;
pub mod cache;
pub mod content_favorites;
pub mod content_search;
pub mod curseforge;
pub mod drop_classifier;
pub mod friends;
pub mod google_ip;
pub mod handler;
pub mod hongshi;
pub mod instance;
pub mod jre;
pub(crate) mod loader_metadata;
pub mod logs;
pub mod mcarchive;
pub mod memory;
pub mod metadata;
pub mod minecraft_auth;
pub mod minecraft_news;
pub mod minecraft_skins;
pub mod mr_auth;
pub mod multiplayer;
pub mod pack;
pub mod planet_minecraft;
pub mod process;
pub mod server_address;
pub mod servers;
pub mod settings;
pub mod symlink;
pub mod tags;
pub mod terracotta;
pub mod translation;
pub mod worlds;
pub mod data {
pub use crate::instance::McArchiveCoreInstallResult;
pub use crate::launcher::ExternalGameDirMode;
pub use crate::state::{
AppliedContentSetPatch, CacheBehaviour, CacheValueType, CachedEntry,
ContentFavorite, ContentFavoriteInput, ContentFavoriteProvider,
ContentFavoriteType, ContentFile, ContentItem, ContentItemCapabilities,
ContentItemOwner, ContentItemProject, ContentItemVersion,
ContentOwnershipKind, ContentProvider, ContentProviderRef,
ContentUpdatePlan, ContentUpdatePlanAction, ContentUpdateResolution,
ContentUpdateResolutionChoice, ContentUpdateScope, CoreComponent,
CoreComponentKind, CoreComponentSource, CoreJarPreview,
CreateDirectLinkInstance, CreateInstance, Credentials, Dependency,
DirectLinkSyncReport, DirectoryInfo, EditInstance,
ExternalMinecraftRoot, Hooks, InstanceContentPack,
InstanceContentSnapshot, InstanceContentSnapshotItem,
InstanceContentWarning, InstanceInstallCandidate,
InstanceInstallTarget, InstanceLaunchOverridesPatch, InstanceLink,
InstanceMetadata, InstancePostUpgradeNotice,
InstancePostUpgradeWarning, InstanceUpgradeAction,
InstanceUpgradeDependencyChange, InstanceUpgradeDependencyChangeKind,
InstanceUpgradeEnvironment, InstanceUpgradeFixedConstraint,
InstanceUpgradeIssue, InstanceUpgradeIssueCode, InstanceUpgradeItem,
InstanceUpgradeItemStatus, InstanceUpgradePlan,
InstanceUpgradeResolution, InstanceUpgradeResolutionBatchResult,
InstanceUpgradeResolutionResult, InstanceUpgradeSelection,
InstanceUpgradeSolution, InstanceUpgradeSolutionChoice,
InstanceUpgradeSolutionKind, JavaVersion, LinkedModpackInfo,
LoaderComponent, LoaderComponentKind, LoaderComponentRole,
ManualDownloadOperationKind, ManualDownloadState, MemorySettings,
ModLoader, ModrinthCredentials, Organization, OwnerType,
PackMemberMaterializationState, PackMemberOverrideKind,
PendingManualDownload, PrivacySettings, ProcessMetadata, Project,
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
Settings, ShaderRuntime, TeamMember, Theme, User, UserFriend, Version,
WindowSize,
};
pub use ariadne::users::UserStatus;
pub use modrinth_content_management::{
ContentType, ResolutionPreferences, ResolveContentPlan,
ResolveContentRequest,
};
}
pub mod prelude {
pub use crate::{
State, ai,
data::*,
event::CommandPayload,
install, instance,
jre::{self, JdkVersionInfo},
metadata, minecraft_auth, mr_auth, pack, process, server_address,
servers, settings,
state::{ReleaseChannel, db_backup::app_db_backup_dir},
translation,
util::{
io::{IOError, canonicalize},
network::{is_network_metered, tcp_listen_any_loopback},
},
};
}

View File

@ -0,0 +1,45 @@
use crate::state::ModrinthCredentials;
#[tracing::instrument]
pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
}
#[tracing::instrument]
pub async fn authenticate_finish_flow(
code: &str,
) -> crate::Result<ModrinthCredentials> {
let state = crate::State::get().await?;
let creds = crate::state::finish_login_flow(
code,
&state.api_semaphore,
&state.pool,
)
.await?;
creds.upsert(&state.pool).await?;
Ok(creds)
}
#[tracing::instrument]
pub async fn logout() -> crate::Result<()> {
let state = crate::State::get().await?;
let current = ModrinthCredentials::get_active(&state.pool).await?;
if let Some(current) = current {
ModrinthCredentials::remove(&current.user_id, &state.pool).await?;
}
Ok(())
}
#[tracing::instrument]
pub async fn get_credentials() -> crate::Result<Option<ModrinthCredentials>> {
let state = crate::State::get().await?;
let current =
ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
.await?;
Ok(current)
}

View File

@ -0,0 +1,292 @@
use eyre::bail;
use serde::{Deserialize, Serialize};
use std::sync::{
LazyLock,
atomic::{AtomicBool, Ordering},
};
use tokio::sync::Mutex;
use super::{hongshi, terracotta};
static ACTIVE_PROVIDER: LazyLock<Mutex<Option<MultiplayerProvider>>> =
LazyLock::new(|| Mutex::new(None));
static MULTIPLAYER_OPERATION: LazyLock<Mutex<()>> =
LazyLock::new(|| Mutex::new(()));
static SHUTTING_DOWN: AtomicBool = AtomicBool::new(false);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MultiplayerProvider {
Terracotta,
Hongshi,
}
impl std::fmt::Display for MultiplayerProvider {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Terracotta => formatter.write_str("terracotta"),
Self::Hongshi => formatter.write_str("hongshi"),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct MultiplayerProviderCapabilities {
pub provider: MultiplayerProvider,
pub supported: bool,
pub can_host: bool,
pub can_join: bool,
pub requires_local_port: bool,
pub unsupported_reason: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct MultiplayerState {
pub active_provider: Option<MultiplayerProvider>,
pub providers: Vec<MultiplayerProviderCapabilities>,
pub terracotta: terracotta::TerracottaState,
pub hongshi: hongshi::HongshiState,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum MultiplayerHostRequest {
Terracotta {
player_name: String,
room_code: Option<String>,
},
Hongshi {
local_port: u16,
node_name: Option<String>,
instance_id: Option<String>,
},
}
#[derive(Clone, Debug, Deserialize)]
pub struct MultiplayerJoinRequest {
pub provider: MultiplayerProvider,
pub player_name: String,
pub room_code: String,
}
pub async fn claim_provider(provider: MultiplayerProvider) -> eyre::Result<()> {
if SHUTTING_DOWN.load(Ordering::Relaxed) {
bail!("the launcher is shutting down");
}
let mut active = ACTIVE_PROVIDER.lock().await;
if let Some(current) = *active
&& current != provider
{
bail!("{current} multiplayer is already running");
}
*active = Some(provider);
Ok(())
}
pub async fn release_provider(provider: MultiplayerProvider) {
let mut active = ACTIVE_PROVIDER.lock().await;
if *active == Some(provider) {
*active = None;
}
}
pub async fn get_state() -> MultiplayerState {
let active_provider = *ACTIVE_PROVIDER.lock().await;
let terracotta = terracotta::get_state().await;
let hongshi = hongshi::get_state().await;
MultiplayerState {
active_provider,
providers: vec![
MultiplayerProviderCapabilities {
provider: MultiplayerProvider::Terracotta,
supported: terracotta::terracotta_platform_key()
!= "unsupported",
can_host: true,
can_join: true,
requires_local_port: false,
unsupported_reason: None,
},
MultiplayerProviderCapabilities {
provider: MultiplayerProvider::Hongshi,
supported: hongshi.supported,
can_host: true,
can_join: false,
requires_local_port: true,
unsupported_reason: (!hongshi.supported).then(|| {
"RedStone is not supported on this platform".to_string()
}),
},
],
terracotta,
hongshi,
}
}
async fn stop_provider(provider: MultiplayerProvider) -> eyre::Result<()> {
match provider {
MultiplayerProvider::Terracotta => terracotta::stop_terracotta().await,
MultiplayerProvider::Hongshi => hongshi::stop().await,
}
}
pub async fn switch_provider(
provider: MultiplayerProvider,
) -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
let active = *ACTIVE_PROVIDER.lock().await;
if let Some(current) = active
&& current != provider
{
stop_provider(current).await?;
}
Ok(())
}
pub async fn prepare_terracotta() -> eyre::Result<()> {
prepare_terracotta_with_options(None, true).await
}
pub async fn prepare_terracotta_with_options(
binary_path: Option<String>,
auto_download: bool,
) -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
claim_provider(MultiplayerProvider::Terracotta).await?;
if terracotta::get_state().await.http_port.is_some() {
return Ok(());
}
if let Err(error) =
terracotta::start_terracotta(binary_path, auto_download).await
{
release_provider(MultiplayerProvider::Terracotta).await;
return Err(error);
}
Ok(())
}
pub async fn stop_terracotta_compat() -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
terracotta::stop_terracotta().await
}
pub async fn reset_terracotta_compat() -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
terracotta::reset_state().await
}
pub async fn host(request: MultiplayerHostRequest) -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
match request {
MultiplayerHostRequest::Terracotta {
player_name,
room_code,
} => {
let already_running =
terracotta::get_state().await.http_port.is_some();
claim_provider(MultiplayerProvider::Terracotta).await?;
let result = async {
if !already_running {
terracotta::start_terracotta(None, true).await?;
}
terracotta::start_hosting(room_code, player_name).await
}
.await;
if result.is_err() && !already_running {
let _ = terracotta::stop_terracotta().await;
}
result
}
MultiplayerHostRequest::Hongshi {
local_port,
node_name,
instance_id,
} => hongshi::start(local_port, node_name, instance_id).await,
}
}
pub async fn join(request: MultiplayerJoinRequest) -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
if request.provider != MultiplayerProvider::Terracotta {
bail!(
"RedStone guests connect directly with the public server address"
);
}
let already_running = terracotta::get_state().await.http_port.is_some();
claim_provider(MultiplayerProvider::Terracotta).await?;
let result = async {
if !already_running {
terracotta::start_terracotta(None, true).await?;
}
terracotta::start_joining(request.room_code, request.player_name).await
}
.await;
if result.is_err() && !already_running {
let _ = terracotta::stop_terracotta().await;
}
result
}
pub async fn stop() -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
let provider = { *ACTIVE_PROVIDER.lock().await };
if let Some(provider) = provider {
stop_provider(provider).await?;
}
Ok(())
}
pub async fn reset() -> eyre::Result<()> {
let _operation = MULTIPLAYER_OPERATION.lock().await;
let provider = { *ACTIVE_PROVIDER.lock().await };
match provider {
Some(MultiplayerProvider::Terracotta) => {
terracotta::reset_state().await
}
Some(MultiplayerProvider::Hongshi) => hongshi::stop().await,
None => Ok(()),
}
}
pub async fn observe_minecraft_log(
instance_id: &str,
instance_name: &str,
process_id: &str,
message: &str,
) {
hongshi::observe_minecraft_log(
instance_id,
instance_name,
process_id,
message,
)
.await;
}
pub async fn minecraft_process_finished(instance_id: &str) {
hongshi::minecraft_process_finished(instance_id).await;
}
pub async fn shutdown() -> eyre::Result<()> {
SHUTTING_DOWN.store(true, Ordering::Relaxed);
stop().await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn active_provider_snapshot_does_not_hold_the_mutex() {
*ACTIVE_PROVIDER.lock().await = Some(MultiplayerProvider::Hongshi);
let provider = { *ACTIVE_PROVIDER.lock().await };
let mut active = tokio::time::timeout(
std::time::Duration::from_millis(100),
ACTIVE_PROVIDER.lock(),
)
.await
.expect("provider mutex should be released before stopping");
assert_eq!(provider, Some(MultiplayerProvider::Hongshi));
*active = None;
}
}

View File

@ -0,0 +1,293 @@
//! Shared helpers for extracting content from local modpack archives.
use std::path::{Component, Path, PathBuf};
use super::detect::decode_zip_entry_name;
use crate::util::io;
use tokio_util::sync::CancellationToken;
const EXTRACTION_SIZE_LIMIT: u64 = 8 * 1024 * 1024 * 1024;
fn archive_error(error: zip::result::ZipError) -> crate::Error {
crate::ErrorKind::InputError(format!("Modpack archive is invalid: {error}"))
.into()
}
pub(crate) fn safe_relative_path(value: &str) -> crate::Result<String> {
let path = Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modpack archive contains an invalid file path".to_string(),
)
.into());
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
/// Extracts every file under `prefix` in the archive into `target_dir`,
/// preserving the directory structure below the prefix. Returns the number of
/// files written.
pub(crate) async fn extract_archive_subdir(
archive_path: PathBuf,
prefix: String,
target_dir: PathBuf,
) -> crate::Result<u32> {
tokio::task::spawn_blocking(move || {
extract_archive_subdir_sync(&archive_path, &prefix, &target_dir, None)
})
.await?
}
pub(crate) async fn extract_archive_subdir_for_instance(
instance_id: String,
cancellation: CancellationToken,
archive_path: PathBuf,
prefix: String,
target_dir: PathBuf,
) -> crate::Result<u32> {
run_blocking_instance_write(
instance_id,
cancellation,
move |cancellation| {
extract_archive_subdir_sync(
&archive_path,
&prefix,
&target_dir,
Some(cancellation),
)
},
)
.await
}
pub(crate) async fn run_blocking_instance_write<T, F>(
instance_id: String,
cancellation: CancellationToken,
operation: F,
) -> crate::Result<T>
where
T: Send + 'static,
F: FnOnce(&CancellationToken) -> crate::Result<T> + Send + 'static,
{
let state = crate::State::get().await?;
let instance_lock =
state.lock_instance_content_exclusive(&instance_id).await;
tokio::task::spawn_blocking(move || {
let _instance_lock = instance_lock;
operation(&cancellation)
})
.await?
}
fn extract_archive_subdir_sync(
archive_path: &Path,
prefix: &str,
target_dir: &Path,
cancellation: Option<&CancellationToken>,
) -> crate::Result<u32> {
let file = std::fs::File::open(archive_path)
.map_err(|error| io::IOError::with_path(error, archive_path))?;
let mut archive = zip::ZipArchive::new(file).map_err(archive_error)?;
let mut files_written = 0_u32;
let mut total_size = 0_u64;
for index in 0..archive.len() {
check_cancellation(cancellation)?;
let mut entry = archive.by_index(index).map_err(archive_error)?;
let entry_name = decode_zip_entry_name(entry.name_raw());
if entry.is_dir() || !entry_name.starts_with(prefix) {
continue;
}
let relative = &entry_name[prefix.len()..];
if relative.is_empty() {
continue;
}
total_size = total_size.saturating_add(entry.size());
if total_size > EXTRACTION_SIZE_LIMIT {
return Err(crate::ErrorKind::InputError(
"Modpack archive contents exceed the extraction limit"
.to_string(),
)
.into());
}
let target = target_dir.join(safe_relative_path(relative)?);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io::IOError::with_path(error, parent))?;
}
let mut output = std::fs::File::create(&target)
.map_err(|error| io::IOError::with_path(error, &target))?;
copy_with_cancellation(&mut entry, &mut output, cancellation, &target)?;
files_written = files_written.saturating_add(1);
}
Ok(files_written)
}
pub(crate) fn check_cancellation(
cancellation: Option<&CancellationToken>,
) -> crate::Result<()> {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return Err(crate::ErrorKind::OtherError(
"Install was canceled".to_string(),
)
.into());
}
Ok(())
}
pub(crate) fn copy_with_cancellation<R, W>(
reader: &mut R,
writer: &mut W,
cancellation: Option<&CancellationToken>,
target: &Path,
) -> crate::Result<u64>
where
R: std::io::Read,
W: std::io::Write,
{
let mut buffer = [0_u8; 64 * 1024];
let mut written = 0_u64;
loop {
check_cancellation(cancellation)?;
let count = reader
.read(&mut buffer)
.map_err(|error| io::IOError::with_path(error, target))?;
if count == 0 {
return Ok(written);
}
check_cancellation(cancellation)?;
writer
.write_all(&buffer[..count])
.map_err(|error| io::IOError::with_path(error, target))?;
written = written.saturating_add(count as u64);
}
}
/// Extracts a single archive entry to the given target file path.
pub(crate) async fn extract_archive_entry_to_file(
archive_path: PathBuf,
entry_name: String,
target: PathBuf,
) -> crate::Result<()> {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path)
.map_err(|error| io::IOError::with_path(error, &archive_path))?;
let mut archive = zip::ZipArchive::new(file).map_err(archive_error)?;
let index = (0..archive.len())
.find(|&index| {
archive
.by_index_raw(index)
.map(|entry| {
decode_zip_entry_name(entry.name_raw()) == entry_name
})
.unwrap_or(false)
})
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack archive is missing {entry_name}"
))
})?;
let mut entry = archive.by_index(index).map_err(archive_error)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io::IOError::with_path(error, parent))?;
}
let mut output = std::fs::File::create(&target)
.map_err(|error| io::IOError::with_path(error, &target))?;
std::io::copy(&mut entry, &mut output)
.map_err(|error| io::IOError::with_path(error, &target))?;
Ok(())
})
.await?
}
/// Reads a single archive entry into a string, tolerating GB18030-encoded
/// file contents produced by Chinese packaging tools.
pub(crate) async fn read_archive_entry_to_string(
archive_path: PathBuf,
entry_name: String,
) -> crate::Result<String> {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path)
.map_err(|error| io::IOError::with_path(error, &archive_path))?;
let mut archive = zip::ZipArchive::new(file).map_err(archive_error)?;
let index = super::detect::find_entry_index(&mut archive, &entry_name)?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack archive is missing {entry_name}"
))
})?;
let mut entry = archive.by_index(index).map_err(archive_error)?;
let mut contents = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut contents)?;
Ok(match String::from_utf8(contents) {
Ok(value) => value,
Err(error) => {
let (decoded, _, _) =
encoding_rs::GB18030.decode(error.as_bytes());
decoded.into_owned()
}
})
})
.await?
}
/// Allocates a unique scratch directory for extracting nested pack content.
pub(crate) async fn create_import_scratch_dir(
state: &crate::State,
) -> crate::Result<PathBuf> {
let dir = state
.directories
.caches_dir()
.join("modpack-import")
.join(uuid::Uuid::new_v4().to_string());
io::create_dir_all(&dir).await?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
struct CancelOnRead {
cancellation: CancellationToken,
read: bool,
}
impl std::io::Read for CancelOnRead {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if self.read {
return Ok(0);
}
self.read = true;
self.cancellation.cancel();
let count = buffer.len().min(1024);
buffer[..count].fill(1);
Ok(count)
}
}
#[test]
fn blocking_copy_stops_before_writing_after_cancellation() {
let cancellation = CancellationToken::new();
let mut reader = CancelOnRead {
cancellation: cancellation.clone(),
read: false,
};
let mut output = Vec::new();
let error = copy_with_cancellation(
&mut reader,
&mut output,
Some(&cancellation),
Path::new("override.bin"),
)
.unwrap_err();
assert!(error.to_string().contains("Install was canceled"));
assert!(output.is_empty());
}
}

View File

@ -0,0 +1,464 @@
//! Local modpack file format detection.
//!
//! Detects the modpack format of a local archive by inspecting its contents
//! rather than its file extension, checking both the archive root and a single
//! wrapping folder, mirroring the detection behavior of PCL.
use std::io::Read;
use std::path::Path;
use tracing::debug;
pub const MRPACK_MANIFEST: &str = "modrinth.index.json";
pub const CURSEFORGE_MANIFEST: &str = "manifest.json";
pub const MCBBS_MANIFEST: &str = "mcbbs.packmeta";
pub const HMCL_MANIFEST: &str = "modpack.json";
pub const MMC_MANIFEST: &str = "mmc-pack.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalPackFormat {
Mrpack,
CurseForge,
Mcbbs,
Hmcl,
MmcExport,
LauncherBundled,
PlainArchive,
InstanceFolder,
}
impl LocalPackFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Mrpack => "Modrinth",
Self::CurseForge => "CurseForge",
Self::Mcbbs => "MCBBS",
Self::Hmcl => "HMCL",
Self::MmcExport => "MultiMC",
Self::LauncherBundled => "launcher bundle",
Self::PlainArchive => "game folder archive",
Self::InstanceFolder => "instance folder",
}
}
}
#[derive(Debug, Clone)]
pub struct DetectedLocalPack {
pub format: LocalPackFormat,
/// Prefix of the folder containing the pack's key files, either empty or
/// a single path segment ending in `/`.
pub base_folder: String,
/// For [`LocalPackFormat::LauncherBundled`], the archive entry of the
/// nested modpack file.
pub inner_pack_entry: Option<String>,
/// For [`LocalPackFormat::PlainArchive`], the version id matched under
/// `versions/<id>/<id>.json`.
pub plain_version_id: Option<String>,
}
/// Decodes a zip entry name, tolerating archives produced by Chinese tools
/// that store GB18030-encoded names without the UTF-8 flag.
pub fn decode_zip_entry_name(raw: &[u8]) -> String {
match std::str::from_utf8(raw) {
Ok(name) => name.to_string(),
Err(_) => {
let (decoded, _, _) = encoding_rs::GB18030.decode(raw);
decoded.into_owned()
}
}
}
pub async fn detect_local_pack(
path: &Path,
) -> crate::Result<DetectedLocalPack> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || detect_local_pack_sync(&path)).await?
}
fn open_error(path: &Path, error: impl std::fmt::Display) -> crate::Error {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("rar"))
{
crate::ErrorKind::InputError(
"RAR modpack archives are not supported; please repackage the modpack as a zip file".to_string(),
)
.into()
} else {
crate::ErrorKind::InputError(format!(
"Failed to open modpack archive: {error}"
))
.into()
}
}
pub fn detect_local_pack_sync(path: &Path) -> crate::Result<DetectedLocalPack> {
let file =
std::fs::File::open(path).map_err(|error| open_error(path, error))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|error| open_error(path, error))?;
let total_entries = archive.len();
debug!(
"detect_local_pack_sync: path={} total_zip_entries={}",
path.display(),
total_entries
);
let mut names = Vec::with_capacity(archive.len());
for index in 0..archive.len() {
let entry = archive.by_index_raw(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
if entry.encrypted() {
return Err(crate::ErrorKind::InputError(
"Encrypted modpack archives are not supported".to_string(),
)
.into());
}
let name = decode_zip_entry_name(entry.name_raw());
debug!(
"detect_local_pack_sync: scanning entry[{}] name={}",
index, name
);
names.push(name);
}
let bases = candidate_bases(&names);
debug!(
"detect_local_pack_sync: path={} candidate_bases={:?}",
path.display(),
bases
);
for base in &bases {
debug!(
"detect_local_pack_sync: path={} trying base={:?}",
path.display(),
base
);
if let Some(detected) = detect_at_base(&mut archive, &names, base)? {
debug!(
"detect_local_pack_sync: path={} base={:?} matched format={:?}",
path.display(),
base,
detected.format
);
return Ok(detected);
}
debug!(
"detect_local_pack_sync: path={} base={:?} no format matched",
path.display(),
base
);
}
debug!(
"detect_local_pack_sync: path={} trying detect_plain_archive",
path.display()
);
if let Some(detected) = detect_plain_archive(&names) {
debug!(
"detect_local_pack_sync: path={} matched PlainArchive version_id={:?}",
path.display(),
detected.plain_version_id
);
return Ok(detected);
}
debug!(
"detect_local_pack_sync: path={} trying detect_instance_folder",
path.display()
);
if let Some(detected) = detect_instance_folder(&names) {
debug!(
"detect_local_pack_sync: path={} matched InstanceFolder",
path.display()
);
return Ok(detected);
}
debug!(
"detect_local_pack_sync: path={} no format matched at all",
path.display()
);
Err(crate::ErrorKind::InputError(
"Unrecognized modpack format: no known pack manifest was found in the archive"
.to_string(),
)
.into())
}
/// The archive root, followed by each distinct single wrapping folder.
fn candidate_bases(names: &[String]) -> Vec<String> {
let mut bases = vec![String::new()];
for name in names {
if let Some((first, rest)) = name.split_once('/')
&& !rest.is_empty()
&& !rest.contains('/')
{
let base = format!("{first}/");
if !bases.contains(&base) {
bases.push(base);
}
}
}
bases
}
pub(crate) fn detect_at_base<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
names: &[String],
base: &str,
) -> crate::Result<Option<DetectedLocalPack>> {
let has = |file: &str| -> bool {
let target = format!("{base}{file}");
let found = names.iter().any(|name| name == &target);
debug!(
"detect_at_base: base={:?} has({}) target={} result={}",
base, file, target, found
);
found
};
let detected = |format: LocalPackFormat| DetectedLocalPack {
format,
base_folder: base.to_string(),
inner_pack_entry: None,
plain_version_id: None,
};
// MCBBS and MultiMC packs may also contain a manifest.json, so both must
// be checked before the CurseForge manifest.
if has(MCBBS_MANIFEST) {
debug!(
"detect_at_base: matched MCBBS via mcbbs.packmeta at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::Mcbbs)));
}
if has(MMC_MANIFEST) {
debug!(
"detect_at_base: matched MmcExport via mmc-pack.json at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::MmcExport)));
}
if has(MRPACK_MANIFEST) {
debug!(
"detect_at_base: matched Mrpack via modrinth.index.json at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::Mrpack)));
}
if has(CURSEFORGE_MANIFEST) {
// A manifest.json with an `addons` array is the MCBBS variant. An
// unreadable manifest.json (e.g. an unrelated mod config inside a
// zipped game folder) does not abort detection of other formats.
match read_entry_json(archive, &format!("{base}{CURSEFORGE_MANIFEST}"))
{
Ok(manifest) => {
let has_addons = manifest
.get("addons")
.is_some_and(|value| !value.is_null());
debug!(
"detect_at_base: matched CurseForge/MCBBS via manifest.json at base={:?} addons={}",
base, has_addons
);
return Ok(Some(detected(if has_addons {
LocalPackFormat::Mcbbs
} else {
LocalPackFormat::CurseForge
})));
}
Err(error) => {
tracing::warn!(
"Ignoring unparsable manifest.json at {base:?} during modpack detection: {error}"
);
}
}
}
if has(HMCL_MANIFEST) {
debug!(
"detect_at_base: matched Hmcl via modpack.json at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::Hmcl)));
}
for inner in ["modpack.zip", "modpack.mrpack"] {
if has(inner) {
debug!(
"detect_at_base: matched LauncherBundled via {} at base={:?}",
inner, base
);
return Ok(Some(DetectedLocalPack {
format: LocalPackFormat::LauncherBundled,
base_folder: base.to_string(),
inner_pack_entry: Some(format!("{base}{inner}")),
plain_version_id: None,
}));
}
}
debug!("detect_at_base: no format matched at base={:?}", base);
Ok(None)
}
/// Finds an entry index by its decoded name, so lookups stay consistent with
/// [`decode_zip_entry_name`] even for archives with GB18030-encoded names.
pub(crate) fn find_entry_index<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
entry_name: &str,
) -> crate::Result<Option<usize>> {
for index in 0..archive.len() {
let entry = archive.by_index_raw(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
if decode_zip_entry_name(entry.name_raw()).replace('\\', "/")
== entry_name
{
return Ok(Some(index));
}
}
Ok(None)
}
fn read_entry_json<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
entry_name: &str,
) -> crate::Result<serde_json::Value> {
let index = find_entry_index(archive, entry_name)?.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack archive is missing {entry_name}"
))
})?;
let mut entry = archive.by_index(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read {entry_name} from modpack archive: {error}"
))
})?;
let mut contents = Vec::new();
entry.read_to_end(&mut contents)?;
// Windows tools often prepend a UTF-8 BOM; serde_json rejects it.
let contents = contents
.strip_prefix(&[0xEF, 0xBB, 0xBF])
.unwrap_or(&contents);
Ok(serde_json::from_slice(contents)?)
}
/// Looks for a `versions/<id>/<id>.json` structure marking a zipped-up game
/// folder, returning the prefix of the folder containing `versions`.
fn detect_plain_archive(names: &[String]) -> Option<DetectedLocalPack> {
for name in names {
let segments: Vec<&str> = name.split('/').collect();
if segments.len() < 3 {
continue;
}
let json = segments[segments.len() - 1];
let version = segments[segments.len() - 2];
let marker = segments[segments.len() - 3];
let is_match = marker == "versions"
&& !version.is_empty()
&& json
.strip_suffix(".json")
.is_some_and(|stem| stem == version);
debug!(
"detect_plain_archive: checking entry={} marker={} version={} json={} is_match={}",
name, marker, version, json, is_match
);
if is_match {
let base = segments[..segments.len() - 3].join("/");
let base = if base.is_empty() {
base
} else {
format!("{base}/")
};
debug!(
"detect_plain_archive: matched version={} base={:?}",
version, base
);
return Some(DetectedLocalPack {
format: LocalPackFormat::PlainArchive,
base_folder: base,
inner_pack_entry: None,
plain_version_id: Some(version.to_string()),
});
}
}
debug!("detect_plain_archive: no versions pattern matched");
None
}
/// Looks for a `mods` folder containing `.jar` files, marking a simple instance folder.
fn detect_instance_folder(names: &[String]) -> Option<DetectedLocalPack> {
// First, collect all paths that contain a "mods" segment
let mut mods_folders = std::collections::HashSet::new();
for name in names {
let segments: Vec<&str> = name.split('/').collect();
for i in 0..segments.len() {
if segments[i] == "mods" {
let base = segments[..i].join("/");
let base = if base.is_empty() {
base
} else {
format!("{base}/")
};
debug!(
"detect_instance_folder: found mods folder at entry={} base={:?}",
name, base
);
mods_folders.insert(base);
}
}
}
debug!(
"detect_instance_folder: found {} unique mods folder(s)",
mods_folders.len()
);
if mods_folders.is_empty() {
debug!("detect_instance_folder: no mods folder found");
return None;
}
// Now check each mods folder to see if there's at least one .jar file in it
for base in &mods_folders {
let mut jar_files: Vec<String> = Vec::new();
for name in names {
let name_without_base =
name.strip_prefix(base.as_str()).unwrap_or(name);
let segments: Vec<&str> = name_without_base.split('/').collect();
if segments.len() == 2
&& segments[0] == "mods"
&& segments[1].to_lowercase().ends_with(".jar")
{
jar_files.push(name.clone());
}
}
let has_jar = !jar_files.is_empty();
debug!(
"detect_instance_folder: base={:?} has_jar={} jar_files={:?}",
base, has_jar, jar_files
);
if has_jar {
debug!(
"detect_instance_folder: matched InstanceFolder at base={:?}",
base
);
return Some(DetectedLocalPack {
format: LocalPackFormat::InstanceFolder,
base_folder: base.clone(),
inner_pack_entry: None,
plain_version_id: None,
});
}
}
debug!("detect_instance_folder: no mods folder with .jar files found");
None
}

View File

@ -0,0 +1,277 @@
use std::{collections::HashMap, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
pack::{
self,
import::{self, finish_import},
install_from::CreatePackDescription,
},
prelude::ModLoader,
state::{
AppliedContentSetPatch, EditInstance, InstanceInstallStage,
InstanceLink,
},
util::io,
};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ATInstance {
pub id: String, // minecraft version id ie: 1.12.1, not a name
pub launcher: ATLauncher,
pub java_version: ATJavaVersion,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncher {
pub name: String,
pub pack: String,
pub version: String, // ie: 1.6
pub loader_version: ATLauncherLoaderVersion,
pub modrinth_project: Option<ATLauncherModrinthProject>,
pub modrinth_version: Option<ATLauncherModrinthVersion>,
pub modrinth_manifest: Option<pack::install_from::PackFormat>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ATJavaVersion {
pub major_version: u8,
pub component: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherLoaderVersion {
pub r#type: String,
pub version: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ATLauncherModrinthProject {
pub id: String,
pub slug: String,
pub project_type: String,
pub team: String,
pub client_side: Option<String>,
pub server_side: Option<String>,
pub categories: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ATLauncherModrinthVersion {
pub id: String,
pub project_id: String,
pub name: String,
pub version_number: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherModrinthVersionFile {
pub hashes: HashMap<String, String>,
pub url: String,
pub filename: String,
pub primary: bool,
pub size: u64,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherModrinthVersionDependency {
pub project_id: Option<String>,
pub version_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherMod {
pub name: String,
pub version: String,
pub file: String,
pub modrinth_project: Option<ATLauncherModrinthProject>,
pub modrinth_version: Option<ATLauncherModrinthVersion>,
}
// Check if folder has a instance.json that parses
pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool {
let instance = serde_json::from_str::<ATInstance>(
&io::read_any_encoding_to_string(
&instance_folder.join("instance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
);
if let Err(e) = instance {
tracing::warn!(
"Could not parse instance.json at {}: {}",
instance_folder.display(),
e
);
false
} else {
true
}
}
#[tracing::instrument]
pub async fn import_atlauncher_dir(
atlauncher_base_path: PathBuf,
atlauncher_instance_path: PathBuf,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let atinstance = serde_json::from_str::<ATInstance>(
&io::read_any_encoding_to_string(
&atlauncher_instance_path.join("instance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
)?;
let icon_path_primary = atlauncher_instance_path.join("instance.png");
let safe_pack_name = atinstance
.launcher
.pack
.replace(|c: char| !c.is_alphanumeric(), "")
.to_lowercase();
let icon_path_secondary = atlauncher_base_path
.join("configs")
.join("images")
.join(safe_pack_name + ".png");
let icon = match (icon_path_primary.exists(), icon_path_secondary.exists())
{
(true, _) => import::recache_icon(icon_path_primary).await?,
(_, true) => import::recache_icon(icon_path_secondary).await?,
_ => None,
};
let description = CreatePackDescription {
icon,
override_title: Some(atinstance.launcher.name.clone()),
project_id: None,
version_id: None,
instance_id: instance_id.to_string(),
source_filename: None,
};
let backup_name = format!(
"ATLauncher-{}",
atlauncher_instance_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default()
);
let minecraft_folder = atlauncher_instance_path;
import_atlauncher_unmanaged(
instance_id,
minecraft_folder,
backup_name,
description,
atinstance,
reporter,
details,
symlink,
)
.await?;
Ok(())
}
async fn import_atlauncher_unmanaged(
instance_id: &str,
minecraft_folder: PathBuf,
backup_name: String,
description: CreatePackDescription,
atinstance: ATInstance,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let mod_loader = format!(
"\"{}\"",
atinstance.launcher.loader_version.r#type.to_lowercase()
);
let mod_loader: ModLoader = serde_json::from_str::<ModLoader>(&mod_loader)
.map_err(|_| {
crate::ErrorKind::InputError(format!(
"Could not parse mod loader type: {mod_loader}"
))
})?;
let game_version = atinstance.id;
let loader_version = if mod_loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
Some(&atinstance.launcher.loader_version.version),
)
.await?
} else {
None
};
let link = match (&description.project_id, &description.version_id) {
(Some(project_id), Some(version_id)) => {
Some(InstanceLink::ModrinthModpack {
project_id: project_id.clone(),
version_id: version_id.clone(),
})
}
_ => None,
};
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
description
.icon
.clone()
.map(|x| x.to_string_lossy().to_string()),
),
link,
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
finish_import(
instance_id,
minecraft_folder,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await?;
Ok(())
}

View File

@ -0,0 +1,178 @@
use std::path::PathBuf;
use serde::Deserialize;
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLaunchOverridesPatch, ModLoader,
ReleaseChannel, instances::InstanceLaunchOverridesData,
},
util::io,
};
use super::{ImportOverrides, finish_import, generic, recache_icon};
const CONFIG_FILE_NAME: &str = "axolotl_config.json";
const CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub struct AxolotlConfigFile {
pub schema_version: u32,
pub instance_id: String,
pub path: String,
pub generated_at: chrono::DateTime<chrono::Utc>,
pub name: String,
pub icon_path: Option<String>,
pub update_channel: String,
pub symlink_target: Option<String>,
pub groups: Vec<String>,
pub content_set: AxolotlContentSet,
pub link: crate::state::instances::InstanceLink,
pub launch_overrides: InstanceLaunchOverridesData,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub struct AxolotlContentSet {
pub source_kind: String,
pub game_version: String,
pub protocol_version: Option<u32>,
pub loader: String,
pub loader_version: Option<String>,
}
/// Imports an Axolotl instance by reading `axolotl_config.json` and applying
/// the migratable fields to the new profile. Invalid or unsupported configs
/// fall back to the generic instance import so the game files still arrive.
pub(crate) async fn import_axolotl(
source_path: PathBuf,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
overrides: &ImportOverrides,
) -> crate::Result<()> {
let config_path = source_path.join(CONFIG_FILE_NAME);
let content = io::read_any_encoding_to_string(&config_path)
.await
.unwrap_or_else(|error| {
tracing::warn!(
"Axolotl import: could not read {}: {error}; falling back to generic import",
config_path.display()
);
(String::new(), encoding_rs::UTF_8)
})
.0;
let config = match serde_json::from_str::<AxolotlConfigFile>(&content) {
Ok(config) => config,
Err(error) => {
tracing::warn!(
"Axolotl import: invalid config {}: {error}; falling back to generic import",
config_path.display()
);
return generic::import_generic(
source_path,
instance_id,
reporter,
details,
symlink,
overrides,
None,
)
.await;
}
};
tracing::debug!(
"Axolotl import: config instance_id={} path={} generated_at={} symlink_target={:?}",
config.instance_id,
config.path,
config.generated_at,
config.symlink_target
);
if config.schema_version != CONFIG_SCHEMA_VERSION
|| config.content_set.game_version.trim().is_empty()
{
tracing::warn!(
"Axolotl import: unsupported schema or missing game version for {}; falling back to generic import",
config_path.display()
);
return generic::import_generic(
source_path,
instance_id,
reporter,
details,
symlink,
overrides,
None,
)
.await;
}
let icon = match config.icon_path.as_ref() {
Some(path) => recache_icon(source_path.join(path)).await?,
None => None,
};
let source_kind =
ContentSourceKind::from_str(&config.content_set.source_kind)
.unwrap_or(ContentSourceKind::Local);
let loader = ModLoader::try_from_string(&config.content_set.loader)?;
let update_channel = ReleaseChannel::from_key(&config.update_channel);
let state = State::get().await?;
crate::state::edit_instance(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(config.name.clone()),
icon_path: Some(icon.map(|p| p.to_string_lossy().to_string())),
update_channel: Some(update_channel),
groups: Some(config.groups.clone()),
link: Some(config.link.clone()),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(source_kind),
game_version: Some(config.content_set.game_version.clone()),
protocol_version: Some(config.content_set.protocol_version),
loader: Some(loader),
loader_version: Some(config.content_set.loader_version.clone()),
}),
launch_overrides: Some(InstanceLaunchOverridesPatch {
java_path: Some(config.launch_overrides.java_path.clone()),
extra_launch_args: Some(
config.launch_overrides.extra_launch_args.clone(),
),
custom_env_vars: Some(
config.launch_overrides.custom_env_vars.clone(),
),
memory: Some(config.launch_overrides.memory),
force_fullscreen: Some(
config.launch_overrides.force_fullscreen,
),
maximize_window: Some(config.launch_overrides.maximize_window),
game_resolution: Some(config.launch_overrides.game_resolution),
launch_preparation_timeout: Some(
config.launch_overrides.launch_preparation_timeout,
),
hooks: Some(config.launch_overrides.hooks.clone()),
}),
..EditInstance::default()
},
&state.pool,
)
.await?;
finish_import(
instance_id,
source_path,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await
}

View File

@ -0,0 +1,275 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::{
fetch::{fetch, write_cached_icon},
io,
},
};
use super::{finish_import, instance_json, recache_icon};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftInstance {
pub name: Option<String>,
pub base_mod_loader: Option<MinecraftInstanceModLoader>,
pub profile_image_path: Option<PathBuf>,
pub installed_modpack: Option<InstalledModpack>,
pub game_version: String, // Minecraft game version. Non-prioritized, use this if Vanilla
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftInstanceModLoader {
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct InstalledModpack {
pub thumbnail_url: Option<String>,
}
fn parse_curseforge_loader(
loader_name: &str,
game_version: &str,
) -> Option<(ModLoader, String)> {
let loader_name = loader_name.trim();
if loader_name.eq_ignore_ascii_case("labymod")
|| loader_name.to_ascii_lowercase().starts_with("labymod-")
{
return None;
}
let family = crate::api::curseforge::loader_family(loader_name);
let loader = match family {
"forge" => ModLoader::Forge,
"fabric" => ModLoader::Fabric,
"quilt" => ModLoader::Quilt,
"neo" | "neoforge" => ModLoader::NeoForge,
_ => return None,
};
let detected_version =
loader_name.strip_prefix(family)?.strip_prefix('-')?.trim();
if detected_version.is_empty() {
return None;
}
let version = instance_json::normalize_imported_loader_version(
loader.as_str(),
game_version,
detected_version,
);
(!version.is_empty()).then_some((loader, version))
}
// Check if folder has a minecraftinstance.json that parses
pub async fn is_valid_curseforge(instance_folder: PathBuf) -> bool {
let minecraft_instance = serde_json::from_str::<MinecraftInstance>(
&io::read_any_encoding_to_string(
&instance_folder.join("minecraftinstance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
);
minecraft_instance.is_ok()
}
pub async fn import_curseforge(
curseforge_instance_folder: PathBuf, // instance's folder
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
// Load minecraftinstance.json
let minecraft_instance = serde_json::from_str::<MinecraftInstance>(
&io::read_any_encoding_to_string(
&curseforge_instance_folder.join("minecraftinstance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
)?;
let override_title = minecraft_instance.name;
let backup_name = format!(
"Curseforge-{}",
curseforge_instance_folder
.file_name()
.map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string())
);
let state = State::get().await?;
// Recache Curseforge Icon if it exists
let mut icon = None;
if let Some(icon_path) = minecraft_instance.profile_image_path.clone() {
icon = recache_icon(icon_path).await?;
} else if let Some(InstalledModpack {
thumbnail_url: Some(thumbnail_url),
}) = minecraft_instance.installed_modpack.clone()
{
let icon_bytes = fetch(
&thumbnail_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let filename = thumbnail_url.rsplit('/').next_back();
if let Some(filename) = filename {
icon = Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
);
}
}
// base mod loader is always None for vanilla
if let Some(instance_mod_loader) = minecraft_instance.base_mod_loader {
let game_version = minecraft_instance.game_version;
let parsed_loader =
parse_curseforge_loader(&instance_mod_loader.name, &game_version);
let (mod_loader, requested_loader_version) = parsed_loader.ok_or_else(|| {
let loader_name = instance_mod_loader.name.trim();
let message = if loader_name.eq_ignore_ascii_case("labymod")
|| loader_name.to_ascii_lowercase().starts_with("labymod-")
{
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances".to_string()
} else {
format!(
"Unsupported loader {loader_name}: the instance was not imported as Vanilla"
)
};
crate::ErrorKind::InputError(message)
})?;
let loader_version = crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
Some(&requested_loader_version),
)
.await?;
if loader_version.is_none() {
return Err(crate::ErrorKind::InputError(format!(
"CurseForge instance loader version {requested_loader_version} is not available for {} {game_version}",
mod_loader.as_str(),
))
.into());
}
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
} else {
crate::api::instance::edit(
instance_id,
EditInstance {
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(minecraft_instance.game_version.clone()),
protocol_version: Some(None),
loader: Some(ModLoader::Vanilla),
loader_version: Some(None),
}),
..EditInstance::default()
},
)
.await?;
}
// Copy in contained folders as overrides
let state = State::get().await?;
finish_import(
instance_id,
curseforge_instance_folder,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_supported_curseforge_instance_loaders() {
for (name, game_version, loader, version) in [
("forge-47.4.22", "1.20.1", ModLoader::Forge, "47.4.22"),
(
"fabric-0.16.10-1.21.1",
"1.21.1",
ModLoader::Fabric,
"0.16.10",
),
("quilt-0.26.4-1.20.1", "1.20.1", ModLoader::Quilt, "0.26.4"),
(
"neoforge-21.4.157",
"1.21.4",
ModLoader::NeoForge,
"21.4.157",
),
] {
assert_eq!(
parse_curseforge_loader(name, game_version),
Some((loader, version.to_string())),
"{name}"
);
}
}
#[test]
fn rejects_unknown_curseforge_instance_loader() {
assert_eq!(parse_curseforge_loader("unknown-1.0", "1.20.1"), None);
}
#[test]
fn rejects_labymod_curseforge_instance_loader() {
assert_eq!(parse_curseforge_loader("labymod-4.4.20", "1.20.1"), None);
}
}

View File

@ -0,0 +1,948 @@
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::{ImportLauncherType, generic, hmcl, instance_json, pcl};
use crate::state::ModLoader;
const TEMP_IMPORT_DIR: &str = "axolotl-launcher-import";
#[derive(Clone, Debug)]
pub(crate) struct ResolvedDirectLink {
pub launcher: ImportLauncherType,
pub launcher_root: PathBuf,
pub dot_minecraft: PathBuf,
/// Resolved `versions/<id>` directory of the linked installation; carried
/// as part of the resolution contract even though current consumers
/// derive their paths from `dot_minecraft`/`version_json` directly.
#[allow(dead_code)]
pub version_dir: PathBuf,
pub version_json: PathBuf,
pub version_id: String,
pub game_version: String,
pub loader: ModLoader,
/// Detected loader version of the linked document. Directly associated
/// instances display no loader version (the loader is managed by the
/// external launcher), so this is not persisted anywhere yet.
#[allow(dead_code)]
pub loader_version: Option<String>,
}
/// The launcher's dialect and root used to resolve a traditional
/// `.minecraft/versions/<id>` directory. The root is intentionally separate
/// from the game directory: PCL stores its settings beside the executable,
/// while its `.minecraft` may be a sibling directory.
#[derive(Clone, Debug)]
pub(crate) struct DirectLinkSource {
pub launcher: ImportLauncherType,
pub launcher_root: PathBuf,
}
/// Identifies the launcher that owns an externally selected version folder.
///
/// Direct-link Settings receives `.minecraft` roots rather than launcher
/// executables, so the normal import scanner cannot infer PCL from its
/// executable alone. Probe the selected root and its parent, where portable
/// PCL/PCL-CE installations keep their executable and `PCL` configuration.
/// HMCL is only selected when its configuration actually references the
/// supplied game directory; an unrelated `.hmcl` folder must not relabel a
/// generic Minecraft installation.
pub(crate) fn detect_direct_link_source(
dot_minecraft: &Path,
version_dir: &Path,
) -> DirectLinkSource {
for launcher_root in [
dot_minecraft.parent().map(Path::to_path_buf),
Some(dot_minecraft.to_path_buf()),
]
.into_iter()
.flatten()
{
if super::pe_info::folder_has_product(
&launcher_root,
"Plain Craft Launcher",
) {
let launcher =
if launcher_root.join("PCL").join("config.v1.yml").is_file() {
ImportLauncherType::PCL2CE
} else {
ImportLauncherType::PCL2
};
return DirectLinkSource {
launcher,
launcher_root,
};
}
if super::hmcl::config_exists(&launcher_root)
&& super::hmcl::configured_game_dir(
&launcher_root,
dot_minecraft,
version_dir,
)
.is_some()
{
return DirectLinkSource {
launcher: ImportLauncherType::HMCL,
launcher_root,
};
}
}
DirectLinkSource {
launcher: ImportLauncherType::Generic,
launcher_root: dot_minecraft.to_path_buf(),
}
}
/// Returns the stable user-facing group for a directly linked `.minecraft`
/// root. The complete normalized path is used as the group name so two roots
/// with the same display folder name remain distinct.
pub(crate) fn direct_link_group(dot_minecraft: &Path) -> Option<String> {
let path = dot_minecraft.to_string_lossy().trim().to_string();
(!path.is_empty()).then_some(path)
}
impl ResolvedDirectLink {
pub(crate) fn launcher_key(&self) -> &'static str {
launcher_key(self.launcher)
.expect("resolved direct links always use a supported launcher")
}
}
/// Resolves the same launcher identity used by the existing import flow into
/// persistent paths for a read-only direct association.
///
/// The conventional repository layout follows HMCL
/// `DefaultGameRepositoryLayout.getInstanceRoot/getInstanceJson` at commit
/// `083dbb18ade1c935e2e56d0bdefcd718be1e2ed6`: shared root plus
/// `versions/<id>/<id>.json`. PCL's fallback follows
/// `ModMinecraft.McInstance.GetJsonPath` at commit
/// `639de1b48a44326cbd5465579295cecf23d9056a`: prefer the same-name JSON,
/// otherwise inspect JSON files in the version directory. PCL-CE keeps the
/// same version-folder model in `Modules/Minecraft/McInstance.cs` at commit
/// `aa3b81c6afb3cd1896dda271578b002066512177`.
pub(crate) async fn resolve_direct_link(
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: String,
instance_path: Option<String>,
) -> crate::Result<ResolvedDirectLink> {
if launcher_key(launcher_type).is_none()
&& launcher_type != ImportLauncherType::Unknown
{
return Err(unsupported_launcher(launcher_type));
}
reject_temporary_import_path(&base_path)?;
if let Some(instance_path) = instance_path.as_deref() {
reject_temporary_import_path(Path::new(instance_path))?;
}
if launcher_type == ImportLauncherType::Unknown {
return resolve_unknown(
base_path,
instance_folder,
instance_path.as_deref(),
)
.await;
}
resolve_known(
launcher_type,
base_path,
&instance_folder,
instance_path.as_deref(),
)
}
async fn resolve_unknown(
base_path: PathBuf,
instance_folder: String,
instance_path: Option<&str>,
) -> crate::Result<ResolvedDirectLink> {
// Match the selected scan result before assigning a dialect. Merely having
// HMCL/PCL configuration beside a Generic version must not relabel that
// version and route it through the wrong launch merger.
if let Ok(instances) = Box::pin(super::get_importable_instances(
ImportLauncherType::HMCL,
base_path.clone(),
))
.await
&& selection_matches(&instances, &instance_folder, instance_path)
&& let Ok(resolved) = resolve_known(
ImportLauncherType::HMCL,
base_path.clone(),
&instance_folder,
instance_path,
)
{
return Ok(resolved);
}
// The existing PCL scanner intentionally merges legacy PCL and PCL-CE
// config sources. Recover the source dialect after matching the selected
// scan item so PCL-CE is not always mislabeled as the first PCL variant.
if let Ok(instances) = Box::pin(super::get_importable_instances(
ImportLauncherType::PCL2,
base_path.clone(),
))
.await
&& selection_matches(&instances, &instance_folder, instance_path)
{
let launcher_type = pcl_dialect(&instance_folder, instance_path);
if let Ok(resolved) = resolve_known(
launcher_type,
base_path.clone(),
&instance_folder,
instance_path,
) {
return Ok(resolved);
}
}
resolve_known(
ImportLauncherType::Generic,
base_path,
&instance_folder,
instance_path,
)
.map_err(|_| {
crate::ErrorKind::InputError(
"Could not resolve a traditional .minecraft instance as HMCL, PCL2, PCL2CE, or Generic"
.to_string(),
)
.into()
})
}
fn selection_matches(
instances: &[super::ImportableInstance],
instance_folder: &str,
instance_path: Option<&str>,
) -> bool {
instances.iter().any(|candidate| {
if let Some(selected) = instance_path {
paths_match(Path::new(selected), Path::new(&candidate.path))
|| candidate.version_path.as_deref().is_some_and(
|version_path| {
paths_match(
Path::new(selected),
Path::new(version_path),
)
},
)
} else {
candidate.name == instance_folder
}
})
}
fn pcl_dialect(
instance_folder: &str,
instance_path: Option<&str>,
) -> ImportLauncherType {
let pcl_sources = pcl::get_pcl_instances();
let pcl_ce_sources = pcl::get_pclce_instances();
pcl_dialect_from_sources(
instance_folder,
instance_path,
&pcl_sources,
&pcl_ce_sources,
)
}
fn pcl_dialect_from_sources(
instance_folder: &str,
instance_path: Option<&str>,
pcl_sources: &[(String, String)],
pcl_ce_sources: &[(String, String)],
) -> ImportLauncherType {
let config_name = split_config_name(instance_folder).0;
let source_matches = |sources: &[(String, String)]| {
sources.iter().any(|(name, path)| {
if let Some(selected) = instance_path {
path_contains(Path::new(path), Path::new(selected))
} else {
name == config_name
}
})
};
let pcl_matches = source_matches(pcl_sources);
let pcl_ce_matches = source_matches(pcl_ce_sources);
if pcl_ce_matches && !pcl_matches {
ImportLauncherType::PCL2CE
} else {
// Preserve the existing importer's legacy-PCL-first precedence for
// duplicate config names and the launcher's local `.minecraft` entry.
ImportLauncherType::PCL2
}
}
fn resolve_known(
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: &str,
instance_path: Option<&str>,
) -> crate::Result<ResolvedDirectLink> {
let launcher_root = canonicalize_checked(&base_path)?;
let (dot_minecraft, version_dir) = if let Some(instance_path) =
instance_path
{
let version_dir = canonicalize_checked(Path::new(instance_path))?;
let dot_minecraft = compatible_game_dir(&base_path, &version_dir)?;
(canonicalize_checked(&dot_minecraft)?, version_dir)
} else {
let source =
resolve_source_path(launcher_type, &base_path, instance_folder)?;
resolve_repository_paths(&source, instance_folder)?
};
reject_temporary_import_path(&launcher_root)?;
reject_temporary_import_path(&dot_minecraft)?;
reject_temporary_import_path(&version_dir)?;
let version_json = discover_version_json(&version_dir)?;
let version_json = canonicalize_checked(&version_json)?;
let version_id = version_json
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| !stem.is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Version JSON has no usable file stem: {}",
version_json.display()
))
.as_error()
})?
.to_string();
let info = instance_json::detect(&version_dir).ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Could not detect Minecraft version from {}",
version_json.display()
))
.as_error()
})?;
let loader = info
.loader
.as_deref()
.map(ModLoader::try_from_string)
.transpose()?
.unwrap_or(ModLoader::Vanilla);
Ok(ResolvedDirectLink {
launcher: launcher_type,
launcher_root,
dot_minecraft,
version_dir,
version_json,
version_id,
game_version: info.vanilla_name,
loader,
loader_version: info.loader_version,
})
}
fn resolve_source_path(
launcher_type: ImportLauncherType,
base_path: &Path,
instance_folder: &str,
) -> crate::Result<PathBuf> {
let (config_name, rest) = split_config_name(instance_folder);
let target = if rest.is_empty() { config_name } else { rest };
let game_dir = match launcher_type {
ImportLauncherType::HMCL => {
hmcl::get_instance_path(base_path, config_name)
.map(PathBuf::from)
.map(|path| {
if path.is_absolute() {
path
} else {
base_path.join(path)
}
})
.unwrap_or_else(|| base_path.to_path_buf())
}
ImportLauncherType::PCL2 | ImportLauncherType::PCL2CE => {
find_pcl_source(config_name, &pcl::get_pcl_instances())
.or_else(|| {
find_pcl_source(config_name, &pcl::get_pclce_instances())
})
.map(PathBuf::from)
.or_else(|| {
(config_name == ".minecraft")
.then(|| base_path.join(".minecraft"))
.filter(|path| path.is_dir())
})
.unwrap_or_else(|| base_path.to_path_buf())
}
ImportLauncherType::Generic => base_path.to_path_buf(),
_ => return Err(unsupported_launcher(launcher_type)),
};
Ok(resolve_instance_path(&game_dir, target))
}
fn resolve_repository_paths(
source: &Path,
instance_folder: &str,
) -> crate::Result<(PathBuf, PathBuf)> {
let source = canonicalize_checked(source)?;
if source
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
{
let dot_minecraft = source
.parent()
.and_then(Path::parent)
.ok_or_else(|| invalid_version_directory(&source))?;
return Ok((canonicalize_checked(dot_minecraft)?, source));
}
let (_, dot_minecraft) = generic::resolve_dotminecraft(&source);
let dot_minecraft = canonicalize_checked(&dot_minecraft)?;
let target = split_config_name(instance_folder).1;
let target = if target.is_empty() {
Path::new(instance_folder)
.strip_prefix("versions")
.unwrap_or_else(|_| Path::new(instance_folder))
} else {
Path::new(target)
.strip_prefix("versions")
.unwrap_or_else(|_| Path::new(target))
};
let version_dir = dot_minecraft.join("versions").join(target);
if !version_dir.is_dir() {
return Err(invalid_version_directory(&version_dir));
}
Ok((dot_minecraft, canonicalize_checked(&version_dir)?))
}
fn compatible_game_dir(
base_path: &Path,
version_dir: &Path,
) -> crate::Result<PathBuf> {
if version_dir
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
{
return version_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.ok_or_else(|| invalid_version_directory(version_dir));
}
let (_, dot_minecraft) = generic::resolve_dotminecraft(base_path);
Ok(dot_minecraft)
}
/// Returns whether a file is a Minecraft version manifest rather than a JSON
/// sidecar produced by the game or a launcher. Version folders often contain
/// files such as `usercache.json`; those must not make a copied PCL instance
/// appear ambiguous.
fn is_minecraft_version_manifest(path: &Path) -> bool {
let Ok(contents) = std::fs::read_to_string(path) else {
return false;
};
let Ok(value) = serde_json::from_str::<Value>(&contents) else {
return false;
};
let Some(object) = value.as_object() else {
return false;
};
object
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| !id.trim().is_empty())
&& [
"arguments",
"assetIndex",
"assets",
"clientVersion",
"downloads",
"inheritsFrom",
"jar",
"libraries",
"mainClass",
"minecraftArguments",
]
.iter()
.any(|field| object.contains_key(*field))
}
/// Returns whether a version directory contains at least one usable Minecraft
/// version manifest. This lets a root scan ignore launcher bookkeeping folders
/// that are not launchable instances.
pub(crate) fn has_minecraft_version_manifest(version_dir: &Path) -> bool {
std::fs::read_dir(version_dir).is_ok_and(|entries| {
entries.flatten().map(|entry| entry.path()).any(|path| {
path.is_file()
&& path.extension().is_some_and(|extension| {
extension.eq_ignore_ascii_case("json")
})
&& is_minecraft_version_manifest(&path)
})
})
}
/// Finds the actual manifest selected by upstream launchers: a valid
/// same-name manifest first, then the sole valid manifest in the version
/// directory. Ambiguous folders are rejected instead of guessing which
/// manifest the UI intended.
pub(crate) fn discover_version_json(
version_dir: &Path,
) -> crate::Result<PathBuf> {
let folder_name = version_dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| invalid_version_directory(version_dir))?;
let same_name = version_dir.join(format!("{folder_name}.json"));
if same_name.is_file() && is_minecraft_version_manifest(&same_name) {
return Ok(same_name);
}
let mut json_files = std::fs::read_dir(version_dir)
.map_err(|error| {
crate::ErrorKind::FSError(format!(
"Failed to inspect version directory {}: {error}",
version_dir.display()
))
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path.extension().is_some_and(|extension| {
extension.eq_ignore_ascii_case("json")
})
&& is_minecraft_version_manifest(path)
})
.collect::<Vec<_>>();
json_files.sort();
match json_files.as_slice() {
[only] => Ok(only.clone()),
[] => Err(crate::ErrorKind::InputError(format!(
"No Minecraft version JSON found in {}",
version_dir.display()
))
.into()),
_ => Err(crate::ErrorKind::InputError(format!(
"Multiple Minecraft version JSON files found in {}; expected a same-name JSON or one unique fallback",
version_dir.display()
))
.into()),
}
}
fn launcher_key(launcher_type: ImportLauncherType) -> Option<&'static str> {
match launcher_type {
ImportLauncherType::HMCL => Some("hmcl"),
ImportLauncherType::PCL2 => Some("pcl2"),
ImportLauncherType::PCL2CE => Some("pcl2_ce"),
ImportLauncherType::Generic => Some("generic"),
_ => None,
}
}
fn reject_temporary_import_path(path: &Path) -> crate::Result<()> {
let temporary_root = std::env::temp_dir().join(TEMP_IMPORT_DIR);
if path.starts_with(&temporary_root) {
return Err(crate::ErrorKind::InputError(
"Direct association is unavailable for extracted launcher archives because the temporary folder is deleted after import"
.to_string(),
)
.into());
}
Ok(())
}
fn canonicalize_checked(path: &Path) -> crate::Result<PathBuf> {
let canonical = crate::util::io::canonicalize(path)?;
reject_temporary_import_path(&canonical)?;
Ok(canonical)
}
fn paths_match(left: &Path, right: &Path) -> bool {
match (
crate::util::io::canonicalize(left),
crate::util::io::canonicalize(right),
) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn path_contains(root: &Path, selected: &Path) -> bool {
match (
crate::util::io::canonicalize(root),
crate::util::io::canonicalize(selected),
) {
(Ok(root), Ok(selected)) => selected.starts_with(root),
_ => selected.starts_with(root),
}
}
fn unsupported_launcher(launcher_type: ImportLauncherType) -> crate::Error {
crate::ErrorKind::InputError(format!(
"Direct association does not support launcher {launcher_type}; expected HMCL, PCL2, PCL2CE, Generic, or Unknown"
))
.into()
}
fn invalid_version_directory(path: &Path) -> crate::Error {
crate::ErrorKind::InputError(format!(
"Expected a traditional .minecraft version directory at {}",
path.display()
))
.into()
}
/// Splits an instance folder identity like `name` or `Version:1.12.2` into
/// (config name, version part); PCL and HMCL instance identities use this
/// shape.
fn split_config_name(name: &str) -> (&str, &str) {
name.split_once(':').unwrap_or((name, ""))
}
/// Resolves the folder of an instance from a base path and its scan identity.
fn resolve_instance_path(base_path: &Path, instance_folder: &str) -> PathBuf {
if let Ok(rest) = Path::new(instance_folder).strip_prefix("versions") {
return base_path.join("versions").join(rest);
}
if base_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.as_deref()
== Some(instance_folder)
{
base_path.to_path_buf()
} else {
base_path.join(instance_folder)
}
}
/// Finds the game directory of a PCL/PCL-CE instance from its scan sources
/// (registry entries or CE config).
fn find_pcl_source(
instance_name: &str,
sources: &[(String, String)],
) -> Option<PathBuf> {
sources
.iter()
.find(|(name, _)| name == instance_name)
.map(|(_, path)| PathBuf::from(path))
.filter(|path| path.is_dir())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn write_json(version_dir: &Path, file_stem: &str) {
std::fs::create_dir_all(version_dir).unwrap();
std::fs::write(
version_dir.join(format!("{file_stem}.json")),
serde_json::to_vec_pretty(&json!({
"id": file_stem,
"mainClass": "net.minecraft.client.main.Main",
"type": "release"
}))
.unwrap(),
)
.unwrap();
}
#[tokio::test]
async fn resolves_normal_versions_layout() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions/1.20.1");
write_json(&version_dir, "1.20.1");
let resolved = resolve_direct_link(
ImportLauncherType::Generic,
root.path().to_path_buf(),
"versions/1.20.1".to_string(),
None,
)
.await
.unwrap();
assert_eq!(resolved.launcher, ImportLauncherType::Generic);
assert_eq!(
resolved.dot_minecraft,
crate::util::io::canonicalize(root.path()).unwrap()
);
assert_eq!(resolved.version_id, "1.20.1");
assert_eq!(
resolved.version_dir,
crate::util::io::canonicalize(&version_dir).unwrap()
);
}
#[test]
fn identifies_hmcl_only_when_it_owns_the_game_directory() {
let root = TempDir::new().unwrap();
let game_dir = root.path().join("game");
std::fs::create_dir_all(game_dir.join("versions")).unwrap();
let hmcl_dir = root.path().join(".hmcl");
std::fs::create_dir_all(&hmcl_dir).unwrap();
std::fs::write(
hmcl_dir.join("hmcl.json"),
serde_json::to_vec(&json!({
"configurations": {
"default": { "gameDir": game_dir.to_string_lossy() }
}
}))
.unwrap(),
)
.unwrap();
let source = detect_direct_link_source(
&game_dir,
&game_dir.join("versions").join("demo"),
);
assert_eq!(source.launcher, ImportLauncherType::HMCL);
assert_eq!(source.launcher_root, root.path());
}
#[tokio::test]
async fn resolves_compatible_mode_from_game_dir_and_version_path() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions/1.12.2-forge");
write_json(&version_dir, "1.12.2-forge");
let resolved = resolve_direct_link(
ImportLauncherType::PCL2,
root.path().to_path_buf(),
"Friendly PCL Name".to_string(),
Some(version_dir.to_string_lossy().to_string()),
)
.await
.unwrap();
assert_eq!(resolved.launcher_key(), "pcl2");
assert_eq!(
resolved.dot_minecraft,
crate::util::io::canonicalize(root.path()).unwrap()
);
assert_eq!(resolved.version_id, "1.12.2-forge");
}
#[tokio::test]
async fn unique_json_fallback_uses_actual_stem_not_display_name() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions/ui-folder");
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::write(
version_dir.join("actual-version-id.json"),
serde_json::to_vec_pretty(&json!({
"id": "actual-version-id",
"clientVersion": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"type": "release"
}))
.unwrap(),
)
.unwrap();
let resolved = resolve_direct_link(
ImportLauncherType::Generic,
root.path().to_path_buf(),
"versions/ui-folder".to_string(),
None,
)
.await
.unwrap();
assert_eq!(resolved.version_id, "actual-version-id");
assert!(resolved.version_json.ends_with("actual-version-id.json"));
}
#[test]
fn ignores_runtime_json_when_finding_a_copied_pcl_instance() {
let root = TempDir::new().unwrap();
let version_dir =
root.path().join("versions").join("1.19.2 - 64bit - copy");
write_json(&version_dir, "1.19.2 - 64bit");
std::fs::write(
version_dir.join("usercache.json"),
r#"[{"name":"player","uuid":"example"}]"#,
)
.unwrap();
assert!(has_minecraft_version_manifest(&version_dir));
assert_eq!(
discover_version_json(&version_dir).unwrap(),
version_dir.join("1.19.2 - 64bit.json")
);
}
#[test]
fn rejects_a_pcl_bookkeeping_directory_without_a_version_manifest() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions").join("Sodium Plus");
std::fs::create_dir_all(version_dir.join("PCL")).unwrap();
std::fs::write(
version_dir.join("PCL").join("config.v1.yml"),
"VersionVanilla: 1.21.1\n",
)
.unwrap();
assert!(!has_minecraft_version_manifest(&version_dir));
}
#[tokio::test]
async fn rejects_temporary_launcher_import_directory() {
let error = resolve_direct_link(
ImportLauncherType::Generic,
std::env::temp_dir().join(TEMP_IMPORT_DIR).join("extracted"),
"versions/1.20.1".to_string(),
None,
)
.await
.unwrap_err();
assert!(error.to_string().contains("temporary"));
}
#[tokio::test]
async fn rejects_unsupported_launcher_before_touching_paths() {
let error = resolve_direct_link(
ImportLauncherType::MultiMC,
PathBuf::from("missing"),
"instance".to_string(),
None,
)
.await
.unwrap_err();
assert!(error.to_string().contains("does not support"));
}
#[test]
fn maps_pcl_and_hmcl_to_distinct_persistent_dialects() {
assert_eq!(launcher_key(ImportLauncherType::HMCL), Some("hmcl"));
assert_eq!(launcher_key(ImportLauncherType::PCL2), Some("pcl2"));
assert_eq!(launcher_key(ImportLauncherType::PCL2CE), Some("pcl2_ce"));
}
#[test]
fn groups_direct_links_by_their_complete_path() {
assert_eq!(
direct_link_group(&Path::new("A").join(".minecraft")),
Some(
Path::new("A")
.join(".minecraft")
.to_string_lossy()
.to_string()
)
);
assert_eq!(
direct_link_group(Path::new(".minecraft")),
Some(".minecraft".to_string())
);
}
#[test]
fn unknown_recovers_pcl_ce_dialect_from_selected_source_path() {
let root = TempDir::new().unwrap();
let pcl_root = root.path().join("pcl");
let pcl_ce_root = root.path().join("pcl-ce");
let selected = pcl_ce_root.join("versions/1.21.1");
std::fs::create_dir_all(&pcl_root).unwrap();
std::fs::create_dir_all(&selected).unwrap();
let pcl_sources = vec![(
"Legacy".to_string(),
pcl_root.to_string_lossy().to_string(),
)];
let pcl_ce_sources = vec![(
"Community".to_string(),
pcl_ce_root.to_string_lossy().to_string(),
)];
assert_eq!(
pcl_dialect_from_sources(
"1.21.1",
Some(selected.to_string_lossy().as_ref()),
&pcl_sources,
&pcl_ce_sources,
),
ImportLauncherType::PCL2CE
);
}
#[tokio::test]
async fn unknown_prefers_detected_hmcl_over_generic() {
let root = TempDir::new().unwrap();
let game_dir = root.path().join("game");
let version_dir = game_dir.join("versions/1.20.4");
write_json(&version_dir, "1.20.4");
std::fs::create_dir_all(root.path().join(".hmcl")).unwrap();
std::fs::write(
root.path().join(".hmcl/hmcl.json"),
serde_json::to_vec_pretty(&json!({
"configurations": {
"HMCL Profile": { "gameDir": game_dir }
}
}))
.unwrap(),
)
.unwrap();
let resolved = resolve_direct_link(
ImportLauncherType::Unknown,
root.path().to_path_buf(),
"HMCL Profile:versions/1.20.4".to_string(),
None,
)
.await
.unwrap();
assert_eq!(resolved.launcher, ImportLauncherType::HMCL);
assert_eq!(resolved.launcher_key(), "hmcl");
}
#[tokio::test]
async fn unknown_does_not_relabel_unmatched_generic_as_hmcl() {
let root = TempDir::new().unwrap();
let hmcl_game = root.path().join("hmcl-game");
write_json(&hmcl_game.join("versions/hmcl"), "1.20.1");
std::fs::create_dir_all(root.path().join(".hmcl")).unwrap();
std::fs::write(
root.path().join(".hmcl/hmcl.json"),
serde_json::to_vec_pretty(&json!({
"configurations": {
"HMCL Profile": { "gameDir": hmcl_game }
}
}))
.unwrap(),
)
.unwrap();
let generic_version = root.path().join("versions/generic");
write_json(&generic_version, "1.20.4");
let resolved = resolve_direct_link(
ImportLauncherType::Unknown,
root.path().to_path_buf(),
// Deliberately collide with the HMCL candidate's display identity;
// the explicitly selected path must take precedence.
"HMCL Profile:versions/hmcl".to_string(),
Some(generic_version.to_string_lossy().to_string()),
)
.await
.unwrap();
assert_eq!(resolved.launcher, ImportLauncherType::Generic);
assert_eq!(resolved.launcher_key(), "generic");
}
}

View File

@ -0,0 +1,130 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::io,
};
use super::{finish_import, recache_icon};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GDLauncherConfig {
pub background: Option<String>,
pub loader: GDLauncherLoader,
// pub mods: Vec<GDLauncherMod>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GDLauncherLoader {
pub loader_type: ModLoader,
pub loader_version: Option<String>,
pub mc_version: String,
pub source: Option<String>,
pub source_name: Option<String>,
}
// Check if folder has a config.json that parses
pub async fn is_valid_gdlauncher(instance_folder: PathBuf) -> bool {
let config = serde_json::from_str::<GDLauncherConfig>(
&io::read_any_encoding_to_string(&instance_folder.join("config.json"))
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
);
config.is_ok()
}
pub async fn import_gdlauncher(
gdlauncher_instance_folder: PathBuf, // instance's folder
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
// Load config.json
let config = serde_json::from_str::<GDLauncherConfig>(
&io::read_any_encoding_to_string(
&gdlauncher_instance_folder.join("config.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
)?;
let override_title = config.loader.source_name;
let backup_name = format!(
"GDLauncher-{}",
gdlauncher_instance_folder
.file_name()
.map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string())
);
// Re-cache icon
let icon = config
.background
.clone()
.map(|b| gdlauncher_instance_folder.join(b));
let icon = if let Some(icon) = icon {
recache_icon(icon).await?
} else {
None
};
let game_version = config.loader.mc_version;
let mod_loader = config.loader.loader_type;
let loader_version = config.loader.loader_version;
let loader_version = if mod_loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
// Copy in contained folders as overrides
let state = State::get().await?;
finish_import(
instance_id,
gdlauncher_instance_folder,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await?;
Ok(())
}

View File

@ -0,0 +1,465 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use super::{ImportOverrides, instance_json};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
launcher::get_loader_version_from_profile,
pack::{
import::finish_import,
install_from::{self, CreatePackDescription, PackDependency},
},
state::ModLoader,
};
/// Import a generic launcher instance folder into an Axolotl profile.
///
/// Runs in four stages: resolve the source folder, validate that it contains
/// a detectable Minecraft version, register the instance metadata, then copy
/// (or symlink) the files into the profile.
pub async fn import_generic(
instance_folder: PathBuf,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
overrides: &ImportOverrides,
instance_path: Option<PathBuf>, // For compatible mode: path to versions/<version>/
) -> crate::Result<()> {
let (name, dotminecraft, json_path) = if let Some(ref inst_path) =
instance_path
{
let name = inst_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "imported".to_string());
tracing::debug!(
"import_generic: compatible mode - dotminecraft={}, json_path={}",
instance_folder.display(),
inst_path.display()
);
(name, instance_folder.to_path_buf(), inst_path.to_path_buf())
} else {
let (name, dotminecraft) = resolve_dotminecraft(&instance_folder);
let json_path = dotminecraft.clone(); // JSON detection will scan dotminecraft
(name, dotminecraft, json_path)
};
let info = detect_instance_info(&json_path, overrides).await?;
register_instance(instance_id, &name, &info).await?;
copy_instance_files(instance_id, &dotminecraft, reporter, details, symlink)
.await
}
/// Stage 1 — resolve the name and the `.minecraft` directory of an imported
/// instance folder. Falls back to the folder itself when there is no nested
/// `.minecraft` subdirectory.
pub(crate) fn resolve_dotminecraft(
instance_folder: &Path,
) -> (String, PathBuf) {
let name = instance_folder
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "imported".to_string());
let dotminecraft = instance_folder.join(".minecraft");
if dotminecraft.is_dir() {
tracing::debug!(
"import_generic: using .minecraft subdir at {}",
dotminecraft.display()
);
(name, dotminecraft)
} else {
tracing::debug!(
"import_generic: using folder directly at {}",
instance_folder.display()
);
(name, instance_folder.to_path_buf())
}
}
/// Stage 2 — validate the folder contains a Minecraft version JSON.
async fn detect_instance_info(
dotminecraft: &Path,
overrides: &ImportOverrides,
) -> crate::Result<instance_json::InstanceInfo> {
tracing::debug!(
"import_generic: about to detect instance_json at dotminecraft={}",
dotminecraft.display()
);
let Some(mut info) = instance_json::detect(dotminecraft) else {
let Some(game_version) = overrides
.game_version
.as_ref()
.filter(|version| !version.trim().is_empty())
else {
tracing::warn!(
"import_generic: instance_json::detect returned None for {}",
dotminecraft.display()
);
return Err(crate::ErrorKind::InputError(
"Could not detect Minecraft version. Make sure the folder contains a valid version JSON."
.into(),
)
.into());
};
return Ok(instance_json::InstanceInfo {
vanilla_name: game_version.clone(),
loader: overrides
.loader
.filter(|loader| *loader != ModLoader::Vanilla)
.map(|loader| loader.as_str().to_string()),
loader_version: overrides
.loader_version
.clone()
.filter(|version| !version.is_empty() && version != "latest"),
adjuncts: Vec::new(),
});
};
if let Some(game_version) = overrides
.game_version
.as_ref()
.filter(|version| !version.trim().is_empty())
{
info.vanilla_name.clone_from(game_version);
}
if let Some(loader) = overrides.loader {
if loader == ModLoader::Vanilla {
info.loader = None;
info.loader_version = None;
info.adjuncts.clear();
} else {
info.loader = Some(loader.as_str().to_string());
info.loader_version = None;
}
}
if let Some(loader_version) = overrides
.loader_version
.as_ref()
.filter(|version| !version.is_empty() && *version != "latest")
{
info.loader_version = Some(loader_version.clone());
}
Ok(info)
}
/// Stage 3 — register the instance metadata (name, game version, loaders)
/// with the app database.
async fn register_instance(
instance_id: &str,
name: &str,
info: &instance_json::InstanceInfo,
) -> crate::Result<()> {
tracing::debug!(
"import_generic: detect result: vanilla_name={} loader={:?} loader_version={:?}",
info.vanilla_name,
info.loader,
info.loader_version
);
let description = CreatePackDescription {
icon: None,
override_title: Some(name.to_string()),
project_id: None,
version_id: None,
instance_id: instance_id.to_string(),
source_filename: None,
};
let dependencies = build_dependencies(info).await?;
tracing::debug!(
"import_generic: setting instance info with dependencies={:?}",
dependencies
);
install_from::set_instance_information(
instance_id.to_string(),
&description,
"Imported from folder",
None,
&dependencies,
false,
)
.await?;
Ok(())
}
/// Builds the dependency map from the detected game version and loader,
/// resolving the loader version from the metadata API when it is missing.
async fn build_dependencies(
info: &instance_json::InstanceInfo,
) -> crate::Result<HashMap<PackDependency, String>> {
let mut dependencies =
HashMap::from([(PackDependency::Minecraft, info.vanilla_name.clone())]);
let Some(ref loader) = info.loader else {
tracing::debug!("import_generic: no loader detected, will be Vanilla");
return Ok(dependencies);
};
let components = std::iter::once((
loader.as_str(),
info.loader_version.as_ref(),
))
.chain(info.adjuncts.iter().filter_map(|(loader, version)| {
(loader != "optifabric").then_some((loader.as_str(), version.as_ref()))
}));
for (loader, version) in components {
if loader.eq_ignore_ascii_case("labymod") {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
let dep = loader_dependency(loader).ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unsupported loader {loader}: the instance was not imported as Vanilla"
))
.as_error()
})?;
let loader_version = resolve_loader_version(
&info.vanilla_name,
loader,
version.map(String::as_str),
)
.await;
match loader_version {
Some(version) => {
dependencies.insert(dep, version);
}
None => {
return Err(crate::ErrorKind::InputError(format!(
"Could not resolve {loader} for Minecraft {}; the instance was not imported as Vanilla",
info.vanilla_name
))
.into());
}
}
}
if let Some((_, version)) = info
.adjuncts
.iter()
.find(|(loader, _)| loader == "optifabric")
{
let version = version.as_ref().ok_or_else(|| {
crate::ErrorKind::InputError(
"Imported OptiFabric component is missing its version"
.to_string(),
)
})?;
dependencies.insert(PackDependency::OptiFabric, version.clone());
}
Ok(dependencies)
}
/// Maps a detected loader name to a dependency the launcher can install.
fn loader_dependency(loader: &str) -> Option<PackDependency> {
match loader {
"forge" => Some(PackDependency::Forge),
"neoforge" => Some(PackDependency::NeoForge),
"fabric" => Some(PackDependency::FabricLoader),
"quilt" => Some(PackDependency::QuiltLoader),
"optifine" => Some(PackDependency::OptiFine),
"cleanroom" => Some(PackDependency::Cleanroom),
"lite_loader" | "liteloader" => Some(PackDependency::LiteLoader),
"legacy_fabric" | "legacyfabric" => Some(PackDependency::LegacyFabric),
_ => None,
}
}
/// Resolves a missing loader version by asking the metadata API for the
/// latest version compatible with the detected game version.
async fn resolve_loader_version(
game_version: &str,
loader: &str,
requested_version: Option<&str>,
) -> Option<String> {
if requested_version
.is_some_and(|version| !version.is_empty() && version != "latest")
{
return requested_version.map(str::to_string);
}
let mod_loader = match loader {
"forge" => Some(ModLoader::Forge),
"neoforge" => Some(ModLoader::NeoForge),
"fabric" => Some(ModLoader::Fabric),
"quilt" => Some(ModLoader::Quilt),
"optifine" => Some(ModLoader::OptiFine),
"cleanroom" => Some(ModLoader::Cleanroom),
"lite_loader" | "liteloader" => Some(ModLoader::LiteLoader),
"legacy_fabric" | "legacyfabric" => Some(ModLoader::LegacyFabric),
_ => None,
}?;
tracing::debug!(
"import_generic: loader={} has no version, resolving latest for game_version={}",
loader,
game_version
);
match get_loader_version_from_profile(game_version, mod_loader, None).await
{
Ok(Some(lv)) => {
tracing::debug!(
"import_generic: resolved latest loader version: {}",
lv.id
);
Some(lv.id)
}
Ok(None) => {
tracing::warn!(
"import_generic: no loader version found for {} {}",
mod_loader.as_str(),
game_version
);
None
}
Err(e) => {
tracing::warn!(
"import_generic: failed to resolve loader version: {e}",
);
None
}
}
}
/// Stage 4 — copy (or symlink) the source files into the instance profile.
async fn copy_instance_files(
instance_id: &str,
dotminecraft: &Path,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let state = State::get().await?;
tracing::debug!(
"import_generic: finishing import for instance_id={}",
instance_id
);
finish_import(
instance_id,
dotminecraft.to_path_buf(),
&state.io_semaphore,
reporter,
details,
symlink,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_dependency_maps_supported_loaders() {
assert_eq!(loader_dependency("forge"), Some(PackDependency::Forge));
assert_eq!(
loader_dependency("neoforge"),
Some(PackDependency::NeoForge)
);
assert_eq!(
loader_dependency("fabric"),
Some(PackDependency::FabricLoader)
);
assert_eq!(
loader_dependency("quilt"),
Some(PackDependency::QuiltLoader)
);
assert_eq!(
loader_dependency("optifine"),
Some(PackDependency::OptiFine)
);
assert_eq!(
loader_dependency("cleanroom"),
Some(PackDependency::Cleanroom)
);
assert_eq!(
loader_dependency("lite_loader"),
Some(PackDependency::LiteLoader)
);
assert_eq!(
loader_dependency("legacy_fabric"),
Some(PackDependency::LegacyFabric)
);
}
#[test]
fn loader_dependency_rejects_unsupported_loaders() {
for loader in ["labymod", "unknown", "vanilla"] {
assert_eq!(loader_dependency(loader), None, "{loader}");
}
}
#[tokio::test]
async fn detect_overrides_replace_missing_detection() {
let directory = tempfile::tempdir().unwrap();
let overrides = ImportOverrides {
game_version: Some("1.20.1".to_string()),
loader: Some(ModLoader::Fabric),
loader_version: Some("0.15.11".to_string()),
};
let info = detect_instance_info(directory.path(), &overrides)
.await
.unwrap();
assert_eq!(info.vanilla_name, "1.20.1");
assert_eq!(info.loader.as_deref(), Some("fabric"));
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
}
#[tokio::test]
async fn detect_overrides_ignore_blank_and_latest_loader_version() {
let directory = tempfile::tempdir().unwrap();
for loader_version in ["", "latest"] {
let overrides = ImportOverrides {
game_version: Some("1.20.1".to_string()),
loader: Some(ModLoader::Fabric),
loader_version: Some(loader_version.to_string()),
};
let info = detect_instance_info(directory.path(), &overrides)
.await
.unwrap();
assert_eq!(info.loader_version, None);
}
}
#[tokio::test]
async fn build_dependencies_uses_override_values() {
let info = instance_json::InstanceInfo {
vanilla_name: "1.20.1".to_string(),
loader: Some("fabric".to_string()),
loader_version: Some("0.15.11".to_string()),
adjuncts: Vec::new(),
};
let dependencies = build_dependencies(&info).await.unwrap();
assert_eq!(
dependencies.get(&PackDependency::Minecraft),
Some(&"1.20.1".to_string())
);
assert_eq!(
dependencies.get(&PackDependency::FabricLoader),
Some(&"0.15.11".to_string())
);
}
#[tokio::test]
async fn labymod_and_unknown_loaders_never_become_vanilla() {
for loader in ["labymod", "unknown_loader"] {
let info = instance_json::InstanceInfo {
vanilla_name: "1.20.1".to_string(),
loader: Some(loader.to_string()),
loader_version: Some("1.0".to_string()),
adjuncts: Vec::new(),
};
let error = build_dependencies(&info).await.unwrap_err();
assert!(error.to_string().contains("Unsupported loader"));
}
}
}

View File

@ -0,0 +1,116 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct HmclConfig {
configurations: HashMap<String, HmclConfiguration>,
}
#[derive(Debug, Deserialize)]
struct HmclConfiguration {
#[serde(rename = "gameDir")]
game_dir: String,
}
fn find_config(base_path: &Path) -> Option<std::path::PathBuf> {
let path = base_path.join(".hmcl").join("hmcl.json");
if path.exists() {
return Some(path);
}
None
}
pub fn config_exists(base_path: &Path) -> bool {
find_config(base_path).is_some()
}
pub fn get_instances(base_path: &Path) -> Vec<(String, String)> {
let Some(config_path) = find_config(base_path) else {
return Vec::new();
};
let Ok(content) = std::fs::read_to_string(&config_path) else {
tracing::warn!(
"hmcl: failed to read config at {}",
config_path.display()
);
return Vec::new();
};
let config: HmclConfig = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"hmcl: failed to parse config at {}: {e}",
config_path.display()
);
return Vec::new();
}
};
let mut instances = Vec::new();
for (key, entry) in &config.configurations {
let game_dir = PathBuf::from(&entry.game_dir);
let resolved = if game_dir.is_absolute() {
game_dir
} else {
base_path.join(&game_dir)
};
if resolved.is_dir() {
instances
.push((key.clone(), resolved.to_string_lossy().to_string()));
}
}
instances.sort_by(|a, b| a.0.cmp(&b.0));
instances
}
pub fn get_instance_path(
base_path: &Path,
instance_key: &str,
) -> Option<String> {
// Reuse get_instances() to avoid parsing the config file twice.
get_instances(base_path)
.into_iter()
.find(|(key, _)| key == instance_key)
.map(|(_, path)| path)
}
/// Returns the configured HMCL game directory when it explicitly owns either
/// the shared `.minecraft` root or this version directory. An explicit entry
/// wins over content-folder heuristics, which cannot distinguish a newly
/// created isolated instance from a shared one.
pub fn configured_game_dir(
base_path: &Path,
dot_minecraft: &Path,
version_dir: &Path,
) -> Option<PathBuf> {
let game_dirs = get_instances(base_path)
.into_iter()
.map(|(_, game_dir)| PathBuf::from(game_dir))
.collect::<Vec<_>>();
game_dirs
.iter()
.find(|game_dir| paths_match(game_dir, version_dir))
.cloned()
.or_else(|| {
game_dirs
.iter()
.find(|game_dir| paths_match(game_dir, dot_minecraft))
.cloned()
})
}
fn paths_match(left: &Path, right: &Path) -> bool {
match (
crate::util::io::canonicalize(left),
crate::util::io::canonicalize(right),
) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}

View File

@ -0,0 +1,89 @@
//! HMCL data directory discovery.
//!
//! HMCL stores its configuration (`launcher-settings.json`) in a data directory
//! that depends on how the launcher was installed. This module finds that
//! directory's root (the `.hmcl` folder itself, not its `config` subfolder)
//! with a threepriority strategy:
//!
//! 1. Portable mode — `{launcher_dir}/.hmcl/config/launcher-settings.json`
//! 2. System install — platformspecific application data directory
//! 3. Environment variable — `$HMCL_DATA_DIR`
use std::path::{Path, PathBuf};
/// Try to locate the HMCL data directory root by probing the three priority
/// levels in order. Returns the first root that contains
/// `config/launcher-settings.json`, or `None` if none does.
pub fn find_hmcl_data_dir(launcher_dir: &Path) -> Option<PathBuf> {
// 1. Portable mode — sidecar `.hmcl` folder next to the launcher jar
let portable = launcher_dir.join(".hmcl");
if portable.join("config/launcher-settings.json").exists() {
return Some(portable);
}
// 2. System install — standard platform data directory
if let Some(system_dir) = system_data_dir()
&& system_dir.join("config/launcher-settings.json").exists()
{
return Some(system_dir);
}
// 3. Environment variable override
if let Ok(env_dir) = std::env::var("HMCL_DATA_DIR") {
let env_path = PathBuf::from(env_dir);
if env_path.join("config/launcher-settings.json").exists() {
return Some(env_path);
}
}
None
}
/// Return the platformspecific HMCL data directory root for a system
/// install.
fn system_data_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
dirs::data_dir().map(|d| d.join(".hmcl"))
}
#[cfg(target_os = "macos")]
{
dirs::data_dir().map(|d| d.join("hmcl"))
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
dirs::data_dir().map(|d| d.join("hmcl"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_system_data_dir_is_some() {
// On any real OS this should return a path (it may or may not exist).
assert!(system_data_dir().is_some());
}
#[test]
fn test_find_hmcl_data_dir_returns_none_for_bogus_path() {
let bogus = Path::new("/tmp/this-does-not-exist-12345");
assert!(find_hmcl_data_dir(bogus).is_none());
}
#[test]
fn test_portable_mode_returns_hmcl_root() {
let dir = tempdir().expect("temp dir");
let config = dir.path().join(".hmcl/config");
std::fs::create_dir_all(&config).expect("create config dir");
std::fs::write(config.join("launcher-settings.json"), "{}")
.expect("write settings");
assert_eq!(
find_hmcl_data_dir(dir.path()),
Some(dir.path().join(".hmcl"))
);
}
}

View File

@ -0,0 +1,840 @@
use std::path::Path;
use serde_json::Value;
use tracing::debug;
pub struct InstanceInfo {
pub vanilla_name: String,
pub loader: Option<String>,
pub loader_version: Option<String>,
pub adjuncts: Vec<(String, Option<String>)>,
}
fn find_json(path: &Path) -> Option<(String, String)> {
let name = path.file_name()?.to_string_lossy().to_string();
let primary = path.join(format!("{name}.json"));
debug!(
"instance_json: path={} looking for primary={}",
path.display(),
primary.display()
);
if primary.exists() {
debug!(
"instance_json: path={} json={} (by name match)",
path.display(),
primary.display()
);
let content = std::fs::read_to_string(&primary).ok()?;
debug!(
"instance_json: path={} primary content (len={}, first_200={:?})",
path.display(),
content.len(),
&content[..content.len().min(200)]
);
return Some((name, content));
}
debug!(
"instance_json: path={} primary={} NOT FOUND, enumerating directory",
path.display(),
primary.display()
);
let mut json_files = Vec::new();
if let Ok(dir) = std::fs::read_dir(path) {
for entry in dir.flatten() {
let p = entry.path();
debug!(
"instance_json: path={} entry={}",
path.display(),
p.display()
);
if p.extension().map(|e| e == "json").unwrap_or(false) {
json_files.push(p);
}
}
}
debug!(
"instance_json: path={} found {} json files",
path.display(),
json_files.len()
);
if json_files.len() == 1 {
debug!(
"instance_json: path={} json={} (sole json fallback)",
path.display(),
json_files[0].display()
);
let content = std::fs::read_to_string(&json_files[0]).ok()?;
debug!(
"instance_json: path={} sole json content (len={}, first_200={:?})",
path.display(),
content.len(),
&content[..content.len().min(200)]
);
let name = json_files[0]
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(name);
return Some((name, content));
}
// Multiple JSONs: try each one, return the first with a valid version.
// A single unreadable or malformed candidate must not abort the loop.
for jf in &json_files {
let Ok(content) = std::fs::read_to_string(jf) else {
debug!(
"instance_json: path={} json={} unreadable, trying next",
path.display(),
jf.display()
);
continue;
};
debug!(
"instance_json: path={} trying json={} (len={}, first_200={:?})",
path.display(),
jf.display(),
content.len(),
&content[..content.len().min(200)]
);
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
else {
debug!(
"instance_json: path={} json={} invalid JSON, trying next",
path.display(),
jf.display()
);
continue;
};
let version = extract_version(&json, &content, None);
if !version.is_empty() {
let fname = jf
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| name.clone());
debug!(
"instance_json: path={} json={} (multiple-json pick, version={})",
path.display(),
jf.display(),
version
);
return Some((fname, content));
}
}
debug!(
"instance_json: path={} multiple={} json files, none yielded a version",
path.display(),
json_files.len()
);
None
}
pub fn detect(path: &Path) -> Option<InstanceInfo> {
let (name, content) = find_json(path)?;
let json: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
debug!("instance_json: path={} parse_err={}", path.display(), e);
return None;
}
};
let mut vanilla_name = extract_version(&json, &content, Some(&name));
debug!(
"instance_json: path={} extract_version returned {:?}",
path.display(),
vanilla_name
);
if vanilla_name.is_empty() {
debug!(
"instance_json: path={} version empty or Unknown",
path.display()
);
return None;
}
vanilla_name = normalize_version(&vanilla_name);
let loader = detect_loader(&content, &json).map(|(loader, version)| {
let version = version.map(|version| {
normalize_imported_loader_version(&loader, &vanilla_name, &version)
});
(loader, version)
});
let adjuncts = detect_adjuncts(
&content,
loader.as_ref().map(|(loader, _)| loader.as_str()),
)
.into_iter()
.map(|(loader, version)| {
let version = version.map(|version| {
normalize_imported_loader_version(&loader, &vanilla_name, &version)
});
(loader, version)
})
.collect();
debug!(
"instance_json: path={} version={} loader={:?}",
path.display(),
vanilla_name,
loader.as_ref().map(|(t, _)| t.as_str())
);
Some(InstanceInfo {
vanilla_name,
loader: loader.as_ref().map(|(t, _)| t.clone()),
loader_version: loader.and_then(|(_, v)| v),
adjuncts,
})
}
fn normalize_version(raw: &str) -> String {
let mut v = raw.to_string();
if (v.starts_with("20.") || v.starts_with("21.")) && !v.starts_with("1.") {
v = format!("1.{v}");
}
v = v.replace("_unobfuscated", "");
v = v.replace(" Unobfuscated", "");
v.trim().to_string()
}
pub(crate) fn normalize_imported_loader_version(
loader: &str,
game_version: &str,
detected_version: &str,
) -> String {
let detected_version = detected_version.trim();
let without_family = match loader {
"fabric" | "legacy_fabric" => detected_version
.strip_prefix("fabric-loader-")
.or_else(|| detected_version.strip_prefix("fabric-")),
"quilt" => detected_version
.strip_prefix("quilt-loader-")
.or_else(|| detected_version.strip_prefix("quilt-")),
"forge" => detected_version.strip_prefix("forge-"),
"neoforge" => detected_version
.strip_prefix("neoforge-")
.or_else(|| detected_version.strip_prefix("neo-")),
_ => None,
}
.unwrap_or(detected_version);
match loader {
"fabric" | "legacy_fabric" | "quilt" => without_family
.strip_suffix(&format!("-{game_version}"))
.unwrap_or(without_family)
.to_string(),
"forge" | "neoforge" => {
let stripped = without_family
.strip_prefix(&format!("{game_version}-"))
.unwrap_or(without_family);
// Legacy Forge (e.g. 1.7.10) embeds the MC version as a trailing
// suffix: `1.7.10-10.13.4.1614-1.7.10` -> `10.13.4.1614`. Strip it so
// the id matches the metadata manifest. When the suffix is absent
// (modern `1.20.1-47.4.22`), keep the already prefix-stripped
// version rather than reverting to the raw detected value.
stripped
.strip_suffix(&format!("-{game_version}"))
.unwrap_or(stripped)
.to_string()
}
_ => without_family.to_string(),
}
}
fn extract_version(
json: &Value,
json_str: &str,
folder_name: Option<&str>,
) -> String {
// ① PCL download record clientVersion
if let Some(v) = json.get("clientVersion").and_then(|v| v.as_str())
&& !v.is_empty()
{
debug!("extract_version: method=① clientVersion value={}", v);
return v.to_string();
}
// ② HMCL patches[].version (id == "game")
if let Some(patches) = json.get("patches").and_then(|v| v.as_array()) {
for patch in patches {
if patch.get("id").and_then(|v| v.as_str()) == Some("game")
&& let Some(ver) = patch.get("version").and_then(|v| v.as_str())
&& !ver.is_empty()
{
debug!(
"extract_version: method=② patches.game.version value={}",
ver
);
return ver.to_string();
}
}
}
// ③ arguments.game --fml.mcVersion (Forge/NeoForge)
if let Some(args) = json
.get("arguments")
.and_then(|v| v.get("game"))
.and_then(|v| v.as_array())
{
let mut mark = false;
for arg in args {
if mark && let Some(v) = arg.as_str() {
debug!("extract_version: method=③ --fml.mcVersion value={}", v);
return v.to_string();
}
if arg.as_str() == Some("--fml.mcVersion") {
mark = true;
}
}
}
// ④ inheritsFrom (version inheritance) — must come before the `jar`
// field, which is not always a version name.
if let Some(v) = json.get("inheritsFrom").and_then(|v| v.as_str())
&& !v.is_empty()
{
debug!("extract_version: method=④ inheritsFrom value={}", v);
return v.to_string();
}
// ⑤ libraries string regex fallback (Forge/OptiFine/FabricLike lib versions)
// Use the original JSON string (from find_json) instead of re-serializing
// the parsed Value, which would allocate a fresh string unnecessarily.
if let Some(v) = extract_version_from_libraries(json_str) {
debug!("extract_version: method=⑤ libraries value={}", v);
return v;
}
// ⑥ JSON id field → extract leading version
if let Some(id) = json.get("id").and_then(|v| v.as_str())
&& let Some(v) = extract_version_from_id(id)
{
debug!("extract_version: method=⑥ id id={} value={}", id, v);
return v;
}
// ⑦ jar field (legacy versions store the base game in `jar`)
if let Some(v) = json.get("jar").and_then(|v| v.as_str())
&& !v.is_empty()
{
debug!("extract_version: method=⑦ jar value={}", v);
return v.to_string();
}
// ⑧ folder name fallback (renamed / non-standard instances)
if let Some(name) = folder_name
&& let Some(v) = extract_version_from_id(name)
{
debug!("extract_version: method=⑧ folder_name value={}", v);
return v;
}
debug!("extract_version: method=✗ all methods failed");
String::new()
}
/// Extracts Minecraft version from library artifact coordinates in the JSON string.
/// Matches PCLCE's approach scanning for Forge/OptiFine/FabricLike lib entries.
/// Order: NeoForge before Forge (NeoForge JSON often also contains forge references).
fn extract_version_from_libraries(content: &str) -> Option<String> {
// NeoForge: net.neoforged:neoforge:1.20.1-44.0.3 → "1.20.1"
// Try known Maven coordinate formats (neoforge before forge).
for needle in [
"net.neoforged:neoforge:",
"net.neoforged.neoforge:neoforge:",
"net.neoforged.fml:modern:",
] {
if let Some(pos) = content.find(needle) {
let after = &content[pos + needle.len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(dash) = ver.find('-') {
return Some(ver[..dash].to_string());
}
return Some(ver.to_string());
}
}
}
// Forge: minecraftforge:forge:1.8.9-11.15.1.1722 → "1.8.9"
// net.minecraftforge:forge:1.21.1-52.0.0 (modern Forge, 1.13+)
for needle in ["minecraftforge:forge:", "net.minecraftforge:forge:"] {
if let Some(pos) = content.find(needle) {
let after = &content[pos + needle.len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(dash) = ver.find('-') {
return Some(ver[..dash].to_string());
}
return Some(ver.to_string());
}
}
}
// OptiFine: optifine:OptiFine:1.8.9_HD_U_H5 → "1.8.9"
if let Some(pos) = content.find("optifine:OptiFine:") {
let after = &content[pos + "optifine:OptiFine:".len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(underscore) = ver.find('_') {
return Some(ver[..underscore].to_string());
}
return Some(ver.to_string());
}
}
// Fabric-like: net.fabricmc:fabric-loader:0.15.11-1.20.1 → "1.20.1"
if let Some(pos) = content.find("net.fabricmc:fabric-loader:") {
let after = &content[pos + "net.fabricmc:fabric-loader:".len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(dash) = ver.rfind('-') {
return Some(ver[dash + 1..].to_string());
}
}
}
None
}
/// Extracts leading version number from the instance id.
/// e.g. "1.8.9-forge-11.15.1.1722" → "1.8.9"
/// Skips hash-like ids (≥32 chars, no separators).
fn extract_version_from_id(id: &str) -> Option<String> {
let ver = id.trim();
if ver.is_empty() {
return None;
}
if ver.len() >= 32
&& !ver.contains('.')
&& !ver.contains('-')
&& !ver.contains('_')
{
return None;
}
if let Some(first_sep) = ver.find(['-', '_', ' ']) {
let candidate = &ver[..first_sep];
if candidate.starts_with("1.") || candidate.starts_with('2') {
return Some(candidate.to_string());
}
}
if ver.starts_with("1.") || ver.starts_with('2') {
return Some(ver.to_string());
}
None
}
///参考自PCL启动器
fn detect_loader(
content: &str,
json: &Value,
) -> Option<(String, Option<String>)> {
let lower = content.to_lowercase();
// LabyMod
if lower.contains("labymod_data") {
let version = json
.get("labymod_data")
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
return Some(("labymod".into(), version));
}
// Legacy Fabric
if lower.contains("net.legacyfabric:intermediary") {
let version = try_extract_version_from_needle(
content,
"net.fabricmc:fabric-loader:",
None,
);
return Some(("legacy_fabric".into(), version));
}
// Fabric
if lower.contains("net.fabricmc:fabric-loader") {
let version = try_extract_version_from_needle(
content,
"net.fabricmc:fabric-loader:",
None,
);
return Some(("fabric".into(), version));
}
// Quilt
if lower.contains("org.quiltmc:quilt-loader") {
let version = try_extract_version_from_needle(
content,
"org.quiltmc:quilt-loader:",
None,
);
return Some(("quilt".into(), version));
}
// Cleanroom
if lower.contains("com.cleanroommc:cleanroom:") {
let version = try_extract_version_from_needle(
content,
"com.cleanroommc:cleanroom:",
None,
);
return Some(("cleanroom".into(), version));
}
// Forge
if lower.contains("minecraftforge") && !lower.contains("net.neoforge") {
let version = try_extract_version_from_needle(
content,
"minecraftforge:forge:",
None,
)
.or_else(|| {
try_extract_version_from_needle(
content,
"net.minecraftforge:forge:",
None,
)
})
.or_else(|| {
try_extract_version_from_needle(
content,
"net.minecraftforge:fmlloader:",
None,
)
});
return Some(("forge".into(), version));
}
// NeoForge
if lower.contains("net.neoforge") {
let version = try_extract_version_from_needle(
content,
"net.neoforged:neoforge:",
None,
)
.or_else(|| {
try_extract_version_from_needle(
content,
"net.neoforged.neoforge:neoforge:",
None,
)
});
return Some(("neoforge".into(), version));
}
// OptiFine
if lower.contains("optifine") {
let version = try_extract_version_from_needle(
content,
"optifine:OptiFine:",
None,
);
if version.is_some() {
return Some(("optifine".into(), version));
}
}
// LiteLoader
if lower.contains("liteloader") {
return Some(("lite_loader".into(), None));
}
debug!("detect_loader: no known loader library found in JSON content");
None
}
fn detect_adjuncts(
content: &str,
primary_loader: Option<&str>,
) -> Vec<(String, Option<String>)> {
let lower = content.to_ascii_lowercase();
let mut adjuncts = Vec::new();
if primary_loader != Some("lite_loader") && lower.contains("liteloader") {
adjuncts.push(("lite_loader".to_string(), None));
}
if primary_loader != Some("optifine") && lower.contains("optifine") {
let version = try_extract_version_from_needle(
content,
"optifine:OptiFine:",
None,
)
.or_else(|| {
try_extract_version_from_needle(&lower, "optifine:optifine:", None)
});
if version.is_some() {
adjuncts.push(("optifine".to_string(), version));
}
}
if lower.contains("optifabric") {
let version = try_extract_version_from_needle(
&lower,
"me.modmuss50:optifabric:",
None,
)
.or_else(|| {
try_extract_version_from_needle(
&lower,
"optifabric:optifabric:",
None,
)
});
adjuncts.push(("optifabric".to_string(), version));
}
adjuncts
}
/// Extracts the loader version string from JSON content by finding a needle
/// and reading until a terminator character.
fn try_extract_version_from_needle(
content: &str,
needle: &str,
split_at: Option<char>,
) -> Option<String> {
let pos = content.find(needle)?;
let after = &content[pos + needle.len()..];
let end = after.find(&['"', ',', '\n', '}'] as &[char])?;
let ver = &after[..end];
if let Some(ch) = split_at
&& let Some(pos) = ver.rfind(ch)
{
Some(ver[pos + 1..].to_string())
} else {
Some(ver.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn detect_from_json(content: &str) -> Option<(String, Option<String>)> {
let json: Value = serde_json::from_str(content).expect("test JSON");
detect_loader(content, &json)
}
fn assert_loader(
content: &str,
expected: &str,
expected_version: Option<&str>,
) {
let (loader, version) = detect_from_json(content)
.unwrap_or_else(|| panic!("expected loader {expected}, got None"));
assert_eq!(loader, expected);
assert_eq!(version.as_deref(), expected_version);
}
#[test]
fn test_forge() {
assert_loader(
r#"{
"id": "1.21.1-forge-52.0.0",
"libraries": [
{
"name": "net.minecraftforge:forge:1.21.1-52.0.0"
}
]
}"#,
"forge",
Some("1.21.1-52.0.0"),
);
}
#[test]
fn normalizes_imported_loader_versions_for_central_resolution() {
for (loader, game_version, detected, expected) in [
("fabric", "1.20.1", "0.15.11-1.20.1", "0.15.11"),
("quilt", "1.20.1", "0.26.4-1.20.1", "0.26.4"),
(
"forge",
"1.7.10",
"1.7.10-10.13.4.1614-1.7.10",
"10.13.4.1614",
),
("forge", "1.20.1", "1.20.1-47.4.22", "47.4.22"),
("neoforge", "1.20.1", "1.20.1-44.0.3", "44.0.3"),
("neoforge", "1.21.4", "21.4.157", "21.4.157"),
] {
assert_eq!(
normalize_imported_loader_version(
loader,
game_version,
detected
),
expected,
"{loader} {game_version} {detected}"
);
}
}
#[test]
fn test_neoforge() {
assert_loader(
r#"{
"id": "1.20.1-neoforge-44.0.3",
"libraries": [
{
"name": "net.neoforged:neoforge:1.20.1-44.0.3"
}
]
}"#,
"neoforge",
Some("1.20.1-44.0.3"),
);
}
#[test]
fn test_fabric() {
assert_loader(
r#"{
"id": "1.20.1-fabric-0.15.11",
"libraries": [
{
"name": "net.fabricmc:fabric-loader:0.15.11-1.20.1"
}
]
}"#,
"fabric",
Some("0.15.11-1.20.1"),
);
}
#[test]
fn test_quilt() {
assert_loader(
r#"{
"id": "1.20.1-quilt-0.26.4",
"libraries": [
{
"name": "org.quiltmc:quilt-loader:0.26.4-1.20.1"
}
]
}"#,
"quilt",
Some("0.26.4-1.20.1"),
);
}
#[test]
fn test_optifine() {
assert_loader(
r#"{
"id": "1.8.9-OptiFine",
"libraries": [
{
"name": "optifine:OptiFine:1.8.9_HD_U_H5"
}
]
}"#,
"optifine",
Some("1.8.9_HD_U_H5"),
);
}
#[test]
fn detects_forge_with_liteloader_and_optifine_adjuncts() {
let content = r#"{
"id": "1.12.2-forge-combined",
"libraries": [
{ "name": "net.minecraftforge:forge:1.12.2-14.23.5.2860" },
{ "name": "com.mumfrey:liteloader:1.12.2-SNAPSHOT" },
{ "name": "optifine:OptiFine:1.12.2_HD_U_G5" }
]
}"#;
let json: Value = serde_json::from_str(content).unwrap();
let primary = detect_loader(content, &json).unwrap();
let adjuncts = detect_adjuncts(content, Some(&primary.0));
assert_eq!(primary.0, "forge");
assert_eq!(
adjuncts,
vec![
("lite_loader".to_string(), None),
("optifine".to_string(), Some("1.12.2_HD_U_G5".to_string())),
]
);
}
#[test]
fn test_legacy_fabric() {
assert_loader(
r#"{
"id": "1.8.9-legacy-fabric-0.13.1.4",
"libraries": [
{
"name": "net.legacyfabric:intermediary:1.8.9"
},
{
"name": "net.fabricmc:fabric-loader:0.13.1.4-1.8.9"
}
]
}"#,
"legacy_fabric",
Some("0.13.1.4-1.8.9"),
);
}
#[test]
fn test_cleanroom() {
assert_loader(
r#"{
"id": "1.12.2-cleanroom-7.1.0",
"libraries": [
{
"name": "com.cleanroommc:cleanroom:1.12.2-7.1.0"
}
]
}"#,
"cleanroom",
Some("1.12.2-7.1.0"),
);
}
#[test]
fn test_labymod() {
assert_loader(
r#"{
"id": "1.20.1-labymod",
"labymod_data": {
"version": "4.4.20"
}
}"#,
"labymod",
Some("4.4.20"),
);
}
#[test]
fn test_lite_loader() {
assert_loader(
r#"{
"id": "1.12.2-LiteLoader-1.12.2-SNAPSHOT",
"libraries": [
{
"name": "com.mumfrey:liteloader:1.12.2"
}
]
}"#,
"lite_loader",
None,
);
}
#[test]
fn test_no_loader() {
assert!(detect_from_json(r#"{"id": "1.20.4"}"#).is_none());
}
#[test]
fn test_detect_end_to_end() {
let dir = tempfile::tempdir().expect("temp dir");
let instance = dir.path().join(".minecraft");
std::fs::create_dir(&instance).expect("create .minecraft dir");
std::fs::write(
instance.join(".minecraft.json"),
r#"{
"id": "1.20.1-fabric-0.15.11",
"libraries": [
{
"name": "net.fabricmc:fabric-loader:0.15.11-1.20.1"
}
]
}"#,
)
.expect("write instance json");
let info = detect(&instance).expect("detect should succeed");
assert_eq!(info.vanilla_name, "1.20.1");
assert_eq!(info.loader.as_deref(), Some("fabric"));
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
}
}

View File

@ -0,0 +1,440 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize, de};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
pack::{
import::{self, finish_import},
install_from::{self, CreatePackDescription, PackDependency},
},
util::io,
};
// instance.cfg
// https://github.com/PrismLauncher/PrismLauncher/blob/develop/launcher/minecraft/MinecraftInstance.cpp
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
#[serde(untagged)]
enum MMCInstanceEnum {
General(MMCInstanceGeneral),
Instance(MMCInstance),
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct MMCInstanceGeneral {
pub general: MMCInstance,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
pub struct MMCInstance {
pub java_path: Option<String>,
pub jvm_args: Option<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_bool")]
pub managed_pack: Option<bool>,
#[serde(rename = "ManagedPackID")]
pub managed_pack_id: Option<String>,
pub managed_pack_type: Option<MMCManagedPackType>,
#[serde(rename = "ManagedPackVersionID")]
pub managed_pack_version_id: Option<String>,
pub managed_pack_version_name: Option<String>,
#[serde(rename = "iconKey")]
pub icon_key: Option<String>,
#[serde(rename = "name")]
pub name: Option<String>,
}
// serde_ini reads 'true' and 'false' as strings, so we need to convert them to booleans
fn deserialize_optional_bool<'de, D>(
deserializer: D,
) -> Result<Option<bool>, D::Error>
where
D: de::Deserializer<'de>,
{
let s = Option::<String>::deserialize(deserializer)?;
match s {
Some(string) => match string.as_str() {
"true" => Ok(Some(true)),
"false" => Ok(Some(false)),
_ => Err(de::Error::custom("expected 'true' or 'false'")),
},
None => Ok(None),
}
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum MMCManagedPackType {
Modrinth,
Flame,
ATLauncher,
#[serde(other)]
Unknown,
}
// mmc-pack.json
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MMCPack {
components: Vec<MMCComponent>,
format_version: u32,
}
// https://github.com/PrismLauncher/PrismLauncher/blob/develop/launcher/minecraft/Component.h
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MMCComponent {
pub uid: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub dependency_only: bool,
#[serde(default)]
pub important: bool,
#[serde(default)]
pub disabled: bool,
pub cached_name: Option<String>,
pub cached_version: Option<String>,
#[serde(default)]
pub cached_requires: Vec<MMCComponentRequirement>,
#[serde(default)]
pub cached_conflicts: Vec<MMCComponentRequirement>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MMCComponentRequirement {
pub uid: String,
pub equals_version: Option<String>,
pub suggests: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
#[serde(untagged)]
enum MMCLauncherEnum {
General(MMCLauncherGeneral),
Instance(MMCLauncher),
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct MMCLauncherGeneral {
pub general: MMCLauncher,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
pub struct MMCLauncher {
instance_dir: String,
}
// Checks if if its a folder, and the folder contains instance.cfg and mmc-pack.json, and they both parse
#[tracing::instrument]
pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
let instance_cfg = instance_folder.join("instance.cfg");
let mmc_pack = instance_folder.join("mmc-pack.json");
let Ok((mmc_pack, _)) = io::read_any_encoding_to_string(&mmc_pack).await
else {
return false;
};
load_instance_cfg(&instance_cfg).await.is_ok()
&& serde_json::from_str::<MMCPack>(&mmc_pack).is_ok()
}
#[tracing::instrument]
pub async fn get_instances_subpath(config: PathBuf) -> Option<String> {
let launcher = io::read_any_encoding_to_string(&config).await.ok()?.0;
let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?;
match launcher {
MMCLauncherEnum::General(p) => Some(p.general.instance_dir),
MMCLauncherEnum::Instance(p) => Some(p.instance_dir),
}
}
// Loading the INI (instance.cfg) file
async fn load_instance_cfg(file_path: &Path) -> crate::Result<MMCInstance> {
match serde_ini::from_str::<MMCInstanceEnum>(
&io::read_any_encoding_to_string(file_path).await?.0,
)? {
MMCInstanceEnum::General(instance_cfg) => Ok(instance_cfg.general),
MMCInstanceEnum::Instance(instance_cfg) => Ok(instance_cfg),
}
}
// #[tracing::instrument]
pub(crate) async fn import_mmc_instance_dir(
mmc_instance_path: PathBuf,
icons_dir: Option<PathBuf>,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let mmc_pack = serde_json::from_str::<MMCPack>(
&io::read_any_encoding_to_string(
&mmc_instance_path.join("mmc-pack.json"),
)
.await?
.0,
)?;
let instance_cfg =
load_instance_cfg(&mmc_instance_path.join("instance.cfg")).await?;
// Re-cache icon
let icon = if let Some(icon_key) = instance_cfg.icon_key {
let mut icon = None;
for icon_dir in
icons_dir.iter().chain(std::iter::once(&mmc_instance_path))
{
let icon_path = icon_dir.join(&icon_key);
icon = import::recache_icon(icon_path).await?;
if icon.is_none() {
let icon_path = icon_dir.join(format!("{icon_key}.png"));
icon = import::recache_icon(icon_path).await?;
}
if icon.is_some() {
break;
}
}
icon
} else {
None
};
// Create description from instance.cfg
let mut description = CreatePackDescription {
icon,
override_title: instance_cfg.name,
project_id: None,
version_id: None,
instance_id: instance_id.to_string(),
source_filename: None,
};
let mut minecraft_folder = mmc_instance_path.join("minecraft");
if !minecraft_folder.is_dir() {
minecraft_folder = mmc_instance_path.join(".minecraft");
if !minecraft_folder.is_dir() {
return Err(crate::ErrorKind::InputError(
"Instance is missing Minecraft directory".to_string(),
)
.into());
}
}
// Managed pack
if instance_cfg.managed_pack.unwrap_or(false) {
match instance_cfg.managed_pack_type {
Some(MMCManagedPackType::Modrinth) => {
description.project_id = instance_cfg.managed_pack_id;
description.version_id = instance_cfg.managed_pack_version_id;
// Modrinth Managed Pack
// Kept separate as we may in the future want to add special handling for modrinth managed packs
import_mmc_unmanaged(instance_id, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack, reporter, details, symlink).await?;
}
Some(MMCManagedPackType::Flame | MMCManagedPackType::ATLauncher) => {
// For flame/atlauncher managed packs
// Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft'
import_mmc_unmanaged(instance_id, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack, reporter, details, symlink).await?;
},
Some(_) => {
// For managed packs that aren't modrinth, flame, atlauncher
// Treat as unmanaged
import_mmc_unmanaged(instance_id, minecraft_folder, "ImportedModpack".to_string(), description, mmc_pack, reporter, details, symlink).await?;
},
_ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into())
}
} else {
// Directly import unmanaged pack
import_mmc_unmanaged(
instance_id,
minecraft_folder,
"Imported Modpack".to_string(),
description,
mmc_pack,
reporter,
details,
symlink,
)
.await?;
}
Ok(())
}
async fn import_mmc_unmanaged(
instance_id: &str,
minecraft_folder: PathBuf,
backup_name: String,
description: CreatePackDescription,
mmc_pack: MMCPack,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let dependencies = mmc_dependencies(&mmc_pack)?;
install_from::set_instance_information(
instance_id.to_string(),
&description,
&backup_name,
None,
&dependencies,
false,
)
.await?;
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
finish_import(
instance_id,
minecraft_folder,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await?;
Ok(())
}
fn mmc_dependencies(
mmc_pack: &MMCPack,
) -> crate::Result<std::collections::HashMap<PackDependency, String>> {
let mut dependencies = std::collections::HashMap::new();
let has_legacy_fabric = mmc_pack.components.iter().any(|component| {
component
.uid
.to_ascii_lowercase()
.starts_with("net.legacyfabric")
});
let has_cleanroom = mmc_pack.components.iter().any(|component| {
component
.uid
.to_ascii_lowercase()
.starts_with("com.cleanroommc")
});
for component in &mmc_pack.components {
let uid = component.uid.to_ascii_lowercase();
if uid.contains("labymod") {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
let dependency = if uid.starts_with("net.fabricmc.fabric-loader") {
Some(if has_legacy_fabric {
PackDependency::LegacyFabric
} else {
PackDependency::FabricLoader
})
} else if uid.starts_with("net.legacyfabric") {
Some(PackDependency::LegacyFabric)
} else if uid.starts_with("net.minecraftforge") {
(!has_cleanroom).then_some(PackDependency::Forge)
} else if uid.starts_with("net.neoforged") {
Some(PackDependency::NeoForge)
} else if uid.starts_with("org.quiltmc.quilt-loader") {
Some(PackDependency::QuiltLoader)
} else if uid.starts_with("com.cleanroommc") {
Some(PackDependency::Cleanroom)
} else if uid.contains("liteloader") {
Some(PackDependency::LiteLoader)
} else if uid.contains("optifabric") {
Some(PackDependency::OptiFabric)
} else if uid.contains("optifine") {
Some(PackDependency::OptiFine)
} else if uid.starts_with("net.minecraft") {
Some(PackDependency::Minecraft)
} else {
None
};
if let Some(dependency) = dependency {
let version = component
.version
.clone()
.filter(|v| !v.is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"MultiMC component {} is missing its version",
component.uid
))
})?;
dependencies.insert(dependency, version);
}
}
Ok(dependencies)
}
#[cfg(test)]
mod tests {
use super::*;
fn component(uid: &str, version: &str) -> MMCComponent {
MMCComponent {
uid: uid.to_string(),
version: Some(version.to_string()),
dependency_only: false,
important: true,
disabled: false,
cached_name: None,
cached_version: None,
cached_requires: Vec::new(),
cached_conflicts: Vec::new(),
}
}
#[test]
fn dependencies_preserve_legacy_fabric_and_optifine_components() {
let pack = MMCPack {
format_version: 1,
components: vec![
component("net.minecraft", "1.8.9"),
component("net.legacyfabric.intermediary", "1.8.9"),
component("net.fabricmc.fabric-loader", "0.13.1.4"),
component("optifine.OptiFine", "1.8.9_HD_U_M6_pre2"),
component("optifabric.OptiFabric", "1.13.16"),
],
};
let dependencies = mmc_dependencies(&pack).unwrap();
assert_eq!(
dependencies.get(&PackDependency::LegacyFabric),
Some(&"0.13.1.4".to_string())
);
assert_eq!(
dependencies.get(&PackDependency::OptiFine),
Some(&"1.8.9_HD_U_M6_pre2".to_string())
);
assert_eq!(
dependencies.get(&PackDependency::OptiFabric),
Some(&"1.13.16".to_string())
);
}
#[test]
fn dependencies_reject_labymod_components() {
let pack = MMCPack {
format_version: 1,
components: vec![component("net.labymod.LabyMod", "4.4.20")],
};
assert!(mmc_dependencies(&pack).is_err());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,254 @@
use std::{collections::HashMap, path::PathBuf};
use sqlx::{
Row,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use crate::{
State,
api::pack::{
import::finish_import,
install_from::{self, CreatePackDescription, PackDependency},
},
install::{InstallPhaseDetails, InstallProgressReporter},
};
async fn open_source_db(
base_path: &PathBuf,
) -> crate::Result<sqlx::SqlitePool> {
let db_path = base_path.join("app.db");
let options = SqliteConnectOptions::new()
.filename(db_path)
.read_only(true)
.create_if_missing(false);
Ok(SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await?)
}
async fn source_config_dir(
base_path: &PathBuf,
pool: &sqlx::SqlitePool,
) -> crate::Result<PathBuf> {
let custom_dir: Option<String> =
sqlx::query_scalar("SELECT custom_dir FROM settings WHERE id = 0")
.fetch_optional(pool)
.await?
.flatten();
Ok(custom_dir.map_or_else(|| base_path.clone(), PathBuf::from))
}
async fn has_table(
pool: &sqlx::SqlitePool,
table: &str,
) -> crate::Result<bool> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(pool)
.await?;
Ok(count > 0)
}
pub async fn get_importable_instances_with_paths(
base_path: PathBuf,
) -> crate::Result<Vec<(String, PathBuf)>> {
let pool = open_source_db(&base_path).await?;
let config_dir = source_config_dir(&base_path, &pool).await?;
let profiles_dir = config_dir.join("profiles");
let rows = if has_table(&pool, "instances").await? {
sqlx::query("SELECT path FROM instances ORDER BY name COLLATE NOCASE")
.fetch_all(&pool)
.await?
} else {
sqlx::query("SELECT path FROM profiles ORDER BY name COLLATE NOCASE")
.fetch_all(&pool)
.await?
};
Ok(rows
.into_iter()
.filter_map(|row| {
let path = row.try_get::<String, _>("path").ok()?;
let full = profiles_dir.join(&path);
full.is_dir().then_some((path, full))
})
.collect())
}
pub async fn get_importable_instances(
base_path: PathBuf,
) -> crate::Result<Vec<String>> {
get_importable_instances_with_paths(base_path)
.await
.map(|v| v.into_iter().map(|(n, _)| n).collect())
}
fn dependencies(
game_version: String,
loader: String,
loader_version: Option<String>,
) -> crate::Result<HashMap<PackDependency, String>> {
let mut dependencies =
HashMap::from([(PackDependency::Minecraft, game_version)]);
let loader = loader.trim().to_ascii_lowercase();
if loader == "vanilla" || loader.is_empty() {
return Ok(dependencies);
}
if loader == "labymod" || loader.starts_with("labymod-") {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
let dependency = match loader.as_str() {
"fabric" => PackDependency::FabricLoader,
"forge" => PackDependency::Forge,
"neoforge" | "neo_forge" => PackDependency::NeoForge,
"quilt" => PackDependency::QuiltLoader,
"optifine" => PackDependency::OptiFine,
"cleanroom" => PackDependency::Cleanroom,
"lite_loader" | "liteloader" => PackDependency::LiteLoader,
"legacy_fabric" | "legacyfabric" => PackDependency::LegacyFabric,
_ => {
return Err(crate::ErrorKind::InputError(format!(
"Unsupported loader {loader}: the instance was not imported as Vanilla"
))
.into());
}
};
let loader_version = loader_version
.filter(|version| !version.trim().is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modrinth source instance is missing the {loader} version and was not imported as Vanilla"
))
})?;
dependencies.insert(dependency, loader_version);
Ok(dependencies)
}
pub async fn import_instance(
base_path: PathBuf,
instance_path: String,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let pool = open_source_db(&base_path).await?;
let config_dir = source_config_dir(&base_path, &pool).await?;
let source = config_dir.join("profiles").join(&instance_path);
let (name, game_version, loader, loader_version, icon_path) = if has_table(
&pool,
"instances",
)
.await?
{
let row = sqlx::query(
"SELECT i.name, s.game_version, s.loader, s.loader_version, i.icon_path \
FROM instances i JOIN instance_content_sets s \
ON s.id = i.applied_content_set_id WHERE i.path = ?",
)
.bind(&instance_path)
.fetch_one(&pool)
.await?;
(
row.try_get("name")?,
row.try_get("game_version")?,
row.try_get("loader")?,
row.try_get("loader_version")?,
row.try_get::<Option<String>, _>("icon_path")?,
)
} else {
let row = sqlx::query(
"SELECT name, game_version, mod_loader AS loader, \
mod_loader_version AS loader_version, icon_path \
FROM profiles WHERE path = ?",
)
.bind(&instance_path)
.fetch_one(&pool)
.await?;
(
row.try_get("name")?,
row.try_get("game_version")?,
row.try_get("loader")?,
row.try_get("loader_version")?,
row.try_get::<Option<String>, _>("icon_path")?,
)
};
let icon = match icon_path {
Some(path) => super::recache_icon(config_dir.join(path)).await?,
None => None,
};
let description = CreatePackDescription {
icon,
override_title: Some(name),
project_id: None,
version_id: None,
instance_id: instance_id.to_string(),
source_filename: None,
};
install_from::set_instance_information(
instance_id.to_string(),
&description,
"Imported from Modrinth source installation",
None,
&dependencies(game_version, loader, loader_version)?,
false,
)
.await?;
let state = State::get().await?;
finish_import(
instance_id,
source,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_dependencies_preserve_supported_loaders() {
let dependencies = dependencies(
"1.12.2".to_string(),
"cleanroom".to_string(),
Some("0.6.11-alpha".to_string()),
)
.unwrap();
assert_eq!(
dependencies.get(&PackDependency::Cleanroom),
Some(&"0.6.11-alpha".to_string())
);
}
#[test]
fn source_dependencies_reject_labymod_and_missing_versions() {
for result in [
dependencies(
"1.20.1".to_string(),
"labymod".to_string(),
Some("4.4.20".to_string()),
),
dependencies("1.12.2".to_string(), "lite_loader".to_string(), None),
] {
assert!(result.is_err());
}
}
}

View File

@ -0,0 +1,109 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[cfg(target_os = "windows")]
pub fn read_pcl_registry() -> Option<String> {
use winreg::enums::HKEY_CURRENT_USER;
let hkcu = winreg::RegKey::predef(HKEY_CURRENT_USER);
let key = hkcu.open_subkey("SOFTWARE\\PCL").ok()?;
let value: String = key.get_value("LaunchFolders").ok()?;
tracing::debug!(raw = %value, "read_pcl_registry: read LaunchFolders from HKCU\\SOFTWARE\\PCL");
Some(value)
}
#[cfg(not(target_os = "windows"))]
pub fn read_pcl_registry() -> Option<String> {
None
}
#[derive(Debug, Deserialize)]
struct PclCeConfig {
#[serde(rename = "LaunchFolders")]
launch_folders: Option<String>,
}
fn read_pclce_config() -> Option<String> {
let path = dirs::data_dir()?.join("PCLCE").join("config.v1.json");
tracing::debug!(path = %path.display(), "read_pclce_config: attempting to read config file");
let content = std::fs::read_to_string(&path).inspect_err(|e| {
tracing::debug!(path = %path.display(), error = %e, "read_pclce_config: failed to read file");
}).ok()?;
let config: PclCeConfig = serde_json::from_str(&content).inspect_err(|e| {
tracing::debug!(path = %path.display(), error = %e, "read_pclce_config: failed to parse JSON");
}).ok()?;
let launch_folders = config.launch_folders.as_deref().unwrap_or("");
tracing::debug!(launch_folders = %launch_folders, "read_pclce_config: parsed LaunchFolders");
config.launch_folders
}
fn parse_pcl_folders(raw: &str) -> Vec<(String, String)> {
let mut result = Vec::new();
for entry in raw.split('|') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if let Some((name, path)) = entry.split_once('>') {
let path = PathBuf::from(path.trim());
let exists = path.is_dir();
tracing::debug!(
entry = %entry,
name = %name.trim(),
path = %path.display(),
exists = exists,
"parse_pcl_folders: entry"
);
if exists {
result.push((
name.trim().to_string(),
path.to_string_lossy().to_string(),
));
}
} else {
tracing::debug!(entry = %entry, "parse_pcl_folders: malformed entry (no '>' separator)");
}
}
result.sort_by(|a, b| a.0.cmp(&b.0));
tracing::debug!(count = result.len(), raw = %raw, "parse_pcl_folders: done");
result
}
pub fn config_exists() -> bool {
let exists = read_pclce_config().is_some();
tracing::debug!(exists = exists, "config_exists");
exists
}
pub fn get_pcl_instances() -> Vec<(String, String)> {
let raw = read_pcl_registry().unwrap_or_default();
let instances = parse_pcl_folders(&raw);
tracing::info!(count = instances.len(), "get_pcl_instances");
instances
}
pub fn get_pclce_instances() -> Vec<(String, String)> {
let raw = read_pclce_config().unwrap_or_default();
let instances = parse_pcl_folders(&raw);
tracing::info!(count = instances.len(), "get_pclce_instances");
instances
}
/// Checks if a `.minecraft` folder exists next to the launcher (i.e. at
/// `base_path/.minecraft`) and returns it as a `(name, path)` pair suitable
/// for merging into the GameDir list.
pub fn get_local_dotminecraft(base_path: &Path) -> Option<(String, String)> {
let dot_mc = base_path.join(".minecraft");
if dot_mc.is_dir() {
tracing::debug!(
path = %dot_mc.display(),
"get_local_dotminecraft: found .minecraft next to launcher"
);
Some((
".minecraft".to_string(),
dot_mc.to_string_lossy().to_string(),
))
} else {
None
}
}

View File

@ -0,0 +1,148 @@
#[cfg(target_os = "windows")]
mod imp {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
pub fn get_product_name(path: &Path) -> Option<String> {
use windows::Win32::Storage::FileSystem::{
GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW,
};
use windows::core::PCWSTR;
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut unused = 0u32;
let size = unsafe {
GetFileVersionInfoSizeW(
PCWSTR::from_raw(wide.as_ptr()),
Some(&raw mut unused),
)
};
if size == 0 {
return None;
}
let mut buffer = vec![0u8; size as usize];
let ok = unsafe {
GetFileVersionInfoW(
PCWSTR::from_raw(wide.as_ptr()),
Some(0),
size,
buffer.as_mut_ptr() as *mut _,
)
};
if ok.is_err() {
return None;
}
let sub = OsStr::new("\\VarFileInfo\\Translation")
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<u16>>();
let mut lang_ptr = std::ptr::null_mut::<std::ffi::c_void>();
let mut lang_len = 0u32;
let ok = unsafe {
VerQueryValueW(
buffer.as_ptr() as *const _,
PCWSTR::from_raw(sub.as_ptr()),
&raw mut lang_ptr,
&raw mut lang_len,
)
};
if ok.0 == 0 || lang_len == 0 || lang_ptr.is_null() {
return None;
}
// lang_len from VerQueryValueW(Translation) is in bytes, not characters
let lang_len_u16 = (lang_len / 2) as usize;
if lang_len_u16 < 2 {
return None;
}
let lang = unsafe {
std::slice::from_raw_parts(lang_ptr as *const u16, lang_len_u16)
};
let block = format!(
"\\StringFileInfo\\{:04x}{:04x}\\ProductName",
lang[0], lang[1]
);
let block_wide: Vec<u16> = OsStr::new(&block)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut val_ptr = std::ptr::null_mut::<std::ffi::c_void>();
let mut val_len = 0u32;
let ok = unsafe {
VerQueryValueW(
buffer.as_ptr() as *const _,
PCWSTR::from_raw(block_wide.as_ptr()),
&raw mut val_ptr,
&raw mut val_len,
)
};
if ok.0 == 0 || val_len == 0 || val_ptr.is_null() {
return None;
}
let slice = unsafe {
std::slice::from_raw_parts(
val_ptr as *const u16,
val_len as usize - 1,
)
};
String::from_utf16(slice).ok()
}
pub fn folder_has_product(base_path: &Path, product_name: &str) -> bool {
if !base_path.is_dir() {
return false;
}
let Ok(read_dir) = std::fs::read_dir(base_path) else {
return false;
};
for entry in read_dir.flatten() {
let p = entry.path();
if p.extension().map(|e| e == "exe").unwrap_or(false)
&& let Some(product) = get_product_name(&p)
&& product.contains(product_name)
{
return true;
}
}
false
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
use std::path::Path;
pub fn folder_has_product(_base_path: &Path, _product_name: &str) -> bool {
false
}
}
pub use imp::folder_has_product;
pub fn folder_has_product_result(
path: &std::path::Path,
product_name: &str,
) -> Result<bool, String> {
#[cfg(target_os = "windows")]
{
Ok(folder_has_product(path, product_name))
}
#[cfg(not(target_os = "windows"))]
{
let _ = (path, product_name);
Err("PE detection is only available on Windows".to_string())
}
}

View File

@ -0,0 +1,774 @@
use crate::State;
use crate::api::pack::detect::detect_local_pack_sync;
use crate::data::ModLoader;
use crate::install::{
InstallErrorContext, InstallJobEventKind, InstallPhaseDetails,
InstallPhaseId, InstallProgress, InstallProgressReporter,
};
use crate::state::{
AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSourceKind,
EditInstance, InstanceInstallStage, InstanceLink, LoaderComponent,
LoaderComponentKind, LoaderComponentRole, ModrinthProjectId,
ModrinthVersionId, SideType,
};
use crate::util::fetch::{
ContentValidation, DownloadMeta, DownloadReason, DownloadRequest,
FetchProgressFn, Integrity, ResourceClass, download_to_path, fetch,
sha1_file_async, write_cached_icon,
};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::future::Future;
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use zip::ZipArchive;
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFormat {
pub game: String,
pub format_version: i32,
pub version_id: String,
pub name: String,
pub summary: Option<String>,
pub files: Vec<PackFile>,
pub dependencies: HashMap<PackDependency, String>,
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
pub path: SafeRelativeUtf8UnixPathBuf,
pub hashes: HashMap<PackFileHash, String>,
pub env: Option<HashMap<EnvType, SideType>>,
pub downloads: Vec<String>,
pub file_size: u32,
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase", from = "String")]
pub enum PackFileHash {
Sha1,
Sha512,
Unknown(String),
}
impl From<String> for PackFileHash {
fn from(s: String) -> Self {
match s.as_str() {
"sha1" => PackFileHash::Sha1,
"sha512" => PackFileHash::Sha512,
_ => PackFileHash::Unknown(s),
}
}
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase")]
pub enum EnvType {
Client,
Server,
}
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
pub enum PackDependency {
#[serde(rename = "forge")]
Forge,
#[serde(rename = "neoforge")]
#[serde(alias = "neo-forge")]
NeoForge,
#[serde(rename = "fabric-loader")]
FabricLoader,
#[serde(rename = "quilt-loader")]
QuiltLoader,
#[serde(rename = "optifine")]
OptiFine,
#[serde(rename = "cleanroom")]
Cleanroom,
#[serde(rename = "lite_loader", alias = "liteloader")]
LiteLoader,
#[serde(rename = "legacy_fabric", alias = "legacyfabric")]
LegacyFabric,
#[serde(rename = "optifabric")]
OptiFabric,
#[serde(rename = "minecraft")]
Minecraft,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum CreatePackLocation {
// Create a pack from a modrinth version ID (such as a modpack)
FromVersionId {
project_id: String,
version_id: String,
title: String,
icon_url: Option<String>,
},
// Create a pack from a file (such as an .mrpack for installing from a file, or a folder name for importing)
FromFile {
path: PathBuf,
},
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePackInstance {
pub name: String, // the name of the instance and relative path
pub game_version: String, // the game version of the instance
pub modloader: ModLoader, // the modloader to use
pub loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader. defaults to latest
pub icon: Option<PathBuf>, // the icon for the instance
pub icon_url: Option<String>, // the URL icon for an instance during import
pub link: Option<InstanceLink>,
pub unknown_file: bool, // true when pack file isn't found on Modrinth via hash lookup
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
// default
impl Default for CreatePackInstance {
fn default() -> Self {
CreatePackInstance {
name: "Untitled".to_string(),
game_version: "1.19.4".to_string(),
modloader: ModLoader::Vanilla,
loader_version: None,
icon: None,
icon_url: None,
link: None,
unknown_file: false,
skip_install_profile: Some(true),
no_watch: Some(false),
}
}
}
#[derive(Clone)]
pub enum CreatePackFile {
Bytes(bytes::Bytes),
// Local packs can be larger than available memory, so keep them file-backed.
Path(PathBuf),
}
#[derive(Clone)]
pub struct CreatePack {
pub file: CreatePackFile,
pub description: CreatePackDescription,
}
// The hash lookup only gates the unknown-pack warning, so avoid a long blocking scan for huge local packs.
const MAX_LOCAL_FILE_HASH_LOOKUP_SIZE: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct CreatePackDescription {
pub icon: Option<PathBuf>,
pub override_title: Option<String>,
pub project_id: Option<String>,
pub version_id: Option<String>,
pub instance_id: String,
pub source_filename: Option<String>,
}
pub async fn get_instance_from_pack(
location: CreatePackLocation,
) -> crate::Result<CreatePackInstance> {
match location {
CreatePackLocation::FromVersionId {
project_id,
version_id,
title,
icon_url,
} => Ok(CreatePackInstance {
name: title,
icon_url,
link: Some(InstanceLink::ModrinthModpack {
project_id,
version_id,
}),
..Default::default()
}),
CreatePackLocation::FromFile { path } => {
let file_name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Validate ZIP structure before proceeding — fail fast on corrupt archives
// rather than discovering the error later during extraction.
let file = std::fs::File::open(&path)
.map_err(crate::ErrorKind::StdIOError)?;
ZipArchive::new(file).map_err(|e| {
crate::ErrorKind::InputError(format!(
"Invalid or corrupt modpack archive ({}): {e}",
path.display()
))
})?;
// Scan ZIP entry names to detect pack format (no extraction, just
// reads the central directory). This tells us what kind of content
// we're dealing with before any expensive operations.
let _has_known_manifest = detect_local_pack_sync(&path).is_ok();
let is_known_file = if tokio::fs::metadata(&path).await?.len()
<= MAX_LOCAL_FILE_HASH_LOOKUP_SIZE
{
let state = State::get().await?;
let (_, hash) = sha1_file_async(&path).await?;
match CachedEntry::get_file_many(
&[&hash],
Some(CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(files) => !files.is_empty(),
Err(err) => {
tracing::warn!(
"Failed to check Modrinth file hash for {}: {}",
path.display(),
err
);
false
}
}
} else {
false
};
Ok(CreatePackInstance {
name: file_name,
unknown_file: !is_known_file,
..Default::default()
})
}
}
}
#[tracing::instrument(skip(reporter))]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn generate_pack_from_version_id_with_reporter(
project_id: String,
version_id: String,
title: String,
icon_url: Option<String>,
instance_id: String,
reason: DownloadReason,
reporter: InstallProgressReporter,
) -> crate::Result<CreatePack> {
let state = State::get().await?;
let has_icon_url = icon_url.is_some();
let version = CachedEntry::get_version(
&ModrinthVersionId::new(version_id.clone())?,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Invalid version ID specified!".to_string(),
)
})?;
// Update instance with correct loader and game version from the API version metadata,
// so the UI shows accurate info while the pack file is still downloading.
if let Some(game_version) = version.game_versions.first() {
let loader = version
.loaders
.first()
.map(|loader| ModLoader::try_from_string(loader))
.transpose()?
.unwrap_or(ModLoader::Vanilla);
let game_version = game_version.clone();
crate::api::instance::edit(
&instance_id,
EditInstance {
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version),
protocol_version: Some(None),
loader: Some(loader),
loader_version: None,
}),
..EditInstance::default()
},
)
.await?;
}
let pack_file = version
.files
.iter()
.find(|file| file.primary)
.or_else(|| version.files.first())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Specified version has no files".to_string(),
)
})?;
let file_name = Path::new(&pack_file.filename);
if file_name.components().count() != 1
|| !matches!(file_name.components().next(), Some(Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modrinth returned an invalid modpack file name".to_string(),
)
.into());
}
let hash = pack_file.hashes.get("sha1");
let pack_path = state
.directories
.caches_dir()
.join("modpacks")
.join(&project_id)
.join(&version_id)
.join(file_name);
let metadata =
crate::api::instance::get(&instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let download_meta = DownloadMeta {
reason,
game_version: metadata.applied_content_set.game_version.clone(),
loader: metadata.applied_content_set.loader.as_str().to_string(),
dependent_on: Some(version_id.clone()),
};
let details = InstallPhaseDetails::Modpack {
project_id: Some(project_id.clone()),
version_id: Some(version_id.clone()),
title: Some(title.clone()),
};
let mut last_reported_bytes = 0_u64;
let mut progress =
|current: u64,
total: u64|
-> Pin<Box<dyn Future<Output = crate::Result<()>> + Send>> {
let min_delta = (total / 200).max(256 * 1024);
if current < total
&& current.saturating_sub(last_reported_bytes) < min_delta
{
return Box::pin(async { Ok(()) });
}
last_reported_bytes = current;
let reporter = reporter.clone();
let details = details.clone();
Box::pin(async move {
reporter
.update(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current,
total,
secondary: None,
}),
details,
)
.await?;
Ok(())
})
};
let progress = Some(&mut progress as &mut FetchProgressFn<'_>);
let context = InstallErrorContext::new("download modpack file")
.urls(vec![pack_file.url.clone()])
.maybe_expected_hash(hash.cloned())
.expected_size(pack_file.size as u64)
.target_path(pack_path.display().to_string())
.project_id(project_id.clone())
.version_id(version_id.clone())
.build();
reporter.set_context(context).await?;
let item_path = pack_path.display().to_string();
reporter
.update_with_events(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current: 0,
total: pack_file.size.max(1) as u64,
secondary: None,
}),
details.clone(),
vec![InstallJobEventKind::ContentFileQueued {
path: item_path,
bytes_total: Some(pack_file.size as u64),
max_attempts: 5,
}],
)
.await?;
reporter.persist().await?;
let download_result = download_to_path(
DownloadRequest::new(&pack_file.url, ResourceClass::Modpack)
.with_integrity(Integrity {
size: Some(pack_file.size as u64),
sha1: hash.cloned(),
sha512: pack_file.hashes.get("sha512").cloned(),
content: ContentValidation::Jar,
..Integrity::default()
})
.with_h2_range_concurrency(16)
.with_download_meta(download_meta)
.with_install_tracking(
reporter.clone(),
pack_path.display().to_string(),
pack_file.filename.clone(),
),
&pack_path,
&state.download_semaphore,
&state.pool,
progress,
)
.await?;
if download_result.attempts > 0 {
reporter
.record_download_metrics(
download_result.source.as_str(),
download_result.fallback_count as u64,
)
.await?;
}
reporter
.update(InstallPhaseId::ResolvingPack, None, details.clone())
.await?;
let project = CachedEntry::get_project(
&ModrinthProjectId::new(version.project_id.clone())?,
None,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Invalid project ID specified!".to_string(),
)
})?;
// Only fetch the pack icon when icon_url is provided (new profile).
// When installing to an existing profile (e.g. server projects),
// icon_url is None and we preserve the profile's existing icon.
let icon = if has_icon_url {
if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
reporter
.set_context(
InstallErrorContext::new("download modpack icon")
.urls(vec![icon_url.clone()])
.project_id(project_id.clone())
.version_id(version_id.clone())
.build(),
)
.await?;
let icon_bytes = fetch(
&icon_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
)
} else {
None
}
} else {
None
}
} else {
None
};
// Set the icon immediately so the UI shows it during download.
if let Some(ref icon_path) = icon {
let _ = crate::api::instance::edit_icon(
&instance_id,
Some(icon_path.as_path()),
)
.await;
}
Ok(CreatePack {
file: CreatePackFile::Path(pack_path),
description: CreatePackDescription {
icon,
override_title: Some(title),
project_id: Some(project_id),
version_id: Some(version_id),
instance_id,
source_filename: None,
},
})
}
#[tracing::instrument]
pub async fn generate_pack_from_file(
path: PathBuf,
instance_id: String,
) -> crate::Result<CreatePack> {
let source_filename =
path.file_name().map(|x| x.to_string_lossy().to_string());
Ok(CreatePack {
file: CreatePackFile::Path(path),
description: CreatePackDescription {
icon: None,
override_title: None,
project_id: None,
version_id: None,
instance_id,
source_filename,
},
})
}
/// Sets generated instance attributes to the pack ones.
/// This includes the pack name, icon, game version, loader version, and loader
pub async fn set_instance_information(
instance_id: String,
description: &CreatePackDescription,
backup_name: &str,
pack_version_id: Option<&str>,
dependencies: &HashMap<PackDependency, String>,
_ignore_lock: bool,
) -> crate::Result<()> {
let mut game_version: Option<&String> = None;
for (key, value) in dependencies {
if *key == PackDependency::Minecraft {
game_version = Some(value);
}
}
let Some(game_version) = game_version else {
return Err(crate::ErrorKind::InputError(
"Pack did not specify Minecraft version".to_string(),
)
.into());
};
let primary_dependencies = [
(PackDependency::Forge, ModLoader::Forge),
(PackDependency::NeoForge, ModLoader::NeoForge),
(PackDependency::FabricLoader, ModLoader::Fabric),
(PackDependency::QuiltLoader, ModLoader::Quilt),
(PackDependency::Cleanroom, ModLoader::Cleanroom),
(PackDependency::LegacyFabric, ModLoader::LegacyFabric),
]
.into_iter()
.filter(|(dependency, _)| dependencies.contains_key(dependency))
.collect::<Vec<_>>();
if primary_dependencies.len() > 1 {
return Err(crate::ErrorKind::InputError(format!(
"Pack declares incompatible primary loaders: {}",
primary_dependencies
.iter()
.map(|(_, loader)| loader.as_str())
.collect::<Vec<_>>()
.join(", ")
))
.into());
}
let primary_loader = primary_dependencies
.first()
.map(|(_, loader)| *loader)
.unwrap_or(ModLoader::Vanilla);
let mut components = vec![LoaderComponent::new_primary(
instance_id.clone(),
primary_loader,
None,
)];
if let Some((dependency, loader)) = primary_dependencies.first() {
let version = resolve_pack_loader_version(
dependencies,
*dependency,
*loader,
game_version,
)
.await?;
components[0].version = Some(version);
components[0].provider_metadata = Some(serde_json::json!({
"source": "pack"
}));
}
for (dependency, loader, kind) in [
(
PackDependency::LiteLoader,
ModLoader::LiteLoader,
LoaderComponentKind::LiteLoader,
),
(
PackDependency::OptiFine,
ModLoader::OptiFine,
LoaderComponentKind::OptiFine,
),
] {
if dependencies.contains_key(&dependency) {
let version = resolve_pack_loader_version(
dependencies,
dependency,
loader,
game_version,
)
.await?;
components.push(LoaderComponent {
instance_id: instance_id.clone(),
kind,
version: Some(version),
role: LoaderComponentRole::Adjunct,
provider_metadata: Some(serde_json::json!({
"source": "pack"
})),
});
}
}
if let Some(version) = dependencies
.get(&PackDependency::OptiFabric)
.filter(|version| !version.trim().is_empty())
{
components.push(LoaderComponent {
instance_id: instance_id.clone(),
kind: LoaderComponentKind::OptiFabric,
version: Some(version.clone()),
role: LoaderComponentRole::Adjunct,
provider_metadata: Some(serde_json::json!({
"projectId": crate::install::runner::OPTIFABRIC_CURSEFORGE_PROJECT_ID,
"provider": "curseforge",
"source": "pack"
})),
});
}
crate::install::runner::validate_loader_components(&components)?;
let (mod_loader, loader_version) =
crate::state::project_loader_components(&components)?;
let link = match (&description.project_id, &description.version_id) {
(Some(project_id), Some(version_id)) => {
Some(InstanceLink::ModrinthModpack {
project_id: project_id.clone(),
version_id: version_id.clone(),
})
}
_ if description.source_filename.is_some() => {
Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(backup_name.to_string()),
version_number: pack_version_id.map(ToString::to_string),
filename: description.source_filename.clone(),
})
}
_ => None,
};
let source_kind = match &link {
Some(InstanceLink::ModrinthModpack { .. }) => {
Some(ContentSourceKind::ModrinthModpack)
}
Some(InstanceLink::ImportedModpack { .. }) => {
Some(ContentSourceKind::ImportedModpack)
}
_ => None,
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: description
.icon
.as_ref()
.map(|icon| Some(icon.to_string_lossy().to_string())),
link,
content_set_patch: Some(AppliedContentSetPatch {
source_kind,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version),
}),
..EditInstance::default()
},
)
.await?;
let state = State::get().await?;
crate::state::instances::commands::replace_instance_loader_components(
&instance_id,
&components,
&state.pool,
)
.await?;
Ok(())
}
async fn resolve_pack_loader_version(
dependencies: &HashMap<PackDependency, String>,
dependency: PackDependency,
loader: ModLoader,
game_version: &str,
) -> crate::Result<String> {
let requested = dependencies
.get(&dependency)
.filter(|version| !version.trim().is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Pack is missing the {} version",
loader.as_str()
))
})?;
crate::launcher::get_loader_version_from_profile(
game_version,
loader,
Some(requested),
)
.await?
.map(|version| version.id)
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Loader version {requested} is not available for {} {game_version}",
loader.as_str()
))
.into()
})
}

View File

@ -0,0 +1,362 @@
//! Installer for HMCL modpacks.
//!
//! HMCL packs are zips carrying a `modpack.json` with the pack name and game
//! version, optionally with an MCBBS-style `addons` array declaring loaders;
//! bundled content ships in a `minecraft/` folder that maps onto the
//! instance's game directory.
use std::path::PathBuf;
use serde::Deserialize;
use super::archive_util;
use crate::State;
use crate::data::ModLoader;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::pack::detect::HMCL_MANIFEST;
use crate::state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLink,
};
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct HmclManifest {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
game_version: Option<String>,
#[serde(default)]
addons: Vec<HmclAddon>,
}
#[derive(Deserialize, Debug)]
struct HmclAddon {
id: String,
version: String,
}
pub(crate) async fn install_hmcl_pack_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let state = State::get().await?;
let manifest_json = archive_util::read_archive_entry_to_string(
archive_path.clone(),
format!("{base_folder}{HMCL_MANIFEST}"),
)
.await?;
let manifest: HmclManifest = serde_json::from_str(&manifest_json)?;
let mut game_version = manifest
.game_version
.clone()
.filter(|version| !version.trim().is_empty());
let mut loader = ModLoader::Vanilla;
let mut loader_version = None;
let mut optifine_version = None;
let mut lite_loader_version = None;
for addon in &manifest.addons {
match addon.id.to_ascii_lowercase().as_str() {
"game" => {
if game_version.is_none() {
game_version = Some(addon.version.clone());
}
}
"forge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Forge;
loader_version = Some(addon.version.clone());
}
"neoforge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::NeoForge;
loader_version = Some(addon.version.clone());
}
"fabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Fabric;
loader_version = Some(addon.version.clone());
}
"quilt" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Quilt;
loader_version = Some(addon.version.clone());
}
"cleanroom" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Cleanroom;
loader_version = Some(addon.version.clone());
}
"legacy_fabric" | "legacyfabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::LegacyFabric;
loader_version = Some(addon.version.clone());
}
"lite_loader" | "liteloader" => {
lite_loader_version = Some(addon.version.clone());
}
"optifine" => optifine_version = Some(addon.version.clone()),
"labymod" => {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
other => {
return Err(crate::ErrorKind::InputError(format!(
"Unsupported HMCL loader component {other} {}",
addon.version
))
.into());
}
}
}
let Some(game_version) = game_version else {
return Err(crate::ErrorKind::InputError(
"HMCL modpack did not specify a Minecraft version".to_string(),
)
.into());
};
let mut lite_loader_as_adjunct = None;
if let Some(lite_loader_version) = lite_loader_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::LiteLoader;
loader_version = Some(lite_loader_version);
}
ModLoader::Forge => {
lite_loader_as_adjunct = Some(lite_loader_version);
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"LiteLoader is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let mut optifine_as_mod = None;
let mut requires_optifabric = false;
if let Some(optifine_version) = optifine_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::OptiFine;
loader_version = Some(optifine_version);
}
ModLoader::Forge | ModLoader::NeoForge => {
optifine_as_mod = Some(optifine_version);
}
ModLoader::Fabric | ModLoader::LegacyFabric => {
optifine_as_mod = Some(optifine_version);
requires_optifabric = true;
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"OptiFine is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let pack_name = manifest
.name
.clone()
.filter(|name| !name.trim().is_empty())
.or_else(|| {
source_filename.as_ref().map(|name| {
std::path::Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
})
.unwrap_or_else(|| "HMCL Modpack".to_string());
let pack_details = InstallPhaseDetails::Modpack {
project_id: None,
version_id: None,
title: Some(pack_name.clone()),
};
reporter
.update(InstallPhaseId::ResolvingPack, None, pack_details.clone())
.await?;
let lite_loader_as_adjunct = if let Some(requested_version) =
lite_loader_as_adjunct
{
Some(
crate::launcher::get_loader_version_from_profile(
&game_version,
ModLoader::LiteLoader,
Some(&requested_version),
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"No LiteLoader version {requested_version} supports Minecraft {game_version}"
))
})?,
)
} else {
None
};
let optifabric_version = if requires_optifabric {
Some(
crate::install::runner::resolve_optifabric_version(&game_version)
.await?,
)
} else {
None
};
let resolved_loader_version = if loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(pack_name.clone()),
link: Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(pack_name.clone()),
version_number: manifest.version.clone(),
filename: source_filename,
}),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::ImportedModpack),
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(loader),
loader_version: Some(
resolved_loader_version.map(|version| version.id),
),
}),
..EditInstance::default()
},
)
.await?;
let minecraft_install =
super::parallel_minecraft_install::ParallelMinecraftInstall::start(
instance_id.clone(),
reporter.clone(),
);
reporter
.update(
InstallPhaseId::ExtractingOverrides,
None,
pack_details.clone(),
)
.await?;
let instance_path =
crate::api::instance::get_full_path(&instance_id).await?;
archive_util::extract_archive_subdir_for_instance(
instance_id.clone(),
reporter.cancellation_token(),
archive_path,
format!("{base_folder}minecraft/"),
instance_path.clone(),
)
.await?;
minecraft_install.join().await?;
if let Some(lite_loader_version) = lite_loader_as_adjunct {
super::install_mcbbs::install_liteloader_component(
&state,
&instance_id,
&game_version,
loader,
&lite_loader_version,
)
.await?;
}
if let Some(optifine_version) = optifine_as_mod {
super::install_mcbbs::install_optifine_mod(
&state,
&instance_id,
reporter.cancellation_token(),
&game_version,
&optifine_version,
&instance_path,
)
.await?;
super::install_mcbbs::record_optifine_component(
&instance_id,
&optifine_version,
)
.await?;
}
if let Some(optifabric_version) = optifabric_version {
super::install_mcbbs::install_optifabric_component(
&instance_id,
&game_version,
&optifabric_version,
)
.await?;
}
reporter.clear_context().await?;
Ok(())
}

View File

@ -0,0 +1,615 @@
//! Installer for MCBBS modpacks.
//!
//! MCBBS packs are zips carrying either an `mcbbs.packmeta` file or a
//! `manifest.json` with an `addons` array. Game and loader versions come from
//! the addons list, bundled content ships in `overrides/`, and optional
//! launch settings come from `launchInfo`.
use std::path::PathBuf;
use serde::Deserialize;
use super::archive_util;
use crate::State;
use crate::data::ModLoader;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::pack::detect::{CURSEFORGE_MANIFEST, MCBBS_MANIFEST};
use crate::state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLaunchOverridesPatch, InstanceLink,
};
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct McbbsManifest {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
addons: Vec<McbbsAddon>,
#[serde(default)]
files: Vec<McbbsFile>,
#[serde(default)]
launch_info: Option<McbbsLaunchInfo>,
}
#[derive(Deserialize, Debug)]
struct McbbsAddon {
id: String,
version: String,
}
/// A `files` entry; `curse` entries carry CurseForge project/file ids while
/// `addition` entries ship inside the overrides folder and need no download.
#[derive(Deserialize, Debug)]
struct McbbsFile {
#[serde(default, rename = "type")]
type_: Option<String>,
#[serde(default, alias = "projectID", alias = "projectId")]
project_id: Option<u32>,
#[serde(default, alias = "fileID", alias = "fileId")]
file_id: Option<u32>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct McbbsLaunchInfo {
#[serde(default)]
java_argument: Option<serde_json::Value>,
}
fn join_arguments(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::String(value) => vec![value.clone()],
serde_json::Value::Array(values) => values
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
pub(crate) async fn install_mcbbs_pack_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let state = State::get().await?;
let manifest_json = match archive_util::read_archive_entry_to_string(
archive_path.clone(),
format!("{base_folder}{MCBBS_MANIFEST}"),
)
.await
{
Ok(contents) => contents,
Err(_) => {
archive_util::read_archive_entry_to_string(
archive_path.clone(),
format!("{base_folder}{CURSEFORGE_MANIFEST}"),
)
.await?
}
};
let manifest: McbbsManifest = serde_json::from_str(&manifest_json)?;
let mut game_version = None;
let mut loader = ModLoader::Vanilla;
let mut loader_version = None;
let mut optifine_version = None;
let mut lite_loader_version = None;
for addon in &manifest.addons {
match addon.id.to_ascii_lowercase().as_str() {
"game" => game_version = Some(addon.version.clone()),
"forge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Forge;
loader_version = Some(addon.version.clone());
}
"neoforge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::NeoForge;
loader_version = Some(addon.version.clone());
}
"fabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Fabric;
loader_version = Some(addon.version.clone());
}
"quilt" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Quilt;
loader_version = Some(addon.version.clone());
}
"cleanroom" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Cleanroom;
loader_version = Some(addon.version.clone());
}
"legacy_fabric" | "legacyfabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::LegacyFabric;
loader_version = Some(addon.version.clone());
}
"lite_loader" | "liteloader" => {
lite_loader_version = Some(addon.version.clone());
}
"optifine" => optifine_version = Some(addon.version.clone()),
"labymod" => {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
other => {
return Err(crate::ErrorKind::InputError(format!(
"Unsupported MCBBS loader component {other} {}",
addon.version
))
.into());
}
}
}
let Some(game_version) = game_version else {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack did not specify a Minecraft version".to_string(),
)
.into());
};
let mut lite_loader_as_adjunct = None;
if let Some(lite_loader_version) = lite_loader_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::LiteLoader;
loader_version = Some(lite_loader_version);
}
ModLoader::Forge => {
lite_loader_as_adjunct = Some(lite_loader_version);
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"LiteLoader is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let mut optifine_as_mod = None;
let mut requires_optifabric = false;
if let Some(optifine_version) = optifine_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::OptiFine;
loader_version = Some(optifine_version);
}
ModLoader::Forge | ModLoader::NeoForge => {
optifine_as_mod = Some(optifine_version);
}
ModLoader::Fabric | ModLoader::LegacyFabric => {
optifine_as_mod = Some(optifine_version);
requires_optifabric = true;
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"OptiFine is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let pack_name = manifest
.name
.clone()
.filter(|name| !name.trim().is_empty())
.or_else(|| {
source_filename.as_ref().map(|name| {
std::path::Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
})
.unwrap_or_else(|| "MCBBS Modpack".to_string());
let pack_details = InstallPhaseDetails::Modpack {
project_id: None,
version_id: None,
title: Some(pack_name.clone()),
};
reporter
.update(InstallPhaseId::ResolvingPack, None, pack_details.clone())
.await?;
let lite_loader_as_adjunct = if let Some(requested_version) =
lite_loader_as_adjunct
{
Some(
crate::launcher::get_loader_version_from_profile(
&game_version,
ModLoader::LiteLoader,
Some(&requested_version),
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"No LiteLoader version {requested_version} supports Minecraft {game_version}"
))
})?,
)
} else {
None
};
let optifabric_version = if requires_optifabric {
Some(
crate::install::runner::resolve_optifabric_version(&game_version)
.await?,
)
} else {
None
};
let resolved_loader_version = if loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
let launch_overrides =
manifest.launch_info.as_ref().and_then(|launch_info| {
let jvm_args = launch_info
.java_argument
.as_ref()
.map(join_arguments)
.filter(|args| !args.is_empty())?;
Some(InstanceLaunchOverridesPatch {
extra_launch_args: Some(Some(jvm_args)),
..InstanceLaunchOverridesPatch::default()
})
});
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(pack_name.clone()),
link: Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(pack_name.clone()),
version_number: manifest.version.clone(),
filename: source_filename,
}),
launch_overrides,
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::ImportedModpack),
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(loader),
loader_version: Some(
resolved_loader_version.map(|version| version.id),
),
}),
..EditInstance::default()
},
)
.await?;
let minecraft_install =
super::parallel_minecraft_install::ParallelMinecraftInstall::start(
instance_id.clone(),
reporter.clone(),
);
let curse_files = manifest
.files
.iter()
.filter(|file| {
file.type_
.as_deref()
.is_none_or(|kind| kind.eq_ignore_ascii_case("curse"))
})
.filter_map(|file| {
Some(crate::api::curseforge::CurseForgeManifestFile {
project_id: file.project_id?,
file_id: file.file_id?,
required: true,
})
})
.collect::<Vec<_>>();
if !curse_files.is_empty() {
let content_loader = (loader != ModLoader::Vanilla
&& loader != ModLoader::OptiFine)
.then(|| loader.as_str().to_string());
crate::api::curseforge::install_local_manifest_files(
&instance_id,
curse_files,
false,
&game_version,
content_loader.as_deref(),
pack_details.clone(),
&reporter,
)
.await?;
}
reporter
.update(
InstallPhaseId::ExtractingOverrides,
None,
pack_details.clone(),
)
.await?;
let instance_path =
crate::api::instance::get_full_path(&instance_id).await?;
archive_util::extract_archive_subdir_for_instance(
instance_id.clone(),
reporter.cancellation_token(),
archive_path,
format!("{base_folder}overrides/"),
instance_path.clone(),
)
.await?;
minecraft_install.join().await?;
if let Some(lite_loader_version) = lite_loader_as_adjunct {
install_liteloader_component(
&state,
&instance_id,
&game_version,
loader,
&lite_loader_version,
)
.await?;
}
if let Some(optifine_version) = optifine_as_mod {
install_optifine_mod(
&state,
&instance_id,
reporter.cancellation_token(),
&game_version,
&optifine_version,
&instance_path,
)
.await?;
record_optifine_component(&instance_id, &optifine_version).await?;
}
if let Some(optifabric_version) = optifabric_version {
install_optifabric_component(
&instance_id,
&game_version,
&optifabric_version,
)
.await?;
}
reporter.clear_context().await?;
Ok(())
}
pub(crate) async fn record_optifine_component(
instance_id: &str,
version: &str,
) -> crate::Result<()> {
record_loader_component(
instance_id,
crate::state::LoaderComponentKind::OptiFine,
version,
Some(serde_json::json!({ "source": "pack" })),
)
.await
}
pub(crate) async fn record_loader_component(
instance_id: &str,
kind: crate::state::LoaderComponentKind,
version: &str,
provider_metadata: Option<serde_json::Value>,
) -> crate::Result<()> {
let state = State::get().await?;
let metadata =
crate::api::instance::get(instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let mut components = metadata.loader_components;
components.retain(|component| component.kind != kind);
components.push(crate::state::LoaderComponent {
instance_id: instance_id.to_string(),
kind,
version: Some(version.to_string()),
role: crate::state::LoaderComponentRole::Adjunct,
provider_metadata,
});
crate::state::instances::commands::replace_instance_loader_components(
instance_id,
&components,
&state.pool,
)
.await
}
pub(crate) async fn install_liteloader_component(
state: &State,
instance_id: &str,
game_version: &str,
primary_loader: ModLoader,
resolved_version: &daedalus::modded::LoaderVersion,
) -> crate::Result<()> {
let metadata =
crate::api::instance::get(instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let version = crate::install::runner::install_liteloader_adjunct_resolved(
state,
&metadata,
game_version,
primary_loader,
resolved_version,
)
.await?;
record_loader_component(
instance_id,
crate::state::LoaderComponentKind::LiteLoader,
&version,
Some(serde_json::json!({ "source": "pack" })),
)
.await
}
pub(crate) async fn install_optifabric_component(
instance_id: &str,
game_version: &str,
version: &str,
) -> crate::Result<()> {
let version = crate::install::runner::install_optifabric_file(
instance_id,
game_version,
version,
)
.await?;
record_loader_component(
instance_id,
crate::state::LoaderComponentKind::OptiFabric,
&version,
Some(serde_json::json!({
"projectId": crate::install::runner::OPTIFABRIC_CURSEFORGE_PROJECT_ID,
"provider": "curseforge"
})),
)
.await
}
/// Installs OptiFine into the instance's mods folder for packs that pair it
/// with Forge or NeoForge. Requires the instance's Minecraft install to have
/// completed so the client jar and a Java runtime are available.
pub(crate) async fn install_optifine_mod(
state: &State,
instance_id: &str,
cancellation: tokio_util::sync::CancellationToken,
game_version: &str,
optifine_version: &str,
instance_path: &std::path::Path,
) -> crate::Result<()> {
let metadata =
crate::api::instance::get(instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let version_jar = match &metadata.applied_content_set.loader_version {
Some(loader_version) => format!("{game_version}-{loader_version}"),
None => game_version.to_string(),
};
let loader_client_jar = state
.directories
.version_dir(&version_jar)
.join(format!("{version_jar}.jar"));
let client_jar = if loader_client_jar.is_file() {
loader_client_jar
} else {
state
.directories
.version_dir(game_version)
.join(format!("{game_version}.jar"))
};
let (manifest, version_index) =
crate::launcher::resolve_minecraft_manifest(game_version, state)
.await?;
let version_info = crate::launcher::download::download_version_info(
state,
&manifest.versions[version_index],
ModLoader::Vanilla,
None,
None,
None,
None,
)
.await?;
let java_key = version_info
.java_version
.as_ref()
.map_or(8, |java| java.major_version);
let java = crate::api::jre::find_java_for_version(java_key)
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"No Java {java_key} runtime is available for the OptiFine installer"
))
})?;
crate::launcher::optifine::install_optifine_as_mod(
state,
instance_id,
cancellation,
std::path::Path::new(&java.path),
game_version,
optifine_version,
&client_jar,
&instance_path.join("mods"),
)
.await?;
Ok(())
}

View File

@ -0,0 +1,63 @@
//! Installer for MultiMC/Prism export zips.
//!
//! Export zips carry the same `mmc-pack.json` + `instance.cfg` layout as an
//! installed MMC instance, so the archive is extracted to a scratch directory
//! and imported through the existing MMC instance importer.
use std::path::PathBuf;
use super::archive_util;
use crate::State;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::pack::import::ImportLauncherType;
pub(crate) async fn install_mmc_zip_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let state = State::get().await?;
let details = InstallPhaseDetails::Import {
launcher_type: ImportLauncherType::MultiMC,
instance_folder: source_filename.unwrap_or_else(|| {
archive_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "MultiMC pack".to_string())
}),
};
reporter
.update(InstallPhaseId::ExtractingOverrides, None, details.clone())
.await?;
let scratch = archive_util::create_import_scratch_dir(&state).await?;
let result = async {
archive_util::extract_archive_subdir(
archive_path,
base_folder,
scratch.clone(),
)
.await?;
crate::pack::import::mmc::import_mmc_instance_dir(
scratch.clone(),
Some(scratch.clone()),
&instance_id,
reporter.clone(),
details,
false, // zip imports don't support symlinks
)
.await
}
.await;
if let Err(error) = tokio::fs::remove_dir_all(&scratch).await {
tracing::warn!(
"Failed to clean up modpack import scratch directory {}: {error}",
scratch.display()
);
}
result
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,312 @@
//! Installer for plain zipped-up game folders.
//!
//! These archives have no pack manifest at all — they are a `.minecraft`
//! folder (optionally wrapped in extra directories) identified by a
//! `versions/<id>/<id>.json` structure. The version JSON is inspected to
//! guess the game version and loader, and the folder contents become the
//! instance's game directory.
use std::path::PathBuf;
use serde::Deserialize;
use super::archive_util;
use crate::data::ModLoader;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLink,
};
#[derive(Deserialize, Debug, Default)]
#[serde(rename_all = "camelCase")]
struct PlainVersionJson {
#[serde(default)]
id: Option<String>,
#[serde(default)]
inherits_from: Option<String>,
#[serde(default)]
libraries: Vec<PlainLibrary>,
}
#[derive(Deserialize, Debug)]
struct PlainLibrary {
#[serde(default)]
name: Option<String>,
}
fn looks_like_game_version(value: &str) -> bool {
let mut parts = value.split('.');
parts.next().is_some_and(|part| part.parse::<u32>().is_ok())
&& value.split('.').skip(1).all(|part| {
part.split('-')
.next()
.is_some_and(|part| part.parse::<u32>().is_ok())
})
}
struct DetectedTarget {
game_version: String,
loader: ModLoader,
loader_version: Option<String>,
}
fn detect_target(version_json: &PlainVersionJson) -> Option<DetectedTarget> {
let mut game_version = version_json
.inherits_from
.clone()
.filter(|value| looks_like_game_version(value));
let mut loader = ModLoader::Vanilla;
let mut loader_version = None;
for library in &version_json.libraries {
let Some(name) = &library.name else {
continue;
};
let parts: Vec<&str> = name.split(':').collect();
if parts.len() < 3 {
continue;
}
let (group, artifact, version) = (parts[0], parts[1], parts[2]);
match (group, artifact) {
("net.fabricmc", "fabric-loader") => {
loader = ModLoader::Fabric;
loader_version = Some(version.to_string());
}
("org.quiltmc", "quilt-loader") => {
loader = ModLoader::Quilt;
loader_version = Some(version.to_string());
}
("net.neoforged", "neoforge" | "forge") => {
loader = ModLoader::NeoForge;
loader_version = Some(version.to_string());
}
("net.minecraftforge", "forge" | "fmlloader") => {
loader = ModLoader::Forge;
// Forge versions are usually stored as `<mc>-<forge>`.
let forge_version = version
.split_once('-')
.map(|(mc, forge)| {
if game_version.is_none() && looks_like_game_version(mc)
{
game_version = Some(mc.to_string());
}
forge.to_string()
})
.unwrap_or_else(|| version.to_string());
loader_version = Some(forge_version);
}
("optifine", "OptiFine") if loader == ModLoader::Vanilla => {
loader = ModLoader::OptiFine;
// OptiFine library versions look like `<mc>_HD_U_I6`.
loader_version = Some(
version
.split_once('_')
.map(|(_, of)| of.to_string())
.unwrap_or_else(|| version.to_string()),
);
}
_ => {}
}
}
if game_version.is_none()
&& let Some(id) = &version_json.id
&& looks_like_game_version(id)
{
game_version = Some(id.clone());
}
game_version.map(|game_version| DetectedTarget {
game_version,
loader,
loader_version,
})
}
/// Reads every `versions/<id>/<id>.json` under the base folder. Archives of
/// modded installs usually contain both the vanilla and the modded version
/// folder, so all candidates are needed to pick the right one.
async fn read_version_candidates(
archive_path: PathBuf,
base_folder: String,
) -> crate::Result<Vec<(String, PlainVersionJson)>> {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path).map_err(|error| {
crate::util::io::IOError::with_path(error, &archive_path)
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Modpack archive is invalid: {error}"
))
})?;
let versions_prefix = format!("{base_folder}versions/");
let mut candidates = Vec::new();
for index in 0..archive.len() {
let name = {
let entry = archive.by_index_raw(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
crate::pack::detect::decode_zip_entry_name(entry.name_raw())
};
let Some(rest) = name.strip_prefix(&versions_prefix) else {
continue;
};
let mut segments = rest.split('/');
let (Some(id), Some(json), None) =
(segments.next(), segments.next(), segments.next())
else {
continue;
};
if json.strip_suffix(".json") != Some(id) {
continue;
}
let id = id.to_string();
let mut entry = archive.by_index(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
let mut contents = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut contents)?;
if let Ok(parsed) =
serde_json::from_slice::<PlainVersionJson>(&contents)
{
candidates.push((id, parsed));
}
}
Ok(candidates)
})
.await?
}
pub(crate) async fn install_plain_archive_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
version_id: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let candidates =
read_version_candidates(archive_path.clone(), base_folder.clone())
.await?;
let mut targets: Vec<(String, DetectedTarget)> = candidates
.iter()
.filter_map(|(id, json)| {
detect_target(json).map(|target| (id.clone(), target))
})
.collect();
let selected = targets
.iter()
.position(|(_, target)| target.loader != ModLoader::Vanilla)
.map(|index| targets.remove(index))
.or_else(|| {
if targets.is_empty() {
None
} else {
Some(targets.remove(0))
}
});
let Some((selected_id, target)) = selected else {
return Err(crate::ErrorKind::InputError(format!(
"Could not determine the Minecraft version of archived instance {version_id}"
))
.into());
};
let pack_name = if selected_id.trim().is_empty() {
source_filename
.as_ref()
.map(|name| {
std::path::Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
.unwrap_or_else(|| "Imported Instance".to_string())
} else {
selected_id.clone()
};
let pack_details = InstallPhaseDetails::Modpack {
project_id: None,
version_id: None,
title: Some(pack_name.clone()),
};
reporter
.update(InstallPhaseId::ResolvingPack, None, pack_details.clone())
.await?;
let resolved_loader_version = if target.loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&target.game_version,
target.loader,
target.loader_version.as_deref(),
)
.await?
} else {
None
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(pack_name.clone()),
link: Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(pack_name),
version_number: None,
filename: source_filename,
}),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::ImportedModpack),
game_version: Some(target.game_version.clone()),
protocol_version: Some(None),
loader: Some(target.loader),
loader_version: Some(
resolved_loader_version.map(|version| version.id),
),
}),
..EditInstance::default()
},
)
.await?;
reporter
.update(
InstallPhaseId::ExtractingOverrides,
None,
pack_details.clone(),
)
.await?;
let instance_path =
crate::api::instance::get_full_path(&instance_id).await?;
archive_util::extract_archive_subdir_for_instance(
instance_id.clone(),
reporter.cancellation_token(),
archive_path,
base_folder,
instance_path.clone(),
)
.await?;
let local_source =
crate::launcher::download::LocalRuntimeSource::discover(&instance_path);
crate::launcher::install_minecraft_for_instance_id_with_local_source(
&instance_id,
local_source,
false,
Some(reporter.clone()),
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
)
.await?;
reporter.clear_context().await?;
Ok(())
}

View File

@ -0,0 +1,10 @@
pub(crate) mod archive_util;
pub mod detect;
pub mod import;
pub mod install_from;
pub(crate) mod install_hmcl;
pub(crate) mod install_mcbbs;
pub(crate) mod install_mmc_zip;
pub mod install_mrpack;
pub(crate) mod install_plain_archive;
pub(crate) mod parallel_minecraft_install;

View File

@ -0,0 +1,65 @@
use crate::install::InstallProgressReporter;
use tokio_util::sync::CancellationToken;
/// Owns a Minecraft core installation that runs alongside pack content work.
/// Dropping the guard cancels the task so an importer cannot leave a core
/// download running after its content installation fails.
pub(crate) struct ParallelMinecraftInstall {
cancel: CancellationToken,
task: Option<tokio::task::JoinHandle<crate::Result<()>>>,
}
impl ParallelMinecraftInstall {
pub(crate) fn start(
instance_id: String,
reporter: InstallProgressReporter,
) -> Self {
let cancel = CancellationToken::new();
let task_cancel = cancel.clone();
let parallel_reporter = reporter.with_parallel_output();
let task = tokio::spawn(async move {
tokio::select! {
_ = task_cancel.cancelled() => {
tracing::debug!(
instance_id = %instance_id,
"Parallel Minecraft install aborted before completion"
);
Ok(())
}
result = crate::launcher::install_minecraft_for_instance_id_with_reporter(
&instance_id,
false,
Some(parallel_reporter),
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
) => result,
}
});
Self {
cancel,
task: Some(task),
}
}
/// Cancels the core install and waits until the task has stopped.
pub(crate) async fn abort(mut self) {
self.cancel.cancel();
if let Some(task) = self.task.take() {
let _ = task.await;
}
}
/// Waits for the core install to finish without cancelling it.
pub(crate) async fn join(mut self) -> crate::Result<()> {
if let Some(task) = self.task.take() {
task.await??;
}
Ok(())
}
}
impl Drop for ParallelMinecraftInstall {
fn drop(&mut self) {
self.cancel.cancel();
}
}

View File

@ -0,0 +1,231 @@
use crate::State;
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const CONNECTOR_URL_ENV: &str = "AXOLOTL_PLANET_MINECRAFT_CONNECTOR_URL";
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanetMinecraftProject {
pub id: String,
pub title: String,
pub page_url: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub versions: Vec<PlanetMinecraftVersion>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanetMinecraftVersion {
pub id: String,
pub name: String,
pub game_versions: Vec<String>,
pub download: PlanetMinecraftDownload,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanetMinecraftDownload {
pub page_url: String,
#[serde(default)]
pub file_name: Option<String>,
#[serde(default)]
pub direct_url: Option<String>,
#[serde(default)]
pub sha256: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum PlanetMinecraftInstallRoute {
Automatic {
direct_url: String,
sha256: String,
file_name: Option<String>,
},
Manual {
page_url: String,
file_name: Option<String>,
},
}
impl PlanetMinecraftDownload {
pub fn install_route(&self) -> PlanetMinecraftInstallRoute {
match (
self.direct_url.as_deref().filter(|url| {
!url.trim().is_empty() && validated_download_url(url).is_ok()
}),
self.sha256
.as_deref()
.filter(|hash| !hash.trim().is_empty()),
) {
(Some(direct_url), Some(sha256)) => {
PlanetMinecraftInstallRoute::Automatic {
direct_url: direct_url.to_string(),
sha256: sha256.to_string(),
file_name: self.file_name.clone(),
}
}
_ => PlanetMinecraftInstallRoute::Manual {
page_url: self.page_url.clone(),
file_name: self.file_name.clone(),
},
}
}
}
pub fn connector_base_url() -> crate::Result<String> {
let value = std::env::var(CONNECTOR_URL_ENV).map_err(|_| {
crate::ErrorKind::InputError(
"Planet Minecraft connector is not configured".to_string(),
)
})?;
let url = reqwest::Url::parse(&value).map_err(|_| {
crate::ErrorKind::InputError(
"Planet Minecraft connector URL is invalid".to_string(),
)
})?;
if url.scheme() != "https" {
return Err(crate::ErrorKind::InputError(
"Planet Minecraft connector must use HTTPS".to_string(),
)
.into());
}
Ok(value.trim_end_matches('/').to_string())
}
pub async fn search_projects(
query: &str,
game_version: Option<&str>,
) -> crate::Result<Vec<PlanetMinecraftProject>> {
let mut url = format!(
"{}/projects?query={}",
connector_base_url()?,
urlencoding::encode(query)
);
if let Some(game_version) = game_version.filter(|value| !value.is_empty()) {
url.push_str(&format!(
"&game_version={}",
urlencoding::encode(game_version)
));
}
connector_get(&url).await
}
pub async fn get_project(id: &str) -> crate::Result<PlanetMinecraftProject> {
connector_get(&format!(
"{}/projects/{}",
connector_base_url()?,
urlencoding::encode(id)
))
.await
}
pub async fn download_verified_file(
direct_url: &str,
expected_sha256: &str,
) -> crate::Result<Bytes> {
let url = validated_download_url(direct_url)?;
if expected_sha256.trim().is_empty() {
return Err(crate::ErrorKind::InputError(
"Planet Minecraft downloads require a SHA-256".to_string(),
)
.into());
}
let state = State::get().await?;
let _permit = state.api_semaphore.0.acquire().await?;
let bytes = INSECURE_REQWEST_CLIENT
.get(url)
.send()
.await?
.error_for_status()?
.bytes()
.await?;
if bytes.is_empty() {
return Err(crate::ErrorKind::InputError(
"Planet Minecraft returned an empty file".to_string(),
)
.into());
}
let actual = format!("{:x}", Sha256::digest(&bytes));
if !actual.eq_ignore_ascii_case(expected_sha256.trim()) {
return Err(crate::ErrorKind::OtherError(
"Planet Minecraft SHA-256 mismatch".to_string(),
)
.into());
}
Ok(bytes)
}
fn validated_download_url(value: &str) -> crate::Result<reqwest::Url> {
let url = reqwest::Url::parse(value).map_err(|_| {
crate::ErrorKind::InputError(
"Planet Minecraft download URL is invalid".to_string(),
)
})?;
if url.scheme() != "https" {
return Err(crate::ErrorKind::InputError(
"Planet Minecraft download URL must use HTTPS".to_string(),
)
.into());
}
Ok(url)
}
async fn connector_get<T>(url: &str) -> crate::Result<T>
where
T: for<'de> Deserialize<'de>,
{
let state = State::get().await?;
let _permit = state.api_semaphore.0.acquire().await?;
Ok(INSECURE_REQWEST_CLIENT
.get(url)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn downloads_without_a_hash_remain_manual() {
let download = PlanetMinecraftDownload {
page_url: "https://www.planetminecraft.com/mod/example".to_string(),
file_name: Some("example.jar".to_string()),
direct_url: Some("https://host.example/example.jar".to_string()),
sha256: None,
};
assert!(matches!(
download.install_route(),
PlanetMinecraftInstallRoute::Manual { .. }
));
}
#[test]
fn direct_downloads_require_https() {
assert!(validated_download_url("http://example.com/file.jar").is_err());
assert!(validated_download_url("https://example.com/file.jar").is_ok());
}
#[test]
fn non_https_direct_downloads_fall_back_to_manual_import() {
let download = PlanetMinecraftDownload {
page_url: "https://www.planetminecraft.com/mod/example".to_string(),
file_name: Some("example.jar".to_string()),
direct_url: Some("http://host.example/example.jar".to_string()),
sha256: Some("abc".to_string()),
};
assert!(matches!(
download.install_route(),
PlanetMinecraftInstallRoute::Manual { .. }
));
}
}

View File

@ -0,0 +1,82 @@
//! Theseus process management interface
use crate::state::ProcessMetadata;
pub use crate::{
State,
state::{Hooks, MemorySettings, Settings, WindowSize},
};
use uuid::Uuid;
// Gets each running stored process in the state
#[tracing::instrument]
pub async fn get_all() -> crate::Result<Vec<ProcessMetadata>> {
let state = State::get().await?;
let processes = state.process_manager.get_all();
Ok(processes)
}
pub async fn resolve_instance_id(instance: &str) -> crate::Result<String> {
let state = State::get().await?;
resolve_instance_id_with_state(instance, &state)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance id or path: {instance}"
))
.as_error()
})
}
async fn resolve_instance_id_with_state(
instance: &str,
state: &State,
) -> crate::Result<Option<String>> {
sqlx::query_scalar!(
"
SELECT id
FROM instances
WHERE id = ? OR path = ?
ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END
LIMIT 1
",
instance,
instance,
instance,
)
.fetch_optional(&state.pool)
.await
.map_err(Into::into)
}
// Gets the UUID of each stored process in the state by instance id
#[tracing::instrument]
pub async fn get_by_instance_id(
instance_id: &str,
) -> crate::Result<Vec<ProcessMetadata>> {
let state = State::get().await?;
let processes = state
.process_manager
.get_all()
.into_iter()
.filter(|x| x.instance_id == instance_id)
.collect();
Ok(processes)
}
// Kill a child process stored in the state by UUID, as a string
#[tracing::instrument]
pub async fn kill(uuid: Uuid) -> crate::Result<()> {
let state = State::get().await?;
state.process_manager.kill(uuid).await?;
Ok(())
}
// Wait for a child process stored in the state by UUID
#[tracing::instrument]
pub async fn wait_for(uuid: Uuid) -> crate::Result<()> {
let state = State::get().await?;
state.process_manager.wait_for(uuid).await?;
Ok(())
}

View File

@ -0,0 +1,195 @@
use crate::{Error, ErrorKind, Result};
use std::fmt::Display;
use std::mem;
use std::net::{Ipv4Addr, Ipv6Addr};
use tokio::sync::Semaphore;
#[derive(Debug, Clone)]
pub enum ServerAddress {
Unresolved(String),
Resolved {
original_host: String,
original_port: u16,
resolved_host: String,
resolved_port: u16,
},
}
impl ServerAddress {
pub async fn resolve(&mut self) -> Result<()> {
match self {
Self::Unresolved(address) => {
let (host, port) = parse_server_address(address)?;
let (resolved_host, resolved_port) =
resolve_server_address(host, port).await?;
*self = Self::Resolved {
original_host: if host.len() == address.len() {
mem::take(address)
} else {
host.to_owned()
},
original_port: port,
resolved_host,
resolved_port,
}
}
Self::Resolved { .. } => {}
}
Ok(())
}
pub fn require_resolved(&self) -> Result<(&str, u16)> {
match self {
Self::Resolved {
resolved_host,
resolved_port,
..
} => Ok((resolved_host, *resolved_port)),
Self::Unresolved(address) => Err(ErrorKind::InputError(format!(
"Unexpected unresolved server address: {address}"
))
.into()),
}
}
}
impl Display for ServerAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unresolved(address) => write!(f, "{address}"),
Self::Resolved {
resolved_host,
resolved_port,
..
} => {
if resolved_host.contains(':') {
write!(f, "[{resolved_host}]:{resolved_port}")
} else {
write!(f, "{resolved_host}:{resolved_port}")
}
}
}
}
}
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let (Some(colon_index), Some(close_bracket_index)) =
(address.find(':'), address.rfind(']'))
else {
return Err(format!("Invalid bracketed host/port: {address}"));
};
if close_bracket_index <= colon_index {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index + 1).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
if let Some((host, port)) = address.split_once(':')
&& !port.contains(':')
{
(host, port)
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparsable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparsable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
#[cfg(test)]
mod tests {
use super::parse_server_address_inner;
#[test]
fn parses_ipv4_server_addresses() {
for (address, expected) in [
("192.0.2.1", ("192.0.2.1", 25565)),
("192.0.2.1:25566", ("192.0.2.1", 25566)),
] {
assert_eq!(parse_server_address_inner(address), Ok(expected));
}
}
#[test]
fn parses_ipv6_server_addresses() {
for (address, expected) in [
("2001:db8::1", ("2001:db8::1", 25565)),
("[2001:db8::1]", ("2001:db8::1", 25565)),
("[2001:db8::1]:25566", ("2001:db8::1", 25566)),
] {
assert_eq!(parse_server_address_inner(address), Ok(expected));
}
}
}
pub async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if port != 25565
|| host.parse::<Ipv4Addr>().is_ok()
|| host.parse::<Ipv6Addr>().is_ok()
{
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.as_ref()
.is_some_and(|x| x.kind().is_no_records_found()) =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@ -0,0 +1,23 @@
//! Managed dedicated Minecraft servers: manifests, downloads, and process control.
//! Each server lives in its own directory under the launcher's `servers` folder
//! and is described by an `axolotl-server.json` manifest.
mod files;
mod forge;
mod lifecycle;
mod logs;
mod manage;
mod manifest;
mod modpack;
mod ports;
pub use self::files::{download_file, read_file, write_file};
pub use self::forge::install_forge;
pub use self::lifecycle::{
kill, resize_console, send_command, send_console_input, start, stop,
};
pub use self::logs::{clear_log, get_log_buffer};
pub use self::manage::{create, delete, get, list, set_icon, update_settings};
pub use self::manifest::{ModpackInfo, ServerInfo, ServerManifest};
pub use self::modpack::install_modpack;
pub use self::ports::{PortProcessInfo, kill_port_process, port_process};

View File

@ -0,0 +1,200 @@
//! Reading, writing, and downloading files inside a server's directory.
use std::path::{Path, PathBuf};
use futures::StreamExt;
use sha1_smol::Sha1;
use crate::event::ServerPayloadType;
use crate::event::emit::emit_server;
use crate::util::io::{self, IOError};
use crate::{ErrorKind, Result};
use super::manifest::server_path;
const DOWNLOAD_PROGRESS_STEP: u64 = 512 * 1024;
pub async fn read_file(server_id: &str, file: &str) -> Result<String> {
let path = resolve_server_file(server_id, file).await?;
let bytes = io::read(&path).await?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok(text)
}
pub async fn write_file(
server_id: &str,
file: &str,
contents: &str,
) -> Result<()> {
let path = resolve_server_file(server_id, file).await?;
io::write(&path, contents).await?;
Ok(())
}
pub async fn download_file(
server_id: &str,
url: &str,
filename: &str,
expected_sha1: Option<String>,
) -> Result<()> {
let dir = server_path(server_id).await?;
download_to_dir(server_id, &dir, url, filename, expected_sha1).await
}
/// Downloads a URL into a nested, relative path inside the server directory
/// (for example `mods/sodium.jar`), creating parent directories as needed.
/// The relative path is validated against traversal; a plain filename behaves
/// identically to [`download_file`].
pub(super) async fn download_to_dir(
server_id: &str,
dir: &Path,
url: &str,
rel_path: &str,
expected_sha1: Option<String>,
) -> Result<()> {
let destination = safe_relative_join(dir, rel_path)?;
let partial = destination.with_extension("part");
if let Some(parent) = destination.parent() {
io::create_dir_all(parent).await?;
}
let client = reqwest::Client::builder()
.user_agent(crate::launcher_user_agent())
.build()
.map_err(|e| ErrorKind::NetworkError(e.to_string()))?;
let response = client
.get(url)
.send()
.await
.and_then(|r| r.error_for_status())?;
let total = response.content_length();
let mut stream = response.bytes_stream();
let mut file = tokio::fs::File::create(&partial)
.await
.map_err(|e| IOError::with_path(e, &partial))?;
let mut hasher = Sha1::new();
let mut downloaded: u64 = 0;
let mut last_reported: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.map_err(|e| IOError::with_path(e, &partial))?;
hasher.update(&chunk);
downloaded += chunk.len() as u64;
if downloaded - last_reported >= DOWNLOAD_PROGRESS_STEP {
last_reported = downloaded;
emit_server(
server_id,
ServerPayloadType::DownloadProgress { downloaded, total },
)
.await
.ok();
}
}
drop(file);
if let Some(expected) = expected_sha1.as_deref() {
let actual = hasher.digest().to_string();
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(&partial).await;
return Err(ErrorKind::NetworkError(format!(
"Download checksum mismatch for {rel_path}: expected {expected}, got {actual}"
))
.as_error());
}
}
tokio::fs::rename(&partial, &destination)
.await
.map_err(|e| IOError::with_path(e, &destination))?;
emit_server(
server_id,
ServerPayloadType::DownloadProgress {
downloaded,
total: Some(downloaded.max(total.unwrap_or(0))),
},
)
.await
.ok();
Ok(())
}
async fn resolve_server_file(server_id: &str, file: &str) -> Result<PathBuf> {
let dir = server_path(server_id).await?;
safe_join(&dir, file)
}
fn safe_join(dir: &Path, file: &str) -> Result<PathBuf> {
if file.is_empty()
|| file.contains('\\')
|| file.starts_with('/')
|| file
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
return Err(ErrorKind::InputError(format!(
"Invalid file name: {file}"
))
.as_error());
}
Ok(dir.join(file))
}
/// Joins a relative path that may contain `/` separators but never escapes the
/// server directory (no `..`, empty segments, or absolute/Windows roots).
pub(super) fn safe_relative_join(dir: &Path, rel: &str) -> Result<PathBuf> {
let is_windows_drive_root =
rel.as_bytes().get(1).is_some_and(|byte| *byte == b':')
&& rel.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if rel.is_empty()
|| Path::new(rel).is_absolute()
|| is_windows_drive_root
|| rel.starts_with('/')
|| rel.starts_with('\\')
|| rel.split(['/', '\\']).any(|segment| {
segment.is_empty() || segment == ".." || segment == "."
})
{
return Err(ErrorKind::InputError(format!("Invalid file path: {rel}"))
.as_error());
}
Ok(dir.join(rel))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_rejects_traversal() {
let dir = Path::new("/tmp/servers/a");
assert!(safe_join(dir, "server.properties").is_ok());
assert!(safe_join(dir, "../secret").is_err());
assert!(safe_join(dir, "/etc/passwd").is_err());
assert!(safe_join(dir, "a//b").is_err());
assert!(safe_join(dir, "").is_err());
}
#[test]
fn safe_relative_join_allows_nested_paths() {
let dir = Path::new("/tmp/servers/a");
assert_eq!(
safe_relative_join(dir, "mods/sodium.jar").unwrap(),
dir.join("mods/sodium.jar")
);
assert_eq!(
safe_relative_join(dir, "config/foo/bar.toml").unwrap(),
dir.join("config/foo/bar.toml")
);
assert!(safe_relative_join(dir, "mods/../secret").is_err());
assert!(safe_relative_join(dir, "../secret").is_err());
assert!(safe_relative_join(dir, "/etc/passwd").is_err());
assert!(safe_relative_join(dir, "C:/windows/system32").is_err());
assert!(safe_relative_join(dir, "mods//double.jar").is_err());
assert!(safe_relative_join(dir, "mods/./dot.jar").is_err());
assert!(safe_relative_join(dir, "").is_err());
}
}

View File

@ -0,0 +1,120 @@
//! Forge server installation: downloading the official installer and running it
//! headlessly to materialize the server launcher (run script, `@args` files, and
//! the mod loader) into a managed server directory.
//!
//! Forge ships a bootstrapper rather than a ready-to-run jar. The launcher jar is
//! produced by `java -jar forge-installer.jar --installServer <dir>`, which lays
//! down the loader, its libraries, and the `@args` launch files. The regular
//! `servers.start` flow then boots that output (see `lifecycle::forge_launch_args`).
use tokio::process::Command;
use crate::event::ServerPayloadType;
use crate::event::emit::emit_server;
use crate::util::io::IOError;
use crate::{ErrorKind, Result};
use super::files::download_to_dir;
use super::manifest::{
InstallState, read_manifest, server_path, write_manifest,
};
const FORGE_MAVEN: &str =
"https://maven.minecraftforge.net/net/minecraftforge/forge";
/// Downloads the Forge installer for `mc_version`/`build` into the server dir
/// and runs it headlessly (`--installServer`) to lay down the launcher. The
/// server is left unstarted; the caller (frontend wizard) writes `eula.txt` and
/// the regular `servers.start` flow boots it.
pub async fn install_forge(
server_id: &str,
mc_version: &str,
build: &str,
java_path: Option<String>,
) -> Result<()> {
let dir = server_path(server_id).await?;
let mut manifest = read_manifest(&dir).await?;
manifest.install_state = Some(InstallState::Incomplete);
manifest.install_error = None;
write_manifest(&dir, &manifest).await?;
drop(manifest);
let installer_name = format!("forge-{mc_version}-{build}-installer.jar");
let installer_url =
format!("{FORGE_MAVEN}/{mc_version}-{build}/{installer_name}");
log(
server_id,
&format!("Downloading Forge installer ({installer_name})"),
)
.await?;
download_to_dir(server_id, &dir, &installer_url, &installer_name, None)
.await?;
let installer_path = dir.join(&installer_name);
let java = java_path.clone().unwrap_or_else(|| "java".to_string());
log(server_id, "Running Forge installer (this may take a while)").await?;
let output = Command::new(&java)
.arg("-jar")
.arg(&installer_path)
.arg("--installServer")
.arg(&dir)
.current_dir(&dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to run Forge installer: {e}"
))
.as_error()
})?;
// The installer is verbose; surface a condensed tail so failures are diagnosable.
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines().rev().take(20) {
log(server_id, line).await.ok();
}
if !output.status.success() {
let mut manifest = read_manifest(&dir).await?;
manifest.install_state = Some(InstallState::Failed);
manifest.install_error =
Some("Forge installer exited with an error".to_string());
write_manifest(&dir, &manifest).await?;
return Err(ErrorKind::LauncherError(
"Forge installer failed. Check that the selected Java version supports this game version."
.to_string(),
)
.as_error());
}
// Ensure eula.txt exists (eula=false) so the manual-start gate can offer it
// without booting the jar.
let eula_path = dir.join("eula.txt");
if !eula_path.exists() {
tokio::fs::write(&eula_path, "eula=false\n")
.await
.map_err(|e| IOError::with_path(e, &eula_path))?;
}
let mut manifest = read_manifest(&dir).await?;
manifest.install_state = None;
manifest.install_error = None;
write_manifest(&dir, &manifest).await?;
log(server_id, "Forge server files installed").await.ok();
Ok(())
}
async fn log(server_id: &str, line: &str) -> Result<()> {
emit_server(
server_id,
ServerPayloadType::Log {
line: line.to_string(),
},
)
.await
}

View File

@ -0,0 +1,641 @@
//! Server process control: starting, stopping, and monitoring the JVM.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine;
use chrono::Utc;
use dashmap::{DashMap, DashSet};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::sync::LazyLock;
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, ChildStdin, Command};
use crate::event::ServerPayloadType;
use crate::event::emit::emit_server;
use crate::state::{clear_log_buffer, get_log_buffer, push_log_line};
use crate::util::io::IOError;
use crate::{ErrorKind, Result};
use super::logs::{
analyze_exit_reason, stream_server_output, stream_server_pty_output,
tail_server_log_file,
};
use super::manifest::{
read_manifest, resolve_jar_name, server_path, write_manifest,
};
const DEFAULT_MEMORY_MB: u32 = 2048;
const STOP_TIMEOUT_SECS: u64 = 60;
const MAX_CONSOLE_ROWS: u16 = 8192;
struct ServerProcess {
child: tokio::sync::Mutex<ServerChild>,
input: tokio::sync::Mutex<ServerInput>,
pty_master: Option<tokio::sync::Mutex<Box<dyn MasterPty + Send>>>,
stop_requested: AtomicBool,
}
enum ServerChild {
Piped(Child),
Pty(Box<dyn portable_pty::Child + Send + Sync>),
}
enum ServerInput {
Piped(ChildStdin),
Pty(Box<dyn std::io::Write + Send>),
}
impl ServerInput {
async fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
match self {
Self::Piped(stdin) => {
stdin.write_all(data).await?;
stdin.flush().await
}
Self::Pty(writer) => {
writer.write_all(data)?;
writer.flush()
}
}
}
}
impl ServerChild {
fn try_wait(&mut self) -> std::io::Result<Option<bool>> {
match self {
Self::Piped(child) => {
child.try_wait().map(|status| status.map(|s| s.success()))
}
Self::Pty(child) => {
child.try_wait().map(|status| status.map(|s| s.success()))
}
}
}
async fn kill(&mut self) -> std::io::Result<()> {
match self {
Self::Piped(child) => child.kill().await,
Self::Pty(child) => child.kill(),
}
}
}
static SERVER_PROCESSES: LazyLock<DashMap<String, Arc<ServerProcess>>> =
LazyLock::new(DashMap::new);
/// Synchronous start-in-flight guard. Reserving the slot before the first
/// `.await` prevents concurrent `start` calls (e.g. double-clicks) from both
/// passing the running check and spawning two JVMs on the same directory.
static SERVER_STARTING: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
pub(super) fn is_running(server_id: &str) -> bool {
SERVER_PROCESSES.contains_key(server_id)
}
/// Whether a server process is currently tracked. Used by the log-file tailer
/// to stop following once the server has exited.
pub(super) fn is_server_running(server_id: &str) -> bool {
SERVER_PROCESSES.contains_key(server_id)
}
pub async fn start(
server_id: &str,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<()> {
if SERVER_PROCESSES.contains_key(server_id)
|| !SERVER_STARTING.insert(server_id.to_string())
{
return Err(ErrorKind::InputError(
"Server is already running".to_string(),
)
.as_error());
}
let result = start_inner(server_id, java_path, memory_mb, jvm_args).await;
SERVER_STARTING.remove(server_id);
result
}
async fn start_inner(
server_id: &str,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<()> {
let dir = server_path(server_id).await?;
let mut manifest = read_manifest(&dir).await?;
let launch_args = if uses_pty_transport(&manifest.server_type) {
forge_launch_args(&dir)?
} else {
let jar_name = resolve_jar_name(&manifest);
let jar_path = dir.join(&jar_name);
if !jar_path.exists() {
return Err(ErrorKind::LauncherError(format!(
"Server jar not found: {jar_name}. Download the server files first."
))
.as_error());
}
vec!["-jar".to_string(), jar_name, "nogui".to_string()]
};
let java = java_path
.or_else(|| manifest.java_path.clone())
.unwrap_or_else(|| "java".to_string());
let memory = memory_mb
.or(manifest.memory_mb)
.unwrap_or(DEFAULT_MEMORY_MB);
// Ensure eula.txt exists (create with eula=false if missing)
let eula_path = dir.join("eula.txt");
let eula_created = !eula_path.exists();
if eula_created {
tokio::fs::write(&eula_path, "eula=false\n")
.await
.map_err(|e| IOError::with_path(e, &eula_path))?;
}
let mut args = vec![format!("-Xmx{memory}M")];
if uses_pty_transport(&manifest.server_type) {
args.push("-Dorg.jline.reader.props.list-max=0".to_string());
}
args.extend(jvm_args.unwrap_or_else(|| manifest.jvm_args.clone()));
args.extend(launch_args);
let removed_environment = [
"DYLD_LIBRARY_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"DYLD_INSERT_LIBRARIES",
];
let (mut child, input, pty_master, stdout, stderr, pty_reader) =
if uses_pty_transport(&manifest.server_type) {
let pair = native_pty_system()
.openpty(PtySize {
rows: 12,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to create Forge console PTY: {e}"
))
.as_error()
})?;
let reader = pair.master.try_clone_reader().map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to capture Forge console output: {e}"
))
.as_error()
})?;
let writer = pair.master.take_writer().map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to capture Forge console input: {e}"
))
.as_error()
})?;
let mut command = CommandBuilder::new(&java);
command.args(&args);
command.cwd(&dir);
command.env("TERM", "xterm-256color");
for variable in removed_environment {
command.env_remove(variable);
}
let child = pair.slave.spawn_command(command).map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to start Forge server process: {e}"
))
.as_error()
})?;
(
ServerChild::Pty(child),
ServerInput::Pty(writer),
Some(tokio::sync::Mutex::new(pair.master)),
None,
None,
Some(reader),
)
} else {
let mut command = Command::new(&java);
command.args(&args);
command.current_dir(&dir);
for variable in removed_environment {
command.env_remove(variable);
}
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command.stdin(std::process::Stdio::piped());
command.kill_on_drop(true);
let mut child = command.spawn().map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to start server process: {e}"
))
.as_error()
})?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdin = child.stdin.take().ok_or_else(|| {
ErrorKind::LauncherError(
"Server stdin could not be captured".to_string(),
)
.as_error()
})?;
(
ServerChild::Piped(child),
ServerInput::Piped(stdin),
None,
stdout,
stderr,
None,
)
};
manifest.last_started_at = Some(Utc::now());
manifest.last_exit_crashed = false;
if let Err(error) = write_manifest(&dir, &manifest).await {
let _ = child.kill().await;
return Err(error);
}
clear_log_buffer(server_id);
// Start each run from a clean log file. Minecraft's log4j appender appends
// to logs/latest.log across launches, so without truncating it the file
// tailer would replay the previous run's history into the fresh buffer on
// every restart.
let _ = std::fs::remove_file(dir.join("logs").join("latest.log"));
// Surface every startup step in the console. A loader's first launch (e.g.
// Fabric downloading the Minecraft server) can stay silent for a long time,
// so these lines stop the console from looking frozen.
let loader_first_run =
matches!(manifest.server_type.as_str(), "fabric" | "quilt")
&& !dir
.join(format!("{}-server-launch.jar", manifest.server_type))
.exists();
log_server_step(
server_id,
&format!(
"Starting server '{}' ({} · Minecraft {})",
manifest.name, manifest.server_type, manifest.game_version,
),
)
.await;
log_server_step(server_id, &format!("Java: {java}")).await;
log_server_step(server_id, &format!("Memory: {memory} MB")).await;
if eula_created {
log_server_step(
server_id,
"eula.txt not found — created with eula=false. Accept the EULA to start the server.",
)
.await;
}
log_server_step(
server_id,
&format!(
"Launching {} server ({} · nogui)",
manifest.server_type,
resolve_jar_name(&manifest),
),
)
.await;
if loader_first_run {
log_server_step(
server_id,
"First launch: downloading Minecraft server files. This may take a few minutes — the console will keep updating as it progresses.",
)
.await;
}
let process = Arc::new(ServerProcess {
child: tokio::sync::Mutex::new(child),
input: tokio::sync::Mutex::new(input),
pty_master,
stop_requested: AtomicBool::new(false),
});
SERVER_PROCESSES.insert(server_id.to_string(), process.clone());
if let Some(stdout) = stdout {
tokio::spawn(stream_server_output(server_id.to_string(), stdout));
}
if let Some(stderr) = stderr {
tokio::spawn(stream_server_output(server_id.to_string(), stderr));
}
if let Some(reader) = pty_reader {
tokio::spawn(stream_server_pty_output(server_id.to_string(), reader));
}
// The process pipes (above) capture JVM/installer output, but the server's
// own log4j console output is normally written to logs/latest.log rather
// than the stdout pipe. Tail that file so the console always shows the
// complete, lossless server log (matching what's on disk).
tokio::spawn(tail_server_log_file(server_id.to_string(), dir.clone()));
tokio::spawn(monitor_server_process(server_id.to_string(), dir, process));
emit_server(server_id, ServerPayloadType::Started)
.await
.ok();
Ok(())
}
pub async fn send_command(server_id: &str, command: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
let mut input = process.input.lock().await;
input
.write_all(&command_bytes(command))
.await
.map_err(|e| {
ErrorKind::LauncherError(format!("Failed to send command: {e}"))
.as_error()
})?;
Ok(())
}
fn uses_pty_transport(server_type: &str) -> bool {
server_type == "forge"
}
fn command_bytes(command: &str) -> Vec<u8> {
format!("{command}\n").into_bytes()
}
pub async fn send_console_input(server_id: &str, data: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
if process.pty_master.is_none() {
return Err(ErrorKind::InputError(
"Raw console input is only available for Forge servers".to_string(),
)
.as_error());
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| {
ErrorKind::InputError(format!("Invalid console input: {e}"))
.as_error()
})?;
if bytes.len() > 64 * 1024 {
return Err(ErrorKind::InputError(
"Console input exceeds 64 KiB".to_string(),
)
.as_error());
}
let mut input = process.input.lock().await;
input.write_all(&bytes).await.map_err(|e| {
ErrorKind::LauncherError(format!("Failed to send command: {e}"))
.as_error()
})?;
Ok(())
}
pub async fn resize_console(
server_id: &str,
cols: u16,
rows: u16,
) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
let master = process.pty_master.as_ref().ok_or_else(|| {
ErrorKind::InputError(
"Console resizing is only available for Forge servers".to_string(),
)
.as_error()
})?;
master
.lock()
.await
.resize(PtySize {
rows: rows.clamp(4, MAX_CONSOLE_ROWS),
cols: cols.clamp(20, 500),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to resize Forge console: {e}"
))
.as_error()
})?;
Ok(())
}
pub async fn stop(server_id: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
process.stop_requested.store(true, Ordering::SeqCst);
let mut input = process.input.lock().await;
let _ = input.write_all(b"stop\n").await;
let watchdog = process.clone();
let server_id = server_id.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(STOP_TIMEOUT_SECS))
.await;
if let Some(current) = SERVER_PROCESSES.get(&server_id)
&& current.stop_requested.load(Ordering::SeqCst)
{
let _ = watchdog.child.lock().await.kill().await;
}
});
Ok(())
}
pub async fn kill(server_id: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
process.stop_requested.store(true, Ordering::SeqCst);
let mut child = process.child.lock().await;
child.kill().await?;
Ok(())
}
async fn monitor_server_process(
server_id: String,
dir: PathBuf,
process: Arc<ServerProcess>,
) {
loop {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let exit_status = {
let mut child = process.child.lock().await;
match child.try_wait() {
Ok(Some(success)) => Some(success),
Ok(None) => continue,
Err(_) => None,
}
};
SERVER_PROCESSES.remove(&server_id);
let stop_requested = process.stop_requested.load(Ordering::SeqCst);
let eula_accepted = read_eula_accepted(&dir).await;
let crashed = exit_status
.map(|success| !success && !stop_requested && eula_accepted)
.unwrap_or(false);
// Classify self-exits from the tail of the console output so the UI
// can react (e.g. offer the EULA dialog). User-requested stops and
// unmatched exits stay unclassified. The brief settle wait lets the
// output-stream tasks flush their final lines into the buffer first.
let reason = if stop_requested {
None
} else {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
analyze_exit_reason(&get_log_buffer(&server_id))
};
if let Ok(mut manifest) = read_manifest(&dir).await {
manifest.last_exit_crashed = crashed;
let _ = write_manifest(&dir, &manifest).await;
}
emit_server(&server_id, ServerPayloadType::Stopped { crashed, reason })
.await
.ok();
return;
}
}
/// Builds the JVM launch arguments for a Forge server. Modern Forge (1.17+)
/// ships `@args` files that enumerate the classpath and main class; legacy Forge
/// (<=1.16) produces a single runnable `forge-*.jar`.
fn forge_launch_args(dir: &Path) -> Result<Vec<String>> {
let forge_dir = dir
.join("libraries")
.join("net")
.join("minecraftforge")
.join("forge");
if let Ok(entries) = std::fs::read_dir(&forge_dir) {
let args_file = if cfg!(windows) {
"win_args.txt"
} else {
"unix_args.txt"
};
for entry in entries.flatten() {
let candidate = entry.path().join(args_file);
if candidate.is_file() {
let mut args = Vec::new();
if dir.join("user_jvm_args.txt").exists() {
args.push("@user_jvm_args.txt".to_string());
}
args.push(format!("@{}", candidate.to_string_lossy()));
args.push("nogui".to_string());
return Ok(args);
}
}
}
if let Some(jar) = find_forge_jar(dir) {
return Ok(vec!["-jar".to_string(), jar, "nogui".to_string()]);
}
Err(ErrorKind::LauncherError(
"Forge server files are missing. Reinstall the server.".to_string(),
)
.as_error())
}
fn find_forge_jar(dir: &Path) -> Option<String> {
let entry = std::fs::read_dir(dir).ok()?.flatten().find(|e| {
e.file_name().to_string_lossy().starts_with("forge-")
&& e.path().extension().is_some_and(|ext| ext == "jar")
})?;
Some(entry.file_name().to_string_lossy().into_owned())
}
async fn read_eula_accepted(dir: &Path) -> bool {
match tokio::fs::read_to_string(dir.join("eula.txt")).await {
Ok(text) => text
.lines()
.find_map(|line| line.split_once('='))
.filter(|(key, _)| key.trim() == "eula")
.is_some_and(|(_, value)| {
value.trim().eq_ignore_ascii_case("true")
}),
Err(_) => false,
}
}
/// Emits a timestamped, info-level line to the server console: it is both
/// persisted to the log buffer and pushed as a live `Log` event, so startup
/// progress is visible even before the JVM produces any output of its own.
async fn log_server_step(server_id: &str, message: &str) {
let line = format!(
"{} [Axolotl/INFO]: {}",
chrono::Local::now().format("%H:%M:%S"),
message,
);
push_log_line(server_id, line.clone());
emit_server(server_id, ServerPayloadType::Log { line })
.await
.ok();
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone)]
struct SharedWriter(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for SharedWriter {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn selects_pty_only_for_forge() {
assert!(uses_pty_transport("forge"));
assert!(!uses_pty_transport("vanilla"));
assert!(!uses_pty_transport("fabric"));
assert!(!uses_pty_transport("neoforge"));
}
#[test]
fn whole_line_commands_end_with_one_newline() {
assert_eq!(command_bytes("say hello"), b"say hello\n");
}
#[tokio::test]
async fn pty_input_preserves_raw_bytes() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut input =
ServerInput::Pty(Box::new(SharedWriter(captured.clone())));
input.write_all(b"\x1b[D\t\x7f").await.unwrap();
assert_eq!(*captured.lock().unwrap(), b"\x1b[D\t\x7f");
}
}

View File

@ -0,0 +1,484 @@
//! Console output buffering and streaming for servers.
use base64::Engine;
use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncSeekExt, BufReader};
use crate::Result;
use crate::api::servers::lifecycle::is_server_running;
use crate::event::emit::emit_server;
use crate::event::{ExitReason, ServerPayloadType};
use crate::state::{clear_log_buffer, push_log_line};
const MAX_PTY_LINE_BYTES: usize = 256 * 1024;
const MAX_SERVER_LOG_LINE_BYTES: usize = 64 * 1024;
const SERVER_LOG_TRUNCATION_MARKER: &str =
" … [log output truncated by Axolotl] … ";
async fn read_bounded_server_log_line<R>(
reader: &mut R,
) -> std::io::Result<Option<String>>
where
R: AsyncBufRead + Unpin,
{
let mut line = Vec::new();
let mut saw_bytes = false;
let mut truncated = false;
loop {
let (consumed, reached_line_end) = {
let available = reader.fill_buf().await?;
if available.is_empty() {
if !saw_bytes {
return Ok(None);
}
break;
}
saw_bytes = true;
let consumed = available
.iter()
.position(|byte| *byte == b'\n')
.map_or(available.len(), |index| index + 1);
let maximum_content_bytes = MAX_SERVER_LOG_LINE_BYTES
.saturating_sub(SERVER_LOG_TRUNCATION_MARKER.len() + 1);
let remaining = maximum_content_bytes.saturating_sub(line.len());
let copied = remaining.min(consumed);
line.extend_from_slice(&available[..copied]);
truncated |= copied < consumed;
(consumed, available[consumed - 1] == b'\n')
};
reader.consume(consumed);
if reached_line_end {
break;
}
}
if truncated {
while matches!(line.last(), Some(b'\r' | b'\n')) {
line.pop();
}
line.extend_from_slice(SERVER_LOG_TRUNCATION_MARKER.as_bytes());
line.push(b'\n');
}
Ok(Some(String::from_utf8_lossy(&line).into_owned()))
}
pub async fn get_log_buffer(server_id: &str) -> Result<Vec<String>> {
Ok(crate::state::get_log_buffer(server_id))
}
pub async fn clear_log(server_id: &str) -> Result<()> {
clear_log_buffer(server_id);
Ok(())
}
pub(super) async fn stream_server_output(
server_id: String,
reader: impl tokio::io::AsyncRead + Unpin,
) {
let mut buf_reader = BufReader::new(reader);
let mut jna_hint_emitted = false;
while let Ok(Some(line)) =
read_bounded_server_log_line(&mut buf_reader).await
{
process_server_output_line(
&server_id,
line.trim_end_matches(['\r', '\n']),
&mut jna_hint_emitted,
)
.await;
}
}
pub(super) async fn stream_server_pty_output(
server_id: String,
mut reader: Box<dyn std::io::Read + Send>,
) {
let (sender, mut receiver) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
tokio::task::spawn_blocking(move || {
let mut buffer = vec![0_u8; 8192];
loop {
match reader.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => {
if sender.blocking_send(buffer[..read].to_vec()).is_err() {
break;
}
}
}
}
});
let mut pending_line = Vec::new();
let mut jna_hint_emitted = false;
while let Some(bytes) = receiver.recv().await {
emit_server(
&server_id,
ServerPayloadType::ConsoleOutput {
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
},
)
.await
.ok();
for line in take_complete_pty_lines(&mut pending_line, &bytes) {
let text = String::from_utf8_lossy(&line);
process_server_output_line(
&server_id,
text.trim_end_matches(['\r', '\n']),
&mut jna_hint_emitted,
)
.await;
}
}
if !pending_line.is_empty() {
process_server_output_line(
&server_id,
&String::from_utf8_lossy(&pending_line),
&mut jna_hint_emitted,
)
.await;
}
}
fn take_complete_pty_lines(
pending: &mut Vec<u8>,
bytes: &[u8],
) -> Vec<Vec<u8>> {
pending.extend_from_slice(bytes);
let Some(last_newline) = pending.iter().rposition(|byte| *byte == b'\n')
else {
if pending.len() > MAX_PTY_LINE_BYTES {
pending.clear();
}
return Vec::new();
};
let remainder = pending.split_off(last_newline + 1);
let completed = std::mem::replace(pending, remainder);
let mut lines = Vec::new();
for line in completed.split_inclusive(|byte| *byte == b'\n') {
if line.len() <= MAX_PTY_LINE_BYTES {
lines.push(line.to_vec());
}
}
lines
}
async fn process_server_output_line(
server_id: &str,
raw_line: &str,
jna_hint_emitted: &mut bool,
) {
let cleaned = strip_ansi(raw_line);
if cleaned.is_empty()
|| is_timestamped_log_line(&cleaned)
|| cleaned.starts_with("> ")
{
return;
}
push_log_line(server_id, cleaned.clone());
emit_server(server_id, ServerPayloadType::Log { line: cleaned })
.await
.ok();
if !*jna_hint_emitted && is_jna_macos_assertion(raw_line) {
*jna_hint_emitted = true;
for hint in JNA_CRASH_HINT_LINES {
push_log_line(server_id, hint.to_string());
emit_server(
server_id,
ServerPayloadType::Log {
line: hint.to_string(),
},
)
.await
.ok();
}
}
}
/// Streams the server's `logs/latest.log` file into the console buffer. The
/// Minecraft/Fabric log4j console output is frequently not delivered through
/// the process stdout pipe (it goes to the log file instead), so tailing this
/// file is the authoritative, lossless source of the server's own logs. Lines
/// already present in the buffer (e.g. delivered via the stdout/stderr pipes)
/// are skipped to avoid duplicates.
pub(super) async fn tail_server_log_file(server_id: String, dir: PathBuf) {
let log_path = dir.join("logs").join("latest.log");
let mut reader = loop {
if !is_server_running(&server_id) {
return;
}
match File::open(&log_path).await {
Ok(file) => break BufReader::new(file),
// The file only appears once the server starts logging; poll until then.
Err(_) => {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
};
loop {
if !is_server_running(&server_id) {
return;
}
match read_bounded_server_log_line(&mut reader).await {
Ok(None) => {
// Caught up. Detect log rotation (file replaced/truncated) and
// otherwise wait for more output to be appended.
if let Ok(meta) = tokio::fs::metadata(&log_path).await
&& let Ok(pos) = reader.stream_position().await
&& meta.len() < pos
&& let Ok(file) = File::open(&log_path).await
{
reader = BufReader::new(file);
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
Ok(Some(line)) => {
let trimmed = line.trim_end_matches(['\r', '\n']);
let cleaned = strip_ansi(trimmed);
let already_present =
crate::state::get_log_buffer(&server_id).contains(&cleaned);
if !cleaned.is_empty() && !already_present {
push_log_line(&server_id, cleaned.clone());
emit_server(
&server_id,
ServerPayloadType::Log { line: cleaned },
)
.await
.ok();
}
}
Err(_) => break,
}
}
}
/// Matches the native abort of the known JNA (< 5.13.0) macOS bug (JNA issue
/// #1452): a failed library load overflows JNA's fixed error buffer and the
/// JVM dies with SIGABRT before any Java-level exception can be reported.
fn is_jna_macos_assertion(line: &str) -> bool {
line.contains("Assertion failed:")
&& line.contains("snprintf() output has been truncated")
&& line.contains("dispatch.c")
}
/// Detects a server log4j line by its leading `[HH:MM:SS]` timestamp, covering
/// both console (`[HH:MM:SS INFO]:`) and file (`[HH:MM:SS] [Thread/INFO]:`)
/// formats. Used to suppress the process-pipe echo of server logs so they are
/// not duplicated by `tail_server_log_file`.
fn is_timestamped_log_line(line: &str) -> bool {
let b = line.as_bytes();
b.first() == Some(&b'[')
&& b.get(1).is_some_and(|c| c.is_ascii_digit())
&& b.get(2).is_some_and(|c| c.is_ascii_digit())
&& b.get(3) == Some(&b':')
&& b.get(4).is_some_and(|c| c.is_ascii_digit())
&& b.get(5).is_some_and(|c| c.is_ascii_digit())
&& b.get(6) == Some(&b':')
&& b.get(7).is_some_and(|c| c.is_ascii_digit())
&& b.get(8).is_some_and(|c| c.is_ascii_digit())
}
/// How many lines at the end of a server's output are inspected when
/// classifying why it exited.
const EXIT_ANALYSIS_TAIL_LINES: usize = 50;
/// Classifies why a server exited on its own by scanning the tail of its
/// console output, newest lines first. Returns `None` when nothing matches:
/// no guess is better than a wrong one, and unmatched exits simply behave as
/// before.
pub(super) fn analyze_exit_reason(lines: &[String]) -> Option<ExitReason> {
lines
.iter()
.rev()
.take(EXIT_ANALYSIS_TAIL_LINES)
.find_map(|line| is_eula_refusal(line).then_some(ExitReason::Eula))
}
/// Matches the vanilla server's refusal to boot before the EULA has been
/// accepted; the process then writes `eula.txt` and exits immediately.
fn is_eula_refusal(line: &str) -> bool {
line.contains("need to agree to the EULA")
}
const JNA_CRASH_HINT_LINES: [&str; 3] = [
"[Axolotl] This crash matches a known JNA bug on macOS (java-native-access#1452):",
"[Axolotl] mods bundling JNA below 5.13.0 abort when a native library fails to load.",
"[Axolotl] Update or remove the affected mod, or ask the modpack author to bump JNA to 5.13.0+.",
];
/// Removes ANSI escape sequences (SGR colors, cursor control, OSC titles) that
/// servers emit when they assume an interactive terminal is attached.
fn strip_ansi(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.char_indices().peekable();
while let Some((_, character)) = chars.next() {
if character != '\u{1b}' {
output.push(character);
continue;
}
match chars.peek().map(|&(_, c)| c) {
// CSI sequence: parameter bytes, then a final byte in @..~
Some('[') => {
chars.next();
for (_, c) in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
// OSC sequence: terminated by BEL or ST (ESC \)
Some(']') => {
chars.next();
let mut saw_escape = false;
for (_, c) in chars.by_ref() {
if c == '\u{7}' || (saw_escape && c == '\\') {
break;
}
saw_escape = c == '\u{1b}';
}
}
// Stray escape byte without a recognized sequence
_ => {}
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_ansi_escape_sequences_from_server_output() {
let line = "[16:02:30 INFO]: \u{1b}[38;2;255;170;0m/mspt: \u{1b}[38;2;255;255;255mView server tick times\u{1b}[0m";
assert_eq!(
strip_ansi(line),
"[16:02:30 INFO]: /mspt: View server tick times"
);
assert_eq!(strip_ansi("\u{1b}]0;Server console\u{7}ready"), "ready");
assert_eq!(strip_ansi("\u{1b}]0;Server console\u{1b}\\done"), "done");
assert_eq!(strip_ansi("plain text stays"), "plain text stays");
assert_eq!(strip_ansi("h\u{e9}llo \u{1b}[31mred"), "h\u{e9}llo red");
}
#[test]
fn splits_pty_output_without_losing_partial_or_raw_bytes() {
let mut pending = Vec::new();
assert_eq!(
take_complete_pty_lines(&mut pending, b"first\r\nsecond"),
vec![b"first\r\n".to_vec()]
);
assert_eq!(pending, b"second");
assert_eq!(
take_complete_pty_lines(&mut pending, b"\nthird\n"),
vec![b"second\n".to_vec(), b"third\n".to_vec()]
);
assert!(pending.is_empty());
}
#[test]
fn bounds_unterminated_pty_output() {
let mut pending = vec![b'x'; MAX_PTY_LINE_BYTES];
assert!(take_complete_pty_lines(&mut pending, b"x").is_empty());
assert!(pending.is_empty());
let oversized = vec![b'x'; MAX_PTY_LINE_BYTES + 1];
assert!(take_complete_pty_lines(&mut pending, &oversized).is_empty());
assert!(pending.is_empty());
}
#[tokio::test]
async fn bounded_server_log_reader_consumes_oversized_line() {
let input =
format!("{}\nnext\n", "x".repeat(MAX_SERVER_LOG_LINE_BYTES * 2));
let mut reader = BufReader::new(std::io::Cursor::new(input));
let first = read_bounded_server_log_line(&mut reader)
.await
.unwrap()
.unwrap();
let second = read_bounded_server_log_line(&mut reader)
.await
.unwrap()
.unwrap();
assert!(first.contains("truncated by Axolotl"));
assert!(first.len() <= MAX_SERVER_LOG_LINE_BYTES);
assert_eq!(second, "next\n");
}
#[test]
fn detects_timestamped_log_lines() {
// Console format (no thread) — duplicated by Paper's stdout echo.
assert!(is_timestamped_log_line(
"[11:13:49 INFO]: [bootstrap] Running Java 25"
));
// File format (with thread) — authoritative source from latest.log.
assert!(is_timestamped_log_line(
"[11:13:49] [ServerMain/INFO]: [bootstrap] Running"
));
assert!(is_timestamped_log_line(
"[11:13:49] [Server thread/INFO]: Stopped IO worker!"
));
// Non-logged process output must NOT be suppressed.
assert!(!is_timestamped_log_line("Downloading mojang_26.2.jar"));
assert!(!is_timestamped_log_line("Applying patches"));
assert!(!is_timestamped_log_line(
"Starting org.bukkit.craftbukkit.Main"
));
assert!(!is_timestamped_log_line(
"WARNING: A terminally deprecated method in sun.misc.Unsafe"
));
assert!(!is_timestamped_log_line(
"2026-08-29T03:13:49.279070900Z ServerMain WARN Advanced terminal features",
));
}
#[test]
fn detects_jna_macos_assertion() {
let line = "Assertion failed: (count <= len && \"snprintf() output has been truncated\"), function LOAD_ERROR, file dispatch.c, line 74.";
assert!(is_jna_macos_assertion(line));
assert!(!is_jna_macos_assertion(
"Assertion failed: something else, file other.c, line 1."
));
assert!(!is_jna_macos_assertion("regular log output"));
}
#[test]
fn classifies_eula_refusal_from_output_tail() {
let eula_line = "[15:26:09] [main/INFO]: You need to agree to the EULA in order to run the server. Go to eula.txt for more info.".to_string();
let mut lines = vec![
"[15:26:09] [main/INFO]: Starting minecraft server version 26.2"
.to_string(),
eula_line.clone(),
];
assert_eq!(analyze_exit_reason(&lines), Some(ExitReason::Eula));
// Detected even when buried under later shutdown chatter.
lines.push(
"[16:44:23] [Server thread/INFO]: Stopped IO worker!".to_string(),
);
assert_eq!(analyze_exit_reason(&lines), Some(ExitReason::Eula));
// A normal shutdown matches nothing and stays unclassified.
let normal = vec![
"[16:44:18] [Server thread/INFO]: Stopping the server".to_string(),
"[16:44:23] [Server thread/INFO]: Stopped IO worker!".to_string(),
];
assert_eq!(analyze_exit_reason(&normal), None);
assert_eq!(analyze_exit_reason(&[]), None);
// Only the tail is inspected; ancient history does not classify a
// much-later exit.
let mut old = vec![eula_line];
old.resize(EXIT_ANALYSIS_TAIL_LINES + 10, "noise".to_string());
assert_eq!(analyze_exit_reason(&old), None);
}
}

View File

@ -0,0 +1,161 @@
//! Listing, creation, and settings management for servers.
use chrono::Utc;
use uuid::Uuid;
use crate::state::remove_log_buffer;
use crate::util::io::{self, IOError};
use crate::{ErrorKind, Result, State};
use super::lifecycle::is_running;
use super::manifest::{
ServerInfo, ServerManifest, build_server_info, read_manifest,
sanitize_folder_name, server_path, type_default_jar_name, write_manifest,
};
pub async fn list() -> Result<Vec<ServerInfo>> {
let state = State::get().await?;
let servers_dir = state.directories.servers_dir();
if !servers_dir.exists() {
return Ok(Vec::new());
}
let mut servers = Vec::new();
let mut entries = tokio::fs::read_dir(&servers_dir)
.await
.map_err(|e| IOError::with_path(e, &servers_dir))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &servers_dir))?
{
let path = entry.path();
if !path.is_dir() {
continue;
}
let Ok(manifest) = read_manifest(&path).await else {
continue;
};
servers.push(build_server_info(&manifest, &path).await);
}
servers.sort_by(|a, b| {
a.manifest
.name
.to_lowercase()
.cmp(&b.manifest.name.to_lowercase())
});
Ok(servers)
}
pub async fn get(server_id: &str) -> Result<ServerInfo> {
let path = server_path(server_id).await?;
let manifest = read_manifest(&path).await?;
Ok(build_server_info(&manifest, &path).await)
}
pub async fn create(
name: &str,
server_type: &str,
game_version: &str,
loader_version: Option<String>,
java_path: Option<String>,
memory_mb: Option<u32>,
) -> Result<ServerManifest> {
let name = name.trim();
if name.is_empty() {
return Err(ErrorKind::InputError(
"Server name cannot be empty".to_string(),
)
.as_error());
}
let state = State::get().await?;
let id = Uuid::new_v4().to_string();
let dir_name = format!("{}-{}", sanitize_folder_name(name), &id[..8]);
let dir = state.directories.servers_dir().join(&dir_name);
io::create_dir_all(&dir).await?;
let manifest = ServerManifest {
id: dir_name,
name: name.to_string(),
server_type: server_type.to_string(),
game_version: game_version.to_string(),
loader_version,
jar_name: type_default_jar_name(server_type),
java_path,
memory_mb,
icon_path: None,
modpack: None,
install_state: None,
install_error: None,
jvm_args: Vec::new(),
created_at: Utc::now(),
last_started_at: None,
last_exit_crashed: false,
};
write_manifest(&dir, &manifest).await?;
Ok(manifest)
}
/// Sets or clears the server icon. `None` resets to the default icon.
pub async fn set_icon(
server_id: &str,
icon_path: Option<String>,
) -> Result<ServerManifest> {
let path = server_path(server_id).await?;
let mut manifest = read_manifest(&path).await?;
manifest.icon_path = icon_path;
write_manifest(&path, &manifest).await?;
Ok(manifest)
}
pub async fn update_settings(
server_id: &str,
name: Option<String>,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<ServerManifest> {
let path = server_path(server_id).await?;
let mut manifest = read_manifest(&path).await?;
if let Some(name) = name {
let name = name.trim().to_string();
if name.is_empty() {
return Err(ErrorKind::InputError(
"Server name cannot be empty".to_string(),
)
.as_error());
}
manifest.name = name;
}
if let Some(java_path) = java_path {
manifest.java_path = if java_path.is_empty() {
None
} else {
Some(java_path)
};
}
if let Some(memory_mb) = memory_mb {
manifest.memory_mb = Some(memory_mb);
}
if let Some(jvm_args) = jvm_args {
manifest.jvm_args = jvm_args;
}
write_manifest(&path, &manifest).await?;
Ok(manifest)
}
pub async fn delete(server_id: &str) -> Result<()> {
if is_running(server_id) {
return Err(ErrorKind::InputError(
"Stop the server before deleting it".to_string(),
)
.as_error());
}
let path = server_path(server_id).await?;
remove_log_buffer(server_id);
tokio::fs::remove_dir_all(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
Ok(())
}

View File

@ -0,0 +1,268 @@
//! Server manifests and per-server metadata: the `axolotl-server.json`
//! document, derived display info, and path helpers shared by the other
//! `servers` submodules.
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::state::State;
use crate::util::io;
use crate::{ErrorKind, Result};
use super::lifecycle::is_running;
const MANIFEST_FILE: &str = "axolotl-server.json";
const DEFAULT_JAR_NAME: &str = "server.jar";
/// Executable launcher jar downloaded from Fabric Meta; must match the
/// filename used by the frontend's `resolveServerJar('fabric')`.
const FABRIC_SERVER_JAR_NAME: &str = "fabric-server.jar";
/// Executable launcher jar downloaded from Quilt Meta; must match the
/// filename used by the frontend's `resolveServerJar('quilt')`.
const QUILT_SERVER_JAR_NAME: &str = "quilt-server.jar";
pub(super) fn type_default_jar_name(server_type: &str) -> Option<String> {
match server_type {
"fabric" => Some(FABRIC_SERVER_JAR_NAME.to_string()),
"quilt" => Some(QUILT_SERVER_JAR_NAME.to_string()),
_ => None,
}
}
/// Resolves the jar a server launches with: the manifest override, then the
/// server type default, then the generic default.
pub(super) fn resolve_jar_name(manifest: &ServerManifest) -> String {
manifest
.jar_name
.clone()
.or_else(|| type_default_jar_name(&manifest.server_type))
.unwrap_or_else(|| DEFAULT_JAR_NAME.to_string())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ServerManifest {
pub id: String,
pub name: String,
pub server_type: String,
pub game_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loader_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jar_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_mb: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modpack: Option<ModpackInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_state: Option<InstallState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub jvm_args: Vec<String>,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_started_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_exit_crashed: bool,
}
/// Tracks whether a modpack server has finished materializing. `Incomplete`
/// is written when an install starts (and left behind if the app exits
/// mid-download); `Failed` records an install error so the UI can offer a
/// retry. Both clear once the install succeeds.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InstallState {
Incomplete,
Failed,
}
/// Identifies a modpack a server was created from. Populated by
/// [`super::modpack::install_modpack`] so the UI can badge and link servers
/// back to their source project.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModpackInfo {
pub project_id: String,
pub version_id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
#[derive(Serialize, Debug, Clone)]
pub struct ServerInfo {
#[serde(flatten)]
pub manifest: ServerManifest,
pub path: String,
pub running: bool,
pub eula_exists: bool,
pub eula_accepted: bool,
pub port: Option<u16>,
}
pub(super) async fn server_path(server_id: &str) -> Result<PathBuf> {
if server_id.contains(['/', '\\'])
|| server_id.contains("..")
|| server_id.is_empty()
{
return Err(ErrorKind::InputError(format!(
"Invalid server id: {server_id}"
))
.as_error());
}
let state = State::get().await?;
let path = state.directories.server_dir(server_id);
if !path.is_dir() {
return Err(ErrorKind::InputError(format!(
"Unknown server: {server_id}"
))
.as_error());
}
Ok(path)
}
pub(super) async fn read_manifest(dir: &Path) -> Result<ServerManifest> {
let bytes = io::read(dir.join(MANIFEST_FILE)).await?;
serde_json::from_slice(&bytes).map_err(|e| {
ErrorKind::FSError(format!("Failed to parse server manifest: {e}"))
.as_error()
})
}
pub(super) async fn write_manifest(
dir: &Path,
manifest: &ServerManifest,
) -> Result<()> {
let contents = serde_json::to_string_pretty(manifest)?;
io::write(dir.join(MANIFEST_FILE), contents).await?;
Ok(())
}
pub(super) fn sanitize_folder_name(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let trimmed = sanitized.trim_matches('-');
if trimmed.is_empty() {
"server".to_string()
} else {
trimmed.chars().take(32).collect()
}
}
pub(super) async fn build_server_info(
manifest: &ServerManifest,
path: &Path,
) -> ServerInfo {
let eula_text = tokio::fs::read_to_string(path.join("eula.txt"))
.await
.unwrap_or_default();
let eula_exists = !eula_text.is_empty();
let eula_accepted = eula_text
.lines()
.find_map(|line| line.split_once('='))
.filter(|(key, _)| key.trim() == "eula")
.is_some_and(|(_, value)| value.trim().eq_ignore_ascii_case("true"));
let port = tokio::fs::read_to_string(path.join("server.properties"))
.await
.ok()
.and_then(|text| {
text.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
(key.trim() == "server-port")
.then(|| value.trim().parse::<u16>().ok())?
})
});
ServerInfo {
manifest: manifest.clone(),
path: path.to_string_lossy().into_owned(),
running: is_running(&manifest.id),
eula_exists,
eula_accepted,
port,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_folder_name_replaces_unsafe_characters() {
assert_eq!(sanitize_folder_name("My Server!"), "My-Server");
assert_eq!(sanitize_folder_name("../etc"), "etc");
assert_eq!(sanitize_folder_name("///"), "server");
}
#[test]
fn server_manifest_round_trips() {
let manifest = ServerManifest {
id: "test-12345678".to_string(),
name: "Test".to_string(),
server_type: "vanilla".to_string(),
game_version: "1.21.4".to_string(),
loader_version: None,
jar_name: None,
java_path: None,
memory_mb: Some(2048),
icon_path: None,
modpack: None,
install_state: None,
install_error: None,
jvm_args: Vec::new(),
created_at: Utc::now(),
last_started_at: None,
last_exit_crashed: false,
};
let json = serde_json::to_string(&manifest).unwrap();
let parsed: ServerManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, manifest.id);
assert_eq!(parsed.name, manifest.name);
}
#[test]
fn resolves_jar_name_from_type_then_manifest() {
let mut manifest = ServerManifest {
id: "test-12345678".to_string(),
name: "Test".to_string(),
server_type: "fabric".to_string(),
game_version: "26.2".to_string(),
loader_version: Some("0.19.3".to_string()),
jar_name: None,
java_path: None,
memory_mb: None,
icon_path: None,
modpack: None,
install_state: None,
install_error: None,
jvm_args: Vec::new(),
created_at: Utc::now(),
last_started_at: None,
last_exit_crashed: false,
};
assert_eq!(resolve_jar_name(&manifest), FABRIC_SERVER_JAR_NAME);
manifest.server_type = "quilt".to_string();
assert_eq!(resolve_jar_name(&manifest), QUILT_SERVER_JAR_NAME);
manifest.server_type = "vanilla".to_string();
assert_eq!(resolve_jar_name(&manifest), DEFAULT_JAR_NAME);
manifest.jar_name = Some("custom.jar".to_string());
assert_eq!(resolve_jar_name(&manifest), "custom.jar");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,170 @@
//! Platform utilities for inspecting and terminating processes that hold a
//! TCP port, used to resolve "port already in use" conflicts.
use serde::Serialize;
use tokio::process::Command;
use crate::{ErrorKind, Result};
pub async fn kill_port_process(port: u16) -> Result<()> {
let pids = port_listener_pids(port).await?;
if pids.is_empty() {
return Err(ErrorKind::InputError(format!(
"No process found listening on port {port}"
))
.as_error());
}
for pid in pids {
force_terminate_pid(pid).await?;
}
Ok(())
}
#[derive(Serialize, Debug, Clone)]
pub struct PortProcessInfo {
pub pid: u32,
pub name: Option<String>,
}
/// Returns the first process listening on the given TCP port, if any.
pub async fn port_process(port: u16) -> Result<Option<PortProcessInfo>> {
let pids = port_listener_pids(port).await?;
let Some(&pid) = pids.first() else {
return Ok(None);
};
Ok(Some(PortProcessInfo {
pid,
name: process_name(pid).await,
}))
}
#[cfg(not(target_os = "windows"))]
async fn port_listener_pids(port: u16) -> Result<Vec<u32>> {
let output = Command::new("lsof")
.args(["-t", "-i", &format!("tcp:{port}"), "-s", "tcp:listen"])
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to look up processes listening on port {port}: {e}"
))
.as_error()
})?;
let mut pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect();
pids.sort_unstable();
pids.dedup();
Ok(pids)
}
#[cfg(not(target_os = "windows"))]
async fn force_terminate_pid(pid: u32) -> Result<()> {
let output = Command::new("kill")
.args(["-9", &pid.to_string()])
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}: {e}"
))
.as_error()
})?;
if !output.status.success() {
return Err(ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}"
))
.as_error());
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
async fn process_name(pid: u32) -> Option<String> {
let output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!name.is_empty()).then_some(name)
}
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
#[cfg(target_os = "windows")]
async fn port_listener_pids(port: u16) -> Result<Vec<u32>> {
let output = Command::new("netstat")
.args(["-ano", "-p", "tcp"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to look up processes listening on port {port}: {e}"
))
.as_error()
})?;
let mut pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let columns: Vec<&str> = line.split_whitespace().collect();
if columns.len() < 5
|| !columns[3].eq_ignore_ascii_case("LISTENING")
{
return None;
}
let local_address = columns[1];
let local_port = local_address.rsplit(':').next()?;
(local_port == port.to_string())
.then(|| columns[4].parse::<u32>().ok())?
})
.collect();
pids.sort_unstable();
pids.dedup();
Ok(pids)
}
#[cfg(target_os = "windows")]
async fn force_terminate_pid(pid: u32) -> Result<()> {
let output = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}: {e}"
))
.as_error()
})?;
if !output.status.success() {
return Err(ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}"
))
.as_error());
}
Ok(())
}
#[cfg(target_os = "windows")]
async fn process_name(pid: u32) -> Option<String> {
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().next()?;
if line.starts_with("INFO") {
return None;
}
let name = line.split(',').next()?.trim_matches('"').to_string();
(!name.is_empty()).then_some(name)
}

View File

@ -0,0 +1,138 @@
//! Theseus settings management interface
pub use crate::util::download::DownloadEngine;
pub use crate::{
State,
state::{
DownloadSourceMode, Hooks, MemorySettings, PrivacySettings, Settings,
WindowSize,
},
};
/// Gets entire settings
#[tracing::instrument]
pub async fn get() -> crate::Result<Settings> {
let state = State::get().await?;
let settings = Settings::get(&state.pool).await?;
Ok(settings)
}
/// Sets entire settings
#[tracing::instrument]
pub async fn set(mut settings: Settings) -> crate::Result<()> {
let state = State::get().await?;
let current = Settings::get(&state.pool).await?;
settings.telemetry = current.telemetry;
settings.telemetry_consent_version = current.telemetry_consent_version;
settings.discord_rpc = current.discord_rpc;
super::terracotta::validate_public_nodes(
&settings.terracotta_public_nodes,
)?;
settings.apply_legacy_download_source_settings();
settings.update(&state.pool).await?;
state.update_download_settings(&settings);
crate::util::download::set_active_engine(settings.download_engine);
Ok(())
}
#[tracing::instrument]
pub async fn set_download_engine(engine: DownloadEngine) -> crate::Result<()> {
let state = State::get().await?;
let mut settings = Settings::get(&state.pool).await?;
settings.download_engine = engine;
settings.update(&state.pool).await?;
crate::util::download::set_active_engine(engine);
Ok(())
}
#[tracing::instrument]
pub async fn get_privacy() -> crate::Result<PrivacySettings> {
let state = State::get().await?;
let settings = Settings::get(&state.pool).await?;
Ok(PrivacySettings {
telemetry: settings.telemetry,
discord_rpc: settings.discord_rpc,
consent_version: settings.telemetry_consent_version,
})
}
#[tracing::instrument]
pub async fn set_privacy(
privacy: PrivacySettings,
) -> crate::Result<PrivacySettings> {
let state = State::get().await?;
let mut transaction = state.pool.begin().await?;
sqlx::query(
"UPDATE settings SET telemetry = ?, discord_rpc = ?, telemetry_consent_version = ? WHERE id = 0",
)
.bind(privacy.telemetry)
.bind(privacy.discord_rpc)
.bind(privacy.consent_version)
.execute(&mut *transaction)
.await?;
sqlx::query("DELETE FROM telemetry_outbox")
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
if let Err(error) =
crate::telemetry::set_enabled(&state, privacy.telemetry).await
{
tracing::debug!(target: "theseus::telemetry", %error, "Failed to apply telemetry state");
}
if let Err(error) = state.discord_rpc.clear_to_default(true).await {
tracing::debug!(target: "theseus::telemetry", %error, "Failed to apply Discord RPC state");
}
get_privacy().await
}
#[tracing::instrument]
pub async fn set_telemetry(enabled: bool) -> crate::Result<PrivacySettings> {
let state = State::get().await?;
let mut transaction = state.pool.begin().await?;
sqlx::query("UPDATE settings SET telemetry = ? WHERE id = 0")
.bind(enabled)
.execute(&mut *transaction)
.await?;
sqlx::query("DELETE FROM telemetry_outbox")
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
if let Err(error) = crate::telemetry::set_enabled(&state, enabled).await {
tracing::debug!(target: "theseus::telemetry", %error, "Failed to apply telemetry state");
}
get_privacy().await
}
#[tracing::instrument]
pub async fn set_discord_rpc(enabled: bool) -> crate::Result<PrivacySettings> {
let state = State::get().await?;
sqlx::query("UPDATE settings SET discord_rpc = ? WHERE id = 0")
.bind(enabled)
.execute(&state.pool)
.await?;
if let Err(error) = state.discord_rpc.clear_to_default(true).await {
tracing::debug!(target: "theseus::telemetry", %error, "Failed to apply Discord RPC state");
}
get_privacy().await
}
#[tracing::instrument]
pub async fn cancel_directory_change(
app_identifier: &str,
) -> crate::Result<()> {
// This is called to handle state initialization errors due to folder migrations
// failing, so fetching a DB connection pool from `State::get` is not reliable here
let pool = crate::state::db::connect(app_identifier).await?;
let mut settings = Settings::get(&pool).await?;
if let Some(prev_custom_dir) = settings.prev_custom_dir {
settings.prev_custom_dir = None;
settings.custom_dir = Some(prev_custom_dir);
}
settings.update(&pool).await?;
Ok(())
}

View File

@ -0,0 +1,14 @@
use crate::util::symlink::SymlinkCapability;
#[tracing::instrument]
pub async fn check_symlink_capability() -> crate::Result<SymlinkCapability> {
Ok(crate::util::symlink::check_symlink_capability().await)
}
/// Entry point for the elevated link-creation helper process. Exits with 0 on
/// success and 1 on failure, after writing the outcome to the request's
/// result file.
#[cfg(target_os = "windows")]
pub fn create_link_elevated_helper(payload: &str) -> i32 {
crate::util::symlink::create_link_elevated_helper(payload)
}

View File

@ -0,0 +1,80 @@
//! Theseus tag management interface
use crate::state::CachedEntry;
pub use crate::{
State,
state::{Category, DonationPlatform, GameVersion, Loader},
};
/// Get category tags
#[tracing::instrument]
pub async fn get_category_tags() -> crate::Result<Vec<Category>> {
let state = State::get().await?;
let categories =
CachedEntry::get_categories(None, &state.pool, &state.api_semaphore)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor("category tags".to_string())
})?;
Ok(categories)
}
/// Get report type tags
#[tracing::instrument]
pub async fn get_report_type_tags() -> crate::Result<Vec<String>> {
let state = State::get().await?;
let report_types =
CachedEntry::get_report_types(None, &state.pool, &state.api_semaphore)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor("report type tags".to_string())
})?;
Ok(report_types)
}
/// Get loader tags
#[tracing::instrument]
pub async fn get_loader_tags() -> crate::Result<Vec<Loader>> {
let state = State::get().await?;
let loaders =
CachedEntry::get_loaders(None, &state.pool, &state.api_semaphore)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor("loader tags".to_string())
})?;
Ok(loaders)
}
/// Get game version tags
#[tracing::instrument]
pub async fn get_game_version_tags() -> crate::Result<Vec<GameVersion>> {
let state = State::get().await?;
let game_versions =
CachedEntry::get_game_versions(None, &state.pool, &state.api_semaphore)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor("game version tags".to_string())
})?;
Ok(game_versions)
}
/// Get donation platform tags
#[tracing::instrument]
pub async fn get_donation_platform_tags() -> crate::Result<Vec<DonationPlatform>>
{
let state = State::get().await?;
let donation_platforms = CachedEntry::get_donation_platforms(
None,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor("donation platform tags".to_string())
})?;
Ok(donation_platforms)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,327 @@
use eyre::{Context, bail};
use std::io::Read;
use std::path::{Path, PathBuf};
use tracing::info;
pub fn terracotta_platform_key() -> &'static str {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "linux-x86_64",
("linux", "aarch64") => "linux-arm64",
("linux", "riscv64") => "linux-riscv64",
("linux", "loongarch64") => "linux-loongarch64",
("macos", "x86_64") => "macos-x86_64",
("macos", "aarch64") => "macos-arm64",
("windows", "x86_64") => "windows-x86_64",
("windows", "aarch64") => "windows-arm64",
("freebsd", "x86_64") => "freebsd-x86_64",
_ => "unsupported",
}
}
pub(super) fn terracotta_binary_name() -> &'static str {
if cfg!(target_os = "windows") {
"terracotta.exe"
} else {
"terracotta"
}
}
pub(super) fn versioned_terracotta_binary_name(
version: &str,
platform: &str,
) -> String {
let extension = if cfg!(target_os = "windows") {
".exe"
} else {
""
};
format!("terracotta-{version}-{platform}{extension}")
}
pub(super) fn validate_terracotta_version(version: &str) -> eyre::Result<()> {
if version.is_empty()
|| version.len() > 64
|| version.contains("..")
|| !version.chars().all(|character| {
character.is_ascii_alphanumeric()
|| matches!(character, '.' | '-' | '_')
})
{
bail!("invalid terracotta version: {version}");
}
Ok(())
}
pub(super) fn is_terracotta_executable(path: &Path) -> bool {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
if !metadata.file_type().is_file() {
return false;
}
let mut magic = [0_u8; 4];
if std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut magic))
.is_err()
{
return false;
}
#[cfg(target_os = "macos")]
{
matches!(
magic,
[0xce, 0xfa, 0xed, 0xfe]
| [0xcf, 0xfa, 0xed, 0xfe]
| [0xfe, 0xed, 0xfa, 0xce]
| [0xfe, 0xed, 0xfa, 0xcf]
| [0xca, 0xfe, 0xba, 0xbe]
| [0xbe, 0xba, 0xfe, 0xca]
| [0xca, 0xfe, 0xba, 0xbf]
| [0xbf, 0xba, 0xfe, 0xca]
)
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
magic == [0x7f, b'E', b'L', b'F']
}
#[cfg(target_os = "windows")]
{
magic[0..2] == [b'M', b'Z']
}
#[cfg(not(any(
target_os = "macos",
target_os = "linux",
target_os = "freebsd",
target_os = "windows"
)))]
{
false
}
}
pub(super) fn find_terracotta_executable(
dir: &Path,
preferred_name: Option<&str>,
) -> Option<PathBuf> {
let mut pending = vec![dir.to_path_buf()];
let mut candidates = Vec::new();
while let Some(current) = pending.pop() {
let entries = match std::fs::read_dir(current) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_dir() {
pending.push(path);
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str())
else {
continue;
};
if (name == terracotta_binary_name()
|| name.starts_with("terracotta-"))
&& is_terracotta_executable(&path)
{
candidates.push(path);
}
}
}
candidates.sort();
preferred_name
.and_then(|preferred| {
candidates
.iter()
.find(|path| {
path.file_name().and_then(|name| name.to_str())
== Some(preferred)
})
.cloned()
})
.or_else(|| candidates.into_iter().next())
}
pub(super) fn terracotta_binary_path() -> PathBuf {
let base_dir = crate::state::DirectoryInfo::global_handle_if_ready()
.map(|directories| directories.config_dir.clone())
.or_else(|| {
crate::state::DirectoryInfo::initial_settings_dir_path(
crate::brand::BUNDLE_IDENTIFIER,
)
})
.unwrap_or_else(|| PathBuf::from("."));
terracotta_binary_path_in(&base_dir)
}
pub(super) fn terracotta_binary_path_in(base_dir: &Path) -> PathBuf {
base_dir.join("terracotta").join(terracotta_binary_name())
}
fn legacy_terracotta_binary_path() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
.join("terracotta")
.join(terracotta_binary_name())
}
pub fn terracotta_download_urls(version: &str, platform: &str) -> Vec<String> {
let artifact = format!("terracotta-{version}-{platform}-pkg.tar.gz");
vec![
format!(
"https://gitee.com/burningtnt/Terracotta/releases/download/v{version}/{artifact}"
),
format!(
"https://github.com/burningtnt/Terracotta/releases/download/v{version}/{artifact}"
),
]
}
pub(super) fn resolve_terracotta_binary_path(bin_path: &Path) -> PathBuf {
let resolved_path = if bin_path.is_absolute() {
bin_path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(bin_path)
};
let preferred_path = if resolved_path.is_dir() {
resolved_path.join(terracotta_binary_name())
} else if resolved_path.is_file() {
resolved_path
} else {
resolved_path.with_file_name(terracotta_binary_name())
};
if is_terracotta_executable(&preferred_path) {
return preferred_path;
}
preferred_path
.parent()
.and_then(|parent| find_terracotta_executable(parent, None))
.unwrap_or(preferred_path)
}
pub(super) fn resolve_installed_terracotta_binary_path() -> PathBuf {
resolve_installed_terracotta_binary_path_from(
&terracotta_binary_path(),
&legacy_terracotta_binary_path(),
)
}
pub(super) fn resolve_installed_terracotta_binary_path_from(
installed_path: &Path,
legacy_path: &Path,
) -> PathBuf {
let installed_path = resolve_terracotta_binary_path(installed_path);
if is_terracotta_executable(&installed_path) {
return installed_path;
}
let legacy_path = resolve_terracotta_binary_path(legacy_path);
if is_terracotta_executable(&legacy_path) {
legacy_path
} else {
installed_path
}
}
pub(super) async fn install_terracotta_binary(
candidate: &Path,
destination: &Path,
) -> eyre::Result<()> {
let file_name = destination
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| eyre::eyre!("invalid terracotta destination path"))?;
let backup = destination.with_file_name(format!("{file_name}.old"));
if backup.exists() {
tokio::fs::remove_file(&backup)
.await
.wrap_err("failed to remove stale terracotta backup")?;
}
if destination.exists() {
tokio::fs::rename(destination, &backup)
.await
.wrap_err("failed to back up the existing terracotta binary")?;
}
if let Err(error) = tokio::fs::rename(candidate, destination).await {
if backup.exists() {
let _ = tokio::fs::rename(&backup, destination).await;
}
return Err(error).wrap_err("failed to install terracotta binary");
}
if backup.exists() {
tokio::fs::remove_file(&backup).await.wrap_err(
"failed to remove terracotta backup after installation",
)?;
}
Ok(())
}
pub(super) async fn cleanup_legacy_versions(
new_version: &str,
) -> eyre::Result<()> {
let target_dir = terracotta_binary_path()
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("terracotta"));
if !target_dir.exists() {
return Ok(());
}
let mut entries = tokio::fs::read_dir(&target_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let name = entry.file_name();
let name = name.to_string_lossy();
if file_type.is_file()
&& (should_cleanup_terracotta_file(&name)
|| name.ends_with(".tar.gz")
|| name.ends_with(".old"))
{
tokio::fs::remove_file(entry.path()).await?;
info!(
"removed extracted terracotta artifact after installing {new_version}: {name}"
);
}
if file_type.is_dir() && name.starts_with("terracotta-") {
tokio::fs::remove_dir_all(entry.path()).await?;
info!(
"removed extracted terracotta directory after installing {new_version}: {name}"
);
}
}
Ok(())
}
pub(super) fn should_cleanup_terracotta_file(name: &str) -> bool {
name.starts_with("terracotta-")
&& name != "terracotta-version.json"
&& name != "terracotta-version.json.tmp"
}

View File

@ -0,0 +1,124 @@
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddrV4};
use tokio::task::JoinHandle;
use tracing::warn;
#[derive(Default)]
pub(super) struct MinecraftLanAnnouncer {
active: Option<ActiveAnnouncement>,
}
struct ActiveAnnouncement {
port: u16,
task: JoinHandle<()>,
}
impl MinecraftLanAnnouncer {
pub(super) fn sync(&mut self, port: Option<u16>) {
if self.active.as_ref().is_some_and(|active| {
Some(active.port) == port && !active.task.is_finished()
}) {
return;
}
if let Some(active) = self.active.take() {
active.task.abort();
}
if let Some(port) = port {
self.active = Some(ActiveAnnouncement {
port,
task: tokio::spawn(run(port)),
});
}
}
}
impl Drop for MinecraftLanAnnouncer {
fn drop(&mut self) {
if let Some(active) = self.active.take() {
active.task.abort();
}
}
}
fn sockets() -> Vec<(Ipv4Addr, Socket)> {
let mut addresses = local_ip_address::list_afinet_netifas()
.unwrap_or_default()
.into_iter()
.filter_map(|(_, address)| match address {
IpAddr::V4(address) if !address.is_unspecified() => Some(address),
_ => None,
})
.collect::<Vec<_>>();
addresses.sort_unstable();
addresses.dedup();
addresses
.into_iter()
.filter_map(|address| {
let socket = (|| -> std::io::Result<Socket> {
let socket = Socket::new(
Domain::IPV4,
Type::DGRAM,
Some(Protocol::UDP),
)?;
socket.set_multicast_if_v4(&address)?;
socket.set_multicast_loop_v4(true)?;
socket.set_multicast_ttl_v4(4)?;
socket.bind(&SockAddr::from(SocketAddrV4::new(
Ipv4Addr::UNSPECIFIED,
0,
)))?;
Ok(socket)
})();
match socket {
Ok(socket) => Some((address, socket)),
Err(error) => {
warn!(
"failed to create Minecraft LAN announcer for {address}: {error}"
);
None
}
}
})
.collect()
}
async fn run(port: u16) {
let sockets = sockets();
if sockets.is_empty() {
warn!("no IPv4 interfaces available for Minecraft LAN announcements");
return;
}
let target =
SockAddr::from(SocketAddrV4::new(Ipv4Addr::new(224, 0, 2, 60), 4445));
let message =
format!("[MOTD]Terracotta | Axolotl Multiplayer[/MOTD][AD]{port}[/AD]");
let mut interval =
tokio::time::interval(std::time::Duration::from_millis(1500));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
let mut sent = 0;
for (address, socket) in &sockets {
match socket.send_to(message.as_bytes(), &target) {
Ok(_) => sent += 1,
Err(error) => tracing::debug!(
target: "terracotta",
interface = %address,
"failed to send Minecraft LAN announcement: {error}"
),
}
}
if sent == 0 {
warn!(
"failed to send Minecraft LAN announcement on every interface"
);
return;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,566 @@
use base64::Engine;
use chrono::{DateTime, Utc};
use either::Either;
use quartz_nbt::{NbtCompound, NbtList, NbtTag};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::SystemTime;
use tokio::task::JoinSet;
use url::Url;
use crate::instance::get_full_path;
use crate::util::io;
use crate::{ErrorKind, Result};
use super::{
World, WorldDetails, get_singleplayer_worlds_in_instance,
read_world_datapack_state,
};
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DatapackKind {
Folder,
Zip,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WorldDatapack {
pub file_name: String,
pub display_name: String,
pub kind: DatapackKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub pack_format: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supported_formats: Option<Vec<i32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<serde_json::Value>,
#[serde(
skip_serializing_if = "Option::is_none",
with = "either::serde_untagged_optional"
)]
pub icon: Option<Either<PathBuf, Url>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
pub size: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<DateTime<Utc>>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WorldWithDatapacks {
#[serde(flatten)]
pub world: World,
pub datapacks: Vec<WorldDatapack>,
}
struct CachedZipDatapackMeta {
len: u64,
modified: Option<SystemTime>,
pack_format: Option<i32>,
supported_formats: Option<Vec<i32>>,
description: Option<serde_json::Value>,
icon: Option<Vec<u8>>,
}
static ZIP_DATAPACK_META_CACHE: LazyLock<
Mutex<HashMap<PathBuf, CachedZipDatapackMeta>>,
> = LazyLock::new(|| Mutex::new(HashMap::new()));
/// Lists every singleplayer save in the instance together with the datapacks
/// found in each save's `datapacks` folder.
pub async fn list_world_datapacks(
instance_id: &str,
) -> Result<Vec<WorldWithDatapacks>> {
let instance_dir = get_full_path(instance_id).await?;
let mut worlds = Vec::new();
get_singleplayer_worlds_in_instance(&instance_dir, &mut worlds).await?;
let saves_dir = instance_dir.join("saves");
let mut tasks = JoinSet::new();
for world in worlds {
let world_path = match &world.details {
WorldDetails::Singleplayer { path, .. } => saves_dir.join(path),
WorldDetails::Server { .. } => continue,
};
tasks.spawn(read_world_datapacks(world_path, world));
}
let mut result = Vec::new();
while let Some(joined) = tasks.join_next().await {
match joined {
Ok(Ok(item)) => result.push(item),
Ok(Err(error)) => {
tracing::warn!("Skipping unreadable world datapacks: {error}");
}
Err(error) => {
tracing::warn!("World datapack read task panicked: {error}");
}
}
}
Ok(result)
}
/// Deletes a datapack (folder or zip) inside a save's `datapacks` folder.
pub async fn delete_world_datapack(
instance_id: &str,
world_path: &str,
file_name: &str,
) -> Result<()> {
let instance_dir = get_full_path(instance_id).await?;
let world_path = Path::new(world_path);
if world_path.components().count() != 1 {
return Err(
ErrorKind::InputError("Invalid world path".into()).as_error()
);
}
let file_name = Path::new(file_name);
if file_name.components().count() != 1 || file_name.as_os_str().is_empty() {
return Err(ErrorKind::InputError("Invalid datapack file name".into())
.as_error());
}
let datapacks_dir = instance_dir
.join("saves")
.join(world_path)
.join("datapacks");
let target = datapacks_dir.join(file_name);
if target.parent() != Some(datapacks_dir.as_path()) {
return Err(ErrorKind::InputError("Invalid datapack file name".into())
.as_error());
}
let meta = io::metadata(&target).await?;
if meta.is_dir() {
io::remove_dir_all(&target).await?;
} else {
io::remove_file(&target).await?;
}
Ok(())
}
/// Enables or disables a datapack by updating the `DataPacks` tag in the
/// world's `level.dat`.
pub async fn set_world_datapack_enabled(
instance_id: &str,
world_path: &str,
file_name: &str,
enabled: bool,
) -> Result<()> {
let instance_dir = get_full_path(instance_id).await?;
let world_path = Path::new(world_path);
if world_path.components().count() != 1 {
return Err(
ErrorKind::InputError("Invalid world path".into()).as_error()
);
}
let file_name = Path::new(file_name);
if file_name.components().count() != 1 || file_name.as_os_str().is_empty() {
return Err(ErrorKind::InputError("Invalid datapack file name".into())
.as_error());
}
let world_dir = instance_dir.join("saves").join(world_path);
let datapacks_dir = world_dir.join("datapacks");
let target = datapacks_dir.join(file_name);
if target.parent() != Some(datapacks_dir.as_path()) {
return Err(ErrorKind::InputError("Invalid datapack file name".into())
.as_error());
}
if !target.exists() {
return Err(
ErrorKind::InputError("Datapack does not exist".into()).as_error()
);
}
let file_id = if target.is_dir() {
format!("file/{}", file_name.to_string_lossy())
} else {
let stem = file_name.file_stem().unwrap_or_default().to_string_lossy();
format!("file/{stem}")
};
let level_dat_path = world_dir.join("level.dat");
let raw = io::read(&level_dat_path).await?;
let updated = tokio::task::spawn_blocking(move || {
let (mut root, _) = quartz_nbt::io::read_nbt(
&mut Cursor::new(raw),
quartz_nbt::io::Flavor::GzCompressed,
)?;
let data = root.get_mut::<_, &mut NbtCompound>("Data")?;
if data.get::<_, &NbtCompound>("DataPacks").is_err() {
data.insert("DataPacks", NbtTag::Compound(NbtCompound::new()));
}
let data_packs = data.get_mut::<_, &mut NbtCompound>("DataPacks")?;
for key in ["Enabled", "Disabled"] {
if let Ok(list) = data_packs.get_mut::<_, &mut NbtList>(key) {
list.inner_mut().retain(|tag| {
!matches!(tag, NbtTag::String(value) if value == &file_id)
});
}
}
let key = if enabled { "Enabled" } else { "Disabled" };
if let Ok(list) = data_packs.get_mut::<_, &mut NbtList>(key) {
list.push(NbtTag::String(file_id.clone()));
} else {
let mut list = NbtList::new();
list.push(NbtTag::String(file_id));
data_packs.insert(key, list);
}
let mut level_data = vec![];
quartz_nbt::io::write_nbt(
&mut level_data,
None,
&root,
quartz_nbt::io::Flavor::GzCompressed,
)?;
Ok::<_, crate::Error>(level_data)
})
.await
.map_err(|error| {
ErrorKind::InputError(format!(
"Datapack state write task failed: {error}"
))
.as_error()
})??;
io::write(level_dat_path, updated).await?;
Ok(())
}
async fn read_world_datapacks(
world_path: PathBuf,
world: World,
) -> Result<WorldWithDatapacks> {
let datapacks_dir = world_path.join("datapacks");
let mut datapacks = Vec::new();
if datapacks_dir.exists() {
// Only read the world's level.dat for the enabled/disabled state when it
// actually has a datapacks folder, and tolerate a missing/corrupt file.
let (enabled, disabled) =
match read_world_datapack_state(&world_path).await {
Ok(state) => state,
Err(error) => {
tracing::debug!(
"Could not read datapack state for world {}: {error}",
world.name
);
(Vec::new(), Vec::new())
}
};
let mut entries = io::read_dir(&datapacks_dir).await?;
let mut tasks = JoinSet::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if entry.file_type().await?.is_dir() {
tasks.spawn(read_folder_datapack(
path,
enabled.clone(),
disabled.clone(),
));
} else if is_zip_path(&path) {
tasks.spawn(read_zip_datapack(
path,
enabled.clone(),
disabled.clone(),
));
}
}
while let Some(joined) = tasks.join_next().await {
match joined {
Ok(Ok(datapack)) => datapacks.push(datapack),
Ok(Err(error)) => {
tracing::warn!("Skipping unreadable datapack: {error}");
}
Err(error) => {
tracing::warn!("Datapack read task panicked: {error}");
}
}
}
}
Ok(WorldWithDatapacks { world, datapacks })
}
async fn read_folder_datapack(
dir: PathBuf,
enabled: Vec<String>,
disabled: Vec<String>,
) -> Result<WorldDatapack> {
let name = dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (pack_format, supported_formats, description) =
read_folder_pack_meta(&dir).await;
let icon = if dir.join("pack.png").exists() {
Some(Either::Left(dir.join("pack.png")))
} else {
None
};
let size = folder_size(&dir).await.unwrap_or(0);
let modified = io::metadata(&dir)
.await
.ok()
.and_then(|meta| meta.modified().ok().map(DateTime::<Utc>::from));
Ok(WorldDatapack {
file_name: name.clone(),
display_name: name.clone(),
kind: DatapackKind::Folder,
pack_format,
supported_formats,
description,
icon,
enabled: match_datapack_state(&name, &enabled, &disabled),
size,
modified,
})
}
async fn read_zip_datapack(
path: PathBuf,
enabled: Vec<String>,
disabled: Vec<String>,
) -> Result<WorldDatapack> {
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let stem = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let zip_path = path.clone();
let (pack_format, supported_formats, description, icon_bytes) =
tokio::task::spawn_blocking(move || read_zip_pack_meta(&zip_path))
.await
.map_err(|error| {
ErrorKind::InputError(format!(
"Datapack zip read task failed: {error}"
))
.as_error()
})??;
let icon = icon_bytes
.map(|bytes| {
Url::parse(&format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
})
.transpose()
.ok()
.flatten();
let size = io::metadata(&path)
.await
.ok()
.map(|meta| meta.len())
.unwrap_or(0);
let modified = io::metadata(&path)
.await
.ok()
.and_then(|meta| meta.modified().ok().map(DateTime::<Utc>::from));
Ok(WorldDatapack {
file_name,
display_name: stem.clone(),
kind: DatapackKind::Zip,
pack_format,
supported_formats,
description,
icon: icon.map(Either::Right),
enabled: match_datapack_state(&stem, &enabled, &disabled),
size,
modified,
})
}
async fn read_folder_pack_meta(
dir: &Path,
) -> (Option<i32>, Option<Vec<i32>>, Option<serde_json::Value>) {
let Ok(bytes) = io::read(dir.join("pack.mcmeta")).await else {
return (None, None, None);
};
parse_pack_mcmeta(&bytes)
}
fn parse_pack_mcmeta(
bytes: &[u8],
) -> (Option<i32>, Option<Vec<i32>>, Option<serde_json::Value>) {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return (None, None, None);
};
let Some(pack) = value.get("pack") else {
return (None, None, None);
};
let pack_format = pack
.get("pack_format")
.and_then(serde_json::Value::as_i64)
.map(|format| format as i32);
let supported_formats = pack
.get("supported_formats")
.and_then(parse_supported_formats);
let description = pack.get("description").cloned();
(pack_format, supported_formats, description)
}
fn parse_supported_formats(value: &serde_json::Value) -> Option<Vec<i32>> {
value.as_array().map(|formats| {
formats
.iter()
.filter_map(serde_json::Value::as_i64)
.map(|format| format as i32)
.collect()
})
}
fn read_zip_pack_meta(
path: &Path,
) -> std::io::Result<(
Option<i32>,
Option<Vec<i32>>,
Option<serde_json::Value>,
Option<Vec<u8>>,
)> {
let metadata = std::fs::metadata(path)?;
let signature = (metadata.len(), metadata.modified().ok());
let cache_key = path.to_path_buf();
{
let cache = ZIP_DATAPACK_META_CACHE.lock().unwrap();
if let Some(cached) = cache.get(&cache_key) {
if cached.len == signature.0 && cached.modified == signature.1 {
return Ok((
cached.pack_format.clone(),
cached.supported_formats.clone(),
cached.description.clone(),
cached.icon.clone(),
));
}
}
}
let parsed = read_zip_pack_meta_uncached(path)?;
let mut cache = ZIP_DATAPACK_META_CACHE.lock().unwrap();
if cache.len() >= 1024 {
cache.clear();
}
cache.insert(
cache_key,
CachedZipDatapackMeta {
len: signature.0,
modified: signature.1,
pack_format: parsed.0.clone(),
supported_formats: parsed.1.clone(),
description: parsed.2.clone(),
icon: parsed.3.clone(),
},
);
Ok(parsed)
}
fn read_zip_pack_meta_uncached(
path: &Path,
) -> std::io::Result<(
Option<i32>,
Option<Vec<i32>>,
Option<serde_json::Value>,
Option<Vec<u8>>,
)> {
let file = std::fs::File::open(path)?;
let mut archive = zip::ZipArchive::new(file)?;
let (pack_format, supported_formats, description) =
read_zip_entry(&mut archive, "pack.mcmeta")?
.as_deref()
.map(parse_pack_mcmeta)
.unwrap_or((None, None, None));
let icon = read_zip_entry(&mut archive, "pack.png")?;
Ok((pack_format, supported_formats, description, icon))
}
fn read_zip_entry(
archive: &mut zip::ZipArchive<std::fs::File>,
name: &str,
) -> std::io::Result<Option<Vec<u8>>> {
if let Ok(mut entry) = archive.by_name(name) {
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
return Ok(Some(bytes));
}
let matching = archive
.file_names()
.map(str::to_string)
.find(|file_name| file_name.ends_with(&format!("/{name}")));
if let Some(matching) = matching {
let mut entry = archive.by_name(&matching)?;
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
return Ok(Some(bytes));
}
Ok(None)
}
fn match_datapack_state(
name: &str,
enabled: &[String],
disabled: &[String],
) -> Option<bool> {
let file_id = format!("file/{name}");
if enabled.iter().any(|value| value == &file_id) {
return Some(true);
}
if disabled.iter().any(|value| value == &file_id) {
return Some(false);
}
None
}
const MAX_SIZE_SCAN_ENTRIES: usize = 50_000;
async fn folder_size(dir: &Path) -> std::io::Result<u64> {
let mut total = 0u64;
let mut stack = vec![dir.to_path_buf()];
let mut scanned = 0usize;
while let Some(current) = stack.pop() {
let mut entries = tokio::fs::read_dir(&current).await?;
while let Some(entry) = entries.next_entry().await? {
scanned += 1;
if scanned > MAX_SIZE_SCAN_ENTRIES {
return Ok(total);
}
if entry.file_type().await?.is_dir() {
stack.push(entry.path());
} else {
total +=
entry.metadata().await.map(|meta| meta.len()).unwrap_or(0);
}
}
}
Ok(total)
}
fn is_zip_path(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
}

View File

@ -0,0 +1,751 @@
use std::io::Cursor;
use std::path::{Path, PathBuf};
use chrono::{DateTime, TimeZone, Utc};
use either::Either;
use quartz_nbt::{NbtCompound, NbtTag};
use serde::{Deserialize, Serialize};
use url::Url;
use super::{
SingleplayerGameMode, get_world_dir, get_world_session_lock,
resolve_instance_data_dir, resolve_instance_identity,
try_get_world_session_lock,
};
use crate::util::io;
use crate::{Error, ErrorKind, Result, State};
#[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorldDifficulty {
Peaceful,
Easy,
Normal,
Hard,
}
impl WorldDifficulty {
fn from_byte(value: i8) -> Self {
match value {
0 => Self::Peaceful,
1 => Self::Easy,
3 => Self::Hard,
_ => Self::Normal,
}
}
fn as_byte(self) -> i8 {
match self {
Self::Peaceful => 0,
Self::Easy => 1,
Self::Normal => 2,
Self::Hard => 3,
}
}
fn from_name(name: &str) -> Option<Self> {
match name {
"peaceful" => Some(Self::Peaceful),
"easy" => Some(Self::Easy),
"normal" => Some(Self::Normal),
"hard" => Some(Self::Hard),
_ => None,
}
}
fn as_name(self) -> &'static str {
match self {
Self::Peaceful => "peaceful",
Self::Easy => "easy",
Self::Normal => "normal",
Self::Hard => "hard",
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct GameRuleEntry {
pub key: String,
pub value: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct WorldLevelData {
pub name: String,
#[serde(
skip_serializing_if = "Option::is_none",
with = "either::serde_untagged_optional"
)]
pub icon: Option<Either<PathBuf, Url>>,
pub game_mode: SingleplayerGameMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub difficulty: Option<WorldDifficulty>,
pub difficulty_locked: bool,
pub hardcore: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_commands: Option<bool>,
/// The world seed as a string, to avoid precision loss for the full
/// i64 range when crossing the IPC boundary into JavaScript.
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<String>,
pub game_rules: Vec<GameRuleEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_played: Option<DateTime<Utc>>,
pub modded: bool,
pub locked: bool,
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
#[serde(default)]
pub struct WorldSettingsPatch {
pub name: Option<String>,
pub game_mode: Option<SingleplayerGameMode>,
pub difficulty: Option<WorldDifficulty>,
pub allow_commands: Option<bool>,
/// The new world seed, as a base-10 string within the i64 range.
pub seed: Option<String>,
/// Values for existing game rules. Keys not present in the world's
/// game rule storage are ignored rather than added.
pub game_rules: Option<Vec<GameRuleEntry>>,
}
/// Where each editable aspect of a world is stored. Modern worlds
/// (roughly 26.x and later) split game rules, world generation settings
/// and player data out of level.dat into sibling files, while older
/// worlds keep everything inline. Detection is based purely on what is
/// present in the world's files, never on version numbers, so reads and
/// writes always target the location the data actually came from.
struct WorldDataSources {
game_rules: Option<GameRulesSource>,
seed: Option<SeedSource>,
difficulty: Option<DifficultyFormat>,
player_game_type: Option<PlayerGameTypeTarget>,
}
enum GameRulesSource {
LevelDat,
Savedata(PathBuf),
}
enum SeedSource {
LevelDatWorldGenSettings,
LevelDatRandomSeed,
Savedata(PathBuf),
}
enum DifficultyFormat {
LegacyByte,
SettingsCompound,
}
enum PlayerGameTypeTarget {
LevelDatPlayer,
PlayerFile(PathBuf),
}
fn savedata_path(world_dir: &Path, file_name: &str) -> PathBuf {
world_dir.join("data").join("minecraft").join(file_name)
}
async fn existing_savedata_path(
world_dir: &Path,
file_name: &str,
) -> Option<PathBuf> {
let path = savedata_path(world_dir, file_name);
tokio::fs::try_exists(&path)
.await
.unwrap_or(false)
.then_some(path)
}
async fn probe_world_data_sources(
world_dir: &Path,
data: &NbtCompound,
) -> WorldDataSources {
let game_rules = if data.get::<_, &NbtCompound>("GameRules").is_ok() {
Some(GameRulesSource::LevelDat)
} else {
existing_savedata_path(world_dir, "game_rules.dat")
.await
.map(GameRulesSource::Savedata)
};
let has_world_gen_settings_seed = data
.get::<_, &NbtCompound>("WorldGenSettings")
.ok()
.and_then(|settings| settings.get::<_, &NbtTag>("seed").ok())
.and_then(numeric_nbt_value)
.is_some();
let seed = if has_world_gen_settings_seed {
Some(SeedSource::LevelDatWorldGenSettings)
} else if data
.get::<_, &NbtTag>("RandomSeed")
.ok()
.and_then(numeric_nbt_value)
.is_some()
{
Some(SeedSource::LevelDatRandomSeed)
} else {
existing_savedata_path(world_dir, "world_gen_settings.dat")
.await
.map(SeedSource::Savedata)
};
let difficulty = if data.get::<_, i8>("Difficulty").is_ok() {
Some(DifficultyFormat::LegacyByte)
} else if data.get::<_, &NbtCompound>("difficulty_settings").is_ok() {
Some(DifficultyFormat::SettingsCompound)
} else {
None
};
let player_game_type = if data.get::<_, &NbtCompound>("Player").is_ok() {
Some(PlayerGameTypeTarget::LevelDatPlayer)
} else {
match data.get::<_, &NbtTag>("singleplayer_uuid") {
Ok(NbtTag::IntArray(parts)) => {
uuid_string_from_parts(parts).map(|uuid| {
PlayerGameTypeTarget::PlayerFile(
world_dir
.join("players")
.join("data")
.join(format!("{uuid}.dat")),
)
})
}
_ => None,
}
};
WorldDataSources {
game_rules,
seed,
difficulty,
player_game_type,
}
}
fn uuid_string_from_parts(parts: &[i32]) -> Option<String> {
if parts.len() != 4 {
return None;
}
let hex: String = parts
.iter()
.map(|part| format!("{:08x}", *part as u32))
.collect();
Some(format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
))
}
fn game_type_of(mode: SingleplayerGameMode) -> i32 {
match mode {
SingleplayerGameMode::Survival => 0,
SingleplayerGameMode::Creative => 1,
SingleplayerGameMode::Adventure => 2,
SingleplayerGameMode::Spectator => 3,
}
}
fn numeric_nbt_value(tag: &NbtTag) -> Option<i64> {
match tag {
NbtTag::Byte(value) => Some(i64::from(*value)),
NbtTag::Short(value) => Some(i64::from(*value)),
NbtTag::Int(value) => Some(i64::from(*value)),
NbtTag::Long(value) => Some(*value),
_ => None,
}
}
fn extract_level_seed(data: &NbtCompound) -> Option<i64> {
if let Ok(settings) = data.get::<_, &NbtCompound>("WorldGenSettings")
&& let Ok(tag) = settings.get::<_, &NbtTag>("seed")
&& let Some(seed) = numeric_nbt_value(tag)
{
return Some(seed);
}
data.get::<_, &NbtTag>("RandomSeed")
.ok()
.and_then(numeric_nbt_value)
}
/// Rewrites every numeric tag named `seed` in the compound, covering the
/// modern `WorldGenSettings.seed` field, the per-dimension generator and
/// biome source seeds used by the 1.16 and 1.17 world formats, and the
/// `data.seed` field of split world_gen_settings.dat files.
fn set_seed_tags_recursively(compound: &mut NbtCompound, seed: i64) {
for (name, tag) in compound.inner_mut() {
if name.eq_ignore_ascii_case("seed") && numeric_nbt_value(tag).is_some()
{
*tag = NbtTag::Long(seed);
continue;
}
set_seed_in_tag(tag, seed);
}
}
fn set_seed_in_tag(tag: &mut NbtTag, seed: i64) {
match tag {
NbtTag::Compound(child) => set_seed_tags_recursively(child, seed),
NbtTag::List(list) => {
for item in list.iter_mut() {
set_seed_in_tag(item, seed);
}
}
_ => {}
}
}
/// Renders a stored game rule value as the string form used across the
/// IPC boundary. Legacy worlds store every rule as a string; split
/// game_rules.dat files store native types, where a Byte of 0 or 1 is a
/// boolean and other numeric tags are plain numbers.
fn rule_tag_to_value(tag: &NbtTag) -> Option<String> {
match tag {
NbtTag::String(value) => Some(value.clone()),
NbtTag::Byte(0) => Some("false".to_string()),
NbtTag::Byte(1) => Some("true".to_string()),
NbtTag::Byte(value) => Some(value.to_string()),
NbtTag::Short(value) => Some(value.to_string()),
NbtTag::Int(value) => Some(value.to_string()),
NbtTag::Long(value) => Some(value.to_string()),
_ => None,
}
}
/// Converts a string value back into the tag type the rule already has
/// in the file, so the file's own schema decides the written type and a
/// value that cannot be represented is rejected instead of guessed at.
fn rule_value_to_tag(existing: &NbtTag, value: &str) -> Result<NbtTag> {
let invalid = || {
Error::from(ErrorKind::InputError(format!(
"Invalid game rule value: {value}"
)))
};
Ok(match existing {
NbtTag::String(_) => NbtTag::String(value.to_string()),
NbtTag::Byte(_) => match value {
"true" => NbtTag::Byte(1),
"false" => NbtTag::Byte(0),
other => NbtTag::Byte(other.trim().parse().map_err(|_| invalid())?),
},
NbtTag::Short(_) => {
NbtTag::Short(value.trim().parse().map_err(|_| invalid())?)
}
NbtTag::Int(_) => {
NbtTag::Int(value.trim().parse().map_err(|_| invalid())?)
}
NbtTag::Long(_) => {
NbtTag::Long(value.trim().parse().map_err(|_| invalid())?)
}
_ => return Err(invalid()),
})
}
fn collect_game_rules(rules: &NbtCompound) -> Vec<GameRuleEntry> {
let mut entries: Vec<GameRuleEntry> = rules
.inner()
.iter()
.filter_map(|(key, tag)| {
rule_tag_to_value(tag).map(|value| GameRuleEntry {
key: key.clone(),
value,
})
})
.collect();
entries.sort_by(|a, b| {
a.key.to_ascii_lowercase().cmp(&b.key.to_ascii_lowercase())
});
entries
}
fn apply_game_rule_patch(
target: &mut NbtCompound,
rules: &[GameRuleEntry],
) -> Result<()> {
for rule in rules {
let Ok(existing) = target.get::<_, &NbtTag>(&*rule.key) else {
continue;
};
let new_tag = rule_value_to_tag(existing, &rule.value)?;
target.insert(rule.key.clone(), new_tag);
}
Ok(())
}
fn read_nbt_root(raw: &[u8]) -> Result<(NbtCompound, String)> {
Ok(quartz_nbt::io::read_nbt(
&mut Cursor::new(raw),
quartz_nbt::io::Flavor::GzCompressed,
)?)
}
fn write_nbt_root(root: &NbtCompound, root_name: &str) -> Result<Vec<u8>> {
let mut out = vec![];
quartz_nbt::io::write_nbt(
&mut out,
Some(root_name),
root,
quartz_nbt::io::Flavor::GzCompressed,
)?;
Ok(out)
}
/// Replaces a world file safely: the previous bytes are kept as a
/// sibling `<name>_old` backup (matching the vanilla level.dat_old
/// convention) and the new bytes land via a temp file and atomic rename.
async fn backup_and_replace(
path: &Path,
original: &[u8],
updated: &[u8],
) -> Result<()> {
let mut backup = path.as_os_str().to_owned();
backup.push("_old");
io::write(PathBuf::from(backup), original).await?;
let mut temp = path.as_os_str().to_owned();
temp.push(".axolotl-tmp");
let temp = PathBuf::from(temp);
io::write(&temp, updated).await?;
io::rename_or_move(&temp, path).await?;
Ok(())
}
async fn read_savedata_data_compound(path: &Path) -> Result<NbtCompound> {
let raw = io::read(path).await?;
let (mut root, _) = read_nbt_root(&raw)?;
match root.inner_mut().remove("data") {
Some(NbtTag::Compound(data)) => Ok(data),
_ => Err(ErrorKind::InputError(format!(
"Missing data tag in {}",
path.display()
))
.into()),
}
}
/// Applies an in-place mutation to the `data` compound of a savedata
/// file, leaving every other tag (including DataVersion) untouched.
async fn modify_savedata_file<F>(path: &Path, mutate: F) -> Result<()>
where
F: FnOnce(&mut NbtCompound) -> Result<()>,
{
let original = io::read(path).await?;
let (mut root, root_name) = read_nbt_root(&original)?;
let data = root.get_mut::<_, &mut NbtCompound>("data").map_err(|_| {
Error::from(ErrorKind::InputError(format!(
"Missing data tag in {}",
path.display()
)))
})?;
mutate(data)?;
let updated = write_nbt_root(&root, &root_name)?;
backup_and_replace(path, &original, &updated).await
}
async fn update_player_game_type(path: &Path, game_type: i32) -> Result<()> {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
return Ok(());
}
let original = io::read(path).await?;
let (mut root, root_name) = read_nbt_root(&original)?;
root.insert("playerGameType", NbtTag::Int(game_type));
let updated = write_nbt_root(&root, &root_name)?;
backup_and_replace(path, &original, &updated).await
}
pub async fn get_world_level_data(
instance: &str,
world: &str,
) -> Result<WorldLevelData> {
let state = State::get().await?;
let (instance_id, instance_path, game_dir_override) =
resolve_instance_identity(instance, &state).await?;
let instance_dir = resolve_instance_data_dir(
&instance_id,
&instance_path,
game_dir_override.as_deref(),
&state,
)
.await?;
let world_dir = get_world_dir(&instance_dir, world);
let locked = try_get_world_session_lock(&world_dir).await?.is_none();
let raw = io::read(world_dir.join("level.dat")).await?;
let (root, _) = read_nbt_root(&raw)?;
let data = root.get::<_, &NbtCompound>("Data").map_err(|_| {
Error::from(ErrorKind::InputError(
"Missing Data tag in level.dat".into(),
))
})?;
let sources = probe_world_data_sources(&world_dir, data).await;
let game_rules = match &sources.game_rules {
Some(GameRulesSource::LevelDat) => data
.get::<_, &NbtCompound>("GameRules")
.map(collect_game_rules)
.unwrap_or_default(),
Some(GameRulesSource::Savedata(path)) => {
match read_savedata_data_compound(path).await {
Ok(rules) => collect_game_rules(&rules),
Err(e) => {
tracing::warn!(
"Failed to read game rules from {}: {e}",
path.display()
);
vec![]
}
}
}
None => vec![],
};
let seed = match &sources.seed {
Some(
SeedSource::LevelDatWorldGenSettings
| SeedSource::LevelDatRandomSeed,
) => extract_level_seed(data),
Some(SeedSource::Savedata(path)) => {
match read_savedata_data_compound(path).await {
Ok(settings) => settings
.get::<_, &NbtTag>("seed")
.ok()
.and_then(numeric_nbt_value),
Err(e) => {
tracing::warn!(
"Failed to read world gen settings from {}: {e}",
path.display()
);
None
}
}
}
None => None,
};
let (difficulty, difficulty_locked, hardcore) = match &sources.difficulty {
Some(DifficultyFormat::LegacyByte) | None => (
data.get::<_, i8>("Difficulty")
.ok()
.map(WorldDifficulty::from_byte),
data.get::<_, i8>("DifficultyLocked").unwrap_or(0) != 0,
data.get::<_, i8>("hardcore").unwrap_or(0) != 0,
),
Some(DifficultyFormat::SettingsCompound) => {
let settings =
data.get::<_, &NbtCompound>("difficulty_settings").ok();
(
settings
.and_then(|s| s.get::<_, &str>("difficulty").ok())
.and_then(WorldDifficulty::from_name),
settings
.map(|s| s.get::<_, i8>("locked").unwrap_or(0) != 0)
.unwrap_or(false),
settings
.map(|s| s.get::<_, i8>("hardcore").unwrap_or(0) != 0)
.unwrap_or(false),
)
}
};
let game_mode = match data.get::<_, i32>("GameType").unwrap_or(0) {
1 => SingleplayerGameMode::Creative,
2 => SingleplayerGameMode::Adventure,
3 => SingleplayerGameMode::Spectator,
_ => SingleplayerGameMode::Survival,
};
let icon_path = world_dir.join("icon.png");
let icon = tokio::fs::try_exists(&icon_path)
.await
.unwrap_or(false)
.then_some(Either::Left(icon_path));
Ok(WorldLevelData {
name: data
.get::<_, &str>("LevelName")
.unwrap_or_default()
.to_string(),
icon,
game_mode,
difficulty,
difficulty_locked,
hardcore,
allow_commands: data
.get::<_, i8>("allowCommands")
.ok()
.map(|allow| allow != 0),
seed: seed.map(|seed| seed.to_string()),
game_rules,
version_name: data
.get::<_, &NbtCompound>("Version")
.ok()
.and_then(|version| version.get::<_, &str>("Name").ok())
.map(str::to_string),
last_played: data
.get::<_, i64>("LastPlayed")
.ok()
.and_then(|millis| Utc.timestamp_millis_opt(millis).single()),
modded: data.get::<_, i8>("WasModded").unwrap_or(0) != 0,
locked,
})
}
pub async fn update_world_settings(
instance: &Path,
world: &str,
patch: WorldSettingsPatch,
) -> Result<()> {
let world_dir = get_world_dir(instance, world);
let level_dat_path = world_dir.join("level.dat");
if !level_dat_path.exists() {
return Err(ErrorKind::InputError(
"The world does not contain a level.dat file".into(),
)
.into());
}
let _lock = get_world_session_lock(&world_dir).await?;
let original = io::read(&level_dat_path).await?;
let (mut root, root_name) = read_nbt_root(&original)?;
let sources = {
let data = root.get::<_, &NbtCompound>("Data").map_err(|_| {
Error::from(ErrorKind::InputError(
"Missing Data tag in level.dat".into(),
))
})?;
probe_world_data_sources(&world_dir, data).await
};
let data = root.get_mut::<_, &mut NbtCompound>("Data").map_err(|_| {
Error::from(ErrorKind::InputError(
"Missing Data tag in level.dat".into(),
))
})?;
let mut level_dirty = false;
if let Some(name) = &patch.name {
data.insert("LevelName", NbtTag::String(name.trim_ascii().to_string()));
level_dirty = true;
}
if let Some(mode) = patch.game_mode {
let game_type = game_type_of(mode);
data.insert("GameType", NbtTag::Int(game_type));
level_dirty = true;
match &sources.player_game_type {
Some(PlayerGameTypeTarget::LevelDatPlayer) => {
if let Ok(player) =
data.get_mut::<_, &mut NbtCompound>("Player")
{
player.insert("playerGameType", NbtTag::Int(game_type));
}
}
Some(PlayerGameTypeTarget::PlayerFile(path)) => {
update_player_game_type(path, game_type).await?;
}
None => {}
}
}
if let Some(difficulty) = patch.difficulty {
match &sources.difficulty {
Some(DifficultyFormat::SettingsCompound) => {
if let Ok(settings) =
data.get_mut::<_, &mut NbtCompound>("difficulty_settings")
{
settings.insert(
"difficulty",
NbtTag::String(difficulty.as_name().to_string()),
);
}
}
Some(DifficultyFormat::LegacyByte) | None => {
data.insert("Difficulty", NbtTag::Byte(difficulty.as_byte()));
}
}
level_dirty = true;
}
if let Some(allow_commands) = patch.allow_commands {
data.insert("allowCommands", NbtTag::Byte(allow_commands as i8));
level_dirty = true;
}
if let Some(seed) = &patch.seed {
let seed = seed.trim().parse::<i64>().map_err(|_| {
Error::from(ErrorKind::InputError(
"The world seed must be a whole number".into(),
))
})?;
match &sources.seed {
Some(SeedSource::LevelDatWorldGenSettings) => {
if let Ok(settings) =
data.get_mut::<_, &mut NbtCompound>("WorldGenSettings")
{
set_seed_tags_recursively(settings, seed);
}
level_dirty = true;
}
Some(SeedSource::LevelDatRandomSeed) => {
data.insert("RandomSeed", NbtTag::Long(seed));
level_dirty = true;
}
Some(SeedSource::Savedata(path)) => {
modify_savedata_file(path, |settings| {
set_seed_tags_recursively(settings, seed);
Ok(())
})
.await?;
}
None => {
return Err(ErrorKind::InputError(
"The world does not store an editable seed".into(),
)
.into());
}
}
}
if let Some(rules) = &patch.game_rules
&& !rules.is_empty()
{
match &sources.game_rules {
Some(GameRulesSource::LevelDat) => {
if let Ok(game_rules) =
data.get_mut::<_, &mut NbtCompound>("GameRules")
{
apply_game_rule_patch(game_rules, rules)?;
level_dirty = true;
}
}
Some(GameRulesSource::Savedata(path)) => {
modify_savedata_file(path, |data| {
apply_game_rule_patch(data, rules)
})
.await?;
}
None => {
return Err(ErrorKind::InputError(
"The world does not store editable game rules".into(),
)
.into());
}
}
}
if level_dirty {
let updated = write_nbt_root(&root, &root_name)?;
backup_and_replace(&level_dat_path, &original, &updated).await?;
}
Ok(())
}

File diff suppressed because it is too large Load Diff