feat:移除了弹窗,服务器添加sls
This commit is contained in:
1016
packages/app-lib/src/launcher/args.rs
Normal file
1016
packages/app-lib/src/launcher/args.rs
Normal file
File diff suppressed because it is too large
Load Diff
1957
packages/app-lib/src/launcher/direct_ensure.rs
Normal file
1957
packages/app-lib/src/launcher/direct_ensure.rs
Normal file
File diff suppressed because it is too large
Load Diff
3485
packages/app-lib/src/launcher/direct_link.rs
Normal file
3485
packages/app-lib/src/launcher/direct_link.rs
Normal file
File diff suppressed because it is too large
Load Diff
3461
packages/app-lib/src/launcher/download.rs
Normal file
3461
packages/app-lib/src/launcher/download.rs
Normal file
File diff suppressed because it is too large
Load Diff
122
packages/app-lib/src/launcher/instance_runtime.rs
Normal file
122
packages/app-lib/src/launcher/instance_runtime.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::state::{DirectoryInfo, Instance};
|
||||
|
||||
use super::DirectLinkedLaunch;
|
||||
|
||||
/// Runtime storage adapters keep mode-specific path rules out of callers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum InstanceRuntimeAdapter {
|
||||
AxolotlManaged { game_dir: PathBuf },
|
||||
MinecraftShared { direct: DirectLinkedLaunch },
|
||||
MinecraftIsolated { direct: DirectLinkedLaunch },
|
||||
}
|
||||
|
||||
impl InstanceRuntimeAdapter {
|
||||
/// Entry point selecting the adapter for an instance.
|
||||
pub(crate) fn for_instance(
|
||||
instance: &Instance,
|
||||
directories: &DirectoryInfo,
|
||||
) -> crate::Result<Self> {
|
||||
if let Some(external) = Self::external_for_instance(instance)? {
|
||||
return Ok(external);
|
||||
}
|
||||
|
||||
Ok(Self::AxolotlManaged {
|
||||
game_dir: directories.instance_game_dir(instance),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn external_for_instance(
|
||||
instance: &Instance,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
if let Some(direct) = DirectLinkedLaunch::from_instance(instance)? {
|
||||
return Ok(Some(Self::from_direct(direct)));
|
||||
}
|
||||
|
||||
let Some(direct) =
|
||||
DirectLinkedLaunch::from_game_dir_override(instance)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Self::from_direct(direct)))
|
||||
}
|
||||
|
||||
fn from_direct(direct: DirectLinkedLaunch) -> Self {
|
||||
let isolated = match direct.game_dir_mode {
|
||||
Some(super::ExternalGameDirMode::Isolated) => true,
|
||||
Some(super::ExternalGameDirMode::Shared) => false,
|
||||
Some(super::ExternalGameDirMode::Automatic) | None => {
|
||||
match direct.dialect {
|
||||
super::LinkedLauncherDialect::Pcl
|
||||
| super::LinkedLauncherDialect::PclCe => true,
|
||||
super::LinkedLauncherDialect::Generic => true,
|
||||
super::LinkedLauncherDialect::Hmcl => false,
|
||||
}
|
||||
}
|
||||
};
|
||||
if isolated {
|
||||
Self::MinecraftIsolated { direct }
|
||||
} else {
|
||||
Self::MinecraftShared { direct }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn direct_link(&self) -> Option<&DirectLinkedLaunch> {
|
||||
match self {
|
||||
Self::AxolotlManaged { .. } => None,
|
||||
Self::MinecraftShared { direct }
|
||||
| Self::MinecraftIsolated { direct } => Some(direct),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn game_dir(&self) -> PathBuf {
|
||||
match self {
|
||||
Self::AxolotlManaged { game_dir } => game_dir.clone(),
|
||||
Self::MinecraftShared { direct } => match direct.resolve() {
|
||||
Ok(resolved) => resolved.game_dir,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
%error,
|
||||
"Falling back after the external instance version chain could not be resolved"
|
||||
);
|
||||
direct.dot_minecraft.clone()
|
||||
}
|
||||
},
|
||||
Self::MinecraftIsolated { direct } => match direct.resolve() {
|
||||
Ok(resolved) => resolved.game_dir,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
%error,
|
||||
"Falling back to the external instance version directory"
|
||||
);
|
||||
direct.version_dir()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn libraries_dir(&self, directories: &DirectoryInfo) -> PathBuf {
|
||||
self.direct_link().map_or_else(
|
||||
|| directories.libraries_dir(),
|
||||
DirectLinkedLaunch::libraries_dir,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn assets_dir(&self, directories: &DirectoryInfo) -> PathBuf {
|
||||
self.direct_link().map_or_else(
|
||||
|| directories.assets_dir(),
|
||||
DirectLinkedLaunch::assets_dir,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn log_configs_dir(
|
||||
&self,
|
||||
directories: &DirectoryInfo,
|
||||
) -> PathBuf {
|
||||
self.direct_link().map_or_else(
|
||||
|| directories.log_configs_dir(),
|
||||
DirectLinkedLaunch::log_configs_dir,
|
||||
)
|
||||
}
|
||||
}
|
||||
589
packages/app-lib/src/launcher/jvm_args.rs
Normal file
589
packages/app-lib/src/launcher/jvm_args.rs
Normal file
@ -0,0 +1,589 @@
|
||||
//! Runtime verification and safe fallback for JVM (GC) arguments.
|
||||
//!
|
||||
//! The frontend picks the *preferred* GC strategy from machine heuristics and
|
||||
//! sends an ordered list of candidate argument blocks. This module verifies
|
||||
//! each candidate against the *actual* JVM binary that will launch Minecraft,
|
||||
//! prunes only the unsupported *tuning* flags (never the GC selector, which
|
||||
//! defines the strategy), and falls back down the candidate chain — ending at
|
||||
//! launching with no GC arguments at all rather than refusing to start.
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
use tracing::warn;
|
||||
|
||||
/// Marker token the frontend persists to mean "auto-select GC".
|
||||
pub const AUTO_GC_PRESET_ARG: &str = "@axolotl:gc:auto";
|
||||
|
||||
/// An `-XX:` flag whose key starts with `+Use` is a collector selector
|
||||
/// (`UseG1GC`, `UseShenandoahGC`, `UseZGC`). Dropping it silently changes the
|
||||
/// collector, so we never prune it — an unsupported selector means the whole
|
||||
/// strategy is unusable on this JVM.
|
||||
fn is_gc_selector(arg: &str) -> bool {
|
||||
arg.starts_with("-XX:+Use")
|
||||
}
|
||||
|
||||
/// Frontend → backend intent describing which GC preset is active and the
|
||||
/// ordered candidates to try.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GcLaunchIntent {
|
||||
/// Frontend preset id (`gc-auto`, `gc-g1gc-mojang`, `gc-g1gc-pcl`,
|
||||
/// `gc-shenandoah` or `gc-zgc`).
|
||||
pub active_preset_id: String,
|
||||
/// Exact tokens currently present in the effective java args that belong
|
||||
/// to this preset — or just the `@axolotl:gc:auto` marker for auto.
|
||||
pub block_tokens: Vec<String>,
|
||||
/// Strategy id for each candidate (`zgc`, `shenandoah`, `g1gc-mojang`,
|
||||
/// `g1gc-pcl`, `minimal-g1`). Parallel to `candidates`, `[0]` preferred.
|
||||
pub candidate_ids: Vec<String>,
|
||||
/// Parallel to `candidate_ids`: the ordered candidate argument blocks.
|
||||
pub candidates: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl GcLaunchIntent {
|
||||
pub fn preferred_id(&self) -> String {
|
||||
self.candidate_ids
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.active_preset_id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of verification/fallback, returned to the frontend so it can tell
|
||||
/// the user what the JVM actually accepted.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GcLaunchReport {
|
||||
pub preferred_strategy: String,
|
||||
pub chosen_strategy: String,
|
||||
pub chosen_args: Vec<String>,
|
||||
pub pruned_args: Vec<String>,
|
||||
pub reason_chain: Vec<String>,
|
||||
}
|
||||
|
||||
impl GcLaunchReport {
|
||||
/// Whether the launch deviated from the preferred resolution (strategy
|
||||
/// fallback or flag-level pruning).
|
||||
pub fn fell_back(&self) -> bool {
|
||||
self.chosen_strategy != self.preferred_strategy
|
||||
|| !self.pruned_args.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProbeOutcome {
|
||||
supported: bool,
|
||||
stderr_text: String,
|
||||
}
|
||||
|
||||
/// Whether a particular (java, args) set already accepted/succeeded.
|
||||
fn probe_cache() -> &'static Mutex<HashMap<(String, Vec<String>), bool>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, Vec<String>), bool>>> =
|
||||
OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Run `java <args> -version` and report whether the JVM accepted `args`.
|
||||
/// A supported flag set exits 0; an unrecognized option exits non-zero with
|
||||
/// an explanatory message on stderr.
|
||||
async fn probe_jvm_arguments(java: &Path, args: &[String]) -> ProbeOutcome {
|
||||
let key = (java.to_string_lossy().into_owned(), args.to_vec());
|
||||
if let Some(supported) = probe_cache().lock().unwrap().get(&key).copied() {
|
||||
return ProbeOutcome {
|
||||
supported,
|
||||
stderr_text: String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let timed = timeout(
|
||||
Duration::from_secs(5),
|
||||
Command::new(java)
|
||||
.args(args)
|
||||
.arg("-version")
|
||||
.env_remove("_JAVA_OPTIONS")
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcome = match timed {
|
||||
Ok(Ok(output)) => {
|
||||
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
if !output.stdout.is_empty() {
|
||||
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
ProbeOutcome {
|
||||
supported: output.status.success(),
|
||||
stderr_text: text,
|
||||
}
|
||||
}
|
||||
// Spawn failure or timeout: treat as unsupported and move on.
|
||||
Ok(Err(_)) | Err(_) => ProbeOutcome {
|
||||
supported: false,
|
||||
stderr_text: String::new(),
|
||||
},
|
||||
};
|
||||
|
||||
probe_cache().lock().unwrap().insert(key, outcome.supported);
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Normalize an option token to a comparable key, e.g.
|
||||
/// `-XX:+UseZGC` → `UseZGC`, `G1UncommitBias=1` → `G1UncommitBias`,
|
||||
/// `-XX:G1HeapRegionSize=32M` → `G1HeapRegionSize`.
|
||||
fn normalize_option_key(arg: &str) -> String {
|
||||
let trimmed = arg.trim_start_matches('-');
|
||||
let trimmed = trimmed.strip_prefix("XX:").unwrap_or(trimmed);
|
||||
let trimmed = trimmed.trim_start_matches(['+', '-']);
|
||||
trimmed.split('=').next().unwrap_or(trimmed).to_string()
|
||||
}
|
||||
|
||||
/// Scan JVM stderr for the quoted option that was rejected, and return its
|
||||
/// index inside `args` when recognizable. Heuristic across HotSpot and OpenJ9
|
||||
/// message formats; `None` means we can't tell, so the caller treats the whole
|
||||
/// candidate as unsupported.
|
||||
fn find_offending_arg(text: &str, args: &[String]) -> Option<usize> {
|
||||
let quote_re = Regex::new(r"'([^']+)'").ok()?;
|
||||
for captures in quote_re.captures_iter(text) {
|
||||
let quoted = captures[1].trim();
|
||||
if quoted.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let key = normalize_option_key(quoted);
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Only consider quoted text that actually resembles a VM option.
|
||||
let looks_like_option = quoted.contains("XX:")
|
||||
|| quoted.starts_with("-XX:")
|
||||
|| args.iter().any(|arg| normalize_option_key(arg) == key);
|
||||
if !looks_like_option {
|
||||
continue;
|
||||
}
|
||||
if let Some(idx) =
|
||||
args.iter().position(|arg| normalize_option_key(arg) == key)
|
||||
{
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const MAX_PRUNE_ROUNDS_PER_CANDIDATE: usize = 8;
|
||||
const MAX_PROBES_PER_LAUNCH: usize = 14;
|
||||
|
||||
/// A probe that checks whether a JVM accepts a set of arguments. Abstracted so
|
||||
/// the selection logic is unit-testable without spawning a real JVM.
|
||||
trait JvmProbe {
|
||||
fn probe<'a>(
|
||||
&'a mut self,
|
||||
args: &'a [String],
|
||||
) -> Pin<Box<dyn Future<Output = ProbeOutcome> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// Probe against a concrete Java binary.
|
||||
struct RealJvmProbe<'a> {
|
||||
java: &'a Path,
|
||||
}
|
||||
|
||||
impl JvmProbe for RealJvmProbe<'_> {
|
||||
fn probe<'a>(
|
||||
&'a mut self,
|
||||
args: &'a [String],
|
||||
) -> Pin<Box<dyn Future<Output = ProbeOutcome> + Send + 'a>> {
|
||||
Box::pin(probe_jvm_arguments(self.java, args))
|
||||
}
|
||||
}
|
||||
|
||||
/// Try each candidate in order against a probe. Pure logic so it is
|
||||
/// unit-testable without spawning a real JVM.
|
||||
async fn select_best_candidate_with_probe<P>(
|
||||
intent: &GcLaunchIntent,
|
||||
probe: &mut P,
|
||||
) -> (Vec<String>, GcLaunchReport)
|
||||
where
|
||||
P: JvmProbe + ?Sized,
|
||||
{
|
||||
let mut report = GcLaunchReport {
|
||||
preferred_strategy: intent.preferred_id(),
|
||||
chosen_strategy: String::new(),
|
||||
chosen_args: Vec::new(),
|
||||
pruned_args: Vec::new(),
|
||||
reason_chain: Vec::new(),
|
||||
};
|
||||
let mut probes = 0usize;
|
||||
let candidate_count =
|
||||
intent.candidates.len().min(intent.candidate_ids.len());
|
||||
|
||||
for index in 0..candidate_count {
|
||||
let candidate_id = &intent.candidate_ids[index];
|
||||
let candidate = &intent.candidates[index];
|
||||
if index > 0 {
|
||||
report
|
||||
.reason_chain
|
||||
.push(format!("{candidate_id} is the fallback candidate"));
|
||||
}
|
||||
|
||||
let mut current = candidate.clone();
|
||||
let mut pruned: Vec<String> = Vec::new();
|
||||
let mut rounds = 0usize;
|
||||
|
||||
loop {
|
||||
if probes >= MAX_PROBES_PER_LAUNCH {
|
||||
report.reason_chain.push(
|
||||
"JVM probe budget exhausted; using JVM default GC"
|
||||
.to_string(),
|
||||
);
|
||||
return (Vec::new(), report);
|
||||
}
|
||||
probes += 1;
|
||||
|
||||
let outcome = probe.probe(¤t).await;
|
||||
if outcome.supported {
|
||||
report.chosen_strategy = candidate_id.clone();
|
||||
report.chosen_args = current;
|
||||
report.pruned_args = pruned;
|
||||
if !report.pruned_args.is_empty() {
|
||||
report.reason_chain.push(format!(
|
||||
"pruned {} unsupported argument(s)",
|
||||
report.pruned_args.len()
|
||||
));
|
||||
}
|
||||
return (report.chosen_args.clone(), report);
|
||||
}
|
||||
|
||||
if rounds >= MAX_PRUNE_ROUNDS_PER_CANDIDATE {
|
||||
break;
|
||||
}
|
||||
rounds += 1;
|
||||
|
||||
match find_offending_arg(&outcome.stderr_text, ¤t) {
|
||||
Some(idx) if !is_gc_selector(¤t[idx]) => {
|
||||
pruned.push(current.remove(idx));
|
||||
}
|
||||
// Unsupported selector (or unrecognizable error): the whole
|
||||
// strategy is unusable on this JVM.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
.reason_chain
|
||||
.push(format!("{candidate_id} is not supported by this JVM"));
|
||||
}
|
||||
|
||||
// Every candidate failed. Launch with no GC arguments and let the JVM
|
||||
// choose its default collector — never block the game over GC tuning.
|
||||
report.reason_chain.push(
|
||||
"no GC strategy is supported; falling back to JVM default GC"
|
||||
.to_string(),
|
||||
);
|
||||
(Vec::new(), report)
|
||||
}
|
||||
|
||||
/// Verify `intent.candidates` against the real JVM and select the first one
|
||||
/// the JVM accepts (with surgical pruning of unsupported tuning flags).
|
||||
pub async fn select_best_candidate(
|
||||
java: &Path,
|
||||
intent: &GcLaunchIntent,
|
||||
) -> (Vec<String>, GcLaunchReport) {
|
||||
select_best_candidate_with_probe(intent, &mut RealJvmProbe { java }).await
|
||||
}
|
||||
|
||||
/// Replace the preset block (or auto marker) in `args` with the verified
|
||||
/// `chosen` tokens. If the block cannot be located, leaves `args` untouched.
|
||||
pub fn replace_gc_block(
|
||||
args: &mut Vec<String>,
|
||||
intent: &GcLaunchIntent,
|
||||
chosen: &[String],
|
||||
) {
|
||||
let block_set: HashSet<&str> =
|
||||
intent.block_tokens.iter().map(String::as_str).collect();
|
||||
let is_auto = block_set.contains(AUTO_GC_PRESET_ARG)
|
||||
|| intent.block_tokens.iter().any(|t| t == AUTO_GC_PRESET_ARG);
|
||||
|
||||
let mut out: Vec<String> = Vec::with_capacity(args.len() + chosen.len());
|
||||
let mut inserted = false;
|
||||
for arg in args.iter() {
|
||||
let belongs_to_block = if is_auto {
|
||||
arg == AUTO_GC_PRESET_ARG
|
||||
} else {
|
||||
block_set.contains(arg.as_str())
|
||||
};
|
||||
if belongs_to_block {
|
||||
if !inserted {
|
||||
out.extend(chosen.iter().cloned());
|
||||
inserted = true;
|
||||
}
|
||||
} else {
|
||||
out.push(arg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !inserted {
|
||||
warn!(
|
||||
"GC intent block not found in effective java args; leaving them unchanged"
|
||||
);
|
||||
return;
|
||||
}
|
||||
*args = out;
|
||||
}
|
||||
|
||||
/// Convenience: select and splice in one call, returning the report.
|
||||
pub async fn resolve_gc_block(
|
||||
java: &Path,
|
||||
java_args: &mut Vec<String>,
|
||||
intent: &crate::launcher::jvm_args::GcLaunchIntent,
|
||||
) -> GcLaunchReport {
|
||||
let (chosen, report) = select_best_candidate(java, intent).await;
|
||||
replace_gc_block(java_args, intent, &chosen);
|
||||
report
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_supported(_args: &[String]) -> ProbeOutcome {
|
||||
ProbeOutcome {
|
||||
supported: true,
|
||||
stderr_text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a synchronous fake probe so it can drive the async selection
|
||||
/// logic without spawning a real JVM.
|
||||
struct StubProbe<F>(F);
|
||||
impl<F> JvmProbe for StubProbe<F>
|
||||
where
|
||||
F: FnMut(&[String]) -> ProbeOutcome,
|
||||
{
|
||||
fn probe<'a>(
|
||||
&'a mut self,
|
||||
args: &'a [String],
|
||||
) -> Pin<Box<dyn Future<Output = ProbeOutcome> + Send + 'a>> {
|
||||
let outcome = (self.0)(args);
|
||||
Box::pin(async move { outcome })
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_option_keys() {
|
||||
assert_eq!(normalize_option_key("-XX:+UseZGC"), "UseZGC");
|
||||
assert_eq!(
|
||||
normalize_option_key("-XX:G1UncommitBias=1"),
|
||||
"G1UncommitBias"
|
||||
);
|
||||
assert_eq!(normalize_option_key("UseZGC"), "UseZGC");
|
||||
assert_eq!(
|
||||
normalize_option_key("-XX:ShenandoahHeapRegionSize=256M"),
|
||||
"ShenandoahHeapRegionSize"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_option_key("-XX:+UnlockExperimentalVMOptions"),
|
||||
"UnlockExperimentalVMOptions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_only_collector_selectors_as_gc_selectors() {
|
||||
assert!(is_gc_selector("-XX:+UseZGC"));
|
||||
assert!(is_gc_selector("-XX:+UseShenandoahGC"));
|
||||
assert!(is_gc_selector("-XX:+UseG1GC"));
|
||||
assert!(!is_gc_selector("-XX:+ZGenerational"));
|
||||
assert!(!is_gc_selector("-XX:G1UncommitBias=1"));
|
||||
assert!(!is_gc_selector("-XX:+UnlockExperimentalVMOptions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_offending_tuning_flag_and_selector() {
|
||||
let args = vec![
|
||||
"-XX:+UseZGC".to_string(),
|
||||
"-XX:+UnlockExperimentalVMOptions".to_string(),
|
||||
"-XX:+ZGenerational".to_string(),
|
||||
];
|
||||
let text = "Unrecognized VM option 'ZGenerational'";
|
||||
assert_eq!(find_offending_arg(text, &args), Some(2));
|
||||
|
||||
let text = "Unrecognized VM option 'UseZGC'";
|
||||
assert_eq!(find_offending_arg(text, &args), Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preferred_candidate_accepted_untouched() {
|
||||
let intent = GcLaunchIntent {
|
||||
active_preset_id: "gc-auto".to_string(),
|
||||
block_tokens: vec!["@axolotl:gc:auto".to_string()],
|
||||
candidate_ids: vec!["zgc".to_string(), "g1gc-mojang".to_string()],
|
||||
candidates: vec![
|
||||
vec!["-XX:+UseZGC".to_string()],
|
||||
vec!["-XX:+UseG1GC".to_string()],
|
||||
],
|
||||
};
|
||||
let (chosen, report) = select_best_candidate_with_probe(
|
||||
&intent,
|
||||
&mut StubProbe(fake_supported),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(report.chosen_strategy, "zgc");
|
||||
assert_eq!(chosen, vec!["-XX:+UseZGC"]);
|
||||
assert!(!report.fell_back());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prunes_unsupported_tuning_flag_and_keeps_strategy() {
|
||||
let intent = GcLaunchIntent {
|
||||
active_preset_id: "gc-auto".to_string(),
|
||||
block_tokens: vec!["@axolotl:gc:auto".to_string()],
|
||||
candidate_ids: vec!["zgc".to_string()],
|
||||
candidates: vec![vec![
|
||||
"-XX:+UseZGC".to_string(),
|
||||
"-XX:+ZGenerational".to_string(),
|
||||
]],
|
||||
};
|
||||
// A real JVM only rejects `-XX:+ZGenerational` while it is present;
|
||||
// once pruned the remaining set must be accepted.
|
||||
let probe = |args: &[String]| {
|
||||
if args
|
||||
.iter()
|
||||
.any(|a| normalize_option_key(a) == "ZGenerational")
|
||||
{
|
||||
ProbeOutcome {
|
||||
supported: false,
|
||||
stderr_text: "Unrecognized VM option 'ZGenerational'"
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
fake_supported(args)
|
||||
}
|
||||
};
|
||||
let (chosen, report) =
|
||||
select_best_candidate_with_probe(&intent, &mut StubProbe(probe))
|
||||
.await;
|
||||
assert_eq!(report.chosen_strategy, "zgc");
|
||||
assert_eq!(chosen, vec!["-XX:+UseZGC"]);
|
||||
assert_eq!(report.pruned_args, vec!["-XX:+ZGenerational"]);
|
||||
assert!(report.fell_back());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selector_unsupported_falls_back_to_next_candidate() {
|
||||
let intent = GcLaunchIntent {
|
||||
active_preset_id: "gc-auto".to_string(),
|
||||
block_tokens: vec!["@axolotl:gc:auto".to_string()],
|
||||
candidate_ids: vec!["zgc".to_string(), "g1gc-mojang".to_string()],
|
||||
candidates: vec![
|
||||
vec!["-XX:+UseZGC".to_string()],
|
||||
vec!["-XX:+UseG1GC".to_string()],
|
||||
],
|
||||
};
|
||||
let probe = |args: &[String]| {
|
||||
if args.iter().any(|a| a == "-XX:+UseZGC") {
|
||||
ProbeOutcome {
|
||||
supported: false,
|
||||
stderr_text: "Unrecognized VM option 'UseZGC'".to_string(),
|
||||
}
|
||||
} else {
|
||||
fake_supported(args)
|
||||
}
|
||||
};
|
||||
let (chosen, report) =
|
||||
select_best_candidate_with_probe(&intent, &mut StubProbe(probe))
|
||||
.await;
|
||||
assert_eq!(report.chosen_strategy, "g1gc-mojang");
|
||||
assert_eq!(chosen, vec!["-XX:+UseG1GC"]);
|
||||
assert!(report.fell_back());
|
||||
assert!(
|
||||
report
|
||||
.reason_chain
|
||||
.iter()
|
||||
.any(|r| r.contains("not supported by this JVM"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_candidates_fail_falls_back_to_empty_block() {
|
||||
let intent = GcLaunchIntent {
|
||||
active_preset_id: "gc-auto".to_string(),
|
||||
block_tokens: vec!["@axolotl:gc:auto".to_string()],
|
||||
candidate_ids: vec![
|
||||
"g1gc-mojang".to_string(),
|
||||
"minimal-g1".to_string(),
|
||||
],
|
||||
candidates: vec![
|
||||
vec!["-XX:+UseG1GC".to_string()],
|
||||
vec!["-XX:+UseG1GC".to_string()],
|
||||
],
|
||||
};
|
||||
let probe = |_args: &[String]| ProbeOutcome {
|
||||
supported: false,
|
||||
stderr_text: "Unrecognized VM option 'UseG1GC'".to_string(),
|
||||
};
|
||||
let (chosen, report) =
|
||||
select_best_candidate_with_probe(&intent, &mut StubProbe(probe))
|
||||
.await;
|
||||
assert!(chosen.is_empty());
|
||||
assert!(report.chosen_strategy.is_empty());
|
||||
assert!(
|
||||
report
|
||||
.reason_chain
|
||||
.iter()
|
||||
.any(|r| r.contains("JVM default"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_auto_marker_block() {
|
||||
let intent = GcLaunchIntent {
|
||||
active_preset_id: "gc-auto".to_string(),
|
||||
block_tokens: vec!["@axolotl:gc:auto".to_string()],
|
||||
candidate_ids: vec!["g1gc-mojang".to_string()],
|
||||
candidates: vec![vec!["-XX:+UseG1GC".to_string()]],
|
||||
};
|
||||
let mut args =
|
||||
vec!["-Xmx2G".to_string(), "@axolotl:gc:auto".to_string()];
|
||||
replace_gc_block(&mut args, &intent, &["-XX:+UseG1GC".to_string()]);
|
||||
assert_eq!(args, vec!["-Xmx2G", "-XX:+UseG1GC"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_manual_preset_block_tokens() {
|
||||
let intent = GcLaunchIntent {
|
||||
active_preset_id: "gc-shenandoah".to_string(),
|
||||
block_tokens: vec![
|
||||
"-XX:+UseShenandoahGC".to_string(),
|
||||
"-XX:ShenandoahHeapRegionSize=256M".to_string(),
|
||||
],
|
||||
candidate_ids: vec!["shenandoah".to_string()],
|
||||
candidates: vec![vec![
|
||||
"-XX:+UseShenandoahGC".to_string(),
|
||||
"-XX:ShenandoahHeapRegionSize=256M".to_string(),
|
||||
]],
|
||||
};
|
||||
let mut args = vec![
|
||||
"-XX:+UseShenandoahGC".to_string(),
|
||||
"-XX:ShenandoahHeapRegionSize=256M".to_string(),
|
||||
"-Dfoo=bar".to_string(),
|
||||
];
|
||||
let chosen = vec!["-XX:+UseG1GC".to_string()];
|
||||
replace_gc_block(&mut args, &intent, &chosen);
|
||||
assert_eq!(args, vec!["-XX:+UseG1GC", "-Dfoo=bar"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_args_unchanged_when_block_missing() {
|
||||
let intent = GcLaunchIntent {
|
||||
active_preset_id: "gc-shenandoah".to_string(),
|
||||
block_tokens: vec!["-XX:+UseShenandoahGC".to_string()],
|
||||
candidate_ids: vec!["shenandoah".to_string()],
|
||||
candidates: vec![vec!["-XX:+UseShenandoahGC".to_string()]],
|
||||
};
|
||||
let mut args = vec!["-Dfoo=bar".to_string()];
|
||||
replace_gc_block(&mut args, &intent, &["-XX:+UseG1GC".to_string()]);
|
||||
assert_eq!(args, vec!["-Dfoo=bar"]);
|
||||
}
|
||||
}
|
||||
236
packages/app-lib/src/launcher/language.rs
Normal file
236
packages/app-lib/src/launcher/language.rs
Normal file
@ -0,0 +1,236 @@
|
||||
//! Applies the launcher display language to the game's `options.txt`.
|
||||
//!
|
||||
//! Minecraft language codes are version-dependent:
|
||||
//! - 1.0 and earlier have no in-game language option
|
||||
//! - 1.1 through 1.10 expect legacy region casing (e.g. `zh_CN`); lowercase
|
||||
//! codes crash 1.1-1.5 with an NPE and reset 1.6-1.10 to English
|
||||
//! - 16w32a (1.11) and later expect lowercase codes (e.g. `zh_cn`); legacy
|
||||
//! casing resets the language to English
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
|
||||
enum LanguageCodeStyle {
|
||||
Unsupported,
|
||||
LegacyRegionCase,
|
||||
Lowercase,
|
||||
}
|
||||
|
||||
/// Computes the `options.txt` entries needed to keep the game language in
|
||||
/// sync with the launcher language, mirroring the behaviour popularized by
|
||||
/// Plain Craft Launcher.
|
||||
///
|
||||
/// The launcher language is only applied to instances that are effectively
|
||||
/// new: the `lang` key is absent, or the instance has never created a saves
|
||||
/// directory (e.g. modpacks that ship a preconfigured `options.txt`). For
|
||||
/// instances the player already uses, their in-game choice is kept and only
|
||||
/// its casing is normalized for the game version to avoid resets or crashes.
|
||||
pub fn game_language_options(
|
||||
launcher_locale: &str,
|
||||
game_release_time: DateTime<Utc>,
|
||||
options_txt: &str,
|
||||
has_saves: bool,
|
||||
) -> Vec<(String, String)> {
|
||||
let style = match language_code_style(game_release_time) {
|
||||
LanguageCodeStyle::Unsupported => return Vec::new(),
|
||||
style => style,
|
||||
};
|
||||
let legacy_region_case =
|
||||
matches!(style, LanguageCodeStyle::LegacyRegionCase);
|
||||
|
||||
let current = current_language(options_txt);
|
||||
let fresh = current.is_none() || !has_saves;
|
||||
|
||||
let desired = if fresh {
|
||||
normalize_language_code(launcher_locale, legacy_region_case)
|
||||
} else {
|
||||
current
|
||||
.as_deref()
|
||||
.and_then(|code| normalize_language_code(code, legacy_region_case))
|
||||
};
|
||||
let Some(desired) = desired else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut options = Vec::new();
|
||||
if current.as_deref() != Some(desired.as_str()) {
|
||||
options.push(("lang".to_string(), desired));
|
||||
}
|
||||
if fresh && needs_unicode_font(launcher_locale) {
|
||||
options.push(("forceUnicodeFont".to_string(), "true".to_string()));
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn language_code_style(release_time: DateTime<Utc>) -> LanguageCodeStyle {
|
||||
let date = release_time.date_naive();
|
||||
if date < NaiveDate::from_ymd_opt(2011, 11, 18).unwrap() {
|
||||
LanguageCodeStyle::Unsupported
|
||||
} else if date < NaiveDate::from_ymd_opt(2016, 8, 10).unwrap() {
|
||||
LanguageCodeStyle::LegacyRegionCase
|
||||
} else {
|
||||
LanguageCodeStyle::Lowercase
|
||||
}
|
||||
}
|
||||
|
||||
fn current_language(options_txt: &str) -> Option<String> {
|
||||
options_txt.lines().find_map(|line| {
|
||||
line.strip_prefix("lang:")
|
||||
.map(|value| value.trim().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_language_code(
|
||||
code: &str,
|
||||
legacy_region_case: bool,
|
||||
) -> Option<String> {
|
||||
let code = code.trim().replace('-', "_");
|
||||
if code.is_empty() || code.eq_ignore_ascii_case("none") {
|
||||
return None;
|
||||
}
|
||||
|
||||
match code.split_once('_') {
|
||||
Some((language, _)) if language.is_empty() => None,
|
||||
Some((language, region)) if region.is_empty() => {
|
||||
Some(language.to_lowercase())
|
||||
}
|
||||
Some((language, region)) => {
|
||||
let region = if legacy_region_case {
|
||||
region.to_uppercase()
|
||||
} else {
|
||||
region.to_lowercase()
|
||||
};
|
||||
Some(format!("{}_{}", language.to_lowercase(), region))
|
||||
}
|
||||
None => Some(code.to_lowercase()),
|
||||
}
|
||||
}
|
||||
|
||||
/// CJK glyphs are not covered by the game's default bitmap font in older
|
||||
/// versions, so first-time setups for these languages also force the
|
||||
/// unicode font.
|
||||
fn needs_unicode_font(launcher_locale: &str) -> bool {
|
||||
launcher_locale
|
||||
.split(['-', '_'])
|
||||
.next()
|
||||
.is_some_and(|language| {
|
||||
language.eq_ignore_ascii_case("zh")
|
||||
|| language.eq_ignore_ascii_case("ja")
|
||||
|| language.eq_ignore_ascii_case("ko")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn release(y: i32, m: u32, d: u32) -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
const MODERN: (i32, u32, u32) = (2023, 6, 12);
|
||||
const LEGACY: (i32, u32, u32) = (2014, 5, 14);
|
||||
|
||||
fn modern() -> DateTime<Utc> {
|
||||
release(MODERN.0, MODERN.1, MODERN.2)
|
||||
}
|
||||
|
||||
fn legacy() -> DateTime<Utc> {
|
||||
release(LEGACY.0, LEGACY.1, LEGACY.2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_instance_follows_launcher_language() {
|
||||
assert_eq!(
|
||||
game_language_options("zh-CN", modern(), "", false),
|
||||
vec![
|
||||
("lang".to_string(), "zh_cn".to_string()),
|
||||
("forceUnicodeFont".to_string(), "true".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_versions_use_uppercase_region() {
|
||||
assert_eq!(
|
||||
game_language_options("zh-CN", legacy(), "", false),
|
||||
vec![
|
||||
("lang".to_string(), "zh_CN".to_string()),
|
||||
("forceUnicodeFont".to_string(), "true".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_cjk_languages_skip_unicode_font() {
|
||||
assert_eq!(
|
||||
game_language_options("en-US", modern(), "", false),
|
||||
vec![("lang".to_string(), "en_us".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_before_1_1_are_left_alone() {
|
||||
assert_eq!(
|
||||
game_language_options("zh-CN", release(2011, 11, 17), "", false),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn played_instances_keep_the_players_language() {
|
||||
assert_eq!(
|
||||
game_language_options(
|
||||
"zh-CN",
|
||||
modern(),
|
||||
"fullscreen:false\nlang:ja_jp\n",
|
||||
true
|
||||
),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn played_instances_get_their_casing_normalized() {
|
||||
assert_eq!(
|
||||
game_language_options("en-US", modern(), "lang:zh_CN\n", true),
|
||||
vec![("lang".to_string(), "zh_cn".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preconfigured_language_without_saves_is_overridden() {
|
||||
assert_eq!(
|
||||
game_language_options("zh-TW", modern(), "lang:en_us\n", false),
|
||||
vec![
|
||||
("lang".to_string(), "zh_tw".to_string()),
|
||||
("forceUnicodeFont".to_string(), "true".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_language_needs_no_update() {
|
||||
assert_eq!(
|
||||
game_language_options("ja-JP", modern(), "lang:ja_jp\n", true),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_locale_makes_no_changes() {
|
||||
assert_eq!(game_language_options("", modern(), "", false), Vec::new());
|
||||
assert_eq!(
|
||||
game_language_options("", modern(), "lang:zh_cn\n", true),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_options_files_are_parsed() {
|
||||
assert_eq!(
|
||||
game_language_options("ko-KR", modern(), "lang:ko_kr\r\n", true),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
}
|
||||
142
packages/app-lib/src/launcher/local_artifact.rs
Normal file
142
packages/app-lib/src/launcher/local_artifact.rs
Normal file
@ -0,0 +1,142 @@
|
||||
//! Single-pass verification and copy of artifacts from an external runtime.
|
||||
|
||||
use crate::util::fetch::{self, IoSemaphore};
|
||||
use crate::util::io;
|
||||
use std::path::Path;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
pub(crate) async fn candidate_is_usable(
|
||||
local: Option<&super::download::LocalRuntimeSource>,
|
||||
relative_path: &Path,
|
||||
expected_size: Option<u64>,
|
||||
) -> crate::Result<bool> {
|
||||
let Some(local) = local else {
|
||||
return Ok(false);
|
||||
};
|
||||
let candidate = local.root.join(relative_path);
|
||||
let metadata = match tokio::fs::metadata(candidate).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
Ok(metadata.is_file()
|
||||
&& expected_size.is_none_or(|size| metadata.len() == size))
|
||||
}
|
||||
|
||||
/// Copy a local artifact through a temporary sibling while calculating its
|
||||
/// SHA-1 from the exact bytes written. Returns `false` for a missing, wrong-
|
||||
/// sized, or hash-mismatched source so the caller can use the network path.
|
||||
pub(crate) async fn copy_verified(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
expected_sha1: Option<&str>,
|
||||
expected_size: Option<u64>,
|
||||
semaphore: &IoSemaphore,
|
||||
) -> crate::Result<bool> {
|
||||
let _permit = semaphore.0.acquire().await?;
|
||||
let metadata = match tokio::fs::metadata(source).await {
|
||||
Ok(metadata) if metadata.is_file() => metadata,
|
||||
Ok(_) | Err(_) => return Ok(false),
|
||||
};
|
||||
if expected_size.is_some_and(|size| metadata.len() != size) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(expected_sha1) = expected_sha1 else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if let Some(parent) = destination.parent() {
|
||||
io::create_dir_all(parent).await?;
|
||||
}
|
||||
let part_path = fetch::suffixed_path(destination, ".part");
|
||||
let mut input = File::open(source).await?;
|
||||
let mut output = File::create(&part_path).await?;
|
||||
let mut hasher = sha1_smol::Sha1::new();
|
||||
let mut copied = 0_u64;
|
||||
let mut buffer = vec![0_u8; 256 * 1024];
|
||||
let copy_result = async {
|
||||
loop {
|
||||
let read = input.read(&mut buffer).await?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
output.write_all(&buffer[..read]).await?;
|
||||
copied += read as u64;
|
||||
}
|
||||
output.flush().await?;
|
||||
Ok::<_, std::io::Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = copy_result {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
let actual_sha1 = hasher.digest().to_string();
|
||||
if expected_size.is_some_and(|size| copied != size)
|
||||
|| !actual_sha1.eq_ignore_ascii_case(expected_sha1)
|
||||
{
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
return Ok(false);
|
||||
}
|
||||
if let Err(error) = fetch::finalize_download(&part_path, destination).await
|
||||
{
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::fetch::IoSemaphore;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn verified_copy_writes_and_hashes_in_one_pass() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let destination = dir.path().join("nested/destination");
|
||||
tokio::fs::write(&source, b"asset").await.unwrap();
|
||||
let semaphore = IoSemaphore(Semaphore::new(1));
|
||||
|
||||
assert!(
|
||||
copy_verified(
|
||||
&source,
|
||||
&destination,
|
||||
Some("05fac94380a70241f23780e7aef62b190894238f"),
|
||||
Some(5),
|
||||
&semaphore,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(tokio::fs::read(&destination).await.unwrap(), b"asset");
|
||||
assert!(!fetch::suffixed_path(&destination, ".part").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_hash_removes_partial_and_destination() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let destination = dir.path().join("destination");
|
||||
tokio::fs::write(&source, b"asset").await.unwrap();
|
||||
let semaphore = IoSemaphore(Semaphore::new(1));
|
||||
|
||||
assert!(
|
||||
!copy_verified(
|
||||
&source,
|
||||
&destination,
|
||||
Some("00"),
|
||||
Some(5),
|
||||
&semaphore
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!destination.exists());
|
||||
assert!(!fetch::suffixed_path(&destination, ".part").exists());
|
||||
}
|
||||
}
|
||||
1308
packages/app-lib/src/launcher/local_version.rs
Normal file
1308
packages/app-lib/src/launcher/local_version.rs
Normal file
File diff suppressed because it is too large
Load Diff
2987
packages/app-lib/src/launcher/mod.rs
Normal file
2987
packages/app-lib/src/launcher/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
716
packages/app-lib/src/launcher/natives.rs
Normal file
716
packages/app-lib/src/launcher/natives.rs
Normal file
@ -0,0 +1,716 @@
|
||||
use crate::instance::QuickPlayType;
|
||||
use crate::launcher::{download, parse_rules};
|
||||
use crc32fast::Hasher;
|
||||
use daedalus::minecraft::Library;
|
||||
use fs4::tokio::AsyncFileExt;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeArchive {
|
||||
pub library_name: String,
|
||||
pub classifier: String,
|
||||
pub archive_path: PathBuf,
|
||||
pub sha1: Option<String>,
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct NativeEntry {
|
||||
relative_path: PathBuf,
|
||||
uncompressed_size: u64,
|
||||
crc32: u32,
|
||||
archive_path: PathBuf,
|
||||
archive_entry_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct NativePreparationReport {
|
||||
pub verified: usize,
|
||||
pub restored: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_native_archives(
|
||||
libraries_dir: &Path,
|
||||
caches_dir: &Path,
|
||||
libraries: &[Library],
|
||||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<Vec<NativeArchive>> {
|
||||
let mut archives = Vec::new();
|
||||
let mut identities = HashSet::new();
|
||||
|
||||
for library in libraries {
|
||||
if let Some(rules) = &library.rules
|
||||
&& !parse_rules(
|
||||
rules,
|
||||
java_arch,
|
||||
&QuickPlayType::None,
|
||||
minecraft_updated,
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !library.downloadable || !download::is_native_library(library) {
|
||||
continue;
|
||||
}
|
||||
let Some(classifier) =
|
||||
download::library_native_classifier(library, java_arch)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let native = library
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.classifiers.as_ref())
|
||||
.and_then(|classifiers| classifiers.get(&classifier));
|
||||
let classified_path = libraries_dir.join(
|
||||
if let Some(path) = native.and_then(|native| native.path.as_deref())
|
||||
{
|
||||
path.to_owned()
|
||||
} else {
|
||||
download::native_library_artifact_path(library, &classifier)?
|
||||
},
|
||||
);
|
||||
let (archive_path, sha1) = if let Some(native) = native {
|
||||
let cached = caches_dir
|
||||
.join("minecraft-natives")
|
||||
.join(format!("{}.jar", native.sha1));
|
||||
// Modern native archives are content-addressed by SHA1. Do not
|
||||
// silently substitute the Maven artifact: it can be a different
|
||||
// classifier/version and produce an ABI-incompatible DLL set.
|
||||
if cached.is_file() {
|
||||
(cached, Some(native.sha1.clone()))
|
||||
} else if classified_path.is_file()
|
||||
&& (native.sha1.len() != 40
|
||||
|| sha1_file(&classified_path)? == native.sha1)
|
||||
{
|
||||
// Older instances downloaded classifiers directly into
|
||||
// libraries/. Reuse that artifact when it is verifiably the
|
||||
// requested archive (or when the legacy metadata has no
|
||||
// usable SHA-1 to verify), allowing repair/import to recover
|
||||
// without a redundant network download.
|
||||
(classified_path, None)
|
||||
} else {
|
||||
(cached, Some(native.sha1.clone()))
|
||||
}
|
||||
} else {
|
||||
(classified_path, None)
|
||||
};
|
||||
let identity = sha1.clone().unwrap_or_else(|| {
|
||||
archive_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| archive_path.clone())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
if !identities.insert(identity) {
|
||||
tracing::debug!(
|
||||
library = %library.name,
|
||||
classifier,
|
||||
"Skipped duplicate native archive"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
archives.push(NativeArchive {
|
||||
library_name: library.name.clone(),
|
||||
classifier,
|
||||
archive_path,
|
||||
sha1,
|
||||
exclude: library
|
||||
.extract
|
||||
.as_ref()
|
||||
.and_then(|extract| extract.exclude.clone())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(archives)
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_native_libraries(
|
||||
natives_root: &Path,
|
||||
libraries_dir: &Path,
|
||||
caches_dir: &Path,
|
||||
libraries: &[Library],
|
||||
version: &str,
|
||||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<NativePreparationReport> {
|
||||
let archives = resolve_native_archives(
|
||||
libraries_dir,
|
||||
caches_dir,
|
||||
libraries,
|
||||
java_arch,
|
||||
minecraft_updated,
|
||||
)?;
|
||||
materialize_native_directory(&archives, natives_root, version).await
|
||||
}
|
||||
|
||||
pub(crate) async fn materialize_native_directory(
|
||||
archives: &[NativeArchive],
|
||||
natives_root: &Path,
|
||||
version: &str,
|
||||
) -> crate::Result<NativePreparationReport> {
|
||||
let natives_dir = natives_root.join(version);
|
||||
let lock_dir = natives_root.join(".locks");
|
||||
tokio::fs::create_dir_all(&natives_dir).await?;
|
||||
tokio::fs::create_dir_all(&lock_dir).await?;
|
||||
let lock_path = lock_dir.join(format!("{}.lock", safe_version_id(version)));
|
||||
let archives = archives.to_vec();
|
||||
let version = version.to_string();
|
||||
let lock = tokio::fs::File::options()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.await?;
|
||||
let report = tokio::task::spawn_blocking(move || {
|
||||
lock.lock_exclusive().map_err(|error| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Failed to lock native directory for Minecraft {version} at {}: {error}",
|
||||
lock_path.display()
|
||||
))
|
||||
})?;
|
||||
materialize_locked(&archives, &natives_dir, &version)
|
||||
})
|
||||
.await??;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn materialize_locked(
|
||||
archives: &[NativeArchive],
|
||||
natives_dir: &Path,
|
||||
version: &str,
|
||||
) -> crate::Result<NativePreparationReport> {
|
||||
let manifest = build_manifest(archives)?;
|
||||
let mut report = NativePreparationReport::default();
|
||||
|
||||
for entry in manifest.values() {
|
||||
let target = natives_dir.join(&entry.relative_path);
|
||||
if file_matches(&target, entry.uncompressed_size, entry.crc32)? {
|
||||
report.verified += 1;
|
||||
continue;
|
||||
}
|
||||
materialize_entry(entry, &target, version)?;
|
||||
if !file_matches(&target, entry.uncompressed_size, entry.crc32)? {
|
||||
return Err(native_error(format!(
|
||||
"Native entry {} failed final validation after extraction from {}",
|
||||
target.display(),
|
||||
entry.archive_path.display()
|
||||
)));
|
||||
}
|
||||
report.restored.push(entry.relative_path.clone());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
target = %natives_dir.display(),
|
||||
archives = archives.len(),
|
||||
entries = manifest.len(),
|
||||
verified = report.verified,
|
||||
restored = report.restored.len(),
|
||||
"Prepared Minecraft native directory"
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn build_manifest(
|
||||
archives: &[NativeArchive],
|
||||
) -> crate::Result<BTreeMap<PathBuf, NativeEntry>> {
|
||||
let mut manifest = BTreeMap::new();
|
||||
for archive in archives {
|
||||
if !archive.archive_path.is_file() {
|
||||
return Err(native_error(format!(
|
||||
"Native archive for {} ({}) is missing at {}. Repair the instance while online and try again",
|
||||
archive.library_name,
|
||||
archive.classifier,
|
||||
archive.archive_path.display()
|
||||
)));
|
||||
}
|
||||
if let Some(expected_sha1) = archive.sha1.as_deref()
|
||||
&& expected_sha1.len() == 40
|
||||
{
|
||||
let actual_sha1 = sha1_file(&archive.archive_path)?;
|
||||
if actual_sha1 != expected_sha1 {
|
||||
return Err(native_error(format!(
|
||||
"Native archive for {} ({}) at {} has SHA1 {}, expected {}. Repair the instance while online and try again",
|
||||
archive.library_name,
|
||||
archive.classifier,
|
||||
archive.archive_path.display(),
|
||||
actual_sha1,
|
||||
expected_sha1
|
||||
)));
|
||||
}
|
||||
}
|
||||
let file = std::fs::File::open(&archive.archive_path)?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|error| {
|
||||
native_error(format!(
|
||||
"Failed to open native archive for {} ({}) at {}: {error}",
|
||||
archive.library_name,
|
||||
archive.classifier,
|
||||
archive.archive_path.display()
|
||||
))
|
||||
})?;
|
||||
for index in 0..zip.len() {
|
||||
let entry = zip.by_index(index).map_err(|error| {
|
||||
native_error(format!(
|
||||
"Failed to inspect native archive {}: {error}",
|
||||
archive.archive_path.display()
|
||||
))
|
||||
})?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(relative_path) = safe_entry_path(entry.name()) else {
|
||||
tracing::warn!(
|
||||
archive = %archive.archive_path.display(),
|
||||
entry = entry.name(),
|
||||
"Ignored unsafe native archive entry"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let normalized = relative_path.to_string_lossy().replace('\\', "/");
|
||||
if normalized == "META-INF" || normalized.starts_with("META-INF/") {
|
||||
continue;
|
||||
}
|
||||
if archive.exclude.iter().any(|excluded| {
|
||||
let excluded = excluded.replace('\\', "/");
|
||||
normalized == excluded.trim_end_matches('/')
|
||||
|| normalized.starts_with(&format!(
|
||||
"{}/",
|
||||
excluded.trim_end_matches('/')
|
||||
))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if let Some(previous) = manifest.insert(
|
||||
relative_path.clone(),
|
||||
NativeEntry {
|
||||
relative_path,
|
||||
uncompressed_size: entry.size(),
|
||||
crc32: entry.crc32(),
|
||||
archive_path: archive.archive_path.clone(),
|
||||
archive_entry_index: index,
|
||||
},
|
||||
) && (previous.uncompressed_size != entry.size()
|
||||
|| previous.crc32 != entry.crc32())
|
||||
{
|
||||
tracing::debug!(
|
||||
entry = normalized,
|
||||
previous = %previous.archive_path.display(),
|
||||
selected = %archive.archive_path.display(),
|
||||
"Native entry is provided by multiple archives; using metadata order"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn safe_entry_path(name: &str) -> Option<PathBuf> {
|
||||
if name.is_empty()
|
||||
|| name.contains('\\')
|
||||
|| name.starts_with('/')
|
||||
|| name.as_bytes().get(1) == Some(&b':')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(name);
|
||||
if path.is_absolute()
|
||||
|| path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir
|
||||
| Component::RootDir
|
||||
| Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(path.to_path_buf())
|
||||
}
|
||||
|
||||
fn file_matches(path: &Path, size: u64, crc32: u32) -> crate::Result<bool> {
|
||||
let Ok(metadata) = std::fs::symlink_metadata(path) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() != size {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Hasher::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hasher.finalize() == crc32)
|
||||
}
|
||||
|
||||
fn sha1_file(path: &Path) -> crate::Result<String> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = sha1_smol::Sha1::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hasher.digest().to_string())
|
||||
}
|
||||
|
||||
fn materialize_entry(
|
||||
entry: &NativeEntry,
|
||||
target: &Path,
|
||||
version: &str,
|
||||
) -> crate::Result<()> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
native_error(format!(
|
||||
"Native target {} has no parent",
|
||||
target.display()
|
||||
))
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let file = std::fs::File::open(&entry.archive_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
native_error(format!(
|
||||
"Failed to open native archive {}: {error}",
|
||||
entry.archive_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut source =
|
||||
archive
|
||||
.by_index(entry.archive_entry_index)
|
||||
.map_err(|error| {
|
||||
native_error(format!(
|
||||
"Failed to read native entry {} from {}: {error}",
|
||||
entry.relative_path.display(),
|
||||
entry.archive_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut temporary = tempfile::Builder::new()
|
||||
.prefix(&format!(".tmp-native-{}-", safe_version_id(version)))
|
||||
.tempfile_in(parent)?;
|
||||
let mut hasher = Hasher::new();
|
||||
let mut written = 0_u64;
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = source.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
temporary.write_all(&buffer[..read])?;
|
||||
hasher.update(&buffer[..read]);
|
||||
written += read as u64;
|
||||
}
|
||||
if written != entry.uncompressed_size || hasher.finalize() != entry.crc32 {
|
||||
return Err(native_error(format!(
|
||||
"Native entry {} extracted from {} did not match its ZIP size and CRC32",
|
||||
entry.relative_path.display(),
|
||||
entry.archive_path.display()
|
||||
)));
|
||||
}
|
||||
temporary.flush()?;
|
||||
temporary.as_file().sync_all()?;
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(target) {
|
||||
if metadata.file_type().is_dir() {
|
||||
std::fs::remove_dir_all(target)?;
|
||||
} else if !metadata.file_type().is_file() {
|
||||
std::fs::remove_file(target)?;
|
||||
}
|
||||
}
|
||||
temporary.persist(target).map_err(|error| {
|
||||
native_error(format!(
|
||||
"Failed to atomically replace native entry {}: {}",
|
||||
target.display(),
|
||||
error.error
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_version_id(version: &str) -> String {
|
||||
let sanitized: String = version
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric()
|
||||
|| matches!(character, '.' | '-' | '_')
|
||||
{
|
||||
character
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if sanitized.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn native_error(message: String) -> crate::Error {
|
||||
crate::ErrorKind::LauncherError(message).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_archive(path: &Path, entries: &[(&str, &[u8])]) {
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, contents) in entries {
|
||||
writer.start_file(*name, options).unwrap();
|
||||
writer.write_all(contents).unwrap();
|
||||
}
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
|
||||
fn archive(path: PathBuf) -> NativeArchive {
|
||||
NativeArchive {
|
||||
library_name: "org.lwjgl:lwjgl:3.2.2".to_string(),
|
||||
classifier: "natives-windows".to_string(),
|
||||
archive_path: path,
|
||||
sha1: None,
|
||||
exclude: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repairs_missing_truncated_same_size_and_directory_entries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let archive_path = root.path().join("natives.jar");
|
||||
write_archive(
|
||||
&archive_path,
|
||||
&[
|
||||
("missing.dll", b"missing"),
|
||||
("bad.dll", b"correct"),
|
||||
("folder.dll", b"file"),
|
||||
],
|
||||
);
|
||||
let target = root.path().join("target");
|
||||
std::fs::create_dir_all(target.join("version/folder.dll")).unwrap();
|
||||
std::fs::write(target.join("version/bad.dll"), b"xxxxxxx").unwrap();
|
||||
|
||||
let report = materialize_native_directory(
|
||||
&[archive(archive_path)],
|
||||
&target,
|
||||
"version",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.restored.len(), 3);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("version/missing.dll")).unwrap(),
|
||||
b"missing"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("version/bad.dll")).unwrap(),
|
||||
b"correct"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("version/folder.dll")).unwrap(),
|
||||
b"file"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preparation_is_idempotent_and_leaves_no_temporary_files() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let archive_path = root.path().join("natives.jar");
|
||||
write_archive(&archive_path, &[("lwjgl.dll", b"native")]);
|
||||
let archives = [archive(archive_path)];
|
||||
let first =
|
||||
materialize_native_directory(&archives, root.path(), "1.18.2")
|
||||
.await
|
||||
.unwrap();
|
||||
let second =
|
||||
materialize_native_directory(&archives, root.path(), "1.18.2")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.restored, [PathBuf::from("lwjgl.dll")]);
|
||||
assert_eq!(second.verified, 1);
|
||||
assert!(second.restored.is_empty());
|
||||
assert!(
|
||||
std::fs::read_dir(root.path().join("1.18.2"))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.all(|entry| !entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".tmp-native-"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modern_native_resolution_does_not_fallback_to_unverified_maven_artifact()
|
||||
{
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let libraries_dir = root.path().join("libraries");
|
||||
let caches_dir = root.path().join("caches");
|
||||
let classified = libraries_dir
|
||||
.join("org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar");
|
||||
std::fs::create_dir_all(classified.parent().unwrap()).unwrap();
|
||||
write_archive(&classified, &[("lwjgl.dll", b"wrong")]);
|
||||
let library: Library = serde_json::from_value(serde_json::json!({
|
||||
"name": "org.lwjgl:lwjgl:3.2.2",
|
||||
"natives": {"windows": "natives-windows"},
|
||||
"downloads": {"classifiers": {"natives-windows": {
|
||||
"sha1": "05359f3aa50d36352815fc662ea73e1c00d22170",
|
||||
"size": 279593,
|
||||
"url": "https://libraries.minecraft.net/native.jar"
|
||||
}}}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let archives = resolve_native_archives(
|
||||
&libraries_dir,
|
||||
&caches_dir,
|
||||
&[library],
|
||||
"x86_64",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(archives.len(), 1);
|
||||
assert_eq!(
|
||||
archives[0].archive_path,
|
||||
caches_dir.join(
|
||||
"minecraft-natives/05359f3aa50d36352815fc662ea73e1c00d22170.jar"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_native_library_is_selected_for_preparation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let library: Library = serde_json::from_value(serde_json::json!({
|
||||
"name": "org.lwjgl:lwjgl:3.2.2:natives-windows",
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"sha1": "abc",
|
||||
"size": 1,
|
||||
"url": "https://example.com/native.jar"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let archives = resolve_native_archives(
|
||||
&root.path().join("libraries"),
|
||||
&root.path().join("caches"),
|
||||
&[library],
|
||||
"x86_64",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(archives.len(), 1);
|
||||
assert_eq!(archives[0].classifier, "natives-windows");
|
||||
assert_eq!(
|
||||
archives[0].archive_path,
|
||||
root.path().join(
|
||||
"libraries/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_modern_archives_with_the_wrong_sha1() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let archive_path = root.path().join("natives.jar");
|
||||
write_archive(&archive_path, &[("lwjgl.dll", b"native")]);
|
||||
let mut archive = archive(archive_path);
|
||||
archive.sha1 =
|
||||
Some("0000000000000000000000000000000000000000".to_string());
|
||||
|
||||
let error = materialize_native_directory(
|
||||
&[archive],
|
||||
root.path(),
|
||||
"1.18.2-forge-40.2.34",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("has SHA1"));
|
||||
assert!(!root.path().join("1.18.2-forge-40.2.34/lwjgl.dll").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_preparation_is_serialized() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let archive_path = root.path().join("natives.jar");
|
||||
write_archive(&archive_path, &[("lwjgl.dll", b"native")]);
|
||||
let archives = vec![archive(archive_path)];
|
||||
let root_path = root.path().to_path_buf();
|
||||
let (left, right) = tokio::join!(
|
||||
materialize_native_directory(&archives, &root_path, "1.18.2"),
|
||||
materialize_native_directory(&archives, &root_path, "1.18.2"),
|
||||
);
|
||||
|
||||
let reports = [left.unwrap(), right.unwrap()];
|
||||
assert_eq!(
|
||||
reports
|
||||
.iter()
|
||||
.map(|report| report.restored.len())
|
||||
.sum::<usize>(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
reports.iter().map(|report| report.verified).sum::<usize>(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsafe_meta_inf_and_duplicate_entries_are_deterministic() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = root.path().join("first.jar");
|
||||
let second = root.path().join("second.jar");
|
||||
write_archive(
|
||||
&first,
|
||||
&[
|
||||
("same.dll", b"first"),
|
||||
("META-INF/MANIFEST.MF", b"meta"),
|
||||
("excluded/skip.dll", b"skip"),
|
||||
],
|
||||
);
|
||||
write_archive(
|
||||
&second,
|
||||
&[
|
||||
("../evil.dll", b"evil"),
|
||||
("C:/evil.dll", b"evil"),
|
||||
("same.dll", b"later"),
|
||||
],
|
||||
);
|
||||
|
||||
let mut first = archive(first);
|
||||
first.exclude = vec!["excluded/".to_string()];
|
||||
materialize_native_directory(
|
||||
&[first, archive(second)],
|
||||
root.path(),
|
||||
"version",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("version/same.dll")).unwrap(),
|
||||
b"later"
|
||||
);
|
||||
assert!(!root.path().join("evil.dll").exists());
|
||||
assert!(!root.path().join("version/META-INF").exists());
|
||||
assert!(!root.path().join("version/excluded").exists());
|
||||
}
|
||||
}
|
||||
472
packages/app-lib/src/launcher/optifine.rs
Normal file
472
packages/app-lib/src/launcher/optifine.rs
Normal file
@ -0,0 +1,472 @@
|
||||
//! OptiFine loader support.
|
||||
//!
|
||||
//! OptiFine has no official metadata API, so available versions and installer
|
||||
//! downloads are resolved through BMCLAPI. Standalone OptiFine instances are
|
||||
//! launched through LaunchWrapper with `optifine.OptiFineTweaker`, mirroring
|
||||
//! the approach used by HMCL and PCL.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use daedalus::minecraft::{
|
||||
Argument, ArgumentType, Library, VersionInfo as GameVersionInfo,
|
||||
};
|
||||
use daedalus::modded::{LoaderVersion, PartialVersionInfo};
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::State;
|
||||
use crate::util::fetch::{
|
||||
ContentValidation, DownloadRequest, Integrity, ResourceClass,
|
||||
download_to_path, fetch_json,
|
||||
};
|
||||
use crate::util::io;
|
||||
|
||||
const BMCLAPI_OPTIFINE_BASE: &str = "https://bmclapi2.bangbang93.com/optifine";
|
||||
pub const OPTIFINE_LOADER_PREFIX: &str = "OptiFine_";
|
||||
const OPTIFINE_TWEAK_CLASS: &str = "optifine.OptiFineTweaker";
|
||||
const LAUNCH_WRAPPER_MAIN_CLASS: &str = "net.minecraft.launchwrapper.Launch";
|
||||
const FALLBACK_LAUNCH_WRAPPER: &str = "net.minecraft:launchwrapper:1.12";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct BmclapiOptifineEntry {
|
||||
mcversion: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
patch: String,
|
||||
}
|
||||
|
||||
impl BmclapiOptifineEntry {
|
||||
fn version_id(&self) -> String {
|
||||
format!("{}_{}", self.type_, self.patch)
|
||||
}
|
||||
|
||||
fn download_url(&self) -> String {
|
||||
format!(
|
||||
"{BMCLAPI_OPTIFINE_BASE}/{}/{}/{}",
|
||||
self.mcversion, self.type_, self.patch
|
||||
)
|
||||
}
|
||||
|
||||
fn is_stable(&self) -> bool {
|
||||
!self.patch.to_ascii_lowercase().contains("pre")
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_entries(
|
||||
game_version: &str,
|
||||
) -> crate::Result<Vec<BmclapiOptifineEntry>> {
|
||||
let state = State::get().await?;
|
||||
let entries: Vec<BmclapiOptifineEntry> = fetch_json(
|
||||
Method::GET,
|
||||
&format!("{BMCLAPI_OPTIFINE_BASE}/{game_version}"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.mcversion == game_version)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Lists installable OptiFine versions for a game version as loader versions,
|
||||
/// ordered oldest to newest as reported by BMCLAPI.
|
||||
pub async fn list_loader_versions(
|
||||
game_version: &str,
|
||||
) -> crate::Result<Vec<LoaderVersion>> {
|
||||
Ok(list_entries(game_version)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|entry| LoaderVersion {
|
||||
id: format!("{OPTIFINE_LOADER_PREFIX}{}", entry.version_id()),
|
||||
url: entry.download_url(),
|
||||
stable: entry.is_stable(),
|
||||
profile_source: Default::default(),
|
||||
fallback_url: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Strips launcher-specific prefixes so pack-provided OptiFine version strings
|
||||
/// like `OptiFine_1.12.2_HD_U_G5`, `1.12.2_HD_U_G5`, or `HD_U_G5` all resolve
|
||||
/// to the same BMCLAPI entry.
|
||||
fn normalize_version_id(game_version: &str, requested: &str) -> String {
|
||||
let mut value = requested.trim();
|
||||
if let Some(stripped) = value.strip_prefix(OPTIFINE_LOADER_PREFIX) {
|
||||
value = stripped;
|
||||
}
|
||||
if let Some(stripped) = value.strip_prefix(game_version)
|
||||
&& let Some(stripped) = stripped.strip_prefix('_')
|
||||
{
|
||||
value = stripped;
|
||||
}
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
pub async fn resolve_loader_version(
|
||||
game_version: &str,
|
||||
requested: Option<&str>,
|
||||
) -> crate::Result<Option<LoaderVersion>> {
|
||||
let resolved = match requested.unwrap_or("latest") {
|
||||
"latest" => list_loader_versions(game_version).await?.pop(),
|
||||
"stable" => {
|
||||
let versions = list_loader_versions(game_version).await?;
|
||||
versions
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|version| version.stable)
|
||||
.or(versions.last())
|
||||
.cloned()
|
||||
}
|
||||
// A pinned version resolves without the network so offline launches of
|
||||
// installed instances keep working; the installer download resolves
|
||||
// the actual BMCLAPI URL itself when needed.
|
||||
id => {
|
||||
let of_id = normalize_version_id(game_version, id);
|
||||
Some(LoaderVersion {
|
||||
id: format!("{OPTIFINE_LOADER_PREFIX}{of_id}"),
|
||||
url: String::new(),
|
||||
stable: !of_id.to_ascii_lowercase().contains("pre"),
|
||||
profile_source: Default::default(),
|
||||
fallback_url: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn optifine_library_name(game_version: &str, of_id: &str) -> String {
|
||||
format!("optifine:OptiFine:{game_version}_{of_id}")
|
||||
}
|
||||
|
||||
fn library(name: String, downloadable: bool) -> Library {
|
||||
Library {
|
||||
downloads: None,
|
||||
extract: None,
|
||||
name,
|
||||
url: None,
|
||||
natives: None,
|
||||
rules: None,
|
||||
checksums: None,
|
||||
include_in_classpath: true,
|
||||
downloadable,
|
||||
}
|
||||
}
|
||||
|
||||
struct InstallerInfo {
|
||||
launchwrapper_library: Library,
|
||||
launchwrapper_entry: Option<String>,
|
||||
needs_patching: bool,
|
||||
}
|
||||
|
||||
fn inspect_installer(path: &Path) -> crate::Result<InstallerInfo> {
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|error| io::IOError::with_path(error, path))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"OptiFine installer archive is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut launchwrapper_version = None;
|
||||
if let Ok(mut entry) = archive.by_name("launchwrapper-of.txt") {
|
||||
let mut version = String::new();
|
||||
entry.read_to_string(&mut version)?;
|
||||
let version = version.trim().to_string();
|
||||
if !version.is_empty() {
|
||||
launchwrapper_version = Some(version);
|
||||
}
|
||||
}
|
||||
if launchwrapper_version.is_none() {
|
||||
launchwrapper_version = archive.file_names().find_map(|name| {
|
||||
name.strip_prefix("launchwrapper-of-")
|
||||
.and_then(|rest| rest.strip_suffix(".jar"))
|
||||
.map(str::to_string)
|
||||
});
|
||||
}
|
||||
|
||||
let needs_patching = archive.by_name("optifine/Patcher.class").is_ok();
|
||||
|
||||
let (launchwrapper_library, launchwrapper_entry) =
|
||||
match launchwrapper_version {
|
||||
Some(version) => (
|
||||
library(format!("optifine:launchwrapper-of:{version}"), false),
|
||||
Some(format!("launchwrapper-of-{version}.jar")),
|
||||
),
|
||||
// Very old OptiFine builds ship without their own LaunchWrapper;
|
||||
// Mojang's launchwrapper 1.12 from libraries.minecraft.net works.
|
||||
None => (library(FALLBACK_LAUNCH_WRAPPER.to_string(), true), None),
|
||||
};
|
||||
|
||||
Ok(InstallerInfo {
|
||||
launchwrapper_library,
|
||||
launchwrapper_entry,
|
||||
needs_patching,
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_installer(
|
||||
state: &State,
|
||||
game_version: &str,
|
||||
of_id: &str,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let path = state
|
||||
.directories
|
||||
.caches_dir()
|
||||
.join("optifine")
|
||||
.join(game_version)
|
||||
.join(format!("{OPTIFINE_LOADER_PREFIX}{of_id}.jar"));
|
||||
if path.is_file() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let entries = list_entries(game_version).await?;
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|entry| entry.version_id() == of_id)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"OptiFine {of_id} is not available for Minecraft {game_version}"
|
||||
))
|
||||
})?;
|
||||
|
||||
download_to_path(
|
||||
DownloadRequest::new(entry.download_url(), ResourceClass::Loader)
|
||||
.with_integrity(Integrity {
|
||||
content: ContentValidation::Jar,
|
||||
..Integrity::default()
|
||||
}),
|
||||
&path,
|
||||
&state.download_semaphore,
|
||||
&state.pool,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn strip_loader_prefix(loader_version_id: &str) -> &str {
|
||||
loader_version_id
|
||||
.strip_prefix(OPTIFINE_LOADER_PREFIX)
|
||||
.unwrap_or(loader_version_id)
|
||||
}
|
||||
|
||||
/// Builds the loader profile for a standalone OptiFine version locally, taking
|
||||
/// the place of the Daedalus partial version metadata used by other loaders.
|
||||
pub(crate) async fn build_partial_version_info(
|
||||
state: &State,
|
||||
vanilla: &GameVersionInfo,
|
||||
game_version: &str,
|
||||
loader_version_id: &str,
|
||||
) -> crate::Result<PartialVersionInfo> {
|
||||
let of_id = strip_loader_prefix(loader_version_id).to_string();
|
||||
let installer = ensure_installer(state, game_version, &of_id).await?;
|
||||
let installer_for_inspect = installer.clone();
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
inspect_installer(&installer_for_inspect)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let libraries = vec![
|
||||
library(optifine_library_name(game_version, &of_id), false),
|
||||
info.launchwrapper_library,
|
||||
];
|
||||
|
||||
let mut minecraft_arguments = None;
|
||||
let mut arguments = None;
|
||||
if let Some(vanilla_arguments) = &vanilla.minecraft_arguments {
|
||||
minecraft_arguments = Some(format!(
|
||||
"{vanilla_arguments} --tweakClass {OPTIFINE_TWEAK_CLASS}"
|
||||
));
|
||||
} else {
|
||||
arguments = Some(HashMap::from([(
|
||||
ArgumentType::Game,
|
||||
vec![
|
||||
Argument::Normal("--tweakClass".to_string()),
|
||||
Argument::Normal(OPTIFINE_TWEAK_CLASS.to_string()),
|
||||
],
|
||||
)]));
|
||||
}
|
||||
|
||||
Ok(PartialVersionInfo {
|
||||
id: format!("{game_version}-{loader_version_id}"),
|
||||
inherits_from: game_version.to_string(),
|
||||
release_time: vanilla.release_time,
|
||||
time: vanilla.time,
|
||||
main_class: Some(LAUNCH_WRAPPER_MAIN_CLASS.to_string()),
|
||||
minecraft_arguments,
|
||||
arguments,
|
||||
libraries,
|
||||
java_version: None,
|
||||
type_: vanilla.type_.clone(),
|
||||
data: None,
|
||||
processors: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_installer_entry(
|
||||
installer: &Path,
|
||||
entry_name: &str,
|
||||
target: &Path,
|
||||
) -> crate::Result<()> {
|
||||
let file = std::fs::File::open(installer)
|
||||
.map_err(|error| io::IOError::with_path(error, installer))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"OptiFine installer archive is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut entry = archive.by_name(entry_name).map_err(|_| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"OptiFine installer is missing {entry_name}"
|
||||
))
|
||||
})?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Materializes the OptiFine libraries after the vanilla client jar has been
|
||||
/// downloaded: extracts the bundled LaunchWrapper and produces the OptiFine
|
||||
/// library jar, running the installer's patcher against the client jar when
|
||||
/// the installer only ships patch data.
|
||||
pub(crate) async fn install_optifine_libraries(
|
||||
state: &State,
|
||||
java_path: &Path,
|
||||
game_version: &str,
|
||||
loader_version_id: &str,
|
||||
client_jar_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
let of_id = strip_loader_prefix(loader_version_id).to_string();
|
||||
let installer = ensure_installer(state, game_version, &of_id).await?;
|
||||
let installer_for_inspect = installer.clone();
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
inspect_installer(&installer_for_inspect)
|
||||
})
|
||||
.await??;
|
||||
let libraries_dir = state.directories.libraries_dir();
|
||||
|
||||
if let Some(entry_name) = info.launchwrapper_entry {
|
||||
let target = libraries_dir.join(daedalus::get_path_from_artifact(
|
||||
&info.launchwrapper_library.name,
|
||||
)?);
|
||||
if !target.exists() {
|
||||
let installer = installer.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
extract_installer_entry(&installer, &entry_name, &target)
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
}
|
||||
|
||||
let optifine_target = libraries_dir.join(daedalus::get_path_from_artifact(
|
||||
&optifine_library_name(game_version, &of_id),
|
||||
)?);
|
||||
if optifine_target.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = optifine_target.parent() {
|
||||
io::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
if info.needs_patching {
|
||||
let mut command = Command::new(java_path);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.arg("-cp")
|
||||
.arg(&installer)
|
||||
.arg("optifine.Patcher")
|
||||
.arg(client_jar_path)
|
||||
.arg(&installer)
|
||||
.arg(&optifine_target);
|
||||
let output = command.output().await.map_err(|error| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Error running OptiFine patcher: {error}"
|
||||
))
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(crate::ErrorKind::LauncherError(format!(
|
||||
"OptiFine patcher error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
} else {
|
||||
io::copy(&installer, &optifine_target).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads the OptiFine jar usable as a Forge/NeoForge mod into the given
|
||||
/// directory, patching it against the client jar when required. Returns the
|
||||
/// target file path.
|
||||
pub async fn install_optifine_as_mod(
|
||||
state: &State,
|
||||
instance_id: &str,
|
||||
cancellation: tokio_util::sync::CancellationToken,
|
||||
java_path: &Path,
|
||||
game_version: &str,
|
||||
requested_version: &str,
|
||||
client_jar_path: &Path,
|
||||
mods_dir: &Path,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let of_id = normalize_version_id(game_version, requested_version);
|
||||
let installer = ensure_installer(state, game_version, &of_id).await?;
|
||||
let installer_for_inspect = installer.clone();
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
inspect_installer(&installer_for_inspect)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let target = mods_dir.join(format!(
|
||||
"{OPTIFINE_LOADER_PREFIX}{game_version}_{of_id}.jar"
|
||||
));
|
||||
io::create_dir_all(mods_dir).await?;
|
||||
|
||||
if info.needs_patching {
|
||||
let mut command = Command::new(java_path);
|
||||
command
|
||||
.arg("-cp")
|
||||
.arg(&installer)
|
||||
.arg("optifine.Patcher")
|
||||
.arg(client_jar_path)
|
||||
.arg(&installer)
|
||||
.arg(&target);
|
||||
let output = super::run_instance_install_command(
|
||||
instance_id.to_string(),
|
||||
cancellation,
|
||||
command,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Error running OptiFine patcher: {error}"
|
||||
))
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(crate::ErrorKind::LauncherError(format!(
|
||||
"OptiFine patcher error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
} else {
|
||||
io::copy(&installer, &target).await?;
|
||||
}
|
||||
|
||||
Ok(target)
|
||||
}
|
||||
102
packages/app-lib/src/launcher/quick_play_version.rs
Normal file
102
packages/app-lib/src/launcher/quick_play_version.rs
Normal file
@ -0,0 +1,102 @@
|
||||
use daedalus::minecraft::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// If modified, also update QuickPlayServerVersion.java
|
||||
#[derive(
|
||||
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum QuickPlayServerVersion {
|
||||
Builtin,
|
||||
BuiltinLegacy,
|
||||
Injected,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl QuickPlayServerVersion {
|
||||
pub fn min_version(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Builtin => Some("23w14a"),
|
||||
Self::BuiltinLegacy => Some("13w17a"),
|
||||
Self::Injected => Some("a1.0.5_01"),
|
||||
Self::Unsupported => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn older_version(&self) -> Option<Self> {
|
||||
match self {
|
||||
Self::Builtin => Some(Self::BuiltinLegacy),
|
||||
Self::BuiltinLegacy => Some(Self::Injected),
|
||||
Self::Injected => Some(Self::Unsupported),
|
||||
Self::Unsupported => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If modified, also update QuickPlaySingleplayerVersion.java
|
||||
#[derive(
|
||||
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum QuickPlaySingleplayerVersion {
|
||||
Builtin,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl QuickPlaySingleplayerVersion {
|
||||
pub fn min_version(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Builtin => Some("23w14a"),
|
||||
Self::Unsupported => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn older_version(&self) -> Option<Self> {
|
||||
match self {
|
||||
Self::Builtin => Some(Self::Unsupported),
|
||||
Self::Unsupported => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub struct QuickPlayVersion {
|
||||
pub server: QuickPlayServerVersion,
|
||||
pub singleplayer: QuickPlaySingleplayerVersion,
|
||||
}
|
||||
|
||||
impl QuickPlayVersion {
|
||||
pub fn find_version(version_index: usize, versions: &[Version]) -> Self {
|
||||
let mut server = QuickPlayServerVersion::Builtin;
|
||||
let mut server_version = server.min_version();
|
||||
|
||||
let mut singleplayer = QuickPlaySingleplayerVersion::Builtin;
|
||||
let mut singleplayer_version = singleplayer.min_version();
|
||||
|
||||
for version in versions.iter().take(version_index) {
|
||||
if let Some(check_version) = server_version
|
||||
&& version.id == check_version
|
||||
{
|
||||
// Safety: older_version will always be Some when min_version is Some
|
||||
server = server.older_version().unwrap();
|
||||
server_version = server.min_version();
|
||||
}
|
||||
|
||||
if let Some(check_version) = singleplayer_version
|
||||
&& version.id == check_version
|
||||
{
|
||||
singleplayer = singleplayer.older_version().unwrap();
|
||||
singleplayer_version = singleplayer.min_version();
|
||||
}
|
||||
|
||||
if server_version.is_none() && singleplayer_version.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
server,
|
||||
singleplayer,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user