feat:移除了弹窗,服务器添加sls
3884
packages/app-lib/src/api/ai.rs
Normal file
177
packages/app-lib/src/api/cache.rs
Normal 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
|
||||
}
|
||||
28
packages/app-lib/src/api/content_favorites.rs
Normal 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
|
||||
}
|
||||
1289
packages/app-lib/src/api/content_search.rs
Normal file
27010
packages/app-lib/src/api/content_search/WikiEntries.txt
Normal file
1120
packages/app-lib/src/api/content_search/searcher_words.txt
Normal file
11126
packages/app-lib/src/api/curseforge.rs
Normal file
3601
packages/app-lib/src/api/drop_classifier.rs
Normal file
36
packages/app-lib/src/api/friends.rs
Normal 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(())
|
||||
}
|
||||
831
packages/app-lib/src/api/google_ip.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
138
packages/app-lib/src/api/handler.rs
Normal 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(())
|
||||
}
|
||||
1397
packages/app-lib/src/api/hongshi.rs
Normal file
81
packages/app-lib/src/api/instance.rs
Normal 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};
|
||||
530
packages/app-lib/src/api/instance/content.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
1128
packages/app-lib/src/api/instance/core_components.rs
Normal file
722
packages/app-lib/src/api/instance/export_mrpack.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
21
packages/app-lib/src/api/instance/get.rs
Normal 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
|
||||
}
|
||||
53
packages/app-lib/src/api/instance/home.rs
Normal 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
|
||||
}
|
||||
48
packages/app-lib/src/api/instance/install.rs
Normal 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
|
||||
}
|
||||
189
packages/app-lib/src/api/instance/lifecycle.rs
Normal 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(())
|
||||
}
|
||||
247
packages/app-lib/src/api/instance/mcarchive.rs
Normal 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
224
packages/app-lib/src/api/instance/paths.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
134
packages/app-lib/src/api/instance/planet_minecraft.rs
Normal 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 })
|
||||
}
|
||||
995
packages/app-lib/src/api/instance/projects.rs
Normal 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()
|
||||
})
|
||||
}
|
||||
516
packages/app-lib/src/api/instance/run.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
966
packages/app-lib/src/api/instance/upgrade.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
2157
packages/app-lib/src/api/jre.rs
Normal file
2822
packages/app-lib/src/api/loader_metadata.rs
Normal file
8448
packages/app-lib/src/api/lobehub_text_models.json
Normal file
1010
packages/app-lib/src/api/logs.rs
Normal file
2049
packages/app-lib/src/api/logs/crash_analysis.rs
Normal file
447
packages/app-lib/src/api/mcarchive.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
320
packages/app-lib/src/api/memory.rs
Normal 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
|
||||
}
|
||||
139
packages/app-lib/src/api/metadata.rs
Normal 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)
|
||||
}
|
||||
274
packages/app-lib/src/api/minecraft_auth.rs
Normal 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)
|
||||
}
|
||||
121
packages/app-lib/src/api/minecraft_news.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1541
packages/app-lib/src/api/minecraft_skins.rs
Normal file
|
After Width: | Height: | Size: 435 B |
BIN
packages/app-lib/src/api/minecraft_skins/assets/test/legacy.png
Normal file
|
After Width: | Height: | Size: 435 B |
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/app-lib/src/api/minecraft_skins/assets/test/notch.png
Normal file
|
After Width: | Height: | Size: 409 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 934 B |
|
After Width: | Height: | Size: 1.7 KiB |
298
packages/app-lib/src/api/minecraft_skins/offline.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
482
packages/app-lib/src/api/minecraft_skins/png_util.rs
Normal 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));
|
||||
}
|
||||
96
packages/app-lib/src/api/mod.rs
Normal 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},
|
||||
},
|
||||
};
|
||||
}
|
||||
45
packages/app-lib/src/api/mr_auth.rs
Normal 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(¤t.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)
|
||||
}
|
||||
292
packages/app-lib/src/api/multiplayer.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
293
packages/app-lib/src/api/pack/archive_util.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
464
packages/app-lib/src/api/pack/detect.rs
Normal 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
|
||||
}
|
||||
277
packages/app-lib/src/api/pack/import/atlauncher.rs
Normal 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(())
|
||||
}
|
||||
178
packages/app-lib/src/api/pack/import/axolotl.rs
Normal 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
|
||||
}
|
||||
275
packages/app-lib/src/api/pack/import/curseforge.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
948
packages/app-lib/src/api/pack/import/direct_link.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
130
packages/app-lib/src/api/pack/import/gdlauncher.rs
Normal 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(())
|
||||
}
|
||||
465
packages/app-lib/src/api/pack/import/generic.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/app-lib/src/api/pack/import/hmcl.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
89
packages/app-lib/src/api/pack/import/hmcl_config.rs
Normal 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 three‑priority strategy:
|
||||
//!
|
||||
//! 1. Portable mode — `{launcher_dir}/.hmcl/config/launcher-settings.json`
|
||||
//! 2. System install — platform‑specific 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 — side‑car `.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 platform‑specific 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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
840
packages/app-lib/src/api/pack/import/instance_json.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
440
packages/app-lib/src/api/pack/import/mmc.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1704
packages/app-lib/src/api/pack/import/mod.rs
Normal file
254
packages/app-lib/src/api/pack/import/modrinth_app.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
109
packages/app-lib/src/api/pack/import/pcl.rs
Normal 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
|
||||
}
|
||||
}
|
||||
148
packages/app-lib/src/api/pack/import/pe_info.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
774
packages/app-lib/src/api/pack/install_from.rs
Normal 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()
|
||||
})
|
||||
}
|
||||
362
packages/app-lib/src/api/pack/install_hmcl.rs
Normal 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(())
|
||||
}
|
||||
615
packages/app-lib/src/api/pack/install_mcbbs.rs
Normal 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(())
|
||||
}
|
||||
63
packages/app-lib/src/api/pack/install_mmc_zip.rs
Normal 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
|
||||
}
|
||||
2227
packages/app-lib/src/api/pack/install_mrpack.rs
Normal file
312
packages/app-lib/src/api/pack/install_plain_archive.rs
Normal 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(())
|
||||
}
|
||||
10
packages/app-lib/src/api/pack/mod.rs
Normal 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;
|
||||
65
packages/app-lib/src/api/pack/parallel_minecraft_install.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
231
packages/app-lib/src/api/planet_minecraft.rs
Normal 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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
82
packages/app-lib/src/api/process.rs
Normal 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(())
|
||||
}
|
||||
195
packages/app-lib/src/api/server_address.rs
Normal 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)),
|
||||
)
|
||||
}
|
||||
23
packages/app-lib/src/api/servers.rs
Normal 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};
|
||||
200
packages/app-lib/src/api/servers/files.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
120
packages/app-lib/src/api/servers/forge.rs
Normal 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
|
||||
}
|
||||
641
packages/app-lib/src/api/servers/lifecycle.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
484
packages/app-lib/src/api/servers/logs.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
161
packages/app-lib/src/api/servers/manage.rs
Normal 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(())
|
||||
}
|
||||
268
packages/app-lib/src/api/servers/manifest.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
1204
packages/app-lib/src/api/servers/modpack.rs
Normal file
170
packages/app-lib/src/api/servers/ports.rs
Normal 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)
|
||||
}
|
||||
138
packages/app-lib/src/api/settings.rs
Normal 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(())
|
||||
}
|
||||
14
packages/app-lib/src/api/symlink.rs
Normal 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)
|
||||
}
|
||||
80
packages/app-lib/src/api/tags.rs
Normal 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)
|
||||
}
|
||||
1715
packages/app-lib/src/api/terracotta.rs
Normal file
327
packages/app-lib/src/api/terracotta/binary.rs
Normal 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"
|
||||
}
|
||||
124
packages/app-lib/src/api/terracotta/lan.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1567
packages/app-lib/src/api/translation.rs
Normal file
566
packages/app-lib/src/api/worlds/datapacks.rs
Normal 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(¤t).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"))
|
||||
}
|
||||
751
packages/app-lib/src/api/worlds/level_data.rs
Normal 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(())
|
||||
}
|
||||
1245
packages/app-lib/src/api/worlds/mod.rs
Normal file
24
packages/app-lib/src/brand.rs
Normal file
@ -0,0 +1,24 @@
|
||||
pub const PRODUCT_NAME: &str = "Axolotl Launcher";
|
||||
pub const SHORT_PRODUCT_NAME: &str = "Axolotl";
|
||||
pub const WEBSITE: &str = "https://www.ghs.red";
|
||||
pub const BUNDLE_IDENTIFIER: &str = "red.ghs.axolotl";
|
||||
pub const DEEP_LINK_SCHEME: &str = "axolotl";
|
||||
|
||||
pub fn user_agent(version: &str, os: &str) -> String {
|
||||
format!("garbage-human-studio/axolotl/{version} ({os})")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_agent_is_unique_and_contains_no_contact_information() {
|
||||
let user_agent = user_agent("1.2.3", "windows");
|
||||
|
||||
assert_eq!(user_agent, "garbage-human-studio/axolotl/1.2.3 (windows)");
|
||||
assert!(!user_agent.contains("ghs.red"));
|
||||
assert!(!user_agent.contains("http"));
|
||||
assert!(!user_agent.contains('@'));
|
||||
}
|
||||
}
|
||||
309
packages/app-lib/src/error.rs
Normal file
@ -0,0 +1,309 @@
|
||||
//! Theseus error type
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::util;
|
||||
use data_url::DataUrlError;
|
||||
use derive_more::Display;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing_error::InstrumentError;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Display)]
|
||||
#[display("{description}")]
|
||||
pub struct LabrinthError {
|
||||
pub error: String,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<u16>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub route: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ErrorKind {
|
||||
#[error("{0:?}")]
|
||||
Any(eyre::Report),
|
||||
|
||||
#[error("Filesystem error: {0}")]
|
||||
FSError(String),
|
||||
|
||||
#[error("Serialization error (INI): {0}")]
|
||||
INIError(#[from] serde_ini::de::Error),
|
||||
|
||||
#[error("Serialization error (JSON): {0}")]
|
||||
JSONError(#[from] serde_json::Error),
|
||||
|
||||
#[error("Serialization error (NBT): {0}")]
|
||||
NBTError(#[from] quartz_nbt::io::NbtIoError),
|
||||
|
||||
#[error("NBT data structure error: {0}")]
|
||||
NBTReprError(#[from] quartz_nbt::NbtReprError),
|
||||
|
||||
#[error("Serialization error (websocket): {0}")]
|
||||
WebsocketSerializationError(
|
||||
#[from] ariadne::networking::serialization::SerializationError,
|
||||
),
|
||||
|
||||
#[error("Error parsing UUID: {0}")]
|
||||
UUIDError(#[from] uuid::Error),
|
||||
|
||||
#[error("Error parsing URL: {0}")]
|
||||
URLError(#[from] url::ParseError),
|
||||
|
||||
#[error("Unable to read {0} from any source")]
|
||||
NoValueFor(String),
|
||||
|
||||
#[error("Metadata error: {0}")]
|
||||
MetadataError(#[from] daedalus::Error),
|
||||
|
||||
#[error("Minecraft authentication error: {0}")]
|
||||
MinecraftAuthenticationError(
|
||||
#[from] crate::state::MinecraftAuthenticationError,
|
||||
),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
IOError(#[from] util::io::IOError),
|
||||
|
||||
#[error("I/O (std) error: {0}")]
|
||||
StdIOError(#[from] std::io::Error),
|
||||
|
||||
#[error("Error launching Minecraft: {0}")]
|
||||
LauncherError(String),
|
||||
|
||||
#[error("Error fetching URL: {0}")]
|
||||
FetchError(#[from] reqwest::Error),
|
||||
|
||||
#[error("Network download error: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
#[error("HTTP {status} for {method} {url}")]
|
||||
HttpError {
|
||||
status: u16,
|
||||
method: String,
|
||||
url: String,
|
||||
},
|
||||
|
||||
#[error("Too many API errors, try again in {0} minutes")]
|
||||
ApiIsDownError(u32),
|
||||
|
||||
#[error("{0}")]
|
||||
LabrinthError(LabrinthError),
|
||||
|
||||
#[error("Websocket error: {0}")]
|
||||
WSError(#[from] async_tungstenite::tungstenite::Error),
|
||||
|
||||
#[error("Websocket closed before {0} could be received!")]
|
||||
WSClosedError(String),
|
||||
|
||||
#[error("Incorrect Sha1 hash for download: {0} != {1}")]
|
||||
HashError(String, String),
|
||||
|
||||
#[error("Regex error: {0}")]
|
||||
RegexError(#[from] regex::Error),
|
||||
|
||||
#[error("Paths stored in the database need to be valid UTF-8: {0}")]
|
||||
UTFError(std::path::PathBuf),
|
||||
|
||||
#[error("Invalid input: {0}")]
|
||||
InputError(String),
|
||||
|
||||
#[error(
|
||||
"The instance upgrade plan is stale (planned revision {planned_revision}, current revision {current_revision})"
|
||||
)]
|
||||
StaleInstanceUpgradePlan {
|
||||
planned_revision: u64,
|
||||
current_revision: u64,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"The instance upgrade plan filesystem source is stale for instance {instance_id}"
|
||||
)]
|
||||
StaleInstanceUpgradePlanSource { instance_id: String },
|
||||
|
||||
#[error("Join handle error: {0}")]
|
||||
JoinError(#[from] tokio::task::JoinError),
|
||||
|
||||
#[error("Recv error: {0}")]
|
||||
RecvError(#[from] tokio::sync::oneshot::error::RecvError),
|
||||
|
||||
#[error("Error acquiring semaphore: {0}")]
|
||||
AcquireError(#[from] tokio::sync::AcquireError),
|
||||
|
||||
#[error("Instance {0} is not managed by the app!")]
|
||||
UnmanagedInstanceError(String),
|
||||
|
||||
#[error(
|
||||
"Instance {instance_id} is not ready while install stage is {stage}"
|
||||
)]
|
||||
InstanceNotReady { instance_id: String, stage: String },
|
||||
|
||||
#[error("User is not logged in, no credentials available!")]
|
||||
NoCredentialsError,
|
||||
|
||||
#[error("JRE error: {0}")]
|
||||
JREError(#[from] crate::util::jre::JREError),
|
||||
|
||||
#[error("Error parsing date: {0}")]
|
||||
ChronoParseError(#[from] chrono::ParseError),
|
||||
|
||||
#[error("Event error: {0}")]
|
||||
EventError(#[from] crate::event::EventError),
|
||||
|
||||
#[error("Zip error: {0}")]
|
||||
ZipError(#[from] async_zip::error::ZipError),
|
||||
|
||||
#[error("File watching error: {0}")]
|
||||
NotifyError(#[from] notify::Error),
|
||||
|
||||
#[error("Error stripping prefix: {0}")]
|
||||
StripPrefixError(#[from] std::path::StripPrefixError),
|
||||
|
||||
#[error("Error: {0}")]
|
||||
OtherError(String),
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
#[error("Tauri error: {0}")]
|
||||
TauriError(#[from] tauri::Error),
|
||||
|
||||
#[error("Error interacting with database: {0}")]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
|
||||
#[error("Unable to read {cache_type} cache: {message}")]
|
||||
CacheReadError {
|
||||
cache_type: String,
|
||||
message: String,
|
||||
sqlite_code: Option<String>,
|
||||
},
|
||||
|
||||
#[error("Error while applying migrations: {0}")]
|
||||
SqlxMigrate(#[from] sqlx::migrate::MigrateError),
|
||||
|
||||
#[error("Move directory error: {0}")]
|
||||
DirectoryMoveError(String),
|
||||
|
||||
#[error("Error resolving DNS: {0}")]
|
||||
DNSError(#[from] hickory_resolver::ResolveError),
|
||||
|
||||
#[error("An online profile for {user_name} is not available")]
|
||||
OnlineMinecraftProfileUnavailable { user_name: String },
|
||||
|
||||
#[error("Invalid data URL: {0}")]
|
||||
InvalidDataUrl(#[from] DataUrlError),
|
||||
|
||||
#[error("Invalid data URL: {0}")]
|
||||
InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64),
|
||||
|
||||
#[error("Invalid PNG")]
|
||||
InvalidPng,
|
||||
|
||||
#[error("Invalid PNG: {0}")]
|
||||
PngDecodingError(#[from] png::DecodingError),
|
||||
|
||||
#[error("PNG encoding error: {0}")]
|
||||
PngEncodingError(#[from] png::EncodingError),
|
||||
|
||||
#[error(
|
||||
"A skin texture must have a dimension of either 64x64 or 64x32 pixels"
|
||||
)]
|
||||
InvalidSkinTexture,
|
||||
|
||||
#[error("RPC error: {0}")]
|
||||
RpcError(String),
|
||||
|
||||
#[cfg(windows)]
|
||||
#[error("Windows error: {0}")]
|
||||
WindowsError(#[from] windows_core::Error),
|
||||
|
||||
#[error("zbus error: {0}")]
|
||||
ZbusError(#[from] zbus::Error),
|
||||
|
||||
#[error("Deserialization error: {0}")]
|
||||
DeserializationError(#[from] serde::de::value::Error),
|
||||
|
||||
#[error("Discord IPC error: {0}")]
|
||||
DiscordRichPresenceError(#[from] discord_rich_presence::error::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub raw: Arc<ErrorKind>,
|
||||
pub source: tracing_error::TracedError<Arc<ErrorKind>>,
|
||||
context: Option<String>,
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
self.source.source()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(fmt, "{}", self.source)?;
|
||||
if let Some(context) = &self.context {
|
||||
write!(fmt, "\n{context}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn with_context(mut self, context: impl Into<String>) -> Self {
|
||||
self.context = Some(context.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a user-facing message for this error, replacing raw database
|
||||
/// errors that are meaningless to end users with an actionable hint.
|
||||
///
|
||||
/// SQLite foreign-key violation (code 787) surfaces when an instance is
|
||||
/// deleted while a content write is still in flight; the write-path
|
||||
/// validation already reports most of these cleanly, this is the final
|
||||
/// fallback at the Tauri boundary.
|
||||
pub fn user_facing_message(&self) -> String {
|
||||
if let ErrorKind::Sqlx(sqlx::Error::Database(db_error)) =
|
||||
self.raw.as_ref()
|
||||
&& db_error.code().as_deref() == Some("787")
|
||||
{
|
||||
return "This instance was deleted or is being modified; refresh the instance list and try again."
|
||||
.to_string();
|
||||
}
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<ErrorKind>> From<E> for Error {
|
||||
fn from(source: E) -> Self {
|
||||
let error = Into::<ErrorKind>::into(source);
|
||||
let boxed_error = Arc::new(error);
|
||||
|
||||
Self {
|
||||
raw: boxed_error.clone(),
|
||||
source: boxed_error.in_current_span(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<eyre::Report> for Error {
|
||||
fn from(value: eyre::Report) -> Self {
|
||||
let error = Arc::new(ErrorKind::Any(value));
|
||||
|
||||
Self {
|
||||
raw: error.clone(),
|
||||
source: error.in_current_span(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorKind {
|
||||
pub fn as_error(self) -> Error {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
517
packages/app-lib/src/event/emit.rs
Normal file
@ -0,0 +1,517 @@
|
||||
use super::{FriendPayload, LoadingBarId};
|
||||
use crate::event::{
|
||||
CommandPayload, EventError, InstanceBulkUpdateProgressPayload,
|
||||
InstancePayloadType, LoadingBar, LoadingBarType, ProcessPayloadType,
|
||||
ServerPayloadType,
|
||||
};
|
||||
#[cfg(feature = "tauri")]
|
||||
use crate::event::{
|
||||
InstancePayload, JavaDiscoveryPayload, JavaDownloadConfirmationPayload,
|
||||
LoadingPayload, ProcessPayload, ServerPayload, WarningPayload,
|
||||
};
|
||||
use futures::prelude::*;
|
||||
use serde_json::Value;
|
||||
#[cfg(feature = "tauri")]
|
||||
use std::sync::LazyLock;
|
||||
#[cfg(feature = "tauri")]
|
||||
use tauri::{Emitter, Manager};
|
||||
#[cfg(feature = "tauri")]
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
const CLI_PROGRESS_BAR_TOTAL: u64 = 1000;
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
static JAVA_DOWNLOAD_CONFIRMATIONS: LazyLock<
|
||||
dashmap::DashMap<Uuid, oneshot::Sender<bool>>,
|
||||
> = LazyLock::new(dashmap::DashMap::new);
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
struct PendingJavaDownloadConfirmation(Uuid);
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
impl Drop for PendingJavaDownloadConfirmation {
|
||||
fn drop(&mut self) {
|
||||
JAVA_DOWNLOAD_CONFIRMATIONS.remove(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Events are a way we can communicate with the Tauri frontend from the Rust backend.
|
||||
We include a feature flag for Tauri, so that we can compile this code without Tauri.
|
||||
|
||||
To use events, we need to do the following:
|
||||
1) Make sure we are using the tauri feature flag
|
||||
2) Initialize the EventState with EventState::init() *before* initializing the theseus State
|
||||
3) Call emit_x functions to send events to the frontend
|
||||
For emit_loading() specifically, we need to initialize the loading bar with init_loading() first and pass the received loader in
|
||||
|
||||
For example:
|
||||
pub async fn loading_function() -> crate::Result<()> {
|
||||
loading_function()).await;
|
||||
}
|
||||
|
||||
pub async fn loading_function() -> crate::Result<()> {
|
||||
let loading_bar = init_loading(LoadingBarType::StateInit, 100.0, "Loading something long...").await;
|
||||
for i in 0..100 {
|
||||
emit_loading(&loading_bar, 1.0, None)?;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/// Initialize a loading bar for use in emit_loading
|
||||
/// This will generate a LoadingBarId, which is used to refer to the loading bar uniquely.
|
||||
/// total is the total amount of work to be done- all emissions will be considered a fraction of this value (should be 1 or 100 for simplicity)
|
||||
/// title is the title of the loading bar
|
||||
/// The app will wait for this loading bar to finish before exiting, as it is considered safe.
|
||||
pub async fn init_loading(
|
||||
bar_type: LoadingBarType,
|
||||
total: f64,
|
||||
title: &str,
|
||||
) -> crate::Result<LoadingBarId> {
|
||||
let key = init_loading_unsafe(bar_type, total, title).await?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// An unsafe loading bar can be created without adding it to the SafeProcesses list,
|
||||
/// meaning that the app won't ask to wait for it to finish before exiting.
|
||||
pub async fn init_loading_unsafe(
|
||||
bar_type: LoadingBarType,
|
||||
total: f64,
|
||||
title: &str,
|
||||
) -> crate::Result<LoadingBarId> {
|
||||
let event_state = crate::EventState::get()?;
|
||||
let key = LoadingBarId(Uuid::new_v4());
|
||||
|
||||
event_state.loading_bars.insert(
|
||||
key.0,
|
||||
LoadingBar {
|
||||
loading_bar_uuid: key.0,
|
||||
message: title.to_string(),
|
||||
total,
|
||||
current: 0.0,
|
||||
last_sent: 0.0,
|
||||
bar_type,
|
||||
#[cfg(feature = "cli")]
|
||||
cli_progress_bar: {
|
||||
let pb = indicatif::ProgressBar::new(CLI_PROGRESS_BAR_TOTAL);
|
||||
|
||||
pb.set_position(0);
|
||||
pb.set_style(
|
||||
indicatif::ProgressStyle::default_bar()
|
||||
.template(
|
||||
"{spinner:.green} [{elapsed_precise}] [{bar:.lime/green}] {pos}/{len} {msg}",
|
||||
).unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
pb
|
||||
},
|
||||
},
|
||||
);
|
||||
// attempt an initial loading_emit event to the frontend
|
||||
emit_loading(&key, 0.0, None)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
// emit_loading emits a loading event to the frontend
|
||||
// key refers to the loading bar to update
|
||||
// increment refers to by what relative increment to the loading struct's total to update
|
||||
// message is the message to display on the loading bar- if None, use the loading bar's default one
|
||||
// By convention, fraction is the fraction of the progress bar that is filled
|
||||
#[tracing::instrument(level = "debug")]
|
||||
pub fn emit_loading(
|
||||
key: &LoadingBarId,
|
||||
increment_frac: f64,
|
||||
message: Option<&str>,
|
||||
) -> crate::Result<()> {
|
||||
let event_state = crate::EventState::get()?;
|
||||
|
||||
let Some(mut loading_bar) = event_state.loading_bars.get_mut(&key.0) else {
|
||||
return Err(EventError::NoLoadingBar(key.0).into());
|
||||
};
|
||||
|
||||
// Tick up loading bar
|
||||
loading_bar.current += increment_frac;
|
||||
let display_frac = loading_bar.current / loading_bar.total;
|
||||
|
||||
if f64::abs(display_frac - loading_bar.last_sent) > 0.005 {
|
||||
// Emit event to indicatif progress bar
|
||||
#[cfg(feature = "cli")]
|
||||
{
|
||||
loading_bar.cli_progress_bar.set_message(
|
||||
message
|
||||
.map(|x| x.to_string())
|
||||
.unwrap_or(loading_bar.message.clone()),
|
||||
);
|
||||
loading_bar.cli_progress_bar.set_position(
|
||||
(display_frac * CLI_PROGRESS_BAR_TOTAL as f64).round() as u64,
|
||||
);
|
||||
}
|
||||
|
||||
//Emit event to tauri
|
||||
#[cfg(feature = "tauri")]
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"loading",
|
||||
LoadingPayload {
|
||||
fraction: if display_frac >= 1.0 {
|
||||
None // by convention, when its done, we submit None
|
||||
// any further updates will be ignored (also sending None)
|
||||
} else {
|
||||
Some(display_frac)
|
||||
},
|
||||
message: message
|
||||
.unwrap_or(&loading_bar.message)
|
||||
.to_string(),
|
||||
event: loading_bar.bar_type.clone(),
|
||||
loader_uuid: loading_bar.loading_bar_uuid,
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
|
||||
#[cfg(not(any(feature = "cli", feature = "tauri")))]
|
||||
let _ = message;
|
||||
|
||||
loading_bar.last_sent = display_frac;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// emit_warning(message)
|
||||
pub async fn emit_warning(message: &str) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"warning",
|
||||
WarningPayload {
|
||||
message: message.to_string(),
|
||||
kind: None,
|
||||
instance_id: None,
|
||||
instance_name: None,
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
tracing::warn!("{}", message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn emit_minecraft_crash_warning(
|
||||
instance_id: &str,
|
||||
instance_name: &str,
|
||||
) -> crate::Result<()> {
|
||||
let message = format!("Instance {instance_name} has crashed");
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"warning",
|
||||
WarningPayload {
|
||||
message: message.clone(),
|
||||
kind: Some("minecraft_crash".to_string()),
|
||||
instance_id: Some(instance_id.to_string()),
|
||||
instance_name: Some(instance_name.to_string()),
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
tracing::warn!(instance_id = instance_id, "{}", message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// emit_java_discovery_update(count)
|
||||
// Fired when a Java rescan changed the set of discovered installations
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_java_discovery_update(count: usize) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("java_discovery_update", JavaDiscoveryPayload { count })
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
tracing::debug!("Java discovery updated: {count} installations");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn request_java_download_confirmation(
|
||||
version: u32,
|
||||
) -> crate::Result<bool> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
let request_id = Uuid::new_v4();
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
JAVA_DOWNLOAD_CONFIRMATIONS.insert(request_id, sender);
|
||||
let _pending = PendingJavaDownloadConfirmation(request_id);
|
||||
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"java_download_confirmation",
|
||||
JavaDownloadConfirmationPayload {
|
||||
request_id,
|
||||
version,
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
|
||||
Ok(receiver.await.unwrap_or(false))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
pub fn respond_to_java_download_confirmation(
|
||||
request_id: Uuid,
|
||||
approved: bool,
|
||||
) -> bool {
|
||||
JAVA_DOWNLOAD_CONFIRMATIONS
|
||||
.remove(&request_id)
|
||||
.is_some_and(|(_, sender)| sender.send(approved).is_ok())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_instance_bulk_update_progress(
|
||||
payload: InstanceBulkUpdateProgressPayload,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("instance_bulk_update_progress", payload)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// emit_command(CommandPayload::Something { something })
|
||||
// ie: installing a pack, opening an .mrpack, etc
|
||||
// Generally used for url deep links and file opens that we want to handle in the frontend
|
||||
pub async fn emit_command(command: CommandPayload) -> crate::Result<()> {
|
||||
tracing::debug!("Command: {}", serde_json::to_string(&command)?);
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("command", command)
|
||||
.map_err(EventError::from)?;
|
||||
|
||||
if let Some(window) = event_state.app.get_window("main") {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// emit_process(instance_id, uuid, event, message, crashed)
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_process(
|
||||
instance_id: &str,
|
||||
uuid: Uuid,
|
||||
pid: u32,
|
||||
maximize_window: bool,
|
||||
event: ProcessPayloadType,
|
||||
message: &str,
|
||||
crashed: Option<bool>,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"process",
|
||||
ProcessPayload {
|
||||
instance_id: instance_id.to_string(),
|
||||
uuid,
|
||||
pid,
|
||||
maximize_window,
|
||||
event,
|
||||
message: message.to_string(),
|
||||
crashed,
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// emit_instance(path, event)
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_instance(
|
||||
instance_id: &str,
|
||||
event: InstancePayloadType,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"instance",
|
||||
InstancePayload {
|
||||
instance_id: instance_id.to_string(),
|
||||
event,
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// emit_server(server_id, event)
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_server(
|
||||
server_id: &str,
|
||||
event: ServerPayloadType,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit(
|
||||
"server",
|
||||
ServerPayload {
|
||||
server_id: server_id.to_string(),
|
||||
event,
|
||||
},
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("friend", payload)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_notification(payload: Value) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get()?;
|
||||
event_state
|
||||
.app
|
||||
.emit("notification", payload)
|
||||
.map_err(EventError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// loading_join! macro
|
||||
// loading_join!(key: Option<&LoadingBarId>, total: f64, message: Option<&str>; task1, task2, task3...)
|
||||
// This will submit a loading event with the given message for each task as they complete
|
||||
// task1, task2, task3 are async tasks that you want to to join on await on
|
||||
// Key is the key to use for which loading bar to submit these results to- a LoadingBarId. If None, it does nothing
|
||||
// Total is the total amount of progress that the loading bar should take up by all futures in this (will be split evenly amongst them).
|
||||
// If message is Some(t) you will overwrite this loading bar's message with a custom one
|
||||
// For example, if you want the tasks to range as 0.1, 0.2, 0.3 (of the progress bar), you would do:
|
||||
// loading_join!(loading_bar, 0.1; task1, task2, task3)
|
||||
// This will await on each of the tasks, and as each completes, it will emit a loading event for 0.033, 0.066, 0.099, etc
|
||||
// This should function as a drop-in replacement for tokio::try_join_all! in most cases- except the function *itself* calls ? rather than needing it.
|
||||
#[macro_export]
|
||||
macro_rules! count {
|
||||
() => (0usize);
|
||||
( $x:tt $($xs:tt)* ) => (1usize + $crate::count!($($xs)*));
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! loading_join {
|
||||
($key:expr, $total:expr, $message:expr; $($task:expr $(,)?)+) => {
|
||||
{
|
||||
let key = $key;
|
||||
let message : Option<&str> = $message;
|
||||
|
||||
let num_futures = $crate::count!($($task)*);
|
||||
let increment = $total / num_futures as f64;
|
||||
|
||||
|
||||
paste::paste! {
|
||||
$( let [ <unique_name $task>] = {
|
||||
{
|
||||
let key = key.clone();
|
||||
let message = message.clone();
|
||||
async move {
|
||||
let res = $task.await;
|
||||
if let Some(key) = key {
|
||||
$crate::event::emit::emit_loading(key, increment, message)?;
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
};)+
|
||||
}
|
||||
|
||||
paste::paste! {
|
||||
tokio::try_join! (
|
||||
$( [ <unique_name $task>] ),+
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// A drop in replacement to try_for_each_concurrent that emits loading events as it goes
|
||||
// Key is the key to use for which loading bar- a LoadingBarId. If None, does nothing
|
||||
// Total is the total amount of progress that the loading bar should take up by all futures in this (will be split evenly amongst them).
|
||||
// If message is Some(t) you will overwrite this loading bar's message with a custom one
|
||||
// num_futs is the number of futures that will be run, which is needed as we allow Iterator to be passed in, which doesn't have a size
|
||||
#[tracing::instrument(skip(stream, f))]
|
||||
|
||||
pub async fn loading_try_for_each_concurrent<I, F, Fut, T>(
|
||||
stream: I,
|
||||
limit: Option<usize>,
|
||||
key: Option<&LoadingBarId>,
|
||||
total: f64,
|
||||
num_futs: usize, // num is in here as we allow Iterator to be passed in, which doesn't have a size
|
||||
message: Option<&str>,
|
||||
f: F,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
I: futures::TryStreamExt<Error = crate::Error> + TryStream<Ok = T>,
|
||||
F: FnMut(T) -> Fut + Send,
|
||||
Fut: Future<Output = crate::Result<()>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let mut f = f;
|
||||
stream
|
||||
.try_for_each_concurrent(limit, |item| {
|
||||
let f = f(item);
|
||||
async move {
|
||||
f.await?;
|
||||
if let Some(key) = key {
|
||||
emit_loading(key, total / (num_futs as f64), message)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
414
packages/app-lib/src/event/mod.rs
Normal file
@ -0,0 +1,414 @@
|
||||
//! Theseus state management system
|
||||
use ariadne::ids::UserId;
|
||||
use ariadne::users::UserStatus;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
#[cfg(feature = "tauri")]
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::OnceCell;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod emit;
|
||||
|
||||
// Global event state
|
||||
// Stores the Tauri app handle and other event-related state variables
|
||||
static EVENT_STATE: OnceCell<Arc<EventState>> = OnceCell::const_new();
|
||||
pub struct EventState {
|
||||
/// Tauri app
|
||||
#[cfg(feature = "tauri")]
|
||||
pub app: tauri::AppHandle,
|
||||
pub loading_bars: DashMap<Uuid, LoadingBar>,
|
||||
}
|
||||
|
||||
impl EventState {
|
||||
#[cfg(feature = "tauri")]
|
||||
pub async fn init(app: tauri::AppHandle) -> crate::Result<Arc<Self>> {
|
||||
EVENT_STATE
|
||||
.get_or_try_init(|| async {
|
||||
Ok(Arc::new(Self {
|
||||
app,
|
||||
loading_bars: DashMap::new(),
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
pub async fn init() -> crate::Result<Arc<Self>> {
|
||||
EVENT_STATE
|
||||
.get_or_try_init(|| async {
|
||||
Ok(Arc::new(Self {
|
||||
loading_bars: DashMap::new(),
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn get() -> crate::Result<Arc<Self>> {
|
||||
Ok(EVENT_STATE.get().ok_or(EventError::NotInitialized)?.clone())
|
||||
}
|
||||
|
||||
// Values provided should not be used directly, as they are clones and are not guaranteed to be up-to-date
|
||||
pub async fn list_progress_bars() -> crate::Result<DashMap<Uuid, LoadingBar>>
|
||||
{
|
||||
let value = Self::get()?;
|
||||
Ok(value.loading_bars.clone())
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
pub async fn get_main_window() -> crate::Result<Option<tauri::WebviewWindow>>
|
||||
{
|
||||
use tauri::Manager;
|
||||
let value = Self::get()?;
|
||||
Ok(value.app.get_webview_window("main"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct LoadingBar {
|
||||
// loading_bar_uuid not be used directly by external functions as it may not reflect the current state of the loading bar/hashmap
|
||||
pub loading_bar_uuid: Uuid,
|
||||
pub message: String,
|
||||
pub total: f64,
|
||||
pub current: f64,
|
||||
#[serde(skip)]
|
||||
pub last_sent: f64,
|
||||
pub bar_type: LoadingBarType,
|
||||
#[cfg(feature = "cli")]
|
||||
#[serde(skip)]
|
||||
pub cli_progress_bar: indicatif::ProgressBar,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct LoadingBarId(Uuid);
|
||||
|
||||
// When Loading bar id is dropped, we should remove it from the hashmap
|
||||
impl Drop for LoadingBarId {
|
||||
fn drop(&mut self) {
|
||||
let loader_uuid = self.0;
|
||||
tokio::spawn(async move {
|
||||
if let Ok(event_state) = EventState::get() {
|
||||
#[cfg(any(feature = "tauri", feature = "cli"))]
|
||||
if let Some((_, bar)) =
|
||||
event_state.loading_bars.remove(&loader_uuid)
|
||||
{
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let loader_uuid = bar.loading_bar_uuid;
|
||||
let event = bar.bar_type.clone();
|
||||
let fraction = bar.current / bar.total;
|
||||
|
||||
let _ = event_state.app.emit(
|
||||
"loading",
|
||||
LoadingPayload {
|
||||
fraction: None,
|
||||
message: "Completed".to_string(),
|
||||
event,
|
||||
loader_uuid,
|
||||
},
|
||||
);
|
||||
tracing::trace!(
|
||||
"Exited at {fraction} for loading bar: {:?}",
|
||||
loader_uuid
|
||||
);
|
||||
}
|
||||
|
||||
// Emit event to indicatif progress bar arc
|
||||
#[cfg(feature = "cli")]
|
||||
{
|
||||
let cli_progress_bar = bar.cli_progress_bar;
|
||||
cli_progress_bar.finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tauri", feature = "cli")))]
|
||||
event_state.loading_bars.remove(&loader_uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Hash, PartialEq, Eq)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoadingBarType {
|
||||
LegacyDataMigration,
|
||||
DirectoryMove {
|
||||
old: PathBuf,
|
||||
new: PathBuf,
|
||||
},
|
||||
JavaDownload {
|
||||
version: u32,
|
||||
},
|
||||
PackFileDownload {
|
||||
instance_id: String,
|
||||
pack_name: String,
|
||||
icon: Option<String>,
|
||||
pack_version: String,
|
||||
},
|
||||
PackDownload {
|
||||
instance_id: String,
|
||||
pack_name: String,
|
||||
icon: Option<PathBuf>,
|
||||
pack_id: Option<String>,
|
||||
pack_version: Option<String>,
|
||||
},
|
||||
MinecraftDownload {
|
||||
instance_id: String,
|
||||
instance_name: String,
|
||||
},
|
||||
InstanceUpdate {
|
||||
instance_id: String,
|
||||
instance_name: String,
|
||||
},
|
||||
ZipExtract {
|
||||
instance_id: String,
|
||||
instance_name: String,
|
||||
},
|
||||
ConfigChange {
|
||||
new_path: PathBuf,
|
||||
},
|
||||
CopyInstance {
|
||||
import_location: PathBuf,
|
||||
instance_name: String,
|
||||
},
|
||||
LauncherUpdate {
|
||||
version: String,
|
||||
current_version: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[cfg(feature = "tauri")]
|
||||
pub struct LoadingPayload {
|
||||
pub event: LoadingBarType,
|
||||
pub loader_uuid: Uuid,
|
||||
pub fraction: Option<f64>, // by convention, if optional, it means the loading is done
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[cfg(feature = "tauri")]
|
||||
pub struct WarningPayload {
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instance_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instance_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[cfg(feature = "tauri")]
|
||||
pub struct JavaDiscoveryPayload {
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[cfg(feature = "tauri")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JavaDownloadConfirmationPayload {
|
||||
pub request_id: Uuid,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceBulkUpdateProgressPayload {
|
||||
pub instance_id: String,
|
||||
pub stage: InstanceBulkUpdateProgressStage,
|
||||
pub current: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceBulkUpdateProgressStage {
|
||||
ResolvingVersions,
|
||||
Downloading,
|
||||
Finishing,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "event")]
|
||||
pub enum CommandPayload {
|
||||
InstallMod {
|
||||
id: String,
|
||||
},
|
||||
InstallVersion {
|
||||
id: String,
|
||||
},
|
||||
InstallModpack {
|
||||
id: String,
|
||||
},
|
||||
InstallServer {
|
||||
id: String,
|
||||
},
|
||||
LaunchInstance {
|
||||
id: String,
|
||||
server: Option<String>,
|
||||
singleplayer_world: Option<String>,
|
||||
},
|
||||
RunMRPack {
|
||||
// run or install .mrpack
|
||||
path: PathBuf,
|
||||
},
|
||||
OpenSeedMap {
|
||||
// URL query string describing the shared seed-map state
|
||||
query: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[cfg(feature = "tauri")]
|
||||
pub struct ProcessPayload {
|
||||
pub instance_id: String,
|
||||
pub uuid: Uuid,
|
||||
pub pid: u32,
|
||||
pub maximize_window: bool,
|
||||
pub event: ProcessPayloadType,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crashed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProcessPayloadType {
|
||||
Launched,
|
||||
Finished,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[cfg(feature = "tauri")]
|
||||
pub struct ServerPayload {
|
||||
#[serde(rename = "serverId")]
|
||||
pub server_id: String,
|
||||
#[serde(flatten)]
|
||||
pub event: ServerPayloadType,
|
||||
}
|
||||
|
||||
/// Classifies why a server process exited on its own, derived from the tail
|
||||
/// of its console output so the UI can react (e.g. offer the EULA dialog)
|
||||
/// instead of just reporting a dead process.
|
||||
#[derive(Serialize, Clone, Debug, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExitReason {
|
||||
Eula,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum ServerPayloadType {
|
||||
Log {
|
||||
line: String,
|
||||
},
|
||||
ConsoleOutput {
|
||||
data: String,
|
||||
},
|
||||
DownloadProgress {
|
||||
downloaded: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total: Option<u64>,
|
||||
},
|
||||
Started,
|
||||
Stopped {
|
||||
crashed: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<ExitReason>,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
EulaRequired {
|
||||
server_id: String,
|
||||
eula_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[cfg(feature = "tauri")]
|
||||
pub struct InstancePayload {
|
||||
pub instance_id: String,
|
||||
#[serde(flatten)]
|
||||
pub event: InstancePayloadType,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum InstancePayloadType {
|
||||
Created,
|
||||
Synced,
|
||||
ContentChanged {
|
||||
revision: u64,
|
||||
},
|
||||
ServersUpdated,
|
||||
WorldUpdated {
|
||||
world: String,
|
||||
},
|
||||
ServerJoined {
|
||||
host: String,
|
||||
port: u16,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
Edited,
|
||||
ContentInstallFinished {
|
||||
project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
dependency_project_ids: Vec<String>,
|
||||
},
|
||||
ContentInstallFailed {
|
||||
project_ids: Vec<String>,
|
||||
message: String,
|
||||
},
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[serde(tag = "event")]
|
||||
pub enum FriendPayload {
|
||||
FriendRequest { from: UserId },
|
||||
UserOffline { id: UserId },
|
||||
StatusUpdate { user_status: UserStatus },
|
||||
StatusSync,
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
pub use self::log_types::*;
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
mod log_types {
|
||||
use crate::state::Log4jEvent;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum LogEvent {
|
||||
Log4j(Log4jEvent),
|
||||
Legacy { message: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct LogPayload {
|
||||
pub instance_id: String,
|
||||
#[serde(flatten)]
|
||||
pub event: LogEvent,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EventError {
|
||||
#[error("Event state was not properly initialized")]
|
||||
NotInitialized,
|
||||
|
||||
#[error("Non-existent loading bar of key: {0}")]
|
||||
NoLoadingBar(Uuid),
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
#[error("Tauri error: {0}")]
|
||||
TauriError(#[from] tauri::Error),
|
||||
}
|
||||