3009 lines
99 KiB
Rust
3009 lines
99 KiB
Rust
//! Logic for launching Minecraft
|
|
use crate::api::pack::import::direct_link::direct_link_group;
|
|
use crate::data::ModLoader;
|
|
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
|
use crate::event::{InstancePayloadType, LoadingBarType};
|
|
use crate::install::{
|
|
InstallJavaStep, InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
|
InstallProgressReporter,
|
|
};
|
|
use crate::instance::QuickPlayType;
|
|
pub use crate::launcher::direct_link::ExternalGameDirMode;
|
|
pub(crate) use crate::launcher::direct_link::{
|
|
DirectLinkedLaunch, LinkedLauncherDialect, apply_hmcl_settings,
|
|
conservative_launch_facts, external_version_dir_for_game_override,
|
|
extract_linked_natives, hmcl_java_candidates, hmcl_with_global_fallback,
|
|
merged_to_version_info, normalize_merged_loader_libraries,
|
|
pcl_available_memory_gb, pcl_ram_profile,
|
|
};
|
|
use crate::launcher::download::{LocalRuntimeSource, download_log_config};
|
|
use crate::launcher::instance_runtime::InstanceRuntimeAdapter;
|
|
use crate::launcher::quick_play_version::{
|
|
QuickPlayServerVersion, QuickPlayVersion,
|
|
};
|
|
use crate::server_address::{ServerAddress, parse_server_address};
|
|
use crate::state::server_join_log::JoinLogEntry;
|
|
use crate::state::{
|
|
CacheBehaviour, Credentials, Instance, InstanceInstallStage,
|
|
InstanceLaunchContext, InstanceLink, JavaVersion, MemorySettings,
|
|
ProcessMetadata, WindowSize,
|
|
};
|
|
use crate::util::io;
|
|
use crate::util::rpc::RpcServerBuilder;
|
|
use crate::{State, get_resource_file, process};
|
|
use base64::Engine;
|
|
use base64::prelude::BASE64_STANDARD;
|
|
use chrono::Utc;
|
|
use daedalus as d;
|
|
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
|
use daedalus::modded::{LoaderVersion, Manifest};
|
|
use regex::Regex;
|
|
use serde::Deserialize;
|
|
use std::fmt::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Output, Stdio};
|
|
use std::time::Instant;
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::process::Command;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
|
|
|
|
mod args;
|
|
mod direct_ensure;
|
|
mod direct_link;
|
|
pub(crate) mod instance_runtime;
|
|
mod local_artifact;
|
|
mod natives;
|
|
|
|
pub mod download;
|
|
pub mod jvm_args;
|
|
pub mod language;
|
|
pub mod local_version;
|
|
pub mod optifine;
|
|
pub mod quick_play_version;
|
|
|
|
const UTF8_GAME_ARGUMENT_PREFIX: &str = "__THESEUS_UTF8__:";
|
|
|
|
fn encode_game_argument(argument: String) -> String {
|
|
if argument.is_ascii() && !argument.starts_with(UTF8_GAME_ARGUMENT_PREFIX) {
|
|
argument
|
|
} else {
|
|
format!(
|
|
"{UTF8_GAME_ARGUMENT_PREFIX}{}",
|
|
BASE64_STANDARD.encode(argument.as_bytes())
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn set_high_performance_gpu_preference(
|
|
executable: impl AsRef<std::path::Path>,
|
|
) -> crate::Result<()> {
|
|
const REGISTRY_PATH: &str =
|
|
"Software\\Microsoft\\DirectX\\UserGpuPreferences";
|
|
const REGISTRY_VALUE: &str = "GpuPreference=2;";
|
|
|
|
let current_user = RegKey::predef(HKEY_CURRENT_USER);
|
|
let (key, _) = current_user.create_subkey(REGISTRY_PATH)?;
|
|
let executable = executable.as_ref().to_string_lossy();
|
|
|
|
if key
|
|
.get_value::<String, _>(executable.as_ref())
|
|
.ok()
|
|
.as_deref()
|
|
== Some(REGISTRY_VALUE)
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
key.set_value(executable.as_ref(), ®ISTRY_VALUE)?;
|
|
tracing::info!(%executable, "Set high-performance GPU preference");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
async fn high_performance_gpu_environment() -> Vec<(String, String)> {
|
|
match switcheroo_gpu_environment().await {
|
|
Ok(Some(environment)) => {
|
|
tracing::info!(
|
|
"Using switcheroo-control environment for high-performance GPU"
|
|
);
|
|
environment
|
|
}
|
|
Ok(None) => fallback_high_performance_gpu_environment(),
|
|
Err(error) => {
|
|
tracing::debug!(
|
|
%error,
|
|
"Failed to query switcheroo-control; using PRIME fallback"
|
|
);
|
|
fallback_high_performance_gpu_environment()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
async fn switcheroo_gpu_environment()
|
|
-> crate::Result<Option<Vec<(String, String)>>> {
|
|
use std::collections::HashMap;
|
|
use zbus::zvariant::OwnedValue;
|
|
use zbus::{Connection, Proxy};
|
|
|
|
let connection = Connection::system().await?;
|
|
let proxy = Proxy::new(
|
|
&connection,
|
|
"net.hadess.SwitcherooControl",
|
|
"/net/hadess/SwitcherooControl",
|
|
"net.hadess.SwitcherooControl",
|
|
)
|
|
.await?;
|
|
let gpus: Vec<HashMap<String, OwnedValue>> =
|
|
proxy.get_property("GPUs").await?;
|
|
|
|
for mut gpu in gpus {
|
|
let is_default = gpu
|
|
.get("Default")
|
|
.and_then(|value| bool::try_from(value).ok());
|
|
if is_default != Some(false) {
|
|
continue;
|
|
}
|
|
|
|
let Some(environment) = gpu
|
|
.remove("Environment")
|
|
.and_then(|value| Vec::<String>::try_from(value).ok())
|
|
else {
|
|
continue;
|
|
};
|
|
let mut entries = environment.chunks_exact(2);
|
|
let parsed = entries
|
|
.by_ref()
|
|
.filter(|entry| !entry[0].is_empty())
|
|
.map(|entry| (entry[0].clone(), entry[1].clone()))
|
|
.collect::<Vec<_>>();
|
|
if entries.remainder().is_empty() && !parsed.is_empty() {
|
|
return Ok(Some(parsed));
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn fallback_high_performance_gpu_environment() -> Vec<(String, String)> {
|
|
[
|
|
("DRI_PRIME", "1"),
|
|
("__NV_PRIME_RENDER_OFFLOAD", "1"),
|
|
("__VK_LAYER_NV_optimus", "NVIDIA_only"),
|
|
("__GLX_VENDOR_LIBRARY_NAME", "nvidia"),
|
|
]
|
|
.into_iter()
|
|
.map(|(key, value)| (key.to_string(), value.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
/// Mojang/HMCL ordered rule semantics: an empty list allows, a non-empty list
|
|
/// starts disallowed, and each matching rule replaces the previous decision.
|
|
#[tracing::instrument]
|
|
pub fn parse_rules(
|
|
rules: &[d::minecraft::Rule],
|
|
java_version: &str,
|
|
quick_play_type: &QuickPlayType,
|
|
minecraft_updated: bool,
|
|
) -> bool {
|
|
if rules.is_empty() {
|
|
return true;
|
|
}
|
|
|
|
rules.iter().fold(false, |allowed, rule| {
|
|
parse_rule(rule, java_version, quick_play_type, minecraft_updated)
|
|
.unwrap_or(allowed)
|
|
})
|
|
}
|
|
|
|
/// Returns this rule's action only when every declared OS and feature
|
|
/// restriction matches. OS and features on the same rule are conjunctive.
|
|
#[tracing::instrument]
|
|
pub fn parse_rule(
|
|
rule: &d::minecraft::Rule,
|
|
java_version: &str,
|
|
quick_play_type: &QuickPlayType,
|
|
minecraft_updated: bool,
|
|
) -> Option<bool> {
|
|
let os_matches = rule.os.as_ref().is_none_or(|os| {
|
|
crate::util::platform::os_rule(os, java_version, minecraft_updated)
|
|
});
|
|
let features_match = rule.features.as_ref().is_none_or(|features| {
|
|
let facts = [
|
|
(features.is_demo_user, false),
|
|
(features.has_custom_resolution, true),
|
|
(features.has_quick_plays_support, true),
|
|
(
|
|
features.is_quick_play_singleplayer,
|
|
matches!(quick_play_type, QuickPlayType::Singleplayer(_)),
|
|
),
|
|
(
|
|
features.is_quick_play_multiplayer,
|
|
matches!(quick_play_type, QuickPlayType::Server(..)),
|
|
),
|
|
(features.is_quick_play_realms, false),
|
|
];
|
|
facts.into_iter().all(|(expected, actual)| {
|
|
expected.is_none_or(|expected| expected == actual)
|
|
})
|
|
});
|
|
|
|
(os_matches && features_match)
|
|
.then_some(matches!(rule.action, RuleAction::Allow))
|
|
}
|
|
|
|
macro_rules! processor_rules {
|
|
($dest:expr; $($name:literal : client => $client:expr, server => $server:expr;)+) => {
|
|
$(std::collections::HashMap::insert(
|
|
$dest,
|
|
String::from($name),
|
|
daedalus::modded::SidedDataEntry {
|
|
client: String::from($client),
|
|
server: String::from($server),
|
|
},
|
|
);)+
|
|
}
|
|
}
|
|
|
|
fn processor_output_sha1(value: &str) -> Option<&str> {
|
|
let value = value.trim().trim_matches(['\'', '"']);
|
|
let value = value
|
|
.strip_prefix("sha1:")
|
|
.or_else(|| value.strip_prefix("SHA1:"))
|
|
.unwrap_or(value);
|
|
(value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
|
.then_some(value)
|
|
}
|
|
|
|
async fn processor_outputs_are_current(
|
|
processor: &d::modded::Processor,
|
|
libraries_dir: &Path,
|
|
data: &std::collections::HashMap<String, d::modded::SidedDataEntry>,
|
|
allowed_roots: &[&Path],
|
|
) -> bool {
|
|
let inferred_outputs: std::collections::HashMap<String, String>;
|
|
let outputs = if let Some(outputs) = processor
|
|
.outputs
|
|
.as_ref()
|
|
.filter(|outputs| !outputs.is_empty())
|
|
{
|
|
outputs
|
|
} else {
|
|
let is_binary_patcher =
|
|
processor.jar.split(':').nth(1).is_some_and(|artifact| {
|
|
artifact.eq_ignore_ascii_case("binarypatcher")
|
|
});
|
|
if !is_binary_patcher {
|
|
return false;
|
|
}
|
|
let mut inferred = std::collections::HashMap::new();
|
|
for (index, argument) in processor.args.iter().enumerate() {
|
|
if argument != "--output" {
|
|
continue;
|
|
}
|
|
let Some(path_template) = processor.args.get(index + 1) else {
|
|
return false;
|
|
};
|
|
let Some(key) = path_template
|
|
.strip_prefix('{')
|
|
.and_then(|key| key.strip_suffix('}'))
|
|
else {
|
|
return false;
|
|
};
|
|
let hash_key = format!("{key}_SHA");
|
|
if !data.contains_key(&hash_key) {
|
|
return false;
|
|
}
|
|
inferred.insert(path_template.clone(), format!("{{{hash_key}}}"));
|
|
}
|
|
if inferred.is_empty() {
|
|
return false;
|
|
}
|
|
inferred_outputs = inferred;
|
|
&inferred_outputs
|
|
};
|
|
let mut canonical_roots = Vec::with_capacity(allowed_roots.len());
|
|
for root in allowed_roots {
|
|
if let Ok(root) = tokio::fs::canonicalize(root).await {
|
|
canonical_roots.push(root);
|
|
}
|
|
}
|
|
if canonical_roots.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
for (path_template, hash_template) in outputs {
|
|
let resolved = match args::get_processor_arguments(
|
|
libraries_dir,
|
|
&[path_template, hash_template],
|
|
data,
|
|
) {
|
|
Ok(resolved) => resolved,
|
|
Err(error) => {
|
|
tracing::debug!(
|
|
processor = %processor.jar,
|
|
%error,
|
|
"Could not resolve processor output"
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
let [resolved_path, resolved_hash] = resolved.as_slice() else {
|
|
return false;
|
|
};
|
|
let Some(expected_sha1) = processor_output_sha1(resolved_hash) else {
|
|
return false;
|
|
};
|
|
let path = PathBuf::from(resolved_path);
|
|
if !path.is_absolute() {
|
|
return false;
|
|
}
|
|
let Ok(path) = tokio::fs::canonicalize(path).await else {
|
|
return false;
|
|
};
|
|
if !canonical_roots.iter().any(|root| path.starts_with(root)) {
|
|
return false;
|
|
}
|
|
let Ok((_, actual_sha1)) =
|
|
crate::util::fetch::sha1_file_async(&path).await
|
|
else {
|
|
return false;
|
|
};
|
|
if !actual_sha1.eq_ignore_ascii_case(expected_sha1) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
pub async fn get_java_version_from_launch_context(
|
|
context: &InstanceLaunchContext,
|
|
version_info: &VersionInfo,
|
|
) -> crate::Result<Option<JavaVersion>> {
|
|
if let Some(java) = context.launch_overrides.java_path.as_ref() {
|
|
let java =
|
|
crate::api::jre::check_jre(std::path::PathBuf::from(java)).await;
|
|
|
|
if let Ok(java) = java {
|
|
validate_loader_java_version(version_info, &java)?;
|
|
return Ok(Some(java));
|
|
}
|
|
}
|
|
|
|
let key = required_java_major(version_info);
|
|
|
|
let java_version = crate::api::jre::find_java_for_version(key).await?;
|
|
|
|
if let Some(java_version) = &java_version {
|
|
validate_loader_java_version(version_info, java_version)?;
|
|
}
|
|
|
|
Ok(java_version)
|
|
}
|
|
|
|
fn required_java_major(version_info: &VersionInfo) -> u32 {
|
|
if version_uses_liteloader(version_info) {
|
|
8
|
|
} else {
|
|
version_info
|
|
.java_version
|
|
.as_ref()
|
|
.map_or(8, |java| java.major_version)
|
|
}
|
|
}
|
|
|
|
fn version_uses_liteloader(version_info: &VersionInfo) -> bool {
|
|
version_info
|
|
.libraries
|
|
.iter()
|
|
.any(|library| is_liteloader_library(&library.name))
|
|
}
|
|
|
|
fn is_liteloader_library(name: &str) -> bool {
|
|
let mut coordinates = name.split(':');
|
|
coordinates.next() == Some("com.mumfrey")
|
|
&& coordinates.next() == Some("liteloader")
|
|
&& coordinates.next().is_some()
|
|
}
|
|
|
|
fn validate_loader_java_version(
|
|
version_info: &VersionInfo,
|
|
java_version: &JavaVersion,
|
|
) -> crate::Result<()> {
|
|
if version_uses_liteloader(version_info) && java_version.parsed_version != 8
|
|
{
|
|
return Err(crate::ErrorKind::LauncherError(format!(
|
|
"LiteLoader requires Java 8, but Java {} is selected",
|
|
java_version.parsed_version
|
|
))
|
|
.into());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_loader_version_from_profile(
|
|
game_version: &str,
|
|
loader: ModLoader,
|
|
loader_version: Option<&str>,
|
|
) -> crate::Result<Option<LoaderVersion>> {
|
|
get_loader_version_from_profile_with_cache(
|
|
game_version,
|
|
loader,
|
|
loader_version,
|
|
None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_loader_version_from_profile_with_cache(
|
|
game_version: &str,
|
|
loader: ModLoader,
|
|
loader_version: Option<&str>,
|
|
cache_behaviour: Option<CacheBehaviour>,
|
|
) -> crate::Result<Option<LoaderVersion>> {
|
|
if loader == ModLoader::Vanilla {
|
|
return Ok(None);
|
|
}
|
|
|
|
if loader == ModLoader::OptiFine {
|
|
return optifine::resolve_loader_version(game_version, loader_version)
|
|
.await;
|
|
}
|
|
|
|
let versions =
|
|
crate::api::metadata::get_loader_versions_for_game_with_cache(
|
|
loader.as_meta_str(),
|
|
game_version,
|
|
cache_behaviour,
|
|
)
|
|
.await?;
|
|
|
|
if let Some(loaders) =
|
|
loader_versions_for_game_version(&versions, game_version)
|
|
{
|
|
Ok(select_loader_version(loaders, loader_version).cloned())
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
fn select_loader_version<'a>(
|
|
loaders: &'a [LoaderVersion],
|
|
requested: Option<&str>,
|
|
) -> Option<&'a LoaderVersion> {
|
|
let requested = requested.unwrap_or("latest");
|
|
loaders
|
|
.iter()
|
|
.find(|version| match requested {
|
|
"latest" => true,
|
|
"stable" => version.stable,
|
|
id => version.id == id,
|
|
})
|
|
.or_else(|| (requested == "stable").then(|| loaders.first()).flatten())
|
|
}
|
|
|
|
fn explicit_loader_version(requested: Option<&str>) -> Option<&str> {
|
|
requested.filter(|version| {
|
|
!version.is_empty() && !matches!(*version, "latest" | "stable")
|
|
})
|
|
}
|
|
|
|
fn missing_loader_version_fallback(
|
|
loader: ModLoader,
|
|
game_version: &str,
|
|
requested: Option<&str>,
|
|
) -> Result<&'static str, String> {
|
|
if let Some(requested) = explicit_loader_version(requested) {
|
|
Err(format!(
|
|
"Loader version {requested} is not available for {} {game_version}",
|
|
loader.as_str()
|
|
))
|
|
} else {
|
|
Ok("stable")
|
|
}
|
|
}
|
|
|
|
fn installed_offline_loader_version(
|
|
loader: ModLoader,
|
|
requested: Option<&str>,
|
|
) -> Option<LoaderVersion> {
|
|
if !matches!(
|
|
loader,
|
|
ModLoader::Fabric
|
|
| ModLoader::Forge
|
|
| ModLoader::NeoForge
|
|
| ModLoader::Quilt
|
|
| ModLoader::Cleanroom
|
|
| ModLoader::LiteLoader
|
|
| ModLoader::LegacyFabric
|
|
| ModLoader::Babric
|
|
) {
|
|
return None;
|
|
}
|
|
let id = explicit_loader_version(requested)?;
|
|
Some(LoaderVersion {
|
|
id: id.to_string(),
|
|
url: String::new(),
|
|
stable: false,
|
|
profile_source: Default::default(),
|
|
fallback_url: None,
|
|
})
|
|
}
|
|
|
|
fn loader_versions_for_game_version<'a>(
|
|
manifest: &'a Manifest,
|
|
game_version: &str,
|
|
) -> Option<&'a [LoaderVersion]> {
|
|
let version = manifest.game_versions.iter().find(|x| {
|
|
x.id.replace(daedalus::modded::DUMMY_REPLACE_STRING, game_version)
|
|
== game_version
|
|
})?;
|
|
|
|
if let Some(version_group) = &version.version_group {
|
|
manifest
|
|
.version_groups
|
|
.iter()
|
|
.find(|group| group.id == *version_group)
|
|
.map(|group| group.loaders.as_slice())
|
|
} else {
|
|
Some(version.loaders.as_slice())
|
|
}
|
|
}
|
|
|
|
/// Resolves the Minecraft version manifest and finds the index for the given
|
|
/// game version. If the version isn't found in the cache, forces a manifest
|
|
/// refresh to pick up newly-released versions.
|
|
pub async fn resolve_minecraft_manifest(
|
|
game_version: &str,
|
|
state: &State,
|
|
) -> crate::Result<(d::minecraft::VersionManifest, usize)> {
|
|
resolve_minecraft_manifest_with_cache(game_version, state, None).await
|
|
}
|
|
|
|
pub async fn resolve_minecraft_manifest_with_cache(
|
|
game_version: &str,
|
|
state: &State,
|
|
cache_behaviour: Option<CacheBehaviour>,
|
|
) -> crate::Result<(d::minecraft::VersionManifest, usize)> {
|
|
let minecraft = crate::api::metadata::get_minecraft_versions_with_cache(
|
|
cache_behaviour,
|
|
)
|
|
.await?;
|
|
|
|
if let Some(idx) = minecraft
|
|
.versions
|
|
.iter()
|
|
.position(|it| it.id == game_version)
|
|
{
|
|
return Ok((minecraft, idx));
|
|
}
|
|
|
|
// Version not found in the first manifest lookup. Online launches force a
|
|
// refresh for newly released versions; offline launches repeat a cache-only
|
|
// lookup so they never reach the network.
|
|
let refreshed = crate::state::CachedEntry::get_minecraft_manifest(
|
|
if cache_behaviour == Some(CacheBehaviour::CacheOnly) {
|
|
Some(CacheBehaviour::CacheOnly)
|
|
} else {
|
|
Some(CacheBehaviour::MustRevalidate)
|
|
},
|
|
&state.pool,
|
|
&state.api_semaphore,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::NoValueFor("minecraft versions".to_string())
|
|
})?;
|
|
|
|
let idx = refreshed
|
|
.versions
|
|
.iter()
|
|
.position(|it| it.id == game_version)
|
|
.ok_or(crate::ErrorKind::LauncherError(format!(
|
|
"Invalid game version: {game_version}"
|
|
)))?;
|
|
|
|
Ok((refreshed, idx))
|
|
}
|
|
|
|
async fn get_instance_full_path(
|
|
instance_path: &str,
|
|
game_dir_override: Option<&str>,
|
|
) -> crate::Result<PathBuf> {
|
|
let state = State::get().await?;
|
|
let full_path = io::canonicalize(
|
|
state
|
|
.directories
|
|
.resolve_game_dir(instance_path, game_dir_override),
|
|
)?;
|
|
Ok(full_path)
|
|
}
|
|
|
|
/// Writes downloaded version metadata and the client jar into the external
|
|
/// `.minecraft/versions/<name>` directory. Shared artifacts remain in
|
|
/// Axolotl's metadata cache, while the external root retains the conventional
|
|
/// Minecraft version metadata structure.
|
|
async fn materialize_external_version(
|
|
instance: &Instance,
|
|
version_id: &str,
|
|
version_info: &VersionInfo,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
let Some((version_dir, _)) =
|
|
external_version_dir_for_game_override(instance)
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let Some(version_name) =
|
|
version_dir.file_name().and_then(|name| name.to_str())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
io::create_dir_all(&version_dir).await?;
|
|
|
|
let mut serialized = serde_json::to_value(version_info)?;
|
|
if let Some(object) = serialized.as_object_mut() {
|
|
object.insert("id".to_string(), version_name.to_string().into());
|
|
}
|
|
let version_json = version_dir.join(format!("{version_name}.json"));
|
|
io::write(&version_json, serde_json::to_vec(&serialized)?).await?;
|
|
|
|
let source_jar = state
|
|
.directories
|
|
.version_dir(version_id)
|
|
.join(format!("{version_id}.jar"));
|
|
let target_jar = version_dir.join(format!("{version_name}.jar"));
|
|
tokio::fs::copy(source_jar, target_jar).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn promote_external_instance_link(
|
|
instance: &Instance,
|
|
state: &State,
|
|
) -> crate::Result<()> {
|
|
if instance.is_direct_linked() {
|
|
return Ok(());
|
|
}
|
|
let Some(direct) = DirectLinkedLaunch::from_game_dir_override(instance)?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let game_dir_mode = direct.game_dir_mode.ok_or_else(|| {
|
|
crate::ErrorKind::LauncherError(
|
|
"External instance link has no game-directory mode".to_string(),
|
|
)
|
|
})?;
|
|
let root = direct.dot_minecraft.to_string_lossy().to_string();
|
|
let version_json = direct
|
|
.version_json
|
|
.as_deref()
|
|
.map(io::canonicalize)
|
|
.transpose()?
|
|
.map(|path| path.to_string_lossy().to_string());
|
|
let mut tx = state.pool.begin().await?;
|
|
crate::state::instances::adapters::sqlite::instance_rows::set_direct_link_fields(
|
|
&instance.id,
|
|
&crate::state::instances::adapters::sqlite::instance_rows::DirectLinkFields {
|
|
launcher: Some("generic".to_string()),
|
|
launcher_root: Some(root.clone()),
|
|
dot_minecraft: Some(root.clone()),
|
|
version_id: Some(direct.version_id.clone()),
|
|
version_json_path: version_json,
|
|
game_dir_mode: Some(game_dir_mode.key().to_string()),
|
|
},
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
if let Some(group) = direct_link_group(&direct.dot_minecraft) {
|
|
crate::state::instances::adapters::sqlite::instance_rows::replace_instance_groups(
|
|
&instance.id,
|
|
&[group],
|
|
&mut tx,
|
|
)
|
|
.await?;
|
|
}
|
|
tx.commit().await?;
|
|
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum InstanceCompletionPolicy {
|
|
FinalizeHere,
|
|
DeferToInstallJob,
|
|
}
|
|
|
|
pub(crate) async fn run_instance_install_command(
|
|
instance_id: String,
|
|
cancellation: CancellationToken,
|
|
mut command: Command,
|
|
) -> crate::Result<Output> {
|
|
let state = State::get().await?;
|
|
let instance_lock =
|
|
state.lock_instance_content_exclusive(&instance_id).await;
|
|
let task = tokio::spawn(async move {
|
|
let _instance_lock = instance_lock;
|
|
if cancellation.is_cancelled() {
|
|
return Err(crate::ErrorKind::OtherError(
|
|
"Install was canceled".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
command
|
|
.kill_on_drop(true)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
let mut child = command.spawn()?;
|
|
let stdout = child.stdout.take();
|
|
let stderr = child.stderr.take();
|
|
let stdout_task = tokio::spawn(async move {
|
|
let mut output = Vec::new();
|
|
if let Some(mut stdout) = stdout {
|
|
stdout.read_to_end(&mut output).await?;
|
|
}
|
|
Ok::<_, std::io::Error>(output)
|
|
});
|
|
let stderr_task = tokio::spawn(async move {
|
|
let mut output = Vec::new();
|
|
if let Some(mut stderr) = stderr {
|
|
stderr.read_to_end(&mut output).await?;
|
|
}
|
|
Ok::<_, std::io::Error>(output)
|
|
});
|
|
|
|
let (status, canceled) = tokio::select! {
|
|
biased;
|
|
_ = cancellation.cancelled() => {
|
|
let _ = child.start_kill();
|
|
(child.wait().await?, true)
|
|
}
|
|
status = child.wait() => (status?, false),
|
|
};
|
|
let stdout = stdout_task.await.map_err(std::io::Error::other)??;
|
|
let stderr = stderr_task.await.map_err(std::io::Error::other)??;
|
|
if canceled {
|
|
return Err(crate::ErrorKind::OtherError(
|
|
"Install was canceled".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
Ok(Output {
|
|
status,
|
|
stdout,
|
|
stderr,
|
|
})
|
|
});
|
|
|
|
task.await?
|
|
}
|
|
|
|
pub async fn install_minecraft_with_reporter(
|
|
context: &InstanceLaunchContext,
|
|
repairing: bool,
|
|
reporter: Option<InstallProgressReporter>,
|
|
completion_policy: InstanceCompletionPolicy,
|
|
) -> crate::Result<()> {
|
|
install_minecraft_with_local_source(
|
|
context,
|
|
repairing,
|
|
reporter,
|
|
None,
|
|
completion_policy,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn install_minecraft_with_local_source(
|
|
context: &InstanceLaunchContext,
|
|
repairing: bool,
|
|
reporter: Option<InstallProgressReporter>,
|
|
local_source: Option<&LocalRuntimeSource>,
|
|
completion_policy: InstanceCompletionPolicy,
|
|
) -> crate::Result<()> {
|
|
let instance = &context.instance;
|
|
let content_set = &context.applied_content_set;
|
|
let phase_details = InstallPhaseDetails::Minecraft {
|
|
game_version: content_set.game_version.clone(),
|
|
loader: content_set.loader,
|
|
};
|
|
let loading_bar = if reporter.is_none() {
|
|
Some(
|
|
init_loading(
|
|
LoadingBarType::MinecraftDownload {
|
|
// If we are downloading minecraft for a profile, provide its name and uuid
|
|
instance_name: instance.name.clone(),
|
|
instance_id: instance.id.clone(),
|
|
},
|
|
100.0,
|
|
"Downloading Minecraft",
|
|
)
|
|
.await?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let state = State::get().await?;
|
|
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
&instance.id,
|
|
InstanceInstallStage::MinecraftInstalling,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
|
|
|
let instance_path = get_instance_full_path(
|
|
&instance.path,
|
|
instance.game_dir_override.as_deref(),
|
|
)
|
|
.await?;
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::ResolvingMinecraft,
|
|
None,
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
let (minecraft, version_index) =
|
|
resolve_minecraft_manifest(&content_set.game_version, &state).await?;
|
|
let version = &minecraft.versions[version_index];
|
|
let minecraft_updated = version_index
|
|
<= minecraft
|
|
.versions
|
|
.iter()
|
|
.position(|x| x.id == "22w16a")
|
|
.unwrap_or(0);
|
|
|
|
if content_set.loader != ModLoader::Vanilla
|
|
&& let Some(reporter) = &reporter
|
|
{
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::ResolvingLoader,
|
|
None,
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let mut loader_version = get_loader_version_from_profile(
|
|
&content_set.game_version,
|
|
content_set.loader,
|
|
content_set.loader_version.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
// If no loader version is selected, try to select the stable version!
|
|
if content_set.loader != ModLoader::Vanilla && loader_version.is_none() {
|
|
let fallback = missing_loader_version_fallback(
|
|
content_set.loader,
|
|
&content_set.game_version,
|
|
content_set.loader_version.as_deref(),
|
|
)
|
|
.map_err(crate::ErrorKind::LauncherError)?;
|
|
loader_version = get_loader_version_from_profile(
|
|
&content_set.game_version,
|
|
content_set.loader,
|
|
Some(fallback),
|
|
)
|
|
.await?;
|
|
|
|
crate::state::instances::commands::set_applied_content_set_loader_version(
|
|
&instance.id,
|
|
loader_version.as_ref().map(|x| x.id.as_str()),
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let version_jar =
|
|
loader_version.as_ref().map_or(version.id.clone(), |it| {
|
|
format!("{}-{}", version.id.clone(), it.id.clone())
|
|
});
|
|
|
|
// Download version info (5)
|
|
let mut version_info = download::download_version_info(
|
|
&state,
|
|
version,
|
|
content_set.loader,
|
|
loader_version.as_ref(),
|
|
Some(repairing),
|
|
loading_bar.as_ref(),
|
|
reporter.as_ref(),
|
|
)
|
|
.await?;
|
|
|
|
let key = required_java_major(&version_info);
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::PreparingJava,
|
|
Some(InstallProgress {
|
|
current: 0,
|
|
total: 4,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Java {
|
|
major_version: key,
|
|
step: InstallJavaStep::Resolving,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
let java_installation = if let Some(java_version) =
|
|
get_java_version_from_launch_context(context, &version_info).await?
|
|
{
|
|
Some((std::path::PathBuf::from(java_version.path), false))
|
|
} else if let Some(discovered) = crate::api::jre::find_java_for_version(key)
|
|
.await
|
|
.unwrap_or_default()
|
|
{
|
|
tracing::info!(
|
|
"Reusing discovered Java {} at {}",
|
|
discovered.version,
|
|
discovered.path
|
|
);
|
|
Some((std::path::PathBuf::from(discovered.path), true))
|
|
} else {
|
|
if let Some(reporter) = &reporter {
|
|
crate::api::jre::auto_install_java_with_reporter(
|
|
key,
|
|
reporter.clone(),
|
|
)
|
|
.await?
|
|
} else {
|
|
crate::api::jre::auto_install_java_with_loading(key, true).await?
|
|
}
|
|
.map(|path| (path, true))
|
|
};
|
|
|
|
let java_version = if let Some((java_path, set_java)) = java_installation {
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::PreparingJava,
|
|
Some(InstallProgress {
|
|
current: 4,
|
|
total: 4,
|
|
secondary: None,
|
|
}),
|
|
InstallPhaseDetails::Java {
|
|
major_version: key,
|
|
step: InstallJavaStep::Validating,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
let java_version = crate::api::jre::check_jre(java_path).await?;
|
|
validate_loader_java_version(&version_info, &java_version)?;
|
|
|
|
if set_java {
|
|
java_version.upsert(&state.pool).await?;
|
|
}
|
|
|
|
Some(java_version)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Download minecraft (5-90)
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::DownloadingMinecraft,
|
|
None,
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
download::download_minecraft(
|
|
&state,
|
|
local_source,
|
|
&content_set.game_version,
|
|
&version_info,
|
|
loading_bar.as_ref(),
|
|
java_version
|
|
.as_ref()
|
|
.map(|java| java.architecture.as_str())
|
|
.unwrap_or(std::env::consts::ARCH),
|
|
repairing,
|
|
minecraft_updated,
|
|
reporter.clone(),
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
|
|
materialize_external_version(instance, &version_jar, &version_info, &state)
|
|
.await?;
|
|
|
|
// Version-isolated external instances are direct-managed from creation.
|
|
// Complete their external assets/libraries now so the first launch never
|
|
// falls back to Axolotl's shared runtime directories.
|
|
let runtime_adapter =
|
|
InstanceRuntimeAdapter::for_instance(instance, &state.directories)?;
|
|
if let Some(direct) = runtime_adapter.direct_link() {
|
|
let resolved = direct.resolve()?;
|
|
direct_ensure::ensure_direct_launch_dependencies(
|
|
&state,
|
|
&direct,
|
|
&resolved.merged.libraries,
|
|
&version_info,
|
|
java_version
|
|
.as_ref()
|
|
.map(|java| java.architecture.as_str())
|
|
.unwrap_or(std::env::consts::ARCH),
|
|
minecraft_updated,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let client_path = state
|
|
.directories
|
|
.version_dir(&version_jar)
|
|
.join(format!("{version_jar}.jar"));
|
|
|
|
let Some(java_version) = java_version else {
|
|
let protocol_version =
|
|
read_protocol_version_from_jar(client_path).await?;
|
|
crate::state::instances::commands::set_applied_content_set_protocol_version(
|
|
&instance.id,
|
|
protocol_version,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
promote_external_instance_link(instance, &state).await?;
|
|
if completion_policy == InstanceCompletionPolicy::FinalizeHere {
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
&instance.id,
|
|
InstanceInstallStage::Installed,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
|
}
|
|
if let Some(loading_bar) = &loading_bar {
|
|
emit_loading(
|
|
loading_bar,
|
|
1.0,
|
|
Some("Finished downloading Minecraft resources"),
|
|
)?;
|
|
}
|
|
tracing::info!(
|
|
java_version = key,
|
|
instance_id = instance.id,
|
|
"Postponed Java setup after downloading Minecraft resources"
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
if content_set.loader == ModLoader::OptiFine
|
|
&& let Some(loader_version) = &loader_version
|
|
{
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::RunningLoaderProcessors,
|
|
None,
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
optifine::install_optifine_libraries(
|
|
&state,
|
|
std::path::Path::new(&java_version.path),
|
|
&content_set.game_version,
|
|
&loader_version.id,
|
|
&client_path,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
if let Some(processors) = &version_info.processors {
|
|
let libraries_dir = state.directories.libraries_dir();
|
|
|
|
if let Some(ref mut data) = version_info.data {
|
|
processor_rules! {
|
|
data;
|
|
"SIDE":
|
|
client => "client",
|
|
server => "";
|
|
"MINECRAFT_JAR" :
|
|
client => client_path.to_string_lossy(),
|
|
server => "";
|
|
"MINECRAFT_VERSION":
|
|
client => content_set.game_version.clone(),
|
|
server => "";
|
|
"ROOT":
|
|
client => instance_path.to_string_lossy(),
|
|
server => "";
|
|
"LIBRARY_DIR":
|
|
client => libraries_dir.to_string_lossy(),
|
|
server => "";
|
|
}
|
|
|
|
if let Some(loading_bar) = &loading_bar {
|
|
emit_loading(
|
|
loading_bar,
|
|
0.0,
|
|
Some("Running forge processors"),
|
|
)?;
|
|
}
|
|
let total_length = processors.len();
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::RunningLoaderProcessors,
|
|
Some(InstallProgress {
|
|
current: 0,
|
|
total: total_length as u64,
|
|
secondary: None,
|
|
}),
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Forge processors (90-100)
|
|
for (index, processor) in processors.iter().enumerate() {
|
|
let processor_started = Instant::now();
|
|
if let Some(sides) = &processor.sides
|
|
&& !sides.contains(&String::from("client"))
|
|
{
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::RunningLoaderProcessors,
|
|
Some(InstallProgress {
|
|
current: (index + 1) as u64,
|
|
total: total_length as u64,
|
|
secondary: None,
|
|
}),
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
continue;
|
|
}
|
|
if processor_outputs_are_current(
|
|
processor,
|
|
&libraries_dir,
|
|
data,
|
|
&[&instance_path, &libraries_dir],
|
|
)
|
|
.await
|
|
{
|
|
tracing::info!(
|
|
processor = %processor.jar,
|
|
index,
|
|
duration_ms = processor_started.elapsed().as_millis(),
|
|
"Skipped Forge processor because all declared outputs are valid"
|
|
);
|
|
if let Some(loading_bar) = &loading_bar {
|
|
emit_loading(
|
|
loading_bar,
|
|
30.0 / total_length as f64,
|
|
Some(&format!(
|
|
"Running forge processor {index}/{total_length}"
|
|
)),
|
|
)?;
|
|
}
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::RunningLoaderProcessors,
|
|
Some(InstallProgress {
|
|
current: (index + 1) as u64,
|
|
total: total_length as u64,
|
|
secondary: None,
|
|
}),
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let cp = {
|
|
let mut cp = processor.classpath.clone();
|
|
cp.push(processor.jar.clone());
|
|
cp
|
|
};
|
|
|
|
let mut command = Command::new(&java_version.path);
|
|
command
|
|
.arg("-cp")
|
|
.arg(args::get_class_paths_jar(
|
|
&libraries_dir,
|
|
&cp,
|
|
&java_version.architecture,
|
|
)?)
|
|
.arg(
|
|
args::get_processor_main_class(args::get_lib_path(
|
|
&libraries_dir,
|
|
&processor.jar,
|
|
false,
|
|
)?)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::LauncherError(format!(
|
|
"Could not find processor main class for {}",
|
|
processor.jar
|
|
))
|
|
})?,
|
|
)
|
|
.args(args::get_processor_arguments(
|
|
&libraries_dir,
|
|
&processor.args,
|
|
data,
|
|
)?);
|
|
let child = run_instance_install_command(
|
|
instance.id.clone(),
|
|
reporter
|
|
.as_ref()
|
|
.map(InstallProgressReporter::cancellation_token)
|
|
.unwrap_or_default(),
|
|
command,
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
crate::ErrorKind::LauncherError(format!(
|
|
"Error running processor: {err}",
|
|
))
|
|
})?;
|
|
|
|
if !child.status.success() {
|
|
return Err(crate::ErrorKind::LauncherError(format!(
|
|
"Processor error: {}",
|
|
String::from_utf8_lossy(&child.stderr)
|
|
))
|
|
.as_error());
|
|
}
|
|
tracing::info!(
|
|
processor = %processor.jar,
|
|
index,
|
|
duration_ms = processor_started.elapsed().as_millis(),
|
|
"Completed Forge processor"
|
|
);
|
|
|
|
if let Some(loading_bar) = &loading_bar {
|
|
emit_loading(
|
|
loading_bar,
|
|
30.0 / total_length as f64,
|
|
Some(&format!(
|
|
"Running forge processor {index}/{total_length}"
|
|
)),
|
|
)?;
|
|
}
|
|
if let Some(reporter) = &reporter {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::RunningLoaderProcessors,
|
|
Some(InstallProgress {
|
|
current: (index + 1) as u64,
|
|
total: total_length as u64,
|
|
secondary: None,
|
|
}),
|
|
phase_details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let protocol_version = read_protocol_version_from_jar(client_path).await?;
|
|
|
|
crate::state::instances::commands::set_applied_content_set_protocol_version(
|
|
&instance.id,
|
|
protocol_version,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
promote_external_instance_link(instance, &state).await?;
|
|
if completion_policy == InstanceCompletionPolicy::FinalizeHere {
|
|
crate::state::instances::commands::set_instance_install_stage(
|
|
&instance.id,
|
|
InstanceInstallStage::Installed,
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
|
}
|
|
if let Some(loading_bar) = &loading_bar {
|
|
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn install_minecraft_for_instance_id_with_reporter(
|
|
instance_id: &str,
|
|
repairing: bool,
|
|
reporter: Option<InstallProgressReporter>,
|
|
completion_policy: InstanceCompletionPolicy,
|
|
) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let context =
|
|
crate::state::instances::commands::get_instance_launch_context(
|
|
instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::OtherError(format!(
|
|
"Tried to install a nonexistent or unloaded instance {instance_id}!"
|
|
))
|
|
})?;
|
|
|
|
install_minecraft_with_reporter(
|
|
&context,
|
|
repairing,
|
|
reporter,
|
|
completion_policy,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn install_minecraft_for_instance_id_with_local_source(
|
|
instance_id: &str,
|
|
local_source: Option<LocalRuntimeSource>,
|
|
repairing: bool,
|
|
reporter: Option<InstallProgressReporter>,
|
|
completion_policy: InstanceCompletionPolicy,
|
|
) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let context =
|
|
crate::state::instances::commands::get_instance_launch_context(
|
|
instance_id,
|
|
&state.pool,
|
|
)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::OtherError(format!(
|
|
"Tried to install a nonexistent or unloaded instance {instance_id}!"
|
|
))
|
|
})?;
|
|
|
|
install_minecraft_with_local_source(
|
|
&context,
|
|
repairing,
|
|
reporter,
|
|
local_source.as_ref(),
|
|
completion_policy,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn read_protocol_version_from_jar(
|
|
path: PathBuf,
|
|
) -> crate::Result<Option<u32>> {
|
|
let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?;
|
|
let Some(entry_index) = zip
|
|
.file()
|
|
.entries()
|
|
.iter()
|
|
.position(|x| matches!(x.filename().as_str(), Ok("version.json")))
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct VersionData {
|
|
protocol_version: Option<u32>,
|
|
}
|
|
|
|
let mut data = vec![];
|
|
zip.reader_with_entry(entry_index)
|
|
.await?
|
|
.read_to_end_checked(&mut data)
|
|
.await?;
|
|
let data: VersionData = serde_json::from_slice(&data)?;
|
|
|
|
Ok(data.protocol_version)
|
|
}
|
|
|
|
/// Selects a Java runtime from the HMCL private settings of a directly
|
|
/// associated instance, if one is configured and usable.
|
|
async fn select_hmcl_java(
|
|
direct: &DirectLinkedLaunch,
|
|
settings: &crate::launcher::local_version::HmclVersionSettings,
|
|
) -> Option<JavaVersion> {
|
|
select_linked_java(hmcl_java_candidates(settings, &direct.dot_minecraft))
|
|
.await
|
|
}
|
|
|
|
/// Selects a Java runtime from the PCL/PCL-CE per-instance Java preference of
|
|
/// a directly associated instance (see
|
|
/// `PclLaunchSettings::java_candidates`).
|
|
async fn select_pcl_java(
|
|
direct: &DirectLinkedLaunch,
|
|
settings: &direct_link::PclLaunchSettings,
|
|
) -> Option<JavaVersion> {
|
|
select_linked_java(settings.java_candidates(
|
|
&direct.version_dir(),
|
|
direct.launcher_root.as_deref(),
|
|
))
|
|
.await
|
|
}
|
|
|
|
/// Verifies linked-launcher Java candidates in order and returns the first
|
|
/// usable one.
|
|
async fn select_linked_java(candidates: Vec<PathBuf>) -> Option<JavaVersion> {
|
|
for candidate in candidates {
|
|
match crate::api::jre::check_jre(candidate).await {
|
|
Ok(java) => {
|
|
tracing::info!(
|
|
java = %java.path,
|
|
"Using the Java runtime configured by the linked launcher"
|
|
);
|
|
return Some(java);
|
|
}
|
|
Err(error) => {
|
|
tracing::debug!(
|
|
%error,
|
|
"Skipping unusable linked-launcher Java candidate"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Resolves the game directory a directly associated instance actually plays
|
|
/// from. The selected runtime adapter preserves each external launcher's
|
|
/// shared or version-isolated path semantics. `None` when the instance is not
|
|
/// directly associated or its linked metadata is incomplete.
|
|
///
|
|
/// Content browsing (mods/worlds listing) uses this so it reads the same
|
|
/// folders the launch does. If a version chain cannot be resolved, the
|
|
/// selected runtime adapter supplies a mode-appropriate external fallback.
|
|
pub(crate) fn linked_game_dir(instance: &Instance) -> Option<PathBuf> {
|
|
let adapter =
|
|
InstanceRuntimeAdapter::external_for_instance(instance).ok()??;
|
|
Some(adapter.game_dir())
|
|
}
|
|
|
|
fn link_project_and_version(
|
|
link: &InstanceLink,
|
|
) -> (Option<&String>, Option<&String>) {
|
|
match link {
|
|
InstanceLink::ModrinthModpack {
|
|
project_id,
|
|
version_id,
|
|
}
|
|
| InstanceLink::CurseForgeModpack {
|
|
project_id,
|
|
version_id,
|
|
} => (Some(project_id), Some(version_id)),
|
|
InstanceLink::ServerProject { project_id } => (Some(project_id), None),
|
|
InstanceLink::ServerProjectModpack {
|
|
server_project_id,
|
|
content_version_id,
|
|
..
|
|
} => (Some(server_project_id), Some(content_version_id)),
|
|
InstanceLink::ImportedModpack {
|
|
project_id,
|
|
version_id,
|
|
..
|
|
} => (project_id.as_ref(), version_id.as_ref()),
|
|
InstanceLink::Unmanaged | InstanceLink::SharedInstance { .. } => {
|
|
(None, None)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn launch_minecraft(
|
|
java_args: &[String],
|
|
env_args: &[(String, String)],
|
|
mc_set_options: &[(String, String)],
|
|
wrapper: &Option<String>,
|
|
memory: &MemorySettings,
|
|
resolution: &WindowSize,
|
|
maximize_window: bool,
|
|
credentials: &Credentials,
|
|
post_exit_hook: Option<String>,
|
|
context: &InstanceLaunchContext,
|
|
gc_intent: Option<crate::launcher::jvm_args::GcLaunchIntent>,
|
|
gc_report: &mut Option<crate::launcher::jvm_args::GcLaunchReport>,
|
|
mut quick_play_type: QuickPlayType,
|
|
offline_mode: bool,
|
|
) -> crate::Result<ProcessMetadata> {
|
|
let instance = &context.instance;
|
|
let content_set = &context.applied_content_set;
|
|
let mut __lt = std::time::Instant::now();
|
|
let mut combined_java_args =
|
|
crate::pack::hosted::java_arguments(&instance.id).await?;
|
|
tracing::info!("[launch-timing] hosted_java_arguments: {}ms", __lt.elapsed().as_millis());
|
|
__lt = std::time::Instant::now();
|
|
combined_java_args.extend_from_slice(java_args);
|
|
let java_args = combined_java_args.as_slice();
|
|
|
|
if instance.install_stage == InstanceInstallStage::PackInstalling
|
|
|| instance.install_stage == InstanceInstallStage::MinecraftInstalling
|
|
{
|
|
return Err(crate::ErrorKind::LauncherError(
|
|
"Instance is still installing".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
if instance.install_stage != InstanceInstallStage::Installed {
|
|
return Err(crate::ErrorKind::LauncherError(
|
|
"Instance is not installed; start an install job first".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
let state = State::get().await?;
|
|
|
|
let runtime =
|
|
InstanceRuntimeAdapter::for_instance(instance, &state.directories)?;
|
|
let direct_launch = runtime.direct_link().cloned();
|
|
if direct_launch.is_some() && !instance.is_direct_linked() {
|
|
// Instances created before direct-link metadata was introduced retain
|
|
// their external `game_dir_override`. Their adapter above already
|
|
// launches them in place; persist the resolved identity now so future
|
|
// launches, content scans, and settings all use the same external
|
|
// runtime. A persistence failure must not make an otherwise valid
|
|
// existing instance unlaunchable.
|
|
if let Err(error) =
|
|
promote_external_instance_link(instance, &state).await
|
|
{
|
|
tracing::warn!(
|
|
%error,
|
|
instance = %instance.id,
|
|
"Could not persist the migrated external instance link"
|
|
);
|
|
}
|
|
}
|
|
let mut resolved_linked = direct_launch
|
|
.as_ref()
|
|
.map(DirectLinkedLaunch::resolve)
|
|
.transpose()?;
|
|
// PCL/PCL-CE may isolate a version under versions/<id>; HMCL and generic
|
|
// linked installations launch from the shared .minecraft root.
|
|
let instance_path = if let Some(resolved) = &resolved_linked {
|
|
io::canonicalize(&resolved.game_dir)?
|
|
} else {
|
|
get_instance_full_path(
|
|
&instance.path,
|
|
instance.game_dir_override.as_deref(),
|
|
)
|
|
.await?
|
|
};
|
|
let offline_skin_pack = if direct_launch.is_some() {
|
|
// Writing the offline skin pack would modify the linked folder.
|
|
crate::minecraft_skins::OfflineSkinPackOptions {
|
|
enabled_pack_id: None,
|
|
}
|
|
} else {
|
|
crate::minecraft_skins::prepare_offline_skin_resource_pack(
|
|
credentials,
|
|
&instance_path,
|
|
&content_set.game_version,
|
|
)
|
|
.await?
|
|
};
|
|
|
|
let cache_behaviour = offline_mode.then_some(CacheBehaviour::CacheOnly);
|
|
let mut linked_libraries = None;
|
|
|
|
// PCL/PCL-CE expose per-instance launch preferences (Java, memory, custom
|
|
// arguments) in their own config files; load them once for this launch.
|
|
let pcl_launch = direct_launch
|
|
.as_ref()
|
|
.filter(|direct| {
|
|
matches!(
|
|
direct.dialect,
|
|
LinkedLauncherDialect::Pcl | LinkedLauncherDialect::PclCe
|
|
)
|
|
})
|
|
.map(DirectLinkedLaunch::pcl_launch_settings);
|
|
|
|
let (
|
|
version_info,
|
|
minecraft_updated,
|
|
version_jar,
|
|
launch_version_id,
|
|
launch_version_type,
|
|
quick_play_version,
|
|
hmcl_settings,
|
|
launch_release_time,
|
|
) = if let Some(direct) = &direct_launch {
|
|
// The dialect-specific resolver has already folded the linked manifest;
|
|
// no version metadata is downloaded and no repair pass runs.
|
|
let mut merged = resolved_linked
|
|
.take()
|
|
.expect("direct launch resolution accompanies direct metadata")
|
|
.merged;
|
|
// Same loader normalization the managed install path applies. The
|
|
// merged list drives the ensure pass, native extraction, the launch
|
|
// classpath, and the VersionInfo projection below, so dropping the
|
|
// Cleanroom conflicts here — the whole vanilla LWJGL 2 family
|
|
// (`org.lwjgl.lwjgl:lwjgl` binding, `lwjgl_util` at
|
|
// 2.9.4-nightly-20150209, and the natives-only `lwjgl-platform`
|
|
// carrier whose root-level `lwjgl.dll` would otherwise be what
|
|
// `System.loadLibrary("lwjgl")` resolves first), plus the vanilla JNA
|
|
// platform and Mojang ICU (pre-existing Axolotl Cleanroom rule) —
|
|
// covers direct ensure, classpath, and launch at once; the Cleanroom
|
|
// LWJGL 3 line, lwjglxx, and the vanilla JNA core are never affected.
|
|
for removed_library in
|
|
normalize_merged_loader_libraries(content_set.loader, &mut merged)
|
|
{
|
|
tracing::info!(
|
|
loader = content_set.loader.as_str(),
|
|
version = %merged.id,
|
|
removed_library,
|
|
"Removed loader-incompatible library from linked version profile"
|
|
);
|
|
}
|
|
linked_libraries = Some(merged.libraries.clone());
|
|
|
|
// Best-effort reuse of the shared metadata manifest for behaviors
|
|
// keyed on the vanilla release order (Quick Play support, pre-1.13
|
|
// library rules); unknown base versions fall back conservatively.
|
|
let (minecraft_updated, quick_play_version) =
|
|
match resolve_minecraft_manifest_with_cache(
|
|
&content_set.game_version,
|
|
&state,
|
|
cache_behaviour,
|
|
)
|
|
.await
|
|
{
|
|
Ok((minecraft, version_index)) => (
|
|
version_index
|
|
<= minecraft
|
|
.versions
|
|
.iter()
|
|
.position(|x| x.id == "22w16a")
|
|
.unwrap_or(0),
|
|
QuickPlayVersion::find_version(
|
|
version_index,
|
|
&minecraft.versions,
|
|
),
|
|
),
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
%error,
|
|
version = %content_set.game_version,
|
|
"Linked version not found in Minecraft metadata; \
|
|
using conservative launch defaults"
|
|
);
|
|
conservative_launch_facts(&merged)
|
|
}
|
|
};
|
|
|
|
let version_jar = merged
|
|
.jar
|
|
.clone()
|
|
.unwrap_or_else(|| content_set.game_version.clone());
|
|
let hmcl_settings = std::mem::take(&mut merged.hmcl_settings);
|
|
let version_info = merged_to_version_info(merged)?;
|
|
let launch_version_type = version_info.type_.clone();
|
|
|
|
(
|
|
version_info,
|
|
minecraft_updated,
|
|
version_jar,
|
|
direct.version_id.clone(),
|
|
launch_version_type,
|
|
quick_play_version,
|
|
hmcl_settings,
|
|
// Unused: the options.txt language pass never runs for directly
|
|
// associated instances.
|
|
chrono::DateTime::<Utc>::MIN_UTC,
|
|
)
|
|
} else {
|
|
let (minecraft, version_index) = resolve_minecraft_manifest_with_cache(
|
|
&content_set.game_version,
|
|
&state,
|
|
cache_behaviour,
|
|
)
|
|
.await?;
|
|
let version = &minecraft.versions[version_index];
|
|
let minecraft_updated = version_index
|
|
<= minecraft
|
|
.versions
|
|
.iter()
|
|
.position(|x| x.id == "22w16a")
|
|
.unwrap_or(0);
|
|
|
|
let loader_version = if offline_mode {
|
|
if let Some(loader_version) = installed_offline_loader_version(
|
|
content_set.loader,
|
|
content_set.loader_version.as_deref(),
|
|
) {
|
|
Some(loader_version)
|
|
} else {
|
|
get_loader_version_from_profile_with_cache(
|
|
&content_set.game_version,
|
|
content_set.loader,
|
|
content_set.loader_version.as_deref(),
|
|
cache_behaviour,
|
|
)
|
|
.await?
|
|
}
|
|
} else {
|
|
get_loader_version_from_profile_with_cache(
|
|
&content_set.game_version,
|
|
content_set.loader,
|
|
content_set.loader_version.as_deref(),
|
|
cache_behaviour,
|
|
)
|
|
.await?
|
|
};
|
|
|
|
if content_set.loader != ModLoader::Vanilla && loader_version.is_none()
|
|
{
|
|
return Err(crate::ErrorKind::LauncherError(format!(
|
|
"No loader version selected for {}",
|
|
content_set.loader.as_str()
|
|
))
|
|
.into());
|
|
}
|
|
|
|
let version_jar =
|
|
loader_version.as_ref().map_or(version.id.clone(), |it| {
|
|
format!("{}-{}", version.id.clone(), it.id.clone())
|
|
});
|
|
|
|
let mut version_info = if offline_mode {
|
|
download::load_local_version_info(
|
|
&state,
|
|
version,
|
|
content_set.loader,
|
|
loader_version.as_ref(),
|
|
)
|
|
.await?
|
|
} else {
|
|
download::download_version_info(
|
|
&state,
|
|
version,
|
|
content_set.loader,
|
|
loader_version.as_ref(),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?
|
|
};
|
|
if version_info.logging.is_none() {
|
|
let requires_logging_info = version_index
|
|
<= minecraft
|
|
.versions
|
|
.iter()
|
|
.position(|x| x.id == "13w39a")
|
|
.unwrap_or(0);
|
|
if requires_logging_info && !offline_mode {
|
|
version_info = download::download_version_info(
|
|
&state,
|
|
version,
|
|
content_set.loader,
|
|
loader_version.as_ref(),
|
|
Some(true),
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
let quick_play_version =
|
|
QuickPlayVersion::find_version(version_index, &minecraft.versions);
|
|
let launch_version_id = version.id.clone();
|
|
let launch_release_time = version.release_time;
|
|
|
|
(
|
|
version_info,
|
|
minecraft_updated,
|
|
version_jar,
|
|
launch_version_id,
|
|
version.type_.clone(),
|
|
quick_play_version,
|
|
crate::launcher::local_version::HmclVersionSettings::default(),
|
|
launch_release_time,
|
|
)
|
|
};
|
|
|
|
if direct_launch.is_none() {
|
|
if offline_mode {
|
|
download::ensure_local_log_config(&state, &version_info)?;
|
|
} else {
|
|
let _ = download_log_config(
|
|
&state,
|
|
None,
|
|
&version_info,
|
|
None,
|
|
false,
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
tracing::info!("[launch-timing] resolve_version_info: {}ms", __lt.elapsed().as_millis());
|
|
__lt = std::time::Instant::now();
|
|
|
|
let java_version = if let Some(java) =
|
|
context.launch_overrides.java_path.as_ref()
|
|
{
|
|
crate::api::jre::check_jre(std::path::PathBuf::from(java)).await?
|
|
} else if direct_launch
|
|
.as_ref()
|
|
.is_some_and(|direct| direct.dialect == LinkedLauncherDialect::Hmcl)
|
|
&& let Some(java) = select_hmcl_java(
|
|
direct_launch.as_ref().expect("checked above"),
|
|
&hmcl_settings,
|
|
)
|
|
.await
|
|
{
|
|
// The linked launcher configured its own Java runtime for this
|
|
// version; prefer it over Axolotl's discovery.
|
|
java
|
|
} else if let (Some(direct), Some(pcl)) =
|
|
(direct_launch.as_ref(), pcl_launch.as_ref())
|
|
&& matches!(
|
|
direct.dialect,
|
|
LinkedLauncherDialect::Pcl | LinkedLauncherDialect::PclCe
|
|
)
|
|
&& let Some(java) = select_pcl_java(direct, pcl).await
|
|
{
|
|
// The linked PCL/PCL-CE instance selected its own Java runtime;
|
|
// prefer it over Axolotl's discovery.
|
|
java
|
|
} else {
|
|
let key = required_java_major(&version_info);
|
|
|
|
if let Some(java) = crate::api::jre::find_java_for_version(key).await? {
|
|
java
|
|
} else if let Some(java) =
|
|
crate::api::jre::find_compatible_java_for_version(key).await?
|
|
{
|
|
tracing::info!(
|
|
version = java.version,
|
|
java = java.path,
|
|
"Using a compatible Java runtime instead of the recommended version"
|
|
);
|
|
java
|
|
} else {
|
|
return Err(crate::ErrorKind::LauncherError(
|
|
"Missing correct java installation".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
};
|
|
|
|
// Test jre version
|
|
let java_version =
|
|
crate::api::jre::check_jre(java_version.path.clone().into()).await?;
|
|
validate_loader_java_version(&version_info, &java_version)?;
|
|
tracing::info!("[launch-timing] resolve_java: {}ms", __lt.elapsed().as_millis());
|
|
__lt = std::time::Instant::now();
|
|
|
|
// Runtime-verify and fall back for GC arguments against the *actual* JVM
|
|
// that will run Minecraft. The frontend supplies an ordered candidate
|
|
// chain; we keep the preferred strategy only if this JVM understands it,
|
|
// pruning unsupported tuning flags and falling back down the chain as
|
|
// needed. A `None` keeps the args passed in untouched.
|
|
let mut resolved_java_args = java_args.to_vec();
|
|
// Custom JVM arguments from the linked launcher enter the same GC
|
|
// compatibility pipeline below: unsupported tuning flags are pruned
|
|
// against the actual JVM instead of reaching it unvalidated.
|
|
if let Some(pcl) = &pcl_launch {
|
|
resolved_java_args.extend(pcl.extra_jvm_args());
|
|
}
|
|
if let Some(gc_intent) = gc_intent {
|
|
tracing::info!(
|
|
java = %java_version.path,
|
|
"Verifying GC arguments against the selected JVM",
|
|
);
|
|
let report = crate::launcher::jvm_args::resolve_gc_block(
|
|
Path::new(java_version.path.as_str()),
|
|
&mut resolved_java_args,
|
|
&gc_intent,
|
|
)
|
|
.await;
|
|
if report.fell_back() {
|
|
tracing::info!(?report, "GC arguments adjusted for this JVM");
|
|
}
|
|
*gc_report = Some(report);
|
|
}
|
|
tracing::info!("[launch-timing] resolve_gc: {}ms", __lt.elapsed().as_millis());
|
|
__lt = std::time::Instant::now();
|
|
|
|
// Apply the mappable linked-launcher private settings for directly
|
|
// associated instances on top of the resolved launch configuration.
|
|
let mut effective_memory = *memory;
|
|
let mut effective_resolution = *resolution;
|
|
// Extra game arguments appended after the vanilla game arguments.
|
|
let mut extra_game_args =
|
|
crate::pack::hosted::game_arguments(&instance.id).await?;
|
|
if let Some(direct) = direct_launch.as_ref()
|
|
&& direct.dialect == LinkedLauncherDialect::Hmcl
|
|
{
|
|
let hmcl_effective = hmcl_with_global_fallback(
|
|
&hmcl_settings,
|
|
direct.launcher_root.as_deref(),
|
|
);
|
|
extra_game_args.extend(apply_hmcl_settings(
|
|
&hmcl_effective,
|
|
&mut effective_memory,
|
|
&mut effective_resolution,
|
|
&mut resolved_java_args,
|
|
Some(java_version.parsed_version),
|
|
));
|
|
} else if let (Some(direct), Some(pcl)) =
|
|
(direct_launch.as_ref(), pcl_launch.as_ref())
|
|
&& matches!(
|
|
direct.dialect,
|
|
LinkedLauncherDialect::Pcl | LinkedLauncherDialect::PclCe
|
|
)
|
|
{
|
|
// PCL's RAM estimate needs the mod presence of the resolved game
|
|
// directory and the bitness of the JVM chosen above.
|
|
let (modable, opti_fine) =
|
|
pcl_ram_profile(version_info.libraries.as_slice());
|
|
let is_32_bit_java =
|
|
crate::launcher::direct_link::linked_architecture_width(
|
|
&java_version.architecture,
|
|
) == "32";
|
|
if let Some(maximum) = pcl.resolve_ram_mb(
|
|
&instance_path,
|
|
modable,
|
|
opti_fine,
|
|
pcl_available_memory_gb(),
|
|
is_32_bit_java,
|
|
) {
|
|
tracing::info!(
|
|
maximum,
|
|
"Using the memory allocation configured by the linked PCL \
|
|
instance"
|
|
);
|
|
effective_memory.maximum = maximum;
|
|
}
|
|
extra_game_args.extend(pcl.extra_game_args());
|
|
}
|
|
|
|
let settings = crate::state::Settings::get(&state.pool).await?;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
if settings.auto_set_java_high_performance_mode {
|
|
if let Err(error) =
|
|
set_high_performance_gpu_preference(&java_version.path)
|
|
{
|
|
tracing::warn!(%error, java = %java_version.path, "Failed to set Java high-performance GPU preference");
|
|
}
|
|
|
|
match std::env::current_exe() {
|
|
Ok(launcher_path) => {
|
|
if let Err(error) =
|
|
set_high_performance_gpu_preference(&launcher_path)
|
|
{
|
|
tracing::warn!(%error, launcher = %launcher_path.display(), "Failed to set launcher high-performance GPU preference");
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, "Failed to determine launcher executable for high-performance GPU preference");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
let high_performance_gpu_environment =
|
|
if settings.auto_set_java_high_performance_mode {
|
|
high_performance_gpu_environment().await
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let client_path = if let Some(direct) = &direct_launch {
|
|
// The game jar lives inside the linked installation.
|
|
direct.client_jar(&version_jar)
|
|
} else {
|
|
let vanilla_client_path = state
|
|
.directories
|
|
.version_dir(&version_jar)
|
|
.join(format!("{version_jar}.jar"));
|
|
match crate::api::instance::assemble_for_launch(
|
|
&instance_path,
|
|
&content_set.game_version,
|
|
&vanilla_client_path,
|
|
)
|
|
.await?
|
|
{
|
|
Some(assembled) => assembled,
|
|
None => vanilla_client_path,
|
|
}
|
|
};
|
|
tracing::info!("[launch-timing] assemble_client: {}ms", __lt.elapsed().as_millis());
|
|
__lt = std::time::Instant::now();
|
|
|
|
let args = version_info.arguments.clone().unwrap_or_default();
|
|
let mut command = match wrapper {
|
|
Some(hook) => {
|
|
let mut cmd = shlex::split(hook)
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::LauncherError(format!(
|
|
"Invalid wrapper command: {hook}",
|
|
))
|
|
})?
|
|
.into_iter();
|
|
let mut command = Command::new(cmd.next().ok_or(
|
|
crate::ErrorKind::LauncherError(
|
|
"Empty wrapper command".to_owned(),
|
|
),
|
|
)?);
|
|
command.args(cmd);
|
|
command.arg(&java_version.path);
|
|
command
|
|
}
|
|
None => Command::new(&java_version.path),
|
|
};
|
|
|
|
let env_args = Vec::from(env_args);
|
|
|
|
// Check if instance has a running process, and reject running the command if it does
|
|
// Done late so a quick double call doesn't launch two instances
|
|
let existing_processes = process::get_by_instance_id(&instance.id).await?;
|
|
if let Some(process) = existing_processes.first() {
|
|
return Err(crate::ErrorKind::LauncherError(format!(
|
|
"Instance {} is already running as process {}",
|
|
instance.id, process.uuid
|
|
))
|
|
.as_error());
|
|
}
|
|
|
|
// Ensure phase (HMCL/PCL parity): before anything reads from the linked
|
|
// installation, complete its missing libraries, assets, and logging
|
|
// config in the standard shared locations. Version JSONs, launcher
|
|
// private configuration, and game data are never written. Offline mode
|
|
// forbids network fetches, so it keeps the previous strict behavior.
|
|
if !offline_mode
|
|
&& let (Some(direct), Some(libraries)) =
|
|
(&direct_launch, linked_libraries.as_deref())
|
|
{
|
|
let __t_ensure = std::time::Instant::now();
|
|
direct_ensure::ensure_direct_launch_dependencies(
|
|
&state,
|
|
direct,
|
|
libraries,
|
|
&version_info,
|
|
&java_version.architecture,
|
|
minecraft_updated,
|
|
)
|
|
.await?;
|
|
tracing::info!("[launch-timing] ensure_deps(libs+assets+log): {}ms", __t_ensure.elapsed().as_millis());
|
|
}
|
|
|
|
let natives_dir = if let Some(direct) = &direct_launch {
|
|
state
|
|
.directories
|
|
.version_natives_dir(&direct.natives_cache_key())
|
|
} else {
|
|
state.directories.version_natives_dir(&version_jar)
|
|
};
|
|
// Linked native archives can change outside Axolotl, so rebuild only the
|
|
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
|
|
let __t_rm = std::time::Instant::now();
|
|
if direct_launch.is_some() && natives_dir.exists() {
|
|
io::remove_dir_all(&natives_dir).await?;
|
|
}
|
|
tracing::info!("[launch-timing] remove_old_natives: {}ms", __t_rm.elapsed().as_millis());
|
|
if !natives_dir.exists() {
|
|
io::create_dir_all(&natives_dir).await?;
|
|
}
|
|
if let (Some(direct), Some(libraries)) =
|
|
(direct_launch.clone(), linked_libraries.clone())
|
|
{
|
|
// Linked instances rebuild the Axolotl-owned natives cache on every
|
|
// launch; the managed restore path below must never touch them.
|
|
let target = natives_dir.clone();
|
|
let java_arch = java_version.architecture.clone();
|
|
let __t_extract = std::time::Instant::now();
|
|
tokio::task::spawn_blocking(move || {
|
|
extract_linked_natives(
|
|
&direct,
|
|
&libraries,
|
|
&target,
|
|
&java_arch,
|
|
minecraft_updated,
|
|
)
|
|
})
|
|
.await??;
|
|
tracing::info!("[launch-timing] extract_linked_natives: {}ms", __t_extract.elapsed().as_millis());
|
|
} else if direct_launch.is_none() {
|
|
if offline_mode {
|
|
natives::prepare_native_libraries(
|
|
&state.directories.natives_dir(),
|
|
&state.directories.libraries_dir(),
|
|
&state.directories.caches_dir(),
|
|
version_info.libraries.as_slice(),
|
|
&version_jar,
|
|
&java_version.architecture,
|
|
minecraft_updated,
|
|
)
|
|
.await?;
|
|
} else {
|
|
download::download_libraries(
|
|
&state,
|
|
None,
|
|
version_info.libraries.as_slice(),
|
|
&version_jar,
|
|
None,
|
|
0.0,
|
|
&java_version.architecture,
|
|
false,
|
|
minecraft_updated,
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
tracing::info!("[launch-timing] ensure_libraries_and_natives: {}ms", __lt.elapsed().as_millis());
|
|
__lt = std::time::Instant::now();
|
|
tracing::debug!(
|
|
"Found QuickPlayVersion for {}: {quick_play_version:?}",
|
|
content_set.game_version
|
|
);
|
|
if let QuickPlayType::Server(address) = &mut quick_play_type
|
|
&& quick_play_version.server >= QuickPlayServerVersion::BuiltinLegacy
|
|
{
|
|
// Record last-played for the original server address immediately so
|
|
// recent-worlds can match without DNS/SRV resolution.
|
|
let original = match address {
|
|
ServerAddress::Unresolved(address) => parse_server_address(address)
|
|
.ok()
|
|
.map(|(h, p)| (h.to_owned(), p)),
|
|
ServerAddress::Resolved {
|
|
original_host,
|
|
original_port,
|
|
..
|
|
} => Some((original_host.clone(), *original_port)),
|
|
};
|
|
if let Some((host, port)) = original
|
|
&& let Err(e) = (JoinLogEntry {
|
|
instance_id: instance.id.clone(),
|
|
host,
|
|
port,
|
|
join_time: Utc::now(),
|
|
})
|
|
.upsert(&state.pool)
|
|
.await
|
|
{
|
|
tracing::warn!("Failed to write server join log entry: {e}");
|
|
}
|
|
|
|
address.resolve().await?;
|
|
}
|
|
|
|
let (main_class_keep_alive, main_class_path) =
|
|
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
|
|
let yggdrasil_agent = if credentials.is_yggdrasil() {
|
|
let account = credentials.yggdrasil.as_ref().ok_or_else(|| {
|
|
crate::ErrorKind::LauncherError(
|
|
"Yggdrasil account metadata is missing".to_string(),
|
|
)
|
|
.as_error()
|
|
})?;
|
|
let metadata =
|
|
crate::state::fetch_yggdrasil_metadata(&account.api_root).await?;
|
|
let agent_path =
|
|
main_class_keep_alive.path().join("authlib-injector.jar");
|
|
io::write(
|
|
&agent_path,
|
|
include_bytes!(concat!(
|
|
env!("JAVA_JARS_DIR"),
|
|
"/authlib-injector.jar"
|
|
)),
|
|
)
|
|
.await?;
|
|
Some((
|
|
io::canonicalize(agent_path)?,
|
|
metadata.api_root,
|
|
BASE64_STANDARD.encode(metadata.raw),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let rpc_server = RpcServerBuilder::new().launch().await?;
|
|
|
|
let launch_libraries_dir = runtime.libraries_dir(&state.directories);
|
|
let launch_log_configs_dir = runtime.log_configs_dir(&state.directories);
|
|
let class_paths = if let (Some(direct), Some(libraries)) =
|
|
(&direct_launch, linked_libraries.as_deref())
|
|
{
|
|
args::get_linked_class_paths(
|
|
direct,
|
|
libraries,
|
|
&[&main_class_path, &client_path],
|
|
&java_version.architecture,
|
|
minecraft_updated,
|
|
)?
|
|
} else {
|
|
args::get_class_paths(
|
|
&launch_libraries_dir,
|
|
version_info.libraries.as_slice(),
|
|
&[&main_class_path, &client_path],
|
|
&java_version.architecture,
|
|
minecraft_updated,
|
|
)?
|
|
};
|
|
|
|
command.args(
|
|
args::get_jvm_arguments(
|
|
args.get(&d::minecraft::ArgumentType::Jvm)
|
|
.map(|x| x.as_slice()),
|
|
&natives_dir,
|
|
&launch_libraries_dir,
|
|
&launch_log_configs_dir,
|
|
&class_paths,
|
|
&main_class_path,
|
|
&version_jar,
|
|
effective_memory,
|
|
resolved_java_args.clone(),
|
|
&java_version.architecture,
|
|
&quick_play_type,
|
|
quick_play_version,
|
|
version_info
|
|
.logging
|
|
.as_ref()
|
|
.and_then(|x| x.get(&LoggingSide::Client)),
|
|
rpc_server.address(),
|
|
)?
|
|
.into_iter(),
|
|
);
|
|
|
|
// The java launcher requires access to java.lang.reflect in order to force access in to
|
|
// whatever module the main class is in
|
|
if java_version.parsed_version >= 9 {
|
|
command.arg("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
|
}
|
|
|
|
// The java launcher code requires internal JDK code in Java 25+ in order to support JEP 512
|
|
if java_version.parsed_version >= 25 {
|
|
command.arg("--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED");
|
|
}
|
|
|
|
if let Some((agent_path, api_root, prefetched_metadata)) = yggdrasil_agent {
|
|
command
|
|
.arg(format!(
|
|
"-javaagent:{}={api_root}",
|
|
agent_path.to_string_lossy()
|
|
))
|
|
.arg("-Dauthlibinjector.side=client")
|
|
.arg(format!(
|
|
"-Dauthlibinjector.yggdrasil.prefetched={prefetched_metadata}"
|
|
));
|
|
}
|
|
|
|
let launch_assets_dir = runtime.assets_dir(&state.directories);
|
|
|
|
command
|
|
.arg("com.modrinth.theseus.MinecraftLaunch")
|
|
.arg(version_info.main_class.clone())
|
|
.args(
|
|
args::get_minecraft_arguments(
|
|
args.get(&d::minecraft::ArgumentType::Game)
|
|
.map(|x| x.as_slice()),
|
|
version_info.minecraft_arguments.as_deref(),
|
|
credentials,
|
|
&launch_version_id,
|
|
&version_info.asset_index.id,
|
|
&instance_path,
|
|
&launch_assets_dir,
|
|
&launch_version_type,
|
|
effective_resolution,
|
|
&java_version.architecture,
|
|
&quick_play_type,
|
|
quick_play_version,
|
|
)
|
|
.await?
|
|
.into_iter()
|
|
.map(encode_game_argument),
|
|
)
|
|
// Linked-launcher game arguments (PCL VersionAdvanceGame, HMCL
|
|
// auto-connect server) ride at the tail of the vanilla arguments.
|
|
.args(extra_game_args)
|
|
.current_dir(instance_path.clone());
|
|
|
|
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
|
|
#[cfg(target_os = "macos")]
|
|
if std::env::var("CARGO").is_ok() {
|
|
command.env_remove("DYLD_FALLBACK_LIBRARY_PATH");
|
|
}
|
|
// Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them)
|
|
command.env_remove("_JAVA_OPTIONS");
|
|
|
|
command.envs(env_args);
|
|
|
|
#[cfg(target_os = "linux")]
|
|
command.envs(high_performance_gpu_environment);
|
|
|
|
// Overwrites the minecraft options.txt file with the settings from the profile
|
|
// Uses 'a:b' syntax which is not quite yaml
|
|
// Directly associated instances never write into the linked folder.
|
|
let options_path = instance_path.join("options.txt");
|
|
let options_existed = options_path.exists();
|
|
|
|
if direct_launch.is_none()
|
|
&& (!mc_set_options.is_empty()
|
|
|| offline_skin_pack.enabled_pack_id.is_some()
|
|
|| options_existed
|
|
|| !settings.locale.is_empty())
|
|
{
|
|
let (mut options_string, input_encoding) = if options_existed {
|
|
io::read_any_encoding_to_string(&options_path).await?
|
|
} else {
|
|
(String::new(), encoding_rs::UTF_8)
|
|
};
|
|
|
|
// UTF-16 encodings may be successfully detected and read, but we cannot encode
|
|
// them back, and it's technically possible that the game client strongly expects
|
|
// such encoding
|
|
if input_encoding != input_encoding.output_encoding() {
|
|
return Err(crate::ErrorKind::LauncherError(format!(
|
|
"The instance options.txt file uses an unsupported encoding: {}. \
|
|
Please either turn off instance options that need to modify this file, \
|
|
or convert the file to an encoding that both the game and this app support, \
|
|
such as UTF-8.",
|
|
input_encoding.name()
|
|
))
|
|
.into());
|
|
}
|
|
|
|
let language_options = language::game_language_options(
|
|
&settings.locale,
|
|
launch_release_time,
|
|
&options_string,
|
|
instance_path.join("saves").exists(),
|
|
);
|
|
|
|
if !mc_set_options.is_empty()
|
|
|| !language_options.is_empty()
|
|
|| offline_skin_pack.enabled_pack_id.is_some()
|
|
|| options_existed
|
|
{
|
|
for (key, value) in
|
|
mc_set_options.iter().chain(language_options.iter())
|
|
{
|
|
let re =
|
|
Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
|
|
// check if the regex exists in the file
|
|
if !re.is_match(&options_string) {
|
|
// The key was not found in the file, so append it
|
|
write!(&mut options_string, "\n{key}:{value}").unwrap();
|
|
} else {
|
|
let replaced_string = re
|
|
.replace_all(&options_string, &format!("{key}:{value}"))
|
|
.to_string();
|
|
options_string = replaced_string;
|
|
}
|
|
}
|
|
|
|
update_offline_skin_resource_pack_option(
|
|
&mut options_string,
|
|
offline_skin_pack,
|
|
)?;
|
|
|
|
io::write(&options_path, input_encoding.encode(&options_string).0)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
crate::state::instances::commands::set_instance_last_played(
|
|
&instance.id,
|
|
Utc::now(),
|
|
&state.pool,
|
|
)
|
|
.await?;
|
|
|
|
// The launcher log must land where the game and log browser read it. The
|
|
// resolved directory is PCL's PathIndie equivalent for direct links.
|
|
let logs_folder = state.directories.game_logs_dir(&instance_path);
|
|
// Create Minecraft child by inserting it into the state
|
|
// This also spawns the process and prepares the subsequent processes
|
|
let __launch_process = state
|
|
.process_manager
|
|
.insert_new_process(
|
|
&instance.id,
|
|
&instance.path,
|
|
&instance.name,
|
|
command,
|
|
post_exit_hook,
|
|
maximize_window,
|
|
instance_path.clone(),
|
|
logs_folder,
|
|
version_info.logging.is_some(),
|
|
main_class_keep_alive,
|
|
rpc_server,
|
|
async |process: &ProcessMetadata, rpc_server| {
|
|
let process_start_time = process.start_time.to_rfc3339();
|
|
let instance_created_time = instance.created.to_rfc3339();
|
|
let instance_modified_time = instance.modified.to_rfc3339();
|
|
let (link_project_id, link_version_id) =
|
|
link_project_and_version(&context.link);
|
|
let system_properties = [
|
|
("modrinth.process.startTime", Some(&process_start_time)),
|
|
("modrinth.profile.created", Some(&instance_created_time)),
|
|
("modrinth.profile.icon", instance.icon_path.as_ref()),
|
|
("modrinth.profile.link.project", link_project_id),
|
|
("modrinth.profile.link.version", link_version_id),
|
|
(
|
|
"modrinth.profile.modified",
|
|
Some(&instance_modified_time),
|
|
),
|
|
("modrinth.profile.name", Some(&instance.name)),
|
|
];
|
|
for (key, value) in system_properties {
|
|
let Some(value) = value else {
|
|
continue;
|
|
};
|
|
rpc_server
|
|
.call_method_2::<()>("set_system_property", key, value)
|
|
.await?;
|
|
}
|
|
rpc_server.call_method::<()>("launch").await?;
|
|
Ok(())
|
|
},
|
|
)
|
|
.await;
|
|
tracing::info!("[launch-timing] process_spawn: {}ms", __lt.elapsed().as_millis());
|
|
__launch_process
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod game_argument_encoding_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn ascii_game_argument_stays_unchanged() {
|
|
assert_eq!(
|
|
encode_game_argument("--username".to_string()),
|
|
"--username"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_ascii_game_argument_uses_ascii_transport() {
|
|
let original = r"E:\Games\Minecraft\profiles\Prominence™ II";
|
|
let encoded = encode_game_argument(original.to_string());
|
|
|
|
assert!(encoded.is_ascii());
|
|
let payload = encoded
|
|
.strip_prefix(UTF8_GAME_ARGUMENT_PREFIX)
|
|
.expect("non-ASCII argument should be encoded");
|
|
assert_eq!(
|
|
BASE64_STANDARD.decode(payload).unwrap(),
|
|
original.as_bytes()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_prefix_is_escaped() {
|
|
let original = format!("{UTF8_GAME_ARGUMENT_PREFIX}literal");
|
|
let encoded = encode_game_argument(original.clone());
|
|
let payload = encoded.strip_prefix(UTF8_GAME_ARGUMENT_PREFIX).unwrap();
|
|
|
|
assert_eq!(
|
|
BASE64_STANDARD.decode(payload).unwrap(),
|
|
original.as_bytes()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn update_offline_skin_resource_pack_option(
|
|
options_string: &mut String,
|
|
offline_skin_pack: crate::minecraft_skins::OfflineSkinPackOptions,
|
|
) -> crate::Result<()> {
|
|
use crate::minecraft_skins::{
|
|
OFFLINE_SKIN_PACK_LEGACY_ID, OFFLINE_SKIN_PACK_MODERN_ID,
|
|
};
|
|
|
|
let resource_packs = Regex::new(r"(?m)^resourcePacks:(.*)$")?;
|
|
let existing_value = resource_packs
|
|
.captures(options_string)
|
|
.and_then(|captures| captures.get(1))
|
|
.map_or("[]", |value| value.as_str().trim());
|
|
if offline_skin_pack.enabled_pack_id.is_none()
|
|
&& !existing_value.contains(OFFLINE_SKIN_PACK_LEGACY_ID)
|
|
&& !existing_value.contains(OFFLINE_SKIN_PACK_MODERN_ID)
|
|
{
|
|
return Ok(());
|
|
}
|
|
let Ok(mut packs) = serde_json::from_str::<Vec<String>>(existing_value)
|
|
else {
|
|
tracing::warn!(
|
|
"Skipping offline skin resource-pack option update because resourcePacks in options.txt is malformed"
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
packs.retain(|pack| {
|
|
pack != OFFLINE_SKIN_PACK_LEGACY_ID
|
|
&& pack != OFFLINE_SKIN_PACK_MODERN_ID
|
|
});
|
|
if let Some(pack_id) = offline_skin_pack.enabled_pack_id
|
|
&& !packs.iter().any(|pack| pack == pack_id)
|
|
{
|
|
if pack_id == OFFLINE_SKIN_PACK_MODERN_ID
|
|
&& !packs.iter().any(|pack| pack == "vanilla")
|
|
{
|
|
packs.insert(0, "vanilla".to_string());
|
|
}
|
|
packs.push(pack_id.to_string());
|
|
}
|
|
|
|
let value = serde_json::to_string(&packs)?;
|
|
if resource_packs.is_match(options_string) {
|
|
*options_string = resource_packs
|
|
.replace_all(options_string, format!("resourcePacks:{value}"))
|
|
.to_string();
|
|
} else if options_string.is_empty() {
|
|
write!(options_string, "resourcePacks:{value}").unwrap();
|
|
} else {
|
|
write!(options_string, "\nresourcePacks:{value}").unwrap();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod loader_resolution_tests {
|
|
use super::*;
|
|
use daedalus::minecraft::RuleAction;
|
|
|
|
#[test]
|
|
fn disallow_only_library_rules_are_not_downloaded() {
|
|
let rules = [d::minecraft::Rule {
|
|
action: RuleAction::Disallow,
|
|
os: None,
|
|
features: None,
|
|
}];
|
|
|
|
assert!(!parse_rules(&rules, "8", &QuickPlayType::None, false,));
|
|
assert!(parse_rules(&[], "8", &QuickPlayType::None, false,));
|
|
}
|
|
|
|
fn loader_version(id: &str, stable: bool) -> LoaderVersion {
|
|
LoaderVersion {
|
|
id: id.to_string(),
|
|
url: format!("https://example.invalid/{id}"),
|
|
stable,
|
|
profile_source: Default::default(),
|
|
fallback_url: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn liteloader_runtime_detection_uses_the_maven_coordinate() {
|
|
assert!(is_liteloader_library(
|
|
"com.mumfrey:liteloader:1.12.2-SNAPSHOT"
|
|
));
|
|
assert!(!is_liteloader_library("example:liteloader:1.12.2-SNAPSHOT"));
|
|
assert!(!is_liteloader_library("com.mumfrey:liteloader"));
|
|
}
|
|
|
|
#[test]
|
|
fn repair_exact_loader_selection_never_substitutes_or_persists_fallback() {
|
|
let versions = vec![
|
|
loader_version("47.4.22", true),
|
|
loader_version("47.2.0", false),
|
|
];
|
|
|
|
assert_eq!(
|
|
select_loader_version(&versions, Some("47.2.0"))
|
|
.map(|version| version.id.as_str()),
|
|
Some("47.2.0")
|
|
);
|
|
assert!(select_loader_version(&versions, Some("47.1.0")).is_none());
|
|
assert_eq!(
|
|
select_loader_version(&versions, None)
|
|
.map(|version| version.id.as_str()),
|
|
Some("47.4.22")
|
|
);
|
|
assert_eq!(explicit_loader_version(Some("47.1.0")), Some("47.1.0"));
|
|
assert_eq!(explicit_loader_version(None), None);
|
|
assert_eq!(explicit_loader_version(Some("latest")), None);
|
|
assert_eq!(explicit_loader_version(Some("stable")), None);
|
|
assert_eq!(
|
|
missing_loader_version_fallback(
|
|
ModLoader::Forge,
|
|
"1.20.1",
|
|
Some("47.1.0")
|
|
),
|
|
Err("Loader version 47.1.0 is not available for forge 1.20.1"
|
|
.to_string())
|
|
);
|
|
assert_eq!(
|
|
missing_loader_version_fallback(ModLoader::Forge, "1.20.1", None),
|
|
Ok("stable")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn installed_instances_use_persisted_exact_loader_offline() {
|
|
for loader in [
|
|
ModLoader::Fabric,
|
|
ModLoader::Forge,
|
|
ModLoader::NeoForge,
|
|
ModLoader::Quilt,
|
|
] {
|
|
let version = installed_offline_loader_version(
|
|
loader,
|
|
Some("persisted-exact-version"),
|
|
)
|
|
.expect("installed exact loader should not require metadata");
|
|
assert_eq!(version.id, "persisted-exact-version", "{loader:?}");
|
|
assert!(version.url.is_empty(), "{loader:?}");
|
|
}
|
|
|
|
assert!(
|
|
installed_offline_loader_version(ModLoader::Forge, Some("latest"))
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
installed_offline_loader_version(ModLoader::Forge, None).is_none()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod processor_output_tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
|
|
fn processor(
|
|
path: impl Into<String>,
|
|
hash: impl Into<String>,
|
|
) -> d::modded::Processor {
|
|
d::modded::Processor {
|
|
jar: "example:processor:1.0".to_string(),
|
|
classpath: Vec::new(),
|
|
args: Vec::new(),
|
|
outputs: Some(HashMap::from([(path.into(), hash.into())])),
|
|
sides: None,
|
|
}
|
|
}
|
|
|
|
fn data(
|
|
path: impl Into<String>,
|
|
hash: impl Into<String>,
|
|
) -> HashMap<String, d::modded::SidedDataEntry> {
|
|
HashMap::from([
|
|
(
|
|
"OUTPUT".to_string(),
|
|
d::modded::SidedDataEntry {
|
|
client: path.into(),
|
|
server: String::new(),
|
|
},
|
|
),
|
|
(
|
|
"HASH".to_string(),
|
|
d::modded::SidedDataEntry {
|
|
client: hash.into(),
|
|
server: String::new(),
|
|
},
|
|
),
|
|
])
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn valid_processor_output_skips_execution() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let libraries = directory.path().join("libraries");
|
|
tokio::fs::create_dir_all(&libraries).await.unwrap();
|
|
let output = libraries.join("output.jar");
|
|
tokio::fs::write(&output, b"valid processor output")
|
|
.await
|
|
.unwrap();
|
|
let hash = sha1_smol::Sha1::from(b"valid processor output").hexdigest();
|
|
let data = data(output.to_string_lossy(), format!("'{hash}'"));
|
|
|
|
assert!(
|
|
processor_outputs_are_current(
|
|
&processor("{OUTPUT}", "{HASH}"),
|
|
&libraries,
|
|
&data,
|
|
&[&libraries],
|
|
)
|
|
.await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn binary_patcher_uses_the_matching_output_sha() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let libraries = directory.path().join("libraries");
|
|
tokio::fs::create_dir_all(&libraries).await.unwrap();
|
|
let output = libraries.join("patched.jar");
|
|
tokio::fs::write(&output, b"patched minecraft")
|
|
.await
|
|
.unwrap();
|
|
let hash = sha1_smol::Sha1::from(b"patched minecraft").hexdigest();
|
|
let data = HashMap::from([
|
|
(
|
|
"PATCHED".to_string(),
|
|
d::modded::SidedDataEntry {
|
|
client: output.to_string_lossy().into_owned(),
|
|
server: String::new(),
|
|
},
|
|
),
|
|
(
|
|
"PATCHED_SHA".to_string(),
|
|
d::modded::SidedDataEntry {
|
|
client: format!("'{hash}'"),
|
|
server: String::new(),
|
|
},
|
|
),
|
|
]);
|
|
let processor = d::modded::Processor {
|
|
jar: "net.minecraftforge:binarypatcher:1.1.1".to_string(),
|
|
classpath: Vec::new(),
|
|
args: vec!["--output".to_string(), "{PATCHED}".to_string()],
|
|
outputs: None,
|
|
sides: None,
|
|
};
|
|
|
|
assert!(
|
|
processor_outputs_are_current(
|
|
&processor,
|
|
&libraries,
|
|
&data,
|
|
&[&libraries],
|
|
)
|
|
.await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_processor_output_runs_execution() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let libraries = directory.path().join("libraries");
|
|
tokio::fs::create_dir_all(&libraries).await.unwrap();
|
|
let output = libraries.join("missing.jar");
|
|
let data = data(
|
|
output.to_string_lossy(),
|
|
"0000000000000000000000000000000000000000",
|
|
);
|
|
|
|
assert!(
|
|
!processor_outputs_are_current(
|
|
&processor("{OUTPUT}", "{HASH}"),
|
|
&libraries,
|
|
&data,
|
|
&[&libraries],
|
|
)
|
|
.await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrong_processor_output_hash_runs_execution() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let libraries = directory.path().join("libraries");
|
|
tokio::fs::create_dir_all(&libraries).await.unwrap();
|
|
let output = libraries.join("output.jar");
|
|
tokio::fs::write(&output, b"wrong hash").await.unwrap();
|
|
let data = data(
|
|
output.to_string_lossy(),
|
|
"0000000000000000000000000000000000000000",
|
|
);
|
|
|
|
assert!(
|
|
!processor_outputs_are_current(
|
|
&processor("{OUTPUT}", "{HASH}"),
|
|
&libraries,
|
|
&data,
|
|
&[&libraries],
|
|
)
|
|
.await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_or_escaped_processor_outputs_run_execution() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let libraries = directory.path().join("libraries");
|
|
tokio::fs::create_dir_all(&libraries).await.unwrap();
|
|
let outside = directory.path().join("outside.jar");
|
|
tokio::fs::write(&outside, b"outside").await.unwrap();
|
|
let hash = sha1_smol::Sha1::from(b"outside").hexdigest();
|
|
let escaped_data = data(outside.to_string_lossy(), &hash);
|
|
|
|
assert!(
|
|
!processor_outputs_are_current(
|
|
&processor("{OUTPUT}", "{HASH}"),
|
|
&libraries,
|
|
&escaped_data,
|
|
&[&libraries],
|
|
)
|
|
.await
|
|
);
|
|
let malformed_data = data(outside.to_string_lossy(), "not-a-sha1");
|
|
assert!(
|
|
!processor_outputs_are_current(
|
|
&processor("{OUTPUT}", "{HASH}"),
|
|
&libraries,
|
|
&malformed_data,
|
|
&[directory.path()],
|
|
)
|
|
.await
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod offline_skin_resource_pack_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn enables_offline_skin_pack_when_options_file_is_new() {
|
|
let mut options = String::new();
|
|
|
|
update_offline_skin_resource_pack_option(
|
|
&mut options,
|
|
crate::minecraft_skins::OfflineSkinPackOptions {
|
|
enabled_pack_id: Some(
|
|
crate::minecraft_skins::OFFLINE_SKIN_PACK_MODERN_ID,
|
|
),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
options,
|
|
"resourcePacks:[\"vanilla\",\"file/Axolotl Offline Skin.zip\"]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn adds_modern_offline_skin_pack_without_removing_existing_packs() {
|
|
let mut options =
|
|
"resourcePacks:[\"vanilla\",\"file/user-pack.zip\"]".to_string();
|
|
|
|
update_offline_skin_resource_pack_option(
|
|
&mut options,
|
|
crate::minecraft_skins::OfflineSkinPackOptions {
|
|
enabled_pack_id: Some(
|
|
crate::minecraft_skins::OFFLINE_SKIN_PACK_MODERN_ID,
|
|
),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
options,
|
|
"resourcePacks:[\"vanilla\",\"file/user-pack.zip\",\"file/Axolotl Offline Skin.zip\"]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn removes_both_offline_skin_pack_id_variants() {
|
|
let mut options = "resourcePacks:[\"vanilla\",\"Axolotl Offline Skin.zip\",\"file/Axolotl Offline Skin.zip\"]".to_string();
|
|
|
|
update_offline_skin_resource_pack_option(
|
|
&mut options,
|
|
crate::minecraft_skins::OfflineSkinPackOptions {
|
|
enabled_pack_id: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(options, "resourcePacks:[\"vanilla\"]");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod linked_rule_tests {
|
|
use super::*;
|
|
use d::minecraft::{FeatureRule, OsRule, Rule};
|
|
|
|
fn rule(
|
|
action: RuleAction,
|
|
os: Option<OsRule>,
|
|
features: Option<FeatureRule>,
|
|
) -> Rule {
|
|
Rule {
|
|
action,
|
|
os,
|
|
features,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ordered_rules_use_the_last_matching_action() {
|
|
let rules = [
|
|
rule(RuleAction::Allow, None, None),
|
|
rule(RuleAction::Disallow, None, None),
|
|
rule(RuleAction::Allow, None, None),
|
|
];
|
|
assert!(parse_rules(
|
|
&rules,
|
|
std::env::consts::ARCH,
|
|
&QuickPlayType::None,
|
|
true
|
|
));
|
|
assert!(!parse_rules(
|
|
&rules[..2],
|
|
std::env::consts::ARCH,
|
|
&QuickPlayType::None,
|
|
true
|
|
));
|
|
assert!(parse_rules(
|
|
&[],
|
|
std::env::consts::ARCH,
|
|
&QuickPlayType::None,
|
|
true
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn os_and_features_on_one_rule_are_conjunctive() {
|
|
let matching_os = OsRule {
|
|
name: Some(d::minecraft::Os::native_arch(std::env::consts::ARCH)),
|
|
version: None,
|
|
arch: Some(std::env::consts::ARCH.to_string()),
|
|
};
|
|
let custom_resolution = FeatureRule {
|
|
is_demo_user: None,
|
|
has_custom_resolution: Some(true),
|
|
has_quick_plays_support: None,
|
|
is_quick_play_singleplayer: None,
|
|
is_quick_play_multiplayer: None,
|
|
is_quick_play_realms: None,
|
|
};
|
|
let matching_rule = rule(
|
|
RuleAction::Allow,
|
|
Some(matching_os),
|
|
Some(custom_resolution),
|
|
);
|
|
assert!(parse_rules(
|
|
&[matching_rule],
|
|
std::env::consts::ARCH,
|
|
&QuickPlayType::None,
|
|
true
|
|
));
|
|
|
|
let mismatched = rule(
|
|
RuleAction::Allow,
|
|
Some(OsRule {
|
|
name: Some(d::minecraft::Os::native_arch(
|
|
std::env::consts::ARCH,
|
|
)),
|
|
version: None,
|
|
arch: Some("definitely-not-this-architecture".to_string()),
|
|
}),
|
|
Some(FeatureRule {
|
|
is_demo_user: None,
|
|
has_custom_resolution: Some(true),
|
|
has_quick_plays_support: None,
|
|
is_quick_play_singleplayer: None,
|
|
is_quick_play_multiplayer: None,
|
|
is_quick_play_realms: None,
|
|
}),
|
|
);
|
|
assert!(!parse_rules(
|
|
&[mismatched],
|
|
std::env::consts::ARCH,
|
|
&QuickPlayType::None,
|
|
true
|
|
));
|
|
}
|
|
}
|