diff --git a/public/images/icons/OptiFine.png b/public/images/icons/OptiFine.png new file mode 100644 index 000000000..b67f5d4e1 Binary files /dev/null and b/public/images/icons/OptiFine.png differ diff --git a/src-tauri/src/instance/commands.rs b/src-tauri/src/instance/commands.rs index a23a0ab74..c7082cbb2 100644 --- a/src-tauri/src/instance/commands.rs +++ b/src-tauri/src/instance/commands.rs @@ -5,6 +5,9 @@ use crate::instance::helpers::client_json::{replace_native_libraries, McClientIn use crate::instance::helpers::game_version::{build_game_version_cmp_fn, compare_game_versions}; use crate::instance::helpers::loader::common::{execute_processors, install_mod_loader}; use crate::instance::helpers::loader::forge::InstallProfile; +use crate::instance::helpers::loader::optifine::{ + download_optifine_installer, finish_optifine_install, +}; use crate::instance::helpers::misc::{ get_instance_game_config, get_instance_subdir_path_by_id, get_instance_subdir_paths, refresh_and_update_instances, unify_instance_name, @@ -26,7 +29,8 @@ use crate::instance::helpers::server::{ use crate::instance::helpers::world::{load_level_data_from_nbt, load_world_info_from_dir}; use crate::instance::models::misc::{ Instance, InstanceError, InstanceSubdirType, InstanceSummary, LocalModInfo, ModLoader, - ModLoaderStatus, ModLoaderType, ResourcePackInfo, SchematicInfo, ScreenshotInfo, ShaderPackInfo, + ModLoaderStatus, ModLoaderType, OptiFine, ResourcePackInfo, SchematicInfo, ScreenshotInfo, + ShaderPackInfo, }; use crate::instance::models::world::base::WorldInfo; use crate::instance::models::world::level::LevelData; @@ -35,7 +39,9 @@ use crate::launcher_config::helpers::misc::get_global_game_config; use crate::launcher_config::models::{GameConfig, GameDirectory, LauncherConfig}; use crate::partial::{PartialError, PartialUpdate}; use crate::resource::helpers::misc::get_source_priority_list; -use crate::resource::models::{GameClientResourceInfo, ModLoaderResourceInfo}; +use crate::resource::models::{ + GameClientResourceInfo, ModLoaderResourceInfo, OptiFineResourceInfo, +}; use crate::storage::{load_json_async, save_json_async, Storage}; use crate::tasks::commands::schedule_progressive_task_group; use crate::tasks::download::DownloadParam; @@ -862,6 +868,7 @@ pub async fn create_instance( icon_src: String, game: GameClientResourceInfo, mod_loader: ModLoaderResourceInfo, + optifine: Option, modpack_path: Option, is_install_fabric_api: Option, ) -> SJMCLResult<()> { @@ -878,6 +885,11 @@ pub async fn create_instance( if version_path.exists() { return Err(InstanceError::ConflictNameError.into()); } + let optifine_info = optifine.as_ref().map(|info| OptiFine { + filename: info.filename.clone(), + version: format!("{}_{}", info.r#type, info.patch), + status: ModLoaderStatus::NotDownloaded, + }); // Create instance config let instance = Instance { @@ -898,6 +910,7 @@ pub async fn create_instance( version: mod_loader.version.clone(), branch: mod_loader.branch.clone(), }, + optifine: optifine_info, description, icon_src, starred: false, @@ -969,6 +982,7 @@ pub async fn create_instance( task_params .extend(get_invalid_assets(&app, &version_info, priority_list[0], assets_dir, false).await?); + // download loader (installer) if instance.mod_loader.loader_type != ModLoaderType::Unknown { install_mod_loader( app.clone(), @@ -984,6 +998,16 @@ pub async fn create_instance( .await?; } + if let Some(info) = optifine.as_ref() { + download_optifine_installer( + &instance.version, + info, + libraries_dir.to_path_buf(), + &mut task_params, + ) + .await?; + } + // If modpack path is provided, install it if let Some(modpack_path) = modpack_path { let path = PathBuf::from(modpack_path); @@ -1079,6 +1103,31 @@ pub async fn finish_mod_loader_install(app: AppHandle, instance_id: String) -> S execute_processors(&app, &instance, &client_info, &install_profile).await?; } + if let Some(optifine) = &instance.optifine { + match optifine.status { + // prevent duplicated installation + ModLoaderStatus::DownloadFailed => { + return Err(InstanceError::ProcessorExecutionFailed.into()); + } + ModLoaderStatus::Installing => { + return Err(InstanceError::InstallationDuplicated.into()); + } + ModLoaderStatus::Installed => { + return Ok(()); + } + _ => {} + } + { + let binding = app.state::>>(); + let mut state = binding.lock()?; + let instance = state + .get_mut(&instance_id) + .ok_or(InstanceError::InstanceNotFoundByID)?; + instance.optifine.as_mut().unwrap().status = ModLoaderStatus::Installing; + }; + finish_optifine_install(&app, &instance, &client_info).await?; + } + let instance = { let binding = app.state::>>(); let mut state = binding.lock()?; @@ -1086,6 +1135,9 @@ pub async fn finish_mod_loader_install(app: AppHandle, instance_id: String) -> S .get_mut(&instance_id) .ok_or(InstanceError::InstanceNotFoundByID)?; instance.mod_loader.status = ModLoaderStatus::Installed; + if let Some(optifine) = &mut instance.optifine { + optifine.status = ModLoaderStatus::Installed; + } instance.clone() }; instance.save_json_cfg().await?; diff --git a/src-tauri/src/instance/helpers/client_json.rs b/src-tauri/src/instance/helpers/client_json.rs index 9d33efc83..6fbae6348 100644 --- a/src-tauri/src/instance/helpers/client_json.rs +++ b/src-tauri/src/instance/helpers/client_json.rs @@ -1,7 +1,7 @@ use crate::error::{SJMCLError, SJMCLResult}; -use crate::instance::helpers::game_version::compare_game_versions; use crate::instance::models::misc::{Instance, ModLoaderType}; use crate::launcher_config::models::LauncherConfig; +use crate::resource::models::OptiFineResourceInfo; use crate::utils::fs::get_app_resource_filepath; use regex::RegexBuilder; use serde::{Deserialize, Deserializer, Serialize}; @@ -306,10 +306,16 @@ pub struct LoggingFile { pub fn patches_to_info( patches: &[McClientInfo], -) -> (Option, Option, ModLoaderType) { +) -> ( + Option, + Option, + ModLoaderType, + Option, +) { let mut loader_type = ModLoaderType::Unknown; let mut game_version = None; let mut loader_version = None; + let mut optifine_info: Option = None; for patch in patches { if game_version.is_none() && patch.id == "game" { game_version = patch.version.clone(); @@ -320,18 +326,25 @@ pub fn patches_to_info( loader_version = patch.version.clone(); } } - - if game_version.is_some() && loader_type != ModLoaderType::Unknown { - break; + if patch.id == "optifine" { + optifine_info = Some(OptiFineResourceInfo { + patch: "".to_string(), + filename: "".to_string(), + r#type: patch.version.clone().unwrap_or_default(), + }); } } - - (game_version, loader_version, loader_type) + (game_version, loader_version, loader_type, optifine_info) } pub async fn libraries_to_info( client: &McClientInfo, -) -> (Option, Option, ModLoaderType) { +) -> ( + Option, + Option, + ModLoaderType, + Option, +) { let game_version: Option = client.client_version.clone(); let mut loader_version: Option = None; let mut loader_type = ModLoaderType::Unknown; @@ -394,7 +407,7 @@ pub async fn libraries_to_info( } } - (game_version, loader_version, loader_type) + (game_version, loader_version, loader_type, None) } fn rules_is_allowed(rules: &Vec, feature: &FeaturesInfo) -> SJMCLResult { @@ -478,6 +491,7 @@ pub async fn replace_native_libraries( #[cfg(all(target_arch = "aarch64", target_os = "macos"))] { + use crate::instance::helpers::game_version::compare_game_versions; if compare_game_versions(app, instance.version.as_str(), "1.20.1", true).await == Ordering::Greater { diff --git a/src-tauri/src/instance/helpers/loader/common.rs b/src-tauri/src/instance/helpers/loader/common.rs index 131e5b353..c553f6b90 100644 --- a/src-tauri/src/instance/helpers/loader/common.rs +++ b/src-tauri/src/instance/helpers/loader/common.rs @@ -59,7 +59,7 @@ pub async fn install_mod_loader( .await } ModLoaderType::Forge => { - install_forge_loader(priority, game_version, loader, lib_dir, task_params).await + install_forge_loader(priority, game_version, loader, lib_dir.clone(), task_params).await } ModLoaderType::NeoForge => { install_neoforge_loader(priority, loader, lib_dir, task_params).await diff --git a/src-tauri/src/instance/helpers/loader/forge.rs b/src-tauri/src/instance/helpers/loader/forge.rs index a0f59c8e6..0c9034b16 100644 --- a/src-tauri/src/instance/helpers/loader/forge.rs +++ b/src-tauri/src/instance/helpers/loader/forge.rs @@ -95,8 +95,7 @@ pub async fn download_forge_libraries( app: &AppHandle, priority: &[SourceType], instance: &Instance, - client_info: &McClientInfo, - is_retry: bool, // do not modify client info, just download necessary files + client_info: &mut McClientInfo, ) -> SJMCLResult<()> { let subdirs = get_instance_subdir_paths( app, @@ -109,8 +108,6 @@ pub async fn download_forge_libraries( }; let mut task_params = vec![]; - let mut client_info = client_info.clone(); - let installer_coord = format!( "net.minecraftforge:forge:{}-installer", instance.mod_loader.version @@ -124,6 +121,9 @@ pub async fn download_forge_libraries( ), None, )?); + if !installer_path.exists() { + return Err(InstanceError::LoaderInstallerNotFound.into()); + } let file = File::open(&installer_path)?; let mut archive = ZipArchive::new(file)?; @@ -304,14 +304,14 @@ pub async fn download_forge_libraries( })); } - let (arguments, minecraft_arguments) = if let Some(v_args) = client_info.arguments { + let (arguments, minecraft_arguments) = if let Some(v_args) = &client_info.arguments { let nf_args = forge_info .arguments .ok_or(InstanceError::ModLoaderVersionParseError)?; let new_args = LaunchArgumentTemplate { - game: [v_args.game, nf_args.game].concat(), - jvm: [v_args.jvm, nf_args.jvm].concat(), + game: [v_args.game.clone(), nf_args.game.clone()].concat(), + jvm: [v_args.jvm.clone(), nf_args.jvm.clone()].concat(), }; (Some(new_args), None) } else { @@ -447,13 +447,6 @@ pub async fn download_forge_libraries( ) .await?; - if !is_retry { - let vjson_path = instance - .version_path - .join(format!("{}.json", instance.name)); - fs::write(vjson_path, serde_json::to_vec_pretty(&client_info)?)?; - } - Ok(()) } diff --git a/src-tauri/src/instance/helpers/loader/mod.rs b/src-tauri/src/instance/helpers/loader/mod.rs index 11476f783..0806ad901 100644 --- a/src-tauri/src/instance/helpers/loader/mod.rs +++ b/src-tauri/src/instance/helpers/loader/mod.rs @@ -2,3 +2,4 @@ pub mod common; pub mod fabric; pub mod forge; pub mod neoforge; +pub mod optifine; diff --git a/src-tauri/src/instance/helpers/loader/neoforge.rs b/src-tauri/src/instance/helpers/loader/neoforge.rs index 21feffe4c..30565cbdb 100644 --- a/src-tauri/src/instance/helpers/loader/neoforge.rs +++ b/src-tauri/src/instance/helpers/loader/neoforge.rs @@ -72,8 +72,7 @@ pub async fn download_neoforge_libraries( app: &AppHandle, priority: &[SourceType], instance: &Instance, - client_info: &McClientInfo, - is_retry: bool, // do not modify client info, just download necessary files + client_info: &mut McClientInfo, ) -> SJMCLResult<()> { let subdirs = get_instance_subdir_paths( app, @@ -86,8 +85,6 @@ pub async fn download_neoforge_libraries( }; let mut task_params = vec![]; - let mut client_info = client_info.clone(); - let name = if instance.mod_loader.version.starts_with("1.20.1-") { "forge" } else { @@ -107,6 +104,9 @@ pub async fn download_neoforge_libraries( ), None, )?); + if !installer_path.exists() { + return Err(InstanceError::LoaderInstallerNotFound.into()); + } let (content, version) = { let file = File::open(&installer_path)?; let mut archive = ZipArchive::new(file)?; @@ -341,11 +341,5 @@ pub async fn download_neoforge_libraries( ) .await?; - if !is_retry { - let vjson_path = instance - .version_path - .join(format!("{}.json", instance.name)); - fs::write(vjson_path, serde_json::to_vec_pretty(&client_info)?)?; - } Ok(()) } diff --git a/src-tauri/src/instance/helpers/loader/optifine.rs b/src-tauri/src/instance/helpers/loader/optifine.rs new file mode 100644 index 000000000..7ecb70fcf --- /dev/null +++ b/src-tauri/src/instance/helpers/loader/optifine.rs @@ -0,0 +1,439 @@ +use crate::error::SJMCLResult; +use crate::instance::helpers::client_json::{ArgumentsItem, LaunchArgumentTemplate}; +use crate::instance::helpers::client_json::{LibrariesValue, McClientInfo}; +use crate::instance::helpers::loader::common::add_library_entry; +use crate::instance::helpers::misc::{get_instance_game_config, get_instance_subdir_paths}; +use crate::instance::models::misc::{Instance, InstanceError, InstanceSubdirType, ModLoaderType}; +use crate::launch::helpers::file_validator::convert_library_name_to_path; +use crate::launch::helpers::jre_selector::select_java_runtime; +use crate::launcher_config::models::JavaInfo; +use crate::launcher_config::models::LauncherConfig; +use crate::resource::helpers::misc::get_source_priority_list; +use crate::resource::helpers::misc::{convert_url_to_target_source, get_download_api}; +use crate::resource::models::{OptiFineResourceInfo, ResourceType, SourceType}; +use crate::tasks::commands::schedule_progressive_task_group; +use crate::tasks::download::DownloadParam; +use crate::tasks::PTaskParam; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; +use zip::{write::FileOptions, ZipArchive, ZipWriter}; + +pub async fn download_optifine_installer( + game_version: &str, + optifine: &OptiFineResourceInfo, + lib_dir: PathBuf, + task_params: &mut Vec, +) -> SJMCLResult<()> { + // only have BMCLAPI source + let root = get_download_api(SourceType::BMCLAPIMirror, ResourceType::OptiFine)?; + let installer_url = root.join(&format!( + "{}/{}/{}", + game_version, optifine.r#type, optifine.patch + ))?; + + let installer_coord = format!( + "net.minecraftforge:optifine:{}-installer", + optifine.filename + ); + let installer_rel = convert_library_name_to_path(&installer_coord, None)?; + let installer_path = lib_dir.join(&installer_rel); + + task_params.push(PTaskParam::Download(DownloadParam { + src: installer_url, + dest: installer_path.clone(), + filename: None, + sha1: None, + })); + + Ok(()) +} + +async fn download_optifine_libraries( + app: &AppHandle, + priority: &[SourceType], + instance: &Instance, + client_info: &McClientInfo, +) -> SJMCLResult<()> { + let mut client_info = client_info.clone(); + let optifine = instance + .optifine + .as_ref() + .ok_or(InstanceError::ClientJsonParseError)?; + + let subdirs = get_instance_subdir_paths( + app, + instance, + &[&InstanceSubdirType::Root, &InstanceSubdirType::Libraries], + ) + .ok_or(InstanceError::InvalidSourcePath)?; + let [_root_dir, lib_dir] = subdirs.as_slice() else { + return Err(InstanceError::InvalidSourcePath.into()); + }; + + let mut task_params: Vec = vec![]; + let installer_coord = format!("net.minecraftforge:optifine:{}", optifine.filename); + let installer_rel = convert_library_name_to_path(&installer_coord, None)?; + let installer_path = lib_dir.join(&installer_rel); + + if !installer_path.exists() { + return Err(InstanceError::ProcessorExecutionFailed.into()); + } + let mut has_launchwrapper = false; + let mut lw_coord = "".to_string(); + { + let file = std::fs::File::open(&installer_path)?; + let mut archive = ZipArchive::new(file)?; + let ver_opt: Option = match archive.by_name("launchwrapper-of.txt") { + Ok(mut txt) => { + let mut s = String::new(); + txt.read_to_string(&mut s)?; + let v = s.trim().to_string(); + if v.is_empty() { + None + } else { + Some(v) + } + } + Err(_) => None, + }; + + if let Some(ver) = ver_opt { + let jar_name = format!("launchwrapper-of-{}.jar", ver); + + if let Ok(mut lwo) = archive.by_name(&jar_name) { + let lwo_coord = format!("optifine:launchwrapper-of:{}", ver); + lw_coord = lwo_coord.clone(); + let lwo_rel = convert_library_name_to_path(&lwo_coord, None)?; + let lwo_path = lib_dir.join(lwo_rel); + if let Some(p) = lwo_path.parent() { + if !p.exists() { + fs::create_dir_all(p)?; + } + } + let mut out = std::fs::File::create(&lwo_path)?; + std::io::copy(&mut lwo, &mut out)?; + has_launchwrapper = true; + + add_library_entry(&mut client_info.libraries, &lwo_coord, None)?; + } + } + + if !has_launchwrapper { + if let Ok(mut lw2) = archive.by_name("launchwrapper-2.0.jar") { + let lw2_coord = "optifine:launchwrapper:2.0".to_string(); + lw_coord = lw2_coord.clone(); + let lw2_rel = convert_library_name_to_path(&lw2_coord, None)?; + let lw2_path = lib_dir.join(lw2_rel); + if let Some(p) = lw2_path.parent() { + if !p.exists() { + fs::create_dir_all(p)?; + } + } + let mut out = std::fs::File::create(&lw2_path)?; + std::io::copy(&mut lw2, &mut out)?; + has_launchwrapper = true; + + add_library_entry(&mut client_info.libraries, &lw2_coord, None)?; + } + } + } + + if !has_launchwrapper { + lw_coord = "net.minecraft:launchwrapper:1.12".to_string(); + add_library_entry(&mut client_info.libraries, &lw_coord, None)?; + + let lw_rel = convert_library_name_to_path(&lw_coord, None)?; + let lw_dest = lib_dir.join(&lw_rel); + + let base = get_download_api(priority[0], ResourceType::Libraries)?; + let src = convert_url_to_target_source( + &base.join(&lw_rel)?, + &[ResourceType::Libraries], + &priority[0], + )?; + + task_params.push(PTaskParam::Download(DownloadParam { + src, + dest: lw_dest, + filename: None, + sha1: None, + })); + } + + let optifine_runtime_coord = format!("net.minecraftforge:optifine:{}", optifine.filename); + add_library_entry(&mut client_info.libraries, &optifine_runtime_coord, None)?; + let lw_main = "net.minecraft.launchwrapper.Launch".to_string(); + + if let Some(v_args) = client_info.arguments.clone() { + let mut g: Vec = v_args.game.clone(); + let flag = ArgumentsItem { + value: vec!["--tweakClass".to_string()], + rules: vec![], + }; + let val = if instance.mod_loader.loader_type == ModLoaderType::Forge { + ArgumentsItem { + value: vec!["optifine.OptiFineForgeTweaker".to_string()], + rules: vec![], + } + } else { + ArgumentsItem { + value: vec!["optifine.OptiFineTweaker".to_string()], + rules: vec![], + } + }; + + if let Some(pos) = g.iter().position(|item| { + item + .value + .first() + .map(|v| v == "--launchTarget") + .unwrap_or(false) + }) { + g.insert(pos, val); + g.insert(pos, flag); + } else { + g.insert(0, val); + g.insert(0, flag); + } + + let new_args = LaunchArgumentTemplate { + game: g, + jvm: v_args.jvm.clone(), + }; + client_info.arguments = Some(new_args); + } else { + let mut s = client_info.minecraft_arguments.clone().unwrap_or_default(); + if !s.is_empty() && !s.ends_with(' ') { + s.push(' '); + } + if instance.mod_loader.loader_type == ModLoaderType::Forge { + s.push_str("--tweakClass optifine.OptiFineForgeTweaker"); + } else { + s.push_str("--tweakClass optifine.OptiFineTweaker"); + } + client_info.minecraft_arguments = Some(s); + }; + + let (patch_arguments, patch_minecraft_arguments) = if client_info.arguments.is_some() { + let patch_args = LaunchArgumentTemplate { + game: vec![ + ArgumentsItem { + value: vec!["--tweakClass".to_string()], + rules: vec![], + }, + ArgumentsItem { + value: vec!["optifine.OptiFineTweaker".to_string()], + rules: vec![], + }, + ], + jvm: vec![], + }; + (Some(patch_args), None) + } else { + ( + None, + Some("--tweakClass optifine.OptiFineTweaker".to_string()), + ) + }; + + client_info.patches.push(McClientInfo { + id: "optifine".to_string(), + version: Some(optifine.version.clone()), + priority: Some(10000), + main_class: Some(lw_main.clone()), + arguments: patch_arguments, + minecraft_arguments: patch_minecraft_arguments, + libraries: vec![ + LibrariesValue { + name: optifine_runtime_coord.clone(), + ..Default::default() + }, + LibrariesValue { + name: lw_coord.clone(), + ..Default::default() + }, + ], + ..Default::default() + }); + if client_info.main_class == Some("net.minecraft.client.main.Main".to_string()) { + client_info.main_class = Some(lw_main.clone()); + } + + if !task_params.is_empty() { + schedule_progressive_task_group( + app.clone(), + format!("optifine-libraries?{}", instance.id), + task_params, + true, + ) + .await?; + } + + let vjson_path = instance + .version_path + .join(format!("{}.json", instance.name)); + fs::write(vjson_path, serde_json::to_vec_pretty(&client_info)?)?; + + Ok(()) +} + +async fn run_optifine_patcher( + app: &AppHandle, + instance: &Instance, + client_info: &McClientInfo, + installer_jar: &Path, + base_client_jar: &Path, + out_optifine_jar: &Path, +) -> SJMCLResult<()> { + let javas_state = app.state::>>(); + let javas = javas_state.lock()?.clone(); + + let game_config = get_instance_game_config(app, instance); + + let selected_java = select_java_runtime( + app, + &game_config.game_java, + &javas, + instance, + client_info + .java_version + .as_ref() + .ok_or(InstanceError::ProcessorExecutionFailed)? + .major_version, + ) + .await?; + + let mut cmd = Command::new(&selected_java.exec_path); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + + cmd + .arg("-cp") + .arg(installer_jar) + .arg("optifine.Patcher") + .arg(base_client_jar) + .arg(installer_jar) + .arg(out_optifine_jar); + + let output = cmd.output()?; + + if !output.status.success() { + return Err(InstanceError::ProcessorExecutionFailed.into()); + } + Ok(()) +} + +pub async fn finish_optifine_install( + app: &AppHandle, + instance: &Instance, + client_info: &McClientInfo, +) -> SJMCLResult<()> { + let subdirs = get_instance_subdir_paths(app, instance, &[&InstanceSubdirType::Libraries]) + .ok_or(InstanceError::InstanceNotFoundByID)?; + let libraries_dir = subdirs.first().ok_or(InstanceError::InstanceNotFoundByID)?; + let optifine = instance + .optifine + .as_ref() + .ok_or(InstanceError::ModLoaderVersionParseError)?; + let installer_coord = format!( + "net.minecraftforge:optifine:{}-installer", + optifine.filename + ); + let optifine_coord = format!("net.minecraftforge:optifine:{}", optifine.filename); + let installer_rel = convert_library_name_to_path(&installer_coord, None)?; + let installer_path = libraries_dir.join(&installer_rel); + let optifine_rel = convert_library_name_to_path(&optifine_coord, None)?; + let optifine_path = libraries_dir.join(&optifine_rel); + if !installer_path.exists() { + return Err(InstanceError::LoaderInstallerNotFound.into()); + } + + let f = fs::File::open(&installer_path)?; + let mut archive = ZipArchive::new(f)?; + + let candidate = "optifine/Patcher.class"; + let has_patcher = archive.by_name(candidate).is_ok(); + + let base_client_jar = instance.version_path.join(format!("{}.jar", instance.name)); + + if let Some(parent) = optifine_path.parent() { + std::fs::create_dir_all(parent)?; + } + if has_patcher { + run_optifine_patcher( + app, + instance, + client_info, + &installer_path, + &base_client_jar, + &optifine_path, + ) + .await?; + } else { + fs::copy(&installer_path, &optifine_path)?; + } + + remove_entry_from_zip(&optifine_path, "META-INF/mods.toml")?; + + let priority_list = { + let launcher_config_state = app.state::>(); + let launcher_config = launcher_config_state.lock()?; + get_source_priority_list(&launcher_config) + }; + + download_optifine_libraries(app, &priority_list, instance, client_info).await?; + + Ok(()) +} + +fn remove_entry_from_zip(zip_path: &Path, entry_name: &str) -> SJMCLResult<()> { + if !zip_path.exists() { + return Ok(()); + } + + let tmp_path = { + let mut p = zip_path.to_path_buf(); + let ext = p + .extension() + .map(|e| e.to_string_lossy().to_string()) + .unwrap_or_else(|| "jar".to_string()); + p.set_extension(format!("{}.tmp", ext)); + p + }; + + let src = fs::File::open(zip_path)?; + let mut archive = ZipArchive::new(src)?; + + let dst = fs::File::create(&tmp_path)?; + let mut writer = ZipWriter::new(dst); + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let name = file.name().to_string(); + + if name == entry_name { + continue; + } + if name.ends_with('/') { + writer.add_directory(name, FileOptions::<()>::default())?; + continue; + } + + let options = FileOptions::<()>::default().compression_method(file.compression()); + writer.start_file(name, options)?; + io::copy(&mut file, &mut writer)?; + } + + writer.finish()?; + fs::rename(&tmp_path, zip_path)?; + + Ok(()) +} diff --git a/src-tauri/src/instance/helpers/misc.rs b/src-tauri/src/instance/helpers/misc.rs index ff65763ba..739b614a7 100644 --- a/src-tauri/src/instance/helpers/misc.rs +++ b/src-tauri/src/instance/helpers/misc.rs @@ -4,7 +4,7 @@ use crate::instance::helpers::client_json::{libraries_to_info, patches_to_info, use crate::instance::helpers::loader::forge::download_forge_libraries; use crate::instance::helpers::loader::neoforge::download_neoforge_libraries; use crate::instance::models::misc::{ - Instance, InstanceError, InstanceSubdirType, ModLoader, ModLoaderStatus, ModLoaderType, + Instance, InstanceError, InstanceSubdirType, ModLoader, ModLoaderStatus, ModLoaderType, OptiFine, }; use crate::launcher_config::helpers::misc::get_global_game_config; use crate::launcher_config::models::{GameConfig, GameDirectory, LauncherConfig}; @@ -154,7 +154,7 @@ pub async fn refresh_instances( continue; // not a valid instance } - let client_data = match load_json_async::(&json_path).await { + let mut client_data = match load_json_async::(&json_path).await { Ok(v) => v, Err(e) => { println!("Failed to load client info for {}: {}", name, e); @@ -186,25 +186,34 @@ pub async fn refresh_instances( }; if let Err(e) = { match cfg_read.mod_loader.status { - ModLoaderStatus::NotDownloaded => match cfg_read.mod_loader.loader_type { - ModLoaderType::Forge => { - cfg_read.mod_loader.status = ModLoaderStatus::Downloading; - download_forge_libraries(app, &priority_list, &cfg_read, &client_data, false).await - } - ModLoaderType::NeoForge => { - cfg_read.mod_loader.status = ModLoaderStatus::Downloading; - download_neoforge_libraries(app, &priority_list, &cfg_read, &client_data, false).await + ModLoaderStatus::NotDownloaded => { + match cfg_read.mod_loader.loader_type { + ModLoaderType::Forge => { + cfg_read.mod_loader.status = ModLoaderStatus::Downloading; + download_forge_libraries(app, &priority_list, &cfg_read, &mut client_data).await?; + } + ModLoaderType::NeoForge => { + cfg_read.mod_loader.status = ModLoaderStatus::Downloading; + download_neoforge_libraries(app, &priority_list, &cfg_read, &mut client_data) + .await?; + } + _ => {} } - _ => Ok(()), - }, + let vjson_path = cfg_read + .version_path + .join(format!("{}.json", cfg_read.name)); + fs::write(vjson_path, serde_json::to_vec_pretty(&client_data)?)?; + + Ok(()) + } ModLoaderStatus::DownloadFailed => match cfg_read.mod_loader.loader_type { ModLoaderType::Forge => { cfg_read.mod_loader.status = ModLoaderStatus::Downloading; - download_forge_libraries(app, &priority_list, &cfg_read, &client_data, true).await + download_forge_libraries(app, &priority_list, &cfg_read, &mut client_data).await } ModLoaderType::NeoForge => { cfg_read.mod_loader.status = ModLoaderStatus::Downloading; - download_neoforge_libraries(app, &priority_list, &cfg_read, &client_data, true).await + download_neoforge_libraries(app, &priority_list, &cfg_read, &mut client_data).await } _ => Ok(()), }, @@ -225,11 +234,12 @@ pub async fn refresh_instances( } } - let (mut game_version, loader_version, loader_type) = if !client_data.patches.is_empty() { - patches_to_info(&client_data.patches) - } else { - libraries_to_info(&client_data).await - }; + let (mut game_version, loader_version, loader_type, optifine_info) = + if !client_data.patches.is_empty() { + patches_to_info(&client_data.patches) + } else { + libraries_to_info(&client_data).await + }; // TODO: patches related logic if game_version.is_none() { let file = Cursor::new(tokio::fs::read(jar_path).await?); @@ -242,11 +252,18 @@ pub async fn refresh_instances( cfg_read.icon_src = loader_type.to_icon_path().to_string(); } + let mod_loader_installed = cfg_read.mod_loader.status == ModLoaderStatus::Installed; + let optifine_installed = cfg_read + .optifine + .as_ref() + .is_some_and(|o| o.status == ModLoaderStatus::Installed); + let optifine_filename = optifine_info.as_ref().map(|info| info.filename.clone()); + let optifine_version = optifine_info.map(|info| format!("{}_{}", info.r#type, info.patch)); let instance = Instance { name, version: game_version.unwrap_or_default(), version_path, - mod_loader: if cfg_read.mod_loader.status != ModLoaderStatus::Installed { + mod_loader: if !mod_loader_installed { // pass mod loader check if download is not ready cfg_read.mod_loader } else { @@ -257,6 +274,15 @@ pub async fn refresh_instances( branch: None, } }, + optifine: if !optifine_installed { + cfg_read.optifine.clone() + } else { + Some(OptiFine { + filename: optifine_filename.unwrap_or_default(), + version: optifine_version.unwrap_or_default(), + status: ModLoaderStatus::Installed, + }) + }, ..cfg_read }; // ignore error here, for now diff --git a/src-tauri/src/instance/models/misc.rs b/src-tauri/src/instance/models/misc.rs index 8210eb1e9..6ab48a0ec 100644 --- a/src-tauri/src/instance/models/misc.rs +++ b/src-tauri/src/instance/models/misc.rs @@ -81,7 +81,7 @@ pub enum ModLoaderStatus { structstruck::strike! { #[strikethrough[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)]] - #[strikethrough[serde(rename_all = "camelCase", deny_unknown_fields, default)]] + #[strikethrough[serde(rename_all = "camelCase", default)]] pub struct Instance { pub id: String, pub name: String, @@ -97,6 +97,7 @@ structstruck::strike! { pub version: String, pub branch: Option, // Optional branch name for mod loaders like Forge }, + pub optifine: Option, // if true, use the spec_game_config, else use the global game config pub use_spec_game_config: bool, // if use_spec_game_config is false, this field is ignored @@ -134,6 +135,7 @@ pub struct InstanceSummary { pub version: String, pub major_version: String, pub mod_loader: ModLoader, + pub optifine: Option, pub support_quick_play: bool, pub use_spec_game_config: bool, pub is_version_isolated: bool, @@ -156,6 +158,7 @@ impl InstanceSummary { version_path: instance.version_path.clone(), version: instance.version.clone(), mod_loader: instance.mod_loader.clone(), + optifine: instance.optifine.clone(), // skip fallback remote fetch in `get_major_game_version` and `compare_game_versions` to avoid instance list load delay. // ref: https://github.com/UNIkeEN/SJMCL/pull/799 major_version: get_major_game_version(app, &instance.version, false).await, @@ -269,6 +272,15 @@ pub enum InstanceError { InstallationDuplicated, ProcessorExecutionFailed, SemaphoreAcquireFailed, + LoaderInstallerNotFound, +} + +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct OptiFine { + pub filename: String, + pub version: String, + pub status: ModLoaderStatus, } impl std::error::Error for InstanceError {} diff --git a/src-tauri/src/launcher_config/models.rs b/src-tauri/src/launcher_config/models.rs index 8b2dcd29a..418c7c7fc 100644 --- a/src-tauri/src/launcher_config/models.rs +++ b/src-tauri/src/launcher_config/models.rs @@ -296,7 +296,7 @@ structstruck::strike! { #[default([true, true])] pub accordion_states: [bool; 2], }, - pub instance_resourcepack_page: struct { + pub instance_resource_packs_page: struct { #[default([true, true])] pub accordion_states: [bool; 2], }, @@ -304,6 +304,10 @@ structstruck::strike! { #[default([true, true])] pub accordion_states: [bool; 2], }, + pub instance_shader_packs_page: struct { + #[default([true, true])] + pub accordion_states: [bool; 2], + } } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86ea217f9..e81adc8a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -135,6 +135,7 @@ pub async fn run() { resource::commands::fetch_game_version_list, resource::commands::fetch_game_version_specific, resource::commands::fetch_mod_loader_version_list, + resource::commands::fetch_optifine_version_list, resource::commands::fetch_resource_list_by_name, resource::commands::fetch_resource_version_packs, resource::commands::download_game_server, diff --git a/src-tauri/src/resource/commands.rs b/src-tauri/src/resource/commands.rs index 50b8c4d19..3bcfd05dd 100644 --- a/src-tauri/src/resource/commands.rs +++ b/src-tauri/src/resource/commands.rs @@ -10,6 +10,7 @@ use crate::resource::helpers::curseforge::{ use crate::resource::helpers::loader_meta::fabric::get_fabric_meta_by_game_version; use crate::resource::helpers::loader_meta::forge::get_forge_meta_by_game_version; use crate::resource::helpers::loader_meta::neoforge::get_neoforge_meta_by_game_version; +use crate::resource::helpers::loader_meta::optifine::get_optifine_meta_by_game_version; use crate::resource::helpers::misc::get_source_priority_list; use crate::resource::helpers::modrinth::{ fetch_remote_resource_by_id_modrinth, fetch_remote_resource_by_local_modrinth, @@ -17,9 +18,9 @@ use crate::resource::helpers::modrinth::{ }; use crate::resource::helpers::version_manifest::get_game_version_manifest; use crate::resource::models::{ - GameClientResourceInfo, ModLoaderResourceInfo, ModUpdateQuery, OtherResourceFileInfo, - OtherResourceInfo, OtherResourceSearchQuery, OtherResourceSearchRes, OtherResourceSource, - OtherResourceVersionPack, OtherResourceVersionPackQuery, ResourceError, + GameClientResourceInfo, ModLoaderResourceInfo, ModUpdateQuery, OptiFineResourceInfo, + OtherResourceFileInfo, OtherResourceInfo, OtherResourceSearchQuery, OtherResourceSearchRes, + OtherResourceSource, OtherResourceVersionPack, OtherResourceVersionPackQuery, ResourceError, }; use crate::tasks::commands::schedule_progressive_task_group; use crate::tasks::download::DownloadParam; @@ -80,6 +81,19 @@ pub async fn fetch_mod_loader_version_list( } } +#[tauri::command] +pub async fn fetch_optifine_version_list( + app: AppHandle, + game_version: String, +) -> SJMCLResult> { + let priority_list = { + let launcher_config_state = app.state::>(); + let launcher_config = launcher_config_state.lock()?; + get_source_priority_list(&launcher_config) + }; + get_optifine_meta_by_game_version(&app, &priority_list, &game_version).await +} + #[tauri::command] pub async fn fetch_resource_list_by_name( app: AppHandle, diff --git a/src-tauri/src/resource/helpers/loader_meta/forge.rs b/src-tauri/src/resource/helpers/loader_meta/forge.rs index ae6fd6891..faf165e27 100644 --- a/src-tauri/src/resource/helpers/loader_meta/forge.rs +++ b/src-tauri/src/resource/helpers/loader_meta/forge.rs @@ -1,4 +1,4 @@ -use crate::error::{SJMCLError, SJMCLResult}; +use crate::error::SJMCLResult; use crate::instance::models::misc::ModLoaderType; use crate::resource::helpers::misc::get_download_api; use crate::resource::models::{ModLoaderResourceInfo, ResourceError, ResourceType, SourceType}; @@ -79,5 +79,5 @@ pub async fn get_forge_meta_by_game_version( } } } - Err(SJMCLError(String::new())) + Err(ResourceError::NoDownloadApi.into()) } diff --git a/src-tauri/src/resource/helpers/loader_meta/mod.rs b/src-tauri/src/resource/helpers/loader_meta/mod.rs index 2df19e58f..567cef5d4 100644 --- a/src-tauri/src/resource/helpers/loader_meta/mod.rs +++ b/src-tauri/src/resource/helpers/loader_meta/mod.rs @@ -1,3 +1,4 @@ pub mod fabric; pub mod forge; pub mod neoforge; +pub mod optifine; diff --git a/src-tauri/src/resource/helpers/loader_meta/optifine.rs b/src-tauri/src/resource/helpers/loader_meta/optifine.rs new file mode 100644 index 000000000..4aef9ed3b --- /dev/null +++ b/src-tauri/src/resource/helpers/loader_meta/optifine.rs @@ -0,0 +1,43 @@ +use crate::error::SJMCLResult; +use crate::resource::helpers::misc::get_download_api; +use crate::resource::models::{OptiFineResourceInfo, ResourceError, ResourceType, SourceType}; +use tauri::Manager; +use tauri_plugin_http::reqwest; + +async fn get_optifine_meta_by_game_version_bmcl( + app: &tauri::AppHandle, + game_version: &str, +) -> SJMCLResult> { + let client = app.state::(); + let url = + get_download_api(SourceType::BMCLAPIMirror, ResourceType::OptiFine)?.join(game_version)?; + match client.get(url).send().await { + Ok(response) => { + if response.status().is_success() { + response + .json::>() + .await + .map_err(|_| ResourceError::ParseError.into()) + } else { + Err(ResourceError::NetworkError.into()) + } + } + Err(_) => Err(ResourceError::NetworkError.into()), + } +} + +pub async fn get_optifine_meta_by_game_version( + app: &tauri::AppHandle, + priority_list: &[SourceType], + game_version: &str, +) -> SJMCLResult> { + for source_type in priority_list.iter() { + match source_type { + SourceType::BMCLAPIMirror => { + return get_optifine_meta_by_game_version_bmcl(app, game_version).await; + } + _ => continue, + } + } + Err(ResourceError::NoDownloadApi.into()) +} diff --git a/src-tauri/src/resource/helpers/misc.rs b/src-tauri/src/resource/helpers/misc.rs index 89969641a..834a87120 100644 --- a/src-tauri/src/resource/helpers/misc.rs +++ b/src-tauri/src/resource/helpers/misc.rs @@ -41,7 +41,7 @@ pub fn get_download_api(source: SourceType, resource_type: ResourceType) -> SJMC ResourceType::ForgeInstall => Ok(Url::parse("https://maven.minecraftforge.net/net/minecraftforge/forge/")?), ResourceType::ForgeMeta => Err(ResourceError::NoDownloadApi.into()), // https://github.com/HMCL-dev/HMCL/pull/3259/files ResourceType::Liteloader => Ok(Url::parse("https://dl.liteloader.com/versions/versions.json")?), - ResourceType::Optifine => Err(ResourceError::NoDownloadApi.into()), // + ResourceType::OptiFine => Err(ResourceError::NoDownloadApi.into()), // ResourceType::AuthlibInjector => Ok(Url::parse("https://authlib-injector.yushi.moe/")?), ResourceType::FabricMeta => Ok(Url::parse("https://meta.fabricmc.net/")?), ResourceType::FabricMaven => Ok(Url::parse("https://maven.fabricmc.net/")?), @@ -69,7 +69,7 @@ pub fn get_download_api(source: SourceType, resource_type: ResourceType) -> SJMC ResourceType::FabricMaven => Ok(Url::parse("https://bmclapi2.bangbang93.com/maven/")?), ResourceType::NeoforgeMetaForge | ResourceType::NeoforgeMetaNeoforge => Ok(Url::parse("https://bmclapi2.bangbang93.com/neoforge/")?), ResourceType::NeoforgeInstall => Ok(Url::parse("https://bmclapi2.bangbang93.com/neoforge/version/")?), - ResourceType::Optifine => Err(ResourceError::NoDownloadApi.into()), + ResourceType::OptiFine => Ok(Url::parse("https://bmclapi2.bangbang93.com/optifine/")?), ResourceType::QuiltMaven => Ok(Url::parse("https://bmclapi2.bangbang93.com/maven/")?), ResourceType::QuiltMeta => Ok(Url::parse("https://bmclapi2.bangbang93.com/quilt-meta/")?), }, diff --git a/src-tauri/src/resource/models.rs b/src-tauri/src/resource/models.rs index 10707c055..2db19c1ed 100644 --- a/src-tauri/src/resource/models.rs +++ b/src-tauri/src/resource/models.rs @@ -18,7 +18,7 @@ pub enum ResourceType { ForgeMavenNew, ForgeInstall, Liteloader, - Optifine, + OptiFine, AuthlibInjector, FabricMeta, FabricMaven, @@ -182,6 +182,14 @@ pub struct ModLoaderResourceInfo { pub branch: Option, } +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct OptiFineResourceInfo { + pub filename: String, + pub patch: String, + pub r#type: String, +} + #[derive(Debug, Display)] #[strum(serialize_all = "SCREAMING_SNAKE_CASE")] pub enum ResourceError { diff --git a/src/components/common/selectable-card.tsx b/src/components/common/selectable-card.tsx new file mode 100644 index 000000000..0a7f6f1ca --- /dev/null +++ b/src/components/common/selectable-card.tsx @@ -0,0 +1,113 @@ +import { + Card, + CardProps, + Flex, + HStack, + Icon, + IconButton, + Image, + Text, + VStack, +} from "@chakra-ui/react"; +import { LuChevronRight, LuX } from "react-icons/lu"; +import { useLauncherConfig } from "@/contexts/config"; +import { useThemedCSSStyle } from "@/hooks/themed-css"; + +export interface SelectableCardProps extends CardProps { + title: string; + iconSrc?: string; + description?: string; + displayMode: "entry" | "selector"; + isLoading?: boolean; + isDisabled?: boolean; + isSelected: boolean; + isChevronShown?: boolean; + onSelect: () => void; + onCancel?: () => void; +} + +const SelectableCard: React.FC = ({ + title, + iconSrc, + description, + displayMode, + isLoading = false, + isDisabled = false, + isSelected, + isChevronShown = true, + onSelect, + onCancel, + ...boxProps +}) => { + const { config } = useLauncherConfig(); + const primaryColor = config.appearance.theme.primaryColor; + const themedStyles = useThemedCSSStyle(); + + const borderWidth = "1px"; + const basePadding = boxProps.padding || "12px"; + const selectedPadding = `calc(${basePadding} - ${borderWidth})`; + + return ( + + + + {!!iconSrc && ( + {title} + )} + + + {title} + + {!!description && ( + + {description} + + )} + + + + {displayMode === "selector" && isSelected && !!onCancel && ( + } + variant="ghost" + size="xs" + disabled={isLoading || isDisabled} + onClick={() => onCancel()} + /> + )} + {isChevronShown && ( + } + variant="ghost" + size="xs" + disabled={isLoading || isDisabled} + onClick={() => onSelect()} + /> + )} + + + + ); +}; + +export default SelectableCard; diff --git a/src/components/instance-icon-selector.tsx b/src/components/instance-icon-selector.tsx index 3ec2d070e..1fd052792 100644 --- a/src/components/instance-icon-selector.tsx +++ b/src/components/instance-icon-selector.tsx @@ -140,6 +140,7 @@ export const InstanceIconSelector: React.FC = ({ "/images/icons/Fabric.png", "/images/icons/Anvil.png", "/images/icons/NeoForge.png", + "/images/icons/OptiFine.png", ...(instanceId ? [ , diff --git a/src/components/loader-selector.tsx b/src/components/loader-selector.tsx new file mode 100644 index 000000000..d43a7451c --- /dev/null +++ b/src/components/loader-selector.tsx @@ -0,0 +1,326 @@ +import { + Center, + HStack, + Image, + Radio, + RadioGroup, + Tag, + VStack, +} from "@chakra-ui/react"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BeatLoader } from "react-spinners"; +import Empty from "@/components/common/empty"; +import { + OptionItemProps, + VirtualOptionItemGroup, +} from "@/components/common/option-item-virtual"; +import { Section } from "@/components/common/section"; +import SelectableCard, { + SelectableCardProps, +} from "@/components/common/selectable-card"; +import { useLauncherConfig } from "@/contexts/config"; +import { useToast } from "@/contexts/toast"; +import { ModLoaderType } from "@/enums/instance"; +import { + GameClientResourceInfo, + ModLoaderResourceInfo, + OptiFineResourceInfo, + defaultModLoaderResourceInfo, +} from "@/models/resource"; +import { ResourceService } from "@/services/resource"; +import { ISOToDatetime } from "@/utils/datetime"; + +export const modLoaderTypes: ModLoaderType[] = [ + ModLoaderType.Forge, + ModLoaderType.Fabric, + ModLoaderType.NeoForge, +]; + +export const modLoaderTypesToIcon: Record = { + Unknown: "", + Fabric: "Fabric.png", + Forge: "Forge.png", + NeoForge: "NeoForge.png", +}; + +interface LoaderSelectorProps { + selectedGameVersion: GameClientResourceInfo; + selectedModLoader: ModLoaderResourceInfo; + onSelectModLoader: (v: ModLoaderResourceInfo) => void; + selectedOptiFine?: OptiFineResourceInfo | undefined; + onSelectOptiFine?: (v: OptiFineResourceInfo | undefined) => void; +} + +export const LoaderSelector: React.FC = ({ + selectedGameVersion, + selectedModLoader, + onSelectModLoader, + selectedOptiFine, + onSelectOptiFine, + ...props +}) => { + const { t } = useTranslation(); + const { config } = useLauncherConfig(); + const toast = useToast(); + const primaryColor = config.appearance.theme.primaryColor; + const [versionList, setVersionList] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedType, setSelectedType] = useState( + ModLoaderType.Unknown + ); + const [selectedId, setSelectedId] = useState(""); + + useEffect(() => { + if (selectedOptiFine) { + setSelectedType("OptiFine"); + setSelectedId(selectedOptiFine ? selectedOptiFine.filename : ""); + } else { + setSelectedType(selectedModLoader.loaderType); + setSelectedId(selectedModLoader.version); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function isModLoaderResourceInfo( + version: ModLoaderResourceInfo | OptiFineResourceInfo + ): version is ModLoaderResourceInfo { + return (version as ModLoaderResourceInfo).loaderType !== undefined; + } + + const buildOptionItems = useCallback( + ( + version: ModLoaderResourceInfo | OptiFineResourceInfo + ): OptionItemProps => { + let title = isModLoaderResourceInfo(version) + ? version.version + : version?.filename; + return { + title, + description: isModLoaderResourceInfo(version) && version.description, + prefixElement: ( + + + {title} + + ), + titleExtra: ( + + {t( + `LoaderSelector.${(isModLoaderResourceInfo(version) ? version.stable : !version.patch.startsWith("pre")) ? "stable" : "beta"}` + )} + + ), + children: <>, + isFullClickZone: true, + onClick: () => { + if (isModLoaderResourceInfo(version)) { + onSelectModLoader(version); + } else { + onSelectOptiFine?.(version); + } + setSelectedId(title); + }, + }; + }, + [primaryColor, t, onSelectModLoader, onSelectOptiFine] + ); + + const handleFetchModLoaderVersionList = useCallback( + (type: ModLoaderType) => { + setIsLoading(true); + ResourceService.fetchModLoaderVersionList(selectedGameVersion.id, type) + .then((res) => { + if (res.status === "success") { + setVersionList( + res.data + .map((loader) => ({ + ...loader, + description: + loader.description && + t("LoaderSelector.releaseDate", { + date: ISOToDatetime(loader.description), + }), + })) + .map(buildOptionItems) + ); + } else { + setVersionList([]); + toast({ + status: "error", + title: res.message, + description: res.details, + }); + } + }) + .finally(() => setIsLoading(false)); + }, + [selectedGameVersion.id, buildOptionItems, t, toast] + ); + + const handleFetchOptiFineVersionList = useCallback(() => { + setIsLoading(true); + ResourceService.fetchOptiFineVersionList(selectedGameVersion.id) + .then((res) => { + if (res.status === "success") { + setVersionList(res.data.map(buildOptionItems)); + } else { + setVersionList([]); + toast({ + status: "error", + title: res.message, + description: res.details, + }); + } + }) + .finally(() => setIsLoading(false)); + }, [selectedGameVersion.id, buildOptionItems, toast]); + + let selectableCardItems = modLoaderTypes.map( + (type): SelectableCardProps => ({ + title: type, + iconSrc: `/images/icons/${modLoaderTypesToIcon[type]}`, + description: + selectedModLoader.loaderType !== ModLoaderType.Unknown + ? selectedModLoader.loaderType === type + ? selectedModLoader.version || t("LoaderSelector.noVersionSelected") + : t("LoaderSelector.notCompatibleWith", { + item: selectedModLoader.loaderType, + }) + : t("LoaderSelector.noVersionSelected"), + displayMode: "selector", + isLoading, + isSelected: type === selectedModLoader.loaderType, + isChevronShown: selectedType !== type, + onSelect: () => { + setSelectedType(type); + if (selectedModLoader.loaderType !== type) { + onSelectModLoader({ + loaderType: type, + version: "", + description: "", + stable: false, + }); + setSelectedId(""); + } else { + setSelectedId(selectedModLoader.version); + } + if ( + type !== ModLoaderType.Forge || + (selectedOptiFine && !selectedOptiFine.filename) + ) { + // When OptiFine is not compatible with the selected mod loader, or selected without a version, clear it + onSelectOptiFine?.(undefined); + } + }, + onCancel: () => { + if (selectedType === type) { + setSelectedType(ModLoaderType.Unknown); + setSelectedId(""); + } + onSelectModLoader(defaultModLoaderResourceInfo); + }, + }) + ); + + if (typeof onSelectOptiFine === "function") { + selectableCardItems.push({ + title: "OptiFine", + iconSrc: "/images/icons/OptiFine.png", + description: selectedOptiFine + ? selectedOptiFine.type + " " + selectedOptiFine.patch + : selectedModLoader.loaderType === ModLoaderType.Forge || + selectedModLoader.loaderType === ModLoaderType.Unknown + ? t("LoaderSelector.noVersionSelected") + : t("LoaderSelector.notCompatibleWith", { + item: selectedModLoader.loaderType, + }), + displayMode: "selector", + isLoading, + isSelected: !!selectedOptiFine, + isDisabled: !( + selectedModLoader.loaderType === ModLoaderType.Forge || + selectedModLoader.loaderType === ModLoaderType.Unknown + ), + isChevronShown: selectedType !== "OptiFine", + onSelect: () => { + setSelectedType("OptiFine"); + if (!selectedOptiFine) { + onSelectOptiFine?.({ + filename: "", + patch: "", + type: "", + }); + setSelectedId(""); + } else { + setSelectedId(selectedOptiFine.filename); + } + + if ( + selectedModLoader.loaderType !== ModLoaderType.Unknown && + !selectedModLoader.version + ) { + // When some mod loader was selected without a version, clear it + onSelectModLoader(defaultModLoaderResourceInfo); + } + }, + onCancel: () => { + if (selectedType === "OptiFine") { + setSelectedType(ModLoaderType.Unknown); + setSelectedId(""); + } + onSelectOptiFine?.(undefined); + }, + }); + } + + useEffect(() => { + if (selectedType === "OptiFine") { + handleFetchOptiFineVersionList(); + } else if (selectedType !== ModLoaderType.Unknown) { + handleFetchModLoaderVersionList(selectedType); + } else { + setVersionList([]); + } + }, [ + handleFetchModLoaderVersionList, + handleFetchOptiFineVersionList, + selectedType, + ]); + + return ( + + + {selectableCardItems.map((item, index) => ( + + ))} + +
+ {isLoading ? ( +
+ +
+ ) : versionList.length === 0 ? ( +
+ +
+ ) : ( + + + + )} +
+
+ ); +}; diff --git a/src/components/mod-loader-cards.tsx b/src/components/mod-loader-cards.tsx deleted file mode 100644 index cd56c62a9..000000000 --- a/src/components/mod-loader-cards.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { - BoxProps, - Card, - Flex, - Grid, - HStack, - Icon, - IconButton, - Image, - Text, - VStack, -} from "@chakra-ui/react"; -import { useTranslation } from "react-i18next"; -import { LuChevronRight, LuX } from "react-icons/lu"; -import { useLauncherConfig } from "@/contexts/config"; -import { ModLoaderType } from "@/enums/instance"; -import { useThemedCSSStyle } from "@/hooks/themed-css"; -import { parseModLoaderVersion } from "@/utils/instance"; - -interface ModLoaderCardsProps extends BoxProps { - currentType: ModLoaderType; - currentVersion?: string; - displayMode: "entry" | "selector"; - loading?: boolean; - onTypeSelect?: (type: ModLoaderType) => void; -} - -const ModLoaderCards: React.FC = ({ - currentType, - currentVersion, - displayMode, - loading = false, - onTypeSelect, - ...boxProps -}) => { - const { t } = useTranslation(); - const { config } = useLauncherConfig(); - const primaryColor = config.appearance.theme.primaryColor; - const themedStyles = useThemedCSSStyle(); - - const borderWidth = "1px"; - const basePadding = boxProps.padding || "12px"; - const selectedPadding = `calc(${basePadding} - ${borderWidth})`; - - const loaderTypes: ModLoaderType[] = [ - ModLoaderType.Fabric, - ModLoaderType.Forge, - ModLoaderType.NeoForge, - ]; - - const renderCard = (type: ModLoaderType) => { - const isSelected = - type === currentType && currentType !== ModLoaderType.Unknown; - - return ( - - - - {type} - - - {type} - - - {displayMode === "entry" - ? isSelected - ? `${t("ModLoaderCards.installed")} ${parseModLoaderVersion(currentVersion || "")}` - : t("ModLoaderCards.unInstalled") - : isSelected - ? currentVersion || t("ModLoaderCards.versionNotSelected") - : currentType === ModLoaderType.Unknown - ? t("ModLoaderCards.versionNotSelected") - : t("ModLoaderCards.notCompatibleWith", { - modLoader: currentType, - })} - - - - - } - variant="ghost" - size="xs" - disabled={loading} - onClick={() => onTypeSelect?.(type)} - /> - - - ); - }; - - return ( - <> - - {loaderTypes.map(renderCard)} - - - ); -}; - -export default ModLoaderCards; diff --git a/src/components/mod-loader-selector.tsx b/src/components/mod-loader-selector.tsx deleted file mode 100644 index fa4f42767..000000000 --- a/src/components/mod-loader-selector.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { - Center, - HStack, - Image, - Radio, - RadioGroup, - Tag, - VStack, -} from "@chakra-ui/react"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { BeatLoader } from "react-spinners"; -import Empty from "@/components/common/empty"; -import { - OptionItemProps, - VirtualOptionItemGroup, -} from "@/components/common/option-item-virtual"; -import { Section } from "@/components/common/section"; -import ModLoaderCards from "@/components/mod-loader-cards"; -import { useLauncherConfig } from "@/contexts/config"; -import { - GameClientResourceInfo, - ModLoaderResourceInfo, - defaultModLoaderResourceInfo, -} from "@/models/resource"; -import { ResourceService } from "@/services/resource"; -import { ISOToDatetime } from "@/utils/datetime"; - -const modLoaderTypesToIcon: Record = { - Unknown: "", - Fabric: "Fabric.png", - Forge: "Forge.png", - NeoForge: "NeoForge.png", -}; - -interface ModLoaderSelectorProps { - selectedGameVersion: GameClientResourceInfo; - selectedModLoader: ModLoaderResourceInfo; - onSelectModLoader: (v: ModLoaderResourceInfo) => void; -} - -export const ModLoaderSelector: React.FC = ({ - selectedGameVersion, - selectedModLoader, - onSelectModLoader, - ...props -}) => { - const { t } = useTranslation(); - const { config } = useLauncherConfig(); - const primaryColor = config.appearance.theme.primaryColor; - const [modLoaders, setModLoaders] = useState([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - setLoading(true); - ResourceService.fetchModLoaderVersionList( - selectedGameVersion.id, - selectedModLoader.loaderType - ) - .then((res) => { - if (res.status === "success") { - setModLoaders( - res.data.map((loader) => ({ - ...loader, - description: - loader.description && - t("ModLoaderSelector.releaseDate", { - date: ISOToDatetime(loader.description), - }), - })) - ); - } else { - setModLoaders([]); - } - }) - .finally(() => setLoading(false)); - }, [selectedGameVersion.id, selectedModLoader.loaderType, t]); - - const onSelectModLoaderVersion = useCallback( - (version: string) => { - if (version === selectedModLoader.version) { - onSelectModLoader(defaultModLoaderResourceInfo); - } else { - let _modLoader = modLoaders.filter( - (loader) => loader.version === version - )[0]; - onSelectModLoader(_modLoader); - } - }, - [modLoaders, onSelectModLoader, selectedModLoader.version] - ); - - const buildOptionItems = useCallback( - (version: ModLoaderResourceInfo): OptionItemProps => ({ - title: version.version, - description: version.description, - prefixElement: ( - - - {version.loaderType} - - ), - titleExtra: ( - - {t(`ModLoaderSelector.${version.stable ? "stable" : "beta"}`)} - - ), - children: <>, - isFullClickZone: true, - onClick: () => { - if (version.version !== "") { - onSelectModLoader(version); - } - }, - }), - [primaryColor, t, onSelectModLoader] - ); - - return ( - - { - if (loaderType !== selectedModLoader.loaderType) { - onSelectModLoader({ - loaderType, - version: "", - description: "", - stable: false, - }); - } else { - onSelectModLoader(defaultModLoaderResourceInfo); - } - }} - w="100%" - /> - -
- {loading ? ( -
- -
- ) : modLoaders.length === 0 ? ( - - ) : ( - - - - )} -
-
- ); -}; diff --git a/src/components/modals/change-mod-loader-modal.tsx b/src/components/modals/change-mod-loader-modal.tsx index b5b4b63d1..44b6c2590 100644 --- a/src/components/modals/change-mod-loader-modal.tsx +++ b/src/components/modals/change-mod-loader-modal.tsx @@ -21,7 +21,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuArrowRight } from "react-icons/lu"; import { OptionItem } from "@/components/common/option-item"; -import { ModLoaderSelector } from "@/components/mod-loader-selector"; +import { LoaderSelector } from "@/components/loader-selector"; import { useLauncherConfig } from "@/contexts/config"; import { useInstanceSharedData } from "@/contexts/instance"; import { useToast } from "@/contexts/toast"; @@ -196,18 +196,19 @@ export const ChangeModLoaderModal: React.FC = ({ )} - {summary?.version && ( - - )} + {summary?.version && + selectedModLoader.loaderType !== ModLoaderType.Unknown && ( + + )} {selectedModLoader.loaderType === ModLoaderType.Fabric && ( diff --git a/src/components/modals/create-instance-modal.tsx b/src/components/modals/create-instance-modal.tsx index 8ccf82694..69551b653 100644 --- a/src/components/modals/create-instance-modal.tsx +++ b/src/components/modals/create-instance-modal.tsx @@ -30,7 +30,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { GameVersionSelector } from "@/components/game-version-selector"; import { InstanceBasicSettings } from "@/components/instance-basic-settings"; -import { ModLoaderSelector } from "@/components/mod-loader-selector"; +import { LoaderSelector } from "@/components/loader-selector"; import { useLauncherConfig } from "@/contexts/config"; import { useToast } from "@/contexts/toast"; import { ModLoaderType } from "@/enums/instance"; @@ -38,6 +38,7 @@ import { GameDirectory } from "@/models/config"; import { GameClientResourceInfo, ModLoaderResourceInfo, + OptiFineResourceInfo, defaultModLoaderResourceInfo, } from "@/models/resource"; import { InstanceService } from "@/services/instance"; @@ -49,11 +50,12 @@ export const gameTypesToIcon: Record = { april_fools: "/images/icons/YellowGlazedTerracotta.png", }; -export const modLoaderTypesToIcon: Record = { +export const loaderTypesToIcon: Record = { Unknown: "", Fabric: "/images/icons/Fabric.png", Forge: "/images/icons/Anvil.png", // differ from that in mod-loader-selector NeoForge: "/images/icons/NeoForge.png", + OptiFine: "/images/icons/OptiFine.png", }; export const CreateInstanceModal: React.FC> = ({ @@ -74,6 +76,9 @@ export const CreateInstanceModal: React.FC> = ({ useState(); const [selectedModLoader, setSelectedModLoader] = useState(defaultModLoaderResourceInfo); + const [selectedOptiFine, setSelectedOptiFine] = useState< + OptiFineResourceInfo | undefined + >(undefined); const [instanceName, setInstanceName] = useState(""); const [instanceDescription, setInstanceDescription] = useState(""); const [instanceIconSrc, setInstanceIconSrc] = useState(""); @@ -102,6 +107,7 @@ export const CreateInstanceModal: React.FC> = ({ instanceIconSrc, selectedGameVersion, selectedModLoader, + selectedOptiFine, undefined, // modpackPath isInstallFabricApi ) @@ -126,10 +132,11 @@ export const CreateInstanceModal: React.FC> = ({ instanceDescription, instanceIconSrc, selectedModLoader, + selectedOptiFine, isInstallFabricApi, - toast, modalProps, router, + toast, ]); const step1Content = useMemo(() => { @@ -164,10 +171,12 @@ export const CreateInstanceModal: React.FC> = ({ selectedGameVersion && ( <> - @@ -198,7 +207,8 @@ export const CreateInstanceModal: React.FC> = ({ colorScheme={primaryColor} onClick={() => { if (!selectedModLoader.version) { - setSelectedModLoader(defaultModLoaderResourceInfo); // if the user selected the loader but did not choose a version from the list + // if the user selected the loader but did not choose a version from the list + setSelectedModLoader(defaultModLoaderResourceInfo); setInstanceName(selectedGameVersion.id); setInstanceIconSrc( gameTypesToIcon[selectedGameVersion.gameType] @@ -208,9 +218,19 @@ export const CreateInstanceModal: React.FC> = ({ `${selectedGameVersion.id}-${selectedModLoader.loaderType}` ); setInstanceIconSrc( - modLoaderTypesToIcon[selectedModLoader.loaderType] + loaderTypesToIcon[selectedModLoader.loaderType] ); } + + if (selectedOptiFine) { + if (!selectedOptiFine.filename) { + // if the user selected OptiFine but did not choose a version from the list + setSelectedOptiFine(undefined); + } else { + setInstanceName((prev) => `${prev}-OptiFine`); + setInstanceIconSrc(loaderTypesToIcon["OptiFine"]); + } + } setActiveStep(2); }} > @@ -222,13 +242,14 @@ export const CreateInstanceModal: React.FC> = ({ ) ); }, [ - modalProps.onClose, - primaryColor, selectedGameVersion, selectedModLoader, + selectedOptiFine, + primaryColor, isInstallFabricApi, - setActiveStep, t, + modalProps.onClose, + setActiveStep, ]); const step3Content = useMemo(() => { @@ -289,10 +310,21 @@ export const CreateInstanceModal: React.FC> = ({ { key: "loader", content: step2Content, - description: - selectedModLoader.loaderType === ModLoaderType.Unknown - ? t("CreateInstanceModal.stepper.skipped") - : `${selectedModLoader.loaderType} ${selectedModLoader.version}`, + description: (() => { + if (selectedModLoader.loaderType === ModLoaderType.Unknown) { + return selectedOptiFine + ? "OptiFine" + : t("LoaderSelector.noVersionSelected"); + } else { + let desc = `${selectedModLoader.loaderType} ${ + selectedModLoader.version || t("LoaderSelector.noVersionSelected") + }`; + if (selectedOptiFine) { + desc += ` + OptiFine`; + } + return desc; + } + })(), }, { key: "info", @@ -302,12 +334,13 @@ export const CreateInstanceModal: React.FC> = ({ ], [ step1Content, + selectedGameVersion, + t, step2Content, step3Content, - selectedGameVersion, selectedModLoader.loaderType, selectedModLoader.version, - t, + selectedOptiFine, ] ); diff --git a/src/components/modals/import-modpack-modal.tsx b/src/components/modals/import-modpack-modal.tsx index f0f7e3353..5472490d8 100644 --- a/src/components/modals/import-modpack-modal.tsx +++ b/src/components/modals/import-modpack-modal.tsx @@ -27,7 +27,7 @@ import { import { InstanceIconSelectorPopover } from "@/components/instance-icon-selector"; import { gameTypesToIcon, - modLoaderTypesToIcon, + loaderTypesToIcon, } from "@/components/modals/create-instance-modal"; import { useLauncherConfig } from "@/contexts/config"; import { useToast } from "@/contexts/toast"; @@ -220,6 +220,7 @@ const ImportModpackModal: React.FC = ({ stable: true, } as ModLoaderResourceInfo) : defaultModLoaderResourceInfo, + undefined, path ); if (createResp.status === "success") { @@ -260,7 +261,7 @@ const ImportModpackModal: React.FC = ({ setDescription(response.data.description || ""); setIconSrc( response.data.modLoader - ? modLoaderTypesToIcon[response.data.modLoader.loaderType] + ? loaderTypesToIcon[response.data.modLoader.loaderType] : gameTypesToIcon["release"] ); } else { diff --git a/src/contexts/task.tsx b/src/contexts/task.tsx index f4b9608e7..a31b36d46 100644 --- a/src/contexts/task.tsx +++ b/src/contexts/task.tsx @@ -437,6 +437,7 @@ export const TaskContextProvider: React.FC<{ children: React.ReactNode }> = ({ break; case "forge-libraries": case "neoforge-libraries": + case "optifine-libraries": if (version) { let instanceName = getInstanceList()?.find( (i) => i.id === version diff --git a/src/locales/en.json b/src/locales/en.json index 610df5055..a0dc40145 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -414,7 +414,7 @@ }, "stepper": { "game": "Game Client", - "loader": "Mod Loader", + "loader": "Game Loader", "skipped": "Skipped", "info": "Basic Settings" }, @@ -1196,7 +1196,8 @@ }, "InstanceModsPage": { "modLoaderList": { - "title": "Mod Loader" + "title": "Mod Loader", + "notInstalled": "Not Installed" }, "modList": { "title": "Mods", @@ -1242,6 +1243,10 @@ } }, "InstanceShaderPacksPage": { + "shaderLoaderList": { + "title": "Loaders", + "notInstalled": "Not Installed" + }, "shaderPackList": { "title": "Shader Packs" } @@ -1359,6 +1364,13 @@ "noSelectedPlayer": "Please add and select a player first" } }, + "LoaderSelector": { + "stable": "Stable", + "beta": "Beta", + "releaseDate": "Released at {{date}}", + "notCompatibleWith": "Not compatible with {{item}}", + "noVersionSelected": "No version selected" + }, "ManageSkinModal": { "skinManage": "Manage Skin", "default": "Default", @@ -1400,17 +1412,6 @@ "MenuSelector": { "selectedCount": "{{count}} selected" }, - "ModLoaderCards": { - "installed": "Installed", - "unInstalled": "Not Installed", - "notCompatibleWith": "Not compatible with {{modLoader}}", - "versionNotSelected": "No version selected" - }, - "ModLoaderSelector": { - "stable": "Stable", - "beta": "Beta", - "releaseDate": "Released at {{date}}" - }, "NotFoundPage": { "text": "Page not found, redirecting to launch page in {{seconds}} seconds..." }, @@ -2364,7 +2365,8 @@ "INSTANCE_NOT_FOUND_BY_ID": "Instance ID does not exist", "INSTALLATION_DUPLICATED": "Another installation process is already in progress", "MAIN_CLASS_NOT_FOUND": "Main class not found in the mod loader version", - "PROCESSOR_EXECUTION_FAILED": "Failed to execute the install process" + "PROCESSOR_EXECUTION_FAILED": "Failed to execute the install process", + "LOADER_INSTALLER_NOT_FOUND": "Mod loader installer file not found" } } }, diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index e2fbce3cb..69ad4e228 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1196,7 +1196,8 @@ }, "InstanceModsPage": { "modLoaderList": { - "title": "加载器" + "title": "加载器", + "notInstalled": "未安装" }, "modList": { "title": "模组", @@ -1242,6 +1243,10 @@ } }, "InstanceShaderPacksPage": { + "shaderLoaderList": { + "title": "加载器", + "notInstalled": "未安装" + }, "shaderPackList": { "title": "光影包" } @@ -1359,6 +1364,13 @@ "noSelectedPlayer": "请先添加并选择游戏角色" } }, + "LoaderSelector": { + "stable": "正式版", + "beta": "测试版", + "releaseDate": "发布于 {{date}}", + "notCompatibleWith": "与 {{item}} 不兼容", + "noVersionSelected": "未选择版本" + }, "ManageSkinModal": { "skinManage": "管理皮肤", "default": "默认", @@ -1400,17 +1412,6 @@ "MenuSelector": { "selectedCount": "已选 {{count}} 项" }, - "ModLoaderCards": { - "installed": "已安装", - "unInstalled": "未安装", - "notCompatibleWith": "与 {{modLoader}} 不兼容", - "versionNotSelected": "未选择版本" - }, - "ModLoaderSelector": { - "stable": "正式版", - "beta": "测试版", - "releaseDate": "发布于 {{date}}" - }, "NotFoundPage": { "text": "页面不存在,即将在 {{seconds}} 秒后跳转" }, @@ -2364,7 +2365,8 @@ "INSTANCE_NOT_FOUND_BY_ID": "实例 ID 不存在", "INSTALLATION_DUPLICATED": "检测到另一个进程已在安装中", "MAIN_CLASS_NOT_FOUND": "主类未找到", - "PROCESSOR_EXECUTION_FAILED": "安装任务执行失败" + "PROCESSOR_EXECUTION_FAILED": "安装任务执行失败", + "LOADER_INSTALLER_NOT_FOUND": "加载器安装程序不存在" } } }, diff --git a/src/models/config.ts b/src/models/config.ts index 9e4545d6c..49cb32065 100644 --- a/src/models/config.ts +++ b/src/models/config.ts @@ -147,12 +147,15 @@ export interface LauncherConfig { instanceModsPage: { accordionStates: boolean[]; }; - instanceResourcepackPage: { + instanceResourcePacksPage: { accordionStates: boolean[]; }; instanceWorldsPage: { accordionStates: boolean[]; }; + instanceShaderPacksPage: { + accordionStates: boolean[]; + }; }; } @@ -303,12 +306,15 @@ export const defaultConfig: LauncherConfig = { instanceModsPage: { accordionStates: [true, true], }, - instanceResourcepackPage: { + instanceResourcePacksPage: { accordionStates: [true, true], }, instanceWorldsPage: { accordionStates: [true, true], }, + instanceShaderPacksPage: { + accordionStates: [true, true], + }, }, }; diff --git a/src/models/instance/misc.ts b/src/models/instance/misc.ts index 7dcd41656..a157cf079 100644 --- a/src/models/instance/misc.ts +++ b/src/models/instance/misc.ts @@ -16,6 +16,12 @@ export interface ModLoader { branch?: string; } +export interface OptiFine { + filename: string; + version: string; + status: ModLoaderStatus; +} + export interface InstanceSummary { id: string; iconSrc: string; @@ -27,6 +33,7 @@ export interface InstanceSummary { version: string; majorVersion: string; modLoader: ModLoader; + optifine?: OptiFine; supportQuickPlay: boolean; useSpecGameConfig: boolean; isVersionIsolated: boolean; diff --git a/src/models/mock/resource.ts b/src/models/mock/resource.ts index e4398a359..54f79576b 100644 --- a/src/models/mock/resource.ts +++ b/src/models/mock/resource.ts @@ -543,7 +543,7 @@ export const mockDownloadResourceList: OtherResourceInfo[] = [ }, { type: OtherResourceType.Mod, - name: "Optifine", + name: "OptiFine", description: "A Minecraft mod that optimizes Minecraft's graphics.", iconSrc: "/images/icons/GrassBlock.png", tags: ["Graphics", "Optimization"], @@ -589,7 +589,7 @@ export const mockDownloadResourceList: OtherResourceInfo[] = [ }, { type: OtherResourceType.Mod, - name: "Optifine", + name: "OptiFine", description: "A Minecraft mod that optimizes Minecraft's graphics.", iconSrc: "/images/icons/GrassBlock.png", tags: ["Graphics", "Optimization"], diff --git a/src/models/resource.ts b/src/models/resource.ts index 9fbd4f4c2..ad729b6db 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -74,6 +74,12 @@ export const defaultModLoaderResourceInfo: ModLoaderResourceInfo = { stable: true, }; +export interface OptiFineResourceInfo { + filename: string; + patch: string; + type: string; +} + export interface ModUpdateRecord { name: string; curVersion: string; diff --git a/src/pages/instances/details/[id]/mods.tsx b/src/pages/instances/details/[id]/mods.tsx index d21d6f78e..f17af3284 100644 --- a/src/pages/instances/details/[id]/mods.tsx +++ b/src/pages/instances/details/[id]/mods.tsx @@ -27,7 +27,13 @@ import CountTag from "@/components/common/count-tag"; import Empty from "@/components/common/empty"; import { OptionItem, OptionItemGroup } from "@/components/common/option-item"; import { Section } from "@/components/common/section"; -import ModLoaderCards from "@/components/mod-loader-cards"; +import SelectableCard, { + SelectableCardProps, +} from "@/components/common/selectable-card"; +import { + modLoaderTypes, + modLoaderTypesToIcon, +} from "@/components/loader-selector"; import { ChangeModLoaderModal } from "@/components/modals/change-mod-loader-modal"; import CheckModUpdateModal from "@/components/modals/check-mod-update-modal"; import ModInfoModal from "@/components/modals/mod-info-modal"; @@ -43,6 +49,7 @@ import { LocalModInfo } from "@/models/instance/misc"; import { InstanceService } from "@/services/instance"; import { ResourceService } from "@/services/resource"; import { UtilsService } from "@/services/utils"; +import { parseModLoaderVersion } from "@/utils/instance"; import { base64ImgSrc } from "@/utils/string"; const InstanceModsPage = () => { @@ -343,6 +350,20 @@ const InstanceModsPage = () => { }, ]; + const selectableCardItems = modLoaderTypes.map( + (type): SelectableCardProps => ({ + title: type, + iconSrc: `/images/icons/${modLoaderTypesToIcon[type]}`, + description: + summary?.modLoader.loaderType === type + ? parseModLoaderVersion(summary?.modLoader.version || "") + : t("InstanceModsPage.modLoaderList.notInstalled"), + displayMode: "entry", + isSelected: summary?.modLoader.loaderType === type, + onSelect: () => handleTypeSelect(type), + }) + ); + return ( <>
{ ); }} > - + + {selectableCardItems.map((item, index) => ( + + ))} +
{ isServerResourcePackListLoading, } = useInstanceSharedData(); const accordionStates = - config.states.instanceResourcepackPage.accordionStates; + config.states.instanceResourcePacksPage.accordionStates; const { openSharedModal } = useSharedModals(); const [resourcePacks, setResourcePacks] = useState([]); diff --git a/src/pages/instances/details/[id]/shaderpacks.tsx b/src/pages/instances/details/[id]/shaderpacks.tsx index c506de4a1..9a9cfc4c0 100644 --- a/src/pages/instances/details/[id]/shaderpacks.tsx +++ b/src/pages/instances/details/[id]/shaderpacks.tsx @@ -8,6 +8,10 @@ import CountTag from "@/components/common/count-tag"; import Empty from "@/components/common/empty"; import { OptionItem, OptionItemGroup } from "@/components/common/option-item"; import { Section } from "@/components/common/section"; +import SelectableCard, { + SelectableCardProps, +} from "@/components/common/selectable-card"; +import { useLauncherConfig } from "@/contexts/config"; import { useInstanceSharedData } from "@/contexts/instance"; import { useSharedModals } from "@/contexts/shared-modal"; import { InstanceSubdirType } from "@/enums/instance"; @@ -17,6 +21,7 @@ import { ShaderPackInfo } from "@/models/instance/misc"; import { ResourceService } from "@/services/resource"; const InstanceShaderPacksPage = () => { + const { config, update } = useLauncherConfig(); const { t } = useTranslation(); const { summary, @@ -26,6 +31,7 @@ const InstanceShaderPacksPage = () => { isShaderPackListLoading: isLoading, } = useInstanceSharedData(); const { openSharedModal } = useSharedModals(); + const accordionStates = config.states.instanceShaderPacksPage.accordionStates; const [shaderPacks, setShaderPacks] = useState([]); @@ -107,51 +113,101 @@ const InstanceShaderPacksPage = () => { }, ]; + const selectableCardItems: SelectableCardProps[] = [ + { + title: "OptiFine", + iconSrc: "/images/icons/OptiFine.png", + description: + summary?.optifine?.status === "Installed" + ? summary?.optifine?.version + : t("InstanceShaderPacksPage.shaderLoaderList.notInstalled"), + displayMode: "entry", + isSelected: summary?.optifine?.status === "Installed", + onSelect: () => {}, + // TODO: add OptiFine installation support + isDisabled: true, + isChevronShown: false, + }, + ]; + return ( -
} - headExtra={ - - {shaderSecMenuOperations.map((btn, index) => ( - +
{ + update( + "states.instanceShaderPacksPage.accordionStates", + accordionStates.toSpliced(0, 1, isOpen) + ); + }} + > + + {selectableCardItems.map((item, index) => ( + ))} - } - > - {isLoading ? ( -
- -
- ) : shaderPacks.length > 0 ? ( - ( - - - {shaderItemMenuOperations(pack).map((item, index) => ( - - ))} - - - ))} - /> - ) : ( - - )} -
+
+
{ + update( + "states.instanceShaderPacksPage.accordionStates", + accordionStates.toSpliced(1, 1, isOpen) + ); + }} + titleExtra={} + headExtra={ + + {shaderSecMenuOperations.map((btn, index) => ( + + ))} + + } + > + {isLoading ? ( +
+ +
+ ) : shaderPacks.length > 0 ? ( + ( + + + {shaderItemMenuOperations(pack).map((item, index) => ( + + ))} + + + ))} + /> + ) : ( + + )} +
+ ); }; diff --git a/src/services/instance.ts b/src/services/instance.ts index 7ce7f92e8..17544316f 100644 --- a/src/services/instance.ts +++ b/src/services/instance.ts @@ -15,6 +15,7 @@ import { LevelData, WorldInfo } from "@/models/instance/world"; import { GameClientResourceInfo, ModLoaderResourceInfo, + OptiFineResourceInfo, } from "@/models/resource"; import { InvokeResponse } from "@/models/response"; import { responseHandler } from "@/utils/response"; @@ -42,6 +43,7 @@ export class InstanceService { * @param {string} iconSrc - The icon source of the instance. * @param {GameClientResourceInfo} game - The game resource info of the instance. * @param {ModLoaderResourceInfo} modLoader - The mod loader info of the instance. + * @param {OptiFineResourceInfo} [optifine] - Optional OptiFine installation. * @param {string} [modpackPath] - Optional path to the modpack archive file. * @param {boolean} [isInstallFabricApi] - Optional flag to indicate whether to install Fabric API (only valid when modLoader is Fabric). * @returns {Promise>} @@ -54,6 +56,7 @@ export class InstanceService { iconSrc: string, game: GameClientResourceInfo, modLoader: ModLoaderResourceInfo, + optifine?: OptiFineResourceInfo, modpackPath?: string, isInstallFabricApi?: boolean ): Promise> { @@ -64,6 +67,7 @@ export class InstanceService { iconSrc, game, modLoader, + optifine, modpackPath, isInstallFabricApi, }); diff --git a/src/services/resource.ts b/src/services/resource.ts index d8d982c8e..b978fa972 100644 --- a/src/services/resource.ts +++ b/src/services/resource.ts @@ -6,6 +6,7 @@ import { GameClientResourceInfo, ModLoaderResourceInfo, ModUpdateQuery, + OptiFineResourceInfo, OtherResourceFileInfo, OtherResourceInfo, OtherResourceSearchRes, @@ -43,6 +44,8 @@ export class ResourceService { /** * FETCH the list of mode loader versions. + * @param {string} gameVersion - The game version to fetch mod loaders for. + * @param {ModLoaderType} modLoaderType - The type of mod loader to fetch. * @returns {Promise>} */ @responseHandler("resource") @@ -56,6 +59,18 @@ export class ResourceService { }); } + /** + * FETCH the list of OptiFine versions. + * @param {string} gameVersion - The game version to fetch OptiFine versions for. + * @returns {Promise>} + */ + @responseHandler("resource") + static async fetchOptiFineVersionList( + gameVersion: string + ): Promise> { + return await invoke("fetch_optifine_version_list", { gameVersion }); + } + /** * FETCH the list of resources according to the given parameters. * @returns {Promise>} diff --git a/src/styles/globals.css b/src/styles/globals.css index 1c79edcd9..d15c4a8d2 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -68,6 +68,10 @@ body img { text-overflow: ellipsis; } +.force-break { + word-break: break-all; +} + /* card-style content container */ .content-full-y {