diff --git a/crates/lodestone-minecraft/src/jar.rs b/crates/lodestone-minecraft/src/jar.rs index 6adf89d92..1fcbd323b 100644 --- a/crates/lodestone-minecraft/src/jar.rs +++ b/crates/lodestone-minecraft/src/jar.rs @@ -521,6 +521,63 @@ pub fn parse_plugin_yml(text: &str) -> ModFileMetadata { } } +/// The icon a metadata file names, as a path inside the jar: `icon` in `fabric.mod.json`, +/// `quilt_loader.metadata.icon` in `quilt.mod.json` (a path, or a map of sizes to paths, +/// of which the largest is taken), or `logoFile` in `mods.toml`, `neoforge.mods.toml` +/// (on the first mod, or the file) and `mcmod.info`. `file` is the metadata file's name in +/// the jar, as in [`metadata_files`]. +pub fn metadata_icon(file: &str, text: &str) -> Option { + fn icon_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(path) => Some(path.clone()), + serde_json::Value::Object(sizes) => sizes + .iter() + .filter_map(|(size, path)| Some((size.parse::().ok()?, path.as_str()?))) + .max_by_key(|(size, _)| *size) + .map(|(_, path)| path.to_string()), + _ => None, + } + } + let path = match file { + "fabric.mod.json" => { + let json: serde_json::Value = parse_json_leniently(text)?; + icon_value(json.get("icon")?) + } + "quilt.mod.json" => { + let json: serde_json::Value = parse_json_leniently(text)?; + icon_value(json.get("quilt_loader")?.get("metadata")?.get("icon")?) + } + "META-INF/mods.toml" | "META-INF/neoforge.mods.toml" => { + let table: toml::Table = toml::from_str(text).ok()?; + let first_mod = table + .get("mods") + .and_then(|m| m.as_array()) + .and_then(|m| m.first()) + .and_then(|m| m.as_table()); + first_mod + .and_then(|m| m.get("logoFile")) + .or_else(|| table.get("logoFile")) + .and_then(|v| v.as_str()) + .map(String::from) + } + "mcmod.info" => { + let json: serde_json::Value = parse_json_leniently(text)?; + let entry = match &json { + serde_json::Value::Array(list) => list.first()?, + serde_json::Value::Object(o) => o.get("modList")?.as_array()?.first()?, + _ => return None, + }; + entry + .get("logoFile") + .and_then(|v| v.as_str()) + .map(String::from) + } + _ => None, + }?; + let path = path.trim().trim_start_matches('/').to_string(); + (!path.is_empty()).then_some(path) +} + #[cfg(test)] mod tests { #[cfg(feature = "zip")] @@ -758,4 +815,52 @@ Item viewer None ); } + #[test] + fn metadata_icons() { + assert_eq!( + metadata_icon( + "fabric.mod.json", + r#"{"id": "a", "icon": "assets/a/icon.png"}"# + ), + Some("assets/a/icon.png".into()) + ); + assert_eq!( + metadata_icon( + "fabric.mod.json", + r#"{"icon": {"16": "small.png", "128": "big.png", "64": "mid.png"}}"# + ), + Some("big.png".into()) + ); + assert_eq!( + metadata_icon( + "quilt.mod.json", + r#"{"quilt_loader": {"metadata": {"icon": "q.png"}}}"# + ), + Some("q.png".into()) + ); + assert_eq!( + metadata_icon( + "META-INF/mods.toml", + "modLoader=\"javafml\"\n[[mods]]\nmodId=\"x\"\nlogoFile=\"logo.png\"\n" + ), + Some("logo.png".into()) + ); + assert_eq!( + metadata_icon( + "META-INF/neoforge.mods.toml", + "logoFile=\"top.png\"\n[[mods]]\nmodId=\"x\"\n" + ), + Some("top.png".into()) + ); + assert_eq!( + metadata_icon( + "mcmod.info", + r#"[{"modid": "x", "logoFile": "/assets/x/logo.png"}]"# + ), + Some("assets/x/logo.png".into()) + ); + assert_eq!(metadata_icon("fabric.mod.json", r#"{"icon": ""}"#), None); + assert_eq!(metadata_icon("fabric.mod.json", "not json"), None); + assert_eq!(metadata_icon("plugin.yml", "name: x"), None); + } } diff --git a/crates/lodestone-minecraft/src/modrinth/mod.rs b/crates/lodestone-minecraft/src/modrinth/mod.rs index f22d304bc..d89a30c42 100644 --- a/crates/lodestone-minecraft/src/modrinth/mod.rs +++ b/crates/lodestone-minecraft/src/modrinth/mod.rs @@ -5,7 +5,7 @@ pub mod links; pub mod search; pub mod types; -pub use search::SearchParams; +pub use search::{ClientProjectType, ClientSearchParams, SearchParams}; pub use types::*; /// The only host that version files (mods, plugins and modpacks) are downloaded from. diff --git a/crates/lodestone-minecraft/src/modrinth/search.rs b/crates/lodestone-minecraft/src/modrinth/search.rs index 9ade2e41f..f03586c12 100644 --- a/crates/lodestone-minecraft/src/modrinth/search.rs +++ b/crates/lodestone-minecraft/src/modrinth/search.rs @@ -46,6 +46,52 @@ fn optional_filter(value: Option, what: &str) -> Result, } } +fn check_query(query: Option) -> Result, Error> { + let query = query + .map(|q| q.trim().to_string()) + .filter(|q| !q.is_empty()); + if let Some(q) = &query { + if q.chars().count() > MAX_QUERY_CHARS { + return Err(bad_request(format!( + "The search text cannot be longer than {MAX_QUERY_CHARS} characters" + ))); + } + if q.chars().any(|c| c.is_control()) { + return Err(bad_request( + "The search text cannot contain control characters", + )); + } + } + Ok(query) +} + +fn check_offset(offset: Option) -> Result { + let offset = offset.unwrap_or(0); + if offset > MAX_SEARCH_OFFSET { + return Err(bad_request("The search offset is too large")); + } + Ok(offset) +} + +/// The query string for Modrinth's `GET /search`. +fn search_query_pairs( + query: &Option, + facets: &[Vec], + index: ModrinthSearchIndex, + offset: u32, + limit: u32, +) -> Vec<(&'static str, String)> { + let mut pairs = Vec::new(); + if let Some(q) = query { + pairs.push(("query", q.clone())); + } + pairs.push(("facets", serde_json::to_string(facets).unwrap_or_default())); + pairs.push(("index", index.as_str().to_string())); + pairs.push(("offset", offset.to_string())); + pairs.push(("limit", limit.to_string())); + pairs +} + impl SearchParams { /// Checks the values a client sent. `limit` is capped at [`MAX_SEARCH_LIMIT`]. pub fn new( @@ -57,25 +103,8 @@ impl SearchParams { offset: Option, limit: Option, ) -> Result { - let query = query - .map(|q| q.trim().to_string()) - .filter(|q| !q.is_empty()); - if let Some(q) = &query { - if q.chars().count() > MAX_QUERY_CHARS { - return Err(bad_request(format!( - "The search text cannot be longer than {MAX_QUERY_CHARS} characters" - ))); - } - if q.chars().any(|c| c.is_control()) { - return Err(bad_request( - "The search text cannot contain control characters", - )); - } - } - let offset = offset.unwrap_or(0); - if offset > MAX_SEARCH_OFFSET { - return Err(bad_request("The search offset is too large")); - } + let query = check_query(query)?; + let offset = check_offset(offset)?; Ok(Self { query, project_type, @@ -122,18 +151,125 @@ impl SearchParams { /// The query string for Modrinth's `GET /search`. pub fn query_pairs(&self) -> Vec<(&'static str, String)> { - let mut pairs = Vec::new(); - if let Some(q) = &self.query { - pairs.push(("query", q.clone())); + search_query_pairs( + &self.query, + &self.facets(), + self.index, + self.offset, + self.limit, + ) + } +} + +/// What a game client's folder holds, as Modrinth project types: mods, resource packs and +/// shader packs. Lodestone Launcher browses these. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ClientProjectType { + Mod, + ResourcePack, + Shader, +} + +impl ClientProjectType { + /// The Modrinth `project_type`. + pub fn as_str(&self) -> &'static str { + match self { + ClientProjectType::Mod => "mod", + ClientProjectType::ResourcePack => "resourcepack", + ClientProjectType::Shader => "shader", + } + } + + pub fn from_project_type(project_type: &str) -> Option { + match project_type { + "mod" => Some(ClientProjectType::Mod), + "resourcepack" => Some(ClientProjectType::ResourcePack), + "shader" => Some(ClientProjectType::Shader), + _ => None, + } + } +} + +/// A checked search for content that runs on a game client. Unlike [`SearchParams`], it +/// has no server-side facet; mod searches hide mods that don't run on the client instead. +#[derive(Debug, Clone, PartialEq)] +pub struct ClientSearchParams { + pub query: Option, + pub project_type: ClientProjectType, + pub game_version: Option, + /// Any of these Modrinth loaders (categories). Empty for no loader filter. + pub loaders: Vec, + pub index: ModrinthSearchIndex, + pub offset: u32, + pub limit: u32, +} + +impl ClientSearchParams { + /// Checks the values, as [`SearchParams::new`] does. + pub fn new( + query: Option, + project_type: ClientProjectType, + game_version: Option, + loaders: Vec, + index: Option, + offset: Option, + limit: Option, + ) -> Result { + let mut checked = Vec::new(); + for loader in loaders { + if let Some(loader) = optional_filter(Some(loader), "loader")? { + let loader = loader.to_lowercase(); + if !checked.contains(&loader) { + checked.push(loader); + } + } + } + Ok(Self { + query: check_query(query)?, + project_type, + game_version: optional_filter(game_version, "game version")?, + loaders: checked, + index: index.unwrap_or_default(), + offset: check_offset(offset)?, + limit: limit + .unwrap_or(DEFAULT_SEARCH_LIMIT) + .clamp(1, MAX_SEARCH_LIMIT), + }) + } + + /// The Modrinth facets: the project type, the loaders (any of them), the game version, + /// and for mods a client-side facet so server-only mods are hidden. + pub fn facets(&self) -> Vec> { + let mut facets = vec![vec![format!("project_type:{}", self.project_type.as_str())]]; + if !self.loaders.is_empty() { + facets.push( + self.loaders + .iter() + .map(|l| format!("categories:{l}")) + .collect(), + ); + } + if let Some(game_version) = &self.game_version { + facets.push(vec![format!("versions:{game_version}")]); + } + if self.project_type == ClientProjectType::Mod { + facets.push(vec![ + "client_side:required".to_string(), + "client_side:optional".to_string(), + ]); } - pairs.push(( - "facets", - serde_json::to_string(&self.facets()).unwrap_or_default(), - )); - pairs.push(("index", self.index.as_str().to_string())); - pairs.push(("offset", self.offset.to_string())); - pairs.push(("limit", self.limit.to_string())); - pairs + facets + } + + /// The query string for Modrinth's `GET /search`. + pub fn query_pairs(&self) -> Vec<(&'static str, String)> { + search_query_pairs( + &self.query, + &self.facets(), + self.index, + self.offset, + self.limit, + ) } } @@ -246,6 +382,66 @@ mod tests { assert!(pairs.contains(&("query", "sodium".to_string()))); } + #[test] + fn client_search_facets() { + let new = |project_type, gv: Option<&str>, loaders: &[&str]| { + ClientSearchParams::new( + Some("x".into()), + project_type, + gv.map(String::from), + loaders.iter().map(|l| l.to_string()).collect(), + None, + None, + None, + ) + }; + let mods = new( + ClientProjectType::Mod, + Some("1.20.1"), + &["Quilt", "fabric", "quilt"], + ) + .unwrap() + .facets(); + assert_eq!( + mods, + vec![ + vec!["project_type:mod".to_string()], + vec![ + "categories:quilt".to_string(), + "categories:fabric".to_string() + ], + vec!["versions:1.20.1".to_string()], + vec![ + "client_side:required".to_string(), + "client_side:optional".to_string() + ], + ] + ); + // no server-side facet anywhere: shader and resource packs are client-only + for project_type in [ClientProjectType::ResourcePack, ClientProjectType::Shader] { + let facets = new(project_type, None, &[]).unwrap().facets(); + assert_eq!( + facets, + vec![vec![format!("project_type:{}", project_type.as_str())]] + ); + } + let shaders = new( + ClientProjectType::Shader, + Some("1.21.1"), + &["iris", "optifine"], + ) + .unwrap() + .facets(); + assert_eq!(shaders[1], vec!["categories:iris", "categories:optifine"]); + assert!(new(ClientProjectType::Mod, None, &["fabric,forge"]).is_err()); + assert!(new(ClientProjectType::Mod, Some("1.20\"]]"), &[]).is_err()); + assert_eq!( + ClientProjectType::from_project_type("resourcepack"), + Some(ClientProjectType::ResourcePack) + ); + assert_eq!(ClientProjectType::from_project_type("modpack"), None); + } + #[test] fn checks_client_values() { let new = |gv: Option<&str>, loader: Option<&str>, limit: Option| { diff --git a/crates/lodestone-minecraft/src/modrinth/types.rs b/crates/lodestone-minecraft/src/modrinth/types.rs index 5d5c20641..fb9c5459a 100644 --- a/crates/lodestone-minecraft/src/modrinth/types.rs +++ b/crates/lodestone-minecraft/src/modrinth/types.rs @@ -330,6 +330,8 @@ impl From for ModrinthProject { #[derive(Debug, Clone, Default, Deserialize)] pub struct RawHashes { + #[serde(default)] + pub sha1: Option, #[serde(default)] pub sha512: Option, } @@ -369,6 +371,16 @@ pub struct RawVersion { pub dependencies: Vec, } +impl RawVersion { + /// The primary file, or the first file if none is primary. + pub fn primary_file(&self) -> Option<&RawVersionFile> { + self.files + .iter() + .find(|f| f.primary) + .or_else(|| self.files.first()) + } +} + impl From for ModrinthVersion { fn from(raw: RawVersion) -> Self { ModrinthVersion { @@ -425,6 +437,9 @@ mod tests { ] })) .unwrap(); + assert_eq!(raw.primary_file().unwrap().filename, "y.jar"); + assert_eq!(raw.files[0].hashes.sha1.as_deref(), Some("aa")); + assert_eq!(raw.files[1].hashes.sha1, None); let version = ModrinthVersion::from(raw); assert_eq!(version.files[0].sha512, "abc"); assert_eq!(version.primary_file().unwrap().filename, "y.jar"); diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index 92cc2d7a8..c9e243767 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -2912,6 +2912,7 @@ dependencies = [ "lodestone_minecraft", "serde_json", "url", + "zip", ] [[package]] diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index 02b47913d..d193114b6 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -30,7 +30,8 @@ base64 = "0.22" dirs = "6.0" futures = "0.3.34" md-5 = "0.10" -image = { version = "0.25.1", default-features = false, features = ["png", "jpeg"] } +# WebP and GIF read Modrinth's project icons; GPUI enables them too. +image = { version = "0.25.1", default-features = false, features = ["png", "jpeg", "webp", "gif"] } libc = "0.2" # Zed's requirement and features. regex = { version = "1.5", default-features = false, features = ["std", "perf"] } diff --git a/launcher/assets/icons/arrow-down.svg b/launcher/assets/icons/arrow-down.svg new file mode 100644 index 000000000..ad940e506 --- /dev/null +++ b/launcher/assets/icons/arrow-down.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/arrow-left.svg b/launcher/assets/icons/arrow-left.svg new file mode 100644 index 000000000..607c3e74a --- /dev/null +++ b/launcher/assets/icons/arrow-left.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/arrow-up.svg b/launcher/assets/icons/arrow-up.svg new file mode 100644 index 000000000..75bd006f7 --- /dev/null +++ b/launcher/assets/icons/arrow-up.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/circle-up.svg b/launcher/assets/icons/circle-up.svg new file mode 100644 index 000000000..72596a51c --- /dev/null +++ b/launcher/assets/icons/circle-up.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/cube.svg b/launcher/assets/icons/cube.svg new file mode 100644 index 000000000..4b8ab7786 --- /dev/null +++ b/launcher/assets/icons/cube.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/download.svg b/launcher/assets/icons/download.svg new file mode 100644 index 000000000..33b66f937 --- /dev/null +++ b/launcher/assets/icons/download.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/magnifying-glass.svg b/launcher/assets/icons/magnifying-glass.svg new file mode 100644 index 000000000..a353bf207 --- /dev/null +++ b/launcher/assets/icons/magnifying-glass.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/trash-can.svg b/launcher/assets/icons/trash-can.svg new file mode 100644 index 000000000..5ac0ccc44 --- /dev/null +++ b/launcher/assets/icons/trash-can.svg @@ -0,0 +1 @@ + diff --git a/launcher/assets/icons/upload.svg b/launcher/assets/icons/upload.svg new file mode 100644 index 000000000..fff5125d9 --- /dev/null +++ b/launcher/assets/icons/upload.svg @@ -0,0 +1 @@ + diff --git a/launcher/crates/launcher-core/src/content/files.rs b/launcher/crates/launcher-core/src/content/files.rs new file mode 100644 index 000000000..8ad7af80c --- /dev/null +++ b/launcher/crates/launcher-core/src/content/files.rs @@ -0,0 +1,593 @@ +//! Changing content by hand: adding local files ("Add file…" and drag and drop), +//! enabling and disabling mods (`.disabled`), reordering resource packs, picking the +//! shader pack, and deleting (to the Trash). + +use std::{ + fs::{self, File}, + io, + path::{Path, PathBuf}, +}; + +use super::{ + ContentError, ContentKind, InstanceDirs, MAX_FILE_BYTES, Target, disabled_name, enabled_name, + io_error, is_content_name, loader_change, + options::{self, PackChange, PackOptions}, + packs::Compatibility, + provenance::Provenance, + scan::{self, ModFormat}, + shaders::{self, ShaderLoader}, +}; +use crate::{game::libraries::safe_relative_path, instances::Loader, storage}; + +/// A folder pack may hold at most this many files, and this many bytes. +const MAX_FOLDER_FILES: usize = 50_000; +const MAX_FOLDER_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +fn format_label(format: ModFormat) -> &'static str { + match format { + ModFormat::Fabric => "Fabric", + ModFormat::Quilt => "Quilt", + ModFormat::Forge => "Forge", + ModFormat::NeoForge => "NeoForge", + } +} + +/// Checks that `path` can be added as `kind` to an instance with `target`, and returns +/// the name it gets. +pub fn check_addition(kind: ContentKind, path: &Path, target: &Target) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|err| err.to_string())?; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or("The name isn't valid text.")? + .to_string(); + let is_dir = metadata.is_dir(); + if metadata.file_type().is_symlink() { + return Err("Links can't be added; add the file itself.".into()); + } + if is_dir && !kind.allows_folders() { + return Err("Mods are .jar files, not folders.".into()); + } + if !is_content_name(kind, &name, is_dir) + || (kind == ContentKind::Mod && name.to_ascii_lowercase().ends_with(".disabled")) + { + return Err(match kind { + ContentKind::Mod => "Mods are .jar files.".into(), + _ => format!("{} are .zip files or folders.", kind.label()), + }); + } + if !is_dir && metadata.len() > MAX_FILE_BYTES { + return Err(format!( + "It is larger than {} MB.", + MAX_FILE_BYTES / (1024 * 1024) + )); + } + let meta = scan::read_meta(kind, path, is_dir); + match kind { + ContentKind::Mod => { + if let Some(problem) = meta.problem { + return Err(problem); + } + if target.loader == Loader::None { + return Err("This instance is vanilla; mods need a mod loader.".into()); + } + // OptiFine runs on Forge, or on Fabric through OptiFabric. + let fits = meta.optifine + || loader_change::loads_on(&meta.mod_formats, target.loader, &target.game_version) + == Some(true); + if !fits { + let made_for: Vec<&str> = + meta.mod_formats.iter().map(|f| format_label(*f)).collect(); + return Err(format!( + "It's a {} mod, and this instance uses {}.", + made_for.join("/"), + target.loader.label() + )); + } + } + ContentKind::ResourcePack | ContentKind::ShaderPack => { + if let Some(problem) = meta.problem { + return Err(problem); + } + } + } + Ok(name) +} + +/// Copies `path` into the content folder of `kind`, under its own name. Fails if that +/// name is taken. The copy is written under a temporary name and renamed into place. +pub fn add( + dirs: &InstanceDirs, + target: &Target, + kind: ContentKind, + path: &Path, +) -> Result { + let name = check_addition(kind, path, target).map_err(ContentError::Invalid)?; + let dir = dirs.content_dir(kind); + fs::create_dir_all(&dir).map_err(io_error(&dir))?; + let dest = dir.join(&name); + let taken = [name.clone(), disabled_name(&name)] + .iter() + .take(if kind == ContentKind::Mod { 2 } else { 1 }) + .any(|n| fs::symlink_metadata(dir.join(n)).is_ok()); + if taken { + return Err(ContentError::Conflict(format!( + "{name} is already in {}.", + kind.dir() + ))); + } + if path.is_dir() { + copy_folder(path, &dir, &name)?; + } else { + let mut source = File::open(path).map_err(io_error(path))?; + storage::write_atomic_with(&dest, |file| io::copy(&mut source, file).map(|_| ())) + .map_err(io_error(&dest))?; + } + Ok(name) +} + +/// Copies the folder `source` to `dir/name`, through a temporary folder. Symbolic links +/// are skipped, and every path is checked to stay inside the copy. +fn copy_folder(source: &Path, dir: &Path, name: &str) -> Result<(), ContentError> { + let temp = dir.join(format!( + ".lodestone-add-{:016x}", + u64::from_le_bytes(crate::random_bytes()) + )); + let result = (|| { + fs::create_dir(&temp).map_err(io_error(&temp))?; + let mut files = 0; + let mut bytes = 0; + copy_tree(source, &temp, "", &mut files, &mut bytes)?; + let dest = dir.join(name); + fs::rename(&temp, &dest).map_err(io_error(&dest)) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&temp); + } + result +} + +fn copy_tree( + from: &Path, + to_root: &Path, + prefix: &str, + files: &mut usize, + bytes: &mut u64, +) -> Result<(), ContentError> { + for entry in fs::read_dir(from).map_err(io_error(from))? { + let entry = entry.map_err(io_error(from))?; + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + let Ok(safe) = safe_relative_path(&relative) else { + continue; + }; + let file_type = entry.file_type().map_err(io_error(&entry.path()))?; + let dest = to_root.join(&safe); + if file_type.is_dir() { + fs::create_dir_all(&dest).map_err(io_error(&dest))?; + copy_tree(&entry.path(), to_root, &relative, files, bytes)?; + } else if file_type.is_file() { + *files += 1; + *bytes += entry.metadata().map(|m| m.len()).unwrap_or(0); + if *files > MAX_FOLDER_FILES || *bytes > MAX_FOLDER_BYTES { + return Err(ContentError::Invalid( + "The folder is too big to add.".into(), + )); + } + fs::copy(entry.path(), &dest).map_err(io_error(&dest))?; + } + } + Ok(()) +} + +/// The path of `file_name` in the content folder of `kind`, if it is a content name that +/// exists there (not following links). +pub fn existing( + dirs: &InstanceDirs, + kind: ContentKind, + file_name: &str, +) -> Result { + let path = dirs.content_dir(kind).join(file_name); + let metadata = fs::symlink_metadata(&path) + .map_err(|_| ContentError::NotFound(format!("{file_name} isn't in {}.", kind.dir())))?; + if !is_content_name(kind, file_name, metadata.is_dir()) || metadata.file_type().is_symlink() { + return Err(ContentError::Invalid(format!( + "{file_name} can't be changed." + ))); + } + Ok(path) +} + +/// Enables or disables a mod by renaming it to or from `.jar.disabled`. Returns the +/// new name. Changing nothing is fine. +pub fn set_mod_enabled( + dirs: &InstanceDirs, + file_name: &str, + enabled: bool, +) -> Result { + let path = existing(dirs, ContentKind::Mod, file_name)?; + let new_name = if enabled { + enabled_name(file_name) + } else { + disabled_name(file_name) + }; + if new_name == file_name { + return Ok(new_name); + } + let new_path = dirs.content_dir(ContentKind::Mod).join(&new_name); + if fs::symlink_metadata(&new_path).is_ok() { + return Err(ContentError::Conflict(format!( + "{new_name} already exists, so {file_name} can't be renamed to it." + ))); + } + fs::rename(&path, &new_path).map_err(io_error(&path))?; + Ok(new_name) +} + +/// Removes a file or folder with `remove` (the app moves it to the Trash), and forgets +/// it: its provenance, its place in `options.txt`, and the shader selection. +pub fn delete_with( + dirs: &InstanceDirs, + target: &Target, + kind: ContentKind, + file_name: &str, + remove: impl FnOnce(&Path) -> Result<(), String>, +) -> Result<(), ContentError> { + let path = existing(dirs, kind, file_name)?; + remove(&path).map_err(|reason| { + ContentError::Invalid(format!("Couldn't remove {file_name}: {reason}")) + })?; + let _ = Provenance::update(&dirs.instance, |provenance| { + provenance.remove(kind, file_name); + }); + match kind { + ContentKind::ResourcePack => { + let listed = PackOptions::read(&dirs.game) + .map(|packs| { + packs + .enabled_files(&target.game_version) + .iter() + .any(|n| n == file_name) + }) + .unwrap_or(false); + if listed { + change_pack( + dirs, + target, + &PackChange::Disable { + name: file_name.to_string(), + }, + )?; + } + } + ContentKind::ShaderPack => { + if let Some(loader) = target.shader_loader + && shaders::read_selection(&dirs.game, loader)?.pack.as_deref() == Some(file_name) + { + shaders::write_selection(&dirs.game, loader, None)?; + } + } + ContentKind::Mod => {} + } + Ok(()) +} + +/// Moves a file or folder to the Trash, see [`delete_with`]. +pub fn delete( + dirs: &InstanceDirs, + target: &Target, + kind: ContentKind, + file_name: &str, +) -> Result<(), ContentError> { + delete_with(dirs, target, kind, file_name, |path| { + trash::delete(path).map_err(|err| err.to_string()) + }) +} + +/// Changes the resource packs in `options.txt`. +pub fn change_pack( + dirs: &InstanceDirs, + target: &Target, + change: &PackChange, +) -> Result { + options::change_packs(&dirs.game, &target.game_version, change) + .map_err(|err| ContentError::Invalid(err.to_string())) +} + +/// Enables or disables a resource pack. `compatibility` is the pack's badge: one that +/// isn't known to fit is also listed as incompatible, so the game keeps it enabled. +pub fn set_pack_enabled( + dirs: &InstanceDirs, + target: &Target, + file_name: &str, + enabled: bool, + compatibility: Compatibility, +) -> Result { + existing(dirs, ContentKind::ResourcePack, file_name)?; + let name = file_name.to_string(); + change_pack( + dirs, + target, + &if enabled { + PackChange::Enable { + name, + compatible: compatibility == Compatibility::Compatible, + } + } else { + PackChange::Disable { name } + }, + ) +} + +/// Makes the shader mod use the shader pack `file_name`, or no shaders. +pub fn select_shader( + dirs: &InstanceDirs, + loader: ShaderLoader, + file_name: Option<&str>, +) -> Result<(), ContentError> { + if let Some(name) = file_name { + existing(dirs, ContentKind::ShaderPack, name)?; + } + shaders::write_selection(&dirs.game, loader, file_name) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::scan::tests::write_zip; + + fn setup(loader: Loader) -> (tempfile::TempDir, InstanceDirs, Target) { + let dir = tempfile::tempdir().unwrap(); + let dirs = InstanceDirs::new(dir.path().join("instance")); + fs::create_dir_all(&dirs.game).unwrap(); + (dir, dirs, Target::new("1.20.1", loader)) + } + + #[test] + fn toggles_mods_with_the_disabled_suffix() { + let (_dir, dirs, _) = setup(Loader::Fabric); + let mods = dirs.content_dir(ContentKind::Mod); + write_zip(&mods.join("sodium.jar"), &[("fabric.mod.json", b"{}")]); + assert_eq!( + set_mod_enabled(&dirs, "sodium.jar", false).unwrap(), + "sodium.jar.disabled" + ); + assert!(mods.join("sodium.jar.disabled").is_file()); + assert!(!mods.join("sodium.jar").exists()); + // idempotent + assert_eq!( + set_mod_enabled(&dirs, "sodium.jar.disabled", false).unwrap(), + "sodium.jar.disabled" + ); + assert_eq!( + set_mod_enabled(&dirs, "sodium.jar.disabled", true).unwrap(), + "sodium.jar" + ); + // a conflict leaves both files alone + write_zip( + &mods.join("sodium.jar.disabled"), + &[("fabric.mod.json", b"{}")], + ); + assert!(matches!( + set_mod_enabled(&dirs, "sodium.jar", false), + Err(ContentError::Conflict(_)) + )); + assert!(mods.join("sodium.jar").is_file()); + for bad in ["../sodium.jar", "missing.jar", "sodium.zip", ""] { + assert!(set_mod_enabled(&dirs, bad, false).is_err(), "{bad}"); + } + } + + #[test] + fn checks_added_files() { + let (dir, dirs, fabric) = setup(Loader::Fabric); + let downloads = dir.path().join("Downloads"); + write_zip( + &downloads.join("lithium.jar"), + &[("fabric.mod.json", br#"{"id": "lithium"}"#)], + ); + write_zip( + &downloads.join("jei.jar"), + &[("META-INF/mods.toml", b"[[mods]]\nmodId=\"jei\"\n")], + ); + write_zip(&downloads.join("readme.jar"), &[("readme.txt", b"x")]); + write_zip( + &downloads.join("OptiFine.jar"), + &[("net/optifine/Config.class", b"x")], + ); + fs::write(downloads.join("fake.jar"), b"not a zip").unwrap(); + write_zip( + &downloads.join("Pack.zip"), + &[( + "pack.mcmeta", + br#"{"pack": {"pack_format": 15, "description": "x"}}"#, + )], + ); + write_zip( + &downloads.join("Nested.zip"), + &[( + "Nested/pack.mcmeta", + br#"{"pack": {"pack_format": 15, "description": "x"}}"#, + )], + ); + write_zip(&downloads.join("NoMeta.zip"), &[("assets/a.png", b"x")]); + write_zip( + &downloads.join("Shader.zip"), + &[("shaders/final.fsh", b"x")], + ); + fs::create_dir_all(downloads.join("Folder pack")).unwrap(); + fs::write( + downloads.join("Folder pack/pack.mcmeta"), + br#"{"pack": {"pack_format": 15, "description": "x"}}"#, + ) + .unwrap(); + + let check = + |kind, name: &str, target: &Target| check_addition(kind, &downloads.join(name), target); + assert_eq!( + check(ContentKind::Mod, "lithium.jar", &fabric).unwrap(), + "lithium.jar" + ); + assert!( + check(ContentKind::Mod, "jei.jar", &fabric) + .unwrap_err() + .contains("Forge mod") + ); + assert!(check(ContentKind::Mod, "readme.jar", &fabric).is_err()); + assert!(check(ContentKind::Mod, "fake.jar", &fabric).is_err()); + assert!(check(ContentKind::Mod, "OptiFine.jar", &fabric).is_ok()); + assert!(check(ContentKind::Mod, "Pack.zip", &fabric).is_err()); + // NeoForge reads mods.toml before 1.20.5 only + let neoforge = Target::new("1.20.4", Loader::NeoForge); + assert!(check(ContentKind::Mod, "jei.jar", &neoforge).is_ok()); + let neoforge = Target::new("1.21.1", Loader::NeoForge); + assert!(check(ContentKind::Mod, "jei.jar", &neoforge).is_err()); + let vanilla = Target::new("1.21.1", Loader::None); + assert!(check(ContentKind::Mod, "lithium.jar", &vanilla).is_err()); + + assert!(check(ContentKind::ResourcePack, "Pack.zip", &fabric).is_ok()); + assert!(check(ContentKind::ResourcePack, "Folder pack", &fabric).is_ok()); + assert!( + check(ContentKind::ResourcePack, "Nested.zip", &fabric) + .unwrap_err() + .contains("inside a folder") + ); + assert!( + check(ContentKind::ResourcePack, "NoMeta.zip", &fabric) + .unwrap_err() + .contains("no pack.mcmeta") + ); + assert!(check(ContentKind::ResourcePack, "lithium.jar", &fabric).is_err()); + assert!(check(ContentKind::ShaderPack, "Shader.zip", &fabric).is_ok()); + assert!(check(ContentKind::ShaderPack, "Pack.zip", &fabric).is_err()); + + // adding copies the file; adding it again is a conflict + assert_eq!( + add( + &dirs, + &fabric, + ContentKind::Mod, + &downloads.join("lithium.jar") + ) + .unwrap(), + "lithium.jar" + ); + assert!( + dirs.content_dir(ContentKind::Mod) + .join("lithium.jar") + .is_file() + ); + assert!(downloads.join("lithium.jar").is_file()); + assert!(matches!( + add( + &dirs, + &fabric, + ContentKind::Mod, + &downloads.join("lithium.jar") + ), + Err(ContentError::Conflict(_)) + )); + // a disabled copy counts as taken + set_mod_enabled(&dirs, "lithium.jar", false).unwrap(); + assert!( + add( + &dirs, + &fabric, + ContentKind::Mod, + &downloads.join("lithium.jar") + ) + .is_err() + ); + + // folders are copied without their links + #[cfg(unix)] + std::os::unix::fs::symlink("/etc", downloads.join("Folder pack/etc")).unwrap(); + fs::create_dir_all(downloads.join("Folder pack/assets/minecraft")).unwrap(); + fs::write(downloads.join("Folder pack/assets/minecraft/a.json"), b"{}").unwrap(); + add( + &dirs, + &fabric, + ContentKind::ResourcePack, + &downloads.join("Folder pack"), + ) + .unwrap(); + let copied = dirs + .content_dir(ContentKind::ResourcePack) + .join("Folder pack"); + assert!(copied.join("pack.mcmeta").is_file()); + assert!(copied.join("assets/minecraft/a.json").is_file()); + assert!(fs::symlink_metadata(copied.join("etc")).is_err()); + let leftovers: Vec<_> = fs::read_dir(dirs.content_dir(ContentKind::ResourcePack)) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with('.')) + .collect(); + assert!(leftovers.is_empty()); + } + + #[test] + fn delete_forgets_the_file() { + let (_dir, dirs, mut target) = setup(Loader::Fabric); + target.shader_loader = Some(ShaderLoader::Iris); + write_zip( + &dirs.content_dir(ContentKind::ResourcePack).join("Pack.zip"), + &[( + "pack.mcmeta", + br#"{"pack": {"pack_format": 15, "description": "x"}}"#, + )], + ); + write_zip( + &dirs.content_dir(ContentKind::ShaderPack).join("BSL.zip"), + &[("shaders/a", b"x")], + ); + set_pack_enabled(&dirs, &target, "Pack.zip", true, Compatibility::Compatible).unwrap(); + select_shader(&dirs, ShaderLoader::Iris, Some("BSL.zip")).unwrap(); + assert!(select_shader(&dirs, ShaderLoader::Iris, Some("Missing.zip")).is_err()); + let mut removed = Vec::new(); + delete_with( + &dirs, + &target, + ContentKind::ResourcePack, + "Pack.zip", + |path| { + removed.push(path.to_path_buf()); + fs::remove_file(path).map_err(|e| e.to_string()) + }, + ) + .unwrap(); + assert_eq!( + removed, + [dirs.content_dir(ContentKind::ResourcePack).join("Pack.zip")] + ); + assert_eq!( + fs::read_to_string(dirs.game.join("options.txt")).unwrap(), + "resourcePacks:[\"vanilla\"]\n" + ); + delete_with(&dirs, &target, ContentKind::ShaderPack, "BSL.zip", |path| { + fs::remove_file(path).map_err(|e| e.to_string()) + }) + .unwrap(); + assert_eq!( + shaders::read_selection(&dirs.game, ShaderLoader::Iris) + .unwrap() + .pack, + None + ); + assert!( + delete_with( + &dirs, + &target, + ContentKind::Mod, + "../instance.json", + |_| Ok(()) + ) + .is_err() + ); + } +} diff --git a/launcher/crates/launcher-core/src/content/install.rs b/launcher/crates/launcher-core/src/content/install.rs new file mode 100644 index 000000000..0e813160d --- /dev/null +++ b/launcher/crates/launcher-core/src/content/install.rs @@ -0,0 +1,605 @@ +//! Installing a Modrinth version into an instance, with the same rules as the dashboard's +//! server mods (`core/src/mods/install.rs`): +//! +//! - a version that doesn't fit the instance (loader, Minecraft version) is refused unless +//! forced; +//! - a mod's required dependencies are installed too: the version the dependency names, +//! else the newest fitting release (or the newest fitting version), at most +//! [`MAX_DEPENDENCY_DEPTH`] levels deep, skipping projects already installed and cycles; +//! optional, incompatible and embedded dependencies never are; +//! - nothing is installed if a planned version is incompatible with an installed one; +//! - an older file of the same project is replaced once the new one is in place; a +//! disabled mod stays disabled, an enabled resource pack keeps its place, and the +//! selected shader pack stays selected. +//! +//! Files come only from Modrinth's CDN, through the [`Downloader`] (SHA-1 and size +//! checked, written to a temporary file and renamed), and their SHA-512 is checked before +//! they get their final name. + +use std::{ + collections::{BTreeSet, HashMap, HashSet, VecDeque}, + fs, + sync::Mutex, +}; + +use lodestone_minecraft::{ + hash, + modrinth::{ModrinthDependencyType, ModrinthProject, ModrinthVersionType, RawVersion}, +}; + +use super::{ + ContentError, ContentKind, InstanceDirs, MAX_FILE_BYTES, Modrinth, ModrinthError, Target, + disabled_name, io_error, + listing::refresh, + modrinth::sort_newest_first, + options::{self, PackChange}, + provenance::{Entry, ModrinthSource, Provenance, nanos}, + safe_download_name, shaders, +}; +use crate::game::download::{CancelToken, DownloadItem, Downloader, Progress, ProgressSnapshot}; + +pub const MAX_DEPENDENCY_DEPTH: usize = 3; + +/// The newest `release` among `versions` that fits `target`, or else the newest version +/// that fits. +pub fn pick_version( + target: &Target, + kind: ContentKind, + mut versions: Vec, +) -> Option { + versions.retain(|v| target.incompatibility(kind, v).is_none()); + sort_newest_first(&mut versions); + let release = versions + .iter() + .position(|v| v.version_type == ModrinthVersionType::Release); + match release { + Some(i) => Some(versions.swap_remove(i)), + None => versions.into_iter().next(), + } +} + +#[derive(Clone, Debug)] +pub struct InstallRequest { + pub kind: ContentKind, + pub version: RawVersion, + /// The version's project, when the caller has it already. + pub project: Option, + pub install_dependencies: bool, + /// Install even if the version doesn't fit the instance. + pub force: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Skipped { + pub project: String, + pub reason: String, +} + +#[derive(Clone, Debug)] +pub struct PlannedFile { + pub kind: ContentKind, + pub version: RawVersion, + pub project: Option, + /// 0 for the requested version, 1 for its dependencies, and so on. + pub depth: usize, +} + +impl PlannedFile { + pub fn title(&self) -> String { + self.project + .as_ref() + .map(|p| p.title.clone()) + .unwrap_or_else(|| self.version.name.clone()) + } +} + +#[derive(Clone, Debug)] +pub struct InstallPlan { + /// The requested version first, then its dependencies. + pub files: Vec, + pub skipped: Vec, +} + +impl InstallPlan { + /// The bytes to download. + pub fn download_size(&self) -> u64 { + self.files + .iter() + .filter_map(|f| f.version.primary_file()) + .map(|f| f.size) + .sum() + } +} + +fn skipped(project: impl Into, reason: impl Into) -> Skipped { + Skipped { + project: project.into(), + reason: reason.into(), + } +} + +/// The version of a required dependency to install, or why there is none. +async fn dependency_version( + api: &Modrinth, + target: &Target, + project_id: &str, + pinned: Option, +) -> Result, ContentError> { + if let Some(version) = pinned { + return Ok(match target.incompatibility(ContentKind::Mod, &version) { + None => Ok(version), + Some(reason) => Err(format!("The required version doesn't fit: {reason}")), + }); + } + let versions = match api + .versions( + project_id, + &target.accepted_loaders(ContentKind::Mod), + std::slice::from_ref(&target.game_version), + ) + .await + { + Ok(versions) => versions, + Err(ModrinthError::NotFound(_)) => return Ok(Err("Not found on Modrinth".into())), + Err(err) => return Err(err.into()), + }; + Ok( + pick_version(target, ContentKind::Mod, versions).ok_or_else(|| { + format!( + "No version for {} on Minecraft {}", + target.loader.label(), + target.game_version + ) + }), + ) +} + +/// Plans what installing `request` downloads. Matches the installed files to Modrinth +/// first, so installed projects are known. +pub async fn plan( + api: &Modrinth, + dirs: &InstanceDirs, + target: &Target, + request: InstallRequest, +) -> Result { + let kind = request.kind; + if !request.force + && let Some(reason) = target.incompatibility(kind, &request.version) + { + return Err(ContentError::Invalid(reason)); + } + let (_, provenance, _) = refresh(Some(api), dirs, kind).await?; + let installed = provenance.by_project(kind); + + let mut plan = vec![PlannedFile { + kind, + version: request.version, + project: request.project, + depth: 0, + }]; + let mut seen: HashSet = HashSet::from([plan[0].version.project_id.clone()]); + let mut skipped_list = Vec::new(); + // Resource and shader packs have no dependencies Lodestone installs: a shader pack + // that names Iris gets the shader mod offer instead. + if request.install_dependencies && kind == ContentKind::Mod { + let mut queue = VecDeque::from([0_usize]); + while let Some(index) = queue.pop_front() { + let depth = plan[index].depth + 1; + let dependencies: Vec<_> = plan[index] + .version + .dependencies + .iter() + .filter(|d| d.dependency_type == ModrinthDependencyType::Required) + .cloned() + .collect(); + for dependency in dependencies { + let mut pinned = None; + let project_id = match (&dependency.project_id, &dependency.version_id) { + (Some(project_id), _) => project_id.clone(), + (None, Some(version_id)) => match api.version(version_id).await { + Ok(version) => { + let project_id = version.project_id.clone(); + pinned = Some(version); + project_id + } + Err(ModrinthError::NotFound(_)) => { + skipped_list.push(skipped(version_id.clone(), "Not found on Modrinth")); + continue; + } + Err(err) => return Err(err.into()), + }, + (None, None) => { + let name = dependency + .file_name + .clone() + .unwrap_or_else(|| "A dependency".to_string()); + skipped_list.push(skipped( + name.clone(), + format!("{name} isn't on Modrinth; add it yourself"), + )); + continue; + } + }; + if installed.contains_key(&project_id) || seen.contains(&project_id) { + continue; + } + if depth > MAX_DEPENDENCY_DEPTH { + skipped_list.push(skipped( + project_id, + format!("More than {MAX_DEPENDENCY_DEPTH} levels of dependencies"), + )); + continue; + } + seen.insert(project_id.clone()); + if pinned.is_none() + && let Some(version_id) = &dependency.version_id + { + pinned = match api.version(version_id).await { + Ok(version) => Some(version), + Err(ModrinthError::NotFound(_)) => None, + Err(err) => return Err(err.into()), + }; + } + match dependency_version(api, target, &project_id, pinned).await? { + Ok(version) => { + plan.push(PlannedFile { + kind, + version, + project: None, + depth, + }); + queue.push_back(plan.len() - 1); + } + Err(reason) => skipped_list.push(skipped(project_id, reason)), + } + } + } + } + + // Nothing is installed if a planned version is incompatible with an installed one. + for planned in &plan { + for dependency in &planned.version.dependencies { + if dependency.dependency_type != ModrinthDependencyType::Incompatible { + continue; + } + let conflict = provenance + .files + .values() + .filter_map(|e| e.modrinth.as_ref()) + .find(|m| { + m.project_id != planned.version.project_id + && (dependency.project_id.as_deref() == Some(m.project_id.as_str()) + || dependency.version_id.as_deref() == Some(m.version_id.as_str())) + }); + if let Some(conflict) = conflict { + return Err(ContentError::Invalid(format!( + "{} {} is incompatible with the installed {}. Remove it first.", + planned.version.name, planned.version.version_number, conflict.title + ))); + } + } + } + + let missing: Vec = plan + .iter() + .filter(|p| p.project.is_none()) + .map(|p| p.version.project_id.clone()) + .collect::>() + .into_iter() + .collect(); + if !missing.is_empty() { + let projects: HashMap = api + .projects(&missing) + .await? + .into_iter() + .map(|p| (p.id.clone(), p)) + .collect(); + for planned in plan.iter_mut().filter(|p| p.project.is_none()) { + planned.project = projects.get(&planned.version.project_id).cloned(); + } + } + Ok(InstallPlan { + files: plan, + skipped: skipped_list, + }) +} + +/// How an install is going. +#[derive(Debug, Default)] +pub struct InstallProgress { + pub downloads: Progress, + label: Mutex, +} + +impl InstallProgress { + pub fn label(&self) -> String { + self.label.lock().unwrap_or_else(|p| p.into_inner()).clone() + } + + pub fn set_label(&self, label: impl Into) { + *self.label.lock().unwrap_or_else(|p| p.into_inner()) = label.into(); + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct InstallOutcome { + /// Titles and file names. + pub installed: Vec<(String, String)>, + /// File names. + pub removed: Vec, + pub skipped: Vec, +} + +/// Downloads and puts in place every file of `plan`, dependencies first. +pub async fn execute( + api: &Modrinth, + downloader: &Downloader, + dirs: &InstanceDirs, + target: &Target, + plan: InstallPlan, + progress: &InstallProgress, + cancel: &CancelToken, +) -> Result { + let total_files = plan.files.len() as u64; + let total_bytes = plan.download_size(); + let mut done_bytes = 0; + let mut outcome = InstallOutcome { + skipped: plan.skipped.clone(), + ..InstallOutcome::default() + }; + for (index, planned) in plan.files.iter().rev().enumerate() { + progress.set_label(format!("Installing {}", planned.title())); + progress.downloads.set(ProgressSnapshot { + files_total: total_files, + files_done: index as u64, + bytes_total: total_bytes, + bytes_done: done_bytes, + }); + let (name, removed) = + install_file(api, downloader, dirs, target, planned, progress, cancel).await?; + done_bytes += planned.version.primary_file().map_or(0, |f| f.size); + outcome.installed.push((planned.title(), name)); + outcome.removed.extend(removed); + } + progress.downloads.set(ProgressSnapshot { + files_total: total_files, + files_done: total_files, + bytes_total: total_bytes, + bytes_done: total_bytes, + }); + outcome.installed.reverse(); + Ok(outcome) +} + +fn is_hex(value: &str, len: usize) -> bool { + value.len() == len && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Installs one planned file. Returns its name and the names of the files it replaced. +async fn install_file( + api: &Modrinth, + downloader: &Downloader, + dirs: &InstanceDirs, + target: &Target, + planned: &PlannedFile, + progress: &InstallProgress, + cancel: &CancelToken, +) -> Result<(String, Vec), ContentError> { + let kind = planned.kind; + let version = &planned.version; + let dir = dirs.content_dir(kind); + let mut provenance = Provenance::load(&dirs.instance); + let old: Vec = provenance + .by_project(kind) + .remove(&version.project_id) + .unwrap_or_default() + .into_iter() + .filter_map(|name| on_disk(dirs, kind, &name)) + .collect(); + + // The same version is installed already: keep it, drop the others. + if let Some(same) = old.iter().find(|name| { + provenance + .get(kind, name) + .and_then(|e| e.modrinth.as_ref()) + .is_some_and(|m| m.version_id == version.id) + }) { + let same = same.clone(); + let removed = remove_old(dirs, kind, &old, &same, &mut provenance)?; + save(dirs, kind, &removed, &same, None)?; + return Ok((same, removed)); + } + + let file = version + .primary_file() + .ok_or_else(|| ContentError::Invalid(format!("{} has no files", version.name)))?; + let url = api.cdn_url(&file.url)?; + if file.size > MAX_FILE_BYTES { + return Err(ContentError::Invalid(format!( + "{} is larger than {} MB", + file.filename, + MAX_FILE_BYTES / (1024 * 1024) + ))); + } + let sha1 = file + .hashes + .sha1 + .clone() + .unwrap_or_default() + .to_ascii_lowercase(); + let sha512 = file + .hashes + .sha512 + .clone() + .unwrap_or_default() + .to_ascii_lowercase(); + if !is_hex(&sha1, 40) || !is_hex(&sha512, 128) { + return Err(ContentError::Invalid(format!( + "Modrinth sent no hashes for {}, so it can't be checked", + file.filename + ))); + } + let name = safe_download_name(kind, &file.filename).ok_or_else(|| { + ContentError::Invalid(format!( + "{} isn't a file name that can be used", + file.filename + )) + })?; + let disabled = kind == ContentKind::Mod + && !old.is_empty() + && old.iter().all(|n| super::is_disabled_name(n)); + let target_name = if disabled { disabled_name(&name) } else { name }; + let target_path = dir.join(&target_name); + if fs::symlink_metadata(&target_path).is_ok() && !old.contains(&target_name) { + return Err(ContentError::Conflict(format!( + "{target_name} already exists in {}. Remove it first.", + kind.dir() + ))); + } + + fs::create_dir_all(&dir).map_err(io_error(&dir))?; + let staging = dir.join(format!(".lodestone-{}.download", sha1)); + let item = DownloadItem { + url, + dest: staging.clone(), + sha1, + size: Some(file.size), + executable: false, + }; + let result = downloader.fetch(&item, &progress.downloads, cancel).await; + if let Err(err) = result { + let _ = fs::remove_file(&staging); + return Err(err.into()); + } + let actual = hash::sha512_of(&staging).map_err(io_error(&staging))?; + if !actual.eq_ignore_ascii_case(&sha512) { + let _ = fs::remove_file(&staging); + return Err(ContentError::Invalid(format!( + "The download of {} is damaged: its SHA-512 hash doesn't match", + file.filename + ))); + } + if target_path.exists() { + // An older file of this project with the same name; Windows can't rename over it. + fs::remove_file(&target_path).map_err(io_error(&target_path))?; + } + if let Err(err) = fs::rename(&staging, &target_path) { + let _ = fs::remove_file(&staging); + return Err(io_error(&target_path)(err)); + } + + let enabled_old = match kind { + ContentKind::ResourcePack => { + options::PackOptions::read(&dirs.game) + .ok() + .and_then(|packs| { + let enabled = packs.enabled_files(&target.game_version); + old.iter().find(|n| enabled.contains(n)).cloned() + }) + } + ContentKind::ShaderPack => target.shader_loader.and_then(|loader| { + let selected = shaders::read_selection(&dirs.game, loader).ok()?.pack?; + old.contains(&selected).then_some(selected) + }), + ContentKind::Mod => None, + }; + let removed = remove_old(dirs, kind, &old, &target_name, &mut provenance)?; + let modified = fs::metadata(&target_path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(nanos); + let entry = Entry { + size: file.size, + modified, + sha1: item.sha1.clone(), + sha512: sha512.clone(), + modrinth: Some(ModrinthSource::new( + version, + planned.project.as_ref(), + &sha512, + )), + looked_up: false, + }; + save(dirs, kind, &removed, &target_name, Some(entry))?; + if let Some(from) = enabled_old.filter(|from| from != &target_name) { + match kind { + ContentKind::ResourcePack => { + options::change_packs( + &dirs.game, + &target.game_version, + &PackChange::Rename { + from, + to: target_name.clone(), + }, + ) + .map_err(|err| ContentError::Invalid(err.to_string()))?; + } + ContentKind::ShaderPack => { + if let Some(loader) = target.shader_loader { + shaders::write_selection(&dirs.game, loader, Some(&target_name))?; + } + } + ContentKind::Mod => {} + } + } + Ok((target_name, removed)) +} + +/// The name on disk of the file with provenance key name `name` (a mod may be disabled). +fn on_disk(dirs: &InstanceDirs, kind: ContentKind, name: &str) -> Option { + let dir = dirs.content_dir(kind); + [name.to_string(), disabled_name(name)] + .into_iter() + .take(if kind == ContentKind::Mod { 2 } else { 1 }) + .find(|candidate| fs::symlink_metadata(dir.join(candidate)).is_ok()) +} + +/// Records an install in `content.json`: the `removed` files are forgotten (unless they +/// were `kept` under its other name) and `kept` gets `entry`. +fn save( + dirs: &InstanceDirs, + kind: ContentKind, + removed: &[String], + kept: &str, + entry: Option, +) -> Result<(), ContentError> { + Provenance::update(&dirs.instance, |provenance| { + for name in removed { + if super::enabled_name(name) != super::enabled_name(kept) { + provenance.remove(kind, name); + } + } + if let Some(entry) = entry { + provenance.insert(kind, kept, entry); + } + }) + .map(|_| ()) + .map_err(|err| ContentError::Invalid(err.to_string())) +} + +/// Removes the `old` files except `keep`. +fn remove_old( + dirs: &InstanceDirs, + kind: ContentKind, + old: &[String], + keep: &str, + provenance: &mut Provenance, +) -> Result, ContentError> { + let dir = dirs.content_dir(kind); + let mut removed = Vec::new(); + for name in old.iter().filter(|n| n.as_str() != keep) { + let path = dir.join(name); + let result = if path.is_dir() { + fs::remove_dir_all(&path) + } else { + fs::remove_file(&path) + }; + result.map_err(io_error(&path))?; + // `keep` may be the same file under its other name (`.disabled`). + if super::enabled_name(name) != super::enabled_name(keep) { + provenance.remove(kind, name); + } + removed.push(name.clone()); + } + Ok(removed) +} diff --git a/launcher/crates/launcher-core/src/content/listing.rs b/launcher/crates/launcher-core/src/content/listing.rs new file mode 100644 index 000000000..f6b2e34f7 --- /dev/null +++ b/launcher/crates/launcher-core/src/content/listing.rs @@ -0,0 +1,303 @@ +//! The content list an instance's Content tab shows: the scanned files, matched to +//! Modrinth through [`Provenance`], with each pack's enabled state and order and the +//! shader mod's selection. + +use std::collections::{BTreeSet, HashMap}; + +use lodestone_minecraft::{hash, modrinth::RawVersion}; + +use super::{ + ContentError, ContentKind, InstanceDirs, Modrinth, Target, io_error, + options::{OptionsError, PackOptions}, + packs::{self, Compatibility}, + provenance::{Entry, ModrinthSource, Provenance, nanos}, + scan::{self, ScannedFile}, + shaders::{self, ShaderLoader, ShaderSelection}, +}; + +/// One file of the list. +#[derive(Clone, Debug, PartialEq)] +pub struct Item { + pub file: ScannedFile, + pub sha512: Option, + pub modrinth: Option, + /// Mods: not `.disabled`. Resource packs: in `options.txt`'s list. Shader packs: the + /// selected one. + pub enabled: bool, + /// Enabled resource packs: 0 is the highest priority. + pub priority: Option, + /// Resource packs: whether its formats fit the instance's version. + pub compatibility: Compatibility, +} + +impl Item { + /// The name to show: Modrinth's title, the metadata's name, or the file name. + pub fn display_name(&self) -> String { + match &self.modrinth { + Some(source) if !source.title.is_empty() => source.title.clone(), + _ => self.file.display_name(), + } + } + + /// The version to show: Modrinth's version number, else the metadata's. + pub fn display_version(&self) -> Option { + self.modrinth + .as_ref() + .map(|m| m.version_number.clone()) + .filter(|v| !v.is_empty()) + .or_else(|| self.file.meta.version.clone()) + } +} + +/// The shader mod and the pack it uses. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ShaderState { + pub loader: Option, + pub selection: ShaderSelection, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Listing { + pub kind: ContentKind, + pub items: Vec, + /// Why some files couldn't be matched to Modrinth (offline, rate limited). + pub lookup_error: Option, + /// Why `options.txt` couldn't be read, for resource packs. + pub options_error: Option, + pub shaders: ShaderState, +} + +/// Brings `content.json` up to date with the folder of `kind`: new and changed files are +/// hashed, and, with `api`, files without a source are looked up on Modrinth by SHA-1. +/// Returns the files, the provenance, and why the lookup failed if it did. +pub async fn refresh( + api: Option<&Modrinth>, + dirs: &InstanceDirs, + kind: ContentKind, +) -> Result<(Vec, Provenance, Option), ContentError> { + let files = scan::scan(dirs, kind)?; + let mut provenance = Provenance::load(&dirs.instance); + let before = provenance.clone(); + provenance.retain_present(kind, &files); + let dir = dirs.content_dir(kind); + for file in files.iter().filter(|f| !f.is_dir) { + if provenance.current(file).is_some() { + continue; + } + let path = dir.join(&file.file_name); + let sha512 = hash::sha512_of(&path).map_err(io_error(&path))?; + let old = provenance.get(kind, &file.file_name).cloned(); + let same = old.as_ref().filter(|e| e.sha512 == sha512); + let sha1 = match same { + Some(entry) => entry.sha1.clone(), + None => hash::sha1_of(&path).map_err(io_error(&path))?, + }; + provenance.insert( + kind, + &file.file_name, + Entry { + size: file.size, + modified: file.modified.and_then(nanos), + sha1, + sha512, + modrinth: same.and_then(|e| e.modrinth.clone()), + looked_up: same.is_some_and(|e| e.looked_up), + }, + ); + } + + let mut lookup_error = None; + if let Some(api) = api { + let prefix = format!("{}/", kind.dir()); + let unknown: Vec = provenance + .files + .iter() + .filter(|(k, e)| k.starts_with(&prefix) && e.modrinth.is_none() && !e.looked_up) + .map(|(_, e)| e.sha1.clone()) + .collect::>() + .into_iter() + .collect(); + if !unknown.is_empty() { + match lookup(api, &unknown).await { + Ok(found) => { + for (k, entry) in provenance.files.iter_mut() { + if !k.starts_with(&prefix) || entry.modrinth.is_some() || entry.looked_up { + continue; + } + match found.get(&entry.sha1) { + Some((version, project)) => { + entry.modrinth = Some(ModrinthSource::new( + version, + project.as_ref(), + &entry.sha512, + )); + } + None => entry.looked_up = true, + } + } + } + Err(err) => lookup_error = Some(err.to_string()), + } + } + } + if provenance != before { + // The list still works without the cache, so a failed save isn't an error. + let _ = Provenance::update(&dirs.instance, |current| { + current.merge_refreshed(kind, &provenance, &dir) + }); + } + Ok((files, provenance, lookup_error)) +} + +type Found = HashMap< + String, + ( + RawVersion, + Option, + ), +>; + +/// Looks up SHA-1 hashes, with the projects of the versions found. +async fn lookup(api: &Modrinth, hashes: &[String]) -> Result { + let versions = api.versions_by_sha1(hashes).await?; + let ids: Vec = versions + .values() + .map(|v| v.project_id.clone()) + .collect::>() + .into_iter() + .collect(); + let projects: HashMap = if ids.is_empty() { + HashMap::new() + } else { + api.projects(&ids) + .await? + .into_iter() + .map(|p| (p.id.clone(), p)) + .collect() + }; + Ok(versions + .into_iter() + .map(|(hash, version)| { + let project = projects.get(&version.project_id).cloned(); + (hash, (version, project)) + }) + .collect()) +} + +/// The shader mod in the instance's enabled mods, and the pack it uses. +pub fn shader_state(dirs: &InstanceDirs) -> Result { + let mods = scan::scan(dirs, ContentKind::Mod)?; + let provenance = Provenance::load(&dirs.instance); + let loader = shaders::detect(&mods, &provenance); + let selection = match loader { + Some(loader) => shaders::read_selection(&dirs.game, loader)?, + None => ShaderSelection::default(), + }; + Ok(ShaderState { loader, selection }) +} + +/// Lists the content of `kind`. Without `api` (or when Modrinth can't be reached) files +/// are matched only from what `content.json` already knows. +pub async fn list( + api: Option<&Modrinth>, + dirs: &InstanceDirs, + target: &Target, + kind: ContentKind, +) -> Result { + let (files, provenance, lookup_error) = refresh(api, dirs, kind).await?; + let shaders = if kind == ContentKind::ShaderPack { + shader_state(dirs)? + } else { + ShaderState::default() + }; + let (pack_options, options_error) = if kind == ContentKind::ResourcePack { + match PackOptions::read(&dirs.game) { + Ok(options) => (options, None), + Err(err @ OptionsError::Unreadable { .. }) => { + (PackOptions::default(), Some(err.to_string())) + } + Err(err) => return Err(ContentError::Invalid(err.to_string())), + } + } else { + (PackOptions::default(), None) + }; + let order = pack_options.enabled_files(&target.game_version); + let items = files + .into_iter() + .map(|file| { + let entry = provenance.get(kind, &file.file_name); + let priority = order.iter().position(|name| name == &file.file_name); + let enabled = match kind { + ContentKind::Mod => file.enabled_by_name(), + ContentKind::ResourcePack => priority.is_some(), + ContentKind::ShaderPack => { + shaders.selection.pack.as_deref() == Some(file.file_name.as_str()) + } + }; + let compatibility = if kind == ContentKind::ResourcePack { + packs::compatibility(file.meta.formats.as_ref(), &target.game_version) + } else { + Compatibility::Unknown + }; + Item { + sha512: entry.map(|e| e.sha512.clone()), + modrinth: entry.and_then(|e| e.modrinth.clone()), + enabled, + priority, + compatibility, + file, + } + }) + .collect(); + Ok(Listing { + kind, + items, + lookup_error, + options_error, + shaders, + }) +} + +/// The newer versions of the listed files that came from Modrinth, by file name. +pub async fn check_updates( + api: &Modrinth, + dirs: &InstanceDirs, + target: &Target, + kind: ContentKind, +) -> Result, ContentError> { + let provenance = Provenance::load(&dirs.instance); + let files = scan::scan(dirs, kind)?; + let mut by_hash: HashMap = HashMap::new(); + for file in &files { + if let Some(entry) = provenance.current(file) + && let Some(source) = &entry.modrinth + { + by_hash.insert(entry.sha1.clone(), (file.file_name.clone(), source.clone())); + } + } + if by_hash.is_empty() { + return Ok(HashMap::new()); + } + let hashes: Vec = by_hash.keys().cloned().collect(); + let latest = api + .latest_by_sha1( + &hashes, + &target.accepted_loaders(kind), + std::slice::from_ref(&target.game_version), + ) + .await?; + let mut updates = HashMap::new(); + for (hash, version) in latest { + let Some((file_name, source)) = by_hash.get(&hash) else { + continue; + }; + if version.id != source.version_id + && version.project_id == source.project_id + && version.date_published > source.date_published + && target.incompatibility(kind, &version).is_none() + { + updates.insert(file_name.clone(), version); + } + } + Ok(updates) +} diff --git a/launcher/crates/launcher-core/src/content/loader_change.rs b/launcher/crates/launcher-core/src/content/loader_change.rs new file mode 100644 index 000000000..3aa525b3c --- /dev/null +++ b/launcher/crates/launcher-core/src/content/loader_change.rs @@ -0,0 +1,262 @@ +//! What changing an instance's mod loader does to its game folder: enabled mods made for +//! another loader stop loading, and worlds that used modded blocks or items can lose +//! them. The Settings tab asks before such a change, and can turn those mods off (with +//! `.disabled`, so it can be undone). + +use std::{cmp::Ordering, fs}; + +use lodestone_minecraft::versions::compare_versions; + +use super::{ + ContentError, ContentKind, InstanceDirs, + files::set_mod_enabled, + scan::{self, ModFormat}, +}; +use crate::instances::Loader; + +/// Whether a mod with metadata for `formats` loads on `loader` for `game_version`, or +/// `None` when the jar says nothing about its loader (unknown jars are left alone). +/// +/// Quilt loads Fabric mods. Forge reads `mods.toml` and `mcmod.info`; NeoForge reads +/// `neoforge.mods.toml`, and before 1.20.5 also `mods.toml`. +pub fn loads_on(formats: &[ModFormat], loader: Loader, game_version: &str) -> Option { + if formats.is_empty() { + return None; + } + let neoforge_reads_mods_toml = compare_versions(game_version, "1.20.5") == Ordering::Less + && game_version.starts_with("1."); + Some(formats.iter().any(|format| { + matches!( + (loader, format), + (Loader::Fabric, ModFormat::Fabric) + | (Loader::Quilt, ModFormat::Fabric | ModFormat::Quilt) + | (Loader::Forge, ModFormat::Forge) + | (Loader::NeoForge, ModFormat::NeoForge) + ) || (loader == Loader::NeoForge && *format == ModFormat::Forge && neoforge_reads_mods_toml) + })) +} + +/// A mod that won't load after the change. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MismatchedMod { + pub file_name: String, + pub name: String, + /// The loaders it is made for, such as "Fabric" or "Forge/NeoForge". + pub made_for: String, +} + +/// What a loader change would leave behind. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LoaderChangeCheck { + pub mismatched: Vec, + /// Worlds in `saves/`. + pub worlds: usize, + /// Whether the worlds may lose modded blocks: the change is away from a mod loader. + pub worlds_at_risk: bool, +} + +impl LoaderChangeCheck { + /// Whether the change needs the user's confirmation. + pub fn needs_confirmation(&self) -> bool { + !self.mismatched.is_empty() || (self.worlds > 0 && self.worlds_at_risk) + } +} + +fn format_label(format: ModFormat) -> &'static str { + match format { + ModFormat::Fabric => "Fabric", + ModFormat::Quilt => "Quilt", + ModFormat::Forge => "Forge", + ModFormat::NeoForge => "NeoForge", + } +} + +/// The worlds in the game folder: folders in `saves/` with a `level.dat`. +pub fn count_worlds(dirs: &InstanceDirs) -> usize { + fs::read_dir(dirs.game.join("saves")) + .map(|entries| { + entries + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter(|e| e.path().join("level.dat").is_file()) + .count() + }) + .unwrap_or(0) +} + +/// Checks a change from `from` to `to` for an instance of `game_version`. +pub fn check( + dirs: &InstanceDirs, + from: Loader, + to: Loader, + game_version: &str, +) -> Result { + if from == to { + return Ok(LoaderChangeCheck::default()); + } + let mut mismatched = Vec::new(); + for file in scan::scan(dirs, ContentKind::Mod)? { + if !file.enabled_by_name() { + continue; + } + let fits = match to { + // Vanilla loads no mods at all. + Loader::None => (!file.meta.mod_formats.is_empty()).then_some(false), + _ => loads_on(&file.meta.mod_formats, to, game_version), + }; + if fits == Some(false) { + let mut made_for: Vec<&str> = file + .meta + .mod_formats + .iter() + .map(|f| format_label(*f)) + .collect(); + made_for.dedup(); + mismatched.push(MismatchedMod { + name: file.display_name(), + made_for: made_for.join("/"), + file_name: file.file_name, + }); + } + } + Ok(LoaderChangeCheck { + mismatched, + worlds: count_worlds(dirs), + worlds_at_risk: from != Loader::None, + }) +} + +/// Turns the mods off by adding `.disabled`. Returns the new names. +pub fn disable_mods( + dirs: &InstanceDirs, + file_names: &[String], +) -> Result, ContentError> { + file_names + .iter() + .map(|name| set_mod_enabled(dirs, name, false)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::scan::tests::write_zip; + + #[test] + fn mismatch_matrix() { + use Loader::{Fabric, Forge, NeoForge, Quilt}; + use ModFormat as F; + let cases: &[(&[ModFormat], Loader, &str, Option)] = &[ + (&[F::Fabric], Fabric, "1.20.1", Some(true)), + (&[F::Fabric], Quilt, "1.20.1", Some(true)), + (&[F::Quilt], Quilt, "1.20.1", Some(true)), + (&[F::Quilt], Fabric, "1.20.1", Some(false)), + (&[F::Fabric], Forge, "1.20.1", Some(false)), + (&[F::Fabric], NeoForge, "1.21.1", Some(false)), + (&[F::Forge], Fabric, "1.20.1", Some(false)), + (&[F::Forge], Forge, "1.20.1", Some(true)), + (&[F::Forge], NeoForge, "1.21.1", Some(false)), + (&[F::Forge], NeoForge, "1.20.4", Some(true)), + (&[F::NeoForge], NeoForge, "1.21.1", Some(true)), + (&[F::NeoForge], Forge, "1.21.1", Some(false)), + (&[F::NeoForge, F::Forge], Forge, "1.21.1", Some(true)), + (&[F::Fabric, F::Forge], Forge, "1.20.1", Some(true)), + (&[], Fabric, "1.20.1", None), + (&[], Forge, "1.20.1", None), + ]; + for (formats, loader, version, expected) in cases { + assert_eq!( + loads_on(formats, *loader, version), + *expected, + "{formats:?} on {loader:?} {version}" + ); + } + } + + fn setup() -> (tempfile::TempDir, InstanceDirs) { + let dir = tempfile::tempdir().unwrap(); + let dirs = InstanceDirs::new(dir.path()); + let mods = dirs.content_dir(ContentKind::Mod); + write_zip( + &mods.join("sodium.jar"), + &[("fabric.mod.json", br#"{"id": "sodium", "name": "Sodium"}"#)], + ); + write_zip( + &mods.join("qsl.jar"), + &[("quilt.mod.json", br#"{"quilt_loader": {"id": "qsl"}}"#)], + ); + write_zip( + &mods.join("jei.jar"), + &[( + "META-INF/mods.toml", + b"[[mods]]\nmodId=\"jei\"\ndisplayName=\"JEI\"\n", + )], + ); + write_zip( + &mods.join("off.jar.disabled"), + &[("fabric.mod.json", br#"{"id": "off"}"#)], + ); + write_zip(&mods.join("mystery.jar"), &[("a.class", b"")]); + fs::write(mods.join("broken.jar"), b"not a zip").unwrap(); + (dir, dirs) + } + + fn names(check: &LoaderChangeCheck) -> Vec<&str> { + check + .mismatched + .iter() + .map(|m| m.file_name.as_str()) + .collect() + } + + #[test] + fn lists_the_enabled_mods_that_stop_loading() { + let (_dir, dirs) = setup(); + let to_forge = check(&dirs, Loader::Fabric, Loader::Forge, "1.20.1").unwrap(); + assert_eq!(names(&to_forge), ["qsl.jar", "sodium.jar"]); + assert_eq!(to_forge.mismatched[1].name, "Sodium"); + assert_eq!(to_forge.mismatched[1].made_for, "Fabric"); + let to_fabric = check(&dirs, Loader::Forge, Loader::Fabric, "1.20.1").unwrap(); + assert_eq!(names(&to_fabric), ["jei.jar", "qsl.jar"]); + let to_quilt = check(&dirs, Loader::Fabric, Loader::Quilt, "1.20.1").unwrap(); + assert_eq!(names(&to_quilt), ["jei.jar"]); + let to_vanilla = check(&dirs, Loader::Fabric, Loader::None, "1.20.1").unwrap(); + assert_eq!(names(&to_vanilla), ["jei.jar", "qsl.jar", "sodium.jar"]); + assert_eq!( + check(&dirs, Loader::Fabric, Loader::Fabric, "1.20.1").unwrap(), + LoaderChangeCheck::default() + ); + } + + #[test] + fn worlds_and_turning_mods_off() { + let (_dir, dirs) = setup(); + let check_now = |from, to| check(&dirs, from, to, "1.20.1").unwrap(); + assert_eq!(check_now(Loader::Fabric, Loader::Forge).worlds, 0); + fs::create_dir_all(dirs.game.join("saves/World")).unwrap(); + fs::write(dirs.game.join("saves/World/level.dat"), b"x").unwrap(); + fs::create_dir_all(dirs.game.join("saves/not a world")).unwrap(); + let away = check_now(Loader::Fabric, Loader::None); + assert_eq!((away.worlds, away.worlds_at_risk), (1, true)); + // from vanilla to a loader, worlds lose nothing + let to_fabric = check_now(Loader::None, Loader::Fabric); + assert!(!to_fabric.worlds_at_risk); + assert!( + to_fabric + .mismatched + .iter() + .all(|m| m.file_name != "sodium.jar") + ); + + let changed = disable_mods(&dirs, &["sodium.jar".into(), "qsl.jar".into()]).unwrap(); + assert_eq!(changed, ["sodium.jar.disabled", "qsl.jar.disabled"]); + assert_eq!( + names(&check_now(Loader::Fabric, Loader::Forge)), + Vec::<&str>::new() + ); + assert!( + check_now(Loader::Fabric, Loader::Forge).needs_confirmation(), + "the world still asks" + ); + } +} diff --git a/launcher/crates/launcher-core/src/content/mod.rs b/launcher/crates/launcher-core/src/content/mod.rs new file mode 100644 index 000000000..0614e48ae --- /dev/null +++ b/launcher/crates/launcher-core/src/content/mod.rs @@ -0,0 +1,456 @@ +//! An instance's content: mods (`mods/`), resource packs (`resourcepacks/`) and shader +//! packs (`shaderpacks/`), all in the instance's game folder. +//! +//! - [`scan`] lists what is there, with the metadata that is cheap to read; +//! - [`provenance`] remembers which Modrinth version each file came from (`content.json`); +//! - [`modrinth`] is the Modrinth API client, [`install`] installs versions from it with +//! their dependencies, and [`files`] adds, enables, disables and deletes files; +//! - [`options`] edits the resource packs in `options.txt`, [`packs`] reads `pack.mcmeta`, +//! and [`shaders`] finds the shader mod and edits its selected pack. +//! +//! Nothing here checks whether the game is running; the app refuses changes then, since +//! the game rewrites `options.txt` and the shader configs when it exits. + +pub mod files; +pub mod install; +pub mod listing; +pub mod loader_change; +pub mod modrinth; +pub mod options; +pub mod packs; +pub mod provenance; +pub mod scan; +pub mod shaders; + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use lodestone_minecraft::modrinth::{ClientProjectType, RawVersion}; + +use crate::{game::download::DownloadError, instances::Loader}; + +pub use modrinth::{Modrinth, ModrinthError}; +pub use shaders::ShaderLoader; + +/// The kinds of content an instance holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ContentKind { + Mod, + ResourcePack, + ShaderPack, +} + +impl ContentKind { + pub const ALL: [ContentKind; 3] = [ + ContentKind::Mod, + ContentKind::ResourcePack, + ContentKind::ShaderPack, + ]; + + /// The folder in the game folder. + pub fn dir(self) -> &'static str { + match self { + ContentKind::Mod => "mods", + ContentKind::ResourcePack => "resourcepacks", + ContentKind::ShaderPack => "shaderpacks", + } + } + + pub fn label(self) -> &'static str { + match self { + ContentKind::Mod => "Mods", + ContentKind::ResourcePack => "Resource packs", + ContentKind::ShaderPack => "Shaders", + } + } + + /// "mod", "resource pack" or "shader pack". + pub fn noun(self) -> &'static str { + match self { + ContentKind::Mod => "mod", + ContentKind::ResourcePack => "resource pack", + ContentKind::ShaderPack => "shader pack", + } + } + + pub fn project_type(self) -> ClientProjectType { + match self { + ContentKind::Mod => ClientProjectType::Mod, + ContentKind::ResourcePack => ClientProjectType::ResourcePack, + ContentKind::ShaderPack => ClientProjectType::Shader, + } + } + + pub fn from_project_type(project_type: &str) -> Option { + Some(match ClientProjectType::from_project_type(project_type)? { + ClientProjectType::Mod => ContentKind::Mod, + ClientProjectType::ResourcePack => ContentKind::ResourcePack, + ClientProjectType::Shader => ContentKind::ShaderPack, + }) + } + + /// The extension of the files Modrinth hands out for this kind. + pub fn extension(self) -> &'static str { + match self { + ContentKind::Mod => ".jar", + ContentKind::ResourcePack | ContentKind::ShaderPack => ".zip", + } + } + + /// Whether a folder (rather than an archive) can be this kind of content. + pub fn allows_folders(self) -> bool { + !matches!(self, ContentKind::Mod) + } +} + +/// The suffix of a disabled mod, the same convention as the dashboard and other launchers. +pub const DISABLED_SUFFIX: &str = ".disabled"; + +/// Files bigger than this are never downloaded or added. +pub const MAX_FILE_BYTES: u64 = 512 * 1024 * 1024; + +/// Whether a mod file name is disabled (`.jar.disabled`, ignoring case). +pub fn is_disabled_name(name: &str) -> bool { + name.to_ascii_lowercase().ends_with(".jar.disabled") +} + +/// The name of a file without `.disabled`. +pub fn enabled_name(name: &str) -> String { + if is_disabled_name(name) { + name[..name.len() - DISABLED_SUFFIX.len()].to_string() + } else { + name.to_string() + } +} + +/// The name of a mod file with `.disabled`. +pub fn disabled_name(name: &str) -> String { + if is_disabled_name(name) { + name.to_string() + } else { + format!("{name}{DISABLED_SUFFIX}") + } +} + +/// Whether `name` is a single plain file or folder name: no separators, `..`, NUL, `:`, +/// control characters or leading dot, and not too long. +pub fn is_plain_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 255 + && !name.contains(['/', '\\', ':', '\0']) + && !name.contains("..") + && !name.starts_with('.') + && name.trim() == name + && !name.chars().any(|c| c.is_control()) +} + +/// Whether `name` is a file name the content list shows and changes, for `kind`. +/// `is_dir` is whether it names a folder (packs only). +pub fn is_content_name(kind: ContentKind, name: &str, is_dir: bool) -> bool { + if !is_plain_name(name) { + return false; + } + let lower = name.to_ascii_lowercase(); + match kind { + ContentKind::Mod => { + !is_dir + && (lower.ends_with(".jar") || lower.ends_with(".jar.disabled")) + && enabled_name(name).len() > ".jar".len() + } + ContentKind::ResourcePack | ContentKind::ShaderPack => { + is_dir || (lower.ends_with(".zip") && name.len() > ".zip".len()) + } + } +} + +/// A plain file name for a file Modrinth calls `name`, or `None` if none can be made: the +/// last path segment, without characters that can't be in file names, and with the +/// kind's extension. +pub fn safe_download_name(kind: ContentKind, name: &str) -> Option { + let base = name.rsplit(['/', '\\']).next().unwrap_or_default(); + let cleaned: String = base + .chars() + .filter(|c| !c.is_control() && !matches!(c, ':' | '*' | '?' | '"' | '<' | '>' | '|')) + .collect(); + let cleaned = cleaned.trim().trim_start_matches('.').trim().to_string(); + let lower = cleaned.to_ascii_lowercase(); + (lower.ends_with(kind.extension()) && is_content_name(kind, &cleaned, false)).then_some(cleaned) +} + +/// What an instance accepts: its Minecraft version, loader and shader mod. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub game_version: String, + pub loader: Loader, + /// The shader mod installed, if any; it decides which shader packs fit. + pub shader_loader: Option, +} + +impl Target { + pub fn new(game_version: impl Into, loader: Loader) -> Self { + Self { + game_version: game_version.into(), + loader, + shader_loader: None, + } + } + + /// The Modrinth loaders whose files fit, for `kind`. Quilt runs Fabric mods too. + pub fn accepted_loaders(&self, kind: ContentKind) -> Vec { + let list: &[&str] = match kind { + ContentKind::Mod => match self.loader { + Loader::None => &[], + Loader::Fabric => &["fabric"], + Loader::Quilt => &["quilt", "fabric"], + Loader::Forge => &["forge"], + Loader::NeoForge => &["neoforge"], + }, + ContentKind::ResourcePack => &["minecraft"], + ContentKind::ShaderPack => match self.shader_loader { + Some(ShaderLoader::OptiFine) => &["optifine"], + // Iris (and so Oculus) reads OptiFine-format packs too. + Some(ShaderLoader::Iris | ShaderLoader::Oculus) | None => &["iris", "optifine"], + }, + }; + list.iter().map(|l| l.to_string()).collect() + } + + /// The loaders to search with by default. For shader packs, only the instance's shader + /// loader's own tag (`iris`, or `optifine`), since nearly every pack lists both. + pub fn search_loaders(&self, kind: ContentKind) -> Vec { + match kind { + ContentKind::ShaderPack => vec![match self.shader_loader { + Some(ShaderLoader::OptiFine) => "optifine".to_string(), + _ => "iris".to_string(), + }], + // Resource packs are all tagged `minecraft`; the filter adds nothing. + ContentKind::ResourcePack => Vec::new(), + ContentKind::Mod => self.accepted_loaders(kind), + } + } + + /// Why `version` doesn't fit this instance as `kind`, or `None` if it does. + pub fn incompatibility(&self, kind: ContentKind, version: &RawVersion) -> Option { + fn list(values: &[String]) -> String { + let mut shown: Vec<&str> = values.iter().take(5).map(String::as_str).collect(); + if values.len() > shown.len() { + shown.push("…"); + } + if shown.is_empty() { + "nothing".to_string() + } else { + shown.join(", ") + } + } + let accepted = self.accepted_loaders(kind); + if kind == ContentKind::Mod && accepted.is_empty() { + return Some("Mods need a mod loader, and this instance is vanilla".to_string()); + } + if !version.loaders.iter().any(|l| accepted.contains(l)) { + let uses = match kind { + ContentKind::Mod => self.loader.label().to_string(), + ContentKind::ShaderPack => self + .shader_loader + .map_or("Iris".to_string(), |l| l.label().to_string()), + ContentKind::ResourcePack => "Minecraft".to_string(), + }; + return Some(format!( + "{} {} is for {}, but this instance uses {uses}", + version.name, + version.version_number, + list(&version.loaders), + )); + } + if !version.game_versions.contains(&self.game_version) { + return Some(format!( + "{} {} is for Minecraft {}, but this instance runs {}", + version.name, + version.version_number, + list(&version.game_versions), + self.game_version + )); + } + None + } +} + +/// Where an instance's content lives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstanceDirs { + /// The instance's folder, with `instance.json` and `content.json`. + pub instance: PathBuf, + /// The game folder, `minecraft/`. + pub game: PathBuf, +} + +impl InstanceDirs { + pub fn new(instance: impl Into) -> Self { + let instance = instance.into(); + let game = instance.join("minecraft"); + Self { instance, game } + } + + pub fn content_dir(&self, kind: ContentKind) -> PathBuf { + self.game.join(kind.dir()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ContentError { + #[error("{path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error(transparent)] + Modrinth(#[from] ModrinthError), + #[error("{0}")] + Invalid(String), + #[error("{0}")] + Conflict(String), + #[error("{0}")] + NotFound(String), + #[error(transparent)] + Download(#[from] DownloadError), +} + +pub(crate) fn io_error(path: &Path) -> impl FnOnce(io::Error) -> ContentError + '_ { + move |source| ContentError::Io { + path: path.to_owned(), + source, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names() { + assert!(is_content_name(ContentKind::Mod, "sodium.jar", false)); + assert!(is_content_name( + ContentKind::Mod, + "Sodium.JAR.disabled", + false + )); + assert!(!is_content_name(ContentKind::Mod, ".jar", false)); + assert!(!is_content_name(ContentKind::Mod, "a.zip", false)); + assert!(!is_content_name(ContentKind::Mod, "folder", true)); + assert!(is_content_name( + ContentKind::ResourcePack, + "Faithful 32x.zip", + false + )); + assert!(is_content_name(ContentKind::ResourcePack, "My pack", true)); + assert!(is_content_name( + ContentKind::ShaderPack, + "BSL_v8.zip", + false + )); + for bad in [ + "", + "../x.zip", + "a/b.zip", + "a\\b.zip", + ".hidden.zip", + "c:x.zip", + "a\0.zip", + " x.zip", + "line\nbreak.zip", + ] { + assert!( + !is_content_name(ContentKind::ResourcePack, bad, false), + "{bad:?}" + ); + } + assert_eq!(enabled_name("a.jar.disabled"), "a.jar"); + assert_eq!(disabled_name("a.jar"), "a.jar.disabled"); + assert_eq!(disabled_name("a.jar.disabled"), "a.jar.disabled"); + } + + #[test] + fn download_names() { + assert_eq!( + safe_download_name(ContentKind::Mod, "sodium-fabric-0.5.13+mc1.20.1.jar").as_deref(), + Some("sodium-fabric-0.5.13+mc1.20.1.jar") + ); + assert_eq!( + safe_download_name(ContentKind::Mod, "../../evil/../x.jar").as_deref(), + Some("x.jar") + ); + assert_eq!( + safe_download_name(ContentKind::ShaderPack, "C:\\x\\BSL?.zip").as_deref(), + Some("BSL.zip") + ); + assert_eq!(safe_download_name(ContentKind::Mod, "x.zip"), None); + assert_eq!(safe_download_name(ContentKind::Mod, "..jar"), None); + assert_eq!(safe_download_name(ContentKind::ResourcePack, "..."), None); + assert_eq!( + safe_download_name(ContentKind::ResourcePack, "a..b.zip"), + None + ); + } + + fn version(loaders: &[&str], game_versions: &[&str]) -> RawVersion { + serde_json::from_value(serde_json::json!({ + "id": "v", "project_id": "p", "name": "P", "version_number": "1.0", + "version_type": "release", "loaders": loaders, "game_versions": game_versions, + })) + .unwrap() + } + + #[test] + fn compatibility() { + let quilt = Target::new("1.20.1", Loader::Quilt); + assert_eq!( + quilt.incompatibility(ContentKind::Mod, &version(&["fabric"], &["1.20.1"])), + None + ); + let forge = Target::new("1.20.1", Loader::Forge); + assert!( + forge + .incompatibility(ContentKind::Mod, &version(&["fabric"], &["1.20.1"])) + .unwrap() + .contains("is for fabric, but this instance uses Forge") + ); + assert!( + forge + .incompatibility(ContentKind::Mod, &version(&["forge"], &["1.19.2"])) + .unwrap() + .contains("is for Minecraft 1.19.2, but this instance runs 1.20.1") + ); + let vanilla = Target::new("1.21.1", Loader::None); + assert!( + vanilla + .incompatibility(ContentKind::Mod, &version(&["fabric"], &["1.21.1"])) + .is_some() + ); + assert_eq!( + vanilla.incompatibility( + ContentKind::ResourcePack, + &version(&["minecraft"], &["1.21.1"]) + ), + None + ); + let mut iris = Target::new("1.21.1", Loader::Fabric); + assert_eq!( + iris.incompatibility( + ContentKind::ShaderPack, + &version(&["optifine"], &["1.21.1"]) + ), + None + ); + iris.shader_loader = Some(ShaderLoader::OptiFine); + assert!( + iris.incompatibility(ContentKind::ShaderPack, &version(&["iris"], &["1.21.1"])) + .is_some() + ); + assert_eq!(iris.search_loaders(ContentKind::ShaderPack), ["optifine"]); + iris.shader_loader = None; + assert_eq!(iris.search_loaders(ContentKind::ShaderPack), ["iris"]); + assert_eq!(quilt.search_loaders(ContentKind::Mod), ["quilt", "fabric"]); + } +} diff --git a/launcher/crates/launcher-core/src/content/modrinth.rs b/launcher/crates/launcher-core/src/content/modrinth.rs new file mode 100644 index 000000000..e154c6a4e --- /dev/null +++ b/launcher/crates/launcher-core/src/content/modrinth.rs @@ -0,0 +1,528 @@ +//! The Modrinth API (), v2, which needs no key. +//! +//! Requests carry [`USER_AGENT`], time out after [`REQUEST_TIMEOUT`] and read at most +//! [`MAX_BODY_BYTES`]. Answers are cached in memory ([`CACHE_TTL`], hash lookups +//! [`HASH_CACHE_TTL`]), up to [`CACHE_ENTRIES`]. Modrinth allows 300 requests a minute per +//! IP: once it says none are left (`X-Ratelimit-Remaining: 0`, or a 429), calls fail at +//! once until `X-Ratelimit-Reset` has passed, rather than retrying. +//! +//! Files are only downloaded from `https://cdn.modrinth.com` ([`Modrinth::cdn_url`]); +//! tests serve that host from a loopback mirror, like the Mojang URLs. + +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use futures::AsyncReadExt as _; +use http_client::{AsyncBody, HttpClient, HttpRequestExt as _, Method, RedirectPolicy, Request}; +use lodestone_minecraft::modrinth::{ + CDN_HOST, ClientSearchParams, ModrinthProject, ModrinthSearchResponse, RawProject, RawVersion, + links::{check_api_segment, parse_modrinth_url}, +}; +use serde_json::json; +use url::Url; + +/// The User-Agent Modrinth asks API clients to send: the app and where to find it. +pub const USER_AGENT: &str = concat!( + "Lodestone/", + env!("CARGO_PKG_VERSION"), + " (https://github.com/Lodestone-Team/lodestone)" +); +pub const API_BASE: &str = "https://api.modrinth.com/v2"; +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; +/// Icons bigger than this are not shown. +pub const MAX_IMAGE_BYTES: usize = 2 * 1024 * 1024; +pub const CACHE_TTL: Duration = Duration::from_secs(5 * 60); +pub const HASH_CACHE_TTL: Duration = Duration::from_secs(60 * 60); +pub const CACHE_ENTRIES: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ModrinthError { + #[error("{0} isn't on Modrinth")] + NotFound(String), + #[error("Modrinth asks to slow down; try again in {seconds} seconds")] + RateLimited { seconds: u64 }, + #[error("Modrinth isn't reachable: {0}")] + Unreachable(String), + #[error("{0}")] + Invalid(String), +} + +impl From for ModrinthError { + fn from(err: lodestone_minecraft::Error) -> Self { + ModrinthError::Invalid(err.to_string()) + } +} + +/// What a modrinth.com link points to. +#[derive(Clone, Debug)] +pub struct LinkTarget { + pub project: ModrinthProject, + pub version: Option, +} + +#[derive(Default)] +struct Cache { + entries: HashMap>)>, + order: VecDeque, +} + +impl Cache { + fn get(&mut self, key: &str) -> Option>> { + let (stored, ttl, body) = self.entries.get(key)?; + if stored.elapsed() < *ttl { + return Some(body.clone()); + } + self.entries.remove(key); + self.order.retain(|k| k != key); + None + } + + fn put(&mut self, key: String, ttl: Duration, body: Arc>) { + if self + .entries + .insert(key.clone(), (Instant::now(), ttl, body)) + .is_none() + { + self.order.push_back(key); + } + while self.order.len() > CACHE_ENTRIES { + if let Some(old) = self.order.pop_front() { + self.entries.remove(&old); + } + } + } +} + +/// The Modrinth client. Share it with an `Arc`. +pub struct Modrinth { + http: Arc, + api: Url, + /// Where `https://cdn.modrinth.com/` is fetched from in tests. + cdn_mirror: Option, + cache: Mutex, + /// Calls fail at once until then. + blocked_until: Mutex>, + requests: std::sync::atomic::AtomicU64, +} + +impl Modrinth { + pub fn production(http: Arc) -> Self { + Self::with_urls(http, Url::parse(API_BASE).expect("a valid URL"), None) + } + + /// A client for `api` (instead of [`API_BASE`]) serving the CDN from + /// `/cdn.modrinth.com/`, for tests. + pub fn with_urls(http: Arc, api: Url, cdn_mirror: Option) -> Self { + Self { + http, + api, + cdn_mirror, + cache: Mutex::default(), + blocked_until: Mutex::new(None), + requests: Default::default(), + } + } + + /// The number of requests sent, for tests of the cache and rate limit. + pub fn requests_sent(&self) -> u64 { + self.requests.load(std::sync::atomic::Ordering::Relaxed) + } + + fn url(&self, path: &str) -> String { + crate::http::join(&self.api, path) + } + + fn check_blocked(&self) -> Result<(), ModrinthError> { + let blocked = self.blocked_until.lock().unwrap_or_else(|p| p.into_inner()); + match *blocked { + Some(until) if until > Instant::now() => Err(ModrinthError::RateLimited { + seconds: until + .saturating_duration_since(Instant::now()) + .as_secs() + .max(1), + }), + _ => Ok(()), + } + } + + fn block_for(&self, seconds: u64) { + *self.blocked_until.lock().unwrap_or_else(|p| p.into_inner()) = + Some(Instant::now() + Duration::from_secs(seconds)); + } + + async fn send( + &self, + method: Method, + url: &str, + body: Option>, + limit: usize, + ) -> Result, ModrinthError> { + self.check_blocked()?; + let mut builder = Request::builder() + .method(method) + .uri(url) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + .follow_redirects(RedirectPolicy::NoFollow) + .timeout(REQUEST_TIMEOUT); + if body.is_some() { + builder = builder.header("Content-Type", "application/json"); + } + let request = builder + .body(body.map_or_else(AsyncBody::empty, AsyncBody::from)) + .map_err(|err| ModrinthError::Invalid(err.to_string()))?; + self.requests + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let http = self.http.clone(); + crate::http::in_http_runtime(async move { + let response = http + .send(request) + .await + .map_err(|err| ModrinthError::Unreachable(format!("{err:#}")))?; + let status = response.status().as_u16(); + let header = |name: &str| { + response + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + }; + let reset = header("x-ratelimit-reset").unwrap_or(60).clamp(1, 3600); + if status == 429 { + self.block_for(reset); + return Err(ModrinthError::RateLimited { seconds: reset }); + } + if header("x-ratelimit-remaining") == Some(0) { + self.block_for(reset); + } + let mut body = Vec::new(); + response + .into_body() + .take(limit as u64 + 1) + .read_to_end(&mut body) + .await + .map_err(|err| ModrinthError::Unreachable(err.to_string()))?; + match status { + 200..=299 if body.len() > limit => Err(ModrinthError::Invalid(format!( + "Modrinth's answer was larger than {} MB", + limit / (1024 * 1024) + ))), + 200..=299 => Ok(body), + 404 | 410 => Err(ModrinthError::NotFound("That".into())), + 400..=499 => Err(ModrinthError::Invalid(format!( + "Modrinth refused the request (HTTP {status})" + ))), + _ => Err(ModrinthError::Unreachable(format!("HTTP {status}"))), + } + }) + .await + } + + async fn cached( + &self, + method: Method, + url: String, + body: Option, + ttl: Duration, + ) -> Result>, ModrinthError> { + let body = body.map(|b| serde_json::to_vec(&b).unwrap_or_default()); + let key = format!( + "{method} {url} {}", + body.as_deref() + .map(String::from_utf8_lossy) + .unwrap_or_default() + ); + if let Some(hit) = self + .cache + .lock() + .unwrap_or_else(|p| p.into_inner()) + .get(&key) + { + return Ok(hit); + } + let bytes = Arc::new(self.send(method, &url, body, MAX_BODY_BYTES).await?); + self.cache + .lock() + .unwrap_or_else(|p| p.into_inner()) + .put(key, ttl, bytes.clone()); + Ok(bytes) + } + + async fn get_json( + &self, + url: String, + what: &str, + ) -> Result { + let bytes = self + .cached(Method::GET, url, None, CACHE_TTL) + .await + .map_err(|err| named(err, what))?; + parse(&bytes) + } + + pub async fn search( + &self, + params: &ClientSearchParams, + ) -> Result { + let mut url = Url::parse(&self.url("search")) + .map_err(|err| ModrinthError::Invalid(err.to_string()))?; + url.query_pairs_mut().extend_pairs(params.query_pairs()); + let response: ModrinthSearchResponse = self.get_json(url.into(), "The search").await?; + Ok(response.normalize()) + } + + pub async fn project(&self, id_or_slug: &str) -> Result { + let id = check_api_segment(id_or_slug, "project id")?; + let raw: RawProject = self + .get_json(self.url(&format!("project/{id}")), id) + .await?; + Ok(raw.into()) + } + + /// Several projects by id, in one request per 100. + pub async fn projects(&self, ids: &[String]) -> Result, ModrinthError> { + let mut projects = Vec::new(); + for chunk in ids.chunks(100) { + for id in chunk { + check_api_segment(id, "project id")?; + } + let mut url = Url::parse(&self.url("projects")) + .map_err(|err| ModrinthError::Invalid(err.to_string()))?; + url.query_pairs_mut() + .append_pair("ids", &serde_json::to_string(chunk).unwrap_or_default()); + let raw: Vec = self.get_json(url.into(), "The projects").await?; + projects.extend(raw.into_iter().map(ModrinthProject::from)); + } + Ok(projects) + } + + /// The versions of a project for any of `loaders` and `game_versions` (no filter when + /// empty), newest first. + pub async fn versions( + &self, + project: &str, + loaders: &[String], + game_versions: &[String], + ) -> Result, ModrinthError> { + let id = check_api_segment(project, "project id")?; + let mut url = Url::parse(&self.url(&format!("project/{id}/version"))) + .map_err(|err| ModrinthError::Invalid(err.to_string()))?; + if !loaders.is_empty() { + url.query_pairs_mut().append_pair( + "loaders", + &serde_json::to_string(loaders).unwrap_or_default(), + ); + } + if !game_versions.is_empty() { + url.query_pairs_mut().append_pair( + "game_versions", + &serde_json::to_string(game_versions).unwrap_or_default(), + ); + } + let mut versions: Vec = self.get_json(url.into(), id).await?; + sort_newest_first(&mut versions); + Ok(versions) + } + + pub async fn version(&self, id: &str) -> Result { + let id = check_api_segment(id, "version id")?; + self.get_json(self.url(&format!("version/{id}")), "That version") + .await + } + + /// A version of `project` by id or version number. + pub async fn project_version( + &self, + project: &str, + version: &str, + ) -> Result { + let project = check_api_segment(project, "project id")?; + let version = check_api_segment(version, "version")?; + self.get_json( + self.url(&format!("project/{project}/version/{version}")), + "That version", + ) + .await + } + + async fn hash_post( + &self, + path: &str, + hashes: &[String], + extra: serde_json::Value, + ) -> Result, ModrinthError> { + let mut found = HashMap::new(); + let hashes: Vec = hashes + .iter() + .map(|h| h.to_ascii_lowercase()) + .filter(|h| h.len() == 40 && h.chars().all(|c| c.is_ascii_hexdigit())) + .collect(); + for chunk in hashes.chunks(200) { + let mut body = json!({ "hashes": chunk, "algorithm": "sha1" }); + if let (Some(body), Some(extra)) = (body.as_object_mut(), extra.as_object()) { + body.extend(extra.clone()); + } + let bytes = self + .cached(Method::POST, self.url(path), Some(body), HASH_CACHE_TTL) + .await?; + let map: HashMap = parse(&bytes)?; + found.extend(map.into_iter().map(|(k, v)| (k.to_ascii_lowercase(), v))); + } + Ok(found) + } + + /// The versions files with these SHA-1 hashes belong to, by hash. Unknown hashes are + /// left out. + pub async fn versions_by_sha1( + &self, + hashes: &[String], + ) -> Result, ModrinthError> { + self.hash_post("version_files", hashes, json!({})).await + } + + /// The newest version for `loaders` and `game_versions` of the projects these files + /// belong to, by hash. + pub async fn latest_by_sha1( + &self, + hashes: &[String], + loaders: &[String], + game_versions: &[String], + ) -> Result, ModrinthError> { + self.hash_post( + "version_files/update", + hashes, + json!({ "loaders": loaders, "game_versions": game_versions }), + ) + .await + } + + /// The project (and version) a pasted modrinth.com link points to. + pub async fn resolve_link(&self, link: &str) -> Result { + let parsed = parse_modrinth_url(link)?; + let project = self.project(&parsed.project).await?; + let version = match &parsed.version { + Some(version) => Some(self.project_version(&project.id, version).await?), + None => None, + }; + Ok(LinkTarget { project, version }) + } + + /// The URL to download a Modrinth file from, if it is an HTTPS URL on + /// `cdn.modrinth.com`. + pub fn cdn_url(&self, url: &str) -> Result { + let parsed = + Url::parse(url).map_err(|_| ModrinthError::Invalid(format!("{url} isn't a URL")))?; + if parsed.scheme() != "https" + || parsed.host_str() != Some(CDN_HOST) + || parsed.port().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(ModrinthError::Invalid(format!( + "Refusing to download from {}: files only come from {CDN_HOST}", + parsed.host_str().unwrap_or("an unknown host") + ))); + } + let path = match parsed.query() { + Some(query) => format!("{}?{query}", parsed.path()), + None => parsed.path().to_string(), + }; + Ok(match &self.cdn_mirror { + None => format!("https://{CDN_HOST}{path}"), + Some(base) => format!("{}/{CDN_HOST}{path}", base.as_str().trim_end_matches('/')), + }) + } + + /// The bytes of an image on Modrinth's CDN (project icons), at most + /// [`MAX_IMAGE_BYTES`]. + pub async fn image(&self, url: &str) -> Result, ModrinthError> { + let url = self.cdn_url(url)?; + self.send(Method::GET, &url, None, MAX_IMAGE_BYTES).await + } +} + +fn named(err: ModrinthError, what: &str) -> ModrinthError { + match err { + ModrinthError::NotFound(_) => ModrinthError::NotFound(what.to_string()), + other => other, + } +} + +fn parse(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|err| { + ModrinthError::Invalid(format!("Modrinth sent an answer that can't be read: {err}")) + }) +} + +/// Sorts versions newest first, by their publication time. +pub fn sort_newest_first(versions: &mut [RawVersion]) { + versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_agent_names_lodestone() { + assert_eq!( + USER_AGENT, + format!( + "Lodestone/{} (https://github.com/Lodestone-Team/lodestone)", + env!("CARGO_PKG_VERSION") + ) + ); + } + + #[test] + fn only_the_cdn_is_downloaded_from() { + let http = crate::http::default_client().unwrap(); + let modrinth = Modrinth::production(http.clone()); + assert_eq!( + modrinth + .cdn_url("https://cdn.modrinth.com/data/AANobbMI/versions/x/sodium.jar") + .unwrap(), + "https://cdn.modrinth.com/data/AANobbMI/versions/x/sodium.jar" + ); + for bad in [ + "http://cdn.modrinth.com/data/x.jar", + "https://cdn.modrinth.com.evil.com/x.jar", + "https://evil.com/cdn.modrinth.com/x.jar", + "https://user@cdn.modrinth.com/x.jar", + "https://cdn.modrinth.com:444/x.jar", + "https://github.com/x.jar", + "file:///etc/passwd", + "not a url", + ] { + assert!(modrinth.cdn_url(bad).is_err(), "{bad}"); + } + let mirror = Modrinth::with_urls( + http, + Url::parse("http://127.0.0.1:1/v2").unwrap(), + Some(Url::parse("http://127.0.0.1:1/").unwrap()), + ); + assert_eq!( + mirror + .cdn_url("https://cdn.modrinth.com/data/x.jar") + .unwrap(), + "http://127.0.0.1:1/cdn.modrinth.com/data/x.jar" + ); + } + + #[test] + fn the_cache_is_bounded() { + let mut cache = Cache::default(); + for i in 0..CACHE_ENTRIES + 10 { + cache.put(i.to_string(), CACHE_TTL, Arc::new(vec![])); + } + assert_eq!(cache.entries.len(), CACHE_ENTRIES); + assert!(cache.get("0").is_none()); + assert!(cache.get(&(CACHE_ENTRIES + 9).to_string()).is_some()); + cache.put("old".into(), Duration::ZERO, Arc::new(vec![])); + assert!(cache.get("old").is_none()); + } +} diff --git a/launcher/crates/launcher-core/src/content/options.rs b/launcher/crates/launcher-core/src/content/options.rs new file mode 100644 index 000000000..dadf62649 --- /dev/null +++ b/launcher/crates/launcher-core/src/content/options.rs @@ -0,0 +1,560 @@ +//! The resource packs in `options.txt`: `resourcePacks:[…]` and +//! `incompatibleResourcePacks:[…]`, JSON lists of pack ids. +//! +//! A pack in `resourcepacks/` has the id `file/` (bare `` before 1.13). The +//! game applies the list in order, so the last pack has the highest priority. Ids that +//! aren't `file/` packs (`vanilla`, `fabric`, packs that mods provide) are kept where they +//! are. The game drops an incompatible pack from `resourcePacks` when it loads the options +//! unless the pack is also in `incompatibleResourcePacks` (where the game's "enable +//! anyway" puts it), so enabling a pack that may not fit adds it there too. +//! +//! Every other line of the file is kept as it was, byte for byte. + +use std::{fs, io, path::Path}; + +use crate::storage; + +pub const FILE: &str = "options.txt"; +pub const PACKS_KEY: &str = "resourcePacks"; +pub const INCOMPATIBLE_KEY: &str = "incompatibleResourcePacks"; + +/// `options.txt`, as lines. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OptionsFile { + lines: Vec, + newline: &'static str, + trailing_newline: bool, +} + +impl Default for OptionsFile { + fn default() -> Self { + Self { + lines: Vec::new(), + newline: "\n", + trailing_newline: true, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum OptionsError { + #[error("couldn't read or write {path}: {source}")] + Io { + path: String, + #[source] + source: io::Error, + }, + #[error("the {key} line of options.txt isn't a list Lodestone can read, so it was left alone")] + Unreadable { key: &'static str }, +} + +/// Reads a JSON list of strings. +pub fn parse_list(value: &str) -> Option> { + serde_json::from_str::>(value.trim()).ok() +} + +/// Writes a list the way the game does: a JSON list of strings without spaces. +pub fn format_list(entries: &[String]) -> String { + serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string()) +} + +impl OptionsFile { + pub fn parse(text: &str) -> Self { + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let trailing_newline = text.is_empty() || text.ends_with('\n'); + let lines = text + .lines() + .map(str::to_string) + .filter(|_| !text.is_empty()) + .collect(); + Self { + lines, + newline, + trailing_newline, + } + } + + /// Reads `path`; a missing file is an empty one. + pub fn read(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => Ok(Self::parse(&String::from_utf8_lossy(&bytes))), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Self::default()), + Err(source) => Err(OptionsError::Io { + path: path.display().to_string(), + source, + }), + } + } + + pub fn to_text(&self) -> String { + let mut text = self.lines.join(self.newline); + if self.trailing_newline && !self.lines.is_empty() { + text.push_str(self.newline); + } + text + } + + /// Writes the file atomically. + pub fn write(&self, path: &Path) -> Result<(), OptionsError> { + storage::write_atomic(path, self.to_text().as_bytes()).map_err(|source| OptionsError::Io { + path: path.display().to_string(), + source, + }) + } + + fn line_index(&self, key: &str) -> Option { + self.lines + .iter() + .position(|line| line.split_once(':').is_some_and(|(k, _)| k == key)) + } + + /// The raw value of `key`. + pub fn get(&self, key: &str) -> Option<&str> { + let index = self.line_index(key)?; + self.lines[index].split_once(':').map(|(_, value)| value) + } + + /// The list under `key`: `Ok(None)` when the line is missing. + pub fn list(&self, key: &'static str) -> Result>, OptionsError> { + match self.get(key) { + None => Ok(None), + Some(value) => parse_list(value) + .map(Some) + .ok_or(OptionsError::Unreadable { key }), + } + } + + /// Sets `key`'s line, or adds it at the end. + pub fn set(&mut self, key: &str, value: &str) { + let line = format!("{key}:{value}"); + match self.line_index(key) { + Some(index) => self.lines[index] = line, + None => self.lines.push(line), + } + } +} + +/// The id of the pack file or folder `name` in `options.txt`. +pub fn pack_id(name: &str, game_version: &str) -> String { + if uses_file_prefix(game_version) { + format!("file/{name}") + } else { + name.to_string() + } +} + +/// Whether the game names pack files `file/` (1.13 and later, and every version that +/// isn't an old release). +fn uses_file_prefix(game_version: &str) -> bool { + let mut parts = game_version.split('.'); + match ( + parts.next().and_then(|p| p.parse::().ok()), + parts.next().and_then(|p| p.parse::().ok()), + ) { + (Some(1), Some(minor)) => minor >= 13, + _ => true, + } +} + +/// The pack file name an id refers to, if it is a pack from `resourcepacks/`. Built-in and +/// mod-provided ids (`vanilla`, `fabric`, `programmer_art`, …) are `None`. +pub fn pack_name(id: &str, game_version: &str) -> Option { + match id.strip_prefix("file/") { + Some(name) => Some(name.to_string()), + None if !uses_file_prefix(game_version) && id != "vanilla" && id != "programmer_art" => { + Some(id.to_string()) + } + None => None, + } +} + +/// The resource packs as `options.txt` has them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PackOptions { + /// The ids in the game's order: the last has the highest priority. + pub enabled: Vec, + pub incompatible: Vec, + /// Whether `options.txt` has a `resourcePacks` line. + pub has_line: bool, +} + +impl PackOptions { + pub fn read(game_dir: &Path) -> Result { + let file = OptionsFile::read(&game_dir.join(FILE))?; + let enabled = file.list(PACKS_KEY)?; + Ok(Self { + has_line: enabled.is_some(), + enabled: enabled.unwrap_or_default(), + incompatible: file.list(INCOMPATIBLE_KEY)?.unwrap_or_default(), + }) + } + + /// The enabled pack files, highest priority first, as the game's screen lists them. + pub fn enabled_files(&self, game_version: &str) -> Vec { + self.enabled + .iter() + .rev() + .filter_map(|id| pack_name(id, game_version)) + .collect() + } + + fn position(&self, name: &str) -> Option { + self.enabled + .iter() + .position(|id| id == &format!("file/{name}") || id == name) + } +} + +/// How to change the pack list. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PackChange { + /// Enables a pack on top (highest priority). `compatible` is whether the pack is known + /// to fit the game version; otherwise it goes into `incompatibleResourcePacks` too. + Enable { + name: String, + compatible: bool, + }, + Disable { + name: String, + }, + /// Moves a pack one place up (higher priority) or down among the pack files. + Raise { + name: String, + }, + Lower { + name: String, + }, + /// A pack's file was replaced by one with another name. + Rename { + from: String, + to: String, + }, +} + +/// Applies `change` to the `options.txt` in `game_dir`, creating it (with only the +/// `resourcePacks` line) if it doesn't exist. Lines other than the two lists are kept. +pub fn change_packs( + game_dir: &Path, + game_version: &str, + change: &PackChange, +) -> Result { + let path = game_dir.join(FILE); + let mut file = OptionsFile::read(&path)?; + let had_line = file.list(PACKS_KEY)?.is_some(); + let mut packs = PackOptions { + enabled: file + .list(PACKS_KEY)? + .unwrap_or_else(|| vec!["vanilla".to_string()]), + incompatible: file.list(INCOMPATIBLE_KEY)?.unwrap_or_default(), + has_line: true, + }; + let had_incompatible_line = file.get(INCOMPATIBLE_KEY).is_some(); + let before = packs.clone(); + apply(&mut packs, game_version, change); + if packs == before && had_line { + return Ok(packs); + } + file.set(PACKS_KEY, &format_list(&packs.enabled)); + if had_incompatible_line || !packs.incompatible.is_empty() { + file.set(INCOMPATIBLE_KEY, &format_list(&packs.incompatible)); + } + file.write(&path)?; + Ok(packs) +} + +fn apply(packs: &mut PackOptions, game_version: &str, change: &PackChange) { + let matches = |id: &String, name: &str| id == &format!("file/{name}") || id == name; + match change { + PackChange::Enable { name, compatible } => { + let id = pack_id(name, game_version); + if packs.position(name).is_none() { + packs.enabled.push(id.clone()); + } + let listed = packs.incompatible.iter().any(|i| matches(i, name)); + if !compatible && !listed { + packs.incompatible.push(id); + } + } + PackChange::Disable { name } => { + packs.enabled.retain(|id| !matches(id, name)); + packs.incompatible.retain(|id| !matches(id, name)); + } + PackChange::Raise { name } | PackChange::Lower { name } => { + let Some(from) = packs.position(name) else { + return; + }; + let is_file = |id: &String| pack_name(id, game_version).is_some(); + let neighbour = if matches!(change, PackChange::Raise { .. }) { + (from + 1..packs.enabled.len()).find(|&i| is_file(&packs.enabled[i])) + } else { + (0..from).rev().find(|&i| is_file(&packs.enabled[i])) + }; + if let Some(to) = neighbour { + packs.enabled.swap(from, to); + } + } + PackChange::Rename { from, to } => { + let new_id = pack_id(to, game_version); + for list in [&mut packs.enabled, &mut packs.incompatible] { + for id in list.iter_mut() { + if matches(id, from) { + *id = new_id.clone(); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const OPTIONS: &str = "version:3465\r\nautoJump:false\r\nresourcePacks:[\"vanilla\",\"fabric\",\"file/Faithful 32x.zip\",\"file/My \\\"quoted\\\" pack\"]\r\nincompatibleResourcePacks:[]\r\nlang:en_us\r\nunknownKey:[weird value\r\nkey_key.jump:key.keyboard.space\r\n"; + + fn game_dir(options: Option<&str>) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + if let Some(options) = options { + fs::write(dir.path().join(FILE), options).unwrap(); + } + dir + } + + fn read(dir: &tempfile::TempDir) -> String { + fs::read_to_string(dir.path().join(FILE)).unwrap() + } + + #[test] + fn reads_packs_with_quotes_and_unknown_ids() { + let dir = game_dir(Some(OPTIONS)); + let packs = PackOptions::read(dir.path()).unwrap(); + assert_eq!( + packs.enabled, + [ + "vanilla", + "fabric", + "file/Faithful 32x.zip", + "file/My \"quoted\" pack" + ] + ); + assert_eq!( + packs.enabled_files("1.20.1"), + ["My \"quoted\" pack", "Faithful 32x.zip"] + ); + } + + #[test] + fn enable_disable_and_reorder_keep_other_lines() { + let dir = game_dir(Some(OPTIONS)); + let path = dir.path(); + change_packs( + path, + "1.20.1", + &PackChange::Enable { + name: "New, \"odd\" pack.zip".into(), + compatible: true, + }, + ) + .unwrap(); + let text = read(&dir); + assert!(text.contains( + "resourcePacks:[\"vanilla\",\"fabric\",\"file/Faithful 32x.zip\",\"file/My \\\"quoted\\\" pack\",\"file/New, \\\"odd\\\" pack.zip\"]\r\n" + )); + // every other line is kept as it was + for line in [ + "version:3465\r\n", + "autoJump:false\r\n", + "incompatibleResourcePacks:[]\r\n", + "lang:en_us\r\n", + "unknownKey:[weird value\r\n", + "key_key.jump:key.keyboard.space\r\n", + ] { + assert!(text.contains(line), "{line:?} in {text:?}"); + } + assert!(text.ends_with("\r\n")); + assert_eq!(text.lines().count(), OPTIONS.lines().count()); + + // lowering the top pack swaps it with the next pack file; vanilla and fabric stay + let packs = change_packs( + path, + "1.20.1", + &PackChange::Lower { + name: "New, \"odd\" pack.zip".into(), + }, + ) + .unwrap(); + assert_eq!( + packs.enabled_files("1.20.1"), + [ + "My \"quoted\" pack", + "New, \"odd\" pack.zip", + "Faithful 32x.zip" + ] + ); + let packs = change_packs( + path, + "1.20.1", + &PackChange::Lower { + name: "Faithful 32x.zip".into(), + }, + ) + .unwrap(); + // already the lowest pack file: nothing moves past fabric or vanilla + assert_eq!(&packs.enabled[..2], ["vanilla", "fabric"]); + let packs = change_packs( + path, + "1.20.1", + &PackChange::Raise { + name: "Faithful 32x.zip".into(), + }, + ) + .unwrap(); + assert_eq!( + packs.enabled_files("1.20.1"), + [ + "My \"quoted\" pack", + "Faithful 32x.zip", + "New, \"odd\" pack.zip" + ] + ); + + let packs = change_packs( + path, + "1.20.1", + &PackChange::Disable { + name: "My \"quoted\" pack".into(), + }, + ) + .unwrap(); + assert_eq!( + packs.enabled, + [ + "vanilla", + "fabric", + "file/New, \"odd\" pack.zip", + "file/Faithful 32x.zip" + ] + ); + // the file still parses back to the same lists + assert_eq!(PackOptions::read(path).unwrap().enabled, packs.enabled); + assert!(read(&dir).contains("unknownKey:[weird value\r\n")); + } + + #[test] + fn incompatible_packs_are_listed_so_the_game_keeps_them() { + let dir = game_dir(Some("resourcePacks:[\"vanilla\"]\nfov:0.5\n")); + let packs = change_packs( + dir.path(), + "1.21.1", + &PackChange::Enable { + name: "Old.zip".into(), + compatible: false, + }, + ) + .unwrap(); + assert_eq!(packs.incompatible, ["file/Old.zip"]); + assert_eq!( + read(&dir), + "resourcePacks:[\"vanilla\",\"file/Old.zip\"]\nfov:0.5\nincompatibleResourcePacks:[\"file/Old.zip\"]\n" + ); + // renaming follows the pack in both lists; disabling removes it from both + change_packs( + dir.path(), + "1.21.1", + &PackChange::Rename { + from: "Old.zip".into(), + to: "Old v2.zip".into(), + }, + ) + .unwrap(); + assert_eq!( + read(&dir), + "resourcePacks:[\"vanilla\",\"file/Old v2.zip\"]\nfov:0.5\nincompatibleResourcePacks:[\"file/Old v2.zip\"]\n" + ); + change_packs( + dir.path(), + "1.21.1", + &PackChange::Disable { + name: "Old v2.zip".into(), + }, + ) + .unwrap(); + assert_eq!( + read(&dir), + "resourcePacks:[\"vanilla\"]\nfov:0.5\nincompatibleResourcePacks:[]\n" + ); + } + + #[test] + fn a_missing_file_is_created_with_only_the_pack_line() { + let dir = game_dir(None); + change_packs( + dir.path(), + "1.20.1", + &PackChange::Enable { + name: "Pack.zip".into(), + compatible: true, + }, + ) + .unwrap(); + assert_eq!( + read(&dir), + "resourcePacks:[\"vanilla\",\"file/Pack.zip\"]\n" + ); + } + + #[test] + fn an_unreadable_list_is_left_alone() { + let text = "resourcePacks:[\"vanilla\",\nlang:en_us\n"; + let dir = game_dir(Some(text)); + let err = change_packs( + dir.path(), + "1.20.1", + &PackChange::Enable { + name: "Pack.zip".into(), + compatible: true, + }, + ) + .unwrap_err(); + assert!(matches!(err, OptionsError::Unreadable { .. })); + assert_eq!(read(&dir), text); + } + + #[test] + fn old_versions_use_bare_names() { + assert_eq!(pack_id("Pack.zip", "1.12.2"), "Pack.zip"); + assert_eq!(pack_id("Pack.zip", "1.13"), "file/Pack.zip"); + assert_eq!(pack_id("Pack.zip", "26.3"), "file/Pack.zip"); + assert_eq!(pack_id("Pack.zip", "24w14a"), "file/Pack.zip"); + assert_eq!(pack_name("Pack.zip", "1.8.9").as_deref(), Some("Pack.zip")); + assert_eq!(pack_name("vanilla", "1.8.9"), None); + assert_eq!(pack_name("fabric", "1.20.1"), None); + let dir = game_dir(Some("resourcePacks:[\"A.zip\"]\n")); + let packs = change_packs( + dir.path(), + "1.8.9", + &PackChange::Enable { + name: "B.zip".into(), + compatible: true, + }, + ) + .unwrap(); + assert_eq!(packs.enabled, ["A.zip", "B.zip"]); + assert_eq!(packs.enabled_files("1.8.9"), ["B.zip", "A.zip"]); + } + + #[test] + fn unchanged_lists_are_not_rewritten() { + let text = "resourcePacks:[ \"vanilla\" ]\n"; + let dir = game_dir(Some(text)); + change_packs( + dir.path(), + "1.20.1", + &PackChange::Disable { + name: "Missing.zip".into(), + }, + ) + .unwrap(); + assert_eq!(read(&dir), text); + } +} diff --git a/launcher/crates/launcher-core/src/content/packs.rs b/launcher/crates/launcher-core/src/content/packs.rs new file mode 100644 index 000000000..841947aea --- /dev/null +++ b/launcher/crates/launcher-core/src/content/packs.rs @@ -0,0 +1,379 @@ +//! Resource packs' `pack.mcmeta`: the description and the pack formats a pack is made +//! for, and which resource pack format each Minecraft release reads. + +use std::cmp::Ordering; + +use lodestone_minecraft::versions::compare_versions; +use serde_json::Value; + +/// A pack format, `major.minor`. Before 1.21.9 formats were whole numbers (minor 0). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PackFormat { + pub major: u32, + pub minor: u32, +} + +impl PackFormat { + pub const fn new(major: u32, minor: u32) -> Self { + Self { major, minor } + } +} + +impl std::fmt::Display for PackFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.minor == 0 { + write!(f, "{}", self.major) + } else { + write!(f, "{}.{}", self.major, self.minor) + } + } +} + +/// The formats a pack says it supports, inclusive. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FormatRange { + pub min: PackFormat, + pub max: PackFormat, +} + +/// What `pack.mcmeta` says. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PackMeta { + /// The description as plain text. + pub description: String, + pub formats: Option, +} + +/// The resource pack format of each Minecraft release: the first and last release that +/// read it. From the Minecraft Wiki's "Pack format" page +/// (), read 2026-11-08. +pub const RESOURCE_FORMATS: &[(&str, &str, PackFormat)] = &[ + ("1.6.1", "1.8.9", PackFormat::new(1, 0)), + ("1.9", "1.10.2", PackFormat::new(2, 0)), + ("1.11", "1.12.2", PackFormat::new(3, 0)), + ("1.13", "1.14.4", PackFormat::new(4, 0)), + ("1.15", "1.16.1", PackFormat::new(5, 0)), + ("1.16.2", "1.16.5", PackFormat::new(6, 0)), + ("1.17", "1.17.1", PackFormat::new(7, 0)), + ("1.18", "1.18.2", PackFormat::new(8, 0)), + ("1.19", "1.19.2", PackFormat::new(9, 0)), + ("1.19.3", "1.19.3", PackFormat::new(12, 0)), + ("1.19.4", "1.19.4", PackFormat::new(13, 0)), + ("1.20", "1.20.1", PackFormat::new(15, 0)), + ("1.20.2", "1.20.2", PackFormat::new(18, 0)), + ("1.20.3", "1.20.4", PackFormat::new(22, 0)), + ("1.20.5", "1.20.6", PackFormat::new(32, 0)), + ("1.21", "1.21.1", PackFormat::new(34, 0)), + ("1.21.2", "1.21.3", PackFormat::new(42, 0)), + ("1.21.4", "1.21.4", PackFormat::new(46, 0)), + ("1.21.5", "1.21.5", PackFormat::new(55, 0)), + ("1.21.6", "1.21.6", PackFormat::new(63, 0)), + ("1.21.7", "1.21.8", PackFormat::new(64, 0)), + ("1.21.9", "1.21.10", PackFormat::new(69, 0)), + ("1.21.11", "1.21.11", PackFormat::new(75, 0)), + ("26.1", "26.1.2", PackFormat::new(84, 0)), + ("26.2", "26.2", PackFormat::new(88, 0)), + ("26.3", "26.3", PackFormat::new(97, 1)), +]; + +/// Whether `version` looks like a release (`1.20.1`, `26.3`), not a snapshot. +fn is_release(version: &str) -> bool { + let parts: Vec<&str> = version.split('.').collect(); + (2..=3).contains(&parts.len()) + && parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) +} + +/// The resource pack format `game_version` reads, if it is a release in the table. +pub fn resource_format(game_version: &str) -> Option { + if !is_release(game_version) { + return None; + } + RESOURCE_FORMATS + .iter() + .find(|(first, last, _)| { + compare_versions(game_version, first) != Ordering::Less + && compare_versions(game_version, last) != Ordering::Greater + }) + .map(|(_, _, format)| *format) +} + +/// Whether a pack fits the game, as the game's pack screen says. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Compatibility { + Compatible, + /// "Made for an older version of Minecraft". + TooOld, + /// "Made for a newer version of Minecraft". + TooNew, + /// The game version or the pack's formats are unknown. + Unknown, +} + +impl Compatibility { + /// The game's own warning, if any. + pub fn warning(self) -> Option<&'static str> { + match self { + Compatibility::TooOld => Some("Made for an older version of Minecraft"), + Compatibility::TooNew => Some("Made for a newer version of Minecraft"), + Compatibility::Compatible | Compatibility::Unknown => None, + } + } +} + +/// Whether a pack with `formats` fits `game_version`. +pub fn compatibility(formats: Option<&FormatRange>, game_version: &str) -> Compatibility { + let (Some(formats), Some(game)) = (formats, resource_format(game_version)) else { + return Compatibility::Unknown; + }; + if formats.max < game { + Compatibility::TooOld + } else if formats.min > game { + Compatibility::TooNew + } else { + Compatibility::Compatible + } +} + +/// A format value: a whole number, or `[major, minor]` (`min_format`/`max_format`). +/// `is_max` says how a bare major number is read: as `major.0` for a minimum, and as any +/// minor of that major for a maximum. +fn format_value(value: &Value, is_max: bool) -> Option { + let whole = |v: &Value| v.as_u64().and_then(|n| u32::try_from(n).ok()); + match value { + Value::Number(_) => { + let major = whole(value)?; + Some(PackFormat::new(major, if is_max { u32::MAX } else { 0 })) + } + Value::Array(parts) => match parts.as_slice() { + [major] => Some(PackFormat::new( + whole(major)?, + if is_max { u32::MAX } else { 0 }, + )), + [major, minor] => Some(PackFormat::new(whole(major)?, whole(minor)?)), + _ => None, + }, + _ => None, + } +} + +/// The formats a `pack` object of `pack.mcmeta` declares: `min_format`/`max_format` +/// (25w31a and later), else `supported_formats` (a number, `[min, max]` or +/// `{min_inclusive, max_inclusive}`, 23w31a and later), else `pack_format`. +fn formats(pack: &serde_json::Map) -> Option { + if let (Some(min), Some(max)) = (pack.get("min_format"), pack.get("max_format")) + && let (Some(min), Some(max)) = (format_value(min, false), format_value(max, true)) + { + return Some(FormatRange { min, max }); + } + if let Some(supported) = pack.get("supported_formats") { + let range = match supported { + Value::Number(_) => Some((supported, supported)), + Value::Array(bounds) if bounds.len() == 2 => Some((&bounds[0], &bounds[1])), + Value::Object(bounds) => bounds.get("min_inclusive").zip(bounds.get("max_inclusive")), + _ => None, + }; + if let Some((min, max)) = range + && let (Some(min), Some(max)) = (format_value(min, false), format_value(max, true)) + { + return Some(FormatRange { min, max }); + } + } + let format = pack.get("pack_format")?; + Some(FormatRange { + min: format_value(format, false)?, + max: format_value(format, true)?, + }) +} + +/// Reads `pack.mcmeta`. `None` if it isn't JSON with a `pack` object. +pub fn parse_pack_mcmeta(text: &str) -> Option { + let text = text.trim_start_matches('\u{feff}'); + let value: Value = serde_json::from_str(text).ok()?; + let pack = value.get("pack")?.as_object()?; + Some(PackMeta { + description: pack + .get("description") + .map(plain_text) + .unwrap_or_default() + .trim() + .to_string(), + formats: formats(pack), + }) +} + +/// A text component (a string, a list, or an object with `text`, `translate` +/// (with its `fallback`) and `extra`) as plain text, with `§` formatting codes removed. +pub fn plain_text(component: &Value) -> String { + fn walk(value: &Value, out: &mut String, depth: usize) { + if depth > 16 || out.len() > 4096 { + return; + } + match value { + Value::String(text) => out.push_str(text), + Value::Number(n) => out.push_str(&n.to_string()), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Array(parts) => { + for part in parts { + walk(part, out, depth + 1); + } + } + Value::Object(object) => { + if let Some(text) = object.get("text") { + walk(text, out, depth + 1); + } else if let Some(fallback) = object.get("fallback") { + walk(fallback, out, depth + 1); + } else if let Some(key) = object.get("translate").and_then(Value::as_str) { + out.push_str(key); + } + if let Some(extra) = object.get("extra") { + walk(extra, out, depth + 1); + } + } + Value::Null => {} + } + } + let mut out = String::new(); + walk(component, &mut out, 0); + strip_formatting(&out) +} + +/// Removes `§x` formatting codes. +pub fn strip_formatting(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '§' { + chars.next(); + } else { + out.push(c); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_of_releases() { + assert_eq!(resource_format("1.20.1"), Some(PackFormat::new(15, 0))); + assert_eq!(resource_format("1.20"), Some(PackFormat::new(15, 0))); + assert_eq!(resource_format("1.8.9"), Some(PackFormat::new(1, 0))); + assert_eq!(resource_format("1.21.1"), Some(PackFormat::new(34, 0))); + assert_eq!(resource_format("1.21.10"), Some(PackFormat::new(69, 0))); + assert_eq!(resource_format("1.21.11"), Some(PackFormat::new(75, 0))); + assert_eq!(resource_format("26.1.1"), Some(PackFormat::new(84, 0))); + assert_eq!(resource_format("26.3"), Some(PackFormat::new(97, 1))); + assert_eq!(resource_format("1.5.2"), None); + assert_eq!(resource_format("24w14a"), None); + assert_eq!(resource_format("1.21-pre1"), None); + assert_eq!(resource_format("99.1"), None); + // the table has no gaps or overlaps between releases + for pair in RESOURCE_FORMATS.windows(2) { + assert!(pair[0].2 < pair[1].2, "{pair:?}"); + assert_eq!(compare_versions(pair[0].1, pair[1].0), Ordering::Less); + } + } + + #[test] + fn reads_pack_mcmeta() { + let meta = + parse_pack_mcmeta(r#"{"pack": {"pack_format": 15, "description": "§6Gold §lpack"}}"#) + .unwrap(); + assert_eq!(meta.description, "Gold pack"); + assert_eq!( + meta.formats, + Some(FormatRange { + min: PackFormat::new(15, 0), + max: PackFormat::new(15, u32::MAX) + }) + ); + let meta = parse_pack_mcmeta( + r#"{"pack": {"pack_format": 15, "supported_formats": [15, 34], + "description": [{"text": "A "}, {"text": "pack", "color": "red", "extra": ["!"]}]}}"#, + ) + .unwrap(); + assert_eq!(meta.description, "A pack!"); + assert_eq!(meta.formats.unwrap().max.major, 34); + let meta = parse_pack_mcmeta( + r#"{"pack": {"pack_format": 18, "supported_formats": {"min_inclusive": 16, "max_inclusive": 22}, + "description": {"translate": "pack.x", "fallback": "Fallback"}}}"#, + ) + .unwrap(); + assert_eq!(meta.description, "Fallback"); + assert_eq!(meta.formats.unwrap().min.major, 16); + let meta = parse_pack_mcmeta( + "\u{feff}{\"pack\": {\"min_format\": [69, 0], \"max_format\": 97, \"description\": \"New\"}}", + ) + .unwrap(); + assert_eq!( + meta.formats, + Some(FormatRange { + min: PackFormat::new(69, 0), + max: PackFormat::new(97, u32::MAX) + }) + ); + assert!(parse_pack_mcmeta("{}").is_none()); + assert!(parse_pack_mcmeta("not json").is_none()); + assert_eq!( + parse_pack_mcmeta(r#"{"pack": {"description": "x"}}"#) + .unwrap() + .formats, + None + ); + } + + #[test] + fn compatibility_badges() { + let range = |min: (u32, u32), max: (u32, u32)| FormatRange { + min: PackFormat::new(min.0, min.1), + max: PackFormat::new(max.0, max.1), + }; + let pack_15 = range((15, 0), (15, u32::MAX)); + assert_eq!( + compatibility(Some(&pack_15), "1.20.1"), + Compatibility::Compatible + ); + assert_eq!( + compatibility(Some(&pack_15), "1.21.1"), + Compatibility::TooOld + ); + assert_eq!( + compatibility(Some(&pack_15), "1.19.4"), + Compatibility::TooNew + ); + assert_eq!( + compatibility(Some(&pack_15), "23w31a"), + Compatibility::Unknown + ); + assert_eq!(compatibility(None, "1.20.1"), Compatibility::Unknown); + // supported_formats widens the range + let wide = range((15, 0), (34, u32::MAX)); + assert_eq!( + compatibility(Some(&wide), "1.21.1"), + Compatibility::Compatible + ); + // minor versions: 26.3 reads 97.1 + assert_eq!( + compatibility(Some(&range((97, 0), (97, u32::MAX))), "26.3"), + Compatibility::Compatible + ); + assert_eq!( + compatibility(Some(&range((97, 2), (98, 0))), "26.3"), + Compatibility::TooNew + ); + assert_eq!( + compatibility(Some(&range((88, 0), (97, 0))), "26.3"), + Compatibility::TooOld + ); + assert_eq!( + Compatibility::TooOld.warning(), + Some("Made for an older version of Minecraft") + ); + assert_eq!(PackFormat::new(97, 1).to_string(), "97.1"); + assert_eq!(PackFormat::new(15, 0).to_string(), "15"); + } +} diff --git a/launcher/crates/launcher-core/src/content/provenance.rs b/launcher/crates/launcher-core/src/content/provenance.rs new file mode 100644 index 000000000..4d32dd579 --- /dev/null +++ b/launcher/crates/launcher-core/src/content/provenance.rs @@ -0,0 +1,376 @@ +//! Where an instance's content files came from: `content.json` in the instance's folder. +//! +//! Each entry is keyed by `/`, so enabling or disabling a +//! mod keeps it. It holds the file's size, modification time and hashes, and the Modrinth +//! version it is (from an install, or from a lookup by SHA-1). A file whose size and +//! modification time still match its entry isn't hashed again. `looked_up` records that +//! Modrinth didn't know a file, so it isn't asked again on every listing. +//! +//! The file is a cache: deleting it only means the files are hashed and looked up again. + +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use lodestone_minecraft::modrinth::{ModrinthProject, RawVersion}; +use serde::{Deserialize, Serialize}; + +use super::{ContentKind, enabled_name, scan::ScannedFile}; +use crate::storage::{self, Document, Migration, StorageError}; + +pub const FILE: &str = "content.json"; + +/// The Modrinth version a file is. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModrinthSource { + pub project_id: String, + pub version_id: String, + #[serde(default)] + pub slug: String, + #[serde(default)] + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + #[serde(default)] + pub version_number: String, + #[serde(default)] + pub date_published: String, + /// The file's download URL on Modrinth's CDN. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default)] + pub client_side: String, + #[serde(default)] + pub server_side: String, +} + +impl ModrinthSource { + /// The source of the file with `sha512` in `version` of `project`. + pub fn new(version: &RawVersion, project: Option<&ModrinthProject>, sha512: &str) -> Self { + let file = version + .files + .iter() + .find(|f| { + f.hashes + .sha512 + .as_deref() + .is_some_and(|h| h.eq_ignore_ascii_case(sha512)) + }) + .or_else(|| version.primary_file()); + Self { + project_id: version.project_id.clone(), + version_id: version.id.clone(), + slug: project.map(|p| p.slug.clone()).unwrap_or_default(), + title: project + .map(|p| p.title.clone()) + .unwrap_or_else(|| version.name.clone()), + icon_url: project.and_then(|p| p.icon_url.clone()), + version_number: version.version_number.clone(), + date_published: version.date_published.clone(), + url: file.map(|f| f.url.clone()), + client_side: project.map(|p| p.client_side.clone()).unwrap_or_default(), + server_side: project.map(|p| p.server_side.clone()).unwrap_or_default(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entry { + pub size: u64, + /// Nanoseconds since the Unix epoch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified: Option, + pub sha1: String, + pub sha512: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modrinth: Option, + /// Modrinth was asked about this file and didn't know it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub looked_up: bool, +} + +/// `content.json`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Provenance { + #[serde(default)] + pub files: BTreeMap, +} + +impl Document for Provenance { + const VERSION: u32 = 1; + const MIGRATIONS: &'static [Migration] = &[]; +} + +/// Nanoseconds since the Unix epoch. +pub fn nanos(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .and_then(|d| u64::try_from(d.as_nanos()).ok()) +} + +/// The key of a file of `kind` named `file_name`. +pub fn key(kind: ContentKind, file_name: &str) -> String { + format!("{}/{}", kind.dir(), enabled_name(file_name)) +} + +pub fn path(instance_dir: &Path) -> PathBuf { + instance_dir.join(FILE) +} + +/// Serialises the read-modify-write of `content.json` within the app: listings and +/// installs run in the background at the same time. +static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +impl Provenance { + /// Reads `content.json`, applies `change` and writes it back if it changed, with no + /// other update in between. + pub fn update( + instance_dir: &Path, + change: impl FnOnce(&mut Provenance), + ) -> Result { + let _guard = WRITE_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut provenance = Self::load(instance_dir); + let before = provenance.clone(); + change(&mut provenance); + if provenance != before { + provenance.save(instance_dir)?; + } + Ok(provenance) + } + + /// Takes in what a listing found for `kind` (`refreshed`) without undoing changes made + /// since it started: for each file, the entry that matches the file on disk now wins + /// (the one with a Modrinth source if both do), and files that are gone are dropped. + pub fn merge_refreshed(&mut self, kind: ContentKind, refreshed: &Provenance, dir: &Path) { + let prefix = format!("{}/", kind.dir()); + let stat = |name: &str| { + let candidates = [name.to_string(), super::disabled_name(name)]; + candidates.iter().find_map(|candidate| { + let metadata = std::fs::symlink_metadata(dir.join(candidate)).ok()?; + Some((metadata.len(), metadata.modified().ok().and_then(nanos))) + }) + }; + let mut keys: Vec = self + .files + .keys() + .chain(refreshed.files.keys()) + .filter(|k| k.starts_with(&prefix)) + .cloned() + .collect(); + keys.sort(); + keys.dedup(); + for key in keys { + let name = &key[prefix.len()..]; + let Some((size, modified)) = stat(name) else { + self.files.remove(&key); + continue; + }; + let fits = |e: &&Entry| e.size == size && e.modified == modified; + let mine = self.files.get(&key).filter(fits); + let theirs = refreshed.files.get(&key).filter(fits); + let chosen = match (mine, theirs) { + (Some(mine), Some(theirs)) + if mine.modrinth.is_none() + && (theirs.modrinth.is_some() || theirs.looked_up) => + { + Some(theirs.clone()) + } + (Some(mine), _) => Some(mine.clone()), + (None, Some(theirs)) => Some(theirs.clone()), + (None, None) => None, + }; + match chosen { + Some(entry) => { + self.files.insert(key, entry); + } + None => { + self.files.remove(&key); + } + } + } + } + + /// Reads `content.json`. A broken file is treated as empty: it is only a cache. + pub fn load(instance_dir: &Path) -> Self { + storage::load::(&path(instance_dir)) + .map(|loaded| loaded.document) + .unwrap_or_default() + } + + pub fn save(&self, instance_dir: &Path) -> Result<(), StorageError> { + storage::save(&path(instance_dir), self) + } + + pub fn get(&self, kind: ContentKind, file_name: &str) -> Option<&Entry> { + self.files.get(&key(kind, file_name)) + } + + /// The entry of `file` if its size and modification time still match. + pub fn current(&self, file: &ScannedFile) -> Option<&Entry> { + let entry = self.get(file.kind, &file.file_name)?; + let modified = file.modified.and_then(nanos); + (entry.size == file.size && entry.modified == modified && modified.is_some()) + .then_some(entry) + } + + pub fn insert(&mut self, kind: ContentKind, file_name: &str, entry: Entry) { + self.files.insert(key(kind, file_name), entry); + } + + pub fn remove(&mut self, kind: ContentKind, file_name: &str) -> Option { + self.files.remove(&key(kind, file_name)) + } + + /// Drops the entries of `kind` whose files are gone. + pub fn retain_present(&mut self, kind: ContentKind, present: &[ScannedFile]) { + let prefix = format!("{}/", kind.dir()); + let keep: Vec = present.iter().map(|f| key(kind, &f.file_name)).collect(); + self.files + .retain(|k, _| !k.starts_with(&prefix) || keep.contains(k)); + } + + /// The installed files of `kind` by Modrinth project: project id → file keys. + pub fn by_project(&self, kind: ContentKind) -> BTreeMap> { + let prefix = format!("{}/", kind.dir()); + let mut map: BTreeMap> = BTreeMap::new(); + for (k, entry) in &self.files { + if let (Some(name), Some(source)) = (k.strip_prefix(&prefix), &entry.modrinth) { + map.entry(source.project_id.clone()) + .or_default() + .push(name.to_string()); + } + } + map + } + + /// SHA-512 → Modrinth CDN URL, for files that came from Modrinth: what the `.mrpack` + /// export lists in its index. + pub fn download_urls(&self) -> std::collections::HashMap { + self.files + .values() + .filter_map(|entry| { + let source = entry.modrinth.as_ref()?; + let url = source.url.clone()?; + Some((entry.sha512.clone(), (url, source.clone()))) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_keys_ignore_disabled() { + let dir = tempfile::tempdir().unwrap(); + let mut provenance = Provenance::default(); + provenance.insert( + ContentKind::Mod, + "sodium.jar.disabled", + Entry { + size: 3, + modified: Some(5), + sha1: "a".into(), + sha512: "b".into(), + modrinth: Some(ModrinthSource { + project_id: "AANobbMI".into(), + version_id: "v1".into(), + url: Some("https://cdn.modrinth.com/x.jar".into()), + ..ModrinthSource::default() + }), + looked_up: false, + }, + ); + provenance.save(dir.path()).unwrap(); + let text = std::fs::read_to_string(dir.path().join(FILE)).unwrap(); + assert!(text.contains("\"version\": 1")); + assert!(text.contains("\"mods/sodium.jar\"")); + let loaded = Provenance::load(dir.path()); + assert_eq!(loaded, provenance); + assert!(loaded.get(ContentKind::Mod, "sodium.jar").is_some()); + assert_eq!( + loaded.by_project(ContentKind::Mod)["AANobbMI"], + ["sodium.jar"] + ); + assert!(loaded.by_project(ContentKind::ShaderPack).is_empty()); + assert_eq!( + loaded.download_urls()["b"].0, + "https://cdn.modrinth.com/x.jar" + ); + // a broken file is an empty cache + std::fs::write(dir.path().join(FILE), b"{oops").unwrap(); + assert_eq!(Provenance::load(dir.path()), Provenance::default()); + } + + #[test] + fn a_listing_does_not_undo_a_newer_install() { + let dir = tempfile::tempdir().unwrap(); + let mods = dir.path().join("minecraft/mods"); + std::fs::create_dir_all(&mods).unwrap(); + std::fs::write(mods.join("a.jar"), b"new file").unwrap(); + std::fs::write(mods.join("b.jar.disabled"), b"b").unwrap(); + let stat = |name: &str| { + let m = std::fs::metadata(mods.join(name)).unwrap(); + (m.len(), m.modified().ok().and_then(nanos)) + }; + let entry = |name: &str, project: Option<&str>| { + let (size, modified) = stat(name); + Entry { + size, + modified, + modrinth: project.map(|p| ModrinthSource { + project_id: p.into(), + ..ModrinthSource::default() + }), + ..Entry::default() + } + }; + // what the install wrote, after the listing had scanned + let mut current = Provenance::default(); + current.insert(ContentKind::Mod, "a.jar", entry("a.jar", Some("new"))); + current.insert(ContentKind::Mod, "gone.jar", Entry::default()); + current.insert(ContentKind::ResourcePack, "p.zip", Entry::default()); + // what the listing found: a stale entry for a.jar, and b.jar looked up + let mut refreshed = Provenance::default(); + refreshed.insert( + ContentKind::Mod, + "a.jar", + Entry { + size: 1, + ..Entry::default() + }, + ); + refreshed.insert( + ContentKind::Mod, + "b.jar.disabled", + entry("b.jar.disabled", Some("b")), + ); + current.merge_refreshed(ContentKind::Mod, &refreshed, &mods); + assert_eq!( + current + .get(ContentKind::Mod, "a.jar") + .unwrap() + .modrinth + .as_ref() + .unwrap() + .project_id, + "new" + ); + assert_eq!( + current + .get(ContentKind::Mod, "b.jar") + .unwrap() + .modrinth + .as_ref() + .unwrap() + .project_id, + "b" + ); + assert!(current.get(ContentKind::Mod, "gone.jar").is_none()); + // other kinds are not touched + assert!(current.get(ContentKind::ResourcePack, "p.zip").is_some()); + } +} diff --git a/launcher/crates/launcher-core/src/content/scan.rs b/launcher/crates/launcher-core/src/content/scan.rs new file mode 100644 index 000000000..ff503b8cd --- /dev/null +++ b/launcher/crates/launcher-core/src/content/scan.rs @@ -0,0 +1,560 @@ +//! What is in a content folder, with the metadata that is cheap to read: a mod's +//! metadata file and icon, a resource pack's `pack.mcmeta` and `pack.png`, and whether a +//! shader pack has its `shaders/` folder where Iris and OptiFine look for it. +//! +//! Archives are opened with zip 0.6.6 and only small, named entries are read; nothing is +//! extracted. + +use std::{ + fs::{self, File}, + io::{Read, Seek}, + path::Path, + sync::Arc, + time::SystemTime, +}; + +use lodestone_minecraft::jar; + +use super::{ + ContentError, ContentKind, InstanceDirs, enabled_name, io_error, is_content_name, + is_disabled_name, packs, +}; + +/// Metadata files and icons bigger than this are not read. +const MAX_ENTRY_BYTES: u64 = 1024 * 1024; +/// Icons are shown this big at most, in pixels. +pub const ICON_PIXELS: u32 = 64; + +/// The mod loaders a jar has a metadata file for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ModFormat { + /// `fabric.mod.json`. + Fabric, + /// `quilt.mod.json`. + Quilt, + /// `META-INF/mods.toml` or `mcmod.info`. + Forge, + /// `META-INF/neoforge.mods.toml`. + NeoForge, +} + +/// What a file says about itself. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FileMeta { + /// A mod's id (`iris`, `sodium`). + pub id: Option, + pub name: Option, + pub version: Option, + pub description: Option, + pub authors: Vec, + pub icon: Option>, + /// The loaders a mod jar has metadata for. + pub mod_formats: Vec, + /// Whether the jar is OptiFine (it has OptiFine's classes). + pub optifine: bool, + /// A resource pack's formats. + pub formats: Option, + /// Why the game may not load the file, such as a resource pack without `pack.mcmeta`. + pub problem: Option, +} + +/// A file or folder in a content folder. +#[derive(Clone, Debug, PartialEq)] +pub struct ScannedFile { + pub kind: ContentKind, + /// The name on disk, with `.disabled` for disabled mods. + pub file_name: String, + pub is_dir: bool, + /// Bytes, for files. + pub size: u64, + pub modified: Option, + pub meta: FileMeta, +} + +impl ScannedFile { + /// A mod without `.disabled`; packs are enabled or not in the game's options instead. + pub fn enabled_by_name(&self) -> bool { + !(self.kind == ContentKind::Mod && is_disabled_name(&self.file_name)) + } + + /// The name with `.disabled` removed. + pub fn enabled_name(&self) -> String { + enabled_name(&self.file_name) + } + + /// The name to show: the metadata's, else the file name without its extension. + pub fn display_name(&self) -> String { + if let Some(name) = &self.meta.name + && !name.is_empty() + { + return name.clone(); + } + let name = self.enabled_name(); + let lower = name.to_ascii_lowercase(); + for ext in [".jar", ".zip"] { + if lower.ends_with(ext) && !self.is_dir { + return name[..name.len() - ext.len()].to_string(); + } + } + name + } +} + +/// Lists the content folder of `kind`, sorted by file name ignoring case. A missing folder +/// is empty. Hidden files (Lodestone's temporary downloads among them), symbolic links +/// and names that aren't content are skipped. +pub fn scan(dirs: &InstanceDirs, kind: ContentKind) -> Result, ContentError> { + let dir = dirs.content_dir(kind); + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(io_error(&dir)(err)), + }; + let mut files = Vec::new(); + for entry in entries { + let entry = entry.map_err(io_error(&dir))?; + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() || !(file_type.is_file() || file_type.is_dir()) { + continue; + } + let is_dir = file_type.is_dir(); + if (is_dir && !kind.allows_folders()) || !is_content_name(kind, &name, is_dir) { + continue; + } + let path = entry.path(); + let metadata = entry.metadata().ok(); + files.push(ScannedFile { + kind, + size: if is_dir { + 0 + } else { + metadata.as_ref().map_or(0, |m| m.len()) + }, + modified: metadata.and_then(|m| m.modified().ok()), + meta: read_meta(kind, &path, is_dir), + file_name: name, + is_dir, + }); + } + files.sort_by(|a, b| { + a.file_name + .to_lowercase() + .cmp(&b.file_name.to_lowercase()) + .then_with(|| a.file_name.cmp(&b.file_name)) + }); + Ok(files) +} + +/// Reads the metadata of the file or folder at `path` as `kind`. +pub fn read_meta(kind: ContentKind, path: &Path, is_dir: bool) -> FileMeta { + if is_dir { + return match kind { + ContentKind::Mod => FileMeta::default(), + ContentKind::ResourcePack => folder_pack_meta(path), + ContentKind::ShaderPack => folder_shader_meta(path), + }; + } + let archive = File::open(path) + .ok() + .and_then(|file| zip::ZipArchive::new(file).ok()); + let Some(mut archive) = archive else { + return FileMeta { + problem: Some(match kind { + ContentKind::Mod => "This isn't a valid jar file.".to_string(), + _ => "This isn't a valid zip file.".to_string(), + }), + ..FileMeta::default() + }; + }; + match kind { + ContentKind::Mod => mod_meta(&mut archive), + ContentKind::ResourcePack => zip_pack_meta(&mut archive), + ContentKind::ShaderPack => zip_shader_meta(&mut archive), + } +} + +fn read_entry(archive: &mut zip::ZipArchive, name: &str) -> Option> { + let entry = archive.by_name(name).ok()?; + if !entry.is_file() || entry.size() > MAX_ENTRY_BYTES { + return None; + } + let mut bytes = Vec::new(); + entry.take(MAX_ENTRY_BYTES).read_to_end(&mut bytes).ok()?; + Some(bytes) +} + +fn read_text(archive: &mut zip::ZipArchive, name: &str) -> Option { + let bytes = read_entry(archive, name)?; + Some( + String::from_utf8_lossy(&bytes) + .trim_start_matches('\u{feff}') + .to_string(), + ) +} + +/// Decodes a PNG or JPEG and makes it at most [`ICON_PIXELS`] square. +pub fn decode_icon(bytes: &[u8]) -> Option> { + let image = image::load_from_memory(bytes).ok()?; + let image = if image.width() > ICON_PIXELS || image.height() > ICON_PIXELS { + image.thumbnail(ICON_PIXELS, ICON_PIXELS) + } else { + image + }; + Some(Arc::new(image.to_rgba8())) +} + +const MOD_METADATA: [(&str, ModFormat); 5] = [ + ("fabric.mod.json", ModFormat::Fabric), + ("quilt.mod.json", ModFormat::Quilt), + ("META-INF/neoforge.mods.toml", ModFormat::NeoForge), + ("META-INF/mods.toml", ModFormat::Forge), + ("mcmod.info", ModFormat::Forge), +]; + +fn mod_meta(archive: &mut zip::ZipArchive) -> FileMeta { + let names: Vec = archive.file_names().map(str::to_string).collect(); + let mut meta = FileMeta { + optifine: names + .iter() + .any(|n| n.starts_with("net/optifine/") || n.starts_with("optifine/")), + ..FileMeta::default() + }; + for (file, format) in MOD_METADATA { + if !names.iter().any(|n| n == file) { + continue; + } + if !meta.mod_formats.contains(&format) { + meta.mod_formats.push(format); + } + if meta.name.is_some() || meta.id.is_some() { + continue; + } + let Some(text) = read_text(archive, file) else { + continue; + }; + let parsed = match file { + "fabric.mod.json" => jar::parse_fabric_mod_json(&text), + "quilt.mod.json" => jar::parse_quilt_mod_json(&text), + "mcmod.info" => jar::parse_mcmod_info(&text), + _ => { + let manifest = read_text(archive, "META-INF/MANIFEST.MF") + .and_then(|m| jar::manifest_attribute(&m, "Implementation-Version")); + jar::parse_mods_toml(&text, manifest) + } + }; + let Some(parsed) = parsed else { + continue; + }; + meta.id = parsed.id; + meta.name = parsed.name; + meta.version = parsed.version; + meta.description = parsed.description; + meta.authors = parsed.authors; + meta.icon = jar::metadata_icon(file, &text) + .and_then(|icon| read_entry(archive, &icon)) + .and_then(|bytes| decode_icon(&bytes)); + } + if meta.mod_formats.is_empty() && !meta.optifine { + meta.problem = Some("This jar has no mod metadata, so no loader will load it.".into()); + } + meta +} + +fn pack_meta_from(text: Option, icon: Option>) -> FileMeta { + let Some(text) = text else { + return FileMeta { + problem: Some("It has no pack.mcmeta, so the game won't list it.".into()), + ..FileMeta::default() + }; + }; + match packs::parse_pack_mcmeta(&text) { + Some(pack) => FileMeta { + description: (!pack.description.is_empty()).then_some(pack.description), + formats: pack.formats, + icon: icon.and_then(|bytes| decode_icon(&bytes)), + ..FileMeta::default() + }, + None => FileMeta { + problem: Some("Its pack.mcmeta can't be read.".into()), + icon: icon.and_then(|bytes| decode_icon(&bytes)), + ..FileMeta::default() + }, + } +} + +fn zip_pack_meta(archive: &mut zip::ZipArchive) -> FileMeta { + let text = read_text(archive, "pack.mcmeta"); + if text.is_none() && archive.file_names().any(|n| n.ends_with("/pack.mcmeta")) { + return FileMeta { + problem: Some( + "Its pack.mcmeta is inside a folder in the zip, so the game won't list it.".into(), + ), + ..FileMeta::default() + }; + } + let icon = read_entry(archive, "pack.png"); + pack_meta_from(text, icon) +} + +fn read_small_file(path: &Path) -> Option> { + let metadata = fs::symlink_metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > MAX_ENTRY_BYTES { + return None; + } + fs::read(path).ok() +} + +fn folder_pack_meta(dir: &Path) -> FileMeta { + let text = read_small_file(&dir.join("pack.mcmeta")) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()); + pack_meta_from(text, read_small_file(&dir.join("pack.png"))) +} + +/// Iris and OptiFine find `shaders/` at the root of a pack or one folder down. +fn has_shaders_folder(names: impl Iterator) -> bool { + names.into_iter().any(|name| { + let parts: Vec<&str> = name.split('/').collect(); + matches!(parts.as_slice(), ["shaders", _, ..] | [_, "shaders", _, ..]) + }) +} + +fn no_shaders() -> FileMeta { + FileMeta { + problem: Some("It has no shaders folder, so it isn't a shader pack.".into()), + ..FileMeta::default() + } +} + +fn zip_shader_meta(archive: &mut zip::ZipArchive) -> FileMeta { + if has_shaders_folder(archive.file_names().map(str::to_string)) { + FileMeta::default() + } else { + no_shaders() + } +} + +fn folder_shader_meta(dir: &Path) -> FileMeta { + let is_dir = |path: &Path| fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()); + if is_dir(&dir.join("shaders")) { + return FileMeta::default(); + } + let nested = fs::read_dir(dir) + .ok() + .into_iter() + .flatten() + .flatten() + .any(|entry| { + entry.file_type().is_ok_and(|t| t.is_dir()) && is_dir(&entry.path().join("shaders")) + }); + if nested { + FileMeta::default() + } else { + no_shaders() + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::io::Write as _; + + use super::*; + + /// Writes a zip with `entries` (name, bytes) to `path`. + pub(crate) fn write_zip(path: &Path, entries: &[(&str, &[u8])]) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut zip = zip::ZipWriter::new(File::create(path).unwrap()); + for (name, bytes) in entries { + zip.start_file(*name, zip::write::FileOptions::default()) + .unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap(); + } + + pub(crate) fn png(width: u32, height: u32) -> Vec { + let image = image::RgbaImage::from_pixel(width, height, image::Rgba([10, 20, 30, 255])); + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .unwrap(); + bytes + } + + #[test] + fn lists_mods_with_metadata_and_icons() { + let dir = tempfile::tempdir().unwrap(); + let dirs = InstanceDirs::new(dir.path()); + let mods = dirs.content_dir(ContentKind::Mod); + let icon = png(128, 128); + write_zip( + &mods.join("sodium.jar"), + &[ + ( + "fabric.mod.json", + br#"{"id": "sodium", "name": "Sodium", "version": "0.5.13", "authors": ["JellySquid"], "icon": "assets/sodium/icon.png"}"#, + ), + ("assets/sodium/icon.png", &icon), + ], + ); + write_zip( + &mods.join("Forge Mod.jar.disabled"), + &[ + ( + "META-INF/mods.toml", + b"modLoader=\"javafml\"\n[[mods]]\nmodId=\"jei\"\ndisplayName=\"JEI\"\nversion=\"${file.jarVersion}\"\n", + ), + ("META-INF/MANIFEST.MF", b"Implementation-Version: 15.2.0\n"), + ], + ); + write_zip( + &mods.join("optifine-hd.jar"), + &[("net/optifine/Config.class", b"x")], + ); + write_zip(&mods.join("empty.jar"), &[("readme.txt", b"x")]); + fs::write(mods.join("broken.jar"), b"not a zip").unwrap(); + fs::write(mods.join(".lodestone-download.part"), b"x").unwrap(); + fs::write(mods.join("notes.txt"), b"x").unwrap(); + fs::create_dir_all(mods.join("folder.jar")).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(mods.join("sodium.jar"), mods.join("link.jar")).unwrap(); + + let files = scan(&dirs, ContentKind::Mod).unwrap(); + let names: Vec<&str> = files.iter().map(|f| f.file_name.as_str()).collect(); + assert_eq!( + names, + [ + "broken.jar", + "empty.jar", + "Forge Mod.jar.disabled", + "optifine-hd.jar", + "sodium.jar" + ] + ); + let sodium = &files[4]; + assert_eq!(sodium.meta.id.as_deref(), Some("sodium")); + assert_eq!(sodium.display_name(), "Sodium"); + assert_eq!(sodium.meta.version.as_deref(), Some("0.5.13")); + assert_eq!(sodium.meta.mod_formats, [ModFormat::Fabric]); + let icon = sodium.meta.icon.as_ref().unwrap(); + assert_eq!((icon.width(), icon.height()), (64, 64)); + assert!(sodium.enabled_by_name()); + let jei = &files[2]; + assert!(!jei.enabled_by_name()); + assert_eq!(jei.meta.version.as_deref(), Some("15.2.0")); + assert_eq!(jei.meta.mod_formats, [ModFormat::Forge]); + assert!(files[3].meta.optifine); + assert!(files[3].meta.problem.is_none()); + assert!( + files[1] + .meta + .problem + .as_deref() + .unwrap() + .contains("no mod metadata") + ); + assert!( + files[0] + .meta + .problem + .as_deref() + .unwrap() + .contains("valid jar") + ); + assert_eq!(files[1].display_name(), "empty"); + } + + #[test] + fn lists_resource_packs_as_zips_and_folders() { + let dir = tempfile::tempdir().unwrap(); + let dirs = InstanceDirs::new(dir.path()); + let packs_dir = dirs.content_dir(ContentKind::ResourcePack); + write_zip( + &packs_dir.join("Faithful.zip"), + &[ + ( + "pack.mcmeta", + r#"{"pack": {"pack_format": 15, "description": "§aFaithful §r32x"}}"# + .as_bytes(), + ), + ("pack.png", &png(16, 16)), + ], + ); + write_zip( + &packs_dir.join("Nested.zip"), + &[( + "Nested/pack.mcmeta", + br#"{"pack": {"pack_format": 15, "description": ""}}"#, + )], + ); + write_zip(&packs_dir.join("Nothing.zip"), &[("assets/x.png", b"x")]); + fs::create_dir_all(packs_dir.join("Folder pack/assets")).unwrap(); + fs::write( + packs_dir.join("Folder pack/pack.mcmeta"), + br#"{"pack": {"pack_format": 34, "supported_formats": [34, 42], "description": {"text": "Folder"}}}"#, + ) + .unwrap(); + let files = scan(&dirs, ContentKind::ResourcePack).unwrap(); + let names: Vec<&str> = files.iter().map(|f| f.file_name.as_str()).collect(); + assert_eq!( + names, + ["Faithful.zip", "Folder pack", "Nested.zip", "Nothing.zip"] + ); + assert_eq!(files[0].meta.description.as_deref(), Some("Faithful 32x")); + assert_eq!(files[0].meta.formats.unwrap().min.major, 15); + assert!(files[0].meta.icon.is_some()); + assert_eq!(files[0].display_name(), "Faithful"); + assert!(files[1].is_dir); + assert_eq!(files[1].display_name(), "Folder pack"); + assert_eq!(files[1].meta.formats.unwrap().max.major, 42); + assert!( + files[2] + .meta + .problem + .as_deref() + .unwrap() + .contains("inside a folder") + ); + assert!( + files[3] + .meta + .problem + .as_deref() + .unwrap() + .contains("no pack.mcmeta") + ); + } + + #[test] + fn lists_shader_packs() { + let dir = tempfile::tempdir().unwrap(); + let dirs = InstanceDirs::new(dir.path()); + let shaders = dirs.content_dir(ContentKind::ShaderPack); + write_zip(&shaders.join("BSL.zip"), &[("shaders/composite.fsh", b"x")]); + write_zip( + &shaders.join("Nested.zip"), + &[("Nested-master/shaders/final.fsh", b"x")], + ); + write_zip( + &shaders.join("Deep.zip"), + &[("a/b/shaders/final.fsh", b"x")], + ); + fs::create_dir_all(shaders.join("Folder/shaders")).unwrap(); + fs::write(shaders.join("Folder/shaders/final.fsh"), b"x").unwrap(); + fs::write(shaders.join("BSL.zip.txt"), b"options").unwrap(); + let files = scan(&dirs, ContentKind::ShaderPack).unwrap(); + let names: Vec<&str> = files.iter().map(|f| f.file_name.as_str()).collect(); + assert_eq!(names, ["BSL.zip", "Deep.zip", "Folder", "Nested.zip"]); + assert!(files[0].meta.problem.is_none()); + assert!(files[1].meta.problem.is_some()); + assert!(files[2].meta.problem.is_none()); + assert!(files[3].meta.problem.is_none()); + } +} diff --git a/launcher/crates/launcher-core/src/content/shaders.rs b/launcher/crates/launcher-core/src/content/shaders.rs new file mode 100644 index 000000000..9fce0afdc --- /dev/null +++ b/launcher/crates/launcher-core/src/content/shaders.rs @@ -0,0 +1,406 @@ +//! Shader packs need a shader mod: Iris (Fabric, Quilt, and NeoForge on newer versions), +//! Oculus (its Forge and NeoForge port) or OptiFine. This module finds which one an +//! instance has, which one could be installed, and reads and writes the pack it uses. +//! +//! The selected pack lives in the shader mod's own config, a Java `.properties` file: +//! - Iris: `config/iris.properties`, `shaderPack=` (empty +//! or missing for none) and `enableShaders=true|false`; +//! - Oculus: `config/oculus.properties`, the same keys; +//! - OptiFine: `optionsshaders.txt` in the game folder, `shaderPack=`, where `OFF` +//! is no shaders and `(internal)` the built-in ones. +//! +//! The files are edited with [`PropertiesDocument`], which keeps every other key, comment +//! and line as it was. OptiFine is never downloaded: it isn't on Modrinth and its licence +//! doesn't allow it. + +use std::{fs, io, path::Path}; + +use lodestone_minecraft::{modrinth::RawVersion, properties::PropertiesDocument}; + +use super::{ + ContentError, ContentKind, Modrinth, ModrinthError, Target, install::pick_version, io_error, + provenance::Provenance, scan::ScannedFile, +}; +use crate::{instances::Loader, storage}; + +/// Iris's Modrinth project. +pub const IRIS_PROJECT: &str = "YL57xq9U"; +/// Oculus's Modrinth project. +pub const OCULUS_PROJECT: &str = "GchcoXML"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShaderLoader { + Iris, + Oculus, + OptiFine, +} + +impl ShaderLoader { + pub fn label(self) -> &'static str { + match self { + ShaderLoader::Iris => "Iris", + ShaderLoader::Oculus => "Oculus", + ShaderLoader::OptiFine => "OptiFine", + } + } + + /// The config file that holds the selected pack, relative to the game folder. + pub fn config_file(self) -> &'static str { + match self { + ShaderLoader::Iris => "config/iris.properties", + ShaderLoader::Oculus => "config/oculus.properties", + ShaderLoader::OptiFine => "optionsshaders.txt", + } + } + + /// The Modrinth project, for the mods that are on Modrinth. + pub fn project(self) -> Option<&'static str> { + match self { + ShaderLoader::Iris => Some(IRIS_PROJECT), + ShaderLoader::Oculus => Some(OCULUS_PROJECT), + ShaderLoader::OptiFine => None, + } + } +} + +/// The shader mod among the enabled mods, if any. Iris wins over Oculus, and both over +/// OptiFine, when several are there (the game would crash anyway). +pub fn detect(mods: &[ScannedFile], provenance: &Provenance) -> Option { + let mut found = Vec::new(); + for file in mods + .iter() + .filter(|f| f.kind == ContentKind::Mod && f.enabled_by_name()) + { + let project = provenance + .get(ContentKind::Mod, &file.file_name) + .and_then(|e| e.modrinth.as_ref()) + .map(|m| m.project_id.as_str()); + let id = file.meta.id.as_deref(); + if id == Some("iris") || project == Some(IRIS_PROJECT) { + found.push(ShaderLoader::Iris); + } else if id == Some("oculus") || project == Some(OCULUS_PROJECT) { + found.push(ShaderLoader::Oculus); + } else if file.meta.optifine + || (file.meta.mod_formats.is_empty() + && file.file_name.to_ascii_lowercase().starts_with("optifine")) + { + found.push(ShaderLoader::OptiFine); + } + } + [ + ShaderLoader::Iris, + ShaderLoader::Oculus, + ShaderLoader::OptiFine, + ] + .into_iter() + .find(|loader| found.contains(loader)) +} + +/// The pack a shader mod uses. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ShaderSelection { + /// The file or folder name in `shaderpacks/`, when shaders are on. + pub pack: Option, + /// OptiFine's built-in shaders are on. + pub internal: bool, +} + +fn read_document(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => Ok(PropertiesDocument::from_bytes(&bytes).0), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(PropertiesDocument::default()), + Err(err) => Err(io_error(path)(err)), + } +} + +/// The pack `loader` uses in `game_dir`. +pub fn read_selection( + game_dir: &Path, + loader: ShaderLoader, +) -> Result { + let document = read_document(&game_dir.join(loader.config_file()))?; + let pack = document + .get("shaderPack") + .map(str::trim) + .unwrap_or_default(); + Ok(match loader { + ShaderLoader::OptiFine => match pack { + "" | "OFF" => ShaderSelection::default(), + "(internal)" => ShaderSelection { + pack: None, + internal: true, + }, + name => ShaderSelection { + pack: Some(name.to_string()), + internal: false, + }, + }, + ShaderLoader::Iris | ShaderLoader::Oculus => { + let enabled = document.get("enableShaders").map(str::trim) != Some("false"); + ShaderSelection { + pack: (enabled && !pack.is_empty() && pack != "(internal)") + .then(|| pack.to_string()), + internal: false, + } + } + }) +} + +/// Makes `loader` use `pack` (a name in `shaderpacks/`), or no shaders for `None`. Only +/// the selection keys change; the file is created if it doesn't exist. +pub fn write_selection( + game_dir: &Path, + loader: ShaderLoader, + pack: Option<&str>, +) -> Result<(), ContentError> { + let path = game_dir.join(loader.config_file()); + let mut document = read_document(&path)?; + match loader { + ShaderLoader::OptiFine => document.set("shaderPack", pack.unwrap_or("OFF")), + ShaderLoader::Iris | ShaderLoader::Oculus => match pack { + Some(pack) => { + document.set("shaderPack", pack); + document.set("enableShaders", "true"); + } + None => document.set("enableShaders", "false"), + }, + } + storage::write_atomic(&path, &document.to_bytes()).map_err(io_error(&path)) +} + +/// The shader mods that can be installed for a loader, in the order they are tried. +pub fn candidates(loader: Loader) -> &'static [ShaderLoader] { + match loader { + Loader::Fabric | Loader::Quilt => &[ShaderLoader::Iris], + // Iris has NeoForge builds for newer versions; Oculus for older ones. + Loader::NeoForge => &[ShaderLoader::Iris, ShaderLoader::Oculus], + Loader::Forge => &[ShaderLoader::Oculus], + Loader::None => &[], + } +} + +/// What the Shaders section can offer when the instance has no shader mod. +#[derive(Clone, Debug)] +pub enum ShaderModOffer { + /// Install `loader` at `version`; its required mods (Sodium, Embeddium) come with it. + Install { + loader: ShaderLoader, + version: Box, + }, + /// Shaders need a mod loader; the instance is vanilla. + NeedsModLoader, + /// No shader mod on Modrinth runs on this loader and version. + Unavailable { reason: String }, +} + +/// Finds the shader mod to offer for `target`, asking Modrinth which of the +/// [`candidates`] has a build for the instance's loader and Minecraft version. +pub async fn find_shader_mod( + api: &Modrinth, + target: &Target, +) -> Result { + let candidates = candidates(target.loader); + if candidates.is_empty() { + return Ok(ShaderModOffer::NeedsModLoader); + } + let loaders = target.accepted_loaders(ContentKind::Mod); + for &loader in candidates { + let Some(project) = loader.project() else { + continue; + }; + let versions = match api + .versions( + project, + &loaders, + std::slice::from_ref(&target.game_version), + ) + .await + { + Ok(versions) => versions, + Err(ModrinthError::NotFound(_)) => continue, + Err(err) => return Err(err.into()), + }; + if let Some(version) = pick_version(target, ContentKind::Mod, versions) { + return Ok(ShaderModOffer::Install { + loader, + version: Box::new(version), + }); + } + } + let names: Vec<&str> = candidates.iter().map(|l| l.label()).collect(); + Ok(ShaderModOffer::Unavailable { + reason: format!( + "{} has no build for {} on Minecraft {}.", + names.join(" or "), + target.loader.label(), + target.game_version + ), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{InstanceDirs, scan}; + + #[test] + fn iris_and_oculus_keep_unknown_keys() { + for loader in [ShaderLoader::Iris, ShaderLoader::Oculus] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(loader.config_file()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = "#This file stores configuration options for Iris\n#Sat Nov 08 12:00:00 CET 2026\ncolorSpace=SRGB\nenableShaders=true\nshaderPack=BSL_v8.2.09.zip\nmaxShadowRenderDistance=32\nsomeFutureKey=keep me\n"; + fs::write(&path, original).unwrap(); + assert_eq!( + read_selection(dir.path(), loader).unwrap().pack.as_deref(), + Some("BSL_v8.2.09.zip") + ); + write_selection(dir.path(), loader, Some("Complementary Reimagined r5.zip")).unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert_eq!( + text, + original.replace( + "shaderPack=BSL_v8.2.09.zip", + "shaderPack=Complementary Reimagined r5.zip" + ) + ); + write_selection(dir.path(), loader, None).unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("enableShaders=false\n")); + assert!(text.contains("someFutureKey=keep me\n")); + assert!(text.contains("#This file stores configuration options for Iris\n")); + assert_eq!( + read_selection(dir.path(), loader).unwrap(), + ShaderSelection::default() + ); + } + } + + #[test] + fn iris_selection_is_created_and_escaped() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + read_selection(dir.path(), ShaderLoader::Iris).unwrap(), + ShaderSelection::default() + ); + write_selection(dir.path(), ShaderLoader::Iris, Some("Pack: ünï=1.zip")).unwrap(); + let text = fs::read_to_string(dir.path().join("config/iris.properties")).unwrap(); + assert_eq!( + text, + "shaderPack=Pack\\: \\u00FCn\\u00EF\\=1.zip\nenableShaders=true\n" + ); + assert_eq!( + read_selection(dir.path(), ShaderLoader::Iris) + .unwrap() + .pack + .as_deref(), + Some("Pack: ünï=1.zip") + ); + // Iris treats a missing enableShaders as on, and an empty pack as none + fs::write(dir.path().join("config/iris.properties"), "shaderPack=\n").unwrap(); + assert_eq!( + read_selection(dir.path(), ShaderLoader::Iris).unwrap().pack, + None + ); + } + + #[test] + fn optifine_selection() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("optionsshaders.txt"); + fs::write( + &path, + "shaderPack=(internal)\nantialiasingLevel=0\noldLighting=default\n", + ) + .unwrap(); + assert_eq!( + read_selection(dir.path(), ShaderLoader::OptiFine).unwrap(), + ShaderSelection { + pack: None, + internal: true + } + ); + write_selection(dir.path(), ShaderLoader::OptiFine, Some("SEUS.zip")).unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "shaderPack=SEUS.zip\nantialiasingLevel=0\noldLighting=default\n" + ); + write_selection(dir.path(), ShaderLoader::OptiFine, None).unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "shaderPack=OFF\nantialiasingLevel=0\noldLighting=default\n" + ); + assert_eq!( + read_selection(dir.path(), ShaderLoader::OptiFine).unwrap(), + ShaderSelection::default() + ); + } + + #[test] + fn detects_the_shader_mod() { + use crate::content::scan::tests::write_zip; + let dir = tempfile::tempdir().unwrap(); + let dirs = InstanceDirs::new(dir.path()); + let mods = dirs.content_dir(ContentKind::Mod); + let detect_now = || { + let files = scan::scan(&dirs, ContentKind::Mod).unwrap(); + detect(&files, &Provenance::default()) + }; + write_zip( + &mods.join("sodium.jar"), + &[("fabric.mod.json", br#"{"id": "sodium"}"#)], + ); + assert_eq!(detect_now(), None); + write_zip( + &mods.join("OptiFine_1.20.1_HD_U_I6.jar"), + &[("net/optifine/Config.class", b"")], + ); + assert_eq!(detect_now(), Some(ShaderLoader::OptiFine)); + write_zip( + &mods.join("oculus.jar"), + &[("META-INF/mods.toml", b"[[mods]]\nmodId=\"oculus\"\n")], + ); + assert_eq!(detect_now(), Some(ShaderLoader::Oculus)); + write_zip( + &mods.join("iris.jar.disabled"), + &[("fabric.mod.json", br#"{"id": "iris"}"#)], + ); + // a disabled Iris doesn't count + assert_eq!(detect_now(), Some(ShaderLoader::Oculus)); + fs::rename(mods.join("iris.jar.disabled"), mods.join("iris.jar")).unwrap(); + assert_eq!(detect_now(), Some(ShaderLoader::Iris)); + // a jar Modrinth knows as Iris counts even without readable metadata + let dir = tempfile::tempdir().unwrap(); + let dirs = InstanceDirs::new(dir.path()); + write_zip( + &dirs.content_dir(ContentKind::Mod).join("x.jar"), + &[("a", b"")], + ); + let files = scan::scan(&dirs, ContentKind::Mod).unwrap(); + let mut provenance = Provenance::default(); + provenance.insert( + ContentKind::Mod, + "x.jar", + crate::content::provenance::Entry { + modrinth: Some(crate::content::provenance::ModrinthSource { + project_id: IRIS_PROJECT.into(), + ..Default::default() + }), + ..Default::default() + }, + ); + assert_eq!(detect(&files, &provenance), Some(ShaderLoader::Iris)); + } + + #[test] + fn candidates_per_loader() { + assert_eq!(candidates(Loader::Fabric), [ShaderLoader::Iris]); + assert_eq!(candidates(Loader::Quilt), [ShaderLoader::Iris]); + assert_eq!( + candidates(Loader::NeoForge), + [ShaderLoader::Iris, ShaderLoader::Oculus] + ); + assert_eq!(candidates(Loader::Forge), [ShaderLoader::Oculus]); + assert!(candidates(Loader::None).is_empty()); + } +} diff --git a/launcher/crates/launcher-core/src/export.rs b/launcher/crates/launcher-core/src/export.rs index 80a1a07f8..07fae89b3 100644 --- a/launcher/crates/launcher-core/src/export.rs +++ b/launcher/crates/launcher-core/src/export.rs @@ -2,11 +2,10 @@ //! //! The pack is `modrinth.index.json` (format 1: the Minecraft version and the loader //! as `dependencies`) and an `overrides/` folder with the instance's mods, configs, -//! resource packs, shader packs and options. A mod that is known to be on Modrinth -//! (its SHA-512 is in the map the caller passes, with its CDN URL) is listed in the -//! index instead, so the pack downloads it. Content management, which knows where -//! mods came from, is a later milestone; until then the map is empty and every mod -//! goes into the overrides. +//! resource packs, shader packs and options. A mod, resource pack or shader pack that +//! came from Modrinth (its SHA-512 is in the map the caller passes, from the instance's +//! `content.json`) is listed in the index with its CDN URL instead, so the pack +//! downloads it rather than carrying a copy. //! //! See . @@ -23,6 +22,7 @@ use lodestone_minecraft::mrpack::{ }; use crate::{ + content::{ContentKind, provenance::Provenance}, game::loaders::{LoaderKind, forge::forge_display}, instances::Instance, }; @@ -76,12 +76,54 @@ pub fn dependency_version(kind: LoaderKind, version: &str, game_version: &str) - } } -/// Plans the pack of `instance`, whose game folder is `game_dir`. `on_modrinth` maps -/// a mod's SHA-512 to its Modrinth CDN URL. +/// A file on Modrinth's CDN. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModrinthFile { + pub url: String, + /// The project's `server_side`: `required`, `optional`, `unsupported` or `unknown`. + pub server_side: String, +} + +/// The files of an instance that came from Modrinth, by SHA-512, from its +/// `content.json`. +pub fn modrinth_files(instance_dir: &Path) -> HashMap { + Provenance::load(instance_dir) + .download_urls() + .into_iter() + .map(|(sha512, (url, source))| { + ( + sha512, + ModrinthFile { + url, + server_side: source.server_side, + }, + ) + }) + .collect() +} + +/// Whether a file at `relative` can be an index entry: a mod jar, or a resource or +/// shader pack zip, directly in its folder. +fn index_candidate(relative: &str) -> Option { + let (dir, name) = relative.split_once('/')?; + if name.contains('/') { + return None; + } + let lower = name.to_ascii_lowercase(); + match dir { + "mods" if lower.ends_with(".jar") => Some(ContentKind::Mod), + "resourcepacks" if lower.ends_with(".zip") => Some(ContentKind::ResourcePack), + "shaderpacks" if lower.ends_with(".zip") => Some(ContentKind::ShaderPack), + _ => None, + } +} + +/// Plans the pack of `instance`, whose game folder is `game_dir`. `on_modrinth` maps a +/// file's SHA-512 to where it is on Modrinth. pub fn plan( instance: &Instance, game_dir: &Path, - on_modrinth: &HashMap, + on_modrinth: &HashMap, ) -> Result { let mut files = Vec::new(); let mut overrides = Vec::new(); @@ -105,22 +147,28 @@ pub fn plan( continue; } } - let is_mod = relative.starts_with("mods/") && relative.ends_with(".jar"); - if is_mod { + if let Some(kind) = index_candidate(&relative) { let sha512 = lodestone_minecraft::hash::sha512_of(&path).map_err(io_error(&path))?; - if let Some(url) = on_modrinth.get(&sha512) { + if let Some(file) = on_modrinth.get(&sha512) { let sha1 = lodestone_minecraft::hash::sha1_of(&path).map_err(io_error(&path))?; let size = fs::metadata(&path).map_err(io_error(&path))?.len(); + let server = match (kind, file.server_side.as_str()) { + (ContentKind::Mod, "unsupported") => "unsupported", + (ContentKind::Mod, "optional") => "optional", + (ContentKind::Mod, _) => "required", + // Servers have no resource or shader packs. + _ => "unsupported", + }; files.push(PackIndexFile { path: relative, hashes: PackFileHashes { sha1, sha512 }, env: PackFileEnv { client: "required".into(), - server: "required".into(), + server: server.into(), }, - downloads: vec![url.clone()], + downloads: vec![file.url.clone()], file_size: size, }); continue; @@ -264,7 +312,10 @@ mod tests { lodestone_minecraft::hash::sha512_of(&game.join("mods/known.jar")).unwrap(); let on_modrinth = HashMap::from([( known_sha512.clone(), - "https://cdn.modrinth.com/data/abc/versions/def/known.jar".to_string(), + ModrinthFile { + url: "https://cdn.modrinth.com/data/abc/versions/def/known.jar".to_string(), + server_side: "required".into(), + }, )]); let plan = plan(&instance(), &game, &on_modrinth).unwrap(); assert_eq!(plan.index.dependencies["minecraft"], "1.20.1"); @@ -308,6 +359,119 @@ mod tests { assert!(zip.by_name("overrides/saves/World/level.dat").is_err()); } + #[test] + fn modrinth_files_come_from_provenance() { + use crate::content::provenance::{Entry, ModrinthSource}; + let dir = tempfile::tempdir().unwrap(); + let game = dir.path().join("minecraft"); + for (path, body) in [ + ("mods/sodium.jar", &b"sodium"[..]), + ("mods/modmenu.jar", b"client only"), + ("mods/changed.jar", b"edited by hand"), + ("resourcepacks/Faithful.zip", b"pack"), + ("shaderpacks/BSL.zip", b"shader"), + ("shaderpacks/Mine.zip", b"my own"), + ] { + fs::create_dir_all(game.join(path).parent().unwrap()).unwrap(); + fs::write(game.join(path), body).unwrap(); + } + let mut provenance = Provenance::default(); + let mut add = |kind, name: &str, body: &[u8], server_side: &str| { + let tmp = dir.path().join("hash-me"); + fs::write(&tmp, body).unwrap(); + provenance.insert( + kind, + name, + Entry { + sha512: lodestone_minecraft::hash::sha512_of(&tmp).unwrap(), + modrinth: Some(ModrinthSource { + project_id: name.into(), + url: Some(format!("https://cdn.modrinth.com/data/{name}")), + server_side: server_side.into(), + ..ModrinthSource::default() + }), + ..Entry::default() + }, + ); + }; + add(ContentKind::Mod, "sodium.jar", b"sodium", "optional"); + add( + ContentKind::Mod, + "modmenu.jar", + b"client only", + "unsupported", + ); + // the entry is for the file as it was downloaded, not as it is now + add( + ContentKind::Mod, + "changed.jar", + b"as downloaded", + "required", + ); + add( + ContentKind::ResourcePack, + "Faithful.zip", + b"pack", + "unsupported", + ); + add(ContentKind::ShaderPack, "BSL.zip", b"shader", "unknown"); + provenance.save(dir.path()).unwrap(); + + let plan = plan(&instance(), &game, &modrinth_files(dir.path())).unwrap(); + let index: Vec<(&str, &str, &str)> = plan + .index + .files + .iter() + .map(|f| { + ( + f.path.as_str(), + f.env.server.as_str(), + f.downloads[0].as_str(), + ) + }) + .collect(); + assert_eq!( + index, + [ + ( + "mods/modmenu.jar", + "unsupported", + "https://cdn.modrinth.com/data/modmenu.jar" + ), + ( + "mods/sodium.jar", + "optional", + "https://cdn.modrinth.com/data/sodium.jar" + ), + ( + "resourcepacks/Faithful.zip", + "unsupported", + "https://cdn.modrinth.com/data/Faithful.zip" + ), + ( + "shaderpacks/BSL.zip", + "unsupported", + "https://cdn.modrinth.com/data/BSL.zip" + ), + ] + ); + assert!(plan.index.files.iter().all(|f| f.env.client == "required")); + let overrides: Vec<&str> = plan.overrides.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!( + overrides, + [ + "overrides/mods/changed.jar", + "overrides/shaderpacks/Mine.zip" + ] + ); + // the index's hashes are the files' own + let sodium = &plan.index.files[1]; + assert_eq!( + sodium.hashes.sha1, + lodestone_minecraft::hash::sha1_of(&game.join("mods/sodium.jar")).unwrap() + ); + } + #[test] fn vanilla_and_other_loaders() { let dir = tempfile::tempdir().unwrap(); diff --git a/launcher/crates/launcher-core/src/lib.rs b/launcher/crates/launcher-core/src/lib.rs index 74150ef0c..957141592 100644 --- a/launcher/crates/launcher-core/src/lib.rs +++ b/launcher/crates/launcher-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod accounts; pub mod auth; +pub mod content; pub mod export; pub mod game; pub mod http; diff --git a/launcher/crates/launcher-core/tests/content.rs b/launcher/crates/launcher-core/tests/content.rs new file mode 100644 index 000000000..720a13b7d --- /dev/null +++ b/launcher/crates/launcher-core/tests/content.rs @@ -0,0 +1,816 @@ +//! Content management against a loopback Modrinth mock: matching files by SHA-1, +//! installing with dependencies, replacing older versions, damaged downloads, updates, +//! links, the rate limit, and the shader mod offer per loader and version. + +mod modrinth; + +use std::{fs, path::Path}; + +use futures::executor::block_on; +use launcher_core::{ + content::{ + ContentError, ContentKind, InstanceDirs, ModrinthError, Target, + install::{self, InstallProgress, InstallRequest}, + listing, + modrinth::USER_AGENT, + options::{self, PackChange}, + provenance::Provenance, + shaders::{self, ShaderLoader, ShaderModOffer}, + }, + game::download::{CancelToken, Downloader, recording_sleep}, + http, + instances::Loader, +}; +use lodestone_minecraft::modrinth::{ClientProjectType, ClientSearchParams}; +use modrinth::{FakeVersion, ModrinthMock}; + +const IRIS: &str = shaders::IRIS_PROJECT; +const OCULUS: &str = shaders::OCULUS_PROJECT; +const SODIUM: &str = "AANobbMI"; +const EMBEDDIUM: &str = "sk9rgfiA"; +const FABRIC_API: &str = "P7dR8mSH"; + +fn downloader() -> Downloader { + Downloader::new(http::download_client().unwrap(), recording_sleep().0) +} + +fn instance(dir: &Path) -> InstanceDirs { + let dirs = InstanceDirs::new(dir.join("instance")); + fs::create_dir_all(&dirs.game).unwrap(); + dirs +} + +fn names(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .map(|entries| { + entries + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + names.sort(); + names +} + +/// Iris, Sodium and Fabric API for Fabric 1.20.1, as on Modrinth. +fn fabric_mods(mock: &ModrinthMock) { + mock.project(IRIS, "iris", "mod") + .project(SODIUM, "sodium", "mod") + .project(FABRIC_API, "fabric-api", "mod") + .version( + FakeVersion::new( + "iris176", + IRIS, + "1.7.6+1.20.1", + &["fabric", "quilt"], + &["1.20.1"], + "2025-01-02T00:00:00Z", + ) + .requires(Some(SODIUM), Some("sod0513")), + ) + .version( + FakeVersion::new( + "sod0513", + SODIUM, + "0.5.13", + &["fabric", "quilt"], + &["1.20.1"], + "2024-10-01T00:00:00Z", + ) + .requires(Some(FABRIC_API), None), + ) + .version(FakeVersion::new( + "sod0600", + SODIUM, + "0.6.0", + &["fabric"], + &["1.21.1"], + "2025-01-01T00:00:00Z", + )) + .version(FakeVersion::new( + "fapi-old", + FABRIC_API, + "0.90.0", + &["fabric"], + &["1.20.1"], + "2023-01-01T00:00:00Z", + )) + .version(FakeVersion::new( + "fapi-rel", + FABRIC_API, + "0.92.2", + &["fabric"], + &["1.20.1"], + "2024-06-01T00:00:00Z", + )) + .version( + FakeVersion::new( + "fapi-beta", + FABRIC_API, + "0.93.0-beta", + &["fabric"], + &["1.20.1"], + "2024-12-01T00:00:00Z", + ) + .kind("beta"), + ) + .version(FakeVersion::new( + "fapi-121", + FABRIC_API, + "0.110.0", + &["fabric"], + &["1.21.1"], + "2025-02-01T00:00:00Z", + )); +} + +fn install_version( + mock: &ModrinthMock, + dirs: &InstanceDirs, + target: &Target, + kind: ContentKind, + version_id: &str, +) -> Result { + let api = mock.client(); + block_on(async { + let version = api.version(version_id).await?; + let plan = install::plan( + &api, + dirs, + target, + InstallRequest { + kind, + version, + project: None, + install_dependencies: true, + force: false, + }, + ) + .await?; + install::execute( + &api, + &downloader(), + dirs, + target, + plan, + &InstallProgress::default(), + &CancelToken::new(), + ) + .await + }) +} + +#[test] +fn installs_required_dependencies_and_records_provenance() { + let mock = ModrinthMock::start(); + fabric_mods(&mock); + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let target = Target::new("1.20.1", Loader::Fabric); + let outcome = install_version(&mock, &dirs, &target, ContentKind::Mod, "iris176").unwrap(); + // Iris, the Sodium version it pins, and the newest Fabric API release (not the + // newer beta, nor the 1.21.1 build) + let installed: Vec<&str> = outcome + .installed + .iter() + .map(|(_, name)| name.as_str()) + .collect(); + assert_eq!( + installed, + [ + "YL57xq9U-1.7.6+1.20.1.jar", + "AANobbMI-0.5.13.jar", + "P7dR8mSH-0.92.2.jar" + ] + ); + assert_eq!(outcome.installed[0].0, "iris title"); + let mods = dirs.content_dir(ContentKind::Mod); + assert_eq!( + names(&mods), + [ + "AANobbMI-0.5.13.jar", + "P7dR8mSH-0.92.2.jar", + "YL57xq9U-1.7.6+1.20.1.jar" + ] + ); + assert_eq!( + fs::read(mods.join("AANobbMI-0.5.13.jar")).unwrap(), + b"AANobbMI 0.5.13" + ); + let provenance = Provenance::load(&dirs.instance); + let iris = provenance + .get(ContentKind::Mod, "YL57xq9U-1.7.6+1.20.1.jar") + .unwrap(); + let source = iris.modrinth.as_ref().unwrap(); + assert_eq!( + (source.project_id.as_str(), source.version_id.as_str()), + (IRIS, "iris176") + ); + assert_eq!(source.title, "iris title"); + assert_eq!( + source.url.as_deref(), + Some("https://cdn.modrinth.com/data/YL57xq9U/versions/iris176/YL57xq9U-1.7.6+1.20.1.jar") + ); + assert_eq!(iris.sha512.len(), 128); + // every request carried Lodestone's User-Agent + assert!( + mock.requests() + .iter() + .all(|r| r.user_agent.as_deref() == Some(USER_AGENT) + || r.path.starts_with("/cdn.modrinth.com")) + ); + + // installing again: nothing downloads, the installed dependencies are not planned again + let before = mock + .api_requests( + "GET", + "/cdn.modrinth.com/data/YL57xq9U/versions/iris176/YL57xq9U-1.7.6+1.20.1.jar", + ) + .len(); + let outcome = install_version(&mock, &dirs, &target, ContentKind::Mod, "iris176").unwrap(); + assert_eq!(outcome.installed.len(), 1); + assert_eq!( + mock.api_requests( + "GET", + "/cdn.modrinth.com/data/YL57xq9U/versions/iris176/YL57xq9U-1.7.6+1.20.1.jar" + ) + .len(), + before + ); +} + +#[test] +fn replaces_older_versions_and_keeps_them_disabled() { + let mock = ModrinthMock::start(); + fabric_mods(&mock); + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let target = Target::new("1.20.1", Loader::Fabric); + install_version(&mock, &dirs, &target, ContentKind::Mod, "fapi-old").unwrap(); + launcher_core::content::files::set_mod_enabled(&dirs, "P7dR8mSH-0.90.0.jar", false).unwrap(); + let outcome = install_version(&mock, &dirs, &target, ContentKind::Mod, "fapi-rel").unwrap(); + assert_eq!(outcome.removed, ["P7dR8mSH-0.90.0.jar.disabled"]); + assert_eq!( + names(&dirs.content_dir(ContentKind::Mod)), + ["P7dR8mSH-0.92.2.jar.disabled"] + ); + let provenance = Provenance::load(&dirs.instance); + assert_eq!(provenance.files.len(), 1); + assert_eq!( + provenance + .get(ContentKind::Mod, "P7dR8mSH-0.92.2.jar") + .unwrap() + .modrinth + .as_ref() + .unwrap() + .version_id, + "fapi-rel" + ); +} + +#[test] +fn refuses_versions_that_dont_fit_and_incompatible_mods() { + let mock = ModrinthMock::start(); + fabric_mods(&mock); + mock.project("OPTIF", "optifabric", "mod").version( + FakeVersion::new( + "optifabric1", + "OPTIF", + "1.0", + &["fabric"], + &["1.20.1"], + "2024-01-01T00:00:00Z", + ) + .depends(SODIUM, "incompatible"), + ); + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let target = Target::new("1.20.1", Loader::Fabric); + let err = install_version(&mock, &dirs, &target, ContentKind::Mod, "sod0600").unwrap_err(); + assert!( + err.to_string() + .contains("is for Minecraft 1.21.1, but this instance runs 1.20.1"), + "{err}" + ); + let forge = Target::new("1.20.1", Loader::Forge); + let err = install_version(&mock, &dirs, &forge, ContentKind::Mod, "sod0513").unwrap_err(); + assert!( + err.to_string().contains("this instance uses Forge"), + "{err}" + ); + install_version(&mock, &dirs, &target, ContentKind::Mod, "sod0513").unwrap(); + let err = install_version(&mock, &dirs, &target, ContentKind::Mod, "optifabric1").unwrap_err(); + assert!( + err.to_string() + .contains("incompatible with the installed sodium title"), + "{err}" + ); + assert!( + !names(&dirs.content_dir(ContentKind::Mod)) + .iter() + .any(|n| n.starts_with("OPTIF")) + ); +} + +#[test] +fn damaged_and_off_cdn_downloads_leave_nothing_behind() { + let mock = ModrinthMock::start(); + mock.project("BAD", "bad", "mod"); + let mut damaged = FakeVersion::new( + "bad1", + "BAD", + "1.0", + &["fabric"], + &["1.20.1"], + "2024-01-01T00:00:00Z", + ); + damaged.wrong_sha512 = true; + mock.version(damaged); + let mut elsewhere = FakeVersion::new( + "bad2", + "BAD", + "2.0", + &["fabric"], + &["1.20.1"], + "2024-02-01T00:00:00Z", + ); + elsewhere.url_override = Some("https://evil.example/x.jar".into()); + mock.version(elsewhere); + let mut traversal = FakeVersion::new( + "bad3", + "BAD", + "3.0", + &["fabric"], + &["1.20.1"], + "2024-03-01T00:00:00Z", + ); + traversal.filename = "../../../evil.txt".into(); + mock.version(traversal); + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let target = Target::new("1.20.1", Loader::Fabric); + let err = install_version(&mock, &dirs, &target, ContentKind::Mod, "bad1").unwrap_err(); + assert!(err.to_string().contains("damaged"), "{err}"); + let err = install_version(&mock, &dirs, &target, ContentKind::Mod, "bad2").unwrap_err(); + assert!( + err.to_string() + .contains("files only come from cdn.modrinth.com"), + "{err}" + ); + let err = install_version(&mock, &dirs, &target, ContentKind::Mod, "bad3").unwrap_err(); + assert!(err.to_string().contains("isn't a file name"), "{err}"); + assert!(names(&dirs.content_dir(ContentKind::Mod)).is_empty()); + assert!(!dir.path().join("evil.txt").exists()); +} + +#[test] +fn matches_unknown_files_by_sha1_in_one_batch() { + let mock = ModrinthMock::start(); + fabric_mods(&mock); + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let mods = dirs.content_dir(ContentKind::Mod); + fs::create_dir_all(&mods).unwrap(); + // a manual drop of the Sodium file, one of Fabric API, and a jar Modrinth doesn't know + fs::write(mods.join("sodium-manual.jar"), b"AANobbMI 0.5.13").unwrap(); + fs::write(mods.join("fabric-api.jar.disabled"), b"P7dR8mSH 0.90.0").unwrap(); + fs::write(mods.join("my-own.jar"), b"homemade").unwrap(); + let target = Target::new("1.20.1", Loader::Fabric); + let api = mock.client(); + let listing = block_on(listing::list(Some(&api), &dirs, &target, ContentKind::Mod)).unwrap(); + assert_eq!(listing.lookup_error, None); + let posts = mock.api_requests("POST", "/v2/version_files"); + assert_eq!(posts.len(), 1); + let body: serde_json::Value = serde_json::from_str(&posts[0].body).unwrap(); + assert_eq!(body["hashes"].as_array().unwrap().len(), 3); + let by_name = |name: &str| { + listing + .items + .iter() + .find(|i| i.file.file_name == name) + .unwrap() + .clone() + }; + let sodium = by_name("sodium-manual.jar"); + assert_eq!(sodium.display_name(), "sodium title"); + assert_eq!(sodium.display_version().as_deref(), Some("0.5.13")); + assert!(sodium.enabled); + let fabric_api = by_name("fabric-api.jar.disabled"); + assert!(!fabric_api.enabled); + assert_eq!(fabric_api.modrinth.unwrap().version_id, "fapi-old"); + let own = by_name("my-own.jar"); + assert!(own.modrinth.is_none()); + assert_eq!(own.display_name(), "my-own"); + + // the second listing hashes nothing new and asks Modrinth nothing + let listing2 = block_on(listing::list(Some(&api), &dirs, &target, ContentKind::Mod)).unwrap(); + assert_eq!(listing2.items, listing.items); + assert_eq!(mock.api_requests("POST", "/v2/version_files").len(), 1); + // enabling keeps the match (provenance is keyed without .disabled) + launcher_core::content::files::set_mod_enabled(&dirs, "fabric-api.jar.disabled", true).unwrap(); + let listing3 = block_on(listing::list(None, &dirs, &target, ContentKind::Mod)).unwrap(); + assert!( + listing3 + .items + .iter() + .any(|i| i.file.file_name == "fabric-api.jar" && i.modrinth.is_some()) + ); + + // updates: Fabric API has a newer release for 1.20.1; Sodium is the newest + let updates = block_on(listing::check_updates( + &api, + &dirs, + &target, + ContentKind::Mod, + )) + .unwrap(); + assert_eq!(updates.len(), 1); + assert_eq!(updates["fabric-api.jar"].id, "fapi-beta"); +} + +#[test] +fn offline_listing_still_works() { + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let mods = dirs.content_dir(ContentKind::Mod); + fs::create_dir_all(&mods).unwrap(); + fs::write(mods.join("a.jar"), b"a").unwrap(); + // a client pointed at a closed port + let api = launcher_core::content::Modrinth::with_urls( + http::default_client().unwrap(), + url::Url::parse("http://127.0.0.1:9/v2").unwrap(), + None, + ); + let target = Target::new("1.20.1", Loader::Fabric); + let listing = block_on(listing::list(Some(&api), &dirs, &target, ContentKind::Mod)).unwrap(); + assert_eq!(listing.items.len(), 1); + assert!(listing.lookup_error.unwrap().contains("isn't reachable")); + // nothing was marked as unknown, so the next listing asks again + assert!( + !Provenance::load(&dirs.instance) + .get(ContentKind::Mod, "a.jar") + .unwrap() + .looked_up + ); +} + +#[test] +fn resource_pack_and_shader_updates_keep_their_place() { + let mock = ModrinthMock::start(); + mock.project("FAITH", "faithful", "resourcepack") + .project("BSL", "bsl", "shader") + .version( + FakeVersion::new( + "f1", + "FAITH", + "1", + &["minecraft"], + &["1.20.1"], + "2024-01-01T00:00:00Z", + ) + .file("Faithful 1.zip", b"faithful one"), + ) + .version( + FakeVersion::new( + "f2", + "FAITH", + "2", + &["minecraft"], + &["1.20.1"], + "2024-02-01T00:00:00Z", + ) + .file("Faithful 2.zip", b"faithful two"), + ) + .version( + FakeVersion::new( + "b1", + "BSL", + "8.2", + &["iris", "optifine"], + &["1.20.1"], + "2024-01-01T00:00:00Z", + ) + .file("BSL 8.2.zip", b"bsl one") + // a shader pack that names Iris: not installed into shaderpacks + .requires(Some(IRIS), None), + ) + .version( + FakeVersion::new( + "b2", + "BSL", + "8.3", + &["iris", "optifine"], + &["1.20.1"], + "2024-02-01T00:00:00Z", + ) + .file("BSL 8.3.zip", b"bsl two"), + ); + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let mut target = Target::new("1.20.1", Loader::Fabric); + target.shader_loader = Some(ShaderLoader::Iris); + fs::write( + dirs.game.join("options.txt"), + "fov:0.5\nresourcePacks:[\"vanilla\",\"file/Other.zip\"]\n", + ) + .unwrap(); + + install_version(&mock, &dirs, &target, ContentKind::ResourcePack, "f1").unwrap(); + options::change_packs( + &dirs.game, + "1.20.1", + &PackChange::Enable { + name: "Faithful 1.zip".into(), + compatible: true, + }, + ) + .unwrap(); + options::change_packs( + &dirs.game, + "1.20.1", + &PackChange::Lower { + name: "Faithful 1.zip".into(), + }, + ) + .unwrap(); + let outcome = install_version(&mock, &dirs, &target, ContentKind::ResourcePack, "f2").unwrap(); + assert_eq!(outcome.removed, ["Faithful 1.zip"]); + assert_eq!( + fs::read_to_string(dirs.game.join("options.txt")).unwrap(), + "fov:0.5\nresourcePacks:[\"vanilla\",\"file/Faithful 2.zip\",\"file/Other.zip\"]\n" + ); + + let outcome = install_version(&mock, &dirs, &target, ContentKind::ShaderPack, "b1").unwrap(); + assert_eq!(outcome.installed.len(), 1); + shaders::write_selection(&dirs.game, ShaderLoader::Iris, Some("BSL 8.2.zip")).unwrap(); + install_version(&mock, &dirs, &target, ContentKind::ShaderPack, "b2").unwrap(); + assert_eq!( + names(&dirs.content_dir(ContentKind::ShaderPack)), + ["BSL 8.3.zip"] + ); + assert_eq!( + shaders::read_selection(&dirs.game, ShaderLoader::Iris) + .unwrap() + .pack + .as_deref(), + Some("BSL 8.3.zip") + ); + assert!(names(&dirs.content_dir(ContentKind::Mod)).is_empty()); + + // the listing shows the pack enabled, at its priority, with its source + let listing = block_on(listing::list( + Some(&mock.client()), + &dirs, + &target, + ContentKind::ResourcePack, + )) + .unwrap(); + let pack = &listing.items[0]; + assert_eq!(pack.file.file_name, "Faithful 2.zip"); + assert!(pack.enabled); + assert_eq!(pack.priority, Some(1)); + assert_eq!(pack.modrinth.as_ref().unwrap().version_id, "f2"); +} + +#[test] +fn offers_the_shader_mod_for_each_loader_and_version() { + let mock = ModrinthMock::start(); + mock.project(IRIS, "iris", "mod") + .project(OCULUS, "oculus", "mod") + .project(SODIUM, "sodium", "mod") + .project(EMBEDDIUM, "embeddium", "mod") + .version( + FakeVersion::new( + "iris-fabric", + IRIS, + "1.7.6+1.20.1", + &["fabric", "quilt"], + &["1.20.1"], + "2025-01-02T00:00:00Z", + ) + .requires(Some(SODIUM), None), + ) + .version( + FakeVersion::new( + "iris-neo", + IRIS, + "1.8.12+1.21.1-neoforge", + &["neoforge"], + &["1.21.1"], + "2025-03-01T00:00:00Z", + ) + .requires(Some(SODIUM), None), + ) + .version( + FakeVersion::new( + "iris-neo-beta", + IRIS, + "1.8.14-beta.1+1.21.1-neoforge", + &["neoforge"], + &["1.21.1"], + "2025-05-01T00:00:00Z", + ) + .kind("beta"), + ) + .version( + FakeVersion::new( + "oculus-120", + OCULUS, + "1.20.1-1.8.0", + &["forge", "neoforge"], + &["1.20.1"], + "2024-12-24T00:00:00Z", + ) + .requires(Some(EMBEDDIUM), None), + ) + .version(FakeVersion::new( + "sodium-fabric", + SODIUM, + "0.5.13", + &["fabric", "quilt"], + &["1.20.1"], + "2024-10-01T00:00:00Z", + )) + .version(FakeVersion::new( + "sodium-neo", + SODIUM, + "0.6.13", + &["neoforge"], + &["1.21.1"], + "2025-02-01T00:00:00Z", + )) + .version(FakeVersion::new( + "embeddium-120", + EMBEDDIUM, + "0.3.31", + &["forge"], + &["1.20.1"], + "2024-08-01T00:00:00Z", + )); + let api = mock.client(); + let offer = |loader, version: &str| { + block_on(shaders::find_shader_mod( + &api, + &Target::new(version, loader), + )) + .unwrap() + }; + let install = |offer: ShaderModOffer| match offer { + ShaderModOffer::Install { loader, version } => (loader, version.id), + other => panic!("{other:?}"), + }; + assert_eq!( + install(offer(Loader::Fabric, "1.20.1")), + (ShaderLoader::Iris, "iris-fabric".to_string()) + ); + assert_eq!( + install(offer(Loader::Quilt, "1.20.1")), + (ShaderLoader::Iris, "iris-fabric".to_string()) + ); + // the release, not the newer beta + assert_eq!( + install(offer(Loader::NeoForge, "1.21.1")), + (ShaderLoader::Iris, "iris-neo".to_string()) + ); + assert_eq!( + install(offer(Loader::Forge, "1.20.1")), + (ShaderLoader::Oculus, "oculus-120".to_string()) + ); + match offer(Loader::Forge, "1.21.1") { + ShaderModOffer::Unavailable { reason } => { + assert_eq!(reason, "Oculus has no build for Forge on Minecraft 1.21.1.") + } + other => panic!("{other:?}"), + } + match offer(Loader::NeoForge, "1.20.4") { + ShaderModOffer::Unavailable { reason } => { + assert!(reason.starts_with("Iris or Oculus"), "{reason}") + } + other => panic!("{other:?}"), + } + assert!(matches!( + offer(Loader::None, "1.21.1"), + ShaderModOffer::NeedsModLoader + )); + + // installing the Fabric offer brings Sodium; the listing then sees Iris + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let target = Target::new("1.20.1", Loader::Fabric); + let outcome = install_version(&mock, &dirs, &target, ContentKind::Mod, "iris-fabric").unwrap(); + assert_eq!(outcome.installed.len(), 2); + assert_eq!( + listing::shader_state(&dirs).unwrap().loader, + Some(ShaderLoader::Iris) + ); + // and on Forge, Oculus brings Embeddium + let dir = tempfile::tempdir().unwrap(); + let dirs = instance(dir.path()); + let forge = Target::new("1.20.1", Loader::Forge); + let outcome = install_version(&mock, &dirs, &forge, ContentKind::Mod, "oculus-120").unwrap(); + let installed: Vec<&str> = outcome.installed.iter().map(|(_, n)| n.as_str()).collect(); + assert_eq!( + installed, + ["GchcoXML-1.20.1-1.8.0.jar", "sk9rgfiA-0.3.31.jar"] + ); + assert_eq!( + listing::shader_state(&dirs).unwrap().loader, + Some(ShaderLoader::Oculus) + ); +} + +#[test] +fn resolves_links_and_rejects_others_without_asking() { + let mock = ModrinthMock::start(); + fabric_mods(&mock); + let api = mock.client(); + let target = block_on(api.resolve_link("https://modrinth.com/mod/sodium")).unwrap(); + assert_eq!(target.project.id, SODIUM); + assert!(target.version.is_none()); + let target = block_on(api.resolve_link("modrinth.com/mod/fabric-api/version/0.92.2")).unwrap(); + assert_eq!(target.version.unwrap().id, "fapi-rel"); + let err = block_on(api.resolve_link("https://modrinth.com/mod/missing")).unwrap_err(); + assert!(matches!(err, ModrinthError::NotFound(_))); + let sent = api.requests_sent(); + for bad in [ + "https://evil.com/mod/sodium", + "https://modrinth.com.evil.com/mod/sodium", + "https://modrinth.com/mod/../../v2/user", + "https://modrinth.com/mod/a%2F..%2Fx", + "https://modrinth.com/mod/sodium%3Fx=1", + "https://user:pass@modrinth.com/mod/sodium", + "javascript:alert(1)//modrinth.com/mod/x", + "file:///modrinth.com/mod/x", + "https://modrinth.com:8080/mod/sodium", + "https://modrinth.com/mod/sodium/version/1.0%00", + ] { + let err = block_on(api.resolve_link(bad)).unwrap_err(); + assert!(matches!(err, ModrinthError::Invalid(_)), "{bad}: {err:?}"); + } + assert_eq!(api.requests_sent(), sent); +} + +#[test] +fn the_rate_limit_fails_fast() { + let mock = ModrinthMock::start(); + fabric_mods(&mock); + let api = mock.client(); + mock.state().next_status = Some((429, vec![("X-Ratelimit-Reset".into(), "42".into())])); + let err = block_on(api.project("sodium")).unwrap_err(); + assert_eq!(err, ModrinthError::RateLimited { seconds: 42 }); + let sent = api.requests_sent(); + let err = block_on(api.project("iris")).unwrap_err(); + assert!(matches!(err, ModrinthError::RateLimited { .. })); + assert_eq!(api.requests_sent(), sent, "no request while rate limited"); + + // "none left" on a good answer also stops the next calls + let mock = ModrinthMock::start(); + fabric_mods(&mock); + let api = mock.client(); + mock.state().headers = vec![ + ("X-Ratelimit-Remaining".into(), "0".into()), + ("X-Ratelimit-Reset".into(), "10".into()), + ]; + assert_eq!(block_on(api.project("sodium")).unwrap().id, SODIUM); + assert!(matches!( + block_on(api.project("iris")), + Err(ModrinthError::RateLimited { .. }) + )); +} + +#[test] +fn searches_send_client_facets_and_are_cached() { + let mock = ModrinthMock::start(); + fabric_mods(&mock); + let api = mock.client(); + let target = Target::new("1.20.1", Loader::Quilt); + let params = ClientSearchParams::new( + Some("sodium".into()), + ClientProjectType::Mod, + Some(target.game_version.clone()), + target.search_loaders(ContentKind::Mod), + None, + Some(20), + None, + ) + .unwrap(); + let results = block_on(api.search(¶ms)).unwrap(); + assert_eq!(results.hits.len(), 3); + assert_eq!(results.hits[0].icon_url, None); + block_on(api.search(¶ms)).unwrap(); + let searches = mock.api_requests("GET", "/v2/search"); + assert_eq!(searches.len(), 1, "the second search came from the cache"); + let facets: Vec> = serde_json::from_str(&searches[0].query["facets"]).unwrap(); + assert_eq!( + facets, + [ + vec!["project_type:mod"], + vec!["categories:quilt", "categories:fabric"], + vec!["versions:1.20.1"], + vec!["client_side:required", "client_side:optional"], + ] + ); + assert_eq!(searches[0].query["offset"], "20"); + assert_eq!(searches[0].query["query"], "sodium"); +} diff --git a/launcher/crates/launcher-core/tests/modrinth/mod.rs b/launcher/crates/launcher-core/tests/modrinth/mod.rs new file mode 100644 index 000000000..fe103af15 --- /dev/null +++ b/launcher/crates/launcher-core/tests/modrinth/mod.rs @@ -0,0 +1,488 @@ +//! A mock of the Modrinth API and CDN on a loopback port the OS picks. Each test starts +//! its own. Projects and versions are added by the test; the mock answers the endpoints +//! the launcher uses from them, and records every request. +//! +//! Like `files/`, it is a small HTTP/1.1 server on `std::net`, one thread per connection, +//! closed after one response. + +#![allow(dead_code)] + +use std::{ + collections::HashMap, + io::{BufRead as _, BufReader, Read as _, Write as _}, + net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}, + sync::{ + Arc, Mutex, MutexGuard, + atomic::{AtomicBool, Ordering}, + }, + thread::JoinHandle, +}; + +use launcher_core::content::Modrinth; +use serde_json::{Value, json}; +use sha1::Digest as _; +use url::Url; + +#[derive(Clone, Debug)] +pub struct FakeProject { + pub id: String, + pub slug: String, + pub title: String, + pub project_type: String, + pub client_side: String, + pub server_side: String, +} + +#[derive(Clone, Debug)] +pub struct FakeVersion { + pub id: String, + pub project_id: String, + pub version_number: String, + pub version_type: String, + pub loaders: Vec, + pub game_versions: Vec, + pub date: String, + /// (project_id, version_id, dependency_type) + pub dependencies: Vec<(Option, Option, String)>, + pub filename: String, + pub bytes: Vec, + /// Sent instead of the real SHA-512, to test damaged downloads. + pub wrong_sha512: bool, + /// Sent instead of the CDN URL. + pub url_override: Option, +} + +impl FakeVersion { + pub fn new( + id: &str, + project_id: &str, + number: &str, + loaders: &[&str], + game_versions: &[&str], + date: &str, + ) -> Self { + Self { + id: id.into(), + project_id: project_id.into(), + version_number: number.into(), + version_type: "release".into(), + loaders: loaders.iter().map(|s| s.to_string()).collect(), + game_versions: game_versions.iter().map(|s| s.to_string()).collect(), + date: date.into(), + dependencies: Vec::new(), + filename: format!("{project_id}-{number}.jar"), + bytes: format!("{project_id} {number}").into_bytes(), + wrong_sha512: false, + url_override: None, + } + } + + pub fn file(mut self, filename: &str, bytes: &[u8]) -> Self { + self.filename = filename.into(); + self.bytes = bytes.to_vec(); + self + } + + pub fn kind(mut self, version_type: &str) -> Self { + self.version_type = version_type.into(); + self + } + + pub fn requires(mut self, project: Option<&str>, version: Option<&str>) -> Self { + self.dependencies.push(( + project.map(String::from), + version.map(String::from), + "required".into(), + )); + self + } + + pub fn depends(mut self, project: &str, dependency_type: &str) -> Self { + self.dependencies + .push((Some(project.into()), None, dependency_type.into())); + self + } + + pub fn sha1(&self) -> String { + format!("{:x}", sha1::Sha1::digest(&self.bytes)) + } + + pub fn sha512(&self) -> String { + format!("{:x}", sha2::Sha512::digest(&self.bytes)) + } + + pub fn cdn_url(&self) -> String { + format!( + "https://cdn.modrinth.com/data/{}/versions/{}/{}", + self.project_id, self.id, self.filename + ) + } + + fn json(&self) -> Value { + json!({ + "id": self.id, + "project_id": self.project_id, + "name": format!("{} {}", self.project_id, self.version_number), + "version_number": self.version_number, + "version_type": self.version_type, + "loaders": self.loaders, + "game_versions": self.game_versions, + "date_published": self.date, + "downloads": 1, + "files": [{ + "hashes": { + "sha1": self.sha1(), + "sha512": if self.wrong_sha512 { "0".repeat(128) } else { self.sha512() }, + }, + "url": self.url_override.clone().unwrap_or_else(|| self.cdn_url()), + "filename": self.filename, + "primary": true, + "size": self.bytes.len(), + }], + "dependencies": self.dependencies.iter().map(|(p, v, t)| json!({ + "project_id": p, "version_id": v, "file_name": null, "dependency_type": t, + })).collect::>(), + }) + } +} + +#[derive(Clone, Debug)] +pub struct Recorded { + pub method: String, + pub path: String, + pub query: HashMap, + pub body: String, + pub user_agent: Option, +} + +#[derive(Default)] +pub struct State { + pub projects: Vec, + pub versions: Vec, + pub requests: Vec, + /// Answers the next requests with this status and headers instead. + pub next_status: Option<(u16, Vec<(String, String)>)>, + /// Headers added to every answer. + pub headers: Vec<(String, String)>, +} + +pub struct ModrinthMock { + pub base: Url, + state: Arc>, + stop: Arc, + addr: SocketAddr, + thread: Option>, +} + +impl ModrinthMock { + pub fn start() -> Self { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap(); + let addr = listener.local_addr().unwrap(); + let state = Arc::new(Mutex::new(State::default())); + let stop = Arc::new(AtomicBool::new(false)); + let thread = { + let state = state.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + if stop.load(Ordering::SeqCst) { + break; + } + let Ok(stream) = stream else { continue }; + let state = state.clone(); + std::thread::spawn(move || answer(&state, stream)); + } + }) + }; + Self { + base: Url::parse(&format!("http://{addr}/")).unwrap(), + state, + stop, + addr, + thread: Some(thread), + } + } + + /// A client that uses this mock for the API and the CDN. + pub fn client(&self) -> Modrinth { + Modrinth::with_urls( + launcher_core::http::default_client().unwrap(), + self.base.join("v2").unwrap(), + Some(self.base.clone()), + ) + } + + pub fn state(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap() + } + + pub fn project(&self, id: &str, slug: &str, project_type: &str) -> &Self { + self.state().projects.push(FakeProject { + id: id.into(), + slug: slug.into(), + title: format!("{slug} title"), + project_type: project_type.into(), + client_side: "required".into(), + server_side: "optional".into(), + }); + self + } + + pub fn version(&self, version: FakeVersion) -> &Self { + self.state().versions.push(version); + self + } + + pub fn requests(&self) -> Vec { + self.state().requests.clone() + } + + pub fn api_requests(&self, method: &str, path: &str) -> Vec { + self.requests() + .into_iter() + .filter(|r| r.method == method && r.path == path) + .collect() + } +} + +impl Drop for ModrinthMock { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.addr); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn project_json(project: &FakeProject, versions: &[FakeVersion]) -> Value { + let mine: Vec<&FakeVersion> = versions + .iter() + .filter(|v| v.project_id == project.id) + .collect(); + let mut loaders: Vec<&String> = mine.iter().flat_map(|v| &v.loaders).collect(); + loaders.sort(); + loaders.dedup(); + json!({ + "id": project.id, + "slug": project.slug, + "project_type": project.project_type, + "title": project.title, + "description": format!("About {}", project.title), + "body": "# Body", + "icon_url": format!("https://cdn.modrinth.com/data/{}/icon.png", project.id), + "categories": [], + "loaders": loaders, + "game_versions": [], + "downloads": 1000, + "followers": 10, + "client_side": project.client_side, + "server_side": project.server_side, + "gallery": [], + "published": "2024-01-01T00:00:00Z", + "updated": "2024-01-01T00:00:00Z", + }) +} + +fn list_param(query: &HashMap, key: &str) -> Option> { + query.get(key).and_then(|v| serde_json::from_str(v).ok()) +} + +fn route(state: &mut State, request: &Recorded) -> (u16, Vec) { + let ok = |value: Value| (200, value.to_string().into_bytes()); + let not_found = (404, br#"{"error":"not_found"}"#.to_vec()); + let segments: Vec<&str> = request.path.trim_start_matches('/').split('/').collect(); + let find_project = |state: &State, id: &str| { + state + .projects + .iter() + .find(|p| p.id == id || p.slug == id) + .cloned() + }; + match (request.method.as_str(), segments.as_slice()) { + ("GET", ["cdn.modrinth.com", "data", project, "versions", id, _]) => { + match state + .versions + .iter() + .find(|v| v.id == *id && v.project_id == *project) + { + Some(version) => (200, version.bytes.clone()), + None => not_found, + } + } + ("GET", ["cdn.modrinth.com", "data", _, "icon.png"]) => { + (200, b"\x89PNG not really".to_vec()) + } + ("GET", ["v2", "search"]) => ok(json!({ + "hits": state.projects.iter().map(|p| json!({ + "project_id": p.id, "slug": p.slug, "project_type": p.project_type, + "title": p.title, "description": "", "author": "someone", "icon_url": "", + "downloads": 5, "follows": 1, "categories": [], "versions": [], + "date_modified": "", "client_side": p.client_side, "server_side": p.server_side, + })).collect::>(), + "offset": 0, "limit": 20, "total_hits": state.projects.len(), + })), + ("GET", ["v2", "project", id]) => match find_project(state, id) { + Some(project) => ok(project_json(&project, &state.versions)), + None => not_found, + }, + ("GET", ["v2", "projects"]) => { + let ids = list_param(&request.query, "ids").unwrap_or_default(); + ok(Value::Array( + state + .projects + .iter() + .filter(|p| ids.contains(&p.id)) + .map(|p| project_json(p, &state.versions)) + .collect(), + )) + } + ("GET", ["v2", "project", id, "version"]) => { + let Some(project) = find_project(state, id) else { + return not_found; + }; + let loaders = list_param(&request.query, "loaders"); + let game_versions = list_param(&request.query, "game_versions"); + ok(Value::Array( + state + .versions + .iter() + .filter(|v| v.project_id == project.id) + .filter(|v| { + loaders + .as_ref() + .is_none_or(|l| v.loaders.iter().any(|x| l.contains(x))) + }) + .filter(|v| { + game_versions + .as_ref() + .is_none_or(|g| v.game_versions.iter().any(|x| g.contains(x))) + }) + .map(FakeVersion::json) + .collect(), + )) + } + ("GET", ["v2", "project", id, "version", version]) => { + let Some(project) = find_project(state, id) else { + return not_found; + }; + match state.versions.iter().find(|v| { + v.project_id == project.id && (v.id == *version || v.version_number == *version) + }) { + Some(v) => ok(v.json()), + None => not_found, + } + } + ("GET", ["v2", "version", id]) => match state.versions.iter().find(|v| v.id == *id) { + Some(v) => ok(v.json()), + None => not_found, + }, + ("POST", ["v2", "version_files"]) => { + let body: Value = serde_json::from_str(&request.body).unwrap_or_default(); + assert_eq!(body["algorithm"], "sha1"); + let hashes: Vec = + serde_json::from_value(body["hashes"].clone()).unwrap_or_default(); + let mut map = serde_json::Map::new(); + for version in &state.versions { + if hashes.contains(&version.sha1()) { + map.insert(version.sha1(), version.json()); + } + } + ok(Value::Object(map)) + } + ("POST", ["v2", "version_files", "update"]) => { + let body: Value = serde_json::from_str(&request.body).unwrap_or_default(); + let hashes: Vec = + serde_json::from_value(body["hashes"].clone()).unwrap_or_default(); + let loaders: Vec = + serde_json::from_value(body["loaders"].clone()).unwrap_or_default(); + let game_versions: Vec = + serde_json::from_value(body["game_versions"].clone()).unwrap_or_default(); + let mut map = serde_json::Map::new(); + for installed in state.versions.iter().filter(|v| hashes.contains(&v.sha1())) { + let newest = state + .versions + .iter() + .filter(|v| v.project_id == installed.project_id) + .filter(|v| v.loaders.iter().any(|l| loaders.contains(l))) + .filter(|v| v.game_versions.iter().any(|g| game_versions.contains(g))) + .max_by(|a, b| a.date.cmp(&b.date)); + if let Some(newest) = newest { + map.insert(installed.sha1(), newest.json()); + } + } + ok(Value::Object(map)) + } + _ => not_found, + } +} + +fn read_request(stream: &TcpStream) -> Option<(String, String, Option, String)> { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_string(); + let target = parts.next()?.to_string(); + let mut user_agent = None; + let mut length = 0; + loop { + let mut header = String::new(); + if reader.read_line(&mut header).ok()? == 0 || header.trim().is_empty() { + break; + } + if let Some((name, value)) = header.split_once(':') { + if name.eq_ignore_ascii_case("user-agent") { + user_agent = Some(value.trim().to_string()); + } else if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().unwrap_or(0); + } + } + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).ok()?; + Some(( + method, + target, + user_agent, + String::from_utf8_lossy(&body).into_owned(), + )) +} + +fn answer(state: &Mutex, mut stream: TcpStream) { + let Some((method, target, user_agent, body)) = read_request(&stream) else { + return; + }; + let url = Url::parse(&format!("http://mock{target}")).unwrap(); + let request = Recorded { + method, + path: url.path().to_string(), + query: url.query_pairs().into_owned().collect(), + body, + user_agent, + }; + let (status, body, headers) = { + let mut state = state.lock().unwrap(); + state.requests.push(request.clone()); + let mut headers = state.headers.clone(); + let (status, body) = match state.next_status.take() { + Some((status, extra)) => { + headers.extend(extra); + (status, b"{}".to_vec()) + } + None => route(&mut state, &request), + }; + (status, body, headers) + }; + let mut head = format!( + "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + for (name, value) in headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str("\r\n"); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); +} diff --git a/launcher/crates/lodestone-launcher/Cargo.toml b/launcher/crates/lodestone-launcher/Cargo.toml index 1bdad4edc..7a1a19f7b 100644 --- a/launcher/crates/lodestone-launcher/Cargo.toml +++ b/launcher/crates/lodestone-launcher/Cargo.toml @@ -16,7 +16,7 @@ default = ["runtime-shaders"] runtime-shaders = ["gpui_platform/runtime_shaders"] # Adds `--screenshot ` to the app and the components example, rendering # the window offscreen. Needs GPUI's test support, so it is for development only. -screenshot = ["gpui_platform/test-support"] +screenshot = ["gpui_platform/test-support", "dep:zip"] [dependencies] anyhow.workspace = true @@ -29,6 +29,8 @@ launcher-core.workspace = true url.workspace = true lodestone_minecraft.workspace = true serde_json.workspace = true +# Writes the sample mods and packs of the offscreen screenshots. +zip = { workspace = true, optional = true } [[example]] name = "components" diff --git a/launcher/crates/lodestone-launcher/src/assets.rs b/launcher/crates/lodestone-launcher/src/assets.rs index adbe9b6f7..f056ea577 100644 --- a/launcher/crates/lodestone-launcher/src/assets.rs +++ b/launcher/crates/lodestone-launcher/src/assets.rs @@ -15,16 +15,25 @@ static FILES: &[(&str, &[u8])] = embed!( "images/hero.svg", "images/logo.svg", "images/placeholder-cube.png", + "icons/arrow-down.svg", + "icons/arrow-left.svg", + "icons/arrow-up.svg", "icons/arrow-up-right-from-square.svg", "icons/caret-down.svg", "icons/check.svg", + "icons/circle-up.svg", "icons/copy.svg", + "icons/cube.svg", + "icons/download.svg", "icons/expand.svg", "icons/gear.svg", + "icons/magnifying-glass.svg", "icons/plus.svg", "icons/right-from-bracket.svg", "icons/square-plus.svg", + "icons/trash-can.svg", "icons/triangle-exclamation.svg", + "icons/upload.svg", "icons/user.svg", "icons/xmark.svg", ); diff --git a/launcher/crates/lodestone-launcher/src/components/button.rs b/launcher/crates/lodestone-launcher/src/components/button.rs index 358122d40..e1728ca35 100644 --- a/launcher/crates/lodestone-launcher/src/components/button.rs +++ b/launcher/crates/lodestone-launcher/src/components/button.rs @@ -350,11 +350,7 @@ impl IconButton { impl RenderOnce for IconButton { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let color = if self.disabled { - colors::GRAY_900 - } else { - colors::GRAY_FADED.opacity(30) - }; + let color = colors::GRAY_FADED.opacity(30); div() .id(self.id.clone()) .group("icon-button") @@ -368,6 +364,7 @@ impl RenderOnce for IconButton { .bg(colors::GRAY_800) .border_1() .border_color(colors::GRAY_FADED.opacity(30)) + .when(self.disabled, |this| this.opacity(0.4)) .when(!self.disabled, |this| { this.cursor_pointer() .hover(|style| { diff --git a/launcher/crates/lodestone-launcher/src/components/icon.rs b/launcher/crates/lodestone-launcher/src/components/icon.rs index dcd655c79..edf96caf5 100644 --- a/launcher/crates/lodestone-launcher/src/components/icon.rs +++ b/launcher/crates/lodestone-launcher/src/components/icon.rs @@ -3,32 +3,50 @@ use gpui::{Hsla, Pixels, Styled, Svg, svg}; /// The icons embedded from `launcher/assets/icons` (Font Awesome Free, as in the dashboard). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Icon { + ArrowDown, + ArrowLeft, + ArrowUp, ArrowUpRightFromSquare, CaretDown, Check, + CircleUp, Copy, + Cube, + Download, Expand, Gear, + MagnifyingGlass, Plus, RightFromBracket, SquarePlus, + TrashCan, TriangleExclamation, + Upload, User, Xmark, } impl Icon { - pub const ALL: [Icon; 12] = [ + pub const ALL: [Icon; 21] = [ + Icon::ArrowDown, + Icon::ArrowLeft, + Icon::ArrowUp, Icon::ArrowUpRightFromSquare, Icon::CaretDown, Icon::Check, + Icon::CircleUp, Icon::Copy, + Icon::Cube, + Icon::Download, Icon::Expand, Icon::Gear, + Icon::MagnifyingGlass, Icon::Plus, Icon::RightFromBracket, Icon::SquarePlus, + Icon::TrashCan, Icon::TriangleExclamation, + Icon::Upload, Icon::User, Icon::Xmark, ]; @@ -36,16 +54,25 @@ impl Icon { /// The asset path of the icon's SVG. pub fn path(self) -> &'static str { match self { + Icon::ArrowDown => "icons/arrow-down.svg", + Icon::ArrowLeft => "icons/arrow-left.svg", + Icon::ArrowUp => "icons/arrow-up.svg", Icon::ArrowUpRightFromSquare => "icons/arrow-up-right-from-square.svg", Icon::CaretDown => "icons/caret-down.svg", Icon::Check => "icons/check.svg", + Icon::CircleUp => "icons/circle-up.svg", Icon::Copy => "icons/copy.svg", + Icon::Cube => "icons/cube.svg", + Icon::Download => "icons/download.svg", Icon::Expand => "icons/expand.svg", Icon::Gear => "icons/gear.svg", + Icon::MagnifyingGlass => "icons/magnifying-glass.svg", Icon::Plus => "icons/plus.svg", Icon::RightFromBracket => "icons/right-from-bracket.svg", Icon::SquarePlus => "icons/square-plus.svg", + Icon::TrashCan => "icons/trash-can.svg", Icon::TriangleExclamation => "icons/triangle-exclamation.svg", + Icon::Upload => "icons/upload.svg", Icon::User => "icons/user.svg", Icon::Xmark => "icons/xmark.svg", } diff --git a/launcher/crates/lodestone-launcher/src/instances/content/mod.rs b/launcher/crates/lodestone-launcher/src/instances/content/mod.rs new file mode 100644 index 000000000..25dcfb4ff --- /dev/null +++ b/launcher/crates/lodestone-launcher/src/instances/content/mod.rs @@ -0,0 +1,1261 @@ +//! The Content tab: an instance's mods, resource packs and shader packs, each with what +//! is installed and a Modrinth browser, after the dashboard's Mods tab. +//! +//! The work is done by `launcher-core::content`; this view keeps the state of each +//! section, runs that work in the background and refuses changes while the game runs. + +mod view; + +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; + +use gpui::{ + AppContext as _, Context, Entity, EventEmitter, PathPromptOptions, RenderImage, SharedString, + Subscription, Task, +}; +use launcher_core::{ + content::{ + ContentKind, InstanceDirs, Modrinth, Target, files, + install::{self, InstallOutcome, InstallProgress, InstallRequest}, + listing::{self, Item, Listing, ShaderState}, + options::PackChange, + packs::Compatibility, + shaders::{self, ShaderModOffer}, + }, + game::download::CancelToken, +}; +use lodestone_minecraft::modrinth::{ + ClientSearchParams, ModrinthProject, ModrinthSearchIndex, ModrinthSearchResponse, RawVersion, +}; + +use super::Instances; +use crate::components::TextInput; + +/// Results per page. +pub const PAGE_SIZE: u32 = 20; +/// How long typing must pause before a search starts. +const SEARCH_DELAY: Duration = Duration::from_millis(300); + +/// Something loading in the background. +#[derive(Clone, Debug, Default)] +pub enum Load { + #[default] + NotLoaded, + Loading, + Ready(T), + Failed(SharedString), +} + +impl Load { + pub fn ready(&self) -> Option<&T> { + match self { + Load::Ready(value) => Some(value), + _ => None, + } + } + + pub fn is_loading(&self) -> bool { + matches!(self, Load::Loading) + } +} + +/// What a section shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SubView { + #[default] + Installed, + Browse, +} + +/// A Modrinth project opened from Browse or a link. +pub struct ProjectView { + pub project_id: String, + pub project: Load, + pub versions: Load>, + /// The version a link pointed to; it is listed first. + pub linked_version: Option, + pub show_all: bool, + pub with_dependencies: bool, + /// An incompatible version whose "Install anyway" was clicked once. + pub confirm_force: Option, + _task: Option>, +} + +pub struct Browse { + pub search: Entity, + /// The text the current results are for. + text: String, + pub only_compatible: bool, + pub index: ModrinthSearchIndex, + pub offset: u32, + pub results: Load, + pub project: Option, + task: Option>, +} + +pub struct Section { + pub kind: ContentKind, + pub listing: Load, + /// The decoded icons of the listed files, by file name. + pub icons: HashMap>, + /// Newer versions, by file name. + pub updates: HashMap, + pub checking_updates: bool, + /// Whether updates were looked for once on their own, after the first listing. + auto_checked: bool, + pub view: SubView, + pub browse: Browse, + load_task: Option>, + updates_task: Option>, +} + +impl Section { + fn new(kind: ContentKind, cx: &mut Context) -> Self { + let placeholder = match kind { + ContentKind::Mod => "Search mods on Modrinth, or paste a Modrinth link", + ContentKind::ResourcePack => { + "Search resource packs on Modrinth, or paste a Modrinth link" + } + ContentKind::ShaderPack => "Search shader packs on Modrinth, or paste a Modrinth link", + }; + let search = cx.new(|cx| TextInput::new(cx).with_placeholder(placeholder)); + Self { + kind, + listing: Load::NotLoaded, + icons: HashMap::new(), + updates: HashMap::new(), + checking_updates: false, + auto_checked: false, + view: SubView::Installed, + browse: Browse { + search, + text: String::new(), + only_compatible: true, + index: ModrinthSearchIndex::Relevance, + offset: 0, + results: Load::NotLoaded, + project: None, + task: None, + }, + load_task: None, + updates_task: None, + } + } + + pub fn items(&self) -> &[Item] { + self.listing.ready().map_or(&[], |l| l.items.as_slice()) + } + + /// The installed Modrinth projects, by id, with their version ids. + pub fn installed_projects(&self) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for item in self.items() { + if let Some(source) = &item.modrinth { + map.entry(source.project_id.clone()) + .or_default() + .push(source.version_id.clone()); + } + } + map + } +} + +/// An install in progress. +pub struct Job { + pub progress: Arc, + pub cancel: CancelToken, + _task: Task<()>, + _ticker: Task<()>, +} + +/// An icon from Modrinth's CDN. +pub enum RemoteIcon { + Loading, + Ready(Arc), + Failed, +} + +/// What the Content tab asks the window to do. +pub enum ContentEvent { + /// Open "New instance" with Fabric chosen. + NewFabricInstance, +} + +pub struct ContentView { + instances: Entity, + instance_id: Option, + pub kind: ContentKind, + sections: [Section; 3], + /// The shader mod and pack, from the last listing. + pub shaders: ShaderState, + pub shader_offer: Load, + shader_offer_task: Option>, + pub job: Option, + /// The last result or error. + pub message: Option<(SharedString, bool)>, + /// The file whose Delete was clicked once. + pub confirm_delete: Option<(ContentKind, String)>, + remote_icons: HashMap, + /// Whether the game was running at the last check, to reload when it stops. + was_active: bool, + _prompt: Option>, + _observe: Vec, +} + +impl EventEmitter for ContentView {} + +fn index_of(kind: ContentKind) -> usize { + match kind { + ContentKind::Mod => 0, + ContentKind::ResourcePack => 1, + ContentKind::ShaderPack => 2, + } +} + +impl ContentView { + pub fn new(instances: Entity, cx: &mut Context) -> Self { + let sections = ContentKind::ALL.map(|kind| Section::new(kind, cx)); + let mut observe = vec![cx.observe(&instances, |this, _, cx| this.sync(cx))]; + for section in §ions { + let kind = section.kind; + observe.push(cx.observe(§ion.browse.search, move |this, _, cx| { + this.search_changed(kind, cx) + })); + } + let mut this = Self { + instances, + instance_id: None, + kind: ContentKind::Mod, + sections, + shaders: ShaderState::default(), + shader_offer: Load::NotLoaded, + shader_offer_task: None, + job: None, + message: None, + confirm_delete: None, + remote_icons: HashMap::new(), + was_active: false, + _prompt: None, + _observe: observe, + }; + this.sync(cx); + this + } + + pub fn section(&self, kind: ContentKind) -> &Section { + &self.sections[index_of(kind)] + } + + fn section_mut(&mut self, kind: ContentKind) -> &mut Section { + &mut self.sections[index_of(kind)] + } + + pub fn instance_id(&self) -> Option<&str> { + self.instance_id.as_deref() + } + + /// Follows the selected instance: a new one starts over; a game that stopped may have + /// changed its options, so the lists are read again. + fn sync(&mut self, cx: &mut Context) { + let (selected, active) = { + let instances = self.instances.read(cx); + let selected = instances.selected().map(|i| i.id.clone()); + let active = selected + .as_deref() + .is_some_and(|id| instances.is_active(id)); + (selected, active) + }; + if selected != self.instance_id { + self.instance_id = selected; + self.reset(cx); + } else if self.was_active && !active { + for kind in ContentKind::ALL { + if !matches!(self.section(kind).listing, Load::NotLoaded) { + self.reload(kind, cx); + } + } + } + self.was_active = active; + cx.notify(); + } + + fn reset(&mut self, cx: &mut Context) { + for kind in ContentKind::ALL { + let view = self.section(kind).view; + let fresh = Section::new(kind, cx); + let section = self.section_mut(kind); + // Keep the search box (its observer) and the sub-view. + let search = section.browse.search.clone(); + *section = fresh; + section.browse.search = search; + section.view = view; + section + .browse + .search + .update(cx, |input, cx| input.set_text("", cx)); + } + self.shaders = ShaderState::default(); + self.shader_offer = Load::NotLoaded; + self.shader_offer_task = None; + self.job = None; + self.message = None; + self.confirm_delete = None; + } + + /// The instance's folders and what it accepts, with its shader mod. + pub fn context(&self, cx: &gpui::App) -> Option<(String, InstanceDirs, Target)> { + let id = self.instance_id.clone()?; + let instances = self.instances.read(cx); + let mut target = instances.content_target(&id)?; + target.shader_loader = self.shaders.loader; + Some((id.clone(), instances.content_dirs(&id), target)) + } + + fn modrinth(&self, cx: &gpui::App) -> Option> { + self.instances.read(cx).modrinth() + } + + /// Whether changes are refused: the game is starting or running. + pub fn locked(&self, cx: &gpui::App) -> bool { + self.instance_id + .as_deref() + .is_some_and(|id| self.instances.read(cx).is_active(id)) + } + + /// Whether a change can be made now, saying why not if it can't. + fn can_change(&mut self, cx: &mut Context) -> bool { + if self.locked(cx) { + self.set_message( + "Stop the game first: it rewrites these files when it exits.", + true, + cx, + ); + return false; + } + if self.job.is_some() { + self.set_message("Wait for the install to finish.", true, cx); + return false; + } + true + } + + pub fn set_message( + &mut self, + message: impl Into, + error: bool, + cx: &mut Context, + ) { + self.message = Some((message.into(), error)); + cx.notify(); + } + + pub fn select_kind(&mut self, kind: ContentKind, cx: &mut Context) { + self.kind = kind; + self.confirm_delete = None; + cx.notify(); + } + + pub fn set_view(&mut self, kind: ContentKind, view: SubView, cx: &mut Context) { + self.section_mut(kind).view = view; + if view == SubView::Browse && matches!(self.section(kind).browse.results, Load::NotLoaded) { + self.search(kind, cx); + } + cx.notify(); + } + + /// Loads the section's list if it hasn't been. + pub fn ensure_loaded(&mut self, kind: ContentKind, cx: &mut Context) { + if matches!(self.section(kind).listing, Load::NotLoaded) { + self.reload(kind, cx); + } + } + + /// Reads the section's folder again (and matches new files on Modrinth). + pub fn reload(&mut self, kind: ContentKind, cx: &mut Context) { + let Some((id, dirs, target)) = self.context(cx) else { + return; + }; + let api = self.modrinth(cx); + if matches!( + self.section(kind).listing, + Load::NotLoaded | Load::Failed(_) + ) { + self.section_mut(kind).listing = Load::Loading; + } + let work = cx.background_spawn(async move { + let shaders = listing::shader_state(&dirs)?; + let mut target = target; + target.shader_loader = shaders.loader; + let listing = listing::list(api.as_deref(), &dirs, &target, kind).await?; + Ok::<_, launcher_core::content::ContentError>((listing, shaders)) + }); + let task = cx.spawn(async move |this, cx| { + let result = work.await; + this.update(cx, |this, cx| { + if this.instance_id.as_deref() != Some(id.as_str()) { + return; + } + match result { + Ok((listing, shaders)) => { + let loader_changed = this.shaders.loader != shaders.loader; + this.shaders = shaders; + let section = this.section_mut(kind); + section.icons = listing + .items + .iter() + .filter_map(|item| { + let icon = item.file.meta.icon.as_ref()?; + Some(( + item.file.file_name.clone(), + crate::images::render_image((**icon).clone()), + )) + }) + .collect(); + let from_modrinth = listing.items.iter().any(|i| i.modrinth.is_some()); + section.listing = Load::Ready(listing); + if from_modrinth && !section.auto_checked { + section.auto_checked = true; + this.look_for_updates(kind, true, cx); + } + if this.shaders.loader.is_none() + && (loader_changed || matches!(this.shader_offer, Load::NotLoaded)) + { + this.load_shader_offer(cx); + } + } + Err(err) => { + this.section_mut(kind).listing = Load::Failed(err.to_string().into()) + } + } + cx.notify(); + }) + .ok(); + }); + self.section_mut(kind).load_task = Some(task); + cx.notify(); + } + + /// Asks Modrinth which shader mod fits the instance. + pub fn load_shader_offer(&mut self, cx: &mut Context) { + let Some((id, _, target)) = self.context(cx) else { + return; + }; + if target.loader == launcher_core::instances::Loader::None { + self.shader_offer = Load::Ready(ShaderModOffer::NeedsModLoader); + return; + } + let Some(api) = self.modrinth(cx) else { + return; + }; + self.shader_offer = Load::Loading; + let work = + cx.background_spawn(async move { shaders::find_shader_mod(&api, &target).await }); + self.shader_offer_task = Some(cx.spawn(async move |this, cx| { + let result = work.await; + this.update(cx, |this, cx| { + if this.instance_id.as_deref() != Some(id.as_str()) { + return; + } + this.shader_offer = match result { + Ok(offer) => Load::Ready(offer), + Err(err) => Load::Failed(err.to_string().into()), + }; + cx.notify(); + }) + .ok(); + })); + } + + pub fn check_updates(&mut self, kind: ContentKind, cx: &mut Context) { + self.look_for_updates(kind, false, cx); + } + + /// Asks Modrinth for newer versions. `quiet` (the check after the first listing) + /// says nothing when there are none or Modrinth can't be reached. + fn look_for_updates(&mut self, kind: ContentKind, quiet: bool, cx: &mut Context) { + let (Some((id, dirs, target)), Some(api)) = (self.context(cx), self.modrinth(cx)) else { + return; + }; + self.section_mut(kind).checking_updates = true; + let work = + cx.background_spawn( + async move { listing::check_updates(&api, &dirs, &target, kind).await }, + ); + let task = cx.spawn(async move |this, cx| { + let result = work.await; + this.update(cx, |this, cx| { + if this.instance_id.as_deref() != Some(id.as_str()) { + return; + } + let section = this.section_mut(kind); + section.checking_updates = false; + match result { + Ok(updates) => { + let count = updates.len(); + section.updates = updates; + if count == 0 && !quiet { + this.set_message( + format!("Every {} from Modrinth is up to date.", kind.noun()), + false, + cx, + ); + } + } + Err(_) if quiet => {} + Err(err) => { + this.set_message(format!("Couldn't check for updates: {err}"), true, cx) + } + } + cx.notify(); + }) + .ok(); + }); + self.section_mut(kind).updates_task = Some(task); + cx.notify(); + } + + /// Installs `requests` one after the other in the background, with progress. + pub fn install(&mut self, requests: Vec, cx: &mut Context) { + if requests.is_empty() || !self.can_change(cx) { + return; + } + let (Some((id, dirs, target)), Some(api)) = (self.context(cx), self.modrinth(cx)) else { + return; + }; + let downloader = self.instances.read(cx).content_downloader(); + let progress = Arc::new(InstallProgress::default()); + let cancel = CancelToken::new(); + let kinds: Vec = requests.iter().map(|r| r.kind).collect(); + let work = { + let progress = progress.clone(); + let cancel = cancel.clone(); + cx.background_spawn(async move { + let mut total = InstallOutcome::default(); + let mut target = target; + for request in requests { + let kind = request.kind; + progress.set_label(format!( + "Checking {}", + request + .project + .as_ref() + .map_or("the dependencies", |p| p.title.as_str()) + )); + let plan = install::plan(&api, &dirs, &target, request).await?; + let outcome = install::execute( + &api, + &downloader, + &dirs, + &target, + plan, + &progress, + &cancel, + ) + .await?; + total.installed.extend(outcome.installed); + total.removed.extend(outcome.removed); + total.skipped.extend(outcome.skipped); + if kind == ContentKind::Mod { + // A shader mod installed first changes what fits next. + target.shader_loader = listing::shader_state(&dirs)?.loader; + } + } + Ok::<_, launcher_core::content::ContentError>(total) + }) + }; + let task = cx.spawn(async move |this, cx| { + let result = work.await; + this.update(cx, |this, cx| { + this.job = None; + if this.instance_id.as_deref() != Some(id.as_str()) { + return; + } + match result { + Ok(outcome) => { + let message = outcome_message(&outcome); + this.set_message(message, false, cx); + for kind in &kinds { + this.section_mut(*kind).updates.clear(); + } + } + Err(err) => this.set_message(err.to_string(), true, cx), + } + let mut reload: Vec = kinds.clone(); + reload.push(ContentKind::ShaderPack); + reload.sort(); + reload.dedup(); + for kind in reload { + this.reload(kind, cx); + } + }) + .ok(); + }); + let ticker = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(150)) + .await; + if this.update(cx, |_, cx| cx.notify()).is_err() { + break; + } + } + }); + self.message = None; + self.job = Some(Job { + progress, + cancel, + _task: task, + _ticker: ticker, + }); + cx.notify(); + } + + pub fn cancel_install(&mut self, cx: &mut Context) { + if let Some(job) = &self.job { + job.cancel.cancel(); + } + cx.notify(); + } + + /// Installs a version from the project view. A shader pack on an instance without a + /// shader mod installs the offered shader mod first. + pub fn install_version( + &mut self, + kind: ContentKind, + version: RawVersion, + project: Option, + force: bool, + cx: &mut Context, + ) { + let with_dependencies = self + .section(kind) + .browse + .project + .as_ref() + .is_none_or(|p| p.with_dependencies); + let mut requests = Vec::new(); + if kind == ContentKind::ShaderPack + && self.shaders.loader.is_none() + && let Some(ShaderModOffer::Install { version, .. }) = self.shader_offer.ready() + { + requests.push(InstallRequest { + kind: ContentKind::Mod, + version: (**version).clone(), + project: None, + install_dependencies: true, + force: false, + }); + } + requests.push(InstallRequest { + kind, + version, + project, + install_dependencies: with_dependencies, + force, + }); + self.install(requests, cx); + } + + /// Installs the offered shader mod. + pub fn install_shader_mod(&mut self, cx: &mut Context) { + if let Some(ShaderModOffer::Install { version, .. }) = self.shader_offer.ready() { + let request = InstallRequest { + kind: ContentKind::Mod, + version: (**version).clone(), + project: None, + install_dependencies: true, + force: false, + }; + self.install(vec![request], cx); + } + } + + /// Installs every update the last check found. + pub fn update_all(&mut self, kind: ContentKind, cx: &mut Context) { + let mut versions: Vec = self.section(kind).updates.values().cloned().collect(); + versions.sort_by(|a, b| a.project_id.cmp(&b.project_id)); + let requests = versions + .into_iter() + .map(|version| InstallRequest { + kind, + version, + project: None, + install_dependencies: true, + force: false, + }) + .collect(); + self.install(requests, cx); + } + + pub fn update_one(&mut self, kind: ContentKind, file_name: &str, cx: &mut Context) { + if let Some(version) = self.section(kind).updates.get(file_name).cloned() { + self.install( + vec![InstallRequest { + kind, + version, + project: None, + install_dependencies: true, + force: false, + }], + cx, + ); + } + } + + /// Runs a quick change to the files, then reads the section again. + fn change( + &mut self, + kind: ContentKind, + change: impl FnOnce(&InstanceDirs, &Target) -> Result<(), launcher_core::content::ContentError>, + cx: &mut Context, + ) { + if !self.can_change(cx) { + return; + } + let Some((_, dirs, target)) = self.context(cx) else { + return; + }; + match change(&dirs, &target) { + Ok(()) => self.message = None, + Err(err) => self.message = Some((err.to_string().into(), true)), + } + self.reload(kind, cx); + } + + pub fn set_mod_enabled(&mut self, file_name: String, enabled: bool, cx: &mut Context) { + self.change( + ContentKind::Mod, + |dirs, _| files::set_mod_enabled(dirs, &file_name, enabled).map(|_| ()), + cx, + ); + // A shader mod may have been turned on or off. + self.reload(ContentKind::ShaderPack, cx); + } + + pub fn set_pack_enabled( + &mut self, + file_name: String, + enabled: bool, + compatibility: Compatibility, + cx: &mut Context, + ) { + self.change( + ContentKind::ResourcePack, + |dirs, target| { + files::set_pack_enabled(dirs, target, &file_name, enabled, compatibility) + .map(|_| ()) + }, + cx, + ); + } + + /// Raises (`up`) or lowers a resource pack's priority. + pub fn move_pack(&mut self, file_name: String, up: bool, cx: &mut Context) { + self.change( + ContentKind::ResourcePack, + |dirs, target| { + let name = file_name.clone(); + let change = if up { + PackChange::Raise { name } + } else { + PackChange::Lower { name } + }; + files::change_pack(dirs, target, &change).map(|_| ()) + }, + cx, + ); + } + + pub fn select_shader(&mut self, file_name: Option, cx: &mut Context) { + let Some(loader) = self.shaders.loader else { + return; + }; + self.change( + ContentKind::ShaderPack, + |dirs, _| files::select_shader(dirs, loader, file_name.as_deref()), + cx, + ); + } + + /// The first click asks; the second moves the file to the Trash. + pub fn delete(&mut self, kind: ContentKind, file_name: String, cx: &mut Context) { + if self.confirm_delete.as_ref() != Some(&(kind, file_name.clone())) { + self.confirm_delete = Some((kind, file_name)); + cx.notify(); + return; + } + self.confirm_delete = None; + self.change( + kind, + |dirs, target| files::delete(dirs, target, kind, &file_name), + cx, + ); + if kind == ContentKind::Mod { + self.reload(ContentKind::ShaderPack, cx); + } + } + + pub fn cancel_delete(&mut self, cx: &mut Context) { + self.confirm_delete = None; + cx.notify(); + } + + /// Copies files the user picked or dropped into the section's folder. + pub fn add_files(&mut self, kind: ContentKind, paths: Vec, cx: &mut Context) { + if paths.is_empty() || !self.can_change(cx) { + return; + } + let Some((id, dirs, target)) = self.context(cx) else { + return; + }; + let work = cx.background_spawn(async move { + paths + .into_iter() + .map(|path| { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + (name, files::add(&dirs, &target, kind, &path)) + }) + .collect::>() + }); + self._prompt = Some(cx.spawn(async move |this, cx| { + let results = work.await; + this.update(cx, |this, cx| { + if this.instance_id.as_deref() != Some(id.as_str()) { + return; + } + let added: Vec = results + .iter() + .filter_map(|(_, r)| r.as_ref().ok().cloned()) + .collect(); + let failed: Vec = results + .iter() + .filter_map(|(name, r)| r.as_ref().err().map(|err| format!("{name}: {err}"))) + .collect(); + let mut message = String::new(); + if !added.is_empty() { + message.push_str(&format!("Added {}.", added.join(", "))); + } + if !failed.is_empty() { + if !message.is_empty() { + message.push(' '); + } + message.push_str(&format!("Not added: {}", failed.join("; "))); + } + this.set_message(message, !failed.is_empty(), cx); + this.reload(kind, cx); + if kind == ContentKind::Mod { + this.reload(ContentKind::ShaderPack, cx); + } + }) + .ok(); + })); + } + + /// "Add file…": the system's file dialog. + pub fn pick_files(&mut self, kind: ContentKind, cx: &mut Context) { + if !self.can_change(cx) { + return; + } + let picked = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: kind.allows_folders(), + multiple: true, + prompt: Some(format!("Add to {}", kind.label()).into()), + }); + self._prompt = Some(cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = picked.await else { + return; + }; + this.update(cx, |this, cx| this.add_files(kind, paths, cx)) + .ok(); + })); + } + + pub fn open_folder(&self, kind: ContentKind, cx: &mut Context) { + if let Some((_, dirs, _)) = self.context(cx) { + let dir = dirs.content_dir(kind); + let _ = std::fs::create_dir_all(&dir); + cx.open_with_system(&dir); + } + } + + pub fn switch_to_fabric(&mut self, cx: &mut Context) { + if !self.can_change(cx) { + return; + } + if let Some(id) = self.instance_id.clone() { + self.instances + .update(cx, |instances, cx| instances.switch_to_fabric(&id, cx)); + } + } + + pub fn new_fabric_instance(&mut self, cx: &mut Context) { + cx.emit(ContentEvent::NewFabricInstance); + } + + pub fn is_switching_loader(&self, cx: &gpui::App) -> bool { + self.instances.read(cx).is_switching_loader() + } + + // ---- Browse ---- + + fn search_changed(&mut self, kind: ContentKind, cx: &mut Context) { + let text = self.section(kind).browse.search.read(cx).text().to_string(); + if text == self.section(kind).browse.text { + return; + } + self.section_mut(kind).browse.text = text.clone(); + self.section_mut(kind).browse.offset = 0; + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(SEARCH_DELAY).await; + this.update(cx, |this, cx| { + if text.contains("modrinth.com/") { + this.open_link(kind, &text, cx); + } else { + this.search(kind, cx); + } + }) + .ok(); + }); + self.section_mut(kind).browse.task = Some(task); + } + + pub fn set_only_compatible(&mut self, kind: ContentKind, value: bool, cx: &mut Context) { + let browse = &mut self.section_mut(kind).browse; + browse.only_compatible = value; + browse.offset = 0; + self.search(kind, cx); + } + + pub fn set_index( + &mut self, + kind: ContentKind, + index: ModrinthSearchIndex, + cx: &mut Context, + ) { + let browse = &mut self.section_mut(kind).browse; + browse.index = index; + browse.offset = 0; + self.search(kind, cx); + } + + pub fn set_page(&mut self, kind: ContentKind, offset: u32, cx: &mut Context) { + self.section_mut(kind).browse.offset = offset; + self.search(kind, cx); + } + + /// Searches Modrinth with the section's text, filters and page. + pub fn search(&mut self, kind: ContentKind, cx: &mut Context) { + let (Some((id, _, target)), Some(api)) = (self.context(cx), self.modrinth(cx)) else { + return; + }; + let browse = &self.section(kind).browse; + let text = browse.search.read(cx).text().to_string(); + let (game_version, loaders) = if browse.only_compatible { + ( + Some(target.game_version.clone()), + target.search_loaders(kind), + ) + } else { + (None, Vec::new()) + }; + let params = match ClientSearchParams::new( + Some(text), + kind.project_type(), + game_version, + loaders, + Some(browse.index), + Some(browse.offset), + Some(PAGE_SIZE), + ) { + Ok(params) => params, + Err(err) => { + self.section_mut(kind).browse.results = Load::Failed(err.to_string().into()); + cx.notify(); + return; + } + }; + self.section_mut(kind).browse.results = Load::Loading; + let work = cx.background_spawn(async move { api.search(¶ms).await }); + let task = cx.spawn(async move |this, cx| { + let result = work.await; + this.update(cx, |this, cx| { + if this.instance_id.as_deref() != Some(id.as_str()) { + return; + } + this.section_mut(kind).browse.results = match result { + Ok(results) => Load::Ready(results), + Err(err) => Load::Failed(err.to_string().into()), + }; + cx.notify(); + }) + .ok(); + }); + self.section_mut(kind).browse.task = Some(task); + cx.notify(); + } + + /// Opens what a pasted modrinth.com link points to, in the section of its kind. + fn open_link(&mut self, kind: ContentKind, link: &str, cx: &mut Context) { + let Some(api) = self.modrinth(cx) else { + return; + }; + let link = link.to_string(); + self.section_mut(kind).browse.results = Load::Loading; + let work = cx.background_spawn(async move { api.resolve_link(&link).await }); + let task = cx.spawn(async move |this, cx| { + let result = work.await; + this.update(cx, |this, cx| match result { + Ok(target) => { + this.section_mut(kind).browse.results = Load::NotLoaded; + match ContentKind::from_project_type(&target.project.project_type) { + Some(project_kind) => { + this.kind = project_kind; + this.section_mut(project_kind).view = SubView::Browse; + let version = target.version.map(|v| v.id); + this.open_project(project_kind, target.project.id.clone(), version, cx); + } + None => { + this.section_mut(kind).browse.results = Load::Failed( + format!( + "That link is a {}, which Lodestone Launcher doesn't install yet.", + target.project.project_type + ) + .into(), + ); + } + } + cx.notify(); + } + Err(err) => { + this.section_mut(kind).browse.results = Load::Failed(err.to_string().into()); + cx.notify(); + } + }) + .ok(); + }); + self.section_mut(kind).browse.task = Some(task); + cx.notify(); + } + + /// Opens a project with its versions. + pub fn open_project( + &mut self, + kind: ContentKind, + project_id: String, + linked_version: Option, + cx: &mut Context, + ) { + let Some(api) = self.modrinth(cx) else { + return; + }; + let id = project_id.clone(); + let work = cx.background_spawn(async move { + let project = api.project(&id).await; + let versions = api.versions(&id, &[], &[]).await; + (project, versions) + }); + let task = cx.spawn(async move |this, cx| { + let (project, versions) = work.await; + this.update(cx, |this, cx| { + if let Some(view) = &mut this.section_mut(kind).browse.project { + view.project = match project { + Ok(project) => Load::Ready(project), + Err(err) => Load::Failed(err.to_string().into()), + }; + view.versions = match versions { + Ok(versions) => Load::Ready(versions), + Err(err) => Load::Failed(err.to_string().into()), + }; + } + cx.notify(); + }) + .ok(); + }); + self.section_mut(kind).browse.project = Some(ProjectView { + project_id, + project: Load::Loading, + versions: Load::Loading, + linked_version, + show_all: false, + with_dependencies: true, + confirm_force: None, + _task: Some(task), + }); + if kind == ContentKind::ShaderPack + && self.shaders.loader.is_none() + && matches!(self.shader_offer, Load::NotLoaded) + { + self.load_shader_offer(cx); + } + cx.notify(); + } + + pub fn close_project(&mut self, kind: ContentKind, cx: &mut Context) { + self.section_mut(kind).browse.project = None; + if matches!(self.section(kind).browse.results, Load::NotLoaded) { + self.search(kind, cx); + } + cx.notify(); + } + + pub fn project_mut(&mut self, kind: ContentKind) -> Option<&mut ProjectView> { + self.section_mut(kind).browse.project.as_mut() + } + + /// The icon at a Modrinth CDN URL, fetched the first time it is asked for. + pub fn remote_icon(&mut self, url: &str, cx: &mut Context) -> Option> { + match self.remote_icons.get(url) { + Some(RemoteIcon::Ready(image)) => return Some(image.clone()), + Some(_) => return None, + None => {} + } + let api = self.modrinth(cx)?; + self.remote_icons + .insert(url.to_string(), RemoteIcon::Loading); + let key = url.to_string(); + let work = cx.background_spawn(async move { + let bytes = api.image(&key).await.ok()?; + let icon = launcher_core::content::scan::decode_icon(&bytes)?; + Some((key, icon)) + }); + let url = url.to_string(); + cx.spawn(async move |this, cx| { + let result = work.await; + this.update(cx, |this, cx| { + let icon = match result { + Some((_, icon)) => { + RemoteIcon::Ready(crate::images::render_image((*icon).clone())) + } + None => RemoteIcon::Failed, + }; + this.remote_icons.insert(url, icon); + cx.notify(); + }) + .ok(); + }) + .detach(); + None + } + + // ---- previews ---- + + /// Shows prepared Browse results, for the offscreen screenshots. + pub fn preview_results( + &mut self, + kind: ContentKind, + text: &str, + results: ModrinthSearchResponse, + cx: &mut Context, + ) { + self.kind = kind; + let section = self.section_mut(kind); + section.view = SubView::Browse; + section.browse.text = text.to_string(); + section.browse.results = Load::Ready(results); + let search = section.browse.search.clone(); + search.update(cx, |input, cx| input.set_text(text.to_string(), cx)); + cx.notify(); + } + + /// Shows a prepared project, for the offscreen screenshots. + pub fn preview_project( + &mut self, + kind: ContentKind, + project: ModrinthProject, + versions: Vec, + cx: &mut Context, + ) { + self.kind = kind; + let section = self.section_mut(kind); + section.view = SubView::Browse; + section.browse.project = Some(ProjectView { + project_id: project.id.clone(), + project: Load::Ready(project), + versions: Load::Ready(versions), + linked_version: None, + show_all: false, + with_dependencies: true, + confirm_force: None, + _task: None, + }); + cx.notify(); + } + + /// Shows a prepared shader mod offer and updates, for the offscreen screenshots. + pub fn preview_state( + &mut self, + kind: ContentKind, + offer: Option, + updates: HashMap, + icons: HashMap>, + cx: &mut Context, + ) { + self.kind = kind; + if let Some(offer) = offer { + self.shader_offer = Load::Ready(offer); + } + self.section_mut(kind).updates = updates; + for (url, icon) in icons { + self.remote_icons.insert(url, RemoteIcon::Ready(icon)); + } + cx.notify(); + } +} + +/// "Installed Iris and Sodium. Replaced …", for the message line. +fn outcome_message(outcome: &InstallOutcome) -> String { + let titles: Vec<&str> = outcome + .installed + .iter() + .map(|(title, _)| title.as_str()) + .collect(); + let mut message = match titles.as_slice() { + [] => "Nothing was installed.".to_string(), + [one] => format!("Installed {one}."), + [rest @ .., last] => format!("Installed {} and {last}.", rest.join(", ")), + }; + if !outcome.removed.is_empty() { + message.push_str(&format!(" Replaced {}.", outcome.removed.join(", "))); + } + for skipped in &outcome.skipped { + message.push_str(&format!( + " Skipped {}: {}.", + skipped.project, + skipped.reason.trim_end_matches('.') + )); + } + message +} + +#[cfg(test)] +mod tests { + use super::*; + use launcher_core::content::install::Skipped; + + #[test] + fn outcome_messages() { + let outcome = InstallOutcome { + installed: vec![ + ("Iris".into(), "iris.jar".into()), + ("Sodium".into(), "sodium.jar".into()), + ("Fabric API".into(), "fabric-api.jar".into()), + ], + removed: vec!["sodium-old.jar".into()], + skipped: vec![Skipped { + project: "x".into(), + reason: "Not found on Modrinth".into(), + }], + }; + assert_eq!( + outcome_message(&outcome), + "Installed Iris, Sodium and Fabric API. Replaced sodium-old.jar. Skipped x: Not found on Modrinth." + ); + assert_eq!( + outcome_message(&InstallOutcome::default()), + "Nothing was installed." + ); + } +} diff --git a/launcher/crates/lodestone-launcher/src/instances/content/view.rs b/launcher/crates/lodestone-launcher/src/instances/content/view.rs new file mode 100644 index 000000000..4591aa03e --- /dev/null +++ b/launcher/crates/lodestone-launcher/src/instances/content/view.rs @@ -0,0 +1,1703 @@ +//! How the Content tab looks: the section and sub-view switches, the installed lists, +//! the Shaders section's shader mod card, Browse, and a project's page. + +use std::sync::Arc; + +use gpui::{ + AnyElement, Context, ExternalPaths, IntoElement, ObjectFit, Render, RenderImage, SharedString, + Window, div, img, prelude::*, px, +}; +use launcher_core::{ + content::{ + ContentKind, + listing::Item, + packs::Compatibility, + shaders::{ShaderLoader, ShaderModOffer}, + }, + instances::Loader, +}; +use lodestone_minecraft::{ + modrinth::{ModrinthSearchHit, ModrinthSearchIndex, ModrinthVersionType, RawVersion}, + util::format_byte, +}; + +use super::{ContentView, Load, PAGE_SIZE, SubView}; +use crate::{ + components::{Button, ButtonSize, ButtonVariant, Icon, IconButton, LoadingDots, Toggle}, + theme::{self, colors, fonts, leading, radius, text, weights}, +}; + +/// `72230975` as `72.2M`. +pub fn compact(value: u64) -> String { + match value { + 0..1_000 => value.to_string(), + 1_000..1_000_000 => format!("{:.1}k", value as f64 / 1_000.0), + _ => format!("{:.1}M", value as f64 / 1_000_000.0), + } +} + +/// The project's page on modrinth.com. +pub fn modrinth_page(project_type: &str, slug: &str) -> String { + format!("https://modrinth.com/{project_type}/{slug}") +} + +fn muted(text: impl Into) -> gpui::Div { + div().text_color(colors::GRAY_500).child(text.into()) +} + +fn small(text: impl Into) -> gpui::Div { + div() + .text_size(theme::text_size(text::SMALL)) + .line_height(theme::line_height(text::SMALL, leading::NORMAL)) + .child(text.into()) +} + +/// A small coloured label. +fn badge(label: impl Into, color: theme::Color) -> gpui::Div { + div() + .flex_none() + .px_1p5() + .rounded(radius::DEFAULT) + .border_1() + .border_color(color.opacity(40)) + .bg(color.opacity(12)) + .text_color(color) + .text_size(theme::text_size(text::CAPTION)) + .font_weight(weights::BOLD.into()) + .child(label.into()) +} + +/// An icon image, or the cube placeholder. +fn icon_box(image: Option>, size: f32) -> AnyElement { + match image { + Some(image) => img(image) + .flex_none() + .size(px(size)) + .rounded(radius::MD) + .object_fit(ObjectFit::Contain) + .into_any_element(), + None => div() + .flex() + .flex_none() + .items_center() + .justify_center() + .size(px(size)) + .rounded(radius::MD) + .bg(colors::GRAY_700) + .child(Icon::Cube.render(px(size * 0.45), colors::GRAY_500)) + .into_any_element(), + } +} + +/// A row of pills; the selected one is filled. +fn pills( + id: &'static str, + labels: Vec, + selected: usize, + on_select: impl Fn(usize, &mut Window, &mut gpui::App) + 'static, +) -> impl IntoElement { + let on_select = std::rc::Rc::new(on_select); + div() + .id(id) + .flex() + .flex_row() + .flex_none() + .gap_1() + .p_1() + .rounded(radius::LG) + .bg(colors::GRAY_900) + .border_1() + .border_color(colors::FADE_700) + .children(labels.into_iter().enumerate().map(|(index, label)| { + let on_select = on_select.clone(); + div() + .id((id, index)) + .px_3() + .py_1() + .rounded(radius::MD) + .font_weight(weights::BOLD.into()) + .cursor_pointer() + .map(|this| { + if index == selected { + this.bg(colors::GRAY_700).text_color(colors::GRAY_300) + } else { + this.text_color(colors::GRAY_500) + .hover(|style| style.text_color(colors::GRAY_400)) + } + }) + .on_click(move |_, window, cx| on_select(index, window, cx)) + .child(label) + })) +} + +/// A box for a notice: the shader mod card, the vanilla card, errors. +fn card() -> gpui::Div { + div() + .flex() + .flex_col() + .gap_2() + .p_4() + .rounded(radius::LG) + .border_1() + .border_color(colors::FADE_700) + .bg(colors::GRAY_850) +} + +fn heading(text_value: impl Into) -> gpui::Div { + div() + .font_family(fonts::HEADING) + .text_size(theme::text_size(text::H3)) + .font_weight(weights::BOLD.into()) + .text_color(colors::GRAY_300) + .child(text_value.into()) +} + +fn list_box() -> gpui::Div { + div() + .flex() + .flex_col() + .rounded(radius::LG) + .overflow_hidden() + .border_1() + .border_color(colors::GRAY_FADED.opacity(30)) +} + +fn row_base(id: impl Into, first: bool) -> gpui::Stateful { + div() + .id(id) + .flex() + .flex_row() + .items_center() + .gap_3() + .px_4() + .py_2p5() + .bg(colors::GRAY_800) + .when(!first, |this| { + this.border_t_1() + .border_color(colors::GRAY_FADED.opacity(15)) + }) +} + +fn version_label(kind: ModrinthVersionType) -> (&'static str, theme::Color) { + match kind { + ModrinthVersionType::Release => ("Release", colors::GREEN_300), + ModrinthVersionType::Beta => ("Beta", colors::YELLOW_300), + ModrinthVersionType::Alpha => ("Alpha", colors::RED_200), + } +} + +/// "1.20.1, 1.20" with at most three versions. +fn short_list(values: &[String], max: usize) -> String { + let mut shown: Vec<&str> = values.iter().take(max).map(String::as_str).collect(); + if values.len() > max { + shown.push("…"); + } + shown.join(", ") +} + +impl ContentView { + fn kind_label(&self, kind: ContentKind) -> SharedString { + match self.section(kind).listing.ready() { + Some(listing) if !listing.items.is_empty() => { + format!("{} {}", kind.label(), listing.items.len()).into() + } + _ => kind.label().into(), + } + } + + fn target_label(&self, kind: ContentKind, cx: &gpui::App) -> String { + let Some((_, _, target)) = self.context(cx) else { + return String::new(); + }; + match kind { + ContentKind::Mod => format!("{} / {}", target.game_version, target.loader.label()), + ContentKind::ResourcePack => target.game_version.clone(), + ContentKind::ShaderPack => format!( + "{} / {}", + target.game_version, + self.shaders.loader.map_or("Iris", ShaderLoader::label) + ), + } + } + + fn render_header(&mut self, cx: &mut Context) -> impl IntoElement { + let kind = self.kind; + let selected = ContentKind::ALL + .iter() + .position(|k| *k == kind) + .unwrap_or(0); + let labels: Vec = ContentKind::ALL + .iter() + .map(|k| self.kind_label(*k)) + .collect(); + let entity = cx.entity(); + let view = self.section(kind).view; + let entity2 = entity.clone(); + div() + .flex() + .flex_row() + .flex_wrap() + .items_center() + .justify_between() + .gap_3() + .child(pills( + "content-kinds", + labels, + selected, + move |index, _, cx| { + entity.update(cx, |this, cx| this.select_kind(ContentKind::ALL[index], cx)) + }, + )) + .child(pills( + "content-views", + vec!["Installed".into(), "Browse Modrinth".into()], + if view == SubView::Installed { 0 } else { 1 }, + move |index, _, cx| { + let view = if index == 0 { + SubView::Installed + } else { + SubView::Browse + }; + entity2.update(cx, |this, cx| this.set_view(kind, view, cx)) + }, + )) + } + + fn render_banners(&mut self, cx: &mut Context) -> impl IntoElement { + let locked = self.locked(cx); + let job = self.job.as_ref().map(|job| { + let snapshot = job.progress.downloads.snapshot(); + let fraction = if snapshot.bytes_total > 0 { + (snapshot.bytes_done as f32 / snapshot.bytes_total as f32).clamp(0.0, 1.0) + } else { + 0.0 + }; + let detail = if snapshot.files_total > 1 { + format!( + "{} of {} files · {}", + snapshot.files_done.min(snapshot.files_total), + snapshot.files_total, + lodestone_minecraft::util::format_byte_download( + snapshot.bytes_done, + snapshot.bytes_total + ) + ) + } else { + lodestone_minecraft::util::format_byte_download( + snapshot.bytes_done, + snapshot.bytes_total, + ) + }; + (job.progress.label(), detail, fraction) + }); + div() + .flex() + .flex_col() + .gap_2() + .when(locked, |this| { + this.child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .px_3() + .py_2() + .rounded(radius::MD) + .border_1() + .border_color(colors::YELLOW_300.opacity(60)) + .bg(colors::YELLOW_FADED.opacity(20)) + .text_color(colors::WHITE) + .child(Icon::TriangleExclamation.render(px(14.), colors::YELLOW_300)) + .child("The game is running. Stop it to change its content: it rewrites options.txt and the shader settings when it exits."), + ) + }) + .when_some(job, |this, (label, detail, fraction)| { + this.child( + div() + .flex() + .flex_col() + .gap_1p5() + .px_3() + .py_2() + .rounded(radius::MD) + .bg(colors::GRAY_850) + .border_1() + .border_color(colors::FADE_700) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_3() + .child(LoadingDots) + .child(div().flex_1().font_weight(weights::BOLD.into()).child(label)) + .child(small(detail).text_color(colors::GRAY_500)) + .child( + Button::new("cancel-install", "Cancel") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Slim) + .on_click(cx.listener(|this, _, _, cx| this.cancel_install(cx))), + ), + ) + .child( + div() + .h(px(4.)) + .w_full() + .rounded(radius::XS) + .bg(colors::GRAY_700) + .child( + div() + .h_full() + .rounded(radius::XS) + .bg(colors::BLUE_200) + .w(gpui::relative(fraction)), + ), + ), + ) + }) + .when_some(self.message.clone(), |this, (message, error)| { + this.child( + div() + .flex() + .flex_row() + .items_start() + .gap_2() + .px_3() + .py_2() + .rounded(radius::MD) + .bg(if error { + colors::RED_FADED.opacity(15) + } else { + colors::GREEN_FADED.opacity(15) + }) + .border_1() + .border_color(if error { + colors::RED_200.opacity(40) + } else { + colors::GREEN_300.opacity(40) + }) + .child( + div() + .flex_1() + .whitespace_normal() + .text_color(if error { colors::RED_200 } else { colors::GREEN_300 }) + .child(message), + ) + .child( + IconButton::new("dismiss-message", Icon::Xmark) + .on_click(cx.listener(|this, _, _, cx| { + this.message = None; + cx.notify(); + })), + ), + ) + }) + } + + /// The buttons over an installed list. + fn render_toolbar(&mut self, kind: ContentKind, cx: &mut Context) -> impl IntoElement { + let section = self.section(kind); + let busy = self.locked(cx) || self.job.is_some(); + let count = section.items().len(); + let updates = section.updates.len(); + let checking = section.checking_updates; + let online = self.modrinth(cx).is_some(); + let summary = match (kind, count) { + (_, 0) => String::new(), + (ContentKind::ResourcePack, _) => { + let enabled = section.items().iter().filter(|i| i.enabled).count(); + format!("{count} resource packs, {enabled} enabled") + } + (ContentKind::Mod, _) => { + let enabled = section.items().iter().filter(|i| i.enabled).count(); + format!("{count} mods, {enabled} enabled") + } + (ContentKind::ShaderPack, _) => format!("{count} shader packs"), + }; + div() + .flex() + .flex_row() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .text_color(colors::GRAY_500) + .font_weight(weights::BOLD.into()) + .child(summary), + ) + .when(updates > 0, |this| { + this.child( + Button::new( + ("update-all", kind as usize), + format!("Update all ({updates})"), + ) + .variant(ButtonVariant::Primary) + .icon(Icon::CircleUp) + .size(ButtonSize::Slim) + .disabled(busy) + .on_click(cx.listener(move |this, _, _, cx| this.update_all(kind, cx))), + ) + }) + .child( + Button::new(("check-updates", kind as usize), "Check for updates") + .variant(ButtonVariant::Secondary) + .icon(Icon::CircleUp) + .size(ButtonSize::Slim) + .loading(checking) + .disabled(!online || count == 0) + .on_click(cx.listener(move |this, _, _, cx| this.check_updates(kind, cx))), + ) + .child( + Button::new(("add-file", kind as usize), "Add file…") + .variant(ButtonVariant::Secondary) + .icon(Icon::Upload) + .size(ButtonSize::Slim) + .disabled(busy) + .on_click(cx.listener(move |this, _, _, cx| this.pick_files(kind, cx))), + ) + .child( + Button::new(("open-folder", kind as usize), "Open folder") + .variant(ButtonVariant::Ghost) + .icon(Icon::ArrowUpRightFromSquare) + .size(ButtonSize::Slim) + .on_click(cx.listener(move |this, _, _, cx| this.open_folder(kind, cx))), + ) + } + + /// The icon of an installed item: its own, else its Modrinth project's. + fn item_icon( + &mut self, + kind: ContentKind, + item: &Item, + cx: &mut Context, + ) -> Option> { + if let Some(icon) = self.section(kind).icons.get(&item.file.file_name) { + return Some(icon.clone()); + } + let url = item.modrinth.as_ref()?.icon_url.clone()?; + self.remote_icon(&url, cx) + } + + /// One installed file. + fn render_item( + &mut self, + kind: ContentKind, + index: usize, + item: &Item, + first: bool, + reorder: Option<(bool, bool)>, + cx: &mut Context, + ) -> AnyElement { + // Shader rows sit inside the selectable row, which draws the background. + let inner = kind == ContentKind::ShaderPack; + let busy = self.locked(cx) || self.job.is_some(); + let name = item.file.file_name.clone(); + let icon = self.item_icon(kind, item, cx); + let update = self.section(kind).updates.get(&name).cloned(); + let confirming = self.confirm_delete.as_ref() == Some(&(kind, name.clone())); + let title = item.display_name(); + let version = item.display_version(); + let detail = if item.file.is_dir { + format!("{name} · folder") + } else { + format!("{name} · {}", format_byte(item.file.size)) + }; + let warning = item + .file + .meta + .problem + .clone() + .or_else(|| item.compatibility.warning().map(str::to_string)); + let page = item + .modrinth + .as_ref() + .filter(|m| !m.slug.is_empty()) + .map(|m| { + modrinth_page( + match kind { + ContentKind::Mod => "mod", + ContentKind::ResourcePack => "resourcepack", + ContentKind::ShaderPack => "shader", + }, + &m.slug, + ) + }); + let enabled = item.enabled; + let compatibility = item.compatibility; + let id_base = kind as usize * 100_000 + index; + row_base(("content-row", id_base), first) + .when(inner, |this| this.bg(colors::TRANSPARENT).pl_3()) + .child(icon_box(icon, 36.)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .min_w_0() + .child( + div() + .truncate() + .font_weight(weights::BOLD.into()) + .text_color(if enabled || kind == ContentKind::ShaderPack { + colors::GRAY_300 + } else { + colors::WHITE.opacity(50) + }) + .child(title), + ) + .when_some(version, |this, version| { + this.child( + small(version) + .flex_none() + .text_color(colors::WHITE.opacity(50)), + ) + }) + .when( + kind == ContentKind::ResourcePack + && compatibility == Compatibility::Compatible, + |this| this.child(badge("Compatible", colors::GREEN_300)), + ) + .when(item.compatibility.warning().is_some(), |this| { + this.child(badge( + if compatibility == Compatibility::TooOld { + "Older version" + } else { + "Newer version" + }, + colors::YELLOW_300, + )) + }) + .when(item.modrinth.is_none() && !item.file.is_dir, |this| { + this.child(badge("Not on Modrinth", colors::GRAY_500)) + }), + ) + .child( + small(detail) + .truncate() + .text_color(colors::WHITE.opacity(30)), + ) + .when_some( + item.file + .meta + .description + .clone() + .filter(|_| kind == ContentKind::ResourcePack), + |this, description| { + this.child(small(description).truncate().text_color(colors::GRAY_400)) + }, + ) + .when_some(warning, |this, warning| { + this.child(small(warning).text_color(colors::YELLOW_300)) + }), + ) + .child( + div() + .flex() + .flex_row() + .flex_none() + .items_center() + .gap_2() + .when_some(update, |this, update| { + let name = name.clone(); + this.child( + Button::new( + ("update-one", id_base), + format!("Update to {}", update.version_number), + ) + .variant(ButtonVariant::Primary) + .icon(Icon::CircleUp) + .size(ButtonSize::Slim) + .disabled(busy) + .on_click( + cx.listener(move |this, _, _, cx| this.update_one(kind, &name, cx)), + ), + ) + }) + .when_some(reorder, |this, (can_up, can_down)| { + let up = name.clone(); + let down = name.clone(); + this.child( + IconButton::new(("raise", id_base), Icon::ArrowUp) + .disabled(busy || !can_up) + .on_click(cx.listener(move |this, _, _, cx| { + this.move_pack(up.clone(), true, cx) + })), + ) + .child( + IconButton::new(("lower", id_base), Icon::ArrowDown) + .disabled(busy || !can_down) + .on_click(cx.listener(move |this, _, _, cx| { + this.move_pack(down.clone(), false, cx) + })), + ) + }) + .when_some(page, |this, page| { + this.child( + IconButton::new( + ("modrinth-page", id_base), + Icon::ArrowUpRightFromSquare, + ) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.open_url(&page) + }), + ) + }) + .when(kind != ContentKind::ShaderPack, |this| { + let name = name.clone(); + this.child( + Toggle::new(("enabled", id_base), enabled) + .disabled(busy) + .on_change(cx.listener( + move |this, value: &bool, _, cx| match kind { + ContentKind::Mod => { + this.set_mod_enabled(name.clone(), *value, cx) + } + _ => this.set_pack_enabled( + name.clone(), + *value, + compatibility, + cx, + ), + }, + )), + ) + }) + .map(|this| { + let name = name.clone(); + if confirming { + this.child( + Button::new(("confirm-delete", id_base), "Move to Trash") + .variant(ButtonVariant::Danger) + .size(ButtonSize::Slim) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.delete(kind, name.clone(), cx) + })), + ) + .child( + Button::new(("cancel-delete", id_base), "Cancel") + .variant(ButtonVariant::Ghost) + .size(ButtonSize::Slim) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.cancel_delete(cx) + })), + ) + } else { + this.child( + IconButton::new(("delete", id_base), Icon::TrashCan) + .disabled(busy) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.delete(kind, name.clone(), cx) + })), + ) + } + }), + ) + .into_any_element() + } + + fn render_empty(&self, kind: ContentKind, cx: &mut Context) -> AnyElement { + let message = match kind { + ContentKind::Mod => "No mods yet. Browse Modrinth, or drop .jar files here.", + ContentKind::ResourcePack => { + "No resource packs yet. Browse Modrinth, or drop .zip files or folders here." + } + ContentKind::ShaderPack => { + "No shader packs yet. Browse Modrinth, or drop .zip files or folders here." + } + }; + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_3() + .py_12() + .rounded(radius::XL) + .border_2() + .border_dashed() + .border_color(colors::GRAY_FADED.opacity(10)) + .text_color(colors::GRAY_FADED.opacity(50)) + .font_weight(weights::BOLD.into()) + .child(message) + .child( + Button::new(("browse-empty", kind as usize), "Browse Modrinth") + .variant(ButtonVariant::Secondary) + .icon(Icon::MagnifyingGlass) + .on_click( + cx.listener(move |this, _, _, cx| this.set_view(kind, SubView::Browse, cx)), + ), + ) + .into_any_element() + } + + /// The card for vanilla instances: content that needs a mod loader. + fn render_needs_loader(&self, what: &str, cx: &mut Context) -> AnyElement { + let switching = self.is_switching_loader(cx); + let busy = self.locked(cx) || self.job.is_some(); + card() + .child(heading(format!("{what} need a mod loader"))) + .child( + div() + .text_color(colors::GRAY_400) + .whitespace_normal() + .child(format!( + "This instance is vanilla Minecraft. {what} run through a mod loader such as Fabric; switching keeps your worlds and settings, and Fabric installs the next time you press Play." + )), + ) + .child( + div() + .flex() + .flex_row() + .gap_2() + .child( + Button::new("switch-to-fabric", "Switch to Fabric") + .variant(ButtonVariant::Primary) + .loading(switching) + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| this.switch_to_fabric(cx))), + ) + .child( + Button::new("new-fabric-instance", "New Fabric instance…") + .variant(ButtonVariant::Secondary) + .icon(Icon::SquarePlus) + .on_click(cx.listener(|this, _, _, cx| this.new_fabric_instance(cx))), + ), + ) + .into_any_element() + } + + /// The Shaders section's card: the shader mod in use, or the one to install. + fn render_shader_mod(&mut self, cx: &mut Context) -> AnyElement { + let busy = self.locked(cx) || self.job.is_some(); + if let Some(loader) = self.shaders.loader { + let using = match ( + &self.shaders.selection.pack, + self.shaders.selection.internal, + ) { + (Some(pack), _) => format!("{} is using {pack}.", loader.label()), + (None, true) => format!("{} is using its built-in shaders.", loader.label()), + (None, false) => format!("{} is installed; shaders are off.", loader.label()), + }; + return card() + .flex_row() + .items_center() + .gap_3() + .child(Icon::Check.render(px(16.), colors::GREEN_300)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .child(div().font_weight(weights::BOLD.into()).child(using)) + .child(small(format!( + "Pick a pack below. {} reads its choice from {} when the game starts.", + loader.label(), + loader.config_file() + )) + .text_color(colors::GRAY_500)), + ) + .into_any_element(); + } + let target_loader = self + .context(cx) + .map(|(_, _, t)| t.loader) + .unwrap_or_default(); + if target_loader == Loader::None { + return self.render_needs_loader("Shaders", cx); + } + let optifine_note = small( + "OptiFine works too, but it isn't on Modrinth and its licence doesn't allow Lodestone to download it. If you prefer it, download it from optifine.net and add the jar with Add file… in Mods (Fabric also needs OptiFabric).", + ) + .whitespace_normal() + .text_color(colors::GRAY_500); + let body: AnyElement = match &self.shader_offer { + Load::NotLoaded | Load::Loading => div() + .flex() + .flex_row() + .gap_2() + .text_color(colors::GRAY_500) + .child(LoadingDots) + .child("Looking for a shader mod for this version on Modrinth") + .into_any_element(), + Load::Failed(err) => div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child(small(format!("Couldn't ask Modrinth: {err}")).text_color(colors::RED_200)) + .child( + Button::new("retry-shader-offer", "Try again") + .variant(ButtonVariant::Secondary) + .size(ButtonSize::Slim) + .on_click(cx.listener(|this, _, _, cx| this.load_shader_offer(cx))), + ) + .into_any_element(), + Load::Ready(ShaderModOffer::Install { loader, version }) => { + let label = format!("Install {} {}", loader.label(), version.version_number); + let with = match loader { + ShaderLoader::Iris => " It comes with Sodium, which Iris needs.", + ShaderLoader::Oculus => " It comes with Embeddium, which Oculus needs.", + ShaderLoader::OptiFine => "", + }; + div() + .flex() + .flex_col() + .gap_2() + .child( + div().text_color(colors::GRAY_400).whitespace_normal().child(format!( + "Minecraft can't load shader packs by itself; a shader mod does that. {} runs on this instance's {}.{with}", + loader.label(), + target_loader.label() + )), + ) + .child( + div().flex().child( + Button::new("install-shader-mod", label) + .variant(ButtonVariant::Primary) + .icon(Icon::Download) + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| this.install_shader_mod(cx))), + ), + ) + .into_any_element() + } + Load::Ready(ShaderModOffer::Unavailable { reason }) => div() + .text_color(colors::GRAY_400) + .whitespace_normal() + .child(format!( + "Minecraft can't load shader packs by itself; a shader mod does that. {reason} Fabric instances can always use Iris." + )) + .into_any_element(), + Load::Ready(ShaderModOffer::NeedsModLoader) => return self.render_needs_loader("Shaders", cx), + }; + card() + .child(heading("Shaders need a shader mod")) + .child(body) + .child(optifine_note) + .into_any_element() + } + + fn render_installed(&mut self, kind: ContentKind, cx: &mut Context) -> AnyElement { + let target = self.context(cx).map(|(_, _, t)| t); + let listing = self.section(kind).listing.clone(); + let mut column = div().flex().flex_col().gap_3(); + if kind == ContentKind::ShaderPack { + column = column.child(self.render_shader_mod(cx)); + } + if kind == ContentKind::Mod && target.as_ref().is_some_and(|t| t.loader == Loader::None) { + column = column.child(self.render_needs_loader("Mods", cx)); + } + column = column.child(self.render_toolbar(kind, cx)); + let listing = match listing { + Load::NotLoaded | Load::Loading => { + return column + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .py_6() + .justify_center() + .text_color(colors::GRAY_500) + .child(LoadingDots) + .child(format!("Reading the {} folder", kind.dir())), + ) + .into_any_element(); + } + Load::Failed(err) => { + return column + .child( + small(format!("Couldn't read the {} folder: {err}", kind.dir())) + .text_color(colors::RED_200), + ) + .into_any_element(); + } + Load::Ready(listing) => listing, + }; + if let Some(error) = &listing.lookup_error { + column = column.child( + small(format!( + "Some files couldn't be matched to Modrinth: {error}" + )) + .text_color(colors::GRAY_500), + ); + } + if let Some(error) = &listing.options_error { + column = column.child(small(error.clone()).text_color(colors::YELLOW_300)); + } + if listing.items.is_empty() { + return column.child(self.render_empty(kind, cx)).into_any_element(); + } + match kind { + ContentKind::Mod => { + let mut rows = list_box(); + for (index, item) in listing.items.iter().enumerate() { + rows = rows.child(self.render_item(kind, index, item, index == 0, None, cx)); + } + column = column.child(rows); + } + ContentKind::ResourcePack => { + let mut enabled: Vec<(usize, &Item)> = listing + .items + .iter() + .enumerate() + .filter(|(_, i)| i.priority.is_some()) + .collect(); + enabled.sort_by_key(|(_, i)| i.priority); + let others: Vec<(usize, &Item)> = listing + .items + .iter() + .enumerate() + .filter(|(_, i)| i.priority.is_none()) + .collect(); + if !enabled.is_empty() { + column = column.child( + small("ENABLED · the top pack wins where packs overlap") + .font_weight(weights::BOLD.into()) + .text_color(colors::GRAY_500), + ); + let count = enabled.len(); + let mut rows = list_box(); + for (position, (index, item)) in enabled.into_iter().enumerate() { + let reorder = Some((position > 0, position + 1 < count)); + rows = rows.child(self.render_item( + kind, + index, + item, + position == 0, + reorder, + cx, + )); + } + column = column.child(rows); + } + if !others.is_empty() { + column = column.child( + small("AVAILABLE") + .font_weight(weights::BOLD.into()) + .text_color(colors::GRAY_500), + ); + let mut rows = list_box(); + for (position, (index, item)) in others.into_iter().enumerate() { + rows = rows.child(self.render_item( + kind, + index, + item, + position == 0, + None, + cx, + )); + } + column = column.child(rows); + } + } + ContentKind::ShaderPack => { + let loader = self.shaders.loader; + let busy = self.locked(cx) || self.job.is_some() || loader.is_none(); + let selected_none = self.shaders.selection.pack.is_none(); + let mut rows = list_box().child( + row_base("shaders-off", true) + .when(!busy, |this| { + this.cursor_pointer() + .hover(|style| style.bg(colors::GRAY_750)) + .on_click( + cx.listener(|this, _, _, cx| this.select_shader(None, cx)), + ) + }) + .child(radio(selected_none && loader.is_some())) + .child( + div() + .flex() + .flex_col() + .child(div().font_weight(weights::BOLD.into()).child("Shaders off")) + .child( + small("Minecraft's normal look.") + .text_color(colors::WHITE.opacity(30)), + ), + ), + ); + for (index, item) in listing.items.iter().enumerate() { + let name = item.file.file_name.clone(); + let row = self.render_item(kind, index, item, true, None, cx); + let selected = item.enabled; + rows = rows.child( + div() + .id(("shader-select", index)) + .flex() + .flex_row() + .items_center() + .bg(colors::GRAY_800) + .border_t_1() + .border_color(colors::GRAY_FADED.opacity(15)) + .when(selected, |this| this.bg(colors::BLUE.opacity(15))) + .when(!busy, |this| { + this.cursor_pointer() + .hover(|style| style.bg(colors::GRAY_750)) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_shader(Some(name.clone()), cx) + })) + }) + .child(div().pl_4().child(radio(selected))) + .child(div().flex_1().min_w_0().child(row)), + ); + } + column = column.child(rows); + } + } + column.into_any_element() + } + + fn render_browse(&mut self, kind: ContentKind, cx: &mut Context) -> AnyElement { + if self.modrinth(cx).is_none() + && matches!(self.section(kind).browse.results, Load::NotLoaded) + && self.section(kind).browse.project.is_none() + { + return muted("Browsing Modrinth needs the network.").into_any_element(); + } + if self.section(kind).browse.project.is_some() { + return self.render_project(kind, cx); + } + let browse = &self.section(kind).browse; + let search = browse.search.clone(); + let only = browse.only_compatible; + let index = browse.index; + let offset = browse.offset; + let results = browse.results.clone(); + let target_label = self.target_label(kind, cx); + let indexes = [ + (ModrinthSearchIndex::Relevance, "Relevance"), + (ModrinthSearchIndex::Downloads, "Downloads"), + (ModrinthSearchIndex::Follows, "Follows"), + (ModrinthSearchIndex::Newest, "Newest"), + (ModrinthSearchIndex::Updated, "Updated"), + ]; + let selected_index = indexes.iter().position(|(i, _)| *i == index).unwrap_or(0); + let entity = cx.entity(); + let installed = self.section(kind).installed_projects(); + let mut column = div() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child(Icon::MagnifyingGlass.render(px(14.), colors::GRAY_500)) + .child(div().flex_1().child(search)), + ) + .child( + div() + .flex() + .flex_row() + .flex_wrap() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + Toggle::new(("only-compatible", kind as usize), only).on_change( + cx.listener(move |this, value: &bool, _, cx| { + this.set_only_compatible(kind, *value, cx) + }), + ), + ) + .child( + small(format!("Only show {}s for {target_label}", kind.noun())) + .text_color(colors::GRAY_400), + ), + ) + .child(pills( + "search-index", + indexes + .iter() + .map(|(_, label)| SharedString::from(*label)) + .collect(), + selected_index, + move |i, _, cx| { + let index = indexes[i].0; + entity.update(cx, |this, cx| this.set_index(kind, index, cx)) + }, + )), + ); + if kind == ContentKind::ShaderPack && self.shaders.loader.is_none() { + column = column.child(self.render_shader_mod(cx)); + } + match results { + Load::NotLoaded | Load::Loading => { + column = column.child( + div() + .flex() + .flex_row() + .justify_center() + .gap_2() + .py_6() + .text_color(colors::GRAY_500) + .child(LoadingDots) + .child("Searching Modrinth"), + ); + } + Load::Failed(err) => { + column = column.child(small(err).text_color(colors::RED_200)); + } + Load::Ready(results) => { + if results.hits.is_empty() { + column = column.child(muted("Nothing on Modrinth matches.")); + } + let mut cards = div().flex().flex_col().gap_2(); + for (i, hit) in results.hits.iter().enumerate() { + let installed = installed.contains_key(&hit.project_id); + cards = cards.child(self.render_hit(kind, i, hit, installed, cx)); + } + column = column.child(cards); + let pages = results.total_hits.div_ceil(PAGE_SIZE).max(1); + let page = offset / PAGE_SIZE + 1; + if results.total_hits > PAGE_SIZE { + column = column.child( + div() + .flex() + .flex_row() + .items_center() + .justify_center() + .gap_3() + .child( + Button::new(("previous-page", kind as usize), "Previous") + .variant(ButtonVariant::Secondary) + .size(ButtonSize::Slim) + .disabled(offset == 0) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_page(kind, offset.saturating_sub(PAGE_SIZE), cx) + })), + ) + .child( + small(format!("Page {page} of {pages}")) + .text_color(colors::GRAY_500), + ) + .child( + Button::new(("next-page", kind as usize), "Next") + .variant(ButtonVariant::Secondary) + .size(ButtonSize::Slim) + .disabled(page >= pages) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_page(kind, offset + PAGE_SIZE, cx) + })), + ), + ); + } + } + } + column.into_any_element() + } + + fn render_hit( + &mut self, + kind: ContentKind, + index: usize, + hit: &ModrinthSearchHit, + installed: bool, + cx: &mut Context, + ) -> AnyElement { + let icon = hit + .icon_url + .as_deref() + .and_then(|url| self.remote_icon(url, cx)); + let id = hit.project_id.clone(); + let client_badge = kind == ContentKind::Mod && hit.client_side == "optional"; + div() + .id(("hit", index)) + .flex() + .flex_row() + .items_center() + .gap_3() + .p_3() + .rounded(radius::LG) + .bg(colors::GRAY_800) + .border_1() + .border_color(colors::FADE_700) + .cursor_pointer() + .hover(|style| style.bg(colors::GRAY_750)) + .on_click( + cx.listener(move |this, _, _, cx| this.open_project(kind, id.clone(), None, cx)), + ) + .child(icon_box(icon, 48.)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + div() + .truncate() + .font_weight(weights::BOLD.into()) + .text_color(colors::GRAY_300) + .child(hit.title.clone()), + ) + .child( + small(format!("by {}", hit.author)) + .flex_none() + .text_color(colors::WHITE.opacity(50)), + ) + .when(installed, |this| { + this.child(badge("Installed", colors::GREEN_300)) + }) + .when(client_badge, |this| { + this.child(badge("Optional on servers", colors::GRAY_500)) + }), + ) + .child( + small(hit.description.clone()) + .text_color(colors::GRAY_400) + .line_clamp(2), + ), + ) + .child( + div() + .flex() + .flex_col() + .flex_none() + .items_end() + .child( + small(format!("{} downloads", compact(hit.downloads))) + .text_color(colors::GRAY_400), + ) + .child( + small(format!("{} follows", compact(hit.follows))) + .text_color(colors::GRAY_500), + ), + ) + .into_any_element() + } + + fn render_project(&mut self, kind: ContentKind, cx: &mut Context) -> AnyElement { + let Some(view) = self.section(kind).browse.project.as_ref() else { + return div().into_any_element(); + }; + let project = view.project.clone(); + let versions = view.versions.clone(); + let show_all = view.show_all; + let with_dependencies = view.with_dependencies; + let confirm_force = view.confirm_force.clone(); + let linked = view.linked_version.clone(); + let back = Button::new(("back", kind as usize), "Back to results") + .variant(ButtonVariant::Ghost) + .icon(Icon::ArrowLeft) + .size(ButtonSize::Slim) + .on_click(cx.listener(move |this, _, _, cx| this.close_project(kind, cx))); + let project = match project { + Load::Ready(project) => project, + Load::Failed(err) => { + return div() + .flex() + .flex_col() + .gap_3() + .child(div().flex().child(back)) + .child(small(err).text_color(colors::RED_200)) + .into_any_element(); + } + _ => { + return div() + .flex() + .flex_col() + .gap_3() + .child(div().flex().child(back)) + .child( + div() + .flex() + .flex_row() + .gap_2() + .text_color(colors::GRAY_500) + .child(LoadingDots) + .child("Loading the project"), + ) + .into_any_element(); + } + }; + let icon = project + .icon_url + .as_deref() + .and_then(|url| self.remote_icon(url, cx)); + let page = modrinth_page(&project.project_type, &project.slug); + let mut column = div() + .flex() + .flex_col() + .gap_4() + .child(div().flex().child(back)) + .child( + div() + .flex() + .flex_row() + .items_start() + .gap_4() + .child(icon_box(icon, 72.)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .font_family(fonts::TITLE) + .text_size(theme::text_size(text::H2)) + .font_weight(weights::BOLD.into()) + .text_color(colors::GRAY_300) + .child(project.title.clone()), + ) + .child( + div() + .text_color(colors::GRAY_400) + .whitespace_normal() + .child(project.description.clone()), + ) + .child( + div() + .flex() + .flex_row() + .flex_wrap() + .items_center() + .gap_2() + .child( + small(format!( + "{} downloads · {} followers", + compact(project.downloads), + compact(project.followers) + )) + .text_color(colors::GRAY_500), + ) + .children( + project + .categories + .iter() + .take(6) + .map(|c| badge(c.clone(), colors::BLUE_200)), + ), + ), + ) + .child( + Button::new(("open-modrinth", kind as usize), "View on Modrinth") + .variant(ButtonVariant::Secondary) + .icon(Icon::ArrowUpRightFromSquare) + .size(ButtonSize::Slim) + .on_click(move |_, _, cx| cx.open_url(&page)), + ), + ); + if kind == ContentKind::ShaderPack && self.shaders.loader.is_none() { + column = column.child(self.render_shader_mod(cx)); + } + let versions = match versions { + Load::Ready(versions) => versions, + Load::Failed(err) => { + return column + .child(small(err).text_color(colors::RED_200)) + .into_any_element(); + } + _ => { + return column + .child( + div() + .flex() + .flex_row() + .gap_2() + .text_color(colors::GRAY_500) + .child(LoadingDots) + .child("Loading versions"), + ) + .into_any_element(); + } + }; + let target = self.context(cx).map(|(_, _, t)| t); + let installed_versions = self + .section(kind) + .installed_projects() + .remove(&project.id) + .unwrap_or_default(); + let mut shown: Vec<(RawVersion, Option)> = versions + .into_iter() + .map(|v| { + let problem = target.as_ref().and_then(|t| t.incompatibility(kind, &v)); + (v, problem) + }) + .filter(|(v, problem)| { + show_all || problem.is_none() || linked.as_deref() == Some(v.id.as_str()) + }) + .collect(); + if let Some(linked) = &linked { + shown.sort_by_key(|(v, _)| &v.id != linked); + } + let total = shown.len(); + let busy = self.locked(cx) || self.job.is_some(); + column = column.child( + div() + .flex() + .flex_row() + .flex_wrap() + .items_center() + .gap_4() + .child(heading("Versions")) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + Toggle::new(("show-all", kind as usize), show_all).on_change( + cx.listener(move |this, value: &bool, _, cx| { + if let Some(view) = this.project_mut(kind) { + view.show_all = *value; + } + cx.notify(); + }), + ), + ) + .child( + small("Show versions for other loaders and Minecraft versions") + .text_color(colors::GRAY_400), + ), + ) + .when(kind == ContentKind::Mod, |this| { + this.child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + Toggle::new(("with-deps", kind as usize), with_dependencies) + .on_change(cx.listener(move |this, value: &bool, _, cx| { + if let Some(view) = this.project_mut(kind) { + view.with_dependencies = *value; + } + cx.notify(); + })), + ) + .child( + small("Also install required dependencies") + .text_color(colors::GRAY_400), + ), + ) + }), + ); + if shown.is_empty() { + return column + .child(muted(format!("No version of {} fits this instance. Turn on \"Show versions…\" to see them all.", project.title))) + .into_any_element(); + } + let mut rows = list_box(); + for (index, (version, problem)) in shown.into_iter().take(40).enumerate() { + let (type_label, type_color) = version_label(version.version_type); + let is_installed = installed_versions.contains(&version.id); + let size = version.primary_file().map_or(0, |f| f.size); + let date = version + .date_published + .get(..10) + .unwrap_or_default() + .to_string(); + let forcing = confirm_force.as_deref() == Some(version.id.as_str()); + let version_for_click = version.clone(); + let project_for_click = project.clone(); + let id = version.id.clone(); + let button = if is_installed { + badge("Installed", colors::GREEN_300).into_any_element() + } else if problem.is_some() && !forcing { + Button::new(("install-anyway", index), "Install anyway…") + .variant(ButtonVariant::Secondary) + .size(ButtonSize::Slim) + .disabled(busy) + .on_click(cx.listener(move |this, _, _, cx| { + if let Some(view) = this.project_mut(kind) { + view.confirm_force = Some(id.clone()); + } + cx.notify(); + })) + .into_any_element() + } else { + let label = match ( + kind, + forcing, + self.shaders.loader, + self.shader_offer.ready(), + ) { + (_, true, _, _) => "Install it anyway".to_string(), + ( + ContentKind::ShaderPack, + _, + None, + Some(ShaderModOffer::Install { loader, .. }), + ) => { + format!("Install with {}", loader.label()) + } + _ => "Install".to_string(), + }; + Button::new(("install", index), label) + .variant(if forcing { + ButtonVariant::Danger + } else { + ButtonVariant::Primary + }) + .icon(Icon::Download) + .size(ButtonSize::Slim) + .disabled(busy) + .on_click(cx.listener(move |this, _, _, cx| { + this.install_version( + kind, + version_for_click.clone(), + Some(project_for_click.clone()), + forcing, + cx, + ) + })) + .into_any_element() + }; + rows = rows.child( + row_base(("version-row", index), index == 0) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + div() + .truncate() + .font_weight(weights::BOLD.into()) + .child(version.version_number.clone()), + ) + .child(badge(type_label, type_color)), + ) + .child( + small(format!( + "{} · {} · {date} · {}", + short_list(&version.game_versions, 3), + short_list(&version.loaders, 3), + format_byte(size) + )) + .truncate() + .text_color(colors::WHITE.opacity(40)), + ) + .when_some(problem, |this, problem| { + this.child(small(problem).text_color(colors::YELLOW_300)) + }), + ) + .child(button), + ); + } + column = column.child(rows); + if total > 40 { + column = column.child( + small(format!("{} more versions are on Modrinth.", total - 40)) + .text_color(colors::GRAY_500), + ); + } + column.into_any_element() + } +} + +/// A round selection mark. +fn radio(selected: bool) -> impl IntoElement { + div() + .flex() + .flex_none() + .items_center() + .justify_center() + .size(px(16.)) + .rounded(px(8.)) + .border_2() + .border_color(if selected { + colors::BLUE_200 + } else { + colors::GRAY_600 + }) + .when(selected, |this| { + this.child(div().size(px(6.)).rounded(px(3.)).bg(colors::BLUE_200)) + }) +} + +impl Render for ContentView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let kind = self.kind; + // Every section, for the counts in the switch; the Shaders card and shader + // searches also need to know the shader mod. + for kind in ContentKind::ALL { + self.ensure_loaded(kind, cx); + } + let view = self.section(kind).view; + if view == SubView::Browse + && matches!(self.section(kind).browse.results, Load::NotLoaded) + && self.section(kind).browse.project.is_none() + { + self.search(kind, cx); + } + let body = match view { + SubView::Installed => self.render_installed(kind, cx), + SubView::Browse => self.render_browse(kind, cx), + }; + let accepts_drop = view == SubView::Installed; + div() + .flex() + .flex_col() + .size_full() + .gap_3() + .child(self.render_header(cx)) + .child(self.render_banners(cx)) + .child( + div() + .id(("content-body", kind as usize)) + .flex_1() + .min_h_0() + .overflow_y_scroll() + .rounded(radius::LG) + .border_2() + .border_color(colors::TRANSPARENT) + .when(accepts_drop, |this| { + this.drag_over::(|style, _, _, _| { + style + .border_color(colors::BLUE_200.opacity(60)) + .bg(colors::BLUE.opacity(8)) + }) + .on_drop(cx.listener( + move |this, paths: &ExternalPaths, _, cx| { + this.add_files(kind, paths.paths().to_vec(), cx) + }, + )) + }) + .child(div().pb_4().child(body)), + ) + } +} diff --git a/launcher/crates/lodestone-launcher/src/instances/loader_picker.rs b/launcher/crates/lodestone-launcher/src/instances/loader_picker.rs index 534e562a0..6e858bc55 100644 --- a/launcher/crates/lodestone-launcher/src/instances/loader_picker.rs +++ b/launcher/crates/lodestone-launcher/src/instances/loader_picker.rs @@ -63,7 +63,7 @@ impl LoaderPicker { cx.notify(); } - fn set_kind(&mut self, kind: Option, cx: &mut Context) { + pub fn set_kind(&mut self, kind: Option, cx: &mut Context) { if self.kind == kind { return; } diff --git a/launcher/crates/lodestone-launcher/src/instances/mod.rs b/launcher/crates/lodestone-launcher/src/instances/mod.rs index db529970d..15a892eb0 100644 --- a/launcher/crates/lodestone-launcher/src/instances/mod.rs +++ b/launcher/crates/lodestone-launcher/src/instances/mod.rs @@ -2,6 +2,7 @@ //! instance's run (installing, playing) with its console log. pub mod console; +pub mod content; pub mod fields; pub mod loader_picker; pub mod new_instance; @@ -17,6 +18,7 @@ use std::{ use futures::StreamExt as _; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, RenderImage, SharedString, Task}; use launcher_core::{ + content::{InstanceDirs, Modrinth, Target}, export, game::{ crash::{self, CrashSummary, Outcome}, @@ -232,6 +234,8 @@ pub enum InstancesEvent { title: SharedString, message: SharedString, }, + /// The instance's loader was changed outside its Settings tab. + LoaderChanged(String), } pub struct Instances { @@ -258,6 +262,13 @@ pub struct Instances { exports: Vec>, /// Replaces the Play button's state, for the offscreen screenshots. preview_state: Option, + /// The Modrinth client the Content tab shares, with its cache. `None` keeps the + /// offscreen screenshots offline. + modrinth: Option>, + /// The downloader for content files. + content_downloader: Downloader, + /// "Switch to Fabric" in progress. + loader_switch: Option>, } impl EventEmitter for Instances {} @@ -285,6 +296,8 @@ impl Instances { let http = launcher_core::http::default_client().expect("could not start the HTTP client"); let download_http = launcher_core::http::download_client().expect("could not start the HTTP client"); + let modrinth = Arc::new(Modrinth::production(http.clone())); + let content_downloader = Downloader::new(download_http.clone(), gpui_sleep(cx)); let installer = Installer::new( http, Downloader::new(download_http, gpui_sleep(cx)), @@ -318,6 +331,9 @@ impl Instances { loader_tasks: HashMap::new(), exports: Vec::new(), preview_state: None, + modrinth: Some(modrinth), + content_downloader, + loader_switch: None, }; this.reload(cx); if settings_notice.is_some() { @@ -629,10 +645,11 @@ impl Instances { return; }; let game_dir = self.store.game_dir(id); + let instance_dir = self.store.dir(id); let write = cx.background_spawn(async move { - // Content management (a later milestone) will know which mods are on - // Modrinth; until then every mod goes into the overrides. - let plan = export::plan(&instance, &game_dir, &HashMap::new())?; + // Files that came from Modrinth are listed by URL; the rest are copied. + let on_modrinth = export::modrinth_files(&instance_dir); + let plan = export::plan(&instance, &game_dir, &on_modrinth)?; export::write(&plan, &out)?; Ok::<_, export::ExportError>((plan, out)) }); @@ -708,6 +725,111 @@ impl Instances { self.store.game_dir(id) } + /// The Modrinth client, unless the app is offline (the offscreen screenshots). + pub fn modrinth(&self) -> Option> { + self.modrinth.clone() + } + + /// Keeps the Content tab off the network, for the offscreen screenshots. + pub fn preview_offline(&mut self) { + self.modrinth = None; + } + + pub fn content_downloader(&self) -> Downloader { + self.content_downloader.clone() + } + + pub fn content_dirs(&self, id: &str) -> InstanceDirs { + InstanceDirs::new(self.store.dir(id)) + } + + /// What the instance accepts, without its shader mod (the Content tab adds it). + pub fn content_target(&self, id: &str) -> Option { + let instance = self.list.iter().find(|i| i.id == id)?; + Some(Target::new(instance.game_version.clone(), instance.loader)) + } + + pub fn is_switching_loader(&self) -> bool { + self.loader_switch.is_some() + } + + /// Makes a vanilla instance a Fabric one, with Fabric's newest stable loader for its + /// Minecraft version: the same change as picking Fabric in its Settings tab. Fabric + /// installs the next time the game starts. + pub fn switch_to_fabric(&mut self, id: &str, cx: &mut Context) { + if self.is_active(id) || self.loader_switch.is_some() { + return; + } + let Ok(instance) = self.store.get(id) else { + return; + }; + let installer = self.installer.clone(); + let game_version = instance.game_version.clone(); + let fetch = cx.background_spawn(async move { + installer + .loader_versions(LoaderKind::Fabric, &game_version) + .await + }); + let id = id.to_string(); + self.loader_switch = Some(cx.spawn(async move |this, cx| { + let result = fetch.await; + this.update(cx, |this, cx| { + this.loader_switch = None; + let version = match result { + Ok(list) => list + .iter() + .find(|v| v.recommended) + .or_else(|| list.iter().find(|v| v.stable)) + .or_else(|| list.first()) + .cloned(), + Err(err) => { + cx.emit(InstancesEvent::LaunchFailed { + title: "Couldn't switch to Fabric".into(), + message: err.to_string().into(), + }); + cx.notify(); + return; + } + }; + let Some(version) = version else { + cx.emit(InstancesEvent::LaunchFailed { + title: "Couldn't switch to Fabric".into(), + message: format!( + "Fabric has no loader for Minecraft {}.", + instance.game_version + ) + .into(), + }); + cx.notify(); + return; + }; + let mut changed = instance.clone(); + changed.loader = launcher_core::instances::Loader::Fabric; + changed.loader_version = Some(version.id.clone()); + match this.save(&changed, cx) { + Ok(()) => { + cx.emit(InstancesEvent::LoaderChanged(id.clone())); + cx.emit(InstancesEvent::Notice { + title: "Switched to Fabric".into(), + message: format!( + "{} now uses Fabric {}. It installs the next time you press Play.", + changed.name, version.display + ) + .into(), + }); + } + Err(err) => cx.emit(InstancesEvent::LaunchFailed { + title: "Couldn't switch to Fabric".into(), + message: err.into(), + }), + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + /// The Play button's state for the selected instance. pub fn play_state(&self) -> PlayState { if let Some(state) = &self.preview_state { diff --git a/launcher/crates/lodestone-launcher/src/instances/new_instance.rs b/launcher/crates/lodestone-launcher/src/instances/new_instance.rs index ec0648a56..4d7a688b7 100644 --- a/launcher/crates/lodestone-launcher/src/instances/new_instance.rs +++ b/launcher/crates/lodestone-launcher/src/instances/new_instance.rs @@ -139,6 +139,16 @@ impl NewInstanceDialog { .update(cx, |picker, cx| picker.set_game_version(version, cx)); } + /// Chooses a loader, as clicking it would. + pub fn choose_loader( + &mut self, + kind: launcher_core::game::loaders::LoaderKind, + cx: &mut Context, + ) { + self.loader + .update(cx, |picker, cx| picker.set_kind(Some(kind), cx)); + } + /// The loader picker, for the offscreen screenshots. pub fn loader_picker(&self) -> &Entity { &self.loader diff --git a/launcher/crates/lodestone-launcher/src/instances/settings.rs b/launcher/crates/lodestone-launcher/src/instances/settings.rs index 2320a6505..e0594c38c 100644 --- a/launcher/crates/lodestone-launcher/src/instances/settings.rs +++ b/launcher/crates/lodestone-launcher/src/instances/settings.rs @@ -9,6 +9,7 @@ use gpui::{ Subscription, Task, Window, div, img, prelude::*, px, }; use launcher_core::{ + content::loader_change::{self, LoaderChangeCheck}, game::runtime::{self, JavaInfo}, instances::{Instance, Loader, Memory}, }; @@ -26,6 +27,17 @@ use crate::{ /// Asks the shell to confirm deleting the instance. pub struct DeleteRequested(pub String); +/// Asks the shell to confirm a loader change that leaves mods behind or puts worlds at +/// risk. +#[derive(Clone, Debug)] +pub struct LoaderChangePrompt { + /// The instance as it will be saved. + pub instance: Instance, + /// The loader it uses now. + pub from: Loader, + pub check: LoaderChangeCheck, +} + /// The result of checking the Java override. #[derive(Clone, Debug)] enum JavaCheck { @@ -62,6 +74,7 @@ pub struct InstanceSettings { } impl EventEmitter for InstanceSettings {} +impl EventEmitter for InstanceSettings {} impl InstanceSettings { pub fn new(instances: Entity, instance: &Instance, cx: &mut Context) -> Self { @@ -151,26 +164,102 @@ impl InstanceSettings { cx.notify(); } + /// Saves the fields. A loader change that would leave enabled mods unable to load, + /// or worlds without their modded blocks, asks first (see [`LoaderChangePrompt`]). fn save(&mut self, cx: &mut Context) { let before = self.instance(cx); - let result = self.read_fields(cx).and_then(|instance| { - self.instances - .update(cx, |instances, cx| instances.save(&instance, cx)) - .map(|()| instance) - }); + let instance = match self.read_fields(cx) { + Ok(instance) => instance, + Err(err) => return self.set_message(err, true, cx), + }; + if let Some(prompt) = self.loader_change_prompt(before.as_ref(), &instance, cx) { + cx.emit(prompt); + return; + } + self.commit(instance, &[], cx); + } + + /// The question to ask before saving `instance`, if its loader change needs one. + fn loader_change_prompt( + &self, + before: Option<&Instance>, + instance: &Instance, + cx: &App, + ) -> Option { + let before = before?; + if before.loader == instance.loader { + return None; + } + let dirs = self.instances.read(cx).content_dirs(&instance.id); + let check = loader_change::check( + &dirs, + before.loader, + instance.loader, + &instance.game_version, + ) + .ok()?; + check.needs_confirmation().then(|| LoaderChangePrompt { + instance: instance.clone(), + from: before.loader, + check, + }) + } + + /// The prompt for changing the loader to `loader` at `version`, as Save would ask + /// it, for the offscreen screenshots. + pub fn preview_loader_change( + &self, + loader: Loader, + version: &str, + cx: &App, + ) -> Option { + let before = self.instance(cx)?; + let mut instance = before.clone(); + instance.loader = loader; + instance.loader_version = Some(version.to_string()); + self.loader_change_prompt(Some(&before), &instance, cx) + } + + /// Saves `instance`, after turning off `disable` (mods, with `.disabled`). + pub fn commit(&mut self, instance: Instance, disable: &[String], cx: &mut Context) { + let before = self.instance(cx); + if !disable.is_empty() { + let dirs = self.instances.read(cx).content_dirs(&instance.id); + if let Err(err) = loader_change::disable_mods(&dirs, disable) { + return self.set_message(format!("Couldn't turn the mods off: {err}"), true, cx); + } + } + let running = self.instances.read(cx).is_active(&instance.id); + let result = self + .instances + .update(cx, |instances, cx| instances.save(&instance, cx)); match result { - Ok(instance) => { + Ok(()) => { let loader_changed = before.is_some_and(|before| { (before.loader, &before.loader_version) != (instance.loader, &instance.loader_version) }); - let message = if loader_changed { - format!( - "Saved. {} installs the next time you press Play.", + let turned_off = match disable.len() { + 0 => String::new(), + 1 => " 1 mod was turned off.".to_string(), + n => format!(" {n} mods were turned off."), + }; + let message = match (loader_changed, running) { + (true, true) => format!( + "Saved. The game is running; {} installs the next time it starts.{turned_off}", instance.loader_label() - ) - } else { - "Saved. The changes apply the next time you press Play.".to_string() + ), + (true, false) => format!( + "Saved. {} installs the next time you press Play.{turned_off}", + instance.loader_label() + ), + (false, true) => { + "Saved. The game is running, so the changes apply the next time it starts." + .to_string() + } + (false, false) => { + "Saved. The changes apply the next time you press Play.".to_string() + } }; self.set_message(message, false, cx); } diff --git a/launcher/crates/lodestone-launcher/src/preview.rs b/launcher/crates/lodestone-launcher/src/preview.rs index b67a79abd..e2db69382 100644 --- a/launcher/crates/lodestone-launcher/src/preview.rs +++ b/launcher/crates/lodestone-launcher/src/preview.rs @@ -48,6 +48,14 @@ pub const SCENES: &[&str] = &[ "instance-settings", "crash", "rosetta", + "content-mods", + "content-browse", + "content-project", + "content-resourcepacks", + "content-shaders", + "content-shaders-missing", + "content-shaders-vanilla", + "loader-change", ]; /// Scenes that show made-up instances. @@ -55,7 +63,8 @@ fn has_instances(scene: &str) -> bool { matches!( scene, "play-progress" | "playing" | "logs" | "instance-settings" | "crash" | "rosetta" - ) + ) || scene.starts_with("content-") + || scene == "loader-change" } pub fn requested_scene() -> Option { @@ -169,6 +178,11 @@ pub fn instances(scene: &str, accounts: &Entity, cx: &mut App) -> Enti if let Ok(mut instance) = store.create(name, version, choice.as_ref()) { instance.group = group.map(str::to_string); let _ = store.save(&instance); + if name == "Survival 1.20.1" + && (scene.starts_with("content-") || scene == "loader-change") + { + content::fill(&store.dir(&instance.id), scene); + } if played > 0 { let _ = store.record_play(&instance.id, 1_762_500_000, played); } @@ -179,6 +193,8 @@ pub fn instances(scene: &str, accounts: &Entity, cx: &mut App) -> Enti let scene = scene.to_string(); cx.new(|cx| { let mut instances = Instances::new(data_dir, accounts, cx); + // The screenshots never touch the network. + instances.preview_offline(); let manifest: MojangVersionManifest = serde_json::from_str(include_str!( "../../launcher-core/testdata/version_manifest_v2.json" )) @@ -194,10 +210,20 @@ pub fn instances(scene: &str, accounts: &Entity, cx: &mut App) -> Enti "1.20.1", crate::instances::LoaderVersions::Ready(Arc::new(fabric)), ); + let wanted = match scene.as_str() { + "content-shaders-vanilla" => Some("Latest release"), + scene if scene.starts_with("content-") || scene == "loader-change" => { + Some("Survival 1.20.1") + } + _ => None, + }; let Some(first) = instances .list() .iter() - .find(|i| i.game_version == "1.20.1") + .find(|i| match wanted { + Some(name) => i.name == name, + None => i.game_version == "1.20.1", + }) .cloned() else { return instances; @@ -307,6 +333,8 @@ fn sample_log() -> Vec { lines } +mod content; + pub fn apply(scene: &str, shell: &mut LauncherShell, cx: &mut Context) { match scene { "new-instance" => shell.open_new_instance_with(Some(("Survival", "1.")), None, cx), @@ -336,6 +364,24 @@ pub fn apply(scene: &str, shell: &mut LauncherShell, cx: &mut Context { + shell.select_tab(InstanceTab::Content, cx); + let view = shell.content().clone(); + view.update(cx, |view, cx| content::apply(scene, view, cx)); + } + "loader-change" => { + shell.select_tab(InstanceTab::Settings, cx); + let prompt = shell.settings_view().and_then(|settings| { + settings.read(cx).preview_loader_change( + launcher_core::instances::Loader::Forge, + "1.20.1-47.4.28", + cx, + ) + }); + if let Some(prompt) = prompt { + shell.show_loader_change(prompt, cx); + } + } "account-menu" | "needs-sign-in" | "account-menu-launcher" => shell.open_account_menu(cx), "launcher-identity" => shell.preview_enable_launcher_identity(cx), "sign-in-launcher" => shell.open_sign_in_at(Stage::Choose, None, cx), diff --git a/launcher/crates/lodestone-launcher/src/preview/content.rs b/launcher/crates/lodestone-launcher/src/preview/content.rs new file mode 100644 index 000000000..23afff15f --- /dev/null +++ b/launcher/crates/lodestone-launcher/src/preview/content.rs @@ -0,0 +1,590 @@ +//! The Content tab's screenshots: sample mods, resource packs and shader packs written +//! into the made-up instance, their provenance, and prepared Modrinth answers. + +use std::{collections::HashMap, fs, io::Write as _, path::Path}; + +use gpui::Context; +use launcher_core::content::{ + ContentKind, InstanceDirs, + provenance::{Entry, ModrinthSource, Provenance}, + shaders::{ShaderLoader, ShaderModOffer}, +}; +use lodestone_minecraft::modrinth::{ + ModrinthProject, ModrinthSearchResponse, RawProject, RawVersion, +}; +use serde_json::json; + +use crate::instances::content::ContentView; + +/// A square icon in two shades of `rgb`, with a lighter block in the middle. +fn icon(rgb: [u8; 3]) -> image::RgbaImage { + image::RgbaImage::from_fn(64, 64, |x, y| { + let inner = (16..48).contains(&x) && (16..48).contains(&y); + let shade = if inner { + 1.25 + } else if (x / 8 + y / 8) % 2 == 0 { + 1.0 + } else { + 0.85 + }; + let c = |v: u8| ((v as f32 * shade).min(255.0)) as u8; + image::Rgba([c(rgb[0]), c(rgb[1]), c(rgb[2]), 255]) + }) +} + +fn png(rgb: [u8; 3]) -> Vec { + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(icon(rgb)) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("a PNG encodes"); + bytes +} + +fn write_zip(path: &Path, entries: &[(&str, Vec)]) { + let _ = fs::create_dir_all(path.parent().expect("a folder")); + let Ok(file) = fs::File::create(path) else { + return; + }; + let mut zip = zip::ZipWriter::new(file); + for (name, bytes) in entries { + if zip + .start_file(*name, zip::write::FileOptions::default()) + .is_ok() + { + let _ = zip.write_all(bytes); + } + } + let _ = zip.finish(); +} + +fn fabric_mod(path: &Path, id: &str, name: &str, version: &str, color: Option<[u8; 3]>) { + let mut json = json!({"schemaVersion": 1, "id": id, "name": name, "version": version}); + let mut entries = Vec::new(); + if let Some(color) = color { + json["icon"] = json!(format!("assets/{id}/icon.png")); + entries.push((format!("assets/{id}/icon.png"), png(color))); + } + let mut all: Vec<(&str, Vec)> = vec![("fabric.mod.json", json.to_string().into_bytes())]; + all.extend(entries.iter().map(|(n, b)| (n.as_str(), b.clone()))); + write_zip(path, &all); +} + +fn resource_pack(path: &Path, description: &str, format: serde_json::Value, color: [u8; 3]) { + let mut pack = json!({"description": description}); + if let Some(object) = format.as_object() { + for (k, v) in object { + pack[k] = v.clone(); + } + } + write_zip( + path, + &[ + ( + "pack.mcmeta", + json!({"pack": pack}).to_string().into_bytes(), + ), + ("pack.png", png(color)), + ], + ); +} + +fn source(project: &str, slug: &str, title: &str, version: &str, icon: bool) -> ModrinthSource { + ModrinthSource { + project_id: project.into(), + version_id: format!("{project}-{version}"), + slug: slug.into(), + title: title.into(), + icon_url: icon.then(|| icon_url(project)), + version_number: version.into(), + date_published: "2025-01-01T00:00:00Z".into(), + url: Some(format!( + "https://cdn.modrinth.com/data/{project}/versions/x/{slug}.jar" + )), + client_side: "required".into(), + server_side: "optional".into(), + } +} + +fn icon_url(project: &str) -> String { + format!("https://cdn.modrinth.com/data/{project}/icon.png") +} + +/// Records `file` as coming from Modrinth, with its real size and time so the listing +/// trusts the entry without asking Modrinth. +fn remember( + provenance: &mut Provenance, + dirs: &InstanceDirs, + kind: ContentKind, + file: &str, + source: ModrinthSource, +) { + let path = dirs.content_dir(kind).join(file); + let Ok(metadata) = fs::metadata(&path) else { + return; + }; + provenance.insert( + kind, + file, + Entry { + size: metadata.len(), + modified: metadata + .modified() + .ok() + .and_then(launcher_core::content::provenance::nanos), + sha1: lodestone_minecraft::hash::sha1_of(&path).unwrap_or_default(), + sha512: lodestone_minecraft::hash::sha512_of(&path).unwrap_or_default(), + modrinth: Some(source), + looked_up: false, + }, + ); +} + +/// Writes the sample content of `scene` into the instance at `instance_dir`. +pub fn fill(instance_dir: &Path, scene: &str) { + let dirs = InstanceDirs::new(instance_dir); + let mods = dirs.content_dir(ContentKind::Mod); + let packs = dirs.content_dir(ContentKind::ResourcePack); + let shaders = dirs.content_dir(ContentKind::ShaderPack); + let mut provenance = Provenance::default(); + + fabric_mod( + &mods.join("sodium-fabric-0.5.13+mc1.20.1.jar"), + "sodium", + "Sodium", + "0.5.13+mc1.20.1", + None, + ); + fabric_mod( + &mods.join("fabric-api-0.92.2+1.20.1.jar"), + "fabric-api", + "Fabric API", + "0.92.2+1.20.1", + None, + ); + fabric_mod( + &mods.join("modmenu-7.2.2.jar.disabled"), + "modmenu", + "Mod Menu", + "7.2.2", + None, + ); + fabric_mod( + &mods.join("lithium-fabric-0.11.2.jar"), + "lithium", + "Lithium", + "0.11.2", + None, + ); + fabric_mod( + &mods.join("homemade-tweaks.jar"), + "homemade", + "Homemade Tweaks", + "1.0.0", + Some([196, 120, 60]), + ); + remember( + &mut provenance, + &dirs, + ContentKind::Mod, + "sodium-fabric-0.5.13+mc1.20.1.jar", + source("AANobbMI", "sodium", "Sodium", "mc1.20.1-0.5.13", true), + ); + remember( + &mut provenance, + &dirs, + ContentKind::Mod, + "fabric-api-0.92.2+1.20.1.jar", + source( + "P7dR8mSH", + "fabric-api", + "Fabric API", + "0.92.2+1.20.1", + true, + ), + ); + remember( + &mut provenance, + &dirs, + ContentKind::Mod, + "modmenu-7.2.2.jar.disabled", + source("mOgUt4GM", "modmenu", "Mod Menu", "7.2.2", true), + ); + remember( + &mut provenance, + &dirs, + ContentKind::Mod, + "lithium-fabric-0.11.2.jar", + source("gvQqBUqZ", "lithium", "Lithium", "mc1.20.1-0.11.2", true), + ); + if matches!( + scene, + "content-mods" | "content-shaders" | "content-browse" | "content-project" + ) { + fabric_mod( + &mods.join("iris-1.7.6+mc1.20.1.jar"), + "iris", + "Iris", + "1.7.6+mc1.20.1", + None, + ); + remember( + &mut provenance, + &dirs, + ContentKind::Mod, + "iris-1.7.6+mc1.20.1.jar", + source("YL57xq9U", "iris", "Iris Shaders", "1.7.6+1.20.1", true), + ); + } + + resource_pack( + &packs.join("Faithful 32x - 1.20.1.zip"), + "§7The §6Faithful§7 look, at 32x", + json!({"pack_format": 15}), + [90, 150, 70], + ); + resource_pack( + &packs.join("FreshAnimations_v1.9.zip"), + "Breathing new life into mobs", + json!({"pack_format": 15, "supported_formats": [15, 34]}), + [200, 140, 70], + ); + resource_pack( + &packs.join("Old Classic Textures.zip"), + "Classic textures from 1.12", + json!({"pack_format": 3}), + [120, 120, 160], + ); + resource_pack( + &packs.join("Stay True 1.21.zip"), + "Made for 1.21", + json!({"pack_format": 34}), + [70, 130, 170], + ); + write_zip( + &packs.join("Nested Download.zip"), + &[( + "Nested Download/pack.mcmeta", + b"{\"pack\": {\"pack_format\": 15, \"description\": \"\"}}".to_vec(), + )], + ); + let _ = fs::create_dir_all(packs.join("My Edits")); + let _ = fs::write( + packs.join("My Edits/pack.mcmeta"), + json!({"pack": {"pack_format": 15, "description": "Textures I made"}}).to_string(), + ); + remember( + &mut provenance, + &dirs, + ContentKind::ResourcePack, + "Faithful 32x - 1.20.1.zip", + source("PB8Tee5S", "faithful-32x", "Faithful 32x", "1.20.1", false), + ); + remember( + &mut provenance, + &dirs, + ContentKind::ResourcePack, + "FreshAnimations_v1.9.zip", + source( + "50dA9Sha", + "fresh-animations", + "Fresh Animations", + "1.9", + false, + ), + ); + let _ = fs::write( + dirs.game.join("options.txt"), + "version:3465\nautoJump:false\nresourcePacks:[\"vanilla\",\"fabric\",\"file/Old Classic Textures.zip\",\"file/FreshAnimations_v1.9.zip\",\"file/Faithful 32x - 1.20.1.zip\"]\nincompatibleResourcePacks:[\"file/Old Classic Textures.zip\"]\nlang:en_us\n", + ); + + write_zip( + &shaders.join("ComplementaryReimagined_r5.3.zip"), + &[("shaders/composite.fsh", b"x".to_vec())], + ); + write_zip( + &shaders.join("BSL_v8.2.09.zip"), + &[("shaders/final.fsh", b"x".to_vec())], + ); + write_zip( + &shaders.join("MakeUp-UltraFast-9.2.zip"), + &[("MakeUp-UltraFast/shaders/final.fsh", b"x".to_vec())], + ); + remember( + &mut provenance, + &dirs, + ContentKind::ShaderPack, + "ComplementaryReimagined_r5.3.zip", + source( + "HVnmMxH1", + "complementary-reimagined", + "Complementary Shaders - Reimagined", + "r5.3", + true, + ), + ); + remember( + &mut provenance, + &dirs, + ContentKind::ShaderPack, + "BSL_v8.2.09.zip", + source("Q1vvjJYV", "bsl-shaders", "BSL Shaders", "v8.2.09", true), + ); + let _ = fs::create_dir_all(dirs.game.join("config")); + let _ = fs::write( + dirs.game.join("config/iris.properties"), + "#This file stores configuration options for Iris\nenableShaders=true\nshaderPack=ComplementaryReimagined_r5.3.zip\n", + ); + let _ = provenance.save(&dirs.instance); + if scene == "loader-change" { + for world in ["New World", "Creative build"] { + let dir = dirs.game.join("saves").join(world); + let _ = fs::create_dir_all(&dir); + let _ = fs::write(dir.join("level.dat"), b"not a real level"); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn version( + id: &str, + project: &str, + number: &str, + kind: &str, + loaders: &[&str], + games: &[&str], + date: &str, + size: u64, +) -> RawVersion { + serde_json::from_value(json!({ + "id": id, "project_id": project, "name": number, "version_number": number, + "version_type": kind, "loaders": loaders, "game_versions": games, + "date_published": date, "downloads": 1, + "files": [{"hashes": {"sha1": "0".repeat(40), "sha512": "0".repeat(128)}, + "url": format!("https://cdn.modrinth.com/data/{project}/versions/{id}/file.jar"), + "filename": "file.jar", "primary": true, "size": size}], + "dependencies": [], + })) + .expect("a valid version") +} + +fn project( + id: &str, + slug: &str, + title: &str, + description: &str, + downloads: u64, +) -> ModrinthProject { + let raw: RawProject = serde_json::from_value(json!({ + "id": id, "slug": slug, "project_type": "mod", "title": title, "description": description, + "icon_url": icon_url(id), "categories": ["optimization", "fabric", "quilt"], + "loaders": ["fabric", "quilt"], "downloads": downloads, "followers": downloads / 1500, + "client_side": "required", "server_side": "optional", + })) + .expect("a valid project"); + raw.into() +} + +fn icons() -> HashMap> { + [ + ("AANobbMI", [70, 160, 210]), + ("P7dR8mSH", [210, 180, 120]), + ("mOgUt4GM", [140, 100, 200]), + ("gvQqBUqZ", [90, 190, 120]), + ("YL57xq9U", [110, 80, 210]), + ("HVnmMxH1", [230, 150, 60]), + ("Q1vvjJYV", [60, 120, 200]), + ("NNAgCjsB", [200, 90, 90]), + ("Bh37bMuy", [80, 170, 170]), + ] + .into_iter() + .map(|(id, rgb)| (icon_url(id), crate::images::render_image(icon(rgb)))) + .collect() +} + +pub fn apply(scene: &str, view: &mut ContentView, cx: &mut Context) { + match scene { + "content-mods" => view.preview_state( + ContentKind::Mod, + None, + HashMap::from([( + "fabric-api-0.92.2+1.20.1.jar".to_string(), + version( + "fapi", + "P7dR8mSH", + "0.92.6+1.20.1", + "release", + &["fabric"], + &["1.20.1"], + "2025-06-01T00:00:00Z", + 2_000_000, + ), + )]), + icons(), + cx, + ), + "content-resourcepacks" => { + view.preview_state(ContentKind::ResourcePack, None, HashMap::new(), icons(), cx) + } + "content-shaders" => { + view.preview_state(ContentKind::ShaderPack, None, HashMap::new(), icons(), cx) + } + "content-shaders-missing" => view.preview_state( + ContentKind::ShaderPack, + Some(ShaderModOffer::Install { + loader: ShaderLoader::Iris, + version: Box::new(version( + "iris176", + "YL57xq9U", + "1.7.6+1.20.1", + "release", + &["fabric", "quilt"], + &["1.20.1"], + "2025-01-02T00:00:00Z", + 2_500_000, + )), + }), + HashMap::new(), + icons(), + cx, + ), + "content-shaders-vanilla" => { + view.preview_state(ContentKind::ShaderPack, None, HashMap::new(), icons(), cx) + } + "content-browse" => { + view.preview_state(ContentKind::Mod, None, HashMap::new(), icons(), cx); + let hits = [ + ( + "AANobbMI", + "sodium", + "Sodium", + "JellySquid", + "The fastest and most compatible rendering optimization mod for Minecraft.", + 98_400_000_u64, + ), + ( + "P7dR8mSH", + "fabric-api", + "Fabric API", + "modmuss50", + "Lightweight and modular API providing common hooks and intercompatibility measures utilized by mods using the Fabric toolchain.", + 120_300_000, + ), + ( + "YL57xq9U", + "iris", + "Iris Shaders", + "coderbot", + "A modern shader pack loader for Minecraft intended to be compatible with existing OptiFine shader packs.", + 70_100_000, + ), + ( + "gvQqBUqZ", + "lithium", + "Lithium", + "JellySquid", + "No-compromises game logic optimization mod.", + 41_000_000, + ), + ( + "NNAgCjsB", + "entityculling", + "Entity Culling", + "tr7zw", + "Using async path-tracing to hide Block-/Entities that are not visible.", + 38_200_000, + ), + ( + "Bh37bMuy", + "reeses-sodium-options", + "Reese's Sodium Options", + "FlashyReese", + "Alternative Options Menu for Sodium.", + 21_700_000, + ), + ]; + let response: ModrinthSearchResponse = serde_json::from_value(json!({ + "hits": hits.iter().map(|(id, slug, title, author, description, downloads)| json!({ + "project_id": id, "slug": slug, "project_type": "mod", "title": title, + "description": description, "author": author, "icon_url": icon_url(id), + "downloads": downloads, "follows": downloads / 1400, "categories": [], "versions": [], + "date_modified": "", "client_side": if *slug == "entityculling" { "optional" } else { "required" }, + "server_side": "optional", + })).collect::>(), + "offset": 0, "limit": 20, "total_hits": 2481, + })) + .expect("a valid response"); + view.preview_results(ContentKind::Mod, "", response.normalize(), cx); + } + "content-project" => { + view.preview_state(ContentKind::Mod, None, HashMap::new(), icons(), cx); + let versions = vec![ + version( + "AANobbMI-mc1.20.1-0.5.13", + "AANobbMI", + "mc1.20.1-0.5.13", + "release", + &["fabric", "quilt"], + &["1.20.1"], + "2025-03-10T00:00:00Z", + 1_140_000, + ), + version( + "s6", + "AANobbMI", + "mc1.20.1-0.5.11", + "release", + &["fabric", "quilt"], + &["1.20.1"], + "2024-07-01T00:00:00Z", + 1_120_000, + ), + version( + "s5", + "AANobbMI", + "mc1.20.1-0.5.10-beta.2", + "beta", + &["fabric"], + &["1.20.1"], + "2024-05-01T00:00:00Z", + 1_100_000, + ), + version( + "s4", + "AANobbMI", + "mc1.20.1-0.5.8", + "release", + &["fabric", "quilt"], + &["1.20", "1.20.1"], + "2024-02-01T00:00:00Z", + 1_050_000, + ), + version( + "s9", + "AANobbMI", + "mc1.21.1-0.6.13-fabric", + "release", + &["fabric"], + &["1.21.1"], + "2025-04-01T00:00:00Z", + 1_300_000, + ), + ]; + view.preview_project( + ContentKind::Mod, + project( + "AANobbMI", + "sodium", + "Sodium", + "The fastest and most compatible rendering optimization mod for Minecraft. Now available for both NeoForge and Fabric!", + 98_400_000, + ), + versions, + cx, + ); + } + _ => {} + } +} diff --git a/launcher/crates/lodestone-launcher/src/shell/mod.rs b/launcher/crates/lodestone-launcher/src/shell/mod.rs index 82c90a538..88675933d 100644 --- a/launcher/crates/lodestone-launcher/src/shell/mod.rs +++ b/launcher/crates/lodestone-launcher/src/shell/mod.rs @@ -14,7 +14,7 @@ pub use top_bar::TOP_BAR_HEIGHT; use gpui::{ AnyElement, ClipboardItem, Context, Entity, FocusHandle, Focusable as _, IntoElement, Render, - SharedString, Subscription, Window, div, prelude::*, + SharedString, Subscription, Window, div, prelude::*, px, }; use crate::{ @@ -23,12 +23,13 @@ use crate::{ offline_dialog::{OfflineDialog, OfflineDialogDismissed}, sign_in::{SignInDialog, SignInEvent, Stage}, }, - components::{Button, ButtonVariant, Dialog}, + components::{Button, ButtonVariant, Dialog, Icon}, instances::{ CrashInfo, Instances, InstancesEvent, console::Console, + content::{ContentEvent, ContentView}, new_instance::{NewInstanceDialog, NewInstanceDismissed}, - settings::{DeleteRequested, InstanceSettings}, + settings::{DeleteRequested, InstanceSettings, LoaderChangePrompt}, }, theme::{self, colors, fonts, leading, radius, text, weights}, }; @@ -46,7 +47,7 @@ pub enum Page { pub enum InstanceTab { #[default] Play, - Mods, + Content, Worlds, Settings, Logs, @@ -55,7 +56,7 @@ pub enum InstanceTab { impl InstanceTab { pub const ALL: [InstanceTab; 5] = [ InstanceTab::Play, - InstanceTab::Mods, + InstanceTab::Content, InstanceTab::Worlds, InstanceTab::Settings, InstanceTab::Logs, @@ -64,7 +65,7 @@ impl InstanceTab { pub fn label(self) -> &'static str { match self { InstanceTab::Play => "Play", - InstanceTab::Mods => "Mods", + InstanceTab::Content => "Content", InstanceTab::Worlds => "Worlds", InstanceTab::Settings => "Settings", InstanceTab::Logs => "Logs", @@ -107,6 +108,11 @@ enum Modal { username: String, focus: FocusHandle, }, + /// Saving a loader change that leaves mods behind or puts worlds at risk. + LoaderChange { + prompt: Box, + focus: FocusHandle, + }, /// The disclosure before "Sign in as the Minecraft Launcher" is turned on. EnableLauncherIdentity { focus: FocusHandle }, /// Turning it off, with the accounts that used it. @@ -121,10 +127,12 @@ pub struct LauncherShell { accounts: Entity, instances: Entity, console: Entity, + /// The Content tab: mods, resource packs and shader packs. + content: Entity, /// The launcher's Settings page. launcher_settings: Entity, /// The Settings tab of the selected instance, rebuilt when the selection changes. - settings: Option<(Entity, Subscription)>, + settings: Option<(Entity, [Subscription; 2])>, page: Page, tab: InstanceTab, modal: Option, @@ -157,9 +165,20 @@ impl LauncherShell { }); cx.notify(); } + InstancesEvent::LoaderChanged(_) => { + // The Settings tab shows the loader it was built with; build it again. + this.settings = None; + this.sync_settings(cx); + cx.notify(); + } }), ]; let console = cx.new(|cx| Console::new(instances.clone(), cx)); + let content = cx.new(|cx| ContentView::new(instances.clone(), cx)); + let mut observe = observe; + observe.push(cx.subscribe(&content, |this, _, event, cx| match event { + ContentEvent::NewFabricInstance => this.open_new_instance_with_loader(cx), + })); let launcher_settings = { let instances = instances.clone(); cx.new(|cx| LauncherSettings::new(instances, cx)) @@ -168,6 +187,7 @@ impl LauncherShell { accounts, instances, console, + content, launcher_settings, settings: None, page: Page::default(), @@ -189,6 +209,10 @@ impl LauncherShell { &self.console } + pub fn content(&self) -> &Entity { + &self.content + } + /// Rebuilds the Settings tab when another instance is selected. fn sync_settings(&mut self, cx: &mut Context) { let selected = self.instances.read(cx).selected().cloned(); @@ -201,10 +225,15 @@ impl LauncherShell { Some(instance) if current.as_deref() != Some(instance.id.as_str()) => { let instances = self.instances.clone(); let settings = cx.new(|cx| InstanceSettings::new(instances, &instance, cx)); - let subscription = cx.subscribe(&settings, |this, _, DeleteRequested(id), cx| { - this.confirm_delete_instance(id, cx) - }); - self.settings = Some((settings, subscription)); + let subscriptions = [ + cx.subscribe(&settings, |this, _, DeleteRequested(id), cx| { + this.confirm_delete_instance(id, cx) + }), + cx.subscribe(&settings, |this, _, prompt: &LoaderChangePrompt, cx| { + this.show_loader_change(prompt.clone(), cx) + }), + ]; + self.settings = Some((settings, subscriptions)); } Some(_) => {} } @@ -234,6 +263,140 @@ impl LauncherShell { cx.notify(); } + /// Asks before saving a loader change (from the Settings tab). + pub fn show_loader_change(&mut self, prompt: LoaderChangePrompt, cx: &mut Context) { + self.modal = Some(Modal::LoaderChange { + prompt: Box::new(prompt), + focus: cx.focus_handle(), + }); + cx.notify(); + } + + /// Saves the change the dialog asked about, turning the mismatched mods off first if + /// `turn_off`. + fn finish_loader_change(&mut self, turn_off: bool, cx: &mut Context) { + let Some(Modal::LoaderChange { prompt, .. }) = self.modal.take() else { + return; + }; + let disable: Vec = if turn_off { + prompt + .check + .mismatched + .iter() + .map(|m| m.file_name.clone()) + .collect() + } else { + Vec::new() + }; + if let Some(settings) = self.settings_view().cloned() { + settings.update(cx, |settings, cx| { + settings.commit(prompt.instance, &disable, cx) + }); + } + cx.notify(); + } + + fn render_loader_change( + &self, + prompt: &LoaderChangePrompt, + focus: FocusHandle, + cx: &mut Context, + ) -> AnyElement { + const SHOWN: usize = 8; + let to = prompt.instance.loader.label(); + let check = &prompt.check; + let title = match prompt.instance.loader.kind() { + Some(_) => format!("Switch to {to}?"), + None => "Switch to vanilla?".to_string(), + }; + let mods_text = match prompt.instance.loader.kind() { + Some(_) => { + format!("These enabled mods are made for another loader and won't load on {to}:") + } + None => { + "Vanilla Minecraft loads no mods, so these enabled mods won't load:".to_string() + } + }; + let has_mods = !check.mismatched.is_empty(); + let entity = cx.entity(); + Dialog::new("loader-change", title, focus) + .on_close(move |_, cx| entity.update(cx, |this, cx| this.close_modal(cx))) + .child( + div() + .flex() + .flex_col() + .gap_2() + .text_color(colors::GRAY_400) + .when(has_mods, |this| { + let more = check.mismatched.len().saturating_sub(SHOWN); + this.child(div().whitespace_normal().child(mods_text)) + .child( + div() + .flex() + .flex_col() + .px_3() + .py_2() + .rounded(radius::LG) + .bg(colors::GRAY_900) + .children(check.mismatched.iter().take(SHOWN).map(|m| { + div() + .flex() + .flex_row() + .gap_2() + .child(div().text_color(colors::GRAY_300).child(m.name.clone())) + .child(div().text_color(colors::GRAY_500).child(format!("{} mod", m.made_for))) + })) + .when(more > 0, |this| { + this.child(div().text_color(colors::GRAY_500).child(format!("and {more} more"))) + }), + ) + .child(div().whitespace_normal().child( + "Turning them off renames them to .disabled, so you can turn them back on in Content.", + )) + }) + .when(check.worlds > 0 && check.worlds_at_risk, |this| { + this.child( + div() + .flex() + .flex_row() + .gap_2() + .px_3() + .py_2() + .rounded(radius::LG) + .border_1() + .border_color(colors::YELLOW_300.opacity(50)) + .bg(colors::YELLOW_FADED.opacity(15)) + .text_color(colors::GRAY_300) + .child(Icon::TriangleExclamation.render(px(14.), colors::YELLOW_300)) + .child(div().flex_1().min_w_0().whitespace_normal().child( + "Worlds that used blocks or items from these mods can lose them. Back up your worlds first.", + )), + ) + }), + ) + .when(has_mods, |this| { + this.footer( + Button::new("loader-change-disable", "Turn them off and save") + .variant(ButtonVariant::Primary) + .on_click(cx.listener(|this, _, _, cx| this.finish_loader_change(true, cx))), + ) + }) + .footer( + Button::new( + "loader-change-keep", + if has_mods { "Save and keep them" } else { "Save" }, + ) + .variant(if has_mods { ButtonVariant::Secondary } else { ButtonVariant::Primary }) + .on_click(cx.listener(|this, _, _, cx| this.finish_loader_change(false, cx))), + ) + .footer( + Button::new("loader-change-cancel", "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(cx.listener(|this, _, _, cx| this.close_modal(cx))), + ) + .into_any_element() + } + fn confirm_delete_instance(&mut self, id: &str, cx: &mut Context) { let Some(instance) = self .instances @@ -284,6 +447,17 @@ impl LauncherShell { self.open_new_instance_with(None, Some(window), cx); } + /// Opens "New instance" with Fabric chosen, from the Content tab of a vanilla + /// instance. + fn open_new_instance_with_loader(&mut self, cx: &mut Context) { + self.open_new_instance_with(None, None, cx); + if let Some(Modal::NewInstance { dialog, .. }) = &self.modal { + dialog.update(cx, |dialog, cx| { + dialog.choose_loader(launcher_core::game::loaders::LoaderKind::Fabric, cx) + }); + } + } + /// Opens "New instance", with the fields filled in for screenshots. pub fn open_new_instance_with( &mut self, @@ -501,6 +675,9 @@ impl LauncherShell { ) .into_any_element() } + Modal::LoaderChange { prompt, focus } => { + self.render_loader_change(prompt, focus.clone(), cx) + } Modal::EnableLauncherIdentity { focus } => { self.render_enable_launcher_identity(focus.clone(), cx) } diff --git a/launcher/crates/lodestone-launcher/src/shell/play.rs b/launcher/crates/lodestone-launcher/src/shell/play.rs index 91fcc728c..d70baba11 100644 --- a/launcher/crates/lodestone-launcher/src/shell/play.rs +++ b/launcher/crates/lodestone-launcher/src/shell/play.rs @@ -91,10 +91,11 @@ pub fn render(shell: &LauncherShell, cx: &mut Context) -> impl In .into_any_element(), }, (InstanceTab::Logs, Some(_)) => shell.console.clone().into_any_element(), - (InstanceTab::Mods | InstanceTab::Worlds, Some(_)) => placeholder( - "Mods and worlds come in the next milestone of Lodestone Launcher.", - ) - .into_any_element(), + (InstanceTab::Content, Some(_)) => shell.content.clone().into_any_element(), + (InstanceTab::Worlds, Some(_)) => { + placeholder("Worlds come in a later milestone of Lodestone Launcher.") + .into_any_element() + } (other, None) => placeholder(format!( "{} appear here once you have an instance.", other.label()