diff --git a/assets/config.toml b/assets/config.toml index 0ceed75..d05cd51 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -1,9 +1,9 @@ [paths] # where instances are stored -instances_dir = "~/.local/share/mcl/instances" +instances_dir = "~/.local/share/rmcl/instances" # metadata, cache, whatever else ends up there -meta_dir = "~/.local/share/mcl/meta" +meta_dir = "~/.local/share/rmcl/meta" # optional java override (empty = uses JAVA_HOME) java_path = "" diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 5fa9483..ba9086c 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -8,13 +8,19 @@ use std::path::{Path, PathBuf}; use thiserror::Error; -use crate::auth::{Account, AccountType}; +use crate::auth::AccountType; use crate::instance::models::{InstanceConfig, ModLoader}; +use crate::launch_profile::model::LaunchProfile; +use crate::launch_profile::rules::{self, FeatureSet, RuleContext}; +use crate::launch_profile::templates::TemplateContext; +use crate::launch_profile::{render, resolve, system}; #[derive(Debug, Error)] pub enum LaunchError { #[error("Version metadata not found: {0}. Re-create the instance to fix this.")] MetaNotFound(String), + #[error("Profile error: {0}")] + Parse(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] @@ -25,133 +31,175 @@ pub enum LaunchError { Auth(String), } -// subset of mojang's version meta json, only the bits relevant to launching -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct MetaJson { - main_class: String, - asset_index: MetaAssetIndex, - libraries: Vec, -} - -#[derive(serde::Deserialize)] -struct MetaAssetIndex { - id: String, +fn build_game_args( + profile: &LaunchProfile, + rule_ctx: &RuleContext<'_>, + template_ctx: &TemplateContext<'_>, +) -> Result<(Vec, Vec), LaunchError> { + let rendered = render::render_args(profile, rule_ctx, template_ctx) + .map_err(|e| LaunchError::Parse(format!("Failed to render args: {e}")))?; + Ok((rendered.jvm, rendered.game)) } -#[derive(serde::Deserialize)] -struct MetaLibrary { - downloads: Option, - rules: Option>, -} +// existing installs from rmcl <= 0.3.0 have meta.json files in the +// stripped legacy format (no `arguments`, no `minecraftArguments`). every +// real upstream profile has at least one of those fields. on detecting the +// stripped format, re-fetch the version metadata from mojang's manifest +// and overwrite the file with the raw upstream bytes. +async fn migrate_legacy_meta_if_needed( + meta_path: &Path, + profile: &LaunchProfile, + game_version: &str, +) -> Result, LaunchError> { + if profile.arguments.is_some() || profile.minecraft_arguments.is_some() { + return Ok(None); + } -#[derive(serde::Deserialize)] -struct MetaLibraryDownloads { - artifact: Option, -} + tracing::warn!( + "Cached meta.json for {game_version} is missing arguments; re-fetching from Mojang" + ); -#[derive(serde::Deserialize)] -struct MetaArtifact { - path: String, -} + let client = crate::net::HttpClient::new(); + let manifest = match crate::net::mojang::fetch_version_manifest(&client).await { + Ok(m) => m, + Err(e) => { + tracing::warn!( + "Could not reach Mojang manifest ({e}); proceeding with the cached legacy profile. \ + Modern features like Forge's --add-opens flags may be missing until the next online launch." + ); + return Ok(None); + } + }; -#[derive(serde::Deserialize)] -struct MetaRule { - action: String, - os: Option, -} + let entry = manifest + .versions + .iter() + .find(|v| v.id == game_version) + .ok_or_else(|| { + LaunchError::Parse(format!( + "Version {game_version} not found in Mojang manifest" + )) + })?; + + let (_meta, raw) = match crate::net::mojang::fetch_version_meta_with_raw(&client, entry).await { + Ok(ok) => ok, + Err(e) => { + tracing::warn!( + "Could not refetch version metadata from Mojang ({e}); proceeding with the cached legacy profile." + ); + return Ok(None); + } + }; -#[derive(serde::Deserialize)] -struct MetaOsRule { - name: Option, -} + tokio::fs::write(meta_path, &raw).await?; -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct LoaderProfileJson { - main_class: String, - libraries: Vec, - #[serde(default)] - game_arguments: Vec, + let refreshed: LaunchProfile = serde_json::from_slice(&raw) + .map_err(|e| LaunchError::Parse(format!("Failed to parse refreshed meta: {e}")))?; + Ok(Some(refreshed)) } -#[derive(serde::Deserialize)] -struct LoaderLibrary { - name: String, +// the forge/neoforge installer writes its version JSON to a path that's +// loader-specific. encode the naming convention here so migration code +// can find the original file when it needs to rebuild our cache. +fn installer_version_dir_name( + loader: ModLoader, + game_version: &str, + loader_version: &str, +) -> Option { + match loader { + ModLoader::Forge => Some(format!("{game_version}-forge-{loader_version}")), + ModLoader::NeoForge => Some(format!("neoforge-{loader_version}")), + ModLoader::Vanilla | ModLoader::Fabric | ModLoader::Quilt => None, + } } -struct GameAuth { - username: String, - uuid: String, - token: String, - user_type: String, -} +// loader profiles installed by rmcl <= 0.3.0 are in our stripped +// `{mainClass, libraries[, gameArguments]}` format, which silently drops +// `inheritsFrom`, `arguments.jvm`, and conditional rules from upstream. +// detect that shape (no inheritsFrom AND no arguments AND no +// minecraftArguments - every real upstream profile has at least one) and +// rebuild from the installer's original JSON if it's still on disk. +async fn migrate_legacy_loader_profile_if_needed( + profile_path: &Path, + profile: &LaunchProfile, + config: &InstanceConfig, + instance_dir: &Path, +) -> Result, LaunchError> { + // Fabric and Quilt fetch their profiles from a network endpoint at + // install time; there's no installer-written JSON on disk to recover + // from. their upstream profiles also happen to match the "legacy + // stripped" predicate (no inheritsFrom, no arguments), so without this + // early return every Fabric/Quilt launch would incorrectly fail + // migration. resolve() handles their lack of inheritsFrom via the + // implicit fallback in the launch flow. + if matches!(config.loader, ModLoader::Fabric | ModLoader::Quilt) { + return Ok(None); + } -fn account_can_launch(has_microsoft_account: bool, account: &Account) -> bool { - account.account_type == AccountType::Microsoft || has_microsoft_account -} + // tightened predicate per the spec: only treat a profile as "legacy + // stripped" when our old `gameArguments` field is present. that field + // is unique to rmcl <= 0.3.0's custom shape; no upstream profile + // emits it. without this gate, an upstream profile that happens to + // omit inheritsFrom/arguments/minecraftArguments would be mistakenly + // re-extracted from the installer JSON. + let is_legacy = profile.inherits_from.is_none() + && profile.arguments.is_none() + && profile.minecraft_arguments.is_none() + && profile.game_arguments.is_some(); + if !is_legacy { + return Ok(None); + } -// mojang's library rules are a fun little state machine: each rule can allow -// or disallow based on OS. if no rule matches the current OS, the library is -// included only if no rule "dominated" (matched at all). yes, it's weird. -fn lib_allowed(lib: &MetaLibrary) -> bool { - let Some(rules) = &lib.rules else { - return true; + let Some(loader_version) = config.loader_version.as_deref() else { + return Err(LaunchError::Parse(format!( + "Loader profile at {} is in an outdated format and the instance config has no \ + loader_version recorded. Reinstall {} for this instance.", + profile_path.display(), + config.loader + ))); }; - let current_os = match std::env::consts::OS { - "macos" => "osx", - other => other, + let Some(version_dir) = + installer_version_dir_name(config.loader, &config.game_version, loader_version) + else { + // unreachable today: only Vanilla/Fabric/Quilt return None, and + // Vanilla doesn't pass this code path (no loader profile to + // migrate) while Fabric/Quilt are filtered above. + return Err(LaunchError::Parse(format!( + "Loader profile at {} is in an outdated format. Reinstall {} for this instance.", + profile_path.display(), + config.loader + ))); }; - let mut dominated = false; - for rule in rules { - let matches_os = rule - .os - .as_ref() - .and_then(|os| os.name.as_deref()) - .is_none_or(|n| n == current_os); - if !matches_os { - continue; - } - dominated = true; - match rule.action.as_str() { - "disallow" => return false, - "allow" => return true, - _ => {} - } + + let installer_json_path = instance_dir + .join(".minecraft") + .join("versions") + .join(&version_dir) + .join(format!("{version_dir}.json")); + + if !installer_json_path.exists() { + return Err(LaunchError::Parse(format!( + "Loader profile at {} is in an outdated format and the installer JSON at {} \ + is missing. Reinstall {} for this instance.", + profile_path.display(), + installer_json_path.display(), + config.loader + ))); } - !dominated -} -fn build_game_args( - config: &InstanceConfig, - minecraft_dir: &Path, - meta_dir: &Path, - asset_index_id: &str, - auth: GameAuth, - loader_game_args: Vec, -) -> Vec { - let mut game_args = vec![ - "--username".to_string(), - auth.username, - "--version".to_string(), - config.game_version.clone(), - "--gameDir".to_string(), - minecraft_dir.to_string_lossy().into_owned(), - "--assetsDir".to_string(), - meta_dir.join("assets").to_string_lossy().into_owned(), - "--assetIndex".to_string(), - asset_index_id.to_string(), - "--uuid".to_string(), - auth.uuid, - "--accessToken".to_string(), - auth.token, - "--userProperties".to_string(), - "{}".to_string(), - "--userType".to_string(), - auth.user_type, - ]; - game_args.extend(loader_game_args); - game_args + tracing::warn!( + "Loader profile {} is in legacy format; rebuilding from {}", + profile_path.display(), + installer_json_path.display() + ); + + let raw = tokio::fs::read(&installer_json_path).await?; + tokio::fs::write(profile_path, &raw).await?; + + let refreshed: LaunchProfile = serde_json::from_slice(&raw).map_err(|e| { + LaunchError::Parse(format!("Failed to parse refreshed loader profile: {e}")) + })?; + Ok(Some(refreshed)) } pub async fn launch( @@ -170,21 +218,28 @@ pub async fn launch( if !meta_path.exists() { return Err(LaunchError::MetaNotFound(meta_path.display().to_string())); } - let meta: MetaJson = serde_json::from_slice(&tokio::fs::read(&meta_path).await?)?; + let meta: LaunchProfile = serde_json::from_slice(&tokio::fs::read(&meta_path).await?)?; + let meta = match migrate_legacy_meta_if_needed(&meta_path, &meta, &config.game_version).await? { + Some(refreshed) => refreshed, + None => meta, + }; + + let current_features = FeatureSet::default(); + let host_os_version = system::mojang_os_version(); + let rule_ctx = RuleContext { + os_name: system::mojang_os_name(), + os_version: &host_os_version, + arch: system::mojang_arch_name(), + features: ¤t_features, + }; + + let asset_index_id = meta + .asset_index + .as_ref() + .map(|ai| ai.id.clone()) + .unwrap_or_default(); let lib_dir = meta_dir.join("libraries"); - let mut classpath: Vec = meta - .libraries - .iter() - .filter(|l| lib_allowed(l)) - .filter_map(|l| { - l.downloads - .as_ref()? - .artifact - .as_ref() - .map(|a| lib_dir.join(&a.path)) - }) - .collect(); let lv = config.loader_version.as_deref().unwrap_or("unknown"); let profile_filename = match config.loader { @@ -195,44 +250,94 @@ pub async fn launch( ModLoader::NeoForge => Some(format!("neoforge-{}.json", lv)), }; - // if there's a mod loader, read its profile to get the real main class, - // extra libraries, and any additional game arguments (e.g. --tweakClass) - let (main_class, loader_game_args) = if let Some(filename) = profile_filename { - let profile_path = meta_dir.join("loader-profiles").join(&filename); + // load the loader profile (if any), migrate from the old stripped format + // if needed, and resolve `inheritsFrom` against the vanilla parent (which + // the vanilla meta migration above ensured is fresh on disk). when no + // loader is configured we use the already-loaded vanilla meta directly. + let merged_profile: LaunchProfile = if let Some(filename) = &profile_filename { + let profile_path = meta_dir.join("loader-profiles").join(filename); if !profile_path.exists() { return Err(LaunchError::MetaNotFound( profile_path.display().to_string(), )); } - let profile: LoaderProfileJson = + let mut loader_profile: LaunchProfile = serde_json::from_slice(&tokio::fs::read(&profile_path).await?)?; - // forge/neoforge install some libs locally in the instance dir. - // local libs take priority so modpacks can ship patched versions - // (e.g. GTNH's launchwrapper patched for java 9+ compatibility) - let has_local_libs = matches!(config.loader, ModLoader::Forge | ModLoader::NeoForge); - let local_lib_dir = minecraft_dir.join("libraries"); - - for lib in &profile.libraries { - if let Some(p) = crate::net::maven_coord_to_path(&lib.name) { - if has_local_libs { - let in_local = local_lib_dir.join(&p); - let in_meta = lib_dir.join(&p); - if in_local.exists() { - classpath.push(in_local); - } else if in_meta.exists() { - classpath.push(in_meta); - } - } else { - classpath.push(lib_dir.join(p)); - } - } + if let Some(refreshed) = migrate_legacy_loader_profile_if_needed( + &profile_path, + &loader_profile, + config, + &instance_dir, + ) + .await? + { + loader_profile = refreshed; } - (profile.main_class, profile.game_arguments) + + // legacy installer-written profiles (and any loader profile that + // omits inheritsFrom) still need to be layered over vanilla. set + // the inherit explicitly so resolve() walks the chain. + if loader_profile.inherits_from.is_none() { + loader_profile.inherits_from = Some(config.game_version.clone()); + } + + resolve::resolve(loader_profile, meta_dir) + .await + .map_err(|e| LaunchError::Parse(format!("Failed to resolve loader profile: {e}")))? } else { - (meta.main_class.clone(), Vec::new()) + meta.clone() }; + let main_class = merged_profile + .main_class + .clone() + .ok_or_else(|| LaunchError::Parse("merged profile missing mainClass".into()))?; + + // rebuild the classpath from the merged profile. vanilla-style libraries + // have `downloads.artifact.path` set and live in meta_dir/libraries/. + // loader-style libraries only have a maven coordinate; for forge/neoforge, + // the installer drops some of them into /.minecraft/libraries/ + // so we check there first. + let has_local_libs = matches!(config.loader, ModLoader::Forge | ModLoader::NeoForge); + let local_lib_dir = minecraft_dir.join("libraries"); + + let mut classpath: Vec = Vec::new(); + for lib in &merged_profile.libraries { + if let Some(rules) = &lib.rules + && !rules::evaluate(rules, &rule_ctx) + { + continue; + } + + // resolve a relative path for this library. prefer downloads.artifact.path + // when present (vanilla-style), fall back to maven_coord_to_path(name) + // for loader-style entries that only have a coord. + let rel: PathBuf = match lib + .downloads + .as_ref() + .and_then(|d| d.artifact.as_ref()) + .map(|a| PathBuf::from(&a.path)) + .or_else(|| crate::net::maven_coord_to_path(&lib.name).map(PathBuf::from)) + { + Some(p) => p, + None => continue, + }; + + // for forge/neoforge, the installer drops some libs (notably the + // bootstrap library) into /.minecraft/libraries/ rather + // than the shared meta cache. check there first regardless of + // whether the lib has a downloads.artifact entry. + if has_local_libs { + let in_local = local_lib_dir.join(&rel); + if in_local.exists() { + classpath.push(in_local); + continue; + } + } + classpath.push(lib_dir.join(rel)); + } + classpath.push( meta_dir .join("versions") @@ -269,13 +374,6 @@ pub async fn launch( }) .unwrap_or_else(crate::net::detect_java_path); - let mut jvm: Vec = vec![ - format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), - format!("-Xmx{}", config.memory_max.as_deref().unwrap_or("2G")), - ]; - jvm.extend(patch_jvm_args); - jvm.extend(config.jvm_args.clone()); - // resolve auth credentials, refreshing the microsoft token if needed. let mut account_store = crate::auth::AccountStore::load(); let (mc_username, mc_uuid, mc_token, mc_user_type) = match account_store @@ -283,7 +381,10 @@ pub async fn launch( .cloned() { Some(acc) => { - if !account_can_launch(account_store.has_microsoft_account(), &acc) { + // offline accounts can only launch if a microsoft account exists + // (proves the user owns minecraft). + if acc.account_type != AccountType::Microsoft && !account_store.has_microsoft_account() + { return Err(LaunchError::Auth( "Offline accounts require a Microsoft account that owns Minecraft".to_owned(), )); @@ -330,19 +431,43 @@ pub async fn launch( None => return Err(LaunchError::Auth("No account selected".to_owned())), }; - let game_args = build_game_args( - config, - &minecraft_dir, - meta_dir, - &meta.asset_index.id, - GameAuth { - username: mc_username, - uuid: mc_uuid, - token: mc_token, - user_type: mc_user_type, - }, - loader_game_args, - ); + let assets_root = meta_dir.join("assets"); + let natives_dir = meta_dir + .join("versions") + .join(&config.game_version) + .join("natives"); + let version_type = merged_profile.type_.as_deref().unwrap_or("release"); + let template_ctx = TemplateContext { + library_directory: &lib_dir, + classpath_separator: sep, + version_name: &config.game_version, + version_type, + natives_directory: &natives_dir, + classpath: &cp_str, + game_directory: &minecraft_dir, + assets_root: &assets_root, + assets_index_name: &asset_index_id, + auth_player_name: &mc_username, + auth_uuid: &mc_uuid, + auth_access_token: &mc_token, + auth_xuid: "0", + user_type: &mc_user_type, + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: env!("CARGO_PKG_VERSION"), + clientid: "0", + }; + + let (upstream_jvm_args, game_args) = + build_game_args(&merged_profile, &rule_ctx, &template_ctx)?; + + let mut jvm: Vec = vec![ + format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), + format!("-Xmx{}", config.memory_max.as_deref().unwrap_or("2G")), + ]; + jvm.extend(patch_jvm_args); + jvm.extend(upstream_jvm_args); + jvm.extend(config.jvm_args.clone()); let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); crate::running::register_kill(&name, kill_tx); @@ -477,85 +602,215 @@ pub async fn launch( #[cfg(test)] mod tests { use super::*; - use crate::auth::{Account, AccountType}; - use chrono::Utc; - - fn test_config() -> InstanceConfig { - InstanceConfig { - name: "test".to_owned(), - game_version: "1.7.10".to_owned(), - loader: ModLoader::Forge, - loader_version: Some("10.13.4.1614".to_owned()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - } + + #[test] + fn build_game_args_renders_upstream_arguments_and_appends_loader_args() { + use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; + use crate::launch_profile::rules::{FeatureSet, RuleContext}; + use TemplateContext; + use std::path::PathBuf; + + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game_dir = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + + let template_ctx = TemplateContext { + library_directory: &lib, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory: &nat, + classpath: "a.jar:b.jar", + game_directory: &game_dir, + assets_root: &assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "test", + clientid: "0", + version_type: "release", + }; + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let profile = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.minecraft.client.main.Main".into()), + libraries: Vec::new(), + arguments: Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }), + ..Default::default() + }; + + let (jvm, game_args) = build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); + assert_eq!(game_args, vec!["--username", "Player"]); } - fn test_account(account_type: AccountType) -> Account { - Account { - uuid: "00000000-0000-0000-0000-000000000001".to_owned(), - username: "TestPlayer".to_owned(), - account_type, - active: true, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: None, - cached_mc_token_expires_at: None, - } + #[test] + fn migration_predicate_triggers_when_both_argument_fields_absent() { + use LaunchProfile; + let stripped = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.test.Main".into()), + libraries: Vec::new(), + ..Default::default() + }; + assert!(stripped.arguments.is_none() && stripped.minecraft_arguments.is_none()); } #[test] - fn game_args_include_empty_user_properties() { - let args = build_game_args( - &test_config(), - Path::new("/instances/test/.minecraft"), - Path::new("/meta"), - "legacy", - GameAuth { - username: "TestPlayer".to_owned(), - uuid: "00000000-0000-0000-0000-000000000000".to_owned(), - token: "token".to_owned(), - user_type: "msa".to_owned(), - }, - vec![ - "--tweakClass".to_owned(), - "cpw.mods.fml.common.launcher.FMLTweaker".to_owned(), - ], - ); + fn migration_predicate_skips_when_modern_arguments_present() { + use crate::launch_profile::model::{Arguments, LaunchProfile}; + let modern = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.test.Main".into()), + libraries: Vec::new(), + arguments: Some(Arguments::default()), + ..Default::default() + }; + assert!(modern.arguments.is_some()); + } - let position = args - .iter() - .position(|arg| arg == "--userProperties") - .expect("game args should include --userProperties"); - assert_eq!(args.get(position + 1).map(String::as_str), Some("{}")); - assert!( - args.windows(2) - .any(|pair| pair == ["--tweakClass", "cpw.mods.fml.common.launcher.FMLTweaker"]) - ); + #[test] + fn migration_predicate_skips_when_legacy_minecraft_arguments_present() { + use LaunchProfile; + let legacy = LaunchProfile { + id: "1.7.10".into(), + inherits_from: None, + main_class: Some("net.test.Main".into()), + libraries: Vec::new(), + arguments: None, + minecraft_arguments: Some("--username Player".into()), + ..Default::default() + }; + assert!(legacy.minecraft_arguments.is_some()); } #[test] - fn offline_account_cannot_launch_without_microsoft_account() { - let offline = test_account(AccountType::Offline); + fn installer_version_dir_name_for_forge() { + assert_eq!( + installer_version_dir_name(ModLoader::Forge, "1.20.1", "47.2.0"), + Some("1.20.1-forge-47.2.0".to_string()) + ); + } - assert!(!account_can_launch(false, &offline)); + #[test] + fn installer_version_dir_name_for_neoforge() { + assert_eq!( + installer_version_dir_name(ModLoader::NeoForge, "1.21.1", "21.1.0"), + Some("neoforge-21.1.0".to_string()) + ); } #[test] - fn offline_account_can_launch_with_microsoft_account() { - let offline = test_account(AccountType::Offline); + fn installer_version_dir_name_for_non_installer_loaders() { + assert!(installer_version_dir_name(ModLoader::Vanilla, "1.20.1", "v").is_none()); + assert!(installer_version_dir_name(ModLoader::Fabric, "1.20.1", "0.14.21").is_none()); + assert!(installer_version_dir_name(ModLoader::Quilt, "1.20.1", "0.20.0").is_none()); + } - assert!(account_can_launch(true, &offline)); + #[test] + fn loader_profile_legacy_predicate_triggers_when_all_three_absent() { + use LaunchProfile; + let legacy = LaunchProfile { + id: "1.20.1-forge-47.2.0".into(), + inherits_from: None, + main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), + libraries: Vec::new(), + ..Default::default() + }; + let is_legacy = legacy.inherits_from.is_none() + && legacy.arguments.is_none() + && legacy.minecraft_arguments.is_none(); + assert!(is_legacy); } #[test] - fn microsoft_account_can_launch_without_offline_gate() { - let microsoft = test_account(AccountType::Microsoft); + fn loader_profile_legacy_predicate_skips_modern_profile_with_inherits_from() { + use LaunchProfile; + let modern = LaunchProfile { + id: "1.20.1-forge-47.2.0".into(), + inherits_from: Some("1.20.1".into()), + main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), + libraries: Vec::new(), + ..Default::default() + }; + let is_legacy = modern.inherits_from.is_none() + && modern.arguments.is_none() + && modern.minecraft_arguments.is_none(); + assert!(!is_legacy); + } + + #[tokio::test] + async fn migrate_legacy_loader_profile_skips_fabric() { + // a fresh upstream Fabric profile happens to match the "legacy" + // shape (no inheritsFrom, no arguments, no minecraftArguments). + // make sure the migration helper recognises this is Fabric and + // returns Ok(None) instead of erroring with "reinstall Fabric". + use LaunchProfile; + use chrono::Utc; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + std::fs::create_dir_all(&instance_dir).unwrap(); + let profile_path = tmp.path().join("fabric-1.20.1-0.14.21.json"); + std::fs::write(&profile_path, b"{}").unwrap(); + + let upstream_fabric_shape = LaunchProfile { + id: "fabric-loader-0.14.21-1.20.1".into(), + inherits_from: None, + main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into()), + libraries: Vec::new(), + ..Default::default() + }; + + let config = InstanceConfig { + name: "test".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Fabric, + loader_version: Some("0.14.21".into()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + }; + + let result = migrate_legacy_loader_profile_if_needed( + &profile_path, + &upstream_fabric_shape, + &config, + &instance_dir, + ) + .await; - assert!(account_can_launch(false, µsoft)); + assert!( + matches!(result, Ok(None)), + "expected Ok(None), got {result:?}" + ); } } diff --git a/src/instance/loader/fabric.rs b/src/instance/loader/fabric.rs index a442c70..ae69dcb 100644 --- a/src/instance/loader/fabric.rs +++ b/src/instance/loader/fabric.rs @@ -41,16 +41,14 @@ impl ModLoaderInstaller for FabricInstaller { _instance_dir: &Path, meta_dir: &Path, ) -> Result<(), NetError> { - let profile = - fabric_api::fetch_fabric_profile(client, game_version, loader_version).await?; + let (profile, raw_bytes) = + fabric_api::fetch_fabric_profile_with_raw(client, game_version, loader_version).await?; fabric_api::download_fabric_libraries(client, &profile, meta_dir).await?; - - super::save_profile_json( + super::save_profile_bytes( meta_dir, &format!("fabric-{game_version}-{loader_version}.json"), - &profile, + &raw_bytes, )?; - Ok(()) } } diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index a0d3752..e670341 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -46,23 +46,26 @@ pub trait ModLoaderInstaller: Send + Sync { ) -> Result<(), NetError>; } -pub(crate) fn save_profile_json( +// writes raw profile JSON bytes to meta_dir/loader-profiles/. +// callers that already have the upstream bytes (fabric/quilt http fetch, +// legacy forge versionInfo extract) use this directly to keep the on-disk +// file byte-for-byte identical to the source. +pub(crate) fn save_profile_bytes( meta_dir: &Path, filename: &str, - profile: &impl serde::Serialize, -) -> Result<(), NetError> { + bytes: &[u8], +) -> std::io::Result<()> { let profiles_dir = meta_dir.join("loader-profiles"); std::fs::create_dir_all(&profiles_dir)?; - let profile_path = profiles_dir.join(filename); - let json = serde_json::to_string_pretty(profile) - .map_err(|e| NetError::Parse(format!("Failed to serialize profile {filename}: {e}")))?; - std::fs::write(&profile_path, &json)?; - Ok(()) + std::fs::write(profiles_dir.join(filename), bytes) } // used by forge/neoforge. their java installer drops a version json into -// .minecraft/versions/. parses that to extract the main class and library -// list, then saves a stripped-down profile for use at launch time. +// .minecraft/versions/. we copy that file byte-for-byte to our loader +// profile cache so launch-time code sees the full upstream JSON - +// inheritsFrom, arguments.jvm, library rules, all of it - instead of a +// stripped-down version that would silently drop modern features (e.g. +// the --add-opens flags forge 1.17+ ships for java 17+ support). pub(crate) fn save_installer_profile( instance_dir: &Path, meta_dir: &Path, @@ -82,37 +85,13 @@ pub(crate) fn save_installer_profile( ))); } - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct InstallerVersionJson { - main_class: String, - #[serde(default)] - libraries: Vec, - } - #[derive(serde::Deserialize)] - struct InstallerLib { - name: String, - } - let raw = std::fs::read(&ver_json_path)?; - let ver: InstallerVersionJson = serde_json::from_slice(&raw).map_err(|e| { - NetError::Parse(format!( - "Invalid version JSON at {}: {e}", - ver_json_path.display() - )) - })?; - - let libs: Vec = ver - .libraries - .iter() - .map(|l| serde_json::json!({"name": l.name})) - .collect(); - let json_val = serde_json::json!({ - "mainClass": ver.main_class, - "libraries": libs - }); - - save_profile_json(meta_dir, profile_filename, &json_val) + + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir)?; + let profile_path = profiles_dir.join(profile_filename); + std::fs::write(&profile_path, &raw)?; + Ok(()) } pub fn get_installer(loader: ModLoader) -> Box { @@ -165,4 +144,163 @@ mod tests { assert!(!versions.is_empty()); assert!(versions.iter().any(|v| v.id == "1.20.1")); } + + #[test] + fn save_installer_profile_copies_raw_bytes_verbatim() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + let meta_dir = tmp.path().join("meta"); + + // a synthetic installer version JSON with the modern arguments + // object - exactly the shape we used to strip. + let installer_json = br#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [{ "name": "net.minecraftforge:forge:47.2.0" }], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": ["--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler"] + } + }"#; + + let ver_dir = instance_dir + .join(".minecraft") + .join("versions") + .join("1.20.1-forge-47.2.0"); + std::fs::create_dir_all(&ver_dir).unwrap(); + let ver_json_path = ver_dir.join("1.20.1-forge-47.2.0.json"); + std::fs::write(&ver_json_path, installer_json).unwrap(); + + save_installer_profile( + &instance_dir, + &meta_dir, + "1.20.1-forge-47.2.0", + "forge-1.20.1-47.2.0.json", + ) + .unwrap(); + + let saved = std::fs::read( + meta_dir + .join("loader-profiles") + .join("forge-1.20.1-47.2.0.json"), + ) + .unwrap(); + assert_eq!( + saved, + installer_json.to_vec(), + "saved profile should be byte-for-byte identical to installer output" + ); + } + + #[test] + fn legacy_forge_version_info_round_trips_through_save_profile_json() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let meta_dir = tmp.path().to_path_buf(); + + // synthetic versionInfo shape from a 1.7.10 forge install_profile.json. + // legacy forge uses minecraftArguments (the flat string format) and + // typically omits inheritsFrom (the launch flow's implicit fallback + // handles that). + let version_info = serde_json::json!({ + "id": "1.7.10-Forge10.13.4.1614-1.7.10", + "mainClass": "net.minecraft.launchwrapper.Launch", + "minecraftArguments": "--username ${auth_player_name} --tweakClass cpw.mods.fml.common.launcher.FMLTweaker", + "libraries": [ + { "name": "net.minecraftforge:forge:10.13.4.1614", "url": "http://files.minecraftforge.net/maven/" }, + { "name": "net.minecraft:launchwrapper:1.9" } + ] + }); + + // mirror the install path: write versionInfo as compact JSON. + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir).unwrap(); + let serialized = serde_json::to_vec(&version_info).unwrap(); + std::fs::write( + profiles_dir.join("forge-1.7.10-10.13.4.1614.json"), + &serialized, + ) + .unwrap(); + + let saved_bytes = std::fs::read( + meta_dir + .join("loader-profiles") + .join("forge-1.7.10-10.13.4.1614.json"), + ) + .unwrap(); + + // the cached profile must parse as a LaunchProfile so the launch + // flow's render_args + resolve pipeline can consume it. + let profile: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(&saved_bytes).unwrap(); + assert_eq!(profile.id, "1.7.10-Forge10.13.4.1614-1.7.10"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.launchwrapper.Launch") + ); + assert!(profile.inherits_from.is_none()); // launch flow's implicit fallback adds it + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("--tweakClass") + ); + assert_eq!(profile.libraries.len(), 2); + assert_eq!( + profile.libraries[0].name, + "net.minecraftforge:forge:10.13.4.1614" + ); + assert_eq!( + profile.libraries[0].url.as_deref(), + Some("http://files.minecraftforge.net/maven/") + ); + // legacy libs typically have no `downloads.artifact` - they're + // resolved at launch time via maven_coord_to_path(name). + assert!(profile.libraries[0].downloads.is_none()); + } + + #[test] + fn raw_fabric_profile_bytes_parse_as_launch_profile() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let meta_dir = tmp.path(); + + // a synthetic upstream fabric profile shape (id, mainClass, + // libraries with name+url, no inheritsFrom, no arguments). + // simulates what the install path will write verbatim. + let upstream_bytes = br#"{ + "id": "fabric-loader-0.14.21-1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [ + { "name": "net.fabricmc:fabric-loader:0.14.21", "url": "https://maven.fabricmc.net/" }, + { "name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/" } + ] + }"#; + + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir).unwrap(); + let profile_path = profiles_dir.join("fabric-1.20.1-0.14.21.json"); + std::fs::write(&profile_path, upstream_bytes).unwrap(); + + let saved = std::fs::read(&profile_path).unwrap(); + assert_eq!(saved, upstream_bytes.to_vec()); + + let parsed: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(&saved).unwrap(); + assert_eq!(parsed.id, "fabric-loader-0.14.21-1.20.1"); + assert_eq!( + parsed.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + assert!(parsed.inherits_from.is_none()); // launch flow's implicit fallback handles it + assert!(parsed.arguments.is_none()); + assert_eq!(parsed.libraries.len(), 2); + assert_eq!( + parsed.libraries[0].url.as_deref(), + Some("https://maven.fabricmc.net/") + ); + } } diff --git a/src/instance/loader/quilt.rs b/src/instance/loader/quilt.rs index 5935f5e..8ad4b8b 100644 --- a/src/instance/loader/quilt.rs +++ b/src/instance/loader/quilt.rs @@ -41,15 +41,14 @@ impl ModLoaderInstaller for QuiltInstaller { _instance_dir: &Path, meta_dir: &Path, ) -> Result<(), NetError> { - let profile = quilt_api::fetch_quilt_profile(client, game_version, loader_version).await?; + let (profile, raw_bytes) = + quilt_api::fetch_quilt_profile_with_raw(client, game_version, loader_version).await?; quilt_api::download_quilt_libraries(client, &profile, meta_dir).await?; - - super::save_profile_json( + super::save_profile_bytes( meta_dir, &format!("quilt-{game_version}-{loader_version}.json"), - &profile, + &raw_bytes, )?; - Ok(()) } } diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 87bddf6..41ffecd 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -115,8 +115,8 @@ impl InstanceManager { } }; - let version_meta = - crate::net::mojang::fetch_version_meta(&self.client, version_entry).await?; + let (version_meta, raw_meta_bytes) = + crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry).await?; crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) .await?; @@ -126,15 +126,13 @@ impl InstanceManager { .join("versions") .join(game_version) .join("meta.json"); - match serde_json::to_string_pretty(&version_meta) { - Ok(json) => { - if let Err(e) = std::fs::write(&meta_json_path, &json) { - tracing::warn!("Failed to save version meta: {}", e); - } - } - Err(e) => { - tracing::warn!("Failed to serialize version meta: {}", e); - } + if let Some(parent) = meta_json_path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + tracing::warn!("Failed to ensure meta dir exists: {}", e); + } + if let Err(e) = std::fs::write(&meta_json_path, &raw_meta_bytes) { + tracing::warn!("Failed to save version meta: {}", e); } crate::net::mojang::download_libraries(&self.client, &version_meta, &self.meta_dir).await?; diff --git a/src/launch_profile/mod.rs b/src/launch_profile/mod.rs new file mode 100644 index 0000000..3e5cbde --- /dev/null +++ b/src/launch_profile/mod.rs @@ -0,0 +1,11 @@ +// primitives for parsing, merging, and rendering mojang-format launch +// profiles. consumed by the vanilla launcher and the loader install paths +// (forge/neoforge/fabric/quilt) - anything that reads a mojang-style +// version JSON. + +pub mod model; +pub mod render; +pub mod resolve; +pub mod rules; +pub mod system; +pub mod templates; diff --git a/src/launch_profile/model.rs b/src/launch_profile/model.rs new file mode 100644 index 0000000..127c3ed --- /dev/null +++ b/src/launch_profile/model.rs @@ -0,0 +1,281 @@ +// mojang-format launch profile types. mirrors the on-disk JSON schema +// used by vanilla versions, forge installer output, neoforge installer +// output, fabric profiles, and quilt profiles. parsing is lossless for +// the fields we care about; unknown fields are silently dropped (serde +// default behavior) - which is fine because we write upstream JSON +// byte-for-byte on the install side. + +use serde::{Deserialize, Serialize}; + +use super::rules::Rule; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchProfile { + pub id: String, + pub inherits_from: Option, + pub main_class: Option, + #[serde(default)] + pub libraries: Vec, + pub arguments: Option, + pub minecraft_arguments: Option, + pub asset_index: Option, + pub assets: Option, + pub java_version: Option, + pub downloads: Option, + pub release_time: Option, + pub time: Option, + #[serde(rename = "type")] + pub type_: Option, + // present only in rmcl <= 0.3.0's stripped loader-profile shape. + // we deserialize it so the launch-time legacy-detection predicate + // can confirm "this really is our old format, not an upstream + // profile that happens to omit arguments". skipped on serialize so + // we never propagate this field outward. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub game_arguments: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct Arguments { + #[serde(default)] + pub game: Vec, + #[serde(default)] + pub jvm: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Argument { + Literal(String), + Conditional { + rules: Vec, + value: ArgumentValue, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ArgumentValue { + Single(String), + Multiple(Vec), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct Library { + pub name: String, + pub downloads: Option, + pub rules: Option>, + pub url: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct LibraryDownloads { + pub artifact: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct Artifact { + pub url: String, + pub path: String, + pub sha1: String, + pub size: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct AssetIndex { + pub id: String, + pub url: String, + pub sha1: String, + pub size: Option, + pub total_size: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaVersion { + pub component: Option, + pub major_version: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct VersionDownloads { + pub client: Download, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct Download { + pub url: String, + pub sha1: String, + pub size: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::launch_profile::rules::RuleAction; + + const MODERN_FIXTURE: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assetIndex": { + "id": "5", + "url": "https://example.invalid/5.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "javaVersion": { + "component": "java-runtime-gamma", + "majorVersion": 17 + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + }, + "rules": [ + { "action": "allow", "os": { "name": "linux" } } + ] + } + ], + "arguments": { + "game": [ + "--username", "${auth_player_name}", + { + "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], + "value": "--demo" + } + ], + "jvm": [ + "-Djava.library.path=${natives_directory}", + { + "rules": [{ "action": "allow", "os": { "name": "osx" } }], + "value": ["-XstartOnFirstThread"] + } + ] + } + }"#; + + const LEGACY_FIXTURE: &str = r#"{ + "id": "1.7.10", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory}", + "assetIndex": { + "id": "1.7.10", + "url": "https://example.invalid/1.7.10.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "libraries": [] + }"#; + + const LOADER_FIXTURE: &str = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler", + "-DlibraryDirectory=${library_directory}" + ] + } + }"#; + + #[test] + fn parses_modern_arguments_object() { + let profile: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + assert!(profile.inherits_from.is_none()); + assert!(profile.minecraft_arguments.is_none()); + + let args = profile.arguments.as_ref().expect("arguments present"); + assert_eq!(args.game.len(), 3); + assert_eq!(args.jvm.len(), 2); + + // first game arg should be a literal "--username" + match &args.game[0] { + Argument::Literal(s) => assert_eq!(s, "--username"), + _ => panic!("expected literal"), + } + // third game arg should be a conditional with a single-string value + match &args.game[2] { + Argument::Conditional { rules, value } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + assert!(matches!(value, ArgumentValue::Single(_))); + } + _ => panic!("expected conditional"), + } + // second jvm arg should be a conditional with a multi-string value + match &args.jvm[1] { + Argument::Conditional { value, .. } => { + assert!(matches!(value, ArgumentValue::Multiple(_))); + } + _ => panic!("expected conditional"), + } + } + + #[test] + fn parses_legacy_minecraft_arguments_string() { + let profile: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.7.10"); + assert!(profile.arguments.is_none()); + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("${version_name}") + ); + assert!(profile.libraries.is_empty()); + } + + #[test] + fn parses_loader_profile_with_inherits_from() { + let profile: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1-forge-47.2.0"); + assert_eq!(profile.inherits_from.as_deref(), Some("1.20.1")); + assert!(profile.asset_index.is_none()); // inherited from parent + let args = profile.arguments.as_ref().unwrap(); + assert_eq!(args.game.len(), 2); + assert_eq!(args.jvm.len(), 3); + } + + #[test] + fn modern_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); + } + + #[test] + fn loader_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); + } + + #[test] + fn legacy_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); + } +} diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs new file mode 100644 index 0000000..a75e14c --- /dev/null +++ b/src/launch_profile/render.rs @@ -0,0 +1,413 @@ +// renders a parsed launch profile into final argv-style lists for the JVM +// and the game. resolves conditional argument shapes (`{rules, value}`), +// filters them through the rule evaluator, and substitutes mojang template +// variables. legacy `minecraftArguments` strings are tokenised on whitespace +// and treated as a list of game args. pure function; no I/O. + +use super::model::{Argument, ArgumentValue, LaunchProfile}; +use super::rules::{RuleContext, evaluate}; +use super::templates::{TemplateContext, substitute}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedArgs { + pub jvm: Vec, + pub main_class: String, + pub game: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum RenderError { + #[error("launch profile is missing a main class")] + MissingMainClass, +} + +pub fn render_args( + profile: &LaunchProfile, + rule_ctx: &RuleContext, + template_ctx: &TemplateContext, +) -> Result { + let main_class = profile + .main_class + .clone() + .ok_or(RenderError::MissingMainClass)?; + + let mut jvm = Vec::new(); + let mut game = Vec::new(); + + if let Some(args) = &profile.arguments { + for arg in &args.jvm { + push_argument(arg, rule_ctx, template_ctx, &mut jvm); + } + for arg in &args.game { + push_argument(arg, rule_ctx, template_ctx, &mut game); + } + } else if let Some(legacy) = &profile.minecraft_arguments { + for token in legacy.split_whitespace() { + game.push(substitute(token, template_ctx)); + } + } + + Ok(RenderedArgs { + jvm, + main_class, + game, + }) +} + +fn push_argument( + arg: &Argument, + rule_ctx: &RuleContext, + template_ctx: &TemplateContext, + out: &mut Vec, +) { + match arg { + Argument::Literal(s) => out.push(substitute(s, template_ctx)), + Argument::Conditional { rules, value } => { + if !evaluate(rules, rule_ctx) { + return; + } + match value { + ArgumentValue::Single(s) => out.push(substitute(s, template_ctx)), + ArgumentValue::Multiple(items) => { + for s in items { + out.push(substitute(s, template_ctx)); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::launch_profile::model::Arguments; + use crate::launch_profile::rules::{FeatureSet, OsCondition, Rule, RuleAction}; + use std::path::PathBuf; + + fn template_fixture<'a>( + library_directory: &'a std::path::Path, + natives_directory: &'a std::path::Path, + game_directory: &'a std::path::Path, + assets_root: &'a std::path::Path, + ) -> TemplateContext<'a> { + TemplateContext { + library_directory, + classpath_separator: ":", + version_name: "1.20.1", + version_type: "release", + natives_directory, + classpath: "a.jar:b.jar", + game_directory, + assets_root, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + } + } + + fn minimal_profile() -> LaunchProfile { + LaunchProfile { + id: "test".into(), + main_class: Some("net.test.Main".into()), + ..Default::default() + } + } + + #[test] + fn legacy_minecraft_arguments_render_into_game() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.minecraft_arguments = + Some("--username ${auth_player_name} --version ${version_name}".into()); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(rendered.main_class, "net.test.Main"); + assert!(rendered.jvm.is_empty()); + assert_eq!( + rendered.game, + vec!["--username", "Player", "--version", "1.20.1"] + ); + } + + #[test] + fn modern_arguments_render_with_literals_and_substitutions() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(rendered.game, vec!["--username", "Player"]); + assert_eq!(rendered.jvm, vec!["-Djava.library.path=/m/natives"]); + } + + #[test] + fn conditional_argument_with_single_value_is_filtered_by_os_rule() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let osx_only = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + ..Default::default() + }), + features: None, + }], + value: ArgumentValue::Single("-XstartOnFirstThread".into()), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![osx_only], + }); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert!( + rendered.jvm.is_empty(), + "osx-only arg should be skipped on linux" + ); + } + + #[test] + fn conditional_argument_with_multiple_value_pushes_all() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let linux_arg = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }], + value: ArgumentValue::Multiple(vec![ + "--add-opens".into(), + "java.base/sun.security.util=ALL-UNNAMED".into(), + ]), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![linux_arg], + }); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!( + rendered.jvm, + vec!["--add-opens", "java.base/sun.security.util=ALL-UNNAMED"] + ); + } + + #[test] + fn missing_main_class_returns_error() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.main_class = None; + + let result = render_args(&profile, &rule_ctx, &template_ctx); + assert!(matches!(result, Err(RenderError::MissingMainClass))); + } + + #[tokio::test] + async fn end_to_end_resolve_then_render_modern_forge_shape() { + // exercises the full pipeline: load a synthetic vanilla profile + // from disk, load a synthetic loader profile with inheritsFrom, + // resolve the chain, then render args. catches integration bugs + // that unit tests of each layer would miss. + use crate::launch_profile::resolve; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let vanilla_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); + std::fs::create_dir_all(vanilla_path.parent().unwrap()).unwrap(); + let vanilla_json = br#"{ + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + } + } + ], + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], + "jvm": ["-Djava.library.path=${natives_directory}"] + } + }"#; + std::fs::write(&vanilla_path, vanilla_json).unwrap(); + + let loader_json = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + } + }"#; + let loader_profile: LaunchProfile = serde_json::from_str(loader_json).unwrap(); + + let merged = resolve::resolve(loader_profile, tmp.path()).await.unwrap(); + + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let rendered = render_args(&merged, &rule_ctx, &template_ctx).unwrap(); + + // child main_class wins after merge + assert_eq!( + rendered.main_class, + "cpw.mods.bootstraplauncher.BootstrapLauncher" + ); + // game args: parent first then child + assert_eq!( + rendered.game, + vec![ + "--username", + "Player", + "--version", + "1.20.1", + "--launchTarget", + "forge_client" + ] + ); + // jvm args: parent first then child + assert_eq!( + rendered.jvm, + vec![ + "-Djava.library.path=/m/natives", + "--add-opens", + "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + ); + } + + #[test] + fn modern_arguments_takes_precedence_over_legacy_field() { + // a profile that somehow has both arguments and minecraft_arguments + // should use arguments only (legacy is fallback). + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-arguments".into())], + jvm: Vec::new(), + }); + profile.minecraft_arguments = Some("--from-legacy".into()); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(rendered.game, vec!["--from-arguments"]); + } +} diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs new file mode 100644 index 0000000..fe1b50e --- /dev/null +++ b/src/launch_profile/resolve.rs @@ -0,0 +1,565 @@ +// resolves the `inheritsFrom` chain of a parsed `LaunchProfile`. mojang's +// version JSON allows a profile to inherit from another profile by id; the +// loaders (forge / neoforge / fabric / quilt) use this to layer their +// additions on top of a vanilla base. this module walks the chain and +// returns a single flat profile. +// +// merge semantics (per the mojang launcher and the major third-party +// launchers that interoperate with it): +// - scalar fields: child wins if Some, else parent. +// - libraries and arguments: parent ++ child (parent first). child +// entries are appended after parent's. +// - merge_into preserves parent's inherits_from so resolve() can keep +// walking; resolve() clears the final result's inherits_from after +// the loop exits. +// +// pure function `merge_into` handles the field-by-field merge math. +// async `resolve` does the chain walking with cycle detection and a depth +// cap. tests cover both layers independently. + +use std::path::Path; + +use super::model::{Arguments, LaunchProfile}; + +const MAX_INHERITANCE_DEPTH: usize = 8; + +#[derive(Debug, thiserror::Error)] +pub enum ResolveError { + #[error("parent profile not found at {0}")] + ParentNotFound(String), + #[error("failed to parse parent profile {0}: {1}")] + ParseError(String, String), + #[error("circular inheritance detected: {0} appears more than once in the chain")] + CircularInheritance(String), + #[error("inheritance chain exceeded {0} levels")] + DepthExceeded(usize), + #[error("I/O error reading parent profile: {0}")] + Io(#[from] std::io::Error), +} + +// merges `child` on top of `parent`. child takes precedence for scalar +// fields. for libraries and arguments, child entries are appended after +// parent's. `id` is taken from child. `inherits_from` is taken from +// parent (so resolve() can keep walking the chain - resolve() clears it +// to None after the final iteration). +pub fn merge_into(child: LaunchProfile, parent: LaunchProfile) -> LaunchProfile { + LaunchProfile { + id: child.id, + inherits_from: parent.inherits_from, + main_class: child.main_class.or(parent.main_class), + libraries: merge_libraries(child.libraries, parent.libraries), + arguments: merge_arguments(child.arguments, parent.arguments), + minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments), + asset_index: child.asset_index.or(parent.asset_index), + assets: child.assets.or(parent.assets), + java_version: child.java_version.or(parent.java_version), + downloads: child.downloads.or(parent.downloads), + release_time: child.release_time.or(parent.release_time), + time: child.time.or(parent.time), + game_arguments: None, + type_: child.type_.or(parent.type_), + } +} + +// extracts the `group:artifact` portion of a maven coordinate, dropping +// version and any classifier. used as the dedup key when merging library +// lists from a child profile on top of its parent. +fn coord_key(name: &str) -> &str { + // mojang maven coords are `group:artifact:version[:classifier]`. take + // everything up to the second colon. + let mut it = name.match_indices(':').map(|(i, _)| i); + it.next(); + it.next().map_or(name, |i| &name[..i]) +} + +// child entries take precedence over parent entries with the same +// group:artifact. mojang and the major third-party launchers (prism, +// multimc) all dedup this way - without it, loader overrides of vanilla +// libraries (e.g. forge bumping log4j) would lose to vanilla because the +// JVM picks the first classpath match. +fn merge_libraries( + child: Vec, + parent: Vec, +) -> Vec { + use std::collections::HashSet; + let child_keys: HashSet<&str> = child.iter().map(|l| coord_key(&l.name)).collect(); + + let mut out: Vec = parent + .into_iter() + .filter(|l| !child_keys.contains(coord_key(&l.name))) + .collect(); + out.extend(child); + out +} + +fn merge_arguments(child: Option, parent: Option) -> Option { + match (child, parent) { + (None, None) => None, + (Some(c), None) => Some(c), + (None, Some(p)) => Some(p), + (Some(c), Some(p)) => { + let mut game = p.game; + game.extend(c.game); + let mut jvm = p.jvm; + jvm.extend(c.jvm); + Some(Arguments { game, jvm }) + } + } +} + +pub async fn resolve( + profile: LaunchProfile, + meta_dir: &Path, +) -> Result { + use std::collections::HashSet; + + let mut visited: HashSet = HashSet::new(); + visited.insert(profile.id.clone()); + + let mut current = profile; + let mut depth = 0; + + while let Some(parent_id) = current.inherits_from.clone() { + depth += 1; + if depth > MAX_INHERITANCE_DEPTH { + return Err(ResolveError::DepthExceeded(MAX_INHERITANCE_DEPTH)); + } + if !visited.insert(parent_id.clone()) { + return Err(ResolveError::CircularInheritance(parent_id)); + } + + let parent_path = meta_dir.join("versions").join(&parent_id).join("meta.json"); + if !parent_path.exists() { + return Err(ResolveError::ParentNotFound( + parent_path.display().to_string(), + )); + } + let parent_bytes = tokio::fs::read(&parent_path).await?; + let parent: LaunchProfile = serde_json::from_slice(&parent_bytes) + .map_err(|e| ResolveError::ParseError(parent_id.clone(), e.to_string()))?; + + current = merge_into(current, parent); + } + + current.inherits_from = None; + Ok(current) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::launch_profile::model::{Argument, ArgumentValue, AssetIndex, JavaVersion, Library}; + use crate::launch_profile::rules::{Rule, RuleAction}; + + fn empty_profile(id: &str) -> LaunchProfile { + LaunchProfile { + id: id.into(), + ..Default::default() + } + } + + fn lib(name: &str) -> Library { + Library { + name: name.into(), + ..Default::default() + } + } + + fn allow_linux_rule() -> Rule { + Rule { + action: RuleAction::Allow, + os: Some(crate::launch_profile::rules::OsCondition { + name: Some("linux".into()), + ..Default::default() + }), + features: None, + } + } + + #[test] + fn child_id_wins() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.main_class = None; + let merged = merge_into(child, parent); + assert_eq!(merged.id, "child"); + } + + #[test] + fn merge_carries_parent_inherits_from() { + // merge_into preserves parent's inherits_from so resolve() can keep + // walking. resolve() itself clears the final result's inherits_from + // after the loop exits. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let mut parent = empty_profile("parent"); + parent.inherits_from = Some("grandparent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.inherits_from.as_deref(), Some("grandparent")); + } + + #[test] + fn merge_with_root_parent_clears_inherits_from() { + // parent with no inherits_from means the chain ends. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let parent = empty_profile("parent"); + let merged = merge_into(child, parent); + assert!(merged.inherits_from.is_none()); + } + + #[test] + fn child_main_class_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.main_class = Some("child.Main".into()); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("child.Main")); + } + + #[test] + fn parent_main_class_used_when_child_missing() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("parent.Main")); + } + + #[test] + fn libraries_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.libraries = vec![lib("p1"), lib("p2")]; + child.libraries = vec![lib("c1")]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["p1", "p2", "c1"]); + } + + #[test] + fn child_library_supersedes_parent_with_same_group_artifact() { + // forge declares log4j 2.17.0; vanilla declared 2.0-beta9. without + // dedup, both end up on the classpath and the JVM picks the first + // (vanilla) match - defeating forge's override. dedup keeps child's. + let mut child = empty_profile("forge"); + let mut parent = empty_profile("vanilla"); + parent.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.0-beta9"), + lib("org.lwjgl:lwjgl:3.3.1"), + ]; + child.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.17.0"), + lib("net.minecraftforge:forge:47.2.0"), + ]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + // parent's log4j-core is filtered (superseded by child); parent's + // lwjgl stays (no conflict); child's log4j and forge come last. + assert_eq!( + names, + vec![ + "org.lwjgl:lwjgl:3.3.1", + "org.apache.logging.log4j:log4j-core:2.17.0", + "net.minecraftforge:forge:47.2.0", + ] + ); + } + + #[test] + fn coord_key_extracts_group_artifact() { + assert_eq!(coord_key("org.lwjgl:lwjgl:3.3.1"), "org.lwjgl:lwjgl"); + assert_eq!( + coord_key("org.apache.logging.log4j:log4j-core:2.17.0"), + "org.apache.logging.log4j:log4j-core" + ); + // with classifier + assert_eq!( + coord_key("org.lwjgl:lwjgl:3.3.1:natives-linux"), + "org.lwjgl:lwjgl" + ); + // malformed (no colons) - return as-is + assert_eq!(coord_key("malformed"), "malformed"); + } + + #[test] + fn arguments_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-parent-game".into())], + jvm: vec![Argument::Literal("--from-parent-jvm".into())], + }); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-child-game".into())], + jvm: vec![Argument::Literal("--from-child-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!( + args.game, + vec![ + Argument::Literal("--from-parent-game".into()), + Argument::Literal("--from-child-game".into()), + ] + ); + assert_eq!( + args.jvm, + vec![ + Argument::Literal("--from-parent-jvm".into()), + Argument::Literal("--from-child-jvm".into()), + ] + ); + } + + #[test] + fn arguments_from_child_only_carry_through() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--child".into())], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!(args.game.len(), 1); + assert!(args.jvm.is_empty()); + } + + #[test] + fn arguments_from_parent_only_carry_through() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![Argument::Literal("--parent-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert!(args.game.is_empty()); + assert_eq!(args.jvm.len(), 1); + } + + #[test] + fn conditional_arguments_with_rules_survive_merge() { + // make sure the Argument::Conditional shape isn't accidentally + // flattened or filtered during merging - rule eval happens later + // at render time, not during merge. + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Conditional { + rules: vec![allow_linux_rule()], + value: ArgumentValue::Single("--linux-only".into()), + }], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + match &args.game[0] { + Argument::Conditional { rules, .. } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + } + _ => panic!("expected conditional argument to survive merge"), + } + } + + #[test] + fn legacy_minecraft_arguments_child_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.minecraft_arguments = Some("--child".into()); + parent.minecraft_arguments = Some("--parent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.minecraft_arguments.as_deref(), Some("--child")); + } + + #[test] + fn asset_index_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.asset_index = Some(AssetIndex { + id: "5".into(), + url: "https://example.invalid/5.json".into(), + sha1: "0".repeat(40), + size: None, + total_size: None, + }); + let merged = merge_into(child, parent); + assert!(merged.asset_index.is_some()); + assert_eq!(merged.asset_index.unwrap().id, "5"); + } + + #[test] + fn java_version_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.java_version = Some(JavaVersion { + component: Some("java-runtime-gamma".into()), + major_version: 17, + }); + let merged = merge_into(child, parent); + assert_eq!( + merged.java_version.as_ref().map(|j| j.major_version), + Some(17) + ); + } + + use tempfile::TempDir; + + fn write_profile(meta_dir: &Path, profile: &LaunchProfile) { + let path = meta_dir + .join("versions") + .join(&profile.id) + .join("meta.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let json = serde_json::to_string_pretty(profile).unwrap(); + std::fs::write(&path, json).unwrap(); + } + + #[tokio::test] + async fn resolve_returns_unchanged_when_no_inherits_from() { + let tmp = TempDir::new().unwrap(); + let profile = empty_profile("standalone"); + let resolved = resolve(profile, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "standalone"); + assert!(resolved.inherits_from.is_none()); + } + + #[tokio::test] + async fn resolve_single_level_inheritance_merges_parent() { + let tmp = TempDir::new().unwrap(); + + let mut parent = empty_profile("1.20.1"); + parent.main_class = Some("net.minecraft.client.main.Main".into()); + parent.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &parent); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + + let resolved = resolve(child, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib"]); + } + + #[tokio::test] + async fn resolve_errors_when_parent_missing() { + let tmp = TempDir::new().unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParentNotFound(_)), + "expected ParentNotFound, got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_errors_when_parent_is_invalid_json() { + let tmp = TempDir::new().unwrap(); + + let parent_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); + std::fs::create_dir_all(parent_path.parent().unwrap()).unwrap(); + std::fs::write(&parent_path, "{ not valid json").unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParseError(_, _)), + "expected ParseError, got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_multi_level_chain_merges_all_parents() { + let tmp = TempDir::new().unwrap(); + + // chain: grandchild -> child -> root (vanilla). + let mut root = empty_profile("1.20.1"); + root.main_class = Some("net.minecraft.client.main.Main".into()); + root.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &root); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + write_profile(tmp.path(), &child); + + let mut grandchild = empty_profile("1.20.1-forge-47.2.0-modpack"); + grandchild.inherits_from = Some("1.20.1-forge-47.2.0".into()); + grandchild.libraries = vec![lib("modpack-lib")]; + + let resolved = resolve(grandchild, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0-modpack"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + // libs: root ++ child ++ grandchild (each parent prepended) + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib", "modpack-lib"]); + } + + #[tokio::test] + async fn resolve_detects_circular_chain() { + let tmp = TempDir::new().unwrap(); + + // a -> b -> a (cycle) + let mut a = empty_profile("a"); + a.inherits_from = Some("b".into()); + write_profile(tmp.path(), &a); + + let mut b = empty_profile("b"); + b.inherits_from = Some("a".into()); + write_profile(tmp.path(), &b); + + // start from a fresh "a" profile that asks to inherit from b + let mut entry = empty_profile("a"); + entry.inherits_from = Some("b".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::CircularInheritance(ref s) if s == "a"), + "expected CircularInheritance(a), got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_caps_depth() { + let tmp = TempDir::new().unwrap(); + + // build a chain 0 -> 1 -> 2 -> ... -> 10. with cap of 8, hitting 10 + // should fail with DepthExceeded. + for i in 0..=10 { + let mut p = empty_profile(&format!("v{i}")); + if i < 10 { + p.inherits_from = Some(format!("v{}", i + 1)); + } + write_profile(tmp.path(), &p); + } + + let mut entry = empty_profile("entry"); + entry.inherits_from = Some("v0".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::DepthExceeded(_)), + "expected DepthExceeded, got {err:?}" + ); + } +} diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs new file mode 100644 index 0000000..2d6d899 --- /dev/null +++ b/src/launch_profile/rules.rs @@ -0,0 +1,408 @@ +// mojang rule evaluation. profiles, libraries, and argument entries can +// carry conditional rules that filter them by OS, architecture, or feature +// flags. this module is the single source of truth for that semantics - +// see `evaluate` below for the exact rules. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleAction { + Allow, + Disallow, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct OsCondition { + pub name: Option, + pub arch: Option, + // mojang occasionally constrains natives selection on os.version with a + // regex. rare in practice - when present, it's a substring/anchor match + // against the host OS version reported by `system::mojang_os_version`. + pub version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct FeatureSet { + pub is_demo_user: Option, + pub has_custom_resolution: Option, + // quick-play feature flags (1.20+). rmcl never sets these, so any rule + // gated on them is filtered out by features_match. listing them + // explicitly is what makes that filter work: without the fields, + // serde would silently drop them during deserialization, leaving a + // FeatureSet::default that matches everything. + pub has_quick_plays_support: Option, + pub is_quick_play_singleplayer: Option, + pub is_quick_play_multiplayer: Option, + pub is_quick_play_realms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Rule { + pub action: RuleAction, + pub os: Option, + pub features: Option, +} + +pub struct RuleContext<'a> { + pub os_name: &'a str, + pub os_version: &'a str, + pub arch: &'a str, + pub features: &'a FeatureSet, +} + +pub fn evaluate(rules: &[Rule], ctx: &RuleContext) -> bool { + if rules.is_empty() { + return true; + } + let mut allowed = false; + for rule in rules { + if rule_matches(rule, ctx) { + allowed = matches!(rule.action, RuleAction::Allow); + } + } + allowed +} + +fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool { + if let Some(os) = &rule.os { + if let Some(name) = &os.name + && name != ctx.os_name + { + return false; + } + if let Some(arch) = &os.arch + && arch != ctx.arch + { + return false; + } + if let Some(pattern) = &os.version + && !os_version_matches(pattern, ctx.os_version) + { + return false; + } + } + if let Some(required) = &rule.features + && !features_match(required, ctx.features) + { + return false; + } + true +} + +// mojang's os.version constraints are typically anchored regex patterns +// (e.g. `^10\\.`). we do a substring containment check as a defensive +// approximation that doesn't pull in the `regex` crate. when the host +// os_version is empty (Windows fallback path returns ""), version-gated +// rules don't match - which is the conservative default. +fn os_version_matches(pattern: &str, host_version: &str) -> bool { + if host_version.is_empty() { + return false; + } + // strip common regex anchors and metacharacters for substring lookup. + // good enough for the rare profile that uses os.version. + let needle = pattern + .trim_start_matches('^') + .trim_end_matches('$') + .trim_end_matches('.') + .trim_end_matches('\\'); + host_version.contains(needle) +} + +fn features_match(required: &FeatureSet, current: &FeatureSet) -> bool { + let pairs = [ + (required.is_demo_user, current.is_demo_user), + (required.has_custom_resolution, current.has_custom_resolution), + ( + required.has_quick_plays_support, + current.has_quick_plays_support, + ), + ( + required.is_quick_play_singleplayer, + current.is_quick_play_singleplayer, + ), + ( + required.is_quick_play_multiplayer, + current.is_quick_play_multiplayer, + ), + (required.is_quick_play_realms, current.is_quick_play_realms), + ]; + pairs.iter().all(|(req, cur)| match req { + Some(want) => cur.unwrap_or(false) == *want, + None => true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> { + RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features, + } + } + + #[test] + fn empty_rules_allow() { + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&[], &ctx)); + } + + #[test] + fn single_allow_matching_os_allows() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn single_disallow_matching_os_disallows() { + let rules = vec![Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn allow_without_os_match_disallows_by_default() { + // explicit allow for windows; we are on linux; nothing matches; + // default state remains disallow. + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + ..Default::default() + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn last_matching_rule_wins_when_allow_then_disallow() { + let rules = vec![ + Rule { + action: RuleAction::Allow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + ..Default::default() + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let osx_ctx = RuleContext { + os_name: "osx", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &osx_ctx)); + // and on linux: only the first rule matches → still allow. + let lin = linux_ctx(&features); + assert!(evaluate(&rules, &lin)); + } + + #[test] + fn last_matching_rule_wins_when_disallow_then_allow() { + let rules = vec![ + Rule { + action: RuleAction::Disallow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + ..Default::default() + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn arch_mismatch_blocks_rule() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: Some("arm64".into()), + version: None, + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + // we are on linux but x86_64, not arm64 → rule does not match. + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn feature_required_true_matches_when_ctx_true() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }), + }]; + let demo_features = FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }; + let ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &demo_features, + }; + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn feature_required_true_blocks_when_ctx_false() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + ..Default::default() + }), + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn no_os_no_features_rule_always_matches() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn os_version_pattern_matches_against_host() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx_match = RuleContext { + os_name: "osx", + os_version: "10.15.7", + arch: "x86_64", + features: &features, + }; + assert!(evaluate(&rules, &ctx_match)); + let ctx_mismatch = RuleContext { + os_name: "osx", + os_version: "13.2.1", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx_mismatch)); + } + + #[test] + fn os_version_pattern_does_not_match_when_host_unknown() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = RuleContext { + os_name: "windows", + os_version: "", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn rule_deserializes_from_mojang_json() { + // shape lifted from real mojang library rules. + let json = r#"{ + "action": "allow", + "os": { "name": "osx" } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert_eq!( + rule.os.as_ref().and_then(|o| o.name.as_deref()), + Some("osx") + ); + assert!(rule.os.as_ref().and_then(|o| o.arch.as_ref()).is_none()); + assert!(rule.features.is_none()); + } + + #[test] + fn rule_deserializes_with_features() { + let json = r#"{ + "action": "allow", + "features": { "is_demo_user": true } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert!(rule.os.is_none()); + assert_eq!( + rule.features.as_ref().and_then(|f| f.is_demo_user), + Some(true) + ); + } +} diff --git a/src/launch_profile/system.rs b/src/launch_profile/system.rs new file mode 100644 index 0000000..f41da61 --- /dev/null +++ b/src/launch_profile/system.rs @@ -0,0 +1,37 @@ +// system-detection helpers shared by launch and install paths. mojang +// names some things differently from rust's std::env::consts (e.g. macOS +// is "osx" in mojang profile rules), so this module is the single source +// of truth for translating. + +pub fn mojang_os_name() -> &'static str { + match std::env::consts::OS { + "macos" => "osx", + other => other, + } +} + +pub fn mojang_arch_name() -> &'static str { + match std::env::consts::ARCH { + "x86" => "x86", + "x86_64" => "x86_64", + "aarch64" => "arm64", + other => other, + } +} + +// the host OS version string. mojang rules occasionally constrain natives +// selection on os.version with a regex (e.g. macOS 10.x-only natives). +// rust's stdlib doesn't expose this, so we read it where it's cheap and +// reliable: linux via /proc/sys/kernel/osrelease, other platforms return +// empty. when the host string is empty, version-gated rules don't match +// (conservative default in the rule evaluator) - which is fine because +// real-world profiles using os.version are vanishingly rare. +pub fn mojang_os_version() -> String { + #[cfg(target_os = "linux")] + { + if let Ok(s) = std::fs::read_to_string("/proc/sys/kernel/osrelease") { + return s.trim().to_string(); + } + } + String::new() +} diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs new file mode 100644 index 0000000..4d53058 --- /dev/null +++ b/src/launch_profile/templates.rs @@ -0,0 +1,275 @@ +// template substitution for mojang-style launch arguments. profiles use +// `${variable_name}` placeholders that the launcher fills in at launch +// time from the active session: paths, the user's account info, the +// classpath, the resolved natives directory, and so on. +// +// the full set of variables is documented in `TemplateContext`. unknown +// placeholders are left as-is and logged at `warn` level - that way if +// mojang adds a new variable in the future, the launcher fails open +// rather than silently swallowing it. + +use std::path::Path; + +pub struct TemplateContext<'a> { + pub library_directory: &'a Path, + pub classpath_separator: &'a str, + pub version_name: &'a str, + pub version_type: &'a str, + pub natives_directory: &'a Path, + pub classpath: &'a str, + pub game_directory: &'a Path, + pub assets_root: &'a Path, + pub assets_index_name: &'a str, + pub auth_player_name: &'a str, + pub auth_uuid: &'a str, + pub auth_access_token: &'a str, + pub auth_xuid: &'a str, + pub user_type: &'a str, + pub user_properties: &'a str, + pub launcher_name: &'a str, + pub launcher_version: &'a str, + pub clientid: &'a str, +} + +pub fn substitute(input: &str, ctx: &TemplateContext) -> String { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(open) = rest.find("${") { + out.push_str(&rest[..open]); + let after_open = &rest[open + 2..]; + match after_open.find('}') { + Some(close_rel) => { + let name = &after_open[..close_rel]; + match lookup(name, ctx) { + Some(value) => out.push_str(&value), + None => { + tracing::warn!("unknown launch template variable: ${{{}}}", name); + out.push_str("${"); + out.push_str(name); + out.push('}'); + } + } + rest = &after_open[close_rel + 1..]; + } + None => { + // unclosed `${...` - emit the rest literally and stop. + out.push_str("${"); + out.push_str(after_open); + return out; + } + } + } + out.push_str(rest); + out +} + +// quick-play templates (`${quickPlayPath}`, `${quickPlaySingleplayer}`, +// `${quickPlayMultiplayer}`, `${quickPlayRealms}`) are intentionally not +// listed here. they only appear in `arguments.game` entries gated on +// `is_quick_play_*` feature flags; since rmcl never sets those flags +// (FeatureSet defaults to None across the board), the surrounding +// conditional argument is filtered out by the rule evaluator before +// template substitution even runs. if we ever expose quick-play to users, +// add the variables here AND set the corresponding feature flags in +// RuleContext at launch. +fn lookup(name: &str, ctx: &TemplateContext) -> Option { + Some(match name { + "library_directory" => ctx.library_directory.display().to_string(), + "classpath_separator" => ctx.classpath_separator.to_string(), + "version_name" => ctx.version_name.to_string(), + "version_type" => ctx.version_type.to_string(), + "natives_directory" => ctx.natives_directory.display().to_string(), + "classpath" => ctx.classpath.to_string(), + "game_directory" => ctx.game_directory.display().to_string(), + "assets_root" => ctx.assets_root.display().to_string(), + "assets_index_name" => ctx.assets_index_name.to_string(), + "auth_player_name" => ctx.auth_player_name.to_string(), + "auth_uuid" => ctx.auth_uuid.to_string(), + "auth_access_token" => ctx.auth_access_token.to_string(), + "auth_xuid" => ctx.auth_xuid.to_string(), + "user_type" => ctx.user_type.to_string(), + "user_properties" => ctx.user_properties.to_string(), + "launcher_name" => ctx.launcher_name.to_string(), + "launcher_version" => ctx.launcher_version.to_string(), + "clientid" => ctx.clientid.to_string(), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn ctx_fixture<'a>( + library_directory: &'a Path, + natives_directory: &'a Path, + game_directory: &'a Path, + assets_root: &'a Path, + ) -> TemplateContext<'a> { + TemplateContext { + library_directory, + classpath_separator: ":", + version_name: "1.20.1", + version_type: "release", + natives_directory, + classpath: "a.jar:b.jar", + game_directory, + assets_root, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + } + } + + #[test] + fn no_placeholders_unchanged() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("--add-modules ALL-MODULE-PATH", &ctx), + "--add-modules ALL-MODULE-PATH" + ); + } + + #[test] + fn single_known_substitution() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!(substitute("v=${version_name}", &ctx), "v=1.20.1"); + } + + #[test] + fn unknown_placeholder_left_as_is() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("x=${not_a_real_var}y", &ctx), + "x=${not_a_real_var}y" + ); + } + + #[test] + fn unclosed_placeholder_left_as_is() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("--prefix ${unclosed", &ctx), + "--prefix ${unclosed" + ); + } + + #[test] + fn dollar_without_brace_is_literal() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!(substitute("$$ literal $5 $", &ctx), "$$ literal $5 $"); + } + + #[test] + fn multiple_substitutions() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("${version_name}-${auth_player_name}", &ctx), + "1.20.1-Player" + ); + } + + #[test] + fn path_substitution() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("-DlibraryDirectory=${library_directory}", &ctx), + "-DlibraryDirectory=/m/libraries" + ); + } + + #[test] + fn substituted_value_is_not_recursively_substituted() { + // simulate a `user_properties` value that happens to contain a `${...}` + // pattern. it should NOT trigger another substitution pass. + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = TemplateContext { + library_directory: &lib, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory: &nat, + classpath: "a.jar:b.jar", + game_directory: &game, + assets_root: &assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "${version_name}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + version_type: "release", + }; + assert_eq!(substitute("${user_properties}", &ctx), "${version_name}"); + } + + #[test] + fn empty_input_is_empty() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!(substitute("", &ctx), ""); + } + + #[test] + fn windows_style_backslashes_in_value_pass_through() { + // simulate a Windows install where library_directory is a path with + // backslashes. the substitution must not interpret backslashes as + // escape sequences or do anything else clever - it just copies the + // value into the output. + let lib = PathBuf::from(r"C:\Users\test\.minecraft\libraries"); + let nat = PathBuf::from(r"C:\Users\test\.minecraft\natives"); + let game = PathBuf::from(r"C:\Users\test\.minecraft"); + let assets = PathBuf::from(r"C:\Users\test\.minecraft\assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + let result = substitute("-Dpath=${library_directory}", &ctx); + assert!( + result.contains(r"C:\Users\test\.minecraft\libraries"), + "expected backslashes preserved, got: {result}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 1179e54..c9cd4b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; pub mod config; pub mod instance; pub mod instance_logs; +pub mod launch_profile; mod migrate; pub mod net; pub mod running; diff --git a/src/net/fabric.rs b/src/net/fabric.rs index 86bcdc4..aea2644 100644 --- a/src/net/fabric.rs +++ b/src/net/fabric.rs @@ -77,6 +77,22 @@ pub async fn fetch_fabric_profile( client.get_json(&url).await } +// like fetch_fabric_profile but also returns the raw response bytes so the +// caller can write the upstream JSON byte-for-byte to disk. used by the +// install path so we don't lose data (e.g. any future arguments field) by +// re-serializing through our narrow FabricProfile struct. +pub async fn fetch_fabric_profile_with_raw( + client: &HttpClient, + game_version: &str, + loader_version: &str, +) -> Result<(FabricProfile, Vec), NetError> { + let url = format!( + "{}/versions/loader/{}/{}/profile/json", + FABRIC_META_BASE, game_version, loader_version + ); + client.get_json_with_raw(&url, "Fabric profile").await +} + // each fabric library entry has a maven coordinate and a base url. // the coordinate gets resolved to a path and combined with the url to download. pub async fn download_fabric_libraries( diff --git a/src/net/forge.rs b/src/net/forge.rs index e3fddac..ba11279 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -194,12 +194,6 @@ pub(crate) async fn install_forge_from_profile( .get("install") .ok_or_else(|| NetError::Parse("install_profile.json missing install section".into()))?; - let main_class = version_info - .get("mainClass") - .and_then(|v| v.as_str()) - .ok_or_else(|| NetError::Parse("missing versionInfo.mainClass".into()))? - .to_string(); - let libraries = version_info .get("libraries") .and_then(|v| v.as_array()) @@ -271,78 +265,26 @@ pub(crate) async fn install_forge_from_profile( download_file(client, &download_url, &dest, |_, _| {}).await?; } - // old forge profiles store game arguments (like --tweakClass) in - // minecraftArguments. extract non-template args to pass at launch. - let game_arguments: Vec = version_info - .get("minecraftArguments") - .and_then(|v| v.as_str()) - .map(extract_extra_game_args) - .unwrap_or_default(); - set_action("Saving Forge profile..."); - let lib_entries: Vec = libraries - .iter() - .filter_map(|l| { - l.get("name") - .and_then(|v| v.as_str()) - .map(|n| serde_json::json!({"name": n})) - }) - .collect(); - - let profile_json = serde_json::json!({ - "mainClass": main_class, - "libraries": lib_entries, - "gameArguments": game_arguments, - }); - - crate::instance::loader::save_profile_json(meta_dir, profile_filename, &profile_json)?; - + // write the installer's versionInfo as compact JSON. it already has the + // mainClass, the full library list (with name + url for forge-hosted + // libs), and minecraftArguments (the legacy --tweakClass etc). the + // launch flow parses this as a LaunchProfile and - if there's no + // inheritsFrom field - implicitly inherits from the configured game + // version so vanilla libraries layer in via resolve(). + // + // we use serde_json::to_vec (not the pretty-print variant via + // save_profile_json) so the written file is content-faithful: every + // field present in the installer's versionInfo round-trips. key order + // and whitespace may differ from the original installer JSON because + // the source is a serde_json::Value (which doesn't preserve order), + // but no field is silently dropped. + let serialized = serde_json::to_vec(version_info) + .map_err(|e| NetError::Parse(format!("Failed to serialize Forge profile: {e}")))?; + crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized)?; Ok(()) } -// pulls out game arguments from old forge's minecraftArguments string that -// the launcher doesn't already handle. skips template variables (${...}) -// and standard args like --username that we build ourselves. -fn extract_extra_game_args(minecraft_arguments: &str) -> Vec { - let handled = [ - "--username", - "--version", - "--gameDir", - "--assetsDir", - "--assetIndex", - "--uuid", - "--accessToken", - "--userProperties", - "--userType", - ]; - - let tokens: Vec<&str> = minecraft_arguments.split_whitespace().collect(); - let mut result = Vec::new(); - let mut i = 0; - while i < tokens.len() { - let token = tokens[i]; - if token.starts_with("--") { - if handled.contains(&token) { - // skip the flag and its value - i += 2; - continue; - } - result.push(token.to_string()); - // if the next token is a value (not a flag or template), include it - if i + 1 < tokens.len() && !tokens[i + 1].starts_with("--") { - let val = tokens[i + 1]; - if !val.starts_with("${") { - result.push(val.to_string()); - } - i += 2; - continue; - } - } - i += 1; - } - result -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/net/mod.rs b/src/net/mod.rs index 65da353..cdd05cb 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -69,8 +69,66 @@ impl HttpClient { } pub async fn get_json(&self, url: &str) -> Result { - Ok(self.get(url).await?.json().await?) + get_with_retry(self, url, |resp| async move { Ok(resp.json().await?) }).await } + + pub async fn get_bytes(&self, url: &str) -> Result, NetError> { + get_with_retry( + self, + url, + |resp| async move { Ok(resp.bytes().await?.to_vec()) }, + ) + .await + } + + // fetch JSON and also keep the raw bytes. used by install paths that + // want both the parsed shape (for downloading libraries from it) and + // the original bytes (to write byte-for-byte to the loader-profiles + // cache, so any field we don't know about survives). + pub async fn get_json_with_raw( + &self, + url: &str, + label: &str, + ) -> Result<(T, Vec), NetError> { + let raw = self.get_bytes(url).await?; + let parsed: T = serde_json::from_slice(&raw) + .map_err(|e| NetError::Parse(format!("Failed to parse {label}: {e}")))?; + Ok((parsed, raw)) + } +} + +// shared retry envelope around `client.get(url).await? -> decode`. retries +// transient failures (timeouts, connect errors, 5xx) with exponential +// backoff. used by both get_json and get_bytes. +async fn get_with_retry(client: &HttpClient, url: &str, decode: F) -> Result +where + F: Fn(reqwest::Response) -> Fut, + Fut: std::future::Future>, +{ + let mut last_error = None; + for attempt in 0..=MAX_RETRIES { + if attempt > 0 { + let delay = RETRY_BASE_DELAY_MS * 2u64.pow(attempt - 1); + tracing::warn!( + "retrying request (attempt {}/{}): {}", + attempt + 1, + MAX_RETRIES + 1, + url + ); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + } + + match client.get(url).await { + Ok(resp) => match decode(resp).await { + Ok(value) => return Ok(value), + Err(e) if is_retryable(&e) => last_error = Some(e), + Err(e) => return Err(e), + }, + Err(e) if is_retryable(&e) => last_error = Some(e), + Err(e) => return Err(e), + } + } + Err(last_error.unwrap()) } const MAX_RETRIES: u32 = 3; @@ -140,7 +198,10 @@ async fn download_file_once( Ok(()) } -// body decode errors and timeouts are worth retrying, but a 404 or disk error isn't +// body decode errors and timeouts are worth retrying, but a 404 or disk +// error isn't. Parse errors stay non-retryable: by the time we hit one +// the response body has fully arrived, so the failure means the upstream +// returned malformed JSON - retrying won't fix that. fn is_retryable(err: &NetError) -> bool { match err { NetError::Http(e) => e.is_timeout() || e.is_body() || e.is_connect(), diff --git a/src/net/mojang.rs b/src/net/mojang.rs index 1a9b2f8..d8b0dcc 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -76,7 +76,7 @@ pub struct Download { pub struct Library { pub name: String, pub downloads: LibraryDownloads, - pub rules: Option>, + pub rules: Option>, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -92,17 +92,6 @@ pub struct Artifact { pub size: u64, } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Rule { - pub action: String, - pub os: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct OsRule { - pub name: Option, -} - #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct JavaVersion { @@ -124,11 +113,15 @@ pub async fn fetch_version_manifest(client: &HttpClient) -> Result Result { - client.get_json(&entry.url).await +) -> Result<(VersionMeta, Vec), NetError> { + client.get_json_with_raw(&entry.url, "version meta").await } pub async fn download_client_jar( @@ -169,9 +162,20 @@ pub async fn download_libraries( ) -> Result<(), NetError> { set_action("Downloading libraries..."); + let features = crate::launch_profile::rules::FeatureSet::default(); + let host_os_version = crate::launch_profile::system::mojang_os_version(); + let rule_ctx = crate::launch_profile::rules::RuleContext { + os_name: crate::launch_profile::system::mojang_os_name(), + os_version: &host_os_version, + arch: crate::launch_profile::system::mojang_arch_name(), + features: &features, + }; + let mut downloads = Vec::new(); for library in &meta.libraries { - if !library_allowed_for_current_os(library) { + if let Some(rules) = &library.rules + && !crate::launch_profile::rules::evaluate(rules, &rule_ctx) + { continue; } @@ -281,51 +285,6 @@ pub async fn download_assets( result } -// mojang's rule system is a bit quirky: no rules means "allow everywhere", -// and rules are evaluated in order where the last matching rule wins. -// a "disallow" for the current OS is an immediate reject though. -fn library_allowed_for_current_os(library: &Library) -> bool { - let rules = match &library.rules { - Some(rules) => rules, - None => return true, - }; - - let current_os = mojang_os_name(); - let mut allowed = false; - - for rule in rules { - let matches_os = match &rule.os { - Some(os_rule) => match &os_rule.name { - Some(name) => name == current_os, - None => true, - }, - None => true, - }; - - if !matches_os { - continue; - } - - if rule.action == "disallow" { - return false; - } - - if rule.action == "allow" { - allowed = true; - } - } - - allowed -} - -// mojang calls macOS "osx" because apparently it's still 2012 -fn mojang_os_name() -> &'static str { - match std::env::consts::OS { - "macos" => "osx", - other => other, - } -} - // bounded parallel downloader. spawns up to MAX_CONCURRENT_DOWNLOADS tasks // and feeds new ones in as each completes. collects errors but keeps going // so it downloads as much as possible before reporting the first failure. diff --git a/src/net/quilt.rs b/src/net/quilt.rs index 74560ec..eb9ceec 100644 --- a/src/net/quilt.rs +++ b/src/net/quilt.rs @@ -74,6 +74,22 @@ pub async fn fetch_quilt_profile( client.get_json(&url).await } +// like fetch_quilt_profile but also returns the raw response bytes so the +// caller can write the upstream JSON byte-for-byte to disk. used by the +// install path so we don't lose data (e.g. any future arguments field) by +// re-serializing through our narrow QuiltProfile struct. +pub async fn fetch_quilt_profile_with_raw( + client: &HttpClient, + game_version: &str, + loader_version: &str, +) -> Result<(QuiltProfile, Vec), NetError> { + let url = format!( + "{}/versions/loader/{}/{}/profile/json", + QUILT_META_BASE, game_version, loader_version + ); + client.get_json_with_raw(&url, "Quilt profile").await +} + pub async fn download_quilt_libraries( client: &HttpClient, profile: &QuiltProfile,