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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,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,
)
}
}

View 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(&current).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, &current) {
Some(idx) if !is_gc_selector(&current[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"]);
}
}

View 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()
);
}
}

View 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());
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,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());
}
}

View 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)
}

View 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,
}
}
}